@astryxdesign/core 0.1.3 → 0.1.4-canary.02643db

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 (168) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/dist/Button/Button.d.ts.map +1 -1
  3. package/dist/Button/Button.js +29 -8
  4. package/dist/Calendar/Calendar.d.ts.map +1 -1
  5. package/dist/Calendar/Calendar.js +49 -20
  6. package/dist/Calendar/dayCellUtils.d.ts +67 -5
  7. package/dist/Calendar/dayCellUtils.d.ts.map +1 -1
  8. package/dist/Calendar/dayCellUtils.js +139 -18
  9. package/dist/CheckboxInput/CheckboxInput.d.ts +6 -1
  10. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  11. package/dist/CheckboxInput/CheckboxInput.js +7 -1
  12. package/dist/Code/Code.d.ts +15 -1
  13. package/dist/Code/Code.d.ts.map +1 -1
  14. package/dist/Code/Code.js +32 -2
  15. package/dist/Code/index.d.ts +1 -1
  16. package/dist/Code/index.d.ts.map +1 -1
  17. package/dist/CodeBlock/index.d.ts +1 -1
  18. package/dist/CodeBlock/index.d.ts.map +1 -1
  19. package/dist/CommandPalette/CommandPaletteEmpty.d.ts +1 -1
  20. package/dist/CommandPalette/CommandPaletteEmpty.d.ts.map +1 -1
  21. package/dist/CommandPalette/CommandPaletteEmpty.js +24 -4
  22. package/dist/ContextMenu/ContextMenu.d.ts +9 -1
  23. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  24. package/dist/ContextMenu/ContextMenu.js +6 -3
  25. package/dist/DateRangeInput/DateRangeInput.js +2 -2
  26. package/dist/DateTimeInput/DateTimeInput.d.ts +0 -5
  27. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  28. package/dist/Divider/Divider.d.ts +0 -22
  29. package/dist/Divider/Divider.d.ts.map +1 -1
  30. package/dist/Markdown/parser.d.ts +7 -0
  31. package/dist/Markdown/parser.d.ts.map +1 -1
  32. package/dist/Markdown/parser.js +300 -6
  33. package/dist/MultiSelector/MultiSelector.d.ts +7 -1
  34. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  35. package/dist/MultiSelector/MultiSelector.js +10 -1
  36. package/dist/RadioList/RadioList.d.ts +7 -1
  37. package/dist/RadioList/RadioList.d.ts.map +1 -1
  38. package/dist/RadioList/RadioList.js +3 -1
  39. package/dist/RadioList/RadioListItem.d.ts.map +1 -1
  40. package/dist/RadioList/RadioListItem.js +6 -1
  41. package/dist/Selector/Selector.d.ts +6 -0
  42. package/dist/Selector/Selector.d.ts.map +1 -1
  43. package/dist/Selector/Selector.js +9 -0
  44. package/dist/Slider/Slider.d.ts +6 -0
  45. package/dist/Slider/Slider.d.ts.map +1 -1
  46. package/dist/Slider/Slider.js +10 -1
  47. package/dist/Spinner/Spinner.d.ts.map +1 -1
  48. package/dist/Spinner/Spinner.js +1 -1
  49. package/dist/Switch/Switch.d.ts +6 -1
  50. package/dist/Switch/Switch.d.ts.map +1 -1
  51. package/dist/Switch/Switch.js +7 -1
  52. package/dist/Table/BaseTable.d.ts.map +1 -1
  53. package/dist/Table/BaseTable.js +8 -1
  54. package/dist/Table/TableCell.d.ts.map +1 -1
  55. package/dist/Table/TableCell.js +73 -4
  56. package/dist/Table/index.d.ts +2 -0
  57. package/dist/Table/index.d.ts.map +1 -1
  58. package/dist/Table/index.js +1 -0
  59. package/dist/Table/plugins/rowExpansion/index.d.ts +3 -0
  60. package/dist/Table/plugins/rowExpansion/index.d.ts.map +1 -0
  61. package/dist/Table/plugins/rowExpansion/index.js +3 -0
  62. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts +90 -0
  63. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -0
  64. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +398 -0
  65. package/dist/Table/tableContextMenu.d.ts +5 -1
  66. package/dist/Table/tableContextMenu.d.ts.map +1 -1
  67. package/dist/Table/tableContextMenu.js +10 -3
  68. package/dist/Text/Text.d.ts +1 -1
  69. package/dist/Text/Text.d.ts.map +1 -1
  70. package/dist/Text/Text.js +4 -3
  71. package/dist/Text/text.stylex.d.ts +35 -0
  72. package/dist/Text/text.stylex.d.ts.map +1 -1
  73. package/dist/Text/text.stylex.js +52 -1
  74. package/dist/Tokenizer/Tokenizer.d.ts +6 -1
  75. package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
  76. package/dist/Tokenizer/Tokenizer.js +10 -1
  77. package/dist/astryx.css +16 -0
  78. package/dist/astryx.umd.js +51 -49
  79. package/dist/astryx.umd.js.map +4 -4
  80. package/dist/docs-types.d.ts +12 -0
  81. package/dist/docs-types.d.ts.map +1 -1
  82. package/package.json +1 -1
  83. package/src/AspectRatio/AspectRatio.doc.mjs +1 -1
  84. package/src/Avatar/Avatar.doc.mjs +2 -2
  85. package/src/Button/Button.tsx +39 -6
  86. package/src/ButtonGroup/ButtonGroup.doc.mjs +1 -1
  87. package/src/Calendar/Calendar.test.tsx +106 -0
  88. package/src/Calendar/Calendar.tsx +59 -22
  89. package/src/Calendar/dayCellUtils.test.ts +306 -1
  90. package/src/Calendar/dayCellUtils.ts +206 -13
  91. package/src/Card/Card.doc.mjs +1 -1
  92. package/src/Chat/useChatNewMessages.test.tsx +4 -4
  93. package/src/CheckboxInput/CheckboxInput.doc.mjs +9 -1
  94. package/src/CheckboxInput/CheckboxInput.test.tsx +36 -0
  95. package/src/CheckboxInput/CheckboxInput.tsx +11 -0
  96. package/src/CheckboxList/CheckboxList.doc.mjs +1 -1
  97. package/src/Code/Code.test.tsx +54 -0
  98. package/src/Code/Code.tsx +48 -4
  99. package/src/Code/index.ts +1 -1
  100. package/src/CodeBlock/index.ts +1 -1
  101. package/src/CommandPalette/CommandPaletteEmpty.tsx +15 -1
  102. package/src/ContextMenu/ContextMenu.tsx +17 -1
  103. package/src/DateInput/DateInput.doc.mjs +6 -1
  104. package/src/DateRangeInput/DateRangeInput.doc.mjs +1 -1
  105. package/src/DateRangeInput/DateRangeInput.tsx +2 -2
  106. package/src/DateTimeInput/DateTimeInput.doc.mjs +1 -1
  107. package/src/DateTimeInput/DateTimeInput.tsx +1 -6
  108. package/src/Divider/Divider.tsx +1 -23
  109. package/src/DropdownMenu/DropdownMenu.test.tsx +5 -3
  110. package/src/EmptyState/EmptyState.doc.mjs +1 -1
  111. package/src/Field/Field.doc.mjs +1 -1
  112. package/src/FileInput/FileInput.doc.mjs +1 -1
  113. package/src/FormLayout/__snapshots__/FormLayout.test.tsx.snap +0 -3
  114. package/src/Grid/Grid.doc.mjs +3 -3
  115. package/src/Layout/__tests__/edgeCompensation.test.tsx +72 -0
  116. package/src/Markdown/Markdown.test.tsx +23 -0
  117. package/src/Markdown/incremental.test.ts +36 -0
  118. package/src/Markdown/parser.test.ts +210 -0
  119. package/src/Markdown/parser.ts +301 -6
  120. package/src/MobileNav/MobileNav.doc.mjs +20 -0
  121. package/src/MultiSelector/MultiSelector.doc.mjs +14 -1
  122. package/src/MultiSelector/MultiSelector.test.tsx +33 -0
  123. package/src/MultiSelector/MultiSelector.tsx +20 -0
  124. package/src/PowerSearch/PowerSearch.doc.mjs +2 -2
  125. package/src/RadioList/RadioList.doc.mjs +7 -1
  126. package/src/RadioList/RadioList.test.tsx +46 -0
  127. package/src/RadioList/RadioList.tsx +10 -1
  128. package/src/RadioList/RadioListItem.tsx +4 -0
  129. package/src/Section/Section.test.tsx +4 -10
  130. package/src/SegmentedControl/SegmentedControl.doc.mjs +1 -1
  131. package/src/Selector/Selector.doc.mjs +12 -1
  132. package/src/Selector/Selector.test.tsx +30 -0
  133. package/src/Selector/Selector.tsx +18 -0
  134. package/src/Slider/Slider.doc.mjs +14 -2
  135. package/src/Slider/Slider.test.tsx +30 -0
  136. package/src/Slider/Slider.tsx +20 -0
  137. package/src/Spinner/Spinner.tsx +1 -0
  138. package/src/Switch/Switch.doc.mjs +14 -2
  139. package/src/Switch/Switch.test.tsx +38 -0
  140. package/src/Switch/Switch.tsx +12 -0
  141. package/src/TabList/TabList.doc.mjs +3 -3
  142. package/src/Table/BaseTable.tsx +9 -1
  143. package/src/Table/Table.test.tsx +23 -0
  144. package/src/Table/TableCell.tsx +87 -8
  145. package/src/Table/index.ts +5 -0
  146. package/src/Table/plugins/rowExpansion/index.ts +7 -0
  147. package/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx +155 -0
  148. package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +590 -0
  149. package/src/Table/tableContextMenu.test.tsx +27 -3
  150. package/src/Table/tableContextMenu.tsx +20 -2
  151. package/src/Table/useTablePagination.doc.mjs +4 -1
  152. package/src/Table/useTableRowExpansion.doc.mjs +80 -0
  153. package/src/Table/useTableSortable.doc.mjs +4 -1
  154. package/src/Text/Text.doc.mjs +2 -2
  155. package/src/Text/Text.test.tsx +11 -0
  156. package/src/Text/Text.tsx +4 -2
  157. package/src/Text/text.stylex.ts +41 -0
  158. package/src/TextArea/TextArea.doc.mjs +1 -1
  159. package/src/TextInput/TextInput.doc.mjs +1 -1
  160. package/src/TimeInput/TimeInput.doc.mjs +11 -6
  161. package/src/Tokenizer/Tokenizer.doc.mjs +14 -2
  162. package/src/Tokenizer/Tokenizer.test.tsx +33 -0
  163. package/src/Tokenizer/Tokenizer.tsx +19 -0
  164. package/src/Toolbar/Toolbar.test.tsx +1 -1
  165. package/src/Typeahead/BaseTypeahead.doc.mjs +10 -0
  166. package/src/Typeahead/Typeahead.doc.mjs +6 -1
  167. package/src/docs-types.ts +12 -0
  168. package/src/hooks/useKeyboardHint.doc.mjs +6 -6
