@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,26 +1,24 @@
1
1
  import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
2
2
  import { join, resolve } from 'node:path'
3
- import { DECK_ENTRY, readFrontmatter, WORKSPACE_CONFIG } from '@lincyaw/compiler'
3
+ import { WORKSPACE_CONFIG } from '@lincyaw/compiler'
4
4
  import type { OperatorSettings } from '@lincyaw/compiler'
5
- import { errorMessage, isRecord } from '@lincyaw/kernel'
6
- import type { ApprovalKind } from '@lincyaw/kernel'
5
+ import { APPROVAL_KINDS, DOCUMENT_ENTRY, errorMessage, isRecord } from '@lincyaw/kernel'
6
+ import type { ApprovalKind, DocumentSummary } from '@lincyaw/kernel'
7
7
  import type { ApprovalPolicy } from '@lincyaw/server'
8
8
  import { parse as parseYaml } from 'yaml'
9
- import type { WorkspaceDocument } from '../domain/api.ts'
10
9
  import { matchesPattern, patternSegments } from '../domain/paths.ts'
11
10
 
12
- export const DEFAULT_DOCUMENTS = [`*/${DECK_ENTRY}`]
11
+ export const DEFAULT_DOCUMENTS = [`*/${DOCUMENT_ENTRY}`]
13
12
 
14
13
  const SKIPPED = new Set(['node_modules', '.git', '.presmium', 'dist'])
15
14
 
16
- const KINDS: ApprovalKind[] = ['operate', 'spend', 'write', 'agent']
17
15
  const POLICIES = new Set<ApprovalPolicy>(['off', 'ask', 'allow'])
18
16
 
