@hyperframes/lint 0.7.22 → 0.7.23

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
@@ -222,8 +222,12 @@ function truncateSnippet(value, maxLength = 220) {
222
222
  function buildLintContext(html, options = {}) {
223
223
  const rawSource = html || "";
224
224
  let source = stripHtmlComments(rawSource);
225
+ const sourceWithoutTemplates = source.replace(
226
+ /<template\b[^>]*>[\s\S]*?<\/template(?:\s[^>]*)?>/gi,
227
+ " "
228
+ );
225
229
  const templateMatch = source.match(/<template[^>]*>([\s\S]*)<\/template>/i);
226
- if (templateMatch?.[1]) source = templateMatch[1];
230
+ if (templateMatch?.[1] && !findRootTag(sourceWithoutTemplates)) source = templateMatch[1];
227
231
  const tags = extractOpenTags(source);
228
232
  const styles = [
229
233
  ...extractBlocks(source, STYLE_BLOCK_PATTERN),
@@ -294,6 +298,8 @@ var STRAY_HEAD_CLOSE_PATTERN = /<\/(?:style|script)(?:\s[^>]*)?>/i;
294
298
  var MARKDOWN_CODE_FENCE_PATTERN = /```[^\r\n`]*(?:\r?\n|$)[\s\S]*?```/i;
295
299
  var ORPHAN_CSS_AT_RULE_PATTERN = /(?:^|\s)@(?:container|font-face|keyframes|layer|media|page|property|scope|supports)[^{<]*\{[\s\S]*?:[\s\S]*?\}/i;
296
300
  var ORPHAN_CSS_RULE_PATTERN = /(?:^|\s)(?:\/\*[\s\S]*?\*\/\s*)?(?:@[a-z-]+[^{}<]*|[.#][\w-]+[^{}<]*|[a-z][\w-]*(?:\s+[.#:[\w-][^{}<]*)?)\s*\{[^{}]*:[^{}]*\}/i;
301
+ var VISIBLE_MARKUP_COMMENT_PATTERN = /\/\*[\s\S]*?\*\//g;
302
+ var VISIBLE_MARKUP_COMMENT_PROTECTED_BLOCK_PATTERN = /<(style|script|template|title|noscript|pre|code|textarea|text)\b[^>]*>[\s\S]*?<\/\1(?:\s[^>]*)?>/gi;
297
303
  function findCodeFenceLeak(headWithoutValidBlocks) {
298
304
  return MARKDOWN_CODE_FENCE_PATTERN.exec(headWithoutValidBlocks)?.[0] ?? null;
299
305
  }
@@ -332,6 +338,46 @@ function findLeakedTextBeforeCompositionRoot(source, rootTag) {
332
338
  if (prefixEnd <= prefixStart) return null;
333
339
  return findLeakedTextInHeadContent(source.slice(prefixStart, prefixEnd));
334
340
  }
341
+ function findProtectedVisibleMarkupRanges(source) {
342
+ const ranges = [];
343
+ for (const match of source.matchAll(VISIBLE_MARKUP_COMMENT_PROTECTED_BLOCK_PATTERN)) {
344
+ ranges.push({ start: match.index, end: match.index + match[0].length });
345
+ }
346
+ return ranges;
347
+ }
348
+ function isInsideSourceRange(index, ranges) {
349
+ return ranges.some((range) => range.start <= index && index < range.end);
350
+ }
351
+ function isInsideHtmlTag(source, index) {
352
+ let inTag = false;
353
+ let quote = null;
354
+ for (let i = 0; i < index; i++) {
355
+ const char = source[i];
356
+ if (!inTag) {
357
+ if (char === "<") inTag = true;
358
+ continue;
359
+ }
360
+ if (quote) {
361
+ if (char === quote) quote = null;
362
+ continue;
363
+ }
364
+ if (char === '"' || char === "'") {
365
+ quote = char;
366
+ } else if (char === ">") {
367
+ inTag = false;
368
+ }
369
+ }
370
+ return inTag;
371
+ }
372
+ function findVisibleMarkupCommentLeak(source) {
373
+ const protectedRanges = findProtectedVisibleMarkupRanges(source);
374
+ for (const match of source.matchAll(VISIBLE_MARKUP_COMMENT_PATTERN)) {
375
+ if (isInsideHtmlTag(source, match.index)) continue;
376
+ if (isInsideSourceRange(match.index, protectedRanges)) continue;
377
+ return match[0];
378
+ }
379
+ return null;
380
+ }
335
381
  var coreRules = [
336
382
  // root_missing_composition_id + root_missing_dimensions
337
383
  ({ rootTag }) => {
@@ -372,6 +418,20 @@ var coreRules = [
372
418
  }
373
419
  ];
374
420
  },
421
+ // visible_markup_comment
422
+ ({ source }) => {
423
+ const snippet = findVisibleMarkupCommentLeak(source);
424
+ if (!snippet) return [];
425
+ return [
426
+ {
427
+ code: "visible_markup_comment",
428
+ severity: "error",
429
+ message: "CSS/JS block comment syntax (`/* ... */`) appears in visible HTML markup. HTML only treats `<!-- ... -->` as comments, so this renders as on-screen text.",
430
+ fixHint: "Remove the text or convert it to a real HTML comment (`<!-- ... -->`). Keep CSS comments inside `<style>` and JS comments inside `<script>`.",
431
+ snippet: truncateSnippet(snippet)
432
+ }
433
+ ];
434
+ },
375
435
  // missing_timeline_registry + timeline_registry_missing_init
376
436
  ({ source, rawSource, options }) => {
377
437
  if (options.isSubComposition || rawSource.trimStart().toLowerCase().startsWith("<template")) {
@@ -2722,6 +2782,91 @@ var compositionRules = [
2722
2782
  snippet: truncateSnippet(rootTag.raw)
2723
2783
  }
2724
2784
  ];
2785
+ },
2786
+ // root_composition_missing_duration_source
2787
+ //
2788
+ // The render engine (packages/engine/src/services/frameCapture.ts) needs a
2789
+ // positive window.__hf.duration to know how many frames to capture. GSAP
2790
+ // timelines set this automatically. Non-GSAP runtimes (CSS, WAAPI, Lottie)
2791
+ // are now auto-inferred by the runtime too (see
2792
+ // packages/core/src/runtime/init.ts resolveAdapterDurationFloorSeconds and
2793
+ // the adapters' getInferredDurationSeconds) — so data-duration is optional
2794
+ // wherever the runtime can work it out on its own.
2795
+ //
2796
+ // This rule fires for cases where the total render length is not reliably
2797
+ // determinable without an explicit data-duration:
2798
+ // - No GSAP timeline AND no data-duration AND no non-GSAP animation
2799
+ // signal at all (nothing for any adapter to discover — render fails).
2800
+ // - Three.js used with no data-duration (no discoverable AnimationClip
2801
+ // duration in this codebase's adapter — see adapters/three.ts).
2802
+ // - Any infinite CSS animation-iteration-count with no data-duration,
2803
+ // EVEN when a finite CSS animation is present alongside it. An unbounded
2804
+ // animation makes the intended total length ambiguous — the runtime will
2805
+ // infer a finite sibling's length if one exists, but that's a fallback,
2806
+ // not a declaration of intent, so we still require data-duration here.
2807
+ // (This is intentionally stricter than the runtime's own inference.)
2808
+ // Purely finite CSS/WAAPI animations and Lottie are excluded — the runtime
2809
+ // infers those unambiguously, so requiring data-duration there would be a
2810
+ // false positive against the runtime's own auto-inference. Note lint is
2811
+ // advisory by default (see shouldBlockRender) — it only blocks render under
2812
+ // --strict/--strict-all — so a strict flag here nudges toward an explicit,
2813
+ // guaranteed-correct value without failing renders that would succeed.
2814
+ // fallow-ignore-next-line complexity
2815
+ ({ rootTag, scripts, styles, tags, options }) => {
2816
+ if (options.isSubComposition) return [];
2817
+ if (!rootTag) return [];
2818
+ if (readAttr(rootTag.raw, "data-composition-id") === null) return [];
2819
+ if (readAttr(rootTag.raw, "data-duration") !== null) return [];
2820
+ const allScriptTexts = scripts.map((s) => stripJsComments(s.content));
2821
+ const hasGsapTimeline = allScriptTexts.some((t) => /gsap\.timeline\s*\(/.test(t));
2822
+ const hasRegisteredTimeline = allScriptTexts.some(
2823
+ (t) => WINDOW_TIMELINE_ASSIGN_PATTERN.test(t)
2824
+ );
2825
+ if (hasGsapTimeline && hasRegisteredTimeline) return [];
2826
+ const allCss = styles.map((s) => s.content).join("\n");
2827
+ const allInlineStyles = tags.map((t) => readAttr(t.raw, "style") || "").join("\n");
2828
+ const combinedCss = `${allCss}
2829
+ ${allInlineStyles}`.replace(/\/\*[\s\S]*?\*\//g, "");
2830
+ const usesLottie = tags.some((t) => readAttr(t.raw, "data-lottie-src") !== null) || allScriptTexts.some((t) => /lottie\.(loadAnimation)\b|__hfLottie\b/.test(t));
2831
+ const usesThree = allScriptTexts.some((t) => /\bTHREE\./.test(t));
2832
+ const usesWaapi = allScriptTexts.some((t) => /\.animate\s*\(\s*[[{$A-Za-z_]/.test(t));
2833
+ const hasCssAnimationName = /\banimation(?:-name)?\s*:/.test(combinedCss);
2834
+ const hasInfiniteCssAnimation = /\banimation(?:-iteration-count)?\s*:[^;{}]*(?<![\w-])infinite(?![\w-])/.test(combinedCss);
2835
+ const hasAnyNonGsapSignal = usesLottie || usesThree || usesWaapi || hasCssAnimationName;
2836
+ if (!hasAnyNonGsapSignal) {
2837
+ return [
2838
+ {
2839
+ code: "root_composition_missing_duration_source",
2840
+ severity: "error",
2841
+ message: 'Root composition has no data-duration, no GSAP timeline, and no CSS/WAAPI/Lottie/Three.js animation for the runtime to infer a duration from. The render engine cannot determine how long to capture and will fail with "Composition has zero duration".',
2842
+ fixHint: 'Add data-duration="<seconds>" to the root element, or add a paused GSAP timeline registered on window.__timelines.',
2843
+ snippet: truncateSnippet(rootTag.raw)
2844
+ }
2845
+ ];
2846
+ }
2847
+ if (usesThree) {
2848
+ return [
2849
+ {
2850
+ code: "root_composition_missing_duration_source",
2851
+ severity: "error",
2852
+ message: `Root composition uses Three.js with no data-duration. The runtime cannot discover a Three.js scene's duration automatically (no AnimationClip/AnimationMixer inspection) \u2014 render will fail with "Composition has zero duration".`,
2853
+ fixHint: 'Add data-duration="<seconds>" to the root element.',
2854
+ snippet: truncateSnippet(rootTag.raw)
2855
+ }
2856
+ ];
2857
+ }
2858
+ if (hasInfiniteCssAnimation && !usesLottie && !usesWaapi) {
2859
+ return [
2860
+ {
2861
+ code: "root_composition_missing_duration_source",
2862
+ severity: "error",
2863
+ message: 'Root composition uses a CSS animation with animation-iteration-count: infinite and no data-duration, so the intended total length is ambiguous. If a finite animation is also present the runtime infers that length; with no finite source the render fails with "Composition has zero duration". Declare the intended length explicitly.',
2864
+ fixHint: 'Add data-duration="<seconds>" to the root element with the intended total length.',
2865
+ snippet: truncateSnippet(rootTag.raw)
2866
+ }
2867
+ ];
2868
+ }
2869
+ return [];
2725
2870
  }
