@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.
- package/LICENSE +202 -0
- package/README.md +24 -2
- package/custom-nav-controls.vue +17 -0
- package/package.json +56 -3
- package/src/client/env.d.ts +1 -0
- package/src/client/home.ts +182 -0
- package/src/client/host.ts +94 -0
- package/src/client/index.ts +4 -0
- package/src/client/page.ts +38 -0
- package/src/client/preview.ts +71 -0
- package/src/client/style.css +427 -0
- package/src/client/theme.ts +322 -0
- package/src/domain/config.ts +99 -0
- package/src/domain/index.ts +7 -0
- package/src/domain/links.ts +62 -0
- package/src/domain/messages.ts +105 -0
- package/src/domain/pages.ts +108 -0
- package/src/node/addons.ts +76 -0
- package/src/node/offline.ts +130 -0
- package/src/node/presmium.ts +10 -0
- package/src/node/publish.ts +101 -0
- package/src/node/renderer.ts +147 -0
- package/src/node/site.ts +349 -0
- package/src/node/sites.ts +531 -0
- package/src/node/vite.ts +166 -0
- package/src/node/vitepress.ts +72 -0
|
@@ -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
|
+
}
|