ファイル
btrc-hub/frontend/scripts/help-assets.ts
T
2026-10-12 20:10:04 +09:00

193 行
7.5 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 }`)
}
export const generateHelpAssets = async (
root: string,
): Promise<{ modules: Map<string, string>; files: 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' })
})
} }
}