sfora-cli 0.10.0 → 0.11.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.
Files changed (68) hide show
  1. package/README.md +139 -0
  2. package/dist/SforaFs.js +8 -6
  3. package/dist/api-client.d.ts +243 -4
  4. package/dist/api-client.js +248 -20
  5. package/dist/block-commands.d.ts +84 -0
  6. package/dist/block-commands.js +155 -0
  7. package/dist/cli.js +317 -26
  8. package/dist/format/blockSplice.d.ts +135 -0
  9. package/dist/format/blockSplice.js +330 -0
  10. package/dist/format/blocks/dropClosure.d.ts +10 -1
  11. package/dist/format/blocks/dropClosure.js +11 -1
  12. package/dist/format/callout.d.ts +69 -7
  13. package/dist/format/callout.js +112 -15
  14. package/dist/format/checklist.js +11 -4
  15. package/dist/format/formatAxes.d.ts +228 -0
  16. package/dist/format/formatAxes.js +454 -0
  17. package/dist/format/index.d.ts +1 -0
  18. package/dist/format/index.js +4 -0
  19. package/dist/format/lineGeometry.d.ts +34 -4
  20. package/dist/format/lineGeometry.js +140 -40
  21. package/dist/format/lint/appliesTo.d.ts +92 -0
  22. package/dist/format/lint/appliesTo.js +369 -0
  23. package/dist/format/lint/config.d.ts +106 -0
  24. package/dist/format/lint/config.js +205 -0
  25. package/dist/format/lint/fixAll.d.ts +62 -0
  26. package/dist/format/lint/fixAll.js +107 -0
  27. package/dist/format/lint/frontmatterSchema.d.ts +181 -0
  28. package/dist/format/lint/frontmatterSchema.js +660 -0
  29. package/dist/format/lint/index.d.ts +34 -5
  30. package/dist/format/lint/index.js +34 -5
  31. package/dist/format/lint/lintSource.d.ts +27 -7
  32. package/dist/format/lint/lintSource.js +67 -33
  33. package/dist/format/lint/rules/frontmatter-schema.d.ts +2 -0
  34. package/dist/format/lint/rules/frontmatter-schema.js +92 -0
  35. package/dist/format/lint/rules/index.d.ts +2 -1
  36. package/dist/format/lint/rules/index.js +7 -1
  37. package/dist/format/lint/rules/malformed-callout.js +25 -16
  38. package/dist/format/lint/rules/malformed-checklist.js +8 -3
  39. package/dist/format/lint/rules/malformed-frontmatter.js +6 -1
  40. package/dist/format/lint/severity.d.ts +15 -0
  41. package/dist/format/lint/severity.js +50 -0
  42. package/dist/format/lint/textEdits.d.ts +86 -0
  43. package/dist/format/lint/textEdits.js +162 -0
  44. package/dist/format/lint/types.d.ts +44 -8
  45. package/dist/format/markdown/slug.d.ts +28 -0
  46. package/dist/format/markdown/slug.js +63 -0
  47. package/dist/format/plaintext.js +13 -3
  48. package/dist/format/sheetCellSpans.d.ts +95 -0
  49. package/dist/format/sheetCellSpans.js +223 -0
  50. package/dist/format/sheetSelection.d.ts +136 -0
  51. package/dist/format/sheetSelection.js +282 -0
  52. package/dist/format/textStats.d.ts +23 -0
  53. package/dist/format/textStats.js +80 -0
  54. package/dist/format/wikiLinks.d.ts +60 -1
  55. package/dist/format/wikiLinks.js +195 -9
  56. package/dist/index.d.ts +26 -1
  57. package/dist/index.js +20 -3
  58. package/dist/opener.d.ts +23 -0
  59. package/dist/opener.js +26 -0
  60. package/dist/render.d.ts +132 -0
  61. package/dist/render.js +208 -0
  62. package/dist/shell-commands.d.ts +34 -0
  63. package/dist/shell-commands.js +108 -0
  64. package/dist/watch.d.ts +79 -0
  65. package/dist/watch.js +113 -0
  66. package/dist/web-url.d.ts +39 -0
  67. package/dist/web-url.js +63 -0
  68. package/package.json +1 -1