@@ -650,3 +650,39 @@ describe('streaming end-to-end: no raw syntax visible', () => {
650
650
  });
651
651
  });
652
652
  });
653
+
654
+ describe('parseMarkdownIncremental link reference definitions', () => {
655
+ function firstLinkHref(blocks: BlockNode[]): string | undefined {
656
+ for (const block of blocks) {
657
+ if (block.type === 'paragraph') {
658
+ for (const node of block.children) {
659
+ if (node.type === 'link') {
660
+ return node.href;
661
+ }
662
+ }
663
+ }
664
+ }
665
+ return undefined;
666
+ }
667
+
668
+ it('resolves a footer reference streamed across chunks (matches full parse)', () => {
669
+ const text = 'See [the docs][d] here.\n\n[d]: https://example.com/d\n';
670
+ const {final} = simulateStreaming(text, 5);
671
+ expect(final).toEqual(parseMarkdown(text));
672
+ expect(firstLinkHref(final)).toBe('https://example.com/d');
673
+ });
674
+
675
+ it('re-resolves an already-settled reference once its definition arrives', () => {
676
+ const state = createIncrementalState();
677
+ // The reference paragraph settles (blank line) before the definition.
678
+ parseMarkdownIncremental('See [d] here.\n\n', state);
679
+ const before = parseMarkdownIncremental('See [d] here.\n\n[d', state);
680
+ expect(firstLinkHref(before)).toBeUndefined();
681
+ // Definition completes — the settled paragraph must pick up the link.
682
+ const after = parseMarkdownIncremental(
683
+ 'See [d] here.\n\n[d]: /docs\n',
684
+ state,
685
+ );
686
+ expect(firstLinkHref(after)).toBe('/docs');
687
+ });
688
+ });
@@ -2,6 +2,7 @@
2
2
 
