@bendyline/squisq 1.4.1 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{Doc-BeSeH2iX.d.ts → Doc-BsMkmzH8.d.ts} +838 -268
- package/dist/{ImageEditDoc-BTIvw0oq.d.ts → ImageEditDoc-DvoeHMP9.d.ts} +11 -4
- package/dist/{chunk-ZTX4PFFT.js → chunk-2OIBZYKP.js} +75 -2
- package/dist/chunk-2OIBZYKP.js.map +1 -0
- package/dist/chunk-2UQOHYE2.js +45 -0
- package/dist/chunk-2UQOHYE2.js.map +1 -0
- package/dist/{chunk-B4EEON3N.js → chunk-4AKUQPEE.js} +536 -103
- package/dist/chunk-4AKUQPEE.js.map +1 -0
- package/dist/chunk-4ZFRZBU2.js +6582 -0
- package/dist/chunk-4ZFRZBU2.js.map +1 -0
- package/dist/{chunk-57CVQCFX.js → chunk-7MQGNR76.js} +79 -3
- package/dist/chunk-7MQGNR76.js.map +1 -0
- package/dist/{chunk-5WFRKURX.js → chunk-DLXZMS5K.js} +4 -2
- package/dist/chunk-DLXZMS5K.js.map +1 -0
- package/dist/{chunk-MYIH7FWD.js → chunk-FBKP5CAO.js} +1 -1
- package/dist/chunk-FBKP5CAO.js.map +1 -0
- package/dist/chunk-KZNXZ5SM.js +333 -0
- package/dist/chunk-KZNXZ5SM.js.map +1 -0
- package/dist/{chunk-WMOI7FN3.js → chunk-LRCV7N7F.js} +12 -2
- package/dist/{chunk-WMOI7FN3.js.map → chunk-LRCV7N7F.js.map} +1 -1
- package/dist/{chunk-3K5OG6KI.js → chunk-OH43RPTP.js} +403 -187
- package/dist/chunk-OH43RPTP.js.map +1 -0
- package/dist/{chunk-H3AXU4MJ.js → chunk-OKJOQXK4.js} +149 -2
- package/dist/chunk-OKJOQXK4.js.map +1 -0
- package/dist/chunk-PGGGKYWG.js +1 -0
- package/dist/chunk-PGGGKYWG.js.map +1 -0
- package/dist/{chunk-7UDSRZKG.js → chunk-Q3ROPT5H.js} +35 -14
- package/dist/chunk-Q3ROPT5H.js.map +1 -0
- package/dist/chunk-QE7TWGVT.js +178 -0
- package/dist/chunk-QE7TWGVT.js.map +1 -0
- package/dist/chunk-RUDYOTA6.js +345 -0
- package/dist/chunk-RUDYOTA6.js.map +1 -0
- package/dist/chunk-TVSGLUID.js +596 -0
- package/dist/chunk-TVSGLUID.js.map +1 -0
- package/dist/chunk-ZLQKNOZQ.js +1 -0
- package/dist/chunk-ZLQKNOZQ.js.map +1 -0
- package/dist/doc/index.d.ts +4 -4
- package/dist/doc/index.js +124 -6
- package/dist/generate/index.d.ts +9 -2
- package/dist/generate/index.js +1 -1
- package/dist/icons/index.d.ts +3 -26
- package/dist/icons/index.js +13 -0
- package/dist/icons/inlineIconMarker.d.ts +1 -0
- package/dist/icons/inlineIconMarker.js +15 -0
- package/dist/icons/inlineIconMarker.js.map +1 -0
- package/dist/imageEdit/index.d.ts +11 -5
- package/dist/imageEdit/index.js +4 -1
- package/dist/index.d.ts +11 -8
- package/dist/index.js +243 -24
- package/dist/inlineIconMarker-CiE8Hlvz.d.ts +59 -0
- package/dist/markdown/index.d.ts +212 -3
- package/dist/markdown/index.js +40 -7
- package/dist/recommend/index.d.ts +1 -1
- package/dist/recommend/index.js +3 -2
- package/dist/schemas/index.d.ts +28 -8
- package/dist/schemas/index.js +35 -5
- package/dist/spatial/index.d.ts +9 -4
- package/dist/spatial/index.js +1 -1
- package/dist/story/index.d.ts +906 -40
- package/dist/story/index.js +124 -6
- package/dist/{themeLibrary-BHjJ0Qx6.d.ts → themeLibrary-DQQKuRMx.d.ts} +1 -1
- package/dist/transform/index.d.ts +48 -10
- package/dist/transform/index.js +17 -481
- package/dist/transform/index.js.map +1 -1
- package/dist/{types-BdZkdJ3z.d.ts → types-DlAZ7MW4.d.ts} +115 -1
- package/package.json +6 -1
- package/src/__tests__/animationUtils.test.ts +40 -0
- package/src/__tests__/annotationCoercion.test.ts +243 -0
- package/src/__tests__/applyRenderStyle.test.ts +146 -0
- package/src/__tests__/atmosphereLayers.test.ts +108 -0
- package/src/__tests__/attrTokens.test.ts +162 -0
- package/src/__tests__/customTemplateSample.test.ts +83 -0
- package/src/__tests__/customTemplatesFrontmatter.test.ts +205 -0
- package/src/__tests__/customThemesFrontmatter.test.ts +118 -0
- package/src/__tests__/diagramBlock.test.ts +122 -0
- package/src/__tests__/diagramLayout.test.ts +134 -0
- package/src/__tests__/drawing.test.ts +109 -0
- package/src/__tests__/drawingBlock.test.ts +124 -0
- package/src/__tests__/drawingLayout.test.ts +171 -0
- package/src/__tests__/geohash.test.ts +6 -5
- package/src/__tests__/getLayers.test.ts +6 -2
- package/src/__tests__/getLayersFallback.test.ts +103 -0
- package/src/__tests__/imageEditExportSvg.test.ts +70 -0
- package/src/__tests__/imageEditVersions.test.ts +72 -0
- package/src/__tests__/inlineIconMarker.test.ts +36 -0
- package/src/__tests__/layoutBlock.test.ts +83 -0
- package/src/__tests__/layoutLayout.test.ts +169 -0
- package/src/__tests__/markdown.test.ts +23 -0
- package/src/__tests__/markdownSanitize.test.ts +64 -0
- package/src/__tests__/markdownToDoc.test.ts +223 -2
- package/src/__tests__/mediaAnnotations.test.ts +64 -0
- package/src/__tests__/mediaSchedule.test.ts +118 -0
- package/src/__tests__/pandocAttrParse.test.ts +187 -0
- package/src/__tests__/resolveDocTheme.test.ts +55 -0
- package/src/__tests__/rootExports.test.ts +43 -0
- package/src/__tests__/shapeGeometry.test.ts +89 -0
- package/src/__tests__/shapeVocabulary.test.ts +133 -0
- package/src/__tests__/structuredData.test.ts +179 -0
- package/src/__tests__/templateAnnotationParse.test.ts +108 -0
- package/src/__tests__/templateMetadata.test.ts +21 -0
- package/src/__tests__/templates.test.ts +145 -4
- package/src/__tests__/themeCompile.test.ts +42 -0
- package/src/__tests__/themeValidator.test.ts +6 -0
- package/src/__tests__/transformV2.test.ts +137 -0
- package/src/__tests__/transitionNormalize.test.ts +61 -0
- package/src/__tests__/validateDoc.test.ts +100 -0
- package/src/doc/customTemplatesFrontmatter.ts +308 -0
- package/src/doc/customThemesFrontmatter.ts +90 -0
- package/src/doc/docToMarkdown.ts +73 -4
- package/src/doc/getLayers.ts +40 -17
- package/src/doc/index.ts +35 -1
- package/src/doc/markdownToDoc.ts +333 -12
- package/src/doc/mediaAnnotations.ts +118 -0
- package/src/doc/resolveDocTheme.ts +55 -0
- package/src/doc/structuredData.ts +230 -0
- package/src/doc/templateInputs.ts +240 -0
- package/src/doc/templates/__tests__/customTemplate.test.ts +151 -0
- package/src/doc/templates/__tests__/customTemplateRegistry.test.ts +110 -0
- package/src/doc/templates/accentImage.ts +30 -6
- package/src/doc/templates/captionUtils.ts +23 -0
- package/src/doc/templates/comparisonBar.ts +26 -18
- package/src/doc/templates/coverBlock.ts +7 -4
- package/src/doc/templates/customTemplate.ts +45 -0
- package/src/doc/templates/dataTable.ts +37 -19
- package/src/doc/templates/dateEvent.ts +41 -20
- package/src/doc/templates/definitionCard.ts +26 -18
- package/src/doc/templates/diagramBlock.ts +204 -0
- package/src/doc/templates/diagramLayout.ts +159 -0
- package/src/doc/templates/drawingBlock.ts +325 -0
- package/src/doc/templates/drawingLayout.ts +539 -0
- package/src/doc/templates/factCard.ts +51 -25
- package/src/doc/templates/fallbackBlock.ts +123 -0
- package/src/doc/templates/featureBlock.ts +20 -13
- package/src/doc/templates/fullBleedQuote.ts +18 -8
- package/src/doc/templates/imageWithCaption.ts +22 -9
- package/src/doc/templates/index.ts +215 -46
- package/src/doc/templates/layoutBlock.ts +43 -0
- package/src/doc/templates/layoutLayout.ts +243 -0
- package/src/doc/templates/listBlock.ts +56 -30
- package/src/doc/templates/mapBlock.ts +15 -12
- package/src/doc/templates/metadata.ts +120 -0
- package/src/doc/templates/persistentLayers.ts +129 -11
- package/src/doc/templates/photoGrid.ts +23 -12
- package/src/doc/templates/pullQuote.ts +30 -12
- package/src/doc/templates/quoteBlock.ts +54 -29
- package/src/doc/templates/sectionHeader.ts +25 -11
- package/src/doc/templates/statHighlight.ts +39 -22
- package/src/doc/templates/titleBlock.ts +59 -21
- package/src/doc/templates/tokens/__tests__/resolveTokens.test.ts +221 -0
- package/src/doc/templates/tokens/resolveTokens.ts +0 -0
- package/src/doc/templates/twoColumn.ts +48 -30
- package/src/doc/templates/videoPullQuote.ts +22 -12
- package/src/doc/templates/videoWithCaption.ts +13 -8
- package/src/doc/utils/animationUtils.ts +267 -3
- package/src/doc/utils/applyRenderStyle.ts +113 -0
- package/src/doc/utils/imageTreatment.ts +70 -0
- package/src/doc/utils/shapeGeometry.ts +464 -0
- package/src/doc/utils/themeUtils.ts +75 -1
- package/src/doc/validate.ts +476 -0
- package/src/generate/slideshowGenerator.ts +7 -0
- package/src/icons/index.ts +8 -0
- package/src/icons/inlineIconMarker.ts +73 -0
- package/src/imageEdit/export.ts +85 -2
- package/src/imageEdit/index.ts +1 -0
- package/src/imageEdit/persistence.ts +51 -0
- package/src/index.ts +2 -0
- package/src/markdown/annotationCoercion.ts +295 -0
- package/src/markdown/attrTokens.ts +222 -0
- package/src/markdown/convert.ts +229 -26
- package/src/markdown/index.ts +36 -0
- package/src/markdown/sanitize.ts +298 -0
- package/src/markdown/stringify.ts +84 -3
- package/src/markdown/types.ts +66 -0
- package/src/markdown/utils.ts +49 -1
- package/src/recommend/templates.ts +24 -0
- package/src/schemas/BlockTemplates.ts +133 -4
- package/src/schemas/CustomTemplates.ts +74 -0
- package/src/schemas/Doc.ts +353 -29
- package/src/schemas/ImageEditDoc.ts +11 -4
- package/src/schemas/Media.ts +169 -0
- package/src/schemas/Theme.ts +23 -1
- package/src/schemas/Transitions.ts +224 -0
- package/src/schemas/colorUtils.ts +33 -0
- package/src/schemas/fontStacks.ts +5 -2
- package/src/schemas/index.ts +3 -0
- package/src/schemas/themeCompile.ts +23 -4
- package/src/schemas/themeValidator.ts +49 -11
- package/src/schemas/themes/cinematic.json +69 -12
- package/src/schemas/themes/documentary.json +58 -11
- package/src/schemas/themes/gezellig.json +60 -11
- package/src/schemas/themes/magazine.json +54 -9
- package/src/schemas/themes/morning-light.json +52 -9
- package/src/schemas/themes/standard-dark.json +48 -13
- package/src/schemas/themes/standard.json +44 -12
- package/src/schemas/themes/tech-dark.json +65 -12
- package/src/spatial/Geohash.ts +52 -21
- package/src/transform/applyTransform.ts +43 -4
- package/src/transform/index.ts +1 -0
- package/src/transform/registry.ts +44 -13
- package/src/transform/styles/dataDriven.ts +2 -1
- package/src/transform/styles/documentary.ts +2 -1
- package/src/transform/styles/magazine.ts +3 -1
- package/src/transform/styles/minimal.ts +2 -1
- package/src/transform/styles/narrative.ts +3 -1
- package/src/transform/templateSelector.ts +87 -11
- package/src/transform/types.ts +35 -2
- package/dist/chunk-3K5OG6KI.js.map +0 -1
- package/dist/chunk-57CVQCFX.js.map +0 -1
- package/dist/chunk-5WFRKURX.js.map +0 -1
- package/dist/chunk-7UDSRZKG.js.map +0 -1
- package/dist/chunk-B4EEON3N.js.map +0 -1
- package/dist/chunk-H3AXU4MJ.js.map +0 -1
- package/dist/chunk-KSWJR66U.js +0 -3706
- package/dist/chunk-KSWJR66U.js.map +0 -1
- package/dist/chunk-MYIH7FWD.js.map +0 -1
- package/dist/chunk-ZTX4PFFT.js.map +0 -1
package/src/markdown/convert.ts
CHANGED
|
@@ -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
|
|
162
|
+
const match = matchTrailingTemplateAnnotation(child.value);
|
|
160
163
|
if (match) {
|
|
161
|
-
const inner = match
|
|
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
|
|
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
|
|
187
|
-
* Output: `{ template: 'chart', params: { colorScheme: 'blue',
|
|
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
|
|
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
|
|
201
|
-
if (
|
|
202
|
-
params[
|
|
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
|
-
|
|
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
|
-
|
|
783
|
-
|
|
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;
|
package/src/markdown/index.ts
CHANGED
|
@@ -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, UrlKind } from './sanitize.js';
|
|
101
136
|
|
|
102
137
|
// Tree utilities
|
|
103
138
|
export {
|
|
@@ -111,4 +146,5 @@ export {
|
|
|
111
146
|
setFrontmatterValues,
|
|
112
147
|
inferDocumentTitle,
|
|
113
148
|
readFrontmatterThemeId,
|
|
149
|
+
plainTextFromInlineHtml,
|
|
114
150
|
} from './utils.js';
|
|
@@ -0,0 +1,298 @@
|
|
|
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
|
+
const SAFE_DATA_MEDIA_RE =
|
|
11
|
+
/^data:(?:image\/(?!svg\+xml)[a-z0-9.+-]+|audio\/[a-z0-9.+-]+|video\/[a-z0-9.+-]+);/i;
|
|
12
|
+
|
|
13
|
+
const SAFE_TAGS = new Set([
|
|
14
|
+
'a',
|
|
15
|
+
'abbr',
|
|
16
|
+
'b',
|
|
17
|
+
'blockquote',
|
|
18
|
+
'br',
|
|
19
|
+
'caption',
|
|
20
|
+
'cite',
|
|
21
|
+
'code',
|
|
22
|
+
'col',
|
|
23
|
+
'colgroup',
|
|
24
|
+
'data',
|
|
25
|
+
'dd',
|
|
26
|
+
'del',
|
|
27
|
+
'details',
|
|
28
|
+
'dfn',
|
|
29
|
+
'div',
|
|
30
|
+
'dl',
|
|
31
|
+
'dt',
|
|
32
|
+
'em',
|
|
33
|
+
'figcaption',
|
|
34
|
+
'figure',
|
|
35
|
+
'h1',
|
|
36
|
+
'h2',
|
|
37
|
+
'h3',
|
|
38
|
+
'h4',
|
|
39
|
+
'h5',
|
|
40
|
+
'h6',
|
|
41
|
+
'hr',
|
|
42
|
+
'i',
|
|
43
|
+
'img',
|
|
44
|
+
'kbd',
|
|
45
|
+
'li',
|
|
46
|
+
'mark',
|
|
47
|
+
'ol',
|
|
48
|
+
'p',
|
|
49
|
+
'pre',
|
|
50
|
+
'q',
|
|
51
|
+
's',
|
|
52
|
+
'samp',
|
|
53
|
+
'small',
|
|
54
|
+
'source',
|
|
55
|
+
'span',
|
|
56
|
+
'strong',
|
|
57
|
+
'sub',
|
|
58
|
+
'summary',
|
|
59
|
+
'sup',
|
|
60
|
+
'table',
|
|
61
|
+
'tbody',
|
|
62
|
+
'td',
|
|
63
|
+
'tfoot',
|
|
64
|
+
'th',
|
|
65
|
+
'thead',
|
|
66
|
+
'time',
|
|
67
|
+
'tr',
|
|
68
|
+
'track',
|
|
69
|
+
'u',
|
|
70
|
+
'ul',
|
|
71
|
+
'var',
|
|
72
|
+
'video',
|
|
73
|
+
'audio',
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
const DROP_WITH_CONTENT_TAGS = new Set([
|
|
77
|
+
'base',
|
|
78
|
+
'button',
|
|
79
|
+
'embed',
|
|
80
|
+
'form',
|
|
81
|
+
'iframe',
|
|
82
|
+
'input',
|
|
83
|
+
'link',
|
|
84
|
+
'meta',
|
|
85
|
+
'object',
|
|
86
|
+
'option',
|
|
87
|
+
'script',
|
|
88
|
+
'select',
|
|
89
|
+
'style',
|
|
90
|
+
'svg',
|
|
91
|
+
'template',
|
|
92
|
+
'textarea',
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
const GLOBAL_ATTRS = new Set(['class', 'id', 'title', 'role']);
|
|
96
|
+
|
|
97
|
+
const TAG_ATTRS: Record<string, Set<string>> = {
|
|
98
|
+
a: new Set(['href', 'target', 'rel']),
|
|
99
|
+
audio: new Set(['src', 'controls', 'preload', 'muted', 'loop']),
|
|
100
|
+
col: new Set(['span', 'width']),
|
|
101
|
+
colgroup: new Set(['span']),
|
|
102
|
+
data: new Set(['value']),
|
|
103
|
+
img: new Set(['src', 'alt', 'width', 'height', 'loading', 'decoding']),
|
|
104
|
+
li: new Set(['value']),
|
|
105
|
+
ol: new Set(['start', 'type']),
|
|
106
|
+
q: new Set(['cite']),
|
|
107
|
+
source: new Set(['src', 'type', 'media']),
|
|
108
|
+
td: new Set(['colspan', 'rowspan', 'align']),
|
|
109
|
+
th: new Set(['colspan', 'rowspan', 'align', 'scope']),
|
|
110
|
+
time: new Set(['datetime']),
|
|
111
|
+
track: new Set(['src', 'kind', 'label', 'srclang', 'default']),
|
|
112
|
+
video: new Set([
|
|
113
|
+
'src',
|
|
114
|
+
'poster',
|
|
115
|
+
'width',
|
|
116
|
+
'height',
|
|
117
|
+
'controls',
|
|
118
|
+
'preload',
|
|
119
|
+
'muted',
|
|
120
|
+
'loop',
|
|
121
|
+
'playsinline',
|
|
122
|
+
]),
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const SAFE_ATTR_NAME_RE = /^[a-z][a-z0-9:_.-]*$/;
|
|
126
|
+
const SAFE_OL_TYPES = new Set(['1', 'a', 'A', 'i', 'I']);
|
|
127
|
+
const SAFE_PRELOAD_VALUES = new Set(['none', 'metadata', 'auto']);
|
|
128
|
+
const SAFE_TRACK_KINDS = new Set(['subtitles', 'captions', 'descriptions', 'chapters', 'metadata']);
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Sanitize a URL for a rendered link or media attribute.
|
|
132
|
+
*
|
|
133
|
+
* Relative URLs are allowed. Absolute URLs are restricted to schemes that
|
|
134
|
+
* cannot execute script in a document context. Media URLs additionally allow
|
|
135
|
+
* `blob:` and image/audio/video `data:` URLs because Squisq uses both for
|
|
136
|
+
* browser-local media previews and self-contained exports.
|
|
137
|
+
*/
|
|
138
|
+
export function sanitizeUrl(url: string | null | undefined, kind: UrlKind = 'link'): string | null {
|
|
139
|
+
if (typeof url !== 'string') return null;
|
|
140
|
+
|
|
141
|
+
const trimmed = url.trim();
|
|
142
|
+
if (!trimmed) return null;
|
|
143
|
+
|
|
144
|
+
const compact = stripUrlSchemeNoise(trimmed);
|
|
145
|
+
const colon = compact.indexOf(':');
|
|
146
|
+
const firstPathChar = firstIndexOfAny(compact, ['/', '?', '#']);
|
|
147
|
+
const hasScheme = colon >= 0 && (firstPathChar < 0 || colon < firstPathChar);
|
|
148
|
+
|
|
149
|
+
if (!hasScheme) return trimmed;
|
|
150
|
+
|
|
151
|
+
const scheme = compact.slice(0, colon).toLowerCase();
|
|
152
|
+
if (kind === 'media') {
|
|
153
|
+
if (scheme === 'data') return SAFE_DATA_MEDIA_RE.test(compact) ? trimmed : null;
|
|
154
|
+
return SAFE_MEDIA_SCHEMES.has(scheme) ? trimmed : null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return SAFE_LINK_SCHEMES.has(scheme) ? trimmed : null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Return a sanitized copy of an HtmlNode tree. The original parsed tree is left
|
|
162
|
+
* untouched so markdown stringify can still preserve source fidelity.
|
|
163
|
+
*/
|
|
164
|
+
export function sanitizeHtmlNodes(nodes: HtmlNode[]): HtmlNode[] {
|
|
165
|
+
const out: HtmlNode[] = [];
|
|
166
|
+
for (const node of nodes) {
|
|
167
|
+
out.push(...sanitizeHtmlNode(node));
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function sanitizeHtmlNode(node: HtmlNode): HtmlNode[] {
|
|
173
|
+
switch (node.type) {
|
|
174
|
+
case 'htmlText':
|
|
175
|
+
return [node];
|
|
176
|
+
case 'htmlComment':
|
|
177
|
+
return [];
|
|
178
|
+
case 'htmlElement':
|
|
179
|
+
return sanitizeHtmlElement(node);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function sanitizeHtmlElement(node: HtmlElement): HtmlNode[] {
|
|
184
|
+
const tag = node.tagName.toLowerCase();
|
|
185
|
+
|
|
186
|
+
if (DROP_WITH_CONTENT_TAGS.has(tag)) return [];
|
|
187
|
+
|
|
188
|
+
const children = sanitizeHtmlNodes(node.children);
|
|
189
|
+
if (!SAFE_TAGS.has(tag)) return children;
|
|
190
|
+
|
|
191
|
+
return [
|
|
192
|
+
{
|
|
193
|
+
type: 'htmlElement',
|
|
194
|
+
tagName: tag,
|
|
195
|
+
attributes: sanitizeAttrs(tag, node.attributes),
|
|
196
|
+
children,
|
|
197
|
+
selfClosing: node.selfClosing,
|
|
198
|
+
},
|
|
199
|
+
];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function sanitizeAttrs(tag: string, attrs: Record<string, string>): Record<string, string> {
|
|
203
|
+
const out: Record<string, string> = {};
|
|
204
|
+
for (const [rawName, rawValue] of Object.entries(attrs)) {
|
|
205
|
+
const name = rawName.toLowerCase();
|
|
206
|
+
if (!isAllowedAttr(tag, name)) continue;
|
|
207
|
+
|
|
208
|
+
const value = String(rawValue);
|
|
209
|
+
const safeValue = sanitizeAttrValue(tag, name, value);
|
|
210
|
+
if (safeValue === null) continue;
|
|
211
|
+
out[name] = safeValue;
|
|
212
|
+
}
|
|
213
|
+
if (tag === 'a' && out.target === '_blank') {
|
|
214
|
+
out.rel = ensureRelTokens(out.rel, ['noopener', 'noreferrer']);
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function isAllowedAttr(tag: string, name: string): boolean {
|
|
220
|
+
if (!SAFE_ATTR_NAME_RE.test(name)) return false;
|
|
221
|
+
if (name.startsWith('on')) return false;
|
|
222
|
+
if (name.startsWith('aria-') || name.startsWith('data-')) return true;
|
|
223
|
+
if (GLOBAL_ATTRS.has(name)) return true;
|
|
224
|
+
return TAG_ATTRS[tag]?.has(name) ?? false;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function sanitizeAttrValue(tag: string, name: string, value: string): string | null {
|
|
228
|
+
if (name === 'href') return sanitizeUrl(value, 'link');
|
|
229
|
+
if (name === 'src' || name === 'poster') return sanitizeUrl(value, 'media');
|
|
230
|
+
if (
|
|
231
|
+
(name === 'width' ||
|
|
232
|
+
name === 'height' ||
|
|
233
|
+
name === 'span' ||
|
|
234
|
+
name === 'colspan' ||
|
|
235
|
+
name === 'rowspan' ||
|
|
236
|
+
name === 'value') &&
|
|
237
|
+
!isNonNegativeInteger(value)
|
|
238
|
+
) {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
if (name === 'start' && !isInteger(value)) return null;
|
|
242
|
+
if (name === 'type' && tag === 'ol' && !SAFE_OL_TYPES.has(value)) return null;
|
|
243
|
+
if (name === 'preload' && !SAFE_PRELOAD_VALUES.has(value)) return null;
|
|
244
|
+
if (name === 'kind' && tag === 'track' && !SAFE_TRACK_KINDS.has(value)) return null;
|
|
245
|
+
if (name === 'align' && value !== 'left' && value !== 'right' && value !== 'center') return null;
|
|
246
|
+
if (name === 'target')
|
|
247
|
+
return value === '_blank' || value === '_self' || value === '_parent' || value === '_top'
|
|
248
|
+
? value
|
|
249
|
+
: null;
|
|
250
|
+
if (name === 'rel') return sanitizeRel(value);
|
|
251
|
+
return value;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function sanitizeRel(value: string): string {
|
|
255
|
+
return relTokens(value).join(' ');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function ensureRelTokens(value: string | undefined, required: string[]): string {
|
|
259
|
+
const tokens = new Set(value ? relTokens(value) : []);
|
|
260
|
+
for (const token of required) tokens.add(token);
|
|
261
|
+
return Array.from(tokens).join(' ');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function relTokens(value: string): string[] {
|
|
265
|
+
const tokens = value
|
|
266
|
+
.split(/\s+/)
|
|
267
|
+
.map((token) => token.trim().toLowerCase())
|
|
268
|
+
.filter(Boolean)
|
|
269
|
+
.filter((token) => /^[a-z0-9_-]+$/.test(token));
|
|
270
|
+
return Array.from(new Set(tokens));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function isInteger(value: string): boolean {
|
|
274
|
+
return /^-?\d+$/.test(value.trim());
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function isNonNegativeInteger(value: string): boolean {
|
|
278
|
+
return /^\d+$/.test(value.trim());
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function firstIndexOfAny(value: string, needles: string[]): number {
|
|
282
|
+
let result = -1;
|
|
283
|
+
for (const needle of needles) {
|
|
284
|
+
const index = value.indexOf(needle);
|
|
285
|
+
if (index >= 0 && (result < 0 || index < result)) result = index;
|
|
286
|
+
}
|
|
287
|
+
return result;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function stripUrlSchemeNoise(value: string): string {
|
|
291
|
+
let out = '';
|
|
292
|
+
for (const char of value) {
|
|
293
|
+
const code = char.charCodeAt(0);
|
|
294
|
+
if (code <= 0x20 || code === 0x7f || char.trim() === '') continue;
|
|
295
|
+
out += char;
|
|
296
|
+
}
|
|
297
|
+
return out;
|
|
298
|
+
}
|