@lincyaw/web 0.0.0-stage → 1.0.0-rc.1

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.
Files changed (47) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +53 -2
  3. package/package.json +57 -3
  4. package/src/app/base.css +117 -0
  5. package/src/app/block-playback.ts +81 -0
  6. package/src/app/block-report.ts +104 -0
  7. package/src/app/document-modules.ts +30 -0
  8. package/src/app/env.d.ts +12 -0
  9. package/src/app/main.ts +198 -0
  10. package/src/app/page-app.ts +166 -0
  11. package/src/app/router.ts +109 -0
  12. package/src/app/server.ts +32 -0
  13. package/src/app/site/home.tsx +117 -0
  14. package/src/app/site/layout.tsx +78 -0
  15. package/src/app/site/search.tsx +22 -0
  16. package/src/app/site.css +1416 -0
  17. package/src/client/document.css +172 -0
  18. package/src/client/host.ts +22 -0
  19. package/src/client/preview-frame.ts +152 -0
  20. package/src/client/runtime.ts +159 -0
  21. package/src/domain/app-config.ts +89 -0
  22. package/src/domain/config.ts +107 -0
  23. package/src/domain/dev-updates.ts +12 -0
  24. package/src/domain/index.ts +17 -0
  25. package/src/domain/landing.ts +83 -0
  26. package/src/domain/links.ts +67 -0
  27. package/src/domain/messages.ts +116 -0
  28. package/src/domain/pages.ts +81 -0
  29. package/src/domain/themes.ts +59 -0
  30. package/src/domain/workspace-home.ts +12 -0
  31. package/src/node/client-modules.ts +92 -0
  32. package/src/node/document-bundle.ts +227 -0
  33. package/src/node/landing-template.ts +34 -0
  34. package/src/node/landing.ts +82 -0
  35. package/src/node/offline.ts +153 -0
  36. package/src/node/presmium.ts +10 -0
  37. package/src/node/publish.ts +169 -0
  38. package/src/node/renderer.ts +108 -0
  39. package/src/node/site-assets.ts +17 -0
  40. package/src/node/site-build.ts +297 -0
  41. package/src/node/site-files.ts +87 -0
  42. package/src/node/site-model.ts +333 -0
  43. package/src/node/site-page.ts +82 -0
  44. package/src/node/site-server.ts +817 -0
  45. package/src/node/slides.ts +135 -0
  46. package/src/node/viewer-app.ts +504 -0
  47. package/src/node/viewer-build.ts +38 -0
