@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
@@ -0,0 +1,318 @@
1
+ import type { HtmlElement, HtmlNode } from './types.js';
2
+
3
+ export type HtmlPolicy = 'strip' | 'sanitize' | 'trusted';
4
+
5
+ export type UrlKind = 'link' | 'media';
6
+
7
+ const SAFE_LINK_SCHEMES = new Set(['http', 'https', 'mailto', 'tel']);
8
+ const SAFE_MEDIA_SCHEMES = new Set(['http', 'https', 'blob']);
9
+
10
+ /** Schemes that can execute script — never allowed, even via extraLinkSchemes. */
11
+ const NEVER_ALLOWED_SCHEMES = new Set(['javascript', 'vbscript', 'data']);
12
+
13
+ export interface SanitizeUrlOptions {
14
+ /**
15
+ * Extra lowercase schemes allowed for `kind: 'link'` — the host-app escape
16
+ * hatch for custom link protocols it intercepts itself (e.g. an app-internal
17
+ * navigation scheme). Executable schemes are refused regardless.
18
+ */
19
+ extraLinkSchemes?: readonly string[];
20
+ }
21
+
22
+ const SAFE_DATA_MEDIA_RE =
23
+ /^data:(?:image\/(?!svg\+xml)[a-z0-9.+-]+|audio\/[a-z0-9.+-]+|video\/[a-z0-9.+-]+);/i;
24
+
25
+ const SAFE_TAGS = new Set([
26
+ 'a',
27
+ 'abbr',
28
+ 'b',
29
+ 'blockquote',
30
+ 'br',
31
+ 'caption',
32
+ 'cite',
33
+ 'code',
34
+ 'col',
35
+ 'colgroup',
36
+ 'data',
37
+ 'dd',
38
+ 'del',
39
+ 'details',
40
+ 'dfn',
41
+ 'div',
42
+ 'dl',
43
+ 'dt',
44
+ 'em',
45
+ 'figcaption',
46
+ 'figure',
47
+ 'h1',
48
+ 'h2',
49
+ 'h3',
50
+ 'h4',
51
+ 'h5',
52
+ 'h6',
53
+ 'hr',
54
+ 'i',
55
+ 'img',
56
+ 'kbd',
57
+ 'li',
58
+ 'mark',
59
+ 'ol',
60
+ 'p',
61
+ 'pre',
62
+ 'q',
63
+ 's',
64
+ 'samp',
65
+ 'small',
66
+ 'source',
67
+ 'span',
68
+ 'strong',
69
+ 'sub',
70
+ 'summary',
71
+ 'sup',
72
+ 'table',
73
+ 'tbody',
74
+ 'td',
75
+ 'tfoot',
76
+ 'th',
77
+ 'thead',
78
+ 'time',
79
+ 'tr',
80
+ 'track',
81
+ 'u',
82
+ 'ul',
83
+ 'var',
84
+ 'video',
85
+ 'audio',
86
+ ]);
87
+
88
+ const DROP_WITH_CONTENT_TAGS = new Set([
89
+ 'base',
90
+ 'button',
91
+ 'embed',
92
+ 'form',
93
+ 'iframe',
94
+ 'input',
95
+ 'link',
96
+ 'meta',
97
+ 'object',
98
+ 'option',
99
+ 'script',
100
+ 'select',
101
+ 'style',
102
+ 'svg',
103
+ 'template',
104
+ 'textarea',
105
+ ]);
106
+
107
+ const GLOBAL_ATTRS = new Set(['class', 'id', 'title', 'role']);
108
+
109
+ const TAG_ATTRS: Record<string, Set<string>> = {
110
+ a: new Set(['href', 'target', 'rel']),
111
+ audio: new Set(['src', 'controls', 'preload', 'muted', 'loop']),
112
+ col: new Set(['span', 'width']),
113
+ colgroup: new Set(['span']),
114
+ data: new Set(['value']),
115
+ img: new Set(['src', 'alt', 'width', 'height', 'loading', 'decoding']),
116
+ li: new Set(['value']),
117
+ ol: new Set(['start', 'type']),
118
+ q: new Set(['cite']),
119
+ source: new Set(['src', 'type', 'media']),
120
+ td: new Set(['colspan', 'rowspan', 'align']),
121
+ th: new Set(['colspan', 'rowspan', 'align', 'scope']),
122
+ time: new Set(['datetime']),
123
+ track: new Set(['src', 'kind', 'label', 'srclang', 'default']),
124
+ video: new Set([
125
+ 'src',
126
+ 'poster',
127
+ 'width',
128
+ 'height',
129
+ 'controls',
130
+ 'preload',
131
+ 'muted',
132
+ 'loop',
133
+ 'playsinline',
134
+ ]),
135
+ };
136
+
137
+ const SAFE_ATTR_NAME_RE = /^[a-z][a-z0-9:_.-]*$/;
138
+ const SAFE_OL_TYPES = new Set(['1', 'a', 'A', 'i', 'I']);
139
+ const SAFE_PRELOAD_VALUES = new Set(['none', 'metadata', 'auto']);
140
+ const SAFE_TRACK_KINDS = new Set(['subtitles', 'captions', 'descriptions', 'chapters', 'metadata']);
141
+
142
+ /**
143
+ * Sanitize a URL for a rendered link or media attribute.
144
+ *
145
+ * Relative URLs are allowed. Absolute URLs are restricted to schemes that
146
+ * cannot execute script in a document context. Media URLs additionally allow
147
+ * `blob:` and image/audio/video `data:` URLs because Squisq uses both for
148
+ * browser-local media previews and self-contained exports.
149
+ */
150
+ export function sanitizeUrl(
151
+ url: string | null | undefined,
152
+ kind: UrlKind = 'link',
153
+ options?: SanitizeUrlOptions,
154
+ ): string | null {
155
+ if (typeof url !== 'string') return null;
156
+
157
+ const trimmed = url.trim();
158
+ if (!trimmed) return null;
159
+
160
+ const compact = stripUrlSchemeNoise(trimmed);
161
+ const colon = compact.indexOf(':');
162
+ const firstPathChar = firstIndexOfAny(compact, ['/', '?', '#']);
163
+ const hasScheme = colon >= 0 && (firstPathChar < 0 || colon < firstPathChar);
164
+
165
+ if (!hasScheme) return trimmed;
166
+
167
+ const scheme = compact.slice(0, colon).toLowerCase();
168
+ if (kind === 'media') {
169
+ if (scheme === 'data') return SAFE_DATA_MEDIA_RE.test(compact) ? trimmed : null;
170
+ return SAFE_MEDIA_SCHEMES.has(scheme) ? trimmed : null;
171
+ }
172
+
173
+ if (SAFE_LINK_SCHEMES.has(scheme)) return trimmed;
174
+ if (options?.extraLinkSchemes?.includes(scheme) && !NEVER_ALLOWED_SCHEMES.has(scheme)) {
175
+ return trimmed;
176
+ }
177
+ return null;
178
+ }
179
+
180
+ /**
181
+ * Return a sanitized copy of an HtmlNode tree. The original parsed tree is left
182
+ * untouched so markdown stringify can still preserve source fidelity.
183
+ */
184
+ export function sanitizeHtmlNodes(nodes: HtmlNode[]): HtmlNode[] {
185
+ const out: HtmlNode[] = [];
186
+ for (const node of nodes) {
187
+ out.push(...sanitizeHtmlNode(node));
188
+ }
189
+ return out;
190
+ }
191
+
192
+ function sanitizeHtmlNode(node: HtmlNode): HtmlNode[] {
193
+ switch (node.type) {
194
+ case 'htmlText':
195
+ return [node];
196
+ case 'htmlComment':
197
+ return [];
198
+ case 'htmlElement':
199
+ return sanitizeHtmlElement(node);
200
+ }
201
+ }
202
+
203
+ function sanitizeHtmlElement(node: HtmlElement): HtmlNode[] {
204
+ const tag = node.tagName.toLowerCase();
205
+
206
+ if (DROP_WITH_CONTENT_TAGS.has(tag)) return [];
207
+
208
+ const children = sanitizeHtmlNodes(node.children);
209
+ if (!SAFE_TAGS.has(tag)) return children;
210
+
211
+ return [
212
+ {
213
+ type: 'htmlElement',
214
+ tagName: tag,
215
+ attributes: sanitizeAttrs(tag, node.attributes),
216
+ children,
217
+ selfClosing: node.selfClosing,
218
+ },
219
+ ];
220
+ }
221
+
222
+ function sanitizeAttrs(tag: string, attrs: Record<string, string>): Record<string, string> {
223
+ const out: Record<string, string> = {};
224
+ for (const [rawName, rawValue] of Object.entries(attrs)) {
225
+ const name = rawName.toLowerCase();
226
+ if (!isAllowedAttr(tag, name)) continue;
227
+
228
+ const value = String(rawValue);
229
+ const safeValue = sanitizeAttrValue(tag, name, value);
230
+ if (safeValue === null) continue;
231
+ out[name] = safeValue;
232
+ }
233
+ if (tag === 'a' && out.target === '_blank') {
234
+ out.rel = ensureRelTokens(out.rel, ['noopener', 'noreferrer']);
235
+ }
236
+ return out;
237
+ }
238
+
239
+ function isAllowedAttr(tag: string, name: string): boolean {
240
+ if (!SAFE_ATTR_NAME_RE.test(name)) return false;
241
+ if (name.startsWith('on')) return false;
242
+ if (name.startsWith('aria-') || name.startsWith('data-')) return true;
243
+ if (GLOBAL_ATTRS.has(name)) return true;
244
+ return TAG_ATTRS[tag]?.has(name) ?? false;
245
+ }
246
+
247
+ function sanitizeAttrValue(tag: string, name: string, value: string): string | null {
248
+ if (name === 'href') return sanitizeUrl(value, 'link');
249
+ if (name === 'src' || name === 'poster') return sanitizeUrl(value, 'media');
250
+ if (
251
+ (name === 'width' ||
252
+ name === 'height' ||
253
+ name === 'span' ||
254
+ name === 'colspan' ||
255
+ name === 'rowspan' ||
256
+ name === 'value') &&
257
+ !isNonNegativeInteger(value)
258
+ ) {
259
+ return null;
260
+ }
261
+ if (name === 'start' && !isInteger(value)) return null;
262
+ if (name === 'type' && tag === 'ol' && !SAFE_OL_TYPES.has(value)) return null;
263
+ if (name === 'preload' && !SAFE_PRELOAD_VALUES.has(value)) return null;
264
+ if (name === 'kind' && tag === 'track' && !SAFE_TRACK_KINDS.has(value)) return null;
265
+ if (name === 'align' && value !== 'left' && value !== 'right' && value !== 'center') return null;
266
+ if (name === 'target')
267
+ return value === '_blank' || value === '_self' || value === '_parent' || value === '_top'
268
+ ? value
269
+ : null;
270
+ if (name === 'rel') return sanitizeRel(value);
271
+ return value;
272
+ }
273
+
274
+ function sanitizeRel(value: string): string {
275
+ return relTokens(value).join(' ');
276
+ }
277
+
278
+ function ensureRelTokens(value: string | undefined, required: string[]): string {
279
+ const tokens = new Set(value ? relTokens(value) : []);
280
+ for (const token of required) tokens.add(token);
281
+ return Array.from(tokens).join(' ');
282
+ }
283
+
284
+ function relTokens(value: string): string[] {
285
+ const tokens = value
286
+ .split(/\s+/)
287
+ .map((token) => token.trim().toLowerCase())
288
+ .filter(Boolean)
289
+ .filter((token) => /^[a-z0-9_-]+$/.test(token));
290
+ return Array.from(new Set(tokens));
291
+ }
292
+
293
+ function isInteger(value: string): boolean {
294
+ return /^-?\d+$/.test(value.trim());
295
+ }
296
+
297
+ function isNonNegativeInteger(value: string): boolean {
298
+ return /^\d+$/.test(value.trim());
299
+ }
300
+
301
+ function firstIndexOfAny(value: string, needles: string[]): number {
302
+ let result = -1;
303
+ for (const needle of needles) {
304
+ const index = value.indexOf(needle);
305
+ if (index >= 0 && (result < 0 || index < result)) result = index;
306
+ }
307
+ return result;
308
+ }
309
+
310
+ function stripUrlSchemeNoise(value: string): string {
311
+ let out = '';
312
+ for (const char of value) {
313
+ const code = char.charCodeAt(0);
314
+ if (code <= 0x20 || code === 0x7f || char.trim() === '') continue;
315
+ out += char;
316
+ }
317
+ return out;
318
+ }
@@ -12,10 +12,65 @@ import remarkMath from 'remark-math';
12
12
  import remarkDirective from 'remark-directive';