19
17
  export interface WorkspaceConfig {
20
18
  root: string
21
19
  title: string
22
20
  documents: string[]
23
- single: boolean
21
+ singleDeck: boolean
24
22
  site: Record<string, unknown>
25
23
  approvals: Partial<Record<ApprovalKind, ApprovalPolicy>>
26
24
  templates?: string
@@ -31,7 +29,7 @@ function strings(value: unknown): string[] {
31
29
  return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string' && v.trim() !== '').map(v => v.trim()) : typeof value === 'string' && value.trim() ? [value.trim()] : []
32
30
  }
33
31
 
34
- export function readWorkspace(root: string): WorkspaceConfig {
32
+ export function readWorkspaceConfig(root: string): WorkspaceConfig {
35
33
  const dir = realpathSync(resolve(root))
36
34
  const file = join(dir, WORKSPACE_CONFIG)
37
35
  let data: Record<string, unknown> = {}
@@ -44,16 +42,20 @@ export function readWorkspace(root: string): WorkspaceConfig {
44
42
  }
45
43
  }
46
44
  const declared = strings(data.documents)
47
- const single = declared.length === 0 && existsSync(join(dir, DECK_ENTRY))
45
+ const singleDeck = declared.length === 0 && existsSync(join(dir, DOCUMENT_ENTRY))
48
46
  const approvals = isRecord(data.approvals) ? data.approvals : {}
49
47
  const settings = isRecord(data.settings) ? data.settings : {}
48
+ const renderers = isRecord(data.renderers) ? data.renderers : {}
49
+ for (const [path, value] of [['renderers.lecture', renderers.lecture], ['settings.presmium.lecture', settings['presmium.lecture']]] as const) {
50
+ if (isRecord(value) && Object.hasOwn(value, 'script')) throw new Error(`${file}: ${path}.script: External lecture scripts are not supported. Author narration in each document's lecture.json narrations.`)
51
+ }
50
52
  return {
51
53
  root: dir,
52
54
  title: typeof data.title === 'string' && data.title.trim() ? data.title.trim() : dir.split('/').at(-1) ?? 'Presmium',
53
- documents: single ? [DECK_ENTRY] : declared.length > 0 ? declared : DEFAULT_DOCUMENTS,
54
- single,
55
+ documents: singleDeck ? [DOCUMENT_ENTRY] : declared.length > 0 ? declared : DEFAULT_DOCUMENTS,
56
+ singleDeck,
55
57
  site: isRecord(data.site) ? data.site : {},
56
- approvals: Object.fromEntries(KINDS.flatMap(kind => (POLICIES.has(approvals[kind] as ApprovalPolicy) ? [[kind, approvals[kind] as ApprovalPolicy]] : []))),
58
+ approvals: Object.fromEntries(APPROVAL_KINDS.flatMap(kind => (POLICIES.has(approvals[kind] as ApprovalPolicy) ? [[kind, approvals[kind] as ApprovalPolicy]] : []))),
57
59
  ...typeof data.templates === 'string' && data.templates.trim() ? { templates: resolve(dir, data.templates.trim()) } : {},
58
60
  settings: Object.fromEntries(Object.entries(settings).map(([id, value]) => [id, isRecord(value) ? value : {}])),
59
61
  }
@@ -62,7 +64,7 @@ export function readWorkspace(root: string): WorkspaceConfig {
62
64
  export function isWorkspaceRoot(dir: string): boolean {
63
65
  if (!existsSync(join(dir, WORKSPACE_CONFIG))) return false
64
66
  try {
65
- return !readWorkspace(dir).single
67
+ return !readWorkspaceConfig(dir).singleDeck
66
68
  } catch {
67
69
  return true
68
70
  }
@@ -87,7 +89,7 @@ function entries(config: WorkspaceConfig): string[] {
87
89
  return names.flatMap((name) => {
88
90
  const path = join(dir, name)
89
91
  const next = rel ? `${rel}/${name}` : name
90
- if (last) return existsSync(path) && statSync(path).isFile() ? [next] : []
92
+ if (last) return name === DOCUMENT_ENTRY && existsSync(path) && statSync(path).isFile() ? [next] : []
91
93
  return existsSync(path) && statSync(path).isDirectory() ? walk(path, next, i + 1) : []
92
94
  })
93
95
  }
@@ -96,15 +98,23 @@ function entries(config: WorkspaceConfig): string[] {
96
98
  return found
97
99
  }
98
100
 
99
- export interface FoundDocument extends WorkspaceDocument {
101
+ export interface DocumentEntry {
102
+ dir: string
100
103
  entry: string
101
104
  }
102
105
 
103
- function documentOf(config: WorkspaceConfig, entry: string): FoundDocument {
104
- const dir = entry.split('/').slice(0, -1).join('/')
106
+ export interface FoundDocument extends DocumentSummary, DocumentEntry {}
107
+
108
+ /** The configured documents that exist on disk, without reading their content. */
109
+ export function listDocumentEntries(config: WorkspaceConfig): DocumentEntry[] {
110
+ return entries(config).map(entry => ({ dir: entry.split('/').slice(0, -1).join('/'), entry }))
111
+ }
112
+
113
+ export function readDocumentSummary(config: WorkspaceConfig, { dir, entry }: DocumentEntry): FoundDocument {
105
114
  let headmatter: Record<string, unknown> = {}
106
115
  try {
107
- headmatter = readFrontmatter(readFileSync(join(config.root, entry), 'utf8'))
116
+ const parsed: unknown = JSON.parse(readFileSync(join(config.root, entry), 'utf8'))
117
+ headmatter = isRecord(parsed) && isRecord(parsed.metadata) ? parsed.metadata : {}
108
118
  } catch {
109
119
  }
110
120
  const tags = strings(headmatter.tags)
@@ -112,15 +122,20 @@ function documentOf(config: WorkspaceConfig, entry: string): FoundDocument {
112
122
  dir,
113
123
  entry,
114
124
  title: typeof headmatter.title === 'string' && headmatter.title.trim() ? headmatter.title.trim() : dir || config.title,
125
+ ...typeof headmatter.description === 'string' && headmatter.description.trim() ? { description: headmatter.description.trim() } : {},
115
126
  ...typeof headmatter.lang === 'string' && headmatter.lang.trim() ? { lang: headmatter.lang.trim() } : {},
116
127
  ...tags.length > 0 ? { tags } : {},
117
128
  }
118
129
  }
119
130
 
120
131
  export function listDocuments(config: WorkspaceConfig): FoundDocument[] {
121
- return entries(config).map(entry => documentOf(config, entry))
132
+ return listDocumentEntries(config).map(entry => readDocumentSummary(config, entry))
133
+ }
134
+
135
+ export function documentSummaries(config: WorkspaceConfig): DocumentSummary[] {
136
+ return listDocuments(config).map(({ entry: _entry, ...document }) => document)
122
137
  }
123
138
 
124
- export function namedByPatterns(config: WorkspaceConfig, dir: string): boolean {
125
- return config.documents.some(pattern => matchesPattern(pattern, dir ? `${dir}/${DECK_ENTRY}` : DECK_ENTRY))
139
+ export function isDocumentDir(config: WorkspaceConfig, dir: string): boolean {
140
+ return config.documents.some(pattern => matchesPattern(pattern, dir ? `${dir}/${DOCUMENT_ENTRY}` : DOCUMENT_ENTRY))
126
141
  }
@@ -0,0 +1,30 @@
1
+ import { createReadStream, realpathSync } from 'node:fs'
2
+ import { basename, isAbsolute, relative, resolve, sep } from 'node:path'
3
+ import { Readable } from 'node:stream'
4
+ import { lookup } from 'mime-types'
5
+ import type { TeachingDocument } from '@lincyaw/kernel'
6
+ import { RequestError } from './errors.ts'
7
+
8
+ /** Read only a file registered by the current canonical document, without compiling it. */
9
+ export function documentAssetResponse(root: string, directory: string, document: TeachingDocument, id: string, readableFile: (path: string) => string, signal?: AbortSignal): Response {
10
+ const asset = document.assets[id]
11
+ if (!asset) throw new RequestError(404, 'document-asset-missing')
12
+ if (!asset.location || /^(?:[a-z][a-z\d+.-]*:|\/)/i.test(asset.location)) throw new RequestError(400, 'document-asset-not-local')
13
+ let location = asset.location.replace(/[?#].*$/s, '')
14
+ try { location = decodeURIComponent(location) } catch { throw new RequestError(400, 'document-asset-path') }
15
+ if (!location || isAbsolute(location) || location.includes('\\') || location.includes('\0')) throw new RequestError(400, 'document-asset-path')
16
+ const workspace = realpathSync(root), target = resolve(workspace, directory, location)
17
+ const path = relative(workspace, target)
18
+ if (!path || path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path)) throw new RequestError(403, 'document-asset-outside-workspace')
19
+ const file = readableFile(path.split(sep).join('/'))
20
+ const type = lookup(file) || 'application/octet-stream'
21
+ const inline = /^(?:image\/(?:png|jpeg|gif|webp|avif|svg\+xml|bmp|x-icon)|audio\/|video\/)/i.test(type)
22
+ return new Response(Readable.toWeb(createReadStream(file, { signal })), { headers: {
23
+ 'content-type': type,
24
+ 'cache-control': 'private, no-store',
25
+ 'content-disposition': `${inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(basename(file))}`,
26
+ 'x-content-type-options': 'nosniff',
27
+ 'cross-origin-resource-policy': 'same-origin',
28
+ 'content-security-policy': "sandbox; default-src 'none'; style-src 'unsafe-inline'",
29
+ } })
30
+ }