@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/sites.ts DELETED
@@ -1,531 +0,0 @@
1
- import { existsSync, mkdirSync, readdirSync, realpathSync, rmSync, statSync } from 'node:fs'
2
- import { createServer } from 'node:http'
3
- import type { IncomingMessage, ServerResponse } from 'node:http'
4
- import type { AddressInfo } from 'node:net'
5
- import type { Duplex } from 'node:stream'
6
- import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
7
- import { fileURLToPath } from 'node:url'
8
- import { DEFAULT_LANG, deckLocale, docBlocks, errorMessage, isRecord, localRequestRefusal, nodeMdast } from '@lincyaw/kernel'
9
- import type { DocumentLink, Finding, IRDocument, MdastNode, RenderContext, RenderOutput, SiteDevServer, SiteDocument, SiteSpec, SourcePos } from '@lincyaw/kernel'
10
- import { FONT_LICENSE, fontLicense, optimizeDeps } from '@lincyaw/kernel/node'
11
- import { createProxyServer } from 'http-proxy-3'
12
- import { pageLink, searchDocuments, sectionLinks, siteShell, SITE_LABELS, toPages, translationPages, urlPath } from '../domain/index.ts'
13
- import type { Neighbour, SearchDocument, WebPage } from '../domain/index.ts'
14
- import { clientModules } from './addons.ts'
15
- import type { ClientModules } from './addons.ts'
16
- import { publishedFile } from './publish.ts'
17
- import type { DocumentPage } from './publish.ts'
18
- import { buildDocument, devDocument } from './renderer.ts'
19
- import type { DocumentDevServer, DocumentSite } from './renderer.ts'
20
- import { allowed, browserPackages, languageLabel, linkDependencies, OG_TITLE, presmiumRoot, SRC_EXCLUDE, write } from './site.ts'
21
- import type { WorkspaceOptions } from './site.ts'
22
- import { vitepressBuild, vitepressDev } from './vitepress.ts'
23
-
24
- const HOME_THEME = fileURLToPath(new URL('../client/home.ts', import.meta.url))
25
- const VITE_PLUGIN = fileURLToPath(new URL('./vite.ts', import.meta.url))
26
- const LOOPBACK = '127.0.0.1'
27
- const GENERATED = '// Generated by @lincyaw/web for a workspace site; do not edit.'
28
- const LIST_PAGE = 'documents'
29
- const WORKSPACE_FILE = 'presmium.workspace.yaml'
30
- const DECK_ENTRY = 'lecture.md'
31
- const REMOTE = /^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i
32
- const LINK_LANGUAGE = new WeakMap<DocumentLink, string>()
33
- const SHELL_KEYS = ['nav', 'footer', 'head', 'sitemap']
34
-
35
- function shellOptions(site: SiteSpec): Record<string, unknown> {
36
- return Object.fromEntries(SHELL_KEYS.filter(key => key in site.config).map(key => [key, site.config[key]]))
37
- }
38
-
39
- function homeDocument(site: SiteSpec): SiteDocument | undefined {
40
- return site.home === undefined ? undefined : site.documents.find(doc => doc.dir === site.home && doc.dir !== '')
41
- }
42
-
43
- function listLink(site: SiteSpec, base: string): string {
44
- return homeDocument(site) ? `${base}${LIST_PAGE}.html` : base
45
- }
46
-
47
- export interface HomeDocument {
48
- dir: string
49
- title: string
50
- lang?: string
51
- tags?: string[]
52
- href: string
53
- description?: string
54
- pages?: number
55
- problems?: { errors: number, warnings: number }
56
- sections?: { id: string, title: string, href: string }[]
57
- }
58
-
59
- function documentBase(base: string, dir: string): string {
60
- return dir ? `${base}${urlPath(dir)}/` : base
61
- }
62
-
63
- function endpoints(base: string): WorkspaceOptions['endpoints'] {
64
- return { ir: `${base}@lincyaw/ir`, source: `${base}@lincyaw/source` }
65
- }
66
-
67
- function homeDocuments(site: SiteSpec, base: string, compiled: ReadonlyMap<string, IRDocument> = new Map()): HomeDocument[] {
68
- return site.documents.map(({ dir, title, lang, tags, doc }) => ({ dir, title, ...lang ? { lang } : {}, ...tags?.length ? { tags } : {}, href: documentBase(base, dir), ...cardOf(compiled.get(dir) ?? doc, documentBase(base, dir)) }))
69
- }
70
-
71
- function siteLanguages(site: SiteSpec, lang: string): string[] {
72
- return [...new Set(site.documents.flatMap(entry => (entry.translations ?? []).map(t => t.lang)))].filter(other => other !== lang)
73
- }
74
-
75
- function translationOf(entry: SiteDocument, lang: string): { doc: IRDocument, pages: WebPage[] } | undefined {
76
- const translated = entry.translations?.find(t => t.lang === lang)
77
- return translated && entry.doc ? { doc: translated.doc, pages: translationPages(translated.doc, toPages(entry.doc)) } : undefined
78
- }
79
-
80
- function localeDocuments(site: SiteSpec, base: string, lang: string): HomeDocument[] {
81
- return site.documents.map((entry) => {
82
- const translated = translationOf(entry, lang)
83
- if (!translated) return homeDocuments({ ...site, documents: [entry] }, base)[0]!
84
- const root = `${documentBase(base, entry.dir)}${lang}/`
85
- return { dir: entry.dir, title: translated.doc.meta.title, lang, ...entry.tags?.length ? { tags: entry.tags } : {}, href: root, ...cardOf(translated.doc, root, translated.pages) }
86
- })
87
- }
88
-
89
- function cardOf(doc: IRDocument | undefined, root: string, pages = doc && toPages(doc)): Pick<HomeDocument, 'description' | 'pages' | 'problems' | 'sections'> {
90
- if (!doc || !pages) return {}
91
- const description = typeof doc.meta.description === 'string' ? doc.meta.description.trim() : ''
92
- const count = (severity: Finding['severity']) => doc.diagnostics.filter(finding => finding.severity === severity).length
93
- const links = sectionLinks(pages)
94
- const sections = doc.sections.flatMap(section => (section.title && links[section.id] ? [{ id: section.id, title: section.title, href: `${root}${links[section.id]!.slice(1)}` }] : []))
95
- return { ...description ? { description } : {}, pages: pages.length, problems: { errors: count('error'), warnings: count('warning') }, sections }
96
- }
97
-
98
- function packageDir(name: string, from: string): string | undefined {
99
- for (let dir = from; ; dir = dirname(dir)) {
100
- const candidate = join(dir, 'node_modules', name)
101
- if (existsSync(join(candidate, 'package.json'))) return realpathSync(candidate)
102
- if (dirname(dir) === dir) return undefined
103
- }
104
- }
105
-
106
- function siteModules(ctx: RenderContext): ClientModules {
107
- const { components, plugins, viewers, vite } = clientModules({ ...ctx, deckDir: join(ctx.workDir, 'site-plugins') }, [])
108
- return { components, plugins, viewers, vite, nodeVite: [] }
109
- }
110
-
111
- function decoded(text: string): string {
112
- try {
113
- return decodeURIComponent(text)
114
- } catch {
115
- return text
116
- }
117
- }
118
-
119
- function documentLinkOf(ctx: RenderContext): (source: SourcePos, url: string) => DocumentLink | undefined {
120
- if (ctx.documentLink) return (source, url) => ctx.documentLink!(source, url)
121
- return (source, url) => {
122
- const path = url.replace(/[?#].*$/s, '')
123
- if (REMOTE.test(url) || !path || path.startsWith('/')) return undefined
124
- let target: string
125
- try {
126
- target = realpathSync(resolve(dirname(source.file), decoded(path)))
127
- } catch {
128
- return undefined
129
- }
130
- const stat = statSync(target)
131
- const dir = stat.isFile() && basename(target) === DECK_ENTRY ? dirname(target) : stat.isDirectory() && existsSync(join(target, DECK_ENTRY)) ? target : undefined
132
- const rel = dir === undefined ? undefined : relative(realpathSync(ctx.workspaceRoot), dir)
133
- if (rel === undefined || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return undefined
134
- const hash = url.indexOf('#')
135
- const fragment = hash < 0 ? '' : decoded(url.slice(hash + 1))
136
- return { dir: rel.split(sep).join('/'), ...fragment ? { fragment } : {} }
137
- }
138
- }
139
-
140
- function labelSection(doc: IRDocument, label: string): string | undefined {
141
- const named = (node: MdastNode): boolean => node.identifier === label || node.label === label || Boolean(node.children?.some(named))
142
- for (const { section, block } of docBlocks(doc)) {
143
- if (block.id === label || nodeMdast(block.node).some(named)) return section.id
144
- }
145
- return undefined
146
- }
147
-
148
- function documentPage(site: SiteSpec, base: string, docs: Map<string, IRDocument>): DocumentPage {
149
- return (link) => {
150
- const { dir, fragment } = link
151
- const entry = site.documents.find(other => other.dir === dir)
152
- if (!entry) return undefined
153
- const lang = LINK_LANGUAGE.get(link)
154
- const translated = lang ? translationOf(entry, lang) : undefined
155
- const root = `${documentBase(base, dir)}${translated ? `${lang}/` : ''}`
156
- const anchor = fragment ? `#${encodeURIComponent(fragment)}` : ''
157
- const doc = docs.get(dir)
158
- if (!doc || !fragment) return `${root}${anchor}`
159
- const links = sectionLinks(translated?.pages ?? toPages(doc))
160
- if (links[fragment]) return `${root}${links[fragment].slice(1)}`
161
- const section = labelSection(doc, fragment)
162
- return `${root}${(section ? links[section]!.split('#')[0]! : '/').slice(1)}${anchor}`
163
- }
164
- }
165
-
166
- function sourceFiles(doc: IRDocument): Set<string> {
167
- return new Set([...doc.sections.map(section => section.source.file), ...[...docBlocks(doc)].map(({ block }) => block.source.file)])
168
- }
169
-
170
- function translatedFiles(doc: IRDocument, translations: SiteDocument['translations'] = []): Map<string, string> {
171
- const original = sourceFiles(doc)
172
- const files = new Map<string, string>()
173
- for (const { lang, doc: translated } of translations) {
174
- for (const file of sourceFiles(translated)) if (!original.has(file)) files.set(file, lang)
175
- }
176
- return files
177
- }
178
-
179
- function documentContext(ctx: RenderContext, site: SiteSpec, dir: string, doc: IRDocument, outDir: string, workDir: string): RenderContext {
180
- const linkOf = documentLinkOf(ctx)
181
- const entry = site.documents.find(other => other.dir === dir)
182
- const translated = translatedFiles(doc, entry?.translations)
183
- const documentLink = (source: SourcePos, url: string): DocumentLink | undefined => {
184
- const link = linkOf(source, url)
185
- const lang = translated.get(source.file)
186
- if (link && lang) LINK_LANGUAGE.set(link, lang)
187
- return link
188
- }
189
- const deckDir = join(ctx.workspaceRoot, dir)
190
- const pluginDirs: Record<string, string> = {}
191
- for (const name of doc.config.plugins) {
192
- const found = isAbsolute(name) ? name : ctx.pluginDirs[name] ?? packageDir(name, deckDir)
193
- if (found && existsSync(found)) pluginDirs[name] = found
194
- }
195
- return {
196
- ...ctx,
197
- deckDir,
198
- outDir,
199
- workDir,
200
- config: shellOptions(site),
201
- plugins: doc.config.plugins.filter(name => pluginDirs[name]),
202
- pluginDirs,
203
- lang: doc.meta.lang,
204
- base: documentBase(ctx.base, dir),
205
- vitePlugins: [],
206
- translations: entry?.translations,
207
- documentLink,
208
- localAsset(source, url) {
209
- if (documentLink(source, url)) return undefined
210
- if (!url.startsWith('/') || url.startsWith('//')) {
211
- const found = ctx.localAsset(source, url)
212
- return found && basename(realpathSync(found.file)) === DECK_ENTRY ? undefined : found
213
- }
214
- const path = url.replace(/[?#].*$/s, '')
215
- let file = join(deckDir, 'public', path)
216
- try {
217
- file = join(deckDir, 'public', decodeURIComponent(path))
218
- } catch {
219
- }
220
- if (!existsSync(file)) return undefined
221
- const rel = relative(dirname(source.file), join(deckDir, 'public', path)).split(sep).join('/')
222
- return ctx.localAsset(source, `${rel}${url.slice(path.length)}`)
223
- },
224
- }
225
- }
226
-
227
- function documentWorkDir(ctx: RenderContext, dir: string): string {
228
- return join(ctx.workDir, 'documents', dir ? encodeURIComponent(dir) : '_')
229
- }
230
-
231
- function firstPage(entry: SiteDocument, doc?: IRDocument): Neighbour {
232
- const page = doc && toPages(doc)[0]
233
- return { text: page?.title ?? entry.title, link: `${urlPath(entry.dir)}/` }
234
- }
235
-
236
- function lastPage(entry: SiteDocument, doc?: IRDocument): Neighbour {
237
- const page = doc && toPages(doc).at(-1)
238
- return page ? { text: page.title, link: `${urlPath(entry.dir)}${pageLink(page)}` } : { text: entry.title, link: `${urlPath(entry.dir)}/` }
239
- }
240
-
241
- function siteLinks(site: SiteSpec, base: string, dir: string, docs: Map<string, IRDocument>): Pick<WorkspaceOptions, 'neighbours' | 'search'> {
242
- const listed = site.documents.filter(entry => entry.dir !== '')
243
- const i = listed.findIndex(entry => entry.dir === dir)
244
- const before = listed[i - 1]
245
- const after = listed[i + 1]
246
- const search: SearchDocument[] = listed.flatMap((entry) => {
247
- const doc = docs.get(entry.dir)
248
- return entry.dir === dir || !doc ? [] : searchDocuments(doc, documentBase(base, entry.dir), toPages(doc), [entry.title])
249
- })
250
- return {
251
- neighbours: i < 0 ? {} : { ...before ? { prev: lastPage(before, docs.get(before.dir)) } : {}, ...after ? { next: firstPage(after, docs.get(after.dir)) } : {} },
252
- search,
253
- }
254
- }
255
-
256
- function documentSite(ctx: RenderContext, site: SiteSpec, dir: string, extra: ClientModules, served: boolean, allow: string[], docs: Map<string, IRDocument>): DocumentSite {
257
- const base = documentBase(ctx.base, dir)
258
- const single = site.documents.length === 1 && site.documents[0]!.dir === ''
259
- return {
260
- documentPage: documentPage(site, ctx.base, docs),
261
- workspace: { document: dir, home: single ? base : listLink(site, ctx.base), endpoints: endpoints(base), served, allow, ...siteLinks(site, ctx.base, dir, docs) },
262
- extra,
263
- config: join(ctx.workspaceRoot, WORKSPACE_FILE),
264
- }
265
- }
266
-
267
- function sitemapPages(site: SiteSpec): string[] {
268
- return site.documents.flatMap((entry) => {
269
- if (!entry.doc || !entry.dir) return []
270
- const translated = (entry.translations ?? []).flatMap(({ lang }) => translationOf(entry, lang)?.pages.map(page => pageLink(page, `/${lang}`)) ?? [])
271
- return [...toPages(entry.doc).map(page => pageLink(page)), ...translated].map(link => `${urlPath(entry.dir)}${link}`.replace(/^\//, ''))
272
- })
273
- }
274
-
275
- function writeHome(workDir: string, site: SiteSpec, ctx: RenderContext, modules: ClientModules, allow?: string[]): string {
276
- const root = join(workDir, 'site')
277
- const lang = typeof site.config.lang === 'string' ? site.config.lang : site.documents.find(doc => doc.lang)?.lang ?? ctx.lang ?? DEFAULT_LANG
278
- const labels = SITE_LABELS[deckLocale(lang)]
279
- const shell = siteShell(site.config)
280
- const dataFile = join(workDir, 'home.json')
281
- const home = homeDocument(site)
282
- const target = home && documentBase(ctx.base, home.dir)
283
- const titles = isRecord(site.config.locales) ? site.config.locales : {}
284
- const locales = siteLanguages(site, lang).map((other) => {
285
- const own = titles[other]
286
- const translatedHome = home && translationOf(home, other) ? `${target}${other}/` : target
287
- return {
288
- lang: other,
289
- label: languageLabel(other),
290
- title: isRecord(own) && typeof own.title === 'string' ? own.title : site.title,
291
- themeConfig: SITE_LABELS[deckLocale(other)].themeConfig,
292
- documents: localeDocuments(site, ctx.base, other),
293
- ...translatedHome ? { redirect: translatedHome, list: `${ctx.base}${other}/${LIST_PAGE}.html` } : {},
294
- }
295
- })
296
- write(dataFile, JSON.stringify({ title: site.title, label: languageLabel(lang), documents: homeDocuments(site, ctx.base), locales, sitemap: sitemapPages(site) }))
297
- write(join(workDir, '.gitignore'), '*\n')
298
- linkDependencies(root)
299
- const optimize = optimizeDeps(root, browserPackages(modules))
300
- const publicDir = join(root, 'public')
301
- write(join(publicDir, FONT_LICENSE), fontLicense(import.meta.url))
302
- const image = shell.head?.image && (/^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(shell.head.image)
303
- ? shell.head.image
304
- : publishedFile(ctx, publicDir, ctx.base, { file: join(ctx.workspaceRoot, WORKSPACE_FILE), line: 1 }, shell.head.image))
305
- const head: unknown[] = shell.head
306
- ? [['meta', { property: 'og:type', content: 'website' }], ...shell.head.description ? [['meta', { property: 'og:description', content: shell.head.description }]] : [], ...image ? [['meta', { property: 'og:image', content: shell.sitemap && image.startsWith('/') ? `${shell.sitemap.hostname}${image}` : image }]] : []]
307
- : []
308
- const config = {
309
- lang,
310
- base: ctx.base,
311
- cleanUrls: false,
312
- ignoreDeadLinks: true,
313
- srcExclude: SRC_EXCLUDE,
314
- ...shell.head?.description ? { description: shell.head.description } : {},
315
- head,
316
- themeConfig: { ...labels.themeConfig, ...shell.nav ? { nav: shell.nav } : {}, ...shell.footer ? { footer: shell.footer } : {} },
317
- }
318
- const sitemap = shell.sitemap ? `site.sitemap = { hostname: ${JSON.stringify(`${shell.sitemap.hostname}${ctx.base}`)}, transformItems: items => [...items, ...data.sitemap.map(url => ({ url }))] }\n` : ''
319
- const vite = modules.vite.map((file, i) => `import vite${i} from ${JSON.stringify(file)}\n`).join('')
320
- write(join(root, '.vitepress/config.mjs'), `${GENERATED}
321
- import { readFileSync } from 'node:fs'
322
- import { presmiumReady } from ${JSON.stringify(VITE_PLUGIN)}
323
- ${vite}
324
- const site = ${JSON.stringify(config, null, 2)}
325
- const data = JSON.parse(readFileSync(${JSON.stringify(dataFile)}, 'utf8'))
326
- site.title = data.title
327
- site.themeConfig.presmium = { home: true, documents: data.documents${target ? `, redirect: ${JSON.stringify(target)}, list: ${JSON.stringify(`${ctx.base}${LIST_PAGE}.html`)}` : ''} }
328
- // The languages of the documents' translations (P-123): a locale each, which lists the documents in its language.
329
- if (data.locales.length > 0) {
330
- site.locales = { root: { label: data.label, lang: site.lang } }
331
- for (const locale of data.locales) {
332
- const presmium = { ...site.themeConfig.presmium, documents: locale.documents, ...locale.redirect ? { redirect: locale.redirect, list: locale.list } : {} }
333
- site.locales[locale.lang] = { label: locale.label, lang: locale.lang, title: locale.title, themeConfig: { ...locale.themeConfig, presmium } }
334
- }
335
- }
336
- site.vite = { plugins: [presmiumReady(), ...[${modules.vite.map((_, i) => `vite${i}`).join(', ')}].flatMap(config => config?.plugins ?? [])], resolve: { dedupe: ['vue'] }${optimize.include.length + optimize.exclude.length > 0 ? `, optimizeDeps: ${JSON.stringify(optimize)}` : ''}${allow ? `, server: { fs: { allow: ${JSON.stringify(allow)} } }` : ''} }
337
- ${sitemap}${shell.head ? OG_TITLE : ''}
338
- export default site
339
- `)
340
- const load = (file: string) => `() => import(${JSON.stringify(file)})`
341
- write(join(root, '.vitepress/theme/index.ts'), `${GENERATED}
342
- import { createSiteHomeTheme } from ${JSON.stringify(HOME_THEME)}
343
-
344
- const ssr = import.meta.env.SSR
345
- export default createSiteHomeTheme({
346
- plugins: ssr ? [] : [
347
- ${modules.plugins.map(file => ` ${load(file)},`).join('\n')}
348
- ],
349
- })
350
- `)
351
- writeIndex(root, home?.title, target)
352
- const langs = new Set(locales.map(locale => locale.lang))
353
- for (const entry of readdirSync(root, { withFileTypes: true })) {
354
- if (entry.isDirectory() && !entry.name.startsWith('.') && !['public', 'node_modules'].includes(entry.name) && !langs.has(entry.name)) rmSync(join(root, entry.name), { recursive: true, force: true })
355
- }
356
- for (const locale of locales) writeIndex(join(root, locale.lang), locale.documents.find(doc => doc.dir === home?.dir)?.title, locale.redirect, true)
357
- return root
358
- }
359
-
360
- function writeIndex(dir: string, title: string | undefined, target: string | undefined, clientOnly = false): void {
361
- const list = `---\nlayout: page\n---\n\n${clientOnly ? '<ClientOnly><PresmiumSiteHome /></ClientOnly>' : '<PresmiumSiteHome />'}\n`
362
- if (target) {
363
- const refresh = JSON.stringify(['meta', { 'http-equiv': 'refresh', 'content': `0; url=${target}` }])
364
- write(join(dir, 'index.md'), `---\nlayout: page\ntitle: ${JSON.stringify(title ?? '')}\nhead:\n - ${refresh}\n---\n\n<PresmiumSiteRedirect />\n`)
365
- write(join(dir, `${LIST_PAGE}.md`), list)
366
- } else {
367
- write(join(dir, 'index.md'), list)
368
- rmSync(join(dir, `${LIST_PAGE}.md`), { force: true })
369
- }
370
- }
371
-
372
- export async function buildSite(site: SiteSpec, ctx: RenderContext): Promise<RenderOutput> {
373
- const extra = siteModules(ctx)
374
- const single = site.documents.length === 1 && site.documents[0]!.dir === ''
375
- if (!single && ctx.config.home !== false) {
376
- const work = join(ctx.workDir, 'home')
377
- const root = writeHome(work, site, ctx, siteModules(ctx))
378
- const code = await vitepressBuild(root, ctx.outDir)
379
- if (code !== 0) throw new Error(`vitepress build of the home page exited with code ${code}`)
380
- }
381
- const docs = new Map(site.documents.flatMap(entry => (entry.doc ? [[entry.dir, entry.doc] as const] : [])))
382
- for (const entry of site.documents) {
383
- if (!entry.doc) continue
384
- const workDir = documentWorkDir(ctx, entry.dir)
385
- const dctx = documentContext(ctx, site, entry.dir, entry.doc, join(ctx.outDir, entry.dir), workDir)
386
- try {
387
- await buildDocument(entry.doc, dctx, workDir, documentSite(ctx, site, entry.dir, extra, false, [], docs))
388
- } catch (error) {
389
- throw new Error(`document ${entry.dir || '.'}: ${errorMessage(error)}`)
390
- }
391
- }
392
- return { outDir: ctx.outDir, skipped: [] }
393
- }
394
-
395
- export async function devSite(site: SiteSpec, ctx: RenderContext, compile: (dir: string) => Promise<IRDocument>): Promise<SiteDevServer> {
396
- const extra = siteModules(ctx)
397
- const shared = allowed(presmiumRoot(), ...Object.values(ctx.pluginDirs))
398
- const latest = new Map<string, IRDocument>()
399
- const documents = new Map<string, Promise<DocumentDevServer>>()
400
- const linking = new Map<string, DocumentSite>()
401
- let home: Promise<{ port: number, close(): Promise<void> }> | undefined
402
- let closed = false
403
- const stopping = new AbortController()
404
- const base = ctx.base
405
-
406
- const startDocument = (dir: string): Promise<DocumentDevServer> => {
407
- let started = documents.get(dir)
408
- if (!started) {
409
- started = (async () => {
410
- const doc = latest.get(dir) ?? await compile(dir)
411
- latest.set(dir, doc)
412
- const workDir = documentWorkDir(ctx, dir)
413
- mkdirSync(join(workDir, 'site'), { recursive: true })
414
- const dctx = documentContext(ctx, site, dir, doc, workDir, workDir)
415
- const allow = allowed(join(workDir, 'site'), dctx.deckDir, ...Object.values(dctx.pluginDirs), ...shared)
416
- const document = documentSite(ctx, site, dir, extra, true, allow, latest)
417
- linking.set(dir, document)
418
- return devDocument(doc, dctx, document, 0, { host: LOOPBACK, signal: stopping.signal })
419
- })()
420
- started.catch(() => documents.delete(dir))
421
- documents.set(dir, started)
422
- }
423
- return started
424
- }
425
-
426
- const startHome = () => {
427
- if (home) return home
428
- home = (async () => {
429
- const work = join(ctx.workDir, 'home')
430
- mkdirSync(join(work, 'site'), { recursive: true })
431
- const root = writeHome(work, site, ctx, siteModules(ctx), allowed(join(work, 'site'), ...shared))
432
- const server = await vitepressDev(root, 0, () => {}, { host: LOOPBACK, signal: stopping.signal })
433
- return { port: server.port, close: () => server.close() }
434
- })()
435
- home.catch(() => {
436
- home = undefined
437
- })
438
- return home
439
- }
440
-
441
- const targetOf = async (path: string): Promise<number> => {
442
- const listed = new Set(site.documents.map(doc => doc.dir))
443
- for (const [dir, server] of documents) {
444
- if (listed.has(dir)) continue
445
- documents.delete(dir)
446
- latest.delete(dir)
447
- linking.delete(dir)
448
- void server.then(s => s.close(), () => undefined)
449
- }
450
- let rest = path.startsWith(base) ? path.slice(base.length) : undefined
451
- if (rest !== undefined) {
452
- try {
453
- rest = decodeURIComponent(rest)
454
- } catch {
455
- }
456
- }
457
- const dir = rest === undefined
458
- ? undefined
459
- : [...listed].filter(d => d === '' || rest === d || rest!.startsWith(`${d}/`)).sort((a, b) => b.length - a.length)[0]
460
- if (dir !== undefined) return (await startDocument(dir)).port
461
- return (await startHome()).port
462
- }
463
-
464
- const proxy = createProxyServer({ ws: true, xfwd: false })
465
- proxy.on('error', (error: Error, _req: unknown, res: unknown) => {
466
- const response = res as ServerResponse | Duplex | undefined
467
- if (response && 'writeHead' in response && !response.headersSent) {
468
- response.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' })
469
- response.end(`site proxy error: ${error.message}`)
470
- } else {
471
- response?.destroy?.()
472
- }
473
- })
474
- const pathOf = (req: IncomingMessage) => new URL(req.url ?? '/', 'http://site').pathname
475
- const server = createServer((req, res) => {
476
- const refused = localRequestRefusal(req.headers)
477
- if (refused) {
478
- res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' })
479
- res.end(refused)
480
- return
481
- }
482
- const path = pathOf(req)
483
- targetOf(path).then(
484
- port => proxy.web(req, res, { target: `http://${LOOPBACK}:${port}` }),
485
- (error: unknown) => {
486
- if (res.headersSent) return res.destroy()
487
- res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' })
488
- res.end(`the site could not show ${path}: ${errorMessage(error)}`)
489
- },
490
- )
491
- })
492
- server.on('upgrade', (req: IncomingMessage, socket: Duplex, head: Buffer) => {
493
- if (localRequestRefusal(req.headers)) return socket.destroy()
494
- targetOf(pathOf(req)).then(
495
- port => proxy.ws(req, socket, head, { target: `http://${LOOPBACK}:${port}` }),
496
- () => socket.destroy(),
497
- )
498
- })
499
- const host = typeof ctx.config.host === 'string' && ctx.config.host ? ctx.config.host : '127.0.0.1'
500
- const port = await new Promise<number>((resolve, reject) => {
501
- server.once('error', reject)
502
- server.listen(Number(ctx.config.port) || 0, host, () => resolve((server.address() as AddressInfo).port))
503
- })
504
-
505
- return {
506
- url: `http://${host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host}:${port}${base}`,
507
- async update(dir: string, doc: IRDocument) {
508
- if (closed) return
509
- latest.set(dir, doc)
510
- const entry = site.documents.find(d => d.dir === dir)
511
- const known: SiteDocument = { dir, title: doc.meta.title, ...doc.meta.lang ? { lang: doc.meta.lang } : {}, ...Array.isArray(doc.meta.tags) ? { tags: (doc.meta.tags as unknown[]).filter((t): t is string => typeof t === 'string') } : {} }
512
- if (entry) Object.assign(entry, known)
513
- else site.documents.push(known)
514
- for (const [other, document] of linking) Object.assign(document.workspace!, siteLinks(site, base, other, latest))
515
- const running = documents.get(dir)
516
- if (running) void running.then(server => server.update(doc)).catch((error: unknown) => console.error(`[presmium] the site did not update ${dir}: ${errorMessage(error)}`))
517
- },
518
- async close() {
519
- closed = true
520
- stopping.abort()
521
- proxy.close()
522
- await new Promise<void>((done) => {
523
- server.close(() => done())
524
- server.closeAllConnections()
525
- })
526
- const all = [...documents.values(), ...home ? [home] : []]
527
- documents.clear()
528
- await Promise.all(all.map(started => Promise.race([started.then(s => s.close(), () => undefined), new Promise(done => setTimeout(done, 10_000).unref())])))
529
- },
530
- }
531
- }