@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
@@ -1,123 +0,0 @@
1
- import { existsSync, statSync, watch } from 'node:fs'
2
- import type { FSWatcher } from 'node:fs'
3
- import { basename, dirname, join } from 'node:path'
4
- import type { Compilation, Compiler } from '@lincyaw/compiler'
5
- import { errorMessage } from '@lincyaw/kernel'
6
- import type { Logger } from '@lincyaw/kernel'
7
-
8
- export class DocumentSession {
9
- readonly dir: string
10
- readonly compiler: Compiler
11
- #latest?: Compilation
12
- #first?: Promise<Compilation>
13
- #running: Promise<unknown> = Promise.resolve()
14
- #queued?: Promise<Compilation>
15
- readonly #listeners = new Set<(compilation: Compilation, changed: string[]) => void | Promise<void>>()
16
- #changedFiles = new Set<string>()
17
- readonly #watchers = new Map<string, FSWatcher>()
18
- #relevant = new Map<string, Set<string>>()
19
- #timer?: NodeJS.Timeout
20
- #closed = false
21
- readonly #log: Logger
22
-
23
- constructor(dir: string, compiler: Compiler, log: Logger) {
24
- this.dir = dir
25
- this.compiler = compiler
26
- this.#log = log
27
- }
28
-
29
- ready(): Promise<Compilation> {
30
- if (this.#latest) return Promise.resolve(this.#latest)
31
- if (!this.#first) {
32
- const first = this.recompile()
33
- first.catch(() => {
34
- if (this.#first === first) this.#first = undefined
35
- })
36
- this.#first = first
37
- }
38
- return this.#first
39
- }
40
-
41
- get latest(): Compilation | undefined {
42
- return this.#latest
43
- }
44
-
45
- onCompile(listener: (compilation: Compilation, changed: string[]) => void | Promise<void>): () => void {
46
- this.#listeners.add(listener)
47
- return () => this.#listeners.delete(listener)
48
- }
49
-
50
- recompile(): Promise<Compilation> {
51
- if (this.#queued) return this.#queued
52
- const next = this.#running.then(async () => {
53
- this.#queued = undefined
54
- const started = Date.now()
55
- const changed = [...this.#changedFiles]
56
- this.#changedFiles.clear()
57
- const compilation = await this.compiler.compile()
58
- this.#latest = compilation
59
- for (const listener of this.#listeners) {
60
- try {
61
- await listener(compilation, changed)
62
- } catch (error) {
63
- this.#log.error(`after compiling ${this.dir || '.'}: ${errorMessage(error)}`)
64
- }
65
- }
66
- this.#watch(compilation, started)
67
- return compilation
68
- })
69
- this.#queued = next
70
- this.#running = next.catch((error: unknown) => this.#log.error(`compiling ${this.dir || '.'} failed: ${errorMessage(error)}`))
71
- return next
72
- }
73
-
74
- close(): void {
75
- this.#closed = true
76
- clearTimeout(this.#timer)
77
- for (const watcher of this.#watchers.values()) watcher.close()
78
- this.#watchers.clear()
79
- this.#listeners.clear()
80
- }
81
-
82
- #watch(compilation: Compilation, started: number): void {
83
- if (this.#closed) return
84
- const files = new Set([...compilation.files, ...compilation.dependencies.files])
85
- const next = new Map<string, Set<string>>()
86
- for (const file of files) {
87
- let dir = dirname(file)
88
- let name = basename(file)
89
- while (!existsSync(dir) && dirname(dir) !== dir) {
90
- name = basename(dir)
91
- dir = dirname(dir)
92
- }
93
- next.set(dir, (next.get(dir) ?? new Set<string>()).add(name))
94
- }
95
- this.#relevant = next
96
- for (const [dir, watcher] of this.#watchers) {
97
- if (next.has(dir)) continue
98
- watcher.close()
99
- this.#watchers.delete(dir)
100
- }
101
- for (const dir of next.keys()) {
102
- if (this.#watchers.has(dir)) continue
103
- try {
104
- this.#watchers.set(dir, watch(dir, (_event, name) => {
105
- if (name && this.#relevant.get(dir)?.has(basename(name))) this.#changed(join(dir, name))
106
- }))
107
- } catch {
108
- }
109
- }
110
- const modified = [...files].filter((file) => {
111
- const time = statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? 0
112
- return time >= started - 20 && time <= Date.now()
113
- })
114
- for (const file of modified) this.#changed(file)
115
- }
116
-
117
- #changed(file: string): void {
118
- if (this.#closed) return
119
- this.#changedFiles.add(file)
120
- clearTimeout(this.#timer)
121
- this.#timer = setTimeout(() => void this.recompile().catch(() => undefined), 50)
122
- }
123
- }
@@ -1,148 +0,0 @@
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
- }
@@ -1,18 +0,0 @@
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
- 本讲的要点。