@lvce-editor/preview-worker 1.5.0 → 1.7.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/README.md CHANGED
@@ -1,3 +1,12 @@
1
1
  # Preview Worker
2
2
 
3
3
  WebWorker for the Preview functionality in Lvce Editor.
4
+
5
+ ## Contributing
6
+
7
+ ```sh
8
+ git clone git@github.com:lvce-editor/preview-worker.git &&
9
+ cd preview-worker &&
10
+ npm ci &&
11
+ npm test
12
+ ```
@@ -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,6 +1846,27 @@ 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']);
1829
1870
  const isDefaultAllowedAttribute = (attributeName, defaultAllowedAttributes) => {
1830
1871
  // Allow data-* attributes
1831
1872
  if (attributeName.startsWith('data-')) {
@@ -1835,8 +1876,8 @@ const isDefaultAllowedAttribute = (attributeName, defaultAllowedAttributes) => {
1835
1876
  if (attributeName.startsWith('aria-')) {
1836
1877
  return true;
1837
1878
  }
1838
- // Allow role attribute
1839
- if (attributeName === 'role') {
1879
+ // Check if it's a common HTML attribute
1880
+ if (commonAllowedAttributes.has(attributeName)) {
1840
1881
  return true;
1841
1882
  }
1842
1883
  // Check if in default list
@@ -1844,11 +1885,21 @@ const isDefaultAllowedAttribute = (attributeName, defaultAllowedAttributes) => {
1844
1885
  };
1845
1886
 
1846
1887
  const isSelfClosingTag = tag => {
1847
- switch (tag) {
1888
+ switch (tag.toLowerCase()) {
1889
+ case 'area':
1890
+ case 'base':
1891
+ case 'col':
1848
1892
  case Br:
1849
1893
  case Hr:
1850
1894
  case Img:
1851
1895
  case Input:
1896
+ case 'embed':
1897
+ case 'link':
1898
+ case 'meta':
1899
+ case 'param':
1900
+ case 'source':
1901
+ case 'track':
1902
+ case 'wbr':
1852
1903
  return true;
1853
1904
  default:
1854
1905
  return false;
@@ -1882,7 +1933,7 @@ const State = {
1882
1933
  TopLevelContent: 1
1883
1934
  };
1884
1935
  const RE_ANGLE_BRACKET_OPEN = /^</;
1885
- const RE_ANGLE_BRACKET_OPEN_TAG = /^<(?![\s!%])/;
1936
+ const RE_ANGLE_BRACKET_OPEN_TAG = /^<(?![\s%])/;
1886
1937
  const RE_ANGLE_BRACKET_CLOSE = /^>/;
1887
1938
  const RE_SLASH = /^\//;
1888
1939
  const RE_TAGNAME = /^[a-zA-Z\d$]+/;
@@ -2117,6 +2168,14 @@ const tokenizeHtml = text => {
2117
2168
  return tokens;
2118
2169
  };
2119
2170
 
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']);
2120
2179
  const parseHtml = (html, allowedAttributes = [], defaultAllowedAttributes = []) => {
2121
2180
  string(html);
2122
2181
  array(allowedAttributes);
@@ -2127,65 +2186,125 @@ const parseHtml = (html, allowedAttributes = [], defaultAllowedAttributes = [])
2127
2186
  const useBuiltInDefaults = allowedAttributes.length === 0;
2128
2187
  const tokens = tokenizeHtml(html);
2129
2188
  const dom = [];
2189
+ const css = [];
2130
2190
  const root = {
2131
2191
  childCount: 0,
2132
2192
  type: 0
2133
2193
  };
2134
2194
  let current = root;
2135
2195
  const stack = [root];
2196
+ const tagStack = []; // Track tag names to match closing tags
2136
2197
  let attributeName = '';
2137
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
+
2138
2203
  for (const token of tokens) {
2139
2204
  switch (token.type) {
2140
2205
  case AttributeName:
2141
- attributeName = token.text;
2206
+ if (skipDepth === 0 && !captureCss) {
2207
+ attributeName = token.text;
2208
+ }
2142
2209
  break;
2143
2210
  case AttributeValue:
2144
- if (allAllowedAttributes.has(attributeName) || useBuiltInDefaults && isDefaultAllowedAttribute(attributeName, defaultAllowedAttributes)) {
2211
+ if (skipDepth === 0 && !captureCss && (allAllowedAttributes.has(attributeName) || useBuiltInDefaults && isDefaultAllowedAttribute(attributeName, defaultAllowedAttributes))) {
2145
2212
  const finalAttributeName = attributeName === 'class' ? 'className' : attributeName;
2146
2213
  current[finalAttributeName] = token.text;
2147
2214
  }
2148
2215
  attributeName = '';
2149
2216
  break;
2150
2217
  case ClosingAngleBracket:
2151
- // Handle boolean attributes (attributes without values)
2152
- if (attributeName && (allAllowedAttributes.has(attributeName) || useBuiltInDefaults && isDefaultAllowedAttribute(attributeName, defaultAllowedAttributes))) {
2153
- const finalAttributeName = attributeName === 'class' ? 'className' : attributeName;
2154
- current[finalAttributeName] = attributeName;
2155
- }
2156
- attributeName = '';
2157
- // Return to parent if the current tag is self-closing
2158
- if (lastTagWasSelfClosing) {
2159
- current = stack.at(-1) || root;
2160
- 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
+ }
2161
2230
  }
2162
2231
  break;
2163
2232
  case Content:
2164
- current.childCount++;
2165
- 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
2166
2242
  break;
2167
2243
  case TagNameEnd:
2168
- if (stack.length > 1) {
2169
- 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;
2170
2261
  }
2171
- current = stack.at(-1) || root;
2172
2262
  break;
2173
2263
  case TagNameStart:
2174
- current.childCount++;
2175
- const newNode = {
2176
- childCount: 0,
2177
- type: getVirtualDomTag(token.text)
2178
- };
2179
- dom.push(newNode);
2180
- current = newNode;
2264
+ const tagNameLower = token.text.toLowerCase();
2181
2265
  lastTagWasSelfClosing = isSelfClosingTag(token.text);
2182
- if (!lastTagWasSelfClosing) {
2183
- 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
+ }
2184
2302
  }
2185
2303
  break;
2186
2304
  case WhitespaceInsideOpeningTag:
2305
+ if (skipDepth === 0 && !captureCss &&
2187
2306
  // Handle boolean attributes (attributes without values)
2188
- if (attributeName && (allAllowedAttributes.has(attributeName) || useBuiltInDefaults && isDefaultAllowedAttribute(attributeName, defaultAllowedAttributes))) {
2307
+ attributeName && (allAllowedAttributes.has(attributeName) || useBuiltInDefaults && isDefaultAllowedAttribute(attributeName, defaultAllowedAttributes))) {
2189
2308
  const finalAttributeName = attributeName === 'class' ? 'className' : attributeName;
2190
2309
  current[finalAttributeName] = attributeName;
2191
2310
  }
@@ -2202,7 +2321,10 @@ const parseHtml = (html, allowedAttributes = [], defaultAllowedAttributes = [])
2202
2321
  } catch {
2203
2322
  dom.rootChildCount = root.childCount;
2204
2323
  }
2205
- return dom;
2324
+ return {
2325
+ css,
2326
+ dom
2327
+ };
2206
2328
  };
2207
2329
 
2208
2330
  const handleEditorChanged = async () => {
@@ -2238,12 +2360,13 @@ const handleEditorChanged = async () => {
2238
2360
  if (matchingEditorUid !== null) {
2239
2361
  try {
2240
2362
  const content = await invoke$1('Editor.getText', matchingEditorUid);
2241
- const parsedDom = parseHtml(content, []);
2363
+ const parseResult = parseHtml(content, []);
2242
2364
  const updatedState = {
2243
2365
  ...state,
2244
2366
  content,
2367
+ css: parseResult.css,
2245
2368
  errorMessage: '',
2246
- parsedDom
2369
+ parsedDom: parseResult.dom
2247
2370
  };
2248
2371
  set(previewUid, state, updatedState);
2249
2372
  } catch (error) {
@@ -2252,6 +2375,7 @@ const handleEditorChanged = async () => {
2252
2375
  const updatedState = {
2253
2376
  ...state,
2254
2377
  content: '',
2378
+ css: [],
2255
2379
  errorMessage,
2256
2380
  parsedDom: []
2257
2381
  };
@@ -2293,11 +2417,16 @@ const updateContent = async (state, uri) => {
2293
2417
  // @ts-ignore
2294
2418
  const content = await readFile(uri);
2295
2419
 
2296
- // Parse the content into virtual DOM
2297
- 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;
2298
2426
  const parsedNodesChildNodeCount = getParsedNodesChildNodeCount(parsedDom);
2299
2427
  return {
2300
2428
  content,
2429
+ css,
2301
2430
  errorMessage: '',
2302
2431
  parsedDom,
2303
2432
  parsedNodesChildNodeCount
@@ -2307,6 +2436,7 @@ const updateContent = async (state, uri) => {
2307
2436
  const errorMessage = error instanceof Error ? error.message : 'Unknown error';
2308
2437
  return {
2309
2438
  content: '',
2439
+ css: [],
2310
2440
  errorMessage,
2311
2441
  parsedDom: [],
2312
2442
  parsedNodesChildNodeCount: 0
@@ -2317,6 +2447,7 @@ const updateContent = async (state, uri) => {
2317
2447
  const handleFileEdited = async state => {
2318
2448
  const {
2319
2449
  content,
2450
+ css,
2320
2451
  errorMessage,
2321
2452
  parsedDom,
2322
2453
  parsedNodesChildNodeCount
@@ -2324,6 +2455,7 @@ const handleFileEdited = async state => {
2324
2455
  return {
2325
2456
  ...state,
2326
2457
  content,
2458
+ css,
2327
2459
  errorMessage,
2328
2460
  parsedDom,
2329
2461
  parsedNodesChildNodeCount
@@ -2345,11 +2477,13 @@ const loadContent = async state => {
2345
2477
  // Read and parse file contents if we have a URI
2346
2478
  const {
2347
2479
  content,
2480
+ css,
2348
2481
  errorMessage,
2349
2482
  parsedDom,
2350
2483
  parsedNodesChildNodeCount
2351
2484
  } = state.uri ? await updateContent(state, state.uri) : {
2352
2485
  content: state.content,
2486
+ css: state.css,
2353
2487
  errorMessage: state.errorMessage,
2354
2488
  parsedDom: state.parsedDom,
2355
2489
  parsedNodesChildNodeCount: state.parsedNodesChildNodeCount
@@ -2357,6 +2491,7 @@ const loadContent = async state => {
2357
2491
  return {
2358
2492
  ...state,
2359
2493
  content,
2494
+ css,
2360
2495
  errorCount: 0,
2361
2496
  errorMessage,
2362
2497
  initial: false,
@@ -2366,6 +2501,51 @@ const loadContent = async state => {
2366
2501
  };
2367
2502
  };
2368
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
+
2369
2549
  const getEmptyPreviewDom = () => {
2370
2550
  return [{
2371
2551
  childCount: 1,
@@ -2432,6 +2612,8 @@ const renderIncremental = (oldState, newState) => {
2432
2612
 
2433
2613
  const getRenderer = diffType => {
2434
2614
  switch (diffType) {
2615
+ case RenderCss:
2616
+ return renderCss;
2435
2617
  case RenderIncremental:
2436
2618
  return renderIncremental;
2437
2619
  case RenderItems:
@@ -2500,6 +2682,7 @@ const saveState = state => {
2500
2682
  const setUri = async (state, uri) => {
2501
2683
  const {
2502
2684
  content,
2685
+ css,
2503
2686
  errorMessage,
2504
2687
  parsedDom,
2505
2688
  parsedNodesChildNodeCount
@@ -2507,6 +2690,7 @@ const setUri = async (state, uri) => {
2507
2690
  return {
2508
2691
  ...state,
2509
2692
  content,
2693
+ css,
2510
2694
  errorMessage,
2511
2695
  parsedDom,
2512
2696
  parsedNodesChildNodeCount,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lvce-editor/preview-worker",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "description": "Preview Worker",
5
5
  "repository": {
6
6
  "type": "git",