Skip to content

Local-font embedding: file:// misclassified, and an unanchored replaceAll can corrupt unrelated URLs #3369

Closed

Description

@miguel-heygen

Describe the bug

Two defects in the local-font embedding pass in packages/producer/src/services/htmlCompiler.ts.
The second is the serious one: an unanchored String.replaceAll over the entire compiled
document can corrupt unrelated URLs.

1. file: is not excluded from the local-font pattern

// packages/producer/src/services/htmlCompiler.ts:1658
const LOCAL_FONTFACE_URL_RE = /url\(["']?(?!data:|https?:\/\/)([^"')]+)["']?\)/gi;

The negative lookahead skips data: and http(s):// but not file:. So a
@font-face { src: url("file:///abs/path/font.ttf") } is classified as a local project
path
and passed to resolve(projectDir, localPath). Since the captured string starts with
f, not /, it is treated as relative and resolved into a path like
<projectDir>/file:/abs/path/font.ttf, which does not exist.

The subsequent read throws into an empty catch (line 1731):

} catch {
  // File read or compression failed — keep the original path
}

so the failure is silent, the rule is left untouched, and the browser then refuses the
file:// fetch with net::ERR_FAILED. The composition renders with the CSS fallback font
and render exits 0.

2. The embed replaces by bare substring across the whole document

// packages/producer/src/services/htmlCompiler.ts:1729
result = result.replaceAll(localPath, dataUri);

localPath here is a bare relative path such as fonts/testfont.ttf, with no surrounding
url(...) or quotes. Every other replacement in this file anchors on the surrounding
syntax:

// :1310
result = result.replaceAll(`"${url}"`, `"${relPath}"`).replaceAll(`'${url}'`, `'${relPath}'`);
// :1434
(h, url, rel) => h.replaceAll(`url(${url})`, `url(${rel})`),
// :1654
(h, url, relPath) => h.replaceAll(`url(${url})`, `url("${relPath}")`),

Line 1729 is the only unanchored one, so it matches that path text anywhere in the
compiled HTML — including inside a longer URL that merely ends with the same characters.

Concretely: a document containing both

  • url("fonts/testfont.ttf") — correctly embedded, and
  • url("file:///abs/path/fonts/testfont.ttf") — the untouched rule from defect 1

produces, after the global replace, a corrupted second rule of the form
url("file:///abs/path/data:font/woff2;base64,..."). This was observed in the compiled
debug output under .debug/<jobId>/compiled/index.html while investigating defect 1.

The collision does not require file:// to be involved. Any two paths where one is a
trailing substring of the other — img/logo.png and assets/img/logo.png, a/font.ttf
and b/a/font.ttf — are enough for the shorter one's embed to rewrite text inside the
longer one.

Expected behavior

An absolute file:// font src is either embedded or left alone, and either way the
compiler does not modify text belonging to a different URL.

Actual behavior

The file:// rule is silently skipped, and if a relative font with a colliding path
suffix is embedded in the same document, the file:// rule is rewritten into a URL that
is neither the original nor a valid data URI.

Suggested fix

Both are small and independent:

  1. Add file: to the lookahead in LOCAL_FONTFACE_URL_RE, so an absolute file:// src is
    not mistaken for a project-relative path. Consider also surfacing the swallowed failure
    at line 1731 — an empty catch on a font read means a composition can silently render
    with the wrong typeface.
  2. Replace by matched url() occurrence rather than by bare substring, matching what
    lines 1310, 1434 and 1654 already do. The regex at line 1699 already yields match
    positions, so the anchored form is available without new parsing.

Fix 2 matters more than the file:// case that surfaced it: today the blast radius is any
project where one font path is a suffix of another.

Environment

hyperframes  0.8.4
Affected     packages/producer/src/services/htmlCompiler.ts:1658, 1729, 1731

Activity

  1. added a commit that references this issue on Aug 21, 2026
    e1191ed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions