@hyperframes/lint 0.8.142 → 0.8.144

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.d.ts CHANGED
@@ -36,10 +36,12 @@ type HyperframeLintResult = {
36
36
  type HyperframeLinterOptions = {
37
37
  filePath?: string;
38
38
  isSubComposition?: boolean;
39
+ compSrcPath?: string;
39
40
  externalStyles?: Array<{
40
41
  href: string;
41
42
  content: string;
42
43
  file?: string;
44
+ rootRelativePath?: string;
43
45
  }>;
44
46
  /**
45
47
  * Set to `true` when linting compositions destined for distributed / Lambda
package/dist/index.js CHANGED
@@ -71,12 +71,13 @@ function parseHtmlStructure(source) {
71
71
  function findHtmlTag(tags) {
72
72
  return tags.find((tag) => tag.name === "html") ?? null;
73
73
  }
74
+ var hasCompositionMarker = (tag) => Boolean(
75
+ readDecodedAttr(tag.raw, "data-composition-id") || readAttr(tag.raw, "data-width") || readAttr(tag.raw, "data-height")
76
+ );
74
77
  function findRootTag(source, parsedTags) {
75
78
  const tags = parsedTags ?? parseHtmlStructure(source).tags;
76
79
  const bodyTag = tags.find((tag) => tag.name === "body");
77
- if (bodyTag && (readDecodedAttr(bodyTag.raw, "data-composition-id") || readAttr(bodyTag.raw, "data-width") || readAttr(bodyTag.raw, "data-height"))) {
78
- return bodyTag;
79
- }
80
+ if (bodyTag && hasCompositionMarker(bodyTag)) return bodyTag;
80
81
  const bodyStart = bodyTag ? bodyTag.index + bodyTag.raw.length : 0;
81
82
  const bodyEnd = bodyTag?.closeIndex ?? source.length;
82
83
  const bodyTags = tags.filter((tag) => tag.index >= bodyStart && tag.index < bodyEnd);
@@ -84,7 +85,8 @@ function findRootTag(source, parsedTags) {
84
85
  for (const tag of bodyTags) {
85
86
  if (tag.index < skipBefore) continue;
86
87
  if (["script", "style", "meta", "link", "title"].includes(tag.name)) continue;
87
- if (tag.name === "svg" && !readDecodedAttr(tag.raw, "data-composition-id") && !readAttr(tag.raw, "data-width") && !readAttr(tag.raw, "data-height")) {
88
+ if ((tag.name === "html" || tag.name === "head") && !hasCompositionMarker(tag)) continue;
89
+ if (tag.name === "svg" && !hasCompositionMarker(tag)) {
88
90
  skipBefore = tag.endIndex ?? Infinity;
89
91
  continue;
90
92
  }
@@ -108,10 +110,7 @@ function hasUnquotedLessThan(attrs) {
108
110
  return false;
109
111
  }
110
112
  function readAttr(tagSource, attr) {
111
- if (!tagSource) return null;
112
- const escaped = attr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
113
- const match = tagSource.match(new RegExp(`(?<![\\w-])${escaped}\\s*=\\s*["']([^"']+)["']`, "i"));
114
- return match?.[1] || null;
113
+ return readAttributeValue(tagSource, attr, false) || null;
115
114
  }
116
115
  function hasAttrName(tagSource, attr) {
117
116
  return readDecodedAttr(tagSource, attr) !== null;
@@ -134,15 +133,18 @@ function mediaWindowsOverlap(a, b) {
134
133
  return a.start < b.end && b.start < a.end;
135
134
  }
136
135
  function readDecodedAttr(tagSource, attr) {
136
+ return readAttributeValue(tagSource, attr, true);
137
+ }
138
+ function readAttributeValue(tagSource, attr, decodeEntities) {
137
139
  if (!tagSource) return null;
138
140
  let value = null;
139
141
  const parser = new Parser(
140
142
  {
141
- onattribute(name, decodedValue) {
142
- if (value === null && name.toLowerCase() === attr.toLowerCase()) value = decodedValue;
143
+ onattribute(name, attributeValue) {
144
+ if (value === null && name.toLowerCase() === attr.toLowerCase()) value = attributeValue;
143
145
  }
144
146
  },
145
- { decodeEntities: true, lowerCaseAttributeNames: false, lowerCaseTags: true }
147
+ { decodeEntities, lowerCaseAttributeNames: false, lowerCaseTags: true }
146
148
  );
147
149
  parser.end(tagSource);
148
150
  return value;
@@ -712,7 +714,8 @@ function buildLintContext(html, options = {}) {
712
714
  content: style.content,
713
715
  raw: style.content,
714
716
  index: -1,
715
- file: style.file ?? style.href
717
+ file: style.file ?? style.href,
718
+ rootRelativePath: style.rootRelativePath
716
719
  }))
717
720
  ];
718
721
  const scripts = structure.scripts;
@@ -748,6 +751,12 @@ import {
748
751
  HTML_BODY_CSS_HEIGHT_FIRST_RE,
749
752
  VIEWPORT_META_SIZE_RE
750
753
  } from "@hyperframes/parsers/composition";
754
+ var SCRIPT_SYNTAX_EXEMPT_TYPES = /* @__PURE__ */ new Set([
755
+ "application/json",
756
+ "application/hyperframes-slideshow+json",
757
+ "importmap",
758
+ "module"
759
+ ]);
751
760
  function repeatedDescendantId(selector) {
752
761
  let repeated = null;
753
762
  const requiredPseudoIds = (pseudo) => {
@@ -1104,7 +1113,7 @@ var coreRules = [
1104
1113
  },
1105
1114
  // root_missing_composition_id + root_missing_dimensions
1106
1115
  // fallow-ignore-next-line complexity
1107
- ({ rootTag }) => {
1116
+ ({ rootTag, options }) => {
1108
1117
  const findings = [];
1109
1118
  if (!rootTag || !readDecodedAttr(rootTag.raw, "data-composition-id")) {
1110
1119
  findings.push({
@@ -1116,7 +1125,7 @@ var coreRules = [
1116
1125
  snippet: truncateSnippet(rootTag?.raw || "")
1117
1126
  });
1118
1127
  }
1119
- if (!rootTag || !readAttr(rootTag.raw, "data-width") || !readAttr(rootTag.raw, "data-height")) {
1128
+ if (!options.isSubComposition && (!rootTag || !readAttr(rootTag.raw, "data-width") || !readAttr(rootTag.raw, "data-height"))) {
1120
1129
  findings.push({
1121
1130
  code: "root_missing_dimensions",
1122
1131
  severity: "error",
@@ -1366,10 +1375,8 @@ var coreRules = [
1366
1375
  ({ scripts, locate }) => {
1367
1376
  const findings = [];
1368
1377
  for (const script of scripts) {
1369
- const attrs = script.attrs || "";
1370
- if (/\bsrc\s*=/.test(attrs) || /\btype\s*=\s*["'](?:application\/json|application\/hyperframes-slideshow\+json|importmap|module)["']/.test(
1371
- attrs
1372
- ))
1378
+ const type = readDecodedAttr(script.raw, "type")?.trim().toLowerCase() ?? "";
1379
+ if (readDecodedAttr(script.raw, "src") !== null || SCRIPT_SYNTAX_EXEMPT_TYPES.has(type))
1373
1380
  continue;
1374
1381
  const syntaxError = getInlineScriptSyntaxError(script.content);
1375
1382
  if (!syntaxError) continue;
@@ -1786,6 +1793,7 @@ function findImperativeMediaControlFindings(ctx) {
1786
1793
  };
1787
1794
  if (mediaTags.length === 0 || ctx.scripts.length === 0) return findings;
1788
1795
  for (const script of ctx.scripts) {
1796
+ const code = stripJsCode(script.content);
1789
1797
  const mediaVars = /* @__PURE__ */ new Map();
1790
1798
  const assignmentPatterns = [
1791
1799
  {
@@ -1802,6 +1810,7 @@ function findImperativeMediaControlFindings(ctx) {
1802
1810
  for (const { pattern, variableIndex, targetIndex } of assignmentPatterns) {
1803
1811
  let match;
1804
1812
  while ((match = pattern.exec(script.content)) !== null) {
1813
+ if (code[match.index] !== script.content[match.index]) continue;
1805
1814
  const variableName = match[variableIndex];
1806
1815
  const target = match[targetIndex];
1807
1816
  if (!variableName || !target) continue;
@@ -1812,12 +1821,12 @@ function findImperativeMediaControlFindings(ctx) {
1812
1821
  }
1813
1822
  const directIdPatterns = [
1814
1823
  {
1815
- pattern: /\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.play\s*\(/g,
1824
+ pattern: /\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\s*(?:\?\.|\.)\s*play\s*(?:\?\.\s*)?\(/g,
1816
1825
  kind: "play()",
1817
1826
  targetIndex: 1
1818
1827
  },
1819
1828
  {
1820
- pattern: /\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.pause\s*\(/g,
1829
+ pattern: /\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\s*(?:\?\.|\.)\s*pause\s*(?:\?\.\s*)?\(/g,
1821
1830
  kind: "pause()",
1822
1831
  targetIndex: 1
1823
1832
  },
@@ -1832,12 +1841,12 @@ function findImperativeMediaControlFindings(ctx) {
1832
1841
  targetIndex: 1
1833
1842
  },
1834
1843
  {
1835
- pattern: /\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\.play\s*\(/g,
1844
+ pattern: /\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\s*(?:\?\.|\.)\s*play\s*(?:\?\.\s*)?\(/g,
1836
1845
  kind: "play()",
1837
1846
  targetIndex: 2
1838
1847
  },
1839
1848
  {
1840
- pattern: /\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\.pause\s*\(/g,
1849
+ pattern: /\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\s*(?:\?\.|\.)\s*pause\s*(?:\?\.\s*)?\(/g,
1841
1850
  kind: "pause()",
1842
1851
  targetIndex: 2
1843
1852
  },
@@ -1855,6 +1864,7 @@ function findImperativeMediaControlFindings(ctx) {
1855
1864
  for (const { pattern, kind, targetIndex } of directIdPatterns) {
1856
1865
  let match;
1857
1866
  while ((match = pattern.exec(script.content)) !== null) {
1867
+ if (code[match.index] !== script.content[match.index]) continue;
1858
1868
  const target = match[targetIndex];
1859
1869
  if (!target) continue;
1860
1870
  const elementId = mediaIndex.ids.has(target) ? target : selectorTargetsManagedMedia(target, mediaIndex) ? void 0 : null;
@@ -1872,8 +1882,20 @@ function findImperativeMediaControlFindings(ctx) {
1872
1882
  for (const [variableName, elementId] of mediaVars) {
1873
1883
  const escapedVar = escapeRegExp(variableName);
1874
1884
  const variablePatterns = [
1875
- { pattern: new RegExp(`\\b${escapedVar}\\.play\\s*\\(`, "g"), kind: "play()" },
1876
- { pattern: new RegExp(`\\b${escapedVar}\\.pause\\s*\\(`, "g"), kind: "pause()" },
1885
+ {
1886
+ pattern: new RegExp(
1887
+ `\\b${escapedVar}\\s*(?:\\?\\.|\\.)\\s*play\\s*(?:\\?\\.\\s*)?\\(`,
1888
+ "g"
1889
+ ),
1890
+ kind: "play()"
1891
+ },
1892
+ {
1893
+ pattern: new RegExp(
1894
+ `\\b${escapedVar}\\s*(?:\\?\\.|\\.)\\s*pause\\s*(?:\\?\\.\\s*)?\\(`,
1895
+ "g"
1896
+ ),
1897
+ kind: "pause()"
1898
+ },
1877
1899
  { pattern: new RegExp(`\\b${escapedVar}\\.currentTime\\s*=`, "g"), kind: "currentTime" },
1878
1900
  {
1879
1901
  pattern: new RegExp(`\\b${escapedVar}\\.muted\\s*=`, "g"),
@@ -1883,6 +1905,7 @@ function findImperativeMediaControlFindings(ctx) {
1883
1905
  for (const { pattern, kind } of variablePatterns) {
1884
1906
  let match;
1885
1907
  while ((match = pattern.exec(script.content)) !== null) {
1908
+ if (code[match.index] !== script.content[match.index]) continue;
1886
1909
  findings.push({
1887
1910
  code: "imperative_media_control",
1888
1911
  severity: "error",
@@ -2585,6 +2608,24 @@ function extractCssUrlReferences(css) {
2585
2608
  }
2586
2609
  return out;
2587
2610
  }
2611
+ function folderDepth(filePath) {
2612
+ if (/^(?:[a-z]:)?[\\/]/i.test(filePath)) return 0;
2613
+ let depth = 0;
2614
+ for (const segment of filePath.split(/[\\/]/).slice(0, -1)) {
2615
+ if (segment === "..") depth = Math.max(0, depth - 1);
2616
+ else if (segment && segment !== ".") depth += 1;
2617
+ }
2618
+ return depth;
2619
+ }
2620
+ function climbsAboveRoot(fileDepth, path) {
2621
+ let depth = fileDepth;
2622
+ for (const segment of path.split("/")) {
2623
+ if (segment === "..") depth -= 1;
2624
+ else if (segment && segment !== ".") depth += 1;
2625
+ if (depth < 0) return true;
2626
+ }
2627
+ return false;
2628
+ }
2588
2629
  function extractCssSelectors(css) {
2589
2630
  const out = [];
2590
2631
  const noComments = css.replace(/\/\*[\s\S]*?\*\//g, "");
@@ -2898,42 +2939,31 @@ var compositionRules = [
2898
2939
  }
2899
2940
  return findings;
2900
2941
  },
2901
- // invalid_parent_traversal_in_asset_path — catches `../` traversal in src,
2902
- // href, inline-style url(), and <style> url() asset references on
2903
- // compositions. Sub-compositions live under compositions/ but are served
2904
- // with the project root as their base URL, so any `../`-traversing path
2905
- // climbs above the project root and 404s in Studio preview. Renders
2906
- // tolerate it because the server-side bundler rewrites `../foo` against
2907
- // each sub-composition's source path; the runtime now mirrors that fallback
2908
- // (see rewriteSubCompositionAssetPaths in runtime/compositionLoader.ts), but
2909
- // the authoring-time signal is still wrong — flag it at lint time so the
2910
- // baked path is plain root-relative and matches what the bundler emits.
2911
- //
2912
- // Mirrors the runtime fallback's surface: `[src]` / `[href]` attribute
2913
- // values, `[style]` inline url(), and `<style>` block url() references.
2914
- // Skips absolute URLs (http(s)://, //, data:, /-prefixed root-relative),
2915
- // hash anchors, and plain relative paths (`assets/x.mp4`) — only `../`
2916
- // traversal is flagged. Subsumes the older `../capture/`-specific rule.
2942
+ // invalid_parent_traversal_in_asset_path — a `../` path in src, href or a CSS url() that leaves
2943
+ // the project once resolved from the file's own folder, as the bundler, producer and runtime
2944
+ // resolve it. "../assets/x" from compositions/ stays inside and passes.
2917
2945
  // fallow-ignore-next-line complexity
2918
2946
  ({ tags, styles, rawSource, options }) => {
2919
2947
  if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
2920
2948
  const offenders = [];
2921
- const collect = (value) => {
2922
- if (!value) return;
2923
- const trimmed = value.trim();
2924
- if (!trimmed.startsWith("../") && trimmed !== "..") return;
2925
- offenders.push(trimmed);
2949
+ const fileDepth = folderDepth(options.compSrcPath ?? "index.html");
2950
+ const collect = (value, depth = fileDepth) => {
2951
+ const trimmed = value?.trim() ?? "";
2952
+ if (/^(?:[a-z][a-z0-9+.-]*:|[\\/#])/i.test(trimmed)) return;
2953
+ const path = (trimmed.split(/[?#]/, 1)[0] ?? "").replace(/\\|%2f/gi, "/").replace(/%2e/gi, ".");
2954
+ if (climbsAboveRoot(depth, path)) offenders.push(trimmed);
2926
2955
  };
2927
2956
  for (const tag of tags) {
2928
- collect(readAttr(tag.raw, "src"));
2929
- collect(readAttr(tag.raw, "href"));
2957
+ collect(readDecodedAttr(tag.raw, "src"));
2958
+ collect(readDecodedAttr(tag.raw, "href"));
2930
2959
  const styleAttr = readJsonAttr(tag.raw, "style");
2931
2960
  if (styleAttr) {
2932
2961
  for (const url of extractCssUrlReferences(styleAttr)) collect(url);
2933
2962
  }
2934
2963
  }
2935
2964
  for (const style of styles) {
2936
- for (const url of extractCssUrlReferences(style.content)) collect(url);
2965
+ const depth = style.rootRelativePath ? folderDepth(style.rootRelativePath) : fileDepth;
2966
+ for (const url of extractCssUrlReferences(style.content)) collect(url, depth);
2937
2967
  }
2938
2968
  if (offenders.length === 0) return [];
2939
2969
  const prefixCounts = /* @__PURE__ */ new Map();
@@ -2946,8 +2976,8 @@ var compositionRules = [
2946
2976
  {
2947
2977
  code: "invalid_parent_traversal_in_asset_path",
2948
2978
  severity: "error",
2949
- message: `Found ${offenders.length} asset path(s) traversing above the project root with "../" (${prefixSummary}). Renders rewrite this against each sub-composition's source path, but Studio preview and other live consumers resolve against the project root and 404.`,
2950
- fixHint: 'Use plain root-relative paths (e.g. "assets/...", "capture/...", "fonts/...") \u2014 compositions are served with the project root as their base URL, so paths must be root-relative, not relative to the compositions/ directory.'
2979
+ message: `Found ${offenders.length} asset path(s) traversing above the project root with "../" (${prefixSummary}), resolved from the folder of the file that holds them. Render and preview clamp them to the project root or resolve them outside it, so they load a different file than written, or none.`,
2980
+ fixHint: `Point the path at a file inside the project: from compositions/scene.html, "../assets/x.png" and "assets/x.png" both reach the project's assets folder.`
2951
2981
  }
2952
2982
  ];
2953
2983
  },
@@ -6729,7 +6759,7 @@ function collectExternalStyles(projectDir, html, compSrcPath) {
6729
6759
  document,
6730
6760
  compSrcPath
6731
6761
  )) {
6732
- styles.push({ href, content, file: join2(projectDir, rootRelativePath) });
6762
+ styles.push({ href, content, file: join2(projectDir, rootRelativePath), rootRelativePath });
6733
6763
  }
6734
6764
  return styles;
6735
6765
  }
@@ -6781,6 +6811,7 @@ async function lintProject(projectDir, entryFile, hostOptions = {}) {
6781
6811
  const rootResult = await lintHyperframeHtml(rootHtml, {
6782
6812
  ...hostOptions,
6783
6813
  filePath: indexPath,
6814
+ compSrcPath: rootFile,
6784
6815
  externalStyles: collectExternalStyles(projectDir, rootHtml, rootCompSrcPath)
6785
6816
  });
6786
6817
  results.push({ file: rootFile, result: rootResult, contentHash: contentDigest(rootHtml) });
@@ -6814,6 +6845,7 @@ async function lintProject(projectDir, entryFile, hostOptions = {}) {
6814
6845
  ...hostOptions,
6815
6846
  filePath,
6816
6847
  isSubComposition: true,
6848
+ compSrcPath,
6817
6849
  externalStyles: collectExternalStyles(projectDir, html, compSrcPath)
6818
6850
  });
6819
6851
  results.push({