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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +53 -2
  3. package/package.json +57 -3
  4. package/src/app/base.css +117 -0
  5. package/src/app/block-playback.ts +81 -0
  6. package/src/app/block-report.ts +104 -0
  7. package/src/app/document-modules.ts +30 -0
  8. package/src/app/env.d.ts +12 -0
  9. package/src/app/main.ts +198 -0
  10. package/src/app/page-app.ts +166 -0
  11. package/src/app/router.ts +109 -0
  12. package/src/app/server.ts +32 -0
  13. package/src/app/site/home.tsx +117 -0
  14. package/src/app/site/layout.tsx +78 -0
  15. package/src/app/site/search.tsx +22 -0
  16. package/src/app/site.css +1416 -0
  17. package/src/client/document.css +172 -0
  18. package/src/client/host.ts +22 -0
  19. package/src/client/preview-frame.ts +152 -0
  20. package/src/client/runtime.ts +159 -0
  21. package/src/domain/app-config.ts +89 -0
  22. package/src/domain/config.ts +107 -0
  23. package/src/domain/dev-updates.ts +12 -0
  24. package/src/domain/index.ts +17 -0
  25. package/src/domain/landing.ts +83 -0
  26. package/src/domain/links.ts +67 -0
  27. package/src/domain/messages.ts +116 -0
  28. package/src/domain/pages.ts +81 -0
  29. package/src/domain/themes.ts +59 -0
  30. package/src/domain/workspace-home.ts +12 -0
  31. package/src/node/client-modules.ts +92 -0
  32. package/src/node/document-bundle.ts +227 -0
  33. package/src/node/landing-template.ts +34 -0
  34. package/src/node/landing.ts +82 -0
  35. package/src/node/offline.ts +153 -0
  36. package/src/node/presmium.ts +10 -0
  37. package/src/node/publish.ts +169 -0
  38. package/src/node/renderer.ts +108 -0
  39. package/src/node/site-assets.ts +17 -0
  40. package/src/node/site-build.ts +297 -0
  41. package/src/node/site-files.ts +87 -0
  42. package/src/node/site-model.ts +333 -0
  43. package/src/node/site-page.ts +82 -0
  44. package/src/node/site-server.ts +817 -0
  45. package/src/node/slides.ts +135 -0
  46. package/src/node/viewer-app.ts +504 -0
  47. package/src/node/viewer-build.ts +38 -0
@@ -0,0 +1,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