@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,227 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { join, resolve, sep } from 'node:path'
|
|
4
|
+
import { pathToFileURL } from 'node:url'
|
|
5
|
+
import { irBlocks } from '@lincyaw/kernel'
|
|
6
|
+
import type { IRDocument, PresmiumNodePlugin } from '@lincyaw/kernel'
|
|
7
|
+
import { collectBrowserModules, deckPackage, emptyClientModules, isRestartablePackage } from './client-modules.ts'
|
|
8
|
+
import { quietDirectives } from './viewer-app.ts'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A document's own browser code, apart from the viewer app that shows it: its components, browser and viewer plugins,
|
|
12
|
+
* and the document modules its plugins generate for it (`PresmiumNodePlugin.documentModules`), as one small bundle that
|
|
13
|
+
* imports React, Presmium's packages and the plugins' public subpaths by name, from the app, through its import map.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const BUNDLE_MANIFEST = 'bundle.json'
|
|
17
|
+
export const BUNDLE_ENTRY = 'document.js'
|
|
18
|
+
const DOCUMENT_MODULE = '\0presmium-document-bundle:'
|
|
19
|
+
const KEEP = 3
|
|
20
|
+
|
|
21
|
+
export interface DocumentBundleInput {
|
|
22
|
+
deckDir: string
|
|
23
|
+
workspaceRoot: string
|
|
24
|
+
/** Where the document's bundles are kept, each in a directory by its key. */
|
|
25
|
+
outDir: string
|
|
26
|
+
/** The specifiers the app's import map names: never bundled. */
|
|
27
|
+
externals: string[]
|
|
28
|
+
/** The Node entries of the document's plugins, with their configuration, for their document modules. */
|
|
29
|
+
nodePlugins: { entry: string, config: Record<string, unknown> }[]
|
|
30
|
+
/** Canonical file implementations, registered by their derived runtime names. */
|
|
31
|
+
components?: DocumentComponent[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DocumentComponent { name: string, file: string, exportName?: string }
|
|
35
|
+
|
|
36
|
+
/** Own React components are selected by the compiled definition, regardless of their directory. */
|
|
37
|
+
export function documentComponents(doc: IRDocument | undefined): DocumentComponent[] {
|
|
38
|
+
const found = new Map<string, DocumentComponent>()
|
|
39
|
+
for (const section of doc?.sections ?? []) for (const block of irBlocks(section.blocks)) {
|
|
40
|
+
const node = block.node
|
|
41
|
+
if (node.type !== 'component' || node.plugin !== 'deck' || !node.definition) continue
|
|
42
|
+
const component = { name: node.name, file: node.definition.file, ...node.definition.exportName !== undefined ? { exportName: node.definition.exportName } : {} }
|
|
43
|
+
const known = found.get(component.name)
|
|
44
|
+
if (known && (known.file !== component.file || known.exportName !== component.exportName)) throw new Error(`Document component ${component.name} names multiple implementations.`)
|
|
45
|
+
found.set(component.name, component)
|
|
46
|
+
}
|
|
47
|
+
return [...found.values()]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface DocumentBundle {
|
|
51
|
+
key: string
|
|
52
|
+
dir: string
|
|
53
|
+
/** The files the bundle was built from, by path, with their size and time then. */
|
|
54
|
+
inputs: Record<string, string>
|
|
55
|
+
restartable: boolean
|
|
56
|
+
/** The names of the document's own components. */
|
|
57
|
+
components: string[]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface DocumentCode {
|
|
61
|
+
key: string
|
|
62
|
+
entry: string
|
|
63
|
+
components: string[]
|
|
64
|
+
/** The document modules' sources, by id. */
|
|
65
|
+
generated: Record<string, string>
|
|
66
|
+
restartable: boolean
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const stamp = (file: string) => {
|
|
70
|
+
try {
|
|
71
|
+
const { size, mtimeMs } = statSync(file)
|
|
72
|
+
return `${size}:${Math.trunc(mtimeMs)}`
|
|
73
|
+
} catch {
|
|
74
|
+
return 'missing'
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The entry of the document's bundle and the key of what it is made of, or none when the document has no code of its own. */
|
|
79
|
+
async function documentCode(input: DocumentBundleInput): Promise<DocumentCode | undefined> {
|
|
80
|
+
const modules = emptyClientModules()
|
|
81
|
+
collectBrowserModules(input.deckDir, 'deck', modules, false, deckPackage(input.deckDir, input.workspaceRoot))
|
|
82
|
+
const components = new Map([...modules.components].map(([name, file]) => [name, { file, exportName: 'default' }]))
|
|
83
|
+
if (input.components?.length && !isRestartablePackage(deckPackage(input.deckDir, input.workspaceRoot))) modules.restartable = false
|
|
84
|
+
for (const component of input.components ?? []) components.set(component.name, { file: resolve(input.deckDir, component.file), exportName: component.exportName ?? 'default' })
|
|
85
|
+
const generated: Record<string, string> = {}
|
|
86
|
+
for (const { entry, config } of input.nodePlugins) {
|
|
87
|
+
const plugin = (await import(pathToFileURL(entry).href) as { default?: PresmiumNodePlugin }).default
|
|
88
|
+
Object.assign(generated, plugin?.documentModules?.({ deckDir: input.deckDir, workspaceRoot: input.workspaceRoot, config }) ?? {})
|
|
89
|
+
}
|
|
90
|
+
if (components.size + modules.plugins.length + modules.viewers.length + Object.keys(generated).length === 0) return undefined
|
|
91
|
+
const load = (file: string) => `() => import(${JSON.stringify(file)})`
|
|
92
|
+
const ids = Object.keys(generated)
|
|
93
|
+
const entry = `${ids.map((id, i) => `import module${i} from ${JSON.stringify(id)}\n`).join('')}
|
|
94
|
+
export const components = {
|
|
95
|
+
${[...components].map(([name, { file, exportName }]) => ` ${JSON.stringify(name)}: () => import(${JSON.stringify(file)}).then(module => { if (!module[${JSON.stringify(exportName)}]) throw new Error(${JSON.stringify(`Document component ${name} has no export ${exportName}.`)}); return { default: module[${JSON.stringify(exportName)}] } }),`).join('\n')}
|
|
96
|
+
}
|
|
97
|
+
export const plugins = [${modules.plugins.map(load).join(', ')}]
|
|
98
|
+
export const viewers = [${modules.viewers.map(({ name, file }) => `{ name: ${JSON.stringify(name)}, load: ${load(file)} }`).join(', ')}]
|
|
99
|
+
export const documentModules = {
|
|
100
|
+
${ids.map((id, i) => ` ${JSON.stringify(id)}: module${i},`).join('\n')}
|
|
101
|
+
}
|
|
102
|
+
export const restartable = ${modules.restartable}
|
|
103
|
+
`
|
|
104
|
+
const key = createHash('sha256').update(JSON.stringify([input.externals.toSorted(), entry, generated])).digest('hex').slice(0, 16)
|
|
105
|
+
return { key, entry, generated, restartable: modules.restartable, components: [...components.keys()] }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function readBundle(dir: string): DocumentBundle | undefined {
|
|
109
|
+
try {
|
|
110
|
+
const bundle = JSON.parse(readFileSync(join(dir, BUNDLE_MANIFEST), 'utf8')) as DocumentBundle
|
|
111
|
+
return Object.entries(bundle.inputs).every(([file, known]) => stamp(file) === known) ? { ...bundle, components: bundle.components ?? [], dir } : undefined
|
|
112
|
+
} catch {
|
|
113
|
+
return undefined
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The newest bundle of the document's code (`key`) built from its files as they are now. */
|
|
118
|
+
function freshBundle(outDir: string, key: string): DocumentBundle | undefined {
|
|
119
|
+
let entries: string[]
|
|
120
|
+
try {
|
|
121
|
+
entries = readdirSync(outDir).filter(entry => entry.startsWith(`${key}-`))
|
|
122
|
+
} catch {
|
|
123
|
+
return undefined
|
|
124
|
+
}
|
|
125
|
+
const builds = entries.map(entry => ({ dir: join(outDir, entry), time: statSync(join(outDir, entry)).mtimeMs })).sort((a, b) => b.time - a.time)
|
|
126
|
+
for (const { dir } of builds) {
|
|
127
|
+
const bundle = readBundle(dir)
|
|
128
|
+
if (bundle) return bundle
|
|
129
|
+
}
|
|
130
|
+
return undefined
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The document's bundle as its files are now, if one was built from them. */
|
|
134
|
+
export async function findDocumentBundle(input: DocumentBundleInput): Promise<DocumentBundle | null | undefined> {
|
|
135
|
+
const code = await documentCode(input)
|
|
136
|
+
if (!code) return null
|
|
137
|
+
return freshBundle(input.outDir, code.key)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Builds the document's bundle (unless it is there already); none when the document has no code of its own. Its
|
|
142
|
+
* directory is named by its code and its content, so that its files never change under their address.
|
|
143
|
+
*/
|
|
144
|
+
export async function buildDocumentBundle(input: DocumentBundleInput): Promise<DocumentBundle | null> {
|
|
145
|
+
const code = await documentCode(input)
|
|
146
|
+
if (!code) return null
|
|
147
|
+
const known = freshBundle(input.outDir, code.key)
|
|
148
|
+
if (known) return known
|
|
149
|
+
const work = join(input.outDir, `.build-${code.key}-${process.pid}`)
|
|
150
|
+
rmSync(work, { recursive: true, force: true })
|
|
151
|
+
mkdirSync(work, { recursive: true })
|
|
152
|
+
// Vite resolves symlinks, including macOS's /var -> /private/var temporary paths.
|
|
153
|
+
const realWork = realpathSync(work)
|
|
154
|
+
writeFileSync(join(work, 'entry.ts'), code.entry)
|
|
155
|
+
const { generated } = code
|
|
156
|
+
const externals = new Set(input.externals)
|
|
157
|
+
const inputs: Record<string, string> = {}
|
|
158
|
+
const { build } = await import('vite')
|
|
159
|
+
await build({
|
|
160
|
+
configFile: false,
|
|
161
|
+
root: input.deckDir,
|
|
162
|
+
base: './',
|
|
163
|
+
logLevel: 'warn',
|
|
164
|
+
mode: 'production',
|
|
165
|
+
esbuild: { jsx: 'automatic' },
|
|
166
|
+
publicDir: false,
|
|
167
|
+
plugins: [
|
|
168
|
+
{
|
|
169
|
+
name: 'presmium:document-bundle',
|
|
170
|
+
// Before Vite's own resolution: the app's modules stay imports by name.
|
|
171
|
+
enforce: 'pre' as const,
|
|
172
|
+
resolveId(id: string) {
|
|
173
|
+
// Headmatter aliases already load the renamed plugins. Older document code must import the same shared
|
|
174
|
+
// modules too, rather than resolving the removed packages or bundling a second copy of their runtime.
|
|
175
|
+
const shared = externals.has(id) ? id : id.replace(/^@lincyaw\/slidev-addon-/, '@lincyaw/addon-')
|
|
176
|
+
if (externals.has(shared)) return { id: shared, external: true }
|
|
177
|
+
return generated[id] === undefined ? undefined : `${DOCUMENT_MODULE}${id}`
|
|
178
|
+
},
|
|
179
|
+
load(id: string) {
|
|
180
|
+
return id.startsWith(DOCUMENT_MODULE) ? generated[id.slice(DOCUMENT_MODULE.length)] : undefined
|
|
181
|
+
},
|
|
182
|
+
generateBundle(_: unknown, bundle: Record<string, { type: string, moduleIds?: string[] }>) {
|
|
183
|
+
for (const chunk of Object.values(bundle)) {
|
|
184
|
+
for (const id of chunk.moduleIds ?? []) {
|
|
185
|
+
const file = id.replace(/[?#].*$/s, '')
|
|
186
|
+
if (file.startsWith('\0') || !existsSync(file)) continue
|
|
187
|
+
const realFile = realpathSync(file)
|
|
188
|
+
if (!realFile.startsWith(realWork + sep) && !realFile.split(sep).includes('node_modules')) inputs[realFile] = stamp(realFile)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
onLog: quietDirectives,
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
resolve: { dedupe: ['react', 'react-dom'] },
|
|
196
|
+
build: {
|
|
197
|
+
outDir: join(work, 'out'),
|
|
198
|
+
emptyOutDir: true,
|
|
199
|
+
target: 'es2022',
|
|
200
|
+
reportCompressedSize: false,
|
|
201
|
+
chunkSizeWarningLimit: 100_000,
|
|
202
|
+
copyPublicDir: false,
|
|
203
|
+
rollupOptions: {
|
|
204
|
+
input: { document: join(work, 'entry.ts') },
|
|
205
|
+
preserveEntrySignatures: 'strict',
|
|
206
|
+
output: {
|
|
207
|
+
format: 'es',
|
|
208
|
+
entryFileNames: BUNDLE_ENTRY,
|
|
209
|
+
chunkFileNames: 'chunks/[name]-[hash].js',
|
|
210
|
+
assetFileNames: 'assets/[name]-[hash][extname]',
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
})
|
|
215
|
+
const content = createHash('sha256').update(readFileSync(join(work, 'out', BUNDLE_ENTRY))).digest('hex').slice(0, 8)
|
|
216
|
+
const name = `${code.key}-${content}`
|
|
217
|
+
const out = join(input.outDir, name)
|
|
218
|
+
const bundle: DocumentBundle = { key: name, dir: out, inputs, restartable: code.restartable, components: code.components }
|
|
219
|
+
writeFileSync(join(work, 'out', BUNDLE_MANIFEST), `${JSON.stringify(bundle, null, 2)}\n`)
|
|
220
|
+
rmSync(out, { recursive: true, force: true })
|
|
221
|
+
renameSync(join(work, 'out'), out)
|
|
222
|
+
rmSync(work, { recursive: true, force: true })
|
|
223
|
+
// The document's last few bundles stay, for pages still loading an earlier one.
|
|
224
|
+
const builds = readdirSync(input.outDir).filter(entry => !entry.startsWith('.')).map(entry => ({ dir: join(input.outDir, entry), time: statSync(join(input.outDir, entry)).mtimeMs })).sort((a, b) => b.time - a.time)
|
|
225
|
+
for (const old of builds.slice(KEEP)) rmSync(old.dir, { recursive: true, force: true })
|
|
226
|
+
return bundle
|
|
227
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { basename, dirname, join } from 'node:path'
|
|
3
|
+
import { mapHtmlUrls } from '@lincyaw/kernel'
|
|
4
|
+
import { build } from 'vite'
|
|
5
|
+
import { SITE_CONTENT_ELEMENT } from '../domain/index.ts'
|
|
6
|
+
import type { SiteContent } from '../domain/index.ts'
|
|
7
|
+
|
|
8
|
+
/** A website template is an ordinary Vite HTML entry, with its own CSS and modules. */
|
|
9
|
+
export async function buildLandingTemplate(file: string, workDir: string, base: string, content: SiteContent, workspaceRoot: string): Promise<{ html: string, dir: string }> {
|
|
10
|
+
const dir = join(workDir, 'template')
|
|
11
|
+
const data = JSON.stringify(content).replace(/</g, '\\u003c')
|
|
12
|
+
await build({
|
|
13
|
+
configFile: false,
|
|
14
|
+
root: dirname(file),
|
|
15
|
+
base,
|
|
16
|
+
publicDir: join(workspaceRoot, 'public'),
|
|
17
|
+
logLevel: 'silent',
|
|
18
|
+
plugins: [{
|
|
19
|
+
name: 'presmium-site-content',
|
|
20
|
+
transformIndexHtml: { order: 'pre', handler: () => [{ tag: 'script', attrs: { id: SITE_CONTENT_ELEMENT, type: 'application/json' }, children: data, injectTo: 'head' }] },
|
|
21
|
+
}],
|
|
22
|
+
build: { outDir: dir, emptyOutDir: true, copyPublicDir: false, assetsDir: '_landing/assets', rollupOptions: { input: file } },
|
|
23
|
+
})
|
|
24
|
+
const html = mapHtmlUrls(readFileSync(join(dir, basename(file)), 'utf8'), url => url.startsWith('/') && !url.startsWith('//') && !url.startsWith(base) ? `${base}${url.slice(1)}` : undefined)
|
|
25
|
+
return { html, dir }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function writeLandingTemplate(output: { html: string, dir: string }, outDir: string, prefix = ''): void {
|
|
29
|
+
// Only emitted assets are shared; the entry itself is mounted as the site's index.html.
|
|
30
|
+
if (existsSync(join(output.dir, '_landing'))) cpSync(join(output.dir, '_landing'), join(outDir, '_landing'), { recursive: true, force: true })
|
|
31
|
+
const index = join(outDir, prefix, 'index.html')
|
|
32
|
+
mkdirSync(dirname(index), { recursive: true })
|
|
33
|
+
writeFileSync(index, output.html)
|
|
34
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
|
|
2
|
+
import { basename, dirname, extname, join, resolve } from 'node:path'
|
|
3
|
+
import { ABSOLUTE_URL, deckLocale, DOCUMENT_ENTRY, isRecord, safeDecodeURIComponent } from '@lincyaw/kernel'
|
|
4
|
+
import type { IRDocument, RenderContext, SiteSpec } from '@lincyaw/kernel'
|
|
5
|
+
import { isInside } from '@lincyaw/kernel/node'
|
|
6
|
+
import { parseLandingPage, WEB_MESSAGES } from '../domain/index.ts'
|
|
7
|
+
import type { HomeDocument, SiteContent, SiteLandingPage } from '../domain/index.ts'
|
|
8
|
+
import { publishedFile } from './publish.ts'
|
|
9
|
+
import { documentLinkOf, documentPage, siteLanguage, siteTitle } from './site-model.ts'
|
|
10
|
+
|
|
11
|
+
export type IndependentLanding = { kind: 'page', page: SiteLandingPage, source: string } | { kind: 'template', file: string }
|
|
12
|
+
|
|
13
|
+
/** Resolve website pages from their own source, independently of the document compiler. */
|
|
14
|
+
export function loadLanding(site: SiteSpec, ctx: RenderContext): IndependentLanding | undefined {
|
|
15
|
+
const input = site.config.landing
|
|
16
|
+
if (input === undefined || typeof input === 'string') return undefined
|
|
17
|
+
const fail = (key: string): never => { throw new Error(WEB_MESSAGES[deckLocale(siteLanguage(site))].invalidSiteParameter(key)) }
|
|
18
|
+
if (!isRecord(input) || site.home !== undefined) return fail('site.landing')
|
|
19
|
+
const file = (value: unknown, key: string, extension: string) => {
|
|
20
|
+
if (typeof value !== 'string' || !value.trim()) return fail(key)
|
|
21
|
+
const path = resolve(ctx.workspaceRoot, value)
|
|
22
|
+
if (!existsSync(path) || !statSync(path).isFile() || extname(path).toLowerCase() !== extension || !isInside(realpathSync(path), realpathSync(ctx.workspaceRoot))) return fail(key)
|
|
23
|
+
return path
|
|
24
|
+
}
|
|
25
|
+
if ('source' in input && 'template' in input) return fail('site.landing')
|
|
26
|
+
if ('template' in input) return { kind: 'template', file: file(input.template, 'site.landing.template', '.html') }
|
|
27
|
+
if ('source' in input) {
|
|
28
|
+
const source = file(input.source, 'site.landing.source', '.json')
|
|
29
|
+
let value: unknown
|
|
30
|
+
try { value = JSON.parse(readFileSync(source, 'utf8')) } catch { return fail('site.landing.source') }
|
|
31
|
+
return { kind: 'page', page: parseLandingPage(value, siteLanguage(site)), source }
|
|
32
|
+
}
|
|
33
|
+
return { kind: 'page', page: parseLandingPage(input, siteLanguage(site)), source: join(ctx.workspaceRoot, 'presmium.workspace.yaml') }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Local media belongs to the page source; site-absolute navigation belongs to the deployment base. */
|
|
37
|
+
export function publishLanding(landing: Extract<IndependentLanding, { kind: 'page' }>, site: SiteSpec, ctx: RenderContext, outDir = ctx.outDir, base = ctx.base, docs: ReadonlyMap<string, IRDocument> = new Map(site.documents.flatMap(entry => entry.doc ? [[entry.dir, entry.doc] as const] : []))): SiteLandingPage {
|
|
38
|
+
const page = structuredClone(landing.page)
|
|
39
|
+
const source = { file: landing.source, line: 1 }
|
|
40
|
+
const publish = (value: string, link = false): string => {
|
|
41
|
+
if (ABSOLUTE_URL.test(value) || value.startsWith('#')) return value
|
|
42
|
+
if (value.startsWith('/')) return `${ctx.base}${value.slice(1)}`
|
|
43
|
+
if (link) {
|
|
44
|
+
const reference = documentLinkOf(ctx)(source, value)
|
|
45
|
+
if (reference) {
|
|
46
|
+
const href = documentPage(site, ctx.base, docs)(reference)
|
|
47
|
+
if (href) return href
|
|
48
|
+
throw new Error(WEB_MESSAGES[deckLocale(siteLanguage(site))].invalidSiteParameter(`site.landing: ${value}`))
|
|
49
|
+
}
|
|
50
|
+
const path = value.replace(/[?#].*$/s, '')
|
|
51
|
+
if (!extname(path)) return `${ctx.base}${value.replace(/^\.\//, '')}`
|
|
52
|
+
}
|
|
53
|
+
const file = resolve(dirname(landing.source), safeDecodeURIComponent(value.replace(/[?#].*$/s, '')))
|
|
54
|
+
if (existsSync(file) && basename(realpathSync(file)) === DOCUMENT_ENTRY) throw new Error(WEB_MESSAGES[deckLocale(siteLanguage(site))].invalidSiteParameter(`site.landing: ${value}`))
|
|
55
|
+
const result = publishedFile(ctx, outDir, base, source, value)
|
|
56
|
+
if (!result) throw new Error(WEB_MESSAGES[deckLocale(siteLanguage(site))].invalidSiteParameter(`site.landing: ${value}`))
|
|
57
|
+
return result
|
|
58
|
+
}
|
|
59
|
+
const actions = (items: { link: string }[] | undefined) => items?.forEach(action => { action.link = publish(action.link, true) })
|
|
60
|
+
if (page.hero) {
|
|
61
|
+
if (page.hero.image) page.hero.image = publish(page.hero.image)
|
|
62
|
+
actions(page.hero.actions)
|
|
63
|
+
}
|
|
64
|
+
for (const section of page.sections) {
|
|
65
|
+
if (section.type === 'features') for (const item of section.items) {
|
|
66
|
+
if (item.image) item.image = publish(item.image)
|
|
67
|
+
if (item.link) item.link = publish(item.link, true)
|
|
68
|
+
}
|
|
69
|
+
if (section.type === 'media') {
|
|
70
|
+
section.src = publish(section.src)
|
|
71
|
+
if (section.poster) section.poster = publish(section.poster)
|
|
72
|
+
if (section.captions) section.captions = Object.fromEntries(Object.entries(section.captions).map(([lang, url]) => [lang, publish(url)]))
|
|
73
|
+
}
|
|
74
|
+
if (section.type === 'cta') actions(section.actions)
|
|
75
|
+
}
|
|
76
|
+
return page
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The public content collection available to a free-form HTML template. */
|
|
80
|
+
export function siteContent(site: SiteSpec, base: string, lang: string, documents: HomeDocument[]): SiteContent {
|
|
81
|
+
return { schemaVersion: 1, title: siteTitle(site, lang), base, lang, documents: documents.map(({ dir, title, href, description, lang, tags, pages }) => ({ id: dir, title, href, description, lang, tags, pages })) }
|
|
82
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { basename, join, sep } from 'node:path'
|
|
4
|
+
import { OFFLINE_REQUESTS } from '@lincyaw/kernel'
|
|
5
|
+
import { SERVICE_WORKER, urlPath } from '../domain/index.ts'
|
|
6
|
+
import { MEDIA } from './publish.ts'
|
|
7
|
+
import { APP_DIR, CODE_DIR } from './site-files.ts'
|
|
8
|
+
|
|
9
|
+
interface OfflineCache {
|
|
10
|
+
prefix: string
|
|
11
|
+
cache: string
|
|
12
|
+
urls: string[]
|
|
13
|
+
runtimeCache: string
|
|
14
|
+
runtimes: string[]
|
|
15
|
+
runtimeBytes: number
|
|
16
|
+
base: string
|
|
17
|
+
app: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function source({ prefix, cache, urls, runtimeCache, runtimes, runtimeBytes, base, app }: OfflineCache): string {
|
|
21
|
+
return `// Generated by @lincyaw/web.
|
|
22
|
+
const CACHE = ${JSON.stringify(cache)}
|
|
23
|
+
const PREFIX = ${JSON.stringify(prefix)}
|
|
24
|
+
const RUNTIME_CACHE = ${JSON.stringify(runtimeCache)}
|
|
25
|
+
const BASE = ${JSON.stringify(base)}
|
|
26
|
+
const APP = ${JSON.stringify(app)}
|
|
27
|
+
const URLS = ${JSON.stringify(urls)}
|
|
28
|
+
const RUNTIMES = ${JSON.stringify(runtimes)}
|
|
29
|
+
const RUNTIME_BYTES = ${runtimeBytes}
|
|
30
|
+
const RUNTIME_PATHS = new Set(RUNTIMES)
|
|
31
|
+
|
|
32
|
+
self.addEventListener('install', (event) => {
|
|
33
|
+
event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(URLS)).then(() => self.skipWaiting()))
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
self.addEventListener('activate', (event) => {
|
|
37
|
+
event.waitUntil(caches.keys()
|
|
38
|
+
.then(keys => Promise.all(keys.filter(key => key.startsWith(PREFIX) && key !== CACHE && key !== RUNTIME_CACHE).map(key => caches.delete(key))))
|
|
39
|
+
.then(() => self.clients.claim()))
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
async function cached(request) {
|
|
43
|
+
const url = new URL(request.url)
|
|
44
|
+
url.search = ''
|
|
45
|
+
url.hash = ''
|
|
46
|
+
const path = url.pathname
|
|
47
|
+
const candidates = [url.href]
|
|
48
|
+
if (path.endsWith('/')) candidates.push(url.href + 'index.html')
|
|
49
|
+
else if (!/\\.[^/]+$/.test(path)) candidates.push(url.href + '.html')
|
|
50
|
+
for (const candidate of candidates) {
|
|
51
|
+
const hit = await caches.match(candidate, { cacheName: CACHE })
|
|
52
|
+
if (hit) return hit
|
|
53
|
+
}
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A runtime file from its cache, else from the network, kept for next time. */
|
|
58
|
+
async function runtime(href) {
|
|
59
|
+
const cache = await caches.open(RUNTIME_CACHE)
|
|
60
|
+
const hit = await cache.match(href)
|
|
61
|
+
if (hit) return hit
|
|
62
|
+
const response = await fetch(href)
|
|
63
|
+
if (response.status === 200) await cache.put(href, response.clone())
|
|
64
|
+
return response
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const runtimeUrls = () => RUNTIMES.map(path => new URL(path, self.location.origin).href)
|
|
68
|
+
|
|
69
|
+
async function saved() {
|
|
70
|
+
const cache = await caches.open(RUNTIME_CACHE)
|
|
71
|
+
const kept = new Set((await cache.keys()).map(request => request.url))
|
|
72
|
+
return runtimeUrls().every(href => kept.has(href))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function save() {
|
|
76
|
+
for (const href of runtimeUrls()) {
|
|
77
|
+
const response = await runtime(href)
|
|
78
|
+
if (response.status !== 200) throw new Error(href + ': ' + response.status)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
self.addEventListener('message', (event) => {
|
|
83
|
+
const port = event.ports[0]
|
|
84
|
+
const type = event.data && event.data.type
|
|
85
|
+
if (!port) return
|
|
86
|
+
if (type === ${JSON.stringify(OFFLINE_REQUESTS.runtimes)}) {
|
|
87
|
+
event.waitUntil(saved().then(done => port.postMessage({ bytes: RUNTIME_BYTES, saved: done })))
|
|
88
|
+
} else if (type === ${JSON.stringify(OFFLINE_REQUESTS.save)}) {
|
|
89
|
+
event.waitUntil(save().then(() => port.postMessage({ ok: true }), () => port.postMessage({ ok: false })))
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
self.addEventListener('fetch', (event) => {
|
|
94
|
+
const { request } = event
|
|
95
|
+
const url = new URL(request.url)
|
|
96
|
+
if (request.method !== 'GET' || url.origin !== self.location.origin || !(url.pathname.startsWith(BASE) || url.pathname.startsWith(APP))) return
|
|
97
|
+
if (RUNTIME_PATHS.has(url.pathname)) {
|
|
98
|
+
url.search = ''
|
|
99
|
+
url.hash = ''
|
|
100
|
+
event.respondWith(runtime(url.href))
|
|
101
|
+
} else if (url.pathname.startsWith(APP) || url.pathname.startsWith(BASE + ${JSON.stringify(`${CODE_DIR}/`)})) {
|
|
102
|
+
event.respondWith(cached(request).then(hit => hit || fetch(request)))
|
|
103
|
+
} else {
|
|
104
|
+
event.respondWith(fetch(request).catch(() => cached(request).then(hit => hit || Response.error())))
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
`
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function fallbackFonts(dir: string, all: string[]): Set<string> {
|
|
111
|
+
const unused = new Set<string>()
|
|
112
|
+
for (const file of all.filter(f => f.endsWith('.css'))) {
|
|
113
|
+
for (const [, face] of readFileSync(join(dir, file), 'utf8').matchAll(/@font-face\s*\{([^}]*)\}/g)) {
|
|
114
|
+
if (!/woff2/.test(face!)) continue
|
|
115
|
+
for (const [, url] of face!.matchAll(/url\(\s*["']?([^"')]+\.(?:woff|ttf))["']?\s*\)/g)) unused.add(url!.split('/').pop()!)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return unused
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function files(dir: string): string[] {
|
|
122
|
+
const all = readdirSync(dir, { recursive: true, encoding: 'utf8' })
|
|
123
|
+
.filter(file => statSync(join(dir, file)).isFile() && file !== SERVICE_WORKER)
|
|
124
|
+
.sort()
|
|
125
|
+
const unused = fallbackFonts(dir, all)
|
|
126
|
+
return all.filter(file => !unused.has(file.split(sep).pop()!))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const isRuntime = (file: string) => /^(?:pyodide|wasi|presmium\/jax-js)\//.test(file) && !/(?:^|\/)LICENSES?$/.test(file)
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Writes the document's service worker: it keeps the document's files and the viewer app's (`app`, below the site's
|
|
133
|
+
* path, shared with the site's other documents) for reading offline, and the plugins' run-time files on request.
|
|
134
|
+
*/
|
|
135
|
+
export function writeServiceWorker(outDir: string, base: string, app: { dir: string, url: string }): void {
|
|
136
|
+
const list = files(outDir).filter(file => !file.startsWith(`${APP_DIR}${sep}`)).map(file => file.split(sep).join('/'))
|
|
137
|
+
const shell = list.filter(file => !isRuntime(file))
|
|
138
|
+
const runtimes = list.filter(isRuntime)
|
|
139
|
+
const hash = (names: string[]) => {
|
|
140
|
+
const digest = createHash('sha256')
|
|
141
|
+
for (const file of names) digest.update(file).update(readFileSync(join(outDir, file)))
|
|
142
|
+
return digest.digest('hex').slice(0, 12)
|
|
143
|
+
}
|
|
144
|
+
const urls = (names: string[]) => names.map(file => file.split('/')).map(parts => base + (parts[0] === MEDIA ? parts.map(encodeURIComponent).join('/') : urlPath(parts.join('/'))))
|
|
145
|
+
const appFiles = files(app.dir).map(file => app.url + urlPath(file.split(sep).join('/')))
|
|
146
|
+
const bytes = runtimes.reduce((sum, file) => sum + statSync(join(outDir, file)).size, 0)
|
|
147
|
+
const scope = createHash('sha256').update(base).digest('hex').slice(0, 16)
|
|
148
|
+
const prefix = `presmium-web-${scope}-`
|
|
149
|
+
writeFileSync(join(outDir, SERVICE_WORKER), source({
|
|
150
|
+
prefix, cache: `${prefix}${hash(shell)}-${basename(app.dir)}`, urls: [...urls(shell), ...appFiles],
|
|
151
|
+
runtimeCache: `${prefix}runtimes-${hash(runtimes)}`, runtimes: urls(runtimes), runtimeBytes: bytes, base, app: app.url,
|
|
152
|
+
}))
|
|
153
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createRequire } from 'node:module'
|
|
2
|
+
import type { PresmiumNodePlugin } from '@lincyaw/kernel'
|
|
3
|
+
import { WEB_ID } from '../domain/index.ts'
|
|
4
|
+
import { renderer } from './renderer.ts'
|
|
5
|
+
|
|
6
|
+
const { version } = createRequire(import.meta.url)('../../package.json') as { version: string }
|
|
7
|
+
|
|
8
|
+
const plugin: PresmiumNodePlugin = { id: WEB_ID, version, kernel: '^1.0.0', renderer }
|
|
9
|
+
|
|
10
|
+
export default plugin
|