UNPKG

svelte-multiselect

Version:
121 lines (120 loc) 6.09 kB
// Svelte preprocessor that adds IDs to headings at build time for SSR support // This ensures fragment navigation (#heading-id) works on initial page load // Match headings in two contexts: // 1. Start of line (for .svelte files with formatted HTML) // 2. After > (for mdsvex output where HTML is on single line, e.g., "</p> <h2>") // Avoid matching inside src={...} attributes by requiring these specific contexts // Note: [^>]* for attributes won't match if an attribute value contains > (e.g., data-foo="a>b") // This edge case is rare in practice and would require significantly more complex parsing const heading_regex_line_start = /^(?<indent>\s*)<(?<tag>h[1-6])(?<attrs>[^>]*)>(?<inner>[\s\S]*?)<\/\k<tag>>/gimu; const heading_regex_after_tag = /(?<gt>>)(?<space>\s*)<(?<tag>h[1-6])(?<attrs>[^>]*)>(?<inner>[\s\S]*?)<\/\k<tag>>/giu; // Remove Svelte expressions handling nested braces (e.g., {fn({a: 1})}) // Treats unmatched } as literal text to avoid dropping content function strip_svelte_expressions(str) { let result = ``; let depth = 0; for (const char of str) { if (char === `{`) depth++; else if (char === `}` && depth > 0) depth--; else if (depth === 0) result += char; } return result; } // Generate URL-friendly slug from text const slugify = (text) => text .toLowerCase() .replaceAll(/\s+/gu, `-`) .replaceAll(/[^\w-]/gu, ``) .replaceAll(/-+/gu, `-`) // collapse multiple dashes .replaceAll(/^-|-$/gu, ``); // trim leading/trailing dashes /** @type {() => import('svelte/compiler').PreprocessorGroup} */ export function heading_ids() { return { name: `heading-ids`, markup({ content }) { const seen_ids = new Map(); let result = content; const process_heading = (attrs, inner) => { // Skip if already has an id (use ^|\s to avoid matching data-id, aria-id, etc.) if (/(?:^|\s)id\s*=/u.test(attrs)) return null; const text = strip_svelte_expressions(inner.replaceAll(/<[^>]+>/gu, ``)).trim(); if (!text) return null; const base_id = slugify(text); if (!base_id) return null; // Handle duplicates within same file const count = seen_ids.get(base_id) ?? 0; seen_ids.set(base_id, count + 1); return count ? `${base_id}-${count}` : base_id; }; // Rewrite a matched heading with a slug id (or return it unchanged). Pass 1 matches // at line start (.svelte files); pass 2 after a closing tag (mdsvex single-line output). const build_heading = (match, prefix, tag, attrs, inner) => { const id = process_heading(attrs, inner); return id ? `${prefix}<${tag} id="${id}"${attrs}>${inner}</${tag}>` : match; }; result = result .replace(heading_regex_line_start, build_heading) .replace(heading_regex_after_tag, (match, gt, space, tag, attrs, inner) => build_heading(match, `${gt}${space}`, tag, attrs, inner)); return { code: result }; }, }; } // SVG link icon for heading anchors const link_svg = `<svg width="16" height="16" viewBox="0 0 16 16" aria-label="Link to heading" role="img"><path d="M7.775 3.275a.75.75 0 0 0 1.06 1.06l1.25-1.25a2 2 0 1 1 2.83 2.83l-2.5 2.5a2 2 0 0 1-2.83 0 .75.75 0 0 0-1.06 1.06 3.5 3.5 0 0 0 4.95 0l2.5-2.5a3.5 3.5 0 0 0-4.95-4.95l-1.25 1.25zm-4.69 9.64a2 2 0 0 1 0-2.83l2.5-2.5a2 2 0 0 1 2.83 0 .75.75 0 0 0 1.06-1.06 3.5 3.5 0 0 0-4.95 0l-2.5 2.5a3.5 3.5 0 0 0 4.95 4.95l1.25-1.25a.75.75 0 0 0-1.06-1.06l-1.25 1.25a2 2 0 0 1-2.83 0z" fill="currentColor"/></svg>`; // Add anchor link to a single heading element function add_anchor_to_heading(heading, icon_svg = link_svg) { if (heading.querySelector(`a[aria-hidden="true"]`)) return; if (!heading.id) { // Generate ID from text content (fallback for dynamic headings) const base_id = slugify((heading.textContent ?? ``).trim()); if (!base_id) return; // Ensure unique ID in document (getElementById since slugs can start with a // digit, which querySelector rejects as an invalid CSS ID selector) let counter = 0; // oxlint-disable-next-line unicorn/prefer-query-selector while (document.getElementById(counter ? `${base_id}-${counter}` : base_id)) counter++; heading.id = counter ? `${base_id}-${counter}` : base_id; } const anchor = document.createElement(`a`); anchor.href = `#${heading.id}`; anchor.setAttribute(`aria-hidden`, `true`); anchor.innerHTML = icon_svg; heading.append(anchor); } const is_heading = (element) => /^H[1-6]$/u.test(element.tagName); const get_default_headings = (node) => [...node.children].flatMap((child) => [ ...(is_heading(child) ? [child] : []), ...[...child.children].filter(is_heading), ]); // Svelte 5 attachment that adds anchor links to headings within a container // Uses MutationObserver to handle dynamically added headings export const heading_anchors = (options = {}) => (node) => { if (typeof document === `undefined`) return undefined; const icon_svg = options.icon_svg ?? link_svg; const selector = options.selector; const get_headings = selector ? () => Array.from(node.querySelectorAll(selector)) : () => get_default_headings(node); // Process existing headings for (const heading of get_headings()) { add_anchor_to_heading(heading, icon_svg); } // Watch for new headings - requery the container to respect nesting depth constraints const observer = new MutationObserver(() => { for (const heading of get_headings()) { add_anchor_to_heading(heading, icon_svg); } }); observer.observe(node, { childList: true, subtree: true }); return () => observer.disconnect(); };