@lincyaw/web 1.0.0-rc.0 → 1.0.0-rc.10

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 (59) hide show
  1. package/README.md +33 -4
  2. package/package.json +21 -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 +199 -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 +93 -0
  14. package/src/app/site/search.tsx +22 -0
  15. package/src/app/site.css +1450 -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 +160 -0
  20. package/src/domain/app-config.ts +91 -0
  21. package/src/domain/config.ts +16 -28
  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 +76 -63
  27. package/src/domain/pages.ts +23 -41
  28. package/src/domain/themes.ts +74 -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 +91 -0
  34. package/src/node/offline.ts +34 -11
  35. package/src/node/publish.ts +83 -15
  36. package/src/node/renderer.ts +77 -117
  37. package/src/node/site-assets.ts +19 -0
  38. package/src/node/site-build.ts +332 -0
  39. package/src/node/site-files.ts +87 -0
  40. package/src/node/site-model.ts +337 -0
  41. package/src/node/site-page.ts +109 -0
  42. package/src/node/site-server.ts +825 -0
  43. package/src/node/site-theme.ts +190 -0
  44. package/src/node/slides.ts +135 -0
  45. package/src/node/viewer-app.ts +504 -0
  46. package/src/node/viewer-build.ts +38 -0
  47. package/custom-nav-controls.vue +0 -17
  48. package/src/client/env.d.ts +0 -1
  49. package/src/client/home.ts +0 -182
  50. package/src/client/index.ts +0 -4
  51. package/src/client/page.ts +0 -38
  52. package/src/client/preview.ts +0 -71
  53. package/src/client/style.css +0 -427
  54. package/src/client/theme.ts +0 -322
  55. package/src/node/addons.ts +0 -76
  56. package/src/node/site.ts +0 -349
  57. package/src/node/sites.ts +0 -531
  58. package/src/node/vite.ts +0 -166
  59. package/src/node/vitepress.ts +0 -72
@@ -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
  }
@@ -1,17 +1,22 @@
1
+ import { createHash } from 'node:crypto'
1
2
  import { statSync } from 'node:fs'
2
3
  import { join } from 'node:path'
3
- import { irBlocks, mapHtmlUrls, publishedUrl, withProp } from '@lincyaw/kernel'
4
- import type { DocumentLink, IRBlock, IRDocument, LocalAsset, MdastNode, RenderContext, SourcePos } from '@lincyaw/kernel'
5
- import { copyPublished } from '@lincyaw/kernel/node'
4
+ import { irBlocks, mapHtmlUrls, NON_FILE_URL, publishedUrl, withPublishedAssets } from '@lincyaw/kernel'
5
+ import type { DocumentLink, IRBlock, IRDocument, IRSection, LocalAsset, MdastNode, RenderContext, SourcePos } from '@lincyaw/kernel'
6
+ import { copyPublished, fileVersion } from '@lincyaw/kernel/node'
6
7
  import katex from 'katex'
7
8
 
8
9
  export const MEDIA = 'media'
9
10
 
11
+ /** The IR revision of an IR written as JSON. */
12
+ export function irRevisionOfJson(json: string): string {
13
+ return createHash('sha256').update(json).digest('hex').slice(0, 16)
14
+ }
15
+
10
16
  const LINKED = new Set(['image', 'link', 'definition', 'card'])
11
17
 
12
18
  interface Asset extends LocalAsset {
13
- size: number
14
- mtimeMs: number
19
+ version: string
15
20
  }
16
21
 
17
22
  const assets = new Map<string, Asset>()
@@ -19,21 +24,22 @@ const assets = new Map<string, Asset>()
19
24
  function localAsset(ctx: RenderContext, source: SourcePos, url: string): Asset | undefined {
20
25
  const key = `${source.file}\0${url}`
21
26
  const known = assets.get(key)
22
- const now = known && statSync(known.file, { throwIfNoEntry: false })
23
- if (known && now && now.size === known.size && now.mtimeMs === known.mtimeMs) return known
27
+ const now = known && statSync(known.file, { bigint: true, throwIfNoEntry: false })
28
+ if (known && now && fileVersion(now) === known.version) return known
24
29
  const found = ctx.localAsset(source, url)
25
- const stat = found && statSync(found.file, { throwIfNoEntry: false })
30
+ const stat = found && statSync(found.file, { bigint: true, throwIfNoEntry: false })
26
31
  if (!found || !stat) {
27
32
  assets.delete(key)
28
33
  return undefined
29
34
  }
30
- const asset = { ...found, size: stat.size, mtimeMs: stat.mtimeMs, directory: found.directory ?? stat.isDirectory() }
35
+ const asset = { ...found, version: fileVersion(stat), directory: found.directory ?? stat.isDirectory() }
31
36
  if (asset.directory) assets.delete(key)
32
37
  else assets.set(key, asset)
33
38
  return asset
34
39
  }
