Skip to content
This repository was archived by the owner on Apr 27, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions packages/astro-markflow/src/transforms/inject-components.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,34 @@ export default function Content() {
expect(result).toContain('import { Aside }');
});

it('should strip single-line getHeadings before scanning', () => {
const code = `
export function getHeadings() { return [{"depth":1,"slug":"aside","text":"Aside"}]; }

export default function Content() {
return <Aside>Content</Aside>;
}`;

const result = injectComponentImports(code, ['Aside'], '@astrojs/starlight/components');

expect(result).toContain('import { Aside }');
});

it('should not false-positive when heading text contains semicolons before component names', () => {
const code = `
export const headings = [
{ depth: 2, slug: 'use-aside', text: 'Use; <Aside> wisely' }
];

export default function Content() {
return <div>No components here</div>;
}`;

const result = injectComponentImports(code, ['Aside'], '@astrojs/starlight/components');

expect(result).not.toContain('import');
});

it('should handle components in nested structures', () => {
const code = `
export default function Content() {
Expand Down
46 changes: 30 additions & 16 deletions packages/astro-markflow/src/transforms/inject-components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,29 @@ import type { Registry } from 'markflow/registry';
import { astroLibrary } from 'markflow/registry';
import { collectImportedNames, insertAfterImports } from '../utils/imports.js';
import { resolveStarlightConfig, type StarlightUserConfig } from '../utils/config.js';
import { stripHeadingsMeta } from '../utils/validation.js';

/** Strip set:html={...} string content to avoid false component matches in code blocks */
function stripSetHtmlContent(code: string): string {
return code.replace(/set:html=\{("(?:[^"\\]|\\.)*")\}/g, 'set:html={""}');

/** Combined regex to strip set:html content and headings metadata in a single pass */
const RE_SCAN_NOISE = /set:html=\{"(?:[^"\\]|\\.)*"\}|export const headings[\s\S]*?];\n?|export function getHeadings\(\) \{(?:[^\n]+\n|[\s\S]*?\n\}\n?)/g;

/** Strip set:html content and headings metadata in a single pass to avoid false component matches */
function stripScanNoise(code: string): string {
return code.replace(RE_SCAN_NOISE, (match) =>
match.startsWith('set:html=') ? 'set:html={""}' : ''
);
}

/** Cache compiled regex per registry instance (registry is immutable after init) */
const registryRegexCache = new WeakMap<Registry, RegExp>();

function getComponentPattern(registry: Registry): RegExp | null {
let cached = registryRegexCache.get(registry);
if (cached) return cached;
const all = registry.getAllComponents();
if (all.length === 0) return null;
cached = new RegExp(`<(${all.map(c => c.name).join('|')})\\b`, 'g');
registryRegexCache.set(registry, cached);
return cached;
}

/**
Expand All @@ -35,7 +53,7 @@ export function injectComponentImports(
if (!code || typeof code !== 'string' || components.length === 0) {
return code;
}
const scanTarget = stripSetHtmlContent(stripHeadingsMeta(code));
const scanTarget = stripScanNoise(code);

// PERF: Use single combined regex instead of per-component regex
// This reduces from O(n) regex compilations to O(1)
Expand All @@ -52,7 +70,7 @@ export function injectComponentImports(
const used = components.filter((name) => usedSet.has(name));
if (used.length === 0) return code;

const imported = collectImportedNames(code);
const imported = collectImportedNames(code, { skipCodeFences: true });
const missing = used.filter((name) => !imported.has(name));
if (missing.length === 0) return code;

Expand Down Expand Up @@ -123,18 +141,13 @@ export function injectComponentImportsFromRegistry(
return code;
}

const allComponents = registry.getAllComponents();
if (allComponents.length === 0) {
return code;
}
const combinedPattern = getComponentPattern(registry);
if (!combinedPattern) return code;

const scanTarget = stripSetHtmlContent(stripHeadingsMeta(code));
const imported = collectImportedNames(code);
const scanTarget = stripScanNoise(code);
const imported = collectImportedNames(code, { skipCodeFences: true });

// PERF: Use single combined regex instead of per-component regex
// This reduces from O(n) regex compilations to O(1)
const componentNames = allComponents.map((c) => c.name);
const combinedPattern = new RegExp(`<(${componentNames.join('|')})\\b`, 'g');
combinedPattern.lastIndex = 0;
const matches = scanTarget.match(combinedPattern);
if (!matches) return code;

Expand All @@ -148,6 +161,7 @@ export function injectComponentImportsFromRegistry(
// Find used components that are missing imports
const missingByModule = new Map<string, Array<{ name: string; exportType: string }>>();

const allComponents = registry.getAllComponents();
for (const comp of allComponents) {
if (usedNames.has(comp.name) && !imported.has(comp.name)) {
const modulePath = comp.modulePath;
Expand Down
Loading