@koda-sl/baker-cli 0.273.0 → 0.274.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
@@ -5740,6 +5740,10 @@ baker landing critique spring-offer summer-offer claude # score three landings
5740
5740
 
5741
5741
  - **Agent washing is a copy tell.** A page that claims autonomy ("fully autonomous", "while you sleep", "no human intervention") and never says anywhere what the buyer approves, overrides, or audits scores an `agent-washing` warn. One signal about oversight of the agent's actions — approval, review, override, undo/rollback, audit log, escalation — anywhere on the page clears it; the rule is page-scope precisely because the hero can defer the trust story to a section below it. Generic privacy boilerplate ("GDPR", "encrypted", "your data") deliberately does **not** clear it, since it appears in most footers and says nothing about who is in charge of the agent.
5742
5742
 
5743
+ - **Positioning integrity is scored too, and it is the one copy rule that blocks on a single sentence.** `competitor-concession` fires when the page ranks somebody else above the client, or says the client does not lead at what the page sells ("su constructor es mejor que el nuestro", "we're not the best"). Every other prose rule waits for a cluster because one AI tell proves nothing; this one is the whole defect on its own — the page is paid traffic, and it is arguing the visitor should leave. Two shapes that read identically are exempt by design: a concession about **price**, in either direction ("we are not the cheapest / the most expensive, and here is why" is premium positioning), and a superlative flipped by a negation ("ningún equipo trabaja más rápido que el nuestro"), which is the strongest claim *for* the client. Its warn-tier sibling `competitor-named-in-comparison` fires when a name from `src/content/competitors/` appears in a comparative sentence outside a `<table>` — a comparison table is a section the landing skill teaches, but naming a rival anywhere else on paid traffic is the client's call, not the build's. Spanish, Portuguese and English.
5744
+
5745
+ - **Copy held in component frontmatter is read like markup copy.** Astro landings keep FAQ pairs, testimonial quotes, feature cards and pricing rows as a `const items = [{ q, a }]` array above the `---` fence and render them in a `.map()`, so a critic that blanked the whole block was blind to a large share of every page's actual words. Prose string literals in that block now feed every copy rule; imports, class strings, URLs and config do not, and the bar is deliberately high in that direction — a Tailwind class list in front of the copy rules is worse than one missed tell.
5746
+
5743
5747
  Output is the standard envelope `{ ok, data, hints }` with `data = { advisory, slug, overall, counts, dimensions, findings }`. `dimensions` scores seven design families (typography, color, borders_depth, motion, spacing, copy, integrity) 0–1 (higher is better); `counts` is the block/warn/advisory tally.
5744
5748
 
5745
5749
  ## Brand Fonts (`baker brand fonts`)
@@ -5875,6 +5879,8 @@ Pushing to `main` with changes in `packages/cli/` triggers the GitHub Actions wo
5875
5879
 
5876
5880
  - **0.256.0**: a structured snippet value over 25 characters is refused before it is staged. Google caps each VALUE at 25 and answers a longer one with a bare `Too long.` at `structured_snippet_asset.values[i]` — no header, no value, nothing to act on — and it takes the asset's campaign link down with it as `RESOURCE_NOT_FOUND`. `assets create --values` and `assets update --values` now cap each entry; the per-asset 3-10 count check was already there, and only the per-value length was missing. Found on a live publish where four of five values were fine and "Bases de datos vectoriales" (26) was not, and confirmed fixed by re-publishing the same snippet at 25 characters against a real account, where it applied and linked cleanly.
5877
5881
 
