Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions docs/features/site-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ CSS declarations containing `var()`, `env()`, `clamp()`, `min()`, or `max()` pas
- **Per-stylesheet import modes:** each top-level linked stylesheet either converts to editable style rules (default) or imports verbatim as a page-scoped `SiteFile` stylesheet (`options.stylesheetModes`, picked in the Review step). There are no generated scope classes — page isolation comes from the kept file's runtime scope.
- `commitImportPlan(plan, adapter)` — uploads assets, then wraps all store writes in a single `adapter.commit` call → one Cmd+Z reverts the whole import.
- Static imports load the current CMS draft into the editor store on demand when launched outside `/admin/site`; if no draft exists, the modal creates an empty site before analysis.
- Conflict resolution: rename with a numeric suffix (default), overwrite, skip, or custom-rename — per page slug, per class name, per design token (colour / font CSS variable), and per divergent cross-stylesheet class definition, with category-level bulk actions. Token renames rewrite `var(--x)` references so imports stay faithful.
- Conflict resolution: rename with a numeric suffix (default), overwrite, skip, or custom-rename — per page slug, per class name, per design token (colour / font CSS variable), and per divergent cross-stylesheet class definition, with category-level bulk actions. Token renames rewrite `var(--x)` references, and declarations of the same variable in other rules, so imports stay faithful.
- What imports: pages, linked CSS plus unconditional local CSS `@import` graphs, `kind:'class'` and `kind:'ambient'` style rules, stylesheets kept as page-scoped files, `@keyframes`, uploadable media/font files, root CSS color tokens, root CSS font tokens, `@font-face` families, known external font stylesheet imports, safe extra HTML attributes on base modules, body-level classes/attributes/style metadata, bare DOM text nodes in mixed content, and executable HTML scripts as page-scoped runtime scripts. Module-script imports from known npm CDNs are rewritten to bare package imports and added to the site dependency manifest.
- CMS bundle import preserves selected exported tables, rows, optional site shell, media, folders, and redirects using the same merge strategies as site transfer (`replace`, `merge-add`, `merge-overwrite`).
- HTML forms import through the shared HTML importer as first-class form primitives (`base.form`, controls, labels, submit buttons), not as custom containers.
Expand Down Expand Up @@ -46,6 +46,8 @@ src/core/siteImport/
├── applyAssetRewrites.ts — patch fragment props + CSS/@keyframes url() with new media URLs (post-upload)
├── linkRewrite.ts — rewrite intra-site <a href> to cms:page:<id> refs
├── conflicts.ts — detect page-slug + class-name + design-token collisions; apply resolutions (incl. var(--x) rewrites)
├── tokenNames.ts — settle the emitted name of every imported colour + font token (authored → emitted renames)
├── varRefs.ts — rename a custom property across imported rules, inline styles and @keyframes (var(--x) refs + --x declarations)
├── adapter.ts — SiteImportAdapter + SiteImportTransaction interfaces (the ONE transaction contract; the editor store implements it directly)
├── paths.ts — dirname/joinPaths for FileMap-relative path resolution
├── planCss.ts — single CSS-source parse path (external sheets + per-page inline <style>) feeding shared plan accumulators
Expand Down Expand Up @@ -208,9 +210,9 @@ interface ImportScript {
| **HTML attributes** | Safe extra attributes on ordinary elements (`id`, ARIA, `role`, custom attrs, `data-*`, etc.) | Stored as `props.htmlAttributes` on base container/text/link/button/image modules so CSS selectors, anchors, classic scripts, accessibility attributes, and template runtime hooks such as `data-bg-src`, `data-aos`, and `data-bs-*` survive import. Users edit the same bag in the Properties panel's Attributes view. `class` is handled by the selector registry, inline `style` becomes `node.inlineStyles`, event handlers are stripped, and reserved Instatic/editor `data-*` names are not imported. Local asset URLs inside these attributes are uploaded and rewritten. |
| **Style rules** | All rules from linked CSS files and their unconditional local `@import` graph | `expandLinkedCssImports` follows bundled local CSS imports first, then `cssToStyleRules` maps selector declaration blocks to `NewStyleRule` entries (class or ambient kind) and stores supported stylesheet-level rules such as `@keyframes` as ambient raw CSS rules |
| **Media** | Uploadable images (JPEG, PNG, GIF, WebP, AVIF, SVG), videos, and fonts — including unreferenced files in the bundle | `buildAssetPlan` collects referenced assets and sweeps uploadable unreferenced files. Source companions such as `.scss`, sourcemaps, PHP mailers, `desktop.ini`, and README files are excluded before upload. An `<img>`'s authored `alt` is carried onto the asset and stored on the Media Library record at upload; `loading`, `decoding`, and `fetchpriority` map onto the Image module's first-class props when they hold a value the module offers (anything else keeps the module default). Uploaded assets are filed under folders mirroring their bundle path; a folder failure leaves the asset at the media root with an `asset-folder-failed` warning and never blocks the URL rewrite. |
| **Color tokens** | CSS custom properties on `:root` / `html` / `body` that look like colours | `extractRootColorTokens` pulls them into `ImportColorToken[]`; they become framework palette tokens. The framework parses hex, rgb/rgba, and hsl/hsla into channels (deriving shades/tints/transparent steps); any other authored value (oklch(), color-mix(), …) still emits its base `--<slug>` verbatim so `var(--x)` references never break. A `--<slug>` that collides with an existing colour token surfaces as a `TokenConflict` (rename / skip / overwrite) |
| **Color tokens** | CSS custom properties on `:root` / `html` / `body` that look like colours | `extractRootColorTokens` pulls them into `ImportColorToken[]`; they become framework palette tokens. The framework parses hex, rgb/rgba, and hsl/hsla into channels (deriving shades/tints/transparent steps); any other authored value (oklch(), color-mix(), …) still emits its base `--<slug>` verbatim so `var(--x)` references never break. The slug is the framework's normalised form (`--brand---neutral--700` → `--brand-neutral-700`), settled at plan time by `normalizeImportedTokens` (`tokenNames.ts`); every imported `var()` reference and declaration of a renamed name is rewritten to it, and two names that normalise to the same slug both survive (the one already in normalised form keeps it, the other takes a `-2` suffix). A `--<slug>` that collides with an existing colour token surfaces as a `TokenConflict` (rename / skip / overwrite) |
| **Fonts** | Self-hosted `@font-face` families with at least one bundled file, plus trusted Google CSS2 imports | `buildFontFamilies` in `assetPlan.ts` picks the best bundled format (woff2 → woff → ttf → otf); `extractGoogleFontImports` turns Google CSS2 `@import` rules into install requests. Commit uploads custom files via `tx.addFonts`, installs Google families through the CMS Google-font installer, then merges those returned `FontEntry` records via `tx.addInstalledFonts` |
| **Font tokens** | Root `--font-*` variables with font-family stacks | `extractRootFontTokens` pulls them into `ImportFontToken[]`; committed via `tx.addFontTokens` after fonts so matching imported families can be assigned. A `--font-*` that collides with an existing font token surfaces as a `TokenConflict` (rename / skip / overwrite) |
| **Font tokens** | Root `--font-*` variables with font-family stacks | `extractRootFontTokens` pulls them into `ImportFontToken[]`. The variable is the store's normalised form (`--font-Heading` → `--font-heading`), settled at plan time by `normalizeImportedTokens` together with the colour slugs (both kinds share one CSS namespace, so neither takes the other's name): references and declarations of a renamed name are rewritten, and two names that normalise to the same variable both survive. Committed via `tx.addFontTokens` after fonts so matching imported families can be assigned. A `--font-*` that collides with an existing font token surfaces as a `TokenConflict` (rename / skip / overwrite) |
| **Scripts** | Executable inline scripts and JS files linked by imported HTML | Ordered so every page's document order holds at once (see `priority` above) and committed via `tx.addScripts` with page scope from the source HTML. Classic scripts remain plain `<script>` assets and bypass bundling; `type="module"` scripts keep module semantics. Module imports from known npm CDNs (`esm.sh`, `esm.run`, `unpkg`, jsDelivr npm URLs) are rewritten to bare package specifiers and recorded as runtime dependencies, so `https://esm.sh/@motion.page/sdk@1.2.4` becomes `@motion.page/sdk` plus `@motion.page/sdk: 1.2.4` in `packageJson.dependencies`. Non-executable script data such as `application/json`, import maps, and templates is skipped. |
| **Stylesheets (kept)** | Top-level linked sheets the user opted into `mode: 'file'` | Flattened `@import` graph, Google imports stripped, `url()` normalised; committed via `tx.addStylesheets` as a `SiteFile` (`type: 'style'`) + `site.runtime.styles` entry scoped to the linking pages. Editable afterwards in the Site panel's Styles section and the code editor. |

