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

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 (59) hide show
  1. package/README.md +33 -4
  2. package/package.json +21 -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 +199 -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 +93 -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 +160 -0
  20. package/src/domain/app-config.ts +91 -0
  21. package/src/domain/config.ts +16 -28
  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 +76 -63
  27. package/src/domain/pages.ts +23 -41
  28. package/src/domain/themes.ts +74 -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 +91 -0
  34. package/src/node/offline.ts +34 -11
  35. package/src/node/publish.ts +83 -15
  36. package/src/node/renderer.ts +77 -117
  37. package/src/node/site-assets.ts +19 -0
  38. package/src/node/site-build.ts +332 -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 +109 -0
  42. package/src/node/site-server.ts +825 -0
  43. package/src/node/site-theme.ts +190 -0
  44. package/src/node/slides.ts +135 -0
  45. package/src/node/viewer-app.ts +504 -0
  46. package/src/node/viewer-build.ts +38 -0
  47. package/custom-nav-controls.vue +0 -17
  48. package/src/client/env.d.ts +0 -1
  49. package/src/client/home.ts +0 -182
  50. package/src/client/index.ts +0 -4
  51. package/src/client/page.ts +0 -38
  52. package/src/client/preview.ts +0 -71
  53. package/src/client/style.css +0 -427
  54. package/src/client/theme.ts +0 -322
  55. package/src/node/addons.ts +0 -76
  56. package/src/node/site.ts +0 -349
  57. package/src/node/sites.ts +0 -531
  58. package/src/node/vite.ts +0 -166
  59. package/src/node/vitepress.ts +0 -72