5882
+ - **0.274.0**: `baker landing critique` gains a `positioning integrity` pair in the `integrity` family, and starts reading the copy Astro components keep in their frontmatter. `competitor-concession` is **block-tier and fires at a single sentence** — unlike every other prose rule, which needs a cluster — because a page that ranks a competitor above the client, or says the client does not lead at what it sells, spends the client's ad budget arguing the visitor should leave. A concession about **price** in either direction never fires it ("we are not the cheapest / the most expensive, and here is why" is premium positioning), and neither does a superlative flipped by a negation ("ningún equipo trabaja más rápido que el nuestro"), which is the strongest claim FOR the client. Its warn-tier sibling `competitor-named-in-comparison` fires when a name from `src/content/competitors/` appears in a comparative sentence outside a `<table>` — naming a rival on paid traffic is the client's call. Both read Spanish, Portuguese and English. Alongside them, prose string literals in a component's `---` block are now scored like markup copy (imports, class strings, URLs and config still are not), so an FAQ held as `const faqs = [{ q, a }]` — the standard Astro shape — is no longer invisible to the critic and to `BRAND.md` copy rules. `CRITIC_VERSION` bumps to `3`.
5883
+
5878
5884
  - **0.273.0**: **an ad's name can no longer take a whole publish down.** `Ad.name` is IMMUTABLE, and `--ad-name` is shared by `ads create` and `ads update` (every content flag is), so an update restating the ad's name put `name` in the field mask and Google refused the operation — and, being one op inside an atomic batch, everything staged with it. The name is now dropped from an update's mutate, and an update that would actually RENAME the ad is refused at stage time instead, so a rename is answered rather than silently ignored. Two related corrections: `--ad-name` is required on `demandGenVideo` **only** — a multi-asset `demandGen` ad published with no name at all and Google created it, so the docs that called it a Demand-Gen-family requirement were wrong — and a content flag on `--format call` is now refused like every other format's, instead of being dropped because a call ad reads its content from `--file` alone. The Demand Gen video ad's live videos and logos are also read into the review card now, so an update that replaces them shows what it is replacing.
5879
5885
 
5880
5886
  - **0.272.0**: what one live publish of a Demand Gen video ad found, all four fixed and each of them a whole-batch failure (a campaign, its ad group, its ad and its assets are one atomic mutate). `--type DEMAND_GEN_AD` and `--type VIDEO_OUTSTREAM` are **gone from `ad-groups create`** — v23's ad group types have never had either, and Google answers with a request-level `Invalid value` that fails every sibling operation. `--type` is now **optional and best omitted**: Google derives an ad group's type from its campaign's channel, and on a Demand Gen campaign that is the only way to create one (its `ad_group.type` comes back empty). `--bidding-strategy MANUAL_CPC` on a **Demand Gen** campaign is refused before staging — Google rejects it outright (`OPERATION_NOT_PERMITTED_FOR_CONTEXT`), and the Demand Gen playbook shipped in every sandbox was teaching it; `MAXIMIZE_CONVERSIONS` and `TARGET_CPC` work. And a new Demand Gen video ad now needs `--ad-name` (Google's "Ad name", required on Demand Gen ads and no other format) and at least one `--logo-image-assets` ref alongside its video — Google only says so at publish, once the assets in the same batch have already been created.
