@lincyaw/web 1.0.0-rc.0 → 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 (58) hide show
  1. package/README.md +33 -4
  2. package/package.json +20 -19
  3. package/src/app/base.css +117 -0
  4. package/src/app/block-playback.ts +81 -0
  5. package/src/app/block-report.ts +104 -0
  6. package/src/app/document-modules.ts +30 -0
  7. package/src/app/env.d.ts +12 -0
  8. package/src/app/main.ts +198 -0
  9. package/src/app/page-app.ts +166 -0
  10. package/src/app/router.ts +109 -0
  11. package/src/app/server.ts +32 -0
  12. package/src/app/site/home.tsx +117 -0
  13. package/src/app/site/layout.tsx +78 -0
  14. package/src/app/site/search.tsx +22 -0
  15. package/src/app/site.css +1416 -0
  16. package/src/client/document.css +172 -0
  17. package/src/client/host.ts +22 -94
  18. package/src/client/preview-frame.ts +152 -0
  19. package/src/client/runtime.ts +159 -0
  20. package/src/domain/app-config.ts +89 -0
  21. package/src/domain/config.ts +17 -9
  22. package/src/domain/dev-updates.ts +12 -0
  23. package/src/domain/index.ts +17 -7
  24. package/src/domain/landing.ts +83 -0
  25. package/src/domain/links.ts +23 -18
  26. package/src/domain/messages.ts +74 -63
  27. package/src/domain/pages.ts +12 -39
  28. package/src/domain/themes.ts +59 -0
  29. package/src/domain/workspace-home.ts +12 -0
  30. package/src/node/client-modules.ts +92 -0
  31. package/src/node/document-bundle.ts +227 -0
  32. package/src/node/landing-template.ts +34 -0
  33. package/src/node/landing.ts +82 -0
  34. package/src/node/offline.ts +34 -11
  35. package/src/node/publish.ts +83 -15
  36. package/src/node/renderer.ts +78 -117
  37. package/src/node/site-assets.ts +17 -0
  38. package/src/node/site-build.ts +297 -0
  39. package/src/node/site-files.ts +87 -0
  40. package/src/node/site-model.ts +333 -0
  41. package/src/node/site-page.ts +82 -0
  42. package/src/node/site-server.ts +817 -0
  43. package/src/node/slides.ts +135 -0
  44. package/src/node/viewer-app.ts +504 -0
  45. package/src/node/viewer-build.ts +38 -0
  46. package/custom-nav-controls.vue +0 -17
  47. package/src/client/env.d.ts +0 -1
  48. package/src/client/home.ts +0 -182
  49. package/src/client/index.ts +0 -4
  50. package/src/client/page.ts +0 -38
  51. package/src/client/preview.ts +0 -71
  52. package/src/client/style.css +0 -427
  53. package/src/client/theme.ts +0 -322
  54. package/src/node/addons.ts +0 -76
  55. package/src/node/site.ts +0 -349
  56. package/src/node/sites.ts +0 -531
  57. package/src/node/vite.ts +0 -166
  58. package/src/node/vitepress.ts +0 -72
