@hyperframes/lint 0.8.49 → 0.8.50

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
@@ -45,6 +45,8 @@ type HyperframeLinterOptions = {
45
45
  * render time — the font will silently fall back to whatever the OS provides.
46
46
  */
47
47
  distributed?: boolean;
48
+ /** Who is running the lint: Studio raises the structure rules to errors, the CLI keeps them warnings. */
49
+ host?: "studio" | "cli";
48
50
  };
49
51
 
50
52
  /**
@@ -90,6 +92,6 @@ interface ProjectLintResult {
90
92
  totalWarnings: number;
91
93
  totalInfos: number;
92
94
  }
93
- declare function lintProject(projectDir: string, entryFile?: string): Promise<ProjectLintResult>;
95
+ declare function lintProject(projectDir: string, entryFile?: string, hostOptions?: Pick<HyperframeLinterOptions, "host">): Promise<ProjectLintResult>;
94
96
 
95
97
  export { type HyperframeLintFinding, type HyperframeLintResult, type HyperframeLintSeverity, type HyperframeLinterOptions, LINT_RULE_COUNT, LINT_RULE_GROUP_COUNTS, type LintTimings, type ProjectLintResult, lintHyperframeHtml, lintMediaUrls, lintProject, shouldBlockRender };
package/dist/index.js CHANGED
@@ -5322,6 +5322,159 @@ var slideshowRules = [
5322
5322
  }
5323
5323
  ];
5324
5324
 
5325
+ // src/rules/structure.ts
5326
+ import {
5327
+ isSubCompositionHost,
5328
+ topLevelElements,
5329
+ trackKindOf
5330
+ } from "@hyperframes/parsers/top-level-elements";
5331
+ var INLINE_TEXT_TAGS = /* @__PURE__ */ new Set([
5332
+ "br",
5333
+ "wbr",
5334
+ "b",
5335
+ "i",
5336
+ "u",
5337
+ "s",
5338
+ "em",
5339
+ "strong",
5340
+ "small",
5341
+ "sub",
5342
+ "sup",
5343
+ "mark",
5344
+ "span",
5345
+ "a",
5346
+ "code",
5347
+ "abbr"
5348
+ ]);
5349
+ var OPAQUE_TAGS = /* @__PURE__ */ new Set([
5350
+ "svg",
5351
+ "canvas",
5352
+ "picture",
5353
+ "video",
5354
+ "audio",
5355
+ "select",
5356
+ "textarea",
5357
+ "iframe",
5358
+ "object"
5359
+ ]);
5360
+ var NON_LAYOUT_TAGS = /* @__PURE__ */ new Set(["style", "script", "template", "noscript"]);
5361
+ var MEDIA_TAGS = /* @__PURE__ */ new Set(["img", "video", "audio"]);
5362
+ var NODE_ATTRS = [
5363
+ "id",
5364
+ "class",
5365
+ "data-start",
5366
+ "data-duration",
5367
+ "data-end",
5368
+ "data-track",
5369
+ "data-track-index",
5370
+ "data-track-kind",
5371
+ "data-composition-id",
5372
+ "data-composition-src"
5373
+ ];
5374
+ function toNode(open) {
5375
+ const attrs = {};
5376
+ for (const name of NODE_ATTRS) attrs[name] = readDecodedAttr(open.raw, name) ?? void 0;
5377
+ return { tag: open.name, attrs, children: [], open };
5378
+ }
5379
+ function enclosing(stack, root, index) {
5380
+ let top = stack.at(-1);
5381
+ while (top && stack.length > 1 && index >= top.end) {
5382
+ stack.pop();
5383
+ top = stack.at(-1);
5384
+ }
5385
+ return top?.node ?? root;
5386
+ }
5387
+ function buildTree(ctx) {
5388
+ const { rootTag } = ctx;
5389
+ const rootEnd = rootTag?.closeIndex;
5390
+ if (!rootTag || rootEnd == null) return null;
5391
+ const root = toNode(rootTag);
5392
+ const stack = [{ node: root, end: rootEnd }];
5393
+ for (const tag of ctx.tags.filter((t) => t.index > rootTag.index && t.index < rootEnd)) {
5394
+ const node = toNode(tag);
5395
+ enclosing(stack, root, tag.index).children.push(node);
5396
+ if (tag.closeIndex != null) stack.push({ node, end: tag.closeIndex });
5397
+ }
5398
+ return root;
5399
+ }
5400
+ function hasNestedStructure(node) {
5401
+ if (OPAQUE_TAGS.has(node.tag)) return null;
5402
+ for (const child of node.children) {
5403
+ if (NON_LAYOUT_TAGS.has(child.tag)) continue;
5404
+ if (!INLINE_TEXT_TAGS.has(child.tag)) return child;
5405
+ const deeper = hasNestedStructure(child);
5406
+ if (deeper) return deeper;
5407
+ }
5408
+ return null;
5409
+ }
5410
+ var describe = (node) => `<${node.tag}${node.attrs.id ? ` id="${node.attrs.id}"` : ""}>`;
5411
+ function nestedStructureFindings(rows, severity) {
5412
+ return rows.flatMap((row) => {
5413
+ const nested = isSubCompositionHost(row) ? null : hasNestedStructure(row);
5414
+ if (!nested) return [];
5415
+ return [
5416
+ {
5417
+ code: "nested_structure_needs_subcomposition",
5418
+ severity,
5419
+ message: `${describe(row)} is a timeline element that contains nested ${describe(nested)}. The timeline shows one row per top-level element, and the root composition is built only from sub-compositions.`,
5420
+ elementId: row.attrs.id,
5421
+ fixHint: `Move ${describe(row)} and its contents into a sub-composition file and mount it with data-composition-src.`
5422
+ }
5423
+ ];
5424
+ });
5425
+ }
5426
+ function missingDurationFindings(rows, severity) {
5427
+ const intendedClip = (row) => !MEDIA_TAGS.has(row.tag) || row.attrs["data-start"] !== void 0 || row.attrs["data-track-index"] !== void 0;
5428
+ return rows.filter(
5429
+ (row) => row.attrs["data-duration"] === void 0 && row.attrs["data-end"] === void 0 && !isSubCompositionHost(row) && intendedClip(row)
5430
+ ).map((row) => {
5431
+ const isMedia = MEDIA_TAGS.has(row.tag);
5432
+ return {
5433
+ code: isMedia ? "media_missing_duration" : "timeline_element_missing_timing",
5434
+ severity,
5435
+ message: isMedia ? `${describe(row)} is media on the timeline without data-duration, so its clip has no length.` : `${describe(row)} is a timeline element without data-duration, so the timeline cannot draw where it ends.`,
5436
+ elementId: row.attrs.id,
5437
+ fixHint: `Add data-duration (in seconds) to ${describe(row)}.`
5438
+ };
5439
+ });
5440
+ }
5441
+ function captionFindings(rows, severity) {
5442
+ const captionRows = rows.filter((row) => trackKindOf(row).kind === "captions");
5443
+ const findings = captionRows.filter((row) => trackKindOf(row).source === "legacy-captions").map((row) => ({
5444
+ code: "caption_track_kind_missing",
5445
+ severity,
5446
+ message: `${describe(row)} looks like captions but is not marked as such, so the timeline cannot group it on the caption track.`,
5447
+ elementId: row.attrs.id,
5448
+ fixHint: `Add data-track-kind="captions" to ${describe(row)}.`
5449
+ }));
5450
+ const lanes = new Set(
5451
+ captionRows.flatMap((row) => row.attrs["data-track-index"] ?? row.attrs["data-track"] ?? [])
5452
+ );
5453
+ if (lanes.size > 1) {
5454
+ findings.push({
5455
+ code: "multiple_caption_tracks",
5456
+ severity,
5457
+ message: `Captions are spread across ${lanes.size} timeline tracks. Keep all captions on one track.`,
5458
+ fixHint: "Give every caption element the same data-track-index."
5459
+ });
5460
+ }
5461
+ return findings;
5462
+ }
5463
+ var structureRules = [
5464
+ (ctx) => {
5465
+ if (ctx.options.isSubComposition) return [];
5466
+ const root = buildTree(ctx);
5467
+ if (!root) return [];
5468
+ const severity = ctx.options.host === "studio" ? "error" : "warning";
5469
+ const rows = topLevelElements(root);
5470
+ return [
5471
+ ...nestedStructureFindings(rows, severity),
5472
+ ...missingDurationFindings(rows, severity),
5473
+ ...captionFindings(rows, severity)
5474
+ ];
5475
+ }
5476
+ ];
5477
+
5325
5478
  // src/hyperframeLinter.ts
