@cmo-so/cli 0.12.4 → 0.12.6

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.
Files changed (3) hide show
  1. package/README.md +1 -0
  2. package/dist/cmo.mjs +199 -9
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -36,6 +36,7 @@ cmo wp status | map [--sections] | pages [--search …] [--missing-seo] | sectio
36
36
  cmo wp page <page> [--section s2] a page's sections (ids: m… bands, s… reading column, a… sidebar)
37
37
  cmo wp page create --title … --from sections.json [--parent …] [--publish] [--menu-parent …]
38
38
  cmo wp page update <page> title=… slug=… status=… | delete <page> [--redirect-to …] | undo <page>
39
+ cmo wp page publish <page> [--homepage] | cmo wp home <page> [--posts-page …] [--latest-posts]
39
40
  cmo wp page duplicate <page> --title … --replace Fairfax=Vienna
40
41
  cmo wp section add <page> <type> --from slots.json [--after s1] [--preview]
41
42
  cmo wp section update <page> <section> --from change.json [--preview] (edits | slots | faq)
package/dist/cmo.mjs CHANGED
@@ -14160,7 +14160,7 @@ var SECTIONS2 = [
14160
14160
  [
14161
14161
  S(s.heading) || S(s.intro) ? prose([...headed(s), para(S(s.intro))]) : "",
14162
14162
  group(boxes, {
14163
- cls: `maggie-stagger grid gap-5 ${boxes.length >= 4 ? "grid-cols-2 md:grid-cols-4" : "sm:grid-cols-3"}`
14163
+ cls: `maggie-stagger grid gap-5 ${boxes.length === 3 ? "sm:grid-cols-3" : "sm:grid-cols-2"}`
14164
14164
  })
14165
14165
  ],
14166
14166
  "flex flex-col gap-8"
@@ -14383,6 +14383,7 @@ var SECTIONS2 = [
14383
14383
  slots: {
14384
14384
  title: str('e.g. "Book your HydraFacial".'),
14385
14385
  text: str("One sentence."),
14386
+ button_label: str('Default "Book Now".'),
14386
14387
  button_url: str("Default: the booking link.")
14387
14388
  },
14388
14389
  required: ["title"],
@@ -14393,7 +14394,7 @@ var SECTIONS2 = [
14393
14394
  buttons(
14394
14395
  [
14395
14396
  button(
14396
- "Book Now",
14397
+ S(s.button_label) || "Book Now",
14397
14398
  S(s.button_url) || kit.bookUrl || "/contact-us/",
14398
14399
  "w-full [&_a]:w-full [&_a]:bg-white [&_a]:text-ink hover:[&_a]:bg-panel"
14399
14400
  )
@@ -15041,7 +15042,7 @@ async function menuSet(site, ops2, id) {
15041
15042
  const blocks = parse(nav.content.raw);
15042
15043
  const before = toItems(blocks);
15043
15044
  const after = applyMenuOps(before, ops2);
15044
- const rest = blocks.filter((b) => b.name && !["core/navigation-link", "core/navigation-submenu"].includes(b.name));
15045
+ const rest = ops2.items ? [] : blocks.filter((b) => b.name && !["core/navigation-link", "core/navigation-submenu"].includes(b.name));
15045
15046
  const content = [fromItems(after), serialize(rest)].filter(Boolean).join("\n");
15046
15047
  await call(site, {
15047
15048
  method: "POST",
@@ -15612,7 +15613,11 @@ function composePage(sections, kit, aside = {}) {
15612
15613
  const asideParts = [
15613
15614
  kit.synced?.share ? group([syncedRef(kit.synced.share)], { cls: "px-2" }) : "",
15614
15615
  serialize([
15615
- buildSection("cta-card", { title: aside.ctaTitle || "Book your visit", text: aside.ctaText ?? "" }, kit)
15616
+ buildSection(
15617
+ "cta-card",
15618
+ { title: aside.ctaTitle || "Book your visit", text: aside.ctaText ?? "", button_label: aside.ctaButton },
15619
+ kit
15620
+ )
15616
15621
  ])
15617
15622
  ];
15618
15623
  const body = band(
@@ -16176,6 +16181,27 @@ async function changePage(ctx, a, fn) {
16176
16181
  undo: "wp_page_undo goes back to the version before this change."
16177
16182
  };
16178
16183
  }
16184
+ var findAllBlocks = (list3) => list3.flatMap((b) => [b, ...findAllBlocks(b.inner)]);
16185
+ async function setHome(site, id, postsPage) {
16186
+ const before = await call(site, {
16187
+ path: "/wp/v2/settings",
16188
+ query: { _fields: "show_on_front,page_on_front,page_for_posts" }
16189
+ });
16190
+ const body = { show_on_front: "page", page_on_front: id };
16191
+ if (postsPage !== void 0) body.page_for_posts = postsPage;
16192
+ if (postsPage === id) throw new Error("The homepage and the posts page can't be the same page.");
16193
+ const after = await call(site, {
16194
+ method: "POST",
16195
+ path: "/wp/v2/settings",
16196
+ query: { _fields: "show_on_front,page_on_front,page_for_posts" },
16197
+ body
16198
+ });
16199
+ return {
16200
+ before,
16201
+ after,
16202
+ undo: before.show_on_front === "page" && before.page_on_front ? `wp_site_home_set page ${before.page_on_front}` : "wp_site_home_set latest_posts true"
16203
+ };
16204
+ }
16179
16205
  function position(a) {
16180
16206
  return {
16181
16207
  after: a.after || void 0,
@@ -16399,7 +16425,11 @@ var pageTools = [
16399
16425
  description: str2(""),
16400
16426
  keywords: str2("")
16401
16427
  }),
16402
- cta: obj("Sidebar booking card: title, text.", { title: str2(""), text: str2("") }),
16428
+ cta: obj("Sidebar booking card: title, text, button label (default Book Now).", {
16429
+ title: str2(""),
16430
+ text: str2(""),
16431
+ button: str2("")
16432
+ }),
16403
16433
  related: bool(
16404
16434
  "Add a related-services band from sibling pages (default true when the page has a reading column)."
16405
16435
  ),
@@ -16444,7 +16474,8 @@ var pageTools = [
16444
16474
  const cta = a.cta ?? {};
16445
16475
  const blocks = composePage(sections, kit, {
16446
16476
  ctaTitle: cta.title || `Book ${String(a.title)}`,
16447
- ctaText: cta.text
16477
+ ctaText: cta.text,
16478
+ ctaButton: cta.button
16448
16479
  });
16449
16480
  const outline = brief2(blocks, kit.syncedTitles);
16450
16481
  if (a.preview) return { preview: true, title: a.title, sections: outline };
@@ -16543,6 +16574,103 @@ var pageTools = [
16543
16574
  };
16544
16575
  }
16545
16576
  }),
16577
+ tool({
16578
+ name: "wp_page_publish",
16579
+ description: "Publish a page (a draft or private page) so visitors and search engines see it. Checks it first: one H1, an SEO title and description (warns when missing). homepage: true also makes it the site's homepage. Afterwards the live page is opened to confirm it works. To take it down again, wp_page_update status draft.",
16580
+ parameters: {
16581
+ type: "object",
16582
+ properties: {
16583
+ page: PAGE,
16584
+ homepage: bool("Also make it the site's homepage."),
16585
+ expect_modified: EXPECT
16586
+ },
16587
+ required: ["page"]
16588
+ },
16589
+ group: "site",
16590
+ need: WRITE,
16591
+ risk: "W3",
16592
+ gates: [...GATES],
16593
+ confirm: (a, lang) => say(
16594
+ lang,
16595
+ `Publish page ${q2(a.page, "")} on your website${a.homepage ? " and make it the homepage" : ""}.`,
16596
+ `\u767C\u4F48\u7DB2\u7AD9\u4E0A\u7684\u9801\u9762 ${q2(a.page, "")}${a.homepage ? "\uFF0C\u4E26\u8A2D\u70BA\u9996\u9801" : ""}\u3002`
16597
+ ),
16598
+ run: async (ctx, a) => {
16599
+ const site = siteOf(ctx);
16600
+ const p = await findPage(site, a.page, true);
16601
+ checkFresh(p, a.expect_modified);
16602
+ const m = p.meta ?? {};
16603
+ const blocks = parse(p.content?.raw ?? "");
16604
+ const h1 = findAllBlocks(blocks).filter((b) => b.name === "core/heading" && Number(b.attrs.level) === 1).length;
16605
+ const warnings = [
16606
+ h1 === 1 ? "" : h1 === 0 ? "The page has no H1 in its content (a template may add one)." : `The page has ${h1} H1 headings.`,
16607
+ m.maggie_seo_title ? "" : "No SEO title: the page title is used.",
16608
+ m.maggie_seo_description ? "" : "No meta description: set one with wp_seo_set."
16609
+ ].filter(Boolean);
16610
+ const wasStatus = p.status;
16611
+ const saved = p.status === "publish" ? p : await savePage(site, p.id, null, { status: "publish" });
16612
+ let home = null;
16613
+ if (a.homepage) home = await setHome(site, p.id);
16614
+ const live = await check(site, saved.link, "publish");
16615
+ return {
16616
+ published: true,
16617
+ id: p.id,
16618
+ path: path(saved.link),
16619
+ before: wasStatus,
16620
+ homepage: home,
16621
+ check: live,
16622
+ warnings
16623
+ };
16624
+ }
16625
+ }),
16626
+ tool({
16627
+ name: "wp_site_home_set",
16628
+ description: "Choose the site's homepage (a published page) and, optionally, the page that lists the blog posts. Returns the previous setting so it can be put back. latest_posts: true shows the newest posts as the homepage instead.",
16629
+ parameters: {
16630
+ type: "object",
16631
+ properties: {
16632
+ page: str2("The page to show as the homepage (path, slug or id)."),
16633
+ posts_page: str2('The page that lists the blog posts (optional; "" to clear).'),
16634
+ latest_posts: bool("Show the newest posts as the homepage instead of a page.")
16635
+ }
16636
+ },
16637
+ group: "site",
16638
+ need: WRITE,
16639
+ risk: "W3",
16640
+ gates: [...GATES],
16641
+ confirm: (a, lang) => a.latest_posts ? say(lang, "Show your newest posts as your website's homepage.", "\u628A\u7DB2\u7AD9\u9996\u9801\u6539\u70BA\u986F\u793A\u6700\u65B0\u6587\u7AE0\u3002") : say(
16642
+ lang,
16643
+ `Make ${q2(a.page, "that page")} your website's homepage.`,
16644
+ `\u628A ${q2(a.page, "\u8A72\u9801\u9762")} \u8A2D\u70BA\u7DB2\u7AD9\u9996\u9801\u3002`
16645
+ ),
16646
+ run: async (ctx, a) => {
16647
+ const site = siteOf(ctx);
16648
+ if (a.latest_posts) {
16649
+ const before = await call(site, {
16650
+ path: "/wp/v2/settings",
16651
+ query: { _fields: "show_on_front,page_on_front,page_for_posts" }
16652
+ });
16653
+ await call(site, {
16654
+ method: "POST",
16655
+ path: "/wp/v2/settings",
16656
+ query: { _fields: "show_on_front" },
16657
+ body: { show_on_front: "posts" }
16658
+ });
16659
+ return { before, after: { show_on_front: "posts" } };
16660
+ }
16661
+ if (!a.page) throw new Error("Which page should be the homepage?");
16662
+ const p = await findPage(site, a.page);
16663
+ if (p.status !== "publish")
16664
+ throw new Error(
16665
+ `${path(p.link)} isn't published yet: publish it first (wp_page_publish homepage: true does both).`
16666
+ );
16667
+ let postsPage;
16668
+ if (typeof a.posts_page === "string")
16669
+ postsPage = a.posts_page === "" ? 0 : (await findPage(site, a.posts_page)).id;
16670
+ const r = await setHome(site, p.id, postsPage);
16671
+ return { ...r, check: await check(site, `${site.url ?? ""}/`, "publish") };
16672
+ }
16673
+ }),
16546
16674
  tool({
16547
16675
  name: "wp_page_delete",
16548
16676
  description: "Move a page to the WordPress trash (restorable for 30 days) and redirect its address (301) to another page, by default its parent or the homepage.",
@@ -17108,6 +17236,17 @@ var childTools = [
17108
17236
  slug: str2("The child theme (default the active one)."),
17109
17237
  colors: obj('e.g. { "olive": "#4F5B48" }.'),
17110
17238
  fonts: obj('Font families by slug: { "body": "Jost, sans-serif" } (files must already be in the child).'),
17239
+ font_files: arr(
17240
+ "Install fonts in the child from https links to font files (woff2/woff/ttf), e.g. Google Fonts' fonts.gstatic.com files: each becomes a fontFace of its family (slug display / body / any).",
17241
+ obj("", {
17242
+ slug: str2("Family slug in theme.json: display, body, \u2026"),
17243
+ family: str2('Family name, e.g. "Roboto Slab".'),
17244
+ fallback: str2('Fallback stack, e.g. "Georgia, serif".'),
17245
+ url: str2("https link to the font file."),
17246
+ weight: str2('e.g. "400", "800" or "100 900".'),
17247
+ style: str2('"normal" or "italic".')
17248
+ })
17249
+ ),
17111
17250
  styles: obj('theme.json styles to merge in (e.g. { "elements": { "button": { \u2026 } } }).'),
17112
17251
  css: str2("Replace the child's CSS (plain CSS)."),
17113
17252
  css_append: str2("Add CSS at the end."),
@@ -17134,9 +17273,54 @@ var childTools = [
17134
17273
  const cur = info.files ?? {};
17135
17274
  const files = {};
17136
17275
  const notes = [];
17276
+ if (Array.isArray(a.font_files) && a.font_files.length) {
17277
+ const tj0 = JSON.parse(files["theme.json"] ?? cur["theme.json"] ?? '{"version":3,"settings":{}}');
17278
+ if (!tj0.settings || Array.isArray(tj0.settings)) tj0.settings = {};
17279
+ const typo = tj0.settings.typography ?? {};
17280
+ const fams = typo.fontFamilies ?? [];
17281
+ for (const f of a.font_files) {
17282
+ if (!/^[a-z0-9-]{2,30}$/.test(f.slug ?? ""))
17283
+ throw new Error(`Font slug "${f.slug}": lowercase letters, digits and dashes.`);
17284
+ if (!/^https:\/\//.test(f.url ?? "")) throw new Error(`${f.family}: the font's link must be https.`);
17285
+ const r = await fetch(f.url);
17286
+ if (!r.ok) throw new Error(`${f.family}: the font's link answered HTTP ${r.status}.`);
17287
+ const bytes = new Uint8Array(await r.arrayBuffer());
17288
+ const head = String.fromCharCode(...bytes.subarray(0, 4));
17289
+ const ext = head === "wOF2" ? "woff2" : head === "wOFF" ? "woff" : head === "OTTO" ? "otf" : head === "\0\0\0" || head === "true" ? "ttf" : "";
17290
+ if (!ext) throw new Error(`${f.family}: that link isn't a font file.`);
17291
+ if (bytes.length > 4 * 1024 * 1024) throw new Error(`${f.family}: the font file is larger than 4 MB.`);
17292
+ const weight = f.weight || "400";
17293
+ const style = f.style || "normal";
17294
+ const name = `${f.family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${weight.replace(/\s+/g, "-")}${style === "italic" ? "-italic" : ""}.${ext}`;
17295
+ let bin = "";
17296
+ for (let i = 0; i < bytes.length; i += 32768) bin += String.fromCharCode(...bytes.subarray(i, i + 32768));
17297
+ files[`assets/fonts/${name}`] = btoa(bin);
17298
+ let fam = fams.find((x) => x.slug === f.slug);
17299
+ const stack = `"${f.family}", ${f.fallback || "sans-serif"}`;
17300
+ if (!fam || !fam.fontFamily.includes(`"${f.family}"`)) {
17301
+ const keep = fam ? fams.indexOf(fam) : -1;
17302
+ fam = { slug: f.slug, name: f.family, fontFamily: stack, fontFace: [] };
17303
+ if (keep >= 0) fams[keep] = fam;
17304
+ else fams.push(fam);
17305
+ }
17306
+ fam.fontFace = [
17307
+ ...(fam.fontFace ?? []).filter((x) => !(x.fontWeight === weight && x.fontStyle === style)),
17308
+ {
17309
+ fontFamily: f.family,
17310
+ fontWeight: weight,
17311
+ fontStyle: style,
17312
+ fontDisplay: "swap",
17313
+ src: [`file:./assets/fonts/${name}`]
17314
+ }
17315
+ ];
17316
+ }
17317
+ tj0.settings.typography = { ...typo, fontFamilies: fams };
17318
+ files["theme.json"] = JSON.stringify(tj0, null, " ");
17319
+ }
17137
17320
  if (a.colors || a.fonts || a.styles) {
17138
- const tj = JSON.parse(cur["theme.json"] ?? '{"version":3,"settings":{}}');
17139
- tj.settings = tj.settings ?? {};
17321
+ const tj = JSON.parse(files["theme.json"] ?? cur["theme.json"] ?? '{"version":3,"settings":{}}');
17322
+ if (!tj.settings || Array.isArray(tj.settings)) tj.settings = {};
17323
+ if (Array.isArray(tj.styles)) tj.styles = {};
17140
17324
  if (a.colors) {
17141
17325
  let palette = tj.settings.color?.palette ?? [];
17142
17326
  if (!palette.length) palette = ((await styleGet(site))._palette ?? []).map((c) => ({ ...c }));
@@ -21022,7 +21206,7 @@ var program = new Command();
21022
21206
  // package.json
21023
21207
  var package_default = {
21024
21208
  name: "@cmo-so/cli",
21025
- version: "0.12.4",
21209
+ version: "0.12.6",
21026
21210
  description: "CMO.so on the command line: your websites, ideas, blog posts, LinkedIn queue and results, plus a local MCP server for Claude Desktop, Cursor and other MCP clients.",
21027
21211
  type: "module",
21028
21212
  license: "MIT",
@@ -31993,6 +32177,9 @@ function registerWp(program3) {
31993
32177
  wp.command("pages").description("find pages").option("--search <words>", "words in the title").option("--missing-seo", "only pages without their own SEO title or description").option("--template <slug>", "only this template").action(
31994
32178
  (o, c) => run(g(c), "wp_pages_list", { search: o.search, missing_seo: !!o.missingSeo, template: o.template })
31995
32179
  );
32180
+ wp.command("home [page]").description("choose the homepage (a published page), the posts page, or the latest posts").option("--posts-page <page>", 'the page that lists the blog posts ("" to clear)').option("--latest-posts", "show the newest posts as the homepage").action(
32181
+ (p, o, c) => run(g(c), "wp_site_home_set", { page: p, posts_page: o.postsPage, latest_posts: !!o.latestPosts })
32182
+ );
31996
32183
  wp.command("sections").description("the section library and page blueprints").action(
31997
32184
  (_o, c) => run(
31998
32185
  g(c),
@@ -32037,6 +32224,9 @@ function registerWp(program3) {
32037
32224
  replace: pairs(o.replace).map(([find2, replace2]) => ({ find: find2, replace: replace2 }))
32038
32225
  })
32039
32226
  );
32227
+ page.command("publish <page>").description("publish a page (checks it first); --homepage also makes it the homepage").option("--homepage", "make it the site's homepage").action(
32228
+ (p, o, c) => run(g(c), "wp_page_publish", { page: p, homepage: !!o.homepage })
32229
+ );
32040
32230
  page.command("undo <page>").description("back to the version before the last change").action((p, _o, c) => run(g(c), "wp_page_undo", { page: p }));
32041
32231
  const section = wp.command("section").description("add, change, move or remove a page's sections");
32042
32232
  const pos = (cmd) => cmd.option("--after <section>", "after this section (id or type)").option("--before <section>", "before this section").option("--region <region>", "main, story or aside").option("--index <n>", "position in the region");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cmo-so/cli",
3
- "version": "0.12.4",
3
+ "version": "0.12.6",
4
4
  "description": "CMO.so on the command line: your websites, ideas, blog posts, LinkedIn queue and results, plus a local MCP server for Claude Desktop, Cursor and other MCP clients.",
5
5
  "type": "module",
6
6
  "license": "MIT",