@fuzdev/mdz 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/LICENSE +10 -17
  2. package/README.md +6 -3
  3. package/dist/Mdz.svelte +8 -7
  4. package/dist/Mdz.svelte.d.ts.map +1 -1
  5. package/dist/MdzNodeView.svelte +74 -30
  6. package/dist/MdzNodeView.svelte.d.ts +1 -2
  7. package/dist/MdzNodeView.svelte.d.ts.map +1 -1
  8. package/dist/MdzPrecompiled.svelte +3 -3
  9. package/dist/MdzPrecompiled.svelte.d.ts.map +1 -1
  10. package/dist/MdzRoot.svelte +3 -3
  11. package/dist/MdzRoot.svelte.d.ts.map +1 -1
  12. package/dist/MdzStream.svelte +3 -3
  13. package/dist/MdzStream.svelte.d.ts.map +1 -1
  14. package/dist/MdzStreamNodeView.svelte +79 -31
  15. package/dist/MdzStreamNodeView.svelte.d.ts +1 -2
  16. package/dist/MdzStreamNodeView.svelte.d.ts.map +1 -1
  17. package/dist/context_helpers.js +1 -1
  18. package/dist/mdz.d.ts +12 -1
  19. package/dist/mdz.d.ts.map +1 -1
  20. package/dist/mdz.js +1 -1
  21. package/dist/mdz_contexts.d.ts +33 -6
  22. package/dist/mdz_contexts.d.ts.map +1 -1
  23. package/dist/mdz_debug_work.d.ts.map +1 -1
  24. package/dist/mdz_helpers.d.ts +31 -3
  25. package/dist/mdz_helpers.d.ts.map +1 -1
  26. package/dist/mdz_helpers.js +104 -12
  27. package/dist/mdz_lexer.d.ts +3 -1
  28. package/dist/mdz_lexer.d.ts.map +1 -1
  29. package/dist/mdz_lexer.js +115 -31
  30. package/dist/mdz_opcodes.d.ts +3 -1
  31. package/dist/mdz_opcodes.d.ts.map +1 -1
  32. package/dist/mdz_opcodes_to_nodes.d.ts.map +1 -1
  33. package/dist/mdz_opcodes_to_nodes.js +25 -17
  34. package/dist/mdz_stream_parser.d.ts.map +1 -1
  35. package/dist/mdz_stream_parser.js +8 -8
  36. package/dist/mdz_stream_parser_block.js +5 -5
  37. package/dist/mdz_stream_parser_inline.js +4 -4
  38. package/dist/mdz_stream_parser_link.d.ts.map +1 -1
  39. package/dist/mdz_stream_parser_link.js +83 -11
  40. package/dist/mdz_stream_parser_list.d.ts.map +1 -1
  41. package/dist/mdz_stream_parser_list.js +13 -7
  42. package/dist/mdz_stream_parser_state.d.ts.map +1 -1
  43. package/dist/mdz_stream_parser_state.js +7 -7
  44. package/dist/mdz_stream_parser_table.d.ts.map +1 -1
  45. package/dist/mdz_stream_parser_table.js +16 -9
  46. package/dist/mdz_stream_parser_text.js +2 -2
  47. package/dist/mdz_stream_parser_url.js +7 -7
  48. package/dist/mdz_stream_state.svelte.d.ts +3 -1
  49. package/dist/mdz_stream_state.svelte.d.ts.map +1 -1
  50. package/dist/mdz_stream_state.svelte.js +4 -0
  51. package/dist/mdz_to_svelte.d.ts +17 -4
  52. package/dist/mdz_to_svelte.d.ts.map +1 -1
  53. package/dist/mdz_to_svelte.js +22 -9
  54. package/dist/mdz_token_parser.d.ts.map +1 -1
  55. package/dist/mdz_token_parser.js +25 -11
  56. package/dist/svelte_preprocess_mdz.d.ts +6 -3
  57. package/dist/svelte_preprocess_mdz.d.ts.map +1 -1
  58. package/dist/svelte_preprocess_mdz.js +11 -11
  59. package/package.json +25 -40
  60. package/src/lib/context_helpers.ts +2 -2
  61. package/src/lib/mdz.ts +15 -3
  62. package/src/lib/mdz_contexts.ts +41 -11
  63. package/src/lib/mdz_debug_work.ts +1 -1
  64. package/src/lib/mdz_helpers.ts +164 -43
  65. package/src/lib/mdz_lexer.ts +173 -80
  66. package/src/lib/mdz_opcodes.ts +3 -1
  67. package/src/lib/mdz_opcodes_to_nodes.ts +40 -30
  68. package/src/lib/mdz_stream_parser.ts +17 -17
  69. package/src/lib/mdz_stream_parser_block.ts +14 -14
  70. package/src/lib/mdz_stream_parser_inline.ts +18 -18
  71. package/src/lib/mdz_stream_parser_link.ts +88 -18
  72. package/src/lib/mdz_stream_parser_list.ts +31 -25
  73. package/src/lib/mdz_stream_parser_state.ts +27 -27
  74. package/src/lib/mdz_stream_parser_table.ts +38 -31
  75. package/src/lib/mdz_stream_parser_text.ts +3 -3
  76. package/src/lib/mdz_stream_parser_url.ts +11 -11
  77. package/src/lib/mdz_stream_state.svelte.ts +11 -8
  78. package/src/lib/mdz_to_svelte.ts +71 -27
  79. package/src/lib/mdz_token_parser.ts +52 -38
  80. package/src/lib/svelte_preprocess_mdz.ts +66 -60
  81. package/src/lib/tsdoc_mdz.ts +1 -1
