@lincyaw/workspace 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 (53) hide show
  1. package/README.md +5 -5
  2. package/package.json +10 -5
  3. package/src/domain/{revisions.ts → commit-message.ts} +7 -7
  4. package/src/domain/locks.ts +5 -0
  5. package/src/domain/paths.ts +2 -2
  6. package/src/index.ts +7 -2
  7. package/src/node/authoring-clients.ts +206 -0
  8. package/src/node/build.ts +4 -4
  9. package/src/node/compile-session.ts +187 -0
  10. package/src/node/config.ts +36 -21
  11. package/src/node/document-assets.ts +30 -0
  12. package/src/node/document-audio.ts +473 -0
  13. package/src/node/document-authoring.ts +436 -0
  14. package/src/node/document-editor.ts +157 -0
  15. package/src/node/document-history.ts +190 -0
  16. package/src/node/document-images.ts +241 -0
  17. package/src/node/document-operations.ts +191 -0
  18. package/src/node/document-transcription-adoption.ts +96 -0
  19. package/src/node/document-transcription.ts +471 -0
  20. package/src/node/document-voice.ts +115 -0
  21. package/src/node/errors.ts +23 -0
  22. package/src/node/git.ts +15 -15
  23. package/src/node/jobs.ts +361 -0
  24. package/src/node/locks.ts +10 -5
  25. package/src/node/page-delete.ts +106 -0
  26. package/src/node/project-files.ts +163 -0
  27. package/src/node/projects.ts +192 -0
  28. package/src/node/review/agent-session-reviews.ts +374 -0
  29. package/src/node/review/git-guard.ts +9 -8
  30. package/src/node/review/git-review.ts +102 -90
  31. package/src/node/review/record.ts +77 -0
  32. package/src/node/review/routes.ts +36 -35
  33. package/src/node/review/task-reviews.ts +50 -0
  34. package/src/node/revisions.ts +321 -68
  35. package/src/node/routes.ts +236 -146
  36. package/src/node/serve.ts +319 -118
  37. package/src/node/site.ts +34 -23
  38. package/src/node/source-editor.ts +332 -0
  39. package/src/node/speech-settings.ts +21 -0
  40. package/src/node/templates.ts +27 -0
  41. package/src/node/workbench-api.ts +176 -0
  42. package/src/node/writer.ts +389 -0
  43. package/templates/document/default/lecture.json +75 -0
  44. package/src/domain/api.ts +0 -102
  45. package/src/domain/index.ts +0 -6
  46. package/src/node/documents.ts +0 -185
  47. package/src/node/files.ts +0 -252
  48. package/src/node/review/review-record.ts +0 -73
  49. package/src/node/review/reviews.ts +0 -422
  50. package/src/node/service.ts +0 -237
  51. package/src/node/session.ts +0 -123
  52. package/src/node/source.ts +0 -148
  53. package/templates/document/default/lecture.md +0 -18
package/src/node/site.ts CHANGED
@@ -1,26 +1,31 @@
1
1
  import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
2
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'
3
+ import { compileTranslations, directoryAsset, fileHash, lineContaining, loadPlugins, nodeVitePlugins, portable, publishesDirectory, viewFor } from '@lincyaw/compiler'
4
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'
5
+ import { DOCUMENT_ENTRY, isRecord, NON_FILE_URL, WEB_RENDERER_PACKAGE } from '@lincyaw/kernel'
6
+ import type { DocumentSummary, Finding, IRDocument, RenderContext, Renderer, SiteDocument, SiteSpec, SourcePos } from '@lincyaw/kernel'
7
+ import { isInside } from '@lincyaw/kernel/node'
7
8
  import type { WorkspaceConfig } from './config.ts'
8
9
 
9
- const WEB_PACKAGE = '@lincyaw/web'
10
-
11
10
  export async function webRenderer(root: string, bundled?: string): Promise<Renderer> {
12
- const { plugins, problems } = await loadPlugins(root, [WEB_PACKAGE], { workspaceRoot: root, listed: [], bundled })
11
+ const { plugins, problems } = await loadPlugins(root, [WEB_RENDERER_PACKAGE], { workspaceRoot: root, listed: [], bundled })
13
12
  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'}`)
13
+ if (!renderer?.buildSite || !renderer.devSite) throw new Error(`the web renderer (${WEB_RENDERER_PACKAGE}) is not installed: ${problems.map(p => p.message).join('; ') || 'it has no workspace site'}`)
15
14
  return renderer
16
15
  }
