patram 0.5.0 → 0.6.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.
@@ -0,0 +1,472 @@
1
+ /** @import * as yaml from 'yaml'; */
2
+ /* eslint-disable max-lines */
3
+ /**
4
+ * @import { PatramDiagnostic } from './load-patram-config.types.ts';
5
+ * @import { ParseClaimsInput, ParseSourceFileResult, PatramClaimFields } from './parse-claims.types.ts';
6
+ */
7
+
8
+ import { isMap, isScalar, isSeq, LineCounter, parseAllDocuments } from 'yaml';
9
+
10
+ import { createClaim, getFileExtension } from './claim-helpers.js';
11
+ import { YAML_SOURCE_FILE_EXTENSIONS } from './source-file-defaults.js';
12
+
13
+ /**
14
+ * YAML claim parsing.
15
+ *
16
+ * Parses standalone YAML metadata files and front matter with one projection
17
+ * model for top-level scalar directives.
18
+ *
19
+ * Kind: parse
20
+ * Status: active
21
+ * Tracked in: ../docs/plans/v0/yaml-source-and-front-matter.md
22
+ * Decided by: ../docs/decisions/yaml-source-and-front-matter.md
23
+ * @patram
24
+ * @see {@link ./parse-claims.js}
25
+ * @see {@link ./parse-markdown-directives.js}
26
+ */
27
+
28
+ const YAML_EXTENSIONS = new Set(YAML_SOURCE_FILE_EXTENSIONS);
29
+
30
+ /**
31
+ * Parse standalone YAML source into neutral directive claims.
32
+ *
33
+ * @param {ParseClaimsInput} parse_input
34
+ * @param {{ multi_value_directive_names?: ReadonlySet<string> }} [parse_options]
35
+ * @returns {ParseSourceFileResult}
36
+ */
37
+ export function parseYamlClaims(parse_input, parse_options) {
38
+ if (!YAML_EXTENSIONS.has(getFileExtension(parse_input.path))) {
39
+ return {
40
+ claims: [],
41
+ diagnostics: [],
42
+ };
43
+ }
44
+
45
+ const parse_result = parseYamlDirectiveFields({
46
+ file_path: parse_input.path,
47
+ parser: 'yaml',
48
+ source_text: parse_input.source,
49
+ start_line: 1,
50
+ multi_value_directive_names: parse_options?.multi_value_directive_names,
51
+ });
52
+
53
+ return {
54
+ claims: parse_result.directive_fields.map((directive_fields, claim_index) =>
55
+ createClaim(
56
+ parse_input.path,
57
+ claim_index + 1,
58
+ 'directive',
59
+ directive_fields,
60
+ ),
61
+ ),
62
+ diagnostics: parse_result.diagnostics,
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Parse YAML metadata into neutral directive fields.
68
+ *
69
+ * @param {{
70
+ * file_path: string,
71
+ * parser: 'markdown' | 'yaml',
72
+ * source_text: string,
73
+ * start_line: number,
74
+ * markdown_style?: 'front_matter',
75
+ * multi_value_directive_names?: ReadonlySet<string>,
76
+ * }} parse_input
77
+ * @returns {{ diagnostics: PatramDiagnostic[], directive_fields: PatramClaimFields[] }}
78
+ */
79
+ export function parseYamlDirectiveFields(parse_input) {
80
+ const line_counter = new LineCounter();
81
+ const yaml_documents = parseAllDocuments(parse_input.source_text, {
82
+ lineCounter: line_counter,
83
+ prettyErrors: false,
84
+ });
85
+ const parse_result = resolveYamlParseResult(
86
+ parse_input,
87
+ yaml_documents,
88
+ line_counter,
89
+ );
90
+
91
+ if (!parse_result.success) {
92
+ return parse_result.value;
93
+ }
94
+
95
+ return {
96
+ diagnostics: [],
97
+ directive_fields: collectDirectiveFields(
98
+ parse_input,
99
+ parse_result.value,
100
+ line_counter,
101
+ ),
102
+ };
103
+ }
104
+
105
+ /**
106
+ * @param {{
107
+ * file_path: string,
108
+ * start_line: number,
109
+ * }} parse_input
110
+ * @param {any[]} yaml_documents
111
+ * @param {LineCounter} line_counter
112
+ * @returns {{
113
+ success: true,
114
+ value: yaml.YAMLMap<unknown, unknown>
115
+ } | {
116
+ success: false,
117
+ value: {diagnostics: PatramDiagnostic[], directive_fields: PatramClaimFields[]}
118
+ }}
119
+ * success: true,
120
+ * value: import('yaml').YAMLMap<unknown, unknown>,
121
+ * } | {
122
+ * success: false,
123
+ * value: { diagnostics: PatramDiagnostic[], directive_fields: PatramClaimFields[] },
124
+ * }}
125
+ */
126
+ function resolveYamlParseResult(parse_input, yaml_documents, line_counter) {
127
+ if (yaml_documents.length !== 1) {
128
+ return {
129
+ success: false,
130
+ value: createDiagnosticResult([
131
+ createYamlDiagnostic(
132
+ parse_input.file_path,
133
+ line_counter,
134
+ yaml_documents[1]?.range?.[0] ?? 0,
135
+ parse_input.start_line,
136
+ 'yaml.multiple_documents',
137
+ 'Patram YAML sources must contain exactly one document.',
138
+ ),
139
+ ]),
140
+ };
141
+ }
142
+
143
+ const yaml_document = yaml_documents[0];
144
+
145
+ if (yaml_document.errors.length > 0) {
146
+ return {
147
+ success: false,
148
+ value: createDiagnosticResult(
149
+ yaml_document.errors.map(
150
+ /** @param {{ message: string, pos: [number, number] }} yaml_error */
151
+ (yaml_error) =>
152
+ createYamlDiagnostic(
153
+ parse_input.file_path,
154
+ line_counter,
155
+ yaml_error.pos[0] ?? 0,
156
+ parse_input.start_line,
157
+ 'yaml.invalid_syntax',
158
+ yaml_error.message,
159
+ ),
160
+ ),
161
+ ),
162
+ };
163
+ }
164
+
165
+ if (!isMap(yaml_document.contents)) {
166
+ return {
167
+ success: false,
168
+ value: createDiagnosticResult([
169
+ createYamlDiagnostic(
170
+ parse_input.file_path,
171
+ line_counter,
172
+ resolveNodeRangeStart(yaml_document.contents),
173
+ parse_input.start_line,
174
+ 'yaml.invalid_root',
175
+ 'Patram YAML metadata must use one top-level mapping.',
176
+ ),
177
+ ]),
178
+ };
179
+ }
180
+
181
+ return {
182
+ success: true,
183
+ value: yaml_document.contents,
184
+ };
185
+ }
186
+
187
+ /**
188
+ * @param {{
189
+ * file_path: string,
190
+ * parser: 'markdown' | 'yaml',
191
+ * start_line: number,
192
+ * markdown_style?: 'front_matter',
193
+ * multi_value_directive_names?: ReadonlySet<string>,
194
+ * }} parse_input
195
+ * @param {import('yaml').YAMLMap<unknown, unknown>} yaml_map
196
+ * @param {LineCounter} line_counter
197
+ * @returns {PatramClaimFields[]}
198
+ */
199
+ function collectDirectiveFields(parse_input, yaml_map, line_counter) {
200
+ /** @type {PatramClaimFields[]} */
201
+ const directive_fields = [];
202
+
203
+ for (const pair of yaml_map.items) {
204
+ const pair_fields = createPairDirectiveFields(
205
+ parse_input,
206
+ pair,
207
+ line_counter,
208
+ );
209
+
210
+ directive_fields.push(...pair_fields);
211
+ }
212
+
213
+ return directive_fields;
214
+ }
215
+
216
+ /**
217
+ * @param {{
218
+ * file_path: string,
219
+ * parser: 'markdown' | 'yaml',
220
+ * start_line: number,
221
+ * markdown_style?: 'front_matter',
222
+ * multi_value_directive_names?: ReadonlySet<string>,
223
+ * }} parse_input
224
+ * @param {any} yaml_pair
225
+ * @param {LineCounter} line_counter
226
+ * @returns {PatramClaimFields[]}
227
+ */
228
+ function createPairDirectiveFields(parse_input, yaml_pair, line_counter) {
229
+ const directive_name = resolveDirectiveName(yaml_pair.key);
230
+
231
+ if (!directive_name || yaml_pair.value === null) {
232
+ return [];
233
+ }
234
+
235
+ if (isScalar(yaml_pair.value)) {
236
+ return createScalarDirectiveFields(
237
+ parse_input,
238
+ directive_name,
239
+ yaml_pair.key,
240
+ yaml_pair.value.value,
241
+ line_counter,
242
+ );
243
+ }
244
+
245
+ if (!shouldCollectSequence(parse_input, directive_name, yaml_pair.value)) {
246
+ return [];
247
+ }
248
+
249
+ const sequence_items =
250
+ /** @type {Array<{ range?: [number, number, number], value: unknown }>} */ (
251
+ yaml_pair.value.items
252
+ );
253
+
254
+ return sequence_items.flatMap((sequence_item) =>
255
+ createScalarDirectiveFields(
256
+ parse_input,
257
+ directive_name,
258
+ sequence_item,
259
+ sequence_item.value,
260
+ line_counter,
261
+ ),
262
+ );
263
+ }
264
+
265
+ /**
266
+ * @param {{
267
+ * file_path: string,
268
+ * parser: 'markdown' | 'yaml',
269
+ * start_line: number,
270
+ * markdown_style?: 'front_matter',
271
+ * }} parse_input
272
+ * @param {string} directive_name
273
+ * @param {{ range?: [number, number, number] }} yaml_node
274
+ * @param {unknown} scalar_value
275
+ * @param {LineCounter} line_counter
276
+ * @returns {PatramClaimFields[]}
277
+ */
278
+ function createScalarDirectiveFields(
279
+ parse_input,
280
+ directive_name,
281
+ yaml_node,
282
+ scalar_value,
283
+ line_counter,
284
+ ) {
285
+ const normalized_value = normalizeScalarValue(scalar_value);
286
+
287
+ if (normalized_value === null) {
288
+ return [];
289
+ }
290
+
291
+ return [
292
+ {
293
+ ...createDirectiveBaseFields(parse_input, yaml_node, line_counter),
294
+ name: directive_name,
295
+ value: normalized_value,
296
+ },
297
+ ];
298
+ }
299
+
300
+ /**
301
+ * @param {{
302
+ * multi_value_directive_names?: ReadonlySet<string>,
303
+ * }} parse_input
304
+ * @param {string} directive_name
305
+ * @param {unknown} yaml_value
306
+ * @returns {boolean}
307
+ */
308
+ function shouldCollectSequence(parse_input, directive_name, yaml_value) {
309
+ return (
310
+ isSeq(yaml_value) &&
311
+ parse_input.multi_value_directive_names?.has(directive_name) === true &&
312
+ yaml_value.items.every(isNonNullScalarNode)
313
+ );
314
+ }
315
+
316
+ /**
317
+ * @param {{
318
+ * file_path: string,
319
+ * parser: 'markdown' | 'yaml',
320
+ * start_line: number,
321
+ * markdown_style?: 'front_matter',
322
+ * }} parse_input
323
+ * @param {{ range?: [number, number, number] }} yaml_node
324
+ * @param {LineCounter} line_counter
325
+ * @returns {PatramClaimFields}
326
+ */
327
+ function createDirectiveBaseFields(parse_input, yaml_node, line_counter) {
328
+ /** @type {PatramClaimFields} */
329
+ const directive_fields = {
330
+ name: '',
331
+ origin: createOrigin(
332
+ parse_input.file_path,
333
+ yaml_node.range?.[0] ?? 0,
334
+ parse_input.start_line,
335
+ line_counter,
336
+ ),
337
+ parser: parse_input.parser,
338
+ value: '',
339
+ };
340
+
341
+ if (parse_input.markdown_style !== undefined) {
342
+ directive_fields.markdown_style = parse_input.markdown_style;
343
+ }
344
+
345
+ return directive_fields;
346
+ }
347
+
348
+ /**
349
+ * @param {unknown} yaml_key
350
+ * @returns {string | null}
351
+ */
352
+ function resolveDirectiveName(yaml_key) {
353
+ if (!isScalar(yaml_key) || typeof yaml_key.value !== 'string') {
354
+ return null;
355
+ }
356
+
357
+ return normalizeDirectiveName(yaml_key.value);
358
+ }
359
+
360
+ /**
361
+ * @param {unknown} scalar_value
362
+ * @returns {string | null}
363
+ */
364
+ function normalizeScalarValue(scalar_value) {
365
+ if (scalar_value === null) {
366
+ return null;
367
+ }
368
+
369
+ if (typeof scalar_value === 'string') {
370
+ return scalar_value;
371
+ }
372
+
373
+ if (typeof scalar_value === 'boolean' || typeof scalar_value === 'number') {
374
+ return String(scalar_value);
375
+ }
376
+
377
+ return null;
378
+ }
379
+
380
+ /**
381
+ * @param {unknown} yaml_node
382
+ * @returns {boolean}
383
+ */
384
+ function isNonNullScalarNode(yaml_node) {
385
+ return isScalar(yaml_node) && normalizeScalarValue(yaml_node.value) !== null;
386
+ }
387
+
388
+ /**
389
+ * @param {string} file_path
390
+ * @param {number} offset
391
+ * @param {number} start_line
392
+ * @param {LineCounter} line_counter
393
+ * @returns {{ column: number, line: number, path: string }}
394
+ */
395
+ function createOrigin(file_path, offset, start_line, line_counter) {
396
+ const location = line_counter.linePos(offset);
397
+
398
+ return {
399
+ column: location?.col ?? 1,
400
+ line: (location?.line ?? 1) + start_line - 1,
401
+ path: file_path,
402
+ };
403
+ }
404
+
405
+ /**
406
+ * @param {string} file_path
407
+ * @param {LineCounter} line_counter
408
+ * @param {number} offset
409
+ * @param {number} start_line
410
+ * @param {string} code
411
+ * @param {string} message
412
+ * @returns {PatramDiagnostic}
413
+ */
414
+ function createYamlDiagnostic(
415
+ file_path,
416
+ line_counter,
417
+ offset,
418
+ start_line,
419
+ code,
420
+ message,
421
+ ) {
422
+ const origin = createOrigin(file_path, offset, start_line, line_counter);
423
+
424
+ return {
425
+ code,
426
+ column: origin.column,
427
+ level: 'error',
428
+ line: origin.line,
429
+ message,
430
+ path: file_path,
431
+ };
432
+ }
433
+
434
+ /**
435
+ * @param {PatramDiagnostic[]} diagnostics
436
+ * @returns {{ diagnostics: PatramDiagnostic[], directive_fields: PatramClaimFields[] }}
437
+ */
438
+ function createDiagnosticResult(diagnostics) {
439
+ return {
440
+ diagnostics,
441
+ directive_fields: [],
442
+ };
443
+ }
444
+
445
+ /**
446
+ * @param {unknown} yaml_node
447
+ * @returns {number}
448
+ */
449
+ function resolveNodeRangeStart(yaml_node) {
450
+ if (
451
+ yaml_node &&
452
+ typeof yaml_node === 'object' &&
453
+ 'range' in yaml_node &&
454
+ Array.isArray(yaml_node.range) &&
455
+ typeof yaml_node.range[0] === 'number'
456
+ ) {
457
+ return yaml_node.range[0];
458
+ }
459
+
460
+ return 0;
461
+ }
462
+
463
+ /**
464
+ * @param {string} directive_label
465
+ * @returns {string}
466
+ */
467
+ function normalizeDirectiveName(directive_label) {
468
+ return directive_label
469
+ .trim()
470
+ .toLowerCase()
471
+ .replaceAll(/[\s-]+/dgu, '_');
472
+ }
@@ -1,5 +1,7 @@
1
1
  export const MARKDOWN_SOURCE_FILE_EXTENSIONS = ['.markdown', '.md'];
2
2
 
3
+ export const YAML_SOURCE_FILE_EXTENSIONS = ['.yaml', '.yml'];
4
+
3
5
  export const JSDOC_SOURCE_FILE_EXTENSIONS = [
4
6
  '.cjs',
5
7
  '.cts',
@@ -14,6 +16,7 @@ export const JSDOC_SOURCE_FILE_EXTENSIONS = [
14
16
  export const SUPPORTED_SOURCE_FILE_EXTENSIONS = [
15
17
  ...JSDOC_SOURCE_FILE_EXTENSIONS,
16
18
  ...MARKDOWN_SOURCE_FILE_EXTENSIONS,
19
+ ...YAML_SOURCE_FILE_EXTENSIONS,
17
20
  ];
18
21
 
19
22
  export const DEFAULT_INCLUDE_PATTERNS =
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patram",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "main": "./lib/patram.js",
6
6
  "exports": {
@@ -60,6 +60,7 @@
60
60
  "shiki": "^4.0.2",
61
61
  "string-width": "^8.2.0",
62
62
  "wrap-ansi": "^10.0.0",
63
+ "yaml": "^2.8.3",
63
64
  "zod": "^4.3.6"
64
65
  },
65
66
  "devDependencies": {