@bendyline/squisq 1.4.1 → 1.5.1

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 (254) hide show
  1. package/README.md +14 -1
  2. package/dist/{ContentContainer-DNx460y8.d.ts → ContentContainer-BXUlIf18.d.ts} +6 -2
  3. package/dist/{Doc-BeSeH2iX.d.ts → Doc-BBVGq1_9.d.ts} +922 -273
  4. package/dist/{ImageEditDoc-BTIvw0oq.d.ts → ImageEditDoc-FuyGtt6o.d.ts} +11 -4
  5. package/dist/{chunk-ZTX4PFFT.js → chunk-2OIBZYKP.js} +75 -2
  6. package/dist/chunk-2OIBZYKP.js.map +1 -0
  7. package/dist/chunk-2UQOHYE2.js +45 -0
  8. package/dist/chunk-2UQOHYE2.js.map +1 -0
  9. package/dist/chunk-6ATE2PSM.js +344 -0
  10. package/dist/chunk-6ATE2PSM.js.map +1 -0
  11. package/dist/{chunk-57CVQCFX.js → chunk-A4O7GIWE.js} +74 -23
  12. package/dist/chunk-A4O7GIWE.js.map +1 -0
  13. package/dist/{chunk-5WFRKURX.js → chunk-DLXZMS5K.js} +4 -2
  14. package/dist/chunk-DLXZMS5K.js.map +1 -0
  15. package/dist/chunk-ET53IIEP.js +1 -0
  16. package/dist/chunk-ET53IIEP.js.map +1 -0
  17. package/dist/chunk-HWVFJAAH.js +362 -0
  18. package/dist/chunk-HWVFJAAH.js.map +1 -0
  19. package/dist/{chunk-2TMAWB4Y.js → chunk-IMSCRLLV.js} +1 -1
  20. package/dist/chunk-IMSCRLLV.js.map +1 -0
  21. package/dist/{chunk-B4EEON3N.js → chunk-KKNUBQ6Y.js} +536 -103
  22. package/dist/chunk-KKNUBQ6Y.js.map +1 -0
  23. package/dist/chunk-LDQ2HJIX.js +596 -0
  24. package/dist/chunk-LDQ2HJIX.js.map +1 -0
  25. package/dist/chunk-LH7I6SH7.js +7208 -0
  26. package/dist/chunk-LH7I6SH7.js.map +1 -0
  27. package/dist/chunk-PGGGKYWG.js +1 -0
  28. package/dist/chunk-PGGGKYWG.js.map +1 -0
  29. package/dist/{chunk-7UDSRZKG.js → chunk-Q3ROPT5H.js} +35 -14
  30. package/dist/chunk-Q3ROPT5H.js.map +1 -0
  31. package/dist/chunk-QE7TWGVT.js +178 -0
  32. package/dist/chunk-QE7TWGVT.js.map +1 -0
  33. package/dist/chunk-RUDYOTA6.js +345 -0
  34. package/dist/chunk-RUDYOTA6.js.map +1 -0
  35. package/dist/{chunk-K32VJONL.js → chunk-T5UK6YOB.js} +41 -2
  36. package/dist/chunk-T5UK6YOB.js.map +1 -0
  37. package/dist/{chunk-3K5OG6KI.js → chunk-TEEEILMP.js} +412 -186
  38. package/dist/chunk-TEEEILMP.js.map +1 -0
  39. package/dist/{chunk-FR2RBTKO.js → chunk-TQWLI6S2.js} +93 -27
  40. package/dist/chunk-TQWLI6S2.js.map +1 -0
  41. package/dist/{chunk-WMOI7FN3.js → chunk-UY7KGQ5R.js} +12 -2
  42. package/dist/{chunk-WMOI7FN3.js.map → chunk-UY7KGQ5R.js.map} +1 -1
  43. package/dist/{chunk-3E5F2XMR.js → chunk-VWUFZ6ZG.js} +29 -2
  44. package/dist/chunk-VWUFZ6ZG.js.map +1 -0
  45. package/dist/chunk-ZLQKNOZQ.js +1 -0
  46. package/dist/chunk-ZLQKNOZQ.js.map +1 -0
  47. package/dist/doc/index.d.ts +5 -5
  48. package/dist/doc/index.js +135 -8
  49. package/dist/generate/index.d.ts +3 -74
  50. package/dist/generate/index.js +2 -7
  51. package/dist/icons/index.d.ts +3 -26
  52. package/dist/icons/index.js +13 -0
  53. package/dist/icons/inlineIconMarker.d.ts +1 -0
  54. package/dist/icons/inlineIconMarker.js +15 -0
  55. package/dist/icons/inlineIconMarker.js.map +1 -0
  56. package/dist/imageEdit/index.d.ts +12 -6
  57. package/dist/imageEdit/index.js +4 -1
  58. package/dist/index.d.ts +14 -11
  59. package/dist/index.js +265 -37
  60. package/dist/inlineIconMarker-CiE8Hlvz.d.ts +59 -0
  61. package/dist/jsonForm/index.d.ts +42 -1
  62. package/dist/jsonForm/index.js +9 -1
  63. package/dist/markdown/index.d.ts +228 -3
  64. package/dist/markdown/index.js +42 -7
  65. package/dist/recommend/index.d.ts +1 -1
  66. package/dist/recommend/index.js +3 -2
  67. package/dist/schemas/index.d.ts +28 -8
  68. package/dist/schemas/index.js +36 -8
  69. package/dist/spatial/index.d.ts +9 -4
  70. package/dist/spatial/index.js +1 -1
  71. package/dist/storage/index.d.ts +2 -2
  72. package/dist/storage/index.js +1 -1
  73. package/dist/story/index.d.ts +1021 -41
  74. package/dist/story/index.js +135 -8
  75. package/dist/{themeLibrary-BHjJ0Qx6.d.ts → themeLibrary-CehcJhzz.d.ts} +1 -1
  76. package/dist/timing/index.js +3 -5
  77. package/dist/transform/index.d.ts +48 -10
  78. package/dist/transform/index.js +17 -481
  79. package/dist/transform/index.js.map +1 -1
  80. package/dist/{types-BdZkdJ3z.d.ts → types-DlAZ7MW4.d.ts} +115 -1
  81. package/dist/versions/index.d.ts +1 -1
  82. package/package.json +6 -1
  83. package/src/__tests__/animationUtils.test.ts +40 -0
  84. package/src/__tests__/annotationCoercion.test.ts +243 -0
  85. package/src/__tests__/applyRenderStyle.test.ts +146 -0
  86. package/src/__tests__/atmosphereLayers.test.ts +108 -0
  87. package/src/__tests__/attrTokens.test.ts +162 -0
  88. package/src/__tests__/contentExtractor.test.ts +60 -0
  89. package/src/__tests__/coverBlock.test.ts +46 -1
  90. package/src/__tests__/customTemplateSample.test.ts +144 -0
  91. package/src/__tests__/customTemplatesFrontmatter.test.ts +236 -0
  92. package/src/__tests__/customThemesFrontmatter.test.ts +145 -0
  93. package/src/__tests__/diagramBlock.test.ts +122 -0
  94. package/src/__tests__/diagramLayout.test.ts +134 -0
  95. package/src/__tests__/drawing.test.ts +109 -0
  96. package/src/__tests__/drawingBlock.test.ts +124 -0
  97. package/src/__tests__/drawingLayout.test.ts +171 -0
  98. package/src/__tests__/geohash.test.ts +6 -5
  99. package/src/__tests__/getLayers.test.ts +6 -2
  100. package/src/__tests__/getLayersFallback.test.ts +103 -0
  101. package/src/__tests__/imageEditExportSvg.test.ts +70 -0
  102. package/src/__tests__/imageEditVersions.test.ts +72 -0
  103. package/src/__tests__/inlineIconMarker.test.ts +36 -0
  104. package/src/__tests__/inputDescriptors.test.ts +162 -0
  105. package/src/__tests__/jsonForm.tokens.test.ts +84 -0
  106. package/src/__tests__/layoutBlock.test.ts +83 -0
  107. package/src/__tests__/layoutLayout.test.ts +169 -0
  108. package/src/__tests__/markdown.test.ts +87 -0
  109. package/src/__tests__/markdownSanitize.test.ts +79 -0
  110. package/src/__tests__/markdownToDoc.test.ts +391 -2
  111. package/src/__tests__/mediaAnnotations.test.ts +91 -0
  112. package/src/__tests__/mediaSchedule.test.ts +118 -0
  113. package/src/__tests__/pandocAttrParse.test.ts +187 -0
  114. package/src/__tests__/resolveDocTheme.test.ts +55 -0
  115. package/src/__tests__/rootExports.test.ts +43 -0
  116. package/src/__tests__/shapeGeometry.test.ts +89 -0
  117. package/src/__tests__/shapeVocabulary.test.ts +133 -0
  118. package/src/__tests__/structuredData.test.ts +249 -0
  119. package/src/__tests__/templateAnnotationParse.test.ts +108 -0
  120. package/src/__tests__/templateMetadata.test.ts +31 -0
  121. package/src/__tests__/templates.test.ts +257 -4
  122. package/src/__tests__/themeCompile.test.ts +42 -0
  123. package/src/__tests__/themeValidator.test.ts +6 -0
  124. package/src/__tests__/transformV2.test.ts +137 -0
  125. package/src/__tests__/transitionNormalize.test.ts +61 -0
  126. package/src/__tests__/validateDoc.test.ts +260 -0
  127. package/src/doc/annotationBlocks.ts +117 -0
  128. package/src/doc/customTemplatesFrontmatter.ts +314 -0
  129. package/src/doc/customThemesFrontmatter.ts +95 -0
  130. package/src/doc/docToMarkdown.ts +104 -5
  131. package/src/doc/getLayers.ts +49 -17
  132. package/src/doc/index.ts +47 -1
  133. package/src/doc/markdownToDoc.ts +411 -13
  134. package/src/doc/mediaAnnotations.ts +100 -0
  135. package/src/doc/resolveDocTheme.ts +55 -0
  136. package/src/doc/standaloneAnnotation.ts +58 -0
  137. package/src/doc/structuredData.ts +301 -0
  138. package/src/doc/templateInputs.ts +240 -0
  139. package/src/doc/templates/__tests__/customTemplate.test.ts +170 -0
  140. package/src/doc/templates/__tests__/customTemplateRegistry.test.ts +110 -0
  141. package/src/doc/templates/accentImage.ts +30 -6
  142. package/src/doc/templates/captionUtils.ts +23 -0
  143. package/src/doc/templates/comparisonBar.ts +33 -18
  144. package/src/doc/templates/coverBlock.ts +35 -5
  145. package/src/doc/templates/customTemplate.ts +51 -0
  146. package/src/doc/templates/dataTable.ts +37 -19
  147. package/src/doc/templates/dateEvent.ts +41 -20
  148. package/src/doc/templates/definitionCard.ts +26 -18
  149. package/src/doc/templates/diagramBlock.ts +204 -0
  150. package/src/doc/templates/diagramLayout.ts +159 -0
  151. package/src/doc/templates/drawingBlock.ts +325 -0
  152. package/src/doc/templates/drawingLayout.ts +539 -0
  153. package/src/doc/templates/factCard.ts +54 -26
  154. package/src/doc/templates/fallbackBlock.ts +123 -0
  155. package/src/doc/templates/featureBlock.ts +20 -13
  156. package/src/doc/templates/fullBleedQuote.ts +18 -8
  157. package/src/doc/templates/imageWithCaption.ts +30 -15
  158. package/src/doc/templates/index.ts +225 -46
  159. package/src/doc/templates/inputDescriptors.ts +432 -0
  160. package/src/doc/templates/layoutBlock.ts +43 -0
  161. package/src/doc/templates/layoutLayout.ts +243 -0
  162. package/src/doc/templates/listBlock.ts +56 -30
  163. package/src/doc/templates/mapBlock.ts +15 -12
  164. package/src/doc/templates/metadata.ts +120 -0
  165. package/src/doc/templates/persistentLayers.ts +129 -11
  166. package/src/doc/templates/photoGrid.ts +22 -34
  167. package/src/doc/templates/pullQuote.ts +30 -12
  168. package/src/doc/templates/quoteBlock.ts +54 -29
  169. package/src/doc/templates/sectionHeader.ts +25 -11
  170. package/src/doc/templates/statHighlight.ts +39 -22
  171. package/src/doc/templates/titleBlock.ts +59 -21
  172. package/src/doc/templates/tokens/__tests__/resolveTokens.test.ts +487 -0
  173. package/src/doc/templates/tokens/resolveTokens.ts +453 -0
  174. package/src/doc/templates/twoColumn.ts +48 -30
  175. package/src/doc/templates/videoPullQuote.ts +22 -12
  176. package/src/doc/templates/videoWithCaption.ts +21 -15
  177. package/src/doc/utils/animationUtils.ts +267 -3
  178. package/src/doc/utils/applyRenderStyle.ts +113 -0
  179. package/src/doc/utils/imageTreatment.ts +70 -0
  180. package/src/doc/utils/nearest.ts +59 -0
  181. package/src/doc/utils/shapeGeometry.ts +464 -0
  182. package/src/doc/utils/themeUtils.ts +75 -1
  183. package/src/doc/validate.ts +575 -0
  184. package/src/generate/contentExtractor.ts +148 -35
  185. package/src/generate/index.ts +0 -3
  186. package/src/icons/index.ts +8 -0
  187. package/src/icons/inlineIconMarker.ts +73 -0
  188. package/src/imageEdit/export.ts +85 -2
  189. package/src/imageEdit/index.ts +1 -0
  190. package/src/imageEdit/persistence.ts +51 -0
  191. package/src/index.ts +2 -0
  192. package/src/jsonForm/index.ts +2 -0
  193. package/src/jsonForm/tokens.ts +78 -0
  194. package/src/markdown/annotationCoercion.ts +295 -0
  195. package/src/markdown/attrTokens.ts +241 -0
  196. package/src/markdown/convert.ts +229 -26
  197. package/src/markdown/index.ts +37 -0
  198. package/src/markdown/sanitize.ts +318 -0
  199. package/src/markdown/stringify.ts +96 -6
  200. package/src/markdown/types.ts +66 -0
  201. package/src/markdown/utils.ts +111 -2
  202. package/src/recommend/templates.ts +24 -0
  203. package/src/schemas/BlockTemplates.ts +133 -4
  204. package/src/schemas/CustomTemplates.ts +126 -0
  205. package/src/schemas/Doc.ts +387 -65
  206. package/src/schemas/ImageEditDoc.ts +11 -4
  207. package/src/schemas/Media.ts +169 -0
  208. package/src/schemas/Theme.ts +23 -1
  209. package/src/schemas/Transitions.ts +224 -0
  210. package/src/schemas/colorUtils.ts +33 -0
  211. package/src/schemas/fontStacks.ts +5 -2
  212. package/src/schemas/index.ts +3 -0
  213. package/src/schemas/themeCompile.ts +24 -5
  214. package/src/schemas/themeValidator.ts +49 -11
  215. package/src/schemas/themes/cinematic.json +69 -12
  216. package/src/schemas/themes/documentary.json +58 -11
  217. package/src/schemas/themes/gezellig.json +60 -11
  218. package/src/schemas/themes/magazine.json +54 -9
  219. package/src/schemas/themes/morning-light.json +52 -9
  220. package/src/schemas/themes/standard-dark.json +48 -13
  221. package/src/schemas/themes/standard.json +44 -12
  222. package/src/schemas/themes/tech-dark.json +65 -12
  223. package/src/spatial/Geohash.ts +52 -21
  224. package/src/storage/ContentContainer.ts +6 -2
  225. package/src/transform/applyTransform.ts +43 -4
  226. package/src/transform/index.ts +1 -0
  227. package/src/transform/registry.ts +44 -13
  228. package/src/transform/styles/dataDriven.ts +2 -1
  229. package/src/transform/styles/documentary.ts +2 -1
  230. package/src/transform/styles/magazine.ts +3 -1
  231. package/src/transform/styles/minimal.ts +2 -1
  232. package/src/transform/styles/narrative.ts +3 -1
  233. package/src/transform/templateSelector.ts +87 -11
  234. package/src/transform/types.ts +35 -2
  235. package/dist/chunk-2TMAWB4Y.js.map +0 -1
  236. package/dist/chunk-3E5F2XMR.js.map +0 -1
  237. package/dist/chunk-3K5OG6KI.js.map +0 -1
  238. package/dist/chunk-4X3JQXNM.js +0 -35
  239. package/dist/chunk-4X3JQXNM.js.map +0 -1
  240. package/dist/chunk-57CVQCFX.js.map +0 -1
  241. package/dist/chunk-5WFRKURX.js.map +0 -1
  242. package/dist/chunk-7UDSRZKG.js.map +0 -1
  243. package/dist/chunk-B4EEON3N.js.map +0 -1
  244. package/dist/chunk-FR2RBTKO.js.map +0 -1
  245. package/dist/chunk-H3AXU4MJ.js +0 -179
  246. package/dist/chunk-H3AXU4MJ.js.map +0 -1
  247. package/dist/chunk-K32VJONL.js.map +0 -1
  248. package/dist/chunk-KSWJR66U.js +0 -3706
  249. package/dist/chunk-KSWJR66U.js.map +0 -1
  250. package/dist/chunk-MYIH7FWD.js +0 -145
  251. package/dist/chunk-MYIH7FWD.js.map +0 -1
  252. package/dist/chunk-ZTX4PFFT.js.map +0 -1
  253. package/src/__tests__/slideshowGenerator.test.ts +0 -128
  254. package/src/generate/slideshowGenerator.ts +0 -273
