From 1a4cbe36fb6ceda392203e1e0322afe82cf76295 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Mon, 12 Oct 2026 15:16:58 +0900 Subject: [PATCH] #424 --- frontend/scripts/help-assets.ts | 190 ++++++++++++++++++++ frontend/src/components/TopNav.tsx | 37 ++-- frontend/src/components/TopNavUser.tsx | 6 +- frontend/src/index.css | 2 +- frontend/src/lib/help.ts | 145 ++++----------- frontend/src/lib/queryKeys.ts | 1 + frontend/src/pages/help/HelpChangesPage.tsx | 126 ++++++++----- frontend/src/pages/help/HelpDetailPage.tsx | 6 +- frontend/src/pages/help/HelpDiffPage.tsx | 2 +- frontend/src/pages/help/HelpListPage.tsx | 113 ++++++++---- frontend/src/pages/wiki/WikiDetailPage.tsx | 16 +- frontend/src/vite-env.d.ts | 28 +++ frontend/vite.config.ts | 4 +- 13 files changed, 451 insertions(+), 225 deletions(-) create mode 100644 frontend/scripts/help-assets.ts diff --git a/frontend/scripts/help-assets.ts b/frontend/scripts/help-assets.ts new file mode 100644 index 0000000..2b21312 --- /dev/null +++ b/frontend/scripts/help-assets.ts @@ -0,0 +1,190 @@ +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 }`) +} + + +const generateHelpAssets = async (root: 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' }) + }) + } } +} diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 1cac8e7..4853e31 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -219,18 +219,19 @@ const TopNav: FC = ({ user }) => { return ( <> -