@sprig-and-prose/sprig-universe 0.4.3 → 0.4.5

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.
@@ -920,6 +920,90 @@ function getRawText(contentSpan, sourceText) {
920
920
  return sourceText.slice(contentSpan.startOffset, contentSpan.endOffset);
921
921
  }
922
922
 
923
+ /**
924
+ * @param {string | undefined} raw
925
+ * @returns {string | undefined}
926
+ */
927
+ function normalizeTitleValue(raw) {
928
+ if (!raw) return undefined;
929
+ const trimmed = raw.trim();
930
+ if (!trimmed) return undefined;
931
+ const unquoted = trimmed.replace(/^['"]|['"]$/g, '');
932
+ return unquoted.trim() || undefined;
933
+ }
934
+
935
+ /**
936
+ * @param {string} base
937
+ * @param {string} path
938
+ * @returns {string}
939
+ */
940
+ function joinRepositoryUrl(base, path) {
941
+ if (base.endsWith('/') && path.startsWith('/')) {
942
+ return base + path.slice(1);
943
+ }
944
+ if (!base.endsWith('/') && !path.startsWith('/')) {
945
+ return `${base}/${path}`;
946
+ }
947
+ return base + path;
948
+ }
949
+
950
+ /**
951
+ * @param {{ title?: string, paths?: string[], urls?: string[] }} ref
952
+ * @returns {string}
953
+ */
954
+ function deriveReferenceName(ref) {
955
+ if (ref.title) {
956
+ return ref.title;
957
+ }
958
+ if (ref.paths && ref.paths.length > 0) {
959
+ const rawPath = ref.paths[0];
960
+ const trimmed = rawPath.replace(/\/+$/, '');
961
+ const parts = trimmed.split('/').filter(Boolean);
962
+ if (parts.length > 0) {
963
+ const lastPart = parts[parts.length - 1];
964
+ const withoutExt = lastPart.replace(/\.[^/.]+$/, '');
965
+ return withoutExt.replace(/\./g, '-');
966
+ }
967
+ }
968
+ if (ref.urls && ref.urls.length > 0) {
969
+ try {
970
+ const parsed = new URL(ref.urls[0]);
971
+ const segments = parsed.pathname.split('/').filter(Boolean);
972
+ if (segments.length > 0) {
973
+ const lastSegment = segments[segments.length - 1];
974
+ const withoutExt = lastSegment.replace(/\.[^/.]+$/, '');
975
+ return withoutExt.replace(/\./g, '-');
976
+ }
977
+ if (parsed.hostname) {
978
+ return parsed.hostname.replace(/\./g, '-');
979
+ }
980
+ } catch {
981
+ // Ignore malformed URLs here; validation handles required fields
982
+ }
983
+ }
984
+ return 'reference';
985
+ }
986
+
987
+ /**
988
+ * @param {Map<string, true>} nameMap
989
+ * @param {string} baseName
990
+ * @returns {string}
991
+ */
992
+ function pickUniqueInlineReferenceName(nameMap, baseName) {
993
+ if (!nameMap.has(baseName)) {
994
+ nameMap.set(baseName, true);
995
+ return baseName;
996
+ }
997
+ let suffix = 2;
998
+ let candidate = `${baseName}-${suffix}`;
999
+ while (nameMap.has(candidate)) {
1000
+ suffix += 1;
1001
+ candidate = `${baseName}-${suffix}`;
1002
+ }
1003
+ nameMap.set(candidate, true);
1004
+ return candidate;
1005
+ }
1006
+
923
1007
  /**
924
1008
  * Pass 4: Build nodes
925
1009
  * @param {BoundDecl[]} boundDecls
@@ -927,11 +1011,12 @@ function getRawText(contentSpan, sourceText) {
927
1011
  * @param {Array<{ sourceText: string, filePath: string }>} fileData
928
1012
  * @returns {{ nodes: Map<DeclId, NodeModel>, repositories: Map<DeclId, RepositoryModel>, references: Map<DeclId, ReferenceModel>, diagnostics: Diagnostic[] }}
929
1013
  */
930
- function buildNodes(boundDecls, schemas, fileData) {
1014
+ function buildNodes(boundDecls, schemas, fileData, universeName) {
931
1015
  const nodes = new Map();
932
1016
  const repositories = new Map();
933
1017
  const references = new Map();
934
1018
  const diagnostics = [];
1019
+ const inlineReferenceNamesByScope = new Map();
935
1020
 
936
1021
  // Build file data map
937
1022
  const fileDataMap = new Map();
@@ -954,6 +1039,47 @@ function buildNodes(boundDecls, schemas, fileData) {
954
1039
  source: block.span,
955
1040
  };
956
1041
  }
1042
+
1043
+ /**
1044
+ * @param {any} block
1045
+ * @param {string} sourceText
1046
+ * @returns {ReferenceModel}
1047
+ */
1048
+ function buildReferenceFromBlock(block, sourceText) {
1049
+ const ref = {
1050
+ id: '',
1051
+ name: '',
1052
+ urls: [],
1053
+ source: block.source || block.span,
1054
+ };
1055
+
1056
+ if (block.repositoryName) {
1057
+ ref.repositoryName = block.repositoryName;
1058
+ }
1059
+
1060
+ const children = block.children || [];
1061
+ for (const child of children) {
1062
+ if (child.kind === 'url' && child.value) {
1063
+ ref.urls = [child.value];
1064
+ } else if (child.kind === 'paths' && child.paths) {
1065
+ ref.paths = child.paths;
1066
+ } else if (child.kind === 'kind' && child.value) {
1067
+ ref.kind = child.value;
1068
+ } else if (child.kind === 'title') {
1069
+ const raw = getRawText(child.contentSpan, sourceText);
1070
+ const titleValue = normalizeTitleValue(raw);
1071
+ if (titleValue) {
1072
+ ref.title = titleValue;
1073
+ }
1074
+ } else if (child.kind === 'describe') {
1075
+ ref.describe = buildTextBlock(child, sourceText);
1076
+ } else if (child.kind === 'note') {
1077
+ ref.note = buildTextBlock(child, sourceText);
1078
+ }
1079
+ }
1080
+
1081
+ return ref;
1082
+ }
957
1083
 
958
1084
  for (const bound of boundDecls) {
959
1085
  const { id, info, decl } = bound;
@@ -1078,7 +1204,7 @@ function buildNodes(boundDecls, schemas, fileData) {
1078
1204
  // Extract unknown blocks (non-container, non-standard blocks)
1079
1205
  const unknownBlocks = (decl.body || []).filter(b => {
1080
1206
  if (!b.kind) return false;
1081
- const knownKinds = ['describe', 'title', 'note', 'relationships', 'from', 'references', 'reference'];
1207
+ const knownKinds = ['describe', 'title', 'note', 'relationships', 'from', 'references', 'reference', 'ordering'];
1082
1208
  return !knownKinds.includes(b.kind);
1083
1209
  });
1084
1210
  if (unknownBlocks.length > 0) {
@@ -1090,13 +1216,49 @@ function buildNodes(boundDecls, schemas, fileData) {
1090
1216
  }));
1091
1217
  }
1092
1218
 
1093
- // Extract references block
1219
+ // Extract references block (resolved after all nodes are built)
1094
1220
  const referencesBlock = (decl.body || []).find(b => b.kind === 'references');
1095
1221
  if (referencesBlock && referencesBlock.items) {
1096
- node.references = referencesBlock.items.map(item => {
1097
- // Will be resolved in Pass 5
1098
- return item.ref || item.name || '';
1099
- });
1222
+ node._pendingReferences = referencesBlock.items
1223
+ .map(item => ({
1224
+ name: item.ref || item.name || '',
1225
+ source: item.span || referencesBlock.source,
1226
+ }))
1227
+ .filter(item => item.name);
1228
+ }
1229
+
1230
+ // Extract inline reference blocks and attach directly
1231
+ const inlineReferenceBlocks = (decl.body || []).filter(b => b.kind === 'reference' && b.children);
1232
+ if (inlineReferenceBlocks.length > 0) {
1233
+ const nameMap = inlineReferenceNamesByScope.get(id) || new Map();
1234
+ inlineReferenceNamesByScope.set(id, nameMap);
1235
+ for (const block of inlineReferenceBlocks) {
1236
+ const refModel = buildReferenceFromBlock(block, sourceText);
1237
+ const baseName = deriveReferenceName(refModel);
1238
+ const uniqueName = pickUniqueInlineReferenceName(nameMap, baseName);
1239
+ const refId = makeDeclId(universeName, 'reference', `${id}:${uniqueName}`);
1240
+ refModel.id = refId;
1241
+ refModel.name = uniqueName;
1242
+ refModel.scopeDeclId = id;
1243
+ references.set(refId, refModel);
1244
+ if (!node.references) {
1245
+ node.references = [];
1246
+ }
1247
+ node.references.push(refId);
1248
+ }
1249
+ }
1250
+
1251
+ // Extract ordering block (applied after all nodes are built)
1252
+ const orderingBlocks = (decl.body || []).filter(b => b.kind === 'ordering');
1253
+ if (orderingBlocks.length > 0) {
1254
+ if (orderingBlocks.length > 1) {
1255
+ diagnostics.push({
1256
+ severity: 'warning',
1257
+ message: `Multiple ordering blocks in ${node.kind} "${node.name}". Using the first one.`,
1258
+ source: orderingBlocks[1].source || orderingBlocks[0].source,
1259
+ });
1260
+ }
1261
+ node._pendingOrdering = orderingBlocks[0];
1100
1262
  }
1101
1263
 
1102
1264
  nodes.set(id, node);
@@ -1172,6 +1334,12 @@ function buildNodes(boundDecls, schemas, fileData) {
1172
1334
  if (pathsBlock && pathsBlock.paths) {
1173
1335
  ref.paths = pathsBlock.paths;
1174
1336
  }
1337
+
1338
+ // Extract kind block
1339
+ const kindBlock = (decl.body || []).find(b => b.kind === 'kind');
1340
+ if (kindBlock && kindBlock.value) {
1341
+ ref.kind = kindBlock.value;
1342
+ }
1175
1343
 
1176
1344
  // Extract title block
1177
1345
  const titleBlock = (decl.body || []).find(b => b.kind === 'title');
@@ -1194,6 +1362,8 @@ function buildNodes(boundDecls, schemas, fileData) {
1194
1362
  if (noteBlock) {
1195
1363
  ref.note = buildTextBlock(noteBlock, sourceText);
1196
1364
  }
1365
+
1366
+ ref.scopeDeclId = info.parentDeclId || id;
1197
1367
 
1198
1368
  references.set(id, ref);
1199
1369
  }
@@ -1202,6 +1372,292 @@ function buildNodes(boundDecls, schemas, fileData) {
1202
1372
  return { nodes, repositories, references, diagnostics };
1203
1373
  }
1204
1374
 
1375
+ /**
1376
+ * Attach named references declared inside containers to their parent nodes.
1377
+ * @param {BoundDecl[]} boundDecls
1378
+ * @param {Map<DeclId, NodeModel>} nodes
1379
+ */
1380
+ function addImplicitReferenceAttachments(boundDecls, nodes) {
1381
+ for (const bound of boundDecls) {
1382
+ const { info } = bound;
1383
+ if (info.kindResolved !== 'reference') continue;
1384
+ const parentId = info.parentDeclId;
1385
+ if (!parentId) continue;
1386
+ const parentNode = nodes.get(parentId);
1387
+ if (!parentNode || parentNode.kind === 'universe') continue;
1388
+ if (!parentNode._pendingReferences) {
1389
+ parentNode._pendingReferences = [];
1390
+ }
1391
+ parentNode._pendingReferences.push({
1392
+ name: info.name,
1393
+ source: info.span,
1394
+ });
1395
+ }
1396
+ }
1397
+
1398
+ /**
1399
+ * Resolve reference declarations to repository URLs and repository references.
1400
+ * @param {BoundDecl[]} boundDecls
1401
+ * @param {Map<DeclId, ReferenceModel>} references
1402
+ * @param {Map<DeclId, RepositoryModel>} repositories
1403
+ * @param {DeclIndex} index
1404
+ * @param {Map<DeclId, DeclId>} parentMap
1405
+ * @returns {Diagnostic[]}
1406
+ */
1407
+ function resolveReferenceModels(boundDecls, references, repositories, index, parentMap) {
1408
+ const diagnostics = [];
1409
+ const unknownRepoCounts = new Map();
1410
+
1411
+ for (const ref of references.values()) {
1412
+ const scopeDeclId = ref.scopeDeclId || ref.id;
1413
+ const source = ref.source;
1414
+ const displayName = ref.name || 'unnamed reference';
1415
+ const hasUrl = Array.isArray(ref.urls) && ref.urls.length > 0;
1416
+ const repositoryName = ref.repositoryName;
1417
+ let urls = hasUrl ? [...ref.urls] : [];
1418
+ let repositoryRef = undefined;
1419
+
1420
+ if (hasUrl && repositoryName) {
1421
+ diagnostics.push({
1422
+ severity: 'error',
1423
+ message: `Reference "${displayName}" cannot include both url { ... } and in <Repository>`,
1424
+ source,
1425
+ });
1426
+ }
1427
+
1428
+ if (!hasUrl && repositoryName) {
1429
+ const pathSegments = repositoryName.split('.');
1430
+ if (pathSegments.length === 1) {
1431
+ const resolved = resolveUnqualified(pathSegments[0], scopeDeclId, index, parentMap, source);
1432
+ if (resolved.ambiguous) {
1433
+ diagnostics.push({
1434
+ severity: 'error',
1435
+ message: `Ambiguous repository "${repositoryName}" - use qualified path`,
1436
+ source,
1437
+ });
1438
+ } else if (resolved.declId) {
1439
+ const repo = repositories.get(resolved.declId);
1440
+ if (!repo) {
1441
+ diagnostics.push({
1442
+ severity: 'error',
1443
+ message: `Reference "${displayName}" uses "${repositoryName}" which is not a repository`,
1444
+ source,
1445
+ });
1446
+ } else if (!repo.url) {
1447
+ diagnostics.push({
1448
+ severity: 'error',
1449
+ message: `Repository "${repo.name}" is missing url { '...' }`,
1450
+ source,
1451
+ });
1452
+ } else {
1453
+ repositoryRef = repo.id;
1454
+ if (ref.paths && ref.paths.length > 0) {
1455
+ urls = ref.paths.map((path) => joinRepositoryUrl(repo.url, path));
1456
+ } else if (ref.paths && ref.paths.length === 0) {
1457
+ diagnostics.push({
1458
+ severity: 'error',
1459
+ message: `Reference "${displayName}" has an empty paths block`,
1460
+ source,
1461
+ });
1462
+ } else {
1463
+ urls = [repo.url];
1464
+ }
1465
+ }
1466
+ } else {
1467
+ const entry = unknownRepoCounts.get(repositoryName) || {
1468
+ count: 0,
1469
+ source,
1470
+ };
1471
+ entry.count += 1;
1472
+ if (!entry.source && source) {
1473
+ entry.source = source;
1474
+ }
1475
+ unknownRepoCounts.set(repositoryName, entry);
1476
+ }
1477
+ } else {
1478
+ const resolved = resolveQualified(pathSegments, index, source);
1479
+ if (resolved.declId) {
1480
+ const repo = repositories.get(resolved.declId);
1481
+ if (!repo) {
1482
+ diagnostics.push({
1483
+ severity: 'error',
1484
+ message: `Reference "${displayName}" uses "${repositoryName}" which is not a repository`,
1485
+ source,
1486
+ });
1487
+ } else if (!repo.url) {
1488
+ diagnostics.push({
1489
+ severity: 'error',
1490
+ message: `Repository "${repo.name}" is missing url { '...' }`,
1491
+ source,
1492
+ });
1493
+ } else {
1494
+ repositoryRef = repo.id;
1495
+ if (ref.paths && ref.paths.length > 0) {
1496
+ urls = ref.paths.map((path) => joinRepositoryUrl(repo.url, path));
1497
+ } else if (ref.paths && ref.paths.length === 0) {
1498
+ diagnostics.push({
1499
+ severity: 'error',
1500
+ message: `Reference "${displayName}" has an empty paths block`,
1501
+ source,
1502
+ });
1503
+ } else {
1504
+ urls = [repo.url];
1505
+ }
1506
+ }
1507
+ } else {
1508
+ const entry = unknownRepoCounts.get(repositoryName) || {
1509
+ count: 0,
1510
+ source,
1511
+ };
1512
+ entry.count += 1;
1513
+ if (!entry.source && source) {
1514
+ entry.source = source;
1515
+ }
1516
+ unknownRepoCounts.set(repositoryName, entry);
1517
+ }
1518
+ }
1519
+ }
1520
+
1521
+ if (!hasUrl && !repositoryName) {
1522
+ diagnostics.push({
1523
+ severity: 'error',
1524
+ message: `Reference "${displayName}" must have url { ... } or declared as reference ${displayName} in <Repository>`,
1525
+ source,
1526
+ });
1527
+ }
1528
+
1529
+ if (urls.length > 0) {
1530
+ ref.urls = urls;
1531
+ }
1532
+ if (repositoryRef) {
1533
+ ref.repositoryRef = repositoryRef;
1534
+ }
1535
+ }
1536
+
1537
+ for (const [repoName, info] of unknownRepoCounts.entries()) {
1538
+ const countText = info.count > 1 ? ` (${info.count} occurrences)` : '';
1539
+ diagnostics.push({
1540
+ severity: 'error',
1541
+ message: `Unknown repository "${repoName}" used by references${countText}.`,
1542
+ source: info.source,
1543
+ });
1544
+ }
1545
+
1546
+ return diagnostics;
1547
+ }
1548
+
1549
+ /**
1550
+ * Resolve references blocks to reference IDs.
1551
+ * @param {Map<DeclId, NodeModel>} nodes
1552
+ * @param {Map<DeclId, ReferenceModel>} references
1553
+ * @param {DeclIndex} index
1554
+ * @param {Map<DeclId, DeclId>} parentMap
1555
+ * @returns {Diagnostic[]}
1556
+ */
1557
+ function resolveReferenceAttachments(nodes, references, index, parentMap) {
1558
+ const diagnostics = [];
1559
+ const unknownReferenceCounts = new Map();
1560
+
1561
+ for (const node of nodes.values()) {
1562
+ if (!node._pendingReferences) continue;
1563
+ const resolvedReferences = [];
1564
+ const existingReferences = Array.isArray(node.references) ? node.references : [];
1565
+
1566
+ for (const item of node._pendingReferences) {
1567
+ const name = item.name;
1568
+ if (!name) continue;
1569
+
1570
+ let resolvedDeclId = null;
1571
+ const pathSegments = name.split('.');
1572
+ if (pathSegments.length === 1) {
1573
+ const resolved = resolveUnqualified(pathSegments[0], node.id, index, parentMap, item.source);
1574
+ if (resolved.ambiguous) {
1575
+ diagnostics.push({
1576
+ severity: 'error',
1577
+ message: `Ambiguous reference "${name}" - use qualified path`,
1578
+ source: item.source,
1579
+ });
1580
+ continue;
1581
+ }
1582
+ resolvedDeclId = resolved.declId;
1583
+ } else {
1584
+ const resolved = resolveQualified(pathSegments, index, item.source);
1585
+ resolvedDeclId = resolved.declId;
1586
+ }
1587
+
1588
+ if (resolvedDeclId && references.has(resolvedDeclId)) {
1589
+ resolvedReferences.push(resolvedDeclId);
1590
+ } else {
1591
+ const entry = unknownReferenceCounts.get(name) || { count: 0, source: item.source };
1592
+ entry.count += 1;
1593
+ if (!entry.source && item.source) {
1594
+ entry.source = item.source;
1595
+ }
1596
+ unknownReferenceCounts.set(name, entry);
1597
+ }
1598
+ }
1599
+
1600
+ const combined = [...existingReferences, ...resolvedReferences];
1601
+ node.references = Array.from(new Set(combined));
1602
+ delete node._pendingReferences;
1603
+ }
1604
+
1605
+ for (const [name, info] of unknownReferenceCounts.entries()) {
1606
+ const countText = info.count > 1 ? ` (${info.count} occurrences)` : '';
1607
+ diagnostics.push({
1608
+ severity: 'error',
1609
+ message: `Unknown reference "${name}" in references list${countText}. References must use reference names, not paths.`,
1610
+ source: info.source,
1611
+ });
1612
+ }
1613
+
1614
+ return diagnostics;
1615
+ }
1616
+
1617
+ /**
1618
+ * Validate and apply ordering blocks after all nodes are created.
1619
+ * @param {Map<DeclId, NodeModel>} nodes
1620
+ * @returns {Diagnostic[]}
1621
+ */
1622
+ function applyOrderingBlocks(nodes) {
1623
+ const diagnostics = [];
1624
+
1625
+ for (const node of nodes.values()) {
1626
+ if (!node._pendingOrdering) continue;
1627
+ const orderingBlock = node._pendingOrdering;
1628
+ const orderingIdentifiers = orderingBlock.identifiers || [];
1629
+
1630
+ const childNameToNodeId = new Map();
1631
+ for (const childId of node.children || []) {
1632
+ const childNode = nodes.get(childId);
1633
+ if (childNode) {
1634
+ childNameToNodeId.set(childNode.name, childId);
1635
+ }
1636
+ }
1637
+
1638
+ const validOrdering = [];
1639
+ for (const identifier of orderingIdentifiers) {
1640
+ if (childNameToNodeId.has(identifier)) {
1641
+ validOrdering.push(identifier);
1642
+ } else {
1643
+ diagnostics.push({
1644
+ severity: 'warning',
1645
+ message: `Ordering block in ${node.kind} "${node.name}" references unknown child "${identifier}". This identifier will be ignored.`,
1646
+ source: orderingBlock.source,
1647
+ });
1648
+ }
1649
+ }
1650
+
1651
+ if (validOrdering.length > 0) {
1652
+ node.ordering = validOrdering;
1653
+ }
1654
+
1655
+ delete node._pendingOrdering;
1656
+ }
1657
+
1658
+ return diagnostics;
1659
+ }
1660
+
1205
1661
  /**
1206
1662
  * Pass 5: Build edges from relationship usage
1207
1663
  * @param {BoundDecl[]} boundDecls
@@ -1466,7 +1922,20 @@ export function buildGraph(fileAstOrFileAsts, options) {
1466
1922
 
1467
1923
  // Pass 4: Build nodes
1468
1924
  const fileData = normalized.map(n => ({ sourceText: n.sourceText, filePath: n.filePath }));
1469
- const { nodes, repositories, references, diagnostics: nodeDiags } = buildNodes(boundDecls, schemas, fileData);
1925
+ const { nodes, repositories, references, diagnostics: nodeDiags } = buildNodes(
1926
+ boundDecls,
1927
+ schemas,
1928
+ fileData,
1929
+ universeName,
1930
+ );
1931
+
1932
+ // Resolve reference models and attachments
1933
+ addImplicitReferenceAttachments(boundDecls, nodes);
1934
+ const referenceModelDiags = resolveReferenceModels(boundDecls, references, repositories, index, parentMap);
1935
+ const referenceAttachmentDiags = resolveReferenceAttachments(nodes, references, index, parentMap);
1936
+
1937
+ // Apply ordering blocks after all nodes are available
1938
+ const orderingDiags = applyOrderingBlocks(nodes);
1470
1939
 
1471
1940
  // Resolve relates endpoints
1472
1941
  const relatesDiags = resolveRelatesEndpoints(nodes, index, parentMap, boundDecls);
@@ -1483,6 +1952,9 @@ export function buildGraph(fileAstOrFileAsts, options) {
1483
1952
  ...bindDiags,
1484
1953
  ...schemaDiags,
1485
1954
  ...nodeDiags,
1955
+ ...referenceModelDiags,
1956
+ ...referenceAttachmentDiags,
1957
+ ...orderingDiags,
1486
1958
  ...relatesDiags,
1487
1959
  ...edgeDiags,
1488
1960
  ];
@@ -342,6 +342,7 @@ const blockParselets = new Map([
342
342
  ['label', (p) => p.parseLabelBlock()],
343
343
  ['reference', (p) => p.parseReferenceBlock()],
344
344
  ['references', (p) => p.parseReferencesBlock()],
345
+ ['ordering', (p) => p.parseOrderingBlock()],
345
346
  ['relationships', (p) => p.parseRelationshipsBlock()],
346
347
  ]);
347
348
 
@@ -1074,6 +1075,54 @@ ParserCore.prototype.parseReferenceBlock = function () {
1074
1075
  };
1075
1076
  };
1076
1077
 
1078
+ /**
1079
+ * Parses an ordering block: ordering { <identifiers> }
1080
+ * Items are identifiers (not paths).
1081
+ * @returns {any | null}
1082
+ */
1083
+ ParserCore.prototype.parseOrderingBlock = function () {
1084
+ const startToken = this.peek();
1085
+ if (!startToken) return null;
1086
+
1087
+ const { token: orderingToken } = this.expectIdentifierOrKeyword('ordering');
1088
+ if (!orderingToken) return null;
1089
+
1090
+ const { token: lbrace } = this.expect('LBRACE');
1091
+ if (!lbrace) return null;
1092
+
1093
+ const identifiers = [];
1094
+
1095
+ // Parse items until closing brace
1096
+ while (!this.isAtEnd() && !this.match('RBRACE')) {
1097
+ // Skip optional commas
1098
+ if (this.match('COMMA')) {
1099
+ this.advance();
1100
+ continue;
1101
+ }
1102
+
1103
+ const itemToken = this.peek();
1104
+ if (!itemToken) break;
1105
+
1106
+ const identifier = this.readIdent();
1107
+ if (!identifier) {
1108
+ this.reportDiagnostic('error', 'Expected identifier in ordering block', itemToken.span);
1109
+ this.advance();
1110
+ continue;
1111
+ }
1112
+
1113
+ identifiers.push(identifier);
1114
+ }
1115
+
1116
+ const { token: rbrace } = this.expect('RBRACE');
1117
+ if (!rbrace) return null;
1118
+
1119
+ return {
1120
+ kind: 'ordering',
1121
+ identifiers,
1122
+ source: this.createSpan(orderingToken, rbrace),
1123
+ };
1124
+ };
1125
+
1077
1126
  /**
1078
1127
  * Parses a references block: references { <items> }
1079
1128
  * Items are identifier paths (reference names)
@@ -37,6 +37,7 @@ const KEYWORDS = new Set([
37
37
  'title',
38
38
  'reference',
39
39
  'references',
40
+ 'ordering',
40
41
  'paths',
41
42
  'url',
42
43
  ]);