svelte-multiselect
Version:
Svelte multi-select component
203 lines (202 loc) • 10.2 kB
JavaScript
// Vite plugin - handles virtual module resolution for example components
import { Buffer } from 'node:buffer';
import process from 'node:process';
// Vite's own ESTree parser (native OXC, no extra dependency): same node types
// and start/end offsets acorn produced, module + latest syntax by default
import { parseSync } from 'vite';
import { EXAMPLE_MODULE_PREFIX } from './mdsvex-transform.js';
// Matches import paths emitted by mdsvex-transform, e.g. ___live_example___0.svelte
const RE_EXAMPLE_IMPORT = new RegExp(`${EXAMPLE_MODULE_PREFIX}(\\d+)\\.svelte`, `u`);
const with_empty_map = (code) => ({
code,
map: { mappings: `` },
});
// Max chars to scan after property end for trailing comma/whitespace cleanup
const TRAILING_CLEANUP_BOUND = 50; // Generous bound - typical trailing content is ", " (2 chars)
// Apply edits in reverse order so positions stay valid
const apply_edits = (source, edits) => edits
.toSorted((a, b) => b.start - a.start)
.reduce((str, { start, end, content }) => str.slice(0, start) + content + str.slice(end), source);
function is_record(val) {
return typeof val === `object` && val !== null;
}
// Single-pass AST walk collecting the two node kinds this plugin rewrites:
// __live_example_src properties and import declarations/expressions
function collect_nodes(tree) {
const src_props = [];
const imports = [];
const walk = (node) => {
if (Array.isArray(node))
return node.forEach(walk);
if (!is_record(node))
return;
if (node.type === `Property` &&
is_record(node.key) &&
node.key.name === `__live_example_src`) {
src_props.push(node);
}
else if (node.type === `ImportDeclaration` || node.type === `ImportExpression`) {
imports.push(node);
}
Object.values(node).forEach(walk);
};
walk(tree);
return { src_props, imports };
}
// Normalize a module ID to absolute path for consistent lookups.
// Can't use path.posix.join because it treats /src/... as already absolute.
function to_absolute(id, cwd) {
if (id.startsWith(`${cwd}/`) || id === cwd)
return id;
return id.startsWith(`/`) ? `${cwd}${id}` : `${cwd}/${id}`;
}
export default function live_examples_plugin(options = {}) {
const { extensions = [`.svelte.md`, `.md`, `.svx`] } = options;
// Extracted examples as individual virtual files (id -> svelte source)
const virtual_files = new Map();
let vite_server;
let pending_hmr_file = null;
const cwd = process.cwd().replaceAll(`\\`, `/`);
// Pre-enforce plugin ensures resolveId runs before vite-plugin-svelte's
// load-compiled-css:resolveId, so CSS derived modules get the same absolute
// path as the main component (needed for module graph CSS cache lookup).
const resolve_plugin = {
name: `live-examples-resolve`,
enforce: `pre`,
resolveId(id) {
return id.includes(EXAMPLE_MODULE_PREFIX) ? to_absolute(id, cwd) : undefined;
},
};
const main_plugin = {
name: `live-examples-plugin`,
configureServer(server) {
vite_server = server;
},
load(id) {
if (!id.includes(EXAMPLE_MODULE_PREFIX))
return undefined;
const [base_id, query = ``] = id.split(`?`);
// Skip derived module requests (CSS, scripts) - let vite-plugin-svelte handle them.
// Must check BEFORE virtual_files lookup to avoid returning Svelte source for CSS.
if (/type=(?<type>style|script|module)/u.test(query))
return undefined;
const src = virtual_files.get(base_id);
if (src !== undefined)
return src;
// For main component requests in production, fail the build
const msg = `Example src not found for ${id}`;
if (process.env.NODE_ENV === `production`) {
throw new Error(msg);
}
// In dev, warn and return error component to surface issue visibly
this.warn(msg);
return `<script>console.error(${JSON.stringify(msg)})</script><p style="color:red">${msg}</p>`;
},
transform(code, id) {
// Strip query params for extension check (Vite adds ?query for HMR, styles, etc.)
const base_id = id.split(`?`)[0];
// Skip non-matching files
const is_example_module = id.includes(EXAMPLE_MODULE_PREFIX);
const is_markdown = extensions.some((ext) => base_id.endsWith(ext));
if (!is_example_module && !is_markdown)
return undefined;
// Skip derived modules (styles, etc.) - only process the main markdown file.
// Match both ?svelte&type= and ?inline&svelte&type= (SSR adds ?inline prefix)
if (id.includes(`svelte&type=`))
return with_empty_map(code);
if (is_markdown) {
// Use AST for precise node location, collect edits to apply at end.
// parseSync reports errors instead of throwing (unlike acorn): code may
// contain Svelte syntax the JS parser can't handle (e.g., template
// blocks, special directives) — skip transformation then.
const { program: tree, errors } = parseSync(`file.js`, code);
if (errors.length > 0)
return with_empty_map(code);
const edits = [];
const { src_props, imports } = collect_nodes(tree);
const invalidate_virtual_modules = (virtual_id) => {
const server = vite_server;
if (!server)
return;
for (const module_id of [
virtual_id,
base_id,
`${virtual_id}?svelte&type=style&lang.css`,
]) {
const mod = server.moduleGraph.getModuleById(module_id);
if (mod)
server.moduleGraph.invalidateModule(mod);
}
// Trigger full-reload only during HMR (not initial page load).
// reloadModule alone doesn't work because vite-plugin-svelte's hot-update
// handler skips CSS-only changes when the JS output is identical.
if (pending_hmr_file !== base_id)
return;
pending_hmr_file = null;
setTimeout(() => {
server.hot.send({ type: `full-reload`, path: `*` });
}, 200);
};
for (const [idx, prop] of src_props.entries()) {
// Read the property value directly instead of recursively searching nested literals.
const prop_value = prop.value;
if (!is_record(prop_value))
continue;
if (prop_value.type !== `Literal` || typeof prop_value.value !== `string`)
continue;
const src = Buffer.from(prop_value.value, `base64`).toString(`utf-8`);
// Use base_id (without query params) to ensure consistent virtual file IDs
const virtual_id = `${base_id}${EXAMPLE_MODULE_PREFIX}${idx}.svelte`;
if (src !== virtual_files.get(virtual_id)) {
virtual_files.set(virtual_id, src);
// Invalidate virtual modules and schedule a deferred reload so
// load() returns fresh content from virtual_files (which was just updated).
invalidate_virtual_modules(virtual_id);
}
// Remove the property (including trailing comma/whitespace)
if (typeof prop.start === `number` && typeof prop.end === `number`) {
let end = prop.end;
const max_end = Math.min(prop.end + TRAILING_CLEANUP_BOUND, code.length);
while (end < max_end && /[\s,]/u.test(code[end]))
end++;
edits.push({ start: prop.start, end, content: `` });
}
}
// Clear pending state even if no examples changed — stale flag would
// cause an unnecessary full-reload on a future edit
if (pending_hmr_file === base_id)
pending_hmr_file = null;
// Update import paths (static and dynamic) to use virtual file IDs
for (const import_node of imports) {
const source = import_node.source;
if (!is_record(source) || typeof source.value !== `string`)
continue;
const match = RE_EXAMPLE_IMPORT.exec(source.value);
if (match &&
typeof source.start === `number` &&
typeof source.end === `number`) {
const virtual_id = `${base_id}${EXAMPLE_MODULE_PREFIX}${match[1]}.svelte`;
edits.push({
start: source.start + 1,
end: source.end - 1,
content: virtual_id,
});
}
}
return with_empty_map(apply_edits(code, edits));
}
return with_empty_map(code);
},
handleHotUpdate(ctx) {
const file = ctx.file.replaceAll(`\\`, `/`);
if (extensions.some((ext) => file.endsWith(ext))) {
pending_hmr_file = file;
}
// Don't add virtual modules here — they'd be loaded with stale content
// since the parent .md hasn't been re-transformed yet. The transform
// hook handles invalidation + reload after virtual_files is updated.
return ctx.modules;
},
};
return [resolve_plugin, main_plugin];
}