@@ -26,6 +26,9 @@ import {
26
26
  LEFT_ANGLE,
27
27
  RIGHT_ANGLE,
28
28
  SLASH,
29
+ EQUALS,
30
+ APOSTROPHE,
31
+ DOUBLE_QUOTE,
29
32
  PERIOD,
30
33
  LEFT_BRACKET,
31
34
  LEFT_PAREN,
@@ -55,11 +58,11 @@ import {
55
58
  TEXT_CLASS_NEWLINE,
56
59
  TEXT_CLASS_STOP,
57
60
  TEXT_CLASS_URL,
58
- TEXT_SCAN_CLASS,
61
+ TEXT_SCAN_CLASS
59
62
  } from './mdz_helpers.ts';
60
63
 
61
- import type {MdzTableAlign} from './mdz.ts';
62
- import {mdz_debug_work} from './mdz_debug_work.ts';
64
+ import type { MdzAttribute, MdzTableAlign } from './mdz.ts';
65
+ import { mdz_debug_work } from './mdz_debug_work.ts';
63
66
 
64
67
  //
65
68
  // Token types
@@ -177,12 +180,14 @@ export interface MdzTokenTagOpen extends MdzTokenBase {
177
180
  type: 'tag_open';
178
181
  name: string;
179
182
  is_component: boolean;
183
+ attributes: Array<MdzAttribute>;
180
184
  }
181
185
 
182
186
  export interface MdzTokenTagSelfClose extends MdzTokenBase {
183
187
  type: 'tag_self_close';
184
188
  name: string;
185
189
  is_component: boolean;
190
+ attributes: Array<MdzAttribute>;
186
191
  }
187
192
 
188
193
  export interface MdzTokenTagClose extends MdzTokenBase {
@@ -295,8 +300,8 @@ export class MdzLexer {
295
300
  #index: number = 0;
296
301
  #tokens: Array<MdzToken> = [];
297
302
  #max_search_index: number = Number.MAX_SAFE_INTEGER;
298
- #search_memo: Map<string, {from: number; result: number}> = new Map();
299
- #break_memo: {from: number; result: number} | null = null;
303
+ #search_memo: Map<string, { from: number; result: number }> = new Map();
304
+ #break_memo: { from: number; result: number } | null = null;
300
305
  /**
301
306
  * Whether `#tokenize_text` is inside a list-item inline run: single
302
307
  * newlines are soft breaks whose following structural indent strips from
@@ -394,7 +399,7 @@ export class MdzLexer {
394
399
  mdz_debug_work.total += (result === -1 ? this.#text.length : result) - from;
395
400
  }
396
401
  if (memo === undefined) {
397
- this.#search_memo.set(needle, {from, result});
402
+ this.#search_memo.set(needle, { from, result });
398
403
  } else {
399
404
  memo.from = from;
400
405
  memo.result = result;
@@ -421,7 +426,7 @@ export class MdzLexer {
421
426
  if (this.#is_at_paragraph_break()) {
422
427
  const start = this.#index;
423
428
  this.#skip_blank_lines();
424
- this.#tokens.push({type: 'paragraph_break', start, end: this.#index});
429
+ this.#tokens.push({ type: 'paragraph_break', start, end: this.#index });
425
430
  continue;
426
431
  }
427
432
 
@@ -540,7 +545,7 @@ export class MdzLexer {
540
545
  type: 'heading_start',
541
546
  level: hash_count as 1 | 2 | 3 | 4 | 5 | 6,
542
547
  start,
543
- end: this.#index, // end of "## " prefix
548
+ end: this.#index // end of "## " prefix
544
549
  });
545
550
 
546
551
  // Find end-of-line to bound nested tokenizers (prevents tag scanner from scanning past heading)
@@ -580,7 +585,7 @@ export class MdzLexer {
580
585
  this.#max_search_index = saved_max;
581
586
 
582
587
  // Emit heading_end marker so the token parser knows where heading content stops
583
- this.#tokens.push({type: 'heading_end', start: this.#index, end: this.#index});
588
+ this.#tokens.push({ type: 'heading_end', start: this.#index, end: this.#index });
584
589
 
585
590
  // Skip newlines after heading
586
591
  this.#skip_blank_lines();
@@ -613,7 +618,7 @@ export class MdzLexer {
613
618
  const start = this.#index;
614
619
  this.#index = i; // consume up to but not including newline
615
620
 
616
- this.#tokens.push({type: 'hr', start, end: this.#index});
621
+ this.#tokens.push({ type: 'hr', start, end: this.#index });
617
622
 
618
623
  // Skip newlines after HR
619
624
  this.#skip_blank_lines();
@@ -641,7 +646,7 @@ export class MdzLexer {
641
646
  * Match a quote prefix in the complete sync text — never `'pending'`.
642
647
  * See `mdz_match_blockquote_prefix`.
643
648
  */
644
- #match_blockquote_prefix(i: number): {depth: number; content_start: number} | null {
649
+ #match_blockquote_prefix(i: number): { depth: number; content_start: number } | null {
645
650
  const prefix = mdz_match_blockquote_prefix(this.#text, i, true);
646
651
  return prefix === 'pending' ? null : prefix;
647
652
  }
@@ -694,7 +699,7 @@ export class MdzLexer {
694
699
  // quote's content parses exactly like a standalone document (a complete
695
700
  // fence opener line on the last quote line opens an empty fence, not text)
696
701
  const parts: Array<string> = [];
697
- const segments: Array<{inner_start: number; source_start: number}> = [];
702
+ const segments: Array<{ inner_start: number; source_start: number }> = [];
698
703
  let inner_len = 0;
699
704
  let line_first = first;
700
705
  let last_line_end = first;
@@ -702,7 +707,7 @@ export class MdzLexer {
702
707
  const strip = line_first + mdz_blockquote_strip_width(this.#text, line_first);
703
708
  let nl = this.#text.indexOf('\n', line_first);
704
709
  if (nl === -1) nl = this.#text.length;
705
- segments.push({inner_start: inner_len, source_start: strip});
710
+ segments.push({ inner_start: inner_len, source_start: strip });
706
711
  const content = this.#text.slice(Math.min(strip, nl), nl < this.#text.length ? nl + 1 : nl);
707
712
  parts.push(content);
708
713
  inner_len += content.length;
@@ -727,14 +732,14 @@ export class MdzLexer {
727
732
  line_first = next_first;
728
733
  }
729
734
 
730
- this.#tokens.push({type: 'blockquote_open', start: first, end: first});
735
+ this.#tokens.push({ type: 'blockquote_open', start: first, end: first });
731
736
  const inner_tokens = new MdzLexer(parts.join('')).tokenize();
732
737
  for (const token of inner_tokens) {
733
738
  token.start = mdz_remap_segments(segments, token.start);
734
739
  token.end = mdz_remap_segments(segments, token.end);
735
740
  this.#tokens.push(token);
736
741
  }
737
- this.#tokens.push({type: 'blockquote_close', start: last_line_end, end: last_line_end});
742
+ this.#tokens.push({ type: 'blockquote_close', start: last_line_end, end: last_line_end });
738
743
  this.#index = last_line_end;
739
744
  }
740
745
 
@@ -763,7 +768,7 @@ export class MdzLexer {
763
768
  if (align === null || align.length !== header_cells.length) return false;
764
769
 
765
770
  // committed — emit the table; the delimiter row is structural (not emitted)
766
- this.#tokens.push({type: 'table_open', align, start: table_start, end: header_end});
771
+ this.#tokens.push({ type: 'table_open', align, start: table_start, end: header_end });
767
772
  this.#emit_table_row(header_cells, true, table_start, header_end);
768
773
 
769
774
  this.#index = delim_end;
@@ -781,23 +786,23 @@ export class MdzLexer {
781
786
  table_end = row_end;
782
787
  }
783
788
 
784
- this.#tokens.push({type: 'table_close', start: table_end, end: table_end});
789
+ this.#tokens.push({ type: 'table_close', start: table_end, end: table_end });
785
790
  this.#skip_blank_lines();
786
791
  return true;
787
792
  }
788
793
 
789
794
  /** Emit one table row's open / cell / close tokens. */
790
795
  #emit_table_row(
791
- cells: Array<{start: number; end: number}>,
796
+ cells: Array<{ start: number; end: number }>,
792
797
  header: boolean,
793
798
  row_start: number,
794
- row_end: number,
799
+ row_end: number
795
800
  ): void {
796
- this.#tokens.push({type: 'table_row_open', header, start: row_start, end: row_start});
801
+ this.#tokens.push({ type: 'table_row_open', header, start: row_start, end: row_start });
797
802
  for (const cell of cells) {
798
803
  this.#emit_table_cell(cell.start, cell.end);
799
804
  }
800
- this.#tokens.push({type: 'table_row_close', start: row_end, end: row_end});
805
+ this.#tokens.push({ type: 'table_row_close', start: row_end, end: row_end });
801
806
  }
802
807
 
803
808
  /**
@@ -807,9 +812,9 @@ export class MdzLexer {
807
812
  * stops at that bound and unescapes `\|`.
808
813
  */
809
814
  #emit_table_cell(cell_start: number, cell_end: number): void {
810
- this.#tokens.push({type: 'table_cell_open', start: cell_start, end: cell_start});
815
+ this.#tokens.push({ type: 'table_cell_open', start: cell_start, end: cell_start });
811
816
  this.#tokenize_cell_inline(cell_start, cell_end);
812
- this.#tokens.push({type: 'table_cell_close', start: cell_end, end: cell_end});
817
+ this.#tokens.push({ type: 'table_cell_close', start: cell_end, end: cell_end });
813
818
  }
814
819
 
815
820
  /**
@@ -838,9 +843,9 @@ export class MdzLexer {
838
843
  */
839
844
  #match_item_table_head(
840
845
  first: number,
841
- marker_indent: number,
846
+ marker_indent: number
842
847
  ): {
843
- header_cells: Array<{start: number; end: number}>;
848
+ header_cells: Array<{ start: number; end: number }>;
844
849
  header_end: number;
845
850
  align: Array<MdzTableAlign>;
846
851
  delim_end: number;
@@ -857,7 +862,7 @@ export class MdzLexer {
857
862
  if (delim_end === -1) delim_end = this.#text.length;
858
863
  const align = mdz_parse_table_delimiter(this.#text, dj, delim_end);
859
864
  if (align === null || align.length !== header_cells.length) return null;
860
- return {header_cells, header_end, align, delim_end};
865
+ return { header_cells, header_end, align, delim_end };
861
866
  }
862
867
 
863
868
  /** Whether a table opens at `first` as a block child of an item at `marker_indent`. */
@@ -874,7 +879,12 @@ export class MdzLexer {
874
879
  */
875
880
  #tokenize_item_table(first: number, marker_indent: number): void {
876
881
  const head = this.#match_item_table_head(first, marker_indent)!;
877
- this.#tokens.push({type: 'table_open', align: head.align, start: first, end: head.header_end});
882
+ this.#tokens.push({
883
+ type: 'table_open',
884
+ align: head.align,
885
+ start: first,
886
+ end: head.header_end
887
+ });
878
888
  this.#emit_table_row(head.header_cells, true, first, head.header_end);
879
889
  this.#index = head.delim_end;
880
890
  let table_end = head.delim_end;
@@ -892,7 +902,7 @@ export class MdzLexer {
892
902
  this.#index = row_end;
893
903
  table_end = row_end;
894
904
  }
895
- this.#tokens.push({type: 'table_close', start: table_end, end: table_end});
905
+ this.#tokens.push({ type: 'table_close', start: table_end, end: table_end });
896
906
  }
897
907
 
898
908
  // -- List tokenizers --
@@ -964,7 +974,7 @@ export class MdzLexer {
964
974
  }
965
975
  if (levels[levels.length - 1]!.ordered === marker.ordered) {
966
976
  // sibling item
967
- this.#tokens.push({type: 'list_item_close', start: first, end: first});
977
+ this.#tokens.push({ type: 'list_item_close', start: first, end: first });
968
978
  this.#list_item_open(marker);
969
979
  } else {
970
980
  // marker-type switch: close the list, open a new one at this indent
@@ -980,13 +990,13 @@ export class MdzLexer {
980
990
  // empty item: only at an indent exactly matching an open level of
981
991
  // the same marker type — anything else degrades below
982
992
  const level_idx = levels.findIndex(
983
- (l) => l.indent === indent && l.ordered === marker.ordered,
993
+ (l) => l.indent === indent && l.ordered === marker.ordered
984
994
  );
985
995
  if (level_idx !== -1) {
986
996
  while (levels.length - 1 > level_idx) {
987
997
  this.#list_close(levels);
988
998
  }
989
- this.#tokens.push({type: 'list_item_close', start: first, end: first});
999
+ this.#tokens.push({ type: 'list_item_close', start: first, end: first });
990
1000
  this.#list_item_open(marker);
991
1001
  // consume the marker and any trailing whitespace, leaving #index
992
1002
  // at the line's newline (or EOF)
@@ -1056,7 +1066,7 @@ export class MdzLexer {
1056
1066
  while (levels.length - 1 > target_idx) {
1057
1067
  this.#list_close(levels);
1058
1068
  }
1059
- this.#tokens.push({type: 'paragraph_break', start: line_start, end: line_start});
1069
+ this.#tokens.push({ type: 'paragraph_break', start: line_start, end: line_start });
1060
1070
  this.#list_run(levels, first);
1061
1071
  continue;
1062
1072
  }
@@ -1091,13 +1101,13 @@ export class MdzLexer {
1091
1101
  start: i,
1092
1102
  marker_end: after,
1093
1103
  content_start: after,
1094
- empty: true,
1104
+ empty: true
1095
1105
  };
1096
1106
  }
1097
1107
  if (this.#text.charCodeAt(after) !== SPACE) return null;
1098
1108
  const content_start = after + 1;
1099
1109
  const empty = this.#rest_is_blank(content_start);
1100
- return {ordered: false, number: 0, start: i, marker_end: after, content_start, empty};
1110
+ return { ordered: false, number: 0, start: i, marker_end: after, content_start, empty };
1101
1111
  }
1102
1112
  if (c >= ZERO && c <= NINE) {
1103
1113
  let j = i + 1;
@@ -1119,13 +1129,13 @@ export class MdzLexer {
1119
1129
  start: i,
1120
1130
  marker_end: after,
1121
1131
  content_start: after,
1122
- empty: true,
1132
+ empty: true
1123
1133
  };
1124
1134
  }
1125
1135
  if (this.#text.charCodeAt(after) !== SPACE) return null;
1126
1136
  const content_start = after + 1;
1127
1137
  const empty = this.#rest_is_blank(content_start);
1128
- return {ordered: true, number, start: i, marker_end: after, content_start, empty};
1138
+ return { ordered: true, number, start: i, marker_end: after, content_start, empty };
1129
1139
  }
1130
1140
  return null;
1131
1141
  }
@@ -1162,26 +1172,31 @@ export class MdzLexer {
1162
1172
  type: 'list_open',
1163
1173
  ordered: marker.ordered,
1164
1174
  start: marker.start,
1165
- end: marker.start,
1175
+ end: marker.start
1166
1176
  });
1167
1177
  this.#list_item_open(marker);
1168
- levels.push({indent, ordered: marker.ordered});
1178
+ levels.push({ indent, ordered: marker.ordered });
1169
1179
  }
1170
1180
 
1171
1181
  /** Emit a `list_item_open` token for `marker`. */
1172
1182
  #list_item_open(marker: MdzListMarker): void {
1173
1183
  const end = marker.empty ? marker.marker_end : marker.content_start;
1174
1184
  if (marker.ordered) {
1175
- this.#tokens.push({type: 'list_item_open', number: marker.number, start: marker.start, end});
1185
+ this.#tokens.push({
1186
+ type: 'list_item_open',
1187
+ number: marker.number,
1188
+ start: marker.start,
1189
+ end
1190
+ });
1176
1191
  } else {
1177
- this.#tokens.push({type: 'list_item_open', start: marker.start, end});
1192
+ this.#tokens.push({ type: 'list_item_open', start: marker.start, end });
1178
1193
  }
