@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
@@ -0,0 +1,337 @@
1
+ import { existsSync, realpathSync, statSync } from 'node:fs'
2
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
3
+ import { DEFAULT_LANG, DOCUMENT_ENTRY, deckLocale, docBlocks, isRecord, nodeMdast, NON_FILE_URL, safeDecodeURIComponent } from '@lincyaw/kernel'
4
+ import type { DocumentLink, Finding, IRDocument, MdastNode, RenderContext, SiteDocument, SiteSpec, SourcePos } from '@lincyaw/kernel'
5
+ import { findInstalledPackage, isInside } from '@lincyaw/kernel/node'
6
+ import { crossFormLinks, pageLink, searchDocuments, sectionLinks, sitePageLayout, siteShell, translationPages, urlPath, WEB_MESSAGES, webPages } from '../domain/index.ts'
7
+ import type { AppConfig, HomeDocument, NavItem, Neighbour, SearchDocument, SiteConfig, SiteLocale, SiteShell, WebPage } from '../domain/index.ts'
8
+ import type { DocumentPage } from './publish.ts'
9
+
10
+ /**
11
+ * A site of the web form: one document at its root, or a workspace's documents below their directories with a home page
12
+ * that lists them. The links between documents (a link to another document's `lecture.json` goes to its page), the pager
13
+ * that goes on from one to the next and the search that finds the others' sections are the workspace's. The built site
14
+ * (`site-build.ts`) and the dev server (`site-server.ts`) share these.
15
+ */
16
+
17
+ export const LIST_PAGE = 'documents'
18
+ const LINK_LANGUAGE = new WeakMap<DocumentLink, string>()
19
+ const SHELL_KEYS = ['nav', 'footer', 'head', 'sitemap', 'theme', 'params']
20
+
21
+ /** A document's source files: those of its sections and of all their blocks. */
22
+ export function documentSourceFiles(doc: IRDocument): Set<string> {
23
+ return new Set([...doc.sections.map(section => section.source.file), ...[...docBlocks(doc)].map(({ block }) => block.source.file)])
24
+ }
25
+
26
+ /** Whether the site is one document at its root, with no home page. */
27
+ export function isSingle(site: SiteSpec): boolean {
28
+ return site.documents.length === 1 && site.documents[0]!.dir === ''
29
+ }
30
+
31
+ export function shellOptions(site: SiteSpec): Record<string, unknown> {
32
+ return Object.fromEntries(SHELL_KEYS.filter(key => key in site.config).map(key => [key, site.config[key]]))
33
+ }
34
+
35
+ /** The document the home page goes on to (`site.home`), if any. */
36
+ export function homeDocument(site: SiteSpec): SiteDocument | undefined {
37
+ return site.home === undefined ? undefined : site.documents.find(doc => doc.dir === site.home && doc.dir !== '')
38
+ }
39
+
40
+ /** A landing page consumes the canonical document at the root; it does not introduce a second content format. */
41
+ export function landingDocument(site: SiteSpec): SiteDocument | undefined {
42
+ const value = site.config.landing
43
+ if (value === undefined) return undefined
44
+ if (site.home !== undefined) throw new Error(WEB_MESSAGES[deckLocale(siteLanguage(site))].invalidLanding)
45
+ if (isRecord(value)) return undefined
46
+ const document = typeof value === 'string' ? site.documents.find(entry => entry.dir !== '' && entry.dir === value) : undefined
47
+ if (!document || site.home !== undefined) throw new Error(WEB_MESSAGES[deckLocale(siteLanguage(site))].invalidLanding)
48
+ return document
49
+ }
50
+
51
+ /** Where the site's list of documents is: its home page, or `documents.html` when the home page is a document. */
52
+ export function listLink(site: SiteSpec, base: string): string {
53
+ return homeDocument(site) || site.config.landing !== undefined ? `${base}${LIST_PAGE}.html` : base
54
+ }
55
+
56
+ export function documentBase(base: string, dir: string): string {
57
+ return dir ? `${base}${urlPath(dir)}/` : base
58
+ }
59
+
60
+ /** The site's own language: the workspace's, else its first document's. */
61
+ export function siteLanguage(site: SiteSpec, fallback?: string): string {
62
+ return typeof site.config.lang === 'string' ? site.config.lang : site.documents.find(doc => doc.lang)?.lang ?? fallback ?? DEFAULT_LANG
63
+ }
64
+
65
+ export function siteTitle(site: SiteSpec, lang: string): string {
66
+ const locales = isRecord(site.config.locales) ? site.config.locales : {}
67
+ const locale = locales[lang]
68
+ return isRecord(locale) && typeof locale.title === 'string' && locale.title.trim() ? locale.title : site.title
69
+ }
70
+
71
+ /** The other languages of the documents' translations, each with the title the workspace gives its home page. */
72
+ export function siteLanguages(site: SiteSpec, lang: string): { lang: string, title: string }[] {
73
+ const titles = isRecord(site.config.locales) ? site.config.locales : {}
74
+ return [...new Set(site.documents.flatMap(entry => (entry.translations ?? []).map(t => t.lang)))].filter(other => other !== lang).map((other) => {
75
+ const own = titles[other]
76
+ return { lang: other, title: isRecord(own) && typeof own.title === 'string' ? own.title : site.title }
77
+ })
78
+ }
79
+
80
+ export function translationOf(entry: SiteDocument, lang: string): { doc: IRDocument, pages: WebPage[] } | undefined {
81
+ const translated = entry.translations?.find(t => t.lang === lang)
82
+ return translated && entry.doc ? { doc: translated.doc, pages: translationPages(translated.doc, webPages(entry.doc)) } : undefined
83
+ }
84
+
85
+ export function languageLabel(lang: string): string {
86
+ const primary = lang.split('-')[0]!
87
+ try {
88
+ return new Intl.DisplayNames([lang], { type: 'language' }).of(primary) ?? lang
89
+ } catch {
90
+ return lang
91
+ }
92
+ }
93
+
94
+ /** A document's languages: its own, then its translations', each below its site path. */
95
+ export function documentLocales(lang: string, base: string, translations: readonly { lang: string }[] = []): SiteLocale[] {
96
+ return [{ lang, label: languageLabel(lang), base }, ...translations.map(t => ({ lang: t.lang, label: languageLabel(t.lang), base: `${base}${t.lang}/` }))]
97
+ }
98
+
99
+ function cardOf(doc: IRDocument | undefined, root: string, pages = doc && webPages(doc)): Pick<HomeDocument, 'description' | 'pages' | 'problems' | 'sections'> {
100
+ if (!doc || !pages) return {}
101
+ const description = typeof doc.meta.description === 'string' ? doc.meta.description.trim() : ''
102
+ const count = (severity: Finding['severity']) => doc.diagnostics.filter(finding => finding.severity === severity).length
103
+ const links = sectionLinks(pages)
104
+ const sections = doc.sections.flatMap(section => (section.title && links[section.id] ? [{ id: section.id, title: section.title, href: `${root}${links[section.id]!.slice(1)}` }] : []))
105
+ return { ...description ? { description } : {}, pages: pages.length, problems: { errors: count('error'), warnings: count('warning') }, sections }
106
+ }
107
+
108
+ /** The home page's cards, in the site's language or in `lang` (a translation's, where a document has one). */
109
+ export function homeDocuments(site: SiteSpec, base: string, compiled: ReadonlyMap<string, IRDocument> = new Map(), lang?: string): HomeDocument[] {
110
+ return site.documents.map((entry) => {
111
+ const translated = lang ? translationOf(entry, lang) : undefined
112
+ if (translated) {
113
+ const root = `${documentBase(base, entry.dir)}${lang}/`
114
+ return { dir: entry.dir, title: translated.doc.meta.title, lang, ...entry.tags?.length ? { tags: entry.tags } : {}, href: root, ...cardOf(translated.doc, root, translated.pages) }
115
+ }
116
+ const { dir, title, description, lang: own, tags, doc } = entry
117
+ const snapshot = compiled.get(dir) ?? doc
118
+ const root = documentBase(base, dir)
119
+ const card = snapshot ? cardOf(snapshot, root) : description?.trim() ? { description: description.trim() } : {}
120
+ return { dir, title, ...own ? { lang: own } : {}, ...tags?.length ? { tags } : {}, href: root, ...card }
121
+ })
122
+ }
123
+
124
+ export function documentLinkOf(ctx: RenderContext): (source: SourcePos, url: string) => DocumentLink | undefined {
125
+ if (ctx.documentLink) return (source, url) => ctx.documentLink!(source, url)
126
+ return (source, url) => {
127
+ const path = url.replace(/[?#].*$/s, '')
128
+ if (NON_FILE_URL.test(url) || !path || path.startsWith('/')) return undefined
129
+ let target: string
130
+ try {
131
+ target = realpathSync(resolve(dirname(source.file), safeDecodeURIComponent(path)))
132
+ } catch {
133
+ return undefined
134
+ }
135
+ const stat = statSync(target)
136
+ const dir = stat.isFile() && basename(target) === DOCUMENT_ENTRY ? dirname(target) : stat.isDirectory() && existsSync(join(target, DOCUMENT_ENTRY)) ? target : undefined
137
+ const root = realpathSync(ctx.workspaceRoot)
138
+ if (dir === undefined || !isInside(dir, root)) return undefined
139
+ const rel = relative(root, dir)
140
+ const hash = url.indexOf('#')
141
+ const fragment = hash < 0 ? '' : safeDecodeURIComponent(url.slice(hash + 1))
142
+ return { dir: rel.split(sep).join('/'), ...fragment ? { fragment } : {} }
143
+ }
144
+ }
145
+
146
+ function labelSection(doc: IRDocument, label: string): string | undefined {
147
+ const named = (node: MdastNode): boolean => node.identifier === label || node.label === label || Boolean(node.children?.some(named))
148
+ for (const { section, block } of docBlocks(doc)) {
149
+ if (block.id === label || nodeMdast(block.node).some(named)) return section.id
150
+ }
151
+ return undefined
152
+ }
153
+
154
+ /** The page of a link to another document of the site, in the linking page's language when that document has it. */
155
+ export function documentPage(site: SiteSpec, base: string, docs: ReadonlyMap<string, IRDocument>): DocumentPage {
156
+ return (link) => {
157
+ const { dir, fragment } = link
158
+ const entry = site.documents.find(other => other.dir === dir)
159
+ if (!entry) return undefined
160
+ const lang = LINK_LANGUAGE.get(link)
161
+ const translated = lang ? translationOf(entry, lang) : undefined
162
+ const root = `${documentBase(base, dir)}${translated ? `${lang}/` : ''}`
163
+ const anchor = fragment ? `#${encodeURIComponent(fragment)}` : ''
164
+ const doc = docs.get(dir)
165
+ if (!doc || !fragment) return `${root}${anchor}`
166
+ const links = sectionLinks(translated?.pages ?? webPages(doc))
167
+ if (links[fragment]) return `${root}${links[fragment].slice(1)}`
168
+ const section = labelSection(doc, fragment)
169
+ return `${root}${(section ? links[section]!.split('#')[0]! : '/').slice(1)}${anchor}`
170
+ }
171
+ }
172
+
173
+ function translatedFiles(doc: IRDocument, translations: SiteDocument['translations'] = []): Map<string, string> {
174
+ const original = documentSourceFiles(doc)
175
+ const files = new Map<string, string>()
176
+ for (const { lang, doc: translated } of translations) {
177
+ for (const file of documentSourceFiles(translated)) if (!original.has(file)) files.set(file, lang)
178
+ }
179
+ return files
180
+ }
181
+
182
+ /** The render context of the document at `dir`: below its own site path, with the workspace's shell and links. */
183
+ export function documentContext(ctx: RenderContext, site: SiteSpec, dir: string, doc: IRDocument, outDir: string, workDir: string): RenderContext {
184
+ const linkOf = documentLinkOf(ctx)
185
+ const entry = site.documents.find(other => other.dir === dir)
186
+ const translated = translatedFiles(doc, entry?.translations)
187
+ const localeTargets = new Map<string, { dir: string, lang: string }>()
188
+ for (const target of site.documents) for (const locale of target.translations ?? []) {
189
+ const variant = { dir: target.dir, lang: locale.lang }
190
+ localeTargets.set(resolve(locale.doc.meta.entry), variant)
191
+ localeTargets.set(dirname(resolve(locale.doc.meta.entry)), variant)
192
+ }
193
+ const documentLink = (source: SourcePos, url: string): DocumentLink | undefined => {
194
+ const path = url.replace(/[?#].*$/s, '')
195
+ const locale = path && !NON_FILE_URL.test(url) && !path.startsWith('/') ? localeTargets.get(resolve(dirname(source.file), safeDecodeURIComponent(path))) : undefined
196
+ const hash = url.indexOf('#')
197
+ const link = locale ? { dir: locale.dir, ...hash >= 0 ? { fragment: safeDecodeURIComponent(url.slice(hash + 1)) } : {} } : linkOf(source, url)
198
+ const lang = locale?.lang ?? translated.get(source.file)
199
+ if (link && lang) LINK_LANGUAGE.set(link, lang)
200
+ return link
201
+ }
202
+ const deckDir = join(ctx.workspaceRoot, dir)
203
+ const pluginDirs: Record<string, string> = {}
204
+ for (const name of doc.config.plugins) {
205
+ const found = isAbsolute(name) ? name : ctx.pluginDirs[name] ?? findInstalledPackage(name, deckDir)
206
+ if (found && existsSync(found)) pluginDirs[name] = found
207
+ }
208
+ return {
209
+ ...ctx,
210
+ deckDir,
211
+ outDir,
212
+ workDir,
213
+ config: shellOptions(site),
214
+ plugins: doc.config.plugins.filter(name => pluginDirs[name]),
215
+ pluginDirs,
216
+ lang: doc.meta.lang,
217
+ base: documentBase(ctx.base, dir),
218
+ translations: entry?.translations,
219
+ documentLink,
220
+ localAsset(source, url) {
221
+ if (documentLink(source, url)) return undefined
222
+ if (!url.startsWith('/') || url.startsWith('//')) {
223
+ const found = ctx.localAsset(source, url)
224
+ return found && basename(realpathSync(found.file)) === DOCUMENT_ENTRY ? undefined : found
225
+ }
226
+ const path = url.replace(/[?#].*$/s, '')
227
+ let file = join(deckDir, 'public', path)
228
+ try {
229
+ file = join(deckDir, 'public', decodeURIComponent(path))
230
+ } catch {
231
+ }
232
+ if (!existsSync(file)) return undefined
233
+ const rel = relative(dirname(source.file), join(deckDir, 'public', path)).split(sep).join('/')
234
+ return ctx.localAsset(source, `${rel}${url.slice(path.length)}`)
235
+ },
236
+ }
237
+ }
238
+
239
+ function firstPage(base: string, entry: SiteDocument, doc?: IRDocument): Neighbour {
240
+ const page = doc && webPages(doc)[0]
241
+ return { text: page?.title ?? entry.title, link: documentBase(base, entry.dir) }
242
+ }
243
+
244
+ function lastPage(base: string, entry: SiteDocument, doc?: IRDocument): Neighbour {
245
+ const page = doc && webPages(doc).at(-1)
246
+ const root = documentBase(base, entry.dir)
247
+ return page ? { text: page.title, link: pageLink(page, root.replace(/\/$/, '')) } : { text: entry.title, link: root }
248
+ }
249
+
250
+ /** The documents before and after `dir` (the pager goes on to them) and the other documents' sections, for its search. */
251
+ export function siteNeighbours(site: SiteSpec, base: string, dir: string, docs: ReadonlyMap<string, IRDocument>): { neighbours: NonNullable<SiteConfig['neighbours']>, search: SearchDocument[] } {
252
+ const listed = site.documents.filter(entry => entry.dir !== '')
253
+ const i = listed.findIndex(entry => entry.dir === dir)
254
+ const before = listed[i - 1]
255
+ const after = listed[i + 1]
256
+ const search: SearchDocument[] = listed.flatMap((entry) => {
257
+ const doc = docs.get(entry.dir)
258
+ return entry.dir === dir || !doc ? [] : searchDocuments(doc, documentBase(base, entry.dir), webPages(doc), [entry.title])
259
+ })
260
+ return {
261
+ neighbours: i < 0 ? {} : { ...before ? { prev: lastPage(base, before, docs.get(before.dir)) } : {}, ...after ? { next: firstPage(base, after, docs.get(after.dir)) } : {} },
262
+ search,
263
+ }
264
+ }
265
+
266
+ /** The site's navigation with its links below the site's path: a link the configuration writes as `/…` is the site's. */
267
+ export function navBelow(base: string, items: readonly NavItem[] = []): NavItem[] {
268
+ return items.map(item => ({
269
+ ...item,
270
+ ...item.link?.startsWith('/') && !item.link.startsWith('//') ? { link: `${base}${item.link.slice(1)}` } : {},
271
+ ...item.items ? { items: navBelow(base, item.items) } : {},
272
+ }))
273
+ }
274
+
275
+ /** The shell of the site's pages, its navigation below the site's path. */
276
+ export function siteShellBelow(site: SiteSpec, base: string): SiteShell {
277
+ const shell = siteShell(shellOptions(site), siteLanguage(site))
278
+ return shell.nav ? { ...shell, nav: navBelow(base, shell.nav) } : shell
279
+ }
280
+
281
+ /** The site around a document's pages: its title, shell, languages, the workspace's list and neighbours. */
282
+ export function documentSiteConfig(input: { site: SiteSpec, base: string, dir: string, doc: IRDocument, lang: string, docs: ReadonlyMap<string, IRDocument>, workspace: boolean }): SiteConfig {
283
+ const { site, base, dir, doc } = input
284
+ const shell = siteShellBelow(site, base)
285
+ const entry = site.documents.find(other => other.dir === dir)
286
+ const links = crossFormLinks(doc.config.presmium, doc.config.renderers)
287
+ const locales = documentLocales(doc.meta.lang ?? input.lang, documentBase(base, dir), entry?.translations).map(locale => {
288
+ const shown = entry?.translations?.find(translated => translated.lang === locale.lang)?.doc ?? doc
289
+ return { ...locale, layout: sitePageLayout(isRecord(shown.config.renderers.web) ? shown.config.renderers.web : {}, locale.lang) }
290
+ })
291
+ const own = siteLanguage(site)
292
+ const prefix = input.lang !== own && siteLanguages(site, own).some(locale => locale.lang === input.lang) ? `${input.lang}/` : ''
293
+ const root = input.workspace ? `${base}${prefix}` : locales.find(locale => locale.lang === input.lang)?.base ?? base
294
+ return {
295
+ title: doc.meta.title,
296
+ brandTitle: input.workspace ? siteTitle(site, input.lang) : doc.meta.title,
297
+ root,
298
+ layout: sitePageLayout(isRecord(doc.config.renderers.web) ? doc.config.renderers.web : {}, doc.meta.lang),
299
+ shell,
300
+ locales,
301
+ links: input.workspace ? { ...links, slides: Boolean(links.slidesUrl) } : links,
302
+ ...input.workspace ? {
303
+ home: listLink(site, root),
304
+ neighbours: siteNeighbours(site, base, dir, input.docs).neighbours,
305
+ documents: site.documents.filter(other => other.dir !== '').map(other => ({ dir: other.dir, title: other.title, href: documentBase(base, other.dir), ...other.tags?.[0] ? { group: other.tags[0] } : {} })),
306
+ } : {},
307
+ }
308
+ }
309
+
310
+ /** Reuse a document's IR, bundle and media, with a root-level route that displays all of its sections. */
311
+ export function landingConfig(config: AppConfig, doc: IRDocument, site: SiteSpec, base: string, lang = ''): AppConfig {
312
+ const root = `${base}${lang ? `${lang}/` : ''}`
313
+ const own = config.site.locales[0]?.lang ?? doc.meta.lang
314
+ return {
315
+ ...config,
316
+ page: 'index',
317
+ landing: true,
318
+ site: {
319
+ ...config.site,
320
+ brandTitle: siteTitle(site, config.lang),
321
+ root,
322
+ layout: 'landing',
323
+ home: `${root}${LIST_PAGE}.html`,
324
+ locales: config.site.locales.map(locale => ({ ...locale, base: `${base}${locale.lang === own ? '' : `${locale.lang}/`}`, layout: 'landing' })),
325
+ },
326
+ }
327
+ }
328
+
329
+ /** The pages of the site for its sitemap, below its path: every document's pages and their translations'. */
330
+ export function sitemapPages(site: SiteSpec): string[] {
331
+ return site.documents.flatMap((entry) => {
332
+ if (!entry.doc) return []
333
+ const root = entry.dir ? `/${urlPath(entry.dir)}` : ''
334
+ const translated = (entry.translations ?? []).flatMap(({ lang }) => translationOf(entry, lang)?.pages.map(page => pageLink(page, `${root}/${lang}`)) ?? [])
335
+ return [...webPages(entry.doc).map(page => pageLink(page, root)), ...translated].map(link => link.replace(/^\//, ''))
336
+ })
337
+ }
@@ -0,0 +1,82 @@
1
+ import escapeHtml from 'escape-html'
2
+ import { APP_CONFIG_ELEMENT } from '../domain/index.ts'
3
+ import type { AppConfig } from '../domain/index.ts'
4
+ import type { AppEntry } from './viewer-app.ts'
5
+
6
+ /**
7
+ * The HTML of a page of the web form: the app's script and styles (from its build manifest), the import map that names
8
+ * the modules a document's own bundle imports, what the page is (`AppConfig`), and the page's body when it was rendered
9
+ * on the server. Built sites, dev servers and the workbench's previews all serve this one shape.
10
+ */
11
+
12
+ /** A `<meta>` of the page's head, by its attributes. */
13
+ export type MetaTag = Record<string, string>
14
+
15
+ export interface PageHtmlInput {
16
+ entry: AppEntry
17
+ /** Where the app's files are, ending with `/`. */
18
+ appUrl: string
19
+ /** The import map's entries: specifier to the file below the app's directory. */
20
+ shared: Record<string, string>
21
+ config: AppConfig
22
+ title: string
23
+ meta?: MetaTag[]
24
+ /** The page rendered on the server, which the app hydrates. */
25
+ body?: string
26
+ }
27
+
28
+ /** The reader's appearance before the first paint: as the workbench asks, as the reader chose, else as the system is. */
29
+ const APPEARANCE = `(() => {
30
+ let chosen
31
+ try { chosen = localStorage.getItem('presmium-appearance') } catch {}
32
+ const asked = new URLSearchParams(location.search).get('presmium-appearance') || chosen
33
+ if (asked === 'dark' || (asked !== 'light' && matchMedia('(prefers-color-scheme: dark)').matches)) document.documentElement.classList.add('dark')
34
+ })()`
35
+
36
+ const json = (value: unknown) => JSON.stringify(value).replace(/</g, '\\u003c')
37
+
38
+ export function pageHtml(input: PageHtmlInput): string {
39
+ const { entry, appUrl, config } = input
40
+ const imports = Object.fromEntries(Object.entries(input.shared).map(([specifier, file]) => [specifier, `${appUrl}${file}`]))
41
+ const attributes = (tag: MetaTag) => Object.entries(tag).map(([name, value]) => `${name}="${escapeHtml(value)}"`).join(' ')
42
+ const head = [
43
+ '<meta charset="utf-8">',
44
+ '<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content">',
45
+ `<title>${escapeHtml(input.title)}</title>`,
46
+ ...(input.meta ?? []).map(tag => `<meta ${attributes(tag)}>`),
47
+ `<script>${APPEARANCE}</script>`,
48
+ `<script type="importmap">${json({ imports })}</script>`,
49
+ ...entry.css.map(file => `<link rel="stylesheet" href="${escapeHtml(`${appUrl}${file}`)}">`),
50
+ ...entry.preload.map(file => `<link rel="modulepreload" href="${escapeHtml(`${appUrl}${file}`)}">`),
51
+ `<script type="application/json" id="${APP_CONFIG_ELEMENT}">${json(config)}</script>`,
52
+ `<script type="module" src="${escapeHtml(`${appUrl}${entry.script}`)}"></script>`,
53
+ ]
54
+ return `<!doctype html>
55
+ <html lang="${escapeHtml(config.lang)}">
56
+ <head>
57
+ ${head.join('\n ')}
58
+ </head>
59
+ <body>
60
+ <div id="app">${input.body ?? ''}</div>
61
+ </body>
62
+ </html>
63
+ `
64
+ }
65
+
66
+ /** A page that sends the reader on to `target` at once (a workspace's home page that is one of its documents). */
67
+ export function redirectHtml(target: string, title: string, lang: string): string {
68
+ const href = escapeHtml(target)
69
+ return `<!doctype html>
70
+ <html lang="${escapeHtml(lang)}">
71
+ <head>
72
+ <meta charset="utf-8">
73
+ <meta http-equiv="refresh" content="0; url=${href}">
74
+ <title>${escapeHtml(title)}</title>
75
+ <script>location.replace(${json(target)})</script>
76
+ </head>
77
+ <body>
78
+ <p><a href="${href}">${escapeHtml(title)}</a></p>
79
+ </body>
80
+ </html>
81
+ `
82
+ }