Expand Down Expand Up @@ -296,7 +298,7 @@ The escape hatch for "this sheet's resets/styles must not leak into other pages

- **`PageConflict`** — a desired slug collides with an existing page slug or with another slug in the same import batch.
- **`RuleConflict`** — a `kind:'class'` rule's name collides with an existing class name. Ambient rules never conflict. One row per name even when the name arrives as several cascade fragments (they rename together).
- **`TokenConflict`** — a design-token CSS custom property collides with an existing token. One type covers both colour tokens (keyed by `--<slug>`, against `framework.colors.tokens`) and font tokens (keyed by `--font-*`, against `fonts.tokens`), since both are just a `--var` contract referenced by `var(--x)` in the imported CSS. Imported tokens are deduped per kind upstream, so only site-vs-import collisions occur. Token conflicts default to auto-rename, with one exception: when the existing font token is an untouched seeded role token (`font-heading`, `font-body` or `font-mono` with its seeded name and fallback and no bound family, checked by `isUntouchedFontRoleToken` in `src/core/fonts/roles.ts`), the default is overwrite. The imported family and fallback land on the role token and imported rules keep `var(--font-body)`, so importing into a new site behaves as if the token were absent. A role token the user has edited keeps the rename default.
- **`TokenConflict`** — a design-token CSS custom property collides with an existing token. One type covers both colour tokens (keyed by `--<slug>`, against `framework.colors.tokens`) and font tokens (keyed by `--font-*`, against `fonts.tokens`), since both are just a `--var` contract referenced by `var(--x)` in the imported CSS. Imported tokens are deduped per kind upstream, so only site-vs-import collisions occur. A rename target also skips the import's other token names and every custom property the imported CSS declares, so a renamed token never lands on a variable that is already in use. Token conflicts default to auto-rename, with one exception: when the existing font token is an untouched seeded role token (`font-heading`, `font-body` or `font-mono` with its seeded name and fallback and no bound family, checked by `isUntouchedFontRoleToken` in `src/core/fonts/roles.ts`), the default is overwrite. The imported family and fallback land on the role token and imported rules keep `var(--font-body)`, so importing into a new site behaves as if the token were absent. A role token the user has edited keeps the rename default.
- **`CrossSheetClassConflict`** — two imported page cascades define the same class differently (see "Cross-sheet class conflicts" above). Keyed by `(desiredName, definitionId)`; resolutions apply FIRST in `applyConflictResolutions` so a renamed definition no longer participates in the site-vs-import conflict on the original name.