@@ -0,0 +1,190 @@
1
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
2
+ import { createRequire } from 'node:module'
3
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path'
4
+ import { deckLocale, isRecord } from '@lincyaw/kernel'
5
+ import type { RenderContext, SiteSpec } from '@lincyaw/kernel'
6
+ import { findInstalledPackage } from '@lincyaw/kernel/node'
7
+ import { isThemeReference, SITE_THEME_IDS, WEB_MESSAGES } from '../domain/index.ts'
8
+ import type { SiteThemeId } from '../domain/index.ts'
9
+ import { isSingle, siteLanguage } from './site-model.ts'
10
+
11
+ /**
12
+ * A site theme of the workspace's own (`site.theme: ./theme`, or an installed package): a directory with `theme.json`.
13
+ * It extends a built-in theme (its layouts and defaults), sets the theme's parameters, overrides design tokens for the
14
+ * light and the dark appearance, and adds web fonts and stylesheets. Its files are published below `_theme/<name>/`;
15
+ * every page of the site links them after the app's styles and carries `data-pm-site-theme-name`.
16
+ */
17
+
18
+ export const THEME_FILE = 'theme.json'
19
+ export const THEME_DIR = '_theme'
20
+ const TOKENS_FILE = 'presmium-tokens.css'
21
+
22
+ export interface ThemeManifest {
23
+ name: string
24
+ extends: SiteThemeId
25
+ params: Record<string, unknown>
26
+ /** Token name (without `--pm-`) to value; those the dark appearance overrides (colors, shadows) are the light ones. */
27
+ tokens: Record<string, string>
28
+ /** Values for the dark appearance, of the tokens it overrides (colors, shadows). */
29
+ dark: Record<string, string>
30
+ /** Stylesheets of web fonts (`https:` URLs). */
31
+ fonts: string[]
32
+ /** The theme's own stylesheets, relative to its directory. */
33
+ styles: string[]
34
+ }
35
+
36
+ export interface LoadedTheme {
37
+ dir: string
38
+ file: string
39
+ manifest: ThemeManifest
40
+ }
41
+
42
+ let designTokens: { all: Set<string>, mode: Set<string> } | undefined
43
+ /** The design system's token names, without `--pm-`: what a theme may set, and those the dark appearance overrides. */
44
+ function tokenNames(): { all: Set<string>, mode: Set<string> } {
45
+ if (designTokens) return designTokens
46
+ const css = readFileSync(createRequire(import.meta.url).resolve('@lincyaw/design/tokens.css'), 'utf8')
47
+ const names = (text: string) => [...text.matchAll(/--pm-([a-z0-9-]+)\s*:/g)].map(match => match[1]!)
48
+ const dark = [...css.matchAll(/:root\.dark\s*\{([^}]*)\}/g)].flatMap(match => names(match[1]!))
49
+ designTokens = { all: new Set(names(css)), mode: new Set(dark) }
50
+ return designTokens
51
+ }
52
+
53
+ const NAME = /^[a-z0-9][a-z0-9-]{0,63}$/
54
+ // A value stays inside its declaration: no other declaration, rule or markup.
55
+ const VALUE = /^[^;{}<>\\]+$/
56
+
57
+ /** The directory `ref` names, from `from`: a path (`./theme`), else an installed package with `theme.json`. */
58
+ function themeDirectory(ref: string, from: string, lang: string): string {
59
+ const t = WEB_MESSAGES[deckLocale(lang)]
60
+ let dir: string | undefined
61
+ if (ref.startsWith('.') || isAbsolute(ref)) dir = resolve(from, ref)
62
+ else {
63
+ const pkg = findInstalledPackage(ref, from)
64
+ if (pkg) {
65
+ const own = JSON.parse(readFileSync(join(pkg, 'package.json'), 'utf8')) as { presmium?: { theme?: unknown } }
66
+ dir = typeof own.presmium?.theme === 'string' ? resolve(pkg, own.presmium.theme) : pkg
67
+ }
68
+ }
69
+ if (!dir || !existsSync(join(dir, THEME_FILE))) throw new Error(t.unknownTheme(ref))
70
+ return dir
71
+ }
72
+
73
+ const loaded = new Map<string, { at: number, theme: LoadedTheme }>()
74
+
75
+ export function loadTheme(ref: string, from: string, lang: string): LoadedTheme {
76
+ const dir = themeDirectory(ref, from, lang)
77
+ const file = join(dir, THEME_FILE)
78
+ const at = statSync(file).mtimeMs
79
+ const known = loaded.get(file)
80
+ if (known?.at === at) return known.theme
81
+ const t = WEB_MESSAGES[deckLocale(lang)]
82
+ const fail = (field: string): never => { throw new Error(t.invalidTheme(file, field)) }
83
+ let input: unknown
84
+ try { input = JSON.parse(readFileSync(file, 'utf8')) } catch { fail('JSON') }
85
+ if (!isRecord(input)) return fail('JSON')
86
+ if (input.schemaVersion !== undefined && input.schemaVersion !== 1) fail('schemaVersion')
87
+ if (typeof input.name !== 'string' || !NAME.test(input.name)) fail('name')
88
+ const base = input.extends ?? 'docs'
89
+ if (!SITE_THEME_IDS.includes(base as SiteThemeId)) fail('extends')
90
+ if (input.params !== undefined && !isRecord(input.params)) fail('params')
91
+ const tokens = (key: 'tokens' | 'dark'): Record<string, string> => {
92
+ const value = input[key]
93
+ if (value === undefined) return {}
94
+ if (!isRecord(value)) return fail(key)
95
+ for (const [name, token] of Object.entries(value)) {
96
+ if (!tokenNames().all.has(name) || key === 'dark' && !tokenNames().mode.has(name)) fail(`${key}.${name}`)
97
+ if (typeof token !== 'string' || !VALUE.test(token.trim())) fail(`${key}.${name}`)
98
+ }
99
+ return Object.fromEntries(Object.entries(value).map(([name, token]) => [name, (token as string).trim()]))
100
+ }
101
+ const list = (key: 'fonts' | 'styles', valid: (item: string) => boolean): string[] => {
102
+ const value = input[key]
103
+ if (value === undefined) return []
104
+ if (!Array.isArray(value)) return fail(key)
105
+ value.forEach((item, i) => { if (typeof item !== 'string' || !valid(item)) fail(`${key}[${i}]`) })
106
+ return value as string[]
107
+ }
108
+ const manifest: ThemeManifest = {
109
+ name: input.name as string,
110
+ extends: base as SiteThemeId,
111
+ params: isRecord(input.params) ? input.params : {},
112
+ tokens: tokens('tokens'),
113
+ dark: tokens('dark'),
114
+ fonts: list('fonts', item => /^https:\/\/[^\s"'<>]+$/.test(item)),
115
+ styles: list('styles', (item) => {
116
+ const path = resolve(dir, item)
117
+ return item.endsWith('.css') && !isAbsolute(item) && !relative(dir, path).startsWith('..') && existsSync(path)
118
+ }),
119
+ }
120
+ const theme = { dir, file, manifest }
121
+ loaded.set(file, { at, theme })
122
+ return theme
123
+ }
124
+
125
+ /** The directory the site's configuration is written in: a theme's path is relative to it. */
126
+ function configDirectory(site: SiteSpec, ctx: RenderContext): string {
127
+ return isSingle(site) ? ctx.deckDir : ctx.workspaceRoot
128
+ }
129
+
130
+ /**
131
+ * The site with its workspace theme resolved: `theme` is the built-in theme it extends, `params` the theme's under the
132
+ * site's own, and `themePackage` names it. Every path that reads the site's shell goes through this first.
133
+ */
134
+ export function themedSite(site: SiteSpec, ctx: RenderContext): SiteSpec {
135
+ const ref = site.config.theme
136
+ if (!isThemeReference(ref) || isRecord(site.config.themePackage)) return site
137
+ const { dir, manifest } = loadTheme(ref.trim(), configDirectory(site, ctx), siteLanguage(site))
138
+ const own = site.config.params
139
+ const params = own === undefined ? manifest.params : isRecord(own) ? { ...manifest.params, ...own } : own
140
+ return { ...site, config: { ...site.config, theme: manifest.extends, params, themePackage: { name: manifest.name, dir } } }
141
+ }
142
+
143
+ /** The design tokens of the theme as CSS: the light appearance's colors and shadows, the dark one's, and the rest. */
144
+ export function themeTokensCss(manifest: ThemeManifest): string {
145
+ const own = `[data-pm-site-theme-name='${manifest.name}']`
146
+ const rule = (selectors: string[], entries: [string, string][]) => entries.length ? `${selectors.join(',\n')} {\n${entries.map(([name, value]) => ` --pm-${name}: ${value};`).join('\n')}\n}\n` : ''
147
+ const tokens = Object.entries(manifest.tokens)
148
+ return [
149
+ `/* The tokens of site theme ${manifest.name}, generated from its ${THEME_FILE}. */\n`,
150
+ rule([`:root:has(${own})`, own], tokens.filter(([name]) => !tokenNames().mode.has(name))),
151
+ rule([`:root:not(.dark):has(${own})`, `:root:not(.dark) ${own}`], tokens.filter(([name]) => tokenNames().mode.has(name))),
152
+ rule([`:root.dark:has(${own})`, `:root.dark ${own}`], Object.entries(manifest.dark)),
153
+ ].filter(Boolean).join('\n')
154
+ }
155
+
156
+ /** The files of a theme directory with their modification times, to copy it again only when one changed. */
157
+ function signature(dir: string): string {
158
+ const files: string[] = []
159
+ const walk = (at: string) => {
160
+ for (const entry of readdirSync(at, { withFileTypes: true })) {
161
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue
162
+ const path = join(at, entry.name)
163
+ if (entry.isDirectory()) walk(path)
164
+ else files.push(`${relative(dir, path)}:${statSync(path).mtimeMs}`)
165
+ }
166
+ }
167
+ walk(dir)
168
+ return files.sort().join('\n')
169
+ }
170
+
171
+ const copied = new Map<string, string>()
172
+
173
+ /** Publishes the theme's files below `outDir/_theme/<name>/` and gives the site the stylesheets its pages link. */
174
+ export function publishedTheme(site: SiteSpec, outDir: string, base: string): SiteSpec {
175
+ const own = site.config.themePackage
176
+ if (!isRecord(own) || typeof own.dir !== 'string' || typeof own.name !== 'string') return site
177
+ const { dir, manifest } = loadTheme(own.dir, own.dir, siteLanguage(site))
178
+ const target = join(outDir, THEME_DIR, manifest.name)
179
+ const files = signature(dir) + manifest.name
180
+ if (copied.get(target) !== files || !existsSync(join(target, TOKENS_FILE))) {
181
+ rmSync(target, { recursive: true, force: true })
182
+ mkdirSync(target, { recursive: true })
183
+ cpSync(dir, target, { recursive: true, filter: source => !source.slice(dir.length).split(sep).some(part => part === 'node_modules' || part.startsWith('.')) })
184
+ writeFileSync(join(target, TOKENS_FILE), themeTokensCss(manifest))
185
+ copied.set(target, files)
186
+ }
187
+ const url = (path: string) => `${base}${THEME_DIR}/${manifest.name}/${relative(dir, resolve(dir, path)).split(sep).join('/')}`
188
+ const stylesheets = [...manifest.fonts, url(TOKENS_FILE), ...manifest.styles.map(url)]
189
+ return { ...site, config: { ...site.config, themePackage: { name: manifest.name, stylesheets } } }
190
+ }
@@ -0,0 +1,135 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { createServer } from 'node:http'
3
+ import type { AddressInfo } from 'node:net'
4
+ import { join } from 'node:path'
5
+ import { PREVIEW_APPEARANCE_PARAM, slideShape } from '@lincyaw/kernel'
6
+ import type { IRDocument, Renderer, SlideShape } from '@lincyaw/kernel'
7
+ import send from 'send'
8
+ import { PRINTED_ATTRIBUTE, printQuery } from '../domain/index.ts'
9
+ import type { SlidesPrintQuery } from '../domain/index.ts'
10
+ import { devDocument } from './renderer.ts'
11
+ import { buildSlidesSite } from './site-build.ts'
12
+
13
+ /**
14
+ * The slides form: the viewer app's slides page (its presentation, one slide at a time on a canvas of a fixed size), built
15
+ * as the web form is, and served by the same dev server. The exports print it: the page lays every slide out at the
16
+ * canvas's size (`?print`), and Chromium prints it to a PDF or takes a picture of each slide.
17
+ */
18
+
19
+ const BUILD_DIR = '.presmium/slides/build'
20
+ const PRINT_TIMEOUT = 120_000
21
+ // The pictures are taken at twice the canvas's size, sharp on a projector.
22
+ const PICTURE_SCALE = 2
23
+
24
+ export const slidesRenderer: Renderer = {
25
+ id: 'slides',
26
+ site: true,
27
+ support: () => 'full',
28
+ async build(doc, ctx) {
29
+ await buildSlidesSite(doc, { ...ctx, workDir: join(ctx.deckDir, BUILD_DIR) }, ctx.nodeVitePlugins ?? [])
30
+ return { outDir: ctx.outDir, skipped: [] }
31
+ },
32
+ dev: (doc, ctx) => devDocument(doc, ctx, 'slides'),
33
+ }
34
+
35
+ interface PrintingPage {
36
+ goto(url: string, options: { waitUntil: 'load', timeout: number }): Promise<unknown>
37
+ waitForSelector(selector: string, options: { timeout: number }): Promise<unknown>
38
+ pdf(options: { path: string, width: string, height: string, printBackground: boolean }): Promise<unknown>
39
+ $$(selector: string): Promise<{ screenshot(options: { path: string }): Promise<unknown> }[]>
40
+ evaluate<T>(run: () => T): Promise<T>
41
+ }
42
+
43
+ /** What printing uses of Playwright's Chromium (`playwright-chromium`), which the caller loads. */
44
+ export interface PrintingChromium {
45
+ launch(): Promise<{
46
+ newPage(options: { viewport: SlideShape, deviceScaleFactor: number, reducedMotion: 'reduce', serviceWorkers: 'block' }): Promise<PrintingPage>
47
+ close(): Promise<void>
48
+ }>
49
+ }
50
+
51
+ export interface PrintOptions extends SlidesPrintQuery {
52
+ /** The slides as `slidesRenderer` built them, for the root path. */
53
+ dir: string
54
+ chromium: PrintingChromium
55
+ /** One PDF file, or a picture of each page in a directory. */
56
+ format: 'pdf' | 'png'
57
+ /** The PDF file, or the directory of the pictures. */
58
+ output: string
59
+ dark?: boolean
60
+ /** How long the page may take to be ready, in milliseconds. */
61
+ timeout?: number
62
+ signal?: AbortSignal
63
+ }
64
+
65
+ /** A printed page: a slide (by number from 1) at a step, and its picture (`<slide>.png`, `<slide>-<step + 1>.png` with `steps`). */
66
+ export interface PrintedPage {
67
+ section: string
68
+ slide: number
69
+ step: number
70
+ file?: string
71
+ }
72
+
73
+ export interface Printed {
74
+ shape: SlideShape
75
+ pages: PrintedPage[]
76
+ }
77
+
78
+ /** Serves the built slides on a loopback port, through `send` (Express's): types and validators as HTTP defines them. */
79
+ async function serveBuilt(dir: string): Promise<{ url: string, close(): Promise<void> }> {
80
+ const server = createServer((req, res) => {
81
+ send(req, new URL(req.url ?? '/', 'http://localhost').pathname, { root: dir, dotfiles: 'allow' })
82
+ .on('error', (error: { status?: number }) => res.writeHead(error.status ?? 500).end())
83
+ .pipe(res)
84
+ })
85
+ await new Promise<void>(done => server.listen(0, '127.0.0.1', done))
86
+ return {
87
+ url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
88
+ close: () => new Promise((done) => {
89
+ server.close(() => done())
90
+ server.closeAllConnections()
91
+ }),
92
+ }
93
+ }
94
+
95
+ /** Prints the built slides: every slide (or `slides`) at its last step, or each of its steps with `steps`. */
96
+ export async function printSlides(options: PrintOptions): Promise<Printed> {
97
+ const shape = slideShape(JSON.parse(await readFile(join(options.dir, 'ir.json'), 'utf8')) as IRDocument)
98
+ const timeout = options.timeout ?? PRINT_TIMEOUT
99
+ const server = await serveBuilt(options.dir)
100
+ try {
101
+ const browser = await options.chromium.launch()
102
+ const stop = () => void browser.close()
103
+ options.signal?.addEventListener('abort', stop, { once: true })
104
+ try {
105
+ options.signal?.throwIfAborted()
106
+ const page = await browser.newPage({ viewport: shape, deviceScaleFactor: options.format === 'png' ? PICTURE_SCALE : 1, reducedMotion: 'reduce', serviceWorkers: 'block' })
107
+ const query = `${printQuery(options)}&${PREVIEW_APPEARANCE_PARAM}=${options.dark ? 'dark' : 'light'}`
108
+ await page.goto(`${server.url}/${query}`, { waitUntil: 'load', timeout })
109
+ await page.waitForSelector(`html[${PRINTED_ATTRIBUTE}]`, { timeout })
110
+ options.signal?.throwIfAborted()
111
+ const pages: PrintedPage[] = await page.evaluate(() => [...document.querySelectorAll<HTMLElement>('.pm-slides-print__page > .pm-slide')]
112
+ .map(slide => ({ section: slide.dataset.section!, slide: Number(slide.dataset.slide), step: Number(slide.dataset.step) })))
113
+ if (options.format === 'pdf') {
114
+ await page.pdf({ path: options.output, width: `${shape.width}px`, height: `${shape.height}px`, printBackground: true })
115
+ return { shape, pages }
116
+ }
117
+ const elements = await page.$$('.pm-slides-print__page')
118
+ for (const [i, element] of elements.entries()) {
119
+ options.signal?.throwIfAborted()
120
+ const printed = pages[i]!
121
+ printed.file = `${printed.slide}${options.steps ? `-${printed.step + 1}` : ''}.png`
122
+ await element.screenshot({ path: join(options.output, printed.file) })
123
+ }
124
+ return { shape, pages }
125
+ } catch (error) {
126
+ options.signal?.throwIfAborted()
127
+ throw error
128
+ } finally {
129
+ options.signal?.removeEventListener('abort', stop)
130
+ await browser.close()
131
+ }
132
+ } finally {
133
+ await server.close()
134
+ }
135
+ }