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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +33 -4
  2. package/package.json +20 -19
  3. package/src/app/base.css +117 -0
  4. package/src/app/block-playback.ts +81 -0
  5. package/src/app/block-report.ts +104 -0
  6. package/src/app/document-modules.ts +30 -0
  7. package/src/app/env.d.ts +12 -0
  8. package/src/app/main.ts +198 -0
  9. package/src/app/page-app.ts +166 -0
  10. package/src/app/router.ts +109 -0
  11. package/src/app/server.ts +32 -0
  12. package/src/app/site/home.tsx +117 -0
  13. package/src/app/site/layout.tsx +92 -0
  14. package/src/app/site/search.tsx +22 -0
  15. package/src/app/site.css +1450 -0
  16. package/src/client/document.css +172 -0
  17. package/src/client/host.ts +22 -94
  18. package/src/client/preview-frame.ts +152 -0
  19. package/src/client/runtime.ts +159 -0
  20. package/src/domain/app-config.ts +91 -0
  21. package/src/domain/config.ts +17 -9
  22. package/src/domain/dev-updates.ts +12 -0
  23. package/src/domain/index.ts +17 -7
  24. package/src/domain/landing.ts +83 -0
  25. package/src/domain/links.ts +23 -18
  26. package/src/domain/messages.ts +74 -63
  27. package/src/domain/pages.ts +23 -41
  28. package/src/domain/themes.ts +59 -0
  29. package/src/domain/workspace-home.ts +12 -0
  30. package/src/node/client-modules.ts +92 -0
  31. package/src/node/document-bundle.ts +227 -0
  32. package/src/node/landing-template.ts +34 -0
  33. package/src/node/landing.ts +82 -0
  34. package/src/node/offline.ts +34 -11
  35. package/src/node/publish.ts +83 -15
  36. package/src/node/renderer.ts +78 -117
  37. package/src/node/site-assets.ts +17 -0
  38. package/src/node/site-build.ts +304 -0
  39. package/src/node/site-files.ts +87 -0
  40. package/src/node/site-model.ts +337 -0
  41. package/src/node/site-page.ts +82 -0
  42. package/src/node/site-server.ts +822 -0
  43. package/src/node/slides.ts +135 -0
  44. package/src/node/viewer-app.ts +504 -0
  45. package/src/node/viewer-build.ts +38 -0
  46. package/custom-nav-controls.vue +0 -17
  47. package/src/client/env.d.ts +0 -1
  48. package/src/client/home.ts +0 -182
  49. package/src/client/index.ts +0 -4
  50. package/src/client/page.ts +0 -38
  51. package/src/client/preview.ts +0 -71
  52. package/src/client/style.css +0 -427
  53. package/src/client/theme.ts +0 -322
  54. package/src/node/addons.ts +0 -76
  55. package/src/node/site.ts +0 -349
  56. package/src/node/sites.ts +0 -531
  57. package/src/node/vite.ts +0 -166
  58. package/src/node/vitepress.ts +0 -72
