@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.
- package/LICENSE +202 -0
- package/README.md +53 -2
- package/package.json +57 -3
- 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 +78 -0
- package/src/app/site/search.tsx +22 -0
- package/src/app/site.css +1416 -0
- package/src/client/document.css +172 -0
- package/src/client/host.ts +22 -0
- package/src/client/preview-frame.ts +152 -0
- package/src/client/runtime.ts +159 -0
- package/src/domain/app-config.ts +89 -0
- package/src/domain/config.ts +107 -0
- package/src/domain/dev-updates.ts +12 -0
- package/src/domain/index.ts +17 -0
- package/src/domain/landing.ts +83 -0
- package/src/domain/links.ts +67 -0
- package/src/domain/messages.ts +116 -0
- package/src/domain/pages.ts +81 -0
- 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 +153 -0
- package/src/node/presmium.ts +10 -0
- package/src/node/publish.ts +169 -0
- package/src/node/renderer.ts +108 -0
- package/src/node/site-assets.ts +17 -0
- package/src/node/site-build.ts +297 -0
- package/src/node/site-files.ts +87 -0
- package/src/node/site-model.ts +333 -0
- package/src/node/site-page.ts +82 -0
- package/src/node/site-server.ts +817 -0
- package/src/node/slides.ts +135 -0
- package/src/node/viewer-app.ts +504 -0
- 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
|
+
}
|