@koda-sl/baker-cli 0.151.0 → 0.153.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 CHANGED
@@ -858,6 +858,7 @@ audit --format md # deliverable-ready markdown table
858
858
 
859
859
  campaign-groups create|update|pause|resume|duplicate # staged writes (see below)
860
860
  campaigns create|update|pause|resume|archive|duplicate
861
+ campaigns url-params <ad-set-id> # UTMs for every ad in the ad set
861
862
  creatives create|update|pause|resume|duplicate
862
863
  audiences create|upload
863
864
  conversions create|update
@@ -880,6 +881,12 @@ baker ads linkedin campaigns create --name "ABM Tier-1" --group li_temp_x1 \
880
881
  baker ads linkedin creatives create --campaign li_temp_x2 --format image \
881
882
  --image-id <bakerImageId> --headline "Book a demo" --landing-url https://example.com/demo --cta REQUEST_DEMO
882
883
 
884
+ # UTMs belong on the ad set — LinkedIn appends them to every ad in it, live ones included
885
+ baker ads linkedin campaigns url-params 123456 \
886
+ --param "utm_source=linkedin&utm_medium=paid-social" \
887
+ --dynamic utm_campaign=CAMPAIGN_NAME --dynamic utm_content=CREATIVE_ID
888
+ baker ads linkedin campaigns url-params 123456 --clear # remove them all
889
+
883
890
  # Fix the classic audit findings
884
891
  baker ads linkedin campaigns update 123456 --audience-expansion off --lan off
885
892
  baker ads linkedin campaigns update 123456 --bid 7.50 --daily-budget 100 --currency USD
@@ -4661,10 +4668,12 @@ Without `--slug` the command behaves as before (one creative record per publishe
4661
4668
  Deterministic design-quality critic for landing pages — an offline, no-LLM detector for the well-known "AI slop" tells (gradient text, overused fonts like Inter, side-tab colored borders, cream/parchment grounds, purple-on-heading palettes, buzzword copy, broken images, and more). It scores a landing's `.astro` source and returns findings tiered **block / warn / advisory**.
4662
4669
 
4663
4670
  ```bash
4664
- baker landing critique spring-offer # score src/pages/spring-offer/
4665
- baker landing critique spring-offer --full # also print advisory-tier findings
4671
+ baker landing critique spring-offer # score src/pages/spring-offer/
4672
+ baker landing critique spring-offer --full # also print advisory-tier findings
4673
+ baker landing critique spring-offer summer-offer claude # score three landings in one call
4666
4674
  ```
4667
4675
 
4676
+ - **Takes several slugs at once.** `data.landings` is always an array with one entry per slug (`{ slug, overall, counts, dimensions, findings }`). For a single slug those fields are also spread at the top of `data`, so existing one-landing readers are unaffected. A wrong slug anywhere in the batch fails the whole call before any landing is scored.
4668
4677
  - **Advisory by design — findings never fail it.** A finding is guidance, not a gate; the command errors (exit 2) only on a wrong slug (`NOT_FOUND` / `INVALID_SLUG`, with `error.fix.availableSlugs`).
4669
4678
  - **BRAND.md is the allowlist.** A font, hue, or ground that the client's `src/brand/BRAND.md` (mirrored into `src/styles/global.css` `@theme`, hex or oklch) commits is a decision, not a tell — matched values downgrade to advisory `brandDrift` so the critic never fights a real brand.
4670
4679
  - **Records a freshness snapshot** to `.cache/landing-critique/<slug>.json` (the source hash it scored). The publish quality gate reads it to enforce that every changed landing was critiqued and ships no block-tier tell. Re-run after edits so the snapshot stays fresh.
@@ -4731,6 +4740,8 @@ This CLI is designed for AI agent consumption. Key patterns:
4731
4740
  - **0.121.0**: `lead-forms create` drops the `privacyPolicyText` field — LinkedIn's versioned lead-form API has no privacy-policy-text slot, so it was silently discarded on publish. Use `legalDisclaimer` (shown under the form) or `consents[]` (disclosure checkboxes) instead. (Companion backend fix: staged lead-form questions were serialized in a shape LinkedIn dropped — they now publish correctly, and the staged preview lists each question.)
4732
4741
  - **0.119.0**: `draft amend`/`draft show` land on both `baker ads google` and `baker ads linkedin` — a generic JSON-merge-patch to update any staged op in place plus a full-payload receipt, replacing remove+recreate as the correction path. Google gains `assets update` and `asset-groups create|update` (Performance Max asset groups are now their own entity — `ads create --format performanceMaxAssetGroup` never worked and is gone); `ads create --format video` moves from a bare YouTube id to `--video-assets` refs (**breaking flag change** — stage the video as an asset first); `--format demandGen` gains `--image-assets`/`--square-image-assets`/`--logo-image-assets` and flag-building for headlines/descriptions. LinkedIn's `draft list` now renders a readable Campaign group ▸ Campaign ▸ Creative tree by default (`--json` for raw), `creatives update` gains `--campaign` (re-parent while staged), and `campaigns update` passes create-only fields (`--group`/`--type`/`--locale`/`--associated-entity`) through when amending a `li_temp_*` staged create instead of always stripping them.
4733
4742
  - **0.150.0**: `baker landing critique` gains an `agent-washing` rule in the `copy` family — an autonomy claim ("fully autonomous", "while you sleep", "no human intervention") with no signal anywhere on the page about who oversees the agent (approval, review, override, undo/rollback, audit log, escalation) scores a warn. Page-scope, so a hero may defer the trust story to a later section; generic privacy boilerplate does not clear it. `CRITIC_VERSION` bumps to `2`.
4743
+ - **0.152.0**: `baker ads linkedin campaigns url-params <ad-set-id>` stages an ad set's URL tracking parameters (LinkedIn's `adTrackingParameters`) — `--param key=value` for fixed values (repeatable or `&`-joined), `--dynamic key=PLACEHOLDER` for values LinkedIn fills in per ad (`CAMPAIGN_NAME`, `CREATIVE_ID`, …), `--clear` to remove them. LinkedIn appends these to the landing URL of every ad in the ad set, including ads already running, so this replaces stamping the same UTM onto each ad's `--landing-url` — which missed later ads and double-appended keys the ad set already set. Account-level parameters remain UI-only (LinkedIn exposes no API for them).
4744
+ - **0.153.0**: `baker landing critique` accepts several slugs in one call (`baker landing critique a b c`). `data.landings[]` always carries one entry per slug; a single slug additionally keeps the old top-level `data.slug`/`counts`/`dimensions`/`findings` shape, so existing readers are unaffected. An invalid or missing slug anywhere in the batch still fails the whole call up front. The scaffold's Stop-hook critique gate now emits one batched command instead of one line per stale landing.
4734
4745
 