@@ -0,0 +1,59 @@
1
+ import { deckLocale, isRecord } from '@lincyaw/kernel'
2
+ import { WEB_MESSAGES } from './messages.ts'
3
+
4
+ export const SITE_THEME_IDS = ['course', 'docs', 'showcase'] as const
5
+ export type SiteThemeId = typeof SITE_THEME_IDS[number]
6
+ export type SitePageLayout = 'article' | 'landing' | 'wide'
7
+
8
+ /** Public presentation options; the content and its stable identities stay in lecture.json. */
9
+ export interface SiteThemeParams {
10
+ accent: 'blue' | 'teal' | 'violet' | 'amber'
11
+ typography: 'reading' | 'sans'
12
+ contentWidth: 'comfortable' | 'wide'
13
+ logo?: string
14
+ logoAlt?: string
15
+ }
16
+
17
+ export interface SiteTheme {
18
+ id: SiteThemeId
19
+ params: SiteThemeParams
20
+ }
21
+
22
+ export const SITE_THEMES: Readonly<Record<SiteThemeId, Readonly<SiteThemeParams>>> = {
23
+ docs: { accent: 'teal', typography: 'reading', contentWidth: 'comfortable' },
24
+ course: { accent: 'teal', typography: 'reading', contentWidth: 'comfortable' },
25
+ showcase: { accent: 'violet', typography: 'sans', contentWidth: 'wide' },
26
+ }
27
+
28
+ /** Resolve and validate the same configuration in builds, dev servers and independent documents. */
29
+ export function siteTheme(config: Record<string, unknown>, lang?: string): SiteTheme {
30
+ const t = WEB_MESSAGES[deckLocale(lang)]
31
+ const id = config.theme ?? 'docs'
32
+ if (!SITE_THEME_IDS.includes(id as SiteThemeId)) throw new Error(t.unknownTheme(String(id)))
33
+ const defaults = SITE_THEMES[id as SiteThemeId]
34
+ if (config.params !== undefined && !isRecord(config.params)) throw new Error(t.invalidSiteParameter('site.params'))
35
+ const input = isRecord(config.params) ? config.params : {}
36
+ const choice = <T extends string>(key: string, values: readonly T[], fallback: T): T => {
37
+ const value = input[key]
38
+ if (value === undefined) return fallback
39
+ if (!values.includes(value as T)) throw new Error(t.invalidSiteParameter(`site.params.${key}`))
40
+ return value as T
41
+ }
42
+ const params: SiteThemeParams = {
43
+ accent: choice('accent', ['blue', 'teal', 'violet', 'amber'], defaults.accent),
44
+ typography: choice('typography', ['reading', 'sans'], defaults.typography),
45
+ contentWidth: choice('contentWidth', ['comfortable', 'wide'], defaults.contentWidth),
46
+ }
47
+ for (const key of ['logo', 'logoAlt'] as const) {
48
+ if (input[key] === undefined) continue
49
+ if (typeof input[key] !== 'string' || key === 'logo' && !input[key].trim()) throw new Error(t.invalidSiteParameter(`site.params.${key}`))
50
+ params[key] = input[key].trim()
51
+ }
52
+ return { id: id as SiteThemeId, params }
53
+ }
54
+
55
+ export function sitePageLayout(config: Record<string, unknown>, lang?: string): SitePageLayout {
56
+ const value = config.layout ?? 'article'
57
+ if (!['article', 'landing', 'wide'].includes(value as string)) throw new Error(WEB_MESSAGES[deckLocale(lang)].invalidSiteParameter('renderers.web.layout'))
58
+ return value as SitePageLayout
59
+ }
@@ -0,0 +1,12 @@
1
+ /** A document's card on the workspace site's home page. */
2
+ export interface HomeDocument {
3
+ dir: string
4
+ title: string
5
+ lang?: string
6
+ tags?: string[]
7
+ href: string
8
+ description?: string
9
+ pages?: number
10
+ problems?: { errors: number, warnings: number }
11
+ sections?: { id: string, title: string, href: string }[]
12
+ }
@@ -0,0 +1,92 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { dirname, join, relative } from 'node:path'
3
+ import { isRecord } from '@lincyaw/kernel'
4
+ import type { NodeVitePlugin, RenderContext } from '@lincyaw/kernel'
5
+ import { packageOf } from '@lincyaw/kernel/node'
6
+
7
+ const CLIENT_PLUGIN = 'src/client/plugin.ts'
8
+ const VIEWER_PLUGIN = 'src/client/viewer.ts'
9
+ const VITE_CONFIG = 'vite.config.ts'
10
+ const COMPONENT = /^[^.]+\.([jt]sx?)$/
11
+
12
+ export interface ClientModules {
13
+ restartable: boolean
14
+ components: Map<string, string>
15
+ plugins: string[]
16
+ viewers: { name: string, file: string }[]
17
+ vite: string[]
18
+ nodeVite: NodeVitePlugin[]
19
+ }
20
+
21
+ /** Adds the browser modules of the package at `dir` to `modules` (a plugin's components do not replace those already there). */
22
+ export function collectBrowserModules(dir: string, name: string, modules: ClientModules, plugin: boolean, manifestDir: string | undefined = dir): void {
23
+ let browser = false
24
+ const components = join(dir, 'components')
25
+ if (existsSync(components)) {
26
+ for (const file of readdirSync(components).filter(f => COMPONENT.test(f)).sort()) {
27
+ browser = true
28
+ const name = file.replace(/\.[^.]+$/, '')
29
+ if (!plugin || !modules.components.has(name)) modules.components.set(name, join(components, file))
30
+ }
31
+ }
32
+ if (existsSync(join(dir, CLIENT_PLUGIN))) {
33
+ browser = true
34
+ modules.plugins.push(join(dir, CLIENT_PLUGIN))
35
+ }
36
+ if (existsSync(join(dir, VIEWER_PLUGIN))) {
37
+ browser = true
38
+ modules.viewers.push({ name, file: join(dir, VIEWER_PLUGIN) })
39
+ }
40
+ if (plugin && existsSync(join(dir, VITE_CONFIG))) {
41
+ browser = true
42
+ modules.vite.push(join(dir, VITE_CONFIG))
43
+ }
44
+ if (browser && !isRestartablePackage(manifestDir)) modules.restartable = false
45
+ }
46
+
47
+ /** The directory of the document's own package: its own `package.json`, else the nearest one up to the workspace's root. */
48
+ export function deckPackage(deckDir: string, workspaceRoot: string): string | undefined {
49
+ for (let dir = deckDir; ; dir = dirname(dir)) {
50
+ if (existsSync(join(dir, 'package.json'))) return dir
51
+ if (dir === workspaceRoot || relative(workspaceRoot, dir).startsWith('..') || dirname(dir) === dir) return undefined
52
+ }
53
+ }
54
+
55
+ function readManifest(dir: string): Record<string, unknown> {
56
+ try {
57
+ const pkg: unknown = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
58
+ return isRecord(pkg) ? pkg : {}
59
+ } catch {
60
+ return {}
61
+ }
62
+ }
63
+
64
+ export function isRestartablePackage(dir: string | undefined): boolean {
65
+ if (!dir) return false
66
+ const { presmium } = readManifest(dir)
67
+ return isRecord(presmium) && isRecord(presmium.preview) && presmium.preview.restartable === true
68
+ }
69
+
70
+ /** No browser modules yet. */
71
+ export function emptyClientModules(nodeVite: NodeVitePlugin[] = []): ClientModules {
72
+ return { restartable: true, components: new Map(), plugins: [], viewers: [], vite: [], nodeVite }
73
+ }
74
+
75
+ /** The browser modules of the document and its plugins: components, browser and viewer plugins, Vite configurations. */
76
+ export function clientModules(ctx: RenderContext, nodeVite: NodeVitePlugin[]): ClientModules {
77
+ const modules = emptyClientModules(nodeVite)
78
+ for (const { entry } of nodeVite) {
79
+ try {
80
+ if (!isRestartablePackage(packageOf(entry))) modules.restartable = false
81
+ } catch {
82
+ modules.restartable = false
83
+ }
84
+ }
85
+ for (const name of ctx.plugins) {
86
+ const dir = ctx.pluginDirs[name]
87
+ const { name: pkg } = dir ? readManifest(dir) : {}
88
+ if (dir) collectBrowserModules(dir, typeof pkg === 'string' ? pkg : name, modules, true)
89
+ }
90
+ collectBrowserModules(ctx.deckDir, 'deck', modules, false, deckPackage(ctx.deckDir, ctx.workspaceRoot))
91
+ return modules
92
+ }
@@ -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
+ }
@@ -1,16 +1,29 @@
1
1
  import { createHash } from 'node:crypto'