5326
5479
  var RULE_GROUPS = [
5327
5480
  { group: "core", rules: coreRules },
@@ -5332,7 +5485,8 @@ var RULE_GROUPS = [
5332
5485
  { group: "adapters", rules: adapterRules },
5333
5486
  { group: "textures", rules: textureRules },
5334
5487
  { group: "fonts", rules: fontRules },
5335
- { group: "slideshow", rules: slideshowRules }
5488
+ { group: "slideshow", rules: slideshowRules },
5489
+ { group: "structure", rules: structureRules }
5336
5490
  ];
5337
5491
  var LINT_RULE_COUNT = RULE_GROUPS.reduce((n, g) => n + g.rules.length, 0);
5338
5492
  var LINT_RULE_GROUP_COUNTS = Object.fromEntries(
@@ -5794,7 +5948,7 @@ function resolveCssAssetCandidates(projectDir, url, htmlCompSrcPath, cssRootRela
5794
5948
  }
5795
5949
  return resolveLocalAssetCandidates(projectDir, url);
5796
5950
  }
5797
- async function lintProject(projectDir, entryFile) {
5951
+ async function lintProject(projectDir, entryFile, hostOptions = {}) {
5798
5952
  const indexPath = entryFile ? resolve(entryFile) : resolve(projectDir, "index.html");
5799
5953
  if (entryFile && !isWithinProjectRoot(projectDir, indexPath)) {
5800
5954
  throw new Error(`Explicit lint entry is outside the project directory: ${entryFile}`);
@@ -5807,6 +5961,7 @@ async function lintProject(projectDir, entryFile) {
5807
5961
  let totalInfos = 0;
5808
5962
  const rootHtml = readFileSync(indexPath, "utf-8");
5809
5963
  const rootResult = await lintHyperframeHtml(rootHtml, {
5964
+ ...hostOptions,
5810
5965
  filePath: indexPath,
5811
5966
  externalStyles: collectExternalStyles(projectDir, rootHtml, rootCompSrcPath)
5812
5967
  });
@@ -5838,6 +5993,7 @@ async function lintProject(projectDir, entryFile) {
5838
5993
  allHtmlSources.push({ html, compSrcPath });
5839
5994
  if (isSnippetFragment(html)) continue;
5840
5995
  const result = await lintHyperframeHtml(html, {
5996
+ ...hostOptions,
5841
5997
  filePath,
5842
5998
  isSubComposition: true,
5843
5999
  externalStyles: collectExternalStyles(projectDir, html, compSrcPath)