1179
1194
  }
1180
1195
 
1181
1196
  /** Close the deepest open level: its current item, then its list. */
1182
1197
  #list_close(levels: Array<MdzListLevel>): void {
1183
- this.#tokens.push({type: 'list_item_close', start: this.#index, end: this.#index});
1184
- this.#tokens.push({type: 'list_close', start: this.#index, end: this.#index});
1198
+ this.#tokens.push({ type: 'list_item_close', start: this.#index, end: this.#index });
1199
+ this.#tokens.push({ type: 'list_close', start: this.#index, end: this.#index });
1185
1200
  levels.pop();
1186
1201
  }
1187
1202
 
@@ -1278,8 +1293,8 @@ export class MdzLexer {
1278
1293
  */
1279
1294
  #match_fence(
1280
1295
  first: number,
1281
- fence_indent: number,
1282
- ): {token: MdzTokenCodeblock; end: number} | null {
1296
+ fence_indent: number
1297
+ ): { token: MdzTokenCodeblock; end: number } | null {
1283
1298
  let i = first;
1284
1299
  let backtick_count = 0;
1285
1300
  while (i < this.#text.length && this.#text.charCodeAt(i) === BACKTICK) {
@@ -1336,8 +1351,8 @@ export class MdzLexer {
1336
1351
  ? this.#text.slice(content_start, Math.max(content_start, line_start - 1))
1337
1352
  : content_lines.join('\n');
1338
1353
  return {
1339
- token: {type: 'codeblock', lang, content, start: first, end: m},
1340
- end: m,
1354
+ token: { type: 'codeblock', lang, content, start: first, end: m },
1355
+ end: m
1341
1356
  };
1342
1357
  }
1343
1358
  }
@@ -1347,7 +1362,7 @@ export class MdzLexer {
1347
1362
  if (fence_indent > 0) {
1348
1363
  const line_end = nl === -1 ? this.#text.length : nl;
1349
1364
  content_lines.push(
1350
- this.#text.slice(line_start + Math.min(fence_indent, line_indent), line_end),
1365
+ this.#text.slice(line_start + Math.min(fence_indent, line_indent), line_end)
1351
1366
  );
1352
1367
  }
1353
1368
  if (nl === -1) break;
@@ -1358,8 +1373,8 @@ export class MdzLexer {
1358
1373
  let content = fence_indent === 0 ? this.#text.slice(content_start) : content_lines.join('\n');
1359
1374
  if (content.endsWith('\n')) content = content.slice(0, -1);
1360
1375
  return {
1361
- token: {type: 'codeblock', lang, content, start: first, end: this.#text.length},
1362
- end: this.#text.length,
1376
+ token: { type: 'codeblock', lang, content, start: first, end: this.#text.length },
1377
+ end: this.#text.length
1363
1378
  };
1364
1379
  }
1365
1380
 
@@ -1453,7 +1468,7 @@ export class MdzLexer {
1453
1468
  }
1454
1469
 
1455
1470
  this.#index = content_end + 1;
1456
- this.#tokens.push({type: 'code', content, start, end: this.#index});
1471
+ this.#tokens.push({ type: 'code', content, start, end: this.#index });
1457
1472
  }
1458
1473
 
1459
1474
  #tokenize_double_delimiter(delimiter: '**' | '~~', kind: 'bold' | 'strikethrough'): void {
@@ -1492,7 +1507,7 @@ export class MdzLexer {
1492
1507
  const open_type = kind === 'bold' ? 'bold_open' : 'strikethrough_open';
1493
1508
  const close_type = kind === 'bold' ? 'bold_close' : 'strikethrough_close';
1494
1509
  const open_token_index = this.#tokens.length;
1495
- this.#tokens.push({type: open_type, start, end: this.#index});
1510
+ this.#tokens.push({ type: open_type, start, end: this.#index });
1496
1511
 
1497
1512
  // Set search boundary for nested parsers
1498
1513
  const saved_max = this.#max_search_index;
@@ -1519,11 +1534,11 @@ export class MdzLexer {
1519
1534
  return;
1520
1535
  }
1521
1536
 
1522
- this.#tokens.push({type: close_type, start: close_index, end: close_index + 2});
1537
+ this.#tokens.push({ type: close_type, start: close_index, end: close_index + 2 });
1523
1538
  this.#index = close_index + 2;
1524
1539
  } else {
1525
1540
  // Didn't reach closing - convert opening to text
1526
- this.#tokens[open_token_index] = {type: 'text', content: delimiter, start, end: start + 2};
1541
+ this.#tokens[open_token_index] = { type: 'text', content: delimiter, start, end: start + 2 };
1527
1542
  }
1528
1543
  }
1529
1544
 
@@ -1566,7 +1581,7 @@ export class MdzLexer {
1566
1581
  // Emit open token
1567
1582
  this.#index++;
1568
1583
  const open_token_index = this.#tokens.length;
1569
- this.#tokens.push({type: 'italic_open', start, end: this.#index});
1584
+ this.#tokens.push({ type: 'italic_open', start, end: this.#index });
1570
1585
 
1571
1586
  // Set search boundary for nested parsers
1572
1587
  const saved_max = this.#max_search_index;
@@ -1596,7 +1611,7 @@ export class MdzLexer {
1596
1611
  this.#tokens.push({
1597
1612
  type: 'italic_close',
1598
1613
  start: close_index,
1599
- end: close_index + 1,
1614
+ end: close_index + 1
1600
1615
  });
1601
1616
  this.#index = close_index + 1;
1602
1617
  } else {
@@ -1605,7 +1620,7 @@ export class MdzLexer {
1605
1620
  type: 'text',
1606
1621
  content: '_',
1607
1622
  start,
1608
- end: start + 1,
1623
+ end: start + 1
1609
1624
  };
1610
1625
  }
1611
1626
  }
