@lincyaw/web 1.0.0-rc.0 → 1.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +78 -0
- package/src/app/site/search.tsx +22 -0
- package/src/app/site.css +1416 -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 +89 -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 +12 -39
- 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 +297 -0
- package/src/node/site-files.ts +87 -0
- package/src/node/site-model.ts +333 -0
- package/src/node/site-page.ts +82 -0
- package/src/node/site-server.ts +817 -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
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { deckLocale, isRecord } from '@lincyaw/kernel'
|
|
2
|
+
import { WEB_MESSAGES } from './messages.ts'
|
|
3
|
+
|
|
4
|
+
export const SITE_THEME_IDS = ['course', 'docs', 'showcase'] as const
|
|
5
|
+
export type SiteThemeId = typeof SITE_THEME_IDS[number]
|
|
6
|
+
export type SitePageLayout = 'article' | 'landing' | 'wide'
|
|
7
|
+
|
|
8
|
+
/** Public presentation options; the content and its stable identities stay in lecture.json. */
|
|
9
|
+
export interface SiteThemeParams {
|
|
10
|
+
accent: 'blue' | 'teal' | 'violet' | 'amber'
|
|
11
|
+
typography: 'reading' | 'sans'
|
|
12
|
+
contentWidth: 'comfortable' | 'wide'
|
|
13
|
+
logo?: string
|
|
14
|
+
logoAlt?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SiteTheme {
|
|
18
|
+
id: SiteThemeId
|
|
19
|
+
params: SiteThemeParams
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const SITE_THEMES: Readonly<Record<SiteThemeId, Readonly<SiteThemeParams>>> = {
|
|
23
|
+
docs: { accent: 'teal', typography: 'reading', contentWidth: 'comfortable' },
|
|
24
|
+
course: { accent: 'teal', typography: 'reading', contentWidth: 'comfortable' },
|
|
25
|
+
showcase: { accent: 'violet', typography: 'sans', contentWidth: 'wide' },
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Resolve and validate the same configuration in builds, dev servers and independent documents. */
|
|
29
|
+
export function siteTheme(config: Record<string, unknown>, lang?: string): SiteTheme {
|
|
30
|
+
const t = WEB_MESSAGES[deckLocale(lang)]
|
|
31
|
+
const id = config.theme ?? 'docs'
|
|
32
|
+
if (!SITE_THEME_IDS.includes(id as SiteThemeId)) throw new Error(t.unknownTheme(String(id)))
|
|
33
|
+
const defaults = SITE_THEMES[id as SiteThemeId]
|
|
34
|
+
if (config.params !== undefined && !isRecord(config.params)) throw new Error(t.invalidSiteParameter('site.params'))
|
|
35
|
+
const input = isRecord(config.params) ? config.params : {}
|
|
36
|
+
const choice = <T extends string>(key: string, values: readonly T[], fallback: T): T => {
|
|
37
|
+
const value = input[key]
|
|
38
|
+
if (value === undefined) return fallback
|
|
39
|
+
if (!values.includes(value as T)) throw new Error(t.invalidSiteParameter(`site.params.${key}`))
|
|
40
|
+
return value as T
|
|
41
|
+
}
|
|
42
|
+
const params: SiteThemeParams = {
|
|
43
|
+
accent: choice('accent', ['blue', 'teal', 'violet', 'amber'], defaults.accent),
|
|
44
|
+
typography: choice('typography', ['reading', 'sans'], defaults.typography),
|
|
45
|
+
contentWidth: choice('contentWidth', ['comfortable', 'wide'], defaults.contentWidth),
|
|
46
|
+
}
|
|
47
|
+
for (const key of ['logo', 'logoAlt'] as const) {
|
|
48
|
+
if (input[key] === undefined) continue
|
|
49
|
+
if (typeof input[key] !== 'string' || key === 'logo' && !input[key].trim()) throw new Error(t.invalidSiteParameter(`site.params.${key}`))
|
|
50
|
+
params[key] = input[key].trim()
|
|
51
|
+
}
|
|
52
|
+
return { id: id as SiteThemeId, params }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function sitePageLayout(config: Record<string, unknown>, lang?: string): SitePageLayout {
|
|
56
|
+
const value = config.layout ?? 'article'
|
|
57
|
+
if (!['article', 'landing', 'wide'].includes(value as string)) throw new Error(WEB_MESSAGES[deckLocale(lang)].invalidSiteParameter('renderers.web.layout'))
|
|
58
|
+
return value as SitePageLayout
|
|
59
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** A document's card on the workspace site's home page. */
|
|
2
|
+
export interface HomeDocument {
|
|
3
|
+
dir: string
|
|
4
|
+
title: string
|
|
5
|
+
lang?: string
|
|
6
|
+
tags?: string[]
|
|
7
|
+
href: string
|
|
8
|
+
description?: string
|
|
9
|
+
pages?: number
|
|
10
|
+
problems?: { errors: number, warnings: number }
|
|
11
|
+
sections?: { id: string, title: string, href: string }[]
|
|
12
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, join, relative } from 'node:path'
|
|
3
|
+
import { isRecord } from '@lincyaw/kernel'
|
|
4
|
+
import type { NodeVitePlugin, RenderContext } from '@lincyaw/kernel'
|
|
5
|
+
import { packageOf } from '@lincyaw/kernel/node'
|
|
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 = /^[^.]+\.([jt]sx?)$/
|
|
11
|
+
|
|
12
|
+
export interface ClientModules {
|
|
13
|
+
restartable: boolean
|
|
14
|
+
components: Map<string, string>
|
|
15
|
+
plugins: string[]
|
|
16
|
+
viewers: { name: string, file: string }[]
|
|
17
|
+
vite: string[]
|
|
18
|
+
nodeVite: NodeVitePlugin[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Adds the browser modules of the package at `dir` to `modules` (a plugin's components do not replace those already there). */
|
|
22
|
+
export function collectBrowserModules(dir: string, name: string, modules: ClientModules, plugin: boolean, manifestDir: string | undefined = dir): void {
|
|
23
|
+
let browser = false
|
|
24
|
+
const components = join(dir, 'components')
|
|
25
|
+
if (existsSync(components)) {
|
|
26
|
+
for (const file of readdirSync(components).filter(f => COMPONENT.test(f)).sort()) {
|
|
27
|
+
browser = true
|
|
28
|
+
const name = file.replace(/\.[^.]+$/, '')
|
|
29
|
+
if (!plugin || !modules.components.has(name)) modules.components.set(name, join(components, file))
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (existsSync(join(dir, CLIENT_PLUGIN))) {
|
|
33
|
+
browser = true
|
|
34
|
+
modules.plugins.push(join(dir, CLIENT_PLUGIN))
|
|
35
|
+
}
|
|
36
|
+
if (existsSync(join(dir, VIEWER_PLUGIN))) {
|
|
37
|
+
browser = true
|
|
38
|
+
modules.viewers.push({ name, file: join(dir, VIEWER_PLUGIN) })
|
|
39
|
+
}
|
|
40
|
+
if (plugin && existsSync(join(dir, VITE_CONFIG))) {
|
|
41
|
+
browser = true
|
|
42
|
+
modules.vite.push(join(dir, VITE_CONFIG))
|
|
43
|
+
}
|
|
44
|
+
if (browser && !isRestartablePackage(manifestDir)) modules.restartable = false
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The directory of the document's own package: its own `package.json`, else the nearest one up to the workspace's root. */
|
|
48
|
+
export function deckPackage(deckDir: string, workspaceRoot: string): string | undefined {
|
|
49
|
+
for (let dir = deckDir; ; dir = dirname(dir)) {
|
|
50
|
+
if (existsSync(join(dir, 'package.json'))) return dir
|
|
51
|
+
if (dir === workspaceRoot || relative(workspaceRoot, dir).startsWith('..') || dirname(dir) === dir) return undefined
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function readManifest(dir: string): Record<string, unknown> {
|
|
56
|
+
try {
|
|
57
|
+
const pkg: unknown = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
|
|
58
|
+
return isRecord(pkg) ? pkg : {}
|
|
59
|
+
} catch {
|
|
60
|
+
return {}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function isRestartablePackage(dir: string | undefined): boolean {
|
|
65
|
+
if (!dir) return false
|
|
66
|
+
const { presmium } = readManifest(dir)
|
|
67
|
+
return isRecord(presmium) && isRecord(presmium.preview) && presmium.preview.restartable === true
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** No browser modules yet. */
|
|
71
|
+
export function emptyClientModules(nodeVite: NodeVitePlugin[] = []): ClientModules {
|
|
72
|
+
return { restartable: true, components: new Map(), plugins: [], viewers: [], vite: [], nodeVite }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The browser modules of the document and its plugins: components, browser and viewer plugins, Vite configurations. */
|
|
76
|
+
export function clientModules(ctx: RenderContext, nodeVite: NodeVitePlugin[]): ClientModules {
|
|
77
|
+
const modules = emptyClientModules(nodeVite)
|
|
78
|
+
for (const { entry } of nodeVite) {
|
|
79
|
+
try {
|
|
80
|
+
if (!isRestartablePackage(packageOf(entry))) modules.restartable = false
|
|
81
|
+
} catch {
|
|
82
|
+
modules.restartable = false
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
for (const name of ctx.plugins) {
|
|
86
|
+
const dir = ctx.pluginDirs[name]
|
|
87
|
+
const { name: pkg } = dir ? readManifest(dir) : {}
|
|
88
|
+
if (dir) collectBrowserModules(dir, typeof pkg === 'string' ? pkg : name, modules, true)
|
|
89
|
+
}
|
|
90
|
+
collectBrowserModules(ctx.deckDir, 'deck', modules, false, deckPackage(ctx.deckDir, ctx.workspaceRoot))
|
|
91
|
+
return modules
|
|
92
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { join, resolve, sep } from 'node:path'
|
|
4
|
+
import { pathToFileURL } from 'node:url'
|
|
5
|
+
import { irBlocks } from '@lincyaw/kernel'
|
|
6
|
+
import type { IRDocument, PresmiumNodePlugin } from '@lincyaw/kernel'
|
|
7
|
+
import { collectBrowserModules, deckPackage, emptyClientModules, isRestartablePackage } from './client-modules.ts'
|
|
8
|
+
import { quietDirectives } from './viewer-app.ts'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A document's own browser code, apart from the viewer app that shows it: its components, browser and viewer plugins,
|
|
12
|
+
* and the document modules its plugins generate for it (`PresmiumNodePlugin.documentModules`), as one small bundle that
|
|
13
|
+
* imports React, Presmium's packages and the plugins' public subpaths by name, from the app, through its import map.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const BUNDLE_MANIFEST = 'bundle.json'
|
|
17
|
+
export const BUNDLE_ENTRY = 'document.js'
|
|
18
|
+
const DOCUMENT_MODULE = '\0presmium-document-bundle:'
|
|
19
|
+
const KEEP = 3
|
|
20
|
+
|
|
21
|
+
export interface DocumentBundleInput {
|
|
22
|
+
deckDir: string
|
|
23
|
+
workspaceRoot: string
|
|
24
|
+
/** Where the document's bundles are kept, each in a directory by its key. */
|
|
25
|
+
outDir: string
|
|
26
|
+
/** The specifiers the app's import map names: never bundled. */
|
|
27
|
+
externals: string[]
|
|
28
|
+
/** The Node entries of the document's plugins, with their configuration, for their document modules. */
|
|
29
|
+
nodePlugins: { entry: string, config: Record<string, unknown> }[]
|
|
30
|
+
/** Canonical file implementations, registered by their derived runtime names. */
|
|
31
|
+
components?: DocumentComponent[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DocumentComponent { name: string, file: string, exportName?: string }
|
|
35
|
+
|
|
36
|
+
/** Own React components are selected by the compiled definition, regardless of their directory. */
|
|
37
|
+
export function documentComponents(doc: IRDocument | undefined): DocumentComponent[] {
|
|
38
|
+
const found = new Map<string, DocumentComponent>()
|
|
39
|
+
for (const section of doc?.sections ?? []) for (const block of irBlocks(section.blocks)) {
|
|
40
|
+
const node = block.node
|
|
41
|
+
if (node.type !== 'component' || node.plugin !== 'deck' || !node.definition) continue
|
|
42
|
+
const component = { name: node.name, file: node.definition.file, ...node.definition.exportName !== undefined ? { exportName: node.definition.exportName } : {} }
|
|
43
|
+
const known = found.get(component.name)
|
|
44
|
+
if (known && (known.file !== component.file || known.exportName !== component.exportName)) throw new Error(`Document component ${component.name} names multiple implementations.`)
|
|
45
|
+
found.set(component.name, component)
|
|
46
|
+
}
|
|
47
|
+
return [...found.values()]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface DocumentBundle {
|
|
51
|
+
key: string
|
|
52
|
+
dir: string
|
|
53
|
+
/** The files the bundle was built from, by path, with their size and time then. */
|
|
54
|
+
inputs: Record<string, string>
|
|
55
|
+
restartable: boolean
|
|
56
|
+
/** The names of the document's own components. */
|
|
57
|
+
components: string[]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface DocumentCode {
|
|
61
|
+
key: string
|
|
62
|
+
entry: string
|
|
63
|
+
components: string[]
|
|
64
|
+
/** The document modules' sources, by id. */
|
|
65
|
+
generated: Record<string, string>
|
|
66
|
+
restartable: boolean
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const stamp = (file: string) => {
|
|
70
|
+
try {
|
|
71
|
+
const { size, mtimeMs } = statSync(file)
|
|
72
|
+
return `${size}:${Math.trunc(mtimeMs)}`
|
|
73
|
+
} catch {
|
|
74
|
+
return 'missing'
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The entry of the document's bundle and the key of what it is made of, or none when the document has no code of its own. */
|
|
79
|
+
async function documentCode(input: DocumentBundleInput): Promise<DocumentCode | undefined> {
|
|
80
|
+
const modules = emptyClientModules()
|
|
81
|
+
collectBrowserModules(input.deckDir, 'deck', modules, false, deckPackage(input.deckDir, input.workspaceRoot))
|
|
82
|
+
const components = new Map([...modules.components].map(([name, file]) => [name, { file, exportName: 'default' }]))
|
|
83
|
+
if (input.components?.length && !isRestartablePackage(deckPackage(input.deckDir, input.workspaceRoot))) modules.restartable = false
|
|
84
|
+
for (const component of input.components ?? []) components.set(component.name, { file: resolve(input.deckDir, component.file), exportName: component.exportName ?? 'default' })
|
|
85
|
+
const generated: Record<string, string> = {}
|
|
86
|
+
for (const { entry, config } of input.nodePlugins) {
|
|
87
|
+
const plugin = (await import(pathToFileURL(entry).href) as { default?: PresmiumNodePlugin }).default
|
|
88
|
+
Object.assign(generated, plugin?.documentModules?.({ deckDir: input.deckDir, workspaceRoot: input.workspaceRoot, config }) ?? {})
|
|
89
|
+
}
|
|
90
|
+
if (components.size + modules.plugins.length + modules.viewers.length + Object.keys(generated).length === 0) return undefined
|
|
91
|
+
const load = (file: string) => `() => import(${JSON.stringify(file)})`
|
|
92
|
+
const ids = Object.keys(generated)
|
|
93
|
+
const entry = `${ids.map((id, i) => `import module${i} from ${JSON.stringify(id)}\n`).join('')}
|
|
94
|
+
export const components = {
|
|
95
|
+
${[...components].map(([name, { file, exportName }]) => ` ${JSON.stringify(name)}: () => import(${JSON.stringify(file)}).then(module => { if (!module[${JSON.stringify(exportName)}]) throw new Error(${JSON.stringify(`Document component ${name} has no export ${exportName}.`)}); return { default: module[${JSON.stringify(exportName)}] } }),`).join('\n')}
|
|
96
|
+
}
|
|
97
|
+
export const plugins = [${modules.plugins.map(load).join(', ')}]
|
|
98
|
+
export const viewers = [${modules.viewers.map(({ name, file }) => `{ name: ${JSON.stringify(name)}, load: ${load(file)} }`).join(', ')}]
|
|
99
|
+
export const documentModules = {
|
|
100
|
+
${ids.map((id, i) => ` ${JSON.stringify(id)}: module${i},`).join('\n')}
|
|
101
|
+
}
|
|
102
|
+
export const restartable = ${modules.restartable}
|
|
103
|
+
`
|
|
104
|
+
const key = createHash('sha256').update(JSON.stringify([input.externals.toSorted(), entry, generated])).digest('hex').slice(0, 16)
|
|
105
|
+
return { key, entry, generated, restartable: modules.restartable, components: [...components.keys()] }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function readBundle(dir: string): DocumentBundle | undefined {
|
|
109
|
+
try {
|
|
110
|
+
const bundle = JSON.parse(readFileSync(join(dir, BUNDLE_MANIFEST), 'utf8')) as DocumentBundle
|
|
111
|
+
return Object.entries(bundle.inputs).every(([file, known]) => stamp(file) === known) ? { ...bundle, components: bundle.components ?? [], dir } : undefined
|
|
112
|
+
} catch {
|
|
113
|
+
return undefined
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The newest bundle of the document's code (`key`) built from its files as they are now. */
|
|
118
|
+
function freshBundle(outDir: string, key: string): DocumentBundle | undefined {
|
|
119
|
+
let entries: string[]
|
|
120
|
+
try {
|
|
121
|
+
entries = readdirSync(outDir).filter(entry => entry.startsWith(`${key}-`))
|
|
122
|
+
} catch {
|
|
123
|
+
return undefined
|
|
124
|
+
}
|
|
125
|
+
const builds = entries.map(entry => ({ dir: join(outDir, entry), time: statSync(join(outDir, entry)).mtimeMs })).sort((a, b) => b.time - a.time)
|
|
126
|
+
for (const { dir } of builds) {
|
|
127
|
+
const bundle = readBundle(dir)
|
|
128
|
+
if (bundle) return bundle
|
|
129
|
+
}
|
|
130
|
+
return undefined
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The document's bundle as its files are now, if one was built from them. */
|
|
134
|
+
export async function findDocumentBundle(input: DocumentBundleInput): Promise<DocumentBundle | null | undefined> {
|
|
135
|
+
const code = await documentCode(input)
|
|
136
|
+
if (!code) return null
|
|
137
|
+
return freshBundle(input.outDir, code.key)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Builds the document's bundle (unless it is there already); none when the document has no code of its own. Its
|
|
142
|
+
* directory is named by its code and its content, so that its files never change under their address.
|
|
143
|
+
*/
|
|
144
|
+
export async function buildDocumentBundle(input: DocumentBundleInput): Promise<DocumentBundle | null> {
|
|
145
|
+
const code = await documentCode(input)
|
|
146
|
+
if (!code) return null
|
|
147
|
+
const known = freshBundle(input.outDir, code.key)
|
|
148
|
+
if (known) return known
|
|
149
|
+
const work = join(input.outDir, `.build-${code.key}-${process.pid}`)
|
|
150
|
+
rmSync(work, { recursive: true, force: true })
|
|
151
|
+
mkdirSync(work, { recursive: true })
|
|
152
|
+
// Vite resolves symlinks, including macOS's /var -> /private/var temporary paths.
|
|
153
|
+
const realWork = realpathSync(work)
|
|
154
|
+
writeFileSync(join(work, 'entry.ts'), code.entry)
|
|
155
|
+
const { generated } = code
|
|
156
|
+
const externals = new Set(input.externals)
|
|
157
|
+
const inputs: Record<string, string> = {}
|
|
158
|
+
const { build } = await import('vite')
|
|
159
|
+
await build({
|
|
160
|
+
configFile: false,
|
|
161
|
+
root: input.deckDir,
|
|
162
|
+
base: './',
|
|
163
|
+
logLevel: 'warn',
|
|
164
|
+
mode: 'production',
|
|
165
|
+
esbuild: { jsx: 'automatic' },
|
|
166
|
+
publicDir: false,
|
|
167
|
+
plugins: [
|
|
168
|
+
{
|
|
169
|
+
name: 'presmium:document-bundle',
|
|
170
|
+
// Before Vite's own resolution: the app's modules stay imports by name.
|
|
171
|
+
enforce: 'pre' as const,
|
|
172
|
+
resolveId(id: string) {
|
|
173
|
+
// Headmatter aliases already load the renamed plugins. Older document code must import the same shared
|
|
174
|
+
// modules too, rather than resolving the removed packages or bundling a second copy of their runtime.
|
|
175
|
+
const shared = externals.has(id) ? id : id.replace(/^@lincyaw\/slidev-addon-/, '@lincyaw/addon-')
|
|
176
|
+
if (externals.has(shared)) return { id: shared, external: true }
|
|
177
|
+
return generated[id] === undefined ? undefined : `${DOCUMENT_MODULE}${id}`
|
|
178
|
+
},
|
|
179
|
+
load(id: string) {
|
|
180
|
+
return id.startsWith(DOCUMENT_MODULE) ? generated[id.slice(DOCUMENT_MODULE.length)] : undefined
|
|
181
|
+
},
|
|
182
|
+
generateBundle(_: unknown, bundle: Record<string, { type: string, moduleIds?: string[] }>) {
|
|
183
|
+
for (const chunk of Object.values(bundle)) {
|
|
184
|
+
for (const id of chunk.moduleIds ?? []) {
|
|
185
|
+
const file = id.replace(/[?#].*$/s, '')
|
|
186
|
+
if (file.startsWith('\0') || !existsSync(file)) continue
|
|
187
|
+
const realFile = realpathSync(file)
|
|
188
|
+
if (!realFile.startsWith(realWork + sep) && !realFile.split(sep).includes('node_modules')) inputs[realFile] = stamp(realFile)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
onLog: quietDirectives,
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
resolve: { dedupe: ['react', 'react-dom'] },
|
|
196
|
+
build: {
|
|
197
|
+
outDir: join(work, 'out'),
|
|
198
|
+
emptyOutDir: true,
|
|
199
|
+
target: 'es2022',
|
|
200
|
+
reportCompressedSize: false,
|
|
201
|
+
chunkSizeWarningLimit: 100_000,
|
|
202
|
+
copyPublicDir: false,
|
|
203
|
+
rollupOptions: {
|
|
204
|
+
input: { document: join(work, 'entry.ts') },
|
|
205
|
+
preserveEntrySignatures: 'strict',
|
|
206
|
+
output: {
|
|
207
|
+
format: 'es',
|
|
208
|
+
entryFileNames: BUNDLE_ENTRY,
|
|
209
|
+
chunkFileNames: 'chunks/[name]-[hash].js',
|
|
210
|
+
assetFileNames: 'assets/[name]-[hash][extname]',
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
})
|
|
215
|
+
const content = createHash('sha256').update(readFileSync(join(work, 'out', BUNDLE_ENTRY))).digest('hex').slice(0, 8)
|
|
216
|
+
const name = `${code.key}-${content}`
|
|
217
|
+
const out = join(input.outDir, name)
|
|
218
|
+
const bundle: DocumentBundle = { key: name, dir: out, inputs, restartable: code.restartable, components: code.components }
|
|
219
|
+
writeFileSync(join(work, 'out', BUNDLE_MANIFEST), `${JSON.stringify(bundle, null, 2)}\n`)
|
|
220
|
+
rmSync(out, { recursive: true, force: true })
|
|
221
|
+
renameSync(join(work, 'out'), out)
|
|
222
|
+
rmSync(work, { recursive: true, force: true })
|
|
223
|
+
// The document's last few bundles stay, for pages still loading an earlier one.
|
|
224
|
+
const builds = readdirSync(input.outDir).filter(entry => !entry.startsWith('.')).map(entry => ({ dir: join(input.outDir, entry), time: statSync(join(input.outDir, entry)).mtimeMs })).sort((a, b) => b.time - a.time)
|
|
225
|
+
for (const old of builds.slice(KEEP)) rmSync(old.dir, { recursive: true, force: true })
|
|
226
|
+
return bundle
|
|
227
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { basename, dirname, join } from 'node:path'
|
|
3
|
+
import { mapHtmlUrls } from '@lincyaw/kernel'
|
|
4
|
+
import { build } from 'vite'
|
|
5
|
+
import { SITE_CONTENT_ELEMENT } from '../domain/index.ts'
|
|
6
|
+
import type { SiteContent } from '../domain/index.ts'
|
|
7
|
+
|
|
8
|
+
/** A website template is an ordinary Vite HTML entry, with its own CSS and modules. */
|
|
9
|
+
export async function buildLandingTemplate(file: string, workDir: string, base: string, content: SiteContent, workspaceRoot: string): Promise<{ html: string, dir: string }> {
|
|
10
|
+
const dir = join(workDir, 'template')
|
|
11
|
+
const data = JSON.stringify(content).replace(/</g, '\\u003c')
|
|
12
|
+
await build({
|
|
13
|
+
configFile: false,
|
|
14
|
+
root: dirname(file),
|
|
15
|
+
base,
|
|
16
|
+
publicDir: join(workspaceRoot, 'public'),
|
|
17
|
+
logLevel: 'silent',
|
|
18
|
+
plugins: [{
|
|
19
|
+
name: 'presmium-site-content',
|
|
20
|
+
transformIndexHtml: { order: 'pre', handler: () => [{ tag: 'script', attrs: { id: SITE_CONTENT_ELEMENT, type: 'application/json' }, children: data, injectTo: 'head' }] },
|
|
21
|
+
}],
|
|
22
|
+
build: { outDir: dir, emptyOutDir: true, copyPublicDir: false, assetsDir: '_landing/assets', rollupOptions: { input: file } },
|
|
23
|
+
})
|
|
24
|
+
const html = mapHtmlUrls(readFileSync(join(dir, basename(file)), 'utf8'), url => url.startsWith('/') && !url.startsWith('//') && !url.startsWith(base) ? `${base}${url.slice(1)}` : undefined)
|
|
25
|
+
return { html, dir }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function writeLandingTemplate(output: { html: string, dir: string }, outDir: string, prefix = ''): void {
|
|
29
|
+
// Only emitted assets are shared; the entry itself is mounted as the site's index.html.
|
|
30
|
+
if (existsSync(join(output.dir, '_landing'))) cpSync(join(output.dir, '_landing'), join(outDir, '_landing'), { recursive: true, force: true })
|
|
31
|
+
const index = join(outDir, prefix, 'index.html')
|
|
32
|
+
mkdirSync(dirname(index), { recursive: true })
|
|
33
|
+
writeFileSync(index, output.html)
|
|
34
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
|
|
2
|
+
import { basename, dirname, extname, join, resolve } from 'node:path'
|
|
3
|
+
import { ABSOLUTE_URL, deckLocale, DOCUMENT_ENTRY, isRecord, safeDecodeURIComponent } from '@lincyaw/kernel'
|
|
4
|
+
import type { IRDocument, RenderContext, SiteSpec } from '@lincyaw/kernel'
|
|
5
|
+
import { isInside } from '@lincyaw/kernel/node'
|
|
6
|
+
import { parseLandingPage, WEB_MESSAGES } from '../domain/index.ts'
|
|
7
|
+
import type { HomeDocument, SiteContent, SiteLandingPage } from '../domain/index.ts'
|
|
8
|
+
import { publishedFile } from './publish.ts'
|
|
9
|
+
import { documentLinkOf, documentPage, siteLanguage, siteTitle } from './site-model.ts'
|
|
10
|
+
|
|
11
|
+
export type IndependentLanding = { kind: 'page', page: SiteLandingPage, source: string } | { kind: 'template', file: string }
|
|
12
|
+
|
|
13
|
+
/** Resolve website pages from their own source, independently of the document compiler. */
|
|
14
|
+
export function loadLanding(site: SiteSpec, ctx: RenderContext): IndependentLanding | undefined {
|
|
15
|
+
const input = site.config.landing
|
|
16
|
+
if (input === undefined || typeof input === 'string') return undefined
|
|
17
|
+
const fail = (key: string): never => { throw new Error(WEB_MESSAGES[deckLocale(siteLanguage(site))].invalidSiteParameter(key)) }
|
|
18
|
+
if (!isRecord(input) || site.home !== undefined) return fail('site.landing')
|
|
19
|
+
const file = (value: unknown, key: string, extension: string) => {
|
|
20
|
+
if (typeof value !== 'string' || !value.trim()) return fail(key)
|
|
21
|
+
const path = resolve(ctx.workspaceRoot, value)
|
|
22
|
+
if (!existsSync(path) || !statSync(path).isFile() || extname(path).toLowerCase() !== extension || !isInside(realpathSync(path), realpathSync(ctx.workspaceRoot))) return fail(key)
|
|
23
|
+
return path
|
|
24
|
+
}
|
|
25
|
+
if ('source' in input && 'template' in input) return fail('site.landing')
|
|
26
|
+
if ('template' in input) return { kind: 'template', file: file(input.template, 'site.landing.template', '.html') }
|
|
27
|
+
if ('source' in input) {
|
|
28
|
+
const source = file(input.source, 'site.landing.source', '.json')
|
|
29
|
+
let value: unknown
|
|
30
|
+
try { value = JSON.parse(readFileSync(source, 'utf8')) } catch { return fail('site.landing.source') }
|
|
31
|
+
return { kind: 'page', page: parseLandingPage(value, siteLanguage(site)), source }
|
|
32
|
+
}
|
|
33
|
+
return { kind: 'page', page: parseLandingPage(input, siteLanguage(site)), source: join(ctx.workspaceRoot, 'presmium.workspace.yaml') }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Local media belongs to the page source; site-absolute navigation belongs to the deployment base. */
|
|
37
|
+
export function publishLanding(landing: Extract<IndependentLanding, { kind: 'page' }>, site: SiteSpec, ctx: RenderContext, outDir = ctx.outDir, base = ctx.base, docs: ReadonlyMap<string, IRDocument> = new Map(site.documents.flatMap(entry => entry.doc ? [[entry.dir, entry.doc] as const] : []))): SiteLandingPage {
|
|
38
|
+
const page = structuredClone(landing.page)
|
|
39
|
+
const source = { file: landing.source, line: 1 }
|
|
40
|
+
const publish = (value: string, link = false): string => {
|
|
41
|
+
if (ABSOLUTE_URL.test(value) || value.startsWith('#')) return value
|
|
42
|
+
if (value.startsWith('/')) return `${ctx.base}${value.slice(1)}`
|
|
43
|
+
if (link) {
|
|
44
|
+
const reference = documentLinkOf(ctx)(source, value)
|
|
45
|
+
if (reference) {
|
|
46
|
+
const href = documentPage(site, ctx.base, docs)(reference)
|
|
47
|
+
if (href) return href
|
|
48
|
+
throw new Error(WEB_MESSAGES[deckLocale(siteLanguage(site))].invalidSiteParameter(`site.landing: ${value}`))
|
|
49
|
+
}
|
|
50
|
+
const path = value.replace(/[?#].*$/s, '')
|
|
51
|
+
if (!extname(path)) return `${ctx.base}${value.replace(/^\.\//, '')}`
|
|
52
|
+
}
|
|
53
|
+
const file = resolve(dirname(landing.source), safeDecodeURIComponent(value.replace(/[?#].*$/s, '')))
|
|
54
|
+
if (existsSync(file) && basename(realpathSync(file)) === DOCUMENT_ENTRY) throw new Error(WEB_MESSAGES[deckLocale(siteLanguage(site))].invalidSiteParameter(`site.landing: ${value}`))
|
|
55
|
+
const result = publishedFile(ctx, outDir, base, source, value)
|
|
56
|
+
if (!result) throw new Error(WEB_MESSAGES[deckLocale(siteLanguage(site))].invalidSiteParameter(`site.landing: ${value}`))
|
|
57
|
+
return result
|
|
58
|
+
}
|
|
59
|
+
const actions = (items: { link: string }[] | undefined) => items?.forEach(action => { action.link = publish(action.link, true) })
|
|
60
|
+
if (page.hero) {
|
|
61
|
+
if (page.hero.image) page.hero.image = publish(page.hero.image)
|
|
62
|
+
actions(page.hero.actions)
|
|
63
|
+
}
|
|
64
|
+
for (const section of page.sections) {
|
|
65
|
+
if (section.type === 'features') for (const item of section.items) {
|
|
66
|
+
if (item.image) item.image = publish(item.image)
|
|
67
|
+
if (item.link) item.link = publish(item.link, true)
|
|
68
|
+
}
|
|
69
|
+
if (section.type === 'media') {
|
|
70
|
+
section.src = publish(section.src)
|
|
71
|
+
if (section.poster) section.poster = publish(section.poster)
|
|
72
|
+
if (section.captions) section.captions = Object.fromEntries(Object.entries(section.captions).map(([lang, url]) => [lang, publish(url)]))
|
|
73
|
+
}
|
|
74
|
+
if (section.type === 'cta') actions(section.actions)
|
|
75
|
+
}
|
|
76
|
+
return page
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The public content collection available to a free-form HTML template. */
|
|
80
|
+
export function siteContent(site: SiteSpec, base: string, lang: string, documents: HomeDocument[]): SiteContent {
|
|
81
|
+
return { schemaVersion: 1, title: siteTitle(site, lang), base, lang, documents: documents.map(({ dir, title, href, description, lang, tags, pages }) => ({ id: dir, title, href, description, lang, tags, pages })) }
|
|
82
|
+
}
|
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
|
}
|