@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.
- package/LICENSE +202 -0
- package/README.md +35 -2
- package/package.json +36 -3
- package/src/domain/api.ts +102 -0
- package/src/domain/index.ts +6 -0
- package/src/domain/paths.ts +98 -0
- package/src/domain/revisions.ts +80 -0
- package/src/index.ts +6 -0
- package/src/node/build.ts +42 -0
- package/src/node/config.ts +126 -0
- package/src/node/documents.ts +185 -0
- package/src/node/files.ts +252 -0
- package/src/node/git.ts +68 -0
- package/src/node/locks.ts +41 -0
- package/src/node/review/git-guard.ts +103 -0
- package/src/node/review/git-review.ts +416 -0
- package/src/node/review/review-record.ts +73 -0
- package/src/node/review/reviews.ts +422 -0
- package/src/node/review/routes.ts +72 -0
- package/src/node/revisions.ts +346 -0
- package/src/node/routes.ts +239 -0
- package/src/node/serve.ts +302 -0
- package/src/node/service.ts +237 -0
- package/src/node/session.ts +123 -0
- package/src/node/site.ts +121 -0
- package/src/node/source.ts +148 -0
- package/templates/document/default/lecture.md +18 -0
package/src/node/site.ts
ADDED
|
@@ -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
|
+
}
|