3
3
  import {describe, it, expect} from 'vitest';
4
4
  import {parseMarkdown, parseInline} from './parser';
5
+ import type {InlineNode} from './parser';
5
6
 
6
7
  describe('parseInline', () => {
7
8
  it('parses plain text', () => {
@@ -946,3 +947,212 @@ describe('citation parsing', () => {
946
947
  });
947
948
  });
948
949
  });
950
+
951
+ // ---------------------------------------------------------------------------
952
+ // Link reference definitions
953
+ // ---------------------------------------------------------------------------
954
+
955
+ describe('link reference definitions', () => {
956
+ // Collect every link/image node across a block tree for concise assertions.
957
+ function collectLinks(
958
+ nodes: InlineNode[],
959
+ ): {type: string; href?: string; src?: string; text: string}[] {
960
+ const out: {type: string; href?: string; src?: string; text: string}[] = [];
961
+ const textOf = (inlineNodes: InlineNode[]): string =>
962
+ inlineNodes
963
+ .map(inline =>
964
+ inline.type === 'text'
965
+ ? inline.content
966
+ : 'children' in inline
967
+ ? textOf(inline.children)
968
+ : '',
969
+ )
970
+ .join('');
971
+ for (const node of nodes) {
972
+ if (node.type === 'link') {
973
+ out.push({type: 'link', href: node.href, text: textOf(node.children)});
974
+ } else if (node.type === 'image') {
975
+ out.push({type: 'image', src: node.src, text: node.alt});
976
+ } else if ('children' in node) {
977
+ out.push(...collectLinks(node.children));
978
+ }
979
+ }
980
+ return out;
981
+ }
982
+
983
+ function paragraphLinks(input: string) {
984
+ const blocks = parseMarkdown(input);
985
+ const links = blocks.flatMap(block =>
986
+ block.type === 'paragraph' ? collectLinks(block.children) : [],
987
+ );
988
+ return {blocks, links};
989
+ }
990
+
991
+ it('resolves a full reference `[text][label]` and drops the definition', () => {
992
+ const {blocks, links} = paragraphLinks(
993
+ 'See [the docs][docs] here.\n\n[docs]: https://example.com/docs\n',
994
+ );
995
+ // Only the paragraph survives; the definition line produces no block.
996
+ expect(blocks).toHaveLength(1);
997
+ expect(blocks[0].type).toBe('paragraph');
998
+ expect(links).toEqual([
999
+ {type: 'link', href: 'https://example.com/docs', text: 'the docs'},
1000
+ ]);
1001
+ });
1002
+
1003
+ it('resolves a collapsed reference `[text][]`', () => {
1004
+ const {links} = paragraphLinks(
1005
+ 'See [the docs][].\n\n[the docs]: https://example.com/docs',
1006
+ );
1007
+ expect(links).toEqual([
1008
+ {type: 'link', href: 'https://example.com/docs', text: 'the docs'},
1009
+ ]);
1010
+ });
1011
+
1012
+ it('resolves a shortcut reference `[text]`', () => {
1013
+ const {links} = paragraphLinks(
1014
+ 'See [the docs].\n\n[the docs]: https://example.com/docs',
1015
+ );
1016
+ expect(links).toEqual([
1017
+ {type: 'link', href: 'https://example.com/docs', text: 'the docs'},
1018
+ ]);
1019
+ });
1020
+
1021
+ it('resolves a reference that appears before its definition', () => {
1022
+ const {links} = paragraphLinks('[foo]\n\n[foo]: /bar');
1023
+ expect(links).toEqual([{type: 'link', href: '/bar', text: 'foo'}]);
1024
+ });
1025
+
1026
+ it('matches labels case-insensitively with collapsed whitespace', () => {
1027
+ const {links} = paragraphLinks(
1028
+ 'See [The Docs][DOCS].\n\n[docs]: https://example.com/d',
1029
+ );
1030
+ expect(links).toEqual([
1031
+ {type: 'link', href: 'https://example.com/d', text: 'The Docs'},
1032
+ ]);
1033
+ });
1034
+
1035
+ it('supports an angle-bracket destination and a title', () => {
1036
+ const {links} = paragraphLinks(
1037
+ 'See [x].\n\n[x]: <https://example.com/x> "the title"',
1038
+ );
1039
+ expect(links).toEqual([
1040
+ {type: 'link', href: 'https://example.com/x', text: 'x'},
1041
+ ]);
1042
+ });
1043
+
1044
+ it('absorbs a title on the line after a title-less definition', () => {
1045
+ const {blocks, links} = paragraphLinks(
1046
+ 'See [x].\n\n[x]: https://example.com/x\n "the title"\n',
1047
+ );
1048
+ // The continuation title line must not leak as its own paragraph.
1049
+ expect(blocks).toHaveLength(1);
1050
+ expect(links).toEqual([
1051
+ {type: 'link', href: 'https://example.com/x', text: 'x'},
1052
+ ]);
1053
+ });
1054
+
1055
+ it('resolves an empty angle-bracket destination to an empty href', () => {
1056
+ const {blocks, links} = paragraphLinks('[foo]\n\n[foo]: <>');
1057
+ expect(blocks).toHaveLength(1);
1058
+ expect(links).toEqual([{type: 'link', href: '', text: 'foo'}]);
1059
+ });
1060
+
1061
+ it('recognizes a definition immediately after a heading (no blank line)', () => {
1062
+ const {blocks, links} = paragraphLinks('# Title\n[x]: /url\n\n[x]');
1063
+ expect(blocks.map(block => block.type)).toEqual(['heading', 'paragraph']);
1064
+ expect(links).toEqual([{type: 'link', href: '/url', text: 'x'}]);
1065
+ });
1066
+
1067
+ it('recognizes a definition immediately after a closed code fence', () => {
1068
+ const {blocks, links} = paragraphLinks('```\ncode\n```\n[x]: /url\n\n[x]');
1069
+ expect(blocks.map(block => block.type)).toEqual(['codeblock', 'paragraph']);
1070
+ expect(links).toEqual([{type: 'link', href: '/url', text: 'x'}]);
1071
+ });
1072
+
1073
+ it('uses the first definition when a label is defined twice', () => {
1074
+ const {links} = paragraphLinks('[a]\n\n[a]: /first\n[a]: /second');
1075
+ expect(links).toEqual([{type: 'link', href: '/first', text: 'a'}]);
1076
+ });
1077
+
1078
+ it('resolves a blockquote-nested definition even with a top-level definition present', () => {
1079
+ const blocks = parseMarkdown('[outer]: /o\n\n> [inner]\n>\n> [inner]: /i');
1080
+ const quote = blocks.find(block => block.type === 'blockquote');
1081
+ expect(quote?.type).toBe('blockquote');
1082
+ if (quote != null && quote.type === 'blockquote') {
1083
+ const links = quote.children.flatMap(block =>
1084
+ block.type === 'paragraph' ? collectLinks(block.children) : [],
1085
+ );
1086
+ expect(links).toEqual([{type: 'link', href: '/i', text: 'inner'}]);
1087
+ }
1088
+ });
1089
+
1090
+ it('resolves a reference image `![alt][label]`', () => {
1091
+ const {links} = paragraphLinks('![logo][l]\n\n[l]: /logo.png');
1092
+ expect(links).toEqual([{type: 'image', src: '/logo.png', text: 'logo'}]);
1093
+ });
1094
+
1095
+ it('does not treat a whitespace-only second label as collapsed', () => {
1096
+ // `[foo][ ]` is a full reference to the empty (normalized) label and matches
1097
+ // nothing; `[foo]` still resolves as a shortcut and `[ ]` stays literal.
1098
+ const blocks = parseMarkdown('[foo][ ]\n\n[foo]: /f');
1099
+ expect(blocks[0].type).toBe('paragraph');
1100
+ if (blocks[0].type === 'paragraph') {
1101
+ expect(collectLinks(blocks[0].children)).toEqual([
1102
+ {type: 'link', href: '/f', text: 'foo'},
1103
+ ]);
1104
+ const literal = blocks[0].children
1105
+ .map(node => (node.type === 'text' ? node.content : ''))
1106
+ .join('');
1107
+ expect(literal).toContain('[ ]');
1108
+ }
1109
+ });
1110
+
1111
+ it('leaves an unresolved reference as literal text', () => {
1112
+ const {blocks, links} = paragraphLinks('See [the docs][missing].');
1113
+ expect(links).toEqual([]);
1114
+ expect(blocks[0].type).toBe('paragraph');
1115
+ if (blocks[0].type === 'paragraph') {
1116
+ expect(blocks[0].children).toContainEqual({
1117
+ type: 'text',
1118
+ content: 'See [the docs][missing].',
1119
+ });
1120
+ }
1121
+ });
1122
+
1123
+ it('does not resolve a definition or reference inside a code fence', () => {
1124
+ const blocks = parseMarkdown('```\n[x]\n[x]: /should-not-resolve\n```');
1125
+ expect(blocks).toHaveLength(1);
1126
+ expect(blocks[0].type).toBe('codeblock');
1127
+ if (blocks[0].type === 'codeblock') {
1128
+ expect(blocks[0].content).toBe('[x]\n[x]: /should-not-resolve');
1129
+ }
1130
+ });
1131
+
1132
+ it('does not treat a definition-shaped line as a definition mid-paragraph', () => {
1133
+ // CommonMark: a definition cannot interrupt a paragraph.
1134
+ const {blocks, links} = paragraphLinks('Foo\n[bar]: /baz');
1135
+ expect(links).toEqual([]);
1136
+ expect(blocks).toHaveLength(1);
1137
+ expect(blocks[0].type).toBe('paragraph');
1138
+ });
1139
+
1140
+ it('leaves footnote markers and definitions untouched', () => {
1141
+ const blocks = parseMarkdown(
1142
+ 'A claim.[^1]\n\n[^1]: https://example.com/note',
1143
+ );
1144
+ // No link nodes; the `[^1]` marker stays literal and `[^1]:` is not
1145
+ // treated as a link reference definition (footnotes are out of scope).
1146
+ const links = blocks.flatMap(block =>
1147
+ block.type === 'paragraph' ? collectLinks(block.children) : [],
1148
+ );
1149
+ expect(links).toEqual([]);
1150
+ expect(blocks).toHaveLength(2);
1151
+ });
1152
+
1153
+ it('leaves ordinary bracketed text with no definition alone', () => {
1154
+ const {links, blocks} = paragraphLinks('an array like [1, 2, 3] here');
1155
+ expect(links).toEqual([]);
1156
+ expect(blocks[0].type).toBe('paragraph');
1157
+ });
1158
+ });
@@ -81,6 +81,13 @@ export type ParseOptions = {
81
81
  type ResolvedOptions = {
82
82
  readonly sourceIds: ReadonlySet<string> | undefined;
83
83
  readonly autolink: 'gfm' | undefined;
84
+ /**
85
+ * Link reference definitions (`[label]: url`) collected from the whole
86
+ * document, keyed by normalized label. Internal only — populated by the
87
+ * block parser, never by the public `ParseOptions`. Enables `parseInlineImpl`
88
+ * to resolve full/collapsed/shortcut reference links and images.
89
+ */
90
+ readonly linkDefs?: ReadonlyMap<string, string>;
84
91
  };
85
92
 
86
93
  const EMPTY_OPTS: ResolvedOptions = {sourceIds: undefined, autolink: undefined};
@@ -103,6 +110,228 @@ function resolveOptions(
103
110
  return {sourceIds: opts.sourceIds, autolink: opts.autolink};
104
111
  }
105
112
 
113
+ // ---------------------------------------------------------------------------
114
+ // Link reference definitions
115
+ // ---------------------------------------------------------------------------
116
+
117
+ // A CommonMark link reference definition line: up to 3 leading spaces, a
118
+ // bracketed label, `:`, a destination (bare or `<...>`), and an optional
119
+ // same-line title (captured as group 4 so a title-less definition can absorb a
120
+ // title on the following line). `^`-leading labels (`[^1]:`) are footnote
121
+ // definitions — a separate, unsupported feature — and are excluded so they
122
+ // pass through verbatim.
123
+ const LINK_DEFINITION_RE =
124
+ /^ {0,3}\[([^\]^](?:\\.|[^\]\\])*)\]:[ \t]*(?:<([^<>\n]*)>|(\S+))([ \t]+(?:"[^"\n]*"|'[^'\n]*'|\([^()\n]*\)))?[ \t]*$/;
125
+
126
+ // A line that is nothing but a title — the continuation form allowed when a
127
+ // definition's destination is followed by its title on the next line.
128
+ const LINK_TITLE_ONLY_RE = /^ {0,3}(?:"[^"\n]*"|'[^'\n]*'|\([^()\n]*\))[ \t]*$/;
129
+
130
+ // CommonMark matches reference labels case-insensitively with leading/trailing
131
+ // whitespace stripped and internal whitespace runs collapsed to one space.
132
+ function normalizeLinkLabel(label: string): string {
133
+ return label.trim().replace(/\s+/g, ' ').toLowerCase();
134
+ }
135
+
136
+ function matchLinkDefinition(
137
+ line: string,
138
+ ): {label: string; destination: string; hasTitle: boolean} | null {
139
+ const match = LINK_DEFINITION_RE.exec(line);
140
+ if (match == null) {
141
+ return null;
142
+ }
143
+ const label = normalizeLinkLabel(match[1]);
144
+ // `match[2]` is the `<...>` destination (present but possibly empty, e.g.
145
+ // `<>` → empty href, valid per CommonMark); `match[3]` is the bare
146
+ // destination (always non-empty). One of the two always matches.
147
+ const destination = match[2] != null ? match[2] : match[3];
148
+ if (label === '' || destination == null) {
149
+ return null;
150
+ }
151
+ return {label, destination, hasTitle: match[4] != null};
152
+ }
153
+
154
+ /**
155
+ * Collect link reference definitions from the whole document and return the
156
+ * input with the definition lines removed. A definition is recognized at a
157
+ * block boundary — document start, after a blank line, after another
158
+ * definition, or after a self-contained block (heading / thematic break /
159
+ * closed fenced code) — but never inside a fenced code block or as a lazy
160
+ * continuation of a paragraph, honoring CommonMark's rule that a definition
161
+ * cannot interrupt a paragraph. First definition wins, and definitions produce
162
+ * no output so stripping unreferenced ones is correct.
163
+ *
164
+ * Scope limit: definitions are collected at the top level only, and a
165
+ * definition directly following a list, blockquote, or table (with no blank
166
+ * line between) is not recognized. A definition nested inside a blockquote or
167
+ * list item resolves within that container (via the recursive parse) but is
168
+ * not exposed to references elsewhere in the document, unlike full CommonMark
169
+ * where every definition is global. Separating a footer definition block with
170
+ * a blank line — the usual form — always works.
171
+ */
172
+ function extractLinkDefinitions(input: string): {
173
+ defs: ReadonlyMap<string, string>;
174
+ cleaned: string;
175
+ } {
176
+ const lines = input.split('\n');
177
+ const defs = new Map<string, string>();
178
+ const keep = new Array<boolean>(lines.length).fill(true);
179
+ let atBoundary = true;
180
+ let inFence = false;
181
+ let fenceMarker = '';
182
+
183
+ for (let index = 0; index < lines.length; index++) {
184
+ const line = lines[index];
185
+ if (inFence) {
186
+ if (line.startsWith(fenceMarker)) {
187
+ inFence = false;
188
+ fenceMarker = '';
189
+ // The line after a closed fence begins a new block.
190
+ atBoundary = true;
191
+ } else {
192
+ atBoundary = false;
193
+ }
194
+ continue;
195
+ }
196
+ const fenceMatch = line.match(/^(`{3,}|~{3,})/);
197
+ if (fenceMatch) {
198
+ inFence = true;
199
+ fenceMarker = fenceMatch[1];
200
+ atBoundary = false;
201
+ continue;
202
+ }
203
+ if (line.trim() === '') {
204
+ atBoundary = true;
205
+ continue;
206
+ }
207
+ if (atBoundary) {
208
+ const def = matchLinkDefinition(line);
209
+ if (def != null) {
210
+ if (!defs.has(def.label)) {
211
+ defs.set(def.label, def.destination);
212
+ }
213
+ keep[index] = false;
214
+ // A title-less definition absorbs a title on the following line
215
+ // (CommonMark), which then also produces no output.
216
+ if (
217
+ !def.hasTitle &&
218
+ index + 1 < lines.length &&
219
+ LINK_TITLE_ONLY_RE.test(lines[index + 1])
220
+ ) {
221
+ keep[index + 1] = false;
222
+ index++;
223
+ }
224
+ // Consecutive definitions stay at a block boundary.
225
+ continue;
226
+ }
227
+ }
228
+ // A heading or thematic break is a self-contained single-line block, so
229
+ // the next line begins a new block where a definition may appear.
230
+ atBoundary = /^ {0,3}#{1,6}(?: |\t|$)/.test(line) || isHorizontalRule(line);
231
+ }
232
+
233
+ if (defs.size === 0) {
234
+ return {defs, cleaned: input};
235
+ }
236
+ const cleaned = lines.filter((_, index) => keep[index]).join('\n');
237
+ return {defs, cleaned};
238
+ }
239
+
240
+ /** Order-independent signature of a link-definition set, for cache checks. */
241
+ function linkDefsSignature(defs: ReadonlyMap<string, string>): string {
242
+ if (defs.size === 0) {
243
+ return '';
244
+ }
245
+ return [...defs]
246
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
247
+ .map(([label, dest]) => `${label}\u0000${dest}`)
248
+ .join('\u0001');
249
+ }
250
+
251
+ /**
252
+ * Resolve a full (`[text][label]`), collapsed (`[text][]`), or shortcut
253
+ * (`[text]`) reference at `start` (which points at `[`) against `linkDefs`.
254
+ * Returns the node plus the index just past the reference, or null when it is
255
+ * not a resolvable reference (caller falls through to literal handling).
256
+ */
257
+ function matchReferenceLink(
258
+ text: string,
259
+ start: number,
260
+ linkDefs: ReadonlyMap<string, string>,
261
+ opts: ResolvedOptions,
262
+ ): {node: InlineNode; end: number} | null {
263
+ const textClose = text.indexOf(']', start + 1);
264
+ if (textClose === -1) {
265
+ return null;
266
+ }
267
+ const linkText = text.slice(start + 1, textClose);
268
+ // Full `[text][label]` / collapsed `[text][]` — a matching definition wins.
269
+ if (text[textClose + 1] === '[') {
270
+ const labelClose = text.indexOf(']', textClose + 2);
271
+ if (labelClose !== -1) {
272
+ const rawLabel = text.slice(textClose + 2, labelClose);
273
+ // Only truly-empty brackets are the collapsed form; a whitespace-only
274
+ // label (`[ ]`) is a full reference whose normalized label is empty and
275
+ // matches nothing.
276
+ const label = rawLabel === '' ? linkText : rawLabel;
277
+ const href = linkDefs.get(normalizeLinkLabel(label));
278
+ if (href != null) {
279
+ return {
280
+ node: {type: 'link', href, children: parseInlineImpl(linkText, opts)},
281
+ end: labelClose + 1,
282
+ };
283
+ }
284
+ // No match — fall back to a shortcut `[text]` (CommonMark back-off),
285
+ // leaving the trailing `[label]` to be parsed separately.
286
+ }
287
+ }
288
+ // Shortcut: `[text]`.
289
+ if (linkText.trim() === '') {
290
+ return null;
291
+ }
292
+ const href = linkDefs.get(normalizeLinkLabel(linkText));
293
+ if (href == null) {
294
+ return null;
295
+ }
296
+ return {
297
+ node: {type: 'link', href, children: parseInlineImpl(linkText, opts)},
298
+ end: textClose + 1,
299
+ };
300
+ }
301
+
302
+ /** Reference-image equivalent of {@link matchReferenceLink} (`![alt][label]`). */
303
+ function matchReferenceImage(
304
+ text: string,
305
+ start: number,
306
+ linkDefs: ReadonlyMap<string, string>,
307
+ ): {node: InlineNode; end: number} | null {
308
+ const altClose = text.indexOf(']', start + 2);
309
+ if (altClose === -1) {
310
+ return null;
311
+ }
312
+ const alt = text.slice(start + 2, altClose);
313
+ if (text[altClose + 1] === '[') {
314
+ const labelClose = text.indexOf(']', altClose + 2);
315
+ if (labelClose !== -1) {
316
+ const rawLabel = text.slice(altClose + 2, labelClose);
317
+ const label = rawLabel === '' ? alt : rawLabel;
318
+ const src = linkDefs.get(normalizeLinkLabel(label));
319
+ if (src != null) {
320
+ return {node: {type: 'image', src, alt}, end: labelClose + 1};
321
+ }
322
+ // No match — fall back to a shortcut `![alt]`.
323
+ }
324
+ }
325
+ if (alt.trim() === '') {
326
+ return null;
327
+ }
328
+ const src = linkDefs.get(normalizeLinkLabel(alt));
329
+ if (src == null) {
330
+ return null;
331
+ }
332
+ return {node: {type: 'image', src, alt}, end: altClose + 1};
333
+ }
334
+
106
335
  // ---------------------------------------------------------------------------
107
336
  // Inline parser helpers
108
337
  // ---------------------------------------------------------------------------
@@ -260,6 +489,16 @@ function parseInlineImpl(text: string, opts: ResolvedOptions): InlineNode[] {
260
489
  }
261
490
  }
262
491
 
492
+ // --- Reference image ![alt][label] / ![alt][] / ![alt] ---
493
+ if (opts.linkDefs != null && text[i] === '!' && text[i + 1] === '[') {
494
+ const ref = matchReferenceImage(text, i, opts.linkDefs);
495
+ if (ref) {
496
+ nodes.push(ref.node);
497
+ i = ref.end;
498
+ continue;
499
+ }
500
+ }
501
+
263
502
  // --- Citation: bracket [id] (before link — link requires `(` after `]`) ---
264
503
  {
265
504
  const citation = matchBracketCitation(text, i, opts);
@@ -287,6 +526,16 @@ function parseInlineImpl(text: string, opts: ResolvedOptions): InlineNode[] {
287
526
  }
288
527
  }
289
528
 
529
+ // --- Reference link [text][label] / [text][] / [text] ---
530
+ if (opts.linkDefs != null && text[i] === '[') {
531
+ const ref = matchReferenceLink(text, i, opts.linkDefs, opts);
532
+ if (ref) {
533
+ nodes.push(ref.node);
534
+ i = ref.end;
535
+ continue;
536
+ }
537
+ }
538
+
290
539
  // --- Bold-italic: *** or ___ ---
291
540
  if (
292
541
  (text[i] === '*' && text[i + 1] === '*' && text[i + 2] === '*') ||
@@ -971,8 +1220,30 @@ export function parseMarkdown(
971
1220
  return parseMarkdownImpl(input, resolveOptions(arg));
972
1221
  }
973
1222
 
974
- function parseMarkdownImpl(input: string, opts: ResolvedOptions): BlockNode[] {
975
- const lines = input.split('\n');
1223
+ function parseMarkdownImpl(
1224
+ input: string,
1225
+ baseOpts: ResolvedOptions,
1226
+ ): BlockNode[] {
1227
+ // Collect this input's link reference definitions and strip their lines,
1228
+ // then merge them with any definitions inherited from an enclosing parse
1229
+ // (the incremental parser passes the whole document's definitions in; a
1230
+ // recursive blockquote/list parse inherits the outer definitions). Inherited
1231
+ // definitions win on conflict, matching CommonMark's first-definition-wins
1232
+ // in document order; locally-nested definitions still resolve within this
1233
+ // parse.
1234
+ const {defs, cleaned} = extractLinkDefinitions(input);
1235
+ const inherited = baseOpts.linkDefs;
1236
+ let linkDefs: ReadonlyMap<string, string> | undefined;
1237
+ if (defs.size === 0) {
1238
+ linkDefs = inherited;
1239
+ } else if (inherited == null) {
1240
+ linkDefs = defs;
1241
+ } else {
1242
+ linkDefs = new Map<string, string>([...defs, ...inherited]);
1243
+ }
1244
+ const opts: ResolvedOptions =
1245
+ linkDefs != null ? {...baseOpts, linkDefs} : baseOpts;
1246
+ const lines = cleaned.split('\n');
976
1247
  const blocks: BlockNode[] = [];
977
1248
  let index = 0;
978
1249
 
@@ -1106,6 +1377,13 @@ export interface IncrementalState {
1106
1377
  * with newly-arriving content.
1107
1378
  */
1108
1379
  autolink?: 'gfm';
1380
+ /**
1381
+ * Signature of the link reference definitions the cached `settledBlocks`
1382
+ * were parsed with. Definitions are document-global and typically arrive
1383
+ * (in a footer) after the references that use them, so when the set changes
1384
+ * the settled cache is invalidated to let earlier references resolve.
1385
+ */
1386
+ linkDefsKey?: string;
1109
1387
  }
1110
1388
 
1111
1389
  export function createIncrementalState(): IncrementalState {
@@ -1434,16 +1712,33 @@ export function parseMarkdownIncremental(
1434
1712
  state.settledText = '';
1435
1713
  state.settledBlocks = [];
1436
1714
  state.settledUpTo = 0;
1715
+ state.linkDefsKey = undefined;
1437
1716
  return [];
1438
1717
  }
1439
1718
 
1719
+ // Link reference definitions are document-global and usually stream in
1720
+ // (as a footer) after the references that use them, so collect them from the
1721
+ // whole input and pass them into every slice parse. When the set changes,
1722
+ // invalidate the settled cache so already-settled references re-resolve.
1723
+ const {defs: linkDefs} = extractLinkDefinitions(input);
1724
+ const linkDefsKey = linkDefsSignature(linkDefs);
1725
+ if (state.linkDefsKey !== linkDefsKey) {
1726
+ state.prevInput = '';
1727
+ state.settledText = '';
1728
+ state.settledBlocks = [];
1729
+ state.settledUpTo = 0;
1730
+ state.linkDefsKey = linkDefsKey;
1731
+ }
1732
+ const parseOpts: ResolvedOptions =
1733
+ linkDefs.size > 0 ? {...opts, linkDefs} : opts;
1734
+
1440
1735
  const lines = input.split('\n');
1441
1736
  const boundary = findSettledBoundary(lines);
1442
1737
 
1443
1738
  if (boundary < 0) {
1444
1739
  // Inside an unclosed fence or no blank-line boundary — full re-parse
1445
1740
  state.prevInput = input;
1446
- return parseMarkdownImpl(input, opts);
1741
+ return parseMarkdownImpl(input, parseOpts);
1447
1742
  }
1448
1743
 
1449
1744
  const settledText = lines.slice(0, boundary).join('\n');
@@ -1461,15 +1756,15 @@ export function parseMarkdownIncremental(
1461
1756
  ) {
1462
1757
  // Settled portion grew — parse only the new delta
1463
1758
  const delta = settledText.slice(state.settledText.length);
1464
- const deltaBlocks = parseMarkdownImpl(delta, opts);
1759
+ const deltaBlocks = parseMarkdownImpl(delta, parseOpts);
1465
1760
  settledBlocks = mergeSettledBlocks(state.settledBlocks, deltaBlocks);
1466
1761
  } else {
1467
1762
  // Content before the boundary changed — full re-parse of settled portion
1468
- settledBlocks = parseMarkdownImpl(settledText, opts);
1763
+ settledBlocks = parseMarkdownImpl(settledText, parseOpts);
1469
1764
  }
1470
1765
 
1471
1766
  const unsettledBlocks = unsettledText
1472
- ? parseMarkdownImpl(unsettledText, opts)
1767
+ ? parseMarkdownImpl(unsettledText, parseOpts)
1473
1768
  : [];
1474
1769
 
1475
1770
  state.settledText = settledText;
@@ -14,6 +14,26 @@ export const docs = {
14
14
  name: 'MobileNav',
15
15
  displayName: 'Mobile Nav',
16
16
  description: 'A slide-out drawer for mobile navigation. Accepts SideNav children.',
17
+ // The drawer opens via showModal() and renders nothing while closed —
18
+ // overlay mode gives the Properties preview an open trigger instead of
19
+ // an empty stage (#2706). Declared on this entry (not the directory
20
+ // doc) so MobileNavToggle does not inherit it.
21
+ playground: {
22
+ overlay: true,
23
+ defaults: {
24
+ isOpen: false,
25
+ header: 'Navigation',
26
+ children: {
27
+ __element: 'SideNavSection',
28
+ props: {title: 'Main'},
29
+ children: [
30
+ {__element: 'SideNavItem', props: {label: 'Dashboard', isSelected: true}},
31
+ {__element: 'SideNavItem', props: {label: 'Projects'}},
32
+ {__element: 'SideNavItem', props: {label: 'Settings'}},
33
+ ],
34
+ },
35
+ },
36
+ },
17
37
  props: [
18
38
  {
19
39
  name: 'isOpen',