import { readFile, readdir } from 'node:fs/promises' import path from 'node:path' import { normalizePath as normalisePath } from 'vite' import { parse as parseYAML } from 'yaml' import type { Plugin } from 'vite' type Language = { currentEdition: string; publishedEditions: string[] } type Article = { identifier: string canonicalLanguage: string languages: Record } type Text = { title: string; body: string } type SearchEntry = Text & { identifier: string; edition: string } type Change = { identifier: string edition: string title: string pred: string | null change: 'new' | 'update' date: string } type RawArticle = { identifier: string canonical_language: string languages: Record } const prefix = 'virtual:help/' const compareEditions = (a: string, b: string): number => { const [dateA, revisionA = '1'] = a.split ('.') const [dateB, revisionB = '1'] = b.split ('.') return dateA.localeCompare (dateB) || Number (revisionA) - Number (revisionB) } const validateEdition = (edition: string): void => { const [date, revision = '1'] = edition.split ('.') if (!(/^\d{4}-\d{2}-\d{2}(?:\.[1-9]\d*)?$/.test (edition)) || new Date (date).toISOString ().slice (0, 10) !== date || !(Number.isSafeInteger (Number (revision)))) throw new Error (`Invalid Help edition: ${ edition }`) } export const generateHelpAssets = async ( root: string, ): Promise<{ modules: Map; files: string[] }> => { const modules = new Map () const files: string[] = [root] const articles: Record = Object.create (null) const search: Record = Object.create (null) const changes: Record = Object.create (null) const titles: Record> = Object.create (null) const components: string[] = [] const sources: string[] = [] const texts: string[] = [] const directories = (await readdir (root, { withFileTypes: true })) .filter (entry => entry.isDirectory () && !(entry.name.startsWith ('.'))) .sort ((a, b) => a.name.localeCompare (b.name)) for (const directory of directories) { const articlePath = path.join (root, directory.name, 'article.yml') files.push (articlePath) const raw = parseYAML (await readFile (articlePath, 'utf8')) as RawArticle | null if (raw == null || raw.identifier !== directory.name || typeof raw.canonical_language !== 'string' || raw.languages == null || typeof raw.languages !== 'object' || !(Object.hasOwn (raw.languages, raw.canonical_language))) throw new Error (`Invalid Help metadata: ${ articlePath }`) const article: Article = { identifier: raw.identifier, canonicalLanguage: raw.canonical_language, languages: Object.create (null) } articles[raw.identifier] = article for (const [language, metadata] of Object.entries (raw.languages)) { if (!(/^[a-zA-Z0-9-]+$/.test (language)) || typeof metadata?.current_edition !== 'string') throw new Error (`Invalid Help language: ${ articlePath }/${ language }`) const currentEdition = metadata.current_edition validateEdition (currentEdition) const editionRoot = path.join (root, directory.name, 'editions', language) files.push (editionRoot) const editions = (await readdir (editionRoot)).filter (name => name.endsWith ('.mdx')) .map (name => name.slice (0, -4)) editions.forEach (validateEdition) if (!(editions.includes (currentEdition))) throw new Error (`Missing current Help edition: ${ editionRoot }/${ currentEdition }`) const publishedEditions = editions .filter (edition => compareEditions (edition, currentEdition) <= 0) .sort (compareEditions) article.languages[language] = { currentEdition, publishedEditions } search[language] ??= [] changes[language] ??= [] titles[language] ??= Object.create (null) for (const [index, edition] of publishedEditions.entries ()) { const sourcePath = path.join (editionRoot, `${ edition }.mdx`) files.push (sourcePath) const mdx = (await readFile (sourcePath, 'utf8')).replace (/^\uFEFF/, '') const heading = mdx.match (/^# +([^\r\n]+)(?:\r?\n|$)/) if (heading == null || !(heading[1].trim ())) throw new Error (`Missing leading Help H1: ${ sourcePath }`) const text = { title: heading[1].trim (), body: mdx.slice (heading[0].length) } const key = JSON.stringify (`${ raw.identifier }/${ language }/${ edition }`) const textId = `${ prefix }text/${ raw.identifier }/${ language }/${ edition }` const componentId = JSON.stringify (normalisePath (sourcePath)) const sourceId = JSON.stringify (normalisePath (sourcePath) + '?raw') modules.set (textId, `export default ${ JSON.stringify (text) }`) texts.push (`${ key }: () => import(${ JSON.stringify (textId) })`) components.push (`${ key }: () => import(${ componentId })`) sources.push (`${ key }: () => import(${ sourceId })`) if (edition === currentEdition) search[language].push ({ identifier: raw.identifier, edition, ...text }) changes[language].push ({ identifier: raw.identifier, edition, title: text.title, pred: publishedEditions[index - 1] ?? null, change: index === 0 ? 'new' : 'update', date: edition.split ('.')[0] }) const title = text.title.trim ().normalize ('NFKC').toLowerCase () const identifiers = titles[language][title] ??= [] if (!(identifiers.includes (raw.identifier))) identifiers.push (raw.identifier) } } } if (Object.keys (articles).length === 0) throw new Error (`No Help articles found: ${ root }`) Object.values (changes).forEach (rows => rows.sort ((a, b) => compareEditions (b.edition, a.edition) || a.identifier.localeCompare (b.identifier))) for (const [name, data] of Object.entries ({ articles, search, changes, titles })) { modules.set (`${ prefix }${ name }`, `export default JSON.parse(${ JSON.stringify (JSON.stringify (data)) })`) } modules.set (`${ prefix }loaders`, [ `export const components = { ${ components.join (',\n') } };`, `export const sources = { ${ sources.join (',\n') } };`, `export const texts = { ${ texts.join (',\n') } };`].join ('\n')) return { modules, files } } export const helpAssets = (): Plugin => { const state: { root: string; data?: ReturnType } = { root: '' } const data = () => state.data ??= generateHelpAssets (state.root) return { name: 'help-assets', configResolved (config) { state.root = path.resolve (config.root, 'help') }, async buildStart () { state.data = generateHelpAssets (state.root) const generated = await data () generated.files.forEach (file => this.addWatchFile (file)) }, resolveId (id) { return id.startsWith (prefix) ? `\0${ id }` : null }, async load (id) { if (!(id.startsWith (`\0${ prefix }`))) return null const source = (await data ()).modules.get (id.slice (1)) if (source == null) throw new Error (`Unknown Help asset: ${ id.slice (1) }`) return source }, configureServer (server) { server.watcher.add (state.root) server.watcher.on ('all', (_event, file) => { if (!(file.startsWith (state.root + path.sep)) || !(/\.(mdx|yml)$/.test (file))) return state.data = undefined for (const module of server.moduleGraph.idToModuleMap.values ()) { if (module.id?.startsWith (`\0${ prefix }`)) server.moduleGraph.invalidateModule (module) } server.ws.send ({ type: 'full-reload' }) }) } } }