2726
2871
  ];
2727
2872
 
@@ -3314,6 +3459,11 @@ import { existsSync, readFileSync, readdirSync } from "fs";
3314
3459
  import { dirname, extname, isAbsolute, join, posix, relative, resolve } from "path";
3315
3460
  import { decodeUrlPathVariants } from "@hyperframes/parsers/composition";
3316
3461
  import { rewriteAssetPath } from "@hyperframes/parsers/asset-paths";
3462
+ import { checkSubCompositionUsability } from "@hyperframes/parsers/sub-composition-validity";
3463
+ import { parseHTML } from "linkedom";
3464
+ function parseSubCompHtml(html) {
3465
+ return parseHTML(html).document;
3466
+ }
3317
3467
  var AUDIO_EXTENSIONS = /* @__PURE__ */ new Set([".mp3", ".wav", ".aac", ".ogg", ".m4a", ".flac", ".opus"]);
3318
3468
  var STYLE_BLOCK_RE = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
3319
3469
  var OPEN_TAG_RE = /<([a-z][\w:-]*)(\s[^<>]*?)?>/gi;
@@ -3475,7 +3625,8 @@ async function lintProject(projectDir) {
3475
3625
  ...lintMissingLocalAsset(projectDir, allHtmlSources),
3476
3626
  ...lintTextureMaskAssetNotFound(projectDir, allHtmlSources),
3477
3627
  ...lintMultipleRootCompositions(projectDir),
3478
- ...lintDuplicateAudioTracks(allHtmlSources)
3628
+ ...lintDuplicateAudioTracks(allHtmlSources),
3629
+ ...lintMissingOrEmptySubComposition(projectDir, rootHtml)
3479
3630
  ];