@@ -1632,7 +1647,7 @@ export class MdzLexer {
1632
1647
  this.#index++; // consume [ (dispatch guarantees it)
1633
1648
 
1634
1649
  // Emit link_text_open
1635
- this.#tokens.push({type: 'link_text_open', start, end: this.#index});
1650
+ this.#tokens.push({ type: 'link_text_open', start, end: this.#index });
1636
1651
 
1637
1652
  // Tokenize children until ] — only `]` delimits link text; a bare `)`
1638
1653
  // is ordinary content (matching the streaming parser, which has no `)`
@@ -1657,7 +1672,7 @@ export class MdzLexer {
1657
1672
  this.#tokens.push({
1658
1673
  type: 'link_text_close',
1659
1674
  start: bracket_close_start,
1660
- end: this.#index,
1675
+ end: this.#index
1661
1676
  });
1662
1677
 
1663
1678
  // Check for (
@@ -1707,7 +1722,7 @@ export class MdzLexer {
1707
1722
  reference,
1708
1723
  link_type,
1709
1724
  start: bracket_close_start + 1, // after ]
1710
- end: this.#index,
1725
+ end: this.#index
1711
1726
  });
1712
1727
  }
1713
1728
 
@@ -1784,9 +1799,78 @@ export class MdzLexer {
1784
1799
  const first_char_code = tag_name.charCodeAt(0);
1785
1800
  const is_component = first_char_code >= A_UPPER && first_char_code <= Z_UPPER;
1786
1801
 
1787
- // Skip whitespace
1788
- while (this.#index < this.#text.length && this.#text.charCodeAt(this.#index) === SPACE) {
1789
- this.#index++;
1802
+ // Attribute loop: consume space-separated attributes until `/>` or `>`.
1803
+ // Registry-blind — every syntactically valid attribute is parsed here;
1804
+ // policy (allowlist filtering) happens at render/build time. The scan is
1805
+ // a single forward pass bounded by the line: any newline (or a missing
1806
+ // separator, bad value, or duplicate name) rejects the whole tag to
1807
+ // literal text via the shared `#reject_tag` bailout.
1808
+ const attributes: Array<MdzAttribute> = [];
1809
+ const seen = new Set<string>(); // O(1) duplicate-name detection
1810
+ for (;;) {
1811
+ // skip a run of literal spaces only (tabs/newlines are not SPACE, so
1812
+ // they fall through to the reject path — the single-line, space-only rule)
1813
+ let had_space = false;
1814
+ while (this.#index < this.#text.length && this.#text.charCodeAt(this.#index) === SPACE) {
1815
+ this.#index++;
1816
+ had_space = true;
1817
+ }
1818
+ const c = this.#index < this.#text.length ? this.#text.charCodeAt(this.#index) : -1;
1819
+ // `/>` or `>` ends the open tag — break to the existing self-close / commit
1820
+ if (c === SLASH || c === RIGHT_ANGLE) break;
1821
+ // otherwise an attribute name must follow, space-separated from what
1822
+ // precedes it (`<a x="1"y="2">` rejects — `y` abuts the closing quote)
1823
+ if (c === -1 || !had_space || !is_letter(c)) {
1824
+ this.#reject_tag(start, bound);
1825
+ return;
1826
+ }
1827
+ // collect the attribute name (same grammar as tag names)
1828
+ const attr_start = this.#index;
1829
+ this.#index++; // first char already passed is_letter
1830
+ while (
1831
+ this.#index < this.#text.length &&
1832
+ is_tag_name_char(this.#text.charCodeAt(this.#index))
1833
+ ) {
1834
+ this.#index++;
1835
+ }
1836
+ const name = this.#text.slice(attr_start, this.#index);
1837
+ // duplicate names are ambiguous (Svelte compile-errors) → literal tag
1838
+ if (seen.has(name)) {
1839
+ this.#reject_tag(start, bound);
1840
+ return;
1841
+ }
1842
+ seen.add(name);
1843
+ let value: string | true = true; // bare name is boolean `true`
1844
+ if (this.#index < this.#text.length && this.#text.charCodeAt(this.#index) === EQUALS) {
1845
+ this.#index++; // consume =
1846
+ // the value must be immediately quoted — no space after `=`, no
1847
+ // `{` brace literal, no unquoted value (this one check covers all)
1848
+ const quote = this.#index < this.#text.length ? this.#text.charCodeAt(this.#index) : -1;
1849
+ if (quote !== DOUBLE_QUOTE && quote !== APOSTROPHE) {
1850
+ this.#reject_tag(start, bound);
1851
+ return;
1852
+ }
1853
+ this.#index++; // consume opening quote
1854
+ const value_start = this.#index;
1855
+ // scan to the matching quote, bounded by newline / text end — NOT
1856
+ // by `#max_search_index` (the open-tag scan never is); a `>` inside
1857
+ // the value is content, consumed atomically before `>` is sought
1858
+ while (
1859
+ this.#index < this.#text.length &&
1860
+ this.#text.charCodeAt(this.#index) !== quote &&
1861
+ this.#text.charCodeAt(this.#index) !== NEWLINE
1862
+ ) {
1863
+ this.#index++;
1864
+ }
1865
+ if (this.#index >= this.#text.length || this.#text.charCodeAt(this.#index) === NEWLINE) {
1866
+ // unterminated quote (or newline inside the value) → literal tag
1867
+ this.#reject_tag(start, bound);
1868
+ return;
1869
+ }
1870
+ value = this.#text.slice(value_start, this.#index);
1871
+ this.#index++; // consume closing quote
1872
+ }
1873
+ attributes.push({ name, value, start: attr_start, end: this.#index });
1790
1874
  }
1791
1875
 
1792
1876
  // Check for self-closing />
@@ -1800,17 +1884,16 @@ export class MdzLexer {
1800
1884
  type: 'tag_self_close',
1801
1885
  name: tag_name,
1802
1886
  is_component,
1887
+ attributes,
1803
1888
  start,
1804
- end: this.#index,
1889
+ end: this.#index
1805
1890
  });
1806
1891
  return;
1807
1892
  }
1808
1893
 
1809
1894
  // Check for >
1810
1895
  if (this.#index >= this.#text.length || this.#text.charCodeAt(this.#index) !== RIGHT_ANGLE) {
1811
- this.#failed_tag.add(`${start}:${bound}`);
1812
- this.#index = start + 1;
1813
- this.#emit_text('<', start);
1896
+ this.#reject_tag(start, bound);
1814
1897
  return;
1815
1898
  }
1816
1899
  this.#index++; // consume >
@@ -1827,21 +1910,24 @@ export class MdzLexer {
1827
1910
  const search_limit = Math.min(this.#max_search_index, this.#text.length);
1828
1911
  const closing_tag_pos = this.#index_of(closing_tag, this.#index);
1829
1912
  if (closing_tag_pos === -1 || closing_tag_pos > search_limit) {
1830
- this.#failed_tag.add(`${start}:${bound}`);
1831
- this.#index = start + 1;
1832
- this.#emit_text('<', start);
1913
+ this.#reject_tag(start, bound);
1833
1914
  return;
1834
1915
  }
1835
1916
  if (this.#has_paragraph_break_between(this.#index, closing_tag_pos)) {
1836
- this.#failed_tag.add(`${start}:${bound}`);
1837
- this.#index = start + 1;
1838
- this.#emit_text('<', start);
1917
+ this.#reject_tag(start, bound);
1839
1918
  return;
1840
1919
  }
1841
1920
 
1842
1921
  // Emit tag_open
1843
1922
  const open_token_index = this.#tokens.length;
1844
- this.#tokens.push({type: 'tag_open', name: tag_name, is_component, start, end: this.#index});
1923
+ this.#tokens.push({
1924
+ type: 'tag_open',
1925
+ name: tag_name,
1926
+ is_component,
1927
+ attributes,
1928
+ start,
1929
+ end: this.#index
1930
+ });
1845
1931
 
1846
1932
  // Tokenize children until the closing tag. Each iteration narrows
1847
1933
  // `#max_search_index` to the next closer so a child's delimiter scan
@@ -1860,7 +1946,7 @@ export class MdzLexer {
1860
1946
  type: 'tag_close',
1861
1947
  name: tag_name,
1862
1948
  start: close_start,
1863
- end: this.#index,
1949
+ end: this.#index
1864
1950
  });
1865
1951
  return;
1866
1952
  }
@@ -1882,8 +1968,15 @@ export class MdzLexer {
1882
1968
  // (the `#revert_tokens_from_link_open` pattern; the old fallthrough
1883
1969
  // left a dangling `tag_open` whose parser fallback discarded content)
1884
1970
  this.#max_search_index = saved_max;
1885
- this.#failed_tag.add(`${start}:${bound}`);
1886
1971
  this.#tokens.splice(open_token_index);
1972
+ this.#reject_tag(start, bound);
1973
+ }
1974
+
1975
+ // a tag open at `start` that can't commit: memoizes the failure for this
1976
+ // `bound` so re-entries bail fast, then emits the `<` as text and resumes
1977
+ // scanning after it — the rest re-parses as ordinary content
1978
+ #reject_tag(start: number, bound: number): void {
1979
+ this.#failed_tag.add(`${start}:${bound}`);
1887
1980
  this.#index = start + 1;
1888
1981
  this.#emit_text('<', start);
1889
1982
  }