@@ -0,0 +1,169 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { statSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { irBlocks, mapHtmlUrls, NON_FILE_URL, publishedUrl, withPublishedAssets } from '@lincyaw/kernel'
5
+ import type { DocumentLink, IRBlock, IRDocument, IRSection, LocalAsset, MdastNode, RenderContext, SourcePos } from '@lincyaw/kernel'
6
+ import { copyPublished, fileVersion } from '@lincyaw/kernel/node'
7
+ import katex from 'katex'
8
+
9
+ export const MEDIA = 'media'
10
+
11
+ /** The IR revision of an IR written as JSON. */
12
+ export function irRevisionOfJson(json: string): string {
13
+ return createHash('sha256').update(json).digest('hex').slice(0, 16)
14
+ }
15
+
16
+ const LINKED = new Set(['image', 'link', 'definition', 'card'])
17
+
18
+ interface Asset extends LocalAsset {
19
+ version: string
20
+ }
21
+
22
+ const assets = new Map<string, Asset>()
23
+
24
+ function localAsset(ctx: RenderContext, source: SourcePos, url: string): Asset | undefined {
25
+ const key = `${source.file}\0${url}`
26
+ const known = assets.get(key)
27
+ const now = known && statSync(known.file, { bigint: true, throwIfNoEntry: false })
28
+ if (known && now && fileVersion(now) === known.version) return known
29
+ const found = ctx.localAsset(source, url)
30
+ const stat = found && statSync(found.file, { bigint: true, throwIfNoEntry: false })
31
+ if (!found || !stat) {
32
+ assets.delete(key)
33
+ return undefined
34
+ }
35
+ const asset = { ...found, version: fileVersion(stat), directory: found.directory ?? stat.isDirectory() }
36
+ if (asset.directory) assets.delete(key)
37
+ else assets.set(key, asset)
38
+ return asset
39
+ }
40
+
41
+ const rendered = new Map<string, string>()
42
+ const PUBLICATION_CACHE_BYTES = 16 << 20
43
+
44
+ function renderMath(tex: string, display: boolean): string {
45
+ const key = `${display ? 'D' : 'I'}${tex}`
46
+ let html = rendered.get(key)
47
+ if (html === undefined) {
48
+ html = katex.renderToString(tex, { displayMode: display, throwOnError: false })
49
+ rendered.set(key, html)
50
+ }
51
+ return html
52
+ }
53
+
54
+ export type DocumentPage = (link: DocumentLink) => string | undefined
55
+
56
+ export class PublicationCache {
57
+ readonly #sections = new Map<string, { key: string, section: IRSection, bytes: number }>()
58
+ #bytes = 0
59
+
60
+ lookup(section: IRSection): { key: string, section?: IRSection } {
61
+ const key = JSON.stringify(section)
62
+ const known = this.#sections.get(section.id)
63
+ return { key, ...known?.key === key ? { section: known.section } : {} }
64
+ }
65
+
66
+ store(original: IRSection, key: string, section: IRSection): void {
67
+ const old = this.#sections.get(original.id)
68
+ if (old) this.#bytes -= old.bytes
69
+ this.#sections.delete(original.id)
70
+ // Local files and raw HTML can change independently of the IR. Revisit them.
71
+ const local = (node: MdastNode): boolean => node.type === 'html'
72
+ || (LINKED.has(node.type) && typeof node.url === 'string' && !NON_FILE_URL.test(node.url))
73
+ || Boolean(node.children?.some(local))
74
+ for (const block of irBlocks(original.blocks)) {
75
+ if (block.node.type === 'markdown' && local(block.node.mdast)) return
76
+ if (block.node.type === 'component' && (Object.keys(block.node.assets ?? {}).length || (block.node.fallback && local(block.node.fallback)))) return
77
+ if (block.stepStates?.some(state => Object.keys(state.assets ?? {}).length)) return
78
+ }
79
+ const bytes = (key.length + JSON.stringify(section).length) * 2
80
+ if (bytes > PUBLICATION_CACHE_BYTES) return
81
+ this.#sections.set(original.id, { key, section, bytes })
82
+ this.#bytes += bytes
83
+ while (this.#bytes > PUBLICATION_CACHE_BYTES || this.#sections.size > 2048) {
84
+ const first = this.#sections.keys().next().value!
85
+ this.#bytes -= this.#sections.get(first)!.bytes
86
+ this.#sections.delete(first)
87
+ }
88
+ }
89
+
90
+ prune(ids: Set<string>): void {
91
+ for (const [id, entry] of this.#sections) {
92
+ if (ids.has(id)) continue
93
+ this.#sections.delete(id)
94
+ this.#bytes -= entry.bytes
95
+ }
96
+ }
97
+ }
98
+
99
+ export function publishedDocument(doc: IRDocument, ctx: RenderContext, publicDir: string, base: string, documentPage?: DocumentPage, cache?: PublicationCache): IRDocument {
100
+ const { deckDir } = ctx
101
+ const copy: IRDocument = structuredClone(cache ? { ...doc, sections: [] } : doc)
102
+ const media = join(publicDir, MEDIA)
103
+ const relative = (pos: { file: string }) => {
104
+ pos.file = ctx.portable(pos.file, deckDir) as string
105
+ }
106
+ const publish = (url: string, source: SourcePos): string | undefined => {
107
+ const linked = documentPage && ctx.documentLink?.(source, url)
108
+ if (linked) return documentPage(linked)
109
+ const asset = localAsset(ctx, source, url)
110
+ if (!asset) return undefined
111
+ copyPublished(asset, media)
112
+ return publishedUrl(asset, url, { prefix: `${base}${MEDIA}/` })
113
+ }
114
+ const mdast = (node: MdastNode, source: SourcePos) => {
115
+ if (LINKED.has(node.type) && typeof node.url === 'string') node.url = publish(node.url, source) ?? node.url
116
+ if (node.type === 'html' && typeof node.value === 'string') node.value = mapHtmlUrls(node.value, url => publish(url, source))
117
+ if ((node.type === 'math' || node.type === 'inlineMath') && typeof node.value === 'string') node.html = renderMath(node.value, node.type === 'math')
118
+ node.children?.forEach(child => mdast(child, source))
119
+ }
120
+ const block = (b: IRBlock) => {
121
+ const { node, source } = b
122
+ if (node.type === 'markdown') mdast(node.mdast, source)
123
+ if (node.type === 'component') {
124
+ if (node.fallback) mdast(node.fallback, source)
125
+ for (const state of b.stepStates ?? []) {
126
+ if (!state.parameters) continue
127
+ // Plugins can declare state-only resources. Other components inherit
128
+ // their declared asset paths, resolved against the current parameters.
129
+ const assets = state.assets ?? Object.fromEntries(Object.entries(node.assets ?? {}).flatMap(([path, original]) => {
130
+ const at = (props: Record<string, unknown>) => path.split('.').reduce<unknown>((current, key) => current && typeof current === 'object' ? (current as Record<string, unknown>)[key] : undefined, props)
131
+ const value = at(state.parameters!)
132
+ return typeof value === 'string' ? [[path, value !== at(node.props) ? value : original]] : []
133
+ }))
134
+ state.parameters = withPublishedAssets({ ...node, props: state.parameters, assets }, url => publish(url, source)).props
135
+ }
136
+ node.props = withPublishedAssets(node, url => publish(url, source)).props
137
+ }
138
+ }
139
+ copy.meta.entry = ctx.portable(copy.meta.entry, deckDir) as string
140
+ copy.config.presmium = ctx.portable(copy.config.presmium, deckDir) as IRDocument['config']['presmium']
141
+ for (const original of cache ? doc.sections : copy.sections) {
142
+ const known = cache?.lookup(original)
143
+ if (known?.section) {
144
+ copy.sections.push(known.section)
145
+ continue
146
+ }
147
+ const section = cache ? structuredClone(original) : original
148
+ relative(section.source)
149
+ if (section.config) section.config = ctx.portable(section.config, deckDir) as typeof section.config
150
+ for (const b of irBlocks(section.blocks)) block(b)
151
+ for (const b of irBlocks(section.blocks)) relative(b.source)
152
+ if (cache) {
153
+ copy.sections.push(section)
154
+ cache.store(original, known!.key, section)
155
+ }
156
+ }
157
+ cache?.prune(new Set(doc.sections.map(section => section.id)))
158
+ for (const finding of copy.diagnostics) {
159
+ if (finding.source) relative(finding.source)
160
+ }
161
+ return copy
162
+ }
163
+
164
+ export function publishedFile(ctx: RenderContext, publicDir: string, base: string, source: SourcePos, url: string): string | undefined {
165
+ const asset = localAsset(ctx, source, url)
166
+ if (!asset || asset.directory) return undefined
167
+ copyPublished(asset, join(publicDir, MEDIA))
168
+ return publishedUrl(asset, url, { prefix: `${base}${MEDIA}/` })
169
+ }
@@ -0,0 +1,108 @@
1
+ import { join, resolve } from 'node:path'
2
+ import type { IRDocument, RenderContext, RenderDevServer, Renderer, SiteDevServer, SiteDocument, SiteSpec } from '@lincyaw/kernel'
3
+ import launchEditor from 'launch-editor'
4
+ import { rendererConfig } from '../domain/index.ts'
5
+ import { buildSite as buildPages } from './site-build.ts'
6
+ import type { BuiltDocument } from './site-build.ts'
7
+ import { documentContext, documentSourceFiles } from './site-model.ts'
8
+ import { startSiteServer } from './site-server.ts'
9
+ import { viewerAppCache } from './viewer-app.ts'
10
+
11
+ const BUILD_DIR = '.presmium/web/build'
12
+ const LOOPBACK = '127.0.0.1'
13
+
14
+ /** One document as a site of its own: at the root of its site path, with no home page. */
15
+ function documentSite(doc: IRDocument, ctx: RenderContext): SiteSpec {
16
+ return { title: doc.meta.title, documents: [{ dir: '', title: doc.meta.title, doc, ...ctx.translations ? { translations: ctx.translations } : {} }], config: ctx.config }
17
+ }
18
+
19
+ function documentWorkDir(ctx: RenderContext, dir: string): string {
20
+ return join(ctx.workDir, 'documents', dir ? encodeURIComponent(dir) : '_')
21
+ }
22
+
23
+ /** The dev server of one document (`presmium dev`): its pages, or its slides. */
24
+ export async function devDocument(doc: IRDocument, ctx: RenderContext, form: 'web' | 'slides'): Promise<RenderDevServer> {
25
+ let current = doc
26
+ const { port } = rendererConfig(ctx)
27
+ const server = await startSiteServer({
28
+ site: () => documentSite(current, ctx),
29
+ form,
30
+ ctx,
31
+ cacheDir: viewerAppCache(),
32
+ workDir: join(ctx.outDir, 'site'),
33
+ document: async () => current,
34
+ context: () => ctx,
35
+ nodePlugins: () => ctx.nodeVitePlugins ?? [],
36
+ translations: () => (form === 'web' ? ctx.translations ?? [] : []),
37
+ listen: { host: LOOPBACK, port: port ?? 0 },
38
+ openSource(file, line) {
39
+ if (!documentSourceFiles(current).has(file)) return
40
+ launchEditor(`${resolve(ctx.deckDir, file)}:${line}`, (opened, error) => {
41
+ console.error(`[presmium] could not open ${opened} in an editor${error ? `: ${error}` : ''}; set $EDITOR or $LAUNCH_EDITOR`)
42
+ })
43
+ },
44
+ })
45
+ return {
46
+ url: server.url,
47
+ async update(next) {
48
+ current = next
49
+ server.update('', next)
50
+ },
51
+ close: () => server.close(),
52
+ }
53
+ }
54
+
55
+ export const renderer: Renderer = {
56
+ id: 'web',
57
+ site: true,
58
+ support: () => 'full',
59
+
60
+ async build(doc: IRDocument, ctx: RenderContext) {
61
+ const work = join(ctx.deckDir, BUILD_DIR)
62
+ const entry: BuiltDocument = { dir: '', doc, ctx, nodePlugins: ctx.nodeVitePlugins ?? [], translations: ctx.translations ?? [] }
63
+ await buildPages(documentSite(doc, ctx), { ...ctx, workDir: work }, [entry])
64
+ return { outDir: ctx.outDir, skipped: [] }
65
+ },
66
+
67
+ dev: (doc, ctx) => devDocument(doc, ctx, 'web'),
68
+
69
+ async buildSite(site: SiteSpec, ctx: RenderContext) {
70
+ const documents: BuiltDocument[] = site.documents.flatMap((entry) => {
71
+ if (!entry.doc) return []
72
+ const own = documentContext(ctx, site, entry.dir, entry.doc, join(ctx.outDir, entry.dir), documentWorkDir(ctx, entry.dir))
73
+ return [{ dir: entry.dir, doc: entry.doc, ctx: own, nodePlugins: entry.nodeVitePlugins ?? [], translations: entry.translations ?? [] }]
74
+ })
75
+ await buildPages(site, ctx, documents)
76
+ return { outDir: ctx.outDir, skipped: [] }
77
+ },
78
+
79
+ async devSite(site: SiteSpec, ctx: RenderContext, compile: (dir: string) => Promise<IRDocument>): Promise<SiteDevServer> {
80
+ const entryOf = (dir: string) => site.documents.find(entry => entry.dir === dir)
81
+ const host = typeof ctx.config.host === 'string' && ctx.config.host ? ctx.config.host : LOOPBACK
82
+ const server = await startSiteServer({
83
+ site: () => site,
84
+ ctx,
85
+ cacheDir: viewerAppCache(),
86
+ workDir: join(ctx.workDir, 'viewer'),
87
+ document: compile,
88
+ context: (dir, doc) => documentContext(ctx, site, dir, doc, documentWorkDir(ctx, dir), documentWorkDir(ctx, dir)),
89
+ nodePlugins: dir => entryOf(dir)?.nodeVitePlugins ?? [],
90
+ translations: dir => entryOf(dir)?.translations ?? [],
91
+ listen: { host, port: Number(ctx.config.port) || 0 },
92
+ })
93
+ return {
94
+ url: server.url,
95
+ async update(dir, doc) {
96
+ const known: SiteDocument = { dir, title: doc.meta.title, ...doc.meta.lang ? { lang: doc.meta.lang } : {}, ...Array.isArray(doc.meta.tags) ? { tags: (doc.meta.tags as unknown[]).filter((t): t is string => typeof t === 'string') } : {} }
97
+ const entry = entryOf(dir)
98
+ if (entry) Object.assign(entry, known)
99
+ else site.documents.push(known)
100
+ server.update(dir, doc)
101
+ },
102
+ target: async () => server.origin,
103
+ app: (dir, wait) => server.app(dir, wait),
104
+ warm: dir => server.warm(dir),
105
+ close: () => server.close(),
106
+ }
107
+ },
108
+ }
@@ -0,0 +1,17 @@
1
+ import { join, resolve } from 'node:path'
2
+ import { ABSOLUTE_URL, isRecord } from '@lincyaw/kernel'
3
+ import type { RenderContext, SiteSpec } from '@lincyaw/kernel'
4
+ import { publishedFile } from './publish.ts'
5
+ import { isSingle } from './site-model.ts'
6
+
7
+ /** Publish shell assets from the site's configuration owner, once for every document in the site. */
8
+ export function publishedSite(site: SiteSpec, ctx: RenderContext, outDir = ctx.outDir, base = ctx.base): SiteSpec {
9
+ const params = isRecord(site.config.params) ? site.config.params : undefined
10
+ if (!params || typeof params.logo !== 'string') return site
11
+ const logo = params.logo.trim()
12
+ const file = isSingle(site) ? resolve(ctx.deckDir, site.documents[0]!.doc?.meta.entry ?? 'lecture.json') : join(ctx.workspaceRoot, 'presmium.workspace.yaml')
13
+ const source = { file, line: 1 }
14
+ const url = ABSOLUTE_URL.test(logo) ? logo : logo.startsWith('/') ? `${ctx.base}${logo.slice(1)}` : publishedFile(ctx, outDir, base, source, logo)
15
+ const { logo: _source, ...rest } = params
16
+ return { ...site, config: { ...site.config, params: { ...rest, ...url ? { logo: url } : {} } } }
17
+ }
@@ -0,0 +1,297 @@
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 { 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
+ }
185
+ if (offline) writeServiceWorker(out, base, { dir: built.appDir, url: built.appUrl })
186
+ }
187
+
188
+ /** A page that shows no document (the home page, a missing page), below the site's path. */
189
+ function pageConfig(kind: 'home' | 'missing', base: string, lang: string, site: SiteConfig, extra: Partial<AppConfig> = {}): AppConfig {
190
+ return { kind, document: '', base, lang, site, origins: [], rendered: true, ...extra }
191
+ }
192
+
193
+ async function buildHome(site: SiteSpec, ctx: RenderContext, built: Built, docs: ReadonlyMap<string, IRDocument>): Promise<void> {
194
+ const lang = siteLanguage(site, ctx.lang)
195
+ const shell = siteShellBelow(site, ctx.base)
196
+ const others = siteLanguages(site, lang)
197
+ const locales = [{ lang, label: languageLabel(lang), base: ctx.base }, ...others.map(other => ({ lang: other.lang, label: languageLabel(other.lang), base: `${ctx.base}${other.lang}/` }))]
198
+ const target = homeDocument(site)
199
+ const landing = landingDocument(site)
200
+ const independent = loadLanding(site, ctx)
201
+ const page = independent?.kind === 'page' ? publishLanding(independent, site, ctx) : undefined
202
+ const image = headImage(shell, url => publishedFile(ctx, ctx.outDir, ctx.base, { file: join(ctx.workspaceRoot, 'presmium.workspace.yaml'), line: 1 }, url))
203
+ for (const locale of [{ lang, title: site.title, prefix: '' }, ...others.map(other => ({ ...other, prefix: `${other.lang}/` }))]) {
204
+ const config = pageConfig('home', ctx.base, locale.lang, { title: locale.title, brandTitle: locale.title, root: `${ctx.base}${locale.prefix}`, shell, locales, links: {} }, {
205
+ documents: homeDocuments(site, ctx.base, docs, locale.prefix ? locale.lang : undefined),
206
+ plugins: ctx.plugins,
207
+ })
208
+ const list = join(ctx.outDir, locale.prefix, target || site.config.landing !== undefined ? `${LIST_PAGE}.html` : 'index.html')
209
+ await renderPage(built, list, config, locale.title, metaTags(shell, image, locale.title), undefined, [])
210
+ if (page) {
211
+ const metadata = { ...shell, head: { ...shell.head, description: page.description ?? shell.head?.description } }
212
+ await renderPage(built, join(ctx.outDir, locale.prefix, 'index.html'), { ...config, landingPage: page }, page.title, metaTags(metadata, image, page.title), undefined, [])
213
+ }
214
+ if (independent?.kind === 'template') {
215
+ const output = await buildLandingTemplate(independent.file, join(ctx.workDir, 'landing', locale.lang), ctx.base, siteContent(site, ctx.base, locale.lang, config.documents ?? []), ctx.workspaceRoot)
216
+ writeLandingTemplate(output, ctx.outDir, locale.prefix)
217
+ }
218
+ if (target) {
219
+ const translated = locale.prefix && translationOf(target, locale.lang) ? `${documentBase(ctx.base, target.dir)}${locale.lang}/` : documentBase(ctx.base, target.dir)
220
+ writeFileSync(join(ctx.outDir, locale.prefix, 'index.html'), redirectHtml(translated, target.title, locale.lang))
221
+ }
222
+ if (landing && locale.prefix && !translationOf(landing, locale.lang)) {
223
+ writeFileSync(join(ctx.outDir, locale.prefix, 'index.html'), redirectHtml(ctx.base, site.title, locale.lang))
224
+ }
225
+ }
226
+ }
227
+
228
+ async function buildMissing(site: SiteSpec, ctx: RenderContext, built: Built, title: string, lang: string): Promise<void> {
229
+ const shell = siteShellBelow(site, ctx.base)
230
+ 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 })
231
+ await renderPage(built, join(ctx.outDir, '404.html'), config, title, [], undefined, [])
232
+ }
233
+
234
+ /** Builds the site of `documents` into `ctx.outDir`: a workspace's (with its home page), or one document's at the root. */
235
+ export async function buildSite(site: SiteSpec, ctx: RenderContext, documents: readonly BuiltDocument[]): Promise<void> {
236
+ const workspace = !isSingle(site)
237
+ // Invalid presentation settings fail before replacing an existing build.
238
+ siteShellBelow(site, ctx.base)
239
+ landingDocument(site)
240
+ loadLanding(site, ctx)
241
+ 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)
242
+ // A build starts empty, as the pages and files of an earlier one may be gone from the document.
243
+ rmSync(ctx.outDir, { recursive: true, force: true })
244
+ if (workspace) copyPublic(ctx.workspaceRoot, ctx.outDir)
245
+ site = publishedSite(site, ctx)
246
+ const built = await prepareApp(ctx, documents)
247
+ // Also allow publishing from a branch without Jekyll filtering the viewer's _app and _code directories.
248
+ writeFileSync(join(ctx.outDir, '.nojekyll'), '')
249
+ const docs = new Map(documents.map(entry => [entry.dir, entry.doc] as const))
250
+ if (workspace && ctx.config.home !== false) await buildHome(site, ctx, built, docs)
251
+ for (const entry of documents) {
252
+ try {
253
+ await buildDocumentPages(site, ctx, built, entry, docs, workspace)
254
+ } catch (error) {
255
+ throw new Error(workspace ? `document ${entry.dir || '.'}: ${errorMessage(error)}` : errorMessage(error))
256
+ }
257
+ }
258
+ const lang = siteLanguage(site, ctx.lang)
259
+ await buildMissing(site, ctx, built, workspace ? site.title : documents[0]?.doc.meta.title ?? site.title, lang)
260
+ const { sitemap } = siteShellBelow(site, ctx.base)
261
+ if (sitemap) {
262
+ const pages = workspace ? ['', ...sitemapPages(site)] : documents.flatMap(({ doc, translations }) => [
263
+ ...webPages(doc).map(page => pageLink(page).replace(/^\//, '')),
264
+ ...translations.flatMap(t => translationPages(t.doc, webPages(doc)).map(page => pageLink(page, `/${t.lang}`).replace(/^\//, ''))),
265
+ ])
266
+ await writeSitemap(join(ctx.outDir, 'sitemap.xml'), sitemap.hostname, ctx.base, pages)
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Builds a document's slides into `ctx.outDir`: one page that shows them (the slides form of the viewer), with the
272
+ * document's slides view (`{only} slides`), its code and files, as the web form's are built.
273
+ */
274
+ export async function buildSlidesSite(doc: IRDocument, ctx: RenderContext, nodePlugins: readonly NodeVitePlugin[]): Promise<void> {
275
+ rmSync(ctx.outDir, { recursive: true, force: true })
276
+ const entry: BuiltDocument = { dir: '', doc, ctx, nodePlugins, translations: [] }
277
+ const built = await prepareApp(ctx, [entry])
278
+ mkdirSync(ctx.outDir, { recursive: true })
279
+ const published = publishedDocument(doc, ctx, ctx.outDir, ctx.base)
280
+ const { code } = await documentFiles(ctx, built, entry)
281
+ writeFileSync(join(ctx.outDir, 'ir.json'), JSON.stringify(published))
282
+ const lang = doc.meta.lang ?? ctx.lang ?? 'zh-CN'
283
+ const site: SiteSpec = { title: doc.meta.title, documents: [{ dir: '', title: doc.meta.title, doc }], config: ctx.config }
284
+ const config: AppConfig = {
285
+ kind: 'document',
286
+ document: '',
287
+ base: ctx.base,
288
+ lang,
289
+ form: 'slides',
290
+ ir: `${ctx.base}ir.json`,
291
+ ...code ? { bundle: `${ctx.base}${CODE_DIR}/${code}/${BUNDLE_ENTRY}` } : {},
292
+ site: documentSiteConfig({ site, base: ctx.base, dir: '', doc, lang, docs: new Map(), workspace: false }),
293
+ plugins: ctx.plugins,
294
+ origins: [],
295
+ }
296
+ writeFileSync(join(ctx.outDir, 'index.html'), pageHtml({ entry: appEntry(built.app), appUrl: built.appUrl, shared: built.app.manifest.shared, config, title: doc.meta.title }))
297
+ }