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

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.
@@ -0,0 +1,108 @@
1
+ import { blockText, chapters } from '@lincyaw/kernel'
2
+ import type { IRDocument } from '@lincyaw/kernel'
3
+
4
+ export interface WebPage {
5
+ path: string
6
+ title: string
7
+ sections: string[]
8
+ }
9
+
10
+ function slug(text: string): string {
11
+ return text.normalize('NFKC').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-+|-+$/g, '') || 'page'
12
+ }
13
+
14
+ function baseName(file: string): string {
15
+ return file.split(/[\\/]/).pop()!.replace(/\.[^.]+$/, '')
16
+ }
17
+
18
+ export function toPages(doc: IRDocument): WebPage[] {
19
+ const paths = new Set<string>()
20
+ let file: string | undefined
21
+ return chapters(doc).map((chapter, i) => {
22
+ const first = chapter.sections[0]!
23
+ const name = i === 0 ? 'index' : slug(first.source.file === file ? first.id : baseName(first.source.file))
24
+ let path = name
25
+ for (let n = 2; paths.has(path); n++) path = `${name}-${n}`
26
+ paths.add(path)
27
+ file = first.source.file
28
+ return { path, title: chapter.title, sections: chapter.sections.map(section => section.id) }
29
+ })
30
+ }
31
+
32
+ export function translationPages(doc: IRDocument, original: readonly WebPage[]): WebPage[] {
33
+ const pages = toPages(doc)
34
+ const matched = pages.map(page => original.find(o => o.sections[0] === page.sections[0])?.path)
35
+ const paths = new Set(matched.filter(path => path !== undefined))
36
+ return pages.map((page, i) => {
37
+ let path = matched[i]
38
+ if (path === undefined) {
39
+ path = page.path
40
+ for (let n = 2; paths.has(path); n++) path = `${page.path}-${n}`
41
+ paths.add(path)
42
+ }
43
+ return { ...page, path }
44
+ })
45
+ }
46
+
47
+ export function pageLink(page: WebPage, prefix = ''): string {
48
+ return page.path === 'index' ? `${prefix}/` : `${prefix}/${page.path}.html`
49
+ }
50
+
51
+ export function sectionLinks(pages: readonly WebPage[], prefix = ''): Record<string, string> {
52
+ return Object.fromEntries(pages.flatMap(page => page.sections.map((id, i) =>
53
+ [id, i === 0 ? pageLink(page, prefix) : `${pageLink(page, prefix)}#${encodeURIComponent(id)}`])))
54
+ }
55
+
56
+ export interface SidebarItem {
57
+ text?: string
58
+ link?: string
59
+ items?: SidebarItem[]
60
+ }
61
+
62
+ export function sidebar(doc: IRDocument, pages: readonly WebPage[], prefix = ''): SidebarItem[] {
63
+ const links = sectionLinks(pages, prefix)
64
+ const titles = new Map(doc.sections.map(section => [section.id, section.title]))
65
+ const chapters = pages.map((page): SidebarItem => {
66
+ const items = page.sections.slice(1).map(id => ({ text: titles.get(id) ?? id, link: links[id]! }))
67
+ return { text: page.title, link: pageLink(page, prefix), ...items.length ? { items } : {} }
68
+ })
69
+ return [{ items: chapters }]
70
+ }
71
+
72
+ export function pageOf(pages: readonly WebPage[], relativePath: string): WebPage | undefined {
73
+ const path = relativePath.replace(/\.md$/, '')
74
+ return pages.find(page => page.path === path)
75
+ }
76
+
77
+ export interface Neighbour {
78
+ text: string
79
+ link: string
80
+ }
81
+
82
+ export function neighbour(page: WebPage | undefined, prefix = ''): Neighbour | undefined {
83
+ return page && { text: page.title, link: pageLink(page, prefix) }
84
+ }
85
+
86
+ export function pageMarkdown(page: WebPage, prev?: Neighbour, next?: Neighbour): string {
87
+ const link = (other?: Neighbour) => (other ? JSON.stringify(other) : 'false')
88
+ return ['---', `title: ${JSON.stringify(page.title)}`, 'outline: [2, 3]', `prev: ${link(prev)}`, `next: ${link(next)}`, '---', '', '<PresmiumPage />', ''].join('\n')
89
+ }
90
+
91
+ export interface SearchDocument {
92
+ id: string
93
+ title: string
94
+ titles: string[]
95
+ text: string
96
+ }
97
+
98
+ export function searchDocuments(doc: IRDocument, base: string, pages: readonly WebPage[] = toPages(doc), titles: string[] = []): SearchDocument[] {
99
+ return pages.flatMap((page) => {
100
+ const path = page.path === 'index' ? '' : `${page.path}.html`
101
+ return page.sections.flatMap(id => doc.sections.filter(s => s.id === id)).map((section, i) => ({
102
+ id: `${base}${path}#${section.id}`,
103
+ title: section.title,
104
+ titles: [...titles, ...i === 0 ? [] : [page.title]],
105
+ text: section.blocks.map(blockText).filter(Boolean).join('\n'),
106
+ }))
107
+ })
108
+ }
@@ -0,0 +1,76 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { join, resolve } from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+ import { isRecord } from '@lincyaw/kernel'
5
+ import type { PresmiumNodePlugin, RenderContext } from '@lincyaw/kernel'
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 = /^[^.]+\.(vue|[jt]sx?)$/
11
+
12
+ export interface VitePluginsEntry {
13
+ file: string
14
+ config: Record<string, unknown>
15
+ }
16
+
17
+ export interface ClientModules {
18
+ components: Map<string, string>
19
+ plugins: string[]
20
+ viewers: { name: string, file: string }[]
21
+ vite: string[]
22
+ nodeVite: VitePluginsEntry[]
23
+ }
24
+
25
+ function collect(dir: string, name: string, modules: ClientModules, plugin: boolean): void {
26
+ const components = join(dir, 'components')
27
+ if (existsSync(components)) {
28
+ for (const file of readdirSync(components).filter(f => COMPONENT.test(f)).sort()) {
29
+ modules.components.set(file.replace(/\.[^.]+$/, ''), join(components, file))
30
+ }
31
+ }
32
+ if (existsSync(join(dir, CLIENT_PLUGIN))) modules.plugins.push(join(dir, CLIENT_PLUGIN))
33
+ if (existsSync(join(dir, VIEWER_PLUGIN))) modules.viewers.push({ name, file: join(dir, VIEWER_PLUGIN) })
34
+ if (plugin && existsSync(join(dir, VITE_CONFIG))) modules.vite.push(join(dir, VITE_CONFIG))
35
+ }
36
+
37
+ function manifest(dir: string): Record<string, unknown> {
38
+ try {
39
+ const pkg: unknown = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
40
+ return isRecord(pkg) ? pkg : {}
41
+ } catch {
42
+ return {}
43
+ }
44
+ }
45
+
46
+ function nodeEntry(dir: string): string | undefined {
47
+ const { exports } = manifest(dir)
48
+ const entry = isRecord(exports) ? exports['./presmium'] : undefined
49
+ return typeof entry === 'string' ? resolve(dir, entry) : undefined
50
+ }
51
+
52
+ export async function nodeVitePlugins(ctx: RenderContext, presmium: Record<string, unknown>, always = false): Promise<VitePluginsEntry[]> {
53
+ if (ctx.vitePlugins.length === 0 && !always) return []
54
+ const entries: VitePluginsEntry[] = []
55
+ for (const name of ctx.plugins) {
56
+ const dir = ctx.pluginDirs[name]
57
+ const file = dir && nodeEntry(dir)
58
+ if (!file) continue
59
+ const plugin = (await import(pathToFileURL(file).href).catch(() => ({}))).default as PresmiumNodePlugin | undefined
60
+ if (typeof plugin?.vitePlugins !== 'function') continue
61
+ const config = presmium[plugin.id]
62
+ entries.push({ file, config: isRecord(config) ? config : {} })
63
+ }
64
+ return entries
65
+ }
66
+
67
+ export function clientModules(ctx: RenderContext, nodeVite: VitePluginsEntry[]): ClientModules {
68
+ const modules: ClientModules = { components: new Map(), plugins: [], viewers: [], vite: [], nodeVite }
69
+ for (const name of ctx.plugins) {
70
+ const dir = ctx.pluginDirs[name]
71
+ const { name: pkg } = dir ? manifest(dir) : {}
72
+ if (dir) collect(dir, typeof pkg === 'string' ? pkg : name, modules, true)
73
+ }
74
+ collect(ctx.deckDir, 'deck', modules, false)
75
+ return modules
76
+ }
@@ -0,0 +1,130 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
3
+ import { join, sep } from 'node:path'
4
+ import { SERVICE_WORKER, urlPath } from '../domain/index.ts'
5
+ import { MEDIA } from './publish.ts'
6
+
7
+ const MESSAGES = { runtimes: 'presmium:offline-runtimes', save: 'presmium:save-offline' }
8
+
9
+ function source(cache: string, urls: string[], runtimeCache: string, runtimes: string[], runtimeBytes: number, base: string): string {
10
+ return `// Generated by @lincyaw/web.
11
+ const CACHE = ${JSON.stringify(cache)}
12
+ const RUNTIME_CACHE = ${JSON.stringify(runtimeCache)}
13
+ const BASE = ${JSON.stringify(base)}
14
+ const URLS = ${JSON.stringify(urls)}
15
+ const RUNTIMES = ${JSON.stringify(runtimes)}
16
+ const RUNTIME_BYTES = ${runtimeBytes}
17
+ const RUNTIME_PATHS = new Set(RUNTIMES)
18
+
19
+ self.addEventListener('install', (event) => {
20
+ event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(URLS)).then(() => self.skipWaiting()))
21
+ })
22
+
23
+ self.addEventListener('activate', (event) => {
24
+ 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))))
26
+ .then(() => self.clients.claim()))
27
+ })
28
+
29
+ async function cached(request) {
30
+ const url = new URL(request.url)
31
+ url.search = ''
32
+ url.hash = ''
33
+ const path = url.pathname
34
+ const candidates = [url.href]
35
+ if (path.endsWith('/')) candidates.push(url.href + 'index.html')
36
+ else if (!/\\.[^/]+$/.test(path)) candidates.push(url.href + '.html')
37
+ for (const candidate of candidates) {
38
+ const hit = await caches.match(candidate, { cacheName: CACHE })
39
+ if (hit) return hit
40
+ }
41
+ return undefined
42
+ }
43
+
44
+ /** A runtime file from its cache, else from the network, kept for next time. */
45
+ async function runtime(href) {
46
+ const cache = await caches.open(RUNTIME_CACHE)
47
+ const hit = await cache.match(href)
48
+ if (hit) return hit
49
+ const response = await fetch(href)
50
+ if (response.status === 200) await cache.put(href, response.clone())
51
+ return response
52
+ }
53
+
54
+ const runtimeUrls = () => RUNTIMES.map(path => new URL(path, self.location.origin).href)
55
+
56
+ async function saved() {
57
+ const cache = await caches.open(RUNTIME_CACHE)
58
+ const kept = new Set((await cache.keys()).map(request => request.url))
59
+ return runtimeUrls().every(href => kept.has(href))
60
+ }
61
+
62
+ async function save() {
63
+ for (const href of runtimeUrls()) {
64
+ const response = await runtime(href)
65
+ if (response.status !== 200) throw new Error(href + ': ' + response.status)
66
+ }
67
+ }
68
+
69
+ self.addEventListener('message', (event) => {
70
+ const port = event.ports[0]
71
+ const type = event.data && event.data.type
72
+ if (!port) return
73
+ if (type === ${JSON.stringify(MESSAGES.runtimes)}) {
74
+ event.waitUntil(saved().then(done => port.postMessage({ bytes: RUNTIME_BYTES, saved: done })))
75
+ } else if (type === ${JSON.stringify(MESSAGES.save)}) {
76
+ event.waitUntil(save().then(() => port.postMessage({ ok: true }), () => port.postMessage({ ok: false })))
77
+ }
78
+ })
79
+
80
+ self.addEventListener('fetch', (event) => {
81
+ const { request } = event
82
+ const url = new URL(request.url)
83
+ if (request.method !== 'GET' || url.origin !== self.location.origin || !url.pathname.startsWith(BASE)) return
84
+ if (RUNTIME_PATHS.has(url.pathname)) {
85
+ url.search = ''
86
+ url.hash = ''
87
+ event.respondWith(runtime(url.href))
88
+ } else if (url.pathname.startsWith(BASE + 'assets/')) {
89
+ event.respondWith(cached(request).then(hit => hit || fetch(request)))
90
+ } else {
91
+ event.respondWith(fetch(request).catch(() => cached(request).then(hit => hit || Response.error())))
92
+ }
93
+ })
94
+ `
95
+ }
96
+
97
+ function fallbackFonts(dir: string, all: string[]): Set<string> {
98
+ const unused = new Set<string>()
99
+ for (const file of all.filter(f => f.endsWith('.css'))) {
100
+ for (const [, face] of readFileSync(join(dir, file), 'utf8').matchAll(/@font-face\s*\{([^}]*)\}/g)) {
101
+ if (!/woff2/.test(face!)) continue
102
+ for (const [, url] of face!.matchAll(/url\(\s*["']?([^"')]+\.(?:woff|ttf))["']?\s*\)/g)) unused.add(url!.split('/').pop()!)
103
+ }
104
+ }
105
+ return unused
106
+ }
107
+
108
+ function files(dir: string): string[] {
109
+ const all = readdirSync(dir, { recursive: true, encoding: 'utf8' })
110
+ .filter(file => statSync(join(dir, file)).isFile() && file !== SERVICE_WORKER)
111
+ .sort()
112
+ const unused = fallbackFonts(dir, all)
113
+ return all.filter(file => !unused.has(file.split(sep).pop()!))
114
+ }
115
+
116
+ const isRuntime = (file: string) => /^(?:pyodide|wasi|presmium\/jax-js)\//.test(file) && !/(?:^|\/)LICENSES?$/.test(file)
117
+
118
+ export function writeServiceWorker(outDir: string, base: string): void {
119
+ const list = files(outDir).map(file => file.split(sep).join('/'))
120
+ const shell = list.filter(file => !isRuntime(file))
121
+ const runtimes = list.filter(isRuntime)
122
+ const hash = (names: string[]) => {
123
+ const digest = createHash('sha256')
124
+ for (const file of names) digest.update(file).update(readFileSync(join(outDir, file)))
125
+ return digest.digest('hex').slice(0, 12)
126
+ }
127
+ const urls = (names: string[]) => names.map(file => file.split('/')).map(parts => base + (parts[0] === MEDIA ? parts.map(encodeURIComponent).join('/') : urlPath(parts.join('/'))))
128
+ 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))
130
+ }
@@ -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
@@ -0,0 +1,101 @@
1
+ import { statSync } from 'node:fs'
2
+ 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'
6
+ import katex from 'katex'
7
+
8
+ export const MEDIA = 'media'
9
+
10
+ const LINKED = new Set(['image', 'link', 'definition', 'card'])
11
+
12
+ interface Asset extends LocalAsset {
13
+ size: number
14
+ mtimeMs: number
15
+ }
16
+
17
+ const assets = new Map<string, Asset>()
18
+
19
+ function localAsset(ctx: RenderContext, source: SourcePos, url: string): Asset | undefined {
20
+ const key = `${source.file}\0${url}`
21
+ 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
24
+ const found = ctx.localAsset(source, url)
25
+ const stat = found && statSync(found.file, { throwIfNoEntry: false })
26
+ if (!found || !stat) {
27
+ assets.delete(key)
28
+ return undefined
29
+ }
30
+ const asset = { ...found, size: stat.size, mtimeMs: stat.mtimeMs, directory: found.directory ?? stat.isDirectory() }
31
+ if (asset.directory) assets.delete(key)
32
+ else assets.set(key, asset)
33
+ return asset
34
+ }
35
+
36
+ const rendered = new Map<string, string>()
37
+
38
+ function renderMath(tex: string, display: boolean): string {
39
+ const key = `${display ? 'D' : 'I'}${tex}`
40
+ let html = rendered.get(key)
41
+ if (html === undefined) {
42
+ html = katex.renderToString(tex, { displayMode: display, throwOnError: false })
43
+ rendered.set(key, html)
44
+ }
45
+ return html
46
+ }
47
+
48
+ export type DocumentPage = (link: DocumentLink) => string | undefined
49
+
50
+ export function publishedDocument(doc: IRDocument, ctx: RenderContext, publicDir: string, base: string, documentPage?: DocumentPage): IRDocument {
51
+ const { deckDir } = ctx
52
+ const copy = structuredClone(doc)
53
+ const media = join(publicDir, MEDIA)
54
+ const relative = (pos: { file: string }) => {
55
+ pos.file = ctx.portable(pos.file, deckDir) as string
56
+ }
57
+ const publish = (url: string, source: SourcePos): string | undefined => {
58
+ const linked = documentPage && ctx.documentLink?.(source, url)
59
+ if (linked) return documentPage(linked)
60
+ const asset = localAsset(ctx, source, url)
61
+ if (!asset) return undefined
62
+ copyPublished(asset, media)
63
+ return publishedUrl(asset, url, { prefix: `${base}${MEDIA}/` })
64
+ }
65
+ const mdast = (node: MdastNode, source: SourcePos) => {
66
+ if (LINKED.has(node.type) && typeof node.url === 'string') node.url = publish(node.url, source) ?? node.url
67
+ if (node.type === 'html' && typeof node.value === 'string') node.value = mapHtmlUrls(node.value, url => publish(url, source))
68
+ if ((node.type === 'math' || node.type === 'inlineMath') && typeof node.value === 'string') node.html = renderMath(node.value, node.type === 'math')
69
+ node.children?.forEach(child => mdast(child, source))
70
+ }
71
+ const block = (b: IRBlock) => {
72
+ const { node, source } = b
73
+ if (node.type === 'markdown') mdast(node.mdast, source)
74
+ if (node.type === 'component') {
75
+ 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)
79
+ }
80
+ }
81
+ }
82
+ copy.meta.entry = ctx.portable(copy.meta.entry, deckDir) as string
83
+ copy.config.presmium = ctx.portable(copy.config.presmium, deckDir) as IRDocument['config']['presmium']
84
+ for (const section of copy.sections) {
85
+ relative(section.source)
86
+ if (section.config) section.config = ctx.portable(section.config, deckDir) as typeof section.config
87
+ for (const b of irBlocks(section.blocks)) block(b)
88
+ for (const b of irBlocks(section.blocks)) relative(b.source)
89
+ }
90
+ for (const finding of copy.diagnostics) {
91
+ if (finding.source) relative(finding.source)
92
+ }
93
+ return copy
94
+ }
95
+
96
+ export function publishedFile(ctx: RenderContext, publicDir: string, base: string, source: SourcePos, url: string): string | undefined {
97
+ const asset = localAsset(ctx, source, url)
98
+ if (!asset || asset.directory) return undefined
99
+ copyPublished(asset, join(publicDir, MEDIA))
100
+ return publishedUrl(asset, url, { prefix: `${base}${MEDIA}/` })
101
+ }
@@ -0,0 +1,147 @@
1
+ import { rmSync } from 'node:fs'
2
+ 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'
6
+ 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'
19
+
20
+ const BUILD_DIR = '.presmium/web/build'
21
+
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
+ }
47
+ }
48
+
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
56
+ }
57
+
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 } : {},
75
+ })
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
+ 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 })
120
+ },
121
+ close: () => server.close(),
122
+ }
123
+ }
124
+
125
+ export const renderer: Renderer = {
126
+ id: 'web',
127
+ site: true,
128
+ support: () => 'full',
129
+
130
+ async build(doc: IRDocument, ctx: RenderContext) {
131
+ await buildDocument(doc, ctx, join(ctx.deckDir, BUILD_DIR))
132
+ return { outDir: ctx.outDir, skipped: [] }
133
+ },
134
+
135
+ async dev(doc: IRDocument, ctx: RenderContext) {
136
+ const server = await devDocument(doc, ctx)
137
+ const { base } = rendererConfig(ctx)
138
+ return {
139
+ url: `http://localhost:${server.port}${base}`,
140
+ update: next => server.update(next),
141
+ close: () => server.close(),
142
+ }
143
+ },
144
+
145
+ buildSite,
146
+ devSite,
147
+ }