4735
4746
  ## Publishing
4736
4747
 
package/dist/cli.js CHANGED
@@ -1297,6 +1297,15 @@ var LINKEDIN_LIMITS = {
1297
1297
  campaign: {
1298
1298
  nameMax: 255
1299
1299
  },
1300
+ /**
1301
+ * LinkedIn documents no ceiling on URL tracking parameters; these are our own
1302
+ * guards against a payload that would build an unusable landing URL.
1303
+ */
1304
+ trackingParams: {
1305
+ keyMax: 100,
1306
+ valueMax: 500,
1307
+ parametersMax: 20
1308
+ },
1300
1309
  creative: {
1301
1310
  commentarySoftMax: 600,
1302
1311
  // feed truncates with "…see more" beyond this
@@ -1435,6 +1444,17 @@ var CONVERSION_TYPES = [
1435
1444
  ];
1436
1445
  var CONVERSION_METHODS = ["INSIGHT_TAG", "CONVERSIONS_API"];
1437
1446
  var ATTRIBUTION_TYPES = ["LAST_TOUCH_BY_CAMPAIGN", "LAST_TOUCH_BY_CONVERSION"];
1447
+ var TRACKING_PARAM_DYNAMIC_VALUES = [
1448
+ "ACCOUNT_ID",
1449
+ "ACCOUNT_NAME",
1450
+ "CAMPAIGN_GROUP_ID",
1451
+ "CAMPAIGN_GROUP_NAME",
1452
+ "CAMPAIGN_ID",
1453
+ "CAMPAIGN_NAME",
1454
+ "CREATIVE_ID",
1455
+ "CREATIVE_NAME"
1456
+ ];
1457
+ var TRACKING_PARAM_KEY_REGEX = /^[A-Za-z0-9_.-]+$/;
1438
1458
  var CURRENCY_MINIMUMS = {
1439
1459
  USD: { dailyBudgetMin: 10, unitCostMin: 2 },
1440
1460
  EUR: { dailyBudgetMin: 10, unitCostMin: 2 },
@@ -1592,6 +1612,35 @@ var campaignUpdateSchema = z3.object({
1592
1612
  }
1593
1613
  validateCampaignBudgets(p, ctx, { requireBudget: false });
1594
1614
  });
1615
+ var trackingParamKeySchema = z3.string().min(1).max(LINKEDIN_LIMITS.trackingParams.keyMax).regex(TRACKING_PARAM_KEY_REGEX, "tracking parameter keys may only use letters, digits, _ . and -");
1616
+ var trackingParamsSetSchema = z3.object({
1617
+ dynamicValueParameters: z3.record(trackingParamKeySchema, z3.enum(TRACKING_PARAM_DYNAMIC_VALUES)).optional(),
1618
+ customValueParameters: z3.record(trackingParamKeySchema, z3.string().min(1).max(LINKEDIN_LIMITS.trackingParams.valueMax)).optional()
1619
+ }).superRefine((p, ctx) => {
1620
+ if (!p.dynamicValueParameters && !p.customValueParameters) {
1621
+ ctx.addIssue({
1622
+ code: "custom",
1623
+ message: "set at least one parameter \u2014 pass --param key=value or --dynamic key=PLACEHOLDER, or --clear (both maps as {}) to remove the ad set's tracking parameters"
1624
+ });
1625
+ }
1626
+ const both = Object.keys(p.dynamicValueParameters ?? {}).filter(
1627
+ (key) => p.customValueParameters?.[key] !== void 0
1628
+ );
1629
+ if (both.length > 0) {
1630
+ ctx.addIssue({
1631
+ code: "custom",
1632
+ path: ["customValueParameters"],
1633
+ message: `${both.join(", ")} is set as both a dynamic and a fixed parameter \u2014 LinkedIn appends each key once, so keep only one`
1634
+ });
1635
+ }
1636
+ const count = Object.keys(p.dynamicValueParameters ?? {}).length + Object.keys(p.customValueParameters ?? {}).length;
1637
+ if (count > LINKEDIN_LIMITS.trackingParams.parametersMax) {
1638
+ ctx.addIssue({
1639
+ code: "custom",
1640
+ message: `${count} parameters \u2014 keep it under ${LINKEDIN_LIMITS.trackingParams.parametersMax}`
1641
+ });
1642
+ }
1643
+ });
1595
1644
  var commentarySchema = z3.string().min(1).max(LINKEDIN_LIMITS.creative.commentaryHardMax);
1596
1645
  var headlineSchema = z3.string().min(1).max(LINKEDIN_LIMITS.creative.headlineMax);
1597
1646
  var httpsUrlSchema = z3.string().url().refine((u) => u.startsWith("https://"), "landing pages must be https");
@@ -1970,6 +2019,7 @@ var LINKEDIN_DRAFT_OP_KINDS = [
1970
2019
  "campaignGroup.update",
1971
2020
  "campaign.create",
1972
2021
  "campaign.update",
2022
+ "trackingParams.set",
1973
2023
  "creative.create",
1974
2024
  "creative.update",
1975
2025
  "audience.create",
@@ -1993,6 +2043,7 @@ var linkedinDraftOpInputSchema = z3.discriminatedUnion("kind", [
1993
2043
  updateOp("campaignGroup.update", campaignGroupUpdateSchema),
1994
2044
  createOp("campaign.create", campaignCreateSchema),
1995
2045
  updateOp("campaign.update", campaignUpdateSchema),
2046
+ updateOp("trackingParams.set", trackingParamsSetSchema),
1996
2047
  createOp("creative.create", creativeCreateSchema),
1997
2048
  updateOp("creative.update", creativeUpdateSchema),
1998
2049
  createOp("audience.create", audienceCreateSchema),
@@ -8490,6 +8541,26 @@ registerSchema({
8490
8541
  file: { type: "string", description: "JSON file with fields to change", required: false }
8491
8542
  }
8492
8543
  });
8544
+ registerSchema({
8545
+ command: "ads.linkedin.campaigns.url-params",
8546
+ description: "Start here for UTMs: stage the URL tracking parameters on an ad set. LinkedIn appends them to the link of EVERY ad in the ad set, including ads already running, so this is the level to set campaign-wide UTMs at \u2014 editing each ad's landing URL instead risks the same key landing twice. --param takes fixed values (repeatable or &-joined); --dynamic takes a value LinkedIn fills in per ad (ACCOUNT_ID, ACCOUNT_NAME, CAMPAIGN_GROUP_ID, CAMPAIGN_GROUP_NAME, CAMPAIGN_ID, CAMPAIGN_NAME, CREATIVE_ID, CREATIVE_NAME). Applies on chat publish; message and conversation ads are unaffected.",
8547
+ args: {
8548
+ id: { type: "positional", description: "Ad set (campaign) id or URN", required: true },
8549
+ ...writeAccountArgs,
8550
+ param: {
8551
+ type: "string",
8552
+ description: 'Fixed key=value, repeatable or &-joined \u2014 e.g. "utm_source=linkedin&utm_medium=paid-social"',
8553
+ required: false
8554
+ },
8555
+ dynamic: {
8556
+ type: "string",
8557
+ description: "key=PLACEHOLDER filled in per ad, repeatable \u2014 e.g. utm_campaign=CAMPAIGN_NAME",
8558
+ required: false
8559
+ },
8560
+ clear: { type: "boolean", description: "Remove every tracking parameter from this ad set", required: false },
8561
+ file: { type: "string", description: "JSON payload file; flags override file keys", required: false }
8562
+ }
8563
+ });
8493
8564
  var statusSugarArgs = {
8494
8565
  id: { type: "positional", description: "Entity id or URN", required: true },
8495
8566
  ...writeAccountArgs
@@ -10014,6 +10085,88 @@ Example: baker ads linkedin ${entity} ${name} 123456`
10014
10085
  }
10015
10086
  });
10016
10087
  }
10088
+ function trackingPairs(value, flag) {
10089
+ const raw = value === void 0 ? [] : Array.isArray(value) ? value : [value];
10090
+ return raw.flatMap(
10091
+ (entry) => String(entry).split("&").filter((pair) => pair.length > 0).map((pair) => {
10092
+ const at = pair.indexOf("=");
10093
+ if (at < 1 || at === pair.length - 1) {
10094
+ failWriteValidation2(`${flag} expects key=value pairs \u2014 got "${pair}"`);
10095
+ }
10096
+ return [pair.slice(0, at).trim(), pair.slice(at + 1).trim()];
10097
+ })
10098
+ );
10099
+ }
10100
+ function asDynamicValue(value) {
10101
+ const bare = value.replace(/^\{+|\}+$/g, "").toUpperCase();
10102
+ return TRACKING_PARAM_DYNAMIC_VALUES.includes(bare) ? bare : void 0;
10103
+ }
10104
+ function trackingParamsPayload(args) {
10105
+ if (args.clear) {
10106
+ return { dynamicValueParameters: {}, customValueParameters: {} };
10107
+ }
10108
+ const customValueParameters = {};
10109
+ for (const [key, value] of trackingPairs(args.param, "--param")) {
10110
+ if (asDynamicValue(value)) {
10111
+ failWriteValidation2(
10112
+ `--param ${key}=${value} names a value LinkedIn fills in per ad \u2014 pass it as --dynamic ${key}=${value.replace(/^\{+|\}+$/g, "").toUpperCase()}`
10113
+ );
10114
+ }
10115
+ customValueParameters[key] = value;
10116
+ }
10117
+ const dynamicValueParameters = {};
10118
+ for (const [key, value] of trackingPairs(args.dynamic, "--dynamic")) {
10119
+ const resolved = asDynamicValue(value);
10120
+ if (!resolved) {
10121
+ failWriteValidation2(
10122
+ `--dynamic ${key}=${value} is not a value LinkedIn can fill in \u2014 use one of ${TRACKING_PARAM_DYNAMIC_VALUES.join(", ")}, or pass a fixed value with --param`
10123
+ );
10124
+ }
10125
+ dynamicValueParameters[key] = resolved;
10126
+ }
10127
+ return mergePayload2(loadJsonFileArg2(args.file), {
10128
+ customValueParameters: Object.keys(customValueParameters).length > 0 ? customValueParameters : void 0,
10129
+ dynamicValueParameters: Object.keys(dynamicValueParameters).length > 0 ? dynamicValueParameters : void 0
10130
+ });
10131
+ }
10132
+ var campaignsUrlParamsCommand = defineCommand37({
10133
+ meta: {
10134
+ name: "url-params",
10135
+ description: `Stage the URL tracking parameters on an ad set \u2014 LinkedIn appends them to the link of EVERY ad in it, including ads already running. ${STAGED_NOTE}
10136
+
10137
+ Start here: set UTMs at this level, not per ad. A per-ad landing URL is only for a genuinely different destination \u2014 LinkedIn appends the ad set's parameters on top of whatever the ad's own URL carries, so the same key set in both places lands twice.
10138
+
10139
+ Values LinkedIn fills in per ad (--dynamic): ACCOUNT_ID, ACCOUNT_NAME, CAMPAIGN_GROUP_ID, CAMPAIGN_GROUP_NAME, CAMPAIGN_ID, CAMPAIGN_NAME, CREATIVE_ID, CREATIVE_NAME.
10140
+
10141
+ Examples:
10142
+ baker ads linkedin campaigns url-params 123456 --param "utm_source=linkedin&utm_medium=paid-social" --dynamic utm_campaign=CAMPAIGN_NAME --dynamic utm_content=CREATIVE_ID
10143
+ baker ads linkedin campaigns url-params 123456 --param utm_agency=baker
10144
+ baker ads linkedin campaigns url-params 123456 --clear`
10145
+ },
10146
+ args: {
10147
+ id: { type: "positional", description: "Ad set (campaign) id or URN", required: true },
10148
+ ...accountArgs,
10149
+ param: {
10150
+ type: "string",
10151
+ description: 'Fixed key=value, repeatable or &-joined (e.g. --param "utm_source=linkedin&utm_medium=paid")'
10152
+ },
10153
+ dynamic: {
10154
+ type: "string",
10155
+ description: "key=PLACEHOLDER LinkedIn fills in per ad, repeatable (e.g. --dynamic utm_campaign=CAMPAIGN_NAME)"
10156
+ },
10157
+ clear: { type: "boolean", description: "Remove every tracking parameter from this ad set" },
10158
+ file: { type: "string", description: "JSON file with the full payload; flags override file keys" }
10159
+ },
10160
+ run: async ({ args }) => {
10161
+ const accountId = bareAccountId(args);
10162
+ await stageOp({
10163
+ kind: "trackingParams.set",
10164
+ accountId,
10165
+ target: requireTarget2(args, "ad set"),
10166
+ payload: trackingParamsPayload(args)
10167
+ });
10168
+ }
10169
+ });
10017
10170
  var campaignsPauseCommand = statusSugarCommand("campaigns", "campaign.update", "pause");
10018
10171
  var campaignsResumeCommand = statusSugarCommand("campaigns", "campaign.update", "resume");
10019
10172
  var campaignsArchiveCommand = statusSugarCommand("campaigns", "campaign.update", "archive");
@@ -10546,7 +10699,8 @@ Examples:
10546
10699
  baker ads linkedin campaigns --account-id 503001492
10547
10700
  baker ads linkedin campaigns --account-id 503001492 --all-statuses --output csv
10548
10701
  baker ads linkedin campaigns update 123456 --daily-budget 100 --currency EUR
10549
- baker ads linkedin campaigns pause 123456`
10702
+ baker ads linkedin campaigns pause 123456
10703
+ baker ads linkedin campaigns url-params 123456 --param utm_source=linkedin \u2014 UTMs for every ad in the ad set`
10550
10704
  },
10551
10705
  subCommands: {
10552
10706
  create: campaignsCreateCommand,
@@ -10554,7 +10708,8 @@ Examples:
10554
10708
  pause: campaignsPauseCommand,
10555
10709
  resume: campaignsResumeCommand,
10556
10710
  archive: campaignsArchiveCommand,
10557
- duplicate: campaignsDuplicateCommand
10711
+ duplicate: campaignsDuplicateCommand,
10712
+ "url-params": campaignsUrlParamsCommand
10558
10713
  },
10559
10714
  args: {
10560
10715
  "account-id": { type: "string", description: "Numeric account ID or urn:li:sponsoredAccount:N" },
@@ -27910,7 +28065,11 @@ registerSchema({
27910
28065
  command: "landing.critique",
27911
28066
  description: "Start here: `baker landing critique my-landing` after building or editing a landing. Deterministic design-quality critic (ADVISORY \u2014 findings never fail it). Flags the known AI 'slop' tells (gradient text, overused fonts, side-tab borders, cream palettes, buzzword copy, broken images\u2026) tiered block/warn/advisory, and records the critique the publish quality gate requires.",
27912
28067
  args: {
27913
- slug: { type: "string", description: "Landing slug (folder under src/pages/)", required: true },
28068
+ slug: {
28069
+ type: "string",
28070
+ description: "Landing slug (folder under src/pages/). Pass several, space-separated, to critique them in one call.",
28071
+ required: true
28072
+ },
27914
28073
  full: {
27915
28074
  type: "boolean",
27916
28075
  description: "Also print advisory-tier findings (default: block + warn only)",
@@ -27919,6 +28078,11 @@ registerSchema({
27919
28078
  }
27920
28079
  });
27921
28080
  var SLUG_RE = /^[a-z0-9][a-z0-9._-]*$/i;
28081
+ function parseCritiqueSlugs(args) {
28082
+ const rest = Array.isArray(args._) ? args._ : [];
28083
+ const all = [args.slug, ...rest].filter((s) => typeof s === "string" && s.length > 0);
28084
+ return [...new Set(all.map(String))];
28085
+ }
27922
28086
  function fail5(code, message, fix) {
27923
28087
  process.stderr.write(
27924
28088
  `${JSON.stringify({ ok: false, error: { code, message, ...fix ? { fix } : {} } }, null, 2)}
@@ -27932,63 +28096,53 @@ var critiqueCommand2 = defineCommand135({
27932
28096
  description: "Start here: `baker landing critique <slug>` after building or editing a landing. Deterministic design-quality critic (ADVISORY \u2014 findings never fail it). Flags the known AI design tells (gradient text, overused fonts, side-tab borders, cream palettes, buzzword copy, broken images) tiered block/warn/advisory, respecting the client's BRAND.md as the allowlist. Also records the critique that publishing requires \u2014 run it before finishing a landing."
27933
28097
  },
27934
28098
  args: {
27935
- slug: { type: "positional", required: true, description: "Landing slug (folder under src/pages/)" },
28099
+ slug: {
28100
+ type: "positional",
28101
+ required: true,
28102
+ description: "Landing slug (folder under src/pages/). Space-separate several to critique them in one call."
28103
+ },
27936
28104
  full: { type: "boolean", description: "Also print advisory-tier findings", default: false }
27937
28105
  },
27938
28106
  async run({ args }) {
27939
- const slug = String(args.slug);
28107
+ const slugs = parseCritiqueSlugs(args);
27940
28108
  const projectRoot = process.cwd();
27941
- if (!SLUG_RE.test(slug) || slug.includes("..")) {
27942
- fail5(
27943
- "INVALID_SLUG",
27944
- `"${slug}" is not a landing slug \u2014 use the folder name directly under src/pages/ (letters, digits, dashes; not a path, not a _-private folder).`,
27945
- { availableSlugs: await listLandingSlugs(projectRoot) }
27946
- );
27947
- }
27948
- const landingDir = path27.resolve(projectRoot, "src", "pages", slug);
27949
- if (!await isDir(landingDir)) {
27950
- fail5("NOT_FOUND", `No landing at src/pages/${slug}/`, {
27951
- availableSlugs: await listLandingSlugs(projectRoot)
27952
- });
27953
- }
27954
- const [sources, brand, sourceSha] = await Promise.all([
27955
- readLandingSources(landingDir),
27956
- loadBrandTokens(projectRoot),
27957
- computeLandingSourceSha(landingDir)
27958
- ]);
27959
- const report = critiqueLanding({ slug, sources, brand });
27960
- let snapshotFailed = false;
27961
- try {
27962
- await writeCritiqueSnapshot(projectRoot, {
27963
- slug,
27964
- sourceSha,
27965
- criticVersion: CRITIC_VERSION,
27966
- at: (/* @__PURE__ */ new Date()).toISOString(),
27967
- blockCount: report.counts.block,
27968
- warnCount: report.counts.warn
27969
- });
27970
- } catch {
27971
- snapshotFailed = true;
28109
+ for (const slug of slugs) {
28110
+ if (!SLUG_RE.test(slug) || slug.includes("..")) {
28111
+ fail5(
28112
+ "INVALID_SLUG",
28113
+ `"${slug}" is not a landing slug \u2014 use the folder name directly under src/pages/ (letters, digits, dashes; not a path, not a _-private folder).`,
28114
+ { availableSlugs: await listLandingSlugs(projectRoot) }
28115
+ );
28116
+ }
28117
+ if (!await isDir(path27.resolve(projectRoot, "src", "pages", slug))) {
28118
+ fail5("NOT_FOUND", `No landing at src/pages/${slug}/`, {
28119
+ availableSlugs: await listLandingSlugs(projectRoot)
28120
+ });
28121
+ }
27972
28122
  }
27973
- const shown = report.findings.filter((f) => args.full ? true : f.severity !== "advisory");
28123
+ const brand = await loadBrandTokens(projectRoot);
28124
+ const results = await Promise.all(slugs.map((slug) => critiqueOne(projectRoot, slug, brand)));
28125
+ const landings = results.map(({ slug, report }) => ({
28126
+ slug,
28127
+ overall: report.overall,
28128
+ counts: report.counts,
28129
+ dimensions: report.dimensions,
28130
+ findings: report.findings.filter((f) => args.full ? true : f.severity !== "advisory").map(present)
28131
+ }));
28132
+ const blocked = results.filter((r) => r.report.counts.block > 0).map((r) => r.slug);
28133
+ const snapshotFailures = results.filter((r) => r.snapshotFailed).map((r) => r.slug);
27974
28134
  const hints = [
27975
- report.counts.block > 0 ? "Block-tier tells are held at publish \u2014 fix them. Warn/advisory are guidance. Scores are 0\u20131 (higher is better)." : "No block-tier tells. Warn/advisory findings are guidance, not gates. Re-run after edits so the publish gate stays fresh.",
27976
- ...snapshotFailed ? [
27977
- "Could not record the critique snapshot (.cache/ not writable?) \u2014 the publish quality gate will still report this landing as un-critiqued."
28135
+ blocked.length > 0 ? `Block-tier tells are held at publish \u2014 fix them (${blocked.join(", ")}). Warn/advisory are guidance. Scores are 0\u20131 (higher is better).` : "No block-tier tells. Warn/advisory findings are guidance, not gates. Re-run after edits so the publish gate stays fresh.",
28136
+ ...snapshotFailures.length > 0 ? [
28137
+ `Could not record the critique snapshot for ${snapshotFailures.join(", ")} (.cache/ not writable?) \u2014 the publish quality gate will still report ${snapshotFailures.length === 1 ? "this landing" : "these landings"} as un-critiqued.`
27978
28138
  ] : []
27979
28139
  ];
28140
+ const single = landings.length === 1 ? landings[0] : null;
27980
28141
  process.stdout.write(
27981
28142
  `${JSON.stringify(
27982
28143
  {
27983
28144
  ok: true,
27984
- data: {
27985
- advisory: true,
27986
- slug,
27987
- overall: report.overall,
27988
- counts: report.counts,
27989
- dimensions: report.dimensions,
27990
- findings: shown.map(present)
27991
- },
28145
+ data: { advisory: true, ...single ?? {}, landings },
27992
28146
  hints
27993
28147
  },
27994
28148
  null,
@@ -27998,6 +28152,25 @@ var critiqueCommand2 = defineCommand135({
27998
28152
  );
27999
28153
  }
28000
28154
  });
28155
+ async function critiqueOne(projectRoot, slug, brand) {
28156
+ const landingDir = path27.resolve(projectRoot, "src", "pages", slug);
28157
+ const [sources, sourceSha] = await Promise.all([readLandingSources(landingDir), computeLandingSourceSha(landingDir)]);
28158
+ const report = critiqueLanding({ slug, sources, brand });
28159
+ let snapshotFailed = false;
28160
+ try {
28161
+ await writeCritiqueSnapshot(projectRoot, {
28162
+ slug,
28163
+ sourceSha,
28164
+ criticVersion: CRITIC_VERSION,
28165
+ at: (/* @__PURE__ */ new Date()).toISOString(),
28166
+ blockCount: report.counts.block,
28167
+ warnCount: report.counts.warn
28168
+ });
28169
+ } catch {
28170
+ snapshotFailed = true;
28171
+ }
28172
+ return { slug, report, snapshotFailed };
28173
+ }
28001
28174
  async function listLandingSlugs(projectRoot) {
28002
28175
  try {
28003
28176
  const entries = await readdir8(path27.join(projectRoot, "src", "pages"), { withFileTypes: true });