package/dist/cli.js CHANGED
@@ -43290,7 +43290,7 @@ Full guide: __tooling__/docs/tools/baker/images.md`
43290
43290
  import { defineCommand as defineCommand168 } from "citty";
43291
43291
 
43292
43292
  // src/commands/landing/critique.ts
43293
- import { readdir as readdir9, stat as stat6 } from "fs/promises";
43293
+ import { readdir as readdir9, readFile as readFile22, stat as stat6 } from "fs/promises";
43294
43294
  import path28 from "path";
43295
43295
  import { defineCommand as defineCommand158 } from "citty";
43296
43296
 
@@ -43420,6 +43420,29 @@ var COPY_TELL_GATES = {
43420
43420
  "inline-header-list": { minCount: 3, severity: "advisory" },
43421
43421
  emoji: { minCount: 3, severity: "advisory" }
43422
43422
  };
43423
+ var CONCESSION_PATTERNS = [
43424
+ // Cedes superiority to somebody else.
43425
+ /\b(?:mejor|peor)(?:es)? que (?:el |la |lo |los |las )?nuestr[oa]s?\b/g,
43426
+ /\bmas [a-z]+ que (?:el |la |lo |los |las )?nuestr[oa]s?\b/g,
43427
+ /\b(?:melhor|pior)(?:es)? que (?:o |a |os |as )?noss[oa]s?\b/g,
43428
+ /\bmais [a-z]+ que (?:o |a |os |as )?noss[oa]s?\b/g,
43429
+ /\bbetter than (?:ours|our own)\b/g,
43430
+ /\b(?:ellos|ellas) lo hacen mejor\b/g,
43431
+ /\beles fazem melhor\b/g,
43432
+ /\bthey do it better\b/g,
43433
+ /\bnos (?:ganan|superan|adelantan) en\b/g,
43434
+ /\bnos (?:ganham|superam) em\b/g,
43435
+ // Denies the client leads.
43436
+ /\b(?:hoy |actualmente )?no somos nosotros\b/g,
43437
+ /\b(?:hoje |atualmente )?nao somos nos\b/g,
43438
+ /\bno somos (?:el|la|los|las) (?:mejor|mas) [a-z]+/g,
43439
+ /\bno somos (?:el|la|los|las) (?:mejores|mejor)\b/g,
43440
+ /\bnao somos (?:o|a|os|as) (?:melhor|melhores|mais) ?[a-z]*/g,
43441
+ /\bwe(?:'re| are) not the (?:best|most|leading|strongest)\b/g,
43442
+ /\b(?:it|that)(?:'s| is) not us\b/g
43443
+ ];
43444
+ var PRICE_CONCESSION_CONTEXT = /barat|econom|precio|preco|cheap|price|low ?cost|coste|custo|\bcar[oa]s?\b|\bexpensive\b/;
43445
+ var CONCESSION_NEGATION_CONTEXT = /\b(?:ningun[oa]?s?|nadie|nada|no hay|no existe|nenhum[oa]?s?|ninguem|nao ha|nao existe|nothing|nobody|no one|none|there is no)\b/;
43423
43446
  var AUTONOMY_CLAIMS = [
43424
43447
  "fully autonomous",
43425
43448
  "completely autonomous",
@@ -43499,6 +43522,16 @@ var RULE_META = {
43499
43522
  severity: "block",
43500
43523
  note: "An <img> with an empty or placeholder src ships a broken image. Source a real asset via `baker images`."
43501
43524
  },
43525
+ "competitor-concession": {
43526
+ family: "integrity",
43527
+ severity: "block",
43528
+ note: "This page ranks somebody else above the client, or says the client does not lead. A paid landing answers an objection by changing the axis, never by conceding it. Rewrite the claim around what this product does that the alternative structurally cannot (`references/sections/faq.md`)."
43529
+ },
43530
+ "competitor-named-in-comparison": {
43531
+ family: "integrity",
43532
+ severity: "warn",
43533
+ note: "A competitor from the vault is named in a comparative sentence outside a comparison table. Naming a rival on paid traffic sells their brand and is the client's decision, not yours \u2014 cite the capability, not the company."
43534
+ },
43502
43535
  "side-tab": {
43503
43536
  family: "borders_depth",
43504
43537
  severity: "warn",
@@ -43856,6 +43889,40 @@ var LINE_MATCHERS = [
43856
43889
  function includesPhraseAtWordStart(haystack, phrase) {
43857
43890
  return new RegExp(`\\b${phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`).test(haystack);
43858
43891
  }
43892
+ function foldForCopy(text2) {
43893
+ return text2.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
43894
+ }
43895
+ function detectConcessions(text2) {
43896
+ const folded = foldForCopy(text2);
43897
+ const out = [];
43898
+ for (const pattern of CONCESSION_PATTERNS) {
43899
+ for (const m of folded.matchAll(pattern)) {
43900
+ const index = m.index ?? 0;
43901
+ if (PRICE_CONCESSION_CONTEXT.test(sentenceAround(folded, index))) continue;
43902
+ if (CONCESSION_NEGATION_CONTEXT.test(clauseBefore(folded, index))) continue;
43903
+ out.push({
43904
+ index,
43905
+ sample: text2.slice(index, index + 70).replace(/\s+/g, " ").trim()
43906
+ });
43907
+ }
43908
+ }
43909
+ return out;
43910
+ }
43911
+ function sentenceAround(text2, index) {
43912
+ const rest = text2.slice(index).search(/[.!?\n]/);
43913
+ return text2.slice(sentenceStart(text2, index), rest === -1 ? text2.length : index + rest);
43914
+ }
43915
+ function clauseBefore(text2, index) {
43916
+ const prefix = text2.slice(sentenceStart(text2, index), index);
43917
+ const lastBreak = Math.max(prefix.lastIndexOf(","), prefix.lastIndexOf(";"), prefix.lastIndexOf(":"));
43918
+ return prefix.slice(lastBreak + 1);
43919
+ }
43920
+ function sentenceStart(text2, index) {
43921
+ for (let i = index; i > 0; i--) {
43922
+ if (/[.!?\n]/.test(text2[i - 1] ?? "")) return i;
43923
+ }
43924
+ return 0;
43925
+ }
43859
43926
  function stripHtmlToText(html) {
43860
43927
  return html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ").replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, " ").replace(/<!--[\s\S]*?-->/g, " ").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ");
43861
43928
  }
@@ -43867,17 +43934,76 @@ function stripHtmlPreservingHeadings(html) {
43867
43934
  `
43868
43935
  ).replace(/<\/(?:p|div|section|li|tr|br)>|<br\s*\/?>/gi, "\n").replace(/<[^>]+>/g, " ").replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n");