@@ -2016,7 +2109,7 @@ export class MdzLexer {
2016
2109
  parts === null
2017
2110
  ? this.#text.slice(start, this.#index)
2018
2111
  : parts.join('') + this.#text.slice(part_start, this.#index);
2019
- this.#tokens.push({type: 'text', content, start, end: this.#index});
2112
+ this.#tokens.push({ type: 'text', content, start, end: this.#index });
2020
2113
  }
2021
2114
 
2022
2115
  // -- Auto-link tokenizers --
@@ -2045,7 +2138,7 @@ export class MdzLexer {
2045
2138
  reference,
2046
2139
  link_type: 'external',
2047
2140
  start,
2048
- end: this.#index,
2141
+ end: this.#index
2049
2142
  });
2050
2143
  }
2051
2144
 
@@ -2070,14 +2163,14 @@ export class MdzLexer {
2070
2163
  reference,
2071
2164
  link_type: 'internal',
2072
2165
  start,
2073
- end: this.#index,
2166
+ end: this.#index
2074
2167
  });
2075
2168
  }
2076
2169
 
2077
2170
  // -- Helper methods --
2078
2171
 
2079
2172
  #emit_text(content: string, start: number): void {
2080
- this.#tokens.push({type: 'text', content, start, end: start + content.length});
2173
+ this.#tokens.push({ type: 'text', content, start, end: start + content.length });
2081
2174
  }