17
16
 
18
- export function siteIR(compilation: Compilation, doc: IRDocument = compilation.doc): IRDocument {
17
+ /** The document as the workspace site has it, every form's blocks in it: its plugins named by their directories. */
18
+ export function siteDocumentIR(compilation: Compilation, doc: IRDocument = compilation.doc): IRDocument {
19
19
  const dirs = doc.config.plugins.flatMap((name) => {
20
20
  const loaded = compilation.plugins.find(plugin => plugin.name === name)
21
21
  return loaded ? [loaded.dir] : []
22
22
  })
23
- return viewFor({ ...doc, config: { ...doc.config, plugins: dirs } }, 'web')
23
+ return { ...doc, config: { ...doc.config, plugins: dirs } }
24
+ }
25
+
26
+ /** The document's web form on the workspace site. */
27
+ export function siteIR(compilation: Compilation, doc: IRDocument = compilation.doc): IRDocument {
28
+ return viewFor(siteDocumentIR(compilation, doc), 'web')
24
29
  }
25
30
 
26
31
  export async function siteTranslations(compilation: Compilation): Promise<{ translations: NonNullable<SiteDocument['translations']>, findings: Finding[] }> {
@@ -29,16 +34,23 @@ export async function siteTranslations(compilation: Compilation): Promise<{ tran
29
34
  return { translations: translations.map(({ lang, doc }) => ({ lang, doc: siteIR(compilation, doc) })), findings }
30
35
  }
31
36
 
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 } : {} }
37
+ export function siteDocument(document: DocumentSummary, doc?: IRDocument, translations?: SiteDocument['translations'], compilation?: Compilation): SiteDocument {
38
+ return { dir: document.dir, title: document.title, description: document.description, ...document.lang ? { lang: document.lang } : {}, ...document.tags ? { tags: document.tags } : {}, ...doc ? { doc } : {}, ...translations?.length ? { translations } : {}, ...compilation ? { nodeVitePlugins: nodeVitePlugins(compilation) } : {} }
39
+ }
40
+
41
+ export function refreshDocumentVitePlugins(site: SiteSpec, dir: string, compilation: Compilation): void {
42
+ let document = site.documents.find(document => document.dir === dir)
43
+ if (!document) {
44
+ document = { dir, title: compilation.doc.meta.title, lang: compilation.doc.meta.lang }
45
+ site.documents.push(document)
46
+ }
47
+ document.nodeVitePlugins = nodeVitePlugins(compilation)
34
48
  }
35
49
 
36
50
  export function siteSpec(config: WorkspaceConfig, documents: SiteDocument[]): SiteSpec {
37
51
  return { title: config.title, documents, ...typeof config.site.home === 'string' ? { home: config.site.home } : {}, config: config.site }
38
52
  }
39
53
 
