|
| 1 | +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; |
| 2 | +import { resolve } from 'node:path'; |
| 3 | +import process from 'node:process'; |
| 4 | +import { visit } from 'unist-util-visit'; |
| 5 | + |
| 6 | +const BASE_PATH = resolve(process.cwd(), '.live-code'); |
| 7 | +const LIVE_CODE_MAP = resolve(BASE_PATH, 'live-code-map.json'); |
| 8 | + |
| 9 | +/** |
| 10 | + * Remark plugin to handle live code blocks (e.g., ```svelte live) |
| 11 | + * Writes live code to .svelte files and imports them |
| 12 | + * MUST run BEFORE any rehype plugins (operates on markdown AST, not HTML) |
| 13 | + * @returns {(tree: import('mdast').Root, vFile: import('vfile').VFile) => void} |
| 14 | + */ |
| 15 | +export function remarkLiveCode() { |
| 16 | + // Ensure directory exists |
| 17 | + if (!existsSync(BASE_PATH)) { |
| 18 | + mkdirSync(BASE_PATH, { recursive: true }); |
| 19 | + } |
| 20 | + |
| 21 | + // Initialize or load the map file |
| 22 | + if (!existsSync(LIVE_CODE_MAP)) { |
| 23 | + writeFileSync(LIVE_CODE_MAP, '{}'); |
| 24 | + } |
| 25 | + |
| 26 | + return (tree, vFile) => { |
| 27 | + const liveCodeImports = []; |
| 28 | + let hasScript = false; |
| 29 | + |
| 30 | + visit(tree, 'code', (node, index, parent) => { |
| 31 | + if (index === null || !parent) return; |
| 32 | + const { meta, lang, value } = node; |
| 33 | + const metaArray = (meta || '').split(' ').filter(Boolean); |
| 34 | + |
| 35 | + // Check if this is a live code block |
| 36 | + if (lang !== 'svelte' || !metaArray.includes('live')) return; |
| 37 | + |
| 38 | + // Use the raw code value |
| 39 | + const rawCode = value || ''; |
| 40 | + |
| 41 | + // Generate unique ID for this code block |
| 42 | + const blockId = `${vFile.path}-${index}`; |
| 43 | + const idMap = JSON.parse(readFileSync(LIVE_CODE_MAP, 'utf-8')); |
| 44 | + |
| 45 | + let componentFileName = idMap[blockId]; |
| 46 | + if (!componentFileName) { |
| 47 | + // Generate unique filename |
| 48 | + const hash = Math.random().toString(36).substring(2, 11); |
| 49 | + componentFileName = `LiveCode${hash}.svelte`; |
| 50 | + idMap[blockId] = componentFileName; |
| 51 | + writeFileSync(LIVE_CODE_MAP, JSON.stringify(idMap, null, 2)); |
| 52 | + } |
| 53 | + |
| 54 | + // Write the live code to a .svelte file |
| 55 | + const componentPath = resolve(BASE_PATH, componentFileName); |
| 56 | + writeFileSync(componentPath, rawCode); |
| 57 | + |
| 58 | + // Generate component name (remove .svelte extension) |
| 59 | + const componentName = componentFileName.replace(/\.svelte$/, ''); |
| 60 | + |
| 61 | + // Track import for later injection |
| 62 | + liveCodeImports.push({ |
| 63 | + componentName, |
| 64 | + path: `/.live-code/${componentFileName}` |
| 65 | + }); |
| 66 | + |
| 67 | + // Create the live code container structure wrapped in LiveCodeWrapper |
| 68 | + const liveCodeContainer = { |
| 69 | + type: 'paragraph', |
| 70 | + data: { |
| 71 | + hName: 'div', |
| 72 | + hProperties: {} |
| 73 | + }, |
| 74 | + children: [ |
| 75 | + { |
| 76 | + type: 'html', |
| 77 | + value: `<LiveCode>{#snippet preview()}<${componentName} />{/snippet}<div class="live-code-source">` |
| 78 | + } |
| 79 | + ] |
| 80 | + }; |
| 81 | + |
| 82 | + // Add code section with original code block (title will be handled by rehype-code-block-title) |
| 83 | + liveCodeContainer.children.push(node); |
| 84 | + |
| 85 | + liveCodeContainer.children.push({ |
| 86 | + type: 'html', |
| 87 | + value: '</div></LiveCode>' // Close live-code-source and LiveCode |
| 88 | + }); |
| 89 | + |
| 90 | + // Replace the code node with the container |
| 91 | + parent.children[index] = liveCodeContainer; |
| 92 | + }); |
| 93 | + |
| 94 | + // Inject imports at the beginning of the file |
| 95 | + if (liveCodeImports.length > 0) { |
| 96 | + const importStatements = [ |
| 97 | + "import LiveCode from '$lib/markdown/components/LiveCode.svelte';", |
| 98 | + ...liveCodeImports.map( |
| 99 | + ({ componentName, path }) => `import ${componentName} from '${path}';` |
| 100 | + ) |
| 101 | + ].join('\n'); |
| 102 | + |
| 103 | + // Find existing script tag or create new one |
| 104 | + visit(tree, 'html', (node, idx, parent) => { |
| 105 | + if (node.value.startsWith('<script') && !hasScript) { |
| 106 | + hasScript = true; |
| 107 | + node.value = node.value.replace( |
| 108 | + /<script[^>]*>/, |
| 109 | + (match) => `${match}\n${importStatements}` |
| 110 | + ); |
| 111 | + return visit.EXIT; |
| 112 | + } |
| 113 | + }); |
| 114 | + |
| 115 | + if (!hasScript) { |
| 116 | + // Create new script tag at the beginning |
| 117 | + tree.children.unshift({ |
| 118 | + type: 'html', |
| 119 | + value: `<script>\n${importStatements}\n</script>` |
| 120 | + }); |
| 121 | + } |
| 122 | + } |
| 123 | + }; |
| 124 | +} |
0 commit comments