svelte-multiselect
Version:
Svelte multi-select component
121 lines (120 loc) • 6.09 kB
JavaScript
// 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();
};