40
- const REMOTE = /^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i
41
-
42
54
  export interface SiteContextOptions {
43
55
  root: string
44
56
  outDir: string
@@ -60,7 +72,6 @@ export function siteContext(options: SiteContextOptions): RenderContext {
60
72
  pluginDirs: {},
61
73
  renderers: ['web'],
62
74
  base: options.base ?? '/',
63
- vitePlugins: [],
64
75
  extensionFor: () => undefined,
65
76
  portable: (value, from) => portable(value, from, root),
66
77
  localAsset: (source, url) => publishedAsset(options, source, url, ctx.report),
@@ -72,15 +83,15 @@ export function siteContext(options: SiteContextOptions): RenderContext {
72
83
  }
73
84
 
74
85
  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
86
+ for (let dir = dirname(file); isInside(dir, root); dir = dirname(dir)) {
87
+ if (existsSync(join(dir, DOCUMENT_ENTRY))) return dir
77
88
  if (dir === root) return undefined
78
89
  }
79
90
  return undefined
80
91
  }
81
92
 
82
93
  function publishedAsset(options: SiteContextOptions, source: SourcePos, url: string, report: (finding: Finding) => void): { file: string, name: string } | undefined {
83
- if (REMOTE.test(url)) return undefined
94
+ if (NON_FILE_URL.test(url)) return undefined
84
95
  const path = url.replace(/[?#].*$/s, '')
85
96
  if (!path || path.startsWith('/')) return undefined
86
97
  let name = path
@@ -88,7 +99,7 @@ function publishedAsset(options: SiteContextOptions, source: SourcePos, url: str
88
99
  name = decodeURIComponent(path)
89
100
  } catch {
90
101
  }
91
- const warn = (message: string) => report({ checkId: 'presmium.links', severity: 'warning', message, source: { file: source.file, line: lineOf(source, url) } })
102
+ const warn = (message: string) => report({ checkId: 'presmium.links', severity: 'warning', message, source: { file: source.file, line: lineContaining(source, url) } })
92
103
  const target = resolve(dirname(source.file), name)
93
104
  const stat = statSync(target, { throwIfNoEntry: false })
94
105
  if (!stat) {
@@ -97,25 +108,25 @@ function publishedAsset(options: SiteContextOptions, source: SourcePos, url: str
97
108
  }
98
109
  const root = realpathSync(options.root)
99
110
  const real = realpathSync(target)
100
- if (!inside(real, root)) {
111
+ if (!isInside(real, root)) {
101
112
  if (stat.isFile()) warn(`${url} is not published: the file lies outside the workspace`)
102
113
  return undefined
103
114
  }
104
115
  if (stat.isFile()) return { file: target, name: `${fileHash(target).slice(0, 8)}-${basename(target)}` }
105
116
  const deck = deckOf(source.file, options.root)
106
117
  if (!deck || !publishesDirectory(target, deck)) return undefined
107
- const lang = readFrontmatter(readFileSync(join(deck, DECK_ENTRY), 'utf8')).lang
118
+ const document: unknown = JSON.parse(readFileSync(join(deck, DOCUMENT_ENTRY), 'utf8'))
119
+ const lang = isRecord(document) && isRecord(document.metadata) ? document.metadata.lang : undefined
108
120
  return directoryAsset(target, deck, join(options.workDir, 'files'), typeof lang === 'string' ? lang : undefined, message => warn(`${url} is not published: ${message}`))
109
121
  }
110
122
 
111
- export function untranslated(compilation: Compilation, root: string): Finding[] {
123
+ export function untranslatedFindings(compilation: Compilation, root: string): Finding[] {
112
124
  const { translations } = compilation.doc.config
113
125
  if (!translations) return []
114
- const text = readFileSync(compilation.entry, 'utf8')
115
126
  return Object.entries(translations).map(([lang, entry]) => ({
116
127
  checkId: 'presmium.translations',
117
128
  severity: 'warning' as const,
118
129
  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) },
130
+ source: { file: compilation.entry, line: 1 },
120
131
  }))
121
132
  }
@@ -0,0 +1,332 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { lstatSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { WORKSPACE_CONFIG } from '@lincyaw/compiler'
5
+ import { DOCUMENT_ENTRY, validateDocument } from '@lincyaw/kernel'
6
+ import type { ConflictAnswer, DocumentFileChange, FilesAnswer, Member, Revision, SavedAnswer, SourceAnswer, SourceFile, SourceTransactionAnswer, SourceTransactionChange } from '@lincyaw/kernel'
7
+ import { isWithin } from '../domain/paths.ts'
8
+ import { listDocumentEntries, readDocumentSummary } from './config.ts'
9
+ import type { DocumentEntry, WorkspaceConfig } from './config.ts'
10
+ import { RequestError } from './errors.ts'
11
+ import { lockedError } from './locks.ts'
12
+ import type { DocumentLocks } from './locks.ts'
13
+ import { documentDirOf, FilesError, writeChanges } from './writer.ts'
14
+ import type { DocumentWriter } from './writer.ts'
15
+
16
+ export const MAX_SOURCE = 2 << 20
17
+ export const MAX_SOURCE_CHANGES = 16
18
+ const MAX_FILES = 2000
19
+ const HIDDEN_FROM_EDITOR = new Set(['node_modules', 'dist', '.git', '.presmium'])
20
+ 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
21
+
22
+ export function validateDocumentSource(path: string, content: string, before?: Uint8Array): void {
23
+ if (path !== DOCUMENT_ENTRY && !path.endsWith(`/${DOCUMENT_ENTRY}`)) return
24
+ let value: unknown
25
+ try { value = JSON.parse(content.replace(/^\uFEFF/, '')) }
26
+ catch { throw new RequestError(422, 'document-invalid', { issues: [{ path: '', code: 'json-syntax', message: 'The document is not valid JSON.', severity: 'error' }] }) }
27
+ const checked = validateDocument(value)
28
+ if (!checked.document || checked.issues.some(issue => issue.severity === 'error')) throw new RequestError(422, 'document-invalid', { issues: checked.issues })
29
+ if (before) {
30
+ let previous: unknown
31
+ try { previous = JSON.parse(Buffer.from(before).toString('utf8').replace(/^\uFEFF/, '')) } catch {}
32
+ if (previous && typeof previous === 'object' && 'id' in previous && typeof previous.id === 'string' && previous.id !== checked.document.id) throw new RequestError(422, 'document-identity-changed')
33
+ }
34
+ }
35
+
36
+ export function versionOf(bytes: Uint8Array | string): string {
37
+ return createHash('sha256').update(bytes).digest('hex').slice(0, 20)
38
+ }
39
+
40
+ export interface SourceEditorOptions {
41
+ config: WorkspaceConfig
42
+ writer: DocumentWriter
43
+ locks?: DocumentLocks
44
+ }
45
+
46
+ interface Scope {
47
+ document: string
48
+ home: boolean
49
+ }
50
+
51
+ export interface SourceTransactionHistory {
52
+ run<T>(work: () => Promise<T>): Promise<T>
53
+ commit(paths: string[], lang: string | undefined): Promise<Revision | undefined>
54
+ }
55
+ interface TransactionFile { path: string, content: string | Uint8Array, base: string | null }
56
+
57
+ /** The source files the browser's editor lists, opens, creates and saves, as far as the member and the page's document may. */
58
+ export class SourceEditor {
59
+ readonly #config: WorkspaceConfig
60
+ readonly #writer: DocumentWriter
61
+ readonly #locks?: DocumentLocks
62
+
63
+ constructor(options: SourceEditorOptions) {
64
+ this.#config = options.config
65
+ this.#writer = options.writer
66
+ this.#locks = options.locks
67
+ }
68
+
69
+ scope(document: string): Scope {
70
+ return { document, home: document === '' && !this.#config.singleDeck }
71
+ }
72
+
73
+ roots(scope: Scope): string[] {
74
+ if (!scope.home) return [scope.document]
75
+ return ['']
76
+ }
77
+
78
+ canEdit(scope: Scope, path: string, member: Member): boolean {
79
+ return this.#canEdit(scope, path, member)
80
+ }
81
+
82
+ #canEdit(scope: Scope, path: string, member: Member, documents?: readonly DocumentEntry[]): boolean {
83
+ if (path === WORKSPACE_CONFIG) return (scope.home || (this.#config.singleDeck && scope.document === '')) && member.roles.includes('owner')
84
+ if (scope.home && member.roles.includes('owner')) return true
85
+ const dir = documentDirOf(this.#config, path)
86
+ if (dir === undefined) return scope.home && member.roles.includes('owner')
87
+ return scope.home ? (documents ?? listDocumentEntries(this.#config)).some(doc => doc.dir === dir) : dir === scope.document
88
+ }
89
+
90
+ files(scope: Scope, member: Member): FilesAnswer {
91
+ const files: SourceFile[] = []
92
+ const documents = scope.home ? listDocumentEntries(this.#config) : undefined
93
+ let truncated = false
94
+ const walk = (dir: string, owner: string | undefined) => {
95
+ let names: string[]
96
+ try {
97
+ names = readdirSync(join(this.#config.root, dir)).sort()
98
+ } catch {
99
+ return
100
+ }
101
+ for (const name of names) {
102
+ if (HIDDEN_FROM_EDITOR.has(name) || name.startsWith('.')) continue
103
+ const path = dir ? `${dir}/${name}` : name
104
+ const found = lstatSync(join(this.#config.root, path), { throwIfNoEntry: false })
105
+ if (!found || found.isSymbolicLink()) continue
106
+ if (found.isDirectory() && (scope.home || documentDirOf(this.#config, `${path}/x`) === owner)) {
107
+ walk(path, owner)
108
+ } else if (found.isFile() && this.#canEdit(scope, path, member, documents)) {
109
+ if (files.length >= MAX_FILES) {
110
+ truncated = true
111
+ return
112
+ }
113
+ files.push({ path, ...BINARY.test(name) ? { binary: true as const } : {} })
114
+ }
115
+ }
116
+ }
117
+ walk(scope.home ? '' : scope.document, scope.home ? undefined : scope.document)
118
+ return { document: scope.document, home: scope.home, ...documents ? { documents: documents.map(doc => doc.dir) } : {}, files, ...truncated ? { truncated: true as const } : {} }
119
+ }
120
+
121
+ #documentHolding(path: string, documents: readonly DocumentEntry[]): DocumentEntry | undefined {
122
+ let found: DocumentEntry | undefined
123
+ for (const doc of documents) if (isWithin(path, doc.dir) && (!found || doc.dir.length > found.dir.length)) found = doc
124
+ return found
125
+ }
126
+
127
+ /** Checks path and document policy without requiring a file to exist. */
128
+ assertEditable(scope: Scope, path: string, member: Member): void {
129
+ this.#assertEditable(scope, path, member)
130
+ }
131
+
132
+ #assertEditable(scope: Scope, path: string, member: Member, documents?: readonly DocumentEntry[]): void {
133
+ if (typeof path !== 'string' || !path || path.startsWith('/') || path.includes('\\') || path.includes('\0') || path.split('/').some(part => part === '' || part === '.' || part === '..' || HIDDEN_FROM_EDITOR.has(part))) throw new RequestError(400, 'workspace-path-invalid', { path: String(path).slice(0, 500) })
134
+ if (!this.#canEdit(scope, path, member, documents)) throw new RequestError(403, 'workspace-file-forbidden', { path })
135
+ }
136
+
137
+ /** A regular file under the same path and document policy as source editing. */
138
+ readableFile(scope: Scope, path: string, member: Member): string {
139
+ return this.#readableFile(scope, path, member)
140
+ }
141
+
142
+ #readableFile(scope: Scope, path: string, member: Member, documents?: readonly DocumentEntry[]): string {
143
+ this.#assertEditable(scope, path, member, documents)
144
+ const file = join(this.#config.root, path)
145
+ let real: string
146
+ try {
147
+ real = realpathSync(file)
148
+ } catch {
149
+ throw new RequestError(404, 'workspace-file-missing', { path })
150
+ }
151
+ if (real !== file || !lstatSync(file).isFile()) throw new RequestError(403, 'workspace-file-irregular', { path })
152
+ return file
153
+ }
154
+
155
+ #readBytes(scope: Scope, path: string, member: Member, documents?: readonly DocumentEntry[]): Buffer {
156
+ const file = this.#readableFile(scope, path, member, documents)
157
+ const bytes = readFileSync(file)
158
+ if (bytes.byteLength > MAX_SOURCE) throw new RequestError(413, `${path} is larger than the editor opens (${MAX_SOURCE} bytes)`)
159
+ return bytes
160
+ }
161
+
162
+ read(scope: Scope, path: string, member: Member): SourceAnswer {
163
+ const bytes = this.#readBytes(scope, path, member)
164
+ if (BINARY.test(path) || bytes.includes(0)) throw new RequestError(415, `${path} is not a text file`)
165
+ let content: string
166
+ try {
167
+ content = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes)
168
+ } catch {
169
+ throw new RequestError(415, `${path} is not UTF-8 text`)
170
+ }
171
+ return { path, content, version: versionOf(bytes) }
172
+ }
173
+
174
+ async create(scope: Scope, path: string, member: Member): Promise<SourceAnswer> {
175
+ const documents = listDocumentEntries(this.#config)
176
+ this.#assertEditable(scope, path, member, documents)
177
+ if (path === DOCUMENT_ENTRY || path.endsWith(`/${DOCUMENT_ENTRY}`)) throw new RequestError(422, 'create-a-document-with-valid-content')
178
+ if (BINARY.test(path)) throw new RequestError(415, `${path} is not a text file`)
179
+ const document = this.#documentHolding(path, documents)?.dir
180
+ const scopeDir = document ?? ''
181
+ const release = this.#locks ? this.#locks.claim(scopeDir, 'editor:create') : () => {}
182
+ if (!release) throw lockedError(scopeDir, this.#locks!.holder(scopeDir)!)
183
+ try {
184
+ await this.#writer.create(path, Buffer.alloc(0), document === undefined)
185
+ return { path, content: '', version: versionOf('') }
186
+ } catch (error) {
187
+ if ((error as NodeJS.ErrnoException).code === 'EEXIST') throw new RequestError(409, `${path} already exists`, { exists: true })
188
+ if (error instanceof FilesError) throw new RequestError(403, error.message)
189
+ throw error
190
+ } finally {
191
+ release()
192
+ }
193
+ }
194
+
195
+ /** Saves related sources as one authoring change; any failed write or commit restores all preimages. */
196
+ async transaction(scope: Scope, changes: SourceTransactionChange[], member: Member, history?: SourceTransactionHistory): Promise<SourceTransactionAnswer> {
197
+ return this.#transaction(scope, changes, member, history)
198
+ }
199
+
200
+ /** Checks the same scope, size, CAS and candidate rules without writing or committing. */
201
+ async planTransaction(scope: Scope, changes: SourceTransactionChange[], member: Member): Promise<SourceTransactionAnswer> {
202
+ return this.#transaction(scope, changes, member, undefined, undefined, true)
203
+ }
204
+
205
+ /** A validated authoring document and one new original attachment share CAS, rollback and history. */
206
+ async transactionWithBinary(scope: Scope, document: SourceTransactionChange, attachment: { path: string, content: Uint8Array }, member: Member, history?: SourceTransactionHistory): Promise<SourceTransactionAnswer> {
207
+ if (document.path !== DOCUMENT_ENTRY && !document.path.endsWith(`/${DOCUMENT_ENTRY}`) || document.base === null) throw new RequestError(400, 'document-transaction-required')
208
+ if (!attachment.content.byteLength || attachment.content.byteLength > 16 << 20) throw new RequestError(413, 'production.asset-size')
209
+ return this.#transaction(scope, [document, { ...attachment, content: new Uint8Array(attachment.content), base: null }], member, history, attachment.path)
210
+ }
211
+
212
+ async #transaction(scope: Scope, changes: TransactionFile[], member: Member, history?: SourceTransactionHistory, binaryPath?: string, dryRun = false): Promise<SourceTransactionAnswer> {
213
+ if (changes.length === 0 || changes.length > MAX_SOURCE_CHANGES) throw new RequestError(400, `a source transaction takes 1 to ${MAX_SOURCE_CHANGES} files`)
214
+ const paths = new Set<string>()
215
+ const known = listDocumentEntries(this.#config)
216
+ let bytes = 0
217
+ const documents = changes.map(change => {
218
+ this.#assertEditable(scope, change.path, member, known)
219
+ if (paths.has(change.path)) throw new RequestError(400, `a source path occurs twice: ${change.path}`)
220
+ paths.add(change.path)
221
+ if (change.path !== binaryPath) {
222
+ if (typeof change.content !== 'string' || BINARY.test(change.path) || change.content.includes('\0')) throw new RequestError(415, `${change.path} is not a text file`)
223
+ bytes += Buffer.byteLength(change.content, 'utf8')
224
+ }
225
+ return this.#documentHolding(change.path, known)
226
+ })
227
+ if (bytes > MAX_SOURCE) throw new RequestError(413, `the transaction is larger than the editor saves (${MAX_SOURCE} bytes)`)
228
+ for (const path of paths) if ([...paths].some(other => path !== other && path.startsWith(`${other}/`))) throw new RequestError(400, `a source file cannot also be a parent directory: ${path}`)
229
+ const document = documents[0]
230
+ if (!document || documents.some(found => found?.dir !== document.dir)) throw new RequestError(400, 'a source transaction must stay in one document')
231
+ const scopeDir = document.dir
232
+ const release = this.#locks ? this.#locks.claim(scopeDir, 'editor:transaction') : () => {}
233
+ if (!release) throw lockedError(scopeDir, this.#locks!.holder(scopeDir)!)
234
+ const work = async (): Promise<SourceTransactionAnswer> => {
235
+ // Read every preimage before creating directories or writing any file.
236
+ const snapshots = changes.map(change => {
237
+ const written = typeof change.content === 'string' ? Buffer.from(change.content, 'utf8') : Buffer.from(change.content)
238
+ if (change.base !== null) {
239
+ const before = this.#readBytes(scope, change.path, member, known)
240
+ const version = versionOf(before)
241
+ if (version !== change.base) throw new RequestError(409, `${change.path} changed since it was opened`, { conflict: { path: change.path, version, content: before.toString('utf8') } })
242
+ if (BINARY.test(change.path) || before.includes(0)) throw new RequestError(415, `${change.path} is not a text file`)
243
+ try { new TextDecoder('utf-8', { fatal: true }).decode(before) } catch { throw new RequestError(415, `${change.path} is not UTF-8 text`) }
244
+ const executable = (lstatSync(join(this.#config.root, change.path)).mode & 0o111) !== 0
245
+ return { path: change.path, before, written, executable }
246
+ }
247
+ const file = join(this.#config.root, change.path)
248
+ let parent = this.#config.root
249
+ for (const part of change.path.split('/').slice(0, -1)) {
250
+ parent = join(parent, part)
251
+ const found = lstatSync(parent, { throwIfNoEntry: false })
252
+ if (found && (!found.isDirectory() || realpathSync(parent) !== parent)) throw new RequestError(403, `${change.path} has no regular workspace parent`)
253
+ if (!found) break
254
+ }
255
+ if (lstatSync(file, { throwIfNoEntry: false })) throw new RequestError(409, `${change.path} already exists`, { exists: true })
256
+ return { path: change.path, before: undefined, written, executable: false }
257
+ })
258
+ for (const snapshot of snapshots) if (snapshot.path !== binaryPath) validateDocumentSource(snapshot.path, snapshot.written.toString('utf8'), snapshot.before)
259
+ if (dryRun) return { versions: snapshots.map(snapshot => ({ path: snapshot.path, version: versionOf(snapshot.written) })) }
260
+ const created: string[] = []
261
+ const edited = snapshots.filter(snapshot => snapshot.before && !snapshot.written.equals(snapshot.before))
262
+ const touched = snapshots.filter(snapshot => !snapshot.before || !snapshot.written.equals(snapshot.before)).map(snapshot => snapshot.path)
263
+ const lang = history && touched.length ? readDocumentSummary(this.#config, document).lang : undefined
264
+ const write = (next: DocumentFileChange[]) => this.#writer.write(next)
265
+ try {
266
+ // Exclusive creation prevents replacing a file created outside serve after validation.
267
+ for (const snapshot of snapshots) if (!snapshot.before) {
268
+ await this.#writer.create(snapshot.path, snapshot.written)
269
+ created.push(snapshot.path)
270
+ }
271
+ if (edited.length) await write(edited.map(snapshot => ({ path: snapshot.path, content: snapshot.written, ...snapshot.executable ? { executable: true } : {} })))
272
+ const revision = history && touched.length ? await history.commit(touched, lang) : undefined
273
+ return { versions: snapshots.map(snapshot => ({ path: snapshot.path, version: versionOf(snapshot.written) })), ...revision ? { revision } : {} }
274
+ } catch (error) {
275
+ try {
276
+ await write([
277
+ ...snapshots.flatMap(snapshot => snapshot.before ? [{ path: snapshot.path, content: snapshot.before, ...snapshot.executable ? { executable: true } : {} }] : []),
278
+ ...created.map(path => ({ path, remove: true as const })),
279
+ ])
280
+ } catch (rollback) {
281
+ throw new Error(`source transaction failed (${error instanceof Error ? error.message : String(error)}); restoring sources also failed (${rollback instanceof Error ? rollback.message : String(rollback)})`)
282
+ }
283
+ throw error
284
+ }
285
+ }
286
+ try {
287
+ return await (history ? history.run(work) : work())
288
+ } finally {
289
+ release()
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Writes the text over the version the editor opened. `commit` makes a change a revision while the document's lock
295
+ * is still held; when it fails, the file is put back as it was and the save fails.
296
+ */
297
+ async save(scope: Scope, path: string, content: string, base: string, member: Member, commit?: (path: string, lang: string | undefined) => Promise<Revision | undefined>): Promise<SavedAnswer> {
298
+ const documents = listDocumentEntries(this.#config)
299
+ const document = this.#documentHolding(path, documents)
300
+ const outside = document === undefined
301
+ const scopeDir = document?.dir ?? ''
302
+ const release = this.#locks ? this.#locks.claim(scopeDir, 'editor:save') : () => {}
303
+ if (!release) throw lockedError(scopeDir, this.#locks!.holder(scopeDir)!)
304
+ try {
305
+ const bytes = this.#readBytes(scope, path, member, documents)
306
+ const now = versionOf(bytes)
307
+ if (now !== base) {
308
+ const conflict: Omit<ConflictAnswer, 'error'> = { conflict: { version: now, content: bytes.toString('utf8') } }
309
+ throw new RequestError(409, `${path} changed since it was opened`, conflict)
310
+ }
311
+ const written = Buffer.from(content, 'utf8')
312
+ validateDocumentSource(path, content, bytes)
313
+ if (written.byteLength > MAX_SOURCE) throw new RequestError(413, `the text is larger than the editor saves (${MAX_SOURCE} bytes)`)
314
+ const change: DocumentFileChange = { path, content: written, ...(lstatSync(join(this.#config.root, path)).mode & 0o111) !== 0 ? { executable: true } : {} }
315
+ const lang = commit && !written.equals(bytes) && document ? readDocumentSummary(this.#config, document).lang : undefined
316
+ const write = async (next: DocumentFileChange) => (outside ? writeChanges(this.#config, [next], true) : await this.#writer.write([next]))
317
+ await write(change)
318
+ let revision: Revision | undefined
319
+ if (commit && !written.equals(bytes)) {
320
+ try {
321
+ revision = await commit(path, lang)
322
+ } catch (error) {
323
+ await write({ ...change, content: bytes })
324
+ throw error
325
+ }
326
+ }
327
+ return { version: versionOf(written), ...revision ? { revision } : {} }
328
+ } finally {
329
+ release()
330
+ }
331
+ }
332
+ }
@@ -0,0 +1,21 @@
1
+ import { isRecord, jsonResponse, problemResponse } from '@lincyaw/kernel'
2
+ import type { Route, SpeechSettingsChange } from '@lincyaw/kernel'
3
+ import { OperatorSpeechSettings, SpeechSettingsError } from '@lincyaw/kernel/node'
4
+
5
+ /** Global operator route: works before opening a document and never returns credential values. */
6
+ export function speechSettingsRoute(directory: string, fallback: (name: string) => string | undefined): Route {
7
+ const settings = new OperatorSpeechSettings(directory, fallback)
8
+ return { role: 'owner', maxBody: 16384, handle: async request => {
9
+ try {
10
+ if (request.method === 'GET') return jsonResponse(settings.read(), 200, { 'cache-control': 'no-store' })
11
+ if (request.method !== 'POST') return problemResponse(405, 'GET or POST')
12
+ const body: unknown = await request.json().catch(() => undefined)
13
+ if (!isRecord(body) || typeof body.revision !== 'string' || !['save', 'clear'].includes(String(body.action)) || Object.keys(body).some(key => !['action', 'revision', 'apiKey', 'appId'].includes(key))
14
+ || body.action === 'save' && (body.apiKey !== undefined && typeof body.apiKey !== 'string' || body.appId !== undefined && typeof body.appId !== 'string')) return problemResponse(400, 'speech-settings-invalid')
15
+ return jsonResponse(await settings.change(body as unknown as SpeechSettingsChange), 200, { 'cache-control': 'no-store' })
16
+ } catch (error) {
17
+ const code = error instanceof SpeechSettingsError ? error.code : 'speech-settings-unavailable'
18
+ return problemResponse(code === 'speech-settings-invalid' ? 400 : code === 'speech-settings-conflict' || code === 'speech-settings-busy' ? 409 : 503, code)
19
+ }
20
+ } }
21
+ }
@@ -0,0 +1,27 @@
1
+ import { existsSync, lstatSync, readdirSync, statSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import type { WorkspaceConfig } from './config.ts'
5
+
6
+ const BUILTIN_TEMPLATES = fileURLToPath(new URL('../../templates/document', import.meta.url))
7
+
8
+ export const DEFAULT_TEMPLATE = 'default'
9
+
10
+ function templatesDir(config: WorkspaceConfig): string {
11
+ return config.templates ?? BUILTIN_TEMPLATES
12
+ }
13
+
14
+ export function listTemplates(config: WorkspaceConfig): string[] {
15
+ const dir = templatesDir(config)
16
+ try {
17
+ return readdirSync(dir).filter(name => statSync(join(dir, name)).isDirectory()).sort()
18
+ } catch {
19
+ return []
20
+ }
21
+ }
22
+
23
+ export function findTemplate(config: WorkspaceConfig, name: string): string | undefined {
24
+ if (!name || name.includes('/') || name.includes('\\') || name.startsWith('.')) return undefined
25
+ const dir = join(templatesDir(config), name)
26
+ return existsSync(dir) && lstatSync(dir, { throwIfNoEntry: false })?.isDirectory() ? dir : undefined
27
+ }