2
2
  import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
3
- import { join, sep } from 'node:path'
3
+ import { basename, join, sep } from 'node:path'
4
+ import { OFFLINE_REQUESTS } from '@lincyaw/kernel'
4
5
  import { SERVICE_WORKER, urlPath } from '../domain/index.ts'
5
6
  import { MEDIA } from './publish.ts'
7
+ import { APP_DIR, CODE_DIR } from './site-files.ts'
6
8
 
7
- const MESSAGES = { runtimes: 'presmium:offline-runtimes', save: 'presmium:save-offline' }
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
+ }
8
19
 
9
- function source(cache: string, urls: string[], runtimeCache: string, runtimes: string[], runtimeBytes: number, base: string): string {
20
+ function source({ prefix, cache, urls, runtimeCache, runtimes, runtimeBytes, base, app }: OfflineCache): string {
10
21
  return `// Generated by @lincyaw/web.
11
22
  const CACHE = ${JSON.stringify(cache)}
23
+ const PREFIX = ${JSON.stringify(prefix)}
12
24
  const RUNTIME_CACHE = ${JSON.stringify(runtimeCache)}
13
25
  const BASE = ${JSON.stringify(base)}
26
+ const APP = ${JSON.stringify(app)}
14
27
  const URLS = ${JSON.stringify(urls)}
15
28
  const RUNTIMES = ${JSON.stringify(runtimes)}
16
29
  const RUNTIME_BYTES = ${runtimeBytes}
@@ -22,7 +35,7 @@ self.addEventListener('install', (event) => {
22
35
 
23
36
  self.addEventListener('activate', (event) => {
24
37
  event.waitUntil(caches.keys()
25
- .then(keys => Promise.all(keys.filter(key => key.startsWith('presmium-web-') && key !== CACHE && key !== RUNTIME_CACHE).map(key => caches.delete(key))))
38
+ .then(keys => Promise.all(keys.filter(key => key.startsWith(PREFIX) && key !== CACHE && key !== RUNTIME_CACHE).map(key => caches.delete(key))))
26
39
  .then(() => self.clients.claim()))
27
40
  })
28
41
 
@@ -70,9 +83,9 @@ self.addEventListener('message', (event) => {
70
83
  const port = event.ports[0]
71
84
  const type = event.data && event.data.type
72
85
  if (!port) return
73
- if (type === ${JSON.stringify(MESSAGES.runtimes)}) {
86
+ if (type === ${JSON.stringify(OFFLINE_REQUESTS.runtimes)}) {
74
87
  event.waitUntil(saved().then(done => port.postMessage({ bytes: RUNTIME_BYTES, saved: done })))
75
- } else if (type === ${JSON.stringify(MESSAGES.save)}) {
88
+ } else if (type === ${JSON.stringify(OFFLINE_REQUESTS.save)}) {
76
89
  event.waitUntil(save().then(() => port.postMessage({ ok: true }), () => port.postMessage({ ok: false })))
77
90
  }
78
91
  })
@@ -80,12 +93,12 @@ self.addEventListener('message', (event) => {
80
93
  self.addEventListener('fetch', (event) => {
81
94
  const { request } = event
82
95
  const url = new URL(request.url)
83
- if (request.method !== 'GET' || url.origin !== self.location.origin || !url.pathname.startsWith(BASE)) return
96
+ if (request.method !== 'GET' || url.origin !== self.location.origin || !(url.pathname.startsWith(BASE) || url.pathname.startsWith(APP))) return
84
97
  if (RUNTIME_PATHS.has(url.pathname)) {
85
98
  url.search = ''
86
99
  url.hash = ''
87
100
  event.respondWith(runtime(url.href))
88
- } else if (url.pathname.startsWith(BASE + 'assets/')) {
101
+ } else if (url.pathname.startsWith(APP) || url.pathname.startsWith(BASE + ${JSON.stringify(`${CODE_DIR}/`)})) {
89
102
  event.respondWith(cached(request).then(hit => hit || fetch(request)))
90
103
  } else {
91
104
  event.respondWith(fetch(request).catch(() => cached(request).then(hit => hit || Response.error())))
@@ -115,8 +128,12 @@ function files(dir: string): string[] {
115
128
 
116
129
  const isRuntime = (file: string) => /^(?:pyodide|wasi|presmium\/jax-js)\//.test(file) && !/(?:^|\/)LICENSES?$/.test(file)
117
130
 
118
- export function writeServiceWorker(outDir: string, base: string): void {
119
- const list = files(outDir).map(file => file.split(sep).join('/'))
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('/'))
120
137
  const shell = list.filter(file => !isRuntime(file))
121
138
  const runtimes = list.filter(isRuntime)
122
139
  const hash = (names: string[]) => {
@@ -125,6 +142,12 @@ export function writeServiceWorker(outDir: string, base: string): void {
125
142
  return digest.digest('hex').slice(0, 12)
126
143
  }
127
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('/')))
128
146
  const bytes = runtimes.reduce((sum, file) => sum + statSync(join(outDir, file)).size, 0)
129
- writeFileSync(join(outDir, SERVICE_WORKER), source(`presmium-web-${hash(shell)}`, urls(shell), `presmium-web-runtimes-${hash(runtimes)}`, urls(runtimes), bytes, base))
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
+ }))
130
153
  }