@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/browser.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/browser.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;
@@ -709,7 +711,8 @@ function buildLintContext(html, options = {}) {
709
711
  content: style.content,
710
712
  raw: style.content,
711
713
  index: -1,
712
- file: style.file ?? style.href
714
+ file: style.file ?? style.href,
715
+ rootRelativePath: style.rootRelativePath
713
716
  }))
714
717
  ];
715
718
  const scripts = structure.scripts;
@@ -745,6 +748,12 @@ import {
745
748
  HTML_BODY_CSS_HEIGHT_FIRST_RE,
746
749
  VIEWPORT_META_SIZE_RE
747
750
  } from "@hyperframes/parsers/composition";
751
+ var SCRIPT_SYNTAX_EXEMPT_TYPES = /* @__PURE__ */ new Set([
752
+ "application/json",
753
+ "application/hyperframes-slideshow+json",
754
+ "importmap",
755
+ "module"
756
+ ]);
748
757
  function repeatedDescendantId(selector) {
749
758
  let repeated = null;
750
759
  const requiredPseudoIds = (pseudo) => {
@@ -1101,7 +1110,7 @@ var coreRules = [
1101
1110
  },
1102
1111
  // root_missing_composition_id + root_missing_dimensions
1103
1112
  // fallow-ignore-next-line complexity
1104
- ({ rootTag }) => {
1113
+ ({ rootTag, options }) => {
1105
1114
  const findings = [];
1106
1115
  if (!rootTag || !readDecodedAttr(rootTag.raw, "data-composition-id")) {
1107
1116
  findings.push({
@@ -1113,7 +1122,7 @@ var coreRules = [
1113
1122
  snippet: truncateSnippet(rootTag?.raw || "")
1114
1123
  });
1115
1124
  }
1116
- if (!rootTag || !readAttr(rootTag.raw, "data-width") || !readAttr(rootTag.raw, "data-height")) {
1125
+ if (!options.isSubComposition && (!rootTag || !readAttr(rootTag.raw, "data-width") || !readAttr(rootTag.raw, "data-height"))) {
1117
1126
  findings.push({
1118
1127
  code: "root_missing_dimensions",
1119
1128
  severity: "error",
@@ -1363,10 +1372,8 @@ var coreRules = [
1363
1372
  ({ scripts, locate }) => {
1364
1373
  const findings = [];
1365
1374
  for (const script of scripts) {
1366
- const attrs = script.attrs || "";
1367
- if (/\bsrc\s*=/.test(attrs) || /\btype\s*=\s*["'](?:application\/json|application\/hyperframes-slideshow\+json|importmap|module)["']/.test(
1368
- attrs
1369
- ))
1375
+ const type = readDecodedAttr(script.raw, "type")?.trim().toLowerCase() ?? "";
1376
+ if (readDecodedAttr(script.raw, "src") !== null || SCRIPT_SYNTAX_EXEMPT_TYPES.has(type))
1370
1377
  continue;
1371
1378
  const syntaxError = getInlineScriptSyntaxError(script.content);
1372
1379
  if (!syntaxError) continue;
@@ -1783,6 +1790,7 @@ function findImperativeMediaControlFindings(ctx) {
1783
1790
  };
1784
1791
  if (mediaTags.length === 0 || ctx.scripts.length === 0) return findings;
1785
1792
  for (const script of ctx.scripts) {
1793
+ const code = stripJsCode(script.content);
1786
1794
  const mediaVars = /* @__PURE__ */ new Map();
1787
1795
  const assignmentPatterns = [
1788
1796
  {
@@ -1799,6 +1807,7 @@ function findImperativeMediaControlFindings(ctx) {
1799
1807
  for (const { pattern, variableIndex, targetIndex } of assignmentPatterns) {
1800
1808
  let match;
1801
1809
  while ((match = pattern.exec(script.content)) !== null) {
1810
+ if (code[match.index] !== script.content[match.index]) continue;
1802
1811
  const variableName = match[variableIndex];
1803
1812
  const target = match[targetIndex];
1804
1813
  if (!variableName || !target) continue;
@@ -1809,12 +1818,12 @@ function findImperativeMediaControlFindings(ctx) {
1809
1818
  }
1810
1819
  const directIdPatterns = [
1811
1820
  {
1812
- pattern: /\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.play\s*\(/g,
1821
+ pattern: /\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\s*(?:\?\.|\.)\s*play\s*(?:\?\.\s*)?\(/g,
1813
1822
  kind: "play()",
1814
1823
  targetIndex: 1
1815
1824
  },
1816
1825
  {
1817
- pattern: /\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.pause\s*\(/g,
1826
+ pattern: /\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\s*(?:\?\.|\.)\s*pause\s*(?:\?\.\s*)?\(/g,
1818
1827
  kind: "pause()",
1819
1828
  targetIndex: 1
1820
1829
  },
@@ -1829,12 +1838,12 @@ function findImperativeMediaControlFindings(ctx) {
1829
1838
  targetIndex: 1
1830
1839
  },
1831
1840
  {
1832
- pattern: /\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\.play\s*\(/g,
1841
+ pattern: /\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\s*(?:\?\.|\.)\s*play\s*(?:\?\.\s*)?\(/g,
1833
1842
  kind: "play()",
1834
1843
  targetIndex: 2
1835
1844
  },
1836
1845
  {
1837
- pattern: /\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\.pause\s*\(/g,
1846
+ pattern: /\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\s*(?:\?\.|\.)\s*pause\s*(?:\?\.\s*)?\(/g,
1838
1847
  kind: "pause()",
1839
1848
  targetIndex: 2
1840
1849
  },
@@ -1852,6 +1861,7 @@ function findImperativeMediaControlFindings(ctx) {
1852
1861
  for (const { pattern, kind, targetIndex } of directIdPatterns) {
1853
1862
  let match;
1854
1863
  while ((match = pattern.exec(script.content)) !== null) {
1864
+ if (code[match.index] !== script.content[match.index]) continue;
1855
1865
  const target = match[targetIndex];
1856
1866
  if (!target) continue;
1857
1867
  const elementId = mediaIndex.ids.has(target) ? target : selectorTargetsManagedMedia(target, mediaIndex) ? void 0 : null;
@@ -1869,8 +1879,20 @@ function findImperativeMediaControlFindings(ctx) {
1869
1879
  for (const [variableName, elementId] of mediaVars) {
1870
1880
  const escapedVar = escapeRegExp(variableName);
1871
1881
  const variablePatterns = [
1872
- { pattern: new RegExp(`\\b${escapedVar}\\.play\\s*\\(`, "g"), kind: "play()" },
1873
- { pattern: new RegExp(`\\b${escapedVar}\\.pause\\s*\\(`, "g"), kind: "pause()" },
1882
+ {
1883
+ pattern: new RegExp(
1884
+ `\\b${escapedVar}\\s*(?:\\?\\.|\\.)\\s*play\\s*(?:\\?\\.\\s*)?\\(`,
1885
+ "g"
1886
+ ),
1887
+ kind: "play()"
1888
+ },
1889
+ {
1890
+ pattern: new RegExp(
1891
+ `\\b${escapedVar}\\s*(?:\\?\\.|\\.)\\s*pause\\s*(?:\\?\\.\\s*)?\\(`,
1892
+ "g"
1893
+ ),
1894
+ kind: "pause()"
1895
+ },
1874
1896
  { pattern: new RegExp(`\\b${escapedVar}\\.currentTime\\s*=`, "g"), kind: "currentTime" },
1875
1897
  {
1876
1898
  pattern: new RegExp(`\\b${escapedVar}\\.muted\\s*=`, "g"),
@@ -1880,6 +1902,7 @@ function findImperativeMediaControlFindings(ctx) {
1880
1902
  for (const { pattern, kind } of variablePatterns) {
1881
1903
  let match;
1882
1904
  while ((match = pattern.exec(script.content)) !== null) {
1905
+ if (code[match.index] !== script.content[match.index]) continue;
1883
1906
  findings.push({
1884
1907
  code: "imperative_media_control",
1885
1908
  severity: "error",
@@ -2582,6 +2605,24 @@ function extractCssUrlReferences(css) {
2582
2605
  }
2583
2606
  return out;
2584
2607
  }
2608
+ function folderDepth(filePath) {
2609
+ if (/^(?:[a-z]:)?[\\/]/i.test(filePath)) return 0;
2610
+ let depth = 0;
2611
+ for (const segment of filePath.split(/[\\/]/).slice(0, -1)) {
2612
+ if (segment === "..") depth = Math.max(0, depth - 1);
2613
+ else if (segment && segment !== ".") depth += 1;
2614
+ }
2615
+ return depth;
2616
+ }
2617
+ function climbsAboveRoot(fileDepth, path) {
2618
+ let depth = fileDepth;
2619
+ for (const segment of path.split("/")) {
2620
+ if (segment === "..") depth -= 1;
2621
+ else if (segment && segment !== ".") depth += 1;
2622
+ if (depth < 0) return true;
2623
+ }
2624
+ return false;
2625
+ }
2585
2626
  function extractCssSelectors(css) {
2586
2627
  const out = [];
2587
2628
  const noComments = css.replace(/\/\*[\s\S]*?\*\//g, "");
@@ -2895,42 +2936,31 @@ var compositionRules = [
2895
2936
  }
2896
2937
  return findings;
2897
2938
  },
2898
- // invalid_parent_traversal_in_asset_path — catches `../` traversal in src,
2899
- // href, inline-style url(), and <style> url() asset references on
2900
- // compositions. Sub-compositions live under compositions/ but are served
2901
- // with the project root as their base URL, so any `../`-traversing path
2902
- // climbs above the project root and 404s in Studio preview. Renders
2903
- // tolerate it because the server-side bundler rewrites `../foo` against
2904
- // each sub-composition's source path; the runtime now mirrors that fallback
2905
- // (see rewriteSubCompositionAssetPaths in runtime/compositionLoader.ts), but
2906
- // the authoring-time signal is still wrong — flag it at lint time so the
2907
- // baked path is plain root-relative and matches what the bundler emits.
2908
- //
2909
- // Mirrors the runtime fallback's surface: `[src]` / `[href]` attribute
2910
- // values, `[style]` inline url(), and `<style>` block url() references.
2911
- // Skips absolute URLs (http(s)://, //, data:, /-prefixed root-relative),
2912
- // hash anchors, and plain relative paths (`assets/x.mp4`) — only `../`
2913
- // traversal is flagged. Subsumes the older `../capture/`-specific rule.
2939
+ // invalid_parent_traversal_in_asset_path — a `../` path in src, href or a CSS url() that leaves
2940
+ // the project once resolved from the file's own folder, as the bundler, producer and runtime
2941
+ // resolve it. "../assets/x" from compositions/ stays inside and passes.
2914
2942
  // fallow-ignore-next-line complexity
2915
2943
  ({ tags, styles, rawSource, options }) => {
2916
2944
  if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
2917
2945
  const offenders = [];
2918
- const collect = (value) => {
2919
- if (!value) return;
2920
- const trimmed = value.trim();
2921
- if (!trimmed.startsWith("../") && trimmed !== "..") return;
2922
- offenders.push(trimmed);
2946
+ const fileDepth = folderDepth(options.compSrcPath ?? "index.html");
2947
+ const collect = (value, depth = fileDepth) => {
2948
+ const trimmed = value?.trim() ?? "";
2949
+ if (/^(?:[a-z][a-z0-9+.-]*:|[\\/#])/i.test(trimmed)) return;
2950
+ const path = (trimmed.split(/[?#]/, 1)[0] ?? "").replace(/\\|%2f/gi, "/").replace(/%2e/gi, ".");
2951
+ if (climbsAboveRoot(depth, path)) offenders.push(trimmed);
2923
2952
  };
2924
2953
  for (const tag of tags) {
2925
- collect(readAttr(tag.raw, "src"));
2926
- collect(readAttr(tag.raw, "href"));
2954
+ collect(readDecodedAttr(tag.raw, "src"));
2955
+ collect(readDecodedAttr(tag.raw, "href"));
2927
2956
  const styleAttr = readJsonAttr(tag.raw, "style");
2928
2957
  if (styleAttr) {
2929
2958
  for (const url of extractCssUrlReferences(styleAttr)) collect(url);
2930
2959
  }
2931
2960
  }
2932
2961
  for (const style of styles) {
2933
- for (const url of extractCssUrlReferences(style.content)) collect(url);
2962
+ const depth = style.rootRelativePath ? folderDepth(style.rootRelativePath) : fileDepth;
2963
+ for (const url of extractCssUrlReferences(style.content)) collect(url, depth);
2934
2964
  }
2935
2965
  if (offenders.length === 0) return [];
2936
2966
  const prefixCounts = /* @__PURE__ */ new Map();
@@ -2943,8 +2973,8 @@ var compositionRules = [
2943
2973
  {
2944
2974
  code: "invalid_parent_traversal_in_asset_path",
2945
2975
  severity: "error",
2946
- 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.`,
2947
- 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.'
2976
+ 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.`,
2977
+ 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.`
2948
2978
  }
2949
2979
  ];
2950
2980
  },