@contentrain/emitter-astro 0.3.0 → 0.5.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/README.md +5 -0
- package/dist/index.d.mts +37 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +154 -48
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -20,6 +20,11 @@ await writeEmit(result, './out/site')
|
|
|
20
20
|
|---|---|
|
|
21
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
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
|
+
| Template markers | `@@mark@@` (escaped) · `@@mark_html@@` (raw, for themes printing a post's own markup) · `<!--@@repeat:list|sep@@-->…<!--@@/repeat@@-->` (per item, with `item` / `item_index` / `item_<key>`) · `<!--@@if:name@@-->…<!--@@/if@@-->` (and `if:!name`). Rendered repeats → conditionals → marks. |
|
|
24
|
+
| Nested addresses | A trailing `*` makes a rest parameter — `/category/:term*` → `[...term].astro`, so `/category/about-cc/events/` keeps its hierarchy instead of collapsing to its last segment. Same for nested page paths. |
|
|
25
|
+
| Collections | `RouteModel.collection` names the content a per-entry route generates from (`EmitContent.collections`); `single` defaults to `posts`. Pages and custom post types get their own routes and their own data files. |
|
|
26
|
+
| Route parameters | Each post carries its own (`EmitPost.params`) — a dated permalink (`/:year/:month/:day/:slug`) generates every post at its real address, so redirects stay unnecessary. |
|
|
27
|
+
| Per-page CSS | `EmitPost.css` / `QueryPage.css` load stylesheets only that page needs (page-builder sites emit CSS per page); the family's `css.files` is the shared union. |
|
|
23
28
|
| Root attributes | `LayoutFamily.root_attrs` lands on `<html>`/`<body>` verbatim — themes key their container rules off `wp-singular`, `single`, `js`, `wf-…`; dropping them costs a correct-content page its whole layout. Values may carry `@@marks@@` (per-page classes like `postid-123`). |
|
|
24
29
|
| 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. |
|
|
25
30
|
| 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. |
|
package/dist/index.d.mts
CHANGED
|
@@ -13,6 +13,18 @@ interface EmitPost {
|
|
|
13
13
|
title: string;
|
|
14
14
|
/** Rendered, asset-rewritten HTML body. */
|
|
15
15
|
body: string;
|
|
16
|
+
/**
|
|
17
|
+
* Route parameters for this post beyond `slug` — the date parts of a
|
|
18
|
+
* `/%year%/%monthnum%/%day%/%postname%/` permalink, a post id, anything the
|
|
19
|
+
* route pattern names. Without them every post but the template one would be
|
|
20
|
+
* generated at the wrong address.
|
|
21
|
+
*/
|
|
22
|
+
params?: Record<string, string>;
|
|
23
|
+
/**
|
|
24
|
+
* Stylesheets only this page loads (page-builder sites emit CSS per page).
|
|
25
|
+
* Family-level stylesheets stay on `LayoutFamily.css.files`.
|
|
26
|
+
*/
|
|
27
|
+
css?: string[];
|
|
16
28
|
/** The site's date-format catalog applied to this post's date (index-aligned with `date{n}` marks). */
|
|
17
29
|
dates?: string[];
|
|
18
30
|
author?: string;
|
|
@@ -22,11 +34,25 @@ interface EmitPost {
|
|
|
22
34
|
/** Local featured-image file names (largest first). */
|
|
23
35
|
featured?: string[];
|
|
24
36
|
excerpt?: string;
|
|
37
|
+
/** Excerpt as source HTML, for themes whose cards keep links and formatting. */
|
|
38
|
+
excerpt_html?: string;
|
|
39
|
+
/** Every author, for repeat blocks — `author` stays the first one. */
|
|
40
|
+
authors?: string[];
|
|
41
|
+
/** Producer-supplied extra marks, merged last. */
|
|
42
|
+
marks?: Record<string, unknown>;
|
|
25
43
|
}
|
|
26
44
|
/** One static path of a list route: its params and the items its list renders. */
|
|
27
45
|
interface QueryPage {
|
|
28
46
|
params: Record<string, string>;
|
|
29
47
|
items: EmitPost[];
|
|
48
|
+
/**
|
|
49
|
+
* Page-level marks for the list page's chrome — a term's display NAME where
|
|
50
|
+
* the route parameter only carries its slug, its description, its count.
|
|
51
|
+
* Without these every category page would print the template category's name.
|
|
52
|
+
*/
|
|
53
|
+
marks?: Record<string, unknown>;
|
|
54
|
+
/** Stylesheets only this list page loads. */
|
|
55
|
+
css?: string[];
|
|
30
56
|
/**
|
|
31
57
|
* Item markup with `@@mark@@` placeholders (title, date{n}, author, excerpt,
|
|
32
58
|
* feat, slug), extracted from the source list. When present, list pages
|
|
@@ -37,8 +63,14 @@ interface QueryPage {
|
|
|
37
63
|
item_template?: string;
|
|
38
64
|
}
|
|
39
65
|
interface EmitContent {
|
|
40
|
-
/**
|
|
66
|
+
/** The default (`posts`) collection — shorthand for `collections.posts`. */
|
|
41
67
|
posts?: EmitPost[];
|
|
68
|
+
/**
|
|
69
|
+
* Content per collection, for sites with more than one kind of single page
|
|
70
|
+
* (posts, pages, custom post types). A `single` route reads the collection
|
|
71
|
+
* named by `RouteModel.collection`.
|
|
72
|
+
*/
|
|
73
|
+
collections?: Record<string, EmitPost[]>;
|
|
42
74
|
/** QueryBinding id → the static paths (and items) that query produces. */
|
|
43
75
|
queries?: Record<string, QueryPage[]>;
|
|
44
76
|
}
|
|
@@ -81,7 +113,10 @@ declare function wrapLegacyCss(path: string, css: string): WrapCssResult;
|
|
|
81
113
|
declare function pascalCase(id: string): string;
|
|
82
114
|
/**
|
|
83
115
|
* Route pattern → Astro pages path.
|
|
84
|
-
* `/` → `index.astro`; `/category/:term/page/:page` → `category/[term]/page/[page].astro
|
|
116
|
+
* `/` → `index.astro`; `/category/:term/page/:page` → `category/[term]/page/[page].astro`;
|
|
117
|
+
* a trailing `*` on a parameter makes it a rest parameter — `/category/:term*`
|
|
118
|
+
* → `category/[...term].astro`, which is what keeps a nested taxonomy address
|
|
119
|
+
* (`/category/about-cc/events/`) from collapsing to its last segment.
|
|
85
120
|
* Returns null for patterns Astro's file routing cannot express (recorded as a warning upstream).
|
|
86
121
|
*/
|
|
87
122
|
declare function patternToPagePath(pattern: string): string | null;
|
package/dist/index.d.mts.map
CHANGED
|
@@ -1 +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;
|
|
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;EASS;EAPT,IAAA;EAcA;;;;;;EAPA,MAAA,GAAS,MAAA;EAgBT;;;;EAXA,GAAA;EAec;EAbd,KAAA;EACA,MAAA;EACA,YAAA;EACA,WAAA;EACA,KAAA;EAqBQ;EAnBR,QAAA;EACA,OAAA;EAWA;EATA,YAAA;EAUA;EARA,OAAA;EAcA;EAZA,KAAA,GAAQ,MAAA;AAAA;;UAIO,SAAA;EACf,MAAA,EAAQ,MAAA;EACR,KAAA,EAAO,QAAA;EAsBmB;;;;;EAhB1B,KAAA,GAAQ,MAAA;EA0BE;EAxBV,GAAA;EAwBgB;;;;;;;EAhBhB,aAAA;AAAA;AAAA,UAMe,WAAA;EAcW;EAZ1B,KAAA,GAAQ,QAAA;EAaR;;AAIF;;;EAXE,WAAA,GAAc,MAAA,SAAe,QAAA;EAerB;EAbR,OAAA,GAAU,MAAA,SAAe,SAAA;AAAA;;UAIV,WAAA;EACf,IAAA;EACA,OAAA;AAAA;AAAA,UAGe,WAAA;EAWM;EATrB,WAAA;EAMI;EAJJ,QAAA;AAAA;AAAA,UAGe,SAAA;EACf,EAAA,EAAI,SAAA;EACJ,OAAA,GAAU,WAAA;EACV,GAAA,GAAM,WAAA;EACN,OAAA,GAAU,WAAA;AAAA;AAUZ;;;;;AAAA,UAAiB,UAAA;EACf,KAAA,EAAO,MAAA;EACP,QAAA;AAAA;;;UChGe,aAAA;EACf,OAAA;EACA,OAAA;AAAA;AAAA,iBAKc,aAAA,CAAc,IAAA,UAAc,GAAA,WAAc,aAAA;;;;iBCtB1C,UAAA,CAAW,EAAA;;AFU3B;;;;;;;iBEMgB,iBAAA,CAAkB,OAAA;;iBAgClB,UAAA,CAAW,KAAA;;;iBCjCX,gBAAA,CAAiB,KAAA,EAAO,SAAA,GAAY,UAAA;;iBA4D9B,SAAA,CAAU,MAAA,EAAQ,UAAA,EAAY,GAAA,WAAc,OAAA"}
|
package/dist/index.mjs
CHANGED
|
@@ -6,17 +6,28 @@ function pascalCase(id) {
|
|
|
6
6
|
}
|
|
7
7
|
/**
|
|
8
8
|
* Route pattern → Astro pages path.
|
|
9
|
-
* `/` → `index.astro`; `/category/:term/page/:page` → `category/[term]/page/[page].astro
|
|
9
|
+
* `/` → `index.astro`; `/category/:term/page/:page` → `category/[term]/page/[page].astro`;
|
|
10
|
+
* a trailing `*` on a parameter makes it a rest parameter — `/category/:term*`
|
|
11
|
+
* → `category/[...term].astro`, which is what keeps a nested taxonomy address
|
|
12
|
+
* (`/category/about-cc/events/`) from collapsing to its last segment.
|
|
10
13
|
* Returns null for patterns Astro's file routing cannot express (recorded as a warning upstream).
|
|
11
14
|
*/
|
|
12
15
|
function patternToPagePath(pattern) {
|
|
13
16
|
if (pattern === "/" || pattern === "") return "index.astro";
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
+
const mapped = pattern.replace(/^\/+|\/+$/g, "").split("/").map((seg) => {
|
|
18
|
+
if (!seg.startsWith(":")) return seg;
|
|
19
|
+
const rest = seg.endsWith("*");
|
|
20
|
+
const name = seg.slice(1, rest ? -1 : void 0);
|
|
21
|
+
return rest ? `[...${name}]` : `[${name}]`;
|
|
22
|
+
});
|
|
23
|
+
if (mapped.some((seg) => !/^(\[\.\.\.[a-zA-Z0-9_]+\]|[[\]a-zA-Z0-9._-]+)$/.test(seg))) return null;
|
|
17
24
|
const last = mapped.pop();
|
|
18
25
|
return [...mapped, `${last}.astro`].join("/");
|
|
19
26
|
}
|
|
27
|
+
/** Index of the rest parameter segment in a page path, or -1. */
|
|
28
|
+
function restParamIndex(pagePath) {
|
|
29
|
+
return pagePath.split("/").findIndex((seg) => seg.startsWith("[..."));
|
|
30
|
+
}
|
|
20
31
|
const sortDeep = (v) => {
|
|
21
32
|
if (Array.isArray(v)) return v.map(sortDeep);
|
|
22
33
|
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)]));
|
|
@@ -38,7 +49,7 @@ function scaffoldFiles(ir, options) {
|
|
|
38
49
|
type: "module",
|
|
39
50
|
scripts: {
|
|
40
51
|
dev: "astro dev",
|
|
41
|
-
build: "astro build",
|
|
52
|
+
build: "astro check && astro build",
|
|
42
53
|
preview: "astro preview",
|
|
43
54
|
...split ? {
|
|
44
55
|
"build:desktop": "VIEWPORT=desktop astro build --outDir dist/desktop",
|
|
@@ -51,6 +62,10 @@ function scaffoldFiles(ir, options) {
|
|
|
51
62
|
tailwindcss: "^4.0.0",
|
|
52
63
|
"@tailwindcss/vite": "^4.0.0"
|
|
53
64
|
} : {}
|
|
65
|
+
},
|
|
66
|
+
devDependencies: {
|
|
67
|
+
"@astrojs/check": "^0.9.0",
|
|
68
|
+
typescript: "^5.7.0"
|
|
54
69
|
}
|
|
55
70
|
});
|
|
56
71
|
files["astro.config.mjs"] = [
|
|
@@ -88,69 +103,136 @@ function modernCss(tokens) {
|
|
|
88
103
|
if (theme.length) lines.push(`@theme {`, ...theme, `}`);
|
|
89
104
|
return `${lines.join("\n")}\n`;
|
|
90
105
|
}
|
|
91
|
-
const FILL_TS = `// Emitted by @contentrain/emitter-astro —
|
|
92
|
-
//
|
|
106
|
+
const FILL_TS = `// Emitted by @contentrain/emitter-astro — the template runtime.
|
|
107
|
+
//
|
|
108
|
+
// Chrome, item templates and attribute values carry three marker forms:
|
|
109
|
+
// @@mark@@ — a value, escaped
|
|
110
|
+
// @@mark_html@@ — a value, raw HTML
|
|
111
|
+
// <!--@@repeat:list@@-->…<!--@@/repeat@@--> — once per list item
|
|
112
|
+
// <!--@@if:name@@-->…<!--@@/if@@--> — only when name has a value
|
|
113
|
+
// Rendering order is repeats → conditionals → marks, so a repeat body may hold
|
|
114
|
+
// conditionals and marks that only make sense per item.
|
|
93
115
|
|
|
94
116
|
/** Where page content splices into the body chrome — must match @contentrain/types CHROME_BODY_SLOT. */
|
|
95
117
|
export const BODY_SLOT = '<!--@@body@@-->'
|
|
96
118
|
|
|
119
|
+
const REPEAT_RE = /<!--@@repeat:([a-z0-9_]+)(?:\\|([\\s\\S]*?))?@@-->([\\s\\S]*?)<!--@@\\/repeat@@-->/gi
|
|
120
|
+
const IF_RE = /<!--@@if:(!?)([a-z0-9_]+)@@-->([\\s\\S]*?)<!--@@\\/if@@-->/gi
|
|
121
|
+
const MARK_RE = /@@([a-z0-9_]+)@@/gi
|
|
122
|
+
|
|
123
|
+
export type Values = Record<string, unknown>
|
|
124
|
+
|
|
97
125
|
export const esc = (value: unknown): string =>
|
|
98
126
|
String(value ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
99
127
|
|
|
100
|
-
|
|
101
|
-
|
|
128
|
+
const isFilled = (value: unknown): boolean =>
|
|
129
|
+
Array.isArray(value) ? value.length > 0 : value !== undefined && value !== null && value !== ''
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Replace @@marks@@. Values are escaped unless the mark name ends in _html —
|
|
133
|
+
* escaping is the default so content-derived text can never break the page,
|
|
134
|
+
* and the _html opt-in exists for themes that print a post's own markup
|
|
135
|
+
* (full content in a list card, a link-bearing excerpt).
|
|
136
|
+
*/
|
|
137
|
+
export function fillMarks(html: string, values: Values): string {
|
|
138
|
+
return html.replace(MARK_RE, (_all, key: string) => {
|
|
139
|
+
const value = values[key]
|
|
140
|
+
if (Array.isArray(value)) return value.map((v) => esc(v)).join(', ')
|
|
141
|
+
return key.toLowerCase().endsWith('_html') ? String(value ?? '') : esc(value ?? '')
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Expand repeat blocks; each item renders the inner fragment with its own values. */
|
|
146
|
+
export function expandRepeats(html: string, values: Values): string {
|
|
147
|
+
return html.replace(REPEAT_RE, (_all, name: string, sep: string | undefined, inner: string) => {
|
|
148
|
+
const list = values[name]
|
|
149
|
+
if (!Array.isArray(list) || list.length === 0) return ''
|
|
150
|
+
return list
|
|
151
|
+
.map((item, index) => {
|
|
152
|
+
const itemValues: Values = { ...values, item, item_index: String(index) }
|
|
153
|
+
if (item && typeof item === 'object') {
|
|
154
|
+
for (const [k, v] of Object.entries(item as Record<string, unknown>)) itemValues['item_' + k] = v
|
|
155
|
+
}
|
|
156
|
+
return renderTemplate(inner, itemValues)
|
|
157
|
+
})
|
|
158
|
+
.join(sep ?? '')
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Drop conditional blocks whose value is empty (or present, when negated). */
|
|
163
|
+
export function applyConditions(html: string, values: Values): string {
|
|
164
|
+
return html.replace(IF_RE, (_all, negate: string, name: string, inner: string) =>
|
|
165
|
+
isFilled(values[name]) !== (negate === '!') ? inner : '',
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Full render: repeats → conditionals → marks. */
|
|
170
|
+
export function renderTemplate(html: string, values: Values): string {
|
|
171
|
+
return fillMarks(applyConditions(expandRepeats(html, values), values), values)
|
|
102
172
|
}
|
|
103
173
|
|
|
104
|
-
/** Fill
|
|
105
|
-
export function fillAttrs(
|
|
106
|
-
attrs: Record<string, string> | undefined,
|
|
107
|
-
marks: Record<string, unknown>,
|
|
108
|
-
): Record<string, string> {
|
|
174
|
+
/** Fill marks inside attribute values (per-page classes like postid-123). */
|
|
175
|
+
export function fillAttrs(attrs: Record<string, string> | undefined, values: Values): Record<string, string> {
|
|
109
176
|
const out: Record<string, string> = {}
|
|
110
|
-
for (const [name, value] of Object.entries(attrs ?? {})) out[name] = fillMarks(value,
|
|
177
|
+
for (const [name, value] of Object.entries(attrs ?? {})) out[name] = fillMarks(value, values)
|
|
111
178
|
return out
|
|
112
179
|
}
|
|
113
180
|
|
|
114
181
|
/**
|
|
115
182
|
* Compose the body chrome with the page content. The split happens BEFORE any
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
183
|
+
* rendering: the @@…@@ pattern would otherwise consume the @@body@@ inside the
|
|
184
|
+
* marker comment, leaving <!----> behind and silently dropping the content
|
|
185
|
+
* (measured cost on a real page: 49.8 vs 97.8).
|
|
119
186
|
*/
|
|
120
|
-
export function composeBody(chromeBody: string,
|
|
187
|
+
export function composeBody(chromeBody: string, values: Values, content: string): string {
|
|
121
188
|
return chromeBody
|
|
122
189
|
.split(BODY_SLOT)
|
|
123
|
-
.map((part) =>
|
|
190
|
+
.map((part) => renderTemplate(part, values))
|
|
124
191
|
.join(content)
|
|
125
192
|
}
|
|
126
193
|
|
|
194
|
+
/** Emitted stylesheets live under /styles/legacy/ — pages reference them by file name. */
|
|
195
|
+
export const cssHref = (file: string): string => '/styles/legacy/' + (file.split('/').pop() ?? file)
|
|
196
|
+
|
|
127
197
|
export interface MarkablePost {
|
|
128
198
|
slug: string
|
|
129
199
|
title: string
|
|
200
|
+
body?: string
|
|
130
201
|
dates?: string[]
|
|
131
202
|
author?: string
|
|
132
203
|
author_first?: string
|
|
133
204
|
author_last?: string
|
|
205
|
+
authors?: string[]
|
|
134
206
|
terms?: string[]
|
|
135
207
|
featured?: string[]
|
|
136
208
|
excerpt?: string
|
|
209
|
+
excerpt_html?: string
|
|
210
|
+
marks?: Record<string, unknown>
|
|
137
211
|
}
|
|
138
212
|
|
|
139
|
-
/**
|
|
140
|
-
|
|
141
|
-
|
|
213
|
+
/**
|
|
214
|
+
* The mark vocabulary: title, author (+ first/last), date{n}, term{n}, feat{n},
|
|
215
|
+
* excerpt, slug — plus \`terms\`/\`authors\` as LISTS for repeat blocks, the
|
|
216
|
+
* _html variants for raw insertion, and any producer-supplied extras.
|
|
217
|
+
*/
|
|
218
|
+
export function postMarks(post: MarkablePost): Values {
|
|
219
|
+
const values: Values = {
|
|
142
220
|
title: post.title,
|
|
143
221
|
author: post.author ?? '',
|
|
144
222
|
author_first: post.author_first ?? '',
|
|
145
223
|
author_last: post.author_last ?? '',
|
|
146
224
|
excerpt: post.excerpt ?? '',
|
|
225
|
+
excerpt_html: post.excerpt_html ?? post.excerpt ?? '',
|
|
226
|
+
body_html: post.body ?? '',
|
|
147
227
|
slug: post.slug,
|
|
148
228
|
feat: post.featured?.[0] ?? '',
|
|
229
|
+
terms: post.terms ?? [],
|
|
230
|
+
authors: post.authors ?? (post.author ? [post.author] : []),
|
|
149
231
|
}
|
|
150
|
-
for (const [i, d] of (post.dates ?? []).entries())
|
|
151
|
-
for (const [i, t] of (post.terms ?? []).entries())
|
|
152
|
-
for (const [i, f] of (post.featured ?? []).entries())
|
|
153
|
-
return marks
|
|
232
|
+
for (const [i, d] of (post.dates ?? []).entries()) values['date' + i] = d
|
|
233
|
+
for (const [i, t] of (post.terms ?? []).entries()) values['term' + i] = t
|
|
234
|
+
for (const [i, f] of (post.featured ?? []).entries()) values['feat' + i] = f
|
|
235
|
+
return { ...values, ...(post.marks ?? {}) }
|
|
154
236
|
}
|
|
155
237
|
`;
|
|
156
238
|
//#endregion
|
|
@@ -180,16 +262,18 @@ function familyFiles(family, lang) {
|
|
|
180
262
|
files[`src/layouts/${name}.astro`] = `---
|
|
181
263
|
// Family: ${family.id}${family.name ? ` (${family.name})` : ""} — emitted by @contentrain/emitter-astro
|
|
182
264
|
import chrome from '../data/chrome/${family.id}.json'
|
|
183
|
-
import { fillAttrs,
|
|
265
|
+
import { cssHref, fillAttrs, renderTemplate, composeBody } from '../lib/fill'
|
|
184
266
|
|
|
185
267
|
interface Props {
|
|
186
268
|
title?: string
|
|
187
|
-
marks?: Record<string,
|
|
269
|
+
marks?: Record<string, unknown>
|
|
188
270
|
/** Page content as an HTML string; slot children are used when absent. */
|
|
189
271
|
body?: string
|
|
272
|
+
/** Stylesheets only this page loads (page-builder sites emit CSS per page). */
|
|
273
|
+
css?: string[]
|
|
190
274
|
}
|
|
191
|
-
const { title = '', marks = {}, body } = Astro.props
|
|
192
|
-
const head =
|
|
275
|
+
const { title = '', marks = {}, body, css = [] } = Astro.props
|
|
276
|
+
const head = renderTemplate(chrome.head, marks)
|
|
193
277
|
// Root attributes carry the theme's layout hooks; values may hold @@marks@@.
|
|
194
278
|
// An explicit lang from the source wins over the project default.
|
|
195
279
|
const htmlAttrs = { lang: ${JSON.stringify(lang)}, ...fillAttrs(chrome.html_attrs, marks) }
|
|
@@ -205,6 +289,9 @@ const html = composeBody(chrome.body, marks, content)
|
|
|
205
289
|
<meta charset="utf-8" />
|
|
206
290
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
207
291
|
${cssLinks.split("\n").filter(Boolean).map((l) => ` ${l}`).join("\n")}
|
|
292
|
+
{css.map((file) => (
|
|
293
|
+
<link rel="stylesheet" href={cssHref(file)} />
|
|
294
|
+
))}
|
|
208
295
|
<Fragment set:html={head} />
|
|
209
296
|
<title>{title}</title>
|
|
210
297
|
</head>
|
|
@@ -236,11 +323,14 @@ function routeFiles(route, family, content) {
|
|
|
236
323
|
const depth = pagePath.split("/").length - 1;
|
|
237
324
|
const up = "../".repeat(depth + 1) || "../";
|
|
238
325
|
const hasParams = pagePath.includes("[");
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
326
|
+
const restAt = restParamIndex(pagePath);
|
|
327
|
+
if (restAt !== -1 && restAt !== pagePath.split("/").length - 1) warnings.push(`route ${route.id}: rest parameter is not the final segment of "${route.pattern}" — Astro matches rest parameters greedily; verify this route resolves`);
|
|
328
|
+
if (route.kind === "single" || route.collection !== void 0) {
|
|
329
|
+
const collection = route.collection ?? "posts";
|
|
330
|
+
const posts = collectionItems(content, collection);
|
|
331
|
+
if (!posts.length) warnings.push(`route ${route.id}: collection "${collection}" has no items — page emitted, data file empty`);
|
|
332
|
+
files[`src/data/${collection}.json`] = stableJson(posts);
|
|
333
|
+
files[`src/pages/${pagePath}`] = singlePage(route, layout, up, collection);
|
|
244
334
|
return {
|
|
245
335
|
files,
|
|
246
336
|
warnings
|
|
@@ -263,20 +353,29 @@ function routeFiles(route, family, content) {
|
|
|
263
353
|
warnings
|
|
264
354
|
};
|
|
265
355
|
}
|
|
266
|
-
|
|
356
|
+
/** Items of a named collection; `posts` also accepts the `content.posts` shorthand. */
|
|
357
|
+
function collectionItems(content, collection) {
|
|
358
|
+
const named = content.collections?.[collection];
|
|
359
|
+
if (named) return named;
|
|
360
|
+
return collection === "posts" ? content.posts ?? [] : [];
|
|
361
|
+
}
|
|
362
|
+
function singlePage(route, layout, up, collection) {
|
|
267
363
|
return `---
|
|
268
|
-
// Route: ${route.id} (${route.pattern}) — emitted by @contentrain/emitter-astro
|
|
364
|
+
// Route: ${route.id} (${route.pattern}) — collection: ${collection} — emitted by @contentrain/emitter-astro
|
|
269
365
|
import Layout from '${up}layouts/${layout}.astro'
|
|
270
|
-
import posts from '${up}data
|
|
366
|
+
import posts from '${up}data/${collection}.json'
|
|
271
367
|
import { postMarks } from '${up}lib/fill'
|
|
272
368
|
|
|
273
369
|
export function getStaticPaths() {
|
|
274
|
-
|
|
370
|
+
// A post carries its own route parameters (date parts of a dated permalink,
|
|
371
|
+
// a post id) — using the template post's would send every other post to the
|
|
372
|
+
// wrong address.
|
|
373
|
+
return posts.map((post) => ({ params: { ...(post.params ?? {}), slug: post.slug }, props: { post } }))
|
|
275
374
|
}
|
|
276
375
|
type Props = { post: (typeof posts)[number] }
|
|
277
376
|
const { post } = Astro.props as Props
|
|
278
377
|
---
|
|
279
|
-
<Layout title={post.title} marks={postMarks(post)} body={post.body} />
|
|
378
|
+
<Layout title={post.title} marks={postMarks(post)} body={post.body} css={post.css ?? []} />
|
|
280
379
|
`;
|
|
281
380
|
}
|
|
282
381
|
function listPage(route, layout, up, hasParams) {
|
|
@@ -289,11 +388,11 @@ const { page } = Astro.props as Props` : `const page = pages[0] ?? { params: {},
|
|
|
289
388
|
// Route: ${route.id} (${route.pattern}) — emitted by @contentrain/emitter-astro
|
|
290
389
|
import Layout from '${up}layouts/${layout}.astro'
|
|
291
390
|
import pages from '${up}data/queries/${route.query}.json'
|
|
292
|
-
import { esc,
|
|
391
|
+
import { esc, postMarks, renderTemplate } from '${up}lib/fill'
|
|
293
392
|
|
|
294
393
|
${paths}
|
|
295
394
|
const items = page.items.map((item) =>
|
|
296
|
-
page.item_template ?
|
|
395
|
+
page.item_template ? renderTemplate(page.item_template, postMarks(item)) : undefined,
|
|
297
396
|
)
|
|
298
397
|
const fallback = items.some((h) => h === undefined)
|
|
299
398
|
const content = fallback
|
|
@@ -301,8 +400,11 @@ const content = fallback
|
|
|
301
400
|
page.items.map((item) => '<li><a href="/' + item.slug + '/">' + esc(item.title) + '</a></li>').join('') +
|
|
302
401
|
'</ul>'
|
|
303
402
|
: items.join('')
|
|
403
|
+
// Route params carry slugs; page marks carry what the chrome needs to SHOW
|
|
404
|
+
// (a term's display name, description, count).
|
|
405
|
+
const marks = { ...page.params, ...(page.marks ?? {}) }
|
|
304
406
|
---
|
|
305
|
-
<Layout title={page.params.term ?? ''} marks={
|
|
407
|
+
<Layout title={String(marks.term_name ?? page.params.term ?? '')} marks={marks} body={content} css={page.css ?? []} />
|
|
306
408
|
`;
|
|
307
409
|
}
|
|
308
410
|
function staticPage(route, layout, up, hasParams, warnings) {
|
|
@@ -378,10 +480,14 @@ function emitAstroProject(input) {
|
|
|
378
480
|
if (wrapped.warning) warnings.push(wrapped.warning);
|
|
379
481
|
add({ [`public/styles/legacy/${css.path.split("/").pop() ?? css.path}`]: wrapped.content });
|
|
380
482
|
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
}
|
|
483
|
+
const providedCss = new Set((input.css ?? []).map((c) => c.path.split("/").pop() ?? c.path));
|
|
484
|
+
const missingCss = (refs, owner) => {
|
|
485
|
+
for (const ref of refs ?? []) if (!providedCss.has(ref.split("/").pop() ?? ref)) warnings.push(`${owner}: css file "${ref}" not provided in input.css`);
|
|
486
|
+
};
|
|
487
|
+
for (const family of ir.families) missingCss(family.css.files, `family ${family.id}`);
|
|
488
|
+
const allPosts = [...input.content?.posts ?? [], ...Object.values(input.content?.collections ?? {}).flat()];
|
|
489
|
+
for (const post of allPosts) missingCss(post.css, `post ${post.slug}`);
|
|
490
|
+
for (const [queryId, queryPages] of Object.entries(input.content?.queries ?? {})) for (const qp of queryPages) missingCss(qp.css, `query ${queryId}`);
|
|
385
491
|
for (const route of ir.routes) {
|
|
386
492
|
const result = routeFiles(route, familiesById.get(route.family), input.content ?? {});
|
|
387
493
|
add(result.files);
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +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 // Canonical URLs and sitemaps hang off \\`site\\` — for a migration, SEO\n // continuity is the point, so the source site's URL always lands here.\n ` site: ${JSON.stringify(ir.site.url)},`,\n ` build: { format: 'directory' },`,\n ...(tailwind ? [` vite: { plugins: [tailwindcss()] },`] : []),\n `})`,\n ``,\n ].join('\\n')\n\n // Astro wants a tsconfig in every project — editors and the compiler read\n // it even in JS-only projects, and the emitted src/lib/fill.ts is TS.\n files['tsconfig.json'] = `${JSON.stringify({ extends: 'astro/tsconfigs/base', include: ['.astro/types.d.ts', '**/*'], exclude: ['dist'] }, null, 2)}\\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\n/** Where page content splices into the body chrome — must match @contentrain/types CHROME_BODY_SLOT. */\nexport const BODY_SLOT = '<!--@@body@@-->'\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\n/** Fill @@marks@@ inside attribute values (per-page classes like postid-123). */\nexport function fillAttrs(\n attrs: Record<string, string> | undefined,\n marks: Record<string, unknown>,\n): Record<string, string> {\n const out: Record<string, string> = {}\n for (const [name, value] of Object.entries(attrs ?? {})) out[name] = fillMarks(value, marks)\n return out\n}\n\n/**\n * Compose the body chrome with the page content. The split happens BEFORE any\n * mark filling: fillMarks' @@…@@ pattern would otherwise consume the @@body@@\n * inside the marker comment, leaving <!----> behind and silently dropping the\n * content (measured cost on a real page: 49.8 vs 97.8).\n */\nexport function composeBody(chromeBody: string, marks: Record<string, unknown>, content: string): string {\n return chromeBody\n .split(BODY_SLOT)\n .map((part) => fillMarks(part, marks))\n .join(content)\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. And the body chrome is injected\n// as ONE fragment: real themes nest the content container deep inside the\n// chrome, so splitting it into before/after halves produces unbalanced\n// fragments that the parser silently \"repairs\" (measured cost: 36 vs 100).\n// Content splices in at CHROME_BODY_SLOT before the single injection.\n//\n// Root attributes travel with the chrome for the same reason: themes key their\n// container rules off `<body class=\"wp-singular single …\">` and `<html class=\"js\n// wf-…\">`, so a page with perfect content and empty root attributes loses its\n// whole layout (measured: 36.4 vs 100).\n\nimport type { LayoutFamily } from '@contentrain/types'\nimport { CHROME_BODY_SLOT } from '@contentrain/types'\nimport { pascalCase, stableJson } from './util.js'\n\nexport interface FamilyGenResult {\n files: Record<string, string>\n warnings: string[]\n}\n\nexport function familyFiles(family: LayoutFamily, lang: string): FamilyGenResult {\n const name = pascalCase(family.id)\n const chunks = family.chrome ?? []\n const warnings: string[] = []\n const joined = (position: string) =>\n chunks.filter((c) => c.position === position).map((c) => c.html).join('\\n')\n\n const bodyChunks = chunks.filter((c) => c.position === 'body')\n let body: string\n if (bodyChunks.length) {\n body = bodyChunks.map((c) => c.html).join('\\n')\n if (!body.includes(CHROME_BODY_SLOT)) {\n warnings.push(`family ${family.id}: body chrome has no ${CHROME_BODY_SLOT} marker — content appended at the end`)\n body += CHROME_BODY_SLOT\n }\n } else {\n // Legacy pair: compose into one string with the slot between the halves.\n body = `${joined('before_body')}${CHROME_BODY_SLOT}${joined('after_body')}`\n }\n\n const files: Record<string, string> = {}\n files[`src/data/chrome/${family.id}.json`] = stableJson({\n head: joined('head'),\n body,\n html_attrs: family.root_attrs?.html ?? {},\n body_attrs: family.root_attrs?.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 { fillAttrs, fillMarks, composeBody } from '../lib/fill'\n\ninterface Props {\n title?: string\n marks?: Record<string, string>\n /** Page content as an HTML string; slot children are used when absent. */\n body?: string\n}\nconst { title = '', marks = {}, body } = Astro.props\nconst head = fillMarks(chrome.head, marks)\n// Root attributes carry the theme's layout hooks; values may hold @@marks@@.\n// An explicit lang from the source wins over the project default.\nconst htmlAttrs = { lang: ${JSON.stringify(lang)}, ...fillAttrs(chrome.html_attrs, marks) }\nconst bodyAttrs = fillAttrs(chrome.body_attrs, marks)\nconst content = body ?? (Astro.slots.has('default') ? await Astro.slots.render('default') : '')\n// Split at the marker FIRST, then fill marks per side — filling first would\n// eat the @@body@@ inside the marker and silently drop the content.\nconst html = composeBody(chrome.body, marks, content)\n---\n<!doctype html>\n<html {...htmlAttrs}>\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 {...bodyAttrs}>\n <Fragment set:html={html} />\n </body>\n</html>\n`\n return { files, warnings }\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}\ntype Props = { post: (typeof posts)[number] }\nconst { post } = Astro.props as Props\n---\n<Layout title={post.title} marks={postMarks(post)} body={post.body} />\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}\ntype Props = { page: (typeof pages)[number] }\nconst { page } = Astro.props as 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 { esc, 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)\nconst content = fallback\n ? '<ul class=\"cr-post-list\">' +\n page.items.map((item) => '<li><a href=\"/' + item.slug + '/\">' + esc(item.title) + '</a></li>').join('') +\n '</ul>'\n : items.join('')\n---\n<Layout title={page.params.term ?? ''} marks={page.params} body={content} />\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 */\ninterface Props {\n variant?: string\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 const lang = ir.site.locales?.[0] ?? 'en'\n for (const family of ir.families) {\n const fam = familyFiles(family, lang)\n add(fam.files)\n warnings.push(...fam.warnings)\n }\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;EAGA,WAAW,KAAK,UAAU,GAAG,KAAK,IAAI,CAAC;EACvC;EACA,GAAI,WAAW,CAAC,wCAAwC,GAAG,EAAE;EAC7D;EACA;EACD,CAAC,KAAK,KAAK;AAIZ,OAAM,mBAAmB,GAAG,KAAK,UAAU;EAAE,SAAS;EAAwB,SAAS,CAAC,qBAAqB,OAAO;EAAE,SAAS,CAAC,OAAO;EAAE,EAAE,MAAM,EAAE,CAAC;AAEpJ,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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AC/ChB,SAAgB,YAAY,QAAsB,MAA+B;CAC/E,MAAM,OAAO,WAAW,OAAO,GAAG;CAClC,MAAM,SAAS,OAAO,UAAU,EAAE;CAClC,MAAM,WAAqB,EAAE;CAC7B,MAAM,UAAU,aACd,OAAO,QAAQ,MAAM,EAAE,aAAa,SAAS,CAAC,KAAK,MAAM,EAAE,KAAK,CAAC,KAAK,KAAK;CAE7E,MAAM,aAAa,OAAO,QAAQ,MAAM,EAAE,aAAa,OAAO;CAC9D,IAAI;AACJ,KAAI,WAAW,QAAQ;AACrB,SAAO,WAAW,KAAK,MAAM,EAAE,KAAK,CAAC,KAAK,KAAK;AAC/C,MAAI,CAAC,KAAK,SAAS,iBAAiB,EAAE;AACpC,YAAS,KAAK,UAAU,OAAO,GAAG,uBAAuB,iBAAiB,uCAAuC;AACjH,WAAQ;;OAIV,QAAO,GAAG,OAAO,cAAc,GAAG,mBAAmB,OAAO,aAAa;CAG3E,MAAM,QAAgC,EAAE;AACxC,OAAM,mBAAmB,OAAO,GAAG,UAAU,WAAW;EACtD,MAAM,OAAO,OAAO;EACpB;EACA,YAAY,OAAO,YAAY,QAAQ,EAAE;EACzC,YAAY,OAAO,YAAY,QAAQ,EAAE;EAC1C,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;;;;;;;;;;;;;4BAanB,KAAK,UAAU,KAAK,CAAC;;;;;;;;;;;;EAY/C,SACC,MAAM,KAAK,CACX,OAAO,QAAQ,CACf,KAAK,MAAM,OAAO,IAAI,CACtB,KAAK,KAAK,CAAC;;;;;;;;;AASZ,QAAO;EAAE;EAAO;EAAU;;;;AC1E5B,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;;;;;;;;;;;AAYhC,SAAS,SAAS,OAAmB,QAAgB,IAAY,WAA4B;CAC3F,MAAM,QAAQ,YACV;;;;yCAKA;AACJ,QAAO;YACG,MAAM,GAAG,IAAI,MAAM,QAAQ;sBACjB,GAAG,UAAU,OAAO;qBACrB,GAAG,eAAe,MAAM,MAAM;6CACN,GAAG;;EAE9C,MAAM;;;;;;;;;;;;;;AAeR,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;;;;;;AC5GR,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;;;;;oBAK/C,KAAK,UAAU,SAAS,MAAM,UAAU,CAAC;;0BAEnC,KAAK,UAAU,EAAE,KAAK,CAAC;;;AAG/C,QAAO;;;;ACVT,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;CAC/D,MAAM,OAAO,GAAG,KAAK,UAAU,MAAM;AACrC,MAAK,MAAM,UAAU,GAAG,UAAU;EAChC,MAAM,MAAM,YAAY,QAAQ,KAAK;AACrC,MAAI,IAAI,MAAM;AACd,WAAS,KAAK,GAAG,IAAI,SAAS;;AAGhC,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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/util.ts","../src/scaffold.ts","../src/layouts.ts","../src/types.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 * a trailing `*` on a parameter makes it a rest parameter — `/category/:term*`\n * → `category/[...term].astro`, which is what keeps a nested taxonomy address\n * (`/category/about-cc/events/`) from collapsing to its last segment.\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 const segments = pattern.replace(/^\\/+|\\/+$/g, '').split('/')\n const mapped = segments.map((seg) => {\n if (!seg.startsWith(':')) return seg\n const rest = seg.endsWith('*')\n const name = seg.slice(1, rest ? -1 : undefined)\n return rest ? `[...${name}]` : `[${name}]`\n })\n if (mapped.some((seg) => !/^(\\[\\.\\.\\.[a-zA-Z0-9_]+\\]|[[\\]a-zA-Z0-9._-]+)$/.test(seg))) return null\n const last = mapped.pop()\n return [...mapped, `${last}.astro`].join('/')\n}\n\n/** Index of the rest parameter segment in a page path, or -1. */\nexport function restParamIndex(pagePath: string): number {\n return pagePath.split('/').findIndex((seg) => seg.startsWith('[...'))\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 // `astro check` first: type errors in generated pages should fail the\n // build, not surface later in a browser.\n build: 'astro check && 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 devDependencies: {\n '@astrojs/check': '^0.9.0',\n typescript: '^5.7.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 // Canonical URLs and sitemaps hang off \\`site\\` — for a migration, SEO\n // continuity is the point, so the source site's URL always lands here.\n ` site: ${JSON.stringify(ir.site.url)},`,\n ` build: { format: 'directory' },`,\n ...(tailwind ? [` vite: { plugins: [tailwindcss()] },`] : []),\n `})`,\n ``,\n ].join('\\n')\n\n // Astro wants a tsconfig in every project — editors and the compiler read\n // it even in JS-only projects, and the emitted src/lib/fill.ts is TS.\n files['tsconfig.json'] = `${JSON.stringify({ extends: 'astro/tsconfigs/base', include: ['.astro/types.d.ts', '**/*'], exclude: ['dist'] }, null, 2)}\\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 — the template runtime.\n//\n// Chrome, item templates and attribute values carry three marker forms:\n// @@mark@@ — a value, escaped\n// @@mark_html@@ — a value, raw HTML\n// <!--@@repeat:list@@-->…<!--@@/repeat@@--> — once per list item\n// <!--@@if:name@@-->…<!--@@/if@@--> — only when name has a value\n// Rendering order is repeats → conditionals → marks, so a repeat body may hold\n// conditionals and marks that only make sense per item.\n\n/** Where page content splices into the body chrome — must match @contentrain/types CHROME_BODY_SLOT. */\nexport const BODY_SLOT = '<!--@@body@@-->'\n\nconst REPEAT_RE = /<!--@@repeat:([a-z0-9_]+)(?:\\\\|([\\\\s\\\\S]*?))?@@-->([\\\\s\\\\S]*?)<!--@@\\\\/repeat@@-->/gi\nconst IF_RE = /<!--@@if:(!?)([a-z0-9_]+)@@-->([\\\\s\\\\S]*?)<!--@@\\\\/if@@-->/gi\nconst MARK_RE = /@@([a-z0-9_]+)@@/gi\n\nexport type Values = Record<string, unknown>\n\nexport const esc = (value: unknown): string =>\n String(value ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')\n\nconst isFilled = (value: unknown): boolean =>\n Array.isArray(value) ? value.length > 0 : value !== undefined && value !== null && value !== ''\n\n/**\n * Replace @@marks@@. Values are escaped unless the mark name ends in _html —\n * escaping is the default so content-derived text can never break the page,\n * and the _html opt-in exists for themes that print a post's own markup\n * (full content in a list card, a link-bearing excerpt).\n */\nexport function fillMarks(html: string, values: Values): string {\n return html.replace(MARK_RE, (_all, key: string) => {\n const value = values[key]\n if (Array.isArray(value)) return value.map((v) => esc(v)).join(', ')\n return key.toLowerCase().endsWith('_html') ? String(value ?? '') : esc(value ?? '')\n })\n}\n\n/** Expand repeat blocks; each item renders the inner fragment with its own values. */\nexport function expandRepeats(html: string, values: Values): string {\n return html.replace(REPEAT_RE, (_all, name: string, sep: string | undefined, inner: string) => {\n const list = values[name]\n if (!Array.isArray(list) || list.length === 0) return ''\n return list\n .map((item, index) => {\n const itemValues: Values = { ...values, item, item_index: String(index) }\n if (item && typeof item === 'object') {\n for (const [k, v] of Object.entries(item as Record<string, unknown>)) itemValues['item_' + k] = v\n }\n return renderTemplate(inner, itemValues)\n })\n .join(sep ?? '')\n })\n}\n\n/** Drop conditional blocks whose value is empty (or present, when negated). */\nexport function applyConditions(html: string, values: Values): string {\n return html.replace(IF_RE, (_all, negate: string, name: string, inner: string) =>\n isFilled(values[name]) !== (negate === '!') ? inner : '',\n )\n}\n\n/** Full render: repeats → conditionals → marks. */\nexport function renderTemplate(html: string, values: Values): string {\n return fillMarks(applyConditions(expandRepeats(html, values), values), values)\n}\n\n/** Fill marks inside attribute values (per-page classes like postid-123). */\nexport function fillAttrs(attrs: Record<string, string> | undefined, values: Values): Record<string, string> {\n const out: Record<string, string> = {}\n for (const [name, value] of Object.entries(attrs ?? {})) out[name] = fillMarks(value, values)\n return out\n}\n\n/**\n * Compose the body chrome with the page content. The split happens BEFORE any\n * rendering: the @@…@@ pattern would otherwise consume the @@body@@ inside the\n * marker comment, leaving <!----> behind and silently dropping the content\n * (measured cost on a real page: 49.8 vs 97.8).\n */\nexport function composeBody(chromeBody: string, values: Values, content: string): string {\n return chromeBody\n .split(BODY_SLOT)\n .map((part) => renderTemplate(part, values))\n .join(content)\n}\n\n/** Emitted stylesheets live under /styles/legacy/ — pages reference them by file name. */\nexport const cssHref = (file: string): string => '/styles/legacy/' + (file.split('/').pop() ?? file)\n\nexport interface MarkablePost {\n slug: string\n title: string\n body?: string\n dates?: string[]\n author?: string\n author_first?: string\n author_last?: string\n authors?: string[]\n terms?: string[]\n featured?: string[]\n excerpt?: string\n excerpt_html?: string\n marks?: Record<string, unknown>\n}\n\n/**\n * The mark vocabulary: title, author (+ first/last), date{n}, term{n}, feat{n},\n * excerpt, slug — plus \\`terms\\`/\\`authors\\` as LISTS for repeat blocks, the\n * _html variants for raw insertion, and any producer-supplied extras.\n */\nexport function postMarks(post: MarkablePost): Values {\n const values: Values = {\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 excerpt_html: post.excerpt_html ?? post.excerpt ?? '',\n body_html: post.body ?? '',\n slug: post.slug,\n feat: post.featured?.[0] ?? '',\n terms: post.terms ?? [],\n authors: post.authors ?? (post.author ? [post.author] : []),\n }\n for (const [i, d] of (post.dates ?? []).entries()) values['date' + i] = d\n for (const [i, t] of (post.terms ?? []).entries()) values['term' + i] = t\n for (const [i, f] of (post.featured ?? []).entries()) values['feat' + i] = f\n return { ...values, ...(post.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. And the body chrome is injected\n// as ONE fragment: real themes nest the content container deep inside the\n// chrome, so splitting it into before/after halves produces unbalanced\n// fragments that the parser silently \"repairs\" (measured cost: 36 vs 100).\n// Content splices in at CHROME_BODY_SLOT before the single injection.\n//\n// Root attributes travel with the chrome for the same reason: themes key their\n// container rules off `<body class=\"wp-singular single …\">` and `<html class=\"js\n// wf-…\">`, so a page with perfect content and empty root attributes loses its\n// whole layout (measured: 36.4 vs 100).\n\nimport type { LayoutFamily } from '@contentrain/types'\nimport { CHROME_BODY_SLOT } from '@contentrain/types'\nimport { pascalCase, stableJson } from './util.js'\n\nexport interface FamilyGenResult {\n files: Record<string, string>\n warnings: string[]\n}\n\nexport function familyFiles(family: LayoutFamily, lang: string): FamilyGenResult {\n const name = pascalCase(family.id)\n const chunks = family.chrome ?? []\n const warnings: string[] = []\n const joined = (position: string) =>\n chunks.filter((c) => c.position === position).map((c) => c.html).join('\\n')\n\n const bodyChunks = chunks.filter((c) => c.position === 'body')\n let body: string\n if (bodyChunks.length) {\n body = bodyChunks.map((c) => c.html).join('\\n')\n if (!body.includes(CHROME_BODY_SLOT)) {\n warnings.push(`family ${family.id}: body chrome has no ${CHROME_BODY_SLOT} marker — content appended at the end`)\n body += CHROME_BODY_SLOT\n }\n } else {\n // Legacy pair: compose into one string with the slot between the halves.\n body = `${joined('before_body')}${CHROME_BODY_SLOT}${joined('after_body')}`\n }\n\n const files: Record<string, string> = {}\n files[`src/data/chrome/${family.id}.json`] = stableJson({\n head: joined('head'),\n body,\n html_attrs: family.root_attrs?.html ?? {},\n body_attrs: family.root_attrs?.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 { cssHref, fillAttrs, renderTemplate, composeBody } from '../lib/fill'\n\ninterface Props {\n title?: string\n marks?: Record<string, unknown>\n /** Page content as an HTML string; slot children are used when absent. */\n body?: string\n /** Stylesheets only this page loads (page-builder sites emit CSS per page). */\n css?: string[]\n}\nconst { title = '', marks = {}, body, css = [] } = Astro.props\nconst head = renderTemplate(chrome.head, marks)\n// Root attributes carry the theme's layout hooks; values may hold @@marks@@.\n// An explicit lang from the source wins over the project default.\nconst htmlAttrs = { lang: ${JSON.stringify(lang)}, ...fillAttrs(chrome.html_attrs, marks) }\nconst bodyAttrs = fillAttrs(chrome.body_attrs, marks)\nconst content = body ?? (Astro.slots.has('default') ? await Astro.slots.render('default') : '')\n// Split at the marker FIRST, then fill marks per side — filling first would\n// eat the @@body@@ inside the marker and silently drop the content.\nconst html = composeBody(chrome.body, marks, content)\n---\n<!doctype html>\n<html {...htmlAttrs}>\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 {css.map((file) => (\n <link rel=\"stylesheet\" href={cssHref(file)} />\n ))}\n <Fragment set:html={head} />\n <title>{title}</title>\n </head>\n <body {...bodyAttrs}>\n <Fragment set:html={html} />\n </body>\n</html>\n`\n return { files, warnings }\n}\n","import type { ProjectIR } from '@contentrain/types'\n\n// ─── Emit input ───\n\n/**\n * One piece of content ready to render: body already HTML, assets already\n * rewritten to local paths, dates already formatted in the site's observed\n * formats. The emitter renders; it does not fetch, localize, or format —\n * those are producer concerns, and keeping them out is what makes this\n * package portable.\n */\nexport interface EmitPost {\n slug: string\n title: string\n /** Rendered, asset-rewritten HTML body. */\n body: string\n /**\n * Route parameters for this post beyond `slug` — the date parts of a\n * `/%year%/%monthnum%/%day%/%postname%/` permalink, a post id, anything the\n * route pattern names. Without them every post but the template one would be\n * generated at the wrong address.\n */\n params?: Record<string, string>\n /**\n * Stylesheets only this page loads (page-builder sites emit CSS per page).\n * Family-level stylesheets stay on `LayoutFamily.css.files`.\n */\n css?: string[]\n /** The site's date-format catalog applied to this post's date (index-aligned with `date{n}` marks). */\n dates?: string[]\n author?: string\n author_first?: string\n author_last?: string\n terms?: string[]\n /** Local featured-image file names (largest first). */\n featured?: string[]\n excerpt?: string\n /** Excerpt as source HTML, for themes whose cards keep links and formatting. */\n excerpt_html?: string\n /** Every author, for repeat blocks — `author` stays the first one. */\n authors?: string[]\n /** Producer-supplied extra marks, merged last. */\n marks?: Record<string, unknown>\n}\n\n/** One static path of a list route: its params and the items its list renders. */\nexport interface QueryPage {\n params: Record<string, string>\n items: EmitPost[]\n /**\n * Page-level marks for the list page's chrome — a term's display NAME where\n * the route parameter only carries its slug, its description, its count.\n * Without these every category page would print the template category's name.\n */\n marks?: Record<string, unknown>\n /** Stylesheets only this list page loads. */\n css?: string[]\n /**\n * Item markup with `@@mark@@` placeholders (title, date{n}, author, excerpt,\n * feat, slug), extracted from the source list. When present, list pages\n * render each item by filling the template; when absent, a plain fallback\n * list is emitted and a warning recorded — a silent fallback would read as\n * fidelity when it is not.\n */\n item_template?: string\n}\n\n/** Default collection name when a `single` route does not name one. */\nexport const DEFAULT_COLLECTION = 'posts'\n\nexport interface EmitContent {\n /** The default (`posts`) collection — shorthand for `collections.posts`. */\n posts?: EmitPost[]\n /**\n * Content per collection, for sites with more than one kind of single page\n * (posts, pages, custom post types). A `single` route reads the collection\n * named by `RouteModel.collection`.\n */\n collections?: Record<string, EmitPost[]>\n /** QueryBinding id → the static paths (and items) that query produces. */\n queries?: Record<string, QueryPage[]>\n}\n\n/** A legacy stylesheet provided by the producer, referenced from `LayoutFamily.css.files`. */\nexport interface EmitCssFile {\n path: string\n content: string\n}\n\nexport interface EmitOptions {\n /** Generated project's package name. Default: \"migrated-site\". */\n projectName?: string\n /** Include Tailwind 4 as the evolution layer (default true). */\n tailwind?: boolean\n}\n\nexport interface EmitInput {\n ir: ProjectIR\n content?: EmitContent\n css?: EmitCssFile[]\n options?: EmitOptions\n}\n\n// ─── Emit output ───\n\n/**\n * Pure result: every generated file as path → content. Writing to disk is a\n * separate, trivial step (`writeEmit`) so the core stays testable and usable\n * from any host — CLI, service, or another emitter wrapping this one.\n */\nexport interface EmitResult {\n files: Record<string, string>\n warnings: string[]\n}\n\nexport type { ProjectIR }\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, EmitPost, QueryPage } from './types.js'\nimport { DEFAULT_COLLECTION } from './types.js'\nimport { pascalCase, patternToPagePath, restParamIndex, 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 // Astro matches a rest parameter greedily, so anything after it is ambiguous.\n const restAt = restParamIndex(pagePath)\n if (restAt !== -1 && restAt !== pagePath.split('/').length - 1) {\n warnings.push(\n `route ${route.id}: rest parameter is not the final segment of \"${route.pattern}\" — Astro matches rest parameters greedily; verify this route resolves`,\n )\n }\n\n // Collection-driven: `single` always, and any route that names a collection —\n // a site's pages and custom post types are per-entry routes too, they just are\n // not called \"single\".\n if (route.kind === 'single' || route.collection !== undefined) {\n const collection = route.collection ?? DEFAULT_COLLECTION\n const posts = collectionItems(content, collection)\n if (!posts.length) {\n warnings.push(`route ${route.id}: collection \"${collection}\" has no items — page emitted, data file empty`)\n }\n files[`src/data/${collection}.json`] = stableJson(posts)\n files[`src/pages/${pagePath}`] = singlePage(route, layout, up, collection)\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\n/** Items of a named collection; `posts` also accepts the `content.posts` shorthand. */\nfunction collectionItems(content: EmitContent, collection: string): EmitPost[] {\n const named = content.collections?.[collection]\n if (named) return named\n return collection === DEFAULT_COLLECTION ? (content.posts ?? []) : []\n}\n\nfunction singlePage(route: RouteModel, layout: string, up: string, collection: string): string {\n return `---\n// Route: ${route.id} (${route.pattern}) — collection: ${collection} — emitted by @contentrain/emitter-astro\nimport Layout from '${up}layouts/${layout}.astro'\nimport posts from '${up}data/${collection}.json'\nimport { postMarks } from '${up}lib/fill'\n\nexport function getStaticPaths() {\n // A post carries its own route parameters (date parts of a dated permalink,\n // a post id) — using the template post's would send every other post to the\n // wrong address.\n return posts.map((post) => ({ params: { ...(post.params ?? {}), slug: post.slug }, props: { post } }))\n}\ntype Props = { post: (typeof posts)[number] }\nconst { post } = Astro.props as Props\n---\n<Layout title={post.title} marks={postMarks(post)} body={post.body} css={post.css ?? []} />\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}\ntype Props = { page: (typeof pages)[number] }\nconst { page } = Astro.props as 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 { esc, postMarks, renderTemplate } from '${up}lib/fill'\n\n${paths}\nconst items = page.items.map((item) =>\n page.item_template ? renderTemplate(page.item_template, postMarks(item)) : undefined,\n)\nconst fallback = items.some((h) => h === undefined)\nconst content = fallback\n ? '<ul class=\"cr-post-list\">' +\n page.items.map((item) => '<li><a href=\"/' + item.slug + '/\">' + esc(item.title) + '</a></li>').join('') +\n '</ul>'\n : items.join('')\n// Route params carry slugs; page marks carry what the chrome needs to SHOW\n// (a term's display name, description, count).\nconst marks = { ...page.params, ...(page.marks ?? {}) }\n---\n<Layout title={String(marks.term_name ?? page.params.term ?? '')} marks={marks} body={content} css={page.css ?? []} />\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 */\ninterface Props {\n variant?: string\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 const lang = ir.site.locales?.[0] ?? 'en'\n for (const family of ir.families) {\n const fam = familyFiles(family, lang)\n add(fam.files)\n warnings.push(...fam.warnings)\n }\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 const providedCss = new Set((input.css ?? []).map((c) => c.path.split('/').pop() ?? c.path))\n const missingCss = (refs: string[] | undefined, owner: string) => {\n for (const ref of refs ?? []) {\n if (!providedCss.has(ref.split('/').pop() ?? ref)) {\n warnings.push(`${owner}: css file \"${ref}\" not provided in input.css`)\n }\n }\n }\n for (const family of ir.families) missingCss(family.css.files, `family ${family.id}`)\n const allPosts = [\n ...(input.content?.posts ?? []),\n ...Object.values(input.content?.collections ?? {}).flat(),\n ]\n for (const post of allPosts) missingCss(post.css, `post ${post.slug}`)\n for (const [queryId, queryPages] of Object.entries(input.content?.queries ?? {})) {\n for (const qp of queryPages) missingCss(qp.css, `query ${queryId}`)\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":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@contentrain/emitter-astro",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Open Astro emitter for Contentrain migrations — renders a ProjectIR into a working Astro project",
|
|
6
6
|
"type": "module",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"dist"
|
|
38
38
|
],
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@contentrain/types": "1.
|
|
40
|
+
"@contentrain/types": "1.8.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/node": "^22.10.0",
|