@@ -0,0 +1,660 @@
1
+ // GENERATED by scripts/sync-format.mjs from packages/markdown/src — DO NOT EDIT.
2
+ // Edit packages/markdown/src and re-run the sync (any sfora-cli build does it).
3
+ // What a document's frontmatter is allowed to say, checked.
4
+ //
5
+ // The last of the four partial rows in the parity charter's §2.1 that was a
6
+ // CAPABILITY gap rather than a decision (card #299): open-knowledge validates
7
+ // frontmatter against JSON Schema — Ajv, four dialects, per-key line anchoring,
8
+ // path-scoped, off by default (`ok/core/markdown/lint/frontmatter-validate.ts`,
9
+ // `okf-frontmatter/registry.ts`) — and sfora parsed frontmatter and never
10
+ // checked a byte of it. This module is sfora's half of that row.
11
+ //
12
+ // ---------------------------------------------------------------------------
13
+ // WHY NOT AJV
14
+ // ---------------------------------------------------------------------------
15
+ //
16
+ // Two reasons, and the second is the one that decides it.
17
+ //
18
+ // The lint core carries no third-party import at all. It is copied verbatim
19
+ // into the published CLI (`packages/sfora/scripts/sync-format.mjs`), so a
20
+ // dependency here is a dependency in the tarball, and the same rules have to
21
+ // run in a Convex mutation, in the editor and in the CLI. `appliesTo.ts` made
22
+ // exactly this trade already, for exactly this reason, and hand-rolled a glob
23
+ // dialect rather than take picomatch.
24
+ //
25
+ // The deciding reason is the VALUE MODEL. Sfora's frontmatter parser
26
+ // (`markdown/yaml.ts`) is a tiny YAML: `key: scalar` and `key: [a, b, c]`,
27
+ // nothing else, and every value it produces is a `string` or a `string[]`.
28
+ // There is no `true`, no `42`, no nested map — not because the parser is
29
+ // unfinished but because that is the shape every sfora fs surface writes. So a
30
+ // general JSON-Schema engine would spend its whole vocabulary on shapes this
31
+ // document model cannot hold, and — much worse — would report `type: "number"`
32
+ // failures on every numeric field in every sfora document, because `42` arrives
33
+ // as `"42"`. Ajv would be confidently wrong here, at volume.
34
+ //
35
+ // What this module does instead: read the JSON Schema an author wrote, and
36
+ // interpret it AGAINST SFORA'S VALUE MODEL. A `type: "number"` is satisfied by
37
+ // a string that spells a number, because that is what a number looks like in
38
+ // this document format. That is a real, stateable semantics rather than a
39
+ // subset with holes in it, and {@link SUPPORTED_KEYWORDS} is the whole of it.
40
+ //
41
+ // ---------------------------------------------------------------------------
42
+ // NOTHING IS SILENTLY IGNORED
43
+ // ---------------------------------------------------------------------------
44
+ //
45
+ // The failure mode a validator has is the one `config.ts` already names: a
46
+ // setting that quietly does nothing, so the document comes back clean and the
47
+ // author believes they are covered. A schema keyword this module does not
48
+ // implement is therefore REPORTED — {@link lintFrontmatterSchemas} is the
49
+ // schema's own linter, the same shape as `lintLintConfig`, and it is what makes
50
+ // "supported subset" a promise instead of an excuse.
51
+ //
52
+ // ---------------------------------------------------------------------------
53
+ // AND IT IS OFF UNTIL A WORKSPACE TURNS IT ON
54
+ // ---------------------------------------------------------------------------
55
+ //
56
+ // No schema ships enabled. `LintConfig.frontmatterSchemas` is empty until a
57
+ // workspace declares one, and a declaration carries the document kind it is
58
+ // for and the paths it applies to. Open-knowledge defaults its whole lint stack
59
+ // to off for the same reason (`ok/core/markdown/lint/plugins.ts:103-108`): a
60
+ // rule about what a document MUST contain is a house rule, and houses differ.
61
+ import { parseYaml } from "../markdown/yaml.js";
62
+ import { compileAppliesTo } from "./appliesTo.js";
63
+ import { isLintSeverity } from "./severity.js";
64
+ /* ------------------------------------------------------------------------- */
65
+ /* The schema dialect */
66
+ /* ------------------------------------------------------------------------- */
67
+ /**
68
+ * Every keyword this module reads, and what it does with it.
69
+ *
70
+ * The list is exported because it is the contract: {@link lintFrontmatterSchemas}
71
+ * reports anything outside it, and the test that keeps the two honest reads
72
+ * this object rather than a second copy of the list.
73
+ *
74
+ * `annotation` keywords are read by nobody and reported by nobody — they are
75
+ * documentation, and a schema that carries a `description` for its agents is
76
+ * doing the right thing.
77
+ */
78
+ export const SUPPORTED_KEYWORDS = {
79
+ $schema: "annotation",
80
+ $id: "annotation",
81
+ title: "annotation",
82
+ description: "annotation",
83
+ examples: "annotation",
84
+ default: "annotation",
85
+ type: "constraint",
86
+ properties: "constraint",
87
+ required: "constraint",
88
+ additionalProperties: "constraint",
89
+ enum: "constraint",
90
+ const: "constraint",
91
+ pattern: "constraint",
92
+ format: "constraint",
93
+ minLength: "constraint",
94
+ maxLength: "constraint",
95
+ items: "constraint",
96
+ minItems: "constraint",
97
+ maxItems: "constraint",
98
+ uniqueItems: "constraint",
99
+ };
100
+ /**
101
+ * The `format` values that mean something here.
102
+ *
103
+ * Deliberately five, and deliberately the five a sfora frontmatter block
104
+ * actually holds — a date, a timestamp, a link, an address, a slug. A `format`
105
+ * outside this set is reported rather than ignored, which is the whole policy
106
+ * of this file in one line.
107
+ */
108
+ export const SUPPORTED_FORMATS = [
109
+ "date",
110
+ "date-time",
111
+ "uri",
112
+ "email",
113
+ "slug",
114
+ ];
115
+ /**
116
+ * The declarations that apply to `path`.
117
+ *
118
+ * Fail-CLOSED where `scopeAdmits` fails open, and the asymmetry is deliberate.
119
+ * A scoped RULE narrows something that would otherwise run everywhere, so a
120
+ * document whose path we do not know keeps the rule (see `config.ts`). A
121
+ * frontmatter schema is the opposite: it exists only for a kind of document,
122
+ * and running a `decision` schema over an unknown document would report a
123
+ * missing `status` on a chat message. Silence is the cheaper failure here, and
124
+ * a declaration with NO `appliesTo` still runs on everything, which is the door
125
+ * for a workspace that means it.
126
+ */
127
+ export function selectFrontmatterSchemas(declarations, path) {
128
+ if (!declarations || declarations.length === 0)
129
+ return [];
130
+ const out = [];
131
+ for (const declaration of declarations) {
132
+ const scope = compileAppliesTo(declaration.appliesTo);
133
+ if (scope.matchesEverything || scope.matches(path)) {
134
+ out.push({ declaration, scope });
135
+ }
136
+ }
137
+ return out;
138
+ }
139
+ /**
140
+ * The default level for each kind of violation, in the vocabulary of
141
+ * ./severity's ladder — which is ordered by WHAT THE DOCUMENT LOSES.
142
+ *
143
+ * `missing` and `invalid` are both `warning`: the bytes are there, and what
144
+ * they say is not what the workspace will read. `unknown` is `info`: the
145
+ * author wrote something, it renders, and nothing will ever look at it —
146
+ * which is the ladder's definition of `info` word for word.
147
+ *
148
+ * Nothing here is `error`. `error` on this ladder means the document as a whole
149
+ * stops meaning what it says, and the one rule that claims it (an unclosed
150
+ * `---` fence) earns it. A `status:` the schema does not recognise costs the
151
+ * document one field, not its identity, and a validator that shouted would
152
+ * teach people to turn the validator off.
153
+ */
154
+ const DEFAULT_SEVERITY = {
155
+ missing: "warning",
156
+ invalid: "warning",
157
+ unknown: "info",
158
+ };
159
+ function severityFor(declaration, kind) {
160
+ const declared = declaration.severity;
161
+ if (declared !== undefined && isLintSeverity(declared))
162
+ return declared;
163
+ return DEFAULT_SEVERITY[kind];
164
+ }
165
+ /** `["a", "b"]` -> `` `a`, `b` ``, the one spelling every message uses. */
166
+ function list(values) {
167
+ return values.map((value) => `\`${String(value)}\``).join(", ");
168
+ }
169
+ const FORMAT_TEST = {
170
+ // Calendar-shaped rather than calendar-correct: `2026-02-31` passes. A lint
171
+ // rule that rejected a real date because February is short would be arguing
172
+ // about a timezone, and this one is about a field being a date at all.
173
+ date: (value) => /^\d{4}-\d{2}-\d{2}$/.test(value),
174
+ "date-time": (value) => /^\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?([Zz]|[+-]\d{2}:?\d{2})?$/.test(value),
175
+ // A scheme and something after it. Not a URL parser: `new URL` would accept
176
+ // `a:b` and reject a bare `example.com`, and neither answer helps an author.
177
+ uri: (value) => /^[a-z][a-z0-9+.-]*:\/\/\S+$|^[a-z][a-z0-9+.-]*:\S+$/i.test(value),
178
+ email: (value) => /^[^\s@]+@[^\s@.]+\.[^\s@]+$/.test(value),
179
+ slug: (value) => /^[a-z0-9]+(-[a-z0-9]+)*$/.test(value),
180
+ };
181
+ /**
182
+ * Does `value` spell a `type`?
183
+ *
184
+ * THE WHOLE INTERPRETATION LIVES HERE. Frontmatter values arrive as strings
185
+ * because `markdown/yaml.ts` produces strings; a schema that says `number`
186
+ * means "a number is written here", and this is where those two facts meet.
187
+ * `integer`, `number` and `boolean` are checked against the SPELLING; `string`
188
+ * accepts any scalar; `array` accepts the one list form the parser produces.
189
+ */
190
+ function matchesType(value, type) {
191
+ const isArray = Array.isArray(value);
192
+ switch (type) {
193
+ case "array":
194
+ return isArray;
195
+ case "string":
196
+ return !isArray;
197
+ case "number":
198
+ return !isArray && value.trim() !== "" && Number.isFinite(Number(value));
199
+ case "integer":
200
+ return !isArray && /^[+-]?\d+$/.test(value.trim());
201
+ case "boolean":
202
+ return !isArray && /^(true|false)$/i.test(value.trim());
203
+ case "object":
204
+ // Sfora's frontmatter has no nested maps at all, so a schema asking for
205
+ // one is asking for something this format cannot hold. Reported by
206
+ // `lintFrontmatterSchemas` as an unsupported type rather than failed
207
+ // here on every document.
208
+ return false;
209
+ case "null":
210
+ return !isArray && value.trim() === "";
211
+ default:
212
+ return true;
213
+ }
214
+ }
215
+ /** The article-free noun a message uses for a type. */
216
+ function typeWord(type) {
217
+ return type === "array" ? "a list" : `a ${type}`;
218
+ }
219
+ /** Read one property schema into the checks it asks for, in report order. */
220
+ function constraintsOf(property) {
221
+ const out = [];
222
+ const type = property.type;
223
+ if (typeof type === "string") {
224
+ out.push({
225
+ keyword: "type",
226
+ check: (value) => matchesType(value, type)
227
+ ? null
228
+ : `must be ${typeWord(type)}, and this is ${Array.isArray(value) ? "a list" : `\`${value}\``}`,
229
+ });
230
+ }
231
+ const constValue = property.const;
232
+ if (constValue !== undefined) {
233
+ const expected = String(constValue);
234
+ out.push({
235
+ keyword: "const",
236
+ check: (value) => !Array.isArray(value) && value === expected
237
+ ? null
238
+ : `must be \`${expected}\``,
239
+ suggest: () => expected,
240
+ });
241
+ }
242
+ const allowed = property.enum;
243
+ if (Array.isArray(allowed)) {
244
+ const spellings = allowed.map(String);
245
+ out.push({
246
+ keyword: "enum",
247
+ check: (value) => !Array.isArray(value) && spellings.includes(value)
248
+ ? null
249
+ : `must be one of ${list(spellings)}`,
250
+ // The near miss, and only the near miss. An author who typed `Shipped`
251
+ // for `shipped` gets a one-click fix; an author who typed something else
252
+ // entirely gets the list and no guess, because a validator that puts
253
+ // words in a document is worse than one that points at a line.
254
+ suggest: (value) => Array.isArray(value)
255
+ ? undefined
256
+ : spellings.find((candidate) => candidate.toLowerCase() === value.trim().toLowerCase()),
257
+ });
258
+ }
259
+ const pattern = property.pattern;
260
+ if (typeof pattern === "string") {
261
+ let re = null;
262
+ try {
263
+ re = new RegExp(pattern);
264
+ }
265
+ catch {
266
+ // Reported by `lintFrontmatterSchemas`. Refusing every document because
267
+ // the SCHEMA is broken would blame the wrong author.
268
+ re = null;
269
+ }
270
+ if (re) {
271
+ const test = re;
272
+ out.push({
273
+ keyword: "pattern",
274
+ check: (value) => !Array.isArray(value) && test.test(value)
275
+ ? null
276
+ : `must match \`${pattern}\``,
277
+ });
278
+ }
279
+ }
280
+ const format = property.format;
281
+ if (typeof format === "string" && format in FORMAT_TEST) {
282
+ const test = FORMAT_TEST[format];
283
+ out.push({
284
+ keyword: "format",
285
+ check: (value) => !Array.isArray(value) && test(value) ? null : `must be a ${format}`,
286
+ });
287
+ }
288
+ const minLength = property.minLength;
289
+ if (typeof minLength === "number") {
290
+ out.push({
291
+ keyword: "minLength",
292
+ check: (value) => !Array.isArray(value) && value.length >= minLength
293
+ ? null
294
+ : `must be at least ${minLength} characters`,
295
+ });
296
+ }
297
+ const maxLength = property.maxLength;
298
+ if (typeof maxLength === "number") {
299
+ out.push({
300
+ keyword: "maxLength",
301
+ check: (value) => !Array.isArray(value) && value.length <= maxLength
302
+ ? null
303
+ : `must be at most ${maxLength} characters`,
304
+ });
305
+ }
306
+ const minItems = property.minItems;
307
+ if (typeof minItems === "number") {
308
+ out.push({
309
+ keyword: "minItems",
310
+ check: (value) => Array.isArray(value) && value.length >= minItems
311
+ ? null
312
+ : `must list at least ${minItems}`,
313
+ });
314
+ }
315
+ const maxItems = property.maxItems;
316
+ if (typeof maxItems === "number") {
317
+ out.push({
318
+ keyword: "maxItems",
319
+ check: (value) => Array.isArray(value) && value.length <= maxItems
320
+ ? null
321
+ : `must list at most ${maxItems}`,
322
+ });
323
+ }
324
+ if (property.uniqueItems === true) {
325
+ out.push({
326
+ keyword: "uniqueItems",
327
+ check: (value) => !Array.isArray(value) || new Set(value).size === value.length
328
+ ? null
329
+ : "must not repeat an entry",
330
+ });
331
+ }
332
+ const items = property.items;
333
+ if (isSchemaObject(items)) {
334
+ const inner = constraintsOf(items);
335
+ out.push({
336
+ keyword: "items",
337
+ check: (value) => {
338
+ if (!Array.isArray(value))
339
+ return null;
340
+ for (const entry of value) {
341
+ for (const constraint of inner) {
342
+ const failure = constraint.check(entry);
343
+ if (failure)
344
+ return `has an entry that ${failure}`;
345
+ }
346
+ }
347
+ return null;
348
+ },
349
+ });
350
+ }
351
+ return out;
352
+ }
353
+ function isSchemaObject(value) {
354
+ return (typeof value === "object" && value !== null && !Array.isArray(value));
355
+ }
356
+ /**
357
+ * Check one document's frontmatter against every schema declared for it.
358
+ *
359
+ * `data` is what `markdown/yaml.ts` read, so this function grades the values
360
+ * SFORA WILL ACTUALLY USE rather than a second reading of the same bytes. That
361
+ * matters more than it sounds: a key the tiny YAML drops (no colon, a nested
362
+ * map) is a key the workspace does not have, and a validator that read it with
363
+ * a fuller parser would pass a document whose `status` no consumer can see.
364
+ * The structural half of that — telling the author their line was dropped —
365
+ * belongs to `malformed-frontmatter`, which is why this rule stays silent about
366
+ * it instead of saying the same thing twice.
367
+ */
368
+ export function validateFrontmatter(data, schemas) {
369
+ const out = [];
370
+ for (const { declaration } of schemas) {
371
+ const { schema, kind } = declaration;
372
+ const properties = isSchemaObject(schema.properties) ? schema.properties : {};
373
+ const required = Array.isArray(schema.required) ? schema.required : [];
374
+ for (const name of required) {
375
+ if (typeof name !== "string")
376
+ continue;
377
+ if (name in data)
378
+ continue;
379
+ const property = isSchemaObject(properties[name])
380
+ ? properties[name]
381
+ : undefined;
382
+ // The suggestion goes in the SENTENCE, not into a fix. Where a key
383
+ // belongs inside a metadata block is the author's ordering, and a
384
+ // validator that writes a line into a document it is only supposed to
385
+ // grade has stopped being a linter — so a schema that names one obvious
386
+ // value says so, and the author types it.
387
+ const only = onlyValueOf(property);
388
+ out.push({
389
+ kind: "missing",
390
+ key: name,
391
+ documentKind: kind,
392
+ keyword: "required",
393
+ severity: severityFor(declaration, "missing"),
394
+ message: only === undefined
395
+ ? `A \`${kind}\` document needs \`${name}\` in its frontmatter.`
396
+ : `A \`${kind}\` document needs \`${name}\` in its frontmatter — the schema allows \`${only}\`.`,
397
+ });
398
+ }
399
+ for (const [name, raw] of Object.entries(data)) {
400
+ const property = properties[name];
401
+ if (!isSchemaObject(property)) {
402
+ if (schema.additionalProperties === false) {
403
+ out.push({
404
+ kind: "unknown",
405
+ key: name,
406
+ documentKind: kind,
407
+ keyword: "additionalProperties",
408
+ severity: severityFor(declaration, "unknown"),
409
+ message: `Nothing reads \`${name}\` on a \`${kind}\` document — it is not in the schema.`,
410
+ });
411
+ }
412
+ continue;
413
+ }
414
+ for (const constraint of constraintsOf(property)) {
415
+ const failure = constraint.check(raw);
416
+ if (failure === null)
417
+ continue;
418
+ const suggestion = constraint.suggest?.(raw);
419
+ out.push({
420
+ kind: "invalid",
421
+ key: name,
422
+ documentKind: kind,
423
+ keyword: constraint.keyword,
424
+ severity: severityFor(declaration, "invalid"),
425
+ message: `On a \`${kind}\` document, \`${name}\` ${failure}.`,
426
+ ...(suggestion === undefined ? {} : { suggestion }),
427
+ });
428
+ // One complaint per key per schema. An author who wrote `Shipped`
429
+ // where the enum wants `shipped` does not need to be told separately
430
+ // that it also failed a `pattern` derived from the same list.
431
+ break;
432
+ }
433
+ }
434
+ }
435
+ return out;
436
+ }
437
+ /** The one value a missing key could obviously take, when the schema names it. */
438
+ function onlyValueOf(property) {
439
+ if (!property)
440
+ return undefined;
441
+ if (property.const !== undefined)
442
+ return String(property.const);
443
+ if (property.default !== undefined && !Array.isArray(property.default)) {
444
+ return String(property.default);
445
+ }
446
+ if (Array.isArray(property.enum) && property.enum.length === 1) {
447
+ return String(property.enum[0]);
448
+ }
449
+ return undefined;
450
+ }
451
+ /**
452
+ * Read a frontmatter block's key lines.
453
+ *
454
+ * Same reading as `parseYaml` — a top-level `key:` and nothing indented — so
455
+ * the line a mark lands on is the line the value came from. Duplicate keys
456
+ * resolve to the FIRST occurrence, which is where `malformed-frontmatter`
457
+ * already points its own duplicate mark, so the two rules agree about which
458
+ * line a key is on.
459
+ */
460
+ export function frontmatterKeyLines(lines, block) {
461
+ const out = new Map();
462
+ for (let i = block.open + 1; i < block.close; i++) {
463
+ const raw = lines[i] ?? "";
464
+ if (raw.startsWith(" ") || raw.startsWith("\t"))
465
+ continue;
466
+ const text = raw.trim();
467
+ if (text === "" || text.startsWith("#"))
468
+ continue;
469
+ const colon = text.indexOf(":");
470
+ if (colon <= 0)
471
+ continue;
472
+ const key = text.slice(0, colon).trim();
473
+ if (key === "" || out.has(key))
474
+ continue;
475
+ out.set(key, i);
476
+ }
477
+ return out;
478
+ }
479
+ /** The block's body, for `parseYaml`. */
480
+ export function frontmatterBody(lines, block) {
481
+ return lines.slice(block.open + 1, block.close).join("\n");
482
+ }
483
+ /** Read a document's frontmatter the way sfora reads it. */
484
+ export function readFrontmatter(lines, block) {
485
+ return parseYaml(frontmatterBody(lines, block));
486
+ }
487
+ /* ------------------------------------------------------------------------- */
488
+ /* The schema's own linter */
489
+ /* ------------------------------------------------------------------------- */
490
+ export const FRONTMATTER_SCHEMA_RULE_IDS = {
491
+ unknownKeyword: "sfora/schema-unknown-keyword",
492
+ unsupportedType: "sfora/schema-unsupported-type",
493
+ unsupportedFormat: "sfora/schema-unsupported-format",
494
+ invalidPattern: "sfora/schema-invalid-pattern",
495
+ invalidSeverity: "sfora/schema-invalid-severity",
496
+ invalidGlob: "sfora/schema-invalid-glob",
497
+ suspiciousGlob: "sfora/schema-suspicious-glob",
498
+ requiredNotDeclared: "sfora/schema-required-not-declared",
499
+ emptyKind: "sfora/schema-no-kind",
500
+ };
501
+ /**
502
+ * Types {@link matchesType} can answer for. `object` is deliberately absent.
503
+ *
504
+ * Exported for the same reason {@link SUPPORTED_FORMATS} is: it is a promise
505
+ * about behaviour, and its test drives the spelling each type accepts off this
506
+ * list rather than off a second copy of it. A type added here without a reading
507
+ * in `matchesType` fails that test.
508
+ */
509
+ export const SUPPORTED_TYPES = [
510
+ "string",
511
+ "array",
512
+ "number",
513
+ "integer",
514
+ "boolean",
515
+ "null",
516
+ ];
517
+ /**
518
+ * Read the declarations back and report every part of them that will silently
519
+ * do nothing.
520
+ *
521
+ * This is the promise that makes a supported SUBSET honest. An author writes a
522
+ * schema, this module reads the keywords it knows, and without this function
523
+ * every keyword it does not know would be a constraint the author believes is
524
+ * being enforced and that nothing enforces. `config.ts` calls that "the worst
525
+ * thing a linter can do, because the symptom is a clean document"; a validator
526
+ * has the same failure mode one layer up.
527
+ */
528
+ export function lintFrontmatterSchemas(declarations) {
529
+ const out = [];
530
+ if (!declarations)
531
+ return out;
532
+ declarations.forEach((declaration, index) => {
533
+ if (typeof declaration.kind !== "string" || declaration.kind.trim() === "") {
534
+ out.push({
535
+ severity: "warning",
536
+ ruleId: FRONTMATTER_SCHEMA_RULE_IDS.emptyKind,
537
+ message: "This schema has no `kind`, so its marks cannot say which kind of document they are about.",
538
+ at: [index, "kind"],
539
+ });
540
+ }
541
+ const severity = declaration.severity;
542
+ if (severity !== undefined && !isLintSeverity(severity)) {
543
+ out.push({
544
+ severity: "warning",
545
+ ruleId: FRONTMATTER_SCHEMA_RULE_IDS.invalidSeverity,
546
+ message: `\`${String(severity)}\` is not a severity, so this schema reports at its defaults.`,
547
+ at: [index, "severity"],
548
+ });
549
+ }
550
+ const compiled = compileAppliesTo(declaration.appliesTo);
551
+ const patterns = declaration.appliesTo === undefined
552
+ ? []
553
+ : typeof declaration.appliesTo === "string"
554
+ ? [declaration.appliesTo]
555
+ : [...declaration.appliesTo];
556
+ for (const bad of compiled.invalid) {
557
+ out.push({
558
+ severity: "warning",
559
+ ruleId: FRONTMATTER_SCHEMA_RULE_IDS.invalidGlob,
560
+ message: `\`${bad.pattern}\` is not a usable path pattern (${bad.detail}), so this schema runs on nothing.`,
561
+ at: [index, "appliesTo", ...indexOf(patterns, bad.pattern)],
562
+ });
563
+ }
564
+ for (const doubt of compiled.suspicious) {
565
+ const hint = doubt.suggestion ? ` Did you mean \`${doubt.suggestion}\`?` : "";
566
+ out.push({
567
+ severity: "warning",
568
+ ruleId: FRONTMATTER_SCHEMA_RULE_IDS.suspiciousGlob,
569
+ message: `\`${doubt.pattern}\` does not look like a document path (${doubt.reason}).${hint}`,
570
+ at: [index, "appliesTo", ...indexOf(patterns, doubt.pattern)],
571
+ });
572
+ }
573
+ const schema = declaration.schema;
574
+ if (!isSchemaObject(schema))
575
+ return;
576
+ const at = [index, "schema"];
577
+ reportKeywords(schema, at, out);
578
+ const properties = isSchemaObject(schema.properties) ? schema.properties : {};
579
+ for (const [name, property] of Object.entries(properties)) {
580
+ if (!isSchemaObject(property))
581
+ continue;
582
+ reportProperty(property, [...at, "properties", name], out);
583
+ }
584
+ // A `required` key with no `properties` entry is the quietest hole of all:
585
+ // the presence check runs, and every constraint the author thinks they
586
+ // wrote for that key is somewhere else entirely.
587
+ const required = Array.isArray(schema.required) ? schema.required : [];
588
+ required.forEach((name, i) => {
589
+ if (typeof name !== "string")
590
+ return;
591
+ if (name in properties)
592
+ return;
593
+ out.push({
594
+ severity: "info",
595
+ ruleId: FRONTMATTER_SCHEMA_RULE_IDS.requiredNotDeclared,
596
+ message: `\`${name}\` is required but has no entry under \`properties\`, so only its presence is checked.`,
597
+ at: [...at, "required", i],
598
+ });
599
+ });
600
+ });
601
+ return out;
602
+ }
603
+ function reportKeywords(schema, at, out) {
604
+ for (const keyword of Object.keys(schema)) {
605
+ if (keyword in SUPPORTED_KEYWORDS)
606
+ continue;
607
+ out.push({
608
+ severity: "warning",
609
+ ruleId: FRONTMATTER_SCHEMA_RULE_IDS.unknownKeyword,
610
+ message: `\`${keyword}\` is not a keyword sfora checks, so it constrains nothing. Supported: ${list(Object.keys(SUPPORTED_KEYWORDS).filter((name) => SUPPORTED_KEYWORDS[name] === "constraint"))}.`,
611
+ at: [...at, keyword],
612
+ });
613
+ }
614
+ }
615
+ function reportProperty(property, at, out) {
616
+ reportKeywords(property, at, out);
617
+ const type = property.type;
618
+ if (typeof type === "string" &&
619
+ !SUPPORTED_TYPES.includes(type)) {
620
+ out.push({
621
+ severity: "warning",
622
+ ruleId: FRONTMATTER_SCHEMA_RULE_IDS.unsupportedType,
623
+ message: type === "object"
624
+ ? "Sfora frontmatter holds scalars and flat lists, so an `object` property can never be satisfied."
625
+ : `\`${type}\` is not a type sfora checks. Supported: ${list(SUPPORTED_TYPES)}.`,
626
+ at: [...at, "type"],
627
+ });
628
+ }
629
+ const format = property.format;
630
+ if (typeof format === "string" && !(format in FORMAT_TEST)) {
631
+ out.push({
632
+ severity: "warning",
633
+ ruleId: FRONTMATTER_SCHEMA_RULE_IDS.unsupportedFormat,
634
+ message: `\`${format}\` is not a format sfora checks, so it constrains nothing. Supported: ${list(SUPPORTED_FORMATS)}.`,
635
+ at: [...at, "format"],
636
+ });
637
+ }
638
+ const pattern = property.pattern;
639
+ if (typeof pattern === "string") {
640
+ try {
641
+ new RegExp(pattern);
642
+ }
643
+ catch (err) {
644
+ out.push({
645
+ severity: "warning",
646
+ ruleId: FRONTMATTER_SCHEMA_RULE_IDS.invalidPattern,
647
+ message: `\`${pattern}\` is not a usable regular expression (${err instanceof Error ? err.message : String(err)}), so it constrains nothing.`,
648
+ at: [...at, "pattern"],
649
+ });
650
+ }
651
+ }
652
+ const items = property.items;
653
+ if (isSchemaObject(items))
654
+ reportProperty(items, [...at, "items"], out);
655
+ }
656
+ /** `["appliesTo", 2]`, or just the tail when the pattern is not found. */
657
+ function indexOf(patterns, pattern) {
658
+ const index = patterns.findIndex((p) => p.trim() === pattern.trim());
659
+ return index === -1 ? [] : [index];
660
+ }