Page slugs can be slash-delimited public paths. Root `index.html` stays the homepage slug `index`; nested `index.html` files use their directory route, so `documentation/index.html` imports as `/documentation` and does not collide with `download-version/index.html`.
Expand Down
111 changes: 111 additions & 0 deletions src/__tests__/siteImport/applyImport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,43 @@ describe('buildImportPlan — structure', () => {
expect(p.unusedCss).toContain('styles/unused.css')
})

it('rewrites var() references to the slug a root colour token is emitted under (#615)', () => {
const html = `<!doctype html><html><head><link rel="stylesheet" href="style.css"></head><body>
<h1 class="title">Home</h1><a class="link" href="/">Link</a>
<p style="border-color: var(--brand---neutral--700, red)">Note</p>
</body></html>`
const css = `
:root {
--brand---neutral--700: #4f5459;
--brand-primary: #0575d6;
--alias: var(--brand---neutral--700);
}
.title { color: var(--brand---neutral--700); }
.link { color: var(--brand-primary); }
.dark { --brand---neutral--700: #000; }
`
const p = buildImportPlan({
fileMap: makeSinglePageFileMap(html, css),
currentSite: makeEmptySiteDocument(),
})

expect(p.colors).toEqual([
{ slug: 'brand-neutral-700', value: '#4f5459' },
{ slug: 'brand-primary', value: '#0575d6' },
])
const rule = (selector: string) => p.styleRules.find((r) => r.selector === selector)
expect(rule('.title')?.styles.color).toBe('var(--brand-neutral-700)')
expect(rule('.link')?.styles.color).toBe('var(--brand-primary)')
expect(rule(':root')?.styles).toEqual({ '--alias': 'var(--brand-neutral-700)' })
// A non-root override follows the token to its new name.
expect(rule('.dark')?.styles).toEqual({ '--brand-neutral-700': '#000' })
const inline = Object.values(p.pages[0].nodeFragment.nodes)
.map((node) => node.inlineStyles)
.find((styles) => styles && 'borderColor' in styles)
expect(inline?.borderColor).toBe('var(--brand-neutral-700, red)')
expect(p.conflicts.tokens).toHaveLength(0)
})

