このコミットが含まれているのは:
@@ -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<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' })
|
||||
})
|
||||
} }
|
||||
}
|
||||
新しいイシューから参照
ユーザーをブロックする