@cmo-so/cli 0.12.1 → 0.12.3
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 +3 -1
- package/dist/cmo.mjs +532 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -46,7 +46,9 @@ cmo wp redirects [ls] | set <from> <to> [--code 301] | remove <from…>
|
|
|
46
46
|
cmo wp menu [get] | set --from menu.json cmo wp header|footer [get] | set --replace a=b --link old=new
|
|
47
47
|
cmo wp header set --button-text "Book Now" --button-url … cmo wp style [get] | set olive=#4F5B48
|
|
48
48
|
cmo wp pattern contact --replace "old=new" shared contact band / share buttons on every page
|
|
49
|
-
cmo wp child
|
|
49
|
+
cmo wp child [status] | create maggie-<name> --name … [--activate] | update --color olive=#4F5B48 --css-file x.css
|
|
50
|
+
cmo wp child activate <slug|maggie-core> | restore <slug> <backup> the site's child theme, on the site (backups kept)
|
|
51
|
+
cmo wp child-theme <slug> --name "…" --from-site [--out dir] export the site's look as a child theme folder (for git)
|
|
50
52
|
cmo wp migrate plan [--out wp-baseline.json] | migrate verify --baseline wp-baseline.json the SEO gate
|
|
51
53
|
```
|
|
52
54
|
|
package/dist/cmo.mjs
CHANGED
|
@@ -7662,6 +7662,8 @@ function domainApi(api2, domain) {
|
|
|
7662
7662
|
wpBuilder: () => api2.request(`${base}/wp/builder`),
|
|
7663
7663
|
connectWpBuilder: (input) => post(`${base}/wp/builder`, input),
|
|
7664
7664
|
disconnectWpBuilder: () => api2.request(`${base}/wp/builder`, { method: "DELETE" }),
|
|
7665
|
+
/** Put one picture in the site's media library: a public https link, or a local file as base64 (≤ 10 MB). */
|
|
7666
|
+
wpMedia: (input) => post(`${base}/wp/media`, input),
|
|
7665
7667
|
/** One WordPress REST request, signed by TOPY with the saved login (allowlisted routes only). */
|
|
7666
7668
|
wpRequest: (input) => post(`${base}/wp/request`, input),
|
|
7667
7669
|
/** A new website API key (aicmo_…) for the user's own code, shown once; for the CLI, which writes it to .env. */
|
|
@@ -14727,6 +14729,27 @@ function walkTouch(b) {
|
|
|
14727
14729
|
}
|
|
14728
14730
|
|
|
14729
14731
|
// ../../packages/tools/src/wp/transport.ts
|
|
14732
|
+
var IMAGE_EXT = {
|
|
14733
|
+
"image/jpeg": "jpg",
|
|
14734
|
+
"image/png": "png",
|
|
14735
|
+
"image/webp": "webp",
|
|
14736
|
+
"image/gif": "gif",
|
|
14737
|
+
"image/avif": "avif"
|
|
14738
|
+
};
|
|
14739
|
+
function sniffImage(b) {
|
|
14740
|
+
const at = (i, ...v) => v.every((x, k) => b[i + k] === x);
|
|
14741
|
+
const ascii = (i, t) => [...t].every((ch, k) => b[i + k] === ch.charCodeAt(0));
|
|
14742
|
+
if (at(0, 255, 216, 255)) return "image/jpeg";
|
|
14743
|
+
if (at(0, 137, 80, 78, 71, 13, 10, 26, 10)) return "image/png";
|
|
14744
|
+
if (ascii(0, "RIFF") && ascii(8, "WEBP")) return "image/webp";
|
|
14745
|
+
if (ascii(0, "GIF87a") || ascii(0, "GIF89a")) return "image/gif";
|
|
14746
|
+
if (ascii(4, "ftypavif") || ascii(4, "ftypavis")) return "image/avif";
|
|
14747
|
+
return null;
|
|
14748
|
+
}
|
|
14749
|
+
function mediaName(name, mime) {
|
|
14750
|
+
const stem = (name ?? "").trim().toLowerCase().replace(/\.[a-z0-9]{2,5}$/, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "picture";
|
|
14751
|
+
return `${stem}.${IMAGE_EXT[mime]}`;
|
|
14752
|
+
}
|
|
14730
14753
|
var WpError = class extends Error {
|
|
14731
14754
|
constructor(message2, status, code) {
|
|
14732
14755
|
super(message2);
|
|
@@ -14774,6 +14797,49 @@ function directSite(siteUrl, username, password) {
|
|
|
14774
14797
|
}
|
|
14775
14798
|
const n2 = (h) => r.headers.get(h) != null ? Number(r.headers.get(h)) : void 0;
|
|
14776
14799
|
return { status: r.status, data, total: n2("x-wp-total"), totalPages: n2("x-wp-totalpages") };
|
|
14800
|
+
},
|
|
14801
|
+
async uploadMedia(input) {
|
|
14802
|
+
let bytes = input.data;
|
|
14803
|
+
if (!bytes && input.url) {
|
|
14804
|
+
if (!/^https:\/\//i.test(input.url)) throw new WpError("The picture's link must be https.", 400);
|
|
14805
|
+
const g = await fetch(input.url, { headers: { Accept: "image/*" } });
|
|
14806
|
+
if (!g.ok) throw new WpError(`The picture's link answered HTTP ${g.status}.`, g.status);
|
|
14807
|
+
bytes = new Uint8Array(await g.arrayBuffer());
|
|
14808
|
+
}
|
|
14809
|
+
if (!bytes) throw new WpError("Give the picture's link or the file.", 400);
|
|
14810
|
+
if (bytes.length > 15 * 1024 * 1024) throw new WpError("The picture is larger than 15 MB.", 413);
|
|
14811
|
+
const mime = sniffImage(bytes);
|
|
14812
|
+
if (!mime) throw new WpError("That isn't a JPEG, PNG, WebP, GIF or AVIF picture.", 415);
|
|
14813
|
+
const name = mediaName(input.filename ?? input.url?.split("/").pop()?.split("?")[0], mime);
|
|
14814
|
+
const r = await fetch(`${base}/?rest_route=${encodeURIComponent("/wp/v2/media")}`, {
|
|
14815
|
+
method: "POST",
|
|
14816
|
+
redirect: "manual",
|
|
14817
|
+
headers: {
|
|
14818
|
+
Authorization: auth,
|
|
14819
|
+
"Content-Type": mime,
|
|
14820
|
+
"Content-Disposition": `attachment; filename="${name}"`,
|
|
14821
|
+
Accept: "application/json"
|
|
14822
|
+
},
|
|
14823
|
+
body: bytes
|
|
14824
|
+
});
|
|
14825
|
+
const media = await r.json().catch(() => null);
|
|
14826
|
+
if (!media?.id)
|
|
14827
|
+
throw new WpError(`WordPress didn't take the picture: ${media?.message ?? `HTTP ${r.status}`}.`, r.status);
|
|
14828
|
+
const meta4 = {};
|
|
14829
|
+
if (input.alt) meta4.alt_text = input.alt;
|
|
14830
|
+
if (input.title) meta4.title = input.title;
|
|
14831
|
+
if (input.caption) meta4.caption = input.caption;
|
|
14832
|
+
if (Object.keys(meta4).length)
|
|
14833
|
+
await this.request({ method: "POST", path: `/wp/v2/media/${media.id}`, body: meta4, query: { _fields: "id" } });
|
|
14834
|
+
return {
|
|
14835
|
+
id: media.id,
|
|
14836
|
+
source_url: media.source_url ?? "",
|
|
14837
|
+
mime_type: mime,
|
|
14838
|
+
bytes: bytes.length,
|
|
14839
|
+
width: media.media_details?.width ?? null,
|
|
14840
|
+
height: media.media_details?.height ?? null,
|
|
14841
|
+
alt: input.alt ?? ""
|
|
14842
|
+
};
|
|
14777
14843
|
}
|
|
14778
14844
|
};
|
|
14779
14845
|
}
|
|
@@ -14782,6 +14848,23 @@ function apiSite(api2, domain) {
|
|
|
14782
14848
|
return {
|
|
14783
14849
|
url: `https://${domain}`,
|
|
14784
14850
|
fetchPage: publicFetch,
|
|
14851
|
+
async uploadMedia(input) {
|
|
14852
|
+
let data_base64;
|
|
14853
|
+
if (input.data) {
|
|
14854
|
+
let bin = "";
|
|
14855
|
+
for (let i = 0; i < input.data.length; i += 32768)
|
|
14856
|
+
bin += String.fromCharCode(...input.data.subarray(i, i + 32768));
|
|
14857
|
+
data_base64 = btoa(bin);
|
|
14858
|
+
}
|
|
14859
|
+
return d.wpMedia({
|
|
14860
|
+
url: input.url,
|
|
14861
|
+
data_base64,
|
|
14862
|
+
filename: input.filename,
|
|
14863
|
+
alt: input.alt,
|
|
14864
|
+
title: input.title,
|
|
14865
|
+
caption: input.caption
|
|
14866
|
+
});
|
|
14867
|
+
},
|
|
14785
14868
|
async request(req) {
|
|
14786
14869
|
let r;
|
|
14787
14870
|
try {
|
|
@@ -16803,6 +16886,393 @@ var sectionTools = [
|
|
|
16803
16886
|
}
|
|
16804
16887
|
})
|
|
16805
16888
|
];
|
|
16889
|
+
var CHAT_FILE = {
|
|
16890
|
+
type: "object",
|
|
16891
|
+
description: "The picture from this chat (ChatGPT passes it here).",
|
|
16892
|
+
properties: {
|
|
16893
|
+
download_url: { type: "string" },
|
|
16894
|
+
file_id: { type: "string" },
|
|
16895
|
+
mime_type: { type: "string" },
|
|
16896
|
+
file_name: { type: "string" }
|
|
16897
|
+
},
|
|
16898
|
+
required: ["download_url", "file_id"]
|
|
16899
|
+
};
|
|
16900
|
+
var mediaTools = [
|
|
16901
|
+
tool({
|
|
16902
|
+
name: "wp_media_upload",
|
|
16903
|
+
description: "Put a picture in the WordPress site's media library: a picture from this chat (file) or a public https link to an image (url). JPEG, PNG, WebP, GIF or AVIF, up to 15 MB; checked as a real image. Give alt text that says what the picture shows. Returns its id and source_url; then use the url in a section (wp_section_update edits.images, or slots image) or as a page's featured_media. Doesn't change any page by itself.",
|
|
16904
|
+
parameters: {
|
|
16905
|
+
type: "object",
|
|
16906
|
+
properties: {
|
|
16907
|
+
file: CHAT_FILE,
|
|
16908
|
+
url: str2("Public https link to the image file."),
|
|
16909
|
+
filename: str2("File name to save it as (default from the link), e.g. maggie-hero.webp."),
|
|
16910
|
+
alt: str2("Alt text: what the picture shows, in one short sentence."),
|
|
16911
|
+
title: str2("Media title (optional)."),
|
|
16912
|
+
caption: str2("Caption (optional).")
|
|
16913
|
+
}
|
|
16914
|
+
},
|
|
16915
|
+
group: "site",
|
|
16916
|
+
need: WRITE,
|
|
16917
|
+
risk: "W2",
|
|
16918
|
+
gates: [...GATES],
|
|
16919
|
+
fileParams: ["file"],
|
|
16920
|
+
confirm: (a, lang) => {
|
|
16921
|
+
const name = a.file?.file_name ?? (typeof a.url === "string" ? a.url.split("/").pop() : "");
|
|
16922
|
+
return say(
|
|
16923
|
+
lang,
|
|
16924
|
+
`Upload the picture ${q2(name, "")} to your website's media library.`,
|
|
16925
|
+
`\u628A\u5716\u7247 ${q2(name, "")} \u4E0A\u50B3\u5230\u7DB2\u7AD9\u7684\u5A92\u9AD4\u5EAB\u3002`
|
|
16926
|
+
);
|
|
16927
|
+
},
|
|
16928
|
+
run: async (ctx, a) => {
|
|
16929
|
+
const site = siteOf(ctx);
|
|
16930
|
+
if (!site.uploadMedia) throw new Error("This connection can't upload pictures.");
|
|
16931
|
+
const chat = a.file;
|
|
16932
|
+
const url = String(chat?.download_url ?? a.url ?? "").trim();
|
|
16933
|
+
if (!url) throw new Error("Give the picture: a file from this chat, or a public https link (url).");
|
|
16934
|
+
const out = await site.uploadMedia({
|
|
16935
|
+
url,
|
|
16936
|
+
filename: a.filename || chat?.file_name || void 0,
|
|
16937
|
+
alt: a.alt || void 0,
|
|
16938
|
+
title: a.title || void 0,
|
|
16939
|
+
caption: a.caption || void 0
|
|
16940
|
+
});
|
|
16941
|
+
return {
|
|
16942
|
+
uploaded: true,
|
|
16943
|
+
...out,
|
|
16944
|
+
next: "Use source_url in wp_section_update (edits.images from \u2192 to, or slots image), or set it as the page's featured_media with wp_page_update."
|
|
16945
|
+
};
|
|
16946
|
+
}
|
|
16947
|
+
}),
|
|
16948
|
+
tool({
|
|
16949
|
+
name: "wp_media_list",
|
|
16950
|
+
description: "Pictures already in the WordPress media library (newest first), with id, link, alt text and size; search by words in the title or file name.",
|
|
16951
|
+
parameters: {
|
|
16952
|
+
type: "object",
|
|
16953
|
+
properties: {
|
|
16954
|
+
search: str2("Words in the title or file name."),
|
|
16955
|
+
limit: int2("How many (default 30, at most 100).")
|
|
16956
|
+
}
|
|
16957
|
+
},
|
|
16958
|
+
group: "site",
|
|
16959
|
+
need: READ,
|
|
16960
|
+
risk: "R0",
|
|
16961
|
+
gates: ["WP"],
|
|
16962
|
+
run: async (ctx, a) => {
|
|
16963
|
+
const rows = await call(siteOf(ctx), {
|
|
16964
|
+
path: "/wp/v2/media",
|
|
16965
|
+
query: {
|
|
16966
|
+
search: a.search || void 0,
|
|
16967
|
+
per_page: Math.min(100, Math.max(1, Number(a.limit) || 30)),
|
|
16968
|
+
media_type: "image",
|
|
16969
|
+
_fields: "id,source_url,alt_text,mime_type,date,title,media_details"
|
|
16970
|
+
}
|
|
16971
|
+
});
|
|
16972
|
+
return {
|
|
16973
|
+
count: rows.length,
|
|
16974
|
+
items: rows.map((m) => ({
|
|
16975
|
+
id: m.id,
|
|
16976
|
+
url: m.source_url,
|
|
16977
|
+
alt: m.alt_text,
|
|
16978
|
+
type: m.mime_type,
|
|
16979
|
+
size: m.media_details?.width ? `${m.media_details.width}\xD7${m.media_details.height}` : null,
|
|
16980
|
+
date: m.date
|
|
16981
|
+
}))
|
|
16982
|
+
};
|
|
16983
|
+
}
|
|
16984
|
+
})
|
|
16985
|
+
];
|
|
16986
|
+
var childInfo = (site, slug) => call(site, { path: "/maggie-core/v1/child-theme", query: { slug } });
|
|
16987
|
+
async function childSlug(site, slug) {
|
|
16988
|
+
if (typeof slug === "string" && slug) return slug;
|
|
16989
|
+
const info = await childInfo(site);
|
|
16990
|
+
const active = info.children.find((c) => c.active);
|
|
16991
|
+
if (!active)
|
|
16992
|
+
throw new Error("No Maggie child theme is active here: name one (slug), or create one with wp_child_theme_create.");
|
|
16993
|
+
return active.slug;
|
|
16994
|
+
}
|
|
16995
|
+
async function homeShape(site) {
|
|
16996
|
+
if (!site.fetchPage || !site.url) return null;
|
|
16997
|
+
const r = await site.fetchPage(`${site.url}/?maggie-check=${Date.now()}`).catch(() => null);
|
|
16998
|
+
if (!r) return null;
|
|
16999
|
+
const text3 = r.html.replace(/<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>/g, " ").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
|
17000
|
+
const fonts = [
|
|
17001
|
+
...new Set([...r.html.matchAll(/font-family:\s*([^;"}]+)/g)].map((m) => m[1].trim()))
|
|
17002
|
+
].sort();
|
|
17003
|
+
const colors = [...r.html.matchAll(/--wp--preset--color--([a-z0-9-]+):\s*([^;]+);/g)].map((m) => `${m[1]}=${m[2].trim()}`).sort();
|
|
17004
|
+
return { status: r.status, words: new Set(text3.toLowerCase().split(" ")), fonts, colors };
|
|
17005
|
+
}
|
|
17006
|
+
function compareShapes(a, b) {
|
|
17007
|
+
if (!a || !b) return { checked: false };
|
|
17008
|
+
const shared = [...a.words].filter((w) => b.words.has(w)).length;
|
|
17009
|
+
const text3 = Math.round(shared / Math.max(1, Math.max(a.words.size, b.words.size)) * 100);
|
|
17010
|
+
const fontsSame = a.fonts.join("|") === b.fonts.join("|");
|
|
17011
|
+
const colorsSame = a.colors.join("|") === b.colors.join("|");
|
|
17012
|
+
const problems = [
|
|
17013
|
+
b.status >= 400 ? `the homepage answers HTTP ${b.status}` : "",
|
|
17014
|
+
text3 < 90 ? `only ${text3}% of the homepage's words are the same` : "",
|
|
17015
|
+
fontsSame ? "" : "the fonts changed",
|
|
17016
|
+
colorsSame ? "" : "the brand colours changed"
|
|
17017
|
+
].filter(Boolean);
|
|
17018
|
+
return {
|
|
17019
|
+
checked: true,
|
|
17020
|
+
same_text_percent: text3,
|
|
17021
|
+
fonts_same: fontsSame,
|
|
17022
|
+
colors_same: colorsSame,
|
|
17023
|
+
ok: !problems.length,
|
|
17024
|
+
problems
|
|
17025
|
+
};
|
|
17026
|
+
}
|
|
17027
|
+
var PATTERN_SCHEMA = obj("A pattern for the editor's inserter.", {
|
|
17028
|
+
slug: str2("File name: lowercase, dashes (e.g. spring-offer)."),
|
|
17029
|
+
title: str2("Name shown in the inserter."),
|
|
17030
|
+
content: str2("Block markup (e.g. a section from wp_section_library written out, or copied from a page)."),
|
|
17031
|
+
description: str2(""),
|
|
17032
|
+
categories: arr("Pattern categories (default maggie).", { type: "string" })
|
|
17033
|
+
});
|
|
17034
|
+
var childTools = [
|
|
17035
|
+
tool({
|
|
17036
|
+
name: "wp_child_theme_status",
|
|
17037
|
+
description: "The site's Maggie child themes (Maggie Core is the parent): which is active, their files and backups, and whether files can be written on this host. With slug: that child's text files in full (theme.json, style.css, parts, patterns) to read before changing them.",
|
|
17038
|
+
parameters: { type: "object", properties: { slug: str2("A child theme to read in full, e.g. maggie-moonlake.") } },
|
|
17039
|
+
group: "site",
|
|
17040
|
+
need: READ,
|
|
17041
|
+
risk: "R0",
|
|
17042
|
+
gates: ["WP"],
|
|
17043
|
+
run: async (ctx, a) => {
|
|
17044
|
+
const info = await childInfo(siteOf(ctx), a.slug || void 0);
|
|
17045
|
+
const files = info.files ? Object.fromEntries(
|
|
17046
|
+
Object.entries(info.files).map(([k, v]) => [k, v.length > 2e4 ? `${v.slice(0, 2e4)}\u2026` : v])
|
|
17047
|
+
) : void 0;
|
|
17048
|
+
return {
|
|
17049
|
+
...info,
|
|
17050
|
+
children: info.children.map((c) => ({
|
|
17051
|
+
...c,
|
|
17052
|
+
files: Object.keys(c.files).length,
|
|
17053
|
+
file_list: Object.keys(c.files)
|
|
17054
|
+
})),
|
|
17055
|
+
...files ? { files } : {}
|
|
17056
|
+
};
|
|
17057
|
+
}
|
|
17058
|
+
}),
|
|
17059
|
+
tool({
|
|
17060
|
+
name: "wp_child_theme_create",
|
|
17061
|
+
description: "Create a child theme of Maggie Core for this site (slug maggie-<name>), by default from the site's current look: its Global Styles (colours, fonts with their files, styles), edited header and footer. Data files only, never code. activate: true switches the site to it and checks the homepage looks the same. Switching keeps the look only because it is copied into the child; Global Styles and edited parts are kept per theme in WordPress.",
|
|
17062
|
+
parameters: {
|
|
17063
|
+
type: "object",
|
|
17064
|
+
properties: {
|
|
17065
|
+
slug: str2("maggie-<name>, e.g. maggie-moonlake."),
|
|
17066
|
+
name: str2("Theme name, e.g. Moon Lake Spa."),
|
|
17067
|
+
from_site: bool("Copy the site's current look into it (default true)."),
|
|
17068
|
+
activate: bool("Switch the site to it now (default false).")
|
|
17069
|
+
},
|
|
17070
|
+
required: ["slug", "name"]
|
|
17071
|
+
},
|
|
17072
|
+
group: "site",
|
|
17073
|
+
need: WRITE,
|
|
17074
|
+
risk: "W3",
|
|
17075
|
+
gates: [...GATES],
|
|
17076
|
+
confirm: (a, lang) => say(
|
|
17077
|
+
lang,
|
|
17078
|
+
`Create the child theme ${q2(a.slug, "")} on your website${a.activate ? " and switch to it" : ""}.`,
|
|
17079
|
+
`\u5728\u7DB2\u7AD9\u4E0A\u5EFA\u7ACB\u5B50\u4E3B\u984C ${q2(a.slug, "")}${a.activate ? "\u4E26\u5207\u63DB\u904E\u53BB" : ""}\u3002`
|
|
17080
|
+
),
|
|
17081
|
+
run: async (ctx, a) => {
|
|
17082
|
+
const site = siteOf(ctx);
|
|
17083
|
+
const out = await call(site, {
|
|
17084
|
+
method: "POST",
|
|
17085
|
+
path: "/maggie-core/v1/child-theme",
|
|
17086
|
+
body: { slug: a.slug, name: a.name, from_site: a.from_site !== false }
|
|
17087
|
+
});
|
|
17088
|
+
if (!a.activate)
|
|
17089
|
+
return {
|
|
17090
|
+
...out,
|
|
17091
|
+
next: "Switch to it with wp_child_theme_activate when ready (the tool checks the homepage looks the same)."
|
|
17092
|
+
};
|
|
17093
|
+
const before = await homeShape(site);
|
|
17094
|
+
const act2 = await call(site, {
|
|
17095
|
+
method: "POST",
|
|
17096
|
+
path: "/maggie-core/v1/child-theme/activate",
|
|
17097
|
+
body: { slug: out.slug }
|
|
17098
|
+
});
|
|
17099
|
+
return { ...out, activated: act2, check: compareShapes(before, await homeShape(site)) };
|
|
17100
|
+
}
|
|
17101
|
+
}),
|
|
17102
|
+
tool({
|
|
17103
|
+
name: "wp_child_theme_update",
|
|
17104
|
+
description: "Change a Maggie child theme's files (default: the active child): brand colours (by name, hex) and fonts (by name, CSS font-family of fonts already in it) in theme.json, extra theme.json styles, plain CSS (replace or append), the header or footer (block markup), templates, and the site's own patterns (shown in the editor's inserter). Data only, never code. Each change keeps a backup (wp_child_theme_restore). Changes made with wp_style_set / wp_header_set are stored in the database and win over the child's files.",
|
|
17105
|
+
parameters: {
|
|
17106
|
+
type: "object",
|
|
17107
|
+
properties: {
|
|
17108
|
+
slug: str2("The child theme (default the active one)."),
|
|
17109
|
+
colors: obj('e.g. { "olive": "#4F5B48" }.'),
|
|
17110
|
+
fonts: obj('Font families by slug: { "body": "Jost, sans-serif" } (files must already be in the child).'),
|
|
17111
|
+
styles: obj('theme.json styles to merge in (e.g. { "elements": { "button": { \u2026 } } }).'),
|
|
17112
|
+
css: str2("Replace the child's CSS (plain CSS)."),
|
|
17113
|
+
css_append: str2("Add CSS at the end."),
|
|
17114
|
+
header: str2("Header block markup (parts/header.html)."),
|
|
17115
|
+
footer: str2("Footer block markup (parts/footer.html)."),
|
|
17116
|
+
templates: obj('Templates by slug \u2192 block markup, e.g. { "page-landing": "\u2026" }.'),
|
|
17117
|
+
patterns: arr("Patterns to add or replace.", PATTERN_SCHEMA),
|
|
17118
|
+
remove_patterns: arr("Pattern slugs to remove.", { type: "string" })
|
|
17119
|
+
}
|
|
17120
|
+
},
|
|
17121
|
+
group: "site",
|
|
17122
|
+
need: WRITE,
|
|
17123
|
+
risk: "W3",
|
|
17124
|
+
gates: [...GATES],
|
|
17125
|
+
confirm: (a, lang) => say(
|
|
17126
|
+
lang,
|
|
17127
|
+
`Change the child theme ${q2(a.slug, "(the active one)")} on your website.`,
|
|
17128
|
+
`\u4FEE\u6539\u7DB2\u7AD9\u7684\u5B50\u4E3B\u984C ${q2(a.slug, "\uFF08\u76EE\u524D\u4F7F\u7528\u4E2D\u7684\uFF09")}\u3002`
|
|
17129
|
+
),
|
|
17130
|
+
run: async (ctx, a) => {
|
|
17131
|
+
const site = siteOf(ctx);
|
|
17132
|
+
const slug = await childSlug(site, a.slug);
|
|
17133
|
+
const info = await childInfo(site, slug);
|
|
17134
|
+
const cur = info.files ?? {};
|
|
17135
|
+
const files = {};
|
|
17136
|
+
const notes = [];
|
|
17137
|
+
if (a.colors || a.fonts || a.styles) {
|
|
17138
|
+
const tj = JSON.parse(cur["theme.json"] ?? '{"version":3,"settings":{}}');
|
|
17139
|
+
tj.settings = tj.settings ?? {};
|
|
17140
|
+
if (a.colors) {
|
|
17141
|
+
let palette = tj.settings.color?.palette ?? [];
|
|
17142
|
+
if (!palette.length) palette = ((await styleGet(site))._palette ?? []).map((c) => ({ ...c }));
|
|
17143
|
+
for (const [slugC, color] of Object.entries(a.colors)) {
|
|
17144
|
+
if (!/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(color))
|
|
17145
|
+
throw new Error(`${slugC}: "${color}" isn't a hex colour.`);
|
|
17146
|
+
const c = palette.find((x) => x.slug === slugC);
|
|
17147
|
+
if (c) c.color = color;
|
|
17148
|
+
else
|
|
17149
|
+
palette.push({
|
|
17150
|
+
slug: slugC,
|
|
17151
|
+
color,
|
|
17152
|
+
name: slugC.replace(/-/g, " ").replace(/\b\w/g, (m) => m.toUpperCase())
|
|
17153
|
+
});
|
|
17154
|
+
}
|
|
17155
|
+
const map = Object.fromEntries(palette.map((c) => [c.slug, c.color]));
|
|
17156
|
+
const bad = contrastReport(map).filter((r) => !r.ok);
|
|
17157
|
+
if (bad.length)
|
|
17158
|
+
notes.push(`Low contrast: ${bad.map((r) => `${r.text} on ${r.background} ${r.ratio}:1`).join(", ")}.`);
|
|
17159
|
+
tj.settings.color = { ...tj.settings.color ?? {}, palette };
|
|
17160
|
+
}
|
|
17161
|
+
if (a.fonts) {
|
|
17162
|
+
const fams = tj.settings.typography?.fontFamilies ?? [];
|
|
17163
|
+
if (!fams.length)
|
|
17164
|
+
throw new Error(
|
|
17165
|
+
"This child has no font list yet (fonts come from the parent): create it from the site first, then change fonts."
|
|
17166
|
+
);
|
|
17167
|
+
for (const [slugF, family] of Object.entries(a.fonts)) {
|
|
17168
|
+
const f = fams.find((x) => x.slug === slugF);
|
|
17169
|
+
if (!f) throw new Error(`No font "${slugF}" in the child. Fonts: ${fams.map((x) => x.slug).join(", ")}`);
|
|
17170
|
+
f.fontFamily = family;
|
|
17171
|
+
}
|
|
17172
|
+
}
|
|
17173
|
+
if (a.styles)
|
|
17174
|
+
tj.styles = deepMerge(tj.styles ?? {}, a.styles);
|
|
17175
|
+
files["theme.json"] = JSON.stringify(tj, null, " ");
|
|
17176
|
+
}
|
|
17177
|
+
if (typeof a.css === "string") files["style.css"] = a.css;
|
|
17178
|
+
if (typeof a.css_append === "string")
|
|
17179
|
+
files["style.css"] = `${(files["style.css"] ?? cur["style.css"] ?? "").replace(/\n*$/, "\n")}${a.css_append}
|
|
17180
|
+
`;
|
|
17181
|
+
const markup = (path3, m) => {
|
|
17182
|
+
if (typeof m !== "string" || !m.trim()) return;
|
|
17183
|
+
if (!parse(m).some((b) => b.name)) throw new Error(`${path3}: give block markup (<!-- wp:\u2026 -->).`);
|
|
17184
|
+
files[path3] = m;
|
|
17185
|
+
};
|
|
17186
|
+
markup("parts/header.html", a.header);
|
|
17187
|
+
markup("parts/footer.html", a.footer);
|
|
17188
|
+
for (const [t, m] of Object.entries(a.templates ?? {}))
|
|
17189
|
+
markup(`templates/${t}.html`, m);
|
|
17190
|
+
for (const p of a.patterns ?? []) {
|
|
17191
|
+
if (!/^[a-z0-9][a-z0-9-]{0,60}$/.test(p.slug ?? ""))
|
|
17192
|
+
throw new Error(`Pattern slug "${p.slug}": lowercase letters, digits and dashes.`);
|
|
17193
|
+
files[`maggie-patterns/${p.slug}.json`] = JSON.stringify({
|
|
17194
|
+
title: p.title,
|
|
17195
|
+
content: p.content,
|
|
17196
|
+
description: p.description ?? "",
|
|
17197
|
+
categories: p.categories?.length ? p.categories : ["maggie"]
|
|
17198
|
+
});
|
|
17199
|
+
}
|
|
17200
|
+
const del = (a.remove_patterns ?? []).map((x) => `maggie-patterns/${x}.json`);
|
|
17201
|
+
if (!Object.keys(files).length && !del.length) throw new Error("Nothing to change.");
|
|
17202
|
+
const out = await call(site, {
|
|
17203
|
+
method: "POST",
|
|
17204
|
+
path: "/maggie-core/v1/child-theme",
|
|
17205
|
+
body: { slug, files, delete: del }
|
|
17206
|
+
});
|
|
17207
|
+
return {
|
|
17208
|
+
slug,
|
|
17209
|
+
...out,
|
|
17210
|
+
...notes.length ? { notes } : {},
|
|
17211
|
+
...out.active ? {} : { note: "This child isn't active: switch with wp_child_theme_activate to see it." }
|
|
17212
|
+
};
|
|
17213
|
+
}
|
|
17214
|
+
}),
|
|
17215
|
+
tool({
|
|
17216
|
+
name: "wp_child_theme_activate",
|
|
17217
|
+
description: "Switch the site to a Maggie child theme, or back to Maggie Core (slug maggie-core). Checks the homepage before and after (words, fonts, brand colours) and reports differences. WordPress keeps Global Styles and edited header/footer per theme: a child made with wp_child_theme_create from the site carries them; switching back to Maggie Core brings its own back.",
|
|
17218
|
+
parameters: {
|
|
17219
|
+
type: "object",
|
|
17220
|
+
properties: { slug: str2("maggie-<name>, or maggie-core to go back.") },
|
|
17221
|
+
required: ["slug"]
|
|
17222
|
+
},
|
|
17223
|
+
group: "site",
|
|
17224
|
+
need: WRITE,
|
|
17225
|
+
risk: "W3",
|
|
17226
|
+
gates: [...GATES],
|
|
17227
|
+
confirm: (a, lang) => say(lang, `Switch your website's theme to ${q2(a.slug, "")}.`, `\u628A\u7DB2\u7AD9\u7684\u4E3B\u984C\u5207\u63DB\u70BA ${q2(a.slug, "")}\u3002`),
|
|
17228
|
+
run: async (ctx, a) => {
|
|
17229
|
+
const site = siteOf(ctx);
|
|
17230
|
+
const before = await homeShape(site);
|
|
17231
|
+
const out = await call(site, {
|
|
17232
|
+
method: "POST",
|
|
17233
|
+
path: "/maggie-core/v1/child-theme/activate",
|
|
17234
|
+
body: { slug: a.slug }
|
|
17235
|
+
});
|
|
17236
|
+
const check2 = compareShapes(before, await homeShape(site));
|
|
17237
|
+
return {
|
|
17238
|
+
...out,
|
|
17239
|
+
check: check2,
|
|
17240
|
+
...check2.checked && !check2.ok ? { undo: `Switch back with wp_child_theme_activate slug ${out.before}.` } : {}
|
|
17241
|
+
};
|
|
17242
|
+
}
|
|
17243
|
+
}),
|
|
17244
|
+
tool({
|
|
17245
|
+
name: "wp_child_theme_restore",
|
|
17246
|
+
description: "Put a child theme's files back as they were before one change (backup ids from wp_child_theme_status). The current files are backed up first, so a restore can be undone too.",
|
|
17247
|
+
parameters: {
|
|
17248
|
+
type: "object",
|
|
17249
|
+
properties: { slug: str2("The child theme."), backup: str2("Backup id, e.g. 20261104-183208-728.") },
|
|
17250
|
+
required: ["slug", "backup"]
|
|
17251
|
+
},
|
|
17252
|
+
group: "site",
|
|
17253
|
+
need: WRITE,
|
|
17254
|
+
risk: "W3",
|
|
17255
|
+
gates: [...GATES],
|
|
17256
|
+
confirm: (a, lang) => say(
|
|
17257
|
+
lang,
|
|
17258
|
+
`Restore the child theme ${q2(a.slug, "")} to backup ${String(a.backup ?? "")}.`,
|
|
17259
|
+
`\u628A\u5B50\u4E3B\u984C ${q2(a.slug, "")} \u9084\u539F\u5230\u5099\u4EFD ${String(a.backup ?? "")}\u3002`
|
|
17260
|
+
),
|
|
17261
|
+
run: (ctx, a) => call(siteOf(ctx), {
|
|
17262
|
+
method: "POST",
|
|
17263
|
+
path: "/maggie-core/v1/child-theme/restore",
|
|
17264
|
+
body: { slug: a.slug, backup: a.backup }
|
|
17265
|
+
})
|
|
17266
|
+
})
|
|
17267
|
+
];
|
|
17268
|
+
function deepMerge(a, b) {
|
|
17269
|
+
const out = { ...a };
|
|
17270
|
+
for (const [k, v] of Object.entries(b)) {
|
|
17271
|
+
const cur = out[k];
|
|
17272
|
+
out[k] = v && typeof v === "object" && !Array.isArray(v) && cur && typeof cur === "object" && !Array.isArray(cur) ? deepMerge(cur, v) : v;
|
|
17273
|
+
}
|
|
17274
|
+
return out;
|
|
17275
|
+
}
|
|
16806
17276
|
var seoFields = {
|
|
16807
17277
|
title: str2("SEO title (\u224860 characters; the site name is added by the theme)."),
|
|
16808
17278
|
description: str2("Meta description (\u2248150-160 characters)."),
|
|
@@ -17058,7 +17528,15 @@ var elementTools = [
|
|
|
17058
17528
|
}
|
|
17059
17529
|
})
|
|
17060
17530
|
];
|
|
17061
|
-
var wordpressTools = [
|
|
17531
|
+
var wordpressTools = [
|
|
17532
|
+
...siteTools,
|
|
17533
|
+
...pageTools,
|
|
17534
|
+
...sectionTools,
|
|
17535
|
+
...mediaTools,
|
|
17536
|
+
...childTools,
|
|
17537
|
+
...seoTools,
|
|
17538
|
+
...elementTools
|
|
17539
|
+
];
|
|
17062
17540
|
|
|
17063
17541
|
// ../../packages/tools/src/index.ts
|
|
17064
17542
|
var tools = [
|
|
@@ -20468,7 +20946,7 @@ var program = new Command();
|
|
|
20468
20946
|
// package.json
|
|
20469
20947
|
var package_default = {
|
|
20470
20948
|
name: "@cmo-so/cli",
|
|
20471
|
-
version: "0.12.
|
|
20949
|
+
version: "0.12.3",
|
|
20472
20950
|
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.",
|
|
20473
20951
|
type: "module",
|
|
20474
20952
|
license: "MIT",
|
|
@@ -31582,6 +32060,32 @@ ${table(r.issues, ["severity", "code", "path", "detail"])}`;
|
|
|
31582
32060
|
fonts: o.font ? Object.fromEntries(pairs(o.font)) : void 0
|
|
31583
32061
|
})
|
|
31584
32062
|
);
|
|
32063
|
+
const media = wp.command("media").description("pictures in the media library");
|
|
32064
|
+
media.command("ls", { isDefault: true }).option("--search <words>").option("-n, --limit <n>", "how many", "30").action(
|
|
32065
|
+
(o, c) => run(g(c), "wp_media_list", { search: o.search, limit: Number(o.limit) })
|
|
32066
|
+
);
|
|
32067
|
+
media.command("upload <fileOrUrl>").description("upload a picture (a local file or a public https link): JPEG, PNG, WebP, GIF or AVIF").option("--alt <text>", "what the picture shows").option("--filename <name>", "save it as").option("--title <title>").action(async (src, o, c) => {
|
|
32068
|
+
const opts = g(c);
|
|
32069
|
+
if (/^https?:\/\//i.test(src))
|
|
32070
|
+
return run(opts, "wp_media_upload", { url: src, alt: o.alt, filename: o.filename, title: o.title });
|
|
32071
|
+
const data = new Uint8Array(
|
|
32072
|
+
await readFile2(src).catch(() => {
|
|
32073
|
+
throw new CliError(`Can't read ${src}.`);
|
|
32074
|
+
})
|
|
32075
|
+
);
|
|
32076
|
+
const site = await siteFor(opts);
|
|
32077
|
+
if (!site.uploadMedia) throw new CliError("This connection can't upload pictures.");
|
|
32078
|
+
const preview = `Upload ${src.split("/").pop()} (${Math.round(data.length / 1024)} KB) to the media library of ${site.url}.`;
|
|
32079
|
+
if (opts.dryRun) {
|
|
32080
|
+
say2(`Would: ${preview}`);
|
|
32081
|
+
return;
|
|
32082
|
+
}
|
|
32083
|
+
if (!opts.yes && !await ask(preview)) throw new CliError("Nothing changed.", 1);
|
|
32084
|
+
print(
|
|
32085
|
+
await site.uploadMedia({ data, filename: o.filename ?? src.split("/").pop(), alt: o.alt, title: o.title }),
|
|
32086
|
+
opts.json
|
|
32087
|
+
);
|
|
32088
|
+
});
|
|
31585
32089
|
wp.command("pattern <pattern>").description("change a synced pattern every page shares (contact, share, quote or id)").option("--replace <find=replace...>").option("--link <from=to...>").action(
|
|
31586
32090
|
(p, o, c) => run(g(c), "wp_pattern_update", {
|
|
31587
32091
|
pattern: p,
|
|
@@ -31591,7 +32095,32 @@ ${table(r.issues, ["severity", "code", "path", "detail"])}`;
|
|
|
31591
32095
|
}
|
|
31592
32096
|
})
|
|
31593
32097
|
);
|
|
31594
|
-
wp.command("child
|
|
32098
|
+
const child = wp.command("child").description("the site's Maggie child theme, on the site itself (data files only, backups kept)");
|
|
32099
|
+
child.command("status [slug]", { isDefault: true }).description("child themes, their files and backups; with a slug, its files in full").action((slug, _o, c) => run(g(c), "wp_child_theme_status", { slug }));
|
|
32100
|
+
child.command("create <slug>").description("create maggie-<name> from the site's current look").requiredOption("--name <name>").option("--empty", "start empty instead of from the site").option("--activate", "switch the site to it (checks the homepage looks the same)").action(
|
|
32101
|
+
(slug, o, c) => run(g(c), "wp_child_theme_create", { slug, name: o.name, from_site: !o.empty, activate: !!o.activate })
|
|
32102
|
+
);
|
|
32103
|
+
child.command("update [slug]").description(
|
|
32104
|
+
"change the child: colours, fonts, CSS, header/footer, templates, patterns (JSON file with the tool's arguments)"
|
|
32105
|
+
).option(
|
|
32106
|
+
"--from <file>",
|
|
32107
|
+
"JSON: { colors, fonts, styles, css, css_append, header, footer, templates, patterns, remove_patterns }"
|
|
32108
|
+
).option("--color <slug=hex...>").option("--css-file <file>", "replace the CSS with this file").option("--header-file <file>", "header block markup").option("--footer-file <file>", "footer block markup").action(
|
|
32109
|
+
async (slug, o, c) => {
|
|
32110
|
+
const args = o.from ? await json(o.from) : {};
|
|
32111
|
+
if (slug) args.slug = slug;
|
|
32112
|
+
if (o.color) args.colors = Object.fromEntries(pairs(o.color));
|
|
32113
|
+
if (o.cssFile) args.css = await readFile2(o.cssFile, "utf8");
|
|
32114
|
+
if (o.headerFile) args.header = await readFile2(o.headerFile, "utf8");
|
|
32115
|
+
if (o.footerFile) args.footer = await readFile2(o.footerFile, "utf8");
|
|
32116
|
+
await run(g(c), "wp_child_theme_update", args);
|
|
32117
|
+
}
|
|
32118
|
+
);
|
|
32119
|
+
child.command("activate <slug>").description("switch the site to a child (or maggie-core to go back); checks the homepage before and after").action((slug, _o, c) => run(g(c), "wp_child_theme_activate", { slug }));
|
|
32120
|
+
child.command("restore <slug> <backup>").description("put a backup of the child's files back").action((slug, backup, _o, c) => run(g(c), "wp_child_theme_restore", { slug, backup }));
|
|
32121
|
+
wp.command("child-theme <slug>").description(
|
|
32122
|
+
"export the site's look as a child theme folder on this computer (to keep in git); cmo wp child works on the site itself"
|
|
32123
|
+
).requiredOption("--name <name>", 'theme name, e.g. "Moon Lake Spa"').option("--out <dir>", "where to write it (default ./<slug>)").option("--from-site", "take the brand, header and footer from the connected site").option("--color <slug=hex...>", "brand colours").option("--font <slug=family...>", "fonts (replaces Maggie Core's font list: ship their files too)").action(
|
|
31595
32124
|
async (slug, o, c) => {
|
|
31596
32125
|
const opts = g(c);
|
|
31597
32126
|
const colors = Object.fromEntries(pairs(o.color));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cmo-so/cli",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.3",
|
|
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",
|