3480
3631
  if (projectFindings.length > 0) {
3481
3632
  for (const finding of projectFindings) {
@@ -3693,6 +3844,52 @@ function lintDuplicateAudioTracks(htmlSources) {
3693
3844
  }
3694
3845
  return findings;
3695
3846
  }
3847
+ function lintMissingOrEmptySubComposition(projectDir, rootHtml) {
3848
+ const checked = /* @__PURE__ */ new Map();
3849
+ const visited = /* @__PURE__ */ new Set();
3850
+ const walk = (html) => {
3851
+ const compositionSrcRe = /<[^>]*\bdata-composition-src\s*=\s*["']([^"']+)["'][^>]*>/gi;
3852
+ const scannable = maskNonScannableRanges(html);
3853
+ let match;
3854
+ while ((match = compositionSrcRe.exec(scannable)) !== null) {
3855
+ const srcPath = (match[1] ?? "").trim();
3856
+ if (!srcPath) continue;
3857
+ if (/^__[A-Z_]+__$/.test(srcPath)) continue;
3858
+ const filePath = resolve(projectDir, srcPath);
3859
+ if (visited.has(filePath)) continue;
3860
+ visited.add(filePath);
3861
+ if (!existsSync(filePath)) {
3862
+ if (!checked.has(srcPath)) {
3863
+ checked.set(srcPath, { srcPath, problem: "the file does not exist" });
3864
+ }
3865
+ continue;
3866
+ }
3867
+ const fileHtml = readFileSync(filePath, "utf-8");
3868
+ const validity = checkSubCompositionUsability(fileHtml, parseSubCompHtml);
3869
+ if (!validity.ok) {
3870
+ if (!checked.has(srcPath)) {
3871
+ checked.set(srcPath, {
3872
+ srcPath,
3873
+ problem: validity.detail ?? "the file is empty or could not be parsed"
3874
+ });
3875
+ }
3876
+ continue;
3877
+ }
3878
+ walk(fileHtml);
3879
+ }
3880
+ };
3881
+ walk(rootHtml);
3882
+ const findings = [];
3883
+ for (const { srcPath, problem } of checked.values()) {
3884
+ findings.push({
3885
+ code: "missing_or_empty_sub_composition",
3886
+ severity: "error",
3887
+ message: `data-composition-src references "${srcPath}", but ${problem}.`,
3888
+ fixHint: `Fix this before rendering \u2014 the render pre-flight rejects unusable sub-compositions. Write valid HTML into "${srcPath}" \u2014 it needs a <template> or <body> containing an element with data-composition-id, data-width, and data-height. Preview/studio still tolerates and skips the scene while you author it. If a scene-authoring step is still running, wait for it to finish before referencing the file, or re-run the step that generates it.`
3889
+ });
3890
+ }
3891
+ return findings;
3892
+ }
3696
3893
  export {
3697
3894
  lintHyperframeHtml,
3698
3895
  lintMediaUrls,