package/src/node/site.ts DELETED
@@ -1,349 +0,0 @@
1
- import { existsSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs'
2
- import { createRequire } from 'node:module'
3
- import { dirname, join, sep } from 'node:path'
4
- import { fileURLToPath } from 'node:url'
5
- import { deckLocale, previewFrameAncestors } from '@lincyaw/kernel'
6
- import type { IRDocument } from '@lincyaw/kernel'
7
- import { FONT_LICENSE, fontLicense, optimizeDeps, packageOf } from '@lincyaw/kernel/node'
8
- import { formOptions, neighbour, pageMarkdown, searchDocuments, SERVER_PROXY, sidebar, SITE_LABELS, toPages, translationPages } from '../domain/index.ts'
9
- import type { FormOptions, NavItem, Neighbour, SearchDocument, SiteShell, WebPage, WebRendererConfig } from '../domain/index.ts'
10
- import type { ClientModules } from './addons.ts'
11
-
12
- const CLIENT_ENTRY = fileURLToPath(new URL('../client/index.ts', import.meta.url))
13
- const PREVIEW_AGENT = fileURLToPath(new URL('../client/preview.ts', import.meta.url))
14
- const SEARCH = createRequire(import.meta.url).resolve('@lincyaw/kernel/reading')
15
- const VITE_PLUGIN = fileURLToPath(new URL('./vite.ts', import.meta.url))
16
- const GENERATED = '// Generated by @lincyaw/web from the IR; do not edit.'
17
- const WEB_DIR = fileURLToPath(new URL('../..', import.meta.url))
18
-
19
- export function siteFiles(workDir: string) {
20
- return {
21
- root: join(workDir, 'site'),
22
- ir: join(workDir, 'ir.json'),
23
- searchDocuments: join(workDir, 'search-documents.json'),
24
- site: join(workDir, 'site.json'),
25
- publicDir: join(workDir, 'site', 'public'),
26
- }
27
- }
28
-
29
- export const SRC_EXCLUDE = ['public/**']
30
-
31
- export function presmiumRoot(): string {
32
- const own = realpathSync(WEB_DIR)
33
- let outermost: string | undefined
34
- for (let dir = own; dirname(dir) !== dir; dir = dirname(dir)) {
35
- if (existsSync(join(dir, 'pnpm-workspace.yaml'))) return dir
36
- if (dir.split(sep).at(-1) === 'node_modules') outermost = dirname(dir)
37
- }
38
- return outermost ?? own
39
- }
40
-
41
- export function allowed(...dirs: (string | undefined)[]): string[] {
42
- return [...new Set(dirs.filter((dir): dir is string => Boolean(dir)).map(dir => (existsSync(dir) ? realpathSync(dir) : dir)))]
43
- }
44
-
45
- export function write(file: string, content: string | Buffer): void {
46
- if (existsSync(file) && readFileSync(file).equals(Buffer.from(content))) return
47
- mkdirSync(dirname(file), { recursive: true })
48
- writeFileSync(file, content)
49
- }
50
-
51
- function copyDir(from: string, to: string): void {
52
- for (const file of readdirSync(from, { recursive: true, encoding: 'utf8' })) {
53
- const source = join(from, file)
54
- const stat = statSync(source)
55
- if (!stat.isFile()) continue
56
- const target = join(to, file)
57
- const copied = existsSync(target) ? statSync(target) : undefined
58
- if (copied && copied.size === stat.size && Math.abs(copied.mtimeMs - stat.mtimeMs) < 1) continue
59
- mkdirSync(dirname(target), { recursive: true })
60
- writeFileSync(target, readFileSync(source))
61
- utimesSync(target, stat.atime, stat.mtime)
62
- }
63
- }
64
-
65
- export function linkDependencies(root: string): void {
66
- const require = createRequire(import.meta.url)
67
- for (const name of ['vitepress', 'vue']) {
68
- const target = dirname(require.resolve(`${name}/package.json`))
69
- const link = join(root, 'node_modules', name)
70
- if (existsSync(link) && readlinkSync(link) === target) continue
71
- rmSync(link, { force: true })
72
- mkdirSync(dirname(link), { recursive: true })
73
- symlinkSync(target, link, 'junction')
74
- }
75
- }
76
-
77
- export function browserPackages(modules: ClientModules): string[] {
78
- const files = [...modules.plugins, ...modules.viewers.map(({ file }) => file), ...modules.components.values()]
79
- return [...new Set([WEB_DIR, ...files.flatMap(file => packageOf(file) ?? [])])]
80
- }
81
-
82
- export function languageLabel(lang: string): string {
83
- const primary = lang.split('-')[0]!
84
- try {
85
- return new Intl.DisplayNames([lang], { type: 'language' }).of(primary) ?? lang
86
- } catch {
87
- return lang
88
- }
89
- }
90
-
91
- export interface SiteLocale {
92
- lang: string
93
- label: string
94
- title: string
95
- sidebar: ReturnType<typeof sidebar>
96
- labels: (typeof SITE_LABELS)[keyof typeof SITE_LABELS]
97
- nav?: NavItem[]
98
- }
99
-
100
- export interface SiteData {
101
- title: string
102
- sidebar: ReturnType<typeof sidebar>
103
- form: FormOptions
104
- label: string
105
- locales: SiteLocale[]
106
- }
107
-
108
- interface Translation {
109
- lang: string
110
- doc: IRDocument
111
- pages: WebPage[]
112
- }
113
-
114
- function siteData(doc: IRDocument, options: WebRendererConfig, translations: Translation[], workspace?: WorkspaceOptions): SiteData {
115
- const form = formOptions(doc.config.presmium, doc.config.renderers)
116
- return {
117
- title: doc.meta.title,
118
- sidebar: sidebar(doc, toPages(doc)),
119
- form: workspace ? { ...form, slides: Boolean(form.slidesUrl) } : form,
120
- label: languageLabel(options.lang),
121
- locales: translations.map(({ lang, doc: translated, pages }) => ({
122
- lang,
123
- label: languageLabel(lang),
124
- title: translated.meta.title,
125
- sidebar: sidebar(translated, pages, `/${lang}`),
126
- labels: SITE_LABELS[deckLocale(lang)],
127
- ...options.shell.nav && workspace ? { nav: relativeNav(options.shell.nav, depthOf(workspace.document) + 1) } : {},
128
- })),
129
- }
130
- }
131
-
132
- export interface WorkspaceOptions {
133
- document: string
134
- home: string
135
- endpoints: { ir: string, source: string }
136
- served?: boolean
137
- allow?: string[]
138
- neighbours?: { prev?: Neighbour, next?: Neighbour }
139
- search?: SearchDocument[]
140
- }
141
-
142
- export interface DeckPaths {
143
- deckDir: string
144
- workspaceRoot: string
145
- }
146
-
147
- function depthOf(dir: string): number {
148
- return dir ? dir.split('/').length : 0
149
- }
150
-
151
- function fromPage(link: string, depth: number): string {
152
- if (!link.startsWith('/') || link.startsWith('//')) return link
153
- return `${'../'.repeat(depth)}${link.slice(1)}` || './'
154
- }
155
-
156
- function relativeNav(items: NavItem[], depth: number): NavItem[] {
157
- return items.map(item => ({ ...item, ...item.link ? { link: fromPage(item.link, depth) } : {}, ...item.items ? { items: relativeNav(item.items, depth) } : {} }))
158
- }
159
-
160
- function neighbourFrom(other: Neighbour | undefined, depth: number): Neighbour | undefined {
161
- return other && { text: other.text, link: fromPage(`/${other.link}`, depth) }
162
- }
163
-
164
- function shellConfig(shell: SiteShell, base: string, image: string | undefined, workspace?: WorkspaceOptions): { site: Record<string, unknown>, ogTitle: boolean } {
165
- const site: Record<string, unknown> = {}
166
- const themeConfig: Record<string, unknown> = {}
167
- if (shell.nav) themeConfig.nav = workspace ? relativeNav(shell.nav, depthOf(workspace.document)) : shell.nav
168
- if (shell.footer) themeConfig.footer = shell.footer
169
- const head: unknown[] = []
170
- if (shell.head) {
171
- head.push(['meta', { property: 'og:type', content: 'website' }])
172
- if (shell.head.description) {
173
- site.description = shell.head.description
174
- head.push(['meta', { property: 'og:description', content: shell.head.description }])
175
- }
176
- if (image) head.push(['meta', { property: 'og:image', content: image }])
177
- }
178
- if (shell.sitemap && !workspace) site.sitemap = { hostname: `${shell.sitemap.hostname}${base}` }
179
- return { site: { ...site, head, themeConfig }, ogTitle: Boolean(shell.head) }
180
- }
181
-
182
- export const OG_TITLE = `site.transformHead = ({ title }) => [['meta', { property: 'og:title', content: title }]]\n`
183
-
184
- function config(options: WebRendererConfig, files: ReturnType<typeof siteFiles>, modules: ClientModules, deck: DeckPaths, workspace: WorkspaceOptions | undefined, image: string | undefined, optimize: { include: string[], exclude: string[] }, preview: SiteInput['preview']): string {
185
- const labels = SITE_LABELS[deckLocale(options.lang)]
186
- const shell = shellConfig(options.shell, options.base, image, workspace)
187
- const site = {
188
- lang: options.lang,
189
- base: options.base,
190
- cleanUrls: false,
191
- ignoreDeadLinks: true,
192
- srcExclude: SRC_EXCLUDE,
193
- ...shell.site,
194
- themeConfig: { ...labels.themeConfig, ...shell.site.themeConfig as object },
195
- }
196
- const imports = [
197
- ...modules.vite.map((file, i) => `import vite${i} from ${JSON.stringify(file)}\n`),
198
- ...modules.nodeVite.map(({ file }, i) => `import node${i} from ${JSON.stringify(file)}\n`),
199
- ].join('')
200
- const nodePlugins = modules.nodeVite.map(({ config: options }, i) =>
201
- `...(node${i}.vitePlugins?.({ ...deck, config: ${JSON.stringify(options)} }) ?? [])`)
202
- const server = options.server ? `
203
- site.themeConfig.presmium.server = true
204
- site.vite.server = { proxy: { ${JSON.stringify(SERVER_PROXY)}: { target: ${JSON.stringify(options.server)}, ws: true } }, fs: { allow: ${JSON.stringify(allowed(files.root, deck.deckDir, deck.workspaceRoot, presmiumRoot()))} } }
205
- ` : ''
206
- const inWorkspace = workspace ? `
207
- Object.assign(site.themeConfig.presmium, ${JSON.stringify({ document: workspace.document, home: workspace.home, ir: workspace.endpoints.ir, ...workspace.served ? { server: true } : {} })})
208
- ${workspace.served ? `site.vite.server = { fs: { allow: ${JSON.stringify(workspace.allow ?? [])} } }
209
- ` : ''}` : ''
210
- const framed = preview ? `site.vite.plugins.push(presmiumFrame(${JSON.stringify(previewFrameAncestors(preview.origins))}))
211
- ` : ''
212
- return `${GENERATED}
213
- import { readFileSync } from 'node:fs'
214
- import { tokenize } from ${JSON.stringify(SEARCH)}
215
- import { presmiumFrame, presmiumIR } from ${JSON.stringify(VITE_PLUGIN)}
216
- ${imports}
217
- const site = ${JSON.stringify(site, null, 2)}
218
-
219
- // Read when the config loads, so this file stays the same while the IR changes.
220
- const data = JSON.parse(readFileSync(${JSON.stringify(files.site)}, 'utf8'))
221
- site.title = data.title
222
- site.themeConfig.sidebar = data.sidebar
223
- site.themeConfig.presmium = { form: data.form }
224
-
225
- // The translations (P-123): a locale each, with its title, sidebar and labels; the language menu keeps the page.
226
- const locales = data.locales.map(locale => locale.lang)
227
- if (locales.length > 0) {
228
- site.locales = { root: { label: data.label, lang: site.lang } }
229
- for (const locale of data.locales) {
230
- site.locales[locale.lang] = { label: locale.label, lang: locale.lang, title: locale.title, themeConfig: { ...locale.labels.themeConfig, sidebar: locale.sidebar, ...locale.nav ? { nav: locale.nav } : {} } }
231
- }
232
- }
233
-
234
- // The Vite plugins of the deck's plugin packages: their \`vite.config.ts\`, as in the slides form, and those
235
- // their Node entry adds (\`vitePlugins\`), with the plugin's configuration.
236
- const deck = ${JSON.stringify(deck)}
237
- site.vite = {
238
- plugins: [
239
- presmiumIR(${JSON.stringify(files.ir)}, ${JSON.stringify(files.searchDocuments)}, ${workspace ? JSON.stringify(workspace.endpoints) : 'undefined'}, locales),
240
- ...[${modules.vite.map((_, i) => `vite${i}`).join(', ')}].flatMap(config => config?.plugins ?? []),${nodePlugins.map(plugin => `\n ${plugin},`).join('')}
241
- ],
242
- resolve: { dedupe: ['vue'] },${optimize.exclude.length > 0 ? `
243
- // The Presmium packages installed under node_modules, as from npm: their browser dependencies
244
- // pre-bundled, themselves never (the pages load their source).
245
- optimizeDeps: ${JSON.stringify(optimize)},` : ''}
246
- }
247
- ${server}${inWorkspace}${framed}${shell.ogTitle ? OG_TITLE : ''}
248
- // Local search over the IR text of each section: titles, narration, prose and code. The index is
249
- // the renderer's own (see presmiumIR); the search box tokenizes queries as it does, Chinese into words.
250
- site.themeConfig.search = {
251
- provider: 'local',
252
- options: {
253
- miniSearch: { options: { tokenize } },${labels.search ? `
254
- translations: ${JSON.stringify(labels.search)},` : ''}
255
- locales: Object.fromEntries(data.locales.filter(locale => locale.labels.search).map(locale => [locale.lang, { translations: locale.labels.search }])),
256
- },
257
- }
258
-
259
- export default site
260
- `
261
- }
262
-
263
- function theme(preview?: { origins: string[], document: string }): string {
264
- return `${GENERATED}
265
- import doc, { revision, translations } from 'virtual:presmium/ir'
266
- import { createPresmiumTheme } from ${JSON.stringify(CLIENT_ENTRY)}
267
- ${preview ? `import { startWebPreview } from ${JSON.stringify(PREVIEW_AGENT)}
268
- ` : ''}import { components, plugins, viewers } from './addons.ts'
269
-
270
- export default createPresmiumTheme({ doc, revision, translations, components, plugins, viewers${preview ? `, preview: page => startWebPreview(page, ${JSON.stringify(preview)})` : ''} })
271
- `
272
- }
273
-
274
- function addons(modules: ClientModules): string {
275
- const load = (file: string) => `() => import(${JSON.stringify(file)})`
276
- const components = [...modules.components].map(([name, file]) => ` ${JSON.stringify(name)}: ssr ? never : ${load(file)},`)
277
- return `${GENERATED}
278
- const ssr = import.meta.env.SSR
279
- const never = () => new Promise(() => {})
280
-
281
- export const components = {
282
- ${components.join('\n')}
283
- }
284
-
285
- export const plugins = ssr ? [] : [
286
- ${modules.plugins.map(file => ` ${load(file)},`).join('\n')}
287
- ]
288
-
289
- export const viewers = ssr ? [] : [
290
- ${modules.viewers.map(({ name, file }) => ` { name: ${JSON.stringify(name)}, load: ${load(file)} },`).join('\n')}
291
- ]
292
- `
293
- }
294
-
295
- export interface SiteInput {
296
- doc: IRDocument
297
- deck: DeckPaths
298
- modules: ClientModules
299
- options: WebRendererConfig
300
- workspace?: WorkspaceOptions
301
- translations?: { lang: string, doc: IRDocument }[]
302
- image?: string
303
- preview?: { origins: string[] }
304
- }
305
-
306
- export function localeFile(file: string, lang: string): string {
307
- return file.replace(/\.json$/, `.${lang}.json`)
308
- }
309
-
310
- function writePages(dir: string, pages: WebPage[], prefix: string, depth: number, workspace?: WorkspaceOptions): void {
311
- const names = new Set(pages.map(page => `${page.path}.md`))
312
- if (existsSync(dir)) for (const file of readdirSync(dir).filter(f => f.endsWith('.md') && !names.has(f))) rmSync(join(dir, file))
313
- pages.forEach((page, i) => {
314
- const prev = neighbour(pages[i - 1], prefix) ?? neighbourFrom(workspace?.neighbours?.prev, depth)
315
- const next = neighbour(pages[i + 1], prefix) ?? neighbourFrom(workspace?.neighbours?.next, depth)
316
- write(join(dir, `${page.path}.md`), pageMarkdown(page, prev, next))
317
- })
318
- }
319
-
320
- export function writeSite(workDir: string, input: SiteInput): void {
321
- const files = siteFiles(workDir)
322
- const { doc, modules, options, workspace } = input
323
- const pages = toPages(doc)
324
- const translations: Translation[] = (input.translations ?? []).map(t => ({ ...t, pages: translationPages(t.doc, pages) }))
325
- const others = workspace?.search ?? []
326
- write(files.ir, JSON.stringify(doc))
327
- write(files.searchDocuments, JSON.stringify([...searchDocuments(doc, options.base), ...others]))
328
- for (const { lang, doc: translated, pages: own } of translations) {
329
- write(localeFile(files.ir, lang), JSON.stringify(translated))
330
- write(localeFile(files.searchDocuments, lang), JSON.stringify([...searchDocuments(translated, `${options.base}${lang}/`, own), ...others]))
331
- }
332
- write(files.site, JSON.stringify(siteData(doc, options, translations, workspace)))
333
- write(join(workDir, '.gitignore'), '*\n')
334
- linkDependencies(files.root)
335
- const optimize = optimizeDeps(files.root, browserPackages(modules))
336
- write(join(files.root, '.vitepress/config.mjs'), config(options, files, modules, input.deck, workspace, input.image, optimize, input.preview))
337
- write(join(files.root, '.vitepress/theme/index.ts'), theme(input.preview && { origins: input.preview.origins, document: workspace?.document ?? '' }))
338
- write(join(files.root, '.vitepress/theme/addons.ts'), addons(modules))
339
- const depth = depthOf(workspace?.document ?? '')
340
- writePages(files.root, pages, '', depth, workspace)
341
- const langs = new Set(translations.map(t => t.lang))
342
- for (const entry of readdirSync(files.root, { withFileTypes: true })) {
343
- if (entry.isDirectory() && !entry.name.startsWith('.') && !['public', 'node_modules'].includes(entry.name) && !langs.has(entry.name)) rmSync(join(files.root, entry.name), { recursive: true, force: true })
344
- }
345
- for (const { lang, pages: own } of translations) writePages(join(files.root, lang), own, `/${lang}`, depth + 1, workspace)
346
- const deckPublic = join(input.deck.deckDir, 'public')
347
- if (existsSync(deckPublic)) copyDir(deckPublic, files.publicDir)
348
- write(join(files.publicDir, FONT_LICENSE), fontLicense(import.meta.url))
349
- }