@contentrain/emitter-astro 0.6.1 → 0.7.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 +2 -0
- package/dist/index.d.mts +12 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +244 -16
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -21,6 +21,8 @@ await writeEmit(result, './out/site')
|
|
|
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
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
|
+
| Header & footer | `ChromeChunk.position: 'header' \| 'footer'` lifts the masthead and the colophon out of the body blob into `src/components/*.astro`, rendered as siblings of the body fragment. Families that carry the same region share ONE component — that is where a jQuery-free menu replaces the theme's, in one place. Identity is by content: two different headers claiming the same name get separate files and a warning, never a silent swap. |
|
|
25
|
+
| Balance check | A lifted region must close what it opens; a split fragment the browser silently repairs costs a page its layout (measured: 36 against 100). The emitter checks header, footer and body chrome and names the dangling tags in a warning instead of trusting the producer. |
|
|
24
26
|
| 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
27
|
| List sections | `QueryPage.sections` renders a list as a sequence of blocks — a big card in its own wrapper, then a grid — instead of forcing one template on every item (or baking the hero into the chrome, which puts the wrong post on page 2). `item_template` stays the single-section shorthand. |
|
|
26
28
|
| Titles & language | `QueryPage.title` / `RouteModel.title` give archive and static pages a real `<title>`; `RouteModel.locale` (and `EmitPost.locale`) put each route's own language on `<html lang>`, so a multilingual site's second language is a route with its own family and query — not a workaround. |
|
package/dist/index.d.mts
CHANGED
|
@@ -147,10 +147,21 @@ declare function patternToPagePath(pattern: string): string | null;
|
|
|
147
147
|
/** JSON with stable key order and trailing newline — deterministic output, stable diffs. */
|
|
148
148
|
declare function stableJson(value: unknown): string;
|
|
149
149
|
//#endregion
|
|
150
|
+
//#region src/balance.d.ts
|
|
151
|
+
interface BalanceReport {
|
|
152
|
+
/** Elements opened and never closed — the fragment ends mid-element. */
|
|
153
|
+
unclosed: string[];
|
|
154
|
+
/** Elements closed but never opened — the fragment starts mid-element. */
|
|
155
|
+
unopened: string[];
|
|
156
|
+
}
|
|
157
|
+
declare function checkBalance(html: string): BalanceReport;
|
|
158
|
+
/** Human-readable balance complaint, or null when the fragment is sound. */
|
|
159
|
+
declare function balanceWarning(html: string): string | null;
|
|
160
|
+
//#endregion
|
|
150
161
|
//#region src/index.d.ts
|
|
151
162
|
declare function emitAstroProject(input: EmitInput): EmitResult;
|
|
152
163
|
/** Write an EmitResult to disk. Separate from emit so the core stays pure. */
|
|
153
164
|
declare function writeEmit(result: EmitResult, dir: string): Promise<void>;
|
|
154
165
|
//#endregion
|
|
155
|
-
export { type EmitContent, type EmitCssFile, type EmitInput, type EmitOptions, type EmitPost, type EmitResult, type QueryPage, emitAstroProject, pascalCase, patternToPagePath, stableJson, wrapLegacyCss, writeEmit };
|
|
166
|
+
export { type BalanceReport, type EmitContent, type EmitCssFile, type EmitInput, type EmitOptions, type EmitPost, type EmitResult, type QueryPage, balanceWarning, checkBalance, emitAstroProject, pascalCase, patternToPagePath, stableJson, wrapLegacyCss, writeEmit };
|
|
156
167
|
//# sourceMappingURL=index.d.mts.map
|
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;EASS;EAPT,IAAA;EAcA;;;;;;EAPA,MAAA,GAAS,MAAA;EAgBT;;;;EAXA,GAAA;EAiBM;EAfN,KAAA;EACA,MAAA;EACA,YAAA;EACA,WAAA;EACA,KAAA;EAwBA;EAtBA,QAAA;EACA,OAAA;EAyBK;EAvBL,YAAA;EA2Be;EAzBf,OAAA;;EAEA,KAAA,GAAQ,MAAA;EAyBD;EAvBP,MAAA;AAAA;;;;;;;;;UAWe,WAAA;EA8Bf;EA5BA,QAAA;EA8BW;EA5BX,OAAA;EA8BK;EA5BL,KAAA;AAAA;;UAIe,SAAA;EACf,MAAA,EAAQ,MAAA;EACR,KAAA,EAAO,QAAA;EAoCO;;;;;EA9Bd,KAAA,GAAQ,MAAA;EAwBA;EAtBR,GAAA;EA4Bc;;;;;;;AAMhB;;EAxBE,aAAA;EAyBA;EAvBA,QAAA,GAAW,WAAA;EA2BI;EAzBf,KAAA;AAAA;AAAA,UAMe,WAAA;EA0BS;EAxBxB,KAAA,GAAQ,QAAA;EAyBJ;;;;;EAnBJ,WAAA,GAAc,MAAA,SAAe,QAAA;EAmB7B;EAjBA,OAAA,GAAU,MAAA,SAAe,SAAA;AAAA;;UAIV,WAAA;EACf,IAAA;EACA,OAAA;AAAA;AAAA,UAGe,WAAA;EAWM;EATrB,WAAA;EAmByB;EAjBzB,QAAA;AAAA;AAAA,UAGe,SAAA;EACf,EAAA,EAAI,SAAA;EACJ,OAAA,GAAU,WAAA;EACV,GAAA,GAAM,WAAA;EACN,OAAA,GAAU,WAAA;AAAA;;;AC7GZ;;;UDuHiB,UAAA;EACf,KAAA,EAAO,MAAA;EACP,QAAA;AAAA;;;UCzHe,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;;;
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/types.ts","../src/css.ts","../src/util.ts","../src/balance.ts","../src/index.ts"],"mappings":";;;;;AAWA;;;;;UAAiB,QAAA;EACf,IAAA;EACA,KAAA;EASS;EAPT,IAAA;EAcA;;;;;;EAPA,MAAA,GAAS,MAAA;EAgBT;;;;EAXA,GAAA;EAiBM;EAfN,KAAA;EACA,MAAA;EACA,YAAA;EACA,WAAA;EACA,KAAA;EAwBA;EAtBA,QAAA;EACA,OAAA;EAyBK;EAvBL,YAAA;EA2Be;EAzBf,OAAA;;EAEA,KAAA,GAAQ,MAAA;EAyBD;EAvBP,MAAA;AAAA;;;;;;;;;UAWe,WAAA;EA8Bf;EA5BA,QAAA;EA8BW;EA5BX,OAAA;EA8BK;EA5BL,KAAA;AAAA;;UAIe,SAAA;EACf,MAAA,EAAQ,MAAA;EACR,KAAA,EAAO,QAAA;EAoCO;;;;;EA9Bd,KAAA,GAAQ,MAAA;EAwBA;EAtBR,GAAA;EA4Bc;;;;;;;AAMhB;;EAxBE,aAAA;EAyBA;EAvBA,QAAA,GAAW,WAAA;EA2BI;EAzBf,KAAA;AAAA;AAAA,UAMe,WAAA;EA0BS;EAxBxB,KAAA,GAAQ,QAAA;EAyBJ;;;;;EAnBJ,WAAA,GAAc,MAAA,SAAe,QAAA;EAmB7B;EAjBA,OAAA,GAAU,MAAA,SAAe,SAAA;AAAA;;UAIV,WAAA;EACf,IAAA;EACA,OAAA;AAAA;AAAA,UAGe,WAAA;EAWM;EATrB,WAAA;EAmByB;EAjBzB,QAAA;AAAA;AAAA,UAGe,SAAA;EACf,EAAA,EAAI,SAAA;EACJ,OAAA,GAAU,WAAA;EACV,GAAA,GAAM,WAAA;EACN,OAAA,GAAU,WAAA;AAAA;;;AC7GZ;;;UDuHiB,UAAA;EACf,KAAA,EAAO,MAAA;EACP,QAAA;AAAA;;;UCzHe,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;;;UCiCV,aAAA;;EAEf,QAAA;EHzEe;EG2Ef,QAAA;AAAA;AAAA,iBAGc,YAAA,CAAa,IAAA,WAAe,aAAA;;iBAiB5B,cAAA,CAAe,IAAA;;;iBCzFf,gBAAA,CAAiB,KAAA,EAAO,SAAA,GAAY,UAAA;;iBAgE9B,SAAA,CAAU,MAAA,EAAQ,UAAA,EAAY,GAAA,WAAc,OAAA"}
|
package/dist/index.mjs
CHANGED
|
@@ -266,12 +266,155 @@ export function postMarks(post: MarkablePost): Values {
|
|
|
266
266
|
for (const [i, f] of (post.featured ?? []).entries()) values['feat' + i] = f
|
|
267
267
|
return { ...values, ...(post.marks ?? {}) }
|
|
268
268
|
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The shapes of the emitted data files. Pages import JSON and assert these
|
|
272
|
+
* rather than letting TypeScript infer the shape from the file's contents:
|
|
273
|
+
* inference reads only the fields the data HAPPENS to carry, so a site whose
|
|
274
|
+
* posts need no extra route parameters produced a type without \`params\` and
|
|
275
|
+
* \`astro check\` — which the build runs first — failed on the page that reads
|
|
276
|
+
* it. The contract is what the emitter may write, not what one site wrote.
|
|
277
|
+
*/
|
|
278
|
+
export interface EmittedPost extends MarkablePost {
|
|
279
|
+
body: string
|
|
280
|
+
/** Route parameters beyond slug — the date parts of a dated permalink, a post id. */
|
|
281
|
+
params?: Record<string, string>
|
|
282
|
+
/** Stylesheets only this page loads. */
|
|
283
|
+
css?: string[]
|
|
284
|
+
locale?: string
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** One static path of a list route, as it appears in the emitted query data. */
|
|
288
|
+
export interface EmittedQueryPage {
|
|
289
|
+
params: Record<string, string>
|
|
290
|
+
items: EmittedPost[]
|
|
291
|
+
marks?: Record<string, unknown>
|
|
292
|
+
css?: string[]
|
|
293
|
+
item_template?: string
|
|
294
|
+
sections?: ListSection[]
|
|
295
|
+
title?: string
|
|
296
|
+
}
|
|
269
297
|
`;
|
|
270
298
|
//#endregion
|
|
299
|
+
//#region src/balance.ts
|
|
300
|
+
const VOID_TAGS = new Set([
|
|
301
|
+
"area",
|
|
302
|
+
"base",
|
|
303
|
+
"br",
|
|
304
|
+
"col",
|
|
305
|
+
"embed",
|
|
306
|
+
"hr",
|
|
307
|
+
"img",
|
|
308
|
+
"input",
|
|
309
|
+
"link",
|
|
310
|
+
"meta",
|
|
311
|
+
"param",
|
|
312
|
+
"source",
|
|
313
|
+
"track",
|
|
314
|
+
"wbr"
|
|
315
|
+
]);
|
|
316
|
+
/** Tags a browser closes for you. Theme markup is full of them, so counting
|
|
317
|
+
* them would report every real page as broken. */
|
|
318
|
+
const OPTIONAL_END_TAGS = new Set([
|
|
319
|
+
"p",
|
|
320
|
+
"li",
|
|
321
|
+
"dt",
|
|
322
|
+
"dd",
|
|
323
|
+
"option",
|
|
324
|
+
"optgroup",
|
|
325
|
+
"thead",
|
|
326
|
+
"tbody",
|
|
327
|
+
"tfoot",
|
|
328
|
+
"tr",
|
|
329
|
+
"td",
|
|
330
|
+
"th",
|
|
331
|
+
"rt",
|
|
332
|
+
"rp"
|
|
333
|
+
]);
|
|
334
|
+
/** Elements whose content is text, not markup — `<script>if (a < b)<\/script>`. */
|
|
335
|
+
const RAW_TEXT_TAGS = new Set([
|
|
336
|
+
"script",
|
|
337
|
+
"style",
|
|
338
|
+
"textarea",
|
|
339
|
+
"title"
|
|
340
|
+
]);
|
|
341
|
+
function scanTags(html) {
|
|
342
|
+
const tags = [];
|
|
343
|
+
let i = 0;
|
|
344
|
+
while (i < html.length) {
|
|
345
|
+
const lt = html.indexOf("<", i);
|
|
346
|
+
if (lt === -1) break;
|
|
347
|
+
if (html.startsWith("<!--", lt)) {
|
|
348
|
+
const end = html.indexOf("-->", lt + 4);
|
|
349
|
+
i = end === -1 ? html.length : end + 3;
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (html.startsWith("<!", lt) || html.startsWith("<?", lt)) {
|
|
353
|
+
const end = html.indexOf(">", lt);
|
|
354
|
+
i = end === -1 ? html.length : end + 1;
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
const [matched = "", slash = "", rawName = ""] = /^<(\/?)([a-zA-Z][a-zA-Z0-9-]*)/.exec(html.slice(lt, lt + 64)) ?? [];
|
|
358
|
+
if (!rawName) {
|
|
359
|
+
i = lt + 1;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
let j = lt + matched.length;
|
|
363
|
+
let quote = "";
|
|
364
|
+
while (j < html.length) {
|
|
365
|
+
const ch = html[j];
|
|
366
|
+
if (quote) {
|
|
367
|
+
if (ch === quote) quote = "";
|
|
368
|
+
} else if (ch === "\"" || ch === "'") quote = ch;
|
|
369
|
+
else if (ch === ">") break;
|
|
370
|
+
j++;
|
|
371
|
+
}
|
|
372
|
+
const name = rawName.toLowerCase();
|
|
373
|
+
const closing = slash === "/";
|
|
374
|
+
tags.push({
|
|
375
|
+
name,
|
|
376
|
+
closing,
|
|
377
|
+
selfClosing: html.slice(lt, j + 1).endsWith("/>")
|
|
378
|
+
});
|
|
379
|
+
i = j + 1;
|
|
380
|
+
if (!closing && RAW_TEXT_TAGS.has(name)) {
|
|
381
|
+
const close = html.toLowerCase().indexOf(`</${name}`, i);
|
|
382
|
+
i = close === -1 ? html.length : close;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return tags;
|
|
386
|
+
}
|
|
387
|
+
function checkBalance(html) {
|
|
388
|
+
const stack = [];
|
|
389
|
+
const unopened = [];
|
|
390
|
+
for (const tag of scanTags(html)) {
|
|
391
|
+
if (VOID_TAGS.has(tag.name) || tag.selfClosing || OPTIONAL_END_TAGS.has(tag.name)) continue;
|
|
392
|
+
if (!tag.closing) {
|
|
393
|
+
stack.push(tag.name);
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
const at = stack.lastIndexOf(tag.name);
|
|
397
|
+
if (at === -1) unopened.push(tag.name);
|
|
398
|
+
else stack.length = at;
|
|
399
|
+
}
|
|
400
|
+
return {
|
|
401
|
+
unclosed: stack.toReversed(),
|
|
402
|
+
unopened
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
/** Human-readable balance complaint, or null when the fragment is sound. */
|
|
406
|
+
function balanceWarning(html) {
|
|
407
|
+
const { unclosed, unopened } = checkBalance(html);
|
|
408
|
+
const parts = [];
|
|
409
|
+
if (unclosed.length) parts.push(`never closed: ${unclosed.join(", ")}`);
|
|
410
|
+
if (unopened.length) parts.push(`closed but never opened: ${unopened.join(", ")}`);
|
|
411
|
+
return parts.length ? parts.join("; ") : null;
|
|
412
|
+
}
|
|
413
|
+
//#endregion
|
|
271
414
|
//#region src/layouts.ts
|
|
272
|
-
function familyFiles(family, lang) {
|
|
415
|
+
function familyFiles(family, lang, components = []) {
|
|
273
416
|
const name = pascalCase(family.id);
|
|
274
|
-
const chunks = family.chrome ?? [];
|
|
417
|
+
const chunks = (family.chrome ?? []).filter((c) => c.position !== "header" && c.position !== "footer");
|
|
275
418
|
const warnings = [];
|
|
276
419
|
const joined = (position) => chunks.filter((c) => c.position === position).map((c) => c.html).join("\n");
|
|
277
420
|
const bodyChunks = chunks.filter((c) => c.position === "body");
|
|
@@ -283,6 +426,8 @@ function familyFiles(family, lang) {
|
|
|
283
426
|
body += CHROME_BODY_SLOT;
|
|
284
427
|
}
|
|
285
428
|
} else body = `${joined("before_body")}${CHROME_BODY_SLOT}${joined("after_body")}`;
|
|
429
|
+
const unbalanced = balanceWarning(body);
|
|
430
|
+
if (unbalanced) warnings.push(`family ${family.id}: body chrome is not balanced (${unbalanced}) — the browser will repair it and the page loses its layout`);
|
|
286
431
|
const files = {};
|
|
287
432
|
files[`src/data/chrome/${family.id}.json`] = stableJson({
|
|
288
433
|
head: joined("head"),
|
|
@@ -291,11 +436,13 @@ function familyFiles(family, lang) {
|
|
|
291
436
|
body_attrs: family.root_attrs?.body ?? {}
|
|
292
437
|
});
|
|
293
438
|
const cssLinks = (family.css.files ?? []).map((f) => `<link rel="stylesheet" href="/styles/legacy/${f.split("/").pop()}" />`).join("\n");
|
|
439
|
+
const componentImports = [...new Set(components.map((c) => c.name))].map((n) => `import ${n} from '../components/${n}.astro'`).join("\n");
|
|
440
|
+
const renderRefs = (position) => components.filter((c) => c.position === position).map((c) => ` <${c.name} marks={marks} />`).join("\n");
|
|
294
441
|
files[`src/layouts/${name}.astro`] = `---
|
|
295
442
|
// Family: ${family.id}${family.name ? ` (${family.name})` : ""} — emitted by @contentrain/emitter-astro
|
|
296
443
|
import chrome from '../data/chrome/${family.id}.json'
|
|
297
444
|
import { cssHref, fillAttrs, renderTemplate, composeBody } from '../lib/fill'
|
|
298
|
-
|
|
445
|
+
${componentImports ? `${componentImports}\n` : ""}
|
|
299
446
|
interface Props {
|
|
300
447
|
title?: string
|
|
301
448
|
marks?: Record<string, unknown>
|
|
@@ -330,8 +477,8 @@ ${cssLinks.split("\n").filter(Boolean).map((l) => ` ${l}`).join("\n")}
|
|
|
330
477
|
<title>{title}</title>
|
|
331
478
|
</head>
|
|
332
479
|
<body {...bodyAttrs}>
|
|
333
|
-
<Fragment set:html={html} />
|
|
334
|
-
</body>
|
|
480
|
+
${renderRefs("header") ? `${renderRefs("header")}\n` : ""} <Fragment set:html={html} />
|
|
481
|
+
${renderRefs("footer") ? `${renderRefs("footer")}\n` : ""} </body>
|
|
335
482
|
</html>
|
|
336
483
|
`;
|
|
337
484
|
return {
|
|
@@ -398,16 +545,22 @@ function singlePage(route, layout, up, collection, siteLocale) {
|
|
|
398
545
|
return `---
|
|
399
546
|
// Route: ${route.id} (${route.pattern}) — collection: ${collection} — emitted by @contentrain/emitter-astro
|
|
400
547
|
import Layout from '${up}layouts/${layout}.astro'
|
|
401
|
-
import
|
|
402
|
-
import { postMarks } from '${up}lib/fill'
|
|
548
|
+
import data from '${up}data/${collection}.json'
|
|
549
|
+
import { postMarks, type EmittedPost } from '${up}lib/fill'
|
|
403
550
|
|
|
404
551
|
export function getStaticPaths() {
|
|
552
|
+
// The JSON is data; its contract is EmittedPost. Inferring the type from the
|
|
553
|
+
// file would type only the fields this site happens to carry, and "astro
|
|
554
|
+
// check" (which the build runs first) would then reject post.params on a site
|
|
555
|
+
// whose permalinks need none. The cast lives INSIDE getStaticPaths because
|
|
556
|
+
// Astro hoists this function: it sees imports, not the component scope.
|
|
557
|
+
const posts = data as EmittedPost[]
|
|
405
558
|
// A post carries its own route parameters (date parts of a dated permalink,
|
|
406
559
|
// a post id) — using the template post's would send every other post to the
|
|
407
560
|
// wrong address.
|
|
408
561
|
return posts.map((post) => ({ params: { ...(post.params ?? {}), slug: post.slug }, props: { post } }))
|
|
409
562
|
}
|
|
410
|
-
type Props = { post:
|
|
563
|
+
type Props = { post: EmittedPost }
|
|
411
564
|
const { post } = Astro.props as Props
|
|
412
565
|
---
|
|
413
566
|
<Layout
|
|
@@ -421,17 +574,18 @@ const { post } = Astro.props as Props
|
|
|
421
574
|
}
|
|
422
575
|
function listPage(route, layout, up, hasParams, siteLocale) {
|
|
423
576
|
const locale = JSON.stringify(route.locale ?? siteLocale);
|
|
424
|
-
const routeTitle = JSON.stringify(route.title
|
|
577
|
+
const routeTitle = route.title === void 0 ? `''` : JSON.stringify(route.title);
|
|
425
578
|
const paths = hasParams ? `export function getStaticPaths() {
|
|
579
|
+
const pages = data as EmittedQueryPage[]
|
|
426
580
|
return pages.map((page) => ({ params: page.params, props: { page } }))
|
|
427
581
|
}
|
|
428
|
-
type Props = { page:
|
|
429
|
-
const { page } = Astro.props as Props` : `const page =
|
|
582
|
+
type Props = { page: EmittedQueryPage }
|
|
583
|
+
const { page } = Astro.props as Props` : `const page = (data as EmittedQueryPage[])[0] ?? { params: {}, items: [], title: undefined }`;
|
|
430
584
|
return `---
|
|
431
585
|
// Route: ${route.id} (${route.pattern}) — emitted by @contentrain/emitter-astro
|
|
432
586
|
import Layout from '${up}layouts/${layout}.astro'
|
|
433
|
-
import
|
|
434
|
-
import { esc, postMarks, renderSections,
|
|
587
|
+
import data from '${up}data/queries/${route.query}.json'
|
|
588
|
+
import { esc, postMarks, renderSections, type EmittedQueryPage } from '${up}lib/fill'
|
|
435
589
|
|
|
436
590
|
${paths}
|
|
437
591
|
// A list renders in sections (a big card then a grid) — one template is the
|
|
@@ -446,7 +600,7 @@ const content = sections.length
|
|
|
446
600
|
// Route params carry slugs; page marks carry what the chrome needs to SHOW
|
|
447
601
|
// (a term's display name, description, count).
|
|
448
602
|
const marks = { ...page.params, ...(page.marks ?? {}) }
|
|
449
|
-
const title = page.title ?? ${routeTitle}
|
|
603
|
+
const title = page.title ?? ${routeTitle}
|
|
450
604
|
---
|
|
451
605
|
<Layout
|
|
452
606
|
title={title || String(marks.term_name ?? page.params.term ?? '')}
|
|
@@ -492,6 +646,77 @@ const { variant = ${JSON.stringify(variants[0] ?? "default")} } = Astro.props
|
|
|
492
646
|
return files;
|
|
493
647
|
}
|
|
494
648
|
//#endregion
|
|
649
|
+
//#region src/chrome.ts
|
|
650
|
+
const DEFAULT_NAME = {
|
|
651
|
+
header: "SiteHeader",
|
|
652
|
+
footer: "SiteFooter"
|
|
653
|
+
};
|
|
654
|
+
/** A component name Astro can import: PascalCase and starting with a letter. */
|
|
655
|
+
function componentName(raw) {
|
|
656
|
+
const pascal = pascalCase(raw);
|
|
657
|
+
return /^[A-Za-z]/.test(pascal) ? pascal : `Chrome${pascal}`;
|
|
658
|
+
}
|
|
659
|
+
function componentSource(name) {
|
|
660
|
+
return `---
|
|
661
|
+
/**
|
|
662
|
+
* Chrome component: ${name} — emitted by @contentrain/emitter-astro.
|
|
663
|
+
* Markup travels as data so the Astro compiler never parses theme markup, and
|
|
664
|
+
* fills the same \`@@mark@@\` placeholders the rest of the chrome does.
|
|
665
|
+
*/
|
|
666
|
+
import chunk from '../data/components/${name}.json'
|
|
667
|
+
import { renderTemplate } from '../lib/fill'
|
|
668
|
+
|
|
669
|
+
interface Props {
|
|
670
|
+
/** Page marks — the region's own placeholders (site name, current-page class). */
|
|
671
|
+
marks?: Record<string, unknown>
|
|
672
|
+
}
|
|
673
|
+
const { marks = {} } = Astro.props
|
|
674
|
+
---
|
|
675
|
+
<Fragment set:html={renderTemplate(chunk.html, marks)} />
|
|
676
|
+
`;
|
|
677
|
+
}
|
|
678
|
+
function chromeComponents(families) {
|
|
679
|
+
const files = {};
|
|
680
|
+
const byFamily = /* @__PURE__ */ new Map();
|
|
681
|
+
const warnings = [];
|
|
682
|
+
/** html → emitted component name, so identical regions collapse into one. */
|
|
683
|
+
const byHtml = /* @__PURE__ */ new Map();
|
|
684
|
+
/** emitted name → its html, so a name clash can be detected and renamed. */
|
|
685
|
+
const byName = /* @__PURE__ */ new Map();
|
|
686
|
+
for (const family of families) {
|
|
687
|
+
const refs = [];
|
|
688
|
+
for (const chunk of family.chrome ?? []) {
|
|
689
|
+
if (chunk.position !== "header" && chunk.position !== "footer") continue;
|
|
690
|
+
const position = chunk.position;
|
|
691
|
+
const where = `family ${family.id}: ${position} chrome`;
|
|
692
|
+
if (chunk.html.includes(CHROME_BODY_SLOT)) warnings.push(`${where} carries ${CHROME_BODY_SLOT} — content goes in the body chunk; the marker is rendered as a comment`);
|
|
693
|
+
const unbalanced = balanceWarning(chunk.html);
|
|
694
|
+
if (unbalanced) warnings.push(`${where} is not balanced (${unbalanced}) — a lifted region must close what it opens; keep it in the body chunk`);
|
|
695
|
+
const wanted = componentName(chunk.component ?? DEFAULT_NAME[position]);
|
|
696
|
+
let name = byHtml.get(chunk.html);
|
|
697
|
+
if (name === void 0) {
|
|
698
|
+
name = wanted;
|
|
699
|
+
for (let n = 2; byName.has(name) && byName.get(name) !== chunk.html; n++) name = `${wanted}${n}`;
|
|
700
|
+
if (name !== wanted) warnings.push(`${where}: component name "${wanted}" is already used by different markup — emitted as "${name}"`);
|
|
701
|
+
byHtml.set(chunk.html, name);
|
|
702
|
+
byName.set(name, chunk.html);
|
|
703
|
+
files[`src/data/components/${name}.json`] = stableJson({ html: chunk.html });
|
|
704
|
+
files[`src/components/${name}.astro`] = componentSource(name);
|
|
705
|
+
}
|
|
706
|
+
refs.push({
|
|
707
|
+
name,
|
|
708
|
+
position
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
if (refs.length) byFamily.set(family.id, refs);
|
|
712
|
+
}
|
|
713
|
+
return {
|
|
714
|
+
files,
|
|
715
|
+
byFamily,
|
|
716
|
+
warnings
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
//#endregion
|
|
495
720
|
//#region src/css.ts
|
|
496
721
|
const LEADING = /^(\s*(?:@charset[^;]+;|@import[^;]+;|\/\*[\s\S]*?\*\/)\s*)+/;
|
|
497
722
|
function wrapLegacyCss(path, css) {
|
|
@@ -520,8 +745,11 @@ function emitAstroProject(input) {
|
|
|
520
745
|
add(scaffoldFiles(ir, input.options ?? {}));
|
|
521
746
|
const familiesById = new Map(ir.families.map((f) => [f.id, f]));
|
|
522
747
|
const lang = ir.site.locales?.[0] ?? "en";
|
|
748
|
+
const chrome = chromeComponents(ir.families);
|
|
749
|
+
add(chrome.files);
|
|
750
|
+
warnings.push(...chrome.warnings);
|
|
523
751
|
for (const family of ir.families) {
|
|
524
|
-
const fam = familyFiles(family, lang);
|
|
752
|
+
const fam = familyFiles(family, lang, chrome.byFamily.get(family.id));
|
|
525
753
|
add(fam.files);
|
|
526
754
|
warnings.push(...fam.warnings);
|
|
527
755
|
}
|
|
@@ -558,6 +786,6 @@ async function writeEmit(result, dir) {
|
|
|
558
786
|
await Promise.all(Object.entries(result.files).map(([path, content]) => writeFile(join(dir, path), content, "utf8")));
|
|
559
787
|
}
|
|
560
788
|
//#endregion
|
|
561
|
-
export { emitAstroProject, pascalCase, patternToPagePath, stableJson, wrapLegacyCss, writeEmit };
|
|
789
|
+
export { balanceWarning, checkBalance, emitAstroProject, pascalCase, patternToPagePath, stableJson, wrapLegacyCss, writeEmit };
|
|
562
790
|
|
|
563
791
|
//# sourceMappingURL=index.mjs.map
|
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/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/** Where a list section's wrapper takes its items — must match @contentrain/types LIST_ITEMS_SLOT. */\nexport const ITEMS_SLOT = '<!--@@items@@-->'\n\nexport interface ListSection {\n template: string\n wrapper?: string\n count?: number\n}\n\n/**\n * Render a list in sections. Themes often render the newest post as a big card\n * and the rest as a grid — two templates in two containers — so a list is a\n * sequence of sections, each taking a count of items (or the remainder).\n */\nexport function renderSections<T>(\n sections: ListSection[],\n items: T[],\n values: (item: T) => Values,\n): string {\n let index = 0\n const out: string[] = []\n for (const section of sections) {\n const take = section.count ?? items.length - index\n const slice = items.slice(index, index + take)\n index += slice.length\n if (slice.length === 0) continue\n const rendered = slice.map((item) => renderTemplate(section.template, values(item))).join('')\n out.push(section.wrapper ? section.wrapper.split(ITEMS_SLOT).join(rendered) : rendered)\n }\n return out.join('')\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 /** Document language — a multilingual site's routes each pass their own. */\n lang?: string\n}\nconst { title = '', marks = {}, body, css = [], lang = ${JSON.stringify(lang)} } = 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, ...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 /** Locale of this entry, on a multilingual site — overrides the route's. */\n locale?: string\n}\n\n/**\n * One block of a list: a template, optional wrapper, and how many items it takes.\n *\n * Themes commonly render the newest post as a big card and the rest as a grid —\n * two different templates in two different containers. One template per list\n * cannot express that: either everything becomes a big card, or the big card is\n * baked into the chrome and page 2 shows the wrong post.\n */\nexport interface ListSection {\n /** Item markup with `@@marks@@`. */\n template: string\n /** Markup around this section's items; `<!--@@items@@-->` marks where they go. */\n wrapper?: string\n /** Items this section takes, in order. Omitted = the remaining items. */\n count?: number\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 * Shorthand for a single-section list; `sections` takes precedence.\n */\n item_template?: string\n /** Lists whose items are not all rendered alike — a big card then a grid. */\n sections?: ListSection[]\n /** Document title for this page (`<title>`), e.g. \"Category: News – Site\". */\n title?: 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 siteLocale: string,\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, siteLocale)\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 && !p.sections?.length)) {\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, siteLocale)\n return { files, warnings }\n }\n\n files[`src/pages/${pagePath}`] = staticPage(route, layout, up, hasParams, warnings, siteLocale)\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, siteLocale: string): string {\n const locale = JSON.stringify(route.locale ?? siteLocale)\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\n title={post.title}\n marks={postMarks(post)}\n body={post.body}\n css={post.css ?? []}\n lang={post.locale ?? ${locale}}\n/>\n`\n}\n\nfunction listPage(route: RouteModel, layout: string, up: string, hasParams: boolean, siteLocale: string): string {\n const locale = JSON.stringify(route.locale ?? siteLocale)\n const routeTitle = JSON.stringify(route.title ?? '')\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, renderSections, renderTemplate } from '${up}lib/fill'\n\n${paths}\n// A list renders in sections (a big card then a grid) — one template is the\n// single-section case. Without a template at all we fall back to a plain list\n// and the emitter has already warned: silence would read as fidelity.\nconst sections = page.sections ?? (page.item_template ? [{ template: page.item_template }] : [])\nconst content = sections.length\n ? renderSections(sections, page.items, postMarks)\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// 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 ?? {}) }\nconst title = page.title ?? ${routeTitle} ?? ''\n---\n<Layout\n title={title || String(marks.term_name ?? page.params.term ?? '')}\n marks={marks}\n body={content}\n css={page.css ?? []}\n lang={${locale}}\n/>\n`\n}\n\nfunction staticPage(route: RouteModel, layout: string, up: string, hasParams: boolean, warnings: string[], siteLocale: 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=${JSON.stringify(route.title ?? '')} marks={{}} lang={${JSON.stringify(route.locale ?? siteLocale)}} />\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 ?? {}, lang)\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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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/util.ts","../src/scaffold.ts","../src/balance.ts","../src/layouts.ts","../src/types.ts","../src/pages.ts","../src/components.ts","../src/chrome.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/** Where a list section's wrapper takes its items — must match @contentrain/types LIST_ITEMS_SLOT. */\nexport const ITEMS_SLOT = '<!--@@items@@-->'\n\nexport interface ListSection {\n template: string\n wrapper?: string\n count?: number\n}\n\n/**\n * Render a list in sections. Themes often render the newest post as a big card\n * and the rest as a grid — two templates in two containers — so a list is a\n * sequence of sections, each taking a count of items (or the remainder).\n */\nexport function renderSections<T>(\n sections: ListSection[],\n items: T[],\n values: (item: T) => Values,\n): string {\n let index = 0\n const out: string[] = []\n for (const section of sections) {\n const take = section.count ?? items.length - index\n const slice = items.slice(index, index + take)\n index += slice.length\n if (slice.length === 0) continue\n const rendered = slice.map((item) => renderTemplate(section.template, values(item))).join('')\n out.push(section.wrapper ? section.wrapper.split(ITEMS_SLOT).join(rendered) : rendered)\n }\n return out.join('')\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/**\n * The shapes of the emitted data files. Pages import JSON and assert these\n * rather than letting TypeScript infer the shape from the file's contents:\n * inference reads only the fields the data HAPPENS to carry, so a site whose\n * posts need no extra route parameters produced a type without \\`params\\` and\n * \\`astro check\\` — which the build runs first — failed on the page that reads\n * it. The contract is what the emitter may write, not what one site wrote.\n */\nexport interface EmittedPost extends MarkablePost {\n body: string\n /** Route parameters beyond slug — the date parts of a dated permalink, a post id. */\n params?: Record<string, string>\n /** Stylesheets only this page loads. */\n css?: string[]\n locale?: string\n}\n\n/** One static path of a list route, as it appears in the emitted query data. */\nexport interface EmittedQueryPage {\n params: Record<string, string>\n items: EmittedPost[]\n marks?: Record<string, unknown>\n css?: string[]\n item_template?: string\n sections?: ListSection[]\n title?: string\n}\n`\n","// Fragment balance check.\n//\n// Chrome is injected as raw HTML, so an unbalanced fragment does not fail the\n// build — the browser silently repairs it and the page loses its layout. That\n// is the failure that cost a page 36 against 100 when body chrome was split\n// into before/after halves. Lifting a header or footer out of the body blob is\n// only safe when each fragment closes what it opens, so the emitter checks and\n// says which tags are dangling rather than trusting the producer's word.\n//\n// This is deliberately not an HTML parser: it skips comments, doctypes and raw\n// text elements, tolerates the tags HTML lets you leave open, and reports what\n// it finds. It answers \"was this split mid-element?\", not \"is this valid HTML?\".\n\nconst VOID_TAGS = new Set([\n 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',\n 'link', 'meta', 'param', 'source', 'track', 'wbr',\n])\n\n/** Tags a browser closes for you. Theme markup is full of them, so counting\n * them would report every real page as broken. */\nconst OPTIONAL_END_TAGS = new Set([\n 'p', 'li', 'dt', 'dd', 'option', 'optgroup', 'thead', 'tbody',\n 'tfoot', 'tr', 'td', 'th', 'rt', 'rp',\n])\n\n/** Elements whose content is text, not markup — `<script>if (a < b)</script>`. */\nconst RAW_TEXT_TAGS = new Set(['script', 'style', 'textarea', 'title'])\n\ninterface Tag {\n name: string\n closing: boolean\n selfClosing: boolean\n}\n\nfunction scanTags(html: string): Tag[] {\n const tags: Tag[] = []\n let i = 0\n while (i < html.length) {\n const lt = html.indexOf('<', i)\n if (lt === -1) break\n if (html.startsWith('<!--', lt)) {\n const end = html.indexOf('-->', lt + 4)\n i = end === -1 ? html.length : end + 3\n continue\n }\n if (html.startsWith('<!', lt) || html.startsWith('<?', lt)) {\n const end = html.indexOf('>', lt)\n i = end === -1 ? html.length : end + 1\n continue\n }\n const head = /^<(\\/?)([a-zA-Z][a-zA-Z0-9-]*)/.exec(html.slice(lt, lt + 64))\n const [matched = '', slash = '', rawName = ''] = head ?? []\n if (!rawName) {\n i = lt + 1\n continue\n }\n // Walk to the tag's `>`, ignoring one inside a quoted attribute value.\n let j = lt + matched.length\n let quote = ''\n while (j < html.length) {\n const ch = html[j]\n if (quote) {\n if (ch === quote) quote = ''\n } else if (ch === '\"' || ch === \"'\") {\n quote = ch\n } else if (ch === '>') {\n break\n }\n j++\n }\n const name = rawName.toLowerCase()\n const closing = slash === '/'\n tags.push({ name, closing, selfClosing: html.slice(lt, j + 1).endsWith('/>') })\n i = j + 1\n if (!closing && RAW_TEXT_TAGS.has(name)) {\n const close = html.toLowerCase().indexOf(`</${name}`, i)\n i = close === -1 ? html.length : close\n }\n }\n return tags\n}\n\nexport interface BalanceReport {\n /** Elements opened and never closed — the fragment ends mid-element. */\n unclosed: string[]\n /** Elements closed but never opened — the fragment starts mid-element. */\n unopened: string[]\n}\n\nexport function checkBalance(html: string): BalanceReport {\n const stack: string[] = []\n const unopened: string[] = []\n for (const tag of scanTags(html)) {\n if (VOID_TAGS.has(tag.name) || tag.selfClosing || OPTIONAL_END_TAGS.has(tag.name)) continue\n if (!tag.closing) {\n stack.push(tag.name)\n continue\n }\n const at = stack.lastIndexOf(tag.name)\n if (at === -1) unopened.push(tag.name)\n else stack.length = at\n }\n return { unclosed: stack.toReversed(), unopened }\n}\n\n/** Human-readable balance complaint, or null when the fragment is sound. */\nexport function balanceWarning(html: string): string | null {\n const { unclosed, unopened } = checkBalance(html)\n const parts: string[] = []\n if (unclosed.length) parts.push(`never closed: ${unclosed.join(', ')}`)\n if (unopened.length) parts.push(`closed but never opened: ${unopened.join(', ')}`)\n return parts.length ? parts.join('; ') : null\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 type { ChromeComponentRef } from './chrome.js'\nimport { balanceWarning } from './balance.js'\nimport { pascalCase, stableJson } from './util.js'\n\nexport interface FamilyGenResult {\n files: Record<string, string>\n warnings: string[]\n}\n\nexport function familyFiles(\n family: LayoutFamily,\n lang: string,\n components: ChromeComponentRef[] = [],\n): FamilyGenResult {\n const name = pascalCase(family.id)\n // Header/footer regions are emitted as shared components (see chrome.ts) and\n // rendered as siblings of the body fragment; the layout injects the rest.\n const chunks = (family.chrome ?? []).filter((c) => c.position !== 'header' && c.position !== 'footer')\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 const unbalanced = balanceWarning(body)\n if (unbalanced) {\n warnings.push(`family ${family.id}: body chrome is not balanced (${unbalanced}) — the browser will repair it and the page loses its layout`)\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 const imported = [...new Set(components.map((c) => c.name))]\n const componentImports = imported\n .map((n) => `import ${n} from '../components/${n}.astro'`)\n .join('\\n')\n const renderRefs = (position: 'header' | 'footer') =>\n components\n .filter((c) => c.position === position)\n .map((c) => ` <${c.name} marks={marks} />`)\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${componentImports ? `${componentImports}\\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 /** Document language — a multilingual site's routes each pass their own. */\n lang?: string\n}\nconst { title = '', marks = {}, body, css = [], lang = ${JSON.stringify(lang)} } = 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, ...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${renderRefs('header') ? `${renderRefs('header')}\\n` : ''} <Fragment set:html={html} />\n${renderRefs('footer') ? `${renderRefs('footer')}\\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 /** Locale of this entry, on a multilingual site — overrides the route's. */\n locale?: string\n}\n\n/**\n * One block of a list: a template, optional wrapper, and how many items it takes.\n *\n * Themes commonly render the newest post as a big card and the rest as a grid —\n * two different templates in two different containers. One template per list\n * cannot express that: either everything becomes a big card, or the big card is\n * baked into the chrome and page 2 shows the wrong post.\n */\nexport interface ListSection {\n /** Item markup with `@@marks@@`. */\n template: string\n /** Markup around this section's items; `<!--@@items@@-->` marks where they go. */\n wrapper?: string\n /** Items this section takes, in order. Omitted = the remaining items. */\n count?: number\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 * Shorthand for a single-section list; `sections` takes precedence.\n */\n item_template?: string\n /** Lists whose items are not all rendered alike — a big card then a grid. */\n sections?: ListSection[]\n /** Document title for this page (`<title>`), e.g. \"Category: News – Site\". */\n title?: 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 siteLocale: string,\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, siteLocale)\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 && !p.sections?.length)) {\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, siteLocale)\n return { files, warnings }\n }\n\n files[`src/pages/${pagePath}`] = staticPage(route, layout, up, hasParams, warnings, siteLocale)\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, siteLocale: string): string {\n const locale = JSON.stringify(route.locale ?? siteLocale)\n return `---\n// Route: ${route.id} (${route.pattern}) — collection: ${collection} — emitted by @contentrain/emitter-astro\nimport Layout from '${up}layouts/${layout}.astro'\nimport data from '${up}data/${collection}.json'\nimport { postMarks, type EmittedPost } from '${up}lib/fill'\n\nexport function getStaticPaths() {\n // The JSON is data; its contract is EmittedPost. Inferring the type from the\n // file would type only the fields this site happens to carry, and \"astro\n // check\" (which the build runs first) would then reject post.params on a site\n // whose permalinks need none. The cast lives INSIDE getStaticPaths because\n // Astro hoists this function: it sees imports, not the component scope.\n const posts = data as EmittedPost[]\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: EmittedPost }\nconst { post } = Astro.props as Props\n---\n<Layout\n title={post.title}\n marks={postMarks(post)}\n body={post.body}\n css={post.css ?? []}\n lang={post.locale ?? ${locale}}\n/>\n`\n}\n\nfunction listPage(route: RouteModel, layout: string, up: string, hasParams: boolean, siteLocale: string): string {\n const locale = JSON.stringify(route.locale ?? siteLocale)\n // Chain the fallbacks HERE, not in the emitted source: a route without a title\n // used to emit `page.title ?? \"\" ?? ''`, which \"astro check\" rejects as never\n // nullish — and the build runs check first, so the whole site failed to build.\n const routeTitle = route.title === undefined ? `''` : JSON.stringify(route.title)\n // The cast lives inside getStaticPaths: Astro hoists it above the component\n // scope, so it can read imports and nothing else.\n const paths = hasParams\n ? `export function getStaticPaths() {\n const pages = data as EmittedQueryPage[]\n return pages.map((page) => ({ params: page.params, props: { page } }))\n}\ntype Props = { page: EmittedQueryPage }\nconst { page } = Astro.props as Props`\n : `const page = (data as EmittedQueryPage[])[0] ?? { params: {}, items: [], title: undefined }`\n return `---\n// Route: ${route.id} (${route.pattern}) — emitted by @contentrain/emitter-astro\nimport Layout from '${up}layouts/${layout}.astro'\nimport data from '${up}data/queries/${route.query}.json'\nimport { esc, postMarks, renderSections, type EmittedQueryPage } from '${up}lib/fill'\n\n${paths}\n// A list renders in sections (a big card then a grid) — one template is the\n// single-section case. Without a template at all we fall back to a plain list\n// and the emitter has already warned: silence would read as fidelity.\nconst sections = page.sections ?? (page.item_template ? [{ template: page.item_template }] : [])\nconst content = sections.length\n ? renderSections(sections, page.items, postMarks)\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// 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 ?? {}) }\nconst title = page.title ?? ${routeTitle}\n---\n<Layout\n title={title || String(marks.term_name ?? page.params.term ?? '')}\n marks={marks}\n body={content}\n css={page.css ?? []}\n lang={${locale}}\n/>\n`\n}\n\nfunction staticPage(route: RouteModel, layout: string, up: string, hasParams: boolean, warnings: string[], siteLocale: 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=${JSON.stringify(route.title ?? '')} marks={{}} lang={${JSON.stringify(route.locale ?? siteLocale)}} />\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","// Header and footer chrome → shared Astro components.\n//\n// The body chrome stays one fragment because the content container is nested\n// deep inside it. The masthead and the colophon are the exception: they sit\n// outside the content path and repeat across families, so lifting them out\n// gives the project one address for the nav (where a jQuery-free menu replaces\n// the theme's) and one copy of the markup instead of N.\n//\n// Identity is by CONTENT, not by declared name: two families whose headers are\n// byte-identical share a component even if the producer named them differently,\n// and two different headers that claim the same name get separate files plus a\n// warning — never a silent swap, which would put one family's nav on another's\n// pages.\n\nimport type { LayoutFamily } from '@contentrain/types'\nimport { CHROME_BODY_SLOT } from '@contentrain/types'\nimport { balanceWarning } from './balance.js'\nimport { pascalCase, stableJson } from './util.js'\n\nexport type ChromePosition = 'header' | 'footer'\n\nexport interface ChromeComponentRef {\n name: string\n position: ChromePosition\n}\n\nexport interface ChromeRegistry {\n files: Record<string, string>\n /** Family id → the components its layout renders, in chunk order. */\n byFamily: Map<string, ChromeComponentRef[]>\n warnings: string[]\n}\n\nconst DEFAULT_NAME: Record<ChromePosition, string> = {\n header: 'SiteHeader',\n footer: 'SiteFooter',\n}\n\n/** A component name Astro can import: PascalCase and starting with a letter. */\nfunction componentName(raw: string): string {\n const pascal = pascalCase(raw)\n return /^[A-Za-z]/.test(pascal) ? pascal : `Chrome${pascal}`\n}\n\nfunction componentSource(name: string): string {\n return `---\n/**\n * Chrome component: ${name} — emitted by @contentrain/emitter-astro.\n * Markup travels as data so the Astro compiler never parses theme markup, and\n * fills the same \\`@@mark@@\\` placeholders the rest of the chrome does.\n */\nimport chunk from '../data/components/${name}.json'\nimport { renderTemplate } from '../lib/fill'\n\ninterface Props {\n /** Page marks — the region's own placeholders (site name, current-page class). */\n marks?: Record<string, unknown>\n}\nconst { marks = {} } = Astro.props\n---\n<Fragment set:html={renderTemplate(chunk.html, marks)} />\n`\n}\n\nexport function chromeComponents(families: LayoutFamily[]): ChromeRegistry {\n const files: Record<string, string> = {}\n const byFamily = new Map<string, ChromeComponentRef[]>()\n const warnings: string[] = []\n /** html → emitted component name, so identical regions collapse into one. */\n const byHtml = new Map<string, string>()\n /** emitted name → its html, so a name clash can be detected and renamed. */\n const byName = new Map<string, string>()\n\n for (const family of families) {\n const refs: ChromeComponentRef[] = []\n for (const chunk of family.chrome ?? []) {\n if (chunk.position !== 'header' && chunk.position !== 'footer') continue\n const position: ChromePosition = chunk.position\n const where = `family ${family.id}: ${position} chrome`\n\n if (chunk.html.includes(CHROME_BODY_SLOT)) {\n warnings.push(\n `${where} carries ${CHROME_BODY_SLOT} — content goes in the body chunk; the marker is rendered as a comment`,\n )\n }\n const unbalanced = balanceWarning(chunk.html)\n if (unbalanced) {\n warnings.push(\n `${where} is not balanced (${unbalanced}) — a lifted region must close what it opens; keep it in the body chunk`,\n )\n }\n\n const wanted = componentName(chunk.component ?? DEFAULT_NAME[position])\n let name = byHtml.get(chunk.html)\n if (name === undefined) {\n name = wanted\n for (let n = 2; byName.has(name) && byName.get(name) !== chunk.html; n++) {\n name = `${wanted}${n}`\n }\n if (name !== wanted) {\n warnings.push(\n `${where}: component name \"${wanted}\" is already used by different markup — emitted as \"${name}\"`,\n )\n }\n byHtml.set(chunk.html, name)\n byName.set(name, chunk.html)\n files[`src/data/components/${name}.json`] = stableJson({ html: chunk.html })\n files[`src/components/${name}.astro`] = componentSource(name)\n }\n refs.push({ name, position })\n }\n if (refs.length) byFamily.set(family.id, refs)\n }\n\n return { files, byFamily, warnings }\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 { chromeComponents } from './chrome.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 // Header/footer regions first: families that share one share the component.\n const chrome = chromeComponents(ir.families)\n add(chrome.files)\n warnings.push(...chrome.warnings)\n for (const family of ir.families) {\n const fam = familyFiles(family, lang, chrome.byFamily.get(family.id))\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 ?? {}, lang)\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'\nexport { checkBalance, balanceWarning } from './balance.js'\nexport type { BalanceReport } from './balance.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.7.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.10.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/node": "^22.10.0",
|