191 行
7.4 KiB
TypeScript
191 行
7.4 KiB
TypeScript
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<string, Language> }
|
|
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<string, { current_edition: string }> }
|
|
|
|
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 }`)
|
|
}
|
|
|
|
|
|
const generateHelpAssets = async (root: string) => {
|
|
const modules = new Map<string, string> ()
|
|
const files: string[] = [root]
|
|
const articles: Record<string, Article> = Object.create (null)
|
|
const search: Record<string, SearchEntry[]> = Object.create (null)
|
|
const changes: Record<string, Change[]> = Object.create (null)
|
|
const titles: Record<string, Record<string, string[]>> = 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<typeof generateHelpAssets> } = { 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' })
|
|
})
|
|
} }
|
|
}
|