* feat(studio): apply a style to a run of characters
Styling text in a composition cannot be done by wrapping a DOM range in a
span. That is three lines, and then every interesting case is a special
case: recolouring nests spans that shadow each other, removing a style
cannot reach the ancestor that set it, and styling across an existing run's
boundary has to split it. Each fix is a new branch and the branches
interact.
So the element is read into a flat list of styled runs, the style is applied
to a span of characters in that list, and the element is rebuilt from it.
Replacing, removing, splitting and merging stop being cases: the rebuild
emits one span per distinct run and cannot nest or duplicate, whatever was
there before.
Selection offsets count UTF-16 units, so a boundary can land between the
halves of an emoji; the applied range widens to whole characters. A colour
an ancestor overpaints is mirrored into the fill, because a colour that does
not paint reads to the user as a colour that did not save.
The toolbar that drives this arrives with the editor in the next change.
* fix(studio): harden inline text styling boundaries
* fix(studio): align inline styling with persistence
* test(studio): pin inline identity delimiters
* feat(core): sanitize rich text on the way into a composition
Studio's patch vocabulary was inline-style, attribute, html-attribute and
text-content. text-content assigns textContent, and the text-field model
escapes markup on the way out and refuses a change in child structure, so a
styled span had no route into a composition file.
Adds a rich-text operation with one, guarded by a single sanitizer called on
both ends of the trip: in the browser so the preview shows what will be
saved, and on the server because that is where the file is written. Tags and
style properties are a small allowlist, and an unexpected tag loses its
formatting rather than its words. Spans an edit adds get their ids in the
same write, so a follow-up write cannot race it.
No UI yet — this is the persistence contract the editor is built on.
* fix(core): document and test the sanitizer boundary
* fix(core): harden rich text sanitizer traversal
* refactor: make @hyperframes/lint depend only on parsers, not core
Relocates the leaf utilities lint pulled from core — URL/asset-path helpers,
font aliases, and the slideshow manifest parser — into the standalone
@hyperframes/parsers base, and drops @hyperframes/core from lint's
dependencies. Core keeps back-compat re-export stubs at the old paths, so
producer/studio/cli are unchanged.
Why: lint was the lightweight validator from #1749, but depending on core
transitively pulled studio-server (hono) and bpm-detective — irrelevant to
linting. Now installing @hyperframes/lint pulls only parsers + postcss, and
the core<->lint dependency cycle is gone.
- parsers main entry stays browser-safe (pure utils only); the node:path
asset helpers live behind the new @hyperframes/parsers/asset-paths subpath
- slideshow parser exposed via @hyperframes/parsers/slideshow
* feat(lint): add browser entry; harden CSS url() regex (ReDoS)
@hyperframes/lint/browser — a fully client-side rule engine (lintHyperframeHtml,
lintMediaUrls, shouldBlockRender) with zero node: builtins, so browser-only
editors can validate compositions with no Node.js and no server round-trip.
Closes the browser-validation ask on #1749.
- shouldBlockRender extracted from the fs-bound project.ts into its own pure
module so the browser entry stays node-free
- pure composition primitives (data types, font aliases, URL helper) exposed via
a new recast-free @hyperframes/parsers/composition subpath, so the browser
bundle tree-shakes out the GSAP/recast machinery (verified: esbuild
platform=browser bundles with 0 node builtins)
- lint built with a platform:browser tsup pass — compile-time guarantee the
browser entry never pulls a node builtin
- harden CSS_URL_RE against polynomial ReDoS (CodeQL js/polynomial-redos);
behavior-preserving, verified against existing tests + an old/new parity check
- parsers/lint marked sideEffects:false
core runs its suite via `vitest run` (include: src/**/*.test.ts), so the bun:test
import broke vitest collection ("Module bun:test has been externalized"), turning
core CI red on main and on every open PR (which build against the merge ref).
vitest's describe/it/expect are API-compatible — no test changes needed.
Extract cssAttrSelector to packages/core/src/utils/cssSelector.ts and
use it (or CSS.escape for browser-side code) at all 12 sites that
previously interpolated raw user-authored values into querySelector
attribute selectors. A " in a composition ID, script src, or
data-start value would produce a malformed selector that throws.
Node-side (core compiler/parser): uses the shared cssAttrSelector.
Browser-side (runtime, studio): uses native CSS.escape().
Supersedes #1568 which fixed only the 3 bundler sites.