@pathmx/completion 0.5.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.md +172 -0
- package/README.md +171 -0
- package/contributors.ts +145 -0
- package/evidence.ts +46 -0
- package/index.ts +251 -0
- package/model.ts +50 -0
- package/package.json +39 -0
- package/react.tsx +116 -0
- package/state.ts +220 -0
- package/targets.ts +240 -0
package/targets.ts
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import type { MarkdownTask, PathMXDataSchema } from "@pathmx/core"
|
|
2
|
+
import { COMPLETION_KEY, type CompletionRef } from "./model.ts"
|
|
3
|
+
|
|
4
|
+
type CompletionNode = Readonly<{
|
|
5
|
+
id: string
|
|
6
|
+
data: Readonly<Record<string, unknown>>
|
|
7
|
+
}>
|
|
8
|
+
|
|
9
|
+
type CompletionBlock = CompletionNode &
|
|
10
|
+
Readonly<{
|
|
11
|
+
body: string
|
|
12
|
+
tasks: readonly MarkdownTask[]
|
|
13
|
+
}>
|
|
14
|
+
|
|
15
|
+
type CompletionSource = CompletionNode &
|
|
16
|
+
Readonly<{
|
|
17
|
+
blocks: readonly CompletionBlock[]
|
|
18
|
+
}>
|
|
19
|
+
|
|
20
|
+
type ManualCompletion = Readonly<{
|
|
21
|
+
target: true
|
|
22
|
+
label?: string
|
|
23
|
+
optional: boolean
|
|
24
|
+
}>
|
|
25
|
+
|
|
26
|
+
type TargetBase = Readonly<{
|
|
27
|
+
ref: CompletionRef
|
|
28
|
+
label: string
|
|
29
|
+
optional: boolean
|
|
30
|
+
}>
|
|
31
|
+
|
|
32
|
+
export type CompletionTarget =
|
|
33
|
+
| (TargetBase & Readonly<{ type: "source" }>)
|
|
34
|
+
| (TargetBase & Readonly<{ type: "block"; block: string }>)
|
|
35
|
+
| (TargetBase & Readonly<{ type: "task"; block: string; line: number }>)
|
|
36
|
+
|
|
37
|
+
type TargetCandidate = Readonly<{
|
|
38
|
+
target: CompletionTarget
|
|
39
|
+
valid: boolean
|
|
40
|
+
}>
|
|
41
|
+
|
|
42
|
+
function issue(message: string, path?: string) {
|
|
43
|
+
return {
|
|
44
|
+
issues: [{ message, ...(path ? { path: [path] } : {}) }],
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseManualCompletion(value: unknown) {
|
|
49
|
+
if (value == null || typeof value !== "object" || Array.isArray(value)) {
|
|
50
|
+
return issue("Expected an object")
|
|
51
|
+
}
|
|
52
|
+
const input = value as Record<string, unknown>
|
|
53
|
+
const extra = Object.keys(input).find(
|
|
54
|
+
(key) => key !== "target" && key !== "label" && key !== "optional",
|
|
55
|
+
)
|
|
56
|
+
if (extra) return issue("Unknown completion field", extra)
|
|
57
|
+
if (input.target !== true) return issue("Expected true", "target")
|
|
58
|
+
if (
|
|
59
|
+
input.label !== undefined &&
|
|
60
|
+
(typeof input.label !== "string" || !input.label.trim())
|
|
61
|
+
) {
|
|
62
|
+
return issue("Expected a non-empty string", "label")
|
|
63
|
+
}
|
|
64
|
+
if (input.optional !== undefined && typeof input.optional !== "boolean") {
|
|
65
|
+
return issue("Expected a boolean", "optional")
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
value: Object.freeze({
|
|
69
|
+
target: true as const,
|
|
70
|
+
...(typeof input.label === "string" ? { label: input.label.trim() } : {}),
|
|
71
|
+
optional: input.optional === true,
|
|
72
|
+
}),
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const CompletionPropInputJson = Object.freeze({
|
|
77
|
+
type: "object",
|
|
78
|
+
additionalProperties: false,
|
|
79
|
+
required: ["target"],
|
|
80
|
+
properties: {
|
|
81
|
+
target: { const: true },
|
|
82
|
+
label: { type: "string", minLength: 1 },
|
|
83
|
+
optional: { type: "boolean" },
|
|
84
|
+
},
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const CompletionPropOutputJson = Object.freeze({
|
|
88
|
+
...CompletionPropInputJson,
|
|
89
|
+
required: ["target", "optional"],
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
export const CompletionPropSchema: PathMXDataSchema<unknown, ManualCompletion> =
|
|
93
|
+
{
|
|
94
|
+
"~standard": {
|
|
95
|
+
version: 1,
|
|
96
|
+
vendor: "pathmx",
|
|
97
|
+
validate: parseManualCompletion,
|
|
98
|
+
jsonSchema: {
|
|
99
|
+
input: () => CompletionPropInputJson,
|
|
100
|
+
output: () => CompletionPropOutputJson,
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function manualCompletion(node: CompletionNode) {
|
|
106
|
+
if (!Object.hasOwn(node.data, "completion")) return
|
|
107
|
+
const result = parseManualCompletion(node.data.completion)
|
|
108
|
+
return "value" in result ? result.value : undefined
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function targetRef(source: CompletionSource, key: string) {
|
|
112
|
+
return Object.freeze({ source: source.id, key })
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function validKey(key: string) {
|
|
116
|
+
return key.length <= 256 && COMPLETION_KEY.test(key)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function authoredBlockId(block: CompletionBlock) {
|
|
120
|
+
return typeof block.data.id === "string" && block.data.id.trim() === block.id
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function sourceTarget(source: CompletionSource): TargetCandidate[] {
|
|
124
|
+
const completion = manualCompletion(source)
|
|
125
|
+
if (!completion) return []
|
|
126
|
+
return [
|
|
127
|
+
{
|
|
128
|
+
target: Object.freeze({
|
|
129
|
+
type: "source",
|
|
130
|
+
ref: targetRef(source, "source/self"),
|
|
131
|
+
label: completion.label ?? "Complete this source",
|
|
132
|
+
optional: completion.optional,
|
|
133
|
+
}),
|
|
134
|
+
valid: true,
|
|
135
|
+
},
|
|
136
|
+
]
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function blockTarget(
|
|
140
|
+
source: CompletionSource,
|
|
141
|
+
block: CompletionBlock,
|
|
142
|
+
issues: string[],
|
|
143
|
+
): TargetCandidate[] {
|
|
144
|
+
const completion = manualCompletion(block)
|
|
145
|
+
if (!completion) return []
|
|
146
|
+
const key = `block/${block.id}`
|
|
147
|
+
const authored = authoredBlockId(block)
|
|
148
|
+
if (!authored) {
|
|
149
|
+
issues.push(
|
|
150
|
+
`Completion Block ${source.id}#${block.id} must author a stable id.`,
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
if (!validKey(key)) {
|
|
154
|
+
issues.push(`Invalid Completion target key ${key} on ${source.id}.`)
|
|
155
|
+
}
|
|
156
|
+
return [
|
|
157
|
+
{
|
|
158
|
+
target: Object.freeze({
|
|
159
|
+
type: "block",
|
|
160
|
+
ref: targetRef(source, key),
|
|
161
|
+
label: completion.label ?? "Complete this section",
|
|
162
|
+
optional: completion.optional,
|
|
163
|
+
block: block.id,
|
|
164
|
+
}),
|
|
165
|
+
valid: authored && validKey(key),
|
|
166
|
+
},
|
|
167
|
+
]
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function taskTarget(
|
|
171
|
+
source: CompletionSource,
|
|
172
|
+
block: CompletionBlock,
|
|
173
|
+
task: MarkdownTask,
|
|
174
|
+
issues: string[],
|
|
175
|
+
): TargetCandidate[] {
|
|
176
|
+
if (!task.id) return []
|
|
177
|
+
const key = `task/${task.id}`
|
|
178
|
+
const label = task.text.trim()
|
|
179
|
+
if (task.checked) {
|
|
180
|
+
issues.push(
|
|
181
|
+
`Completion task ${key} on ${source.id}:${task.line} must be unchecked; [x] is shared document state.`,
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
if (!label) {
|
|
185
|
+
issues.push(
|
|
186
|
+
`Completion task ${key} on ${source.id}:${task.line} needs text.`,
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
if (!validKey(key)) {
|
|
190
|
+
issues.push(`Invalid Completion target key ${key} on ${source.id}.`)
|
|
191
|
+
}
|
|
192
|
+
return [
|
|
193
|
+
{
|
|
194
|
+
target: Object.freeze({
|
|
195
|
+
type: "task",
|
|
196
|
+
ref: targetRef(source, key),
|
|
197
|
+
label,
|
|
198
|
+
optional: task.tags.includes("optional"),
|
|
199
|
+
block: block.id,
|
|
200
|
+
line: task.line,
|
|
201
|
+
}),
|
|
202
|
+
valid: !task.checked && Boolean(label) && validKey(key),
|
|
203
|
+
},
|
|
204
|
+
]
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function inspectCompletionTargets(source: CompletionSource) {
|
|
208
|
+
const issues: string[] = []
|
|
209
|
+
const candidates = [
|
|
210
|
+
...sourceTarget(source),
|
|
211
|
+
...source.blocks.flatMap((block) => [
|
|
212
|
+
...blockTarget(source, block, issues),
|
|
213
|
+
...(block.body.includes("{#")
|
|
214
|
+
? block.tasks.flatMap((task) => taskTarget(source, block, task, issues))
|
|
215
|
+
: []),
|
|
216
|
+
]),
|
|
217
|
+
]
|
|
218
|
+
const counts = new Map<string, number>()
|
|
219
|
+
for (const { target } of candidates) {
|
|
220
|
+
counts.set(target.ref.key, (counts.get(target.ref.key) ?? 0) + 1)
|
|
221
|
+
}
|
|
222
|
+
for (const [key, count] of counts) {
|
|
223
|
+
if (count > 1) {
|
|
224
|
+
issues.push(`Duplicate Completion target ${key} on ${source.id}.`)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return Object.freeze({
|
|
228
|
+
targets: Object.freeze(
|
|
229
|
+
candidates.flatMap(({ target, valid }) =>
|
|
230
|
+
valid && counts.get(target.ref.key) === 1 ? [target] : [],
|
|
231
|
+
),
|
|
232
|
+
),
|
|
233
|
+
issues: Object.freeze(issues),
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Project valid manual and Markdown-task targets in authored order. */
|
|
238
|
+
export function completionTargets(source: CompletionSource) {
|
|
239
|
+
return inspectCompletionTargets(source).targets
|
|
240
|
+
}
|