@lionrapid/core 0.4.0 → 0.6.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/base/event-emitter.d.ts.map +1 -1
- package/dist/base/event-emitter.js +2 -1
- package/dist/base/event-emitter.js.map +1 -1
- package/dist/browser/attribute-extractor.d.ts.map +1 -1
- package/dist/browser/attribute-extractor.js +2 -1
- package/dist/browser/attribute-extractor.js.map +1 -1
- package/dist/browser/auto-init.d.ts +69 -0
- package/dist/browser/auto-init.d.ts.map +1 -1
- package/dist/browser/auto-init.js +193 -61
- package/dist/browser/auto-init.js.map +1 -1
- package/dist/browser/block-extractor.d.ts.map +1 -1
- package/dist/browser/block-extractor.js +3 -1
- package/dist/browser/block-extractor.js.map +1 -1
- package/dist/browser/editor/editor-api.d.ts +50 -6
- package/dist/browser/editor/editor-api.d.ts.map +1 -1
- package/dist/browser/editor/editor-api.js +121 -19
- package/dist/browser/editor/editor-api.js.map +1 -1
- package/dist/browser/editor/editor-init.js +4 -1
- package/dist/browser/editor/editor-init.js.map +1 -1
- package/dist/browser/editor/sidebar.d.ts +4 -3
- package/dist/browser/editor/sidebar.d.ts.map +1 -1
- package/dist/browser/editor/sidebar.js +93 -27
- package/dist/browser/editor/sidebar.js.map +1 -1
- package/dist/browser/editor/snapshot-viewer.d.ts.map +1 -1
- package/dist/browser/editor/snapshot-viewer.js +32 -3
- package/dist/browser/editor/snapshot-viewer.js.map +1 -1
- package/dist/browser/lionrapid.auto.dev.js +599 -245
- package/dist/browser/lionrapid.auto.dev.js.map +4 -4
- package/dist/browser/lionrapid.auto.js +599 -245
- package/dist/browser/lionrapid.auto.js.map +4 -4
- package/dist/browser/lionrapid.auto.min.js +4 -3
- package/dist/browser/lionrapid.auto.min.js.map +4 -4
- package/dist/browser/lionrapid.editor.js +1467 -1115
- package/dist/browser/lionrapid.editor.js.map +4 -4
- package/dist/browser/lionrapid.editor.min.js +4 -3
- package/dist/browser/lionrapid.editor.min.js.map +4 -4
- package/dist/browser/lionrapid.esm.js +951 -196
- package/dist/browser/lionrapid.esm.js.map +4 -4
- package/dist/browser/lionrapid.esm.min.js +3 -2
- package/dist/browser/lionrapid.esm.min.js.map +4 -4
- package/dist/browser/lionrapid.iife.dev.js +951 -196
- package/dist/browser/lionrapid.iife.dev.js.map +4 -4
- package/dist/browser/lionrapid.iife.js +951 -196
- package/dist/browser/lionrapid.iife.js.map +4 -4
- package/dist/browser/lionrapid.iife.min.js +3 -2
- package/dist/browser/lionrapid.iife.min.js.map +4 -4
- package/dist/browser/lionrapid.umd.js +951 -196
- package/dist/browser/lionrapid.umd.js.map +4 -4
- package/dist/browser/lionrapid.umd.min.js +3 -2
- package/dist/browser/lionrapid.umd.min.js.map +4 -4
- package/dist/browser/sanitizer.d.ts +37 -0
- package/dist/browser/sanitizer.d.ts.map +1 -0
- package/dist/browser/sanitizer.js +93 -0
- package/dist/browser/sanitizer.js.map +1 -0
- package/dist/builder.d.ts +16 -0
- package/dist/builder.d.ts.map +1 -1
- package/dist/builder.js +43 -5
- package/dist/builder.js.map +1 -1
- package/dist/config/lionrapid.config.d.ts +0 -1
- package/dist/config/lionrapid.config.d.ts.map +1 -1
- package/dist/config/repository.config.d.ts +2 -2
- package/dist/config/repository.config.d.ts.map +1 -1
- package/dist/content/index.d.ts +3 -0
- package/dist/content/index.d.ts.map +1 -1
- package/dist/content/index.js +5 -0
- package/dist/content/index.js.map +1 -1
- package/dist/content/sanitizer/HTMLSanitizer.d.ts +105 -0
- package/dist/content/sanitizer/HTMLSanitizer.d.ts.map +1 -0
- package/dist/content/sanitizer/HTMLSanitizer.js +257 -0
- package/dist/content/sanitizer/HTMLSanitizer.js.map +1 -0
- package/dist/content/serializer/HTMLSerializer.d.ts +6 -2
- package/dist/content/serializer/HTMLSerializer.d.ts.map +1 -1
- package/dist/content/serializer/HTMLSerializer.js +16 -8
- package/dist/content/serializer/HTMLSerializer.js.map +1 -1
- package/dist/content/splitter/BlockSplitter.d.ts +66 -0
- package/dist/content/splitter/BlockSplitter.d.ts.map +1 -0
- package/dist/content/splitter/BlockSplitter.js +219 -0
- package/dist/content/splitter/BlockSplitter.js.map +1 -0
- package/dist/index.d.ts +9 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +28 -1
- package/dist/index.js.map +1 -1
- package/dist/lionRapid.d.ts +1 -2
- package/dist/lionRapid.d.ts.map +1 -1
- package/dist/lionRapid.js +5 -7
- package/dist/lionRapid.js.map +1 -1
- package/dist/repositories/index.d.ts +1 -0
- package/dist/repositories/index.d.ts.map +1 -1
- package/dist/repositories/index.js +1 -0
- package/dist/repositories/index.js.map +1 -1
- package/dist/repositories/network-repository.d.ts +30 -8
- package/dist/repositories/network-repository.d.ts.map +1 -1
- package/dist/repositories/network-repository.js +88 -74
- package/dist/repositories/network-repository.js.map +1 -1
- package/dist/repositories/static-repository.d.ts +40 -0
- package/dist/repositories/static-repository.d.ts.map +1 -0
- package/dist/repositories/static-repository.js +60 -0
- package/dist/repositories/static-repository.js.map +1 -0
- package/dist/types/lionRapid.types.d.ts +2 -0
- package/dist/types/lionRapid.types.d.ts.map +1 -1
- package/dist/types/translation.types.d.ts +0 -3
- package/dist/types/translation.types.d.ts.map +1 -1
- package/dist/utils/context-utils.d.ts +1 -2
- package/dist/utils/context-utils.d.ts.map +1 -1
- package/dist/utils/context-utils.js +3 -6
- package/dist/utils/context-utils.js.map +1 -1
- package/dist/utils/locale.d.ts +57 -0
- package/dist/utils/locale.d.ts.map +1 -0
- package/dist/utils/locale.js +116 -0
- package/dist/utils/locale.js.map +1 -0
- package/dist/utils/translation-flow.d.ts +1 -1
- package/dist/utils/translation-flow.d.ts.map +1 -1
- package/dist/utils/translation-flow.js +2 -3
- package/dist/utils/translation-flow.js.map +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared allowlist HTML sanitizer — pure string functions, Node-safe (no DOM).
|
|
3
|
+
*
|
|
4
|
+
* One implementation for every sink that turns translation data into HTML:
|
|
5
|
+
* core's HTMLSerializer/auto-translator, the editor text sinks, the snapshot
|
|
6
|
+
* viewer, react `<Trans>`, and WordPress TS. The WordPress PHP port must
|
|
7
|
+
* mirror `SAFE_TAGS`/`SAFE_ATTRS` exactly (see improvement-plan §4 item 2).
|
|
8
|
+
*
|
|
9
|
+
* Threat model: translation values (and their `raw:` styles) come from the
|
|
10
|
+
* server/KV and may be attacker-controlled — they are NOT guaranteed to be in
|
|
11
|
+
* canonical form. Everything here is allowlist-based: unknown tags are
|
|
12
|
+
* rejected, unknown attributes dropped, URL schemes checked after entity
|
|
13
|
+
* decoding.
|
|
14
|
+
*
|
|
15
|
+
* @module content/sanitizer/HTMLSanitizer
|
|
16
|
+
*/
|
|
17
|
+
import { extractTagName, readRawStyle, serializeOpeningTag } from '../canonical-tag.js';
|
|
18
|
+
/**
|
|
19
|
+
* Inline formatting elements allowed inside translated content. Block
|
|
20
|
+
* structure lives outside ContentUnit (block-level pipeline), so anything
|
|
21
|
+
* that isn't inline text formatting is rejected outright.
|
|
22
|
+
*/
|
|
23
|
+
export const SAFE_TAGS = new Set([
|
|
24
|
+
'a', 'abbr', 'b', 'bdi', 'bdo', 'br', 'cite', 'code', 'del', 'dfn',
|
|
25
|
+
'em', 'i', 'img', 'ins', 'kbd', 'mark', 'q', 's', 'samp', 'small',
|
|
26
|
+
'span', 'strong', 'sub', 'sup', 'time', 'u', 'var', 'wbr',
|
|
27
|
+
]);
|
|
28
|
+
/**
|
|
29
|
+
* Flat attribute allowlist (plus `data-*`/`aria-*` prefixes, handled in
|
|
30
|
+
* `sanitizeOpeningTag`). Kept flat — not per-tag — so the PHP mirror stays
|
|
31
|
+
* trivial; an allowed attribute on the wrong tag is inert.
|
|
32
|
+
*/
|
|
33
|
+
export const SAFE_ATTRS = new Set([
|
|
34
|
+
'alt', 'cite', 'class', 'datetime', 'dir', 'height', 'href', 'id',
|
|
35
|
+
'lang', 'loading', 'rel', 'role', 'src', 'target', 'title', 'width',
|
|
36
|
+
]);
|
|
37
|
+
/** Attributes whose values are URLs and must pass `sanitizeUrl`. */
|
|
38
|
+
const URL_ATTRS = new Set(['href', 'src', 'cite']);
|
|
39
|
+
/**
|
|
40
|
+
* CSS properties allowed through an inline `style` attribute.
|
|
41
|
+
*
|
|
42
|
+
* RISK NOTE — why `style` never passes through verbatim: with zero
|
|
43
|
+
* JavaScript, an attacker who controls a style value can render a
|
|
44
|
+
* full-viewport overlay (`position:fixed;inset:0` → pixel-perfect fake
|
|
45
|
+
* login UI on the customer's real domain), fire tracking/exfiltration
|
|
46
|
+
* beacons via `url(...)`, or hide and deface content. Translation data is
|
|
47
|
+
* attacker-writable in our threat model, so `sanitizeStyle` re-parses the
|
|
48
|
+
* declarations and keeps only the properties below: cosmetic text/font
|
|
49
|
+
* properties whose value grammar cannot fetch a URL and cannot reposition
|
|
50
|
+
* or restack the element (no `position`/`inset`/`display`/`z-index`/
|
|
51
|
+
* `opacity`/`transform`, no `background` shorthand). A smuggled `url(...)`
|
|
52
|
+
* inside an allowed property is doubly inert: the value is rejected here,
|
|
53
|
+
* and these properties ignore URL values anyway.
|
|
54
|
+
*
|
|
55
|
+
* CONTINUOUS DEVELOPMENT: this allowlist is intentionally minimal and is
|
|
56
|
+
* expected to grow deliberately as real sites surface legitimate inline
|
|
57
|
+
* styling the strict set drops (tracked as a WATCH item in
|
|
58
|
+
* IMPLEMENTATION.md). When extending it: (1) never add a property that
|
|
59
|
+
* accepts `url()`/`image()`/`element()` values or that affects layout,
|
|
60
|
+
* positioning, or stacking; (2) add both hostile and benign cases to
|
|
61
|
+
* html-sanitizer.test.ts; (3) mirror the change in the WordPress PHP port.
|
|
62
|
+
*/
|
|
63
|
+
export const SAFE_STYLE_PROPS = new Set([
|
|
64
|
+
'color', 'background-color', 'font-family', 'font-size', 'font-style',
|
|
65
|
+
'font-weight', 'letter-spacing', 'line-height', 'text-decoration',
|
|
66
|
+
'text-decoration-color', 'text-decoration-line', 'text-decoration-style',
|
|
67
|
+
'text-transform', 'vertical-align', 'white-space', 'word-spacing',
|
|
68
|
+
]);
|
|
69
|
+
/**
|
|
70
|
+
* Value-level rejections inside an otherwise-allowed declaration: URL/code
|
|
71
|
+
* carriers, and backslashes because CSS escape sequences (`\75\72\6c`)
|
|
72
|
+
* can spell `url(` past a textual check.
|
|
73
|
+
*/
|
|
74
|
+
const DANGEROUS_CSS_VALUE = /url\s*\(|expression\s*\(|image-set\s*\(|element\s*\(|paint\s*\(|javascript:|\\|[<>{}@]/i;
|
|
75
|
+
const SAFE_SCHEMES = new Set(['http', 'https', 'mailto', 'tel']);
|
|
76
|
+
/**
|
|
77
|
+
* The named entities that matter for scheme smuggling (`:`, `	`)
|
|
78
|
+
* plus the basic set. Names are case-sensitive, as in HTML.
|
|
79
|
+
*/
|
|
80
|
+
const NAMED_ENTITIES = {
|
|
81
|
+
amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ',
|
|
82
|
+
colon: ':', semi: ';', sol: '/', bsol: '\\',
|
|
83
|
+
Tab: '\t', NewLine: '\n',
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* Decode entities exactly once — mirroring the single decode a browser does
|
|
87
|
+
* when parsing an attribute value out of HTML.
|
|
88
|
+
*/
|
|
89
|
+
function decodeEntities(value) {
|
|
90
|
+
return value.replace(/&(?:#[xX]([0-9a-fA-F]+);?|#(\d+);?|([a-zA-Z]+);)/g, (full, hex, dec, name) => {
|
|
91
|
+
if (hex)
|
|
92
|
+
return String.fromCodePoint(parseInt(hex, 16));
|
|
93
|
+
if (dec)
|
|
94
|
+
return String.fromCodePoint(parseInt(dec, 10));
|
|
95
|
+
return NAMED_ENTITIES[name] ?? full;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/** HTML-escape text for safe use in element content or attribute values. */
|
|
99
|
+
export function escapeHtml(text) {
|
|
100
|
+
return text
|
|
101
|
+
.replace(/&/g, '&')
|
|
102
|
+
.replace(/</g, '<')
|
|
103
|
+
.replace(/>/g, '>')
|
|
104
|
+
.replace(/"/g, '"')
|
|
105
|
+
.replace(/'/g, ''');
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Validate a URL attribute value. Returns the value unchanged when safe,
|
|
109
|
+
* or `null` when it must be dropped.
|
|
110
|
+
*
|
|
111
|
+
* Entity-decodes first, then strips the control/whitespace characters
|
|
112
|
+
* browsers ignore inside a scheme (`jav\tascript:`), then requires any
|
|
113
|
+
* explicit scheme to be on the allowlist. Scheme-less (relative) URLs pass.
|
|
114
|
+
*/
|
|
115
|
+
export function sanitizeUrl(value) {
|
|
116
|
+
const decoded = decodeEntities(value);
|
|
117
|
+
// eslint-disable-next-line no-control-regex -- stripping C0 controls/space/DEL is the point
|
|
118
|
+
const probe = decoded.replace(/[\u0000-\u0020\u007f]/g, '').toLowerCase();
|
|
119
|
+
const scheme = /^([a-z][a-z0-9+.-]*):/.exec(probe);
|
|
120
|
+
if (!scheme)
|
|
121
|
+
return value;
|
|
122
|
+
return SAFE_SCHEMES.has(scheme[1]) ? value : null;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Sanitize an inline `style` attribute value by re-parsing its declarations
|
|
126
|
+
* and keeping only `SAFE_STYLE_PROPS` with clean values (see the risk note
|
|
127
|
+
* on `SAFE_STYLE_PROPS`). Returns the rebuilt declaration list in canonical
|
|
128
|
+
* `prop: value; prop: value` form, or `null` when nothing survives — the
|
|
129
|
+
* caller then drops the attribute entirely.
|
|
130
|
+
*/
|
|
131
|
+
export function sanitizeStyle(value) {
|
|
132
|
+
return filterCssDeclarations(value, true);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Relaxed CSS profile for the snapshot viewer: keeps ANY standard property
|
|
136
|
+
* (a snapshot is a full captured page — `position:fixed` navbars and layout
|
|
137
|
+
* styles are legitimate and must render) but still rejects value-level
|
|
138
|
+
* dangers: `url(...)` beacons, `expression()`, CSS escape smuggling. The
|
|
139
|
+
* residual risk (page-authored overlay styles) is accepted for snapshots:
|
|
140
|
+
* the PM is deliberately viewing arbitrary captured pages, and the editor
|
|
141
|
+
* UI itself lives in a Shadow DOM host stacked above the snapshot root.
|
|
142
|
+
*/
|
|
143
|
+
export function sanitizeCssDeclarations(value) {
|
|
144
|
+
return filterCssDeclarations(value, false);
|
|
145
|
+
}
|
|
146
|
+
function filterCssDeclarations(value, requirePropAllowlist) {
|
|
147
|
+
const decoded = decodeEntities(value);
|
|
148
|
+
const kept = [];
|
|
149
|
+
for (const declaration of decoded.split(';')) {
|
|
150
|
+
const colon = declaration.indexOf(':');
|
|
151
|
+
if (colon === -1)
|
|
152
|
+
continue;
|
|
153
|
+
const prop = declaration.slice(0, colon).trim().toLowerCase();
|
|
154
|
+
const val = declaration.slice(colon + 1).trim();
|
|
155
|
+
if (!/^[a-z-]+$/.test(prop))
|
|
156
|
+
continue; // also blocks --custom-prop indirection
|
|
157
|
+
if (requirePropAllowlist && !SAFE_STYLE_PROPS.has(prop))
|
|
158
|
+
continue;
|
|
159
|
+
if (val === '' || DANGEROUS_CSS_VALUE.test(val))
|
|
160
|
+
continue;
|
|
161
|
+
kept.push(`${prop}: ${val}`);
|
|
162
|
+
}
|
|
163
|
+
return kept.length > 0 ? kept.join('; ') : null;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Attribute scanner for a (possibly hostile, non-canonical) opening tag.
|
|
167
|
+
* Handles double-quoted, single-quoted, unquoted, and bare boolean attrs.
|
|
168
|
+
*/
|
|
169
|
+
const ATTR_PATTERN = /([^\s=/>"']+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]*)))?/g;
|
|
170
|
+
/**
|
|
171
|
+
* Sanitize a raw opening tag (`raw:` style payload). Returns the canonical
|
|
172
|
+
* rebuilt tag with only allowlisted attributes, or `null` when the element
|
|
173
|
+
* itself is not allowed — callers must then drop the tag entirely (emit the
|
|
174
|
+
* indexed marker verbatim / skip the element).
|
|
175
|
+
*/
|
|
176
|
+
export function sanitizeOpeningTag(rawOpen) {
|
|
177
|
+
if (!rawOpen.startsWith('<'))
|
|
178
|
+
return null;
|
|
179
|
+
const tagName = extractTagName(rawOpen);
|
|
180
|
+
if (!/^[a-z][a-z0-9-]*$/.test(tagName) || !SAFE_TAGS.has(tagName)) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
// The slice between `<tagname` and the closing `>` (or end of string —
|
|
184
|
+
// hostile input may be truncated).
|
|
185
|
+
const end = rawOpen.endsWith('/>')
|
|
186
|
+
? rawOpen.length - 2
|
|
187
|
+
: rawOpen.endsWith('>')
|
|
188
|
+
? rawOpen.length - 1
|
|
189
|
+
: rawOpen.length;
|
|
190
|
+
const attrSource = rawOpen.slice(1 + tagName.length, end);
|
|
191
|
+
const attrs = {};
|
|
192
|
+
for (const match of attrSource.matchAll(ATTR_PATTERN)) {
|
|
193
|
+
const name = match[1].toLowerCase();
|
|
194
|
+
const value = match[2] ?? match[3] ?? match[4] ?? '';
|
|
195
|
+
if (name in attrs)
|
|
196
|
+
continue; // first occurrence wins, as in HTML
|
|
197
|
+
if (name === 'style') {
|
|
198
|
+
// Never verbatim — re-parsed against the CSS property allowlist.
|
|
199
|
+
const cleanStyle = sanitizeStyle(value);
|
|
200
|
+
if (cleanStyle !== null)
|
|
201
|
+
attrs[name] = cleanStyle;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const allowed = SAFE_ATTRS.has(name) ||
|
|
205
|
+
/^data-[a-z0-9-]*$/.test(name) ||
|
|
206
|
+
/^aria-[a-z0-9-]*$/.test(name);
|
|
207
|
+
if (!allowed)
|
|
208
|
+
continue;
|
|
209
|
+
if (URL_ATTRS.has(name)) {
|
|
210
|
+
if (sanitizeUrl(value) === null)
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
attrs[name] = value;
|
|
214
|
+
}
|
|
215
|
+
return serializeOpeningTag(tagName, attrs);
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Sanitize a ContentUnit at an ingest boundary: run every `raw:` style
|
|
219
|
+
* through {@link sanitizeOpeningTag} so no dangerous element/attribute/scheme
|
|
220
|
+
* enters the translation store. Render-time sinks (HTMLSerializer etc.)
|
|
221
|
+
* sanitize again — this is the store-side half, protecting every other
|
|
222
|
+
* consumer of stored units (server, MT/TM, exports, the WP PHP port).
|
|
223
|
+
*
|
|
224
|
+
* `text` is never modified, so `generateAutoKey(unit.text)` is unaffected —
|
|
225
|
+
* key parity across surfaces holds even for hostile input. A rejected
|
|
226
|
+
* element's `raw:` style is dropped entirely; the indexed tag left in the
|
|
227
|
+
* text is emitted by serializers as an inert marker. Returns the SAME object
|
|
228
|
+
* when nothing changed, so benign units round-trip identically.
|
|
229
|
+
*/
|
|
230
|
+
export function sanitizeContentUnit(unit) {
|
|
231
|
+
let changed = false;
|
|
232
|
+
const styles = {};
|
|
233
|
+
for (const [index, styleList] of Object.entries(unit.styles)) {
|
|
234
|
+
const raw = readRawStyle(styleList);
|
|
235
|
+
if (raw === null) {
|
|
236
|
+
styles[index] = styleList; // no raw tag → nothing to sanitize
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const safe = sanitizeOpeningTag(raw);
|
|
240
|
+
if (safe === null) {
|
|
241
|
+
// Element rejected — drop the raw style (keep any non-raw entries).
|
|
242
|
+
changed = true;
|
|
243
|
+
const rest = styleList.filter((s) => !s.startsWith('raw:'));
|
|
244
|
+
if (rest.length > 0)
|
|
245
|
+
styles[index] = rest;
|
|
246
|
+
}
|
|
247
|
+
else if (safe === raw) {
|
|
248
|
+
styles[index] = styleList;
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
changed = true;
|
|
252
|
+
styles[index] = styleList.map((s) => (s.startsWith('raw:') ? `raw:${safe}` : s));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return changed ? { text: unit.text, styles } : unit;
|
|
256
|
+
}
|
|
257
|
+
//# sourceMappingURL=HTMLSanitizer.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"HTMLSanitizer.js","sourceRoot":"","sources":["../../../src/content/sanitizer/HTMLSanitizer.ts"],"names":[],"mappings":"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"}
|
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
* HTML serializer — converts ContentUnit back to HTML strings (raw-only).
|
|
3
3
|
*
|
|
4
4
|
* Every indexed tag carries a `raw:<opening tag>` style. The serializer
|
|
5
|
-
*
|
|
6
|
-
* the embedded tag name. Void elements get no
|
|
5
|
+
* routes the stored opening tag through the shared allowlist sanitizer and
|
|
6
|
+
* derives the close tag from the embedded tag name. Void elements get no
|
|
7
|
+
* close tag. Text segments are HTML-escaped (ContentUnit.text holds decoded
|
|
8
|
+
* Unicode — see MarkupParser.decodeEntities), so serializer output is inert
|
|
9
|
+
* even when the translation value is attacker-controlled. `ContentUnit.text`
|
|
10
|
+
* itself is never modified — auto_key hashing is unaffected.
|
|
7
11
|
*
|
|
8
12
|
* @module content/serializer/HTMLSerializer
|
|
9
13
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"HTMLSerializer.d.ts","sourceRoot":"","sources":["../../../src/content/serializer/HTMLSerializer.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"HTMLSerializer.d.ts","sourceRoot":"","sources":["../../../src/content/serializer/HTMLSerializer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAI/C;;;;;;;;;;;;GAYG;AACH,qBAAa,cAAe,YAAW,WAAW,CAAC,MAAM,CAAC;IACxD,SAAS,CAAC,IAAI,EAAE,WAAW,GAAG,MAAM;CAyCrC"}
|
|
@@ -2,12 +2,17 @@
|
|
|
2
2
|
* HTML serializer — converts ContentUnit back to HTML strings (raw-only).
|
|
3
3
|
*
|
|
4
4
|
* Every indexed tag carries a `raw:<opening tag>` style. The serializer
|
|
5
|
-
*
|
|
6
|
-
* the embedded tag name. Void elements get no
|
|
5
|
+
* routes the stored opening tag through the shared allowlist sanitizer and
|
|
6
|
+
* derives the close tag from the embedded tag name. Void elements get no
|
|
7
|
+
* close tag. Text segments are HTML-escaped (ContentUnit.text holds decoded
|
|
8
|
+
* Unicode — see MarkupParser.decodeEntities), so serializer output is inert
|
|
9
|
+
* even when the translation value is attacker-controlled. `ContentUnit.text`
|
|
10
|
+
* itself is never modified — auto_key hashing is unaffected.
|
|
7
11
|
*
|
|
8
12
|
* @module content/serializer/HTMLSerializer
|
|
9
13
|
*/
|
|
10
14
|
import { extractTagName, readRawStyle, VOID_TAGS } from '../canonical-tag.js';
|
|
15
|
+
import { escapeHtml, sanitizeOpeningTag } from '../sanitizer/HTMLSanitizer.js';
|
|
11
16
|
/**
|
|
12
17
|
* Serializer that converts ContentUnit format back to HTML strings.
|
|
13
18
|
*
|
|
@@ -29,11 +34,12 @@ export class HTMLSerializer {
|
|
|
29
34
|
let lastIndex = 0;
|
|
30
35
|
let match;
|
|
31
36
|
while ((match = tagPattern.exec(text)) !== null) {
|
|
32
|
-
result += text.slice(lastIndex, match.index);
|
|
37
|
+
result += escapeHtml(text.slice(lastIndex, match.index));
|
|
33
38
|
const [fullMatch, closingSlash, indexStr] = match;
|
|
34
39
|
const styleList = styles[indexStr] ?? [];
|
|
35
40
|
const rawOpen = readRawStyle(styleList);
|
|
36
|
-
|
|
41
|
+
const safeOpen = rawOpen === null ? null : sanitizeOpeningTag(rawOpen);
|
|
42
|
+
if (rawOpen && safeOpen !== null) {
|
|
37
43
|
const tagName = extractTagName(rawOpen);
|
|
38
44
|
const isVoid = VOID_TAGS.has(tagName);
|
|
39
45
|
if (closingSlash) {
|
|
@@ -43,17 +49,19 @@ export class HTMLSerializer {
|
|
|
43
49
|
else {
|
|
44
50
|
// Both <N> and <N/> emit the opening tag. For void tags that's the
|
|
45
51
|
// complete element.
|
|
46
|
-
result +=
|
|
52
|
+
result += safeOpen;
|
|
47
53
|
}
|
|
48
54
|
}
|
|
49
55
|
else {
|
|
50
|
-
// No raw style
|
|
51
|
-
//
|
|
56
|
+
// No raw style, or the sanitizer rejected the element — preserve the
|
|
57
|
+
// indexed marker (parses as inert text) so the problem is visible
|
|
58
|
+
// rather than silently dropping content, and never emit a close tag
|
|
59
|
+
// for a rejected element.
|
|
52
60
|
result += fullMatch;
|
|
53
61
|
}
|
|
54
62
|
lastIndex = match.index + fullMatch.length;
|
|
55
63
|
}
|
|
56
|
-
result += text.slice(lastIndex);
|
|
64
|
+
result += escapeHtml(text.slice(lastIndex));
|
|
57
65
|
return result;
|
|
58
66
|
}
|
|
59
67
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"HTMLSerializer.js","sourceRoot":"","sources":["../../../src/content/serializer/HTMLSerializer.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"HTMLSerializer.js","sourceRoot":"","sources":["../../../src/content/serializer/HTMLSerializer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAIH,OAAO,EAAE,cAAc,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAC9E,OAAO,EAAE,UAAU,EAAE,kBAAkB,EAAE,MAAM,+BAA+B,CAAC;AAE/E;;;;;;;;;;;;GAYG;AACH,MAAM,OAAO,cAAc;IACzB,SAAS,CAAC,IAAiB;QACzB,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;QAC9B,MAAM,UAAU,GAAG,qBAAqB,CAAC;QAEzC,IAAI,MAAM,GAAG,EAAE,CAAC;QAChB,IAAI,SAAS,GAAG,CAAC,CAAC;QAClB,IAAI,KAAK,CAAC;QAEV,OAAO,CAAC,KAAK,GAAG,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,KAAK,IAAI,EAAE,CAAC;YAChD,MAAM,IAAI,UAAU,CAAC,IAAI,CAAC,KAAK,CAAC,SAAS,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC;YAEzD,MAAM,CAAC,SAAS,EAAE,YAAY,EAAE,QAAQ,CAAC,GAAG,KAAK,CAAC;YAClD,MAAM,SAAS,GAAG,MAAM,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC;YACzC,MAAM,OAAO,GAAG,YAAY,CAAC,SAAS,CAAC,CAAC;YACxC,MAAM,QAAQ,GAAG,OAAO,KAAK,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,kBAAkB,CAAC,OAAO,CAAC,CAAC;YAEvE,IAAI,OAAO,IAAI,QAAQ,KAAK,IAAI,EAAE,CAAC;gBACjC,MAAM,OAAO,GAAG,cAAc,CAAC,OAAO,CAAC,CAAC;gBACxC,MAAM,MAAM,GAAG,SAAS,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;gBAEtC,IAAI,YAAY,EAAE,CAAC;oBACjB,IAAI,CAAC,MAAM;wBAAE,MAAM,IAAI,KAAK,OAAO,GAAG,CAAC;gBACzC,CAAC;qBAAM,CAAC;oBACN,mEAAmE;oBACnE,oBAAoB;oBACpB,MAAM,IAAI,QAAQ,CAAC;gBACrB,CAAC;YACH,CAAC;iBAAM,CAAC;gBACN,qEAAqE;gBACrE,kEAAkE;gBAClE,oEAAoE;gBACpE,0BAA0B;gBAC1B,MAAM,IAAI,SAAS,CAAC;YACtB,CAAC;YAED,SAAS,GAAG,KAAK,CAAC,KAAK,GAAG,SAAS,CAAC,MAAM,CAAC;QAC7C,CAAC;QAED,MAAM,IAAI,UAAU,CAAC,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC;QAC5C,OAAO,MAAM,CAAC;IAChB,CAAC;CACF"}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BlockSplitter — string-level block scanner shared across surfaces.
|
|
3
|
+
*
|
|
4
|
+
* Lifted from the edge proxy's `translate-html.ts` scanner (webhook Phase 10)
|
|
5
|
+
* so non-DOM surfaces (edge Worker, webhook Worker) split HTML into the SAME
|
|
6
|
+
* block boundaries instead of hand-rolling a second parser. The browser DOM
|
|
7
|
+
* path (`browser/block-extractor.ts`) mirrors the same tag sets via
|
|
8
|
+
* `querySelectorAll`.
|
|
9
|
+
*
|
|
10
|
+
* Structural only: finds innermost block elements (p, h1–h6, li, td, …) and
|
|
11
|
+
* orphan inline `<a>` outside any block, returning each block's opening tag,
|
|
12
|
+
* raw attribute string, and byte-exact innerHTML with source offsets. No
|
|
13
|
+
* keying, no exclude filtering, no translation — callers layer their own
|
|
14
|
+
* policy (edge: data-i18n precedence + ExcludeFilter + replacement stamping;
|
|
15
|
+
* webhook P13: `toContentUnit(inner)` + `generateAutoKey` per block). Blocks
|
|
16
|
+
* inside skip tags (`<script>`/`<style>`/`<pre>`/…), HTML comments, or
|
|
17
|
+
* `data-no-translate` / `translate="no"` regions are not emitted.
|
|
18
|
+
*
|
|
19
|
+
* Pure string ops — Worker-safe and Node-safe (no DOM, no Node APIs).
|
|
20
|
+
*
|
|
21
|
+
* @module content/splitter/BlockSplitter
|
|
22
|
+
*/
|
|
23
|
+
export interface HtmlBlock {
|
|
24
|
+
/** Lowercased tag name (`p`, `h2`, `a`, …). */
|
|
25
|
+
tag: string;
|
|
26
|
+
/** The exact opening-tag source text, e.g. `<p class="intro">`. */
|
|
27
|
+
openTag: string;
|
|
28
|
+
/** Raw attribute string from the opening tag (verbatim, may be empty). */
|
|
29
|
+
attrs: string;
|
|
30
|
+
/** The block's innerHTML, byte-exact as it appears in the input. */
|
|
31
|
+
inner: string;
|
|
32
|
+
/** Offset of `inner` within the input html. */
|
|
33
|
+
innerStart: number;
|
|
34
|
+
/** Offset just past `inner` — the `<` of the matching closing tag. */
|
|
35
|
+
innerEnd: number;
|
|
36
|
+
}
|
|
37
|
+
export interface SplitBlocksResult {
|
|
38
|
+
/** Captured blocks in document order; ranges never overlap. */
|
|
39
|
+
blocks: HtmlBlock[];
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Find the matching close tag for a given tag name starting from pos.
|
|
43
|
+
* Returns the index of the '<' of the closing tag, or -1 if not found.
|
|
44
|
+
*/
|
|
45
|
+
export declare function findCloseTag(html: string, pos: number, tagName: string): number;
|
|
46
|
+
/**
|
|
47
|
+
* Split an HTML string into its translatable block elements.
|
|
48
|
+
*
|
|
49
|
+
* Walks the string once; each captured block is innermost (an outer block
|
|
50
|
+
* whose innerHTML contains another block tag is skipped in favor of the
|
|
51
|
+
* inner one) and non-overlapping, so replacing inners via
|
|
52
|
+
* `replaceBlockInners` is always position-safe.
|
|
53
|
+
*/
|
|
54
|
+
export declare function splitBlocks(html: string): SplitBlocksResult;
|
|
55
|
+
/**
|
|
56
|
+
* Splice replacement innerHTML into blocks previously returned by
|
|
57
|
+
* `splitBlocks(html)` for the SAME html string. Blocks not listed keep
|
|
58
|
+
* their source content; wrapper tags and attributes are never touched.
|
|
59
|
+
*
|
|
60
|
+
* Applied end-to-start so earlier offsets stay valid.
|
|
61
|
+
*/
|
|
62
|
+
export declare function replaceBlockInners(html: string, replacements: ReadonlyArray<{
|
|
63
|
+
block: HtmlBlock;
|
|
64
|
+
inner: string;
|
|
65
|
+
}>): string;
|
|
66
|
+
//# sourceMappingURL=BlockSplitter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"BlockSplitter.d.ts","sourceRoot":"","sources":["../../../src/content/splitter/BlockSplitter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,MAAM,WAAW,SAAS;IACxB,+CAA+C;IAC/C,GAAG,EAAE,MAAM,CAAC;IACZ,mEAAmE;IACnE,OAAO,EAAE,MAAM,CAAC;IAChB,0EAA0E;IAC1E,KAAK,EAAE,MAAM,CAAC;IACd,oEAAoE;IACpE,KAAK,EAAE,MAAM,CAAC;IACd,+CAA+C;IAC/C,UAAU,EAAE,MAAM,CAAC;IACnB,sEAAsE;IACtE,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,iBAAiB;IAChC,+DAA+D;IAC/D,MAAM,EAAE,SAAS,EAAE,CAAC;CACrB;AA6CD;;;GAGG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAmB/E;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,iBAAiB,CAmI3D;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,MAAM,EACZ,YAAY,EAAE,aAAa,CAAC;IAAE,KAAK,EAAE,SAAS,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,GAC/D,MAAM,CAOR"}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BlockSplitter — string-level block scanner shared across surfaces.
|
|
3
|
+
*
|
|
4
|
+
* Lifted from the edge proxy's `translate-html.ts` scanner (webhook Phase 10)
|
|
5
|
+
* so non-DOM surfaces (edge Worker, webhook Worker) split HTML into the SAME
|
|
6
|
+
* block boundaries instead of hand-rolling a second parser. The browser DOM
|
|
7
|
+
* path (`browser/block-extractor.ts`) mirrors the same tag sets via
|
|
8
|
+
* `querySelectorAll`.
|
|
9
|
+
*
|
|
10
|
+
* Structural only: finds innermost block elements (p, h1–h6, li, td, …) and
|
|
11
|
+
* orphan inline `<a>` outside any block, returning each block's opening tag,
|
|
12
|
+
* raw attribute string, and byte-exact innerHTML with source offsets. No
|
|
13
|
+
* keying, no exclude filtering, no translation — callers layer their own
|
|
14
|
+
* policy (edge: data-i18n precedence + ExcludeFilter + replacement stamping;
|
|
15
|
+
* webhook P13: `toContentUnit(inner)` + `generateAutoKey` per block). Blocks
|
|
16
|
+
* inside skip tags (`<script>`/`<style>`/`<pre>`/…), HTML comments, or
|
|
17
|
+
* `data-no-translate` / `translate="no"` regions are not emitted.
|
|
18
|
+
*
|
|
19
|
+
* Pure string ops — Worker-safe and Node-safe (no DOM, no Node APIs).
|
|
20
|
+
*
|
|
21
|
+
* @module content/splitter/BlockSplitter
|
|
22
|
+
*/
|
|
23
|
+
const BLOCK_TAGS = new Set([
|
|
24
|
+
'title', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
25
|
+
'li', 'td', 'th', 'label', 'button', 'figcaption',
|
|
26
|
+
'dt', 'dd', 'summary', 'caption',
|
|
27
|
+
]);
|
|
28
|
+
// Inline elements eligible for "orphan" extraction. The scanner jumps past
|
|
29
|
+
// every captured block via findCloseTag, so any <a> the main loop visits has
|
|
30
|
+
// no captured-block parent (e.g. <div><a>X</a></div>). Conservative on
|
|
31
|
+
// purpose: only <a> — mirrors browser INLINE_BLOCK_SELECTOR.
|
|
32
|
+
const INLINE_BLOCK_TAGS = new Set(['a']);
|
|
33
|
+
const SKIP_TAGS = new Set([
|
|
34
|
+
'script', 'style', 'code', 'pre', 'textarea', 'noscript', 'svg',
|
|
35
|
+
]);
|
|
36
|
+
/** Tag regex — matches <tag attrs>, </tag>, <tag/> with quoted attributes */
|
|
37
|
+
const TAG_REGEX = /<(\/?)([a-zA-Z][a-zA-Z0-9]*)((?:\s+[a-zA-Z][a-zA-Z0-9-]*(?:=(?:"[^"]*"|'[^']*'|[^\s>]*))?)*)\s*(\/?)>/g;
|
|
38
|
+
/**
|
|
39
|
+
* Check if an attribute string contains data-no-translate or translate="no"
|
|
40
|
+
*/
|
|
41
|
+
function hasNoTranslateAttr(attrsString) {
|
|
42
|
+
if (attrsString.includes('data-no-translate'))
|
|
43
|
+
return true;
|
|
44
|
+
return /translate\s*=\s*["']no["']/.test(attrsString);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Check if innerHTML contains a block-level opening tag
|
|
48
|
+
*/
|
|
49
|
+
function containsBlockTag(innerHTML) {
|
|
50
|
+
TAG_REGEX.lastIndex = 0;
|
|
51
|
+
let match;
|
|
52
|
+
while ((match = TAG_REGEX.exec(innerHTML)) !== null) {
|
|
53
|
+
const [, closingSlash, tagName, , selfClosingSlash] = match;
|
|
54
|
+
if (!closingSlash && !selfClosingSlash && BLOCK_TAGS.has(tagName.toLowerCase())) {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Find the matching close tag for a given tag name starting from pos.
|
|
62
|
+
* Returns the index of the '<' of the closing tag, or -1 if not found.
|
|
63
|
+
*/
|
|
64
|
+
export function findCloseTag(html, pos, tagName) {
|
|
65
|
+
const lowerTag = tagName.toLowerCase();
|
|
66
|
+
let depth = 1;
|
|
67
|
+
TAG_REGEX.lastIndex = pos;
|
|
68
|
+
let match;
|
|
69
|
+
while ((match = TAG_REGEX.exec(html)) !== null) {
|
|
70
|
+
const [, closingSlash, matchedTag, , selfClosingSlash] = match;
|
|
71
|
+
if (matchedTag.toLowerCase() !== lowerTag)
|
|
72
|
+
continue;
|
|
73
|
+
if (closingSlash) {
|
|
74
|
+
depth--;
|
|
75
|
+
if (depth === 0)
|
|
76
|
+
return match.index;
|
|
77
|
+
}
|
|
78
|
+
else if (!selfClosingSlash) {
|
|
79
|
+
depth++;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return -1;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Split an HTML string into its translatable block elements.
|
|
86
|
+
*
|
|
87
|
+
* Walks the string once; each captured block is innermost (an outer block
|
|
88
|
+
* whose innerHTML contains another block tag is skipped in favor of the
|
|
89
|
+
* inner one) and non-overlapping, so replacing inners via
|
|
90
|
+
* `replaceBlockInners` is always position-safe.
|
|
91
|
+
*/
|
|
92
|
+
export function splitBlocks(html) {
|
|
93
|
+
const blocks = [];
|
|
94
|
+
let pos = 0;
|
|
95
|
+
let skipDepth = 0;
|
|
96
|
+
let iter = 0;
|
|
97
|
+
const maxIter = 100_000;
|
|
98
|
+
// Stack of byte-positions where each open no-translate region ends.
|
|
99
|
+
// Pushed when we encounter `<TAG data-no-translate>` (any element);
|
|
100
|
+
// drained when `pos` reaches/passes the recorded end. While the stack
|
|
101
|
+
// is non-empty, block + orphan-inline extraction is suppressed.
|
|
102
|
+
const noTranslateEnds = [];
|
|
103
|
+
while (pos < html.length && iter < maxIter) {
|
|
104
|
+
iter++;
|
|
105
|
+
const ltPos = html.indexOf('<', pos);
|
|
106
|
+
if (ltPos === -1)
|
|
107
|
+
break;
|
|
108
|
+
// Check for HTML comment
|
|
109
|
+
if (html.startsWith('<!--', ltPos)) {
|
|
110
|
+
const commentEnd = html.indexOf('-->', ltPos + 4);
|
|
111
|
+
if (commentEnd === -1)
|
|
112
|
+
break; // unclosed comment, stop
|
|
113
|
+
pos = commentEnd + 3;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
// Try to match a tag at this position
|
|
117
|
+
TAG_REGEX.lastIndex = ltPos;
|
|
118
|
+
const match = TAG_REGEX.exec(html);
|
|
119
|
+
// If no match or match isn't at ltPos, skip past this '<'
|
|
120
|
+
if (!match || match.index !== ltPos) {
|
|
121
|
+
pos = ltPos + 1;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const [fullMatch, closingSlash, tagName, attrsString, selfClosingSlash] = match;
|
|
125
|
+
const tag = tagName.toLowerCase();
|
|
126
|
+
const afterMatch = match.index + fullMatch.length;
|
|
127
|
+
// Handle skip tags
|
|
128
|
+
if (SKIP_TAGS.has(tag)) {
|
|
129
|
+
if (closingSlash) {
|
|
130
|
+
if (skipDepth > 0)
|
|
131
|
+
skipDepth--;
|
|
132
|
+
}
|
|
133
|
+
else if (!selfClosingSlash) {
|
|
134
|
+
skipDepth++;
|
|
135
|
+
}
|
|
136
|
+
pos = afterMatch;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
// Inside skip region
|
|
140
|
+
if (skipDepth > 0) {
|
|
141
|
+
pos = afterMatch;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
// Drain no-translate ranges we've exited (pos beyond the recorded end).
|
|
145
|
+
while (noTranslateEnds.length &&
|
|
146
|
+
pos >= noTranslateEnds[noTranslateEnds.length - 1]) {
|
|
147
|
+
noTranslateEnds.pop();
|
|
148
|
+
}
|
|
149
|
+
// Open a new no-translate region when this tag carries the attribute
|
|
150
|
+
// and isn't self-closing. Record its END position (the `<` of `</TAG>`)
|
|
151
|
+
// so the drain loop above can pop us out cleanly.
|
|
152
|
+
if (!closingSlash && !selfClosingSlash && hasNoTranslateAttr(attrsString)) {
|
|
153
|
+
const regionEnd = findCloseTag(html, afterMatch, tagName);
|
|
154
|
+
if (regionEnd !== -1) {
|
|
155
|
+
noTranslateEnds.push(regionEnd);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// Inside a no-translate region (including the tag that opened it) —
|
|
159
|
+
// suppress extraction. We still advance through tags so the stack
|
|
160
|
+
// drains correctly when we exit.
|
|
161
|
+
if (noTranslateEnds.length > 0) {
|
|
162
|
+
pos = afterMatch;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
// Block tags + orphan inline <a>: same capture logic. Any <a> reaching
|
|
166
|
+
// here has no captured-block ancestor — the scanner jumps past every
|
|
167
|
+
// captured block's closing tag.
|
|
168
|
+
if ((BLOCK_TAGS.has(tag) || INLINE_BLOCK_TAGS.has(tag)) &&
|
|
169
|
+
!closingSlash &&
|
|
170
|
+
!selfClosingSlash) {
|
|
171
|
+
// An unclosed no-translate tag lands here (no region was pushed);
|
|
172
|
+
// findCloseTag returns the same -1 → skipped like any malformed block.
|
|
173
|
+
const closeTagStart = findCloseTag(html, afterMatch, tagName);
|
|
174
|
+
if (closeTagStart === -1) {
|
|
175
|
+
// Malformed — skip
|
|
176
|
+
pos = afterMatch;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const innerHTML = html.slice(afterMatch, closeTagStart);
|
|
180
|
+
// If innerHTML contains nested block tags, skip this outer element
|
|
181
|
+
// and let the scanner extract the inner blocks
|
|
182
|
+
if (containsBlockTag(innerHTML)) {
|
|
183
|
+
pos = afterMatch;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
blocks.push({
|
|
187
|
+
tag,
|
|
188
|
+
openTag: fullMatch,
|
|
189
|
+
attrs: attrsString,
|
|
190
|
+
inner: innerHTML,
|
|
191
|
+
innerStart: afterMatch,
|
|
192
|
+
innerEnd: closeTagStart,
|
|
193
|
+
});
|
|
194
|
+
// Skip past the closing tag
|
|
195
|
+
TAG_REGEX.lastIndex = closeTagStart;
|
|
196
|
+
const closeMatch = TAG_REGEX.exec(html);
|
|
197
|
+
pos = closeMatch ? closeMatch.index + closeMatch[0].length : closeTagStart + 1;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
pos = afterMatch;
|
|
201
|
+
}
|
|
202
|
+
return { blocks };
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Splice replacement innerHTML into blocks previously returned by
|
|
206
|
+
* `splitBlocks(html)` for the SAME html string. Blocks not listed keep
|
|
207
|
+
* their source content; wrapper tags and attributes are never touched.
|
|
208
|
+
*
|
|
209
|
+
* Applied end-to-start so earlier offsets stay valid.
|
|
210
|
+
*/
|
|
211
|
+
export function replaceBlockInners(html, replacements) {
|
|
212
|
+
const ordered = [...replacements].sort((a, b) => b.block.innerStart - a.block.innerStart);
|
|
213
|
+
let result = html;
|
|
214
|
+
for (const { block, inner } of ordered) {
|
|
215
|
+
result = result.slice(0, block.innerStart) + inner + result.slice(block.innerEnd);
|
|
216
|
+
}
|
|
217
|
+
return result;
|
|
218
|
+
}
|
|
219
|
+
//# sourceMappingURL=BlockSplitter.js.map
|