13
13
  import type { MarkdownDocument, StringifyOptions } from './types.js';
14
14
  import { toMdast } from './convert.js';
15
+ import { formatBlockScalar } from './utils.js';
15
16
 
16
17
  // Cache the default processor (all extensions, default formatting) to avoid rebuilding on every call.
17
18
  let defaultProcessor: any;
18
19
 
20
+ // ============================================
21
+ // Annotation-span unescaping
22
+ // ============================================
23
+
24
+ // Quoted runs as they appear in remark-stringify output. Backslashes inside
25
+ // the span are doubled by remark (a literal `\` before punctuation becomes
26
+ // `\\`), so `\\.` covers both authored escapes and remark's own.
27
+ const DQ_RUN = `"(?:[^"\\\\]|\\\\.)*"`;
28
+ const SQ_RUN = `'(?:[^'\\\\]|\\\\.)*'`;
29
+
30
+ /**
31
+ * A `{[…]}` template-annotation (or inline-icon) span as it appears in
32
+ * remark output: `{\[inner]}`, where `inner` may contain backslash escapes
33
+ * and quoted runs (which may themselves contain `]`).
34
+ */
35
+ const ESCAPED_TEMPLATE_SPAN_RE = new RegExp(
36
+ `\\{\\\\\\[((?:${DQ_RUN}|${SQ_RUN}|\\\\.|[^\\]\\\\])+)\\]\\}`,
37
+ 'g',
38
+ );
39
+
40
+ /** A heading line (annotations are only emitted trailing on headings). */
41
+ const HEADING_LINE_RE = /^#{1,6} .*$/gm;
42
+
43
+ /**
44
+ * The trailing Pandoc `{…}` span on a heading line, possibly followed by an
45
+ * already-unescaped `{[…]}` template span (canonical emit order).
46
+ */
47
+ const TRAILING_ESCAPED_PANDOC_SPAN_RE = new RegExp(
48
+ `\\{(?!\\\\?\\[)((?:${DQ_RUN}|${SQ_RUN}|\\\\.|[^}\\\\])*)\\}(?=\\s*(?:\\{\\[.*)?$)`,
49
+ );
50
+
51
+ /**
52
+ * Backslash+punctuation pairs that are safe to unescape inside an
53
+ * annotation span: the bare character re-parses as literal text in
54
+ * trailing-heading position, so removing the backslash is purely
55
+ * cosmetic and round-trips.
56
+ *
57
+ * Deliberately EXCLUDED (left escaped in the emitted markdown): emphasis
58
+ * and other inline-construct openers (`*`, `_`, `` ` ``, `~`, `<`, `>`,
59
+ * `&`, `!`, `|`, `^`). Unescaping those could make remark-parse split the
60
+ * heading's text run into inline nodes on reparse, which would hide the
61
+ * annotation from the extractor. remark-parse resolves the surviving
62
+ * `\X` escapes back to bare characters, so values still round-trip —
63
+ * the escapes are a cosmetic cost only.
64
+ *
65
+ * Pairs are consumed left to right, so an authored `\"` that remark
66
+ * doubled to `\\"` correctly comes back as `\"`.
67
+ */
68
+ const UNESCAPE_PUNCT_RE = /\\([\\[\]:#.,+=/?;@%$(){}'"-])/g;
69
+
70
+ function unescapeMarkdownPunct(text: string): string {
71
+ return text.replace(UNESCAPE_PUNCT_RE, '$1');
72
+ }
73
+
19
74
  /**
20
75
  * Serialize a MarkdownDocument back to a markdown string.
21
76
  *
@@ -106,15 +161,50 @@ export function stringifyMarkdown(doc: MarkdownDocument, options?: StringifyOpti
106
161
  // Stringify mdast → markdown string
107
162
  const result = processor.stringify(mdastTree) as string;
108
163
 
109
- // remark-stringify escapes `[` in text nodes (to prevent link syntax),
110
- // which turns `{[template]}` into `{\[template]}`. Unescape our annotation syntax.
111
- const cleaned = result.replace(/\{\\\[([^\]]+)\]\}/g, '{[$1]}');
164
+ // remark-stringify backslash-escapes markdown-significant characters in
165
+ // text nodes (`[` to prevent link syntax, `:` for directives, `*`/`_` for
166
+ // emphasis, a literal `\` before punctuation, ). Our annotations are
167
+ // injected as plain text, so their interiors come back escaped. Undo that
168
+ // inside `{[…]}` template-annotation spans: removing one backslash from
169
+ // each backslash+punctuation pair (left to right) exactly inverts
170
+ // remark's escaping — including restoring an authored `\"` that remark
171
+ // doubled to `\\"`. The span regex understands quoted values, so a
172
+ // quoted `]` doesn't terminate the span.
173
+ let cleaned = result.replace(
174
+ ESCAPED_TEMPLATE_SPAN_RE,
175
+ (_m, inner: string) => `{[${unescapeMarkdownPunct(inner)}]}`,
176
+ );
177
+
178
+ // Same treatment for the trailing Pandoc `{…}` attribute block on heading
179
+ // lines (canonical emit order is Pandoc block then template annotation,
180
+ // so the span sits at end-of-line or just before a `{[…]}` span).
181
+ cleaned = cleaned.replace(HEADING_LINE_RE, (line) =>
182
+ line.replace(
183
+ TRAILING_ESCAPED_PANDOC_SPAN_RE,
184
+ (_m, inner: string) => `{${unescapeMarkdownPunct(inner)}}`,
185
+ ),
186
+ );
187
+
188
+ // remark-directive escapes `:` in text (since `:name` would round-trip as
189
+ // a text directive). Inside any other Pandoc-style `{…}` brace span, `:`
190
+ // is purely a value separator (e.g. `connectsTo=foo:flow`), so unescape
191
+ // it to keep round-trips lossless. The negative lookahead skips `{[…]}`
192
+ // template annotations — those are handled above.
193
+ cleaned = cleaned.replace(/\{(?!\\?\[)[^}]*\}/g, (match) => match.replace(/\\:/g, ':'));
112
194
 
113
195
  // Prepend YAML frontmatter if present
114
196
  if (doc.frontmatter && Object.keys(doc.frontmatter).length > 0) {
115
- const yamlLines = Object.entries(doc.frontmatter).map(
116
- ([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`,
117
- );
197
+ const yamlLines = Object.entries(doc.frontmatter).map(([k, v]) => {
198
+ if (typeof v === 'string') {
199
+ // Multi-line strings emit as a YAML literal block scalar, matching
200
+ // `formatFrontmatterValue` / `setFrontmatterValues` so both paths
201
+ // agree; single-line strings stay verbatim as before.
202
+ return /[\r\n]/.test(v)
203
+ ? `${k}: ${formatBlockScalar(v.replace(/\r\n?/g, '\n'))}`
204
+ : `${k}: ${v}`;
205
+ }
206
+ return `${k}: ${JSON.stringify(v)}`;
207
+ });
118
208
  return `---\n${yamlLines.join('\n')}\n---\n\n${cleaned}`;
119
209
  }
120
210
 
@@ -126,6 +126,65 @@ export interface HeadingTemplateAnnotation {
126
126
  params?: Record<string, string>;
127
127
  }
128
128
 
129
+ /**
130
+ * A directed connection from one block to another, parsed from a heading
131
+ * attribute `connectsTo=target1,target2:type,target3`. Mirrors the schema
132
+ * `BlockConnection` type — duplicated here so this module stays
133
+ * dependency-free from the doc schema.
134
+ */
135
+ export interface BlockConnection {
136
+ /** Target block id. */
137
+ target: string;
138
+ /** Optional connection type/label (e.g., "flow", "requires"). */
139
+ type?: string;
140
+ }
141
+
142
+ /**
143
+ * Pandoc-style heading attribute block, parsed from `{#id .class key=value}`
144
+ * appearing at the end of a heading line (alongside, or instead of, the
145
+ * squisq-native `{[templateName …]}` block).
146
+ *
147
+ * @example
148
+ * ```markdown
149
+ * ## Architecture {#arch x=600 y=300 connectsTo=foo:flow,bar:requires}
150
+ * ```
151
+ * →
152
+ * ```ts
153
+ * {
154
+ * id: 'arch',
155
+ * params: { x: '600', y: '300', connectsTo: 'foo:flow,bar:requires' },
156
+ * blockMeta: {
157
+ * x: 600,
158
+ * y: 300,
159
+ * connectsTo: [{ target: 'foo', type: 'flow' }, { target: 'bar', type: 'requires' }],
160
+ * },
161
+ * }
162
+ * ```
163
+ *
164
+ * `params` carries the raw pre-coercion strings (lossless for round-tripping),
165
+ * while `blockMeta` carries the typed values for known keys. Unknown keys flow
166
+ * to `metadata`.
167
+ */
168
+ export interface HeadingAttributes {
169
+ /** Block id from `#identifier` (overrides slug-from-heading). */
170
+ id?: string;
171
+ /** Class tokens from `.classname` (reserved for downstream use). */
172
+ classes?: string[];
173
+ /** All key=value attributes as raw strings (lossless source for serialization). */
174
+ params?: Record<string, string>;
175
+ /** Typed values for known block-meta keys (coerced from `params`). */
176
+ blockMeta?: {
177
+ x?: number;
178
+ y?: number;
179
+ startTime?: number;
180
+ duration?: number;
181
+ connectsTo?: BlockConnection[];
182
+ transition?: import('../schemas/Transitions.js').Transition;
183
+ };
184
+ /** Subset of `params` whose keys are NOT in the known-key registry. */
185
+ metadata?: Record<string, string>;
186
+ }
187
+
129
188
  /**
130
189
  * ATX or setext heading (# Heading).
131
190
  */
@@ -142,6 +201,13 @@ export interface MarkdownHeading extends MarkdownNodeBase {
142
201
  * stringification, so it round-trips transparently.
143
202
  */
144
203
  templateAnnotation?: HeadingTemplateAnnotation;
204
+ /**
205
+ * Pandoc-style heading attributes parsed from trailing `{#id .class key=value}`
206
+ * syntax. Independent from `templateAnnotation` — both may appear on the
207
+ * same heading in either order. Stripped from `children` during parsing
208
+ * and re-injected during stringification.
209
+ */
210
+ attributes?: HeadingAttributes;
145
211
  }
146
212
 
147
213
  /**
@@ -5,7 +5,8 @@
5
5
  * All functions are pure and operate on the JSON node interfaces.
6
6
  */
7
7
 
8
- import type { MarkdownNode, MarkdownDocument } from './types.js';
8
+ import type { MarkdownNode, MarkdownDocument, HtmlNode } from './types.js';
9
+ import { parseHtmlToNodes } from './htmlParse.js';
9
10
 
10
11
  /**
11
12
  * Get the children of any markdown node, if it has children.
@@ -123,8 +124,10 @@ export function parseFrontmatter(yaml: string): Record<string, unknown> | null {
123
124
  if (!yaml || !yaml.trim()) return null;
124
125
 
125
126
  const result: Record<string, unknown> = {};
127
+ const lines = yaml.split('\n');
126
128
 
127
- for (const line of yaml.split('\n')) {
129
+ for (let i = 0; i < lines.length; i++) {
130
+ const line = lines[i];
128
131
  const trimmed = line.trim();
129
132
  if (!trimmed || trimmed.startsWith('#')) continue;
130
133
 
@@ -134,6 +137,45 @@ export function parseFrontmatter(yaml: string): Record<string, unknown> | null {
134
137
  const key = trimmed.slice(0, colonIdx).trim();
135
138
  let value: string | boolean | number = trimmed.slice(colonIdx + 1).trim();
136
139
 
140
+ // YAML literal block scalar: `key: |` or `key: |-`. Subsequent lines
141
+ // that are more-indented than the key belong to the scalar; they are
142
+ // dedented by their common leading indent and joined with `\n`. A line
143
+ // at the key's indent (or less) ends the scalar. Both `|` and `|-`
144
+ // yield the joined string here (`|-` without a trailing newline); we do
145
+ // not model `|`'s trailing-newline / `+`/`-` chomping nuances beyond
146
+ // stripping trailing blank lines.
147
+ if (value === '|' || value === '|-') {
148
+ const keyIndent = line.length - line.trimStart().length;
149
+ const blockLines: string[] = [];
150
+ let j = i + 1;
151
+ for (; j < lines.length; j++) {
152
+ const bl = lines[j];
153
+ if (bl.trim() === '') {
154
+ blockLines.push('');
155
+ continue;
156
+ }
157
+ const indent = bl.length - bl.trimStart().length;
158
+ if (indent <= keyIndent) break; // dedent ends the scalar
159
+ blockLines.push(bl);
160
+ }
161
+ i = j - 1; // resume the outer walk at the terminating line
162
+
163
+ // Drop trailing blank lines captured inside the scalar.
164
+ while (blockLines.length > 0 && blockLines[blockLines.length - 1] === '') {
165
+ blockLines.pop();
166
+ }
167
+
168
+ // Dedent by the common leading whitespace of the non-blank lines.
169
+ let commonIndent = Infinity;
170
+ for (const bl of blockLines) {
171
+ if (bl === '') continue;
172
+ commonIndent = Math.min(commonIndent, bl.length - bl.trimStart().length);
173
+ }
174
+ if (!isFinite(commonIndent)) commonIndent = 0;
175
+ result[key] = blockLines.map((bl) => (bl === '' ? '' : bl.slice(commonIndent))).join('\n');
176
+ continue;
177
+ }
178
+
137
179
  // Remove surrounding quotes
138
180
  if (
139
181
  (value.startsWith('"') && value.endsWith('"')) ||
@@ -157,11 +199,37 @@ export function parseFrontmatter(yaml: string): Record<string, unknown> | null {
157
199
  return Object.keys(result).length > 0 ? result : null;
158
200
  }
159
201
 
202
+ /**
203
+ * Format a multi-line string as a YAML literal block scalar body, i.e.
204
+ * `|-` followed by each source line indented two spaces. Paired with
205
+ * {@link parseFrontmatter}'s block-scalar reader so multi-line frontmatter
206
+ * values (e.g. pretty-printed JSON payloads) round-trip. Blank lines stay
207
+ * blank (no trailing whitespace) so the output diffs cleanly.
208
+ */
209
+ export function formatBlockScalar(value: string): string {
210
+ const body = value
211
+ .split('\n')
212
+ .map((line) => (line === '' ? '' : ` ${line}`))
213
+ .join('\n');
214
+ return `|-\n${body}`;
215
+ }
216
+
160
217
  const FRONTMATTER_BLOCK_RE = /^---\r?\n([\s\S]*?)\r?\n---(\r?\n)?/;
161
218
 
162
219
  /** Quote a frontmatter scalar so it round-trips cleanly through `parseFrontmatter`. */
163
220
  function formatFrontmatterValue(value: string | number | boolean): string {
164
221
  if (typeof value === 'boolean' || typeof value === 'number') return String(value);
222
+ // Multi-line strings can't live on one line — emit a YAML literal block
223
+ // scalar (`|-`) that `parseFrontmatter` reads back. This carries pretty-
224
+ // printed JSON payloads (opt-in custom templates/themes). Single-line
225
+ // values keep their exact historical output below.
226
+ if (/[\r\n]/.test(value)) return formatBlockScalar(value.replace(/\r\n?/g, '\n'));
227
+ // A single-line JSON object/array literal round-trips verbatim through
228
+ // the line-based `parseFrontmatter` (no leading quote to strip, no
229
+ // newline to split on), so write it unquoted to keep it human-readable
230
+ // — matching how `stringifyMarkdown` emits string values. Used by the
231
+ // compact custom-layouts payload.
232
+ if (/^[{[]/.test(value) && !/[\r\n]/.test(value)) return value;
165
233
  // Quote when needed: leading/trailing whitespace, leading punctuation that
166
234
  // could trigger YAML modes, or values that look like reserved literals.
167
235
  const needsQuote =
@@ -326,3 +394,44 @@ export function readFrontmatterThemeId(
326
394
  }
327
395
  return undefined;
328
396
  }
397
+
398
+ /**
399
+ * Project an inline-HTML string (as stored on `TextLayer.content.html`) down
400
+ * to plain text — the value mirrored into `content.text` for plain consumers
401
+ * (PDF/markdown export, search, accessibility) and the SVG `<text>` fallback.
402
+ *
403
+ * `<br>` and block-level tags (`p`, `div`, `h1`–`h6`, `li`, `blockquote`,
404
+ * `pre`) become line breaks so multi-line rich content reads sensibly. Entity
405
+ * decoding is handled by {@link parseHtmlToNodes} (parse5-backed), so this is
406
+ * more correct than a regex tag-stripper.
407
+ */
408
+ export function plainTextFromInlineHtml(html: string): string {
409
+ const BLOCK = new Set([
410
+ 'p',
411
+ 'div',
412
+ 'h1',
413
+ 'h2',
414
+ 'h3',
415
+ 'h4',
416
+ 'h5',
417
+ 'h6',
418
+ 'li',
419
+ 'blockquote',
420
+ 'pre',
421
+ ]);
422
+ const walk = (nodes: HtmlNode[]): string =>
423
+ nodes
424
+ .map((n) => {
425
+ if (n.type === 'htmlText') return n.value;
426
+ if (n.type !== 'htmlElement') return '';
427
+ const tag = n.tagName.toLowerCase();
428
+ if (tag === 'br') return '\n';
429
+ const inner = walk(n.children);
430
+ return BLOCK.has(tag) ? `${inner}\n` : inner;
431
+ })
432
+ .join('');
433
+ return walk(parseHtmlToNodes(html))
434
+ .replace(/\u00A0/g, ' ')
435
+ .replace(/\n{3,}/g, '\n\n')
436
+ .trim();
437
+ }