@contentrain/emitter-astro 0.1.0
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/LICENSE +21 -0
- package/README.md +28 -0
- package/dist/index.d.mts +97 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +347 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Contentrain
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# @contentrain/emitter-astro
|
|
2
|
+
|
|
3
|
+
Open Astro emitter for Contentrain migrations: renders a **`ProjectIR`** (route model, layout families, component variants, query bindings, design tokens — see `@contentrain/types`) plus prepared content into a complete Astro project, returned as a pure file map.
|
|
4
|
+
|
|
5
|
+
The analysis that *produces* a good ProjectIR is the hard part and lives elsewhere. Rendering one is deliberately boring — which is why this half is open (MIT), portable, and replaceable by community emitters for other frameworks.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { emitAstroProject, writeEmit } from '@contentrain/emitter-astro'
|
|
11
|
+
|
|
12
|
+
const result = emitAstroProject({ ir, content, css })
|
|
13
|
+
console.warn(result.warnings)
|
|
14
|
+
await writeEmit(result, './out/site')
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## What it emits
|
|
18
|
+
|
|
19
|
+
| Piece | How |
|
|
20
|
+
|---|---|
|
|
21
|
+
| Layouts (`src/layouts/*.astro`) | One per `LayoutFamily`. Chrome travels as **data** (`src/data/chrome/*.json`) injected via `set:html` — the Astro compiler never parses theme markup. `@@mark@@` placeholders (title, author, `date{n}`, `term{n}`, `feat{n}`, slug) are filled per page. |
|
|
22
|
+
| Pages (`src/pages/…`) | From `RouteModel` patterns: `single` routes render post bodies; list routes render items through the extracted `item_template` (plain fallback list + warning when absent). Pagination is a route param, never a separate family. |
|
|
23
|
+
| Legacy CSS (`public/styles/legacy/`) | Quarantined in `@layer legacy { … }` with leading `@import`s hoisted and layered; a mid-file `@import` is left as-is with a warning. |
|
|
24
|
+
| Evolution layer (`src/styles/modern.css`) | Tailwind 4, CSS-first: extracted design tokens land in `@theme`. Migrated layouts never load it; new pages build on it. |
|
|
25
|
+
| Components (`src/components/*.astro`) | `<cr-component>` placeholders carrying type/source/variants; `runtime`-sourced ones point to the migration handoff offers. |
|
|
26
|
+
| Split viewports | With `viewport_strategy: 'split'`, `build:desktop` / `build:mobile` scripts scaffold per-device production. |
|
|
27
|
+
|
|
28
|
+
Everything content-shaped flows through JSON data files, never through generated template source — determinism and safe escaping fall out of that one rule.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { ProjectIR } from "@contentrain/types";
|
|
2
|
+
|
|
3
|
+
//#region src/types.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* One piece of content ready to render: body already HTML, assets already
|
|
6
|
+
* rewritten to local paths, dates already formatted in the site's observed
|
|
7
|
+
* formats. The emitter renders; it does not fetch, localize, or format —
|
|
8
|
+
* those are producer concerns, and keeping them out is what makes this
|
|
9
|
+
* package portable.
|
|
10
|
+
*/
|
|
11
|
+
interface EmitPost {
|
|
12
|
+
slug: string;
|
|
13
|
+
title: string;
|
|
14
|
+
/** Rendered, asset-rewritten HTML body. */
|
|
15
|
+
body: string;
|
|
16
|
+
/** The site's date-format catalog applied to this post's date (index-aligned with `date{n}` marks). */
|
|
17
|
+
dates?: string[];
|
|
18
|
+
author?: string;
|
|
19
|
+
author_first?: string;
|
|
20
|
+
author_last?: string;
|
|
21
|
+
terms?: string[];
|
|
22
|
+
/** Local featured-image file names (largest first). */
|
|
23
|
+
featured?: string[];
|
|
24
|
+
excerpt?: string;
|
|
25
|
+
}
|
|
26
|
+
/** One static path of a list route: its params and the items its list renders. */
|
|
27
|
+
interface QueryPage {
|
|
28
|
+
params: Record<string, string>;
|
|
29
|
+
items: EmitPost[];
|
|
30
|
+
/**
|
|
31
|
+
* Item markup with `@@mark@@` placeholders (title, date{n}, author, excerpt,
|
|
32
|
+
* feat, slug), extracted from the source list. When present, list pages
|
|
33
|
+
* render each item by filling the template; when absent, a plain fallback
|
|
34
|
+
* list is emitted and a warning recorded — a silent fallback would read as
|
|
35
|
+
* fidelity when it is not.
|
|
36
|
+
*/
|
|
37
|
+
item_template?: string;
|
|
38
|
+
}
|
|
39
|
+
interface EmitContent {
|
|
40
|
+
/** Content for `single` routes, keyed by nothing — slug lives on the post. */
|
|
41
|
+
posts?: EmitPost[];
|
|
42
|
+
/** QueryBinding id → the static paths (and items) that query produces. */
|
|
43
|
+
queries?: Record<string, QueryPage[]>;
|
|
44
|
+
}
|
|
45
|
+
/** A legacy stylesheet provided by the producer, referenced from `LayoutFamily.css.files`. */
|
|
46
|
+
interface EmitCssFile {
|
|
47
|
+
path: string;
|
|
48
|
+
content: string;
|
|
49
|
+
}
|
|
50
|
+
interface EmitOptions {
|
|
51
|
+
/** Generated project's package name. Default: "migrated-site". */
|
|
52
|
+
projectName?: string;
|
|
53
|
+
/** Include Tailwind 4 as the evolution layer (default true). */
|
|
54
|
+
tailwind?: boolean;
|
|
55
|
+
}
|
|
56
|
+
interface EmitInput {
|
|
57
|
+
ir: ProjectIR;
|
|
58
|
+
content?: EmitContent;
|
|
59
|
+
css?: EmitCssFile[];
|
|
60
|
+
options?: EmitOptions;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Pure result: every generated file as path → content. Writing to disk is a
|
|
64
|
+
* separate, trivial step (`writeEmit`) so the core stays testable and usable
|
|
65
|
+
* from any host — CLI, service, or another emitter wrapping this one.
|
|
66
|
+
*/
|
|
67
|
+
interface EmitResult {
|
|
68
|
+
files: Record<string, string>;
|
|
69
|
+
warnings: string[];
|
|
70
|
+
}
|
|
71
|
+
//#endregion
|
|
72
|
+
//#region src/css.d.ts
|
|
73
|
+
interface WrapCssResult {
|
|
74
|
+
content: string;
|
|
75
|
+
warning?: string;
|
|
76
|
+
}
|
|
77
|
+
declare function wrapLegacyCss(path: string, css: string): WrapCssResult;
|
|
78
|
+
//#endregion
|
|
79
|
+
//#region src/util.d.ts
|
|
80
|
+
/** "f-archive" → "FArchive", "posts" → "Posts" — Astro component file names. */
|
|
81
|
+
declare function pascalCase(id: string): string;
|
|
82
|
+
/**
|
|
83
|
+
* Route pattern → Astro pages path.
|
|
84
|
+
* `/` → `index.astro`; `/category/:term/page/:page` → `category/[term]/page/[page].astro`.
|
|
85
|
+
* Returns null for patterns Astro's file routing cannot express (recorded as a warning upstream).
|
|
86
|
+
*/
|
|
87
|
+
declare function patternToPagePath(pattern: string): string | null;
|
|
88
|
+
/** JSON with stable key order and trailing newline — deterministic output, stable diffs. */
|
|
89
|
+
declare function stableJson(value: unknown): string;
|
|
90
|
+
//#endregion
|
|
91
|
+
//#region src/index.d.ts
|
|
92
|
+
declare function emitAstroProject(input: EmitInput): EmitResult;
|
|
93
|
+
/** Write an EmitResult to disk. Separate from emit so the core stays pure. */
|
|
94
|
+
declare function writeEmit(result: EmitResult, dir: string): Promise<void>;
|
|
95
|
+
//#endregion
|
|
96
|
+
export { type EmitContent, type EmitCssFile, type EmitInput, type EmitOptions, type EmitPost, type EmitResult, type QueryPage, emitAstroProject, pascalCase, patternToPagePath, stableJson, wrapLegacyCss, writeEmit };
|
|
97
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/types.ts","../src/css.ts","../src/util.ts","../src/index.ts"],"mappings":";;;;;AAWA;;;;;UAAiB,QAAA;EACf,IAAA;EACA,KAAA;EAKA;EAHA,IAAA;EAKA;EAHA,KAAA;EACA,MAAA;EACA,YAAA;EACA,WAAA;EACA,KAAA;EAOe;EALf,QAAA;EACA,OAAA;AAAA;;UAIe,SAAA;EACf,MAAA,EAAQ,MAAA;EACR,KAAA,EAAO,QAAA;EAQP;;;AAGF;;;;EAHE,aAAA;AAAA;AAAA,UAGe,WAAA;EAIC;EAFhB,KAAA,GAAQ,QAAA;EAAA;EAER,OAAA,GAAU,MAAA,SAAe,SAAA;AAAA;;UAIV,WAAA;EACf,IAAA;EACA,OAAA;AAAA;AAAA,UAGe,WAAA;EAJf;EAMA,WAAA;EAFe;EAIf,QAAA;AAAA;AAAA,UAGe,SAAA;EACf,EAAA,EAAI,SAAA;EACJ,OAAA,GAAU,WAAA;EACV,GAAA,GAAM,WAAA;EACN,OAAA,GAAU,WAAA;AAAA;;;;;;UAUK,UAAA;EACf,KAAA,EAAO,MAAA;EACP,QAAA;AAAA;;;UC7De,aAAA;EACf,OAAA;EACA,OAAA;AAAA;AAAA,iBAKc,aAAA,CAAc,IAAA,UAAc,GAAA,WAAc,aAAA;;;;iBCtB1C,UAAA,CAAW,EAAA;;AFU3B;;;;iBEGgB,iBAAA,CAAkB,OAAA;;iBAuBlB,UAAA,CAAW,KAAA;;;iBCrBX,gBAAA,CAAiB,KAAA,EAAO,SAAA,GAAY,UAAA;;iBA8C9B,SAAA,CAAU,MAAA,EAAQ,UAAA,EAAY,GAAA,WAAc,OAAA"}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { LEGACY_CSS_LAYER } from "@contentrain/types";
|
|
2
|
+
//#region src/util.ts
|
|
3
|
+
/** "f-archive" → "FArchive", "posts" → "Posts" — Astro component file names. */
|
|
4
|
+
function pascalCase(id) {
|
|
5
|
+
return id.split(/[^a-zA-Z0-9]+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Route pattern → Astro pages path.
|
|
9
|
+
* `/` → `index.astro`; `/category/:term/page/:page` → `category/[term]/page/[page].astro`.
|
|
10
|
+
* Returns null for patterns Astro's file routing cannot express (recorded as a warning upstream).
|
|
11
|
+
*/
|
|
12
|
+
function patternToPagePath(pattern) {
|
|
13
|
+
if (pattern === "/" || pattern === "") return "index.astro";
|
|
14
|
+
if (pattern.includes("*")) return null;
|
|
15
|
+
const mapped = pattern.replace(/^\/+|\/+$/g, "").split("/").map((seg) => seg.startsWith(":") ? `[${seg.slice(1)}]` : seg);
|
|
16
|
+
if (mapped.some((seg) => !/^[[\]a-zA-Z0-9._-]+$/.test(seg))) return null;
|
|
17
|
+
const last = mapped.pop();
|
|
18
|
+
return [...mapped, `${last}.astro`].join("/");
|
|
19
|
+
}
|
|
20
|
+
const sortDeep = (v) => {
|
|
21
|
+
if (Array.isArray(v)) return v.map(sortDeep);
|
|
22
|
+
if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).toSorted(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, val]) => [k, sortDeep(val)]));
|
|
23
|
+
return v;
|
|
24
|
+
};
|
|
25
|
+
/** JSON with stable key order and trailing newline — deterministic output, stable diffs. */
|
|
26
|
+
function stableJson(value) {
|
|
27
|
+
return `${JSON.stringify(sortDeep(value), null, 2)}\n`;
|
|
28
|
+
}
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region src/scaffold.ts
|
|
31
|
+
function scaffoldFiles(ir, options) {
|
|
32
|
+
const tailwind = options.tailwind !== false;
|
|
33
|
+
const split = ir.viewport_strategy === "split";
|
|
34
|
+
const files = {};
|
|
35
|
+
files["package.json"] = stableJson({
|
|
36
|
+
name: options.projectName ?? "migrated-site",
|
|
37
|
+
private: true,
|
|
38
|
+
type: "module",
|
|
39
|
+
scripts: {
|
|
40
|
+
dev: "astro dev",
|
|
41
|
+
build: "astro build",
|
|
42
|
+
preview: "astro preview",
|
|
43
|
+
...split ? {
|
|
44
|
+
"build:desktop": "VIEWPORT=desktop astro build --outDir dist/desktop",
|
|
45
|
+
"build:mobile": "VIEWPORT=mobile astro build --outDir dist/mobile"
|
|
46
|
+
} : {}
|
|
47
|
+
},
|
|
48
|
+
dependencies: {
|
|
49
|
+
astro: "^5.0.0",
|
|
50
|
+
...tailwind ? {
|
|
51
|
+
tailwindcss: "^4.0.0",
|
|
52
|
+
"@tailwindcss/vite": "^4.0.0"
|
|
53
|
+
} : {}
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
files["astro.config.mjs"] = [
|
|
57
|
+
`import { defineConfig } from 'astro/config'`,
|
|
58
|
+
...tailwind ? [`import tailwindcss from '@tailwindcss/vite'`] : [],
|
|
59
|
+
``,
|
|
60
|
+
`export default defineConfig({`,
|
|
61
|
+
` build: { format: 'directory' },`,
|
|
62
|
+
...tailwind ? [` vite: { plugins: [tailwindcss()] },`] : [],
|
|
63
|
+
`})`,
|
|
64
|
+
``
|
|
65
|
+
].join("\n");
|
|
66
|
+
if (tailwind) files["src/styles/modern.css"] = modernCss(ir.tokens);
|
|
67
|
+
files["src/lib/fill.ts"] = FILL_TS;
|
|
68
|
+
return files;
|
|
69
|
+
}
|
|
70
|
+
/** Tailwind 4 evolution layer: CSS-first config with the site's extracted tokens. */
|
|
71
|
+
function modernCss(tokens) {
|
|
72
|
+
const lines = [`@import 'tailwindcss';`, ``];
|
|
73
|
+
const theme = [];
|
|
74
|
+
const push = (prefix, map) => {
|
|
75
|
+
for (const [key, value] of Object.entries(map ?? {})) theme.push(` --${prefix}-${key}: ${value};`);
|
|
76
|
+
};
|
|
77
|
+
push("color", tokens?.colors);
|
|
78
|
+
push("font", tokens?.font_families);
|
|
79
|
+
push("text", tokens?.font_sizes);
|
|
80
|
+
push("spacing", tokens?.spacing);
|
|
81
|
+
push("breakpoint", tokens?.breakpoints);
|
|
82
|
+
if (theme.length) lines.push(`@theme {`, ...theme, `}`);
|
|
83
|
+
return `${lines.join("\n")}\n`;
|
|
84
|
+
}
|
|
85
|
+
const FILL_TS = `// Emitted by @contentrain/emitter-astro — shared mark-filling helpers.
|
|
86
|
+
// Chrome and item templates carry @@mark@@ placeholders; pages fill them from content.
|
|
87
|
+
|
|
88
|
+
export const esc = (value: unknown): string =>
|
|
89
|
+
String(value ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
90
|
+
|
|
91
|
+
export function fillMarks(html: string, marks: Record<string, unknown>): string {
|
|
92
|
+
return html.replace(/@@([a-z0-9_]+)@@/gi, (_all, key: string) => esc(marks[key] ?? ''))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface MarkablePost {
|
|
96
|
+
slug: string
|
|
97
|
+
title: string
|
|
98
|
+
dates?: string[]
|
|
99
|
+
author?: string
|
|
100
|
+
author_first?: string
|
|
101
|
+
author_last?: string
|
|
102
|
+
terms?: string[]
|
|
103
|
+
featured?: string[]
|
|
104
|
+
excerpt?: string
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The mark vocabulary the measurement chain proved out: title, author (+ first/last), date{n}, term{n}, feat{n}, excerpt, slug. */
|
|
108
|
+
export function postMarks(post: MarkablePost): Record<string, string> {
|
|
109
|
+
const marks: Record<string, string> = {
|
|
110
|
+
title: post.title,
|
|
111
|
+
author: post.author ?? '',
|
|
112
|
+
author_first: post.author_first ?? '',
|
|
113
|
+
author_last: post.author_last ?? '',
|
|
114
|
+
excerpt: post.excerpt ?? '',
|
|
115
|
+
slug: post.slug,
|
|
116
|
+
feat: post.featured?.[0] ?? '',
|
|
117
|
+
}
|
|
118
|
+
for (const [i, d] of (post.dates ?? []).entries()) marks[\`date\${i}\`] = d
|
|
119
|
+
for (const [i, t] of (post.terms ?? []).entries()) marks[\`term\${i}\`] = t
|
|
120
|
+
for (const [i, f] of (post.featured ?? []).entries()) marks[\`feat\${i}\`] = f
|
|
121
|
+
return marks
|
|
122
|
+
}
|
|
123
|
+
`;
|
|
124
|
+
//#endregion
|
|
125
|
+
//#region src/layouts.ts
|
|
126
|
+
function familyFiles(family) {
|
|
127
|
+
const name = pascalCase(family.id);
|
|
128
|
+
const chunks = family.chrome ?? [];
|
|
129
|
+
const joined = (position) => chunks.filter((c) => c.position === position).map((c) => c.html).join("\n");
|
|
130
|
+
const files = {};
|
|
131
|
+
files[`src/data/chrome/${family.id}.json`] = stableJson({
|
|
132
|
+
head: joined("head"),
|
|
133
|
+
before: joined("before_body"),
|
|
134
|
+
after: joined("after_body")
|
|
135
|
+
});
|
|
136
|
+
const cssLinks = (family.css.files ?? []).map((f) => `<link rel="stylesheet" href="/styles/legacy/${f.split("/").pop()}" />`).join("\n");
|
|
137
|
+
files[`src/layouts/${name}.astro`] = `---
|
|
138
|
+
// Family: ${family.id}${family.name ? ` (${family.name})` : ""} — emitted by @contentrain/emitter-astro
|
|
139
|
+
import chrome from '../data/chrome/${family.id}.json'
|
|
140
|
+
import { fillMarks } from '../lib/fill'
|
|
141
|
+
const { title = '', marks = {} } = Astro.props
|
|
142
|
+
const head = fillMarks(chrome.head, marks)
|
|
143
|
+
const before = fillMarks(chrome.before, marks)
|
|
144
|
+
const after = fillMarks(chrome.after, marks)
|
|
145
|
+
---
|
|
146
|
+
<!doctype html>
|
|
147
|
+
<html lang="en">
|
|
148
|
+
<head>
|
|
149
|
+
<meta charset="utf-8" />
|
|
150
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
151
|
+
${cssLinks.split("\n").filter(Boolean).map((l) => ` ${l}`).join("\n")}
|
|
152
|
+
<Fragment set:html={head} />
|
|
153
|
+
<title>{title}</title>
|
|
154
|
+
</head>
|
|
155
|
+
<body>
|
|
156
|
+
<Fragment set:html={before} />
|
|
157
|
+
<slot />
|
|
158
|
+
<Fragment set:html={after} />
|
|
159
|
+
</body>
|
|
160
|
+
</html>
|
|
161
|
+
`;
|
|
162
|
+
return files;
|
|
163
|
+
}
|
|
164
|
+
//#endregion
|
|
165
|
+
//#region src/pages.ts
|
|
166
|
+
function routeFiles(route, family, content) {
|
|
167
|
+
const warnings = [];
|
|
168
|
+
const files = {};
|
|
169
|
+
const pagePath = patternToPagePath(route.pattern);
|
|
170
|
+
if (!pagePath) return {
|
|
171
|
+
files,
|
|
172
|
+
warnings: [`route ${route.id}: pattern "${route.pattern}" cannot be expressed as an Astro page — skipped`]
|
|
173
|
+
};
|
|
174
|
+
if (!family) return {
|
|
175
|
+
files,
|
|
176
|
+
warnings: [`route ${route.id}: family "${route.family}" not found — skipped`]
|
|
177
|
+
};
|
|
178
|
+
const layout = pascalCase(family.id);
|
|
179
|
+
const depth = pagePath.split("/").length - 1;
|
|
180
|
+
const up = "../".repeat(depth + 1) || "../";
|
|
181
|
+
const hasParams = pagePath.includes("[");
|
|
182
|
+
if (route.kind === "single") {
|
|
183
|
+
const posts = content.posts ?? [];
|
|
184
|
+
if (!posts.length) warnings.push(`route ${route.id}: single route with no posts — page emitted, data file empty`);
|
|
185
|
+
files["src/data/posts.json"] = stableJson(posts);
|
|
186
|
+
files[`src/pages/${pagePath}`] = singlePage(route, layout, up);
|
|
187
|
+
return {
|
|
188
|
+
files,
|
|
189
|
+
warnings
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const queryPages = route.query ? content.queries?.[route.query] ?? [] : [];
|
|
193
|
+
if (route.query) {
|
|
194
|
+
if (!queryPages.length) warnings.push(`route ${route.id}: query "${route.query}" has no data — page emitted, data file empty`);
|
|
195
|
+
if (queryPages.some((p) => !p.item_template)) warnings.push(`route ${route.id}: item template missing on some pages — plain fallback list rendered (not fidelity)`);
|
|
196
|
+
files[`src/data/queries/${route.query}.json`] = stableJson(queryPages);
|
|
197
|
+
files[`src/pages/${pagePath}`] = listPage(route, layout, up, hasParams);
|
|
198
|
+
return {
|
|
199
|
+
files,
|
|
200
|
+
warnings
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
files[`src/pages/${pagePath}`] = staticPage(route, layout, up, hasParams, warnings);
|
|
204
|
+
return {
|
|
205
|
+
files,
|
|
206
|
+
warnings
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
function singlePage(route, layout, up) {
|
|
210
|
+
return `---
|
|
211
|
+
// Route: ${route.id} (${route.pattern}) — emitted by @contentrain/emitter-astro
|
|
212
|
+
import Layout from '${up}layouts/${layout}.astro'
|
|
213
|
+
import posts from '${up}data/posts.json'
|
|
214
|
+
import { postMarks } from '${up}lib/fill'
|
|
215
|
+
|
|
216
|
+
export function getStaticPaths() {
|
|
217
|
+
return posts.map((post) => ({ params: { slug: post.slug }, props: { post } }))
|
|
218
|
+
}
|
|
219
|
+
const { post } = Astro.props
|
|
220
|
+
---
|
|
221
|
+
<Layout title={post.title} marks={postMarks(post)}>
|
|
222
|
+
<Fragment set:html={post.body} />
|
|
223
|
+
</Layout>
|
|
224
|
+
`;
|
|
225
|
+
}
|
|
226
|
+
function listPage(route, layout, up, hasParams) {
|
|
227
|
+
const paths = hasParams ? `export function getStaticPaths() {
|
|
228
|
+
return pages.map((page) => ({ params: page.params, props: { page } }))
|
|
229
|
+
}
|
|
230
|
+
const { page } = Astro.props` : `const page = pages[0] ?? { params: {}, items: [] }`;
|
|
231
|
+
return `---
|
|
232
|
+
// Route: ${route.id} (${route.pattern}) — emitted by @contentrain/emitter-astro
|
|
233
|
+
import Layout from '${up}layouts/${layout}.astro'
|
|
234
|
+
import pages from '${up}data/queries/${route.query}.json'
|
|
235
|
+
import { fillMarks, postMarks } from '${up}lib/fill'
|
|
236
|
+
|
|
237
|
+
${paths}
|
|
238
|
+
const items = page.items.map((item) =>
|
|
239
|
+
page.item_template ? fillMarks(page.item_template, postMarks(item)) : undefined,
|
|
240
|
+
)
|
|
241
|
+
const fallback = items.some((h) => h === undefined)
|
|
242
|
+
---
|
|
243
|
+
<Layout title={page.params.term ?? ''} marks={page.params}>
|
|
244
|
+
{fallback ? (
|
|
245
|
+
<ul class="cr-post-list">
|
|
246
|
+
{page.items.map((item) => (
|
|
247
|
+
<li><a href={'/' + item.slug + '/'}>{item.title}</a></li>
|
|
248
|
+
))}
|
|
249
|
+
</ul>
|
|
250
|
+
) : (
|
|
251
|
+
<Fragment set:html={items.join('')} />
|
|
252
|
+
)}
|
|
253
|
+
</Layout>
|
|
254
|
+
`;
|
|
255
|
+
}
|
|
256
|
+
function staticPage(route, layout, up, hasParams, warnings) {
|
|
257
|
+
if (hasParams) warnings.push(`route ${route.id}: parameterized route without a query — emitted with empty paths`);
|
|
258
|
+
const paths = hasParams ? `export function getStaticPaths() {
|
|
259
|
+
return []
|
|
260
|
+
}
|
|
261
|
+
` : "";
|
|
262
|
+
return `---
|
|
263
|
+
// Route: ${route.id} (${route.pattern}) — emitted by @contentrain/emitter-astro
|
|
264
|
+
import Layout from '${up}layouts/${layout}.astro'
|
|
265
|
+
${paths}---
|
|
266
|
+
<Layout title="" marks={{}} />
|
|
267
|
+
`;
|
|
268
|
+
}
|
|
269
|
+
//#endregion
|
|
270
|
+
//#region src/components.ts
|
|
271
|
+
function componentFiles(components) {
|
|
272
|
+
const files = {};
|
|
273
|
+
for (const c of components) {
|
|
274
|
+
const variants = (c.variants ?? []).map((v) => v.key);
|
|
275
|
+
files[`src/components/${pascalCase(c.id)}.astro`] = `---
|
|
276
|
+
/**
|
|
277
|
+
* cr-component: type=${c.type} source=${c.source}${c.name ? ` — ${c.name}` : ""}
|
|
278
|
+
* Emitted by @contentrain/emitter-astro as a placeholder.${c.source === "runtime" ? "\n * Needs a live provider — see the migration handoff offers." : ""}${variants.length ? `\n * Variants: ${variants.join(", ")}` : ""}
|
|
279
|
+
*/
|
|
280
|
+
const { variant = ${JSON.stringify(variants[0] ?? "default")} } = Astro.props
|
|
281
|
+
---
|
|
282
|
+
<cr-component data-type=${JSON.stringify(c.type)} data-variant={variant}></cr-component>
|
|
283
|
+
`;
|
|
284
|
+
}
|
|
285
|
+
return files;
|
|
286
|
+
}
|
|
287
|
+
//#endregion
|
|
288
|
+
//#region src/css.ts
|
|
289
|
+
const LEADING = /^(\s*(?:@charset[^;]+;|@import[^;]+;|\/\*[\s\S]*?\*\/)\s*)+/;
|
|
290
|
+
function wrapLegacyCss(path, css) {
|
|
291
|
+
const rest = css.replace(LEADING, "");
|
|
292
|
+
if (/@import\s/.test(rest)) return {
|
|
293
|
+
content: css,
|
|
294
|
+
warning: `${path}: @import after other rules — left unwrapped (fix the source stylesheet; mid-file imports are ignored by browsers)`
|
|
295
|
+
};
|
|
296
|
+
return { content: `${css.slice(0, css.length - rest.length).replace(/@import\s+([^;]+);/g, (full, spec) => /\blayer\s*\(/.test(spec) ? full : `@import ${spec.trim()} layer(${LEGACY_CSS_LAYER});`)}@layer ${LEGACY_CSS_LAYER} {\n${rest}\n}\n` };
|
|
297
|
+
}
|
|
298
|
+
//#endregion
|
|
299
|
+
//#region src/index.ts
|
|
300
|
+
function emitAstroProject(input) {
|
|
301
|
+
const { ir } = input;
|
|
302
|
+
const warnings = [];
|
|
303
|
+
const files = {};
|
|
304
|
+
const add = (batch) => {
|
|
305
|
+
for (const [path, content] of Object.entries(batch)) {
|
|
306
|
+
if (files[path] !== void 0 && files[path] !== content) {
|
|
307
|
+
warnings.push(`duplicate file with different content: ${path} — keeping the first`);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
files[path] = content;
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
add(scaffoldFiles(ir, input.options ?? {}));
|
|
314
|
+
const familiesById = new Map(ir.families.map((f) => [f.id, f]));
|
|
315
|
+
for (const family of ir.families) add(familyFiles(family));
|
|
316
|
+
for (const css of input.css ?? []) {
|
|
317
|
+
const wrapped = wrapLegacyCss(css.path, css.content);
|
|
318
|
+
if (wrapped.warning) warnings.push(wrapped.warning);
|
|
319
|
+
add({ [`public/styles/legacy/${css.path.split("/").pop() ?? css.path}`]: wrapped.content });
|
|
320
|
+
}
|
|
321
|
+
for (const family of ir.families) for (const ref of family.css.files ?? []) {
|
|
322
|
+
const base = ref.split("/").pop();
|
|
323
|
+
if (!(input.css ?? []).some((c) => (c.path.split("/").pop() ?? c.path) === base)) warnings.push(`family ${family.id}: css file "${ref}" not provided in input.css`);
|
|
324
|
+
}
|
|
325
|
+
for (const route of ir.routes) {
|
|
326
|
+
const result = routeFiles(route, familiesById.get(route.family), input.content ?? {});
|
|
327
|
+
add(result.files);
|
|
328
|
+
warnings.push(...result.warnings);
|
|
329
|
+
}
|
|
330
|
+
add(componentFiles(ir.components ?? []));
|
|
331
|
+
return {
|
|
332
|
+
files,
|
|
333
|
+
warnings
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
/** Write an EmitResult to disk. Separate from emit so the core stays pure. */
|
|
337
|
+
async function writeEmit(result, dir) {
|
|
338
|
+
const { mkdir, writeFile } = await import("node:fs/promises");
|
|
339
|
+
const { dirname, join } = await import("node:path");
|
|
340
|
+
const dirs = new Set(Object.keys(result.files).map((p) => dirname(join(dir, p))));
|
|
341
|
+
await Promise.all([...dirs].map((d) => mkdir(d, { recursive: true })));
|
|
342
|
+
await Promise.all(Object.entries(result.files).map(([path, content]) => writeFile(join(dir, path), content, "utf8")));
|
|
343
|
+
}
|
|
344
|
+
//#endregion
|
|
345
|
+
export { emitAstroProject, pascalCase, patternToPagePath, stableJson, wrapLegacyCss, writeEmit };
|
|
346
|
+
|
|
347
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/util.ts","../src/scaffold.ts","../src/layouts.ts","../src/pages.ts","../src/components.ts","../src/css.ts","../src/index.ts"],"sourcesContent":["/** \"f-archive\" → \"FArchive\", \"posts\" → \"Posts\" — Astro component file names. */\nexport function pascalCase(id: string): string {\n return id\n .split(/[^a-zA-Z0-9]+/)\n .filter(Boolean)\n .map((part) => part.charAt(0).toUpperCase() + part.slice(1))\n .join('')\n}\n\n/**\n * Route pattern → Astro pages path.\n * `/` → `index.astro`; `/category/:term/page/:page` → `category/[term]/page/[page].astro`.\n * Returns null for patterns Astro's file routing cannot express (recorded as a warning upstream).\n */\nexport function patternToPagePath(pattern: string): string | null {\n if (pattern === '/' || pattern === '') return 'index.astro'\n if (pattern.includes('*')) return null\n const segments = pattern.replace(/^\\/+|\\/+$/g, '').split('/')\n const mapped = segments.map((seg) => (seg.startsWith(':') ? `[${seg.slice(1)}]` : seg))\n if (mapped.some((seg) => !/^[[\\]a-zA-Z0-9._-]+$/.test(seg))) return null\n const last = mapped.pop()\n return [...mapped, `${last}.astro`].join('/')\n}\n\nconst sortDeep = (v: unknown): unknown => {\n if (Array.isArray(v)) return v.map(sortDeep)\n if (v && typeof v === 'object') {\n return Object.fromEntries(\n Object.entries(v as Record<string, unknown>)\n .toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))\n .map(([k, val]) => [k, sortDeep(val)]),\n )\n }\n return v\n}\n\n/** JSON with stable key order and trailing newline — deterministic output, stable diffs. */\nexport function stableJson(value: unknown): string {\n return `${JSON.stringify(sortDeep(value), null, 2)}\\n`\n}\n","// Project scaffolding: manifest, config, styles, and the shared fill helper\n// the generated pages use to pour content into `@@mark@@` placeholders.\n\nimport type { DesignTokens, ProjectIR } from '@contentrain/types'\nimport type { EmitOptions } from './types.js'\nimport { stableJson } from './util.js'\n\nexport function scaffoldFiles(ir: ProjectIR, options: EmitOptions): Record<string, string> {\n const tailwind = options.tailwind !== false\n const split = ir.viewport_strategy === 'split'\n const files: Record<string, string> = {}\n\n const pkg: Record<string, unknown> = {\n name: options.projectName ?? 'migrated-site',\n private: true,\n type: 'module',\n scripts: {\n dev: 'astro dev',\n build: 'astro build',\n preview: 'astro preview',\n // Split viewport production (skeleton): one build per device class, served by device.\n ...(split ? { 'build:desktop': 'VIEWPORT=desktop astro build --outDir dist/desktop', 'build:mobile': 'VIEWPORT=mobile astro build --outDir dist/mobile' } : {}),\n },\n dependencies: {\n astro: '^5.0.0',\n ...(tailwind ? { tailwindcss: '^4.0.0', '@tailwindcss/vite': '^4.0.0' } : {}),\n },\n }\n files['package.json'] = stableJson(pkg)\n\n files['astro.config.mjs'] = [\n `import { defineConfig } from 'astro/config'`,\n ...(tailwind ? [`import tailwindcss from '@tailwindcss/vite'`] : []),\n ``,\n `export default defineConfig({`,\n ` build: { format: 'directory' },`,\n ...(tailwind ? [` vite: { plugins: [tailwindcss()] },`] : []),\n `})`,\n ``,\n ].join('\\n')\n\n if (tailwind) files['src/styles/modern.css'] = modernCss(ir.tokens)\n\n files['src/lib/fill.ts'] = FILL_TS\n return files\n}\n\n/** Tailwind 4 evolution layer: CSS-first config with the site's extracted tokens. */\nfunction modernCss(tokens: DesignTokens | undefined): string {\n const lines: string[] = [`@import 'tailwindcss';`, ``]\n const theme: string[] = []\n const push = (prefix: string, map: Record<string, string> | undefined) => {\n for (const [key, value] of Object.entries(map ?? {})) theme.push(` --${prefix}-${key}: ${value};`)\n }\n push('color', tokens?.colors)\n push('font', tokens?.font_families)\n push('text', tokens?.font_sizes)\n push('spacing', tokens?.spacing)\n push('breakpoint', tokens?.breakpoints)\n if (theme.length) lines.push(`@theme {`, ...theme, `}`)\n return `${lines.join('\\n')}\\n`\n}\n\nconst FILL_TS = `// Emitted by @contentrain/emitter-astro — shared mark-filling helpers.\n// Chrome and item templates carry @@mark@@ placeholders; pages fill them from content.\n\nexport const esc = (value: unknown): string =>\n String(value ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')\n\nexport function fillMarks(html: string, marks: Record<string, unknown>): string {\n return html.replace(/@@([a-z0-9_]+)@@/gi, (_all, key: string) => esc(marks[key] ?? ''))\n}\n\nexport interface MarkablePost {\n slug: string\n title: string\n dates?: string[]\n author?: string\n author_first?: string\n author_last?: string\n terms?: string[]\n featured?: string[]\n excerpt?: string\n}\n\n/** The mark vocabulary the measurement chain proved out: title, author (+ first/last), date{n}, term{n}, feat{n}, excerpt, slug. */\nexport function postMarks(post: MarkablePost): Record<string, string> {\n const marks: Record<string, string> = {\n title: post.title,\n author: post.author ?? '',\n author_first: post.author_first ?? '',\n author_last: post.author_last ?? '',\n excerpt: post.excerpt ?? '',\n slug: post.slug,\n feat: post.featured?.[0] ?? '',\n }\n for (const [i, d] of (post.dates ?? []).entries()) marks[\\`date\\${i}\\`] = d\n for (const [i, t] of (post.terms ?? []).entries()) marks[\\`term\\${i}\\`] = t\n for (const [i, f] of (post.featured ?? []).entries()) marks[\\`feat\\${i}\\`] = f\n return marks\n}\n`\n","// Layout families → Astro layouts.\n//\n// Chrome travels as DATA, not as Astro template source: the compiler must\n// never parse browser-tolerated theme markup (that distinction alone took a\n// measured corpus from build failures to zero). Each family's chrome chunks\n// land in a JSON data file; the layout injects them with `set:html` and fills\n// `@@mark@@` placeholders from the page's props.\n\nimport type { LayoutFamily } from '@contentrain/types'\nimport { pascalCase, stableJson } from './util.js'\n\nexport function familyFiles(family: LayoutFamily): Record<string, string> {\n const name = pascalCase(family.id)\n const chunks = family.chrome ?? []\n const joined = (position: 'head' | 'before_body' | 'after_body') =>\n chunks.filter((c) => c.position === position).map((c) => c.html).join('\\n')\n\n const files: Record<string, string> = {}\n files[`src/data/chrome/${family.id}.json`] = stableJson({\n head: joined('head'),\n before: joined('before_body'),\n after: joined('after_body'),\n })\n\n const cssLinks = (family.css.files ?? [])\n .map((f) => `<link rel=\"stylesheet\" href=\"/styles/legacy/${f.split('/').pop()}\" />`)\n .join('\\n')\n\n files[`src/layouts/${name}.astro`] = `---\n// Family: ${family.id}${family.name ? ` (${family.name})` : ''} — emitted by @contentrain/emitter-astro\nimport chrome from '../data/chrome/${family.id}.json'\nimport { fillMarks } from '../lib/fill'\nconst { title = '', marks = {} } = Astro.props\nconst head = fillMarks(chrome.head, marks)\nconst before = fillMarks(chrome.before, marks)\nconst after = fillMarks(chrome.after, marks)\n---\n<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\" />\n${cssLinks\n .split('\\n')\n .filter(Boolean)\n .map((l) => ` ${l}`)\n .join('\\n')}\n <Fragment set:html={head} />\n <title>{title}</title>\n </head>\n <body>\n <Fragment set:html={before} />\n <slot />\n <Fragment set:html={after} />\n </body>\n</html>\n`\n return files\n}\n","// Routes → Astro pages.\n//\n// Three shapes, matching what a migrated site actually needs:\n// - `single`: one page per post, body injected with `set:html` inside the family layout.\n// - list kinds (`archive`/`term`/`author`/`date`/`front` with a query): static\n// paths come from the producer's query data; items render through the\n// extracted item template when one exists.\n// - static kinds (no query): the family chrome IS the page.\n//\n// Pagination is a route parameter (`/news/page/:page`), never a separate\n// family — the generated paths simply include the page number in params.\n\nimport type { RouteModel, LayoutFamily } from '@contentrain/types'\nimport type { EmitContent, QueryPage } from './types.js'\nimport { pascalCase, patternToPagePath, stableJson } from './util.js'\n\nexport interface PageGenResult {\n files: Record<string, string>\n warnings: string[]\n}\n\nexport function routeFiles(\n route: RouteModel,\n family: LayoutFamily | undefined,\n content: EmitContent,\n): PageGenResult {\n const warnings: string[] = []\n const files: Record<string, string> = {}\n const pagePath = patternToPagePath(route.pattern)\n if (!pagePath) {\n return { files, warnings: [`route ${route.id}: pattern \"${route.pattern}\" cannot be expressed as an Astro page — skipped`] }\n }\n if (!family) {\n return { files, warnings: [`route ${route.id}: family \"${route.family}\" not found — skipped`] }\n }\n const layout = pascalCase(family.id)\n const depth = pagePath.split('/').length - 1\n const up = '../'.repeat(depth + 1) || '../'\n const hasParams = pagePath.includes('[')\n\n if (route.kind === 'single') {\n const posts = content.posts ?? []\n if (!posts.length) warnings.push(`route ${route.id}: single route with no posts — page emitted, data file empty`)\n files['src/data/posts.json'] = stableJson(posts)\n files[`src/pages/${pagePath}`] = singlePage(route, layout, up)\n return { files, warnings }\n }\n\n const queryPages: QueryPage[] = route.query ? (content.queries?.[route.query] ?? []) : []\n if (route.query) {\n if (!queryPages.length) warnings.push(`route ${route.id}: query \"${route.query}\" has no data — page emitted, data file empty`)\n if (queryPages.some((p) => !p.item_template)) {\n warnings.push(`route ${route.id}: item template missing on some pages — plain fallback list rendered (not fidelity)`)\n }\n files[`src/data/queries/${route.query}.json`] = stableJson(queryPages)\n files[`src/pages/${pagePath}`] = listPage(route, layout, up, hasParams)\n return { files, warnings }\n }\n\n files[`src/pages/${pagePath}`] = staticPage(route, layout, up, hasParams, warnings)\n return { files, warnings }\n}\n\nfunction singlePage(route: RouteModel, layout: string, up: string): string {\n return `---\n// Route: ${route.id} (${route.pattern}) — emitted by @contentrain/emitter-astro\nimport Layout from '${up}layouts/${layout}.astro'\nimport posts from '${up}data/posts.json'\nimport { postMarks } from '${up}lib/fill'\n\nexport function getStaticPaths() {\n return posts.map((post) => ({ params: { slug: post.slug }, props: { post } }))\n}\nconst { post } = Astro.props\n---\n<Layout title={post.title} marks={postMarks(post)}>\n <Fragment set:html={post.body} />\n</Layout>\n`\n}\n\nfunction listPage(route: RouteModel, layout: string, up: string, hasParams: boolean): string {\n const paths = hasParams\n ? `export function getStaticPaths() {\n return pages.map((page) => ({ params: page.params, props: { page } }))\n}\nconst { page } = Astro.props`\n : `const page = pages[0] ?? { params: {}, items: [] }`\n return `---\n// Route: ${route.id} (${route.pattern}) — emitted by @contentrain/emitter-astro\nimport Layout from '${up}layouts/${layout}.astro'\nimport pages from '${up}data/queries/${route.query}.json'\nimport { fillMarks, postMarks } from '${up}lib/fill'\n\n${paths}\nconst items = page.items.map((item) =>\n page.item_template ? fillMarks(page.item_template, postMarks(item)) : undefined,\n)\nconst fallback = items.some((h) => h === undefined)\n---\n<Layout title={page.params.term ?? ''} marks={page.params}>\n {fallback ? (\n <ul class=\"cr-post-list\">\n {page.items.map((item) => (\n <li><a href={'/' + item.slug + '/'}>{item.title}</a></li>\n ))}\n </ul>\n ) : (\n <Fragment set:html={items.join('')} />\n )}\n</Layout>\n`\n}\n\nfunction staticPage(route: RouteModel, layout: string, up: string, hasParams: boolean, warnings: string[]): string {\n if (hasParams) warnings.push(`route ${route.id}: parameterized route without a query — emitted with empty paths`)\n const paths = hasParams\n ? `export function getStaticPaths() {\n return []\n}\n`\n : ''\n return `---\n// Route: ${route.id} (${route.pattern}) — emitted by @contentrain/emitter-astro\nimport Layout from '${up}layouts/${layout}.astro'\n${paths}---\n<Layout title=\"\" marks={{}} />\n`\n}\n","// Component placeholders.\n//\n// The emitter cannot invent a comments form or an ad slot — it marks the\n// spot. `rest`-sourced components are candidates for real implementations\n// against content data; `runtime` components need a live provider (that\n// conversation belongs to MigrationHandoff offers); `chrome` components\n// already travel inside the family chrome and get a stub for refactoring\n// toward.\n\nimport type { ComponentDef } from '@contentrain/types'\nimport { pascalCase } from './util.js'\n\nexport function componentFiles(components: ComponentDef[]): Record<string, string> {\n const files: Record<string, string> = {}\n for (const c of components) {\n const variants = (c.variants ?? []).map((v) => v.key)\n files[`src/components/${pascalCase(c.id)}.astro`] = `---\n/**\n * cr-component: type=${c.type} source=${c.source}${c.name ? ` — ${c.name}` : ''}\n * Emitted by @contentrain/emitter-astro as a placeholder.${\n c.source === 'runtime' ? '\\n * Needs a live provider — see the migration handoff offers.' : ''\n }${variants.length ? `\\n * Variants: ${variants.join(', ')}` : ''}\n */\nconst { variant = ${JSON.stringify(variants[0] ?? 'default')} } = Astro.props\n---\n<cr-component data-type=${JSON.stringify(c.type)} data-variant={variant}></cr-component>\n`\n }\n return files\n}\n","// Legacy CSS quarantine (the fidelity layer).\n//\n// Migrated pages must render pixel-identically, and a utility framework's\n// preflight/reset would fight the theme's CSS. The truce is a cascade layer:\n// every legacy stylesheet is wrapped in `@layer legacy { … }`, and modern CSS\n// lives in its own layer — the two never meet in specificity.\n//\n// One CSS rule constrains the wrapping: `@import` (and `@charset`) must\n// precede all other statements, so a file cannot simply be wrapped whole.\n// Leading imports are hoisted above the wrapper and given `layer(legacy)`;\n// an import *after* other rules is already invalid CSS that browsers ignore,\n// so the file is left unwrapped and a warning recorded instead of silently\n// producing different invalidity.\n\nimport { LEGACY_CSS_LAYER } from '@contentrain/types'\n\nexport interface WrapCssResult {\n content: string\n warning?: string\n}\n\nconst LEADING = /^(\\s*(?:@charset[^;]+;|@import[^;]+;|\\/\\*[\\s\\S]*?\\*\\/)\\s*)+/\n\nexport function wrapLegacyCss(path: string, css: string): WrapCssResult {\n const rest = css.replace(LEADING, '')\n if (/@import\\s/.test(rest)) {\n return {\n content: css,\n warning: `${path}: @import after other rules — left unwrapped (fix the source stylesheet; mid-file imports are ignored by browsers)`,\n }\n }\n const leading = css.slice(0, css.length - rest.length)\n const hoisted = leading.replace(/@import\\s+([^;]+);/g, (full, spec: string) =>\n /\\blayer\\s*\\(/.test(spec) ? full : `@import ${spec.trim()} layer(${LEGACY_CSS_LAYER});`,\n )\n return { content: `${hoisted}@layer ${LEGACY_CSS_LAYER} {\\n${rest}\\n}\\n` }\n}\n","// @contentrain/emitter-astro — the open half of the migration pipeline.\n//\n// A ProjectIR (route model, layout families, component variants, query\n// bindings, design tokens) plus prepared content goes in; a complete Astro\n// project comes out as a pure file map. The analysis that *produces* a good\n// ProjectIR is hard and lives elsewhere; rendering one is deliberately\n// boring — which is exactly why it can be open, portable, and replaceable\n// by community emitters for other frameworks.\n\nimport type { EmitInput, EmitResult } from './types.js'\nimport { scaffoldFiles } from './scaffold.js'\nimport { familyFiles } from './layouts.js'\nimport { routeFiles } from './pages.js'\nimport { componentFiles } from './components.js'\nimport { wrapLegacyCss } from './css.js'\n\nexport function emitAstroProject(input: EmitInput): EmitResult {\n const { ir } = input\n const warnings: string[] = []\n const files: Record<string, string> = {}\n const add = (batch: Record<string, string>) => {\n for (const [path, content] of Object.entries(batch)) {\n if (files[path] !== undefined && files[path] !== content) {\n warnings.push(`duplicate file with different content: ${path} — keeping the first`)\n continue\n }\n files[path] = content\n }\n }\n\n add(scaffoldFiles(ir, input.options ?? {}))\n\n const familiesById = new Map(ir.families.map((f) => [f.id, f]))\n for (const family of ir.families) add(familyFiles(family))\n\n for (const css of input.css ?? []) {\n const wrapped = wrapLegacyCss(css.path, css.content)\n if (wrapped.warning) warnings.push(wrapped.warning)\n const base = css.path.split('/').pop() ?? css.path\n add({ [`public/styles/legacy/${base}`]: wrapped.content })\n }\n for (const family of ir.families) {\n for (const ref of family.css.files ?? []) {\n const base = ref.split('/').pop()\n if (!(input.css ?? []).some((c) => (c.path.split('/').pop() ?? c.path) === base)) {\n warnings.push(`family ${family.id}: css file \"${ref}\" not provided in input.css`)\n }\n }\n }\n\n for (const route of ir.routes) {\n const result = routeFiles(route, familiesById.get(route.family), input.content ?? {})\n add(result.files)\n warnings.push(...result.warnings)\n }\n\n add(componentFiles(ir.components ?? []))\n\n return { files, warnings }\n}\n\n/** Write an EmitResult to disk. Separate from emit so the core stays pure. */\nexport async function writeEmit(result: EmitResult, dir: string): Promise<void> {\n const { mkdir, writeFile } = await import('node:fs/promises')\n const { dirname, join } = await import('node:path')\n const dirs = new Set(Object.keys(result.files).map((p) => dirname(join(dir, p))))\n await Promise.all([...dirs].map((d) => mkdir(d, { recursive: true })))\n await Promise.all(\n Object.entries(result.files).map(([path, content]) => writeFile(join(dir, path), content, 'utf8')),\n )\n}\n\nexport type {\n EmitInput,\n EmitResult,\n EmitContent,\n EmitPost,\n QueryPage,\n EmitCssFile,\n EmitOptions,\n} from './types.js'\nexport { wrapLegacyCss } from './css.js'\nexport { pascalCase, patternToPagePath, stableJson } from './util.js'\n"],"mappings":";;;AACA,SAAgB,WAAW,IAAoB;AAC7C,QAAO,GACJ,MAAM,gBAAgB,CACtB,OAAO,QAAQ,CACf,KAAK,SAAS,KAAK,OAAO,EAAE,CAAC,aAAa,GAAG,KAAK,MAAM,EAAE,CAAC,CAC3D,KAAK,GAAG;;;;;;;AAQb,SAAgB,kBAAkB,SAAgC;AAChE,KAAI,YAAY,OAAO,YAAY,GAAI,QAAO;AAC9C,KAAI,QAAQ,SAAS,IAAI,CAAE,QAAO;CAElC,MAAM,SADW,QAAQ,QAAQ,cAAc,GAAG,CAAC,MAAM,IAAI,CACrC,KAAK,QAAS,IAAI,WAAW,IAAI,GAAG,IAAI,IAAI,MAAM,EAAE,CAAC,KAAK,IAAK;AACvF,KAAI,OAAO,MAAM,QAAQ,CAAC,uBAAuB,KAAK,IAAI,CAAC,CAAE,QAAO;CACpE,MAAM,OAAO,OAAO,KAAK;AACzB,QAAO,CAAC,GAAG,QAAQ,GAAG,KAAK,QAAQ,CAAC,KAAK,IAAI;;AAG/C,MAAM,YAAY,MAAwB;AACxC,KAAI,MAAM,QAAQ,EAAE,CAAE,QAAO,EAAE,IAAI,SAAS;AAC5C,KAAI,KAAK,OAAO,MAAM,SACpB,QAAO,OAAO,YACZ,OAAO,QAAQ,EAA6B,CACzC,UAAU,CAAC,IAAI,CAAC,OAAQ,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,EAAG,CACpD,KAAK,CAAC,GAAG,SAAS,CAAC,GAAG,SAAS,IAAI,CAAC,CAAC,CACzC;AAEH,QAAO;;;AAIT,SAAgB,WAAW,OAAwB;AACjD,QAAO,GAAG,KAAK,UAAU,SAAS,MAAM,EAAE,MAAM,EAAE,CAAC;;;;AC/BrD,SAAgB,cAAc,IAAe,SAA8C;CACzF,MAAM,WAAW,QAAQ,aAAa;CACtC,MAAM,QAAQ,GAAG,sBAAsB;CACvC,MAAM,QAAgC,EAAE;AAkBxC,OAAM,kBAAkB,WAhBa;EACnC,MAAM,QAAQ,eAAe;EAC7B,SAAS;EACT,MAAM;EACN,SAAS;GACP,KAAK;GACL,OAAO;GACP,SAAS;GAET,GAAI,QAAQ;IAAE,iBAAiB;IAAsD,gBAAgB;IAAoD,GAAG,EAAE;GAC/J;EACD,cAAc;GACZ,OAAO;GACP,GAAI,WAAW;IAAE,aAAa;IAAU,qBAAqB;IAAU,GAAG,EAAE;GAC7E;EACF,CACsC;AAEvC,OAAM,sBAAsB;EAC1B;EACA,GAAI,WAAW,CAAC,8CAA8C,GAAG,EAAE;EACnE;EACA;EACA;EACA,GAAI,WAAW,CAAC,wCAAwC,GAAG,EAAE;EAC7D;EACA;EACD,CAAC,KAAK,KAAK;AAEZ,KAAI,SAAU,OAAM,2BAA2B,UAAU,GAAG,OAAO;AAEnE,OAAM,qBAAqB;AAC3B,QAAO;;;AAIT,SAAS,UAAU,QAA0C;CAC3D,MAAM,QAAkB,CAAC,0BAA0B,GAAG;CACtD,MAAM,QAAkB,EAAE;CAC1B,MAAM,QAAQ,QAAgB,QAA4C;AACxE,OAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,OAAO,EAAE,CAAC,CAAE,OAAM,KAAK,OAAO,OAAO,GAAG,IAAI,IAAI,MAAM,GAAG;;AAErG,MAAK,SAAS,QAAQ,OAAO;AAC7B,MAAK,QAAQ,QAAQ,cAAc;AACnC,MAAK,QAAQ,QAAQ,WAAW;AAChC,MAAK,WAAW,QAAQ,QAAQ;AAChC,MAAK,cAAc,QAAQ,YAAY;AACvC,KAAI,MAAM,OAAQ,OAAM,KAAK,YAAY,GAAG,OAAO,IAAI;AACvD,QAAO,GAAG,MAAM,KAAK,KAAK,CAAC;;AAG7B,MAAM,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;ACpDhB,SAAgB,YAAY,QAA8C;CACxE,MAAM,OAAO,WAAW,OAAO,GAAG;CAClC,MAAM,SAAS,OAAO,UAAU,EAAE;CAClC,MAAM,UAAU,aACd,OAAO,QAAQ,MAAM,EAAE,aAAa,SAAS,CAAC,KAAK,MAAM,EAAE,KAAK,CAAC,KAAK,KAAK;CAE7E,MAAM,QAAgC,EAAE;AACxC,OAAM,mBAAmB,OAAO,GAAG,UAAU,WAAW;EACtD,MAAM,OAAO,OAAO;EACpB,QAAQ,OAAO,cAAc;EAC7B,OAAO,OAAO,aAAa;EAC5B,CAAC;CAEF,MAAM,YAAY,OAAO,IAAI,SAAS,EAAE,EACrC,KAAK,MAAM,+CAA+C,EAAE,MAAM,IAAI,CAAC,KAAK,CAAC,MAAM,CACnF,KAAK,KAAK;AAEb,OAAM,eAAe,KAAK,WAAW;aAC1B,OAAO,KAAK,OAAO,OAAO,KAAK,OAAO,KAAK,KAAK,GAAG;qCAC3B,OAAO,GAAG;;;;;;;;;;;;EAY7C,SACC,MAAM,KAAK,CACX,OAAO,QAAQ,CACf,KAAK,MAAM,OAAO,IAAI,CACtB,KAAK,KAAK,CAAC;;;;;;;;;;;AAWZ,QAAO;;;;ACpCT,SAAgB,WACd,OACA,QACA,SACe;CACf,MAAM,WAAqB,EAAE;CAC7B,MAAM,QAAgC,EAAE;CACxC,MAAM,WAAW,kBAAkB,MAAM,QAAQ;AACjD,KAAI,CAAC,SACH,QAAO;EAAE;EAAO,UAAU,CAAC,SAAS,MAAM,GAAG,aAAa,MAAM,QAAQ,kDAAkD;EAAE;AAE9H,KAAI,CAAC,OACH,QAAO;EAAE;EAAO,UAAU,CAAC,SAAS,MAAM,GAAG,YAAY,MAAM,OAAO,uBAAuB;EAAE;CAEjG,MAAM,SAAS,WAAW,OAAO,GAAG;CACpC,MAAM,QAAQ,SAAS,MAAM,IAAI,CAAC,SAAS;CAC3C,MAAM,KAAK,MAAM,OAAO,QAAQ,EAAE,IAAI;CACtC,MAAM,YAAY,SAAS,SAAS,IAAI;AAExC,KAAI,MAAM,SAAS,UAAU;EAC3B,MAAM,QAAQ,QAAQ,SAAS,EAAE;AACjC,MAAI,CAAC,MAAM,OAAQ,UAAS,KAAK,SAAS,MAAM,GAAG,8DAA8D;AACjH,QAAM,yBAAyB,WAAW,MAAM;AAChD,QAAM,aAAa,cAAc,WAAW,OAAO,QAAQ,GAAG;AAC9D,SAAO;GAAE;GAAO;GAAU;;CAG5B,MAAM,aAA0B,MAAM,QAAS,QAAQ,UAAU,MAAM,UAAU,EAAE,GAAI,EAAE;AACzF,KAAI,MAAM,OAAO;AACf,MAAI,CAAC,WAAW,OAAQ,UAAS,KAAK,SAAS,MAAM,GAAG,WAAW,MAAM,MAAM,+CAA+C;AAC9H,MAAI,WAAW,MAAM,MAAM,CAAC,EAAE,cAAc,CAC1C,UAAS,KAAK,SAAS,MAAM,GAAG,qFAAqF;AAEvH,QAAM,oBAAoB,MAAM,MAAM,UAAU,WAAW,WAAW;AACtE,QAAM,aAAa,cAAc,SAAS,OAAO,QAAQ,IAAI,UAAU;AACvE,SAAO;GAAE;GAAO;GAAU;;AAG5B,OAAM,aAAa,cAAc,WAAW,OAAO,QAAQ,IAAI,WAAW,SAAS;AACnF,QAAO;EAAE;EAAO;EAAU;;AAG5B,SAAS,WAAW,OAAmB,QAAgB,IAAoB;AACzE,QAAO;YACG,MAAM,GAAG,IAAI,MAAM,QAAQ;sBACjB,GAAG,UAAU,OAAO;qBACrB,GAAG;6BACK,GAAG;;;;;;;;;;;;AAahC,SAAS,SAAS,OAAmB,QAAgB,IAAY,WAA4B;CAC3F,MAAM,QAAQ,YACV;;;gCAIA;AACJ,QAAO;YACG,MAAM,GAAG,IAAI,MAAM,QAAQ;sBACjB,GAAG,UAAU,OAAO;qBACrB,GAAG,eAAe,MAAM,MAAM;wCACX,GAAG;;EAEzC,MAAM;;;;;;;;;;;;;;;;;;;AAoBR,SAAS,WAAW,OAAmB,QAAgB,IAAY,WAAoB,UAA4B;AACjH,KAAI,UAAW,UAAS,KAAK,SAAS,MAAM,GAAG,kEAAkE;CACjH,MAAM,QAAQ,YACV;;;IAIA;AACJ,QAAO;YACG,MAAM,GAAG,IAAI,MAAM,QAAQ;sBACjB,GAAG,UAAU,OAAO;EACxC,MAAM;;;;;;ACjHR,SAAgB,eAAe,YAAoD;CACjF,MAAM,QAAgC,EAAE;AACxC,MAAK,MAAM,KAAK,YAAY;EAC1B,MAAM,YAAY,EAAE,YAAY,EAAE,EAAE,KAAK,MAAM,EAAE,IAAI;AACrD,QAAM,kBAAkB,WAAW,EAAE,GAAG,CAAC,WAAW;;wBAEhC,EAAE,KAAK,UAAU,EAAE,SAAS,EAAE,OAAO,MAAM,EAAE,SAAS,GAAG;4DAE9E,EAAE,WAAW,YAAY,mEAAmE,KAC3F,SAAS,SAAS,kBAAkB,SAAS,KAAK,KAAK,KAAK,GAAG;;oBAE/C,KAAK,UAAU,SAAS,MAAM,UAAU,CAAC;;0BAEnC,KAAK,UAAU,EAAE,KAAK,CAAC;;;AAG/C,QAAO;;;;ACPT,MAAM,UAAU;AAEhB,SAAgB,cAAc,MAAc,KAA4B;CACtE,MAAM,OAAO,IAAI,QAAQ,SAAS,GAAG;AACrC,KAAI,YAAY,KAAK,KAAK,CACxB,QAAO;EACL,SAAS;EACT,SAAS,GAAG,KAAK;EAClB;AAMH,QAAO,EAAE,SAAS,GAJF,IAAI,MAAM,GAAG,IAAI,SAAS,KAAK,OAAO,CAC9B,QAAQ,wBAAwB,MAAM,SAC5D,eAAe,KAAK,KAAK,GAAG,OAAO,WAAW,KAAK,MAAM,CAAC,SAAS,iBAAiB,IACrF,CAC4B,SAAS,iBAAiB,MAAM,KAAK,QAAQ;;;;ACnB5E,SAAgB,iBAAiB,OAA8B;CAC7D,MAAM,EAAE,OAAO;CACf,MAAM,WAAqB,EAAE;CAC7B,MAAM,QAAgC,EAAE;CACxC,MAAM,OAAO,UAAkC;AAC7C,OAAK,MAAM,CAAC,MAAM,YAAY,OAAO,QAAQ,MAAM,EAAE;AACnD,OAAI,MAAM,UAAU,KAAA,KAAa,MAAM,UAAU,SAAS;AACxD,aAAS,KAAK,0CAA0C,KAAK,sBAAsB;AACnF;;AAEF,SAAM,QAAQ;;;AAIlB,KAAI,cAAc,IAAI,MAAM,WAAW,EAAE,CAAC,CAAC;CAE3C,MAAM,eAAe,IAAI,IAAI,GAAG,SAAS,KAAK,MAAM,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC;AAC/D,MAAK,MAAM,UAAU,GAAG,SAAU,KAAI,YAAY,OAAO,CAAC;AAE1D,MAAK,MAAM,OAAO,MAAM,OAAO,EAAE,EAAE;EACjC,MAAM,UAAU,cAAc,IAAI,MAAM,IAAI,QAAQ;AACpD,MAAI,QAAQ,QAAS,UAAS,KAAK,QAAQ,QAAQ;AAEnD,MAAI,GAAG,wBADM,IAAI,KAAK,MAAM,IAAI,CAAC,KAAK,IAAI,IAAI,SACN,QAAQ,SAAS,CAAC;;AAE5D,MAAK,MAAM,UAAU,GAAG,SACtB,MAAK,MAAM,OAAO,OAAO,IAAI,SAAS,EAAE,EAAE;EACxC,MAAM,OAAO,IAAI,MAAM,IAAI,CAAC,KAAK;AACjC,MAAI,EAAE,MAAM,OAAO,EAAE,EAAE,MAAM,OAAO,EAAE,KAAK,MAAM,IAAI,CAAC,KAAK,IAAI,EAAE,UAAU,KAAK,CAC9E,UAAS,KAAK,UAAU,OAAO,GAAG,cAAc,IAAI,6BAA6B;;AAKvF,MAAK,MAAM,SAAS,GAAG,QAAQ;EAC7B,MAAM,SAAS,WAAW,OAAO,aAAa,IAAI,MAAM,OAAO,EAAE,MAAM,WAAW,EAAE,CAAC;AACrF,MAAI,OAAO,MAAM;AACjB,WAAS,KAAK,GAAG,OAAO,SAAS;;AAGnC,KAAI,eAAe,GAAG,cAAc,EAAE,CAAC,CAAC;AAExC,QAAO;EAAE;EAAO;EAAU;;;AAI5B,eAAsB,UAAU,QAAoB,KAA4B;CAC9E,MAAM,EAAE,OAAO,cAAc,MAAM,OAAO;CAC1C,MAAM,EAAE,SAAS,SAAS,MAAM,OAAO;CACvC,MAAM,OAAO,IAAI,IAAI,OAAO,KAAK,OAAO,MAAM,CAAC,KAAK,MAAM,QAAQ,KAAK,KAAK,EAAE,CAAC,CAAC,CAAC;AACjF,OAAM,QAAQ,IAAI,CAAC,GAAG,KAAK,CAAC,KAAK,MAAM,MAAM,GAAG,EAAE,WAAW,MAAM,CAAC,CAAC,CAAC;AACtE,OAAM,QAAQ,IACZ,OAAO,QAAQ,OAAO,MAAM,CAAC,KAAK,CAAC,MAAM,aAAa,UAAU,KAAK,KAAK,KAAK,EAAE,SAAS,OAAO,CAAC,CACnG"}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@contentrain/emitter-astro",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "Open Astro emitter for Contentrain migrations — renders a ProjectIR into a working Astro project",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Contentrain/ai.git",
|
|
10
|
+
"directory": "packages/emitter-astro"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://ai.contentrain.io",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/Contentrain/ai/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"contentrain",
|
|
18
|
+
"astro",
|
|
19
|
+
"emitter",
|
|
20
|
+
"migration",
|
|
21
|
+
"wordpress",
|
|
22
|
+
"static-site"
|
|
23
|
+
],
|
|
24
|
+
"sideEffects": false,
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.mts",
|
|
31
|
+
"import": "./dist/index.mjs"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"main": "./dist/index.mjs",
|
|
35
|
+
"types": "./dist/index.d.mts",
|
|
36
|
+
"files": [
|
|
37
|
+
"dist"
|
|
38
|
+
],
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@contentrain/types": "1.4.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "^22.10.0",
|
|
44
|
+
"tsdown": "^0.21.0",
|
|
45
|
+
"typescript": "^5.7.0"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"build": "tsdown src/index.ts --format esm --dts",
|
|
49
|
+
"dev": "tsdown src/index.ts --format esm --dts --watch",
|
|
50
|
+
"test": "vitest run",
|
|
51
|
+
"typecheck": "tsc --noEmit",
|
|
52
|
+
"clean": "rm -rf dist"
|
|
53
|
+
}
|
|
54
|
+
}
|