Plugin Directory

Changeset 3454428


Ignore:
Timestamp:
02/05/2026 09:46:05 AM (2 weeks ago)
Author:
markmarkmark
Message:

Update to version 1.4.0 from GitHub

Location:
djot-markup
Files:
4 added
40 edited
1 copied

Legend:

Unmodified
Added
Removed
  • djot-markup/tags/1.4.0/assets/blocks/djot/block.json

    r3454148 r3454428  
    33    "apiVersion": 3,
    44    "name": "wpdjot/djot",
    5     "version": "1.3.1",
     5    "version": "1.4.0",
    66    "title": "Djot",
    77    "category": "text",
  • djot-markup/tags/1.4.0/assets/blocks/djot/index.asset.php

    r3454148 r3454428  
    1010        'wp-api-fetch',
    1111    ],
    12     'version' => '1.3.1',
     12    'version' => '1.4.0',
    1313];
  • djot-markup/tags/1.4.0/assets/css/djot.css

    r3454148 r3454428  
    279279    text-align: center;
    280280    font-style: italic;
     281}
     282
     283/* Table of Contents */
     284.wpdjot-toc {
     285    background: #f8f9fa;
     286    border: 1px solid #e9ecef;
     287    border-radius: 6px;
     288    padding: 0;
     289    margin-bottom: 1.5em;
     290}
     291
     292.wpdjot-toc summary {
     293    padding: 0.75em 1.5em;
     294    font-weight: 600;
     295    cursor: pointer;
     296    list-style: none;
     297    display: flex;
     298    align-items: center;
     299    gap: 0.5em;
     300}
     301
     302.wpdjot-toc summary::before {
     303    content: "▶";
     304    font-size: 0.7em;
     305    transition: transform 0.2s ease;
     306}
     307
     308.wpdjot-toc[open] summary::before {
     309    transform: rotate(90deg);
     310}
     311
     312.wpdjot-toc summary::-webkit-details-marker {
     313    display: none;
     314}
     315
     316.wpdjot-toc > ul,
     317.wpdjot-toc > ol {
     318    margin: 0;
     319    padding: 0 1.5em 1em;
     320}
     321
     322.wpdjot-toc ul,
     323.wpdjot-toc ol {
     324    margin: 0;
     325    padding-left: 1.5em;
     326}
     327
     328.wpdjot-toc li {
     329    margin: 0.25em 0;
     330    line-height: 1.4;
     331}
     332
     333.wpdjot-toc a {
     334    color: inherit;
     335    text-decoration: none;
     336}
     337
     338.wpdjot-toc a:hover {
     339    text-decoration: underline;
     340    color: #0066cc;
     341}
     342
     343/* Heading Permalinks */
     344.djot-content .permalink-wrapper {
     345    opacity: 0;
     346    transition: opacity 0.2s ease;
     347    margin-left: 0.3em;
     348}
     349
     350.djot-content section:hover > h1 .permalink-wrapper,
     351.djot-content section:hover > h2 .permalink-wrapper,
     352.djot-content section:hover > h3 .permalink-wrapper,
     353.djot-content section:hover > h4 .permalink-wrapper,
     354.djot-content section:hover > h5 .permalink-wrapper,
     355.djot-content section:hover > h6 .permalink-wrapper {
     356    opacity: 1;
     357}
     358
     359.djot-content .wpdjot-permalink {
     360    color: #57606a;
     361    text-decoration: none;
     362    font-weight: 400;
     363    cursor: pointer;
     364}
     365
     366.djot-content .wpdjot-permalink:hover {
     367    color: #0969da;
     368    text-decoration: none;
    281369}
    282370
     
    690778        color: #8b949e;
    691779    }
    692 }
     780
     781    .wpdjot-toc {
     782        background: #161b22;
     783        border-color: #30363d;
     784    }
     785
     786    .wpdjot-toc a:hover {
     787        color: #58a6ff;
     788    }
     789
     790    .djot-content .wpdjot-permalink {
     791        color: #8b949e;
     792    }
     793
     794    .djot-content .wpdjot-permalink:hover {
     795        color: #58a6ff;
     796    }
     797}
  • djot-markup/tags/1.4.0/composer.json

    r3454148 r3454428  
    1212    "require": {
    1313        "php": ">=8.2",
    14         "php-collective/djot": "^0.1.11"
     14        "php-collective/djot": "^0.1.13"
    1515    },
    1616    "require-dev": {
  • djot-markup/tags/1.4.0/readme.txt

    r3454148 r3454428  
    55Tested up to: 6.9
    66Requires PHP: 8.2
    7 Stable tag: 1.3.1
     7Stable tag: 1.4.0
    88License: MIT
    99License URI: https://opensource.org/licenses/MIT
     
    2020* **Shortcode Support**: Use `[djot]...[/djot]` in your content
    2121* **Content Filtering**: Automatically process `{djot}...{/djot}` blocks
     22* **Table of Contents**: Automatic TOC generation from headings with configurable levels and position
    2223* **Safe Mode**: XSS protection for untrusted content
    2324* **Syntax Highlighting**: Built-in highlight.js integration with 12+ themes
     
    9091== Changelog ==
    9192
     93= 1.4.0 =
     94* Added automatic Table of Contents generation from headings
     95* Configurable TOC position (top/bottom), heading levels, and list type
     96* Light and dark mode styling for TOC
     97* Added heading permalinks with show-on-hover and copy-to-clipboard
     98* Added locale-aware smart quotes (20 locales + Auto from site language)
     99* Bumped php-collective/djot to ^0.1.13
     100
    92101= 1.3.1 =
    93102* Fixed text domain to match plugin slug (djot-markup)
     
    128137== Upgrade Notice ==
    129138
     139= 1.4.0 =
     140Adds Table of Contents, heading permalinks, and locale-aware smart quotes.
     141
    130142= 1.3.1 =
    131143Text domain, escaping, and Plugin Check fixes for WordPress.org plugin review compliance.
  • djot-markup/tags/1.4.0/src/Admin/Settings.php

    r3454148 r3454428  
    99    exit;
    1010}
     11
     12use Djot\Extension\SmartQuotesExtension;
    1113
    1214/**
     
    206208        );
    207209
     210        add_settings_field(
     211            'smart_quotes_locale',
     212            __('Smart Quotes', 'djot-markup'),
     213            [$this, 'renderSmartQuotesLocaleSelect'],
     214            self::PAGE_SLUG,
     215            'wpdjot_rendering',
     216            ['field' => 'smart_quotes_locale', 'description' => __('Choose which typographic quote characters to use. Default English uses curly quotes.', 'djot-markup')],
     217        );
     218
    208219        // Code Highlighting Section
    209220        add_settings_section(
     
    247258            'wpdjot_advanced',
    248259            ['field' => 'shortcode_tag', 'description' => __('The shortcode tag to use (default: djot).', 'djot-markup')],
     260        );
     261
     262        // Table of Contents Section
     263        add_settings_section(
     264            'wpdjot_toc',
     265            __('Table of Contents', 'djot-markup'),
     266            [$this, 'renderTocSectionDescription'],
     267            self::PAGE_SLUG,
     268        );
     269
     270        add_settings_field(
     271            'toc_enabled',
     272            __('Enable TOC', 'djot-markup'),
     273            [$this, 'renderCheckboxField'],
     274            self::PAGE_SLUG,
     275            'wpdjot_toc',
     276            ['field' => 'toc_enabled', 'description' => __('Automatically generate a table of contents from headings in posts and pages.', 'djot-markup')],
     277        );
     278
     279        add_settings_field(
     280            'toc_position',
     281            __('TOC Position', 'djot-markup'),
     282            [$this, 'renderTocPositionSelect'],
     283            self::PAGE_SLUG,
     284            'wpdjot_toc',
     285            ['field' => 'toc_position', 'description' => __('Where to insert the table of contents.', 'djot-markup')],
     286        );
     287
     288        add_settings_field(
     289            'toc_min_level',
     290            __('Minimum Heading Level', 'djot-markup'),
     291            [$this, 'renderHeadingLevelSelect'],
     292            self::PAGE_SLUG,
     293            'wpdjot_toc',
     294            ['field' => 'toc_min_level', 'description' => __('Include headings starting from this level (default: 2 to skip page title).', 'djot-markup')],
     295        );
     296
     297        add_settings_field(
     298            'toc_max_level',
     299            __('Maximum Heading Level', 'djot-markup'),
     300            [$this, 'renderHeadingLevelSelect'],
     301            self::PAGE_SLUG,
     302            'wpdjot_toc',
     303            ['field' => 'toc_max_level', 'description' => __('Include headings up to this level.', 'djot-markup')],
     304        );
     305
     306        add_settings_field(
     307            'toc_list_type',
     308            __('TOC List Type', 'djot-markup'),
     309            [$this, 'renderTocListTypeSelect'],
     310            self::PAGE_SLUG,
     311            'wpdjot_toc',
     312            ['field' => 'toc_list_type', 'description' => __('Use ordered (numbered) or unordered (bulleted) list.', 'djot-markup')],
     313        );
     314
     315        add_settings_field(
     316            'permalinks_enabled',
     317            __('Heading Permalinks', 'djot-markup'),
     318            [$this, 'renderCheckboxField'],
     319            self::PAGE_SLUG,
     320            'wpdjot_toc',
     321            ['field' => 'permalinks_enabled', 'description' => __('Add clickable # symbols to headings. Shown on hover, clicking copies the heading URL to clipboard.', 'djot-markup')],
    249322        );
    250323    }
     
    287360                ? $input['comment_soft_break']
    288361                : 'newline',
     362            'smart_quotes_locale' => in_array($input['smart_quotes_locale'] ?? '', array_merge(['auto'], SmartQuotesExtension::getSupportedLocales()), true)
     363                ? $input['smart_quotes_locale']
     364                : 'en',
     365            'toc_enabled' => !empty($input['toc_enabled']),
     366            'toc_position' => in_array($input['toc_position'] ?? '', ['top', 'bottom'], true)
     367                ? $input['toc_position']
     368                : 'top',
     369            'toc_min_level' => in_array((int)($input['toc_min_level'] ?? 2), [1, 2, 3, 4, 5, 6], true)
     370                ? (int)$input['toc_min_level']
     371                : 2,
     372            'toc_max_level' => in_array((int)($input['toc_max_level'] ?? 4), [1, 2, 3, 4, 5, 6], true)
     373                ? (int)$input['toc_max_level']
     374                : 4,
     375            'toc_list_type' => in_array($input['toc_list_type'] ?? '', ['ul', 'ol'], true)
     376                ? $input['toc_list_type']
     377                : 'ul',
     378            'permalinks_enabled' => !empty($input['permalinks_enabled']),
    289379        ];
    290380    }
     
    558648        echo '</ul>';
    559649    }
     650
     651    /**
     652     * Render smart quotes locale select dropdown.
     653     *
     654     * @param array<string, mixed> $args
     655     */
     656    public function renderSmartQuotesLocaleSelect(array $args): void
     657    {
     658        $options = get_option(self::OPTION_GROUP, []);
     659        $field = $args['field'];
     660        $current = $options[$field] ?? 'en';
     661
     662        $localeLabels = [
     663            'en' => "Default (English \u{201C}\u{2026}\u{201D} \u{2018}\u{2026}\u{2019})",
     664            'auto' => 'Auto (from site language)',
     665            'cs' => "Czech \u{201E}\u{2026}\u{201C} \u{201A}\u{2026}\u{2018}",
     666            'da' => "Danish \u{201E}\u{2026}\u{201C} \u{201A}\u{2026}\u{2018}",
     667            'nl' => "Dutch \u{201C}\u{2026}\u{201D} \u{2018}\u{2026}\u{2019}",
     668            'fi' => "Finnish \u{201D}\u{2026}\u{201D} \u{2019}\u{2026}\u{2019}",
     669            'fr' => "French \u{00AB}\u{2026}\u{00BB} \u{2039}\u{2026}\u{203A}",
     670            'de' => "German \u{201E}\u{2026}\u{201C} \u{201A}\u{2026}\u{2018}",
     671            'de-CH' => "Swiss German \u{00AB}\u{2026}\u{00BB} \u{2039}\u{2026}\u{203A}",
     672            'hu' => "Hungarian \u{201E}\u{2026}\u{201D} \u{201A}\u{2026}\u{2019}",
     673            'it' => "Italian \u{00AB}\u{2026}\u{00BB} \u{201C}\u{2026}\u{201D}",
     674            'ja' => "Japanese \u{300C}\u{2026}\u{300D} \u{300E}\u{2026}\u{300F}",
     675            'nb' => "Norwegian Bokm\u{00E5}l \u{00AB}\u{2026}\u{00BB} \u{2018}\u{2026}\u{2019}",
     676            'nn' => "Norwegian Nynorsk \u{00AB}\u{2026}\u{00BB} \u{2018}\u{2026}\u{2019}",
     677            'pl' => "Polish \u{201E}\u{2026}\u{201D} \u{201A}\u{2026}\u{2019}",
     678            'pt' => "Portuguese \u{00AB}\u{2026}\u{00BB} \u{201C}\u{2026}\u{201D}",
     679            'ru' => "Russian \u{00AB}\u{2026}\u{00BB} \u{201E}\u{2026}\u{201C}",
     680            'es' => "Spanish \u{00AB}\u{2026}\u{00BB} \u{201C}\u{2026}\u{201D}",
     681            'sv' => "Swedish \u{201D}\u{2026}\u{201D} \u{2019}\u{2026}\u{2019}",
     682            'uk' => "Ukrainian \u{00AB}\u{2026}\u{00BB} \u{201E}\u{2026}\u{201C}",
     683            'zh' => "Chinese \u{300C}\u{2026}\u{300D} \u{300E}\u{2026}\u{300F}",
     684        ];
     685
     686        printf(
     687            '<select id="%1$s" name="%2$s[%1$s]">',
     688            esc_attr($field),
     689            esc_attr(self::OPTION_GROUP),
     690        );
     691
     692        foreach ($localeLabels as $value => $label) {
     693            printf(
     694                '<option value="%s" %s>%s</option>',
     695                esc_attr($value),
     696                selected($current, $value, false),
     697                esc_html($label),
     698            );
     699        }
     700
     701        echo '</select>';
     702
     703        if (!empty($args['description'])) {
     704            printf('<p class="description">%s</p>', esc_html($args['description']));
     705        }
     706    }
     707
     708    public function renderTocSectionDescription(): void
     709    {
     710        echo '<p>' . esc_html__('Configure automatic table of contents generation for posts and pages.', 'djot-markup') . '</p>';
     711    }
     712
     713    /**
     714     * Render TOC position select dropdown.
     715     *
     716     * @param array<string, mixed> $args
     717     */
     718    public function renderTocPositionSelect(array $args): void
     719    {
     720        $options = get_option(self::OPTION_GROUP, []);
     721        $field = $args['field'];
     722        $current = $options[$field] ?? 'top';
     723
     724        $positions = [
     725            'top' => __('Top of content', 'djot-markup'),
     726            'bottom' => __('Bottom of content', 'djot-markup'),
     727        ];
     728
     729        printf(
     730            '<select id="%1$s" name="%2$s[%1$s]">',
     731            esc_attr($field),
     732            esc_attr(self::OPTION_GROUP),
     733        );
     734
     735        foreach ($positions as $value => $label) {
     736            printf(
     737                '<option value="%s" %s>%s</option>',
     738                esc_attr($value),
     739                selected($current, $value, false),
     740                esc_html($label),
     741            );
     742        }
     743
     744        echo '</select>';
     745
     746        if (!empty($args['description'])) {
     747            printf('<p class="description">%s</p>', esc_html($args['description']));
     748        }
     749    }
     750
     751    /**
     752     * Render heading level select dropdown.
     753     *
     754     * @param array<string, mixed> $args
     755     */
     756    public function renderHeadingLevelSelect(array $args): void
     757    {
     758        $options = get_option(self::OPTION_GROUP, []);
     759        $field = $args['field'];
     760        $default = $field === 'toc_min_level' ? 2 : 4;
     761        $current = $options[$field] ?? $default;
     762
     763        printf(
     764            '<select id="%1$s" name="%2$s[%1$s]">',
     765            esc_attr($field),
     766            esc_attr(self::OPTION_GROUP),
     767        );
     768
     769        for ($level = 1; $level <= 6; $level++) {
     770            printf(
     771                '<option value="%d" %s>H%d</option>',
     772                $level,
     773                selected((int)$current, $level, false),
     774                $level,
     775            );
     776        }
     777
     778        echo '</select>';
     779
     780        if (!empty($args['description'])) {
     781            printf('<p class="description">%s</p>', esc_html($args['description']));
     782        }
     783    }
     784
     785    /**
     786     * Render TOC list type select dropdown.
     787     *
     788     * @param array<string, mixed> $args
     789     */
     790    public function renderTocListTypeSelect(array $args): void
     791    {
     792        $options = get_option(self::OPTION_GROUP, []);
     793        $field = $args['field'];
     794        $current = $options[$field] ?? 'ul';
     795
     796        $listTypes = [
     797            'ul' => __('Unordered (bullets)', 'djot-markup'),
     798            'ol' => __('Ordered (numbers)', 'djot-markup'),
     799        ];
     800
     801        printf(
     802            '<select id="%1$s" name="%2$s[%1$s]">',
     803            esc_attr($field),
     804            esc_attr(self::OPTION_GROUP),
     805        );
     806
     807        foreach ($listTypes as $value => $label) {
     808            printf(
     809                '<option value="%s" %s>%s</option>',
     810                esc_attr($value),
     811                selected($current, $value, false),
     812                esc_html($label),
     813            );
     814        }
     815
     816        echo '</select>';
     817
     818        if (!empty($args['description'])) {
     819            printf('<p class="description">%s</p>', esc_html($args['description']));
     820        }
     821    }
    560822}
  • djot-markup/tags/1.4.0/src/Converter.php

    r3454148 r3454428  
    1111
    1212use Djot\DjotConverter;
     13use Djot\Extension\HeadingPermalinksExtension;
     14use Djot\Extension\SmartQuotesExtension;
     15use Djot\Extension\TableOfContentsExtension;
    1316use Djot\Profile;
    1417use Djot\Renderer\SoftBreakMode;
     
    3841
    3942    private bool $markdownMode;
     43
     44    private bool $tocEnabled;
     45
     46    private string $tocPosition;
     47
     48    private int $tocMinLevel;
     49
     50    private int $tocMaxLevel;
     51
     52    private string $tocListType;
     53
     54    private bool $permalinksEnabled;
     55
     56    private string $smartQuotesLocale;
    4057
    4158    /**
     
    5168        string $commentSoftBreak = 'newline',
    5269        bool $markdownMode = false,
     70        bool $tocEnabled = false,
     71        string $tocPosition = 'top',
     72        int $tocMinLevel = 2,
     73        int $tocMaxLevel = 4,
     74        string $tocListType = 'ul',
     75        bool $permalinksEnabled = false,
     76        string $smartQuotesLocale = 'en',
    5377    ) {
    5478        $this->defaultSafeMode = $safeMode;
     
    5882        $this->commentSoftBreak = $commentSoftBreak;
    5983        $this->markdownMode = $markdownMode;
     84        $this->tocEnabled = $tocEnabled;
     85        $this->tocPosition = $tocPosition;
     86        $this->tocMinLevel = $tocMinLevel;
     87        $this->tocMaxLevel = $tocMaxLevel;
     88        $this->tocListType = $tocListType;
     89        $this->permalinksEnabled = $permalinksEnabled;
     90        $this->smartQuotesLocale = $smartQuotesLocale;
    6091        $this->converter = new DjotConverter(safeMode: false);
    6192        $this->converter->getRenderer()->setCodeBlockTabWidth(4);
     
    80111            commentSoftBreak: $options['comment_soft_break'] ?? 'newline',
    81112            markdownMode: !empty($options['markdown_mode']),
     113            tocEnabled: !empty($options['toc_enabled']),
     114            tocPosition: $options['toc_position'] ?? 'top',
     115            tocMinLevel: (int)($options['toc_min_level'] ?? 2),
     116            tocMaxLevel: (int)($options['toc_max_level'] ?? 4),
     117            tocListType: $options['toc_list_type'] ?? 'ul',
     118            permalinksEnabled: !empty($options['permalinks_enabled']),
     119            smartQuotesLocale: $options['smart_quotes_locale'] ?? 'en',
    82120        );
    83121    }
     
    93131    {
    94132        $softBreakSetting = $context === 'comment' ? $this->commentSoftBreak : $this->postSoftBreak;
    95         $key = $profileName . ($safeMode ? '_safe' : '_unsafe') . '_' . $softBreakSetting . ($this->markdownMode ? '_md' : '');
     133        $tocKey = ($this->tocEnabled && $context === 'article')
     134            ? '_toc_' . $this->tocPosition . '_' . $this->tocMinLevel . '_' . $this->tocMaxLevel . '_' . $this->tocListType
     135            : '';
     136        $permalinksKey = ($this->permalinksEnabled && $context === 'article') ? '_permalinks' : '';
     137        $smartQuotesKey = $this->smartQuotesLocale !== 'en' ? '_sq_' . $this->smartQuotesLocale : '';
     138        $key = $profileName . ($safeMode ? '_safe' : '_unsafe') . '_' . $softBreakSetting . ($this->markdownMode ? '_md' : '') . $tocKey . $permalinksKey . $smartQuotesKey;
    96139
    97140        if (!isset($this->profileConverters[$key])) {
     
    123166            // Convert tabs to 4 spaces in code blocks for consistent display
    124167            $converter->getRenderer()->setCodeBlockTabWidth(4);
     168
     169            // Add Table of Contents extension for articles when enabled
     170            if ($this->tocEnabled && $context === 'article') {
     171                $tocExtension = new TableOfContentsExtension(
     172                    minLevel: $this->tocMinLevel,
     173                    maxLevel: $this->tocMaxLevel,
     174                    listType: $this->tocListType,
     175                    cssClass: 'wpdjot-toc',
     176                    position: $this->tocPosition,
     177                );
     178                $converter->addExtension($tocExtension);
     179
     180                // Wrap TOC in collapsible <details>/<summary>
     181                $converter->addOutputTransformer(function (string $html): string {
     182                    $label = __('Table of Contents', 'djot-markup');
     183
     184                    return (string)preg_replace(
     185                        '#<nav class="wpdjot-toc">\n(.*?)</nav>\n#s',
     186                        '<details class="wpdjot-toc">' . "\n"
     187                            . '<summary>' . esc_html($label) . '</summary>' . "\n"
     188                            . '$1</details>' . "\n",
     189                        $html,
     190                    );
     191                });
     192            }
     193
     194            // Add heading permalinks for articles when enabled
     195            if ($this->permalinksEnabled && $context === 'article') {
     196                $converter->addExtension(new HeadingPermalinksExtension(
     197                    symbol: '#',
     198                    cssClass: 'wpdjot-permalink',
     199                ));
     200            }
     201
     202            // Add smart quotes extension for non-English locales
     203            if ($this->smartQuotesLocale !== 'en') {
     204                $locale = $this->smartQuotesLocale === 'auto' ? $this->getWpLocale() : $this->smartQuotesLocale;
     205                $converter->addExtension(new SmartQuotesExtension(locale: $locale));
     206            }
    125207
    126208            // Allow customization via WordPress filters
     
    367449        return $allowedHtml;
    368450    }
     451
     452    /**
     453     * Get the WordPress locale, falling back to 'en'.
     454     */
     455    private function getWpLocale(): string
     456    {
     457        if (function_exists('get_locale')) {
     458            return get_locale() ?: 'en';
     459        }
     460
     461        return 'en';
     462    }
    369463}
  • djot-markup/tags/1.4.0/src/Plugin.php

    r3454148 r3454428  
    491491                'document.addEventListener("DOMContentLoaded", function() { hljs.highlightAll(); });',
    492492            );
     493        }
     494
     495        // Heading permalink copy-to-clipboard
     496        if ($this->options['permalinks_enabled']) {
     497            $copyJs = 'document.addEventListener("click",function(e){'
     498                . 'var a=e.target.closest(".wpdjot-permalink");'
     499                . 'if(!a)return;'
     500                . 'e.preventDefault();'
     501                . 'var hash=a.getAttribute("href");'
     502                . 'history.replaceState(null,null,hash);'
     503                . 'navigator.clipboard.writeText(location.href);'
     504                . '});';
     505            wp_register_script('wpdjot-permalink', false, [], (string)WPDJOT_VERSION, ['in_footer' => true]);
     506            wp_enqueue_script('wpdjot-permalink');
     507            wp_add_inline_script('wpdjot-permalink', $copyJs);
    493508        }
    494509
     
    533548            'highlight_theme' => 'github',
    534549            'shortcode_tag' => 'djot',
     550            'toc_enabled' => false,
     551            'toc_position' => 'top',
     552            'toc_min_level' => 2,
     553            'toc_max_level' => 4,
     554            'toc_list_type' => 'ul',
     555            'permalinks_enabled' => false,
     556            'smart_quotes_locale' => 'en',
    535557        ];
    536558
  • djot-markup/tags/1.4.0/vendor/autoload.php

    r3454148 r3454428  
    2020require_once __DIR__ . '/composer/autoload_real.php';
    2121
    22 return ComposerAutoloaderInit4fce3a77000f5adc49d1807e59eeac64::getLoader();
     22return ComposerAutoloaderInit03e39cad62747481332e0a708bd351b8::getLoader();
  • djot-markup/tags/1.4.0/vendor/composer/autoload_classmap.php

    r3454148 r3454428  
    2222    'Djot\\Extension\\HeadingPermalinksExtension' => $vendorDir . '/php-collective/djot/src/Extension/HeadingPermalinksExtension.php',
    2323    'Djot\\Extension\\MentionsExtension' => $vendorDir . '/php-collective/djot/src/Extension/MentionsExtension.php',
     24    'Djot\\Extension\\SmartQuotesExtension' => $vendorDir . '/php-collective/djot/src/Extension/SmartQuotesExtension.php',
    2425    'Djot\\Extension\\TableOfContentsExtension' => $vendorDir . '/php-collective/djot/src/Extension/TableOfContentsExtension.php',
    2526    'Djot\\Filter\\ProfileFilter' => $vendorDir . '/php-collective/djot/src/Filter/ProfileFilter.php',
     
    8081    'Djot\\Profile' => $vendorDir . '/php-collective/djot/src/Profile.php',
    8182    'Djot\\ProfileViolation' => $vendorDir . '/php-collective/djot/src/ProfileViolation.php',
     83    'Djot\\Renderer\\HeadingIdTracker' => $vendorDir . '/php-collective/djot/src/Renderer/HeadingIdTracker.php',
    8284    'Djot\\Renderer\\HtmlRenderer' => $vendorDir . '/php-collective/djot/src/Renderer/HtmlRenderer.php',
    8385    'Djot\\Renderer\\MarkdownRenderer' => $vendorDir . '/php-collective/djot/src/Renderer/MarkdownRenderer.php',
     
    8789    'Djot\\Renderer\\Utility\\EventDispatcherTrait' => $vendorDir . '/php-collective/djot/src/Renderer/Utility/EventDispatcherTrait.php',
    8890    'Djot\\SafeMode' => $vendorDir . '/php-collective/djot/src/SafeMode.php',
     91    'WpDjot\\Admin\\Settings' => $baseDir . '/src/Admin/Settings.php',
     92    'WpDjot\\Blocks\\DjotBlock' => $baseDir . '/src/Blocks/DjotBlock.php',
     93    'WpDjot\\CLI\\MigrateCommand' => $baseDir . '/src/CLI/MigrateCommand.php',
     94    'WpDjot\\Converter' => $baseDir . '/src/Converter.php',
     95    'WpDjot\\Converter\\WpHtmlToDjot' => $baseDir . '/src/Converter/WpHtmlToDjot.php',
     96    'WpDjot\\Converter\\WpMarkdownToDjot' => $baseDir . '/src/Converter/WpMarkdownToDjot.php',
     97    'WpDjot\\Migration\\Migrator' => $baseDir . '/src/Migration/Migrator.php',
     98    'WpDjot\\Plugin' => $baseDir . '/src/Plugin.php',
     99    'WpDjot\\Shortcodes\\DjotShortcode' => $baseDir . '/src/Shortcodes/DjotShortcode.php',
    89100);
  • djot-markup/tags/1.4.0/vendor/composer/autoload_real.php

    r3454148 r3454428  
    33// autoload_real.php @generated by Composer
    44
    5 class ComposerAutoloaderInit4fce3a77000f5adc49d1807e59eeac64
     5class ComposerAutoloaderInit03e39cad62747481332e0a708bd351b8
    66{
    77    private static $loader;
     
    2525        require __DIR__ . '/platform_check.php';
    2626
    27         spl_autoload_register(array('ComposerAutoloaderInit4fce3a77000f5adc49d1807e59eeac64', 'loadClassLoader'), true, true);
     27        spl_autoload_register(array('ComposerAutoloaderInit03e39cad62747481332e0a708bd351b8', 'loadClassLoader'), true, true);
    2828        self::$loader = $loader = new \Composer\Autoload\ClassLoader(\dirname(__DIR__));
    29         spl_autoload_unregister(array('ComposerAutoloaderInit4fce3a77000f5adc49d1807e59eeac64', 'loadClassLoader'));
     29        spl_autoload_unregister(array('ComposerAutoloaderInit03e39cad62747481332e0a708bd351b8', 'loadClassLoader'));
    3030
    3131        require __DIR__ . '/autoload_static.php';
    32         call_user_func(\Composer\Autoload\ComposerStaticInit4fce3a77000f5adc49d1807e59eeac64::getInitializer($loader));
     32        call_user_func(\Composer\Autoload\ComposerStaticInit03e39cad62747481332e0a708bd351b8::getInitializer($loader));
    3333
    3434        $loader->register(true);
    3535
    36         $filesToLoad = \Composer\Autoload\ComposerStaticInit4fce3a77000f5adc49d1807e59eeac64::$files;
     36        $filesToLoad = \Composer\Autoload\ComposerStaticInit03e39cad62747481332e0a708bd351b8::$files;
    3737        $requireFile = \Closure::bind(static function ($fileIdentifier, $file) {
    3838            if (empty($GLOBALS['__composer_autoload_files'][$fileIdentifier])) {
  • djot-markup/tags/1.4.0/vendor/composer/autoload_static.php

    r3454148 r3454428  
    55namespace Composer\Autoload;
    66
    7 class ComposerStaticInit4fce3a77000f5adc49d1807e59eeac64
     7class ComposerStaticInit03e39cad62747481332e0a708bd351b8
    88{
    99    public static $files = array (
     
    4949        'Djot\\Extension\\HeadingPermalinksExtension' => __DIR__ . '/..' . '/php-collective/djot/src/Extension/HeadingPermalinksExtension.php',
    5050        'Djot\\Extension\\MentionsExtension' => __DIR__ . '/..' . '/php-collective/djot/src/Extension/MentionsExtension.php',
     51        'Djot\\Extension\\SmartQuotesExtension' => __DIR__ . '/..' . '/php-collective/djot/src/Extension/SmartQuotesExtension.php',
    5152        'Djot\\Extension\\TableOfContentsExtension' => __DIR__ . '/..' . '/php-collective/djot/src/Extension/TableOfContentsExtension.php',
    5253        'Djot\\Filter\\ProfileFilter' => __DIR__ . '/..' . '/php-collective/djot/src/Filter/ProfileFilter.php',
     
    107108        'Djot\\Profile' => __DIR__ . '/..' . '/php-collective/djot/src/Profile.php',
    108109        'Djot\\ProfileViolation' => __DIR__ . '/..' . '/php-collective/djot/src/ProfileViolation.php',
     110        'Djot\\Renderer\\HeadingIdTracker' => __DIR__ . '/..' . '/php-collective/djot/src/Renderer/HeadingIdTracker.php',
    109111        'Djot\\Renderer\\HtmlRenderer' => __DIR__ . '/..' . '/php-collective/djot/src/Renderer/HtmlRenderer.php',
    110112        'Djot\\Renderer\\MarkdownRenderer' => __DIR__ . '/..' . '/php-collective/djot/src/Renderer/MarkdownRenderer.php',
     
    114116        'Djot\\Renderer\\Utility\\EventDispatcherTrait' => __DIR__ . '/..' . '/php-collective/djot/src/Renderer/Utility/EventDispatcherTrait.php',
    115117        'Djot\\SafeMode' => __DIR__ . '/..' . '/php-collective/djot/src/SafeMode.php',
     118        'WpDjot\\Admin\\Settings' => __DIR__ . '/../..' . '/src/Admin/Settings.php',
     119        'WpDjot\\Blocks\\DjotBlock' => __DIR__ . '/../..' . '/src/Blocks/DjotBlock.php',
     120        'WpDjot\\CLI\\MigrateCommand' => __DIR__ . '/../..' . '/src/CLI/MigrateCommand.php',
     121        'WpDjot\\Converter' => __DIR__ . '/../..' . '/src/Converter.php',
     122        'WpDjot\\Converter\\WpHtmlToDjot' => __DIR__ . '/../..' . '/src/Converter/WpHtmlToDjot.php',
     123        'WpDjot\\Converter\\WpMarkdownToDjot' => __DIR__ . '/../..' . '/src/Converter/WpMarkdownToDjot.php',
     124        'WpDjot\\Migration\\Migrator' => __DIR__ . '/../..' . '/src/Migration/Migrator.php',
     125        'WpDjot\\Plugin' => __DIR__ . '/../..' . '/src/Plugin.php',
     126        'WpDjot\\Shortcodes\\DjotShortcode' => __DIR__ . '/../..' . '/src/Shortcodes/DjotShortcode.php',
    116127    );
    117128
     
    119130    {
    120131        return \Closure::bind(function () use ($loader) {
    121             $loader->prefixLengthsPsr4 = ComposerStaticInit4fce3a77000f5adc49d1807e59eeac64::$prefixLengthsPsr4;
    122             $loader->prefixDirsPsr4 = ComposerStaticInit4fce3a77000f5adc49d1807e59eeac64::$prefixDirsPsr4;
    123             $loader->classMap = ComposerStaticInit4fce3a77000f5adc49d1807e59eeac64::$classMap;
     132            $loader->prefixLengthsPsr4 = ComposerStaticInit03e39cad62747481332e0a708bd351b8::$prefixLengthsPsr4;
     133            $loader->prefixDirsPsr4 = ComposerStaticInit03e39cad62747481332e0a708bd351b8::$prefixDirsPsr4;
     134            $loader->classMap = ComposerStaticInit03e39cad62747481332e0a708bd351b8::$classMap;
    124135
    125136        }, null, ClassLoader::class);
  • djot-markup/tags/1.4.0/vendor/composer/installed.json

    r3454148 r3454428  
    33        {
    44            "name": "php-collective/djot",
    5             "version": "0.1.12",
    6             "version_normalized": "0.1.12.0",
     5            "version": "0.1.13",
     6            "version_normalized": "0.1.13.0",
    77            "source": {
    88                "type": "git",
    99                "url": "https://github.com/php-collective/djot-php.git",
    10                 "reference": "0b8e06827b24e0447acfb6c79f6ff717b90440d1"
     10                "reference": "c79cba85d26483246e7d8b1bfdf387c0dad3d489"
    1111            },
    1212            "dist": {
    1313                "type": "zip",
    14                 "url": "https://api.github.com/repos/php-collective/djot-php/zipball/0b8e06827b24e0447acfb6c79f6ff717b90440d1",
    15                 "reference": "0b8e06827b24e0447acfb6c79f6ff717b90440d1",
     14                "url": "https://api.github.com/repos/php-collective/djot-php/zipball/c79cba85d26483246e7d8b1bfdf387c0dad3d489",
     15                "reference": "c79cba85d26483246e7d8b1bfdf387c0dad3d489",
    1616                "shasum": ""
    1717            },
     
    2525                "phpunit/phpunit": "^11.0 || ^12.0"
    2626            },
    27             "time": "2026-01-20T05:36:44+00:00",
     27            "time": "2026-02-05T08:44:43+00:00",
    2828            "bin": [
    2929                "bin/djot"
     
    5555            "support": {
    5656                "issues": "https://github.com/php-collective/djot-php/issues",
    57                 "source": "https://github.com/php-collective/djot-php/tree/0.1.12"
     57                "source": "https://github.com/php-collective/djot-php/tree/0.1.13"
    5858            },
    5959            "funding": [
  • djot-markup/tags/1.4.0/vendor/composer/installed.php

    r3454148 r3454428  
    22    'root' => array(
    33        'name' => 'php-collective/wp-djot',
    4         'pretty_version' => '1.0.0+no-version-set',
    5         'version' => '1.0.0.0',
    6         'reference' => null,
     4        'pretty_version' => '1.4.0',
     5        'version' => '1.4.0.0',
     6        'reference' => '8557458d2d6cb8cc4b406577b26b2afc51868377',
    77        'type' => 'wordpress-plugin',
    88        'install_path' => __DIR__ . '/../../',
     
    1212    'versions' => array(
    1313        'php-collective/djot' => array(
    14             'pretty_version' => '0.1.12',
    15             'version' => '0.1.12.0',
    16             'reference' => '0b8e06827b24e0447acfb6c79f6ff717b90440d1',
     14            'pretty_version' => '0.1.13',
     15            'version' => '0.1.13.0',
     16            'reference' => 'c79cba85d26483246e7d8b1bfdf387c0dad3d489',
    1717            'type' => 'library',
    1818            'install_path' => __DIR__ . '/../php-collective/djot',
     
    2121        ),
    2222        'php-collective/wp-djot' => array(
    23             'pretty_version' => '1.0.0+no-version-set',
    24             'version' => '1.0.0.0',
    25             'reference' => null,
     23            'pretty_version' => '1.4.0',
     24            'version' => '1.4.0.0',
     25            'reference' => '8557458d2d6cb8cc4b406577b26b2afc51868377',
    2626            'type' => 'wordpress-plugin',
    2727            'install_path' => __DIR__ . '/../../',
  • djot-markup/tags/1.4.0/vendor/php-collective/djot/src/DjotConverter.php

    r3454148 r3454428  
    1010use Djot\Node\Document;
    1111use Djot\Parser\BlockParser;
     12use Djot\Renderer\HeadingIdTracker;
    1213use Djot\Renderer\HtmlRenderer;
    1314use Djot\Renderer\SoftBreakMode;
     
    280281
    281282    /**
     283     * Get the heading ID tracker
     284     */
     285    public function getHeadingIdTracker(): HeadingIdTracker
     286    {
     287        return $this->renderer->getHeadingIdTracker();
     288    }
     289
     290    /**
    282291     * Get the block parser for direct access
    283292     */
  • djot-markup/tags/1.4.0/vendor/php-collective/djot/src/Extension/HeadingPermalinksExtension.php

    r3454148 r3454428  
    88use Djot\Event\RenderEvent;
    99use Djot\Node\Block\Heading;
    10 use Djot\Node\Inline\HardBreak;
    1110use Djot\Node\Inline\Link;
    12 use Djot\Node\Inline\SoftBreak;
    1311use Djot\Node\Inline\Span;
    1412use Djot\Node\Inline\Text;
    15 use Djot\Node\Node;
    1613
    1714/**
     
    3330 *     ariaLabel: 'Link to this section',
    3431 * ));
     32 *
     33 * // Show on hover only, with copy-to-clipboard:
     34 * $converter->addExtension(new HeadingPermalinksExtension(
     35 *     showOnHover: true, // adds 'permalink-hover' class to wrapper
     36 *     copyToClipboard: true, // adds 'data-permalink-copy' attribute to link
     37 * ));
     38 * ```
     39 *
     40 * When `showOnHover` is enabled, add CSS like:
     41 * ```css
     42 * .permalink-hover { opacity: 0; transition: opacity .2s; }
     43 * h1:hover > .permalink-hover, h2:hover > .permalink-hover,
     44 * h3:hover > .permalink-hover, h4:hover > .permalink-hover,
     45 * h5:hover > .permalink-hover, h6:hover > .permalink-hover,
     46 * .permalink-hover:focus-within { opacity: 1; }
     47 * ```
     48 *
     49 * When `copyToClipboard` is enabled, add JavaScript like:
     50 * ```javascript
     51 * document.addEventListener('click', function(e) {
     52 *     var a = e.target.closest('[data-permalink-copy]');
     53 *     if (!a) return;
     54 *     e.preventDefault();
     55 *     navigator.clipboard.writeText(a.href);
     56 *     history.replaceState(null, null, a.getAttribute('href'));
     57 * });
    3558 * ```
    3659 *
     
    5073     * @param string $ariaLabel Accessibility label for the link
    5174     * @param array<int> $levels Which heading levels to add permalinks to (1-6)
     75     * @param bool $showOnHover Only show permalink on heading hover (injects CSS)
     76     * @param bool $copyToClipboard Copy URL to clipboard on click instead of navigating (injects JS)
    5277     */
    5378    public function __construct(
     
    5782        protected string $ariaLabel = 'Permalink',
    5883        protected array $levels = [1, 2, 3, 4, 5, 6],
     84        protected bool $showOnHover = false,
     85        protected bool $copyToClipboard = false,
    5986    ) {
    6087    }
     
    6289    public function register(DjotConverter $converter): void
    6390    {
    64         $converter->on('render.heading', function (RenderEvent $event): void {
     91        $tracker = $converter->getHeadingIdTracker();
     92
     93        $converter->on('render.heading', function (RenderEvent $event) use ($tracker): void {
    6594            $node = $event->getNode();
    6695            if (!$node instanceof Heading) {
     
    72101            }
    73102
    74             // Get the ID (either explicit or will be generated by renderer)
    75             /** @var string|null $id */
    76             $id = $node->getAttribute('id');
    77             if ($id === null || $id === '') {
    78                 // Generate ID from heading text (matching renderer logic)
    79                 $id = $this->generateIdFromNode($node);
    80                 // Set it explicitly so the renderer uses this ID, not one generated
    81                 // from the modified heading content (which would include the permalink symbol)
    82                 if ($id !== '') {
    83                     $node->setAttribute('id', $id);
    84                 }
    85             }
     103            // Get the deduplicated ID from the shared tracker
     104            $id = $tracker->getIdForHeading($node);
    86105
    87106            if ($id === '') {
    88107                return;
     108            }
     109
     110            // Set it explicitly so the renderer uses this ID, not one generated
     111            // from the modified heading content (which would include the permalink symbol)
     112            if (!$node->hasAttribute('id')) {
     113                $node->setAttribute('id', $id);
    89114            }
    90115
     
    95120            $link->appendChild(new Text($this->symbol));
    96121
     122            if ($this->copyToClipboard) {
     123                $link->setAttribute('data-permalink-copy', '');
     124            }
     125
    97126            // Wrap in span for styling flexibility
    98127            $span = new Span();
    99128            $span->addClass('permalink-wrapper');
     129            if ($this->showOnHover) {
     130                $span->addClass('permalink-hover');
     131            }
    100132            $span->appendChild($link);
    101133
    102134            // Add to heading
    103135            if ($this->position === 'before') {
    104                 // Prepend space + span, then the space
    105136                $space = new Text(' ');
    106137                $node->prependChild($space);
    107138                $node->prependChild($span);
    108139            } else {
    109                 // Add space before
    110140                $node->appendChild(new Text(' '));
    111141                $node->appendChild($span);
     
    113143        });
    114144    }
    115 
    116     /**
    117      * Generate a slug from heading content (matches HtmlRenderer behavior)
    118      */
    119     protected function generateIdFromNode(Heading $node): string
    120     {
    121         $text = $this->getPlainText($node);
    122         if ($text === '') {
    123             return '';
    124         }
    125 
    126         // Match HtmlRenderer::getSectionId() behavior:
    127         // 1. Strip # characters entirely
    128         // 2. Trim whitespace
    129         // 3. Replace whitespace sequences with single dashes
    130         $id = str_replace('#', '', $text);
    131         $id = trim($id);
    132         $id = preg_replace('/[\s]+/', '-', $id) ?? $id;
    133 
    134         return $id;
    135     }
    136 
    137     /**
    138      * Extract plain text from a node and its children (recursive)
    139      */
    140     protected function getPlainText(Node $node): string
    141     {
    142         $text = '';
    143         foreach ($node->getChildren() as $child) {
    144             if ($child instanceof Text) {
    145                 $text .= $child->getContent();
    146             } elseif ($child instanceof SoftBreak || $child instanceof HardBreak) {
    147                 $text .= ' ';
    148             } elseif ($child instanceof Node) {
    149                 $text .= $this->getPlainText($child);
    150             }
    151         }
    152 
    153         return $text;
    154     }
    155145}
  • djot-markup/tags/1.4.0/vendor/php-collective/djot/src/Extension/TableOfContentsExtension.php

    r3454148 r3454428  
    77use Djot\DjotConverter;
    88use Djot\Node\Block\Heading;
    9 use Djot\Node\Inline\Text;
    10 use Djot\Node\Node;
    119
    1210/**
     
    7573    public function register(DjotConverter $converter): void
    7674    {
     75        $tracker = $converter->getHeadingIdTracker();
     76
    7777        // Hook into heading rendering to collect TOC entries
    78         $converter->on('render.heading', function ($event): void {
     78        $converter->on('render.heading', function ($event) use ($tracker): void {
    7979            $node = $event->getNode();
    8080            if (!$node instanceof Heading) {
     
    8888            }
    8989
    90             $text = $this->getPlainText($node);
    91             $id = $this->generateId($node, $text);
     90            $text = $tracker->getPlainText($node);
     91            $id = $tracker->getIdForHeading($node);
    9292
    9393            $this->toc[] = [
     
    158158
    159159    /**
    160      * Extract plain text from a node
    161      */
    162     protected function getPlainText(Node $node): string
    163     {
    164         $text = '';
    165         foreach ($node->getChildren() as $child) {
    166             if ($child instanceof Text) {
    167                 $text .= $child->getContent();
    168             } elseif (method_exists($child, 'getChildren')) {
    169                 $text .= $this->getPlainText($child);
    170             }
    171         }
    172 
    173         return $text;
    174     }
    175 
    176     /**
    177      * Generate ID for a heading (matches HtmlRenderer::getSectionId() behavior)
    178      */
    179     protected function generateId(Heading $heading, string $text): string
    180     {
    181         // Check for explicit ID attribute
    182         $id = $heading->getAttribute('id');
    183         if (is_string($id) && $id !== '') {
    184             return $id;
    185         }
    186 
    187         // Generate from text - match HtmlRenderer::getSectionId() behavior:
    188         // 1. Strip # characters entirely
    189         // 2. Trim whitespace
    190         // 3. Replace whitespace sequences with single dashes
    191         if ($text === '') {
    192             return '';
    193         }
    194 
    195         $id = str_replace('#', '', $text);
    196         $id = trim($id);
    197         $id = preg_replace('/[\s]+/', '-', $id) ?? $id;
    198 
    199         return $id;
    200     }
    201 
    202     /**
    203160     * Render TOC as nested HTML list
    204161     *
  • djot-markup/tags/1.4.0/vendor/php-collective/djot/src/Parser/InlineParser.php

    r3454148 r3454428  
    6565    protected ?array $cachedAbbreviations = null;
    6666
     67    /**
     68     * Smart quote characters (configurable via SmartQuotesExtension for locale support)
     69     */
     70    protected string $openDoubleQuote = "\u{201C}";
     71
     72    protected string $closeDoubleQuote = "\u{201D}";
     73
     74    protected string $openSingleQuote = "\u{2018}";
     75
     76    protected string $closeSingleQuote = "\u{2019}";
     77
     78    /**
     79     * Apostrophe character (always U+2019 RIGHT SINGLE QUOTATION MARK)
     80     *
     81     * Not configurable via extension — apostrophes are language-independent.
     82     */
     83    protected string $apostrophe = "\u{2019}";
     84
    6785    public function __construct(protected BlockParser $blockParser)
    6886    {
     
    117135    {
    118136        return $this->customPatterns;
     137    }
     138
     139    /**
     140     * Set locale-specific smart quote characters
     141     *
     142     * Apostrophes (mid-word and before digits) always remain U+2019
     143     * regardless of this setting.
     144     */
     145    public function setQuoteCharacters(
     146        string $openDoubleQuote,
     147        string $closeDoubleQuote,
     148        string $openSingleQuote,
     149        string $closeSingleQuote,
     150    ): void {
     151        $this->openDoubleQuote = $openDoubleQuote;
     152        $this->closeDoubleQuote = $closeDoubleQuote;
     153        $this->openSingleQuote = $openSingleQuote;
     154        $this->closeSingleQuote = $closeSingleQuote;
     155    }
     156
     157    /**
     158     * Get the current smart quote characters
     159     *
     160     * @return array{openDouble: string, closeDouble: string, openSingle: string, closeSingle: string}
     161     */
     162    public function getQuoteCharacters(): array
     163    {
     164        return [
     165            'openDouble' => $this->openDoubleQuote,
     166            'closeDouble' => $this->closeDoubleQuote,
     167            'openSingle' => $this->openSingleQuote,
     168            'closeSingle' => $this->closeSingleQuote,
     169        ];
    119170    }
    120171
     
    10551106            // Must be followed by closing }
    10561107            if ($quotePos < $length && $text[$quotePos] === '}') {
    1057                 // Generate curly quotes based on count
    1058                 $openQuote = $marker === "'" ? "\u{2018}" : "\u{201C}";
    1059                 $closeQuote = $marker === "'" ? "\u{2019}" : "\u{201D}";
     1108                // Generate quotes based on count
     1109                $openQuote = $marker === "'" ? $this->openSingleQuote : $this->openDoubleQuote;
     1110                $closeQuote = $marker === "'" ? $this->closeSingleQuote : $this->closeDoubleQuote;
    10601111
    10611112                // For pairs like {''}, output left + right
    1062                 // For single {'}, output just right (used for apostrophe)
     1113                // For single {'}, output apostrophe (always U+2019), {"} output close double
    10631114                if ($quoteCount === 1) {
    1064                     $result = $closeQuote;
     1115                    $result = $marker === "'" ? $this->apostrophe : $closeQuote;
    10651116                } elseif ($quoteCount === 2) {
    10661117                    $result = $openQuote . $closeQuote;
     
    11221173        // Quote immediately after = is always an opener (attribute value start)
    11231174        if ($prevChar === '=') {
    1124             return $quote === '"' ? "\u{201C}" : "\u{2018}";
     1175            return $quote === '"' ? $this->openDoubleQuote : $this->openSingleQuote;
    11251176        }
    11261177
     
    11471198        // Single quote before digit is always apostrophe (e.g., '70s)
    11481199        if ($quote === "'" && ctype_digit($nextChar)) {
    1149             return "\u{2019}"; // closing/apostrophe
     1200            return $this->apostrophe;
    11501201        }
    11511202
    11521203        // A quote after ] or ) cannot be an opener
    11531204        if ($prevChar === ']' || $prevChar === ')') {
    1154             return $quote === '"' ? "\u{201D}" : "\u{2019}";
     1205            return $quote === '"' ? $this->closeDoubleQuote : $this->closeSingleQuote;
    11551206        }
    11561207
    11571208        if ($quote === '"') {
    11581209            // Opening if preceded by space or start, closing otherwise
    1159             return $prevIsSpace && !$nextIsSpace ? "\u{201C}" : "\u{201D}";
     1210            return $prevIsSpace && !$nextIsSpace ? $this->openDoubleQuote : $this->closeDoubleQuote;
    11601211        }
    11611212
     
    11661217            $matchingCloser = $this->findMatchingSingleQuoteCloser($text, $pos);
    11671218            if ($matchingCloser !== null) {
    1168                 return "\u{2018}"; // opening quote
     1219                return $this->openSingleQuote;
    11691220            }
    11701221
    11711222            // No matching closer found, treat as apostrophe
    1172             return "\u{2019}";
    1173         }
    1174 
    1175         // Closing/apostrophe
    1176         return "\u{2019}";
     1223            return $this->apostrophe;
     1224        }
     1225
     1226        // Check if this is mid-word (next char is a word character) — apostrophe
     1227        if (preg_match('/\w/u', $nextChar)) {
     1228            return $this->apostrophe;
     1229        }
     1230
     1231        // Closing single quote
     1232        return $this->closeSingleQuote;
    11771233    }
    11781234
     
    13591415            }
    13601416
    1361             // Check for multi-byte UTF-8 curly quotes (3 bytes each)
     1417            // Check for multi-byte configured quote characters
    13621418            // These act as word boundaries for attribute attachment
    1363             if (ord($char) >= 0x98 && ord($char) <= 0x9D && $wordStart >= 3) {
    1364                 $threeBytes = substr($textBuffer, $wordStart - 3, 3);
    1365                 // Check for curly quotes: " " ' ' (U+201C, U+201D, U+2018, U+2019)
    1366                 if (
    1367                     $threeBytes === "\u{201C}" || $threeBytes === "\u{201D}" ||
    1368                     $threeBytes === "\u{2018}" || $threeBytes === "\u{2019}"
    1369                 ) {
    1370                     break;
     1419            foreach ($this->getConfiguredQuoteStrings() as $quoteStr) {
     1420                $quoteLen = strlen($quoteStr);
     1421                if ($wordStart >= $quoteLen && substr($textBuffer, $wordStart - $quoteLen, $quoteLen) === $quoteStr) {
     1422                    break 2;
    13711423                }
    13721424            }
     
    14061458            'pos' => $attrEnd + 1,
    14071459        ];
     1460    }
     1461
     1462    /**
     1463     * Get all unique configured quote strings for word boundary detection
     1464     *
     1465     * @return array<string>
     1466     */
     1467    protected function getConfiguredQuoteStrings(): array
     1468    {
     1469        return array_unique([
     1470            $this->openDoubleQuote,
     1471            $this->closeDoubleQuote,
     1472            $this->openSingleQuote,
     1473            $this->closeSingleQuote,
     1474            $this->apostrophe,
     1475        ]);
    14081476    }
    14091477
  • djot-markup/tags/1.4.0/vendor/php-collective/djot/src/Renderer/HtmlRenderer.php

    r3454148 r3454428  
    7171    protected array $footnoteRefCounts = [];
    7272
    73     /**
    74      * Tracks used IDs for deduplication
    75      *
    76      * @var array<string, int>
    77      */
    78     protected array $usedIds = [];
    79 
    80     /**
    81      * Counter for auto-generated section IDs (when heading has no text)
    82      */
    83     protected int $sectionCounter = 0;
     73    protected HeadingIdTracker $headingIdTracker;
    8474
    8575    /**
     
    116106    public function __construct(protected bool $xhtml = false)
    117107    {
     108        $this->headingIdTracker = new HeadingIdTracker();
    118109        $this->initNodeRenderers();
     110    }
     111
     112    /**
     113     * Get the heading ID tracker
     114     */
     115    public function getHeadingIdTracker(): HeadingIdTracker
     116    {
     117        return $this->headingIdTracker;
    119118    }
    120119
     
    247246        // Reset state for each render
    248247        $this->footnoteRefCounts = [];
    249         $this->usedIds = [];
    250         $this->sectionCounter = 0;
     248        $this->headingIdTracker->reset();
    251249        $this->footnoteNumbers = [];
    252250        $this->footnoteCounter = 0;
     
    337335    protected function getSectionId(Heading $node): string
    338336    {
    339         // If heading has explicit id attribute, use it
     337        return $this->headingIdTracker->getIdForHeading($node);
     338    }
     339
     340    /**
     341     * Track ID usage from non-heading elements (like paragraphs with explicit IDs)
     342     */
     343    protected function trackIdFromNode(Node $node): void
     344    {
    340345        if ($node->hasAttribute('id')) {
    341346            $idAttr = $node->getAttribute('id');
    342347            $id = is_string($idAttr) ? $idAttr : '';
    343             // Track explicit IDs so auto-generated IDs don't conflict
    344             if (!isset($this->usedIds[$id])) {
    345                 $this->usedIds[$id] = 0;
    346             }
    347 
    348             return $id;
    349         }
    350 
    351         // Generate from heading text
    352         $headingText = $this->getPlainText($node);
    353 
    354         if ($headingText === '') {
    355             // Generate fallback ID
    356             $this->sectionCounter++;
    357 
    358             return 's-' . $this->sectionCounter;
    359         }
    360 
    361         // Convert to valid ID:
    362         // 1. Strip # characters entirely
    363         // 2. Trim whitespace
    364         // 3. Replace whitespace sequences with single dashes
    365         $baseId = str_replace('#', '', $headingText);
    366         $baseId = trim($baseId);
    367         $baseId = preg_replace('/[\s]+/', '-', $baseId) ?? $baseId;
    368 
    369         // Track and deduplicate
    370         if (!isset($this->usedIds[$baseId])) {
    371             $this->usedIds[$baseId] = 0;
    372 
    373             return $baseId;
    374         }
    375 
    376         // Already used, add suffix (first conflict is -1, second is -2, etc.)
    377         $this->usedIds[$baseId]++;
    378 
    379         return $baseId . '-' . $this->usedIds[$baseId];
    380     }
    381 
    382     /**
    383      * Track ID usage from non-heading elements (like paragraphs with explicit IDs)
    384      */
    385     protected function trackIdFromNode(Node $node): void
    386     {
    387         if ($node->hasAttribute('id')) {
    388             $idAttr = $node->getAttribute('id');
    389             $id = is_string($idAttr) ? $idAttr : '';
    390             if ($id !== '' && !isset($this->usedIds[$id])) {
    391                 $this->usedIds[$id] = 0;
    392             }
     348            $this->headingIdTracker->trackId($id);
    393349        }
    394350    }
     
    516472    protected function getPlainText(Node $node): string
    517473    {
    518         $text = '';
    519         foreach ($node->getChildren() as $child) {
    520             if ($child instanceof Text) {
    521                 $text .= $child->getContent();
    522             } elseif ($child instanceof SoftBreak || $child instanceof HardBreak) {
    523                 $text .= ' ';
    524             } elseif ($child instanceof Node) {
    525                 $text .= $this->getPlainText($child);
    526             }
    527         }
    528 
    529         return $text;
     474        return $this->headingIdTracker->getPlainText($node);
    530475    }
    531476
  • djot-markup/tags/1.4.0/wp-djot.php

    r3454148 r3454428  
    22/**
    33 * Plugin Name: Djot Markup
    4  * Plugin URI: https://github.com/php-collective/wp-djot
     4 * Plugin URI: https://wordpress.org/plugins/djot-markup/
    55 * Description: <a href="https://djot.net/" target="_blank">Djot</a> markup language support for WordPress – a modern, cleaner alternative to Markdown with syntax highlighting. Convert Djot syntax to HTML in posts, pages, and comments.
    6  * Version: 1.3.1
     6 * Version: 1.4.0
    77 * Requires at least: 6.0
    88 * Requires PHP: 8.2
     
    2525
    2626// Plugin constants
    27 define('WPDJOT_VERSION', '1.3.1');
     27define('WPDJOT_VERSION', '1.4.0');
    2828define('WPDJOT_PLUGIN_DIR', plugin_dir_path(__FILE__));
    2929define('WPDJOT_PLUGIN_URL', plugin_dir_url(__FILE__));
  • djot-markup/trunk/assets/blocks/djot/block.json

    r3454148 r3454428  
    33    "apiVersion": 3,
    44    "name": "wpdjot/djot",
    5     "version": "1.3.1",
     5    "version": "1.4.0",
    66    "title": "Djot",
    77    "category": "text",
  • djot-markup/trunk/assets/blocks/djot/index.asset.php

    r3454148 r3454428  
    1010        'wp-api-fetch',
    1111    ],
    12     'version' => '1.3.1',
     12    'version' => '1.4.0',
    1313];
  • djot-markup/trunk/assets/css/djot.css

    r3454148 r3454428  
    279279    text-align: center;
    280280    font-style: italic;
     281}
     282
     283/* Table of Contents */
     284.wpdjot-toc {
     285    background: #f8f9fa;
     286    border: 1px solid #e9ecef;
     287    border-radius: 6px;
     288    padding: 0;
     289    margin-bottom: 1.5em;
     290}
     291
     292.wpdjot-toc summary {
     293    padding: 0.75em 1.5em;
     294    font-weight: 600;
     295    cursor: pointer;
     296    list-style: none;
     297    display: flex;
     298    align-items: center;
     299    gap: 0.5em;
     300}
     301
     302.wpdjot-toc summary::before {
     303    content: "▶";
     304    font-size: 0.7em;
     305    transition: transform 0.2s ease;
     306}
     307
     308.wpdjot-toc[open] summary::before {
     309    transform: rotate(90deg);
     310}
     311
     312.wpdjot-toc summary::-webkit-details-marker {
     313    display: none;
     314}
     315
     316.wpdjot-toc > ul,
     317.wpdjot-toc > ol {
     318    margin: 0;
     319    padding: 0 1.5em 1em;
     320}
     321
     322.wpdjot-toc ul,
     323.wpdjot-toc ol {
     324    margin: 0;
     325    padding-left: 1.5em;
     326}
     327
     328.wpdjot-toc li {
     329    margin: 0.25em 0;
     330    line-height: 1.4;
     331}
     332
     333.wpdjot-toc a {
     334    color: inherit;
     335    text-decoration: none;
     336}
     337
     338.wpdjot-toc a:hover {
     339    text-decoration: underline;
     340    color: #0066cc;
     341}
     342
     343/* Heading Permalinks */
     344.djot-content .permalink-wrapper {
     345    opacity: 0;
     346    transition: opacity 0.2s ease;
     347    margin-left: 0.3em;
     348}
     349
     350.djot-content section:hover > h1 .permalink-wrapper,
     351.djot-content section:hover > h2 .permalink-wrapper,
     352.djot-content section:hover > h3 .permalink-wrapper,
     353.djot-content section:hover > h4 .permalink-wrapper,
     354.djot-content section:hover > h5 .permalink-wrapper,
     355.djot-content section:hover > h6 .permalink-wrapper {
     356    opacity: 1;
     357}
     358
     359.djot-content .wpdjot-permalink {
     360    color: #57606a;
     361    text-decoration: none;
     362    font-weight: 400;
     363    cursor: pointer;
     364}
     365
     366.djot-content .wpdjot-permalink:hover {
     367    color: #0969da;
     368    text-decoration: none;
    281369}
    282370
     
    690778        color: #8b949e;
    691779    }
    692 }
     780
     781    .wpdjot-toc {
     782        background: #161b22;
     783        border-color: #30363d;
     784    }
     785
     786    .wpdjot-toc a:hover {
     787        color: #58a6ff;
     788    }
     789
     790    .djot-content .wpdjot-permalink {
     791        color: #8b949e;
     792    }
     793
     794    .djot-content .wpdjot-permalink:hover {
     795        color: #58a6ff;
     796    }
     797}
  • djot-markup/trunk/composer.json

    r3454148 r3454428  
    1212    "require": {
    1313        "php": ">=8.2",
    14         "php-collective/djot": "^0.1.11"
     14        "php-collective/djot": "^0.1.13"
    1515    },
    1616    "require-dev": {
  • djot-markup/trunk/readme.txt

    r3454148 r3454428  
    55Tested up to: 6.9
    66Requires PHP: 8.2
    7 Stable tag: 1.3.1
     7Stable tag: 1.4.0
    88License: MIT
    99License URI: https://opensource.org/licenses/MIT
     
    2020* **Shortcode Support**: Use `[djot]...[/djot]` in your content
    2121* **Content Filtering**: Automatically process `{djot}...{/djot}` blocks
     22* **Table of Contents**: Automatic TOC generation from headings with configurable levels and position
    2223* **Safe Mode**: XSS protection for untrusted content
    2324* **Syntax Highlighting**: Built-in highlight.js integration with 12+ themes
     
    9091== Changelog ==
    9192
     93= 1.4.0 =
     94* Added automatic Table of Contents generation from headings
     95* Configurable TOC position (top/bottom), heading levels, and list type
     96* Light and dark mode styling for TOC
     97* Added heading permalinks with show-on-hover and copy-to-clipboard
     98* Added locale-aware smart quotes (20 locales + Auto from site language)
     99* Bumped php-collective/djot to ^0.1.13
     100
    92101= 1.3.1 =
    93102* Fixed text domain to match plugin slug (djot-markup)
     
    128137== Upgrade Notice ==
    129138
     139= 1.4.0 =
     140Adds Table of Contents, heading permalinks, and locale-aware smart quotes.
     141
    130142= 1.3.1 =
    131143Text domain, escaping, and Plugin Check fixes for WordPress.org plugin review compliance.
  • djot-markup/trunk/src/Admin/Settings.php

    r3454148 r3454428  
    99    exit;
    1010}
     11
     12use Djot\Extension\SmartQuotesExtension;
    1113
    1214/**
     
    206208        );
    207209
     210        add_settings_field(
     211            'smart_quotes_locale',
     212            __('Smart Quotes', 'djot-markup'),
     213            [$this, 'renderSmartQuotesLocaleSelect'],
     214            self::PAGE_SLUG,
     215            'wpdjot_rendering',
     216            ['field' => 'smart_quotes_locale', 'description' => __('Choose which typographic quote characters to use. Default English uses curly quotes.', 'djot-markup')],
     217        );
     218
    208219        // Code Highlighting Section
    209220        add_settings_section(
     
    247258            'wpdjot_advanced',
    248259            ['field' => 'shortcode_tag', 'description' => __('The shortcode tag to use (default: djot).', 'djot-markup')],
     260        );
     261
     262        // Table of Contents Section
     263        add_settings_section(
     264            'wpdjot_toc',
     265            __('Table of Contents', 'djot-markup'),
     266            [$this, 'renderTocSectionDescription'],
     267            self::PAGE_SLUG,
     268        );
     269
     270        add_settings_field(
     271            'toc_enabled',
     272            __('Enable TOC', 'djot-markup'),
     273            [$this, 'renderCheckboxField'],
     274            self::PAGE_SLUG,
     275            'wpdjot_toc',
     276            ['field' => 'toc_enabled', 'description' => __('Automatically generate a table of contents from headings in posts and pages.', 'djot-markup')],
     277        );
     278
     279        add_settings_field(
     280            'toc_position',
     281            __('TOC Position', 'djot-markup'),
     282            [$this, 'renderTocPositionSelect'],
     283            self::PAGE_SLUG,
     284            'wpdjot_toc',
     285            ['field' => 'toc_position', 'description' => __('Where to insert the table of contents.', 'djot-markup')],
     286        );
     287
     288        add_settings_field(
     289            'toc_min_level',
     290            __('Minimum Heading Level', 'djot-markup'),
     291            [$this, 'renderHeadingLevelSelect'],
     292            self::PAGE_SLUG,
     293            'wpdjot_toc',
     294            ['field' => 'toc_min_level', 'description' => __('Include headings starting from this level (default: 2 to skip page title).', 'djot-markup')],
     295        );
     296
     297        add_settings_field(
     298            'toc_max_level',
     299            __('Maximum Heading Level', 'djot-markup'),
     300            [$this, 'renderHeadingLevelSelect'],
     301            self::PAGE_SLUG,
     302            'wpdjot_toc',
     303            ['field' => 'toc_max_level', 'description' => __('Include headings up to this level.', 'djot-markup')],
     304        );
     305
     306        add_settings_field(
     307            'toc_list_type',
     308            __('TOC List Type', 'djot-markup'),
     309            [$this, 'renderTocListTypeSelect'],
     310            self::PAGE_SLUG,
     311            'wpdjot_toc',
     312            ['field' => 'toc_list_type', 'description' => __('Use ordered (numbered) or unordered (bulleted) list.', 'djot-markup')],
     313        );
     314
     315        add_settings_field(
     316            'permalinks_enabled',
     317            __('Heading Permalinks', 'djot-markup'),
     318            [$this, 'renderCheckboxField'],
     319            self::PAGE_SLUG,
     320            'wpdjot_toc',
     321            ['field' => 'permalinks_enabled', 'description' => __('Add clickable # symbols to headings. Shown on hover, clicking copies the heading URL to clipboard.', 'djot-markup')],
    249322        );
    250323    }
     
    287360                ? $input['comment_soft_break']
    288361                : 'newline',
     362            'smart_quotes_locale' => in_array($input['smart_quotes_locale'] ?? '', array_merge(['auto'], SmartQuotesExtension::getSupportedLocales()), true)
     363                ? $input['smart_quotes_locale']
     364                : 'en',
     365            'toc_enabled' => !empty($input['toc_enabled']),
     366            'toc_position' => in_array($input['toc_position'] ?? '', ['top', 'bottom'], true)
     367                ? $input['toc_position']
     368                : 'top',
     369            'toc_min_level' => in_array((int)($input['toc_min_level'] ?? 2), [1, 2, 3, 4, 5, 6], true)
     370                ? (int)$input['toc_min_level']
     371                : 2,
     372            'toc_max_level' => in_array((int)($input['toc_max_level'] ?? 4), [1, 2, 3, 4, 5, 6], true)
     373                ? (int)$input['toc_max_level']
     374                : 4,
     375            'toc_list_type' => in_array($input['toc_list_type'] ?? '', ['ul', 'ol'], true)
     376                ? $input['toc_list_type']
     377                : 'ul',
     378            'permalinks_enabled' => !empty($input['permalinks_enabled']),
    289379        ];
    290380    }
     
    558648        echo '</ul>';
    559649    }
     650
     651    /**
     652     * Render smart quotes locale select dropdown.
     653     *
     654     * @param array<string, mixed> $args
     655     */
     656    public function renderSmartQuotesLocaleSelect(array $args): void
     657    {
     658        $options = get_option(self::OPTION_GROUP, []);
     659        $field = $args['field'];
     660        $current = $options[$field] ?? 'en';
     661
     662        $localeLabels = [
     663            'en' => "Default (English \u{201C}\u{2026}\u{201D} \u{2018}\u{2026}\u{2019})",
     664            'auto' => 'Auto (from site language)',
     665            'cs' => "Czech \u{201E}\u{2026}\u{201C} \u{201A}\u{2026}\u{2018}",
     666            'da' => "Danish \u{201E}\u{2026}\u{201C} \u{201A}\u{2026}\u{2018}",
     667            'nl' => "Dutch \u{201C}\u{2026}\u{201D} \u{2018}\u{2026}\u{2019}",
     668            'fi' => "Finnish \u{201D}\u{2026}\u{201D} \u{2019}\u{2026}\u{2019}",
     669            'fr' => "French \u{00AB}\u{2026}\u{00BB} \u{2039}\u{2026}\u{203A}",
     670            'de' => "German \u{201E}\u{2026}\u{201C} \u{201A}\u{2026}\u{2018}",
     671            'de-CH' => "Swiss German \u{00AB}\u{2026}\u{00BB} \u{2039}\u{2026}\u{203A}",
     672            'hu' => "Hungarian \u{201E}\u{2026}\u{201D} \u{201A}\u{2026}\u{2019}",
     673            'it' => "Italian \u{00AB}\u{2026}\u{00BB} \u{201C}\u{2026}\u{201D}",
     674            'ja' => "Japanese \u{300C}\u{2026}\u{300D} \u{300E}\u{2026}\u{300F}",
     675            'nb' => "Norwegian Bokm\u{00E5}l \u{00AB}\u{2026}\u{00BB} \u{2018}\u{2026}\u{2019}",
     676            'nn' => "Norwegian Nynorsk \u{00AB}\u{2026}\u{00BB} \u{2018}\u{2026}\u{2019}",
     677            'pl' => "Polish \u{201E}\u{2026}\u{201D} \u{201A}\u{2026}\u{2019}",
     678            'pt' => "Portuguese \u{00AB}\u{2026}\u{00BB} \u{201C}\u{2026}\u{201D}",
     679            'ru' => "Russian \u{00AB}\u{2026}\u{00BB} \u{201E}\u{2026}\u{201C}",
     680            'es' => "Spanish \u{00AB}\u{2026}\u{00BB} \u{201C}\u{2026}\u{201D}",
     681            'sv' => "Swedish \u{201D}\u{2026}\u{201D} \u{2019}\u{2026}\u{2019}",
     682            'uk' => "Ukrainian \u{00AB}\u{2026}\u{00BB} \u{201E}\u{2026}\u{201C}",
     683            'zh' => "Chinese \u{300C}\u{2026}\u{300D} \u{300E}\u{2026}\u{300F}",
     684        ];
     685
     686        printf(
     687            '<select id="%1$s" name="%2$s[%1$s]">',
     688            esc_attr($field),
     689            esc_attr(self::OPTION_GROUP),
     690        );
     691
     692        foreach ($localeLabels as $value => $label) {
     693            printf(
     694                '<option value="%s" %s>%s</option>',
     695                esc_attr($value),
     696                selected($current, $value, false),
     697                esc_html($label),
     698            );
     699        }
     700
     701        echo '</select>';
     702
     703        if (!empty($args['description'])) {
     704            printf('<p class="description">%s</p>', esc_html($args['description']));
     705        }
     706    }
     707
     708    public function renderTocSectionDescription(): void
     709    {
     710        echo '<p>' . esc_html__('Configure automatic table of contents generation for posts and pages.', 'djot-markup') . '</p>';
     711    }
     712
     713    /**
     714     * Render TOC position select dropdown.
     715     *
     716     * @param array<string, mixed> $args
     717     */
     718    public function renderTocPositionSelect(array $args): void
     719    {
     720        $options = get_option(self::OPTION_GROUP, []);
     721        $field = $args['field'];
     722        $current = $options[$field] ?? 'top';
     723
     724        $positions = [
     725            'top' => __('Top of content', 'djot-markup'),
     726            'bottom' => __('Bottom of content', 'djot-markup'),
     727        ];
     728
     729        printf(
     730            '<select id="%1$s" name="%2$s[%1$s]">',
     731            esc_attr($field),
     732            esc_attr(self::OPTION_GROUP),
     733        );
     734
     735        foreach ($positions as $value => $label) {
     736            printf(
     737                '<option value="%s" %s>%s</option>',
     738                esc_attr($value),
     739                selected($current, $value, false),
     740                esc_html($label),
     741            );
     742        }
     743
     744        echo '</select>';
     745
     746        if (!empty($args['description'])) {
     747            printf('<p class="description">%s</p>', esc_html($args['description']));
     748        }
     749    }
     750
     751    /**
     752     * Render heading level select dropdown.
     753     *
     754     * @param array<string, mixed> $args
     755     */
     756    public function renderHeadingLevelSelect(array $args): void
     757    {
     758        $options = get_option(self::OPTION_GROUP, []);
     759        $field = $args['field'];
     760        $default = $field === 'toc_min_level' ? 2 : 4;
     761        $current = $options[$field] ?? $default;
     762
     763        printf(
     764            '<select id="%1$s" name="%2$s[%1$s]">',
     765            esc_attr($field),
     766            esc_attr(self::OPTION_GROUP),
     767        );
     768
     769        for ($level = 1; $level <= 6; $level++) {
     770            printf(
     771                '<option value="%d" %s>H%d</option>',
     772                $level,
     773                selected((int)$current, $level, false),
     774                $level,
     775            );
     776        }
     777
     778        echo '</select>';
     779
     780        if (!empty($args['description'])) {
     781            printf('<p class="description">%s</p>', esc_html($args['description']));
     782        }
     783    }
     784
     785    /**
     786     * Render TOC list type select dropdown.
     787     *
     788     * @param array<string, mixed> $args
     789     */
     790    public function renderTocListTypeSelect(array $args): void
     791    {
     792        $options = get_option(self::OPTION_GROUP, []);
     793        $field = $args['field'];
     794        $current = $options[$field] ?? 'ul';
     795
     796        $listTypes = [
     797            'ul' => __('Unordered (bullets)', 'djot-markup'),
     798            'ol' => __('Ordered (numbers)', 'djot-markup'),
     799        ];
     800
     801        printf(
     802            '<select id="%1$s" name="%2$s[%1$s]">',
     803            esc_attr($field),
     804            esc_attr(self::OPTION_GROUP),
     805        );
     806
     807        foreach ($listTypes as $value => $label) {
     808            printf(
     809                '<option value="%s" %s>%s</option>',
     810                esc_attr($value),
     811                selected($current, $value, false),
     812                esc_html($label),
     813            );
     814        }
     815
     816        echo '</select>';
     817
     818        if (!empty($args['description'])) {
     819            printf('<p class="description">%s</p>', esc_html($args['description']));
     820        }
     821    }
    560822}
  • djot-markup/trunk/src/Converter.php

    r3454148 r3454428  
    1111
    1212use Djot\DjotConverter;
     13use Djot\Extension\HeadingPermalinksExtension;
     14use Djot\Extension\SmartQuotesExtension;
     15use Djot\Extension\TableOfContentsExtension;
    1316use Djot\Profile;
    1417use Djot\Renderer\SoftBreakMode;
     
    3841
    3942    private bool $markdownMode;
     43
     44    private bool $tocEnabled;
     45
     46    private string $tocPosition;
     47
     48    private int $tocMinLevel;
     49
     50    private int $tocMaxLevel;
     51
     52    private string $tocListType;
     53
     54    private bool $permalinksEnabled;
     55
     56    private string $smartQuotesLocale;
    4057
    4158    /**
     
    5168        string $commentSoftBreak = 'newline',
    5269        bool $markdownMode = false,
     70        bool $tocEnabled = false,
     71        string $tocPosition = 'top',
     72        int $tocMinLevel = 2,
     73        int $tocMaxLevel = 4,
     74        string $tocListType = 'ul',
     75        bool $permalinksEnabled = false,
     76        string $smartQuotesLocale = 'en',
    5377    ) {
    5478        $this->defaultSafeMode = $safeMode;
     
    5882        $this->commentSoftBreak = $commentSoftBreak;
    5983        $this->markdownMode = $markdownMode;
     84        $this->tocEnabled = $tocEnabled;
     85        $this->tocPosition = $tocPosition;
     86        $this->tocMinLevel = $tocMinLevel;
     87        $this->tocMaxLevel = $tocMaxLevel;
     88        $this->tocListType = $tocListType;
     89        $this->permalinksEnabled = $permalinksEnabled;
     90        $this->smartQuotesLocale = $smartQuotesLocale;
    6091        $this->converter = new DjotConverter(safeMode: false);
    6192        $this->converter->getRenderer()->setCodeBlockTabWidth(4);
     
    80111            commentSoftBreak: $options['comment_soft_break'] ?? 'newline',
    81112            markdownMode: !empty($options['markdown_mode']),
     113            tocEnabled: !empty($options['toc_enabled']),
     114            tocPosition: $options['toc_position'] ?? 'top',
     115            tocMinLevel: (int)($options['toc_min_level'] ?? 2),
     116            tocMaxLevel: (int)($options['toc_max_level'] ?? 4),
     117            tocListType: $options['toc_list_type'] ?? 'ul',
     118            permalinksEnabled: !empty($options['permalinks_enabled']),
     119            smartQuotesLocale: $options['smart_quotes_locale'] ?? 'en',
    82120        );
    83121    }
     
    93131    {
    94132        $softBreakSetting = $context === 'comment' ? $this->commentSoftBreak : $this->postSoftBreak;
    95         $key = $profileName . ($safeMode ? '_safe' : '_unsafe') . '_' . $softBreakSetting . ($this->markdownMode ? '_md' : '');
     133        $tocKey = ($this->tocEnabled && $context === 'article')
     134            ? '_toc_' . $this->tocPosition . '_' . $this->tocMinLevel . '_' . $this->tocMaxLevel . '_' . $this->tocListType
     135            : '';
     136        $permalinksKey = ($this->permalinksEnabled && $context === 'article') ? '_permalinks' : '';
     137        $smartQuotesKey = $this->smartQuotesLocale !== 'en' ? '_sq_' . $this->smartQuotesLocale : '';
     138        $key = $profileName . ($safeMode ? '_safe' : '_unsafe') . '_' . $softBreakSetting . ($this->markdownMode ? '_md' : '') . $tocKey . $permalinksKey . $smartQuotesKey;
    96139
    97140        if (!isset($this->profileConverters[$key])) {
     
    123166            // Convert tabs to 4 spaces in code blocks for consistent display
    124167            $converter->getRenderer()->setCodeBlockTabWidth(4);
     168
     169            // Add Table of Contents extension for articles when enabled
     170            if ($this->tocEnabled && $context === 'article') {
     171                $tocExtension = new TableOfContentsExtension(
     172                    minLevel: $this->tocMinLevel,
     173                    maxLevel: $this->tocMaxLevel,
     174                    listType: $this->tocListType,
     175                    cssClass: 'wpdjot-toc',
     176                    position: $this->tocPosition,
     177                );
     178                $converter->addExtension($tocExtension);
     179
     180                // Wrap TOC in collapsible <details>/<summary>
     181                $converter->addOutputTransformer(function (string $html): string {
     182                    $label = __('Table of Contents', 'djot-markup');
     183
     184                    return (string)preg_replace(
     185                        '#<nav class="wpdjot-toc">\n(.*?)</nav>\n#s',
     186                        '<details class="wpdjot-toc">' . "\n"
     187                            . '<summary>' . esc_html($label) . '</summary>' . "\n"
     188                            . '$1</details>' . "\n",
     189                        $html,
     190                    );
     191                });
     192            }
     193
     194            // Add heading permalinks for articles when enabled
     195            if ($this->permalinksEnabled && $context === 'article') {
     196                $converter->addExtension(new HeadingPermalinksExtension(
     197                    symbol: '#',
     198                    cssClass: 'wpdjot-permalink',
     199                ));
     200            }
     201
     202            // Add smart quotes extension for non-English locales
     203            if ($this->smartQuotesLocale !== 'en') {
     204                $locale = $this->smartQuotesLocale === 'auto' ? $this->getWpLocale() : $this->smartQuotesLocale;
     205                $converter->addExtension(new SmartQuotesExtension(locale: $locale));
     206            }
    125207
    126208            // Allow customization via WordPress filters
     
    367449        return $allowedHtml;
    368450    }
     451
     452    /**
     453     * Get the WordPress locale, falling back to 'en'.
     454     */
     455    private function getWpLocale(): string
     456    {
     457        if (function_exists('get_locale')) {
     458            return get_locale() ?: 'en';
     459        }
     460
     461        return 'en';
     462    }
    369463}
  • djot-markup/trunk/src/Plugin.php

    r3454148 r3454428  
    491491                'document.addEventListener("DOMContentLoaded", function() { hljs.highlightAll(); });',
    492492            );
     493        }
     494
     495        // Heading permalink copy-to-clipboard
     496        if ($this->options['permalinks_enabled']) {
     497            $copyJs = 'document.addEventListener("click",function(e){'
     498                . 'var a=e.target.closest(".wpdjot-permalink");'
     499                . 'if(!a)return;'
     500                . 'e.preventDefault();'
     501                . 'var hash=a.getAttribute("href");'
     502                . 'history.replaceState(null,null,hash);'
     503                . 'navigator.clipboard.writeText(location.href);'
     504                . '});';
     505            wp_register_script('wpdjot-permalink', false, [], (string)WPDJOT_VERSION, ['in_footer' => true]);
     506            wp_enqueue_script('wpdjot-permalink');
     507            wp_add_inline_script('wpdjot-permalink', $copyJs);
    493508        }
    494509
     
    533548            'highlight_theme' => 'github',
    534549            'shortcode_tag' => 'djot',
     550            'toc_enabled' => false,
     551            'toc_position' => 'top',
     552            'toc_min_level' => 2,
     553            'toc_max_level' => 4,
     554            'toc_list_type' => 'ul',
     555            'permalinks_enabled' => false,
     556            'smart_quotes_locale' => 'en',
    535557        ];
    536558
  • djot-markup/trunk/vendor/autoload.php

    r3454148 r3454428  
    2020require_once __DIR__ . '/composer/autoload_real.php';
    2121
    22 return ComposerAutoloaderInit4fce3a77000f5adc49d1807e59eeac64::getLoader();
     22return ComposerAutoloaderInit03e39cad62747481332e0a708bd351b8::getLoader();
  • djot-markup/trunk/vendor/composer/autoload_classmap.php

    r3454148 r3454428  
    2222    'Djot\\Extension\\HeadingPermalinksExtension' => $vendorDir . '/php-collective/djot/src/Extension/HeadingPermalinksExtension.php',
    2323    'Djot\\Extension\\MentionsExtension' => $vendorDir . '/php-collective/djot/src/Extension/MentionsExtension.php',
     24    'Djot\\Extension\\SmartQuotesExtension' => $vendorDir . '/php-collective/djot/src/Extension/SmartQuotesExtension.php',
    2425    'Djot\\Extension\\TableOfContentsExtension' => $vendorDir . '/php-collective/djot/src/Extension/TableOfContentsExtension.php',
    2526    'Djot\\Filter\\ProfileFilter' => $vendorDir . '/php-collective/djot/src/Filter/ProfileFilter.php',
     
    8081    'Djot\\Profile' => $vendorDir . '/php-collective/djot/src/Profile.php',
    8182    'Djot\\ProfileViolation' => $vendorDir . '/php-collective/djot/src/ProfileViolation.php',
     83    'Djot\\Renderer\\HeadingIdTracker' => $vendorDir . '/php-collective/djot/src/Renderer/HeadingIdTracker.php',
    8284    'Djot\\Renderer\\HtmlRenderer' => $vendorDir . '/php-collective/djot/src/Renderer/HtmlRenderer.php',
    8385    'Djot\\Renderer\\MarkdownRenderer' => $vendorDir . '/php-collective/djot/src/Renderer/MarkdownRenderer.php',
     
    8789    'Djot\\Renderer\\Utility\\EventDispatcherTrait' => $vendorDir . '/php-collective/djot/src/Renderer/Utility/EventDispatcherTrait.php',
    8890    'Djot\\SafeMode' => $vendorDir . '/php-collective/djot/src/SafeMode.php',
     91    'WpDjot\\Admin\\Settings' => $baseDir . '/src/Admin/Settings.php',
     92    'WpDjot\\Blocks\\DjotBlock' => $baseDir . '/src/Blocks/DjotBlock.php',
     93    'WpDjot\\CLI\\MigrateCommand' => $baseDir . '/src/CLI/MigrateCommand.php',
     94    'WpDjot\\Converter' => $baseDir . '/src/Converter.php',
     95    'WpDjot\\Converter\\WpHtmlToDjot' => $baseDir . '/src/Converter/WpHtmlToDjot.php',
     96    'WpDjot\\Converter\\WpMarkdownToDjot' => $baseDir . '/src/Converter/WpMarkdownToDjot.php',
     97    'WpDjot\\Migration\\Migrator' => $baseDir . '/src/Migration/Migrator.php',
     98    'WpDjot\\Plugin' => $baseDir . '/src/Plugin.php',
     99    'WpDjot\\Shortcodes\\DjotShortcode' => $baseDir . '/src/Shortcodes/DjotShortcode.php',
    89100);
  • djot-markup/trunk/vendor/composer/autoload_real.php

    r3454148 r3454428  
    33// autoload_real.php @generated by Composer
    44
    5 class ComposerAutoloaderInit4fce3a77000f5adc49d1807e59eeac64
     5class ComposerAutoloaderInit03e39cad62747481332e0a708bd351b8
    66{
    77    private static $loader;
     
    2525        require __DIR__ . '/platform_check.php';
    2626
    27         spl_autoload_register(array('ComposerAutoloaderInit4fce3a77000f5adc49d1807e59eeac64', 'loadClassLoader'), true, true);
     27        spl_autoload_register(array('ComposerAutoloaderInit03e39cad62747481332e0a708bd351b8', 'loadClassLoader'), true, true);
    2828        self::$loader = $loader = new \Composer\Autoload\ClassLoader(\dirname(__DIR__));
    29         spl_autoload_unregister(array('ComposerAutoloaderInit4fce3a77000f5adc49d1807e59eeac64', 'loadClassLoader'));
     29        spl_autoload_unregister(array('ComposerAutoloaderInit03e39cad62747481332e0a708bd351b8', 'loadClassLoader'));
    3030
    3131        require __DIR__ . '/autoload_static.php';
    32         call_user_func(\Composer\Autoload\ComposerStaticInit4fce3a77000f5adc49d1807e59eeac64::getInitializer($loader));
     32        call_user_func(\Composer\Autoload\ComposerStaticInit03e39cad62747481332e0a708bd351b8::getInitializer($loader));
    3333
    3434        $loader->register(true);
    3535
    36         $filesToLoad = \Composer\Autoload\ComposerStaticInit4fce3a77000f5adc49d1807e59eeac64::$files;
     36        $filesToLoad = \Composer\Autoload\ComposerStaticInit03e39cad62747481332e0a708bd351b8::$files;
    3737        $requireFile = \Closure::bind(static function ($fileIdentifier, $file) {
    3838            if (empty($GLOBALS['__composer_autoload_files'][$fileIdentifier])) {
  • djot-markup/trunk/vendor/composer/autoload_static.php

    r3454148 r3454428  
    55namespace Composer\Autoload;
    66
    7 class ComposerStaticInit4fce3a77000f5adc49d1807e59eeac64
     7class ComposerStaticInit03e39cad62747481332e0a708bd351b8
    88{
    99    public static $files = array (
     
    4949        'Djot\\Extension\\HeadingPermalinksExtension' => __DIR__ . '/..' . '/php-collective/djot/src/Extension/HeadingPermalinksExtension.php',
    5050        'Djot\\Extension\\MentionsExtension' => __DIR__ . '/..' . '/php-collective/djot/src/Extension/MentionsExtension.php',
     51        'Djot\\Extension\\SmartQuotesExtension' => __DIR__ . '/..' . '/php-collective/djot/src/Extension/SmartQuotesExtension.php',
    5152        'Djot\\Extension\\TableOfContentsExtension' => __DIR__ . '/..' . '/php-collective/djot/src/Extension/TableOfContentsExtension.php',
    5253        'Djot\\Filter\\ProfileFilter' => __DIR__ . '/..' . '/php-collective/djot/src/Filter/ProfileFilter.php',
     
    107108        'Djot\\Profile' => __DIR__ . '/..' . '/php-collective/djot/src/Profile.php',
    108109        'Djot\\ProfileViolation' => __DIR__ . '/..' . '/php-collective/djot/src/ProfileViolation.php',
     110        'Djot\\Renderer\\HeadingIdTracker' => __DIR__ . '/..' . '/php-collective/djot/src/Renderer/HeadingIdTracker.php',
    109111        'Djot\\Renderer\\HtmlRenderer' => __DIR__ . '/..' . '/php-collective/djot/src/Renderer/HtmlRenderer.php',
    110112        'Djot\\Renderer\\MarkdownRenderer' => __DIR__ . '/..' . '/php-collective/djot/src/Renderer/MarkdownRenderer.php',
     
    114116        'Djot\\Renderer\\Utility\\EventDispatcherTrait' => __DIR__ . '/..' . '/php-collective/djot/src/Renderer/Utility/EventDispatcherTrait.php',
    115117        'Djot\\SafeMode' => __DIR__ . '/..' . '/php-collective/djot/src/SafeMode.php',
     118        'WpDjot\\Admin\\Settings' => __DIR__ . '/../..' . '/src/Admin/Settings.php',
     119        'WpDjot\\Blocks\\DjotBlock' => __DIR__ . '/../..' . '/src/Blocks/DjotBlock.php',
     120        'WpDjot\\CLI\\MigrateCommand' => __DIR__ . '/../..' . '/src/CLI/MigrateCommand.php',
     121        'WpDjot\\Converter' => __DIR__ . '/../..' . '/src/Converter.php',
     122        'WpDjot\\Converter\\WpHtmlToDjot' => __DIR__ . '/../..' . '/src/Converter/WpHtmlToDjot.php',
     123        'WpDjot\\Converter\\WpMarkdownToDjot' => __DIR__ . '/../..' . '/src/Converter/WpMarkdownToDjot.php',
     124        'WpDjot\\Migration\\Migrator' => __DIR__ . '/../..' . '/src/Migration/Migrator.php',
     125        'WpDjot\\Plugin' => __DIR__ . '/../..' . '/src/Plugin.php',
     126        'WpDjot\\Shortcodes\\DjotShortcode' => __DIR__ . '/../..' . '/src/Shortcodes/DjotShortcode.php',
    116127    );
    117128
     
    119130    {
    120131        return \Closure::bind(function () use ($loader) {
    121             $loader->prefixLengthsPsr4 = ComposerStaticInit4fce3a77000f5adc49d1807e59eeac64::$prefixLengthsPsr4;
    122             $loader->prefixDirsPsr4 = ComposerStaticInit4fce3a77000f5adc49d1807e59eeac64::$prefixDirsPsr4;
    123             $loader->classMap = ComposerStaticInit4fce3a77000f5adc49d1807e59eeac64::$classMap;
     132            $loader->prefixLengthsPsr4 = ComposerStaticInit03e39cad62747481332e0a708bd351b8::$prefixLengthsPsr4;
     133            $loader->prefixDirsPsr4 = ComposerStaticInit03e39cad62747481332e0a708bd351b8::$prefixDirsPsr4;
     134            $loader->classMap = ComposerStaticInit03e39cad62747481332e0a708bd351b8::$classMap;
    124135
    125136        }, null, ClassLoader::class);
  • djot-markup/trunk/vendor/composer/installed.json

    r3454148 r3454428  
    33        {
    44            "name": "php-collective/djot",
    5             "version": "0.1.12",
    6             "version_normalized": "0.1.12.0",
     5            "version": "0.1.13",
     6            "version_normalized": "0.1.13.0",
    77            "source": {
    88                "type": "git",
    99                "url": "https://github.com/php-collective/djot-php.git",
    10                 "reference": "0b8e06827b24e0447acfb6c79f6ff717b90440d1"
     10                "reference": "c79cba85d26483246e7d8b1bfdf387c0dad3d489"
    1111            },
    1212            "dist": {
    1313                "type": "zip",
    14                 "url": "https://api.github.com/repos/php-collective/djot-php/zipball/0b8e06827b24e0447acfb6c79f6ff717b90440d1",
    15                 "reference": "0b8e06827b24e0447acfb6c79f6ff717b90440d1",
     14                "url": "https://api.github.com/repos/php-collective/djot-php/zipball/c79cba85d26483246e7d8b1bfdf387c0dad3d489",
     15                "reference": "c79cba85d26483246e7d8b1bfdf387c0dad3d489",
    1616                "shasum": ""
    1717            },
     
    2525                "phpunit/phpunit": "^11.0 || ^12.0"
    2626            },
    27             "time": "2026-01-20T05:36:44+00:00",
     27            "time": "2026-02-05T08:44:43+00:00",
    2828            "bin": [
    2929                "bin/djot"
     
    5555            "support": {
    5656                "issues": "https://github.com/php-collective/djot-php/issues",
    57                 "source": "https://github.com/php-collective/djot-php/tree/0.1.12"
     57                "source": "https://github.com/php-collective/djot-php/tree/0.1.13"
    5858            },
    5959            "funding": [
  • djot-markup/trunk/vendor/composer/installed.php

    r3454148 r3454428  
    22    'root' => array(
    33        'name' => 'php-collective/wp-djot',
    4         'pretty_version' => '1.0.0+no-version-set',
    5         'version' => '1.0.0.0',
    6         'reference' => null,
     4        'pretty_version' => '1.4.0',
     5        'version' => '1.4.0.0',
     6        'reference' => '8557458d2d6cb8cc4b406577b26b2afc51868377',
    77        'type' => 'wordpress-plugin',
    88        'install_path' => __DIR__ . '/../../',
     
    1212    'versions' => array(
    1313        'php-collective/djot' => array(
    14             'pretty_version' => '0.1.12',
    15             'version' => '0.1.12.0',
    16             'reference' => '0b8e06827b24e0447acfb6c79f6ff717b90440d1',
     14            'pretty_version' => '0.1.13',
     15            'version' => '0.1.13.0',
     16            'reference' => 'c79cba85d26483246e7d8b1bfdf387c0dad3d489',
    1717            'type' => 'library',
    1818            'install_path' => __DIR__ . '/../php-collective/djot',
     
    2121        ),
    2222        'php-collective/wp-djot' => array(
    23             'pretty_version' => '1.0.0+no-version-set',
    24             'version' => '1.0.0.0',
    25             'reference' => null,
     23            'pretty_version' => '1.4.0',
     24            'version' => '1.4.0.0',
     25            'reference' => '8557458d2d6cb8cc4b406577b26b2afc51868377',
    2626            'type' => 'wordpress-plugin',
    2727            'install_path' => __DIR__ . '/../../',
  • djot-markup/trunk/vendor/php-collective/djot/src/DjotConverter.php

    r3454148 r3454428  
    1010use Djot\Node\Document;
    1111use Djot\Parser\BlockParser;
     12use Djot\Renderer\HeadingIdTracker;
    1213use Djot\Renderer\HtmlRenderer;
    1314use Djot\Renderer\SoftBreakMode;
     
    280281
    281282    /**
     283     * Get the heading ID tracker
     284     */
     285    public function getHeadingIdTracker(): HeadingIdTracker
     286    {
     287        return $this->renderer->getHeadingIdTracker();
     288    }
     289
     290    /**
    282291     * Get the block parser for direct access
    283292     */
  • djot-markup/trunk/vendor/php-collective/djot/src/Extension/HeadingPermalinksExtension.php

    r3454148 r3454428  
    88use Djot\Event\RenderEvent;
    99use Djot\Node\Block\Heading;
    10 use Djot\Node\Inline\HardBreak;
    1110use Djot\Node\Inline\Link;
    12 use Djot\Node\Inline\SoftBreak;
    1311use Djot\Node\Inline\Span;
    1412use Djot\Node\Inline\Text;
    15 use Djot\Node\Node;
    1613
    1714/**
     
    3330 *     ariaLabel: 'Link to this section',
    3431 * ));
     32 *
     33 * // Show on hover only, with copy-to-clipboard:
     34 * $converter->addExtension(new HeadingPermalinksExtension(
     35 *     showOnHover: true, // adds 'permalink-hover' class to wrapper
     36 *     copyToClipboard: true, // adds 'data-permalink-copy' attribute to link
     37 * ));
     38 * ```
     39 *
     40 * When `showOnHover` is enabled, add CSS like:
     41 * ```css
     42 * .permalink-hover { opacity: 0; transition: opacity .2s; }
     43 * h1:hover > .permalink-hover, h2:hover > .permalink-hover,
     44 * h3:hover > .permalink-hover, h4:hover > .permalink-hover,
     45 * h5:hover > .permalink-hover, h6:hover > .permalink-hover,
     46 * .permalink-hover:focus-within { opacity: 1; }
     47 * ```
     48 *
     49 * When `copyToClipboard` is enabled, add JavaScript like:
     50 * ```javascript
     51 * document.addEventListener('click', function(e) {
     52 *     var a = e.target.closest('[data-permalink-copy]');
     53 *     if (!a) return;
     54 *     e.preventDefault();
     55 *     navigator.clipboard.writeText(a.href);
     56 *     history.replaceState(null, null, a.getAttribute('href'));
     57 * });
    3558 * ```
    3659 *
     
    5073     * @param string $ariaLabel Accessibility label for the link
    5174     * @param array<int> $levels Which heading levels to add permalinks to (1-6)
     75     * @param bool $showOnHover Only show permalink on heading hover (injects CSS)
     76     * @param bool $copyToClipboard Copy URL to clipboard on click instead of navigating (injects JS)
    5277     */
    5378    public function __construct(
     
    5782        protected string $ariaLabel = 'Permalink',
    5883        protected array $levels = [1, 2, 3, 4, 5, 6],
     84        protected bool $showOnHover = false,
     85        protected bool $copyToClipboard = false,
    5986    ) {
    6087    }
     
    6289    public function register(DjotConverter $converter): void
    6390    {
    64         $converter->on('render.heading', function (RenderEvent $event): void {
     91        $tracker = $converter->getHeadingIdTracker();
     92
     93        $converter->on('render.heading', function (RenderEvent $event) use ($tracker): void {
    6594            $node = $event->getNode();
    6695            if (!$node instanceof Heading) {
     
    72101            }
    73102
    74             // Get the ID (either explicit or will be generated by renderer)
    75             /** @var string|null $id */
    76             $id = $node->getAttribute('id');
    77             if ($id === null || $id === '') {
    78                 // Generate ID from heading text (matching renderer logic)
    79                 $id = $this->generateIdFromNode($node);
    80                 // Set it explicitly so the renderer uses this ID, not one generated
    81                 // from the modified heading content (which would include the permalink symbol)
    82                 if ($id !== '') {
    83                     $node->setAttribute('id', $id);
    84                 }
    85             }
     103            // Get the deduplicated ID from the shared tracker
     104            $id = $tracker->getIdForHeading($node);
    86105
    87106            if ($id === '') {
    88107                return;
     108            }
     109
     110            // Set it explicitly so the renderer uses this ID, not one generated
     111            // from the modified heading content (which would include the permalink symbol)
     112            if (!$node->hasAttribute('id')) {
     113                $node->setAttribute('id', $id);
    89114            }
    90115
     
    95120            $link->appendChild(new Text($this->symbol));
    96121
     122            if ($this->copyToClipboard) {
     123                $link->setAttribute('data-permalink-copy', '');
     124            }
     125
    97126            // Wrap in span for styling flexibility
    98127            $span = new Span();
    99128            $span->addClass('permalink-wrapper');
     129            if ($this->showOnHover) {
     130                $span->addClass('permalink-hover');
     131            }
    100132            $span->appendChild($link);
    101133
    102134            // Add to heading
    103135            if ($this->position === 'before') {
    104                 // Prepend space + span, then the space
    105136                $space = new Text(' ');
    106137                $node->prependChild($space);
    107138                $node->prependChild($span);
    108139            } else {
    109                 // Add space before
    110140                $node->appendChild(new Text(' '));
    111141                $node->appendChild($span);
     
    113143        });
    114144    }
    115 
    116     /**
    117      * Generate a slug from heading content (matches HtmlRenderer behavior)
    118      */
    119     protected function generateIdFromNode(Heading $node): string
    120     {
    121         $text = $this->getPlainText($node);
    122         if ($text === '') {
    123             return '';
    124         }
    125 
    126         // Match HtmlRenderer::getSectionId() behavior:
    127         // 1. Strip # characters entirely
    128         // 2. Trim whitespace
    129         // 3. Replace whitespace sequences with single dashes
    130         $id = str_replace('#', '', $text);
    131         $id = trim($id);
    132         $id = preg_replace('/[\s]+/', '-', $id) ?? $id;
    133 
    134         return $id;
    135     }
    136 
    137     /**
    138      * Extract plain text from a node and its children (recursive)
    139      */
    140     protected function getPlainText(Node $node): string
    141     {
    142         $text = '';
    143         foreach ($node->getChildren() as $child) {
    144             if ($child instanceof Text) {
    145                 $text .= $child->getContent();
    146             } elseif ($child instanceof SoftBreak || $child instanceof HardBreak) {
    147                 $text .= ' ';
    148             } elseif ($child instanceof Node) {
    149                 $text .= $this->getPlainText($child);
    150             }
    151         }
    152 
    153         return $text;
    154     }
    155145}
  • djot-markup/trunk/vendor/php-collective/djot/src/Extension/TableOfContentsExtension.php

    r3454148 r3454428  
    77use Djot\DjotConverter;
    88use Djot\Node\Block\Heading;
    9 use Djot\Node\Inline\Text;
    10 use Djot\Node\Node;
    119
    1210/**
     
    7573    public function register(DjotConverter $converter): void
    7674    {
     75        $tracker = $converter->getHeadingIdTracker();
     76
    7777        // Hook into heading rendering to collect TOC entries
    78         $converter->on('render.heading', function ($event): void {
     78        $converter->on('render.heading', function ($event) use ($tracker): void {
    7979            $node = $event->getNode();
    8080            if (!$node instanceof Heading) {
     
    8888            }
    8989
    90             $text = $this->getPlainText($node);
    91             $id = $this->generateId($node, $text);
     90            $text = $tracker->getPlainText($node);
     91            $id = $tracker->getIdForHeading($node);
    9292
    9393            $this->toc[] = [
     
    158158
    159159    /**
    160      * Extract plain text from a node
    161      */
    162     protected function getPlainText(Node $node): string
    163     {
    164         $text = '';
    165         foreach ($node->getChildren() as $child) {
    166             if ($child instanceof Text) {
    167                 $text .= $child->getContent();
    168             } elseif (method_exists($child, 'getChildren')) {
    169                 $text .= $this->getPlainText($child);
    170             }
    171         }
    172 
    173         return $text;
    174     }
    175 
    176     /**
    177      * Generate ID for a heading (matches HtmlRenderer::getSectionId() behavior)
    178      */
    179     protected function generateId(Heading $heading, string $text): string
    180     {
    181         // Check for explicit ID attribute
    182         $id = $heading->getAttribute('id');
    183         if (is_string($id) && $id !== '') {
    184             return $id;
    185         }
    186 
    187         // Generate from text - match HtmlRenderer::getSectionId() behavior:
    188         // 1. Strip # characters entirely
    189         // 2. Trim whitespace
    190         // 3. Replace whitespace sequences with single dashes
    191         if ($text === '') {
    192             return '';
    193         }
    194 
    195         $id = str_replace('#', '', $text);
    196         $id = trim($id);
    197         $id = preg_replace('/[\s]+/', '-', $id) ?? $id;
    198 
    199         return $id;
    200     }
    201 
    202     /**
    203160     * Render TOC as nested HTML list
    204161     *
  • djot-markup/trunk/vendor/php-collective/djot/src/Parser/InlineParser.php

    r3454148 r3454428  
    6565    protected ?array $cachedAbbreviations = null;
    6666
     67    /**
     68     * Smart quote characters (configurable via SmartQuotesExtension for locale support)
     69     */
     70    protected string $openDoubleQuote = "\u{201C}";
     71
     72    protected string $closeDoubleQuote = "\u{201D}";
     73
     74    protected string $openSingleQuote = "\u{2018}";
     75
     76    protected string $closeSingleQuote = "\u{2019}";
     77
     78    /**
     79     * Apostrophe character (always U+2019 RIGHT SINGLE QUOTATION MARK)
     80     *
     81     * Not configurable via extension — apostrophes are language-independent.
     82     */
     83    protected string $apostrophe = "\u{2019}";
     84
    6785    public function __construct(protected BlockParser $blockParser)
    6886    {
     
    117135    {
    118136        return $this->customPatterns;
     137    }
     138
     139    /**
     140     * Set locale-specific smart quote characters
     141     *
     142     * Apostrophes (mid-word and before digits) always remain U+2019
     143     * regardless of this setting.
     144     */
     145    public function setQuoteCharacters(
     146        string $openDoubleQuote,
     147        string $closeDoubleQuote,
     148        string $openSingleQuote,
     149        string $closeSingleQuote,
     150    ): void {
     151        $this->openDoubleQuote = $openDoubleQuote;
     152        $this->closeDoubleQuote = $closeDoubleQuote;
     153        $this->openSingleQuote = $openSingleQuote;
     154        $this->closeSingleQuote = $closeSingleQuote;
     155    }
     156
     157    /**
     158     * Get the current smart quote characters
     159     *
     160     * @return array{openDouble: string, closeDouble: string, openSingle: string, closeSingle: string}
     161     */
     162    public function getQuoteCharacters(): array
     163    {
     164        return [
     165            'openDouble' => $this->openDoubleQuote,
     166            'closeDouble' => $this->closeDoubleQuote,
     167            'openSingle' => $this->openSingleQuote,
     168            'closeSingle' => $this->closeSingleQuote,
     169        ];
    119170    }
    120171
     
    10551106            // Must be followed by closing }
    10561107            if ($quotePos < $length && $text[$quotePos] === '}') {
    1057                 // Generate curly quotes based on count
    1058                 $openQuote = $marker === "'" ? "\u{2018}" : "\u{201C}";
    1059                 $closeQuote = $marker === "'" ? "\u{2019}" : "\u{201D}";
     1108                // Generate quotes based on count
     1109                $openQuote = $marker === "'" ? $this->openSingleQuote : $this->openDoubleQuote;
     1110                $closeQuote = $marker === "'" ? $this->closeSingleQuote : $this->closeDoubleQuote;
    10601111
    10611112                // For pairs like {''}, output left + right
    1062                 // For single {'}, output just right (used for apostrophe)
     1113                // For single {'}, output apostrophe (always U+2019), {"} output close double
    10631114                if ($quoteCount === 1) {
    1064                     $result = $closeQuote;
     1115                    $result = $marker === "'" ? $this->apostrophe : $closeQuote;
    10651116                } elseif ($quoteCount === 2) {
    10661117                    $result = $openQuote . $closeQuote;
     
    11221173        // Quote immediately after = is always an opener (attribute value start)
    11231174        if ($prevChar === '=') {
    1124             return $quote === '"' ? "\u{201C}" : "\u{2018}";
     1175            return $quote === '"' ? $this->openDoubleQuote : $this->openSingleQuote;
    11251176        }
    11261177
     
    11471198        // Single quote before digit is always apostrophe (e.g., '70s)
    11481199        if ($quote === "'" && ctype_digit($nextChar)) {
    1149             return "\u{2019}"; // closing/apostrophe
     1200            return $this->apostrophe;
    11501201        }
    11511202
    11521203        // A quote after ] or ) cannot be an opener
    11531204        if ($prevChar === ']' || $prevChar === ')') {
    1154             return $quote === '"' ? "\u{201D}" : "\u{2019}";
     1205            return $quote === '"' ? $this->closeDoubleQuote : $this->closeSingleQuote;
    11551206        }
    11561207
    11571208        if ($quote === '"') {
    11581209            // Opening if preceded by space or start, closing otherwise
    1159             return $prevIsSpace && !$nextIsSpace ? "\u{201C}" : "\u{201D}";
     1210            return $prevIsSpace && !$nextIsSpace ? $this->openDoubleQuote : $this->closeDoubleQuote;
    11601211        }
    11611212
     
    11661217            $matchingCloser = $this->findMatchingSingleQuoteCloser($text, $pos);
    11671218            if ($matchingCloser !== null) {
    1168                 return "\u{2018}"; // opening quote
     1219                return $this->openSingleQuote;
    11691220            }
    11701221
    11711222            // No matching closer found, treat as apostrophe
    1172             return "\u{2019}";
    1173         }
    1174 
    1175         // Closing/apostrophe
    1176         return "\u{2019}";
     1223            return $this->apostrophe;
     1224        }
     1225
     1226        // Check if this is mid-word (next char is a word character) — apostrophe
     1227        if (preg_match('/\w/u', $nextChar)) {
     1228            return $this->apostrophe;
     1229        }
     1230
     1231        // Closing single quote
     1232        return $this->closeSingleQuote;
    11771233    }
    11781234
     
    13591415            }
    13601416
    1361             // Check for multi-byte UTF-8 curly quotes (3 bytes each)
     1417            // Check for multi-byte configured quote characters
    13621418            // These act as word boundaries for attribute attachment
    1363             if (ord($char) >= 0x98 && ord($char) <= 0x9D && $wordStart >= 3) {
    1364                 $threeBytes = substr($textBuffer, $wordStart - 3, 3);
    1365                 // Check for curly quotes: " " ' ' (U+201C, U+201D, U+2018, U+2019)
    1366                 if (
    1367                     $threeBytes === "\u{201C}" || $threeBytes === "\u{201D}" ||
    1368                     $threeBytes === "\u{2018}" || $threeBytes === "\u{2019}"
    1369                 ) {
    1370                     break;
     1419            foreach ($this->getConfiguredQuoteStrings() as $quoteStr) {
     1420                $quoteLen = strlen($quoteStr);
     1421                if ($wordStart >= $quoteLen && substr($textBuffer, $wordStart - $quoteLen, $quoteLen) === $quoteStr) {
     1422                    break 2;
    13711423                }
    13721424            }
     
    14061458            'pos' => $attrEnd + 1,
    14071459        ];
     1460    }
     1461
     1462    /**
     1463     * Get all unique configured quote strings for word boundary detection
     1464     *
     1465     * @return array<string>
     1466     */
     1467    protected function getConfiguredQuoteStrings(): array
     1468    {
     1469        return array_unique([
     1470            $this->openDoubleQuote,
     1471            $this->closeDoubleQuote,
     1472            $this->openSingleQuote,
     1473            $this->closeSingleQuote,
     1474            $this->apostrophe,
     1475        ]);
    14081476    }
    14091477
  • djot-markup/trunk/vendor/php-collective/djot/src/Renderer/HtmlRenderer.php

    r3454148 r3454428  
    7171    protected array $footnoteRefCounts = [];
    7272
    73     /**
    74      * Tracks used IDs for deduplication
    75      *
    76      * @var array<string, int>
    77      */
    78     protected array $usedIds = [];
    79 
    80     /**
    81      * Counter for auto-generated section IDs (when heading has no text)
    82      */
    83     protected int $sectionCounter = 0;
     73    protected HeadingIdTracker $headingIdTracker;
    8474
    8575    /**
     
    116106    public function __construct(protected bool $xhtml = false)
    117107    {
     108        $this->headingIdTracker = new HeadingIdTracker();
    118109        $this->initNodeRenderers();
     110    }
     111
     112    /**
     113     * Get the heading ID tracker
     114     */
     115    public function getHeadingIdTracker(): HeadingIdTracker
     116    {
     117        return $this->headingIdTracker;
    119118    }
    120119
     
    247246        // Reset state for each render
    248247        $this->footnoteRefCounts = [];
    249         $this->usedIds = [];
    250         $this->sectionCounter = 0;
     248        $this->headingIdTracker->reset();
    251249        $this->footnoteNumbers = [];
    252250        $this->footnoteCounter = 0;
     
    337335    protected function getSectionId(Heading $node): string
    338336    {
    339         // If heading has explicit id attribute, use it
     337        return $this->headingIdTracker->getIdForHeading($node);
     338    }
     339
     340    /**
     341     * Track ID usage from non-heading elements (like paragraphs with explicit IDs)
     342     */
     343    protected function trackIdFromNode(Node $node): void
     344    {
    340345        if ($node->hasAttribute('id')) {
    341346            $idAttr = $node->getAttribute('id');
    342347            $id = is_string($idAttr) ? $idAttr : '';
    343             // Track explicit IDs so auto-generated IDs don't conflict
    344             if (!isset($this->usedIds[$id])) {
    345                 $this->usedIds[$id] = 0;
    346             }
    347 
    348             return $id;
    349         }
    350 
    351         // Generate from heading text
    352         $headingText = $this->getPlainText($node);
    353 
    354         if ($headingText === '') {
    355             // Generate fallback ID
    356             $this->sectionCounter++;
    357 
    358             return 's-' . $this->sectionCounter;
    359         }
    360 
    361         // Convert to valid ID:
    362         // 1. Strip # characters entirely
    363         // 2. Trim whitespace
    364         // 3. Replace whitespace sequences with single dashes
    365         $baseId = str_replace('#', '', $headingText);
    366         $baseId = trim($baseId);
    367         $baseId = preg_replace('/[\s]+/', '-', $baseId) ?? $baseId;
    368 
    369         // Track and deduplicate
    370         if (!isset($this->usedIds[$baseId])) {
    371             $this->usedIds[$baseId] = 0;
    372 
    373             return $baseId;
    374         }
    375 
    376         // Already used, add suffix (first conflict is -1, second is -2, etc.)
    377         $this->usedIds[$baseId]++;
    378 
    379         return $baseId . '-' . $this->usedIds[$baseId];
    380     }
    381 
    382     /**
    383      * Track ID usage from non-heading elements (like paragraphs with explicit IDs)
    384      */
    385     protected function trackIdFromNode(Node $node): void
    386     {
    387         if ($node->hasAttribute('id')) {
    388             $idAttr = $node->getAttribute('id');
    389             $id = is_string($idAttr) ? $idAttr : '';
    390             if ($id !== '' && !isset($this->usedIds[$id])) {
    391                 $this->usedIds[$id] = 0;
    392             }
     348            $this->headingIdTracker->trackId($id);
    393349        }
    394350    }
     
    516472    protected function getPlainText(Node $node): string
    517473    {
    518         $text = '';
    519         foreach ($node->getChildren() as $child) {
    520             if ($child instanceof Text) {
    521                 $text .= $child->getContent();
    522             } elseif ($child instanceof SoftBreak || $child instanceof HardBreak) {
    523                 $text .= ' ';
    524             } elseif ($child instanceof Node) {
    525                 $text .= $this->getPlainText($child);
    526             }
    527         }
    528 
    529         return $text;
     474        return $this->headingIdTracker->getPlainText($node);
    530475    }
    531476
  • djot-markup/trunk/wp-djot.php

    r3454148 r3454428  
    22/**
    33 * Plugin Name: Djot Markup
    4  * Plugin URI: https://github.com/php-collective/wp-djot
     4 * Plugin URI: https://wordpress.org/plugins/djot-markup/
    55 * Description: <a href="https://djot.net/" target="_blank">Djot</a> markup language support for WordPress – a modern, cleaner alternative to Markdown with syntax highlighting. Convert Djot syntax to HTML in posts, pages, and comments.
    6  * Version: 1.3.1
     6 * Version: 1.4.0
    77 * Requires at least: 6.0
    88 * Requires PHP: 8.2
     
    2525
    2626// Plugin constants
    27 define('WPDJOT_VERSION', '1.3.1');
     27define('WPDJOT_VERSION', '1.4.0');
    2828define('WPDJOT_PLUGIN_DIR', plugin_dir_path(__FILE__));
    2929define('WPDJOT_PLUGIN_URL', plugin_dir_url(__FILE__));
Note: See TracChangeset for help on using the changeset viewer.