* fix(core,producer,cli): pre-flight validation for empty/malformed sub-compositions
The #1 render failure bucket in production telemetry (PostHog project 356858,
dashboard 1783183 "HyperFrames — Bottom-Line & Activation"; ~65-69K
occurrences / ~27-28K affected users over 30 days, ~80% via AI-agent
authoring flows) is a `data-composition-src` reference pointing at a scene
file that is empty, malformed, or missing.
Root cause, traced end-to-end:
- The literal error "Composition HTML is empty or could not be parsed: <path>"
is real (not a PostHog paraphrase) — thrown by a since-reverted guard in
packages/core/src/compiler/inlineSubCompositions.ts (#1364), then changed to
a silent skip in #1678 to avoid aborting renders on partial content during
authoring. #1629 added per-assembler guards for 3 skill workflows
(product-launch-video, faceless-explainer, pr-to-video), but general-video
and hand-authored flows — where the dominant filename `scene-title.html`
(40K+/68K of the bucket) originates — have no assembler and thus no guard.
#1678 assumed the assembler guards from #1629 covered this pre-render; they
only covered 3 of the many authoring flows.
- On current `main`, an empty/malformed data-composition-src file no longer
crashes or throws during render — it's silently dropped by the tolerant
inliner. Reproduced locally: `hyperframes render` on a project with an
empty scene-title.html "succeeds" after ~93s (two 45s
pollSubCompositionTimelines timeouts) with the scene silently missing from
the output video. `hyperframes validate` also reports "No console errors"
for the same broken project.
- The raw `Cannot destructure property 'firstElementChild' of
'documentElement' as it is null` crash reproduces directly against
linkedom (the DOMParser polyfill packages/cli/src/utils/dom.ts installs in
the real CLI runtime) for empty and non-HTML input — confirmed with a
standalone repro script, not just inferred. jsdom/happy-dom (used in this
repo's own test environment) are spec-compliant and never produce a null
documentElement, which is why this needed a linkedom-specific test file.
Fix:
- New shared helper `checkSubCompositionUsability`
(packages/core/src/compiler/subCompositionValidity.ts) is the single
source of truth for "is this data-composition-src file usable" — mirrors
the inliner's own parse/template/body logic so all callers agree.
- `inlineSubCompositions.ts` (preview/studio bundling) now uses the shared
helper internally but keeps its #1678 tolerant skip-and-continue behavior
unchanged — mid-authoring iteration on a partial project must keep
working. `onMissingComposition` now also receives a human-readable reason.
- New render-only pre-flight (`assertSubCompositionsUsable` in
packages/producer/src/services/htmlCompiler.ts) walks every
data-composition-src reference (including nested ones, root-relative,
matching parseSubCompositions' own resolution) before any compilation
work starts, and throws naming every offending file at once. This is
unconditional — not gated behind --strict — because a render that
silently drops a scene is strictly worse than one that refuses to start.
Confirmed locally: render now fails in ~0.4s with an actionable message
instead of "succeeding" after 93s with a missing scene.
- New `hyperframes lint` rule `missing_or_empty_sub_composition`
(packages/cli/src/utils/lintProject.ts) surfaces the same check as a
file-scoped, actionable lint error (already unconditional — lint exits 1
on any error).
- `hyperframes validate` now also runs this check before launching a
browser, so it no longer reports "No console errors" for a project with a
broken sub-composition.
- `packages/core/src/parsers/htmlParser.ts`: guarded every
`documentElement`-may-be-null access (parseHtml, updateElementInHtml,
addElementToHtml, removeElementFromHtml, extractCompositionMetadata,
validateCompositionHtml) with a new typed `CompositionHtmlParseError` (or,
for validateCompositionHtml's collect-and-report contract, a typed
validation failure) instead of a raw crash.
Tests: empty file, whitespace-only, malformed/non-HTML, missing file, nested
sub-compositions (both happy path and broken-grandchild), and the happy path
— at the shared-helper, lint, and render pre-flight layers.
Not changed: the AI-agent authoring skills (skills/*). general-video and
hand-authored flows have no assemble-index.mjs equivalent to guard, so the
fix is at the CLI/render layer instead — flow-agnostic, covers every
authoring path, and the skills' existing "run lint/validate and stop on
failure" guidance now actually catches this class of mistake once run.
Not run in this environment: the producer package's full regression-harness
test suite (`bun test` in packages/producer) — it performs heavy real
rendering (S3 asset downloads, Google Fonts fetches, full video encodes) and
did not complete in a reasonable time in this sandbox. Verified instead via
the targeted test file for all touched code (76/76 passing), whole-repo
typecheck/build/oxlint, `fallow audit` (complexity/duplication/dead-code
gate, clean), and manual end-to-end CLI runs (render/lint/validate) against
reproduction projects, including a nested sub-composition scenario. CI
should run the full producer suite before merge.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* refactor(parsers,lint): port empty-composition pre-flight to extracted packages
Rebased onto main, which extracted @hyperframes/lint from core (lint depends
only on parsers, not core). Relocate checkSubCompositionUsability from core to
@hyperframes/parsers so both core (inliner) and lint can consume it without a
core<->lint cycle; core keeps a @deprecated re-export shim.
Correctness fixes from code review:
- checkSubCompositionUsability now returns "no-composition-root" when the
<template>/<body> content has no [data-composition-id] element (previously
a marker-free placeholder body passed both guards).
- lint's missing/empty sub-composition rule now only checks files reachable
via data-composition-src from the root (matching render pre-flight), instead
of a raw filesystem walk that false-positived on orphaned files.
- drop `as string` cast in inlineSubCompositions in favor of an explicit
null guard (per CLAUDE.md).
Review-comment items:
- move EmptyCompositionError JSDoc above the class (was above the adapter fn).
- correct stale circular-ref comment to match actual silent-skip behavior.
- rewrite self-contradicting lint message ("silently drop") to describe the
new loud render-pre-flight abort.
- add the __PLACEHOLDER__ (/^__[A-Z_]+__$/) skip to the render pre-flight so
it agrees with lint.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* refactor: extract @hyperframes/studio-server package from core
Moves all studio-api routes, helpers, and Hono server wiring from
packages/core/src/studio-api/ into a new standalone packages/studio-server
package (@hyperframes/studio-server).
Core keeps thin re-export stubs at @hyperframes/core/studio-api and the
subpath helpers (screenshot-clip, draft-markers, etc.) for backward
compatibility. Consumer imports (cli studioServer, vite adapter/config,
producer htmlCompiler, studio manualEditsTypes) are updated to import from
@hyperframes/studio-server directly.
Also exports rewriteInlineStyleAssetUrls from @hyperframes/core root (was
in compiler/rewriteSubCompPaths.ts but not re-exported), required by
@hyperframes/studio-server/helpers/subComposition.
Removes postcss-selector-parser from @hyperframes/core dependencies (moved
to @hyperframes/studio-server which owns the routes that used it).
Depends on @hyperframes/parsers (PR #1755).
* fix(ci): add parsers+studio-server to Dockerfile and build before preview tests
* fix(ci): build @hyperframes/studio-server before Test and studio load smoke
Studio's vite.config.ts imports @hyperframes/studio-server, which resolves
via its "node" export condition to built dist. The Test and studio-load-smoke
jobs only built parsers + core, so esbuild's config load failed to resolve the
package entry. Build studio-server too.
* fix(studio): repoint sdkCutoverParity test import to studio-server
sourceMutation moved from core's studio-api to @hyperframes/studio-server;
the test still imported the deleted core path. This was masked while studio's
vite.config failed to load (couldn't resolve studio-server); now that the
config loads, the test runs and the stale import surfaced.
* refactor: extract @hyperframes/lint package from core
Moves all lint rules, hyperframeLinter, lintProject, and related types
from packages/core/src/lint/ into a new standalone packages/lint package.
Core keeps a thin re-export stub at @hyperframes/core/lint for backward
compatibility. Consumer imports (cli lint command, producer hyperframeLint)
are updated to import from @hyperframes/lint directly.
Depends on @hyperframes/parsers (PR #1755).
* fix: restore postcss-selector-parser in core (sourceMutation.ts still uses it)
* fix(ci): add parsers+lint to Dockerfile and build before preview tests
* chore: update bun.lock after restoring postcss-selector-parser dep
* test(cli): update lintProject test for string-dir signature from @hyperframes/lint
* refactor(core): single-source the lint engine in @hyperframes/lint
Delete core's byte-identical copy of the lint rule engine and re-point
staticGuard at @hyperframes/lint, so the render-time render-gate and the
studio preview share one rule engine instead of two copies that could
silently diverge. Back-compat preserved via the @hyperframes/core/lint stub.
Addresses review feedback on the dual-copy footgun.