@hyperframes/lint 0.7.52 → 0.7.54

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/index.js CHANGED
@@ -1,7 +1,5 @@
1
1
  // src/utils.ts
2
- var TAG_PATTERN = /<([a-z][\w:-]*)(\s[^<>]*?)?>/gi;
3
- var STYLE_BLOCK_PATTERN = /<style\b([^>]*)>([\s\S]*?)<\/style>/gi;
4
- var SCRIPT_BLOCK_PATTERN = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
2
+ import { Parser } from "htmlparser2";
5
3
  var COMPOSITION_ID_IN_CSS_PATTERN = /\[data-composition-id=["']([^"']+)["']\]/g;
6
4
  var TIMELINE_REGISTRY_INIT_PATTERN = /window\.__timelines\s*=\s*window\.__timelines\s*\|\|\s*\{\}|window\.__timelines\s*=\s*\{\}|window\.__timelines\s*\?\?=\s*\{\}/i;
7
5
  var TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN = /window\.__timelines\s*=\s*\{\s*(?:["'][^"']+["']|[A-Za-z_$][\w$]*)\s*:/i;
@@ -11,71 +9,69 @@ var INVALID_SCRIPT_CLOSE_PATTERN = /<script[^>]*>[\s\S]*?<\s*\/\s*script(?!>)/i;
11
9
  var TIMELINE_REGISTRY_KEY_PATTERN = /window\.__timelines(?:\[\s*["']([^"']+)["']\s*\]|\.\s*([A-Za-z_$][\w$]*))\s*=/g;
12
10
  var TIMELINE_REGISTRY_OBJECT_BODY_PATTERN = /window\.__timelines\s*=\s*\{([\s\S]*?)\}/i;
13
11
  var TIMELINE_REGISTRY_OBJECT_ENTRY_PATTERN = /(?:["']([^"']+)["']|([A-Za-z_$][\w$]*))\s*:\s*[A-Za-z_$][\w$]*/g;
14
- function extractOpenTags(source) {
12
+ function parseHtmlStructure(source) {
15
13
  const tags = [];
16
- let match;
17
- const pattern = new RegExp(TAG_PATTERN.source, TAG_PATTERN.flags);
18
- while ((match = pattern.exec(source)) !== null) {
19
- const raw = match[0];
20
- if (raw.startsWith("</") || raw.startsWith("<!")) continue;
21
- tags.push({
22
- raw,
23
- name: (match[1] || "").toLowerCase(),
24
- attrs: match[2] || "",
25
- index: match.index
26
- });
27
- }
28
- return tags;
29
- }
30
- function extractBlocks(source, pattern) {
31
- const blocks = [];
32
- let match;
33
- const p = new RegExp(pattern.source, pattern.flags);
34
- while ((match = p.exec(source)) !== null) {
35
- blocks.push({
36
- attrs: match[1] || "",
37
- content: match[2] || "",
38
- raw: match[0],
39
- index: match.index
40
- });
41
- }
42
- return blocks;
14
+ const blocks = { script: [], style: [] };
15
+ const openTagsByName = /* @__PURE__ */ new Map();
16
+ const openBlocks = [];
17
+ const parser = new Parser(
18
+ {
19
+ onopentag(name) {
20
+ const index = parser.startIndex;
21
+ const raw = source.slice(index, parser.endIndex + 1);
22
+ const attrs = raw.slice(name.length + 1, -1).replace(/\s*\/$/, "");
23
+ const tag = { raw, name, attrs, index };
24
+ tags.push(tag);
25
+ const sameNameStack = openTagsByName.get(name) ?? [];
26
+ sameNameStack.push(tag);
27
+ openTagsByName.set(name, sameNameStack);
28
+ if (name === "script" || name === "style") {
29
+ openBlocks.push({ name, attrs, contentStart: parser.endIndex + 1, index });
30
+ }
31
+ },
32
+ onclosetag(name) {
33
+ const tag = openTagsByName.get(name)?.pop();
34
+ if (tag) {
35
+ tag.closeIndex = parser.startIndex;
36
+ tag.endIndex = parser.endIndex + 1;
37
+ }
38
+ if (name !== "script" && name !== "style") return;
39
+ const block = openBlocks.pop();
40
+ if (!block || block.name !== name) return;
41
+ blocks[name].push({
42
+ attrs: block.attrs,
43
+ content: source.slice(block.contentStart, parser.startIndex),
44
+ raw: source.slice(block.index, parser.endIndex + 1),
45
+ index: block.index
46
+ });
47
+ }
48
+ },
49
+ { decodeEntities: false, lowerCaseAttributeNames: false, lowerCaseTags: true }
50
+ );
51
+ parser.end(source);
52
+ return { tags, scripts: blocks.script, styles: blocks.style };
43
53
  }
44
- function findHtmlTag(source) {
45
- const match = /<html\b([^<>]*)>/i.exec(source);
46
- if (!match) return null;
47
- return {
48
- raw: match[0],
49
- name: "html",
50
- attrs: match[1] ?? "",
51
- index: match.index
52
- };
54
+ function findHtmlTag(tags) {
55
+ return tags.find((tag) => tag.name === "html") ?? null;
53
56
  }
54
- function findRootTag(source) {
55
- const bodyOpenMatch = /<body\b([^>]*)>/i.exec(source);
56
- const bodyCloseMatch = /<\/body>/i.exec(source);
57
- if (bodyOpenMatch && (readAttr(bodyOpenMatch[0], "data-composition-id") || readAttr(bodyOpenMatch[0], "data-width") || readAttr(bodyOpenMatch[0], "data-height"))) {
58
- return {
59
- raw: bodyOpenMatch[0],
60
- name: "body",
61
- attrs: bodyOpenMatch[1] ?? "",
62
- index: bodyOpenMatch.index
63
- };
57
+ function findRootTag(source, parsedTags) {
58
+ const tags = parsedTags ?? parseHtmlStructure(source).tags;
59
+ const bodyTag = tags.find((tag) => tag.name === "body");
60
+ if (bodyTag && (readAttr(bodyTag.raw, "data-composition-id") || readAttr(bodyTag.raw, "data-width") || readAttr(bodyTag.raw, "data-height"))) {
61
+ return bodyTag;
64
62
  }
65
- const bodyStart = bodyOpenMatch ? bodyOpenMatch.index + bodyOpenMatch[0].length : 0;
66
- const bodyEnd = bodyOpenMatch && bodyCloseMatch && bodyCloseMatch.index > bodyStart ? bodyCloseMatch.index : source.length;
67
- const bodyContent = bodyOpenMatch ? source.slice(bodyStart, bodyEnd) : source;
68
- const bodyTags = extractOpenTags(bodyContent);
63
+ const bodyStart = bodyTag ? bodyTag.index + bodyTag.raw.length : 0;
64
+ const bodyEnd = bodyTag?.closeIndex ?? source.length;
65
+ const bodyTags = tags.filter((tag) => tag.index >= bodyStart && tag.index < bodyEnd);
69
66
  let skipBefore = -1;
70
67
  for (const tag of bodyTags) {
71
68
  if (tag.index < skipBefore) continue;
72
69
  if (["script", "style", "meta", "link", "title"].includes(tag.name)) continue;
73
70
  if (tag.name === "svg" && !readAttr(tag.raw, "data-composition-id") && !readAttr(tag.raw, "data-width") && !readAttr(tag.raw, "data-height")) {
74
- const closeMatch = /<\/svg\s*>/i.exec(bodyContent.slice(tag.index));
75
- skipBefore = closeMatch ? tag.index + closeMatch.index + closeMatch[0].length : Infinity;
71
+ skipBefore = tag.endIndex ?? Infinity;
76
72
  continue;
77
73
  }
78
- return { ...tag, index: tag.index + bodyStart };
74
+ return tag;
79
75
  }
80
76
  return null;
81
77
  }
@@ -246,15 +242,24 @@ function truncateSnippet(value, maxLength = 220) {
246
242
  function buildLintContext(html, options = {}) {
247
243
  const rawSource = html || "";
248
244
  let source = stripHtmlComments(rawSource);
249
- const sourceWithoutTemplates = source.replace(
250
- /<template\b[^>]*>[\s\S]*?<\/template(?:\s[^>]*)?>/gi,
251
- " "
245
+ const initialStructure = parseHtmlStructure(source);
246
+ const templateTags = initialStructure.tags.filter(
247
+ (tag) => tag.name === "template" && tag.closeIndex != null
252
248
  );
253
- const templateMatch = source.match(/<template[^>]*>([\s\S]*)<\/template>/i);
254
- if (templateMatch?.[1] && !findRootTag(sourceWithoutTemplates)) source = templateMatch[1];
255
- const tags = extractOpenTags(source);
249
+ let sourceWithoutTemplates = source;
250
+ for (const template2 of [...templateTags].reverse()) {
251
+ const end = template2.endIndex ?? template2.index;
252
+ sourceWithoutTemplates = sourceWithoutTemplates.slice(0, template2.index) + " ".repeat(end - template2.index) + sourceWithoutTemplates.slice(end);
253
+ }
254
+ const template = templateTags[0];
255
+ let structure = initialStructure;
256
+ if (template && !findRootTag(sourceWithoutTemplates)) {
257
+ source = source.slice(template.index + template.raw.length, template.closeIndex);
258
+ structure = parseHtmlStructure(source);
259
+ }
260
+ const tags = structure.tags;
256
261
  const styles = [
257
- ...extractBlocks(source, STYLE_BLOCK_PATTERN),
262
+ ...structure.styles,
258
263
  ...(options.externalStyles ?? []).map((style) => ({
259
264
  attrs: `href="${style.href}"`,
260
265
  content: style.content,
@@ -262,9 +267,9 @@ function buildLintContext(html, options = {}) {
262
267
  index: -1
263
268
  }))
264
269
  ];
265
- const scripts = extractBlocks(source, SCRIPT_BLOCK_PATTERN);
270
+ const scripts = structure.scripts;
266
271
  const compositionIds = collectCompositionIds(tags);
267
- const rootTag = findRootTag(source);
272
+ const rootTag = findRootTag(source, tags);
268
273
  const rootCompositionId = readAttr(rootTag?.raw || "", "data-composition-id");
269
274
  return {
270
275
  source,
@@ -403,6 +408,23 @@ function findVisibleMarkupCommentLeak(source) {
403
408
  return null;
404
409
  }
405
410
  var coreRules = [
411
+ // id_requires_css_escape
412
+ ({ tags }) => {
413
+ const findings = [];
414
+ for (const tag of tags) {
415
+ const id = readAttr(tag.raw, "id");
416
+ if (!id || !/^\d/.test(id)) continue;
417
+ findings.push({
418
+ code: "id_requires_css_escape",
419
+ severity: "warning",
420
+ message: `id="${id}" starts with a digit, so the common selector \`#${id}\` throws a SyntaxError in querySelector().`,
421
+ elementId: id,
422
+ fixHint: "Rename the id to start with a letter (recommended), or build selectors with `#${CSS.escape(id)}` at runtime.",
423
+ snippet: truncateSnippet(tag.raw)
424
+ });
425
+ }
426
+ return findings;
427
+ },
406
428
  // root_missing_composition_id + root_missing_dimensions
407
429
  ({ rootTag }) => {
408
430
  const findings = [];
@@ -457,10 +479,11 @@ var coreRules = [
457
479
  ];
458
480
  },
459
481
  // missing_timeline_registry + timeline_registry_missing_init
460
- ({ source, rawSource, options }) => {
482
+ ({ source, rawSource, rootTag, options }) => {
461
483
  if (options.isSubComposition || rawSource.trimStart().toLowerCase().startsWith("<template")) {
462
484
  return [];
463
485
  }
486
+ if (/(?:^|\s)data-no-timeline(?=[\s=/]|$)/i.test(rootTag?.attrs || "")) return [];
464
487
  const findings = [];
465
488
  if (!TIMELINE_REGISTRY_INIT_PATTERN.test(source) && !TIMELINE_REGISTRY_ASSIGN_PATTERN.test(source) && !TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN.test(source)) {
466
489
  findings.push({
@@ -1981,18 +2004,19 @@ ${right.raw}`)
1981
2004
  async ({ styles, scripts, tags }) => {
1982
2005
  const findings = [];
1983
2006
  const cssOpacityZeroSelectors = /* @__PURE__ */ new Set();
2007
+ const opacityExactlyZero = /opacity\s*:\s*0(?:\.0+)?\s*(?:;|$)/;
1984
2008
  for (const style of styles) {
1985
2009
  for (const [, selector, body] of style.content.matchAll(
1986
2010
  /([#.][a-zA-Z0-9_-]+)\s*\{([^}]+)\}/g
1987
2011
  )) {
1988
- if (body && /opacity\s*:\s*0\s*[;}]/.test(body)) {
2012
+ if (body && opacityExactlyZero.test(body)) {
1989
2013
  cssOpacityZeroSelectors.add((selector ?? "").trim());
1990
2014
  }
1991
2015
  }
1992
2016
  }
1993
2017
  for (const tag of tags) {
1994
2018
  const inlineStyle = readAttr(tag.raw, "style");
1995
- if (!inlineStyle || !/opacity\s*:\s*0/.test(inlineStyle)) continue;
2019
+ if (!inlineStyle || !opacityExactlyZero.test(inlineStyle)) continue;
1996
2020
  const id = readAttr(tag.raw, "id");
1997
2021
  const classes = readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean) ?? [];
1998
2022
  if (id) cssOpacityZeroSelectors.add(`#${id}`);
@@ -2460,21 +2484,20 @@ function collectDeclaredVariableIds(htmlTagRaw) {
2460
2484
  }
2461
2485
  return declared;
2462
2486
  }
2463
- function collectAllDeclaredVariableIds(source) {
2487
+ function collectAllDeclaredVariableIds(tags) {
2464
2488
  const all = /* @__PURE__ */ new Set();
2465
- const tagRe = /<[a-zA-Z][^>]*\bdata-composition-variables\b[^>]*>/gi;
2466
- let match;
2467
- while ((match = tagRe.exec(source)) !== null) {
2468
- const ids = collectDeclaredVariableIds(match[0]);
2489
+ for (const tag of tags) {
2490
+ if (!readAttr(tag.raw, "data-composition-variables")) continue;
2491
+ const ids = collectDeclaredVariableIds(tag.raw);
2469
2492
  if (ids === null) return null;
2470
2493
  for (const id of ids) all.add(id);
2471
2494
  }
2472
2495
  return all;
2473
2496
  }
2474
- function declaredIdsForBindingCheck(source) {
2475
- const declared = collectAllDeclaredVariableIds(source);
2497
+ function declaredIdsForBindingCheck(tags) {
2498
+ const declared = collectAllDeclaredVariableIds(tags);
2476
2499
  if (declared === null) return null;
2477
- if (declared.size === 0 && !findHtmlTag(source)) return null;
2500
+ if (declared.size === 0 && !findHtmlTag(tags)) return null;
2478
2501
  return declared;
2479
2502
  }
2480
2503
  var compositionRules = [
@@ -2878,8 +2901,8 @@ var compositionRules = [
2878
2901
  // nothing, so a typo'd binding is invisible until a customer's override
2879
2902
  // does nothing. Skipped for fragment files (no <html>): their values come
2880
2903
  // from a host's data-variable-values, which this file can't see.
2881
- ({ source, tags }) => {
2882
- const declared = declaredIdsForBindingCheck(source);
2904
+ ({ tags }) => {
2905
+ const declared = declaredIdsForBindingCheck(tags);
2883
2906
  if (!declared) return [];
2884
2907
  const findings = [];
2885
2908
  for (const tag of tags) {
@@ -2904,8 +2927,8 @@ var compositionRules = [
2904
2927
  // catch them at lint time rather than wondering why their `getVariables()`
2905
2928
  // defaults aren't applied.
2906
2929
  // fallow-ignore-next-line complexity
2907
- ({ source }) => {
2908
- const htmlTag = findHtmlTag(source);
2930
+ ({ tags }) => {
2931
+ const htmlTag = findHtmlTag(tags);
2909
2932
  if (!htmlTag) return [];
2910
2933
  const raw = readJsonAttr(htmlTag.raw, "data-composition-variables");
2911
2934
  if (!raw) return [];
@@ -2978,8 +3001,8 @@ var compositionRules = [
2978
3001
  // fixed top-left-origin screenshot region, which RTL layout can shift the
2979
3002
  // actual content away from), only surfaces the already-confirmed footgun
2980
3003
  // before someone hits it blind.
2981
- ({ source }) => {
2982
- const htmlTag = findHtmlTag(source);
3004
+ ({ tags }) => {
3005
+ const htmlTag = findHtmlTag(tags);
2983
3006
  if (!htmlTag) return [];
2984
3007
  const dir = readAttr(htmlTag.raw, "dir");
2985
3008
  if (!dir) return [];
@@ -3671,11 +3694,8 @@ async function lintHyperframeHtml(html, options = {}) {
3671
3694
  }
3672
3695
  function extractMediaUrls(html) {
3673
3696
  const results = [];
3674
- const tagRe = /<(video|audio|img|source)\b[^>]*>/gi;
3675
- let match;
3676
- while ((match = tagRe.exec(html)) !== null) {
3677
- const tagName = (match[1] ?? "").toLowerCase();
3678
- const raw = match[0];
3697
+ for (const { name: tagName, raw } of parseHtmlStructure(html).tags) {
3698
+ if (!/^(?:video|audio|img|source)$/.test(tagName)) continue;
3679
3699
  const src = readAttr(raw, "src");
3680
3700
  if (!src) continue;
3681
3701
  if (/^https?:\/\//i.test(src)) {
@@ -3751,63 +3771,60 @@ import { parseHTML } from "linkedom";
3751
3771
  function parseSubCompHtml(html) {
3752
3772
  return parseHTML(html).document;
3753
3773
  }
3774
+ function querySelectorAllIncludingTemplates(root, selector) {
3775
+ const matches = [...root.querySelectorAll(selector)];
3776
+ for (const template of root.querySelectorAll("template")) {
3777
+ const content = template.content;
3778
+ if (content) matches.push(...querySelectorAllIncludingTemplates(content, selector));
3779
+ }
3780
+ return matches;
3781
+ }
3754
3782
  var AUDIO_EXTENSIONS = /* @__PURE__ */ new Set([".mp3", ".wav", ".aac", ".ogg", ".m4a", ".flac", ".opus"]);
3755
- var STYLE_BLOCK_RE = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
3756
- var OPEN_TAG_RE = /<([a-z][\w:-]*)(\s[^<>]*?)?>/gi;
3757
3783
  var MASK_IMAGE_URL_RE = /\b(?:-webkit-)?mask-image\s*:\s*[^;{}]*url\(\s*(?:"([^"]+)"|'([^']+)'|([^"')\s]+))\s*\)/gi;
3758
- function readHtmlAttr(tag, name) {
3759
- const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3760
- const match = tag.match(new RegExp(`\\b${escaped}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i"));
3761
- return match?.[1] ?? match?.[2] ?? null;
3762
- }
3763
3784
  function isLocalStylesheetHref(href) {
3764
3785
  return !!href && !/^(https?:|data:|blob:|\/\/)/i.test(href);
3765
3786
  }
3766
- function collectExternalStyles(projectDir, html, compSrcPath) {
3787
+ function collectLocalStylesheets(projectDir, document, compSrcPath) {
3767
3788
  const styles = [];
3768
- const linkRe = /<link\b[^>]*>/gi;
3769
- let match;
3770
- while ((match = linkRe.exec(html)) !== null) {
3771
- const tag = match[0];
3772
- const rel = tag.match(/\brel\s*=\s*["']([^"']+)["']/i)?.[1] ?? "";
3789
+ for (const link of querySelectorAllIncludingTemplates(document, "link")) {
3790
+ const rel = link.getAttribute("rel") ?? "";
3773
3791
  if (!rel.split(/\s+/).some((part) => part.toLowerCase() === "stylesheet")) continue;
3774
- const href = tag.match(/\bhref\s*=\s*["']([^"']+)["']/i)?.[1] ?? "";
3792
+ const href = link.getAttribute("href") ?? "";
3775
3793
  if (!isLocalStylesheetHref(href)) continue;
3776
3794
  const rootRelative = compSrcPath ? join(dirname(compSrcPath), href) : href;
3777
3795
  const stylesheet = resolveExistingLocalAsset(projectDir, rootRelative);
3778
3796
  if (!stylesheet) continue;
3779
- styles.push({ href, content: readFileSync(stylesheet.resolved, "utf-8") });
3797
+ styles.push({
3798
+ href,
3799
+ content: readFileSync(stylesheet.resolved, "utf-8"),
3800
+ rootRelativePath: stylesheet.rootRelativePath
3801
+ });
3802
+ }
3803
+ return styles;
3804
+ }
3805
+ function collectExternalStyles(projectDir, html, compSrcPath) {
3806
+ const styles = [];
3807
+ const { document } = parseHTML(html);
3808
+ for (const { href, content } of collectLocalStylesheets(projectDir, document, compSrcPath)) {
3809
+ styles.push({ href, content });
3780
3810
  }
3781
3811
  return styles;
3782
3812
  }
3783
3813
  function collectCssSources(projectDir, html, compSrcPath) {
3784
3814
  const sources = [];
3785
- let styleMatch;
3786
- const stylePattern = new RegExp(STYLE_BLOCK_RE.source, STYLE_BLOCK_RE.flags);
3787
- while ((styleMatch = stylePattern.exec(html)) !== null) {
3788
- sources.push({ content: styleMatch[1] ?? "" });
3815
+ const { document } = parseHTML(html);
3816
+ for (const style of querySelectorAllIncludingTemplates(document, "style")) {
3817
+ sources.push({ content: style.textContent ?? "" });
3789
3818
  }
3790
- const linkRe = /<link\b[^>]*>/gi;
3791
- let linkMatch;
3792
- while ((linkMatch = linkRe.exec(html)) !== null) {
3793
- const tag = linkMatch[0];
3794
- const rel = readHtmlAttr(tag, "rel") ?? "";
3795
- if (!rel.split(/\s+/).some((part) => part.toLowerCase() === "stylesheet")) continue;
3796
- const href = readHtmlAttr(tag, "href") ?? "";
3797
- if (!isLocalStylesheetHref(href)) continue;
3798
- const rootRelativePath = compSrcPath ? join(dirname(compSrcPath), href) : href;
3799
- const stylesheet = resolveExistingLocalAsset(projectDir, rootRelativePath);
3800
- if (!stylesheet) continue;
3801
- sources.push({
3802
- content: readFileSync(stylesheet.resolved, "utf-8"),
3803
- rootRelativePath: stylesheet.rootRelativePath
3804
- });
3819
+ for (const { content, rootRelativePath } of collectLocalStylesheets(
3820
+ projectDir,
3821
+ document,
3822
+ compSrcPath
3823
+ )) {
3824
+ sources.push({ content, rootRelativePath });
3805
3825
  }
3806
- let tagMatch;
3807
- const tagPattern = new RegExp(OPEN_TAG_RE.source, OPEN_TAG_RE.flags);
3808
- while ((tagMatch = tagPattern.exec(html)) !== null) {
3809
- const tag = tagMatch[0];
3810
- const style = readHtmlAttr(tag, "style");
3826
+ for (const element of querySelectorAllIncludingTemplates(document, "[style]")) {
3827
+ const style = element.getAttribute("style");
3811
3828
  if (!style) continue;
3812
3829
  sources.push({ content: style });
3813
3830
  }