@lvce-editor/preview-worker 1.4.0 → 1.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.
@@ -1127,6 +1127,8 @@ const Code$1 = 65;
1127
1127
  const Label$1 = 66;
1128
1128
  const Dt$1 = 67;
1129
1129
  const Iframe$1 = 68;
1130
+ const Style = 72;
1131
+ const Html = 73;
1130
1132
  const Reference = 100;
1131
1133
 
1132
1134
  const TargetName = 'event.target.name';
@@ -1306,6 +1308,7 @@ const create = (uid, uri, x, y, width, height, platform, assetDir) => {
1306
1308
  const state = {
1307
1309
  assetDir,
1308
1310
  content: '',
1311
+ css: [],
1309
1312
  errorCount: 0,
1310
1313
  errorMessage: '',
1311
1314
  initial: true,
@@ -1319,15 +1322,28 @@ const create = (uid, uri, x, y, width, height, platform, assetDir) => {
1319
1322
  set(uid, state, state);
1320
1323
  };
1321
1324
 
1325
+ const iEqual = (oldState, newState) => {
1326
+ if (oldState.css.length !== newState.css.length) {
1327
+ return false;
1328
+ }
1329
+ for (let i = 0; i < oldState.css.length; i++) {
1330
+ if (oldState.css[i] !== newState.css[i]) {
1331
+ return false;
1332
+ }
1333
+ }
1334
+ return true;
1335
+ };
1336
+
1322
1337
  const isEqual = (oldState, newState) => {
1323
- return oldState.warningCount === newState.warningCount && oldState.initial === newState.initial && oldState.content === newState.content && oldState.parsedDom === newState.parsedDom && oldState.parsedNodesChildNodeCount === newState.parsedNodesChildNodeCount;
1338
+ return oldState.warningCount === newState.warningCount && oldState.initial === newState.initial && oldState.content === newState.content && oldState.parsedDom === newState.parsedDom && oldState.parsedNodesChildNodeCount === newState.parsedNodesChildNodeCount && oldState.css === newState.css;
1324
1339
  };
1325
1340
 
1326
1341
  const RenderItems = 4;
1342
+ const RenderCss = 10;
1327
1343
  const RenderIncremental = 11;
1328
1344
 
1329
- const modules = [isEqual];
1330
- const numbers = [RenderIncremental];
1345
+ const modules = [isEqual, iEqual];
1346
+ const numbers = [RenderIncremental, RenderCss];
1331
1347
 
1332
1348
  const diff = (oldState, newState) => {
1333
1349
  const diffResult = [];
@@ -1798,6 +1814,10 @@ const getVirtualDomTag = text => {
1798
1814
  return Tr$1;
1799
1815
  case Ul:
1800
1816
  return Ul$1;
1817
+ case 'html':
1818
+ return Html;
1819
+ case 'style':
1820
+ return Style;
1801
1821
  default:
1802
1822
  return Div$1;
1803
1823
  }
@@ -1826,12 +1846,60 @@ const EndCommentTag = 19;
1826
1846
  const Text = 20;
1827
1847
  const CommentStart = 21;
1828
1848
 
1849
+ /* eslint-disable @cspell/spellchecker */
1850
+ // Common HTML attributes that are safe to allow by default
1851
+ const commonAllowedAttributes = new Set([
1852
+ // Global attributes
1853
+ 'id', 'title', 'tabindex', 'class', 'style', 'lang', 'dir', 'hidden', 'contenteditable', 'draggable', 'spellcheck', 'translate', 'role',
1854
+ // Form input attributes
1855
+ 'disabled', 'name', 'type', 'value', 'placeholder', 'required', 'readonly', 'checked', 'autofocus', 'autocomplete', 'multiple', 'accept', 'min', 'max', 'step', 'pattern', 'maxlength', 'minlength', 'size', 'rows', 'cols', 'wrap', 'inputmode',
1856
+ // Form attributes
1857
+ 'action', 'method', 'enctype', 'target', 'novalidate', 'form',
1858
+ // Link attributes
1859
+ 'href', 'rel', 'download', 'hreflang',
1860
+ // Image attributes
1861
+ 'src', 'alt', 'width', 'height', 'loading', 'decoding', 'crossorigin', 'srcset', 'sizes',
1862
+ // Media attributes
1863
+ 'controls', 'autoplay', 'loop', 'muted', 'preload', 'poster',
1864
+ // Table attributes
1865
+ 'colspan', 'rowspan', 'headers', 'scope',
1866
+ // List attributes
1867
+ 'reversed', 'start',
1868
+ // Other semantic attributes
1869
+ 'open', 'datetime', 'cite', 'for', 'label']);
1870
+ const isDefaultAllowedAttribute = (attributeName, defaultAllowedAttributes) => {
1871
+ // Allow data-* attributes
1872
+ if (attributeName.startsWith('data-')) {
1873
+ return true;
1874
+ }
1875
+ // Allow aria-* attributes
1876
+ if (attributeName.startsWith('aria-')) {
1877
+ return true;
1878
+ }
1879
+ // Check if it's a common HTML attribute
1880
+ if (commonAllowedAttributes.has(attributeName)) {
1881
+ return true;
1882
+ }
1883
+ // Check if in default list
1884
+ return defaultAllowedAttributes.includes(attributeName);
1885
+ };
1886
+
1829
1887
  const isSelfClosingTag = tag => {
1830
- switch (tag) {
1888
+ switch (tag.toLowerCase()) {
1889
+ case 'area':
1890
+ case 'base':
1891
+ case 'col':
1831
1892
  case Br:
1832
1893
  case Hr:
1833
1894
  case Img:
1834
1895
  case Input:
1896
+ case 'embed':
1897
+ case 'link':
1898
+ case 'meta':
1899
+ case 'param':
1900
+ case 'source':
1901
+ case 'track':
1902
+ case 'wbr':
1835
1903
  return true;
1836
1904
  default:
1837
1905
  return false;
@@ -1865,7 +1933,7 @@ const State = {
1865
1933
  TopLevelContent: 1
1866
1934
  };
1867
1935
  const RE_ANGLE_BRACKET_OPEN = /^</;
1868
- const RE_ANGLE_BRACKET_OPEN_TAG = /^<(?![\s!%])/;
1936
+ const RE_ANGLE_BRACKET_OPEN_TAG = /^<(?![\s%])/;
1869
1937
  const RE_ANGLE_BRACKET_CLOSE = /^>/;
1870
1938
  const RE_SLASH = /^\//;
1871
1939
  const RE_TAGNAME = /^[a-zA-Z\d$]+/;
@@ -2100,70 +2168,143 @@ const tokenizeHtml = text => {
2100
2168
  return tokens;
2101
2169
  };
2102
2170
 
2103
- const parseHtml = (html, allowedAttributes) => {
2171
+ // Tags that should be completely skipped (both tag and content)
2172
+ const TAGS_TO_SKIP_COMPLETELY = new Set(['meta', 'title']);
2173
+
2174
+ // Tags that should have their opening/closing tags skipped but content processed
2175
+ const TAGS_TO_SKIP_TAG_ONLY = new Set(['html', 'head']);
2176
+
2177
+ // Tags where we capture content as CSS
2178
+ const TAGS_TO_CAPTURE_AS_CSS = new Set(['style']);
2179
+ const parseHtml = (html, allowedAttributes = [], defaultAllowedAttributes = []) => {
2104
2180
  string(html);
2105
2181
  array(allowedAttributes);
2182
+ array(defaultAllowedAttributes);
2183
+
2184
+ // Combine default allowed attributes with any additional ones provided
2185
+ const allAllowedAttributes = new Set([...defaultAllowedAttributes, ...allowedAttributes]);
2186
+ const useBuiltInDefaults = allowedAttributes.length === 0;
2106
2187
  const tokens = tokenizeHtml(html);
2107
2188
  const dom = [];
2189
+ const css = [];
2108
2190
  const root = {
2109
2191
  childCount: 0,
2110
2192
  type: 0
2111
2193
  };
2112
2194
  let current = root;
2113
2195
  const stack = [root];
2196
+ const tagStack = []; // Track tag names to match closing tags
2114
2197
  let attributeName = '';
2115
2198
  let lastTagWasSelfClosing = false;
2199
+ let skipDepth = 0; // Track how many levels deep we are in skipped content
2200
+ let captureCss = false; // Track if we're inside a style tag
2201
+ let cssContent = ''; // Accumulate CSS content
2202
+
2116
2203
  for (const token of tokens) {
2117
2204
  switch (token.type) {
2118
2205
  case AttributeName:
2119
- attributeName = token.text;
2206
+ if (skipDepth === 0 && !captureCss) {
2207
+ attributeName = token.text;
2208
+ }
2120
2209
  break;
2121
2210
  case AttributeValue:
2122
- if (allowedAttributes.includes(attributeName)) {
2211
+ if (skipDepth === 0 && !captureCss && (allAllowedAttributes.has(attributeName) || useBuiltInDefaults && isDefaultAllowedAttribute(attributeName, defaultAllowedAttributes))) {
2123
2212
  const finalAttributeName = attributeName === 'class' ? 'className' : attributeName;
2124
2213
  current[finalAttributeName] = token.text;
2125
2214
  }
2126
2215
  attributeName = '';
2127
2216
  break;
2128
2217
  case ClosingAngleBracket:
2129
- // Handle boolean attributes (attributes without values)
2130
- if (attributeName && allowedAttributes.includes(attributeName)) {
2131
- const finalAttributeName = attributeName === 'class' ? 'className' : attributeName;
2132
- current[finalAttributeName] = attributeName;
2133
- }
2134
- attributeName = '';
2135
- // Return to parent if the current tag is self-closing
2136
- if (lastTagWasSelfClosing) {
2137
- current = stack.at(-1) || root;
2138
- lastTagWasSelfClosing = false;
2218
+ if (skipDepth === 0 && !captureCss) {
2219
+ // Handle boolean attributes (attributes without values)
2220
+ if (attributeName && (allAllowedAttributes.has(attributeName) || useBuiltInDefaults && isDefaultAllowedAttribute(attributeName, defaultAllowedAttributes))) {
2221
+ const finalAttributeName = attributeName === 'class' ? 'className' : attributeName;
2222
+ current[finalAttributeName] = attributeName;
2223
+ }
2224
+ attributeName = '';
2225
+ // Return to parent if the current tag is self-closing
2226
+ if (lastTagWasSelfClosing) {
2227
+ current = stack.at(-1) || root;
2228
+ lastTagWasSelfClosing = false;
2229
+ }
2139
2230
  }
2140
2231
  break;
2141
2232
  case Content:
2142
- current.childCount++;
2143
- dom.push(text(parseText(token.text)));
2233
+ if (captureCss) {
2234
+ cssContent += token.text;
2235
+ } else if (skipDepth === 0) {
2236
+ current.childCount++;
2237
+ dom.push(text(parseText(token.text)));
2238
+ }
2239
+ break;
2240
+ case Doctype:
2241
+ // Ignore DOCTYPE - it's parsed but not rendered since we're in a div
2144
2242
  break;
2145
2243
  case TagNameEnd:
2146
- if (stack.length > 1) {
2147
- stack.pop();
2244
+ const tagNameToClose = tagStack.pop()?.toLowerCase() || '';
2245
+ if (TAGS_TO_CAPTURE_AS_CSS.has(tagNameToClose)) {
2246
+ // Finished capturing CSS
2247
+ if (cssContent.trim()) {
2248
+ css.push(cssContent);
2249
+ }
2250
+ cssContent = '';
2251
+ captureCss = false;
2252
+ } else if (TAGS_TO_SKIP_COMPLETELY.has(tagNameToClose)) {
2253
+ // We were skipping this content, so decrement skipDepth
2254
+ skipDepth--;
2255
+ } else if (TAGS_TO_SKIP_TAG_ONLY.has(tagNameToClose)) ; else {
2256
+ // Normal tag - pop from stack
2257
+ if (stack.length > 1) {
2258
+ stack.pop();
2259
+ }
2260
+ current = stack.at(-1) || root;
2148
2261
  }
2149
- current = stack.at(-1) || root;
2150
2262
  break;
2151
2263
  case TagNameStart:
2152
- current.childCount++;
2153
- const newNode = {
2154
- childCount: 0,
2155
- type: getVirtualDomTag(token.text)
2156
- };
2157
- dom.push(newNode);
2158
- current = newNode;
2264
+ const tagNameLower = token.text.toLowerCase();
2159
2265
  lastTagWasSelfClosing = isSelfClosingTag(token.text);
2160
- if (!lastTagWasSelfClosing) {
2161
- stack.push(current);
2266
+
2267
+ // Check if this tag captures CSS content
2268
+ if (TAGS_TO_CAPTURE_AS_CSS.has(tagNameLower)) {
2269
+ captureCss = true;
2270
+ cssContent = '';
2271
+ tagStack.push(token.text);
2272
+ }
2273
+ // Check if this tag should be completely skipped (meta, title)
2274
+ else if (TAGS_TO_SKIP_COMPLETELY.has(tagNameLower)) {
2275
+ if (!lastTagWasSelfClosing) {
2276
+ // For non-self-closing tags like title, mark as skipped
2277
+ skipDepth++;
2278
+ tagStack.push(token.text);
2279
+ }
2280
+ // For self-closing tags like meta, we just skip them without tracking
2281
+ }
2282
+ // Check if this tag should have its opening/closing tags skipped (html, head)
2283
+ else if (TAGS_TO_SKIP_TAG_ONLY.has(tagNameLower)) {
2284
+ if (!lastTagWasSelfClosing) {
2285
+ // Track the tag name for matching the closing tag
2286
+ tagStack.push(token.text);
2287
+ }
2288
+ }
2289
+ // Normal tag processing
2290
+ else if (skipDepth === 0) {
2291
+ current.childCount++;
2292
+ const newNode = {
2293
+ childCount: 0,
2294
+ type: getVirtualDomTag(token.text)
2295
+ };
2296
+ dom.push(newNode);
2297
+ current = newNode;
2298
+ if (!lastTagWasSelfClosing) {
2299
+ stack.push(current);
2300
+ tagStack.push(token.text);
2301
+ }
2162
2302
  }
2163
2303
  break;
2164
2304
  case WhitespaceInsideOpeningTag:
2305
+ if (skipDepth === 0 && !captureCss &&
2165
2306
  // Handle boolean attributes (attributes without values)
2166
- if (attributeName && allowedAttributes.includes(attributeName)) {
2307
+ attributeName && (allAllowedAttributes.has(attributeName) || useBuiltInDefaults && isDefaultAllowedAttribute(attributeName, defaultAllowedAttributes))) {
2167
2308
  const finalAttributeName = attributeName === 'class' ? 'className' : attributeName;
2168
2309
  current[finalAttributeName] = attributeName;
2169
2310
  }
@@ -2180,7 +2321,10 @@ const parseHtml = (html, allowedAttributes) => {
2180
2321
  } catch {
2181
2322
  dom.rootChildCount = root.childCount;
2182
2323
  }
2183
- return dom;
2324
+ return {
2325
+ css,
2326
+ dom
2327
+ };
2184
2328
  };
2185
2329
 
2186
2330
  const handleEditorChanged = async () => {
@@ -2216,12 +2360,13 @@ const handleEditorChanged = async () => {
2216
2360
  if (matchingEditorUid !== null) {
2217
2361
  try {
2218
2362
  const content = await invoke$1('Editor.getText', matchingEditorUid);
2219
- const parsedDom = parseHtml(content, []);
2363
+ const parseResult = parseHtml(content, []);
2220
2364
  const updatedState = {
2221
2365
  ...state,
2222
2366
  content,
2367
+ css: parseResult.css,
2223
2368
  errorMessage: '',
2224
- parsedDom
2369
+ parsedDom: parseResult.dom
2225
2370
  };
2226
2371
  set(previewUid, state, updatedState);
2227
2372
  } catch (error) {
@@ -2230,6 +2375,7 @@ const handleEditorChanged = async () => {
2230
2375
  const updatedState = {
2231
2376
  ...state,
2232
2377
  content: '',
2378
+ css: [],
2233
2379
  errorMessage,
2234
2380
  parsedDom: []
2235
2381
  };
@@ -2271,11 +2417,16 @@ const updateContent = async (state, uri) => {
2271
2417
  // @ts-ignore
2272
2418
  const content = await readFile(uri);
2273
2419
 
2274
- // Parse the content into virtual DOM
2275
- const parsedDom = parseHtml(content, []);
2420
+ // Parse the content into virtual DOM and CSS
2421
+ const parseResult = parseHtml(content);
2422
+ const parsedDom = parseResult.dom;
2423
+ const {
2424
+ css
2425
+ } = parseResult;
2276
2426
  const parsedNodesChildNodeCount = getParsedNodesChildNodeCount(parsedDom);
2277
2427
  return {
2278
2428
  content,
2429
+ css,
2279
2430
  errorMessage: '',
2280
2431
  parsedDom,
2281
2432
  parsedNodesChildNodeCount
@@ -2285,6 +2436,7 @@ const updateContent = async (state, uri) => {
2285
2436
  const errorMessage = error instanceof Error ? error.message : 'Unknown error';
2286
2437
  return {
2287
2438
  content: '',
2439
+ css: [],
2288
2440
  errorMessage,
2289
2441
  parsedDom: [],
2290
2442
  parsedNodesChildNodeCount: 0
@@ -2295,6 +2447,7 @@ const updateContent = async (state, uri) => {
2295
2447
  const handleFileEdited = async state => {
2296
2448
  const {
2297
2449
  content,
2450
+ css,
2298
2451
  errorMessage,
2299
2452
  parsedDom,
2300
2453
  parsedNodesChildNodeCount
@@ -2302,6 +2455,7 @@ const handleFileEdited = async state => {
2302
2455
  return {
2303
2456
  ...state,
2304
2457
  content,
2458
+ css,
2305
2459
  errorMessage,
2306
2460
  parsedDom,
2307
2461
  parsedNodesChildNodeCount
@@ -2323,11 +2477,13 @@ const loadContent = async state => {
2323
2477
  // Read and parse file contents if we have a URI
2324
2478
  const {
2325
2479
  content,
2480
+ css,
2326
2481
  errorMessage,
2327
2482
  parsedDom,
2328
2483
  parsedNodesChildNodeCount
2329
2484
  } = state.uri ? await updateContent(state, state.uri) : {
2330
2485
  content: state.content,
2486
+ css: state.css,
2331
2487
  errorMessage: state.errorMessage,
2332
2488
  parsedDom: state.parsedDom,
2333
2489
  parsedNodesChildNodeCount: state.parsedNodesChildNodeCount
@@ -2335,6 +2491,7 @@ const loadContent = async state => {
2335
2491
  return {
2336
2492
  ...state,
2337
2493
  content,
2494
+ css,
2338
2495
  errorCount: 0,
2339
2496
  errorMessage,
2340
2497
  initial: false,
@@ -2344,6 +2501,51 @@ const loadContent = async state => {
2344
2501
  };
2345
2502
  };
2346
2503
 
2504
+ const BODY_SELECTOR_REGEX = /\bbody\b/g;
2505
+ const HTML_SELECTOR_REGEX = /\bhtml\b/g;
2506
+
2507
+ /**
2508
+ * Wraps CSS in a CSS nesting block (.Preview { ... }) and replaces 'html' and 'body'
2509
+ * selectors with '&' (the parent selector in CSS nesting).
2510
+ * This approach uses CSS nesting to automatically scope all selectors to the preview div.
2511
+ * Other selectors like 'button' or '*' are automatically scoped within the nesting.
2512
+ *
2513
+ * @param css The CSS string to process
2514
+ * @returns The CSS string wrapped in .Preview nesting block with proper selector replacements
2515
+ */
2516
+ const replaceCssBodySelector = css => {
2517
+ if (!css.trim()) {
2518
+ return css;
2519
+ }
2520
+
2521
+ // Replace 'html' selector with '&' (CSS nesting parent selector)
2522
+ let result = css.replaceAll(HTML_SELECTOR_REGEX, '&');
2523
+
2524
+ // Replace 'body' selector with '&' (CSS nesting parent selector)
2525
+ result = result.replaceAll(BODY_SELECTOR_REGEX, '&');
2526
+
2527
+ // Wrap the entire CSS in .Preview nesting block
2528
+ result = `.Preview {\n${result}\n}`;
2529
+ return result;
2530
+ };
2531
+
2532
+ const renderCss = (oldState, newState) => {
2533
+ const {
2534
+ css,
2535
+ uid
2536
+ } = newState;
2537
+
2538
+ // Combine all CSS strings into a single string
2539
+ let cssString = css.join('\n');
2540
+
2541
+ // Replace body selector with .Preview since we render the preview in a div element, not a body
2542
+ cssString = replaceCssBodySelector(cssString);
2543
+
2544
+ // Return command in format that can be handled by the viewlet
2545
+ // The 'Viewlet.setCss' is a method that should be called on the viewlet
2546
+ return ['Viewlet.setCss', uid, cssString];
2547
+ };
2548
+
2347
2549
  const getEmptyPreviewDom = () => {
2348
2550
  return [{
2349
2551
  childCount: 1,
@@ -2410,6 +2612,8 @@ const renderIncremental = (oldState, newState) => {
2410
2612
 
2411
2613
  const getRenderer = diffType => {
2412
2614
  switch (diffType) {
2615
+ case RenderCss:
2616
+ return renderCss;
2413
2617
  case RenderIncremental:
2414
2618
  return renderIncremental;
2415
2619
  case RenderItems:
@@ -2478,6 +2682,7 @@ const saveState = state => {
2478
2682
  const setUri = async (state, uri) => {
2479
2683
  const {
2480
2684
  content,
2685
+ css,
2481
2686
  errorMessage,
2482
2687
  parsedDom,
2483
2688
  parsedNodesChildNodeCount
@@ -2485,6 +2690,7 @@ const setUri = async (state, uri) => {
2485
2690
  return {
2486
2691
  ...state,
2487
2692
  content,
2693
+ css,
2488
2694
  errorMessage,
2489
2695
  parsedDom,
2490
2696
  parsedNodesChildNodeCount,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lvce-editor/preview-worker",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "Preview Worker",
5
5
  "repository": {
6
6
  "type": "git",