@nextcommerce/campaigns-os 1.48.0 → 1.52.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/CHANGELOG.md +539 -0
- package/agents/claude/CLAUDE.md +6 -5
- package/agents/codex/AGENTS.md +6 -5
- package/agents/copilot/copilot-instructions.md +3 -3
- package/agents/cursor/campaigns-os.mdc +3 -3
- package/campaign-spec/dist/rules/campaign-metadata.d.ts +5 -1
- package/campaign-spec/dist/rules/campaign-metadata.js +9 -2
- package/campaign-spec/dist/rules/design-source-shape.js +13 -3
- package/campaign-spec/dist/rules/sdk-version.js +2 -1
- package/compatibility.json +1 -1
- package/contracts/commerce-surface-catalog.json +26 -46
- package/contracts/effects.v1.json +254 -2
- package/contracts/release-ledger.json +1239 -0
- package/contracts/supported-surface.json +4 -4
- package/contracts/template-brand-contract.shared-commerce.v0.json +2 -2
- package/contracts/template-slot-manifest.shared-content-core.v0.json +24 -0
- package/docs/brand-theme-bridge.md +12 -6
- package/docs/build-packet.md +101 -12
- package/docs/campaign-build-brief.md +25 -1
- package/docs/effects.md +6 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +10 -0
- package/docs/qa-and-test-orders.md +66 -11
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/package.json +1 -1
- package/schemas/campaign-runtime-build-packet.v0.schema.json +4 -0
- package/schemas/campaigns-os-qa-verdict.v0.schema.json +8 -3
- package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +4 -4
- package/skills/next-campaigns-os/SKILL.md +4 -4
- package/skills/next-campaigns-os/references/session-intake.md +7 -3
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +5 -4
- package/skills/next-campaigns-qa/SKILL.md +6 -5
- package/skills.json +10 -10
- package/src/adapter-decision-contract.mjs +1 -1
- package/src/brand-theme.mjs +25 -2
- package/src/build-brief.mjs +68 -21
- package/src/built-site-scope.mjs +39 -6
- package/src/built-smoke-qc.mjs +1117 -0
- package/src/campaign-identity.mjs +36 -2
- package/src/cart-placeholders.mjs +730 -0
- package/src/cli.mjs +320 -42
- package/src/commercial-journey.mjs +65 -4
- package/src/commercial-parity.mjs +6 -1
- package/src/doctor/checks.mjs +291 -24
- package/src/doctor/inspect.mjs +53 -2
- package/src/doctor/next-step.mjs +1 -1
- package/src/install-mode.mjs +0 -8
- package/src/invocation.mjs +5 -2
- package/src/local-preview-policy.mjs +1 -1
- package/src/local-proof.mjs +4 -1
- package/src/polish-browser.mjs +218 -1
- package/src/polish-capture.mjs +1 -1
- package/src/polish-media-weight.mjs +492 -0
- package/src/polish-node.mjs +96 -4
- package/src/progress-node.mjs +5 -1
- package/src/qa-binding-evidence.mjs +21 -0
- package/src/qa-browser.mjs +338 -97
- package/src/qa-content-params.mjs +889 -0
- package/src/qa-node.mjs +114 -14
- package/src/qa-order-bump.mjs +22 -1
- package/src/qa-policy-links.mjs +1019 -0
- package/src/qa-tracking-params.mjs +1389 -0
- package/src/qa-url-privacy.mjs +168 -0
- package/src/qc-accept.mjs +446 -0
- package/src/qc-check-registry.mjs +83 -0
- package/src/qc-results.mjs +1049 -0
- package/src/sdk-attribute-index.mjs +71 -0
- package/src/sdk-markup.mjs +2 -2
- package/src/sdk-storage-compatibility.mjs +63 -3
- package/src/source-prep.mjs +37 -7
- package/src/stage-record.mjs +356 -36
- package/src/theme-gate.mjs +3 -3
package/src/build-brief.mjs
CHANGED
|
@@ -12,11 +12,15 @@ export const BUILD_BRIEF_CANDIDATE_FILENAMES = Object.freeze([
|
|
|
12
12
|
"campaign-build-brief.json",
|
|
13
13
|
]);
|
|
14
14
|
|
|
15
|
+
// answer_fields: the brief fields whose values close the question, as
|
|
16
|
+
// evaluateCampaignBuildBrief reads them. The guided-questions warning names
|
|
17
|
+
// them so an answer given in conversation can be written where it counts.
|
|
15
18
|
const REQUIRED_HIGH_IMPACT_FIELDS = Object.freeze([
|
|
16
19
|
{
|
|
17
20
|
id: "page_design_authority",
|
|
18
21
|
priority: 1,
|
|
19
22
|
field: "design_authority",
|
|
23
|
+
answer_fields: ["design_authority.<page_id>.source"],
|
|
20
24
|
question: "Which source controls each campaign page: the provided design export, the selected template, or a template adapted to another page?",
|
|
21
25
|
reason: "Page-by-page authority prevents checkout, OTO, and receipt pages from drifting into unrelated starter-template composition.",
|
|
22
26
|
},
|
|
@@ -24,6 +28,7 @@ const REQUIRED_HIGH_IMPACT_FIELDS = Object.freeze([
|
|
|
24
28
|
id: "brand_palette_cta",
|
|
25
29
|
priority: 2,
|
|
26
30
|
field: "brand",
|
|
31
|
+
answer_fields: ["brand.commerce_palette_source", "brand.cta_style"],
|
|
27
32
|
question: "Which palette and CTA style should commerce pages use?",
|
|
28
33
|
reason: "Commerce pages need a business-approved brand layer instead of silent template defaults.",
|
|
29
34
|
},
|
|
@@ -31,6 +36,7 @@ const REQUIRED_HIGH_IMPACT_FIELDS = Object.freeze([
|
|
|
31
36
|
id: "variant_media_rules",
|
|
32
37
|
priority: 3,
|
|
33
38
|
field: "media",
|
|
39
|
+
answer_fields: ["media.sold_variants", "media.allow_other_variant_colors"],
|
|
34
40
|
question: "Which product variants or colors are actually sold, and may media show other variants?",
|
|
35
41
|
reason: "Variant ambiguity often creates wrong-color carousels and unavailable-product claims.",
|
|
36
42
|
},
|
|
@@ -38,6 +44,7 @@ const REQUIRED_HIGH_IMPACT_FIELDS = Object.freeze([
|
|
|
38
44
|
id: "bundle_pricing_presentation",
|
|
39
45
|
priority: 4,
|
|
40
46
|
field: "offer_presentation.bundle_cards",
|
|
47
|
+
answer_fields: ["offer_presentation.bundle_cards.primary_price"],
|
|
41
48
|
question: "How should bundle cards present pricing: simple unit price, savings-led, or full accounting?",
|
|
42
49
|
reason: "CampaignSpec owns prices; the brief owns which shopper-facing price story is appropriate.",
|
|
43
50
|
},
|
|
@@ -45,13 +52,16 @@ const REQUIRED_HIGH_IMPACT_FIELDS = Object.freeze([
|
|
|
45
52
|
id: "promo_urgency_copy",
|
|
46
53
|
priority: 5,
|
|
47
54
|
field: "promo_urgency",
|
|
48
|
-
|
|
49
|
-
|
|
55
|
+
answer_fields: ["promo_urgency.header_claim_source", "promo_urgency.timer_label"],
|
|
56
|
+
answer_hint: 'promo_urgency.header_claim_source "campaign_offers" and promo_urgency.timer_label the template timer\'s label to fill them, or both "none" to remove them',
|
|
57
|
+
question: "Should the starter template's own promo placeholders (demo countdown timers, promo banners, placeholder voucher codes, exit-pop offers) be filled from this campaign's promo codes and offers, or removed?",
|
|
58
|
+
reason: "Template promo placeholders must not go live with demo values. The source design's own promo, proof and urgency copy is the merchant's content: it is built as designed and is not part of this question.",
|
|
50
59
|
},
|
|
51
60
|
{
|
|
52
61
|
id: "payment_methods_trust",
|
|
53
62
|
priority: 6,
|
|
54
63
|
field: "commerce_surfaces.payment_methods_allowed",
|
|
64
|
+
answer_fields: ["commerce_surfaces.payment_methods_allowed"],
|
|
55
65
|
question: "Which payment methods and trust badges may appear?",
|
|
56
66
|
reason: "Templates often carry demo wallets or badges that must not survive without approval.",
|
|
57
67
|
},
|
|
@@ -59,6 +69,7 @@ const REQUIRED_HIGH_IMPACT_FIELDS = Object.freeze([
|
|
|
59
69
|
id: "canonical_display_names",
|
|
60
70
|
priority: 7,
|
|
61
71
|
field: "canonical_display.product_name_source",
|
|
72
|
+
answer_fields: ["canonical_display.product_name_source"],
|
|
62
73
|
question: "Should CampaignSpec display names win, or may runtime/catalog names override them?",
|
|
63
74
|
reason: "Name drift across source, spec, and runtime data is hard to spot after assembly.",
|
|
64
75
|
},
|
|
@@ -66,6 +77,7 @@ const REQUIRED_HIGH_IMPACT_FIELDS = Object.freeze([
|
|
|
66
77
|
id: "regulated_claims",
|
|
67
78
|
priority: 8,
|
|
68
79
|
field: "campaign_intent.compliance",
|
|
80
|
+
answer_fields: ["campaign_intent.compliance.approved_benefit_language", "campaign_intent.compliance.forbidden_claims"],
|
|
69
81
|
question: "Are there regulated claims, forbidden phrases, or approved benefit statements the build must follow?",
|
|
70
82
|
reason: "Health, financial, and other regulated offers need explicit copy boundaries.",
|
|
71
83
|
conditional: "regulated",
|
|
@@ -227,7 +239,18 @@ export function createCampaignBuildBriefArtifact({
|
|
|
227
239
|
};
|
|
228
240
|
}
|
|
229
241
|
|
|
230
|
-
|
|
242
|
+
// "brand_palette_cta (brand.commerce_palette_source, brand.cta_style)": each
|
|
243
|
+
// open question with the brief fields that close it.
|
|
244
|
+
function describeOpenQuestions(questions) {
|
|
245
|
+
return questions.map((question) => {
|
|
246
|
+
const entry = REQUIRED_HIGH_IMPACT_FIELDS.find((candidate) => candidate.id === question?.id);
|
|
247
|
+
if (isNonEmptyString(entry?.answer_hint)) return `${question?.id} (${entry.answer_hint})`;
|
|
248
|
+
const fields = entry?.answer_fields || (isNonEmptyString(question?.field) ? [question.field] : []);
|
|
249
|
+
return fields.length ? `${question?.id} (${fields.join(", ")})` : String(question?.id);
|
|
250
|
+
}).join(", ");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function validateCampaignBuildBriefArtifact(brief, { spec = null, normalizedPath = BUILD_BRIEF_NORMALIZED_REL_PATH } = {}) {
|
|
231
254
|
const errors = [];
|
|
232
255
|
const warnings = [];
|
|
233
256
|
const ready = [];
|
|
@@ -255,14 +278,20 @@ export function validateCampaignBuildBriefArtifact(brief, { spec = null } = {})
|
|
|
255
278
|
if (questions.length) {
|
|
256
279
|
errors.push({
|
|
257
280
|
code: "build_brief.questions_unanswered",
|
|
258
|
-
message: `Prepared Campaign Build Brief has ${questions.length} unresolved business question(s): ${questions.
|
|
281
|
+
message: `Prepared Campaign Build Brief has ${questions.length} unresolved business question(s): ${describeOpenQuestions(questions)}. Set those fields in the brief file and re-run start or prepare-build.`,
|
|
259
282
|
});
|
|
260
283
|
}
|
|
261
284
|
} else {
|
|
262
285
|
if (questions.length) {
|
|
286
|
+
// An answer given in conversation is not recorded until it is in a
|
|
287
|
+
// brief file that start/prepare-build reads: the guided draft is
|
|
288
|
+
// regenerated on every run, and a brief file replaces it whole.
|
|
263
289
|
warnings.push({
|
|
264
290
|
code: "build_brief.guided_questions",
|
|
265
|
-
message: `Generated Campaign Build Brief draft has ${questions.length} high-impact business question(s) to confirm: ${questions
|
|
291
|
+
message: `Generated Campaign Build Brief draft has ${questions.length} high-impact business question(s) to confirm: ${describeOpenQuestions(questions)}. `
|
|
292
|
+
+ `An answer counts only once it is in a brief file: copy ${normalizedPath} to campaign-build-brief.json in the target repo, set those fields, and re-run start or prepare-build with the same arguments `
|
|
293
|
+
+ "(the file is found there automatically, or pass --brief <file>). The file replaces the draft, so start from the copy to keep the fields the draft already filled. "
|
|
294
|
+
+ "Once a stage has recorded evidence, that re-run needs --force, which clears the evidence.",
|
|
266
295
|
});
|
|
267
296
|
}
|
|
268
297
|
for (const gate of blockerGates) {
|
|
@@ -358,6 +387,9 @@ function draftCampaignBuildBrief({ spec, activePages, pageMappings, templateFami
|
|
|
358
387
|
const paymentMethods = collectSpecPaymentMethods(spec);
|
|
359
388
|
const hasExitPop = activePages?.some((page) => page?.type === "checkout" && page?.exit_intent?.enabled === true) === true;
|
|
360
389
|
const hasOrderBump = hasOrderBumpSignals(spec);
|
|
390
|
+
// With no CampaignSpec surface to fill them, the template's promo
|
|
391
|
+
// placeholders are removed: "none" for both.
|
|
392
|
+
const fillsTemplatePromo = templatePromoSurfaces({ spec, activePages }).length > 0;
|
|
361
393
|
|
|
362
394
|
return {
|
|
363
395
|
schema_version: BUILD_BRIEF_SCHEMA,
|
|
@@ -394,8 +426,8 @@ function draftCampaignBuildBrief({ spec, activePages, pageMappings, templateFami
|
|
|
394
426
|
},
|
|
395
427
|
},
|
|
396
428
|
promo_urgency: {
|
|
397
|
-
header_claim_source:
|
|
398
|
-
timer_label:
|
|
429
|
+
header_claim_source: fillsTemplatePromo ? "campaign_offers" : "none",
|
|
430
|
+
timer_label: fillsTemplatePromo ? null : "none",
|
|
399
431
|
show_promo_code_in_timer: false,
|
|
400
432
|
exit_pop: {
|
|
401
433
|
enabled: hasExitPop,
|
|
@@ -427,7 +459,7 @@ function draftCampaignBuildBrief({ spec, activePages, pageMappings, templateFami
|
|
|
427
459
|
brand: "low",
|
|
428
460
|
media: variantSignals.length === 1 ? "medium" : "low",
|
|
429
461
|
offer_presentation: "low",
|
|
430
|
-
promo_urgency:
|
|
462
|
+
promo_urgency: fillsTemplatePromo ? "low" : "medium",
|
|
431
463
|
commerce_surfaces: paymentMethods.length ? "medium" : "low",
|
|
432
464
|
template_family: templateFamily || null,
|
|
433
465
|
},
|
|
@@ -466,10 +498,11 @@ function evaluateCampaignBuildBrief(brief, { spec, activePages, pageMappings, so
|
|
|
466
498
|
options: ["discounted unit price", "savings-led", "full accounting"],
|
|
467
499
|
});
|
|
468
500
|
}
|
|
469
|
-
|
|
501
|
+
const promoSurfaces = templatePromoSurfaces({ spec, activePages });
|
|
502
|
+
if (promoSurfaces.length && (!isNonEmptyString(brief.promo_urgency?.header_claim_source) || !isNonEmptyString(brief.promo_urgency?.timer_label))) {
|
|
470
503
|
addQuestion(questions, "promo_urgency_copy", {
|
|
471
|
-
detail:
|
|
472
|
-
options: ["
|
|
504
|
+
detail: `CampaignSpec maps ${promoSurfaces.join(" and ")}, which would fill the template's promo placeholders. Missing promo_urgency.header_claim_source ("campaign_offers" or "none") or promo_urgency.timer_label (the template timer's label, or "none").`,
|
|
505
|
+
options: ["fill from the campaign's promo codes and offers", "remove the template's promo placeholders"],
|
|
473
506
|
});
|
|
474
507
|
}
|
|
475
508
|
if (!normalizePaymentList(brief.commerce_surfaces?.payment_methods_allowed).length) {
|
|
@@ -638,16 +671,30 @@ export function collectSpecPaymentMethods(spec) {
|
|
|
638
671
|
return [...methods].sort();
|
|
639
672
|
}
|
|
640
673
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
674
|
+
// The CampaignSpec surfaces that fill a starter template's own promo
|
|
675
|
+
// placeholders: the template's promo banner and countdown timer read
|
|
676
|
+
// funnels[].promo_codes, and its exit-pop offer reads a page's exit_intent or
|
|
677
|
+
// promo_code_input. The campaign's offer catalog and discount pricing are not
|
|
678
|
+
// among them (bundle_pricing_presentation covers those), and neither is any
|
|
679
|
+
// promo, proof or urgency copy of the source design: that is the merchant's
|
|
680
|
+
// content, built as designed.
|
|
681
|
+
function templatePromoSurfaces({ spec = null, activePages = [] } = {}) {
|
|
682
|
+
const surfaces = [];
|
|
683
|
+
const funnels = Array.isArray(spec?.funnels) ? spec.funnels : [];
|
|
684
|
+
if (funnels.some((funnel) => Array.isArray(funnel?.promo_codes) && funnel.promo_codes.length > 0)) {
|
|
685
|
+
surfaces.push("promo codes (funnels[].promo_codes)");
|
|
686
|
+
}
|
|
687
|
+
const pages = Array.isArray(activePages) ? activePages : [];
|
|
688
|
+
// Checkout-only and enabled: true, the rule hasExitPop, doctor's exit-pop
|
|
689
|
+
// contract and QA's coupon orders use for these surfaces.
|
|
690
|
+
const checkoutSurface = (page, key) => page?.type === "checkout" && page?.[key]?.enabled === true;
|
|
691
|
+
if (pages.some((page) => checkoutSurface(page, "exit_intent"))) {
|
|
692
|
+
surfaces.push("an exit-intent offer (exit_intent)");
|
|
693
|
+
}
|
|
694
|
+
if (pages.some((page) => checkoutSurface(page, "promo_code_input"))) {
|
|
695
|
+
surfaces.push("a promo-code input (promo_code_input)");
|
|
696
|
+
}
|
|
697
|
+
return surfaces;
|
|
651
698
|
}
|
|
652
699
|
|
|
653
700
|
function hasOrderBumpSignals(value, keyPath = []) {
|
package/src/built-site-scope.mjs
CHANGED
|
@@ -16,6 +16,8 @@ import { createHash } from "node:crypto";
|
|
|
16
16
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
17
17
|
import { basename, join, relative, sep } from "node:path";
|
|
18
18
|
|
|
19
|
+
import { isFileReadFailure } from "./cart-placeholders.mjs";
|
|
20
|
+
|
|
19
21
|
const HTML_EXT = ".html";
|
|
20
22
|
|
|
21
23
|
// The route tokens inferPageType reads for the funnel roles, exported so a
|
|
@@ -56,7 +58,26 @@ export function inferPageType(routeOrName) {
|
|
|
56
58
|
return "page";
|
|
57
59
|
}
|
|
58
60
|
|
|
59
|
-
|
|
61
|
+
// Whether a symbolic link may stand for a built page: its name ends in .html,
|
|
62
|
+
// or its target is a directory, or its target cannot be inspected (missing,
|
|
63
|
+
// EACCES, ELOOP, any file-system error). A link to a regular file (or any
|
|
64
|
+
// other non-directory) not named .html is no page. Only file-system errors
|
|
65
|
+
// are caught; anything else throws.
|
|
66
|
+
function linkMayBePage(full, name) {
|
|
67
|
+
if (name.toLowerCase().endsWith(HTML_EXT)) return true;
|
|
68
|
+
try {
|
|
69
|
+
return statSync(full).isDirectory();
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (!isFileReadFailure(error)) throw error;
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// `links`, when given, collects every symbolic link that may stand for a page
|
|
77
|
+
// (see linkMayBePage). They are never pages themselves (build output holds no
|
|
78
|
+
// links, and a link is not followed), but a caller that must account for every
|
|
79
|
+
// built page can name them.
|
|
80
|
+
function listHtmlFiles(root, links = null) {
|
|
60
81
|
const files = [];
|
|
61
82
|
if (!existsSync(root) || !statSync(root).isDirectory()) return files;
|
|
62
83
|
const walk = (dir) => {
|
|
@@ -67,9 +88,11 @@ function listHtmlFiles(root) {
|
|
|
67
88
|
const full = join(dir, entry.name);
|
|
68
89
|
if (entry.isDirectory()) walk(full);
|
|
69
90
|
else if (entry.isFile() && entry.name.toLowerCase().endsWith(HTML_EXT)) files.push(full);
|
|
91
|
+
else if (links && entry.isSymbolicLink() && linkMayBePage(full, entry.name)) links.push(full);
|
|
70
92
|
}
|
|
71
93
|
};
|
|
72
94
|
walk(root);
|
|
95
|
+
links?.sort();
|
|
73
96
|
return files.sort();
|
|
74
97
|
}
|
|
75
98
|
|
|
@@ -101,7 +124,13 @@ function resolveSiteRoot(targetRepo) {
|
|
|
101
124
|
*
|
|
102
125
|
* @param {string} targetRepo Absolute path to the page-kit target repo (or a
|
|
103
126
|
* `_site/` directory, or a campaign directory).
|
|
104
|
-
* @param {{ slug?: string|null }} [options]
|
|
127
|
+
* @param {{ slug?: string|null, includeLinkedPages?: boolean }} [options]
|
|
128
|
+
* `includeLinkedPages` adds `linked_pages`: every symbolic link under the
|
|
129
|
+
* campaign directory that may stand for a page (named .html, or its target
|
|
130
|
+
* a directory, or its target not inspectable; each one entry, its
|
|
131
|
+
* `built_path` the link itself; skipped as pages, not followed), in the
|
|
132
|
+
* same shape as `pages`. A link to a regular file not named .html is
|
|
133
|
+
* dropped. Off by default; without it the result is unchanged.
|
|
105
134
|
* @returns {{
|
|
106
135
|
* ok: boolean,
|
|
107
136
|
* error?: string,
|
|
@@ -114,7 +143,7 @@ function resolveSiteRoot(targetRepo) {
|
|
|
114
143
|
* slug_candidates?: string[],
|
|
115
144
|
* }}
|
|
116
145
|
*/
|
|
117
|
-
export function resolveBuiltSiteScope(targetRepo, { slug = null } = {}) {
|
|
146
|
+
export function resolveBuiltSiteScope(targetRepo, { slug = null, includeLinkedPages = false } = {}) {
|
|
118
147
|
const base = { ok: false, target_repo: targetRepo, site_root: null, slug: "", campaign_dir: null, pages: [], html_count: 0 };
|
|
119
148
|
if (!targetRepo || !existsSync(targetRepo) || !statSync(targetRepo).isDirectory()) {
|
|
120
149
|
return { ...base, error: `Built campaign directory does not exist: ${targetRepo}` };
|
|
@@ -149,7 +178,7 @@ export function resolveBuiltSiteScope(targetRepo, { slug = null } = {}) {
|
|
|
149
178
|
return { ...base, site_root: siteRoot, slug: resolvedSlug, error: `Campaign directory does not exist: ${campaignDir}` };
|
|
150
179
|
}
|
|
151
180
|
|
|
152
|
-
const
|
|
181
|
+
const toPage = (file) => {
|
|
153
182
|
const route = routeForFile(campaignDir, file);
|
|
154
183
|
return {
|
|
155
184
|
page_id: pageIdForRoute(route),
|
|
@@ -157,10 +186,13 @@ export function resolveBuiltSiteScope(targetRepo, { slug = null } = {}) {
|
|
|
157
186
|
route,
|
|
158
187
|
built_path: file,
|
|
159
188
|
};
|
|
160
|
-
}
|
|
189
|
+
};
|
|
190
|
+
const links = includeLinkedPages ? [] : null;
|
|
191
|
+
const pages = listHtmlFiles(campaignDir, links).map(toPage);
|
|
192
|
+
const linked = links ? { linked_pages: links.map(toPage) } : {};
|
|
161
193
|
|
|
162
194
|
if (!pages.length) {
|
|
163
|
-
return { ...base, site_root: siteRoot, slug: resolvedSlug, campaign_dir: campaignDir, error: `No built HTML pages found under ${campaignDir}.` };
|
|
195
|
+
return { ...base, site_root: siteRoot, slug: resolvedSlug, campaign_dir: campaignDir, ...linked, error: `No built HTML pages found under ${campaignDir}.` };
|
|
164
196
|
}
|
|
165
197
|
|
|
166
198
|
return {
|
|
@@ -171,6 +203,7 @@ export function resolveBuiltSiteScope(targetRepo, { slug = null } = {}) {
|
|
|
171
203
|
campaign_dir: campaignDir,
|
|
172
204
|
pages,
|
|
173
205
|
html_count: pages.length,
|
|
206
|
+
...linked,
|
|
174
207
|
};
|
|
175
208
|
}
|
|
176
209
|
|