2082
2175
 
2083
2176
  #has_paragraph_break_between(from: number, to: number): boolean {
@@ -2116,7 +2209,7 @@ export class MdzLexer {
2116
2209
  i = this.#text.indexOf('\n', j);
2117
2210
  }
2118
2211
  if (memo === null) {
2119
- this.#break_memo = {from, result};
2212
+ this.#break_memo = { from, result };
2120
2213
  } else {
2121
2214
  memo.from = from;
2122
2215
  memo.result = result;
@@ -12,7 +12,7 @@
12
12
  * @module
13
13
  */
14
14
 
15
- import type {MdzTableAlign} from './mdz.ts';
15
+ import type { MdzAttribute, MdzTableAlign } from './mdz.ts';
16
16
 
17
17
  /**
18
18
  * Unique monotonic identifier for each node created by the parser.
@@ -66,6 +66,8 @@ export interface MdzOpcodeOpen {
66
66
  level?: 1 | 2 | 3 | 4 | 5 | 6;
67
67
  /** Tag name. Present when `node_type` is `'Element'` or `'Component'`. */
68
68
  name?: string;
69
+ /** Parsed attributes. Present (possibly empty) when `node_type` is `'Element'` or `'Component'`. */
70
+ attributes?: Array<MdzAttribute>;
69
71
  /** Language hint. Present when `node_type` is `'Codeblock'`. */
70
72
  lang?: string | null;
71
73
  /** Whether the list is ordered. Present when `node_type` is `'List'`. */