@lincyaw/workspace 0.0.0-stage → 1.0.0-rc.0

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.
@@ -0,0 +1,121 @@
1
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
2
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path'
3
+ import { compileTranslations, DECK_ENTRY, directoryAsset, fileHash, frontmatterLine, inside, lineOf, loadPlugins, portable, publishesDirectory, readFrontmatter, viewFor } from '@lincyaw/compiler'
4
+ import type { Compilation } from '@lincyaw/compiler'
5
+ import type { Finding, IRDocument, RenderContext, Renderer, SiteDocument, SiteSpec, SourcePos } from '@lincyaw/kernel'
6
+ import type { WorkspaceDocument } from '../domain/api.ts'
7
+ import type { WorkspaceConfig } from './config.ts'
8
+
9
+ const WEB_PACKAGE = '@lincyaw/web'
10
+
11
+ export async function webRenderer(root: string, bundled?: string): Promise<Renderer> {
12
+ const { plugins, problems } = await loadPlugins(root, [WEB_PACKAGE], { workspaceRoot: root, listed: [], bundled })
13
+ const renderer = plugins[0]?.plugin?.renderer
14
+ if (!renderer?.buildSite || !renderer.devSite) throw new Error(`the web renderer (${WEB_PACKAGE}) is not installed: ${problems.map(p => p.message).join('; ') || 'it has no workspace site'}`)
15
+ return renderer
16
+ }
17
+
18
+ export function siteIR(compilation: Compilation, doc: IRDocument = compilation.doc): IRDocument {
19
+ const dirs = doc.config.plugins.flatMap((name) => {
20
+ const loaded = compilation.plugins.find(plugin => plugin.name === name)
21
+ return loaded ? [loaded.dir] : []
22
+ })
23
+ return viewFor({ ...doc, config: { ...doc.config, plugins: dirs } }, 'web')
24
+ }
25
+
26
+ export async function siteTranslations(compilation: Compilation): Promise<{ translations: NonNullable<SiteDocument['translations']>, findings: Finding[] }> {
27
+ if (!compilation.doc.config.translations) return { translations: [], findings: [] }
28
+ const { translations, findings } = await compileTranslations(compilation)
29
+ return { translations: translations.map(({ lang, doc }) => ({ lang, doc: siteIR(compilation, doc) })), findings }
30
+ }
31
+
32
+ export function siteDocument(document: WorkspaceDocument, doc?: IRDocument, translations?: SiteDocument['translations']): SiteDocument {
33
+ return { dir: document.dir, title: document.title, ...document.lang ? { lang: document.lang } : {}, ...document.tags ? { tags: document.tags } : {}, ...doc ? { doc } : {}, ...translations?.length ? { translations } : {} }
34
+ }
35
+
36
+ export function siteSpec(config: WorkspaceConfig, documents: SiteDocument[]): SiteSpec {
37
+ return { title: config.title, documents, ...typeof config.site.home === 'string' ? { home: config.site.home } : {}, config: config.site }
38
+ }
39
+
40
+ const REMOTE = /^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i
41
+
42
+ export interface SiteContextOptions {
43
+ root: string
44
+ outDir: string
45
+ workDir: string
46
+ base?: string
47
+ findings: Finding[]
48
+ }
49
+
50
+ export function siteContext(options: SiteContextOptions): RenderContext {
51
+ const { root } = options
52
+ const ctx: RenderContext = {
53
+ deckDir: root,
54
+ workspaceRoot: root,
55
+ outDir: options.outDir,
56
+ workDir: options.workDir,
57
+ config: {},
58
+ extensions: [],
59
+ plugins: [],
60
+ pluginDirs: {},
61
+ renderers: ['web'],
62
+ base: options.base ?? '/',
63
+ vitePlugins: [],
64
+ extensionFor: () => undefined,
65
+ portable: (value, from) => portable(value, from, root),
66
+ localAsset: (source, url) => publishedAsset(options, source, url, ctx.report),
67
+ report(finding) {
68
+ if (!options.findings.some(f => JSON.stringify(f) === JSON.stringify(finding))) options.findings.push(finding)
69
+ },
70
+ }
71
+ return ctx
72
+ }
73
+
74
+ function deckOf(file: string, root: string): string | undefined {
75
+ for (let dir = dirname(file); inside(dir, root); dir = dirname(dir)) {
76
+ if (existsSync(join(dir, DECK_ENTRY))) return dir
77
+ if (dir === root) return undefined
78
+ }
79
+ return undefined
80
+ }
81
+
82
+ function publishedAsset(options: SiteContextOptions, source: SourcePos, url: string, report: (finding: Finding) => void): { file: string, name: string } | undefined {
83
+ if (REMOTE.test(url)) return undefined
84
+ const path = url.replace(/[?#].*$/s, '')
85
+ if (!path || path.startsWith('/')) return undefined
86
+ let name = path
87
+ try {
88
+ name = decodeURIComponent(path)
89
+ } catch {
90
+ }
91
+ const warn = (message: string) => report({ checkId: 'presmium.links', severity: 'warning', message, source: { file: source.file, line: lineOf(source, url) } })
92
+ const target = resolve(dirname(source.file), name)
93
+ const stat = statSync(target, { throwIfNoEntry: false })
94
+ if (!stat) {
95
+ warn(`${url} is not published: there is no such file`)
96
+ return undefined
97
+ }
98
+ const root = realpathSync(options.root)
99
+ const real = realpathSync(target)
100
+ if (!inside(real, root)) {
101
+ if (stat.isFile()) warn(`${url} is not published: the file lies outside the workspace`)
102
+ return undefined
103
+ }
104
+ if (stat.isFile()) return { file: target, name: `${fileHash(target).slice(0, 8)}-${basename(target)}` }
105
+ const deck = deckOf(source.file, options.root)
106
+ if (!deck || !publishesDirectory(target, deck)) return undefined
107
+ const lang = readFrontmatter(readFileSync(join(deck, DECK_ENTRY), 'utf8')).lang
108
+ return directoryAsset(target, deck, join(options.workDir, 'files'), typeof lang === 'string' ? lang : undefined, message => warn(`${url} is not published: ${message}`))
109
+ }
110
+
111
+ export function untranslated(compilation: Compilation, root: string): Finding[] {
112
+ const { translations } = compilation.doc.config
113
+ if (!translations) return []
114
+ const text = readFileSync(compilation.entry, 'utf8')
115
+ return Object.entries(translations).map(([lang, entry]) => ({
116
+ checkId: 'presmium.translations',
117
+ severity: 'warning' as const,
118
+ message: `translation "${lang}" (${entry}) is not in the workspace site of presmium serve, which shows the originals; presmium build at the workspace root publishes it (or presmium build ${relative(root, compilation.deckDir).split(sep).join('/') || '.'} for this document alone)`,
119
+ source: { file: compilation.entry, line: frontmatterLine(text, entry) },
120
+ }))
121
+ }
@@ -0,0 +1,148 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { lstatSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import type { DocumentFileChange, Member } from '@lincyaw/kernel'
5
+ import type { ConflictAnswer, FilesAnswer, SourceAnswer, SourceFile } from '../domain/api.ts'
6
+ import { WORKSPACE_FILE } from '../domain/api.ts'
7
+ import { within } from '../domain/paths.ts'
8
+ import { listDocuments } from './config.ts'
9
+ import type { WorkspaceConfig } from './config.ts'
10
+ import { lockedError, RequestError } from './documents.ts'
11
+ import { documentDirOf, writeChanges } from './files.ts'
12
+ import type { DocumentWriter } from './files.ts'
13
+ import type { DocumentLocks } from './locks.ts'
14
+
15
+ export const MAX_SOURCE = 2 << 20
16
+ const MAX_FILES = 2000
17
+ const NOT_SOURCE = new Set(['node_modules', 'dist', '.git', '.presmium'])
18
+ const BINARY = /\.(?:png|jpe?g|gif|webp|avif|ico|bmp|pdf|wasm|zip|gz|tgz|bz2|xz|7z|woff2?|ttf|otf|eot|mp3|mp4|m4a|wav|ogg|oga|webm|mov|avi|mkv|bin|so|dylib|dll|exe|pyc|xlsx?|docx?|pptx?|parquet|npy|npz|pkl|db|sqlite)$/i
19
+
20
+ export function versionOf(bytes: Uint8Array | string): string {
21
+ return createHash('sha256').update(bytes).digest('hex').slice(0, 20)
22
+ }
23
+
24
+ export interface SourceOptions {
25
+ config: WorkspaceConfig
26
+ writer: DocumentWriter
27
+ locks?: DocumentLocks
28
+ }
29
+
30
+ interface Scope {
31
+ document: string
32
+ home: boolean
33
+ }
34
+
35
+ export class SourceFiles {
36
+ readonly #config: WorkspaceConfig
37
+ readonly #writer: DocumentWriter
38
+ readonly #locks?: DocumentLocks
39
+
40
+ constructor(options: SourceOptions) {
41
+ this.#config = options.config
42
+ this.#writer = options.writer
43
+ this.#locks = options.locks
44
+ }
45
+
46
+ scope(document: string): Scope {
47
+ return { document, home: document === '' && !this.#config.single }
48
+ }
49
+
50
+ roots(scope: Scope): string[] {
51
+ if (!scope.home) return [scope.document]
52
+ return ['']
53
+ }
54
+
55
+ allows(scope: Scope, path: string, member: Member): boolean {
56
+ if (path === WORKSPACE_FILE) return (scope.home || (this.#config.single && scope.document === '')) && member.roles.includes('owner')
57
+ if (scope.home && member.roles.includes('owner')) return true
58
+ const dir = documentDirOf(this.#config, path)
59
+ if (dir === undefined) return scope.home && member.roles.includes('owner')
60
+ return scope.home ? listDocuments(this.#config).some(doc => doc.dir === dir) : dir === scope.document
61
+ }
62
+
63
+ files(scope: Scope, member: Member): FilesAnswer {
64
+ const files: SourceFile[] = []
65
+ let truncated = false
66
+ const walk = (dir: string, owner: string | undefined) => {
67
+ let names: string[]
68
+ try {
69
+ names = readdirSync(join(this.#config.root, dir)).sort()
70
+ } catch {
71
+ return
72
+ }
73
+ for (const name of names) {
74
+ if (NOT_SOURCE.has(name) || name.startsWith('.')) continue
75
+ const path = dir ? `${dir}/${name}` : name
76
+ const found = lstatSync(join(this.#config.root, path), { throwIfNoEntry: false })
77
+ if (!found || found.isSymbolicLink()) continue
78
+ if (found.isDirectory() && (scope.home || documentDirOf(this.#config, `${path}/x`) === owner)) {
79
+ walk(path, owner)
80
+ } else if (found.isFile() && this.allows(scope, path, member)) {
81
+ if (files.length >= MAX_FILES) {
82
+ truncated = true
83
+ return
84
+ }
85
+ files.push({ path, ...BINARY.test(name) ? { binary: true as const } : {} })
86
+ }
87
+ }
88
+ }
89
+ walk(scope.home ? '' : scope.document, scope.home ? undefined : scope.document)
90
+ const documents = scope.home ? listDocuments(this.#config).map(doc => doc.dir) : undefined
91
+ return { document: scope.document, home: scope.home, ...documents ? { documents } : {}, files, ...truncated ? { truncated: true as const } : {} }
92
+ }
93
+
94
+ #bytes(scope: Scope, path: string, member: Member): Buffer {
95
+ if (typeof path !== 'string' || !path || path.startsWith('/') || path.includes('\0') || path.split('/').some(part => part === '' || part === '.' || part === '..' || NOT_SOURCE.has(part))) throw new RequestError(400, `not a source path: ${JSON.stringify(path)}`)
96
+ if (!this.allows(scope, path, member)) throw new RequestError(403, `${path} is not a file this page edits`)
97
+ const file = join(this.#config.root, path)
98
+ let real: string
99
+ try {
100
+ real = realpathSync(file)
101
+ } catch {
102
+ throw new RequestError(404, `no file ${path}`)
103
+ }
104
+ if (real !== file || !lstatSync(file).isFile()) throw new RequestError(403, `${path} is not a regular file of the workspace`)
105
+ const bytes = readFileSync(file)
106
+ if (bytes.byteLength > MAX_SOURCE) throw new RequestError(413, `${path} is larger than the editor opens (${MAX_SOURCE} bytes)`)
107
+ return bytes
108
+ }
109
+
110
+ read(scope: Scope, path: string, member: Member): SourceAnswer {
111
+ const bytes = this.#bytes(scope, path, member)
112
+ if (BINARY.test(path) || bytes.includes(0)) throw new RequestError(415, `${path} is not a text file`)
113
+ let content: string
114
+ try {
115
+ content = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
116
+ } catch {
117
+ throw new RequestError(415, `${path} is not UTF-8 text`)
118
+ }
119
+ return { path, content, version: versionOf(bytes) }
120
+ }
121
+
122
+ async save(scope: Scope, path: string, content: string, base: string, member: Member): Promise<string> {
123
+ const document = listDocuments(this.#config).sort((a, b) => b.dir.length - a.dir.length).find(doc => within(path, doc.dir))?.dir
124
+ const outside = document === undefined
125
+ const release = outside || !this.#locks ? () => {} : this.#locks.claim(document, 'editor:save')
126
+ if (!release) throw lockedError(document!, this.#locks!.holder(document!) ?? '')
127
+ try {
128
+ const bytes = this.#bytes(scope, path, member)
129
+ const now = versionOf(bytes)
130
+ if (now !== base) {
131
+ const conflict: Omit<ConflictAnswer, 'error'> = { conflict: { version: now, content: bytes.toString('utf8') } }
132
+ throw new RequestError(409, `${path} changed since it was opened`, conflict)
133
+ }
134
+ const written = Buffer.from(content, 'utf8')
135
+ if (written.byteLength > MAX_SOURCE) throw new RequestError(413, `the text is larger than the editor saves (${MAX_SOURCE} bytes)`)
136
+ const change: DocumentFileChange = { path, content: written, ...(lstatSync(join(this.#config.root, path)).mode & 0o111) !== 0 ? { executable: true } : {} }
137
+ if (outside) writeChanges(this.#config, [change], true)
138
+ else await this.#writer.write([change])
139
+ return versionOf(written)
140
+ } finally {
141
+ release()
142
+ }
143
+ }
144
+
145
+ inScope(scope: Scope, path: string): boolean {
146
+ return this.roots(scope).some(root => root === WORKSPACE_FILE ? path === root : within(path, root))
147
+ }
148
+ }
@@ -0,0 +1,18 @@
1
+ ---
2
+ title: "{{title}}"
3
+ plugins:
4
+ - '@lincyaw/slidev-addon-core' # 幻灯片形态的宿主适配:插件的组件与导出靠它
5
+ ---
6
+
7
+ # 引言
8
+
9
+ 在这里写本讲的讲解。第一级标题开始一章,第二级标题开始一节;每节在网页中是一段,在幻灯片中是一页。
10
+
11
+ :::{slide}
12
+ - 要点一
13
+ - 要点二
14
+ :::
15
+
16
+ # 小结
17
+
18
+ 本讲的要点。