create-ailk 0.8.0 → 0.9.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 +68 -0
- package/component-catalog.json +279 -45
- package/dist/add-module.d.ts +20 -0
- package/dist/add-module.js +43 -7
- package/dist/add-route.d.ts +56 -0
- package/dist/add-route.js +286 -0
- package/dist/apply-design-md.d.ts +7 -0
- package/dist/apply-design-md.js +50 -0
- package/dist/apply-design.d.ts +64 -0
- package/dist/apply-design.js +273 -0
- package/dist/claude-md.d.ts +111 -0
- package/dist/claude-md.js +334 -0
- package/dist/cli.js +92 -4
- package/dist/derive-architecture.js +41 -36
- package/dist/design-compile.d.ts +71 -0
- package/dist/design-compile.js +98 -0
- package/dist/design-package.d.ts +83 -0
- package/dist/design-package.js +468 -0
- package/dist/design-presets.d.ts +86 -0
- package/dist/design-presets.js +642 -0
- package/dist/design-scaffold.d.ts +76 -0
- package/dist/design-scaffold.js +220 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -1
- package/dist/legacy-nav-renderers.d.ts +45 -0
- package/dist/legacy-nav-renderers.js +545 -0
- package/dist/lib/auth-secret-file.d.ts +19 -0
- package/dist/lib/auth-secret-file.js +112 -0
- package/dist/lib/design-checks.d.ts +78 -0
- package/dist/lib/design-checks.js +174 -0
- package/dist/lib/license-key-files.d.ts +9 -0
- package/dist/lib/license-key-files.js +2 -2
- package/dist/lib/module-license-gate.d.ts +19 -9
- package/dist/lib/module-license-gate.js +19 -8
- package/dist/lib/render-verdict.d.ts +52 -0
- package/dist/lib/render-verdict.js +77 -0
- package/dist/lib/scaffold-render-sweep.d.ts +105 -0
- package/dist/lib/scaffold-render-sweep.js +236 -0
- package/dist/module-architecture.d.ts +19 -16
- package/dist/module-architecture.js +64 -21
- package/dist/parse-args.d.ts +12 -2
- package/dist/parse-args.js +74 -13
- package/dist/programmatic.d.ts +30 -6
- package/dist/programmatic.js +206 -17
- package/dist/site-chrome.d.ts +19 -9
- package/dist/site-chrome.js +23 -13
- package/dist/site-manifest.d.ts +25 -0
- package/dist/site-manifest.js +254 -0
- package/dist/site-roster.d.ts +41 -0
- package/dist/site-roster.js +141 -8
- package/dist/starter-grid.d.ts +32 -16
- package/dist/starter-grid.js +129 -33
- package/dist/surfaces.d.ts +41 -9
- package/dist/surfaces.js +200 -83
- package/dist/template-manifest.d.ts +30 -13
- package/dist/template-manifest.js +49 -24
- package/package.json +5 -4
- package/site-templates/_base/content/en/UseCase/persona-1.mdx +7 -6
- package/site-templates/_base/content/en/UseCase/persona-2.mdx +7 -6
- package/site-templates/_base/content/en/UseCase/persona-3.mdx +7 -6
- package/site-templates/{minimalism-site/TEMPLATE-MANIFEST.json → aliases/minimalism-site.TEMPLATE-MANIFEST.json} +72 -40
- package/site-templates/designs/minimalism/DESIGN.md +98 -6
- package/site-templates/designs/minimalism/sample/README.md +48 -0
- package/site-templates/designs/standard/DESIGN.md +98 -6
- package/site-templates/marketing-site/DESIGN.md +98 -6
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +60 -27
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +50 -30
- package/site-templates/marketing-site-free/DESIGN.md +98 -6
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +60 -24
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +44 -27
- package/site-templates/marketing-site-free/ailk-template.json +1 -1
- package/site-templates/saas-product/DESIGN.md +98 -6
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +127 -18
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +74 -20
- package/site-templates/shapes/local-business-service/ailk-template.json +1 -1
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +127 -18
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +74 -20
- package/site-templates/shapes/organization-service/ailk-template.json +1 -1
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +127 -18
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +75 -21
- package/site-templates/shapes/person-service/ailk-template.json +1 -1
- package/site-templates/shapes/person-service/content/en/AboutPage/about.mdx +1 -1
- package/site-templates/shapes/person-service/content/en/HomePage/home.mdx +2 -2
- package/site-templates/shapes/person-service/content/en/PortfolioList/portfolio.mdx +4 -2
- package/site-templates/shapes/person-service/content/en/ServicesList/services.mdx +1 -1
- package/templates/.claude/rules/architecture.md +7 -0
- package/templates/.claude/skills/README.md +3 -2
- package/templates/.claude/skills/add-auth/references/env-vars.md +22 -15
- package/templates/.claude/skills/add-auth/references/oauth-app-setup.md +3 -2
- package/templates/.claude/skills/add-page/SKILL.md +241 -238
- package/templates/.claude/skills/add-page/references/page-types.md +85 -76
- package/templates/.claude/skills/add-page/scripts/site-pages.mjs +87 -0
- package/templates/.claude/skills/add-page/templates/comparison-page.mdx.tmpl +99 -0
- package/templates/.claude/skills/add-page/templates/content.mdx.tmpl +8 -18
- package/templates/.claude/skills/add-page/templates/flow-landing-page.mdx.tmpl +43 -0
- package/templates/.claude/skills/add-page/templates/report-page.mdx.tmpl +52 -0
- package/templates/.claude/skills/add-page/templates/reports-list.mdx.tmpl +38 -0
- package/templates/.claude/skills/check-site/SKILL.md +23 -0
- package/templates/.claude/skills/fill-page/SKILL.md +26 -7
- package/templates/.claude/skills/fill-page/profiles/plumbing.yaml +1 -1
- package/templates/.claude/skills/setup-deploy/references/env-vars-template.md +28 -11
- package/templates/.claude/skills/update-page/SKILL.md +117 -0
- package/templates/.claude/skills/update-page/scripts/update-page.ts +640 -0
- package/templates/.claude/skills/update-page/templates/redirects.mjs.tmpl +31 -0
- package/templates/.claude/skills/upgrade-plan/SKILL.md +12 -8
- package/templates/.github/workflows/ci.yml +4 -0
- package/templates/.prettierignore +1 -0
- package/templates/CLAUDE.md +146 -103
- package/templates/DESIGN.md +98 -6
- package/templates/README.md +50 -38
- package/templates/apps/api/.env.example +2 -1
- package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +2 -0
- package/templates/apps/api/src/__tests__/server.test.ts +116 -0
- package/templates/apps/api/src/lib/stripe.ts +9 -0
- package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +140 -0
- package/templates/apps/api/src/middleware/auth.ts +54 -0
- package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +5 -2
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +628 -0
- package/templates/apps/api/src/routes/checkout/sessions.ts +222 -24
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -1
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +5 -1
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +521 -20
- package/templates/apps/api/src/routes/webhooks/stripe.ts +124 -32
- package/templates/apps/api/src/server.ts +28 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +90 -15
- package/templates/apps/api/src/services/checkout-buyer.ts +72 -0
- package/templates/apps/api/src/services/revalidation.ts +60 -15
- package/templates/apps/mcp/__tests__/tools/create_project.test.ts +30 -24
- package/templates/apps/mcp/src/tools/purchase.ts +4 -2
- package/templates/apps/web/.env.example +13 -0
- package/templates/apps/web/CLAUDE.md +22 -28
- package/templates/apps/web/app/[locale]/(auth)/layout.tsx +1 -1
- package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +24 -0
- package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +17 -6
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +110 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +60 -0
- package/templates/apps/web/app/[locale]/[slug]/page.tsx +1 -1
- package/templates/apps/web/app/[locale]/alternatives/__tests__/page.test.tsx +161 -0
- package/templates/apps/web/app/[locale]/alternatives/page.tsx +104 -0
- package/templates/apps/web/app/[locale]/{use-cases → for}/[slug]/page.tsx +4 -3
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +73 -0
- package/templates/apps/web/app/[locale]/{use-cases → for}/page.tsx +8 -7
- package/templates/apps/web/app/[locale]/glossary/page.tsx +1 -1
- package/templates/apps/web/app/[locale]/layout.tsx +35 -19
- package/templates/apps/web/app/[locale]/newsletter/page.tsx +1 -1
- package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +1 -1
- package/templates/apps/web/app/[locale]/portfolio/page.tsx +1 -1
- package/templates/apps/web/app/[locale]/vs/[slug]/__tests__/page.test.tsx +112 -0
- package/templates/apps/web/app/[locale]/vs/[slug]/page.tsx +70 -0
- package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +236 -44
- package/templates/apps/web/app/api/revalidate/route.ts +78 -36
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +79 -0
- package/templates/apps/web/lib/__tests__/site-theme.test.ts +104 -11
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +21 -4
- package/templates/apps/web/lib/locale-guard.ts +18 -0
- package/templates/apps/web/lib/meet-placeholder.ts +5 -0
- package/templates/apps/web/lib/nav-config.ts +14 -13
- package/templates/apps/web/lib/site-brand.tsx +13 -9
- package/templates/apps/web/lib/site-logo.tsx +36 -0
- package/templates/apps/web/lib/site-theme.ts +46 -26
- package/templates/apps/web/messages/en.json +23 -9
- package/templates/apps/web/middleware.ts +28 -4
- package/templates/content/en/BookCall/book-a-call.mdx +80 -0
- package/templates/content/en/BookCall/onsite-estimate.mdx +56 -0
- package/templates/content/en/BookCall/onsite-service.mdx +56 -0
- package/templates/content/en/BookCall/start-project.mdx +56 -0
- package/templates/content/en/ContactPage/contact.mdx +40 -1
- package/templates/content/en/HomePage/home.mdx +2 -6
- package/templates/content/en/LandingPage/meet.mdx +59 -0
- package/templates/content/en/UseCasesList/{use-cases.mdx → for.mdx} +19 -18
- package/templates/database/CHANGELOG.md +7 -0
- package/templates/database/package.json +1 -1
- package/templates/package.json +2 -1
- package/templates/tools/check-claude-md.ts +128 -0
- package/versions.json +33 -33
- package/site-templates/_base/content/en/BookCall/book-call.mdx +0 -52
- package/site-templates/minimalism-site/DESIGN.md +0 -162
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +0 -458
- package/site-templates/minimalism-site/ailk-template.json +0 -23
- package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +0 -110
- package/site-templates/minimalism-site/content/en/BookCall/book-call.mdx +0 -52
- package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +0 -34
- package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +0 -57
- package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +0 -77
- package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +0 -73
- package/site-templates/minimalism-site/content/en/PurchasePolicyPage/purchase-policy.mdx +0 -123
- package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +0 -52
- package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +0 -52
- package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +0 -52
- package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +0 -52
- package/site-templates/minimalism-site/content/en/Service/first-engagement.mdx +0 -38
- package/site-templates/minimalism-site/content/en/Service/second-engagement.mdx +0 -38
- package/site-templates/minimalism-site/content/en/Service/third-engagement.mdx +0 -38
- package/site-templates/minimalism-site/content/en/ServicesList/services.mdx +0 -73
- package/site-templates/minimalism-site/flows/referral.flow.json +0 -71
- package/site-templates/minimalism-site/provenance.json +0 -4
- package/templates/.claude/skills/add-page/templates/route.tsx.tmpl +0 -28
- package/templates/apps/web/app/[locale]/book-a-call/page.tsx +0 -50
- package/templates/apps/web/app/[locale]/use-cases/__tests__/page-renderer-migration.test.tsx +0 -72
- /package/site-templates/{minimalism-site → designs/minimalism/sample}/content/en/HomePage/home.mdx +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-1.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-2.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-3.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-4.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-5.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-6.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/hero-backdrop.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-1.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-2.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-3.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/wordmark-placeholder.svg +0 -0
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Design section presets (#6816) — a design package's `sections` block
|
|
3
|
+
* (page type → section type → look fields) validated at scaffold time and
|
|
4
|
+
* applied onto the page files of the page types the site's roster places.
|
|
5
|
+
* Spec docs/specs/6816-the-preset-validator-and-applier-spec.md D1–D5.
|
|
6
|
+
*
|
|
7
|
+
* A preset changes how a placed section LOOKS, never what a page SAYS or
|
|
8
|
+
* EMITS: `validateDesignPresets` refuses anything but a look field (the look
|
|
9
|
+
* groups #6815 declared, shipped inside `component-catalog.json` because this
|
|
10
|
+
* package cannot import the registry), and `applyDesignPresets` is a bounded
|
|
11
|
+
* text splice that replaces or inserts one look-field value span at a time —
|
|
12
|
+
* there is no YAML library to re-print a page with, and a re-print would
|
|
13
|
+
* rewrite every byte of a customer's frontmatter.
|
|
14
|
+
*
|
|
15
|
+
* Pure apart from the two catalog/roster readers: every directory it reads is
|
|
16
|
+
* passed in (no `import.meta.url` — Jest's CJS transform, the #6812 constraint).
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { DesignPackageError } from "./design-package.js";
|
|
21
|
+
import { TemplateError } from "./template-manifest.js";
|
|
22
|
+
function isPlainObject(value) {
|
|
23
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
function hasOwn(obj, key) {
|
|
26
|
+
return Object.prototype.hasOwnProperty.call(obj, key);
|
|
27
|
+
}
|
|
28
|
+
/** Read `<pkgRoot>/component-catalog.json` into the look catalog. Throws naming the file on a missing `look` or `schema`. */
|
|
29
|
+
export function readSectionLookCatalog(catalogPath) {
|
|
30
|
+
const doc = JSON.parse(readFileSync(catalogPath, "utf8"));
|
|
31
|
+
if (!Array.isArray(doc.sections)) {
|
|
32
|
+
throw new Error(`${catalogPath}: no sections[] array`);
|
|
33
|
+
}
|
|
34
|
+
const out = {};
|
|
35
|
+
for (const raw of doc.sections) {
|
|
36
|
+
const entry = raw;
|
|
37
|
+
const type = String(entry.type);
|
|
38
|
+
if (!Array.isArray(entry.look)) {
|
|
39
|
+
throw new Error(`${catalogPath}: sections["${type}"] has no look group`);
|
|
40
|
+
}
|
|
41
|
+
if (!isPlainObject(entry.schema)) {
|
|
42
|
+
throw new Error(`${catalogPath}: sections["${type}"] has no schema`);
|
|
43
|
+
}
|
|
44
|
+
const properties = entry.schema["properties"];
|
|
45
|
+
out[type] = {
|
|
46
|
+
look: entry.look.map(String),
|
|
47
|
+
properties: isPlainObject(properties) ? properties : {},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
/** Every `pageType` a manifest file's rows name, added into `out`. */
|
|
53
|
+
function addPageTypesFrom(file, out) {
|
|
54
|
+
if (!existsSync(file))
|
|
55
|
+
return;
|
|
56
|
+
const doc = JSON.parse(readFileSync(file, "utf8"));
|
|
57
|
+
for (const row of Array.isArray(doc.rows) ? doc.rows : []) {
|
|
58
|
+
const pageType = row.pageType;
|
|
59
|
+
if (typeof pageType === "string")
|
|
60
|
+
out.add(pageType);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Every `pageType` named by a row of a shipped `TEMPLATE-MANIFEST.json` under
|
|
65
|
+
* `<siteTemplatesDir>/*`, `<siteTemplatesDir>/shapes/*`, and — a treeless
|
|
66
|
+
* alias's own GENERATED record (spec 6892 D1/D5), which has no directory of
|
|
67
|
+
* its own — every `<siteTemplatesDir>/aliases/*.TEMPLATE-MANIFEST.json` file.
|
|
68
|
+
*/
|
|
69
|
+
export function shippedPageTypes(siteTemplatesDir) {
|
|
70
|
+
const dirs = [];
|
|
71
|
+
for (const parent of [siteTemplatesDir, join(siteTemplatesDir, "shapes")]) {
|
|
72
|
+
if (!existsSync(parent))
|
|
73
|
+
continue;
|
|
74
|
+
for (const entry of readdirSync(parent, { withFileTypes: true })) {
|
|
75
|
+
if (entry.isDirectory())
|
|
76
|
+
dirs.push(join(parent, entry.name));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const out = new Set();
|
|
80
|
+
for (const dir of dirs) {
|
|
81
|
+
addPageTypesFrom(join(dir, "TEMPLATE-MANIFEST.json"), out);
|
|
82
|
+
}
|
|
83
|
+
const aliasesDir = join(siteTemplatesDir, "aliases");
|
|
84
|
+
if (existsSync(aliasesDir)) {
|
|
85
|
+
for (const entry of readdirSync(aliasesDir, { withFileTypes: true })) {
|
|
86
|
+
if (entry.isFile() && entry.name.endsWith(".TEMPLATE-MANIFEST.json")) {
|
|
87
|
+
addPageTypesFrom(join(aliasesDir, entry.name), out);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
// ── D3: validateDesignPresets ──────────────────────────────────────────────
|
|
94
|
+
/** The JSON Schema keywords the value check evaluates (spec § 1.2); `default` is not a constraint. */
|
|
95
|
+
const SUPPORTED_KEYWORDS = new Set([
|
|
96
|
+
"anyOf",
|
|
97
|
+
"const",
|
|
98
|
+
"default",
|
|
99
|
+
"enum",
|
|
100
|
+
"exclusiveMinimum",
|
|
101
|
+
"properties",
|
|
102
|
+
"type",
|
|
103
|
+
]);
|
|
104
|
+
function describe(value) {
|
|
105
|
+
return typeof value === "string" ? value : JSON.stringify(value);
|
|
106
|
+
}
|
|
107
|
+
/** The allowed values a schema enumerates (`enum`, `const`, or an `anyOf` of those), or undefined. */
|
|
108
|
+
function enumeratedValues(schema) {
|
|
109
|
+
if (Array.isArray(schema["enum"]))
|
|
110
|
+
return schema["enum"];
|
|
111
|
+
if (hasOwn(schema, "const"))
|
|
112
|
+
return [schema["const"]];
|
|
113
|
+
if (Array.isArray(schema["anyOf"])) {
|
|
114
|
+
const out = [];
|
|
115
|
+
for (const branch of schema["anyOf"]) {
|
|
116
|
+
const values = isPlainObject(branch) ? enumeratedValues(branch) : undefined;
|
|
117
|
+
if (values === undefined)
|
|
118
|
+
return undefined;
|
|
119
|
+
out.push(...values);
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
function checkValue(schema, value, field, label) {
|
|
126
|
+
if (!isPlainObject(schema))
|
|
127
|
+
return undefined;
|
|
128
|
+
for (const keyword of Object.keys(schema)) {
|
|
129
|
+
if (!SUPPORTED_KEYWORDS.has(keyword)) {
|
|
130
|
+
throw new Error(`validateDesignPresets: unsupported JSON Schema keyword "${keyword}" on ${label}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (Array.isArray(schema["anyOf"])) {
|
|
134
|
+
const branches = schema["anyOf"];
|
|
135
|
+
let last;
|
|
136
|
+
for (const branch of branches) {
|
|
137
|
+
last = checkValue(branch, value, field, label);
|
|
138
|
+
if (last === undefined)
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
if (last !== undefined) {
|
|
142
|
+
const values = enumeratedValues(schema);
|
|
143
|
+
return values !== undefined
|
|
144
|
+
? { field, reason: `must be one of ${values.map(describe).join(", ")}` }
|
|
145
|
+
: last;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const rawType = schema["type"];
|
|
149
|
+
if (rawType !== undefined) {
|
|
150
|
+
const type = typeof rawType === "string" ? rawType : "";
|
|
151
|
+
if (!["string", "number", "integer", "boolean", "object"].includes(type)) {
|
|
152
|
+
throw new Error(`validateDesignPresets: unsupported JSON Schema type ${JSON.stringify(rawType)} on ${label}`);
|
|
153
|
+
}
|
|
154
|
+
const ok = type === "object"
|
|
155
|
+
? isPlainObject(value)
|
|
156
|
+
: type === "integer"
|
|
157
|
+
? Number.isInteger(value)
|
|
158
|
+
: typeof value === type;
|
|
159
|
+
if (!ok)
|
|
160
|
+
return { field, reason: `must be a${type === "object" || type === "integer" ? "n" : ""} ${type}` };
|
|
161
|
+
}
|
|
162
|
+
if (Array.isArray(schema["enum"]) && !schema["enum"].includes(value)) {
|
|
163
|
+
return {
|
|
164
|
+
field,
|
|
165
|
+
reason: `must be one of ${schema["enum"].map(describe).join(", ")}`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
if (hasOwn(schema, "const") && schema["const"] !== value) {
|
|
169
|
+
return { field, reason: `must be one of ${describe(schema["const"])}` };
|
|
170
|
+
}
|
|
171
|
+
const min = schema["exclusiveMinimum"];
|
|
172
|
+
if (typeof min === "number" && !(typeof value === "number" && value > min)) {
|
|
173
|
+
return { field, reason: `must be greater than ${min}` };
|
|
174
|
+
}
|
|
175
|
+
if (isPlainObject(schema["properties"]) && isPlainObject(value)) {
|
|
176
|
+
const properties = schema["properties"];
|
|
177
|
+
for (const [sub, subValue] of Object.entries(value)) {
|
|
178
|
+
if (!hasOwn(properties, sub)) {
|
|
179
|
+
return { field: `${field}.${sub}`, reason: `is not a field of ${label}` };
|
|
180
|
+
}
|
|
181
|
+
const failure = checkValue(properties[sub], subValue, `${field}.${sub}`, `${label}.${sub}`);
|
|
182
|
+
if (failure !== undefined)
|
|
183
|
+
return failure;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Refuse, with a `DesignPackageError` whose `field` names the page type, the
|
|
190
|
+
* section type and the field, the first preset (key order) that names a page
|
|
191
|
+
* type no shipped roster places, an unregistered section type, a field
|
|
192
|
+
* outside the type's look group, or a value outside that field's schema
|
|
193
|
+
* (D3's table, R1–R5b). A valid block returns without effect. Pure.
|
|
194
|
+
*/
|
|
195
|
+
export function validateDesignPresets(sections, opts) {
|
|
196
|
+
function refuse(field, reason) {
|
|
197
|
+
throw new DesignPackageError(opts.file, field, reason);
|
|
198
|
+
}
|
|
199
|
+
for (const [pageType, bySection] of Object.entries(sections)) {
|
|
200
|
+
if (!opts.pageTypes.has(pageType)) {
|
|
201
|
+
refuse(`sections.${pageType}`, `is not a page type any shipped shape places (known: ${[...opts.pageTypes].sort().join(", ")})`);
|
|
202
|
+
}
|
|
203
|
+
for (const [sectionType, preset] of Object.entries(bySection)) {
|
|
204
|
+
const at = `sections.${pageType}.${sectionType}`;
|
|
205
|
+
const entry = hasOwn(opts.catalog, sectionType)
|
|
206
|
+
? opts.catalog[sectionType]
|
|
207
|
+
: undefined;
|
|
208
|
+
if (entry === undefined)
|
|
209
|
+
refuse(at, "is not a registered section type");
|
|
210
|
+
if (!isPlainObject(preset))
|
|
211
|
+
refuse(at, "must be a mapping of look fields");
|
|
212
|
+
const { look, properties } = entry;
|
|
213
|
+
for (const [field, value] of Object.entries(preset)) {
|
|
214
|
+
const fieldAt = `${at}.${field}`;
|
|
215
|
+
if (!look.includes(field)) {
|
|
216
|
+
if (hasOwn(properties, field)) {
|
|
217
|
+
refuse(fieldAt, `is a content field of section type "${sectionType}" — a design preset sets look fields only`);
|
|
218
|
+
}
|
|
219
|
+
refuse(fieldAt, field === "variant"
|
|
220
|
+
? `section type "${sectionType}" has no variants`
|
|
221
|
+
: `is not a field of section type "${sectionType}"`);
|
|
222
|
+
}
|
|
223
|
+
const failure = checkValue(properties[field], value, fieldAt, `${sectionType}.${field}`);
|
|
224
|
+
if (failure === undefined)
|
|
225
|
+
continue;
|
|
226
|
+
if (field === "variant" && failure.field === fieldAt) {
|
|
227
|
+
const variants = isPlainObject(properties[field])
|
|
228
|
+
? enumeratedValues(properties[field])
|
|
229
|
+
: undefined;
|
|
230
|
+
refuse(fieldAt, `"${describe(value)}" is not a variant of section type "${sectionType}"` +
|
|
231
|
+
(variants !== undefined ? ` (variants: ${variants.map(describe).join(", ")})` : ""));
|
|
232
|
+
}
|
|
233
|
+
refuse(failure.field, failure.reason);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
function cannotLocate(file, index, what) {
|
|
239
|
+
const where = index === undefined ? "sections" : `sections[${index}]`;
|
|
240
|
+
throw new TemplateError(`${file}: ${where} — cannot locate ${what} for a design preset`);
|
|
241
|
+
}
|
|
242
|
+
function skipSpace(text, i, end) {
|
|
243
|
+
while (i < end && /\s/.test(text[i]))
|
|
244
|
+
i++;
|
|
245
|
+
return i;
|
|
246
|
+
}
|
|
247
|
+
/** End (exclusive) of a quoted scalar opening at `i`, or -1 when unterminated. */
|
|
248
|
+
function endOfQuoted(text, i, end) {
|
|
249
|
+
const quote = text[i];
|
|
250
|
+
for (let j = i + 1; j < end; j++) {
|
|
251
|
+
const ch = text[j];
|
|
252
|
+
if (quote === '"' && ch === "\\") {
|
|
253
|
+
j++;
|
|
254
|
+
}
|
|
255
|
+
else if (ch === quote) {
|
|
256
|
+
if (quote === "'" && text[j + 1] === "'") {
|
|
257
|
+
j++;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
return j + 1;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return -1;
|
|
264
|
+
}
|
|
265
|
+
/** End (exclusive) of the flow value starting at `i`: a collection, a quoted scalar or a plain scalar. */
|
|
266
|
+
function endOfFlowValue(text, i, end) {
|
|
267
|
+
const ch = text[i];
|
|
268
|
+
if (ch === '"' || ch === "'")
|
|
269
|
+
return endOfQuoted(text, i, end);
|
|
270
|
+
if (ch === "{" || ch === "[") {
|
|
271
|
+
let depth = 0;
|
|
272
|
+
for (let j = i; j < end; j++) {
|
|
273
|
+
const c = text[j];
|
|
274
|
+
if (c === '"' || c === "'") {
|
|
275
|
+
const close = endOfQuoted(text, j, end);
|
|
276
|
+
if (close < 0)
|
|
277
|
+
return -1;
|
|
278
|
+
j = close - 1;
|
|
279
|
+
}
|
|
280
|
+
else if (c === "{" || c === "[") {
|
|
281
|
+
depth++;
|
|
282
|
+
}
|
|
283
|
+
else if (c === "}" || c === "]") {
|
|
284
|
+
depth--;
|
|
285
|
+
if (depth === 0)
|
|
286
|
+
return j + 1;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return -1;
|
|
290
|
+
}
|
|
291
|
+
let j = i;
|
|
292
|
+
while (j < end && !",]}".includes(text[j]))
|
|
293
|
+
j++;
|
|
294
|
+
while (j > i && /\s/.test(text[j - 1]))
|
|
295
|
+
j--;
|
|
296
|
+
return j > i ? j : -1;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* A YAML scalar's string value — quoted (a trailing `# comment` allowed) or
|
|
300
|
+
* plain (a whitespace-preceded `# comment` stripped) — or undefined when it
|
|
301
|
+
* cannot be read (an unterminated quote, an escape JSON does not share).
|
|
302
|
+
*/
|
|
303
|
+
function scalarText(raw) {
|
|
304
|
+
const text = raw.trim();
|
|
305
|
+
if (text.startsWith('"') || text.startsWith("'")) {
|
|
306
|
+
const close = endOfQuoted(text, 0, text.length);
|
|
307
|
+
if (close < 0 || !/^\s*(?:#.*)?$/.test(text.slice(close)))
|
|
308
|
+
return undefined;
|
|
309
|
+
const quoted = text.slice(0, close);
|
|
310
|
+
if (quoted.startsWith("'"))
|
|
311
|
+
return quoted.slice(1, -1).replace(/''/g, "'");
|
|
312
|
+
try {
|
|
313
|
+
return JSON.parse(quoted);
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
return undefined;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return text.replace(/\s+#.*$/, "");
|
|
320
|
+
}
|
|
321
|
+
/** The end of an inline scalar value starting at `i` on its line (before any trailing comment). */
|
|
322
|
+
function endOfInlineScalar(text, i, lineEnd) {
|
|
323
|
+
const start = skipSpace(text, i, lineEnd);
|
|
324
|
+
if (text[start] === '"' || text[start] === "'") {
|
|
325
|
+
const close = endOfQuoted(text, start, lineEnd);
|
|
326
|
+
return close < 0 ? lineEnd : close;
|
|
327
|
+
}
|
|
328
|
+
const comment = /[ \t]+#/.exec(text.slice(start, lineEnd));
|
|
329
|
+
let end = comment ? start + comment.index : lineEnd;
|
|
330
|
+
while (end > start && /\s/.test(text[end - 1]))
|
|
331
|
+
end--;
|
|
332
|
+
return end;
|
|
333
|
+
}
|
|
334
|
+
/** Parse the flow mapping whose `{` is at `open`; undefined when it is not one. */
|
|
335
|
+
function parseFlowMapping(text, open, end) {
|
|
336
|
+
if (text[open] !== "{")
|
|
337
|
+
return undefined;
|
|
338
|
+
const entries = [];
|
|
339
|
+
let i = skipSpace(text, open + 1, end);
|
|
340
|
+
while (i < end && text[i] !== "}") {
|
|
341
|
+
let key;
|
|
342
|
+
if (text[i] === '"' || text[i] === "'") {
|
|
343
|
+
const close = endOfQuoted(text, i, end);
|
|
344
|
+
if (close < 0)
|
|
345
|
+
return undefined;
|
|
346
|
+
const unquoted = scalarText(text.slice(i, close));
|
|
347
|
+
if (unquoted === undefined)
|
|
348
|
+
return undefined;
|
|
349
|
+
key = unquoted;
|
|
350
|
+
i = close;
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
const colon = text.indexOf(":", i);
|
|
354
|
+
if (colon < 0 || colon >= end)
|
|
355
|
+
return undefined;
|
|
356
|
+
key = text.slice(i, colon).trim();
|
|
357
|
+
i = colon;
|
|
358
|
+
}
|
|
359
|
+
i = skipSpace(text, i, end);
|
|
360
|
+
if (text[i] !== ":")
|
|
361
|
+
return undefined;
|
|
362
|
+
const valueStart = skipSpace(text, i + 1, end);
|
|
363
|
+
const valueEnd = endOfFlowValue(text, valueStart, end);
|
|
364
|
+
if (valueEnd < 0)
|
|
365
|
+
return undefined;
|
|
366
|
+
entries.push({ key, valueStart, valueEnd });
|
|
367
|
+
i = skipSpace(text, valueEnd, end);
|
|
368
|
+
if (text[i] === ",")
|
|
369
|
+
i = skipSpace(text, i + 1, end);
|
|
370
|
+
else if (text[i] !== "}")
|
|
371
|
+
return undefined;
|
|
372
|
+
}
|
|
373
|
+
if (text[i] !== "}")
|
|
374
|
+
return undefined;
|
|
375
|
+
return { open, close: i, entries };
|
|
376
|
+
}
|
|
377
|
+
function locateFlow(text, open, end, file) {
|
|
378
|
+
const out = [];
|
|
379
|
+
let i = skipSpace(text, open + 1, end);
|
|
380
|
+
while (i < end && text[i] !== "]") {
|
|
381
|
+
const index = out.length;
|
|
382
|
+
const element = parseFlowMapping(text, i, end);
|
|
383
|
+
if (element === undefined)
|
|
384
|
+
cannotLocate(file, index, "a { … } mapping");
|
|
385
|
+
const typeEntry = element.entries.find((e) => e.key === "type");
|
|
386
|
+
const dataEntry = element.entries.find((e) => e.key === "data");
|
|
387
|
+
if (typeEntry === undefined)
|
|
388
|
+
cannotLocate(file, index, "its type");
|
|
389
|
+
if (dataEntry === undefined)
|
|
390
|
+
cannotLocate(file, index, "its data");
|
|
391
|
+
const type = scalarText(text.slice(typeEntry.valueStart, typeEntry.valueEnd));
|
|
392
|
+
if (type === undefined)
|
|
393
|
+
cannotLocate(file, index, "its type");
|
|
394
|
+
const data = parseFlowMapping(text, dataEntry.valueStart, end);
|
|
395
|
+
if (data === undefined)
|
|
396
|
+
cannotLocate(file, index, "its data mapping");
|
|
397
|
+
out.push({
|
|
398
|
+
type,
|
|
399
|
+
form: "flow",
|
|
400
|
+
dataOpen: data.open,
|
|
401
|
+
dataClose: data.close,
|
|
402
|
+
entryColumn: -1,
|
|
403
|
+
entries: data.entries,
|
|
404
|
+
});
|
|
405
|
+
i = skipSpace(text, element.close + 1, end);
|
|
406
|
+
if (text[i] === ",")
|
|
407
|
+
i = skipSpace(text, i + 1, end);
|
|
408
|
+
else if (text[i] !== "]")
|
|
409
|
+
cannotLocate(file, index, "the , or ] after it");
|
|
410
|
+
}
|
|
411
|
+
if (text[i] !== "]")
|
|
412
|
+
cannotLocate(file, undefined, "the closing ]");
|
|
413
|
+
return out;
|
|
414
|
+
}
|
|
415
|
+
function splitLines(text, start, end) {
|
|
416
|
+
const lines = [];
|
|
417
|
+
let i = start;
|
|
418
|
+
while (i < end) {
|
|
419
|
+
let nl = text.indexOf("\n", i);
|
|
420
|
+
if (nl < 0 || nl > end)
|
|
421
|
+
nl = end;
|
|
422
|
+
const body = text.slice(i, nl);
|
|
423
|
+
const indent = body.length - body.trimStart().length;
|
|
424
|
+
lines.push({ start: i, end: nl, indent, blank: body.trim() === "" });
|
|
425
|
+
i = nl + 1;
|
|
426
|
+
}
|
|
427
|
+
return lines;
|
|
428
|
+
}
|
|
429
|
+
const BLOCK_KEY_RE = /^([A-Za-z_][\w-]*):(?:[ \t]+(.*?))?[ \t]*$/;
|
|
430
|
+
function locateBlock(text, lines, file) {
|
|
431
|
+
const first = lines.find((l) => !l.blank);
|
|
432
|
+
if (first === undefined)
|
|
433
|
+
return [];
|
|
434
|
+
const itemIndent = first.indent;
|
|
435
|
+
const isItemStart = (l) => !l.blank && l.indent === itemIndent && text.startsWith("- ", l.start + itemIndent);
|
|
436
|
+
const items = [];
|
|
437
|
+
for (const line of lines) {
|
|
438
|
+
if (line.blank) {
|
|
439
|
+
items.at(-1)?.push(line);
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
if (isItemStart(line))
|
|
443
|
+
items.push([line]);
|
|
444
|
+
else if (line.indent > itemIndent && items.length > 0)
|
|
445
|
+
items.at(-1).push(line);
|
|
446
|
+
else
|
|
447
|
+
cannotLocate(file, items.length, "a block sequence item");
|
|
448
|
+
}
|
|
449
|
+
const keyColumn = itemIndent + 2;
|
|
450
|
+
return items.map((item, index) => {
|
|
451
|
+
// A key line sits at the item's key column (the first one after `- `).
|
|
452
|
+
const keyAt = (l) => {
|
|
453
|
+
if (l.blank || l.indent > keyColumn)
|
|
454
|
+
return undefined;
|
|
455
|
+
if (l.indent !== keyColumn && l !== item[0])
|
|
456
|
+
return undefined;
|
|
457
|
+
return BLOCK_KEY_RE.exec(text.slice(l.start + keyColumn, l.end));
|
|
458
|
+
};
|
|
459
|
+
let type;
|
|
460
|
+
let dataLine = -1;
|
|
461
|
+
for (const [n, l] of item.entries()) {
|
|
462
|
+
const m = keyAt(l);
|
|
463
|
+
if (m?.[1] === "type" && m[2] !== undefined)
|
|
464
|
+
type = scalarText(m[2]);
|
|
465
|
+
if (m?.[1] === "data")
|
|
466
|
+
dataLine = n;
|
|
467
|
+
}
|
|
468
|
+
if (type === undefined)
|
|
469
|
+
cannotLocate(file, index, "its type");
|
|
470
|
+
if (dataLine < 0)
|
|
471
|
+
cannotLocate(file, index, "its data");
|
|
472
|
+
const data = item[dataLine];
|
|
473
|
+
if (keyAt(data)?.[2] !== undefined)
|
|
474
|
+
cannotLocate(file, index, "its data mapping");
|
|
475
|
+
const entryColumn = keyColumn + 2;
|
|
476
|
+
const entries = [];
|
|
477
|
+
// An entry's value ends at its line's scalar (before any comment), or at
|
|
478
|
+
// the last deeper line when the value continues below it.
|
|
479
|
+
let open;
|
|
480
|
+
let lastDeep = -1;
|
|
481
|
+
const close = () => {
|
|
482
|
+
if (open !== undefined) {
|
|
483
|
+
const { key, valueStart, lineEnd, inlineEnd } = open;
|
|
484
|
+
entries.push({ key, valueStart, valueEnd: lastDeep > lineEnd ? lastDeep : inlineEnd });
|
|
485
|
+
}
|
|
486
|
+
open = undefined;
|
|
487
|
+
};
|
|
488
|
+
for (const l of item.slice(dataLine + 1)) {
|
|
489
|
+
if (l.blank)
|
|
490
|
+
continue;
|
|
491
|
+
if (l.indent <= keyColumn)
|
|
492
|
+
break;
|
|
493
|
+
if (l.indent === entryColumn) {
|
|
494
|
+
const m = BLOCK_KEY_RE.exec(text.slice(l.start + entryColumn, l.end));
|
|
495
|
+
if (!m)
|
|
496
|
+
cannotLocate(file, index, "a data entry");
|
|
497
|
+
close();
|
|
498
|
+
const key = m[1];
|
|
499
|
+
const valueStart = l.start + entryColumn + key.length + 1;
|
|
500
|
+
open = {
|
|
501
|
+
key,
|
|
502
|
+
valueStart,
|
|
503
|
+
lineEnd: l.end,
|
|
504
|
+
inlineEnd: endOfInlineScalar(text, valueStart, l.end),
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
else if (l.indent < entryColumn || open === undefined) {
|
|
508
|
+
cannotLocate(file, index, "a data entry");
|
|
509
|
+
}
|
|
510
|
+
lastDeep = l.end;
|
|
511
|
+
}
|
|
512
|
+
close();
|
|
513
|
+
return {
|
|
514
|
+
type,
|
|
515
|
+
form: "block",
|
|
516
|
+
dataOpen: data.end,
|
|
517
|
+
dataClose: data.end,
|
|
518
|
+
entryColumn,
|
|
519
|
+
entries,
|
|
520
|
+
};
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Locate a page's `sections[]` in its frontmatter — the prettier-formatted
|
|
525
|
+
* flow form (`sections:\n [ { "id": …, "type": …, "data": { … } }, … ]`)
|
|
526
|
+
* or the block form (` - id: …\n type: …\n data:\n key: value`).
|
|
527
|
+
* Undefined when the page has no frontmatter or no column-0 `sections:` key.
|
|
528
|
+
* Any other shape throws `TemplateError` naming the element (D5).
|
|
529
|
+
*/
|
|
530
|
+
export function locatePageSections(text, file) {
|
|
531
|
+
const open = /^---\r?\n/.exec(text);
|
|
532
|
+
if (!open)
|
|
533
|
+
return undefined;
|
|
534
|
+
const closeMatch = /^---[ \t]*\r?$/m.exec(text.slice(open[0].length));
|
|
535
|
+
if (!closeMatch)
|
|
536
|
+
return undefined;
|
|
537
|
+
const fmStart = open[0].length;
|
|
538
|
+
const fmEnd = fmStart + closeMatch.index;
|
|
539
|
+
const key = /^sections:[ \t]*/m.exec(text.slice(fmStart, fmEnd));
|
|
540
|
+
if (!key)
|
|
541
|
+
return undefined;
|
|
542
|
+
const afterKey = fmStart + key.index + key[0].length;
|
|
543
|
+
// The value ends at the next column-0 key, or the frontmatter's end.
|
|
544
|
+
// A column-0 `- ` line is an indentless sequence item, not a key.
|
|
545
|
+
const nextKey = /\n(?=[^\s#-])/.exec(text.slice(afterKey, fmEnd));
|
|
546
|
+
const valueEnd = nextKey ? afterKey + nextKey.index : fmEnd;
|
|
547
|
+
const first = skipSpace(text, afterKey, valueEnd);
|
|
548
|
+
if (text[first] === "[")
|
|
549
|
+
return locateFlow(text, first, valueEnd, file);
|
|
550
|
+
const lineEnd = text.indexOf("\n", afterKey);
|
|
551
|
+
if (lineEnd >= 0 && text.slice(afterKey, lineEnd).trim() === "") {
|
|
552
|
+
const lines = splitLines(text, lineEnd + 1, valueEnd);
|
|
553
|
+
const firstLine = lines.find((l) => !l.blank);
|
|
554
|
+
if (firstLine === undefined || text.startsWith("- ", firstLine.start + firstLine.indent)) {
|
|
555
|
+
return locateBlock(text, lines, file);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return cannotLocate(file, undefined, "a flow or block sequence");
|
|
559
|
+
}
|
|
560
|
+
/** The frontmatter's column-0 `type:` value, or undefined when it has none. */
|
|
561
|
+
function pageTypeOf(text, file) {
|
|
562
|
+
const fm = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*\r?$/m.exec(text);
|
|
563
|
+
if (!fm || fm.index !== 0)
|
|
564
|
+
return undefined;
|
|
565
|
+
const m = /^type:[ \t]*(.+?)[ \t]*$/m.exec(fm[1]);
|
|
566
|
+
if (!m)
|
|
567
|
+
return undefined;
|
|
568
|
+
const type = scalarText(m[1]);
|
|
569
|
+
if (type === undefined) {
|
|
570
|
+
throw new TemplateError(`${file}: type — cannot read the page type for a design preset`);
|
|
571
|
+
}
|
|
572
|
+
return type;
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Set one look field on section `index` of a page, splicing only its value
|
|
576
|
+
* span (D5): an existing value is replaced; an absent field is inserted as
|
|
577
|
+
* `data`'s first entry. Throws `TemplateError` when the page cannot be located.
|
|
578
|
+
*/
|
|
579
|
+
export function setSectionLookField(text, file, index, field, value) {
|
|
580
|
+
const section = locatePageSections(text, file)?.[index];
|
|
581
|
+
if (section === undefined)
|
|
582
|
+
cannotLocate(file, index, "the section");
|
|
583
|
+
const rendered = JSON.stringify(value);
|
|
584
|
+
const existing = section.entries.find((e) => e.key === field);
|
|
585
|
+
if (section.form === "flow") {
|
|
586
|
+
if (existing !== undefined) {
|
|
587
|
+
return text.slice(0, existing.valueStart) + rendered + text.slice(existing.valueEnd);
|
|
588
|
+
}
|
|
589
|
+
const entry = `${JSON.stringify(field)}: ${rendered}`;
|
|
590
|
+
const firstEntry = section.entries[0];
|
|
591
|
+
if (firstEntry === undefined) {
|
|
592
|
+
return `${text.slice(0, section.dataOpen)}{ ${entry} }${text.slice(section.dataClose + 1)}`;
|
|
593
|
+
}
|
|
594
|
+
// The first entry's key starts where the whitespace after `{` ends.
|
|
595
|
+
const keyStart = skipSpace(text, section.dataOpen + 1, firstEntry.valueStart);
|
|
596
|
+
const between = text.slice(section.dataOpen + 1, keyStart);
|
|
597
|
+
const nl = between.lastIndexOf("\n");
|
|
598
|
+
const separator = nl < 0 ? " " : between.slice(nl);
|
|
599
|
+
return `${text.slice(0, keyStart)}${entry},${separator}${text.slice(keyStart)}`;
|
|
600
|
+
}
|
|
601
|
+
if (existing !== undefined) {
|
|
602
|
+
return `${text.slice(0, existing.valueStart)} ${rendered}${text.slice(existing.valueEnd)}`;
|
|
603
|
+
}
|
|
604
|
+
const line = `\n${" ".repeat(section.entryColumn)}${field}: ${rendered}`;
|
|
605
|
+
return text.slice(0, section.dataOpen) + line + text.slice(section.dataOpen);
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Write a validated preset block's look fields onto the sections a page
|
|
609
|
+
* already places, for the pages whose `type:` is in `opts.pageTypes` (the
|
|
610
|
+
* roster's placed set) and has a preset. Never adds, removes, reorders,
|
|
611
|
+
* retypes or re-ids a section; never changes a byte outside the value it
|
|
612
|
+
* sets. Returns only the pages whose text changed. Pure; trusts its input to
|
|
613
|
+
* have passed `validateDesignPresets`.
|
|
614
|
+
*/
|
|
615
|
+
export function applyDesignPresets(pages, sections, opts) {
|
|
616
|
+
const changed = [];
|
|
617
|
+
const applied = [];
|
|
618
|
+
for (const page of pages) {
|
|
619
|
+
const pageType = pageTypeOf(page.text, page.path);
|
|
620
|
+
if (pageType === undefined || !opts.pageTypes.has(pageType))
|
|
621
|
+
continue;
|
|
622
|
+
if (!hasOwn(sections, pageType))
|
|
623
|
+
continue;
|
|
624
|
+
const presets = sections[pageType];
|
|
625
|
+
const located = locatePageSections(page.text, page.path);
|
|
626
|
+
if (located === undefined)
|
|
627
|
+
continue;
|
|
628
|
+
let text = page.text;
|
|
629
|
+
located.forEach((section, index) => {
|
|
630
|
+
const preset = hasOwn(presets, section.type) ? presets[section.type] : undefined;
|
|
631
|
+
if (!isPlainObject(preset))
|
|
632
|
+
return;
|
|
633
|
+
for (const [field, value] of Object.entries(preset)) {
|
|
634
|
+
text = setSectionLookField(text, page.path, index, field, value);
|
|
635
|
+
applied.push({ path: page.path, pageType, index, sectionType: section.type, field });
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
if (text !== page.text)
|
|
639
|
+
changed.push({ path: page.path, text });
|
|
640
|
+
}
|
|
641
|
+
return { pages: changed, applied };
|
|
642
|
+
}
|