it('extracts root font variables into font tokens', () => {
const html = `<!doctype html><html><head><link rel="stylesheet" href="style.css"></head><body><h1>Home</h1></body></html>`
const css = `
Expand Down Expand Up @@ -509,6 +546,80 @@ describe('buildImportPlan — structure', () => {
expect(p.styleRules.find((rule) => rule.selector === 'h1')?.styles.fontFamily).toBe('var(--font-display)')
})

it('rewrites var() references to the variable a root font token is emitted under (#619)', () => {
const html = `<!doctype html><html><head><link rel="stylesheet" href="style.css"></head><body>
<h1 class="title">Home</h1><p class="body">Body</p><p class="hero">Hero</p>
</body></html>`
const css = `
:root {
--font-Heading: "Acme Grotesk", serif;
--font-body--alt: "Inter", sans-serif;
--font-display: "Acme Grotesk", serif;
}
.title { font-family: var(--font-Heading); }
.body { font-family: var(--font-body--alt); }
.hero { font-family: var(--font-display); }
.dark { --font-Heading: Georgia, serif; }
`
const p = buildImportPlan({
fileMap: makeSinglePageFileMap(html, css),
currentSite: makeEmptySiteDocument(),
})

expect(p.fontTokens.map((t) => [t.name, t.variable])).toEqual([
['Heading', 'font-heading'],
['Body Alt', 'font-body-alt'],
['Display', 'font-display'],
])
const rule = (selector: string) => p.styleRules.find((r) => r.selector === selector)
expect(rule('.title')?.styles.fontFamily).toBe('var(--font-heading)')
expect(rule('.body')?.styles.fontFamily).toBe('var(--font-body-alt)')
expect(rule('.hero')?.styles.fontFamily).toBe('var(--font-display)')
// A non-root override follows the token to its new name.
expect(rule('.dark')?.styles).toEqual({ '--font-heading': 'Georgia, serif' })
expect(p.conflicts.tokens).toHaveLength(0)
})

it('keeps both font tokens when two names normalise to the same variable', () => {
const html = `<!doctype html><html><head><link rel="stylesheet" href="style.css"></head><body><h1>x</h1></body></html>`
const css = `
:root { --font-Heading: "Acme Grotesk", serif; --font-heading: "Inter", sans-serif; }
h1 { font-family: var(--font-Heading); }
h2 { font-family: var(--font-heading); }
`
const p = buildImportPlan({
fileMap: makeSinglePageFileMap(html, css),
currentSite: makeEmptySiteDocument(),
})

// The name already in emitted form keeps it; the other takes a suffix.
expect(p.fontTokens.map((t) => [t.name, t.variable, t.family])).toEqual([
['Heading 2', 'font-heading-2', 'Acme Grotesk'],
['Heading', 'font-heading', 'Inter'],
])
expect(p.styleRules.find((r) => r.selector === 'h1')?.styles.fontFamily).toBe('var(--font-heading-2)')
expect(p.styleRules.find((r) => r.selector === 'h2')?.styles.fontFamily).toBe('var(--font-heading)')
})

it('does not give a font token the name of an imported colour token', () => {
const html = `<!doctype html><html><head><link rel="stylesheet" href="style.css"></head><body><h1>x</h1></body></html>`
const css = `
:root { --font-accent: #fc834b; --font-Accent: "Inter", sans-serif; }
h1 { color: var(--font-accent); font-family: var(--font-Accent); }
`
const p = buildImportPlan({
fileMap: makeSinglePageFileMap(html, css),
currentSite: makeEmptySiteDocument(),
})

expect(p.colors).toEqual([{ slug: 'font-accent', value: '#fc834b' }])
expect(p.fontTokens.map((t) => t.variable)).toEqual(['font-accent-2'])
expect(p.styleRules.find((r) => r.selector === 'h1')?.styles).toMatchObject({
color: 'var(--font-accent)',
fontFamily: 'var(--font-accent-2)',
})
})

it('plans Google Fonts @import as installed font requests', () => {
const html = `<!doctype html><html><head><link rel="stylesheet" href="style.css"></head><body><h1>Home</h1></body></html>`
const css = `
Expand Down
Loading