@@ -19,11 +19,20 @@ import type {
19
19
  MarkdownTableCell,
20
20
  MarkdownSourcePosition,
21
21
  HeadingTemplateAnnotation,
22
+ HeadingAttributes,
22
23
  MarkdownHeading,
23
24
  MarkdownInlineIcon,
24
25
  } from './types.js';
25
26
  import { parseHtmlToNodes } from './htmlParse.js';
26
27
  import { resolveIcon } from '../icons/resolve.js';
28
+ import { coerceAnnotationValues } from './annotationCoercion.js';
29
+ import {
30
+ matchTrailingTemplateAnnotation,
31
+ matchTrailingPandocAttr,
32
+ tokenizeAttrTokens,
33
+ splitKeyValueToken,
34
+ quoteAttrValue,
35
+ } from './attrTokens.js';
27
36
 
28
37
  // ============================================
29
38
  // Generic mdast node shape
@@ -131,22 +140,16 @@ function extractText(node: MdastNode): string {
131
140
  // Template annotation helpers
132
141
  // ============================================
133
142
 
134
- /**
135
- * Regex matching a trailing `{[templateName key=value …]}` annotation.
136
- * Captures the content between `{[` and `]}`.
137
- *
138
- * The trailing `[\s\]\}]*` tolerates accidental doubled `]}` that users
139
- * sometimes type when learning the syntax (e.g. `{[foo]}]}`). It does
140
- * not allow trailing word characters, so a non-trailing annotation like
141
- * `## The {[chart]} section` still doesn't match.
142
- */
143
- const TEMPLATE_ANNOTATION_RE = /\s*\{\[([^\]]+)\]\}[\s\]}]*$/;
144
-
145
143
  /**
146
144
  * Extract a `{[templateName key=value …]}` annotation from a heading's
147
145
  * inline children. Mutates the children array in-place: strips the
148
146
  * annotation text from the last text node (or removes the node entirely).
149
147
  *
148
+ * Matching is delegated to {@link matchTrailingTemplateAnnotation}: a
149
+ * quote-aware grammar (values may contain `]` when quoted) with a legacy
150
+ * fallback, plus tolerance for an accidental doubled trailing `]}`. A
151
+ * non-trailing annotation like `## The {[chart]} section` doesn't match.
152
+ *
150
153
  * @returns The parsed annotation, or null if none found.
151
154
  */