35
40
 
36
41
  const rendered = new Map<string, string>()
42
+ const PUBLICATION_CACHE_BYTES = 16 << 20
37
43
 
38
44
  function renderMath(tex: string, display: boolean): string {
39
45
  const key = `${display ? 'D' : 'I'}${tex}`
@@ -47,9 +53,52 @@ function renderMath(tex: string, display: boolean): string {
47
53
 
48
54
  export type DocumentPage = (link: DocumentLink) => string | undefined
49
55
 
50
- export function publishedDocument(doc: IRDocument, ctx: RenderContext, publicDir: string, base: string, documentPage?: DocumentPage): IRDocument {
56
+ export class PublicationCache {
57
+ readonly #sections = new Map<string, { key: string, section: IRSection, bytes: number }>()
58
+ #bytes = 0
59
+
60
+ lookup(section: IRSection): { key: string, section?: IRSection } {
61
+ const key = JSON.stringify(section)
62
+ const known = this.#sections.get(section.id)
63
+ return { key, ...known?.key === key ? { section: known.section } : {} }
64
+ }
65
+
66
+ store(original: IRSection, key: string, section: IRSection): void {
67
+ const old = this.#sections.get(original.id)
68
+ if (old) this.#bytes -= old.bytes
69
+ this.#sections.delete(original.id)
70
+ // Local files and raw HTML can change independently of the IR. Revisit them.
71
+ const local = (node: MdastNode): boolean => node.type === 'html'
72
+ || (LINKED.has(node.type) && typeof node.url === 'string' && !NON_FILE_URL.test(node.url))
73
+ || Boolean(node.children?.some(local))
74
+ for (const block of irBlocks(original.blocks)) {
75
+ if (block.node.type === 'markdown' && local(block.node.mdast)) return
76
+ if (block.node.type === 'component' && (Object.keys(block.node.assets ?? {}).length || (block.node.fallback && local(block.node.fallback)))) return
77
+ if (block.stepStates?.some(state => Object.keys(state.assets ?? {}).length)) return
78
+ }
79
+ const bytes = (key.length + JSON.stringify(section).length) * 2
80
+ if (bytes > PUBLICATION_CACHE_BYTES) return
81
+ this.#sections.set(original.id, { key, section, bytes })
82
+ this.#bytes += bytes
83
+ while (this.#bytes > PUBLICATION_CACHE_BYTES || this.#sections.size > 2048) {
84
+ const first = this.#sections.keys().next().value!
85
+ this.#bytes -= this.#sections.get(first)!.bytes
86
+ this.#sections.delete(first)
87
+ }
88
+ }
89
+
90
+ prune(ids: Set<string>): void {
91
+ for (const [id, entry] of this.#sections) {
92
+ if (ids.has(id)) continue
93
+ this.#sections.delete(id)
94
+ this.#bytes -= entry.bytes
95
+ }
96
+ }
97
+ }
98
+
99
+ export function publishedDocument(doc: IRDocument, ctx: RenderContext, publicDir: string, base: string, documentPage?: DocumentPage, cache?: PublicationCache): IRDocument {
51
100
  const { deckDir } = ctx
52
- const copy = structuredClone(doc)
101
+ const copy: IRDocument = structuredClone(cache ? { ...doc, sections: [] } : doc)
53
102
  const media = join(publicDir, MEDIA)
54
103
  const relative = (pos: { file: string }) => {
55
104
  pos.file = ctx.portable(pos.file, deckDir) as string
@@ -73,20 +122,39 @@ export function publishedDocument(doc: IRDocument, ctx: RenderContext, publicDir
73
122
  if (node.type === 'markdown') mdast(node.mdast, source)
74
123
  if (node.type === 'component') {
75
124
  if (node.fallback) mdast(node.fallback, source)
76
- for (const [path, url] of Object.entries(node.assets ?? {})) {
77
- const published = publish(url, source)
78
- if (published) node.props = withProp(node.props, path, published)
125
+ for (const state of b.stepStates ?? []) {
126
+ if (!state.parameters) continue
127
+ // Plugins can declare state-only resources. Other components inherit
128
+ // their declared asset paths, resolved against the current parameters.
129
+ const assets = state.assets ?? Object.fromEntries(Object.entries(node.assets ?? {}).flatMap(([path, original]) => {
130
+ const at = (props: Record<string, unknown>) => path.split('.').reduce<unknown>((current, key) => current && typeof current === 'object' ? (current as Record<string, unknown>)[key] : undefined, props)
131
+ const value = at(state.parameters!)
132
+ return typeof value === 'string' ? [[path, value !== at(node.props) ? value : original]] : []
133
+ }))
134
+ state.parameters = withPublishedAssets({ ...node, props: state.parameters, assets }, url => publish(url, source)).props
79
135
  }
136
+ node.props = withPublishedAssets(node, url => publish(url, source)).props
80
137
  }
81
138
  }
82
139
  copy.meta.entry = ctx.portable(copy.meta.entry, deckDir) as string
83
140
  copy.config.presmium = ctx.portable(copy.config.presmium, deckDir) as IRDocument['config']['presmium']
84
- for (const section of copy.sections) {
141
+ for (const original of cache ? doc.sections : copy.sections) {
142
+ const known = cache?.lookup(original)
143
+ if (known?.section) {
144
+ copy.sections.push(known.section)
145
+ continue
146
+ }
147
+ const section = cache ? structuredClone(original) : original
85
148
  relative(section.source)
86
149
  if (section.config) section.config = ctx.portable(section.config, deckDir) as typeof section.config
87
150
  for (const b of irBlocks(section.blocks)) block(b)
88
151
  for (const b of irBlocks(section.blocks)) relative(b.source)
152
+ if (cache) {
153
+ copy.sections.push(section)
154
+ cache.store(original, known!.key, section)
155
+ }
89
156
  }
157
+ cache?.prune(new Set(doc.sections.map(section => section.id)))
90
158
  for (const finding of copy.diagnostics) {
91
159
  if (finding.source) relative(finding.source)
92
160
  }
@@ -1,122 +1,51 @@
1
- import { rmSync } from 'node:fs'
2
1
  import { join, resolve } from 'node:path'
3
- import { irBlocks } from '@lincyaw/kernel'
4
- import type { IRDocument, RenderContext, Renderer } from '@lincyaw/kernel'
5
- import { diffDocuments } from '@lincyaw/viewer/domain'
2
+ import type { IRDocument, RenderContext, RenderDevServer, Renderer, SiteDevServer, SiteDocument, SiteSpec } from '@lincyaw/kernel'
6
3
  import launchEditor from 'launch-editor'
7
- import { offlineEnabled, rendererConfig } from '../domain/index.ts'
8
- import type { WebRendererConfig } from '../domain/index.ts'
9
- import { clientModules, nodeVitePlugins } from './addons.ts'
10
- import type { ClientModules } from './addons.ts'
11
- import { writeServiceWorker } from './offline.ts'
12
- import { publishedDocument, publishedFile } from './publish.ts'
13
- import type { DocumentPage } from './publish.ts'
14
- import { siteFiles, write, writeSite } from './site.ts'
15
- import type { WorkspaceOptions } from './site.ts'
16
- import { buildSite, devSite } from './sites.ts'
17
- import { revisionOf } from './vite.ts'
18
- import { vitepressBuild, vitepressDev } from './vitepress.ts'
4
+ import { buildSite as buildPages } from './site-build.ts'
5
+ import type { BuiltDocument } from './site-build.ts'
6
+ import { documentContext, documentSourceFiles } from './site-model.ts'
7
+ import { startSiteServer } from './site-server.ts'
8
+ import { viewerAppCache } from './viewer-app.ts'
19
9
 
20
10
  const BUILD_DIR = '.presmium/web/build'
11
+ const LOOPBACK = '127.0.0.1'
21
12
 
22
- function sourceFiles(doc: IRDocument): Set<string> {
23
- const files = new Set<string>()
24
- for (const section of doc.sections) {
25
- files.add(section.source.file)
26
- for (const block of irBlocks(section.blocks)) files.add(block.source.file)
27
- }
28
- return files
29
- }
30
-
31
- export interface DocumentSite {
32
- workspace?: WorkspaceOptions
33
- extra?: ClientModules
34
- documentPage?: DocumentPage
35
- config?: string
36
- }
37
-
38
- function merge(modules: ClientModules, extra?: ClientModules): ClientModules {
39
- if (!extra) return modules
40
- return {
41
- components: new Map([...extra.components, ...modules.components]),
42
- plugins: [...extra.plugins, ...modules.plugins],
43
- viewers: [...extra.viewers, ...modules.viewers],
44
- vite: [...extra.vite, ...modules.vite],
45
- nodeVite: [...extra.nodeVite, ...modules.nodeVite],
46
- }
13
+ /** One document as a site of its own: at the root of its site path, with no home page. */
14
+ function documentSite(doc: IRDocument, ctx: RenderContext): SiteSpec {
15
+ return { title: doc.meta.title, documents: [{ dir: '', title: doc.meta.title, doc, ...ctx.translations ? { translations: ctx.translations } : {} }], config: ctx.config }
47
16
  }
48
17
 
49
- const REMOTE = /^(?:[a-z][a-z\d+.-]*:|\/\/)/i
50
-
51
- function headImage(doc: IRDocument, ctx: RenderContext, options: WebRendererConfig, publicDir: string, document: DocumentSite): string | undefined {
52
- const image = options.shell.head?.image
53
- if (!image || REMOTE.test(image)) return image
54
- const published = publishedFile(ctx, publicDir, options.base, { file: document.config ?? resolve(ctx.deckDir, doc.meta.entry), line: 1 }, image)
55
- return published && options.shell.sitemap ? `${options.shell.sitemap.hostname}${published}` : published
18
+ function documentWorkDir(ctx: RenderContext, dir: string): string {
19
+ return join(ctx.workDir, 'documents', dir ? encodeURIComponent(dir) : '_')
56
20
  }
57
21
 
58
- async function prepare(doc: IRDocument, ctx: RenderContext, workDir: string, document: DocumentSite = {}) {
59
- const options = rendererConfig(ctx)
60
- const files = siteFiles(workDir)
61
- rmSync(files.publicDir, { recursive: true, force: true })
62
- const deck = { deckDir: ctx.deckDir, workspaceRoot: ctx.workspaceRoot }
63
- const nodeVite = await nodeVitePlugins(ctx, doc.config.presmium, Boolean(document.workspace))
64
- const publish = (d: IRDocument) => publishedDocument(d, ctx, files.publicDir, options.base, document.documentPage)
65
- const image = headImage(doc, ctx, options, files.publicDir, document)
66
- const site = (published: IRDocument) => writeSite(workDir, {
67
- doc: published,
68
- deck,
69
- modules: merge(clientModules(ctx, nodeVite), document.extra),
70
- options,
71
- workspace: document.workspace,
72
- translations: (ctx.translations ?? []).map(({ lang, doc: translated }) => ({ lang, doc: publish(translated) })),
73
- image,
74
- ...ctx.preview ? { preview: ctx.preview } : {},
22
+ /** The dev server of one document (`presmium dev`): its pages, or its slides. */
23
+ export async function devDocument(doc: IRDocument, ctx: RenderContext, form: 'web' | 'slides'): Promise<RenderDevServer> {
24
+ let current = doc
25
+ const port = Number(ctx.config.port) > 0 && Number.isInteger(Number(ctx.config.port)) ? Number(ctx.config.port) : undefined
26
+ const server = await startSiteServer({
27
+ site: () => documentSite(current, ctx),
28
+ form,
29
+ ctx,
30
+ cacheDir: viewerAppCache(),
31
+ workDir: join(ctx.outDir, 'site'),
32
+ document: async () => current,
33
+ context: () => ctx,
34
+ nodePlugins: () => ctx.nodeVitePlugins ?? [],
35
+ translations: () => (form === 'web' ? ctx.translations ?? [] : []),
36
+ listen: { host: LOOPBACK, port: port ?? 0 },
37
+ openSource(file, line) {
38
+ if (!documentSourceFiles(current).has(file)) return
39
+ launchEditor(`${resolve(ctx.deckDir, file)}:${line}`, (opened, error) => {
40
+ console.error(`[presmium] could not open ${opened} in an editor${error ? `: ${error}` : ''}; set $EDITOR or $LAUNCH_EDITOR`)
41
+ })
42
+ },
75
43
  })
76
- return { options, files, publish, site }
77
- }
78
-
79
- export async function buildDocument(doc: IRDocument, ctx: RenderContext, workDir: string, document: DocumentSite = {}): Promise<void> {
80
- const { options, files, publish, site } = await prepare(doc, ctx, workDir, document)
81
- const published = publish(doc)
82
- site(published)
83
- write(join(files.publicDir, 'ir.json'), JSON.stringify(published))
84
- const code = await vitepressBuild(files.root, ctx.outDir)
85
- if (code !== 0) throw new Error(`vitepress build exited with code ${code}`)
86
- if (offlineEnabled(doc.config.presmium)) writeServiceWorker(ctx.outDir, options.base)
87
- }
88
-
89
- export interface DocumentDevServer {
90
- port: number
91
- update(doc: IRDocument): Promise<void>
92
- close(): Promise<void>
93
- }
94
-
95
- export async function devDocument(doc: IRDocument, ctx: RenderContext, document: DocumentSite = {}, port?: number, listen: { host?: string, signal?: AbortSignal } = {}): Promise<DocumentDevServer> {
96
- const { options, files, publish, site } = await prepare(doc, ctx, ctx.outDir, document)
97
- let current = publish(doc)
98
- let revision = revisionOf(JSON.stringify(current))
99
- site(current)
100
- const server = await vitepressDev(files.root, port ?? options.port, (message) => {
101
- if (message.type !== 'presmium:source' || document.workspace || !sourceFiles(current).has(message.file)) return
102
- launchEditor(`${resolve(ctx.deckDir, message.file)}:${message.line}`, (file, error) => {
103
- console.error(`[presmium] could not open ${file} in an editor${error ? `: ${error}` : ''}; set $EDITOR or $LAUNCH_EDITOR`)
104
- })
105
- }, listen)
106
44
  return {
107
- port: server.port,
108
- async update(next: IRDocument) {
109
- const published = publish(next)
110
- const update = diffDocuments(current, published)
111
- site(published)
112
- if (!update) {
113
- if (ctx.translations?.length) server.send({ type: 'presmium:translations' })
114
- return
115
- }
116
- const base = revision
117
- current = published
118
- revision = revisionOf(JSON.stringify(published))
119
- server.send({ type: 'presmium:update', update, base, revision })
45
+ url: server.url,
46
+ async update(next) {
47
+ current = next
48
+ server.update('', next)
120
49
  },
121
50
  close: () => server.close(),
122
51
  }
@@ -128,20 +57,51 @@ export const renderer: Renderer = {
128
57
  support: () => 'full',
129
58
 
130
59
  async build(doc: IRDocument, ctx: RenderContext) {
131
- await buildDocument(doc, ctx, join(ctx.deckDir, BUILD_DIR))
60
+ const work = join(ctx.deckDir, BUILD_DIR)
61
+ const entry: BuiltDocument = { dir: '', doc, ctx, nodePlugins: ctx.nodeVitePlugins ?? [], translations: ctx.translations ?? [] }
62
+ await buildPages(documentSite(doc, ctx), { ...ctx, workDir: work }, [entry])
132
63
  return { outDir: ctx.outDir, skipped: [] }
133
64
  },
134
65
 
135
- async dev(doc: IRDocument, ctx: RenderContext) {
136
- const server = await devDocument(doc, ctx)
137
- const { base } = rendererConfig(ctx)
66
+ dev: (doc, ctx) => devDocument(doc, ctx, 'web'),
67
+
68
+ async buildSite(site: SiteSpec, ctx: RenderContext) {
69
+ const documents: BuiltDocument[] = site.documents.flatMap((entry) => {
70
+ if (!entry.doc) return []
71
+ const own = documentContext(ctx, site, entry.dir, entry.doc, join(ctx.outDir, entry.dir), documentWorkDir(ctx, entry.dir))
72
+ return [{ dir: entry.dir, doc: entry.doc, ctx: own, nodePlugins: entry.nodeVitePlugins ?? [], translations: entry.translations ?? [] }]
73
+ })
74
+ await buildPages(site, ctx, documents)
75
+ return { outDir: ctx.outDir, skipped: [] }
76
+ },
77
+
78
+ async devSite(site: SiteSpec, ctx: RenderContext, compile: (dir: string) => Promise<IRDocument>): Promise<SiteDevServer> {
79
+ const entryOf = (dir: string) => site.documents.find(entry => entry.dir === dir)
80
+ const host = typeof ctx.config.host === 'string' && ctx.config.host ? ctx.config.host : LOOPBACK
81
+ const server = await startSiteServer({
82
+ site: () => site,
83
+ ctx,
84
+ cacheDir: viewerAppCache(),
85
+ workDir: join(ctx.workDir, 'viewer'),
86
+ document: compile,
87
+ context: (dir, doc) => documentContext(ctx, site, dir, doc, documentWorkDir(ctx, dir), documentWorkDir(ctx, dir)),
88
+ nodePlugins: dir => entryOf(dir)?.nodeVitePlugins ?? [],
89
+ translations: dir => entryOf(dir)?.translations ?? [],
90
+ listen: { host, port: Number(ctx.config.port) || 0 },
91
+ })
138
92
  return {
139
- url: `http://localhost:${server.port}${base}`,
140
- update: next => server.update(next),
93
+ url: server.url,
94
+ async update(dir, doc) {
95
+ const known: SiteDocument = { dir, title: doc.meta.title, ...doc.meta.lang ? { lang: doc.meta.lang } : {}, ...Array.isArray(doc.meta.tags) ? { tags: (doc.meta.tags as unknown[]).filter((t): t is string => typeof t === 'string') } : {} }
96
+ const entry = entryOf(dir)
97
+ if (entry) Object.assign(entry, known)
98
+ else site.documents.push(known)
99
+ server.update(dir, doc)
100
+ },
101
+ target: async () => server.origin,
102
+ app: (dir, wait) => server.app(dir, wait),
103
+ warm: dir => server.warm(dir),
141
104
  close: () => server.close(),
142
105
  }
143
106
  },
144
-
145
- buildSite,
146
- devSite,
147
107
  }
@@ -0,0 +1,19 @@
1
+ import { join, resolve } from 'node:path'
2
+ import { ABSOLUTE_URL, isRecord } from '@lincyaw/kernel'
3
+ import type { RenderContext, SiteSpec } from '@lincyaw/kernel'
4
+ import { publishedFile } from './publish.ts'
5
+ import { isSingle } from './site-model.ts'
6
+ import { publishedTheme } from './site-theme.ts'
7
+
8
+ /** Publish shell assets from the site's configuration owner, once for every document in the site: its logo and theme. */
9
+ export function publishedSite(site: SiteSpec, ctx: RenderContext, outDir = ctx.outDir, base = ctx.base): SiteSpec {
10
+ site = publishedTheme(site, outDir, base)
11
+ const params = isRecord(site.config.params) ? site.config.params : undefined
12
+ if (!params || typeof params.logo !== 'string') return site
13
+ const logo = params.logo.trim()
14
+ const file = isSingle(site) ? resolve(ctx.deckDir, site.documents[0]!.doc?.meta.entry ?? 'lecture.json') : join(ctx.workspaceRoot, 'presmium.workspace.yaml')
15
+ const source = { file, line: 1 }
16
+ const url = ABSOLUTE_URL.test(logo) ? logo : logo.startsWith('/') ? `${ctx.base}${logo.slice(1)}` : publishedFile(ctx, outDir, base, source, logo)
17
+ const { logo: _source, ...rest } = params
18
+ return { ...site, config: { ...site.config, params: { ...rest, ...url ? { logo: url } : {} } } }
19
+ }