43869
43936
  }
43937
+ function isProseLiteral(value) {
43938
+ const text2 = value.trim();
43939
+ if (text2.length < 12) return false;
43940
+ if (/^(?:https?:\/\/|\/|\.{1,2}\/|#|@|[a-z]+:)/i.test(text2)) return false;
43941
+ if (!new RegExp("\\p{L}", "u").test(text2)) return false;
43942
+ const words2 = text2.split(/\s+/).filter(Boolean);
43943
+ if (words2.length < 4) return false;
43944
+ const classy = words2.filter((w) => /^-?[a-z][a-z0-9]*(?:[-.:/][a-z0-9.[\]%-]+)+$/.test(w)).length;
43945
+ if (classy >= 3 && classy / words2.length > 0.5) return false;
43946
+ return true;
43947
+ }
43870
43948
  function blankAstroFrontmatter(text2) {
43871
- if (!text2.startsWith("---")) return text2;
43949
+ const cut = frontmatterLength(text2);
43950
+ if (cut === 0) return text2;
43951
+ const head = text2.slice(0, cut);
43952
+ const kept = head.split("").map((ch) => ch === "\n" ? "\n" : " ");
43953
+ for (const literal of stringLiterals(head)) {
43954
+ if (!isProseLiteral(literal.value)) continue;
43955
+ for (let i = 0; i < literal.value.length; i++) {
43956
+ const at = literal.start + i;
43957
+ if (head[at] !== "\n") kept[at] = head[at] ?? " ";
43958
+ }
43959
+ }
43960
+ return kept.join("") + text2.slice(cut);
43961
+ }
43962
+ function frontmatterLength(text2) {
43963
+ if (!text2.startsWith("---")) return 0;
43872
43964
  const lines = text2.split("\n");
43873
- if (!/^---\s*$/.test(lines[0] ?? "")) return text2;
43874
- for (let i = 1; i < lines.length; i++) {
43875
- if (/^---\s*$/.test(lines[i] ?? "")) {
43876
- for (let j = 0; j <= i; j++) lines[j] = "";
43877
- return lines.join("\n");
43965
+ if (!/^---\s*$/.test(lines[0] ?? "")) return 0;
43966
+ const end = lines.findIndex((line, i) => i > 0 && /^---\s*$/.test(line));
43967
+ return end === -1 ? 0 : lines.slice(0, end + 1).join("\n").length;
43968
+ }
43969
+ function stringLiterals(source) {
43970
+ const out = [];
43971
+ for (let i = 0; i < source.length; i++) {
43972
+ const commentEnd = skipComment(source, i);
43973
+ if (commentEnd !== -1) {
43974
+ i = commentEnd;
43975
+ continue;
43878
43976
  }
43977
+ const quote = source[i];
43978
+ if (quote !== '"' && quote !== "'" && quote !== "`") continue;
43979
+ const close = closingQuote(source, i + 1, quote);
43980
+ if (close === -1) break;
43981
+ out.push({ start: i + 1, value: source.slice(i + 1, close) });
43982
+ i = close;
43879
43983
  }
43880
- return text2;
43984
+ return out;
43985
+ }
43986
+ function skipComment(source, i) {
43987
+ if (source[i] !== "/") return -1;
43988
+ if (source[i + 1] === "/") {
43989
+ const nl = source.indexOf("\n", i);
43990
+ return nl === -1 ? source.length : nl;
43991
+ }
43992
+ if (source[i + 1] === "*") {
43993
+ const close = source.indexOf("*/", i + 2);
43994
+ return close === -1 ? source.length : close + 1;
43995
+ }
43996
+ return -1;
43997
+ }
43998
+ function closingQuote(source, from, quote) {
43999
+ for (let j = from; j < source.length; j++) {
44000
+ if (source[j] === "\\") {
44001
+ j++;
44002
+ continue;
44003
+ }
44004
+ if (source[j] === quote) return j;
44005
+ }
44006
+ return -1;
43881
44007
  }
43882
44008
  var ANALYZERS = [
43883
44009
  // Overused primary font (font-family declarations + Google Fonts URLs). Fires
@@ -44068,6 +44194,23 @@ var ANALYZERS = [
44068
44194
  return out;
44069
44195
  }
44070
44196
  },
44197
+ // Positioning concessions. File-scope (fires at 1, and the line is what the
44198
+ // agent needs to fix it) and read from the visible copy only.
44199
+ {
44200
+ scope: "file",
44201
+ run: (text2, file) => {
44202
+ const visible = stripHtmlPreservingHeadings(text2);
44203
+ return detectConcessions(visible).map(({ sample: sample2 }) => {
44204
+ const at = text2.indexOf(sample2.slice(0, 24));
44205
+ return {
44206
+ id: "competitor-concession",
44207
+ snippet: `"${sample2}"`,
44208
+ file,
44209
+ ...at >= 0 ? { line: lineOf(text2, at) } : {}
44210
+ };
44211
+ });
44212
+ }
44213
+ },
44071
44214
  // Gradient text in authored CSS. A rule block spans lines, so this scans a
44072
44215
  // ±6-line window around each `background-clip: text` instead of one line
44073
44216
  // (the Tailwind class form stays a line matcher above).
@@ -44161,6 +44304,72 @@ function lineOf(text2, index) {
44161
44304
  for (let i = 0; i < index && i < text2.length; i++) if (text2[i] === "\n") line++;
44162
44305
  return line;
44163
44306
  }
44307
+ var COMPARISON_MARKERS = /\b(?:vs|versus|frente a|comparad|compara|mejor|mejores|peor|peores|alternativa|melhor|melhores|pior|piores|compared|better|worse|alternative|than)\b/;
44308
+ function tableRanges(text2) {
44309
+ const out = [];
44310
+ for (const m of text2.matchAll(/<table\b[\s\S]*?<\/table>/gi)) {
44311
+ const start = m.index ?? 0;
44312
+ out.push([start, start + m[0].length]);
44313
+ }
44314
+ return out;
44315
+ }
44316
+ function detectCompetitorMentions(sources, competitors) {
44317
+ const names = distinctCompetitorNames(competitors);
44318
+ if (names.length === 0) return [];
44319
+ const out = [];
44320
+ for (const source of sources) out.push(...mentionsInSource(source, names));
44321
+ return dedupe(out);
44322
+ }
44323
+ function distinctCompetitorNames(competitors) {
44324
+ const byFold = /* @__PURE__ */ new Map();
44325
+ for (const raw of competitors) {
44326
+ if (typeof raw !== "string") continue;
44327
+ const name = raw.trim();
44328
+ if (name.length < 4) continue;
44329
+ const key = foldForCopy(name);
44330
+ if (!byFold.has(key) || new RegExp("\\p{Lu}", "u").test(name)) byFold.set(key, name);
44331
+ }
44332
+ return [...byFold.values()];
44333
+ }
44334
+ function mentionsInSource(source, names) {
44335
+ const text2 = blankAstroFrontmatter(source.text);
44336
+ const visible = blankHtmlKeepingOffsets(text2);
44337
+ const tables = tableRanges(text2);
44338
+ const out = [];
44339
+ for (const name of names) {
44340
+ const seen = /* @__PURE__ */ new Set();
44341
+ for (const m of visible.matchAll(new RegExp(`\\b${escapeRegExp(name)}\\b`, "gi"))) {
44342
+ const at = m.index ?? 0;
44343
+ if (tables.some(([from, to]) => at >= from && at < to)) continue;
44344
+ const start = sentenceStart(visible, at);
44345
+ if (seen.has(start)) continue;
44346
+ const sentence = sentenceAround(visible, at).replace(/\s+/g, " ").trim();
44347
+ if (!COMPARISON_MARKERS.test(foldForCopy(sentence))) continue;
44348
+ seen.add(start);
44349
+ out.push({
44350
+ id: "competitor-named-in-comparison",
44351
+ snippet: `"${sentence.slice(0, 70)}" names ${name}`,
44352
+ file: source.path,
44353
+ line: lineOf(text2, at)
44354
+ });
44355
+ }
44356
+ }
44357
+ return out;
44358
+ }
44359
+ function blankHtmlKeepingOffsets(text2) {
44360
+ const out = text2.split("");
44361
+ const pattern = /<script\b[^>]*>[\s\S]*?<\/script>|<style\b[^>]*>[\s\S]*?<\/style>|<!--[\s\S]*?-->|<[^>]+>/gi;
44362
+ for (const m of text2.matchAll(pattern)) {
44363
+ const start = m.index ?? 0;
44364
+ for (let i = start; i < start + m[0].length && i < out.length; i++) {
44365
+ if (out[i] !== "\n") out[i] = " ";
44366
+ }
44367
+ }
44368
+ return out.join("");
44369
+ }
44370
+ function escapeRegExp(value) {
44371
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
44372
+ }
44164
44373
  function detectSource(source) {
44165
44374
  const file = source.path;
44166
44375
  const text2 = blankAstroFrontmatter(source.text);
@@ -44220,6 +44429,7 @@ function critiqueLanding(input) {
44220
44429
  const raws = [];
44221
44430
  for (const source of sources) raws.push(...detectSource(source));
44222
44431
  raws.push(...detectPage(sources));
44432
+ raws.push(...detectCompetitorMentions(sources, input.competitors ?? []));
44223
44433
  for (const raw of raws) {
44224
44434
  const meta = RULE_META[raw.id];
44225
44435
  if (!meta) continue;
@@ -44260,7 +44470,7 @@ function describeCounts(findings) {
44260
44470
  // src/commands/landing/snapshot.ts
44261
44471
  import { mkdir as mkdir8, rename as rename2, writeFile as writeFile12 } from "fs/promises";
44262
44472
  import path26 from "path";
44263
- var CRITIC_VERSION = "2";
44473
+ var CRITIC_VERSION = "3";
44264
44474
  function critiqueCacheDir(projectRoot) {
44265
44475
  return path26.join(projectRoot, ".cache", "landing-critique");
44266
44476
  }
@@ -44334,7 +44544,7 @@ async function walkAstro(dir) {
44334
44544
  // src/commands/landing/critique.ts
44335
44545
  registerSchema({
44336
44546
  command: "landing.critique",
44337
- 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.",
44547
+ 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) plus positioning integrity (copy that ranks a competitor above the client, a rival named in a comparison) tiered block/warn/advisory, and records the critique the publish quality gate requires.",
44338
44548
  args: {
44339
44549
  slug: {
44340
44550
  type: "string",
@@ -44364,7 +44574,7 @@ function fail5(code, message, fix) {
44364
44574
  var critiqueCommand2 = defineCommand158({
44365
44575
  meta: {
44366
44576
  name: "critique",
44367
- 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."
44577
+ 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) and positioning integrity (a concession ranking a competitor above the client, a rival named in a comparison) 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."
44368
44578
  },
44369
44579
  args: {
44370
44580
  slug: {
@@ -44392,7 +44602,8 @@ var critiqueCommand2 = defineCommand158({
44392
44602
  }
44393
44603
  }
44394
44604
  const brand = await loadBrandTokens(projectRoot);
44395
- const results = await Promise.all(slugs.map((slug) => critiqueOne(projectRoot, slug, brand)));
44605
+ const competitors = await readCompetitorNames(projectRoot);
44606
+ const results = await Promise.all(slugs.map((slug) => critiqueOne(projectRoot, slug, brand, competitors)));
44396
44607
  const landings = results.map(({ slug, report }) => ({
44397
44608
  slug,
44398
44609
  overall: report.overall,
@@ -44423,10 +44634,10 @@ var critiqueCommand2 = defineCommand158({
44423
44634
  );
44424
44635
  }
44425
44636
  });
44426
- async function critiqueOne(projectRoot, slug, brand) {
44637
+ async function critiqueOne(projectRoot, slug, brand, competitors) {
44427
44638
  const landingDir = path28.resolve(projectRoot, "src", "pages", slug);
44428
44639
  const [sources, sourceSha] = await Promise.all([readLandingSources(landingDir), computeLandingSourceSha(landingDir)]);
44429
- const report = critiqueLanding({ slug, sources, brand });
44640
+ const report = critiqueLanding({ slug, sources, brand, competitors });
44430
44641
  let snapshotFailed = false;
44431
44642
  try {
44432
44643
  await writeCritiqueSnapshot(projectRoot, {
@@ -44442,6 +44653,26 @@ async function critiqueOne(projectRoot, slug, brand) {
44442
44653
  }
44443
44654
  return { slug, report, snapshotFailed };
44444
44655
  }
44656
+ async function readCompetitorNames(projectRoot) {
44657
+ const dir = path28.join(projectRoot, "src", "content", "competitors");
44658
+ let entries;
44659
+ try {
44660
+ entries = (await readdir9(dir)).filter((f) => f.endsWith(".md"));
44661
+ } catch {
44662
+ return [];
44663
+ }
44664
+ const names = [];
44665
+ for (const entry of entries) {
44666
+ names.push(path28.basename(entry, ".md").replace(/[-_]+/g, " "));
44667
+ try {
44668
+ const head = (await readFile22(path28.join(dir, entry), "utf8")).slice(0, 2e3);
44669
+ const titled = /^\s*(?:title|name)\s*:\s*["']?([^"'\n]+)["']?\s*$/im.exec(head);
44670
+ if (titled?.[1]) names.push(titled[1].trim());
44671
+ } catch {
44672
+ }
44673
+ }
44674
+ return names;
44675
+ }
44445
44676
  async function listLandingSlugs(projectRoot) {
44446
44677
  try {
44447
44678
  const entries = await readdir9(path28.join(projectRoot, "src", "pages"), { withFileTypes: true });
@@ -47645,7 +47876,7 @@ Start here: \`baker landing critique <slug>\` after building or editing a landin
47645
47876
 
47646
47877
  Subcommands:
47647
47878
  baker landing inspiration \u2014 reference library of real landing-page sections; search it during research, BEFORE writing the Direction Contract. Inspiration only: take the mechanism, never the words.
47648
- baker landing critique <slug> \u2014 deterministic design-quality critic (advisory): flags the known AI 'slop' tells (gradient text, overused fonts, side-tab borders, cream palettes, buzzword copy, broken images) tiered block/warn/advisory, respecting the client's BRAND.md. Records the critique the publish quality gate requires \u2014 run it before finishing a landing.`
47879
+ baker landing critique <slug> \u2014 deterministic design-quality critic (advisory): flags the known AI 'slop' tells (gradient text, overused fonts, side-tab borders, cream palettes, buzzword copy, broken images) and positioning integrity (copy ranking a competitor above the client) tiered block/warn/advisory, respecting the client's BRAND.md. Records the critique the publish quality gate requires \u2014 run it before finishing a landing.`
47649
47880
  },
47650
47881
  subCommands: {
47651
47882
  inspiration: inspirationCommand,
@@ -49389,7 +49620,7 @@ var listCommand15 = defineCommand185({
49389
49620
  });
49390
49621
 
49391
49622
  // src/commands/scheduled-actions/templates.ts
49392
- import { readFile as readFile22 } from "fs/promises";
49623
+ import { readFile as readFile23 } from "fs/promises";
49393
49624
  import path36 from "path";
49394
49625
  import { defineCommand as defineCommand186 } from "citty";
49395
49626
  registerSchema({
@@ -49493,7 +49724,7 @@ Full guide: __tooling__/docs/tools/baker/scheduled-actions.md`
49493
49724
  }
49494
49725
  if (save.length > 0) {
49495
49726
  const briefFile = flag("brief-file");
49496
- const brief = briefFile.length > 0 ? await readFile22(path36.resolve(briefFile), "utf8") : flag("brief");
49727
+ const brief = briefFile.length > 0 ? await readFile23(path36.resolve(briefFile), "utf8") : flag("brief");
49497
49728
  if (brief.trim().length === 0) {
49498
49729
  failValidation4("--brief-file (preferred) or --brief is required: the brief is the recipe.");
49499
49730
  }
@@ -52676,7 +52907,7 @@ var searchCommand4 = defineCommand213({
52676
52907
  var tagsCommand6 = makeTagsCommand("videos", "video", "/api/videos/tags");
52677
52908
 
52678
52909
  // src/commands/videos/upload.ts
52679
- import { readFile as readFile23, stat as stat8 } from "fs/promises";
52910
+ import { readFile as readFile24, stat as stat8 } from "fs/promises";
52680
52911
  import { basename as basename3, extname as extname4 } from "path";
52681
52912
  import { defineCommand as defineCommand214 } from "citty";
52682
52913
  var MIME_MAP = {
@@ -52773,7 +53004,7 @@ var uploadCommand2 = defineCommand214({
52773
53004
  originalFilename,
52774
53005
  descriptionContext
52775
53006
  });
52776
- const fileBuffer = await readFile23(filePath);
53007
+ const fileBuffer = await readFile24(filePath);
52777
53008
  const uploadResponse = await fetch(uploadUrl, {
52778
53009
  method: "PUT",
52779
53010
  headers: { "Content-Type": contentType },