@lincyaw/web 1.0.0-rc.0 → 1.0.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -4
- package/package.json +20 -19
- package/src/app/base.css +117 -0
- package/src/app/block-playback.ts +81 -0
- package/src/app/block-report.ts +104 -0
- package/src/app/document-modules.ts +30 -0
- package/src/app/env.d.ts +12 -0
- package/src/app/main.ts +198 -0
- package/src/app/page-app.ts +166 -0
- package/src/app/router.ts +109 -0
- package/src/app/server.ts +32 -0
- package/src/app/site/home.tsx +117 -0
- package/src/app/site/layout.tsx +92 -0
- package/src/app/site/search.tsx +22 -0
- package/src/app/site.css +1450 -0
- package/src/client/document.css +172 -0
- package/src/client/host.ts +22 -94
- package/src/client/preview-frame.ts +152 -0
- package/src/client/runtime.ts +159 -0
- package/src/domain/app-config.ts +91 -0
- package/src/domain/config.ts +17 -9
- package/src/domain/dev-updates.ts +12 -0
- package/src/domain/index.ts +17 -7
- package/src/domain/landing.ts +83 -0
- package/src/domain/links.ts +23 -18
- package/src/domain/messages.ts +74 -63
- package/src/domain/pages.ts +23 -41
- package/src/domain/themes.ts +59 -0
- package/src/domain/workspace-home.ts +12 -0
- package/src/node/client-modules.ts +92 -0
- package/src/node/document-bundle.ts +227 -0
- package/src/node/landing-template.ts +34 -0
- package/src/node/landing.ts +82 -0
- package/src/node/offline.ts +34 -11
- package/src/node/publish.ts +83 -15
- package/src/node/renderer.ts +78 -117
- package/src/node/site-assets.ts +17 -0
- package/src/node/site-build.ts +304 -0
- package/src/node/site-files.ts +87 -0
- package/src/node/site-model.ts +337 -0
- package/src/node/site-page.ts +82 -0
- package/src/node/site-server.ts +822 -0
- package/src/node/slides.ts +135 -0
- package/src/node/viewer-app.ts +504 -0
- package/src/node/viewer-build.ts +38 -0
- package/custom-nav-controls.vue +0 -17
- package/src/client/env.d.ts +0 -1
- package/src/client/home.ts +0 -182
- package/src/client/index.ts +0 -4
- package/src/client/page.ts +0 -38
- package/src/client/preview.ts +0 -71
- package/src/client/style.css +0 -427
- package/src/client/theme.ts +0 -322
- package/src/node/addons.ts +0 -76
- package/src/node/site.ts +0 -349
- package/src/node/sites.ts +0 -531
- package/src/node/vite.ts +0 -166
- package/src/node/vitepress.ts +0 -72
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { cpSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, join, resolve } from 'node:path'
|
|
3
|
+
import { Readable } from 'node:stream'
|
|
4
|
+
import { pathToFileURL } from 'node:url'
|
|
5
|
+
import { ABSOLUTE_URL, errorMessage, isRecord } from '@lincyaw/kernel'
|
|
6
|
+
import type { IRDocument, NodeVitePlugin, RenderContext, SiteSpec } from '@lincyaw/kernel'
|
|
7
|
+
import { FONT_LICENSE, fontLicense } from '@lincyaw/kernel/node'
|
|
8
|
+
import { SitemapStream, streamToPromise } from 'sitemap'
|
|
9
|
+
import { chapterRedirects, offlineEnabled, pageLink, SERVICE_WORKER, searchDocuments, sitePageLayout, translationPages, webPages } from '../domain/index.ts'
|
|
10
|
+
import type { AppConfig, SiteConfig, SiteShell, WebPage } from '../domain/index.ts'
|
|
11
|
+
import { BUNDLE_ENTRY, buildDocumentBundle, documentComponents } from './document-bundle.ts'
|
|
12
|
+
import type { DocumentBundle } from './document-bundle.ts'
|
|
13
|
+
import { writeServiceWorker } from './offline.ts'
|
|
14
|
+
import { publishedDocument, publishedFile } from './publish.ts'
|
|
15
|
+
import { APP_DIR, CODE_DIR, contentKey, copyApp, copyPublic, copyRuntimeAssets, runtimeAssets, searchIndex } from './site-files.ts'
|
|
16
|
+
import { documentBase, documentPage, documentSiteConfig, homeDocument, homeDocuments, isSingle, landingConfig, landingDocument, languageLabel, LIST_PAGE, siteLanguage, siteLanguages, siteNeighbours, siteShellBelow, siteTitle, sitemapPages, translationOf } from './site-model.ts'
|
|
17
|
+
import { publishedSite } from './site-assets.ts'
|
|
18
|
+
import { loadLanding, publishLanding, siteContent } from './landing.ts'
|
|
19
|
+
import { buildLandingTemplate, writeLandingTemplate } from './landing-template.ts'
|
|
20
|
+
import { pageHtml, redirectHtml } from './site-page.ts'
|
|
21
|
+
import type { MetaTag } from './site-page.ts'
|
|
22
|
+
import { appEntry, appPlugin, buildViewerApp, buildViewerServer, viewerAppCache } from './viewer-app.ts'
|
|
23
|
+
import type { AppPlugin, ViewerApp } from './viewer-app.ts'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A built site of the web form: the viewer app copied once below the site's path, and each document as data beside its
|
|
27
|
+
* pages: its IR (and its translations'), its own code, its search index, its published files and its plugins' run-time
|
|
28
|
+
* files. Every page is rendered on the server, so that it reads without its script, and the app then hydrates it.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** A document of the site, as its build has it. */
|
|
32
|
+
export interface BuiltDocument {
|
|
33
|
+
dir: string
|
|
34
|
+
doc: IRDocument
|
|
35
|
+
/** Its render context: below its own site path and output directory. */
|
|
36
|
+
ctx: RenderContext
|
|
37
|
+
nodePlugins: readonly NodeVitePlugin[]
|
|
38
|
+
translations: readonly { lang: string, doc: IRDocument }[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ServerRender {
|
|
42
|
+
render(input: { config: AppConfig, doc?: IRDocument, components: string[] }): Promise<string>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface Built {
|
|
46
|
+
app: ViewerApp
|
|
47
|
+
/** The app's directory in the site, named by its content. */
|
|
48
|
+
appDir: string
|
|
49
|
+
appUrl: string
|
|
50
|
+
server: ServerRender
|
|
51
|
+
plugins: Map<string, AppPlugin>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A document's directory as one segment of a path; the document at the root as `~` (a `.` would be taken out of the address).
|
|
55
|
+
const segment = (dir: string) => (dir ? encodeURIComponent(dir) : '~')
|
|
56
|
+
|
|
57
|
+
/** The URL of the site's Open Graph image: an absolute URL as written, a local file published (with the sitemap's host name when there is one). */
|
|
58
|
+
function headImage(shell: SiteShell, publish: (url: string) => string | undefined): string | undefined {
|
|
59
|
+
const image = shell.head?.image
|
|
60
|
+
if (!image || ABSOLUTE_URL.test(image)) return image
|
|
61
|
+
const published = publish(image)
|
|
62
|
+
return published && shell.sitemap ? `${shell.sitemap.hostname}${published}` : published
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function metaTags(shell: SiteShell, image: string | undefined, title: string): MetaTag[] {
|
|
66
|
+
const head = shell.head
|
|
67
|
+
return [
|
|
68
|
+
...head?.description ? [{ name: 'description', content: head.description }] : [],
|
|
69
|
+
...head ? [{ property: 'og:type', content: 'website' }, { property: 'og:title', content: title }] : [],
|
|
70
|
+
...head?.description ? [{ property: 'og:description', content: head.description }] : [],
|
|
71
|
+
...image ? [{ property: 'og:image', content: image }] : [],
|
|
72
|
+
]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function pageTitle(page: WebPage, doc: IRDocument): string {
|
|
76
|
+
return page.title === doc.meta.title ? page.title : `${page.title} | ${doc.meta.title}`
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function pageFile(page: WebPage): string {
|
|
80
|
+
return page.path === 'index' ? 'index.html' : `${page.path}.html`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function writeSitemap(file: string, hostname: string, base: string, pages: string[]): Promise<void> {
|
|
84
|
+
const stream = new SitemapStream({ hostname: `${hostname}${base}` })
|
|
85
|
+
const xml = await streamToPromise(Readable.from(pages.map(url => ({ url }))).pipe(stream))
|
|
86
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
87
|
+
writeFileSync(file, xml)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Builds the app (with its server-side render) for the plugins of every document and the operator's, and copies it into the site. */
|
|
91
|
+
async function prepareApp(ctx: RenderContext, documents: readonly BuiltDocument[]): Promise<Built> {
|
|
92
|
+
const dirs = new Map<string, string>()
|
|
93
|
+
for (const { ctx: own } of documents) for (const name of own.plugins) if (own.pluginDirs[name]) dirs.set(name, own.pluginDirs[name]!)
|
|
94
|
+
for (const name of ctx.plugins) if (ctx.pluginDirs[name]) dirs.set(name, ctx.pluginDirs[name]!)
|
|
95
|
+
const plugins = new Map<string, AppPlugin>()
|
|
96
|
+
for (const [name, dir] of dirs) plugins.set(name, await appPlugin(name, dir))
|
|
97
|
+
const started = Date.now()
|
|
98
|
+
const app = await buildViewerApp(viewerAppCache(), [...plugins.values()])
|
|
99
|
+
const server = await import(pathToFileURL(await buildViewerServer(app, [...plugins.values()])).href) as ServerRender
|
|
100
|
+
ctx.log?.(`the viewer app is ready in ${((Date.now() - started) / 1000).toFixed(1)} s`)
|
|
101
|
+
const key = copyApp(app, join(ctx.outDir, APP_DIR))
|
|
102
|
+
return { app, appDir: join(ctx.outDir, APP_DIR, key), appUrl: `${ctx.base}${APP_DIR}/${key}/`, server, plugins }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function renderPage(built: Built, file: string, config: AppConfig, title: string, meta: MetaTag[], doc: IRDocument | undefined, components: string[]): Promise<void> {
|
|
106
|
+
const body = await built.server.render({ config, doc, components })
|
|
107
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
108
|
+
writeFileSync(file, pageHtml({ entry: appEntry(built.app), appUrl: built.appUrl, shared: built.app.manifest.shared, config, title, meta, body }))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A document's files beside its pages: its own code, its plugins' run-time files, its `public/`, the fonts' license. */
|
|
112
|
+
async function documentFiles(ctx: RenderContext, built: Built, entry: BuiltDocument): Promise<{ code?: string, components: string[] }> {
|
|
113
|
+
const { dir, ctx: own } = entry
|
|
114
|
+
const out = own.outDir
|
|
115
|
+
// The document's own code, built to import the app's modules by name.
|
|
116
|
+
const bundle: DocumentBundle | null = await buildDocumentBundle({
|
|
117
|
+
deckDir: own.deckDir,
|
|
118
|
+
workspaceRoot: own.workspaceRoot,
|
|
119
|
+
outDir: join(ctx.workDir, 'bundles', segment(dir)),
|
|
120
|
+
externals: Object.keys(built.app.manifest.shared),
|
|
121
|
+
nodePlugins: entry.nodePlugins.map(({ entry: file, config }) => ({ entry: file, config })),
|
|
122
|
+
components: documentComponents(entry.doc),
|
|
123
|
+
})
|
|
124
|
+
// Named by its content, as the app is: the same in any checkout.
|
|
125
|
+
const code = bundle && contentKey(bundle.dir, file => file === 'bundle.json')
|
|
126
|
+
if (bundle && code) cpSync(bundle.dir, join(out, CODE_DIR, code), { recursive: true, filter: source => !source.endsWith('bundle.json') })
|
|
127
|
+
copyRuntimeAssets(await runtimeAssets(entry.nodePlugins, own), out)
|
|
128
|
+
copyPublic(own.deckDir, out)
|
|
129
|
+
mkdirSync(dirname(join(out, FONT_LICENSE)), { recursive: true })
|
|
130
|
+
writeFileSync(join(out, FONT_LICENSE), fontLicense(import.meta.url))
|
|
131
|
+
const components = [...new Set([...own.plugins.flatMap(name => Object.keys(built.plugins.get(name)?.components ?? {})), ...bundle?.components ?? []])]
|
|
132
|
+
return { ...code ? { code } : {}, components }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function buildDocumentPages(site: SiteSpec, ctx: RenderContext, built: Built, entry: BuiltDocument, docs: ReadonlyMap<string, IRDocument>, workspace: boolean): Promise<void> {
|
|
136
|
+
const { dir, doc, ctx: own } = entry
|
|
137
|
+
const out = own.outDir
|
|
138
|
+
const base = documentBase(ctx.base, dir)
|
|
139
|
+
mkdirSync(out, { recursive: true })
|
|
140
|
+
const linked = workspace ? documentPage(site, ctx.base, docs) : undefined
|
|
141
|
+
const published = publishedDocument(doc, own, out, base, linked)
|
|
142
|
+
const pages = webPages(published)
|
|
143
|
+
const translations = entry.translations.map(({ lang, doc: translated }) => {
|
|
144
|
+
const shown = publishedDocument(translated, own, out, base, linked)
|
|
145
|
+
return { lang, doc: shown, pages: translationPages(shown, pages) }
|
|
146
|
+
})
|
|
147
|
+
const lang = doc.meta.lang ?? own.lang ?? 'zh-CN'
|
|
148
|
+
const { code, components } = await documentFiles(ctx, built, entry)
|
|
149
|
+
const others = workspace ? siteNeighbours(site, ctx.base, dir, docs).search : []
|
|
150
|
+
const shell = siteShellBelow(site, ctx.base)
|
|
151
|
+
const image = headImage(shell, url => publishedFile(own, out, base, { file: resolve(own.deckDir, doc.meta.entry), line: 1 }, url))
|
|
152
|
+
const offline = offlineEnabled(doc.config.presmium)
|
|
153
|
+
const locales = [{ prefix: '', lang, doc: published, pages, translated: false }, ...translations.map(t => ({ prefix: `${t.lang}/`, lang: t.lang, doc: t.doc, pages: t.pages, translated: true }))]
|
|
154
|
+
for (const locale of locales) {
|
|
155
|
+
const root = `${base}${locale.prefix}`
|
|
156
|
+
mkdirSync(join(out, locale.prefix), { recursive: true })
|
|
157
|
+
writeFileSync(join(out, locale.prefix, 'ir.json'), JSON.stringify(locale.doc))
|
|
158
|
+
writeFileSync(join(out, locale.prefix, 'search.json'), searchIndex([...searchDocuments(locale.doc, root, locale.pages), ...others]))
|
|
159
|
+
for (const page of locale.pages) {
|
|
160
|
+
const config: AppConfig = {
|
|
161
|
+
kind: 'document',
|
|
162
|
+
document: dir,
|
|
163
|
+
base,
|
|
164
|
+
lang: locale.lang,
|
|
165
|
+
ir: `${root}ir.json`,
|
|
166
|
+
...code ? { bundle: `${base}${CODE_DIR}/${code}/${BUNDLE_ENTRY}` } : {},
|
|
167
|
+
search: `${root}search.json`,
|
|
168
|
+
page: page.path,
|
|
169
|
+
...locale.translated ? { pages: locale.pages } : {},
|
|
170
|
+
site: { ...documentSiteConfig({ site, base: ctx.base, dir, doc, lang: locale.lang, docs, workspace }), brandTitle: workspace ? siteTitle(site, locale.lang) : locale.doc.meta.title, layout: sitePageLayout(isRecord(locale.doc.config.renderers.web) ? locale.doc.config.renderers.web : {}, locale.lang) },
|
|
171
|
+
plugins: [...new Set([...own.plugins, ...ctx.plugins])],
|
|
172
|
+
origins: [],
|
|
173
|
+
...offline ? { serviceWorker: `${base}${SERVICE_WORKER}` } : {},
|
|
174
|
+
rendered: true,
|
|
175
|
+
}
|
|
176
|
+
const title = pageTitle(page, locale.doc)
|
|
177
|
+
await renderPage(built, join(out, locale.prefix, pageFile(page)), config, title, metaTags(shell, image, title), locale.doc, components)
|
|
178
|
+
if (page.path === 'index' && landingDocument(site)?.dir === dir) {
|
|
179
|
+
const landing = landingConfig(config, locale.doc, site, ctx.base, locale.translated ? locale.lang : '')
|
|
180
|
+
const title = siteTitle(site, locale.lang)
|
|
181
|
+
await renderPage(built, join(ctx.outDir, locale.prefix, 'index.html'), landing, title, metaTags(shell, image, title), locale.doc, components)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
for (const { path, section } of chapterRedirects(published)) writeFileSync(join(out, locale.prefix, `${path}.html`), chapterRedirect(section))
|
|
185
|
+
}
|
|
186
|
+
if (offline) writeServiceWorker(out, base, { dir: built.appDir, url: built.appUrl })
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The page a chapter used to have: it goes on to the chapter's section on the document's page. */
|
|
190
|
+
function chapterRedirect(section: string): string {
|
|
191
|
+
const target = `./#${encodeURIComponent(section)}`
|
|
192
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="0; url=${target}"><link rel="canonical" href="${target}"><meta name="robots" content="noindex"></head><body><a href="${target}">${target}</a></body></html>\n`
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** A page that shows no document (the home page, a missing page), below the site's path. */
|
|
196
|
+
function pageConfig(kind: 'home' | 'missing', base: string, lang: string, site: SiteConfig, extra: Partial<AppConfig> = {}): AppConfig {
|
|
197
|
+
return { kind, document: '', base, lang, site, origins: [], rendered: true, ...extra }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function buildHome(site: SiteSpec, ctx: RenderContext, built: Built, docs: ReadonlyMap<string, IRDocument>): Promise<void> {
|
|
201
|
+
const lang = siteLanguage(site, ctx.lang)
|
|
202
|
+
const shell = siteShellBelow(site, ctx.base)
|
|
203
|
+
const others = siteLanguages(site, lang)
|
|
204
|
+
const locales = [{ lang, label: languageLabel(lang), base: ctx.base }, ...others.map(other => ({ lang: other.lang, label: languageLabel(other.lang), base: `${ctx.base}${other.lang}/` }))]
|
|
205
|
+
const target = homeDocument(site)
|
|
206
|
+
const landing = landingDocument(site)
|
|
207
|
+
const independent = loadLanding(site, ctx)
|
|
208
|
+
const page = independent?.kind === 'page' ? publishLanding(independent, site, ctx) : undefined
|
|
209
|
+
const image = headImage(shell, url => publishedFile(ctx, ctx.outDir, ctx.base, { file: join(ctx.workspaceRoot, 'presmium.workspace.yaml'), line: 1 }, url))
|
|
210
|
+
for (const locale of [{ lang, title: site.title, prefix: '' }, ...others.map(other => ({ ...other, prefix: `${other.lang}/` }))]) {
|
|
211
|
+
const config = pageConfig('home', ctx.base, locale.lang, { title: locale.title, brandTitle: locale.title, root: `${ctx.base}${locale.prefix}`, shell, locales, links: {} }, {
|
|
212
|
+
documents: homeDocuments(site, ctx.base, docs, locale.prefix ? locale.lang : undefined),
|
|
213
|
+
plugins: ctx.plugins,
|
|
214
|
+
})
|
|
215
|
+
const list = join(ctx.outDir, locale.prefix, target || site.config.landing !== undefined ? `${LIST_PAGE}.html` : 'index.html')
|
|
216
|
+
await renderPage(built, list, config, locale.title, metaTags(shell, image, locale.title), undefined, [])
|
|
217
|
+
if (page) {
|
|
218
|
+
const metadata = { ...shell, head: { ...shell.head, description: page.description ?? shell.head?.description } }
|
|
219
|
+
await renderPage(built, join(ctx.outDir, locale.prefix, 'index.html'), { ...config, landingPage: page }, page.title, metaTags(metadata, image, page.title), undefined, [])
|
|
220
|
+
}
|
|
221
|
+
if (independent?.kind === 'template') {
|
|
222
|
+
const output = await buildLandingTemplate(independent.file, join(ctx.workDir, 'landing', locale.lang), ctx.base, siteContent(site, ctx.base, locale.lang, config.documents ?? []), ctx.workspaceRoot)
|
|
223
|
+
writeLandingTemplate(output, ctx.outDir, locale.prefix)
|
|
224
|
+
}
|
|
225
|
+
if (target) {
|
|
226
|
+
const translated = locale.prefix && translationOf(target, locale.lang) ? `${documentBase(ctx.base, target.dir)}${locale.lang}/` : documentBase(ctx.base, target.dir)
|
|
227
|
+
writeFileSync(join(ctx.outDir, locale.prefix, 'index.html'), redirectHtml(translated, target.title, locale.lang))
|
|
228
|
+
}
|
|
229
|
+
if (landing && locale.prefix && !translationOf(landing, locale.lang)) {
|
|
230
|
+
writeFileSync(join(ctx.outDir, locale.prefix, 'index.html'), redirectHtml(ctx.base, site.title, locale.lang))
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function buildMissing(site: SiteSpec, ctx: RenderContext, built: Built, title: string, lang: string): Promise<void> {
|
|
236
|
+
const shell = siteShellBelow(site, ctx.base)
|
|
237
|
+
const config = pageConfig('missing', ctx.base, lang, { title, brandTitle: site.title, root: ctx.base, shell, locales: [{ lang, label: languageLabel(lang), base: ctx.base }], links: {} }, { plugins: ctx.plugins })
|
|
238
|
+
await renderPage(built, join(ctx.outDir, '404.html'), config, title, [], undefined, [])
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Builds the site of `documents` into `ctx.outDir`: a workspace's (with its home page), or one document's at the root. */
|
|
242
|
+
export async function buildSite(site: SiteSpec, ctx: RenderContext, documents: readonly BuiltDocument[]): Promise<void> {
|
|
243
|
+
const workspace = !isSingle(site)
|
|
244
|
+
// Invalid presentation settings fail before replacing an existing build.
|
|
245
|
+
siteShellBelow(site, ctx.base)
|
|
246
|
+
landingDocument(site)
|
|
247
|
+
loadLanding(site, ctx)
|
|
248
|
+
for (const entry of documents) for (const doc of [entry.doc, ...entry.translations.map(locale => locale.doc)]) sitePageLayout(isRecord(doc.config.renderers.web) ? doc.config.renderers.web : {}, doc.meta.lang)
|
|
249
|
+
// A build starts empty, as the pages and files of an earlier one may be gone from the document.
|
|
250
|
+
rmSync(ctx.outDir, { recursive: true, force: true })
|
|
251
|
+
if (workspace) copyPublic(ctx.workspaceRoot, ctx.outDir)
|
|
252
|
+
site = publishedSite(site, ctx)
|
|
253
|
+
const built = await prepareApp(ctx, documents)
|
|
254
|
+
// Also allow publishing from a branch without Jekyll filtering the viewer's _app and _code directories.
|
|
255
|
+
writeFileSync(join(ctx.outDir, '.nojekyll'), '')
|
|
256
|
+
const docs = new Map(documents.map(entry => [entry.dir, entry.doc] as const))
|
|
257
|
+
if (workspace && ctx.config.home !== false) await buildHome(site, ctx, built, docs)
|
|
258
|
+
for (const entry of documents) {
|
|
259
|
+
try {
|
|
260
|
+
await buildDocumentPages(site, ctx, built, entry, docs, workspace)
|
|
261
|
+
} catch (error) {
|
|
262
|
+
throw new Error(workspace ? `document ${entry.dir || '.'}: ${errorMessage(error)}` : errorMessage(error))
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const lang = siteLanguage(site, ctx.lang)
|
|
266
|
+
await buildMissing(site, ctx, built, workspace ? site.title : documents[0]?.doc.meta.title ?? site.title, lang)
|
|
267
|
+
const { sitemap } = siteShellBelow(site, ctx.base)
|
|
268
|
+
if (sitemap) {
|
|
269
|
+
const pages = workspace ? ['', ...sitemapPages(site)] : documents.flatMap(({ doc, translations }) => [
|
|
270
|
+
...webPages(doc).map(page => pageLink(page).replace(/^\//, '')),
|
|
271
|
+
...translations.flatMap(t => translationPages(t.doc, webPages(doc)).map(page => pageLink(page, `/${t.lang}`).replace(/^\//, ''))),
|
|
272
|
+
])
|
|
273
|
+
await writeSitemap(join(ctx.outDir, 'sitemap.xml'), sitemap.hostname, ctx.base, pages)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Builds a document's slides into `ctx.outDir`: one page that shows them (the slides form of the viewer), with the
|
|
279
|
+
* document's slides view (`{only} slides`), its code and files, as the web form's are built.
|
|
280
|
+
*/
|
|
281
|
+
export async function buildSlidesSite(doc: IRDocument, ctx: RenderContext, nodePlugins: readonly NodeVitePlugin[]): Promise<void> {
|
|
282
|
+
rmSync(ctx.outDir, { recursive: true, force: true })
|
|
283
|
+
const entry: BuiltDocument = { dir: '', doc, ctx, nodePlugins, translations: [] }
|
|
284
|
+
const built = await prepareApp(ctx, [entry])
|
|
285
|
+
mkdirSync(ctx.outDir, { recursive: true })
|
|
286
|
+
const published = publishedDocument(doc, ctx, ctx.outDir, ctx.base)
|
|
287
|
+
const { code } = await documentFiles(ctx, built, entry)
|
|
288
|
+
writeFileSync(join(ctx.outDir, 'ir.json'), JSON.stringify(published))
|
|
289
|
+
const lang = doc.meta.lang ?? ctx.lang ?? 'zh-CN'
|
|
290
|
+
const site: SiteSpec = { title: doc.meta.title, documents: [{ dir: '', title: doc.meta.title, doc }], config: ctx.config }
|
|
291
|
+
const config: AppConfig = {
|
|
292
|
+
kind: 'document',
|
|
293
|
+
document: '',
|
|
294
|
+
base: ctx.base,
|
|
295
|
+
lang,
|
|
296
|
+
form: 'slides',
|
|
297
|
+
ir: `${ctx.base}ir.json`,
|
|
298
|
+
...code ? { bundle: `${ctx.base}${CODE_DIR}/${code}/${BUNDLE_ENTRY}` } : {},
|
|
299
|
+
site: documentSiteConfig({ site, base: ctx.base, dir: '', doc, lang, docs: new Map(), workspace: false }),
|
|
300
|
+
plugins: ctx.plugins,
|
|
301
|
+
origins: [],
|
|
302
|
+
}
|
|
303
|
+
writeFileSync(join(ctx.outDir, 'index.html'), pageHtml({ entry: appEntry(built.app), appUrl: built.appUrl, shared: built.app.manifest.shared, config, title: doc.meta.title }))
|
|
304
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
3
|
+
import { dirname, join, sep } from 'node:path'
|
|
4
|
+
import { pathToFileURL } from 'node:url'
|
|
5
|
+
import { tokenize } from '@lincyaw/kernel'
|
|
6
|
+
import type { NodeVitePlugin, PresmiumNodePlugin, RuntimeAssets } from '@lincyaw/kernel'
|
|
7
|
+
import MiniSearch from 'minisearch'
|
|
8
|
+
import { SEARCH_FIELDS } from '../domain/index.ts'
|
|
9
|
+
import type { SearchDocument } from '../domain/index.ts'
|
|
10
|
+
import { APP_MANIFEST } from './viewer-app.ts'
|
|
11
|
+
import type { ViewerApp } from './viewer-app.ts'
|
|
12
|
+
|
|
13
|
+
/** Where a built site keeps the viewer app (`_app/<key>/`, below the site's path) and a document its own code. */
|
|
14
|
+
export const APP_DIR = '_app'
|
|
15
|
+
export const CODE_DIR = '_code'
|
|
16
|
+
// What of a cached app a site needs: not its server-side render, nor the build's own records.
|
|
17
|
+
const NOT_PUBLISHED = new Set(['server', '.vite', APP_MANIFEST])
|
|
18
|
+
|
|
19
|
+
/** A name for the files below `dir` by what they hold: the same files have the same name in any checkout and on any machine. */
|
|
20
|
+
export function contentKey(dir: string, skip: (file: string) => boolean = () => false): string {
|
|
21
|
+
const hash = createHash('sha256')
|
|
22
|
+
const files = readdirSync(dir, { recursive: true, encoding: 'utf8' }).map(file => file.split(sep).join('/')).filter(file => !skip(file) && statSync(join(dir, file)).isFile()).sort()
|
|
23
|
+
for (const file of files) hash.update(file).update('\0').update(readFileSync(join(dir, file))).update('\0')
|
|
24
|
+
return hash.digest('hex').slice(0, 16)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const published = (file: string) => !NOT_PUBLISHED.has(file.split('/')[0]!) && !file.startsWith('.')
|
|
28
|
+
const appKeys = new Map<string, string>()
|
|
29
|
+
|
|
30
|
+
/** Copies the app's files a page loads into a directory of `root` named by their content, and gives that name. */
|
|
31
|
+
export function copyApp(app: ViewerApp, root: string): string {
|
|
32
|
+
let key = appKeys.get(app.dir)
|
|
33
|
+
if (!key) appKeys.set(app.dir, key = contentKey(app.dir, file => !published(file)))
|
|
34
|
+
const dir = join(root, key)
|
|
35
|
+
mkdirSync(dir, { recursive: true })
|
|
36
|
+
for (const entry of readdirSync(app.dir)) {
|
|
37
|
+
if (published(entry)) cpSync(join(app.dir, entry), join(dir, entry), { recursive: true })
|
|
38
|
+
}
|
|
39
|
+
return key
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The run-time files of the document's plugins (`PresmiumNodePlugin.assets`), for their configuration of the plugins. */
|
|
43
|
+
export async function runtimeAssets(nodePlugins: readonly NodeVitePlugin[], deck: { deckDir: string, workspaceRoot: string }): Promise<RuntimeAssets[]> {
|
|
44
|
+
const all: RuntimeAssets[] = []
|
|
45
|
+
for (const { entry, config } of nodePlugins) {
|
|
46
|
+
const plugin = (await import(pathToFileURL(entry).href) as { default?: PresmiumNodePlugin }).default
|
|
47
|
+
all.push(...plugin?.assets?.({ ...deck, config }) ?? [])
|
|
48
|
+
}
|
|
49
|
+
return all
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Copies the run-time files a build takes (`emitted`, else all) below `dir`. */
|
|
53
|
+
export function copyRuntimeAssets(assets: readonly RuntimeAssets[], dir: string): void {
|
|
54
|
+
for (const asset of assets) {
|
|
55
|
+
for (const file of asset.emitted ?? Object.keys(asset.types)) {
|
|
56
|
+
const target = join(dir, asset.prefix, file)
|
|
57
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
58
|
+
copyFileSync(asset.source(file), target)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The run-time file at `path` below the document's site path, with its type, if a plugin serves one there. */
|
|
64
|
+
export function findRuntimeAsset(assets: readonly RuntimeAssets[], path: string): { file: string, type: string } | undefined {
|
|
65
|
+
for (const asset of assets) {
|
|
66
|
+
if (!path.startsWith(asset.prefix)) continue
|
|
67
|
+
const name = path.slice(asset.prefix.length)
|
|
68
|
+
if (Object.hasOwn(asset.types, name)) return { file: asset.source(name), type: asset.types[name]! }
|
|
69
|
+
}
|
|
70
|
+
return undefined
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Copies the document's `public/` directory (files it links by their site path) into `dir`. */
|
|
74
|
+
export function copyPublic(deckDir: string, dir: string): void {
|
|
75
|
+
const own = join(deckDir, 'public')
|
|
76
|
+
if (existsSync(own) && statSync(own).isDirectory()) cpSync(own, dir, { recursive: true })
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The search index of a document's sections (MiniSearch's JSON), the words of Chinese text found as the reader types them. */
|
|
80
|
+
export function searchIndex(documents: readonly SearchDocument[]): string {
|
|
81
|
+
const index = new MiniSearch<SearchDocument>({ ...SEARCH_FIELDS, tokenize })
|
|
82
|
+
index.addAll([...documents])
|
|
83
|
+
return JSON.stringify(index)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The response header a dev server gives an IR's revision in (the page compares the changes it is told of with it). */
|
|
87
|
+
export const REVISION_HEADER = 'Presmium-Revision'
|