152
155
  function extractTemplateAnnotation(
@@ -156,13 +159,13 @@ function extractTemplateAnnotation(
156
159
  for (let i = children.length - 1; i >= 0; i--) {
157
160
  const child = children[i];
158
161
  if (child.type === 'text') {
159
- const match = child.value.match(TEMPLATE_ANNOTATION_RE);
162
+ const match = matchTrailingTemplateAnnotation(child.value);
160
163
  if (match) {
161
- const inner = match[1].trim();
164
+ const inner = match.inner.trim();
162
165
  const annotation = parseAnnotationTokens(inner);
163
166
 
164
167
  // Strip the matched portion from the text
165
- const stripped = child.value.slice(0, match.index!).replace(/\s+$/, '');
168
+ const stripped = child.value.slice(0, match.index).replace(/\s+$/, '');
166
169
  if (stripped) {
167
170
  (child as { value: string }).value = stripped;
168
171
  } else {
@@ -183,23 +186,27 @@ function extractTemplateAnnotation(
183
186
  /**
184
187
  * Parse the inner content of a `{[…]}` annotation into template + params.
185
188
  *
186
- * Input: `"chart colorScheme=blue size=large"`
187
- * Output: `{ template: 'chart', params: { colorScheme: 'blue', size: 'large' } }`
189
+ * Input: `"chart colorScheme=blue caption=\"Sales by region\""`
190
+ * Output: `{ template: 'chart', params: { colorScheme: 'blue', caption: 'Sales by region' } }`
191
+ *
192
+ * Tokenization is shared with the Pandoc attribute parser
193
+ * ({@link tokenizeAttrTokens}), so quoting and escaping behave
194
+ * identically in both annotation forms.
188
195
  */
189
196
  function parseAnnotationTokens(inner: string): HeadingTemplateAnnotation {
190
- const tokens = inner.split(/\s+/);
197
+ const tokens = tokenizeAttrTokens(inner);
191
198
  const params: Record<string, string> = {};
192
199
 
193
200
  // If the first token contains '=', there's no template name —
194
201
  // the annotation is purely key-value (e.g., `{[audio=intro.mp3]}`).
195
- const firstIsParam = tokens[0].indexOf('=') > 0;
196
- const template = firstIsParam ? undefined : tokens[0];
202
+ const firstIsParam = tokens.length > 0 && tokens[0].indexOf('=') > 0;
203
+ const template = firstIsParam || tokens.length === 0 ? undefined : tokens[0];
197
204
  const startIdx = firstIsParam ? 0 : 1;
198
205
 
199
206
  for (let i = startIdx; i < tokens.length; i++) {
200
- const eqIdx = tokens[i].indexOf('=');
201
- if (eqIdx > 0) {
202
- params[tokens[i].slice(0, eqIdx)] = tokens[i].slice(eqIdx + 1);
207
+ const kv = splitKeyValueToken(tokens[i]);
208
+ if (kv) {
209
+ params[kv.key] = kv.value;
203
210
  }
204
211
  }
205
212
 
@@ -213,8 +220,166 @@ function parseAnnotationTokens(inner: string): HeadingTemplateAnnotation {
213
220
  return result;
214
221
  }
215
222
 
223
+ // ============================================
224
+ // Pandoc-style heading attribute helpers
225
+ // ============================================
226
+
227
+ /**
228
+ * Extract a trailing Pandoc-style `{…}` attribute block from a heading's
229
+ * inline children. Matching is delegated to {@link matchTrailingPandocAttr}
230
+ * (quote-aware — values may contain `}` when quoted — with a legacy
231
+ * fallback; a `(?!\[)` lookahead keeps it from colliding with the
232
+ * squisq-native `{[…]}` template annotation).
233
+ *
234
+ * Unlike `extractTemplateAnnotation()`, this walks backward through both
235
+ * text nodes AND bare `textDirective` nodes. remark-directive may have
236
+ * fragmented a `{key=val:type,…}` attribute block at colon-prefixed
237
+ * segments like `:type` that look like text directives but are really
238
+ * attribute-value fragments. We virtually re-stitch the trailing run as a
239
+ * single string, match against it, and only physically modify the children
240
+ * array if a match is found — so a heading that legitimately contains a
241
+ * non-bare `:directive` is left alone.
242
+ *
243
+ * Returns the parsed `HeadingAttributes`, or null if no match.
244
+ */
245
+ function extractPandocAttributes(children: MarkdownInlineNode[]): HeadingAttributes | null {
246
+ // Collect the trailing run of [text | bare textDirective] children.
247
+ const trail: { val: string; idx: number; len: number }[] = [];
248
+ for (let i = children.length - 1; i >= 0; i--) {
249
+ const c = children[i];
250
+ if (c.type === 'text') {
251
+ trail.unshift({ val: c.value, idx: i, len: c.value.length });
252
+ } else if (c.type === 'textDirective') {
253
+ const td = c as {
254
+ name?: string;
255
+ children?: unknown[];
256
+ attributes?: Record<string, string>;
257
+ };
258
+ const isBare =
259
+ (!td.children || td.children.length === 0) &&
260
+ (!td.attributes || Object.keys(td.attributes).length === 0);
261
+ if (!isBare) break;
262
+ const synthesized = ':' + (td.name ?? '');
263
+ trail.unshift({ val: synthesized, idx: i, len: synthesized.length });
264
+ } else {
265
+ break;
266
+ }
267
+ }
268
+ if (trail.length === 0) return null;
269
+
270
+ const joined = trail.map((t) => t.val).join('');
271
+ const match = matchTrailingPandocAttr(joined);
272
+ if (!match) return null;
273
+
274
+ const inner = match.inner.trim();
275
+ const attrs = parsePandocAttrTokens(inner);
276
+
277
+ // Strip the matched portion. `match.index` is an offset into `joined`.
278
+ // Walk forward through `trail` until we find the child that straddles
279
+ // the match boundary; trim that child (if text) or drop it (if directive),
280
+ // and remove everything after it from `children`.
281
+ const matchStart = match.index;
282
+ let scannedLen = 0;
283
+ for (let ti = 0; ti < trail.length; ti++) {
284
+ const t = trail[ti];
285
+ if (scannedLen + t.len <= matchStart) {
286
+ scannedLen += t.len;
287
+ continue;
288
+ }
289
+ const firstNode = children[t.idx];
290
+ const keepLen = matchStart - scannedLen;
291
+ if (firstNode.type === 'text') {
292
+ const trimmed = firstNode.value.slice(0, keepLen).replace(/\s+$/, '');
293
+ if (trimmed) {
294
+ (firstNode as { value: string }).value = trimmed;
295
+ children.splice(t.idx + 1);
296
+ } else {
297
+ children.splice(t.idx);
298
+ }
299
+ } else {
300
+ // Match boundary fell inside a textDirective's synthesized ":name"
301
+ // string — drop the directive and everything after.
302
+ children.splice(t.idx);
303
+ }
304
+ return attrs;
305
+ }
306
+ // Match consumes the entire trail.
307
+ children.splice(trail[0].idx);
308
+ return attrs;
309
+ }
310
+
311
+ /**
312
+ * Parse the inner content of a Pandoc-style `{…}` block. Tokens are
313
+ * whitespace-separated, with quoted runs (single or double) treated as
314
+ * single tokens — tokenization is shared with the `{[…]}` template
315
+ * annotation parser via {@link tokenizeAttrTokens}.
316
+ *
317
+ * - `#id` → `attributes.id`
318
+ * - `.class` → push onto `attributes.classes`
319
+ * - `key=value` or `key="quoted value"` → `attributes.params[key] = value`
320
+ *
321
+ * Empty `{}` yields `{}` (preserved as an annotation marker for round-tripping).
322
+ * Duplicate ids and keys: last wins, silent.
323
+ */
324
+ export function parsePandocAttrTokens(inner: string): HeadingAttributes {
325
+ const tokens = tokenizeAttrTokens(inner);
326
+ const attrs: HeadingAttributes = {};
327
+ const params: Record<string, string> = {};
328
+ const classes: string[] = [];
329
+
330
+ for (const token of tokens) {
331
+ if (token.startsWith('#') && !token.includes('=')) {
332
+ attrs.id = token.slice(1);
333
+ } else if (token.startsWith('.') && !token.includes('=')) {
334
+ const cls = token.slice(1);
335
+ if (cls) classes.push(cls);
336
+ } else {
337
+ const kv = splitKeyValueToken(token);
338
+ if (kv) {
339
+ params[kv.key] = kv.value;
340
+ }
341
+ }
342
+ }
343
+
344
+ if (classes.length > 0) attrs.classes = classes;
345
+ if (Object.keys(params).length > 0) {
346
+ attrs.params = params;
347
+ const { blockMeta, metadata } = coerceAnnotationValues(params);
348
+ if (Object.keys(blockMeta).length > 0) attrs.blockMeta = blockMeta;
349
+ if (Object.keys(metadata).length > 0) attrs.metadata = metadata;
350
+ }
351
+ return attrs;
352
+ }
353
+
354
+ /**
355
+ * Serialize a HeadingAttributes object back to a Pandoc `{#id .class key=value}` string.
356
+ * Returns null when the attributes object is entirely empty (nothing to emit).
357
+ *
358
+ * Canonical key order: `#id`, then `.classes` (in original order), then
359
+ * params in their original insertion order. Values are quoted via the
360
+ * shared {@link quoteAttrValue} rule (same as `{[…]}` serialization).
361
+ */
362
+ export function serializePandocAttributes(attrs: HeadingAttributes): string | null {
363
+ const parts: string[] = [];
364
+ if (attrs.id) parts.push(`#${attrs.id}`);
365
+ if (attrs.classes) {
366
+ for (const cls of attrs.classes) parts.push(`.${cls}`);
367
+ }
368
+ if (attrs.params) {
369
+ for (const [key, value] of Object.entries(attrs.params)) {
370
+ parts.push(`${key}=${quoteAttrValue(value)}`);
371
+ }
372
+ }
373
+ // Preserve an empty `{}` annotation marker if it was authored deliberately
374
+ // (e.g., reserved for downstream tooling). Caller decides whether to emit.
375
+ if (parts.length === 0) return '{}';
376
+ return `{${parts.join(' ')}}`;
377
+ }
378
+
216
379
  /**
217
380
  * Serialize a HeadingTemplateAnnotation back to `{[templateName key=value …]}` text.
381
+ * Values are quoted via the shared {@link quoteAttrValue} rule (same as
382
+ * Pandoc attribute serialization).
218
383
  */
219
384
  function serializeTemplateAnnotation(annotation: HeadingTemplateAnnotation): string {
220
385
  const parts: string[] = [];
@@ -223,7 +388,7 @@ function serializeTemplateAnnotation(annotation: HeadingTemplateAnnotation): str
223
388
  }
224
389
  if (annotation.params) {
225
390
  for (const [key, value] of Object.entries(annotation.params)) {
226
- parts.push(`${key}=${value}`);
391
+ parts.push(`${key}=${quoteAttrValue(value)}`);
227
392
  }
228
393
  }
229
394
  return `{[${parts.join(' ')}]}`;
@@ -447,8 +612,33 @@ function convertBlockNode(node: MdastNode, parseHtml: boolean): MarkdownBlockNod
447
612
  // a template annotation. Anything that's NOT the template still
448
613
  // needs to become an icon node, so we run `splitInlineIcons` on
449
614
  // what's left after the template extraction.
615
+ //
616
+ // Both annotation forms ({[…]} and Pandoc {#…}) may appear at the
617
+ // end of the heading in either order. Extract them in a loop so
618
+ // we catch the second-from-last one too. A small bounded retry
619
+ // (max 4) is plenty — authors don't stack more than two trailing
620
+ // brace-blocks in practice.
450
621
  const headingChildren = convertInlineChildren(node.children ?? [], parseHtml, false);
451
- const annotation = extractTemplateAnnotation(headingChildren);
622
+ let annotation: HeadingTemplateAnnotation | null = null;
623
+ let attrs: HeadingAttributes | null = null;
624
+ for (let pass = 0; pass < 4; pass++) {
625
+ let matched = false;
626
+ if (!annotation) {
627
+ const t = extractTemplateAnnotation(headingChildren);
628
+ if (t) {
629
+ annotation = t;
630
+ matched = true;
631
+ }
632
+ }
633
+ if (!attrs) {
634
+ const a = extractPandocAttributes(headingChildren);
635
+ if (a) {
636
+ attrs = a;
637
+ matched = true;
638
+ }
639
+ }
640
+ if (!matched) break;
641
+ }
452
642
  const finalChildren = splitInlineIcons(headingChildren);
453
643
  const result: MarkdownHeading = {
454
644
  type: 'heading',
@@ -459,6 +649,9 @@ function convertBlockNode(node: MdastNode, parseHtml: boolean): MarkdownBlockNod
459
649
  if (annotation) {
460
650
  result.templateAnnotation = annotation;
461
651
  }
652
+ if (attrs) {
653
+ result.attributes = attrs;
654
+ }
462
655
  return result;
463
656
  }
464
657
 
@@ -778,9 +971,19 @@ function blockToMdast(node: MarkdownBlockNode): MdastNode {
778
971
  switch (node.type) {
779
972
  case 'heading': {
780
973
  const mdastChildren = inlineChildrenToMdast(node.children);
974
+ // Canonical trailing order: Pandoc attrs first, then template annotation.
975
+ // Author-supplied order is not preserved across round-trips by design —
976
+ // canonicalization gives stable diffs.
977
+ const suffixes: string[] = [];
978
+ if (node.attributes) {
979
+ const pandoc = serializePandocAttributes(node.attributes);
980
+ if (pandoc != null) suffixes.push(pandoc);
981
+ }
781
982
  if (node.templateAnnotation) {
782
- const suffix = serializeTemplateAnnotation(node.templateAnnotation);
783
- // Append to last text node, or create a new one
983
+ suffixes.push(serializeTemplateAnnotation(node.templateAnnotation));
984
+ }
985
+ if (suffixes.length > 0) {
986
+ const suffix = suffixes.join(' ');
784
987
  const lastChild = mdastChildren[mdastChildren.length - 1];
785
988
  if (lastChild && lastChild.type === 'text') {
786
989
  lastChild.value = (lastChild.value ?? '') + ' ' + suffix;
@@ -32,6 +32,9 @@ export type {
32
32
  // Heading template annotation
33
33
  HeadingTemplateAnnotation,
34
34
 
35
+ // Pandoc-style heading attributes
36
+ HeadingAttributes,
37
+
35
38
  // HTML sub-DOM
36
39
  HtmlElement,
37
40
  HtmlText,
@@ -96,8 +99,40 @@ export { stringifyMarkdown } from './stringify.js';
96
99
  // Conversion layer (for advanced use: working with remark plugins directly)
97
100
  export { fromMdast, toMdast } from './convert.js';
98
101
 
102
+ // Pandoc-style heading attribute helpers — exposed so editor-react can
103
+ // mutate `data-block-attrs` strings without reimplementing the tokenizer.
104
+ export { parsePandocAttrTokens, serializePandocAttributes } from './convert.js';
105
+
106
+ // Shared key=value attribute machinery for both annotation forms
107
+ // (`{[templateName key=value]}` and Pandoc `{#id .class key=value}`) —
108
+ // exposed so editor-react's tiptap bridge tokenizes and matches headings
109
+ // with the exact same grammar as the parser, by import rather than by copy.
110
+ export {
111
+ tokenizeAttrTokens,
112
+ splitKeyValueToken,
113
+ unquoteAttrValue,
114
+ needsQuoting,
115
+ quoteAttrValue,
116
+ matchTrailingTemplateAnnotation,
117
+ matchTrailingPandocAttr,
118
+ } from './attrTokens.js';
119
+ export type { TrailingAnnotationMatch } from './attrTokens.js';
120
+
121
+ // Known `{[name key=value]}` block-meta keys and their editor-facing
122
+ // descriptors — exposed so the markdown editor's attribute autocomplete
123
+ // suggests keys and (where closed) values from the same registry the parser
124
+ // coerces against, rather than a hand-maintained copy.
125
+ export {
126
+ KNOWN_BLOCK_META_KEYS,
127
+ BLOCK_META_KEY_DESCRIPTORS,
128
+ parseTimeSeconds,
129
+ } from './annotationCoercion.js';
130
+ export type { KnownBlockMetaKey, BlockMetaKeyDescriptor } from './annotationCoercion.js';
131
+
99
132
  // HTML sub-DOM utilities
100
133
  export { parseHtmlToNodes, stringifyHtmlNodes } from './htmlParse.js';
134
+ export { sanitizeHtmlNodes, sanitizeUrl } from './sanitize.js';
135
+ export type { HtmlPolicy, SanitizeUrlOptions, UrlKind } from './sanitize.js';
101
136
 
102
137
  // Tree utilities
103
138
  export {
@@ -108,7 +143,9 @@ export {
108
143
  countNodes,
109
144
  createDocument,
110
145
  parseFrontmatter,
146
+ formatBlockScalar,
111
147
  setFrontmatterValues,
112
148
  inferDocumentTitle,
113
149
  readFrontmatterThemeId,
150
+ plainTextFromInlineHtml,
114
151
  } from './utils.js';