@lincyaw/web 1.0.0-rc.0 → 1.0.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -4
- package/package.json +20 -19
- package/src/app/base.css +117 -0
- package/src/app/block-playback.ts +81 -0
- package/src/app/block-report.ts +104 -0
- package/src/app/document-modules.ts +30 -0
- package/src/app/env.d.ts +12 -0
- package/src/app/main.ts +198 -0
- package/src/app/page-app.ts +166 -0
- package/src/app/router.ts +109 -0
- package/src/app/server.ts +32 -0
- package/src/app/site/home.tsx +117 -0
- package/src/app/site/layout.tsx +92 -0
- package/src/app/site/search.tsx +22 -0
- package/src/app/site.css +1450 -0
- package/src/client/document.css +172 -0
- package/src/client/host.ts +22 -94
- package/src/client/preview-frame.ts +152 -0
- package/src/client/runtime.ts +159 -0
- package/src/domain/app-config.ts +91 -0
- package/src/domain/config.ts +17 -9
- package/src/domain/dev-updates.ts +12 -0
- package/src/domain/index.ts +17 -7
- package/src/domain/landing.ts +83 -0
- package/src/domain/links.ts +23 -18
- package/src/domain/messages.ts +74 -63
- package/src/domain/pages.ts +23 -41
- package/src/domain/themes.ts +59 -0
- package/src/domain/workspace-home.ts +12 -0
- package/src/node/client-modules.ts +92 -0
- package/src/node/document-bundle.ts +227 -0
- package/src/node/landing-template.ts +34 -0
- package/src/node/landing.ts +82 -0
- package/src/node/offline.ts +34 -11
- package/src/node/publish.ts +83 -15
- package/src/node/renderer.ts +78 -117
- package/src/node/site-assets.ts +17 -0
- package/src/node/site-build.ts +304 -0
- package/src/node/site-files.ts +87 -0
- package/src/node/site-model.ts +337 -0
- package/src/node/site-page.ts +82 -0
- package/src/node/site-server.ts +822 -0
- package/src/node/slides.ts +135 -0
- package/src/node/viewer-app.ts +504 -0
- package/src/node/viewer-build.ts +38 -0
- package/custom-nav-controls.vue +0 -17
- package/src/client/env.d.ts +0 -1
- package/src/client/home.ts +0 -182
- package/src/client/index.ts +0 -4
- package/src/client/page.ts +0 -38
- package/src/client/preview.ts +0 -71
- package/src/client/style.css +0 -427
- package/src/client/theme.ts +0 -322
- package/src/node/addons.ts +0 -76
- package/src/node/site.ts +0 -349
- package/src/node/sites.ts +0 -531
- package/src/node/vite.ts +0 -166
- package/src/node/vitepress.ts +0 -72
package/src/node/offline.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 +
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/node/publish.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
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
|
}
|
package/src/node/renderer.ts
CHANGED
|
@@ -1,122 +1,52 @@
|
|
|
1
|
-
import { rmSync } from 'node:fs'
|
|
2
1
|
import { join, resolve } from 'node:path'
|
|
3
|
-
import {
|
|
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 {
|
|
8
|
-
import
|
|
9
|
-
import {
|
|
10
|
-
import
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
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 { rendererConfig } from '../domain/index.ts'
|
|
5
|
+
import { buildSite as buildPages } from './site-build.ts'
|
|
6
|
+
import type { BuiltDocument } from './site-build.ts'
|
|
7
|
+
import { documentContext, documentSourceFiles } from './site-model.ts'
|
|
8
|
+
import { startSiteServer } from './site-server.ts'
|
|
9
|
+
import { viewerAppCache } from './viewer-app.ts'
|
|
19
10
|
|
|
20
11
|
const BUILD_DIR = '.presmium/web/build'
|
|
12
|
+
const LOOPBACK = '127.0.0.1'
|
|
21
13
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
}
|
|
14
|
+
/** One document as a site of its own: at the root of its site path, with no home page. */
|
|
15
|
+
function documentSite(doc: IRDocument, ctx: RenderContext): SiteSpec {
|
|
16
|
+
return { title: doc.meta.title, documents: [{ dir: '', title: doc.meta.title, doc, ...ctx.translations ? { translations: ctx.translations } : {} }], config: ctx.config }
|
|
47
17
|
}
|
|
48
18
|
|
|
49
|
-
|
|
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
|
|
19
|
+
function documentWorkDir(ctx: RenderContext, dir: string): string {
|
|
20
|
+
return join(ctx.workDir, 'documents', dir ? encodeURIComponent(dir) : '_')
|
|
56
21
|
}
|
|
57
22
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
23
|
+
/** The dev server of one document (`presmium dev`): its pages, or its slides. */
|
|
24
|
+
export async function devDocument(doc: IRDocument, ctx: RenderContext, form: 'web' | 'slides'): Promise<RenderDevServer> {
|
|
25
|
+
let current = doc
|
|
26
|
+
const { port } = rendererConfig(ctx)
|
|
27
|
+
const server = await startSiteServer({
|
|
28
|
+
site: () => documentSite(current, ctx),
|
|
29
|
+
form,
|
|
30
|
+
ctx,
|
|
31
|
+
cacheDir: viewerAppCache(),
|
|
32
|
+
workDir: join(ctx.outDir, 'site'),
|
|
33
|
+
document: async () => current,
|
|
34
|
+
context: () => ctx,
|
|
35
|
+
nodePlugins: () => ctx.nodeVitePlugins ?? [],
|
|
36
|
+
translations: () => (form === 'web' ? ctx.translations ?? [] : []),
|
|
37
|
+
listen: { host: LOOPBACK, port: port ?? 0 },
|
|
38
|
+
openSource(file, line) {
|
|
39
|
+
if (!documentSourceFiles(current).has(file)) return
|
|
40
|
+
launchEditor(`${resolve(ctx.deckDir, file)}:${line}`, (opened, error) => {
|
|
41
|
+
console.error(`[presmium] could not open ${opened} in an editor${error ? `: ${error}` : ''}; set $EDITOR or $LAUNCH_EDITOR`)
|
|
42
|
+
})
|
|
43
|
+
},
|
|
75
44
|
})
|
|
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
45
|
return {
|
|
107
|
-
|
|
108
|
-
async update(next
|
|
109
|
-
|
|
110
|
-
|
|
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 })
|
|
46
|
+
url: server.url,
|
|
47
|
+
async update(next) {
|
|
48
|
+
current = next
|
|
49
|
+
server.update('', next)
|
|
120
50
|
},
|
|
121
51
|
close: () => server.close(),
|
|
122
52
|
}
|
|
@@ -128,20 +58,51 @@ export const renderer: Renderer = {
|
|
|
128
58
|
support: () => 'full',
|
|
129
59
|
|
|
130
60
|
async build(doc: IRDocument, ctx: RenderContext) {
|
|
131
|
-
|
|
61
|
+
const work = join(ctx.deckDir, BUILD_DIR)
|
|
62
|
+
const entry: BuiltDocument = { dir: '', doc, ctx, nodePlugins: ctx.nodeVitePlugins ?? [], translations: ctx.translations ?? [] }
|
|
63
|
+
await buildPages(documentSite(doc, ctx), { ...ctx, workDir: work }, [entry])
|
|
132
64
|
return { outDir: ctx.outDir, skipped: [] }
|
|
133
65
|
},
|
|
134
66
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
67
|
+
dev: (doc, ctx) => devDocument(doc, ctx, 'web'),
|
|
68
|
+
|
|
69
|
+
async buildSite(site: SiteSpec, ctx: RenderContext) {
|
|
70
|
+
const documents: BuiltDocument[] = site.documents.flatMap((entry) => {
|
|
71
|
+
if (!entry.doc) return []
|
|
72
|
+
const own = documentContext(ctx, site, entry.dir, entry.doc, join(ctx.outDir, entry.dir), documentWorkDir(ctx, entry.dir))
|
|
73
|
+
return [{ dir: entry.dir, doc: entry.doc, ctx: own, nodePlugins: entry.nodeVitePlugins ?? [], translations: entry.translations ?? [] }]
|
|
74
|
+
})
|
|
75
|
+
await buildPages(site, ctx, documents)
|
|
76
|
+
return { outDir: ctx.outDir, skipped: [] }
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
async devSite(site: SiteSpec, ctx: RenderContext, compile: (dir: string) => Promise<IRDocument>): Promise<SiteDevServer> {
|
|
80
|
+
const entryOf = (dir: string) => site.documents.find(entry => entry.dir === dir)
|
|
81
|
+
const host = typeof ctx.config.host === 'string' && ctx.config.host ? ctx.config.host : LOOPBACK
|
|
82
|
+
const server = await startSiteServer({
|
|
83
|
+
site: () => site,
|
|
84
|
+
ctx,
|
|
85
|
+
cacheDir: viewerAppCache(),
|
|
86
|
+
workDir: join(ctx.workDir, 'viewer'),
|
|
87
|
+
document: compile,
|
|
88
|
+
context: (dir, doc) => documentContext(ctx, site, dir, doc, documentWorkDir(ctx, dir), documentWorkDir(ctx, dir)),
|
|
89
|
+
nodePlugins: dir => entryOf(dir)?.nodeVitePlugins ?? [],
|
|
90
|
+
translations: dir => entryOf(dir)?.translations ?? [],
|
|
91
|
+
listen: { host, port: Number(ctx.config.port) || 0 },
|
|
92
|
+
})
|
|
138
93
|
return {
|
|
139
|
-
url:
|
|
140
|
-
|
|
94
|
+
url: server.url,
|
|
95
|
+
async update(dir, doc) {
|
|
96
|
+
const known: SiteDocument = { dir, title: doc.meta.title, ...doc.meta.lang ? { lang: doc.meta.lang } : {}, ...Array.isArray(doc.meta.tags) ? { tags: (doc.meta.tags as unknown[]).filter((t): t is string => typeof t === 'string') } : {} }
|
|
97
|
+
const entry = entryOf(dir)
|
|
98
|
+
if (entry) Object.assign(entry, known)
|
|
99
|
+
else site.documents.push(known)
|
|
100
|
+
server.update(dir, doc)
|
|
101
|
+
},
|
|
102
|
+
target: async () => server.origin,
|
|
103
|
+
app: (dir, wait) => server.app(dir, wait),
|
|
104
|
+
warm: dir => server.warm(dir),
|
|
141
105
|
close: () => server.close(),
|
|
142
106
|
}
|
|
143
107
|
},
|
|
144
|
-
|
|
145
|
-
buildSite,
|
|
146
|
-
devSite,
|
|
147
108
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { join, resolve } from 'node:path'
|
|
2
|
+
import { ABSOLUTE_URL, isRecord } from '@lincyaw/kernel'
|
|
3
|
+
import type { RenderContext, SiteSpec } from '@lincyaw/kernel'
|
|
4
|
+
import { publishedFile } from './publish.ts'
|
|
5
|
+
import { isSingle } from './site-model.ts'
|
|
6
|
+
|
|
7
|
+
/** Publish shell assets from the site's configuration owner, once for every document in the site. */
|
|
8
|
+
export function publishedSite(site: SiteSpec, ctx: RenderContext, outDir = ctx.outDir, base = ctx.base): SiteSpec {
|
|
9
|
+
const params = isRecord(site.config.params) ? site.config.params : undefined
|
|
10
|
+
if (!params || typeof params.logo !== 'string') return site
|
|
11
|
+
const logo = params.logo.trim()
|
|
12
|
+
const file = isSingle(site) ? resolve(ctx.deckDir, site.documents[0]!.doc?.meta.entry ?? 'lecture.json') : join(ctx.workspaceRoot, 'presmium.workspace.yaml')
|
|
13
|
+
const source = { file, line: 1 }
|
|
14
|
+
const url = ABSOLUTE_URL.test(logo) ? logo : logo.startsWith('/') ? `${ctx.base}${logo.slice(1)}` : publishedFile(ctx, outDir, base, source, logo)
|
|
15
|
+
const { logo: _source, ...rest } = params
|
|
16
|
+
return { ...site, config: { ...site.config, params: { ...rest, ...url ? { logo: url } : {} } } }
|
|
17
|
+
}
|