@sprig-and-prose/sprig-universe 0.2.0 → 0.3.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/src/parser.js CHANGED
@@ -16,9 +16,8 @@ import { mergeSpans } from './util/span.js';
16
16
  * @typedef {import('./ast.js').DescribeBlock} DescribeBlock
17
17
  * @typedef {import('./ast.js').UnknownBlock} UnknownBlock
18
18
  * @typedef {import('./ast.js').ReferencesBlock} ReferencesBlock
19
- * @typedef {import('./ast.js').ReferenceBlock} ReferenceBlock
20
- * @typedef {import('./ast.js').NamedReferenceBlock} NamedReferenceBlock
21
- * @typedef {import('./ast.js').UsingInReferencesBlock} UsingInReferencesBlock
19
+ * @typedef {import('./ast.js').RepositoryDecl} RepositoryDecl
20
+ * @typedef {import('./ast.js').ReferenceDecl} ReferenceDecl
22
21
  * @typedef {import('./ast.js').DocumentationBlock} DocumentationBlock
23
22
  * @typedef {import('./ast.js').DocumentBlock} DocumentBlock
24
23
  * @typedef {import('./ast.js').NamedDocumentBlock} NamedDocumentBlock
@@ -106,7 +105,6 @@ class Parser {
106
105
  parseFile() {
107
106
  const universes = [];
108
107
  const scenes = [];
109
- const topLevelDeclsByUniverse = new Map();
110
108
  const topLevelDeclsUnscoped = [];
111
109
  const startToken = this.peek();
112
110
 
@@ -128,18 +126,14 @@ class Parser {
128
126
  decl = this.parseConcept();
129
127
  } else if (keyword === 'anthology') {
130
128
  decl = this.parseAnthology();
129
+ } else if (keyword === 'repository') {
130
+ decl = this.parseRepository();
131
+ } else if (keyword === 'reference') {
132
+ decl = this.parseReferenceDecl();
131
133
  }
132
134
 
133
135
  if (decl) {
134
- if (decl.parentName) {
135
- const universeName = decl.parentName;
136
- if (!topLevelDeclsByUniverse.has(universeName)) {
137
- topLevelDeclsByUniverse.set(universeName, []);
138
- }
139
- topLevelDeclsByUniverse.get(universeName).push(decl);
140
- } else {
141
- topLevelDeclsUnscoped.push(decl);
142
- }
136
+ topLevelDeclsUnscoped.push(decl);
143
137
  } else {
144
138
  // Skip unknown top-level content (tolerant parsing)
145
139
  const token = this.advance();
@@ -156,43 +150,16 @@ class Parser {
156
150
  }
157
151
  }
158
152
 
159
- // Merge top-level declarations into their target universes
160
- if (topLevelDeclsByUniverse.size > 0 || topLevelDeclsUnscoped.length > 0) {
161
- const universesByName = new Map();
162
- for (const universe of universes) {
163
- universesByName.set(universe.name, universe);
164
- }
165
-
166
- for (const [universeName, decls] of topLevelDeclsByUniverse.entries()) {
167
- const universe = universesByName.get(universeName);
168
- if (universe) {
169
- universe.body.push(...decls);
170
- } else {
171
- const firstDecl = decls[0];
172
- const lastDecl = decls[decls.length - 1];
173
- universes.push({
174
- kind: 'universe',
175
- name: universeName,
176
- body: decls,
177
- source: {
178
- file: this.file,
179
- start: firstDecl.source.start,
180
- end: lastDecl.source.end,
181
- },
182
- });
183
- universesByName.set(universeName, universes[universes.length - 1]);
184
- }
185
- }
186
-
187
- if (topLevelDeclsUnscoped.length > 0 && universes.length === 1) {
188
- universes[0].body.push(...topLevelDeclsUnscoped);
189
- }
153
+ // Merge top-level declarations into their target universe
154
+ if (topLevelDeclsUnscoped.length > 0 && universes.length === 1) {
155
+ universes[0].body.push(...topLevelDeclsUnscoped);
190
156
  }
191
157
 
192
158
  return {
193
159
  file: this.file,
194
160
  universes,
195
161
  scenes,
162
+ topLevelDecls: topLevelDeclsUnscoped.length > 0 ? topLevelDeclsUnscoped : undefined,
196
163
  source: startToken
197
164
  ? {
198
165
  file: this.file,
@@ -210,7 +177,7 @@ class Parser {
210
177
  const startToken = this.expect('KEYWORD', 'universe');
211
178
  const nameToken = this.expect('IDENTIFIER');
212
179
  const lbrace = this.expect('LBRACE');
213
- const body = this.parseBlockBody(['anthology', 'series', 'book', 'chapter', 'concept', 'relates', 'describe', 'title', 'repository']);
180
+ const body = this.parseBlockBody(['anthology', 'series', 'book', 'chapter', 'concept', 'relates', 'describe', 'title', 'repository', 'reference', 'references', 'documentation']);
214
181
  const rbrace = this.expect('RBRACE');
215
182
 
216
183
  return {
@@ -227,7 +194,7 @@ class Parser {
227
194
 
228
195
  /**
229
196
  * @param {string[]} allowedKeywords - Keywords allowed in this body
230
- * @returns {Array<SeriesDecl | BookDecl | ChapterDecl | ConceptDecl | RelatesDecl | DescribeBlock | TitleBlock | FromBlock | RelationshipsBlock | ReferencesBlock | DocumentationBlock | NamedReferenceBlock | NamedDocumentBlock | UnknownBlock>}
197
+ * @returns {Array<SeriesDecl | BookDecl | ChapterDecl | ConceptDecl | RelatesDecl | DescribeBlock | TitleBlock | FromBlock | RelationshipsBlock | ReferencesBlock | DocumentationBlock | RepositoryDecl | ReferenceDecl | NamedDocumentBlock | UnknownBlock>}
231
198
  */
232
199
  parseBlockBody(allowedKeywords) {
233
200
  const body = [];
@@ -238,21 +205,6 @@ class Parser {
238
205
  const keyword = this.peek()?.value;
239
206
  if (!keyword) break;
240
207
 
241
- // Check for named reference: reference <IDENTIFIER> { ... }
242
- if (keyword === 'reference') {
243
- const nextPos = this.pos + 1;
244
- if (nextPos < this.tokens.length &&
245
- this.tokens[nextPos].type === 'IDENTIFIER' &&
246
- nextPos + 1 < this.tokens.length &&
247
- this.tokens[nextPos + 1].type === 'LBRACE') {
248
- // This is a named reference block
249
- body.push(this.parseNamedReference());
250
- continue;
251
- }
252
- // Otherwise, it's an inline reference (only valid inside references block)
253
- // Fall through to unknown block handling
254
- }
255
-
256
208
  // Check for named document: document <IDENTIFIER> { ... }
257
209
  if (keyword === 'document') {
258
210
  const nextPos = this.pos + 1;
@@ -286,13 +238,15 @@ class Parser {
286
238
  body.push(this.parseTitle());
287
239
  } else if (keyword === 'repository' && allowedKeywords.includes('repository')) {
288
240
  body.push(this.parseRepository());
241
+ } else if (keyword === 'reference' && allowedKeywords.includes('reference')) {
242
+ body.push(this.parseReferenceDecl());
289
243
  } else if (keyword === 'from' && allowedKeywords.includes('from')) {
290
244
  body.push(this.parseFrom());
291
245
  } else if (keyword === 'relationships' && allowedKeywords.includes('relationships')) {
292
246
  body.push(this.parseRelationships());
293
- } else if (keyword === 'references') {
247
+ } else if (keyword === 'references' && allowedKeywords.includes('references')) {
294
248
  body.push(this.parseReferences());
295
- } else if (keyword === 'documentation') {
249
+ } else if (keyword === 'documentation' && allowedKeywords.includes('documentation')) {
296
250
  body.push(this.parseDocumentation());
297
251
  } else {
298
252
  // Unknown keyword/identifier followed by brace - parse as UnknownBlock
@@ -320,13 +274,32 @@ class Parser {
320
274
  parseAnthology() {
321
275
  const startToken = this.expect('KEYWORD', 'anthology');
322
276
  const nameToken = this.expect('IDENTIFIER');
277
+ let parentName = undefined;
278
+ if (this.match('KEYWORD') && this.peek()?.value === 'in') {
279
+ this.expect('KEYWORD', 'in');
280
+ const parentToken = this.expect('IDENTIFIER');
281
+ parentName = parentToken.value;
282
+ }
323
283
  const lbrace = this.expect('LBRACE');
324
- const body = this.parseBlockBody(['describe', 'title', 'references', 'documentation']);
284
+ const body = this.parseBlockBody([
285
+ 'describe',
286
+ 'title',
287
+ 'series',
288
+ 'book',
289
+ 'chapter',
290
+ 'concept',
291
+ 'relates',
292
+ 'references',
293
+ 'documentation',
294
+ 'repository',
295
+ 'reference',
296
+ ]);
325
297
  const rbrace = this.expect('RBRACE');
326
298
 
327
299
  return {
328
300
  kind: 'anthology',
329
301
  name: nameToken.value,
302
+ parentName,
330
303
  body,
331
304
  source: {
332
305
  file: this.file,
@@ -352,7 +325,7 @@ class Parser {
352
325
  }
353
326
 
354
327
  const lbrace = this.expect('LBRACE');
355
- const body = this.parseBlockBody(['book', 'chapter', 'describe', 'title', 'references', 'documentation']);
328
+ const body = this.parseBlockBody(['book', 'chapter', 'describe', 'title', 'references', 'documentation', 'repository', 'reference']);
356
329
  const rbrace = this.expect('RBRACE');
357
330
 
358
331
  return {
@@ -377,7 +350,7 @@ class Parser {
377
350
  this.expect('KEYWORD', 'in');
378
351
  const parentToken = this.expect('IDENTIFIER');
379
352
  const lbrace = this.expect('LBRACE');
380
- const body = this.parseBlockBody(['chapter', 'describe', 'title', 'references', 'documentation']);
353
+ const body = this.parseBlockBody(['chapter', 'describe', 'title', 'references', 'documentation', 'repository', 'reference']);
381
354
  const rbrace = this.expect('RBRACE');
382
355
 
383
356
  return {
@@ -402,7 +375,7 @@ class Parser {
402
375
  this.expect('KEYWORD', 'in');
403
376
  const parentToken = this.expect('IDENTIFIER');
404
377
  const lbrace = this.expect('LBRACE');
405
- const body = this.parseBlockBody(['describe', 'title', 'references', 'documentation']);
378
+ const body = this.parseBlockBody(['describe', 'title', 'references', 'documentation', 'repository', 'reference']);
406
379
  const rbrace = this.expect('RBRACE');
407
380
 
408
381
  return {
@@ -434,7 +407,7 @@ class Parser {
434
407
  }
435
408
 
436
409
  const lbrace = this.expect('LBRACE');
437
- const body = this.parseBlockBody(['describe', 'title', 'references', 'documentation']);
410
+ const body = this.parseBlockBody(['describe', 'title', 'references', 'documentation', 'repository', 'reference']);
438
411
  const rbrace = this.expect('RBRACE');
439
412
 
440
413
  return {
@@ -652,193 +625,141 @@ class Parser {
652
625
  }
653
626
 
654
627
  /**
655
- * Parses a references block containing nested reference blocks and using blocks
628
+ * Parses a references block containing a list of identifier paths
656
629
  * @returns {ReferencesBlock}
657
630
  */
658
631
  parseReferences() {
659
632
  const startToken = this.expect('IDENTIFIER', 'references');
660
633
  const lbrace = this.expect('LBRACE');
661
- const references = [];
634
+ const items = [];
635
+ let depth = 1;
662
636
 
663
- // Parse nested reference blocks and using blocks
664
- while (!this.match('RBRACE') && !this.match('EOF')) {
665
- // Check for 'using' keyword first (consistent with Scene layer)
666
- if (this.match('KEYWORD') && this.peek()?.value === 'using') {
667
- references.push(this.parseUsingInReferences());
668
- } else if ((this.match('IDENTIFIER') || this.match('KEYWORD')) && this.peek()?.value === 'reference') {
669
- // Look for 'reference' identifier
670
- references.push(this.parseReference());
671
- } else {
672
- // Skip unexpected tokens (tolerant parsing)
637
+ while (this.pos < this.tokens.length && !this.match('EOF')) {
638
+ if (this.match('LBRACE')) {
639
+ throw new Error(`Unexpected '{' in references block at ${this.file}:${this.peek()?.span.start.line}. Use identifiers only.`);
640
+ }
641
+ if (this.match('LBRACE')) {
642
+ depth += 1;
673
643
  this.advance();
644
+ continue;
674
645
  }
675
- }
676
-
677
- const rbrace = this.expect('RBRACE');
678
-
679
- return {
680
- kind: 'references',
681
- references,
682
- source: {
683
- file: this.file,
684
- start: startToken.span.start,
685
- end: rbrace.span.end,
686
- },
687
- };
688
- }
689
646
 
690
- /**
691
- * Parses a using block inside a references block: using { IdentifierList }
692
- * @returns {UsingInReferencesBlock}
693
- */
694
- parseUsingInReferences() {
695
- const startToken = this.expect('KEYWORD', 'using');
696
- const lbrace = this.expect('LBRACE');
697
- const names = [];
698
-
699
- while (!this.match('RBRACE') && !this.match('EOF')) {
700
- if (this.match('IDENTIFIER')) {
701
- names.push(this.expect('IDENTIFIER').value);
702
- // Skip optional comma
703
- if (this.match('COMMA')) {
704
- this.expect('COMMA');
705
- }
706
- } else if (this.match('COMMA')) {
707
- // Skip stray commas
708
- this.expect('COMMA');
709
- } else {
710
- // Skip non-identifiers (tolerant parsing)
647
+ if (this.match('RBRACE')) {
648
+ depth -= 1;
711
649
  this.advance();
650
+ if (depth === 0) {
651
+ break;
652
+ }
653
+ continue;
712
654
  }
713
- }
714
-
715
- const rbrace = this.expect('RBRACE');
716
-
717
- return {
718
- kind: 'using-in-references',
719
- names,
720
- source: {
721
- file: this.file,
722
- start: startToken.span.start,
723
- end: rbrace.span.end,
724
- },
725
- };
726
- }
727
655
 
728
- /**
729
- * Parses a single reference block
730
- * @returns {ReferenceBlock}
731
- */
732
- parseReference() {
733
- const startToken = this.expect('IDENTIFIER', 'reference');
734
- const lbrace = this.expect('LBRACE');
735
- let repository = null;
736
- let paths = [];
737
- let describe = null;
738
- let kind = null;
656
+ if (depth !== 1) {
657
+ this.advance();
658
+ continue;
659
+ }
739
660
 
740
- // Parse repository, paths, optional kind, and optional describe
741
- while (!this.match('RBRACE') && !this.match('EOF')) {
742
- if (this.match('RBRACE')) break;
661
+ if (this.match('COMMA')) {
662
+ this.advance();
663
+ continue;
664
+ }
743
665
 
744
- // Check for identifier or keyword (describe is a keyword)
745
666
  if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
746
- const identifier = this.peek()?.value;
747
- if (identifier === 'repository') {
748
- repository = this.parseStringBlock('repository');
749
- } else if (identifier === 'paths') {
750
- paths = this.parsePathsBlock();
751
- } else if (identifier === 'kind') {
752
- kind = this.parseStringBlock('kind');
753
- } else if (identifier === 'describe') {
754
- describe = this.parseDescribe();
755
- } else {
756
- // Skip unknown identifier/keyword
757
- this.advance();
667
+ const startToken = this.peek();
668
+ const name = this.parseIdentifierPath();
669
+ if (name && startToken) {
670
+ const lastToken = this.tokens[this.pos - 1] || startToken;
671
+ items.push({
672
+ name,
673
+ source: {
674
+ file: this.file,
675
+ start: startToken.span.start,
676
+ end: lastToken.span.end,
677
+ },
678
+ });
758
679
  }
759
- } else {
760
- // Skip unexpected tokens
761
- this.advance();
680
+ continue;
762
681
  }
763
- }
764
682
 
765
- const rbrace = this.expect('RBRACE');
766
-
767
- if (!repository) {
768
- throw new Error(`Missing 'repository' field in reference block at ${this.file}:${startToken.span.start.line}`);
769
- }
770
- if (paths.length === 0) {
771
- throw new Error(`Missing or empty 'paths' field in reference block at ${this.file}:${startToken.span.start.line}`);
683
+ this.advance();
772
684
  }
773
685
 
774
686
  return {
775
- kind: 'reference',
776
- repository,
777
- paths,
778
- referenceKind: kind,
779
- describe,
687
+ kind: 'references',
688
+ items,
780
689
  source: {
781
690
  file: this.file,
782
691
  start: startToken.span.start,
783
- end: rbrace.span.end,
692
+ end: this.tokens[this.pos - 1]?.span.end || lbrace.span.end,
784
693
  },
785
694
  };
786
695
  }
787
696
 
788
697
  /**
789
- * Parses a named reference block: reference <Name> { ... }
790
- * @returns {NamedReferenceBlock}
698
+ * Parses a reference declaration: reference <Name> [in <RepoName>] { ... }
699
+ * @returns {ReferenceDecl}
791
700
  */
792
- parseNamedReference() {
793
- const startToken = this.expect('IDENTIFIER', 'reference');
794
- const nameToken = this.expect('IDENTIFIER');
701
+ parseReferenceDecl() {
702
+ const startToken = this.match('KEYWORD') ? this.expect('KEYWORD', 'reference') : this.expect('IDENTIFIER', 'reference');
703
+ let nameToken = null;
704
+ if (this.match('IDENTIFIER')) {
705
+ nameToken = this.expect('IDENTIFIER');
706
+ }
707
+ let repositoryName = null;
708
+
709
+ if (this.match('KEYWORD') && this.peek()?.value === 'in') {
710
+ this.expect('KEYWORD', 'in');
711
+ const repoName = this.parseIdentifierPath();
712
+ if (!repoName) {
713
+ throw new Error(`Expected repository name after "in" at ${this.file}:${this.peek()?.span.start.line || '?'}`);
714
+ }
715
+ repositoryName = repoName;
716
+ }
717
+
795
718
  const lbrace = this.expect('LBRACE');
796
- let repository = null;
797
- let paths = [];
798
- let describe = null;
719
+ let url = null;
720
+ let paths = null;
799
721
  let kind = null;
722
+ let describe = null;
723
+ let note = null;
724
+ let title = null;
800
725
 
801
- // Parse repository, paths, optional kind, and optional describe (same as parseReference)
802
726
  while (!this.match('RBRACE') && !this.match('EOF')) {
803
727
  if (this.match('RBRACE')) break;
804
728
 
805
- // Check for identifier or keyword (describe is a keyword)
806
729
  if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
807
730
  const identifier = this.peek()?.value;
808
- if (identifier === 'repository') {
809
- repository = this.parseStringBlock('repository');
731
+ if (identifier === 'url') {
732
+ url = this.parseStringBlock('url');
810
733
  } else if (identifier === 'paths') {
811
734
  paths = this.parsePathsBlock();
812
735
  } else if (identifier === 'kind') {
813
736
  kind = this.parseStringBlock('kind');
814
737
  } else if (identifier === 'describe') {
815
738
  describe = this.parseDescribe();
739
+ } else if (identifier === 'note') {
740
+ note = this.parseNote();
741
+ } else if (identifier === 'title') {
742
+ title = this.parseTitle();
816
743
  } else {
817
- // Skip unknown identifier/keyword
818
744
  this.advance();
819
745
  }
820
746
  } else {
821
- // Skip unexpected tokens
822
747
  this.advance();
823
748
  }
824
749
  }
825
750
 
826
751
  const rbrace = this.expect('RBRACE');
827
752
 
828
- if (!repository) {
829
- throw new Error(`Missing 'repository' field in named reference block at ${this.file}:${startToken.span.start.line}`);
830
- }
831
- if (paths.length === 0) {
832
- throw new Error(`Missing or empty 'paths' field in named reference block at ${this.file}:${startToken.span.start.line}`);
833
- }
834
-
835
753
  return {
836
- kind: 'named-reference',
837
- name: nameToken.value,
838
- repository,
839
- paths,
840
- referenceKind: kind,
841
- describe,
754
+ kind: 'reference',
755
+ name: nameToken ? nameToken.value : undefined,
756
+ repositoryName: repositoryName || undefined,
757
+ url: url || undefined,
758
+ paths: paths || undefined,
759
+ referenceKind: kind || undefined,
760
+ describe: describe || undefined,
761
+ note: note || undefined,
762
+ title: title || undefined,
842
763
  source: {
843
764
  file: this.file,
844
765
  start: startToken.span.start,
@@ -999,45 +920,58 @@ class Parser {
999
920
  }
1000
921
 
1001
922
  /**
1002
- * Parses a repository block: repository <Identifier> { kind { ... } options { ... } }
923
+ * Parses a repository block: repository <Identifier> { url { ... } ... }
1003
924
  * @returns {RepositoryDecl}
1004
925
  */
1005
926
  parseRepository() {
1006
927
  const startToken = this.expect('KEYWORD', 'repository');
1007
928
  const nameToken = this.expect('IDENTIFIER');
929
+ let parentName = undefined;
930
+
931
+ if (this.match('KEYWORD') && this.peek()?.value === 'in') {
932
+ this.expect('KEYWORD', 'in');
933
+ const containerName = this.parseIdentifierPath();
934
+ if (!containerName) {
935
+ throw new Error(`Expected container name after "in" at ${this.file}:${this.peek()?.span.start.line || '?'}`);
936
+ }
937
+ parentName = containerName;
938
+ }
1008
939
  const lbrace = this.expect('LBRACE');
1009
940
 
1010
- let kind = null;
1011
- let options = null;
941
+ let url = null;
942
+ let describe = null;
943
+ let note = null;
944
+ let title = null;
1012
945
 
1013
946
  while (!this.match('RBRACE') && !this.match('EOF')) {
1014
947
  if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
1015
948
  const identifier = this.peek()?.value;
1016
- if (identifier === 'kind') {
1017
- kind = this.parseValueBlock('kind');
1018
- } else if (identifier === 'options') {
1019
- options = this.parseOptionsBlock();
949
+ if (identifier === 'url') {
950
+ url = this.parseStringBlock('url');
951
+ } else if (identifier === 'describe') {
952
+ describe = this.parseDescribe();
953
+ } else if (identifier === 'note') {
954
+ note = this.parseNote();
955
+ } else if (identifier === 'title') {
956
+ title = this.parseTitle();
1020
957
  } else {
1021
- // Skip unknown identifier/keyword
1022
958
  this.advance();
1023
959
  }
1024
960
  } else {
1025
- // Skip unexpected tokens
1026
961
  this.advance();
1027
962
  }
1028
963
  }
1029
964
 
1030
965
  const rbrace = this.expect('RBRACE');
1031
966
 
1032
- if (!kind) {
1033
- throw new Error(`Missing 'kind' field in repository block at ${this.file}:${startToken.span.start.line}`);
1034
- }
1035
-
1036
967
  return {
1037
968
  kind: 'repository',
1038
969
  name: nameToken.value,
1039
- repositoryKind: kind,
1040
- options: options || {},
970
+ parentName,
971
+ url: url || undefined,
972
+ describe: describe || undefined,
973
+ note: note || undefined,
974
+ title: title || undefined,
1041
975
  source: {
1042
976
  file: this.file,
1043
977
  start: startToken.span.start,
@@ -1046,6 +980,82 @@ class Parser {
1046
980
  };
1047
981
  }
1048
982
 
983
+ /**
984
+ * Parses an identifier path (IDENTIFIER (DOT IDENTIFIER)*)
985
+ * @returns {string | null}
986
+ */
987
+ parseIdentifierPath() {
988
+ if (!this.match('IDENTIFIER') && !this.match('KEYWORD')) {
989
+ return null;
990
+ }
991
+
992
+ const parts = [];
993
+ parts.push(this.advance().value);
994
+
995
+ while (this.match('DOT')) {
996
+ this.expect('DOT');
997
+ if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
998
+ parts.push(this.advance().value);
999
+ } else {
1000
+ break;
1001
+ }
1002
+ }
1003
+
1004
+ return parts.join('.');
1005
+ }
1006
+
1007
+ /**
1008
+ * Parses a note block, same format as describe
1009
+ * @returns {DescribeBlock}
1010
+ */
1011
+ parseNote() {
1012
+ const startToken = this.expect('IDENTIFIER', 'note');
1013
+ const lbrace = this.expect('LBRACE');
1014
+
1015
+ let depth = 1;
1016
+ const startOffset = lbrace.span.end.offset;
1017
+ let endOffset = startOffset;
1018
+ let endToken = null;
1019
+
1020
+ while (depth > 0 && this.pos < this.tokens.length) {
1021
+ const token = this.tokens[this.pos];
1022
+ if (token.type === 'EOF') break;
1023
+
1024
+ if (token.type === 'LBRACE') {
1025
+ depth++;
1026
+ this.pos++;
1027
+ } else if (token.type === 'RBRACE') {
1028
+ depth--;
1029
+ if (depth === 0) {
1030
+ endToken = token;
1031
+ endOffset = token.span.start.offset;
1032
+ this.pos++;
1033
+ break;
1034
+ } else {
1035
+ this.pos++;
1036
+ }
1037
+ } else {
1038
+ this.pos++;
1039
+ }
1040
+ }
1041
+
1042
+ if (depth > 0) {
1043
+ throw new Error(`Unclosed note block at ${this.file}:${startToken.span.start.line}`);
1044
+ }
1045
+
1046
+ const rawContent = this.sourceText.substring(startOffset, endOffset).trim();
1047
+
1048
+ return {
1049
+ kind: 'note',
1050
+ raw: rawContent,
1051
+ source: {
1052
+ file: this.file,
1053
+ start: lbrace.span.end,
1054
+ end: endToken ? endToken.span.start : lbrace.span.end,
1055
+ },
1056
+ };
1057
+ }
1058
+
1049
1059
  /**
1050
1060
  * Parses a paths block (e.g., paths { 'path1', 'path2' })
1051
1061
  * @returns {string[]}
@@ -5,6 +5,18 @@ universe Amaranthine {
5
5
  progress your character.
6
6
  }
7
7
 
8
+ repository AmaranthineRepo {
9
+ url { 'https://example.com/amaranthine' }
10
+ }
11
+
12
+ reference PlayerRouter in AmaranthineRepo {
13
+ paths { '/backends/api/src/routers/players.js' }
14
+ }
15
+
16
+ reference ItemData in AmaranthineRepo {
17
+ paths { '/data/items/*.yaml' }
18
+ }
19
+
8
20
  series Player {
9
21
  describe {
10
22
  The player is central to everything. From chat to skills to inventory,
@@ -12,10 +24,7 @@ universe Amaranthine {
12
24
  }
13
25
 
14
26
  references {
15
- reference {
16
- repository { 'amaranthine' }
17
- paths { '/backends/api/src/routers/players.js' }
18
- }
27
+ PlayerRouter
19
28
  }
20
29
  }
21
30
 
@@ -25,10 +34,7 @@ universe Amaranthine {
25
34
  }
26
35
 
27
36
  references {
28
- reference {
29
- repository { 'amaranthine' }
30
- paths { '/data/items/*.yaml' }
31
- }
37
+ ItemData
32
38
  }
33
39
  }
34
40