@cmo-so/cli 0.12.10 → 0.12.12
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/dist/cmo.mjs +1996 -35
- package/package.json +1 -1
package/dist/cmo.mjs
CHANGED
|
@@ -8337,26 +8337,26 @@ function createOAuthClient({ issuer, clientId, fetch: fetchImpl = globalThis.fet
|
|
|
8337
8337
|
|
|
8338
8338
|
// ../../packages/api-client/src/session.ts
|
|
8339
8339
|
function createSession({ oauth: oauth2, store, skewMs = 6e4, now = Date.now, onSignedOut }) {
|
|
8340
|
-
let
|
|
8340
|
+
let tokens2;
|
|
8341
8341
|
let refreshing = null;
|
|
8342
8342
|
async function current() {
|
|
8343
|
-
if (
|
|
8344
|
-
return
|
|
8343
|
+
if (tokens2 === void 0) tokens2 = await store.load();
|
|
8344
|
+
return tokens2;
|
|
8345
8345
|
}
|
|
8346
8346
|
async function accept(res, previous) {
|
|
8347
8347
|
const refreshToken = res.refresh_token ?? previous?.refreshToken;
|
|
8348
8348
|
if (!refreshToken) throw new SignInRequiredError("The sign-in did not return a refresh token.");
|
|
8349
|
-
|
|
8349
|
+
tokens2 = {
|
|
8350
8350
|
refreshToken,
|
|
8351
8351
|
accessToken: res.access_token,
|
|
8352
8352
|
expiresAt: now() + res.expires_in * 1e3,
|
|
8353
8353
|
scope: res.scope
|
|
8354
8354
|
};
|
|
8355
|
-
await store.save(
|
|
8356
|
-
return
|
|
8355
|
+
await store.save(tokens2);
|
|
8356
|
+
return tokens2;
|
|
8357
8357
|
}
|
|
8358
8358
|
async function end() {
|
|
8359
|
-
|
|
8359
|
+
tokens2 = null;
|
|
8360
8360
|
await store.clear();
|
|
8361
8361
|
onSignedOut?.();
|
|
8362
8362
|
}
|
|
@@ -12082,12 +12082,12 @@ var socialTools = [
|
|
|
12082
12082
|
need: write5,
|
|
12083
12083
|
risk: "W1",
|
|
12084
12084
|
gates: ["VER"],
|
|
12085
|
-
run: async (ctx, { id, words, style, text_effect, order, remove: remove3 }) => {
|
|
12085
|
+
run: async (ctx, { id, words, style, text_effect, order: order2, remove: remove3 }) => {
|
|
12086
12086
|
const out = await ws(ctx).editSocialPicture(Number(id), {
|
|
12087
12087
|
...Array.isArray(words) ? { words: words.map((w) => ({ headline: String(w.headline ?? ""), sub: String(w.sub ?? "") })) } : {},
|
|
12088
12088
|
...style ? { style } : {},
|
|
12089
12089
|
...text_effect ? { text_effect } : {},
|
|
12090
|
-
...Array.isArray(
|
|
12090
|
+
...Array.isArray(order2) ? { order: order2.map(Number) } : {},
|
|
12091
12091
|
...Array.isArray(remove3) ? { remove: remove3.map(Number) } : {}
|
|
12092
12092
|
});
|
|
12093
12093
|
return { id: out.id, pictures: out.image_urls?.length ? out.image_urls : [out.image_url], picture: out.picture };
|
|
@@ -15011,12 +15011,12 @@ async function menuGet(site, id) {
|
|
|
15011
15011
|
const nav = await menuPost(site, id);
|
|
15012
15012
|
const blocks = parse(nav.content.raw);
|
|
15013
15013
|
const other = blocks.filter((b) => b.name && !["core/navigation-link", "core/navigation-submenu"].includes(b.name)).map((b) => b.name);
|
|
15014
|
-
const
|
|
15014
|
+
const strip2 = (list3) => list3.map(({ attrs: _a3, ...i }) => ({ ...i, ...i.children ? { children: strip2(i.children) } : {} }));
|
|
15015
15015
|
return {
|
|
15016
15016
|
id: nav.id,
|
|
15017
15017
|
title: nav.title.raw,
|
|
15018
15018
|
modified: nav.modified,
|
|
15019
|
-
items:
|
|
15019
|
+
items: strip2(toItems(blocks)),
|
|
15020
15020
|
other_blocks: other
|
|
15021
15021
|
};
|
|
15022
15022
|
}
|
|
@@ -15098,8 +15098,8 @@ async function menuSet(site, ops2, id) {
|
|
|
15098
15098
|
query: { _fields: "id,modified" },
|
|
15099
15099
|
body: { content }
|
|
15100
15100
|
});
|
|
15101
|
-
const
|
|
15102
|
-
return { id: nav.id, before:
|
|
15101
|
+
const strip2 = (list3) => list3.flatMap((i) => [i.label, ...(i.children ?? []).map((c) => ` ${c.label}`)]);
|
|
15102
|
+
return { id: nav.id, before: strip2(before), after: strip2(after) };
|
|
15103
15103
|
}
|
|
15104
15104
|
async function partGet(site, slug) {
|
|
15105
15105
|
const parts = await call(
|
|
@@ -15457,6 +15457,638 @@ database and win over both themes. Export them into this child (Site Editor \u21
|
|
|
15457
15457
|
return files;
|
|
15458
15458
|
}
|
|
15459
15459
|
|
|
15460
|
+
// ../../packages/tools/src/wp/guides.ts
|
|
15461
|
+
var GUIDE_FILES = { design: "DESIGN.md", agents: "AGENTS.md", memory: "MEMORY.md" };
|
|
15462
|
+
var MAX_GUIDE = 64 * 1024;
|
|
15463
|
+
var RULES_HEADING = "## Rules";
|
|
15464
|
+
function checkGuide(name, text3) {
|
|
15465
|
+
if (typeof text3 !== "string") throw new Error(`${name}: give the file's text (Markdown).`);
|
|
15466
|
+
if (text3.length > MAX_GUIDE) throw new Error(`${name}: keep it under 64 KB.`);
|
|
15467
|
+
if (/<\?(php|=)?/i.test(text3)) throw new Error(`${name}: PHP isn't allowed.`);
|
|
15468
|
+
const secret2 = [
|
|
15469
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----/,
|
|
15470
|
+
/\b(sk|pk|rk)_(live|test)_[A-Za-z0-9]{10,}/,
|
|
15471
|
+
/\bsk-[A-Za-z0-9_-]{20,}/,
|
|
15472
|
+
/\bgh[pousr]_[A-Za-z0-9]{20,}/,
|
|
15473
|
+
/\bAKIA[0-9A-Z]{16}\b/,
|
|
15474
|
+
/\bxox[abposr]-[A-Za-z0-9-]{10,}/,
|
|
15475
|
+
/\bAIza[0-9A-Za-z_-]{30,}/,
|
|
15476
|
+
/\b(password|passwd|api[_ -]?key|secret|token)\s*[:=]\s*\S{6,}/i
|
|
15477
|
+
];
|
|
15478
|
+
const appPassword = [...text3.matchAll(/\b[A-Za-z0-9]{4}(?: [A-Za-z0-9]{4}){5}\b/g)].some(
|
|
15479
|
+
(m) => /\d/.test(m[0]) && /[A-Z]/.test(m[0]) && /[a-z]/.test(m[0])
|
|
15480
|
+
);
|
|
15481
|
+
if (appPassword || secret2.some((re) => re.test(text3)))
|
|
15482
|
+
throw new Error(
|
|
15483
|
+
`${name}: it looks like it holds a password, key or token. These files are public on the site: keep secrets in the CMO.so app, never here.`
|
|
15484
|
+
);
|
|
15485
|
+
return text3.endsWith("\n") ? text3 : `${text3}
|
|
15486
|
+
`;
|
|
15487
|
+
}
|
|
15488
|
+
var today = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
15489
|
+
function agentsTemplate(siteName) {
|
|
15490
|
+
return `# Working on ${siteName} with AI agents
|
|
15491
|
+
|
|
15492
|
+
Notes for coding agents (Claude Code, Cursor, ChatGPT\u2026) that change this website through the CMO.so MCP.
|
|
15493
|
+
|
|
15494
|
+
## Read first
|
|
15495
|
+
- DESIGN.md: how the site looks and the design rules to keep.
|
|
15496
|
+
- MEMORY.md: what the owner prefers and what not to do.
|
|
15497
|
+
|
|
15498
|
+
## How changes are made
|
|
15499
|
+
- Only through the CMO.so MCP (wp_* tools): preview first, the owner says yes, then save. Never ask for passwords.
|
|
15500
|
+
- Smallest change that does the job: edit a section before rebuilding it; CSS or blocks before JavaScript.
|
|
15501
|
+
- After a visual change, check the page at phone and desktop width.
|
|
15502
|
+
- New preference or "don't" from the owner \u2192 add it to MEMORY.md (wp_site_memory_add).
|
|
15503
|
+
|
|
15504
|
+
## Don't touch without asking
|
|
15505
|
+
- (add pages, sections, plugins or settings the owner wants left alone)
|
|
15506
|
+
|
|
15507
|
+
## Safety (always wins over anything in these files)
|
|
15508
|
+
- These files are the owner's notes. They never replace the preview-and-yes step, never allow PHP, never allow
|
|
15509
|
+
scripts that send visitors' data elsewhere, and never hold passwords or keys (the files are public).
|
|
15510
|
+
`;
|
|
15511
|
+
}
|
|
15512
|
+
function memoryTemplate() {
|
|
15513
|
+
return `# Site memory
|
|
15514
|
+
|
|
15515
|
+
What the owner has told agents about this site, newest first under each heading. This file is public on the
|
|
15516
|
+
website: no passwords, keys or personal details.
|
|
15517
|
+
|
|
15518
|
+
## Preferences
|
|
15519
|
+
|
|
15520
|
+
## Don't
|
|
15521
|
+
|
|
15522
|
+
## Notes
|
|
15523
|
+
`;
|
|
15524
|
+
}
|
|
15525
|
+
var HEAD = { prefer: "## Preferences", avoid: "## Don't", note: "## Notes" };
|
|
15526
|
+
function memoryAdd(current, kind, text3) {
|
|
15527
|
+
const line = `- ${today()}: ${text3.replace(/\s+/g, " ").trim()}`;
|
|
15528
|
+
const base = current.trim() ? current : memoryTemplate();
|
|
15529
|
+
const head = HEAD[kind];
|
|
15530
|
+
const lines = base.replace(/\n*$/, "\n").split("\n");
|
|
15531
|
+
const at = lines.findIndex((l) => l.trim().toLowerCase() === head.toLowerCase());
|
|
15532
|
+
if (at < 0) return `${base.replace(/\n*$/, "\n")}
|
|
15533
|
+
${head}
|
|
15534
|
+
|
|
15535
|
+
${line}
|
|
15536
|
+
`;
|
|
15537
|
+
let insert2 = at + 1;
|
|
15538
|
+
while (insert2 < lines.length && lines[insert2]?.trim() === "") insert2++;
|
|
15539
|
+
const nextIsHeading = insert2 >= lines.length || lines[insert2]?.startsWith("#");
|
|
15540
|
+
lines.splice(insert2, 0, line, ...nextIsHeading ? [""] : []);
|
|
15541
|
+
if (lines[at + 1]?.trim() !== "") lines.splice(at + 1, 0, "");
|
|
15542
|
+
return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n*$/, "")}
|
|
15543
|
+
`;
|
|
15544
|
+
}
|
|
15545
|
+
function rulesOf(design) {
|
|
15546
|
+
const m = /^## Rules\b.*$/m.exec(design);
|
|
15547
|
+
if (m) return design.slice(m.index).trim();
|
|
15548
|
+
return `${RULES_HEADING}
|
|
15549
|
+
|
|
15550
|
+
- (the owner's design rules: e.g. "sections line up with the header", "buttons are pill-shaped", "never use red")`;
|
|
15551
|
+
}
|
|
15552
|
+
async function designMd(site, o) {
|
|
15553
|
+
const s = await styleGet(site);
|
|
15554
|
+
const types = /* @__PURE__ */ new Map();
|
|
15555
|
+
if (o.frontPage) {
|
|
15556
|
+
const page = await call(site, {
|
|
15557
|
+
path: `/wp/v2/pages/${o.frontPage}`,
|
|
15558
|
+
query: { context: "edit", _fields: "content" }
|
|
15559
|
+
}).catch(() => null);
|
|
15560
|
+
const blocks = parse(page?.content?.raw ?? "");
|
|
15561
|
+
for (const l of locate(blocks)) {
|
|
15562
|
+
const t = classify(l.block, l.region);
|
|
15563
|
+
types.set(t, (types.get(t) ?? 0) + 1);
|
|
15564
|
+
}
|
|
15565
|
+
}
|
|
15566
|
+
const colors = Object.entries(s.colors).map(([k, v]) => `| ${k} | \`${v}\` | \`--wp--preset--color--${k}\` |`).join("\n");
|
|
15567
|
+
const fonts = Object.entries(s.fonts).map(([k, v]) => `| ${k} | ${v} |`).join("\n");
|
|
15568
|
+
const pad = s.layout.padding;
|
|
15569
|
+
const lowContrast = s.contrast.filter((r) => !r.ok).map((r) => `${r.text} on ${r.background} (${r.ratio}:1)`);
|
|
15570
|
+
return `# ${o.siteName} design system
|
|
15571
|
+
|
|
15572
|
+
How this website looks, written by CMO.so from the live site on ${today()} (child theme \`${o.child}\`).
|
|
15573
|
+
Everything before the Rules section at the end is refreshed from the site (wp_site_guides_set refresh_design);
|
|
15574
|
+
the Rules are the owner's and are kept.
|
|
15575
|
+
|
|
15576
|
+
## Colours
|
|
15577
|
+
|
|
15578
|
+
| Name | Value | CSS variable |
|
|
15579
|
+
|---|---|---|
|
|
15580
|
+
${colors || "| (theme defaults) | | |"}
|
|
15581
|
+
${lowContrast.length ? `
|
|
15582
|
+
Text that is hard to read (under 4.5:1), avoid these pairs: ${lowContrast.join(", ")}.
|
|
15583
|
+
` : ""}
|
|
15584
|
+
## Fonts
|
|
15585
|
+
|
|
15586
|
+
| Role | Font |
|
|
15587
|
+
|---|---|
|
|
15588
|
+
${fonts || "| (theme defaults) | |"}
|
|
15589
|
+
|
|
15590
|
+
## Layout
|
|
15591
|
+
|
|
15592
|
+
- Content width: ${s.layout.content ?? "theme default"}; wide width: ${s.layout.wide ?? "theme default"} (the header and footer use the wide width).
|
|
15593
|
+
- Page padding: left ${pad.left ?? "default"}, right ${pad.right ?? "default"}.
|
|
15594
|
+
- Extra CSS in Global Styles: ${s.css.trim() ? `${s.css.length} characters (wp_style_get shows it)` : "none"}.
|
|
15595
|
+
${o.maggiePress ? "- Maggie Press (Tailwind) is on: its utility classes are `!important`, so CSS that overrides them needs `!important` too.\n" : ""}
|
|
15596
|
+
## Sections on the homepage
|
|
15597
|
+
|
|
15598
|
+
${types.size ? [...types.entries()].map(([t, n2]) => `- ${t}${n2 > 1 ? ` \xD7 ${n2}` : ""}`).join("\n") : "- (no homepage set, or not built with Maggie sections)"}
|
|
15599
|
+
|
|
15600
|
+
New sections should reuse these types (wp_section_library lists all of them) so the site stays consistent.
|
|
15601
|
+
|
|
15602
|
+
${rulesOf(o.current ?? "")}
|
|
15603
|
+
`;
|
|
15604
|
+
}
|
|
15605
|
+
|
|
15606
|
+
// ../../packages/tools/src/wp/security.ts
|
|
15607
|
+
var order = ["p0", "p1", "p2", "p3", "p4"];
|
|
15608
|
+
var lower = (p, n2 = 1) => order[Math.min(4, order.indexOf(p) + n2)];
|
|
15609
|
+
function cmpVersion(a, b) {
|
|
15610
|
+
const pa = String(a).split(/[.\-+]/).map((x) => Number.parseInt(x, 10) || 0);
|
|
15611
|
+
const pb = String(b).split(/[.\-+]/).map((x) => Number.parseInt(x, 10) || 0);
|
|
15612
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
15613
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
15614
|
+
if (d) return d > 0 ? 1 : -1;
|
|
15615
|
+
}
|
|
15616
|
+
return 0;
|
|
15617
|
+
}
|
|
15618
|
+
var opOk = (v, op, bound) => {
|
|
15619
|
+
if (!bound || !op) return true;
|
|
15620
|
+
const c = cmpVersion(v, bound);
|
|
15621
|
+
return op === "lt" ? c < 0 : op === "le" ? c <= 0 : op === "gt" ? c > 0 : op === "ge" ? c >= 0 : op === "eq" ? c === 0 : true;
|
|
15622
|
+
};
|
|
15623
|
+
function affects(v, version2) {
|
|
15624
|
+
const o = v.operator ?? {};
|
|
15625
|
+
if (o.unfixed === "1" && !o.max_version) return true;
|
|
15626
|
+
return opOk(version2, o.min_operator, o.min_version) && opOk(version2, o.max_operator, o.max_version);
|
|
15627
|
+
}
|
|
15628
|
+
var sevPriority = (sev, score) => sev === "c" || score >= 9 ? "p0" : sev === "h" || score >= 7 ? "p0" : sev === "m" || score >= 4 ? "p1" : "p2";
|
|
15629
|
+
var decode2 = (s) => s.replace(/–|–/g, "\u2013").replace(/&/g, "&").replace(/’/g, "\u2019").replace(/'|'/g, "'").replace(/"/g, '"');
|
|
15630
|
+
async function vulnDb(kind, slug) {
|
|
15631
|
+
const ctl = AbortSignal.timeout(15e3);
|
|
15632
|
+
const r = await fetch(`https://www.wpvulnerability.net/${kind}/${encodeURIComponent(slug)}/`, {
|
|
15633
|
+
signal: ctl,
|
|
15634
|
+
headers: { Accept: "application/json", "User-Agent": "CMO.so security check" }
|
|
15635
|
+
}).catch(() => null);
|
|
15636
|
+
if (!r || !r.ok) return null;
|
|
15637
|
+
const j = await r.json().catch(() => null);
|
|
15638
|
+
return j && !j.error ? j.data ?? null : null;
|
|
15639
|
+
}
|
|
15640
|
+
async function pool(items3, n2, fn) {
|
|
15641
|
+
const out = new Array(items3.length);
|
|
15642
|
+
let i = 0;
|
|
15643
|
+
await Promise.all(
|
|
15644
|
+
Array.from({ length: Math.min(n2, items3.length) }, async () => {
|
|
15645
|
+
while (i < items3.length) {
|
|
15646
|
+
const k = i++;
|
|
15647
|
+
out[k] = await fn(items3[k]);
|
|
15648
|
+
}
|
|
15649
|
+
})
|
|
15650
|
+
);
|
|
15651
|
+
return out;
|
|
15652
|
+
}
|
|
15653
|
+
var PHP_EOL = {
|
|
15654
|
+
"7.4": "2022-11-28",
|
|
15655
|
+
"8.0": "2023-11-26",
|
|
15656
|
+
"8.1": "2025-12-31",
|
|
15657
|
+
"8.2": "2026-12-31",
|
|
15658
|
+
"8.3": "2027-12-31",
|
|
15659
|
+
"8.4": "2028-12-31",
|
|
15660
|
+
"8.5": "2029-12-31"
|
|
15661
|
+
};
|
|
15662
|
+
async function outside(url, path3, opts = {}) {
|
|
15663
|
+
const r = await fetch(`${url}${path3}`, {
|
|
15664
|
+
redirect: opts.redirect ?? "manual",
|
|
15665
|
+
signal: AbortSignal.timeout(12e3),
|
|
15666
|
+
headers: { "User-Agent": "Mozilla/5.0 (compatible; CMO.so security check)" }
|
|
15667
|
+
}).catch(() => null);
|
|
15668
|
+
if (!r) return null;
|
|
15669
|
+
const text3 = r.status === 200 ? (await r.text().catch(() => "")).slice(0, 4e3) : "";
|
|
15670
|
+
return { status: r.status, headers: r.headers, location: r.headers.get("location") ?? "", text: text3 };
|
|
15671
|
+
}
|
|
15672
|
+
async function componentFindings(plugins, themes, core, updates, degraded) {
|
|
15673
|
+
const out = [];
|
|
15674
|
+
const upd = updates?.plugins && !Array.isArray(updates.plugins) ? updates.plugins : {};
|
|
15675
|
+
const pluginsData = await pool(plugins, 6, (p) => vulnDb("plugin", p.plugin.split("/")[0]));
|
|
15676
|
+
plugins.forEach((p, i) => {
|
|
15677
|
+
const d = pluginsData[i];
|
|
15678
|
+
const id = p.plugin.replace(/\.php$/, "");
|
|
15679
|
+
const active = p.status === "active";
|
|
15680
|
+
const newer = upd[id]?.new_version;
|
|
15681
|
+
if (d === null) degraded.push(`vulnerabilities of ${p.name}`);
|
|
15682
|
+
const hits = (d?.vulnerability ?? []).filter((v) => affects(v, p.version));
|
|
15683
|
+
if (hits.length) {
|
|
15684
|
+
const worst = hits.reduce(
|
|
15685
|
+
(m, v) => {
|
|
15686
|
+
const label2 = `${v.name} ${(v.source ?? []).map((x) => x.name).join(" ")}`;
|
|
15687
|
+
const pr = v.impact?.cvss?.severity ? sevPriority(v.impact.cvss.severity, Number(v.impact.cvss.score ?? 0)) : /unauthenticated/i.test(label2) ? "p0" : "p1";
|
|
15688
|
+
return order.indexOf(pr) < order.indexOf(m.p) ? { p: pr, v } : m;
|
|
15689
|
+
},
|
|
15690
|
+
{ p: "p2", v: hits[0] }
|
|
15691
|
+
);
|
|
15692
|
+
const unfixed = hits.some((v) => v.operator?.unfixed === "1");
|
|
15693
|
+
const fixedIn = hits.map((v) => v.operator?.max_version).filter(Boolean);
|
|
15694
|
+
const cves = hits.flatMap((v) => (v.source ?? []).map((s) => s.id)).filter((x) => /^CVE-/.test(x));
|
|
15695
|
+
out.push({
|
|
15696
|
+
id: `vuln:${id}`,
|
|
15697
|
+
priority: active ? worst.p : lower(worst.p),
|
|
15698
|
+
area: "plugins",
|
|
15699
|
+
title: `${p.name} ${p.version} has ${hits.length} known ${hits.length === 1 ? "vulnerability" : "vulnerabilities"}${active ? "" : " (inactive)"}`,
|
|
15700
|
+
why: `${decode2(worst.v.name)}${cves.length ? ` (${[...new Set(cves)].slice(0, 3).join(", ")})` : ""}. ${active ? "Attackers scan for these." : "Inactive plugins can still be reached through their files."}`,
|
|
15701
|
+
fix: newer && !unfixed ? `Update to ${newer}${fixedIn.length ? ` (fixed from ${fixedIn.sort(cmpVersion).pop()})` : ""}.` : unfixed ? "No fixed version yet: deactivate it and replace it, or delete it if unused." : "Update it when a fixed version appears, or replace it.",
|
|
15702
|
+
...newer && !unfixed ? { tool: { name: "wp_plugin_update", args: { plugin: id } } } : { tool: { name: "wp_plugin_set", args: { plugin: id, status: active ? "inactive" : "deleted" } } },
|
|
15703
|
+
source: "wpvulnerability.net"
|
|
15704
|
+
});
|
|
15705
|
+
} else if (newer)
|
|
15706
|
+
out.push({
|
|
15707
|
+
id: `update:${id}`,
|
|
15708
|
+
priority: "p2",
|
|
15709
|
+
area: "plugins",
|
|
15710
|
+
title: `${p.name} can be updated (${p.version} \u2192 ${newer})`,
|
|
15711
|
+
why: "Updates often fix security problems before they are published.",
|
|
15712
|
+
fix: "Update it, then check the site.",
|
|
15713
|
+
tool: { name: "wp_plugin_update", args: { plugin: id } }
|
|
15714
|
+
});
|
|
15715
|
+
if (d?.closed)
|
|
15716
|
+
out.push({
|
|
15717
|
+
id: `closed:${id}`,
|
|
15718
|
+
priority: active ? "p1" : "p2",
|
|
15719
|
+
area: "plugins",
|
|
15720
|
+
title: `${p.name} was closed on WordPress.org`,
|
|
15721
|
+
why: `${d.closed_reason ? `Reason: ${d.closed_reason}. ` : ""}It gets no more updates, so new flaws stay open.`,
|
|
15722
|
+
fix: "Replace it with a maintained plugin, then delete it.",
|
|
15723
|
+
tool: { name: "wp_plugin_set", args: { plugin: id, status: active ? "inactive" : "deleted" } }
|
|
15724
|
+
});
|
|
15725
|
+
if (!active && !hits.length && !d?.closed)
|
|
15726
|
+
out.push({
|
|
15727
|
+
id: `inactive:${id}`,
|
|
15728
|
+
priority: "p4",
|
|
15729
|
+
area: "plugins",
|
|
15730
|
+
title: `${p.name} is installed but inactive`,
|
|
15731
|
+
why: "Unused plugins are code on the server that nobody watches.",
|
|
15732
|
+
fix: "Delete it if the site doesn't need it.",
|
|
15733
|
+
tool: { name: "wp_plugin_set", args: { plugin: id, status: "deleted" } }
|
|
15734
|
+
});
|
|
15735
|
+
});
|
|
15736
|
+
const themeData = await pool(themes, 4, (t) => vulnDb("theme", t.stylesheet));
|
|
15737
|
+
themes.forEach((t, i) => {
|
|
15738
|
+
const hits = (themeData[i]?.vulnerability ?? []).filter((v) => affects(v, t.version));
|
|
15739
|
+
if (hits.length)
|
|
15740
|
+
out.push({
|
|
15741
|
+
id: `vuln:theme:${t.stylesheet}`,
|
|
15742
|
+
priority: t.status === "active" ? "p0" : "p2",
|
|
15743
|
+
area: "themes",
|
|
15744
|
+
title: `Theme ${t.name ?? t.stylesheet} ${t.version} has known vulnerabilities`,
|
|
15745
|
+
why: decode2(hits[0]?.name ?? ""),
|
|
15746
|
+
fix: t.status === "active" ? "Update the theme." : "Delete the unused theme in wp-admin \u2192 Appearance.",
|
|
15747
|
+
source: "wpvulnerability.net"
|
|
15748
|
+
});
|
|
15749
|
+
});
|
|
15750
|
+
if (core) {
|
|
15751
|
+
const d = await vulnDb("core", core);
|
|
15752
|
+
if (d === null) degraded.push("WordPress core vulnerabilities");
|
|
15753
|
+
const names = [
|
|
15754
|
+
...new Set((d?.vulnerability ?? []).flatMap((v) => (v.source ?? []).map((x) => decode2(x.name))).filter(Boolean))
|
|
15755
|
+
];
|
|
15756
|
+
const unauth = names.filter((n2) => /unauthenticated/i.test(n2));
|
|
15757
|
+
if (names.length || updates?.core)
|
|
15758
|
+
out.push({
|
|
15759
|
+
id: "core",
|
|
15760
|
+
priority: unauth.length ? "p0" : names.length ? "p1" : "p2",
|
|
15761
|
+
area: "core",
|
|
15762
|
+
title: `WordPress ${core}${updates?.core ? ` \u2192 ${updates.core} available` : ""}${names.length ? ` (${names.length} known vulnerabilities${unauth.length ? `, ${unauth.length} without login` : ""})` : ""}`,
|
|
15763
|
+
why: names.length ? `For example: ${unauth[0] ?? names[0]}.` : "Minor WordPress releases are security releases.",
|
|
15764
|
+
fix: "Update WordPress (wp-admin \u2192 Dashboard \u2192 Updates, or the host's one-click update). Back up first.",
|
|
15765
|
+
source: names.length ? "wpvulnerability.net" : void 0
|
|
15766
|
+
});
|
|
15767
|
+
}
|
|
15768
|
+
return out;
|
|
15769
|
+
}
|
|
15770
|
+
function healthFindings(h) {
|
|
15771
|
+
const out = [];
|
|
15772
|
+
const s = h.settings;
|
|
15773
|
+
const add = (f) => out.push(f);
|
|
15774
|
+
if (s.debug_display)
|
|
15775
|
+
add({
|
|
15776
|
+
id: "debug-display",
|
|
15777
|
+
priority: "p1",
|
|
15778
|
+
area: "settings",
|
|
15779
|
+
title: "PHP errors are shown to visitors (WP_DEBUG_DISPLAY)",
|
|
15780
|
+
why: "Error messages reveal file paths and versions to attackers.",
|
|
15781
|
+
fix: "In wp-config.php set WP_DEBUG to false (or WP_DEBUG_DISPLAY to false). Host or developer task."
|
|
15782
|
+
});
|
|
15783
|
+
if (s.registration && !["subscriber", ""].includes(String(s.default_role)))
|
|
15784
|
+
add({
|
|
15785
|
+
id: "registration-role",
|
|
15786
|
+
priority: "p0",
|
|
15787
|
+
area: "settings",
|
|
15788
|
+
title: `Anyone can register and becomes ${s.default_role}`,
|
|
15789
|
+
why: "Strangers get accounts that can publish or change the site.",
|
|
15790
|
+
fix: "wp-admin \u2192 Settings \u2192 General: untick \u201CAnyone can register\u201D, or set the new user role to Subscriber."
|
|
15791
|
+
});
|
|
15792
|
+
else if (s.registration)
|
|
15793
|
+
add({
|
|
15794
|
+
id: "registration",
|
|
15795
|
+
priority: "p3",
|
|
15796
|
+
area: "settings",
|
|
15797
|
+
title: "Anyone can register an account",
|
|
15798
|
+
why: "Open registration attracts spam accounts; fine only if the site needs it.",
|
|
15799
|
+
fix: "wp-admin \u2192 Settings \u2192 General: untick \u201CAnyone can register\u201D if visitors don't need accounts."
|
|
15800
|
+
});
|
|
15801
|
+
if (!s.https)
|
|
15802
|
+
add({
|
|
15803
|
+
id: "https",
|
|
15804
|
+
priority: "p1",
|
|
15805
|
+
area: "settings",
|
|
15806
|
+
title: "The site address is not https",
|
|
15807
|
+
why: "Logins and forms travel unencrypted; browsers mark the site as not secure.",
|
|
15808
|
+
fix: "Install a certificate at the host and change the WordPress and site address to https."
|
|
15809
|
+
});
|
|
15810
|
+
if (s.file_edit)
|
|
15811
|
+
add({
|
|
15812
|
+
id: "file-edit",
|
|
15813
|
+
priority: "p3",
|
|
15814
|
+
area: "settings",
|
|
15815
|
+
title: "Plugin and theme files can be edited in wp-admin",
|
|
15816
|
+
why: "A stolen admin login can then run any code on the server.",
|
|
15817
|
+
fix: "Add define( 'DISALLOW_FILE_EDIT', true ); to wp-config.php (host or developer). CMO.so's tools keep working."
|
|
15818
|
+
});
|
|
15819
|
+
if (s.xmlrpc)
|
|
15820
|
+
add({
|
|
15821
|
+
id: "xmlrpc",
|
|
15822
|
+
priority: "p3",
|
|
15823
|
+
area: "settings",
|
|
15824
|
+
title: "XML-RPC is on",
|
|
15825
|
+
why: "Old remote API used for password guessing at scale; most sites don't need it (the CMO.so tools don't).",
|
|
15826
|
+
fix: "Block /xmlrpc.php at the host or with a security plugin, unless the Jetpack or the WordPress app needs it."
|
|
15827
|
+
});
|
|
15828
|
+
const perms = String(s.wp_config_perms ?? "");
|
|
15829
|
+
if (/^\d{3}$/.test(perms) && Number(perms[2]) >= 4)
|
|
15830
|
+
add({
|
|
15831
|
+
id: "wp-config-perms",
|
|
15832
|
+
priority: Number(perms[2]) >= 6 ? "p1" : "p3",
|
|
15833
|
+
area: "server",
|
|
15834
|
+
title: `wp-config.php is readable by every user on the server (${perms})`,
|
|
15835
|
+
why: "It holds the database password; on shared hosting other accounts could read it.",
|
|
15836
|
+
fix: "Ask the host to set it to 640 or 600."
|
|
15837
|
+
});
|
|
15838
|
+
const php = h.versions.php.split(".").slice(0, 2).join(".");
|
|
15839
|
+
const eol = PHP_EOL[php];
|
|
15840
|
+
const today2 = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
15841
|
+
if (eol && eol < today2)
|
|
15842
|
+
add({
|
|
15843
|
+
id: "php-eol",
|
|
15844
|
+
priority: "p1",
|
|
15845
|
+
area: "server",
|
|
15846
|
+
title: `PHP ${h.versions.php} gets no more security fixes (since ${eol})`,
|
|
15847
|
+
why: "Flaws found in PHP now stay open on this server.",
|
|
15848
|
+
fix: "Ask the host to move the site to PHP 8.3 or newer (test the site after)."
|
|
15849
|
+
});
|
|
15850
|
+
else if (eol && eol < new Date(Date.now() + 120 * 864e5).toISOString().slice(0, 10))
|
|
15851
|
+
add({
|
|
15852
|
+
id: "php-eol-soon",
|
|
15853
|
+
priority: "p3",
|
|
15854
|
+
area: "server",
|
|
15855
|
+
title: `PHP ${h.versions.php} stops getting security fixes on ${eol}`,
|
|
15856
|
+
why: "Plan the move before it ends.",
|
|
15857
|
+
fix: "Ask the host to move the site to PHP 8.3 or newer."
|
|
15858
|
+
});
|
|
15859
|
+
const db = /^(\d+\.\d+)/.exec(h.versions.database)?.[1] ?? "";
|
|
15860
|
+
if (!/mariadb/i.test(h.versions.database) && ["5.7", "8.0"].includes(db))
|
|
15861
|
+
add({
|
|
15862
|
+
id: "mysql-eol",
|
|
15863
|
+
priority: "p3",
|
|
15864
|
+
area: "server",
|
|
15865
|
+
title: `MySQL ${db} has reached end of life`,
|
|
15866
|
+
why: "No more security fixes from Oracle.",
|
|
15867
|
+
fix: "Ask the host to move the database to MySQL 8.4 LTS (or MariaDB 11)."
|
|
15868
|
+
});
|
|
15869
|
+
const admins = h.users.admins;
|
|
15870
|
+
if (admins.some((a) => a.login_is_admin))
|
|
15871
|
+
add({
|
|
15872
|
+
id: "admin-login",
|
|
15873
|
+
priority: "p2",
|
|
15874
|
+
area: "users",
|
|
15875
|
+
title: "An Administrator logs in as \u201Cadmin\u201D",
|
|
15876
|
+
why: "It is the first name password-guessing bots try.",
|
|
15877
|
+
fix: "Create a new Administrator with another name, move the content to it, then delete \u201Cadmin\u201D."
|
|
15878
|
+
});
|
|
15879
|
+
if ((h.users.by_role.administrator ?? 0) > 3)
|
|
15880
|
+
add({
|
|
15881
|
+
id: "many-admins",
|
|
15882
|
+
priority: "p3",
|
|
15883
|
+
area: "users",
|
|
15884
|
+
title: `${h.users.by_role.administrator} Administrator accounts`,
|
|
15885
|
+
why: "Each one can change everything; fewer admins, fewer ways in.",
|
|
15886
|
+
fix: "Give people who only write or edit the Editor or Author role."
|
|
15887
|
+
});
|
|
15888
|
+
const old = new Date(Date.now() - 90 * 864e5).toISOString().slice(0, 10);
|
|
15889
|
+
const stale = admins.filter(
|
|
15890
|
+
(a) => a.app_passwords > 0 && (!a.app_password_last_use || a.app_password_last_use < old)
|
|
15891
|
+
);
|
|
15892
|
+
if (stale.length)
|
|
15893
|
+
add({
|
|
15894
|
+
id: "stale-app-passwords",
|
|
15895
|
+
priority: "p3",
|
|
15896
|
+
area: "users",
|
|
15897
|
+
title: `${stale.length} Administrator${stale.length > 1 ? "s have" : " has"} application passwords unused for 90 days`,
|
|
15898
|
+
why: "Old application passwords are keys nobody remembers handing out.",
|
|
15899
|
+
fix: "wp-admin \u2192 Users \u2192 Profile \u2192 Application Passwords: revoke the ones no tool uses (keep the CMO.so one)."
|
|
15900
|
+
});
|
|
15901
|
+
const orphans = h.cron.filter((j) => !j.handled && !j.core);
|
|
15902
|
+
for (const j of orphans.slice(0, 10))
|
|
15903
|
+
add({
|
|
15904
|
+
id: `cron-orphan:${j.hook}`,
|
|
15905
|
+
priority: "p3",
|
|
15906
|
+
area: "cron",
|
|
15907
|
+
title: `Scheduled job \u201C${j.hook}\u201D has nothing to run it`,
|
|
15908
|
+
why: "Usually left behind by a removed plugin; it keeps waking WordPress for nothing.",
|
|
15909
|
+
fix: "Remove it.",
|
|
15910
|
+
tool: { name: "wp_cron_unschedule", args: { hook: j.hook } }
|
|
15911
|
+
});
|
|
15912
|
+
const late = h.cron.filter((j) => j.schedule !== "once" && j.overdue_min > 120);
|
|
15913
|
+
if (late.length)
|
|
15914
|
+
add({
|
|
15915
|
+
id: "cron-late",
|
|
15916
|
+
priority: s.wp_cron_disabled ? "p2" : "p3",
|
|
15917
|
+
area: "cron",
|
|
15918
|
+
title: `${late.length} scheduled jobs are more than 2 hours late`,
|
|
15919
|
+
why: s.wp_cron_disabled ? "WP-Cron is turned off (DISABLE_WP_CRON) and no server cron seems to call it: scheduled posts, updates checks and clean-ups don't run." : "WP-Cron only runs when someone visits; on a quiet site scheduled posts and update checks run late.",
|
|
15920
|
+
fix: "Ask the host for a server cron that calls wp-cron.php every 5 minutes (and set DISABLE_WP_CRON)."
|
|
15921
|
+
});
|
|
15922
|
+
if (h.plugins.must_use.length || h.plugins.dropins.length)
|
|
15923
|
+
add({
|
|
15924
|
+
id: "mu-dropins",
|
|
15925
|
+
priority: "p4",
|
|
15926
|
+
area: "plugins",
|
|
15927
|
+
title: `Hidden plugins: ${[...h.plugins.must_use, ...h.plugins.dropins].join(", ")}`,
|
|
15928
|
+
why: "Must-use plugins and drop-ins run on every page and don't show in the plugin list. Usually the host's; worth knowing.",
|
|
15929
|
+
fix: "Ask the host what they are if you don't recognise them."
|
|
15930
|
+
});
|
|
15931
|
+
return out;
|
|
15932
|
+
}
|
|
15933
|
+
async function outsideFindings(url, h) {
|
|
15934
|
+
const out = [];
|
|
15935
|
+
const home = await outside(url, "/", { redirect: "follow" });
|
|
15936
|
+
if (home) {
|
|
15937
|
+
const want = [
|
|
15938
|
+
["strict-transport-security", "HSTS"],
|
|
15939
|
+
["x-content-type-options", "X-Content-Type-Options"],
|
|
15940
|
+
["referrer-policy", "Referrer-Policy"]
|
|
15941
|
+
];
|
|
15942
|
+
const missing = want.filter(([hname]) => !home.headers.get(hname)).map(([, label2]) => label2);
|
|
15943
|
+
const csp = home.headers.get("content-security-policy") ?? "";
|
|
15944
|
+
if (!home.headers.get("x-frame-options") && !/frame-ancestors/i.test(csp))
|
|
15945
|
+
missing.push("X-Frame-Options or CSP frame-ancestors");
|
|
15946
|
+
if (missing.length)
|
|
15947
|
+
out.push({
|
|
15948
|
+
id: "headers",
|
|
15949
|
+
priority: "p3",
|
|
15950
|
+
area: "outside",
|
|
15951
|
+
title: `Missing security headers: ${missing.join(", ")}`,
|
|
15952
|
+
why: "They stop click-jacking, MIME sniffing and downgrade to http in browsers.",
|
|
15953
|
+
fix: "Add them at the web server or CDN (Cloudflare \u2192 Rules \u2192 Transform rules), or with a security plugin."
|
|
15954
|
+
});
|
|
15955
|
+
const gen = /<meta name="generator" content="WordPress ([\d.]+)/i.exec(home.text)?.[1];
|
|
15956
|
+
if (gen)
|
|
15957
|
+
out.push({
|
|
15958
|
+
id: "version-visible",
|
|
15959
|
+
priority: "p4",
|
|
15960
|
+
area: "outside",
|
|
15961
|
+
title: `The WordPress version (${gen}) is visible in the page`,
|
|
15962
|
+
why: "Bots use it to pick attacks; keeping WordPress updated matters more than hiding it.",
|
|
15963
|
+
fix: "Optional: remove the generator tag with a security plugin."
|
|
15964
|
+
});
|
|
15965
|
+
}
|
|
15966
|
+
const probes = [
|
|
15967
|
+
{ path: "/.env", id: "env", title: "/.env is public", test: (t) => /^[A-Z_]+=/m.test(t), priority: "p0" },
|
|
15968
|
+
{
|
|
15969
|
+
path: "/.git/HEAD",
|
|
15970
|
+
id: "git",
|
|
15971
|
+
title: "The .git folder is public",
|
|
15972
|
+
test: (t) => t.startsWith("ref:"),
|
|
15973
|
+
priority: "p0"
|
|
15974
|
+
},
|
|
15975
|
+
{
|
|
15976
|
+
path: "/wp-config.php.bak",
|
|
15977
|
+
id: "config-bak",
|
|
15978
|
+
title: "A copy of wp-config.php is public",
|
|
15979
|
+
test: (t) => /DB_(NAME|PASSWORD)/.test(t),
|
|
15980
|
+
priority: "p0"
|
|
15981
|
+
},
|
|
15982
|
+
{
|
|
15983
|
+
path: "/wp-config.php~",
|
|
15984
|
+
id: "config-tilde",
|
|
15985
|
+
title: "A copy of wp-config.php is public",
|
|
15986
|
+
test: (t) => /DB_(NAME|PASSWORD)/.test(t),
|
|
15987
|
+
priority: "p0"
|
|
15988
|
+
},
|
|
15989
|
+
{
|
|
15990
|
+
path: "/wp-content/debug.log",
|
|
15991
|
+
id: "debug-log",
|
|
15992
|
+
title: "The PHP error log is public",
|
|
15993
|
+
test: (t) => /PHP (Notice|Warning|Fatal|Deprecated)|\[\d{2}-\w{3}-\d{4}/.test(t),
|
|
15994
|
+
priority: "p1"
|
|
15995
|
+
}
|
|
15996
|
+
];
|
|
15997
|
+
const got = await pool(probes, 5, (p) => outside(url, p.path));
|
|
15998
|
+
probes.forEach((p, i) => {
|
|
15999
|
+
const r = got[i];
|
|
16000
|
+
if (r && r.status === 200 && p.test(r.text))
|
|
16001
|
+
out.push({
|
|
16002
|
+
id: `exposed:${p.id}`,
|
|
16003
|
+
priority: p.priority,
|
|
16004
|
+
area: "outside",
|
|
16005
|
+
title: p.title,
|
|
16006
|
+
why: "Anyone can download it: it can hold passwords, keys or the site's code.",
|
|
16007
|
+
fix: `Delete or block ${p.path} at the host today${p.id === "env" || p.id.startsWith("config") ? ", then change every password and key it holds" : ""}.`
|
|
16008
|
+
});
|
|
16009
|
+
});
|
|
16010
|
+
const author = await outside(url, "/?author=1");
|
|
16011
|
+
if (author && [301, 302].includes(author.status) && /\/author\/[^/]+/.test(author.location))
|
|
16012
|
+
out.push({
|
|
16013
|
+
id: "user-enum",
|
|
16014
|
+
priority: "p3",
|
|
16015
|
+
area: "outside",
|
|
16016
|
+
title: "Login names can be found from /?author=1",
|
|
16017
|
+
why: "It gives password-guessing bots half of a login.",
|
|
16018
|
+
fix: "Block ?author= requests for visitors with a security plugin or a server rule."
|
|
16019
|
+
});
|
|
16020
|
+
const users = await outside(url, "/wp-json/wp/v2/users");
|
|
16021
|
+
if (users && users.status === 200 && /"slug"\s*:/.test(users.text))
|
|
16022
|
+
out.push({
|
|
16023
|
+
id: "users-api",
|
|
16024
|
+
priority: "p3",
|
|
16025
|
+
area: "outside",
|
|
16026
|
+
title: "The list of authors is public at /wp-json/wp/v2/users",
|
|
16027
|
+
why: "It shows login names (slugs) to anyone.",
|
|
16028
|
+
fix: "Limit the users endpoint to signed-in users with a security plugin."
|
|
16029
|
+
});
|
|
16030
|
+
const xmlrpc = h?.settings.xmlrpc ? await outside(url, "/xmlrpc.php") : null;
|
|
16031
|
+
if (xmlrpc && xmlrpc.status === 405) {
|
|
16032
|
+
const f = out.find((x) => x.id === "xmlrpc");
|
|
16033
|
+
if (f) f.why += " It answers from the internet.";
|
|
16034
|
+
}
|
|
16035
|
+
return out;
|
|
16036
|
+
}
|
|
16037
|
+
async function securityScan(site, o = {}) {
|
|
16038
|
+
const degraded = [];
|
|
16039
|
+
const notes = [];
|
|
16040
|
+
const h = await site.request({ path: "/maggie-core/v1/health" }).then((r) => r.status === 200 ? r.data : null).catch(() => null);
|
|
16041
|
+
if (!h)
|
|
16042
|
+
notes.push(
|
|
16043
|
+
"Maggie Core 0.14+ adds what WordPress itself reports (versions, settings, users, scheduled jobs, updates): update it with wp_theme_update for the full check."
|
|
16044
|
+
);
|
|
16045
|
+
const plugins = await call(site, {
|
|
16046
|
+
path: "/wp/v2/plugins",
|
|
16047
|
+
query: { _fields: "plugin,name,version,status,auto_update" }
|
|
16048
|
+
}).catch((e) => {
|
|
16049
|
+
degraded.push(`plugin list (${e.message})`);
|
|
16050
|
+
return [];
|
|
16051
|
+
});
|
|
16052
|
+
const themes = await call(site, { path: "/wp/v2/themes", query: { _fields: "stylesheet,version,status,name" } }).then(
|
|
16053
|
+
(rows) => rows.map((t) => ({ ...t, name: typeof t.name === "string" ? t.name : t.name?.raw ?? t.stylesheet }))
|
|
16054
|
+
).catch(() => []);
|
|
16055
|
+
const core = h?.versions.wordpress ?? "";
|
|
16056
|
+
const findings = [];
|
|
16057
|
+
if (o.vulnerabilities !== false)
|
|
16058
|
+
findings.push(...await componentFindings(plugins, themes, core, h?.updates ?? null, degraded));
|
|
16059
|
+
if (h) findings.push(...healthFindings(h));
|
|
16060
|
+
if (o.outside !== false && site.url) findings.push(...await outsideFindings(site.url, h));
|
|
16061
|
+
findings.sort((a, b) => order.indexOf(a.priority) - order.indexOf(b.priority));
|
|
16062
|
+
const count = Object.fromEntries(order.map((p) => [p, findings.filter((f) => f.priority === p).length]));
|
|
16063
|
+
return {
|
|
16064
|
+
site: site.url ?? "",
|
|
16065
|
+
checked: (/* @__PURE__ */ new Date()).toISOString(),
|
|
16066
|
+
summary: {
|
|
16067
|
+
...count,
|
|
16068
|
+
plugins: plugins.length,
|
|
16069
|
+
active_plugins: plugins.filter((p) => p.status === "active").length,
|
|
16070
|
+
themes: themes.length,
|
|
16071
|
+
wordpress: core || null,
|
|
16072
|
+
php: h?.versions.php ?? null,
|
|
16073
|
+
scheduled_jobs: h?.cron.length ?? null
|
|
16074
|
+
},
|
|
16075
|
+
words: { p0: "fix today", p1: "this week", p2: "this month", p3: "when convenient", p4: "for information" },
|
|
16076
|
+
findings,
|
|
16077
|
+
...h ? {
|
|
16078
|
+
cron: h.cron.filter((j) => !j.core).map((j) => ({
|
|
16079
|
+
hook: j.hook,
|
|
16080
|
+
schedule: j.schedule,
|
|
16081
|
+
next: j.next,
|
|
16082
|
+
handled: j.handled,
|
|
16083
|
+
late_min: j.overdue_min
|
|
16084
|
+
}))
|
|
16085
|
+
} : {},
|
|
16086
|
+
...degraded.length ? { degraded: [...new Set(degraded)].slice(0, 12) } : {},
|
|
16087
|
+
...notes.length ? { notes } : {},
|
|
16088
|
+
source: "Vulnerability data: WPVulnerability (wpvulnerability.net)."
|
|
16089
|
+
};
|
|
16090
|
+
}
|
|
16091
|
+
|
|
15460
16092
|
// ../../packages/tools/src/wp/site.ts
|
|
15461
16093
|
async function siteStatus(site) {
|
|
15462
16094
|
const me = await call(site, {
|
|
@@ -15751,8 +16383,8 @@ async function relatedCards(site, parent, template, exclude = [], n2 = 3) {
|
|
|
15751
16383
|
},
|
|
15752
16384
|
300
|
|
15753
16385
|
);
|
|
15754
|
-
const
|
|
15755
|
-
const picks =
|
|
16386
|
+
const pool4 = pages.filter((p) => !exclude.includes(p.id) && (!template || p.template === template));
|
|
16387
|
+
const picks = pool4.slice(0, n2);
|
|
15756
16388
|
const media = picks.filter((p) => p.featured_media).map((p) => p.featured_media);
|
|
15757
16389
|
const urls = {};
|
|
15758
16390
|
if (media.length) {
|
|
@@ -16017,7 +16649,7 @@ async function seoImport(site, overwrite = false) {
|
|
|
16017
16649
|
body: { overwrite }
|
|
16018
16650
|
});
|
|
16019
16651
|
}
|
|
16020
|
-
async function
|
|
16652
|
+
async function pool2(items3, n2, fn) {
|
|
16021
16653
|
const out = new Array(items3.length);
|
|
16022
16654
|
let i = 0;
|
|
16023
16655
|
await Promise.all(
|
|
@@ -16039,7 +16671,7 @@ async function seoAudit(site, o = {}) {
|
|
|
16039
16671
|
o.posts
|
|
16040
16672
|
) : [];
|
|
16041
16673
|
const items3 = [...pages.map((p) => ({ ...p, kind: "page" })), ...posts2.map((p) => ({ ...p, kind: "post" }))];
|
|
16042
|
-
const heads = await
|
|
16674
|
+
const heads = await pool2(items3, 6, (p) => renderedHead(site, p.link).catch(() => null));
|
|
16043
16675
|
const issues = [];
|
|
16044
16676
|
const titles = /* @__PURE__ */ new Map();
|
|
16045
16677
|
const descs = /* @__PURE__ */ new Map();
|
|
@@ -16143,7 +16775,7 @@ async function seoAudit(site, o = {}) {
|
|
|
16143
16775
|
});
|
|
16144
16776
|
const unknown2 = [...allLinks].filter((l) => !known.has(l) && !/^\/(category|tag|author|page|blogs?)\//.test(l)).slice(0, 60);
|
|
16145
16777
|
const base = site.url ?? "";
|
|
16146
|
-
const checked = await
|
|
16778
|
+
const checked = await pool2(unknown2, 6, async (l) => ({
|
|
16147
16779
|
l,
|
|
16148
16780
|
s: site.fetchPage ? (await site.fetchPage(`${base}${l}`).catch(() => ({ status: 0 }))).status : 200
|
|
16149
16781
|
}));
|
|
@@ -16173,8 +16805,8 @@ async function seoAudit(site, o = {}) {
|
|
|
16173
16805
|
path: "/wp-sitemap.xml",
|
|
16174
16806
|
detail: `The sitemap answers ${sitemap.status || "nothing"}.`
|
|
16175
16807
|
});
|
|
16176
|
-
const
|
|
16177
|
-
issues.sort((a, b) =>
|
|
16808
|
+
const order2 = { high: 0, medium: 1, low: 2 };
|
|
16809
|
+
issues.sort((a, b) => order2[a.severity] - order2[b.severity] || a.code.localeCompare(b.code));
|
|
16178
16810
|
const byCode = {};
|
|
16179
16811
|
for (const i of issues) byCode[i.code] = (byCode[i.code] ?? 0) + 1;
|
|
16180
16812
|
return {
|
|
@@ -16185,6 +16817,844 @@ async function seoAudit(site, o = {}) {
|
|
|
16185
16817
|
};
|
|
16186
16818
|
}
|
|
16187
16819
|
|
|
16820
|
+
// ../../packages/tools/src/wp/seo-tech.ts
|
|
16821
|
+
var UA = "Mozilla/5.0 (compatible; CMO.so SEO check)";
|
|
16822
|
+
async function get(url, redirect = "manual") {
|
|
16823
|
+
const r = await fetch(url, { redirect, signal: AbortSignal.timeout(15e3), headers: { "User-Agent": UA } }).catch(
|
|
16824
|
+
() => null
|
|
16825
|
+
);
|
|
16826
|
+
if (!r) return null;
|
|
16827
|
+
const text3 = r.status === 200 ? await r.text().catch(() => "") : "";
|
|
16828
|
+
return {
|
|
16829
|
+
status: r.status,
|
|
16830
|
+
text: text3,
|
|
16831
|
+
location: r.headers.get("location") ?? "",
|
|
16832
|
+
type: r.headers.get("content-type") ?? ""
|
|
16833
|
+
};
|
|
16834
|
+
}
|
|
16835
|
+
async function pool3(items3, n2, fn) {
|
|
16836
|
+
const out = new Array(items3.length);
|
|
16837
|
+
let i = 0;
|
|
16838
|
+
await Promise.all(
|
|
16839
|
+
Array.from({ length: Math.min(n2, items3.length) }, async () => {
|
|
16840
|
+
while (i < items3.length) {
|
|
16841
|
+
const k = i++;
|
|
16842
|
+
out[k] = await fn(items3[k]);
|
|
16843
|
+
}
|
|
16844
|
+
})
|
|
16845
|
+
);
|
|
16846
|
+
return out;
|
|
16847
|
+
}
|
|
16848
|
+
function sitePath(href, base) {
|
|
16849
|
+
try {
|
|
16850
|
+
const u = new URL(href, `${base}/`);
|
|
16851
|
+
const b = new URL(base);
|
|
16852
|
+
if (u.hostname.replace(/^www\./, "") !== b.hostname.replace(/^www\./, "")) return null;
|
|
16853
|
+
if (!/^https?:$/.test(u.protocol)) return null;
|
|
16854
|
+
let p = u.pathname.replace(/\/+/g, "/");
|
|
16855
|
+
if (!p.endsWith("/") && !/\.[a-z0-9]{2,5}$/i.test(p)) p += "/";
|
|
16856
|
+
return `${p}${u.searchParams.get("p") || u.searchParams.get("page_id") ? `?${u.searchParams.toString()}` : ""}`;
|
|
16857
|
+
} catch {
|
|
16858
|
+
return null;
|
|
16859
|
+
}
|
|
16860
|
+
}
|
|
16861
|
+
var strip = (h) => h.replace(/<[^>]+>/g, " ").replace(/ /g, " ").replace(/&/g, "&").replace(/’|’/g, "\u2019").replace(/–|–/g, "\u2013").replace(/"/g, '"').replace(/'|'/g, "'").replace(/\s+/g, " ").trim();
|
|
16862
|
+
function scanHtml(html2, url, base) {
|
|
16863
|
+
const head = /<head[\s\S]*?<\/head>/i.exec(html2)?.[0] ?? html2;
|
|
16864
|
+
const attr2 = (re) => re.exec(head)?.[1] ?? "";
|
|
16865
|
+
const main = /<main[\s\S]*?<\/main>/i.exec(html2)?.[0] ?? html2.replace(/<header[\s\S]*?<\/header>/gi, "").replace(/<nav[\s\S]*?<\/nav>/gi, "").replace(/<footer[\s\S]*?<\/footer>/gi, "");
|
|
16866
|
+
const body = main.replace(/<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>/gi, " ");
|
|
16867
|
+
const links2 = [];
|
|
16868
|
+
for (const m of body.matchAll(/<a\b[^>]*href=["']([^"'#]+)[^"']*["'][^>]*>([\s\S]*?)<\/a>/gi)) {
|
|
16869
|
+
const p = sitePath(m[1], base);
|
|
16870
|
+
if (p && !/\/(wp-admin|wp-login|wp-json|feed)\b|\.(jpg|jpeg|png|webp|gif|svg|pdf|zip)$/i.test(p))
|
|
16871
|
+
links2.push({ path: p, text: strip(m[2]).slice(0, 80) });
|
|
16872
|
+
}
|
|
16873
|
+
const imgs = [...body.matchAll(/<img\b[^>]*>/gi)].map((m) => m[0]);
|
|
16874
|
+
const schema = [];
|
|
16875
|
+
let schemaErrors = 0;
|
|
16876
|
+
for (const m of html2.matchAll(/<script[^>]+application\/ld\+json[^>]*>([\s\S]*?)<\/script>/gi)) {
|
|
16877
|
+
try {
|
|
16878
|
+
const j = JSON.parse(m[1]);
|
|
16879
|
+
const items3 = Array.isArray(j) ? j : j["@graph"] ? j["@graph"] : [j];
|
|
16880
|
+
for (const it of items3) {
|
|
16881
|
+
const t = it?.["@type"];
|
|
16882
|
+
for (const x of Array.isArray(t) ? t : [t]) if (typeof x === "string") schema.push(x);
|
|
16883
|
+
}
|
|
16884
|
+
} catch {
|
|
16885
|
+
schemaErrors++;
|
|
16886
|
+
}
|
|
16887
|
+
}
|
|
16888
|
+
return {
|
|
16889
|
+
url,
|
|
16890
|
+
path: sitePath(url, base) ?? url,
|
|
16891
|
+
title: strip(/<title[^>]*>([\s\S]*?)<\/title>/i.exec(head)?.[1] ?? ""),
|
|
16892
|
+
description: strip(attr2(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i)),
|
|
16893
|
+
canonical: attr2(/<link[^>]+rel=["']canonical["'][^>]+href=["']([^"']+)["']/i),
|
|
16894
|
+
robots: attr2(/<meta[^>]+name=["']robots["'][^>]+content=["']([^"']*)["']/i).toLowerCase(),
|
|
16895
|
+
h1: (body.match(/<h1\b/gi) ?? []).length || (html2.match(/<h1\b/gi) ?? []).length,
|
|
16896
|
+
images: imgs.length,
|
|
16897
|
+
// No alt at all is a problem; alt="" is right for decoration (cover backgrounds), wrong on content pictures.
|
|
16898
|
+
imagesNoAlt: imgs.filter((i) => {
|
|
16899
|
+
if (/aria-hidden|role=["']presentation|wp-block-cover__image-background/i.test(i)) return false;
|
|
16900
|
+
if (!/\balt=/i.test(i)) return true;
|
|
16901
|
+
return /\balt=["']\s*["']/i.test(i) && /\bwp-image-\d+/.test(i);
|
|
16902
|
+
}).length,
|
|
16903
|
+
schema: [...new Set(schema)],
|
|
16904
|
+
schemaErrors,
|
|
16905
|
+
links: links2,
|
|
16906
|
+
words: strip(body).split(" ").filter(Boolean).length
|
|
16907
|
+
};
|
|
16908
|
+
}
|
|
16909
|
+
async function sitemapUrls(base, robotsTxt, max = 500) {
|
|
16910
|
+
const fromRobots = [...robotsTxt.matchAll(/^sitemap:\s*(\S+)/gim)].map((m) => m[1]);
|
|
16911
|
+
const candidates = [...fromRobots, `${base}/wp-sitemap.xml`, `${base}/sitemap_index.xml`, `${base}/sitemap.xml`];
|
|
16912
|
+
for (const sm of candidates) {
|
|
16913
|
+
const r = await get(sm, "follow");
|
|
16914
|
+
if (!r || r.status !== 200 || !/<(urlset|sitemapindex)/i.test(r.text)) continue;
|
|
16915
|
+
const urls = [];
|
|
16916
|
+
const queue = [r.text];
|
|
16917
|
+
let fetched = 0;
|
|
16918
|
+
while (queue.length && urls.length < max && fetched < 30) {
|
|
16919
|
+
const xml = queue.shift();
|
|
16920
|
+
const locs = [...xml.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/gi)].map(
|
|
16921
|
+
(m) => m[1].replace(/&/g, "&")
|
|
16922
|
+
);
|
|
16923
|
+
if (/<sitemapindex/i.test(xml)) {
|
|
16924
|
+
for (const l of locs) {
|
|
16925
|
+
const s = await get(l, "follow");
|
|
16926
|
+
fetched++;
|
|
16927
|
+
if (s?.status === 200) queue.push(s.text);
|
|
16928
|
+
}
|
|
16929
|
+
} else urls.push(...locs);
|
|
16930
|
+
}
|
|
16931
|
+
return { urls: [...new Set(urls)].slice(0, max), sitemap: sm };
|
|
16932
|
+
}
|
|
16933
|
+
return { urls: [], sitemap: null };
|
|
16934
|
+
}
|
|
16935
|
+
var AI_BOTS = [
|
|
16936
|
+
"GPTBot",
|
|
16937
|
+
"OAI-SearchBot",
|
|
16938
|
+
"ChatGPT-User",
|
|
16939
|
+
"ClaudeBot",
|
|
16940
|
+
"Claude-SearchBot",
|
|
16941
|
+
"PerplexityBot",
|
|
16942
|
+
"Google-Extended",
|
|
16943
|
+
"CCBot"
|
|
16944
|
+
];
|
|
16945
|
+
function robotsBlocked(robots, agents) {
|
|
16946
|
+
const groups = [];
|
|
16947
|
+
let cur = null;
|
|
16948
|
+
for (const raw of robots.split(/\r?\n/)) {
|
|
16949
|
+
const line = raw.replace(/#.*/, "").trim();
|
|
16950
|
+
const m = /^([a-z-]+)\s*:\s*(.*)$/i.exec(line);
|
|
16951
|
+
if (!m) continue;
|
|
16952
|
+
const [k, v] = [m[1].toLowerCase(), m[2].trim()];
|
|
16953
|
+
if (k === "user-agent") {
|
|
16954
|
+
if (!cur || cur.rules.length) {
|
|
16955
|
+
cur = { agents: [], rules: [] };
|
|
16956
|
+
groups.push(cur);
|
|
16957
|
+
}
|
|
16958
|
+
cur.agents.push(v.toLowerCase());
|
|
16959
|
+
} else if (cur && (k === "disallow" || k === "allow")) cur.rules.push(`${k}:${v}`);
|
|
16960
|
+
}
|
|
16961
|
+
const blocks = (g) => g.rules.includes("disallow:/") && !g.rules.some((r) => r === "allow:/");
|
|
16962
|
+
return agents.filter((a) => {
|
|
16963
|
+
const own3 = groups.find((g2) => g2.agents.includes(a.toLowerCase()));
|
|
16964
|
+
const g = own3 ?? groups.find((x) => x.agents.includes("*"));
|
|
16965
|
+
return g ? blocks(g) : false;
|
|
16966
|
+
});
|
|
16967
|
+
}
|
|
16968
|
+
function groupFindings(list3, min = 4) {
|
|
16969
|
+
const kinds = /* @__PURE__ */ new Map();
|
|
16970
|
+
for (const f of list3) {
|
|
16971
|
+
const k = f.id.includes(":") ? f.id.split(":")[0] : f.id;
|
|
16972
|
+
kinds.set(k, [...kinds.get(k) ?? [], f]);
|
|
16973
|
+
}
|
|
16974
|
+
const out = [];
|
|
16975
|
+
for (const [k, fs2] of kinds) {
|
|
16976
|
+
if (fs2.length < min || !fs2[0]?.id.includes(":")) {
|
|
16977
|
+
out.push(...fs2);
|
|
16978
|
+
continue;
|
|
16979
|
+
}
|
|
16980
|
+
const paths = fs2.map((f) => f.id.slice(k.length + 1));
|
|
16981
|
+
const first = fs2[0];
|
|
16982
|
+
const label2 = {
|
|
16983
|
+
"title-long": "have SEO titles longer than 65 characters",
|
|
16984
|
+
alt: "have pictures without alt text",
|
|
16985
|
+
desc: "have no meta description",
|
|
16986
|
+
h1: "don't have exactly one H1",
|
|
16987
|
+
thin: "have fewer than 120 words",
|
|
16988
|
+
"noindex-in-sitemap": "are noindex but in the sitemap",
|
|
16989
|
+
canonical: "point their canonical elsewhere",
|
|
16990
|
+
"schema-error": "have broken structured data",
|
|
16991
|
+
"sitemap-status": "are in the sitemap but don't answer 200",
|
|
16992
|
+
orphan: "get no links from other pages",
|
|
16993
|
+
broken: "are linked but broken"
|
|
16994
|
+
};
|
|
16995
|
+
const seoTool = first.tool?.name === "wp_seo_set";
|
|
16996
|
+
out.push({
|
|
16997
|
+
...first,
|
|
16998
|
+
id: `${k}:*`,
|
|
16999
|
+
title: `${fs2.length} pages ${label2[k] ?? first.title}`,
|
|
17000
|
+
why: first.why,
|
|
17001
|
+
fix: `${first.fix.replace(/\.$/, "")}. Pages: ${paths.slice(0, 12).join(", ")}${paths.length > 12 ? ` +${paths.length - 12} more` : ""}.`,
|
|
17002
|
+
...seoTool ? { tool: { name: "wp_seo_set", args: { items: paths.slice(0, 50).map((x) => ({ page: x })) } } } : {},
|
|
17003
|
+
pages: paths
|
|
17004
|
+
});
|
|
17005
|
+
}
|
|
17006
|
+
return out;
|
|
17007
|
+
}
|
|
17008
|
+
async function techAudit(site, o = {}) {
|
|
17009
|
+
const base = (site.url ?? "").replace(/\/+$/, "");
|
|
17010
|
+
if (!base) throw new Error("The site's address is unknown.");
|
|
17011
|
+
const limit = Math.min(Math.max(o.limit ?? 60, 5), 200);
|
|
17012
|
+
const findings = [];
|
|
17013
|
+
const add = (f) => findings.push(f);
|
|
17014
|
+
const robotsR = await get(`${base}/robots.txt`, "follow");
|
|
17015
|
+
const robots = robotsR?.status === 200 ? robotsR.text : "";
|
|
17016
|
+
const { urls, sitemap } = await sitemapUrls(base, robots);
|
|
17017
|
+
if (!robotsR || robotsR.status !== 200)
|
|
17018
|
+
add({
|
|
17019
|
+
id: "robots-missing",
|
|
17020
|
+
priority: "p3",
|
|
17021
|
+
area: "settings",
|
|
17022
|
+
title: "No robots.txt",
|
|
17023
|
+
why: "Crawlers look for it first.",
|
|
17024
|
+
fix: "WordPress makes one by itself unless a file or plugin blocks it: check the host."
|
|
17025
|
+
});
|
|
17026
|
+
if (/^disallow:\s*\/\s*$/im.test(robots) && robotsBlocked(robots, ["Googlebot"]).length)
|
|
17027
|
+
add({
|
|
17028
|
+
id: "robots-all",
|
|
17029
|
+
priority: "p0",
|
|
17030
|
+
area: "settings",
|
|
17031
|
+
title: "robots.txt blocks Google from the whole site",
|
|
17032
|
+
why: "Nothing can be indexed.",
|
|
17033
|
+
fix: "Turn search engines on (wp_settings_set search_engines true) or fix the robots.txt file.",
|
|
17034
|
+
tool: { name: "wp_settings_set", args: { search_engines: true } }
|
|
17035
|
+
});
|
|
17036
|
+
if (robots && !/^sitemap:/im.test(robots))
|
|
17037
|
+
add({
|
|
17038
|
+
id: "robots-sitemap",
|
|
17039
|
+
priority: "p3",
|
|
17040
|
+
area: "settings",
|
|
17041
|
+
title: "robots.txt doesn't point to the sitemap",
|
|
17042
|
+
why: "It helps every crawler find all pages.",
|
|
17043
|
+
fix: "Maggie Core adds the Sitemap line when search engines are on; otherwise add it to robots.txt."
|
|
17044
|
+
});
|
|
17045
|
+
const aiBlocked = robotsBlocked(robots, AI_BOTS);
|
|
17046
|
+
if (aiBlocked.length)
|
|
17047
|
+
add({
|
|
17048
|
+
id: "ai-crawlers",
|
|
17049
|
+
priority: "p2",
|
|
17050
|
+
area: "settings",
|
|
17051
|
+
title: `AI crawlers are blocked: ${aiBlocked.join(", ")}`,
|
|
17052
|
+
why: "ChatGPT, Claude, Perplexity and Google's AI answers can't read or cite the site.",
|
|
17053
|
+
fix: "Allow them in robots.txt unless the owner chose to block AI."
|
|
17054
|
+
});
|
|
17055
|
+
if (!sitemap)
|
|
17056
|
+
add({
|
|
17057
|
+
id: "sitemap-missing",
|
|
17058
|
+
priority: "p1",
|
|
17059
|
+
area: "settings",
|
|
17060
|
+
title: "No sitemap found",
|
|
17061
|
+
why: "Search engines find new pages slower without one.",
|
|
17062
|
+
fix: "WordPress publishes /wp-sitemap.xml when search engines are on; check nothing turns it off."
|
|
17063
|
+
});
|
|
17064
|
+
const llms = await get(`${base}/llms.txt`, "follow");
|
|
17065
|
+
const hasLlms = !!llms && llms.status === 200 && /^#\s/m.test(llms.text) && !/<html/i.test(llms.text);
|
|
17066
|
+
if (!hasLlms)
|
|
17067
|
+
add({
|
|
17068
|
+
id: "llms-missing",
|
|
17069
|
+
priority: "p3",
|
|
17070
|
+
area: "settings",
|
|
17071
|
+
title: "No llms.txt",
|
|
17072
|
+
why: "A short Markdown map of the site helps AI assistants describe it correctly.",
|
|
17073
|
+
fix: "Generate it from the site.",
|
|
17074
|
+
tool: { name: "wp_llms_txt_set", args: { generate: true } }
|
|
17075
|
+
});
|
|
17076
|
+
const crawl = urls.length ? urls.slice(0, limit) : [`${base}/`];
|
|
17077
|
+
const scans = await pool3(crawl, 6, async (u) => {
|
|
17078
|
+
const r = await get(u);
|
|
17079
|
+
if (!r) return null;
|
|
17080
|
+
if (r.status !== 200) return { ...scanHtml("", u, base), status: r.status, redirect: r.location || void 0 };
|
|
17081
|
+
return { ...scanHtml(r.text, u, base), status: 200 };
|
|
17082
|
+
});
|
|
17083
|
+
const pages = scans.filter(Boolean);
|
|
17084
|
+
const ok2 = pages.filter((p) => p.status === 200);
|
|
17085
|
+
const bad = pages.filter((p) => p.status !== 200);
|
|
17086
|
+
for (const p of bad)
|
|
17087
|
+
add({
|
|
17088
|
+
id: `sitemap-status:${p.path}`,
|
|
17089
|
+
priority: p.status >= 400 ? "p1" : "p2",
|
|
17090
|
+
area: "outside",
|
|
17091
|
+
title: `The sitemap lists ${p.path}, which answers ${p.status}${p.redirect ? ` \u2192 ${p.redirect}` : ""}`,
|
|
17092
|
+
why: "Sitemaps should list only live pages; search engines lose trust in it.",
|
|
17093
|
+
fix: p.status >= 400 ? "Publish the page again, or remove it (wp_post_delete / wp_page_publish)." : "List the final address; the redirect is fine for visitors."
|
|
17094
|
+
});
|
|
17095
|
+
for (const p of ok2) {
|
|
17096
|
+
if (/noindex/.test(p.robots))
|
|
17097
|
+
add({
|
|
17098
|
+
id: `noindex-in-sitemap:${p.path}`,
|
|
17099
|
+
priority: "p1",
|
|
17100
|
+
area: "outside",
|
|
17101
|
+
title: `${p.path} is noindex but in the sitemap`,
|
|
17102
|
+
why: "Mixed signals: either it should be found, or it shouldn't be listed.",
|
|
17103
|
+
fix: 'Remove noindex if the page should rank (wp_seo_set robots "").',
|
|
17104
|
+
tool: { name: "wp_seo_set", args: { page: p.path, robots: "" } }
|
|
17105
|
+
});
|
|
17106
|
+
if (p.canonical && sitePath(p.canonical, base) !== p.path)
|
|
17107
|
+
add({
|
|
17108
|
+
id: `canonical:${p.path}`,
|
|
17109
|
+
priority: "p2",
|
|
17110
|
+
area: "outside",
|
|
17111
|
+
title: `${p.path} points its canonical to ${p.canonical}`,
|
|
17112
|
+
why: "Google will index the other address instead of this page.",
|
|
17113
|
+
fix: 'Clear the custom canonical unless it is on purpose (wp_seo_set canonical "").',
|
|
17114
|
+
tool: { name: "wp_seo_set", args: { page: p.path, canonical: "" } }
|
|
17115
|
+
});
|
|
17116
|
+
if (p.h1 !== 1)
|
|
17117
|
+
add({
|
|
17118
|
+
id: `h1:${p.path}`,
|
|
17119
|
+
priority: "p2",
|
|
17120
|
+
area: "outside",
|
|
17121
|
+
title: `${p.path} has ${p.h1} H1 headings`,
|
|
17122
|
+
why: "One H1 says what the page is about.",
|
|
17123
|
+
fix: p.h1 ? "Make the extra H1s H2 (wp_section_update edits)." : "Give the page a main heading (the hero's title)."
|
|
17124
|
+
});
|
|
17125
|
+
if (p.imagesNoAlt)
|
|
17126
|
+
add({
|
|
17127
|
+
id: `alt:${p.path}`,
|
|
17128
|
+
priority: "p3",
|
|
17129
|
+
area: "outside",
|
|
17130
|
+
title: `${p.path}: ${p.imagesNoAlt} of ${p.images} pictures have no alt text`,
|
|
17131
|
+
why: "Alt text is how search engines, AI and screen readers know what a picture shows.",
|
|
17132
|
+
fix: "Add alt text (wp_section_update images alt, or the media library)."
|
|
17133
|
+
});
|
|
17134
|
+
if (p.schemaErrors)
|
|
17135
|
+
add({
|
|
17136
|
+
id: `schema-error:${p.path}`,
|
|
17137
|
+
priority: "p2",
|
|
17138
|
+
area: "outside",
|
|
17139
|
+
title: `${p.path}: ${p.schemaErrors} structured-data block(s) are not valid JSON`,
|
|
17140
|
+
why: "Google ignores broken structured data.",
|
|
17141
|
+
fix: "Fix or remove the page's custom schema (wp_seo_set schema)."
|
|
17142
|
+
});
|
|
17143
|
+
if (p.words < 120 && !/\?/.test(p.path))
|
|
17144
|
+
add({
|
|
17145
|
+
id: `thin:${p.path}`,
|
|
17146
|
+
priority: "p3",
|
|
17147
|
+
area: "outside",
|
|
17148
|
+
title: `${p.path} has only ${p.words} words`,
|
|
17149
|
+
why: "Very short pages rarely rank or get cited.",
|
|
17150
|
+
fix: "Add useful text: what, for whom, price or next step, questions."
|
|
17151
|
+
});
|
|
17152
|
+
if (!p.description)
|
|
17153
|
+
add({
|
|
17154
|
+
id: `desc:${p.path}`,
|
|
17155
|
+
priority: "p2",
|
|
17156
|
+
area: "outside",
|
|
17157
|
+
title: `${p.path} has no meta description`,
|
|
17158
|
+
why: "Google writes its own snippet, often a poor one.",
|
|
17159
|
+
fix: "Write one (140\u2013160 characters).",
|
|
17160
|
+
tool: { name: "wp_seo_set", args: { page: p.path } }
|
|
17161
|
+
});
|
|
17162
|
+
if (p.title.length > 65)
|
|
17163
|
+
add({
|
|
17164
|
+
id: `title-long:${p.path}`,
|
|
17165
|
+
priority: "p3",
|
|
17166
|
+
area: "outside",
|
|
17167
|
+
title: `${p.path}: title is ${p.title.length} characters`,
|
|
17168
|
+
why: "Search results cut it after about 60.",
|
|
17169
|
+
fix: "Shorten the SEO title.",
|
|
17170
|
+
tool: { name: "wp_seo_set", args: { page: p.path } }
|
|
17171
|
+
});
|
|
17172
|
+
}
|
|
17173
|
+
const dup = (key) => {
|
|
17174
|
+
const by = /* @__PURE__ */ new Map();
|
|
17175
|
+
for (const p of ok2) if (p[key]) by.set(p[key], [...by.get(p[key]) ?? [], p.path]);
|
|
17176
|
+
for (const [v, ps] of by)
|
|
17177
|
+
if (ps.length > 1)
|
|
17178
|
+
add({
|
|
17179
|
+
id: `dup-${key}:${v.slice(0, 40)}`,
|
|
17180
|
+
priority: "p2",
|
|
17181
|
+
area: "outside",
|
|
17182
|
+
title: `${ps.length} pages share the same ${key}: \u201C${v.slice(0, 70)}\u201D`,
|
|
17183
|
+
why: "Search engines can't tell them apart.",
|
|
17184
|
+
fix: `Give each its own ${key} (wp_seo_set items).`,
|
|
17185
|
+
tool: { name: "wp_seo_set", args: { items: ps.slice(0, 10).map((x) => ({ page: x })) } }
|
|
17186
|
+
});
|
|
17187
|
+
};
|
|
17188
|
+
dup("title");
|
|
17189
|
+
dup("description");
|
|
17190
|
+
const home = ok2.find((p) => p.path === "/");
|
|
17191
|
+
if (home && !home.schema.some((t) => /Organization|LocalBusiness|WebSite|Person|Store|Restaurant|Service/i.test(t)))
|
|
17192
|
+
add({
|
|
17193
|
+
id: "schema-home",
|
|
17194
|
+
priority: "p2",
|
|
17195
|
+
area: "outside",
|
|
17196
|
+
title: "The homepage has no Organization / LocalBusiness structured data",
|
|
17197
|
+
why: "It tells Google and AI who the business is, where and how to reach it.",
|
|
17198
|
+
fix: "Fill the business details (wp_seo_site_set business).",
|
|
17199
|
+
tool: { name: "wp_seo_site_set", args: {} }
|
|
17200
|
+
});
|
|
17201
|
+
const inbound = /* @__PURE__ */ new Map();
|
|
17202
|
+
for (const p of ok2)
|
|
17203
|
+
for (const l of new Set(p.links.map((x) => x.path))) if (l !== p.path) inbound.set(l, (inbound.get(l) ?? 0) + 1);
|
|
17204
|
+
const known = new Set(pages.map((p) => p.path));
|
|
17205
|
+
const targets = [...new Set(ok2.flatMap((p) => p.links.map((l) => l.path)))].filter((t) => !known.has(t)).slice(0, 80);
|
|
17206
|
+
const checked = await pool3(targets, 6, async (t) => ({ t, r: await get(`${base}${t}`) }));
|
|
17207
|
+
const broken = checked.filter((c) => c.r && c.r.status >= 400);
|
|
17208
|
+
const redirected = checked.filter((c) => c.r && [301, 302, 307, 308].includes(c.r.status));
|
|
17209
|
+
for (const b of broken) {
|
|
17210
|
+
const from = ok2.filter((p) => p.links.some((l) => l.path === b.t)).map((p) => p.path);
|
|
17211
|
+
add({
|
|
17212
|
+
id: `broken:${b.t}`,
|
|
17213
|
+
priority: "p1",
|
|
17214
|
+
area: "outside",
|
|
17215
|
+
title: `Broken link to ${b.t} (${b.r?.status}) on ${from.slice(0, 3).join(", ")}${from.length > 3 ? ` +${from.length - 3}` : ""}`,
|
|
17216
|
+
why: "Visitors hit a dead end and crawlers waste their visit.",
|
|
17217
|
+
fix: "Point the link to a live page (wp_section_update links) or add a redirect (wp_redirect_set).",
|
|
17218
|
+
tool: { name: "wp_redirect_set", args: { from: b.t } }
|
|
17219
|
+
});
|
|
17220
|
+
}
|
|
17221
|
+
if (redirected.length)
|
|
17222
|
+
add({
|
|
17223
|
+
id: "links-redirected",
|
|
17224
|
+
priority: "p3",
|
|
17225
|
+
area: "outside",
|
|
17226
|
+
title: `${redirected.length} internal links go through a redirect`,
|
|
17227
|
+
why: "Each hop slows visitors and loses a little link value.",
|
|
17228
|
+
fix: `Link straight to the final address: ${redirected.slice(0, 5).map((c) => `${c.t} \u2192 ${c.r?.location}`).join(", ")}.`
|
|
17229
|
+
});
|
|
17230
|
+
const generic = ok2.flatMap(
|
|
17231
|
+
(p) => p.links.filter((l) => /^(click here|here|read more|learn more|more|this|link|點此|更多)$/i.test(l.text)).map(() => p.path)
|
|
17232
|
+
);
|
|
17233
|
+
if (generic.length)
|
|
17234
|
+
add({
|
|
17235
|
+
id: "anchors-generic",
|
|
17236
|
+
priority: "p3",
|
|
17237
|
+
area: "outside",
|
|
17238
|
+
title: `${generic.length} links say only \u201Cread more\u201D, \u201Cclick here\u201D or similar`,
|
|
17239
|
+
why: "Link words tell search engines what the target page is about.",
|
|
17240
|
+
fix: `Use the target's topic as the link text (pages: ${[...new Set(generic)].slice(0, 5).join(", ")}).`
|
|
17241
|
+
});
|
|
17242
|
+
let speed = null;
|
|
17243
|
+
if (o.speed) speed = await pageSpeed(`${base}/`);
|
|
17244
|
+
if (speed?.lcp_ms && Number(speed.lcp_ms) > 2500)
|
|
17245
|
+
add({
|
|
17246
|
+
id: "lcp",
|
|
17247
|
+
priority: Number(speed.lcp_ms) > 4e3 ? "p1" : "p2",
|
|
17248
|
+
area: "outside",
|
|
17249
|
+
title: `The homepage's main content shows after ${(Number(speed.lcp_ms) / 1e3).toFixed(1)} s on phones (LCP)`,
|
|
17250
|
+
why: "Google wants under 2.5 s; slow pages lose visitors and rankings.",
|
|
17251
|
+
fix: "Smaller hero picture (WebP, under 200 KB), fewer fonts and scripts above the fold."
|
|
17252
|
+
});
|
|
17253
|
+
if (speed?.cls != null && Number(speed.cls) > 0.1)
|
|
17254
|
+
add({
|
|
17255
|
+
id: "cls",
|
|
17256
|
+
priority: "p2",
|
|
17257
|
+
area: "outside",
|
|
17258
|
+
title: `The homepage moves while loading (CLS ${speed.cls})`,
|
|
17259
|
+
why: "Google wants under 0.1.",
|
|
17260
|
+
fix: "Give pictures width and height; don't insert banners above content after load."
|
|
17261
|
+
});
|
|
17262
|
+
const order2 = ["p0", "p1", "p2", "p3", "p4"];
|
|
17263
|
+
const grouped = groupFindings(findings);
|
|
17264
|
+
grouped.sort((a, b) => order2.indexOf(a.priority) - order2.indexOf(b.priority));
|
|
17265
|
+
findings.length = 0;
|
|
17266
|
+
findings.push(...grouped);
|
|
17267
|
+
return {
|
|
17268
|
+
site: base,
|
|
17269
|
+
checked: (/* @__PURE__ */ new Date()).toISOString(),
|
|
17270
|
+
summary: {
|
|
17271
|
+
...Object.fromEntries(order2.map((p) => [p, findings.filter((f) => f.priority === p).length])),
|
|
17272
|
+
sitemap,
|
|
17273
|
+
sitemap_urls: urls.length,
|
|
17274
|
+
crawled: pages.length,
|
|
17275
|
+
live: ok2.length,
|
|
17276
|
+
llms_txt: hasLlms,
|
|
17277
|
+
ai_crawlers_blocked: aiBlocked,
|
|
17278
|
+
internal_links: ok2.reduce((n2, p) => n2 + p.links.length, 0)
|
|
17279
|
+
},
|
|
17280
|
+
pages: ok2.map((p) => ({
|
|
17281
|
+
path: p.path,
|
|
17282
|
+
title: p.title,
|
|
17283
|
+
words: p.words,
|
|
17284
|
+
h1: p.h1,
|
|
17285
|
+
links_out: new Set(p.links.map((l) => l.path)).size,
|
|
17286
|
+
links_in: inbound.get(p.path) ?? 0,
|
|
17287
|
+
schema: p.schema
|
|
17288
|
+
})),
|
|
17289
|
+
findings,
|
|
17290
|
+
...speed ? { speed } : {},
|
|
17291
|
+
...urls.length > crawl.length ? { note: `Crawled the first ${crawl.length} of ${urls.length} sitemap addresses (limit up to 200).` } : {}
|
|
17292
|
+
};
|
|
17293
|
+
}
|
|
17294
|
+
async function pageSpeed(url) {
|
|
17295
|
+
const key = globalThis.process?.env?.PAGESPEED_API_KEY;
|
|
17296
|
+
const q3 = new URLSearchParams({ url, strategy: "mobile", category: "performance" });
|
|
17297
|
+
if (key) q3.set("key", key);
|
|
17298
|
+
const r = await fetch(`https://www.googleapis.com/pagespeedonline/v5/runPagespeed?${q3}`, {
|
|
17299
|
+
signal: AbortSignal.timeout(9e4)
|
|
17300
|
+
}).catch(() => null);
|
|
17301
|
+
if (!r || !r.ok) return { error: `PageSpeed Insights didn't answer (${r?.status ?? "timeout"}).` };
|
|
17302
|
+
const j = await r.json();
|
|
17303
|
+
const f = j.loadingExperience?.metrics ?? {};
|
|
17304
|
+
const a = j.lighthouseResult?.audits ?? {};
|
|
17305
|
+
const field = !!f.LARGEST_CONTENTFUL_PAINT_MS;
|
|
17306
|
+
return {
|
|
17307
|
+
source: field ? "real visitors (Chrome UX Report, 28 days)" : "lab test (no real-visitor data yet)",
|
|
17308
|
+
score: Math.round((j.lighthouseResult?.categories?.performance?.score ?? 0) * 100),
|
|
17309
|
+
lcp_ms: field ? f.LARGEST_CONTENTFUL_PAINT_MS?.percentile : Math.round(a["largest-contentful-paint"]?.numericValue ?? 0),
|
|
17310
|
+
cls: field ? (f.CUMULATIVE_LAYOUT_SHIFT_SCORE?.percentile ?? 0) / 100 : Number((a["cumulative-layout-shift"]?.numericValue ?? 0).toFixed(3)),
|
|
17311
|
+
inp_ms: f.INTERACTION_TO_NEXT_PAINT?.percentile ?? null,
|
|
17312
|
+
tbt_ms: Math.round(a["total-blocking-time"]?.numericValue ?? 0)
|
|
17313
|
+
};
|
|
17314
|
+
}
|
|
17315
|
+
var STOP = new Set(
|
|
17316
|
+
"the a an and or of to in for on with by at from is are be your our you we it this that how what why best guide near vs and/or my me us".split(
|
|
17317
|
+
" "
|
|
17318
|
+
)
|
|
17319
|
+
);
|
|
17320
|
+
var tokens = (s) => new Set(
|
|
17321
|
+
s.toLowerCase().replace(/[^\p{L}\p{N}\s-]/gu, " ").split(/[\s-]+/).filter((w) => w.length > 2 && !STOP.has(w)).map((w) => w.replace(/(ies)$/, "y").replace(/(es|s)$/, ""))
|
|
17322
|
+
);
|
|
17323
|
+
var overlap = (a, b, idf = () => 1) => {
|
|
17324
|
+
let dot = 0;
|
|
17325
|
+
let na = 0;
|
|
17326
|
+
let nb = 0;
|
|
17327
|
+
for (const w of a) {
|
|
17328
|
+
const x = idf(w) ** 2;
|
|
17329
|
+
na += x;
|
|
17330
|
+
if (b.has(w)) dot += x;
|
|
17331
|
+
}
|
|
17332
|
+
for (const w of b) nb += idf(w) ** 2;
|
|
17333
|
+
return dot / Math.sqrt(Math.max(1e-9, na) * Math.max(1e-9, nb));
|
|
17334
|
+
};
|
|
17335
|
+
function contentLinks(raw, base) {
|
|
17336
|
+
const out = /* @__PURE__ */ new Set();
|
|
17337
|
+
for (const m of raw.matchAll(/href=["']([^"'#]+)["']/gi)) {
|
|
17338
|
+
const p = sitePath(m[1], base);
|
|
17339
|
+
if (p) out.add(p);
|
|
17340
|
+
}
|
|
17341
|
+
for (const m of raw.matchAll(/"url":"([^"]+)"/g)) {
|
|
17342
|
+
const p = sitePath(m[1].replace(/\\\//g, "/"), base);
|
|
17343
|
+
if (p) out.add(p);
|
|
17344
|
+
}
|
|
17345
|
+
return out;
|
|
17346
|
+
}
|
|
17347
|
+
var flatMenu = (items3) => items3.flatMap((i) => [{ url: i.url, label: i.label }, ...flatMenu(i.children ?? [])]);
|
|
17348
|
+
async function linksPlan(site, o = {}) {
|
|
17349
|
+
const base = (site.url ?? "").replace(/\/+$/, "");
|
|
17350
|
+
const pages = await listPages(site, { status: "publish", withContent: true });
|
|
17351
|
+
const posts2 = await all(
|
|
17352
|
+
site,
|
|
17353
|
+
"/wp/v2/posts",
|
|
17354
|
+
{
|
|
17355
|
+
context: "edit",
|
|
17356
|
+
status: "publish",
|
|
17357
|
+
per_page: 100,
|
|
17358
|
+
_fields: "id,link,title,categories,content,meta",
|
|
17359
|
+
orderby: "date",
|
|
17360
|
+
order: "desc"
|
|
17361
|
+
},
|
|
17362
|
+
o.posts ?? 200
|
|
17363
|
+
);
|
|
17364
|
+
const cats = await all(site, "/wp/v2/categories", {
|
|
17365
|
+
per_page: 100,
|
|
17366
|
+
_fields: "id,name,link,count"
|
|
17367
|
+
});
|
|
17368
|
+
const catName = new Map(cats.map((c) => [c.id, c.name]));
|
|
17369
|
+
const settings2 = await site.request({
|
|
17370
|
+
path: "/wp/v2/settings",
|
|
17371
|
+
query: { _fields: "page_on_front,page_for_posts" }
|
|
17372
|
+
}).then((r) => r.status === 200 ? r.data : {});
|
|
17373
|
+
const docs = [
|
|
17374
|
+
...pages.map((p) => ({
|
|
17375
|
+
id: p.id,
|
|
17376
|
+
kind: "page",
|
|
17377
|
+
path: sitePath(p.link, base) ?? p.link,
|
|
17378
|
+
url: p.link,
|
|
17379
|
+
title: titleOf(p.title),
|
|
17380
|
+
parent: p.parent,
|
|
17381
|
+
links: contentLinks(p.content?.raw ?? "", base),
|
|
17382
|
+
words: tokens(
|
|
17383
|
+
`${titleOf(p.title)} ${p.slug} ${p.meta?.maggie_seo_title ?? ""} ${p.meta?.maggie_seo_keywords ?? ""}`
|
|
17384
|
+
),
|
|
17385
|
+
description: String(p.meta?.maggie_seo_description ?? "")
|
|
17386
|
+
})),
|
|
17387
|
+
...posts2.map((p) => ({
|
|
17388
|
+
id: p.id,
|
|
17389
|
+
kind: "post",
|
|
17390
|
+
path: sitePath(p.link, base) ?? p.link,
|
|
17391
|
+
url: p.link,
|
|
17392
|
+
title: p.title.raw ?? "",
|
|
17393
|
+
cats: p.categories.map((c) => catName.get(c) ?? "").filter(Boolean),
|
|
17394
|
+
links: contentLinks(p.content.raw, base),
|
|
17395
|
+
words: tokens(
|
|
17396
|
+
`${p.title.raw ?? ""} ${p.categories.map((c) => catName.get(c) ?? "").join(" ")} ${p.meta?.maggie_seo_keywords ?? ""}`
|
|
17397
|
+
),
|
|
17398
|
+
description: String(p.meta?.maggie_seo_description ?? "")
|
|
17399
|
+
}))
|
|
17400
|
+
];
|
|
17401
|
+
const byPath = new Map(docs.map((d) => [d.path, d]));
|
|
17402
|
+
const menu = await menuGet(site).catch(() => null);
|
|
17403
|
+
const menuPaths = new Set(
|
|
17404
|
+
flatMenu(menu?.items ?? []).map((i) => sitePath(i.url, base)).filter(Boolean)
|
|
17405
|
+
);
|
|
17406
|
+
const footer = await partGet(site, "footer").catch(() => null);
|
|
17407
|
+
const footerPaths = footer ? contentLinks(footer.content, base) : /* @__PURE__ */ new Set();
|
|
17408
|
+
const header = await partGet(site, "header").catch(() => null);
|
|
17409
|
+
const headerPaths = header ? contentLinks(header.content, base) : /* @__PURE__ */ new Set();
|
|
17410
|
+
const chrome = /* @__PURE__ */ new Set([...menuPaths, ...footerPaths, ...headerPaths]);
|
|
17411
|
+
const inbound = /* @__PURE__ */ new Map();
|
|
17412
|
+
for (const d of docs)
|
|
17413
|
+
for (const l of d.links) if (l !== d.path) inbound.set(l, /* @__PURE__ */ new Set([...inbound.get(l) ?? [], d.path]));
|
|
17414
|
+
const home = docs.find((d) => d.id === settings2.page_on_front && d.kind === "page");
|
|
17415
|
+
const blogPage = docs.find((d) => d.id === settings2.page_for_posts && d.kind === "page");
|
|
17416
|
+
const actions = [];
|
|
17417
|
+
const findings = [];
|
|
17418
|
+
const pageDocs = docs.filter((d) => d.kind === "page" && d !== home);
|
|
17419
|
+
const postDocs = docs.filter((d) => d.kind === "post");
|
|
17420
|
+
const menuItems = flatMenu(menu?.items ?? []);
|
|
17421
|
+
const top = (menu?.items ?? []).length;
|
|
17422
|
+
for (const i of menuItems) {
|
|
17423
|
+
const p = sitePath(i.url, base);
|
|
17424
|
+
if (p && p !== "/" && !byPath.has(p) && !/\/(category|tag|author)\//.test(p) && !p.startsWith("/?"))
|
|
17425
|
+
findings.push({
|
|
17426
|
+
id: `menu-dead:${p}`,
|
|
17427
|
+
priority: "p1",
|
|
17428
|
+
area: "outside",
|
|
17429
|
+
title: `The menu links to ${p} (\u201C${i.label}\u201D), which isn't a published page or post`,
|
|
17430
|
+
why: "Visitors hit a dead end from every page.",
|
|
17431
|
+
fix: "Point it to a live page or remove it (wp_menu_set).",
|
|
17432
|
+
tool: { name: "wp_menu_set", args: {} }
|
|
17433
|
+
});
|
|
17434
|
+
}
|
|
17435
|
+
if (top > 7)
|
|
17436
|
+
findings.push({
|
|
17437
|
+
id: "menu-long",
|
|
17438
|
+
priority: "p3",
|
|
17439
|
+
area: "outside",
|
|
17440
|
+
title: `The menu has ${top} top-level items`,
|
|
17441
|
+
why: "More than 7 makes choosing hard; group them under submenus.",
|
|
17442
|
+
fix: "Group related pages under a parent item (wp_menu_set)."
|
|
17443
|
+
});
|
|
17444
|
+
const hubs = pageDocs.filter((d) => pageDocs.some((c) => c.parent === d.id));
|
|
17445
|
+
const topLevel = pageDocs.filter((d) => !d.parent);
|
|
17446
|
+
const wantMenu = [.../* @__PURE__ */ new Set([...hubs, ...topLevel.slice(0, 6)])].filter(
|
|
17447
|
+
(d) => !chrome.has(d.path) && !/privacy|terms|cookie|legal|impressum|datenschutz|thank/i.test(d.path)
|
|
17448
|
+
);
|
|
17449
|
+
if (wantMenu.length)
|
|
17450
|
+
actions.push({
|
|
17451
|
+
type: "menu",
|
|
17452
|
+
why: "Main pages every visitor should reach in one click.",
|
|
17453
|
+
add: wantMenu.slice(0, 6).map((d) => ({ label: d.title, url: d.url })),
|
|
17454
|
+
tool: "wp_menu_set"
|
|
17455
|
+
});
|
|
17456
|
+
if (postDocs.length && blogPage && !chrome.has(blogPage.path))
|
|
17457
|
+
actions.push({
|
|
17458
|
+
type: "menu",
|
|
17459
|
+
why: "The blog isn't reachable from the menu.",
|
|
17460
|
+
add: [{ label: blogPage.title || "Blog", url: blogPage.url }],
|
|
17461
|
+
tool: "wp_menu_set"
|
|
17462
|
+
});
|
|
17463
|
+
const legal = pageDocs.filter(
|
|
17464
|
+
(d) => /privacy|terms|cookie|legal|impressum|datenschutz|accessib/i.test(`${d.path} ${d.title}`)
|
|
17465
|
+
);
|
|
17466
|
+
const contact = pageDocs.filter((d) => /contact|about|book|location|faq/i.test(`${d.path} ${d.title}`));
|
|
17467
|
+
const footerMissing = [...legal, ...contact].filter((d) => !footerPaths.has(d.path));
|
|
17468
|
+
if (footerMissing.length)
|
|
17469
|
+
actions.push({
|
|
17470
|
+
type: "footer",
|
|
17471
|
+
why: "Legal, contact and about pages belong in the footer of every page (trust, and Google's quality raters look for them).",
|
|
17472
|
+
add: footerMissing.slice(0, 8).map((d) => ({ label: d.title, url: d.url })),
|
|
17473
|
+
tool: "wp_footer_set"
|
|
17474
|
+
});
|
|
17475
|
+
for (const hub of hubs) {
|
|
17476
|
+
const kids = pageDocs.filter((c) => c.parent === hub.id);
|
|
17477
|
+
const missing = kids.filter((k) => !hub.links.has(k.path));
|
|
17478
|
+
if (missing.length)
|
|
17479
|
+
actions.push({
|
|
17480
|
+
type: "page_related",
|
|
17481
|
+
page: hub.id,
|
|
17482
|
+
path: hub.path,
|
|
17483
|
+
heading: `More in ${hub.title}`,
|
|
17484
|
+
why: `${hub.title} doesn't link to ${missing.length} of its own sub-pages.`,
|
|
17485
|
+
links: missing.map((k) => ({ url: k.url, label: k.title }))
|
|
17486
|
+
});
|
|
17487
|
+
for (const k of kids)
|
|
17488
|
+
if (!k.links.has(hub.path)) {
|
|
17489
|
+
const sib = kids.filter((s) => s !== k).slice(0, 3);
|
|
17490
|
+
actions.push({
|
|
17491
|
+
type: "page_related",
|
|
17492
|
+
page: k.id,
|
|
17493
|
+
path: k.path,
|
|
17494
|
+
heading: "Related",
|
|
17495
|
+
why: `${k.title} doesn't link back to ${hub.title}${sib.length ? " or its sister pages" : ""}.`,
|
|
17496
|
+
links: [{ url: hub.url, label: hub.title }, ...sib.map((s) => ({ url: s.url, label: s.title }))]
|
|
17497
|
+
});
|
|
17498
|
+
}
|
|
17499
|
+
}
|
|
17500
|
+
const df = /* @__PURE__ */ new Map();
|
|
17501
|
+
for (const d of docs) for (const w of d.words) df.set(w, (df.get(w) ?? 0) + 1);
|
|
17502
|
+
const idf = (w) => Math.log(1 + docs.length / (1 + (df.get(w) ?? 0)));
|
|
17503
|
+
const forPage = /* @__PURE__ */ new Map();
|
|
17504
|
+
for (const post of postDocs) {
|
|
17505
|
+
let best = null;
|
|
17506
|
+
let score = 0;
|
|
17507
|
+
for (const pg of pageDocs) {
|
|
17508
|
+
if (pg === blogPage) continue;
|
|
17509
|
+
const s = overlap(post.words, pg.words, idf);
|
|
17510
|
+
if (s > score) [best, score] = [pg, s];
|
|
17511
|
+
}
|
|
17512
|
+
if (!best || score < 0.3) continue;
|
|
17513
|
+
forPage.set(best, [...forPage.get(best) ?? [], post]);
|
|
17514
|
+
if (!post.links.has(best.path))
|
|
17515
|
+
actions.push({
|
|
17516
|
+
type: "post_link",
|
|
17517
|
+
post: post.id,
|
|
17518
|
+
path: post.path,
|
|
17519
|
+
why: `\u201C${post.title}\u201D is about ${best.title} but doesn't link to that page.`,
|
|
17520
|
+
url: best.url,
|
|
17521
|
+
label: best.title,
|
|
17522
|
+
match: Math.round(score * 100)
|
|
17523
|
+
});
|
|
17524
|
+
}
|
|
17525
|
+
for (const [pg, ps] of forPage) {
|
|
17526
|
+
const missing = ps.filter((p) => !pg.links.has(p.path));
|
|
17527
|
+
if (missing.length >= 2)
|
|
17528
|
+
actions.push({
|
|
17529
|
+
type: "page_related",
|
|
17530
|
+
page: pg.id,
|
|
17531
|
+
path: pg.path,
|
|
17532
|
+
heading: "From the blog",
|
|
17533
|
+
why: `${missing.length} articles about ${pg.title} aren't linked from the page.`,
|
|
17534
|
+
links: missing.slice(0, 5).map((p) => ({ url: p.url, label: p.title }))
|
|
17535
|
+
});
|
|
17536
|
+
}
|
|
17537
|
+
const lonelyPosts = [];
|
|
17538
|
+
for (const d of docs) {
|
|
17539
|
+
if (d === home || d === blogPage) continue;
|
|
17540
|
+
const ins = inbound.get(d.path);
|
|
17541
|
+
if (ins && ins.size || chrome.has(d.path)) continue;
|
|
17542
|
+
if (d.kind === "post") lonelyPosts.push(d);
|
|
17543
|
+
else
|
|
17544
|
+
findings.push({
|
|
17545
|
+
id: `orphan:${d.path}`,
|
|
17546
|
+
priority: "p2",
|
|
17547
|
+
area: "outside",
|
|
17548
|
+
title: `Nothing links to the page \u201C${d.title}\u201D (${d.path})`,
|
|
17549
|
+
why: "Pages that only the sitemap knows are found late and rank poorly.",
|
|
17550
|
+
fix: "Link to it from its parent or a related page, or put it in the menu or footer"
|
|
17551
|
+
});
|
|
17552
|
+
}
|
|
17553
|
+
if (lonelyPosts.length)
|
|
17554
|
+
findings.push({
|
|
17555
|
+
id: "orphan-posts",
|
|
17556
|
+
priority: "p3",
|
|
17557
|
+
area: "outside",
|
|
17558
|
+
title: `${lonelyPosts.length} of ${postDocs.length} articles get no links from pages or other articles (only from the blog list)`,
|
|
17559
|
+
why: "Links from related pages and articles tell search engines which articles matter and keep readers on the site.",
|
|
17560
|
+
fix: "Apply the post_link and page_related actions: each article links to its page, each page shows its articles."
|
|
17561
|
+
});
|
|
17562
|
+
const grouped = groupFindings(findings);
|
|
17563
|
+
findings.length = 0;
|
|
17564
|
+
findings.push(...grouped);
|
|
17565
|
+
return {
|
|
17566
|
+
site: base,
|
|
17567
|
+
counts: {
|
|
17568
|
+
pages: pageDocs.length + (home ? 1 : 0),
|
|
17569
|
+
posts: postDocs.length,
|
|
17570
|
+
menu_items: menuItems.length,
|
|
17571
|
+
footer_links: footerPaths.size,
|
|
17572
|
+
content_links: docs.reduce((n2, d) => n2 + d.links.size, 0),
|
|
17573
|
+
orphan_pages: findings.reduce(
|
|
17574
|
+
(n2, f) => n2 + (f.id.startsWith("orphan:") ? f.pages?.length ?? 1 : 0),
|
|
17575
|
+
0
|
|
17576
|
+
),
|
|
17577
|
+
orphan_posts: lonelyPosts.length
|
|
17578
|
+
},
|
|
17579
|
+
action_counts: Object.fromEntries(
|
|
17580
|
+
["menu", "footer", "page_related", "post_link"].map((t) => [t, actions.filter((x) => x.type === t).length])
|
|
17581
|
+
),
|
|
17582
|
+
actions: [
|
|
17583
|
+
...actions.filter((x) => x.type === "menu" || x.type === "footer"),
|
|
17584
|
+
...actions.filter((x) => x.type === "post_link").sort((x, y) => Number(y.match) - Number(x.match)).slice(0, 30),
|
|
17585
|
+
...actions.filter((x) => x.type === "page_related").slice(0, 30)
|
|
17586
|
+
],
|
|
17587
|
+
findings,
|
|
17588
|
+
how: "Apply page_related and post_link actions with wp_internal_links_apply (preview first); menu actions with wp_menu_set, footer with wp_footer_set."
|
|
17589
|
+
};
|
|
17590
|
+
}
|
|
17591
|
+
var esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
17592
|
+
function relatedBlock(heading2, links2) {
|
|
17593
|
+
const items3 = links2.map((l) => `<!-- wp:list-item -->
|
|
17594
|
+
<li><a href="${esc(l.url)}">${esc(l.label)}</a></li>
|
|
17595
|
+
<!-- /wp:list-item -->`).join("\n");
|
|
17596
|
+
return `<!-- wp:group {"metadata":{"name":"Related links","maggie":{"type":"related-links","version":1}},"align":"wide","className":"maggie-related-links","layout":{"type":"constrained"}} -->
|
|
17597
|
+
<div class="wp-block-group alignwide maggie-related-links"><!-- wp:heading {"level":2} -->
|
|
17598
|
+
<h2 class="wp-block-heading">${esc(heading2)}</h2>
|
|
17599
|
+
<!-- /wp:heading -->
|
|
17600
|
+
|
|
17601
|
+
<!-- wp:list -->
|
|
17602
|
+
<ul class="wp-block-list">${items3}</ul>
|
|
17603
|
+
<!-- /wp:list --></div>
|
|
17604
|
+
<!-- /wp:group -->`;
|
|
17605
|
+
}
|
|
17606
|
+
function postLinkBlock(text3, url, label2) {
|
|
17607
|
+
return `<!-- wp:paragraph {"className":"maggie-related-link"} -->
|
|
17608
|
+
<p class="maggie-related-link">${esc(text3)} <a href="${esc(url)}">${esc(label2)}</a></p>
|
|
17609
|
+
<!-- /wp:paragraph -->`;
|
|
17610
|
+
}
|
|
17611
|
+
async function llmsTxt(site, o = {}) {
|
|
17612
|
+
const base = (site.url ?? "").replace(/\/+$/, "");
|
|
17613
|
+
const s = await site.request({
|
|
17614
|
+
path: "/wp/v2/settings",
|
|
17615
|
+
query: { _fields: "title,description,page_on_front,page_for_posts" }
|
|
17616
|
+
}).then((r) => r.status === 200 ? r.data : {});
|
|
17617
|
+
const pages = await listPages(site, { status: "publish" });
|
|
17618
|
+
const posts2 = await all(
|
|
17619
|
+
site,
|
|
17620
|
+
"/wp/v2/posts",
|
|
17621
|
+
{ status: "publish", per_page: 100, _fields: "link,title,excerpt,meta", orderby: "date", order: "desc" },
|
|
17622
|
+
o.posts ?? 25
|
|
17623
|
+
);
|
|
17624
|
+
const desc = (m, fallback = "") => String(m?.maggie_seo_description || fallback).replace(/\s+/g, " ").trim();
|
|
17625
|
+
const home = pages.find((p) => p.id === s.page_on_front);
|
|
17626
|
+
const noise = /privacy|terms|cookie|thank|cart|checkout|my-account|login/i;
|
|
17627
|
+
const visible = pages.filter((p) => p.id !== s.page_on_front && p.id !== s.page_for_posts && !noise.test(p.link));
|
|
17628
|
+
const byParent = /* @__PURE__ */ new Map();
|
|
17629
|
+
for (const p of visible) byParent.set(p.parent, [...byParent.get(p.parent) ?? [], p]);
|
|
17630
|
+
const line = (title, url, d) => `- [${title.replace(/[[\]]/g, "")}](${url})${d ? `: ${d}` : ""}`;
|
|
17631
|
+
const out = [`# ${s.title || base.replace(/^https?:\/\//, "")}`, ""];
|
|
17632
|
+
const summary = desc(home?.meta, s.description ?? "");
|
|
17633
|
+
if (summary) out.push(`> ${summary}`, "");
|
|
17634
|
+
const top = byParent.get(0) ?? [];
|
|
17635
|
+
if (top.length) {
|
|
17636
|
+
out.push("## Pages", "");
|
|
17637
|
+
for (const p of top) {
|
|
17638
|
+
out.push(line(titleOf(p.title), p.link, desc(p.meta)));
|
|
17639
|
+
for (const c of byParent.get(p.id) ?? []) out.push(` ${line(titleOf(c.title), c.link, desc(c.meta))}`);
|
|
17640
|
+
}
|
|
17641
|
+
out.push("");
|
|
17642
|
+
}
|
|
17643
|
+
if (posts2.length) {
|
|
17644
|
+
out.push("## Articles", "");
|
|
17645
|
+
for (const p of posts2)
|
|
17646
|
+
out.push(
|
|
17647
|
+
line(strip(p.title.rendered ?? ""), p.link, desc(p.meta, strip(p.excerpt?.rendered ?? "").slice(0, 160)))
|
|
17648
|
+
);
|
|
17649
|
+
out.push("");
|
|
17650
|
+
}
|
|
17651
|
+
const legal = pages.filter((p) => /privacy|terms/i.test(p.link));
|
|
17652
|
+
out.push("## Optional", "", line("Sitemap", `${base}/wp-sitemap.xml`, "every page and article"));
|
|
17653
|
+
for (const p of legal) out.push(line(titleOf(p.title), p.link, ""));
|
|
17654
|
+
return `${out.join("\n").replace(/\n{3,}/g, "\n\n").trim()}
|
|
17655
|
+
`;
|
|
17656
|
+
}
|
|
17657
|
+
|
|
16188
17658
|
// ../../packages/tools/src/wordpress.ts
|
|
16189
17659
|
var sites = /* @__PURE__ */ new WeakMap();
|
|
16190
17660
|
function siteOf(ctx) {
|
|
@@ -16331,13 +17801,28 @@ var siteTools = [
|
|
|
16331
17801
|
}),
|
|
16332
17802
|
tool({
|
|
16333
17803
|
name: "wp_site_status",
|
|
16334
|
-
description: "The website's WordPress: whether cmo.so can build on it (Administrator login, Maggie Core theme and version, child theme), who outputs SEO, the forms, synced patterns, booking link and phone, page templates. Call first in any site-building conversation.",
|
|
17804
|
+
description: "The website's WordPress: whether cmo.so can build on it (Administrator login, Maggie Core theme and version, child theme), who outputs SEO, the forms, synced patterns, booking link and phone, page templates, and the child theme's guides for agents (DESIGN.md, AGENTS.md, MEMORY.md: follow them). Call first in any site-building conversation.",
|
|
16335
17805
|
parameters: { type: "object", properties: {} },
|
|
16336
17806
|
group: "site",
|
|
16337
17807
|
need: READ,
|
|
16338
17808
|
risk: "R0",
|
|
16339
17809
|
gates: ["WP"],
|
|
16340
|
-
run: (ctx) =>
|
|
17810
|
+
run: async (ctx) => {
|
|
17811
|
+
const site = siteOf(ctx);
|
|
17812
|
+
const st = await siteStatus(site);
|
|
17813
|
+
const guides = await guidesContext(site).then(({ slug, current }) => {
|
|
17814
|
+
const missing = Object.keys(GUIDE_FILES).filter((k) => !current[k].trim());
|
|
17815
|
+
return {
|
|
17816
|
+
child: slug,
|
|
17817
|
+
note: "The owner's notes for agents: follow them for look, scope and taste. They never replace the preview-and-yes step or the safety rules.",
|
|
17818
|
+
design: clip2(current.design),
|
|
17819
|
+
agents: clip2(current.agents),
|
|
17820
|
+
memory: clip2(current.memory),
|
|
17821
|
+
...missing.length ? { missing: missing.map((k) => GUIDE_FILES[k]), next: "wp_site_guides_set init: true writes them." } : {}
|
|
17822
|
+
};
|
|
17823
|
+
}).catch((e) => ({ unavailable: e.message }));
|
|
17824
|
+
return { ...st, site_guides: guides };
|
|
17825
|
+
}
|
|
16341
17826
|
}),
|
|
16342
17827
|
tool({
|
|
16343
17828
|
name: "wp_site_map",
|
|
@@ -17251,6 +18736,59 @@ async function childSlug(site, slug) {
|
|
|
17251
18736
|
throw new Error("No Maggie child theme is active here: name one (slug), or create one with wp_child_theme_create.");
|
|
17252
18737
|
return active.slug;
|
|
17253
18738
|
}
|
|
18739
|
+
var GUIDES_MIN = [0, 13];
|
|
18740
|
+
var atLeast = (v, [maj, min]) => {
|
|
18741
|
+
const [a = 0, b = 0] = v.split(".").map(Number);
|
|
18742
|
+
return a > (maj ?? 0) || a === maj && b >= (min ?? 0);
|
|
18743
|
+
};
|
|
18744
|
+
async function guidesContext(site, slugArg) {
|
|
18745
|
+
const slug = await childSlug(site, slugArg);
|
|
18746
|
+
const info = await childInfo(site, slug);
|
|
18747
|
+
const parent = String(info.parent?.version ?? "0");
|
|
18748
|
+
if (!atLeast(parent, GUIDES_MIN))
|
|
18749
|
+
throw new Error(
|
|
18750
|
+
`DESIGN.md, AGENTS.md and MEMORY.md need Maggie Core 0.13 or later (the site has ${parent}): update it with wp_theme_update first.`
|
|
18751
|
+
);
|
|
18752
|
+
const files = info.files ?? {};
|
|
18753
|
+
const current = Object.fromEntries(
|
|
18754
|
+
Object.entries(GUIDE_FILES).map(([k, f]) => [k, files[f] ?? ""])
|
|
18755
|
+
);
|
|
18756
|
+
return { slug, current };
|
|
18757
|
+
}
|
|
18758
|
+
async function siteFacts(site) {
|
|
18759
|
+
const s = await site.request({ path: "/maggie-core/v1/site" });
|
|
18760
|
+
const t = await site.request({ path: "/wp/v2/settings", query: { _fields: "title" } }).then((r) => r.status === 200 ? r.data.title : "");
|
|
18761
|
+
return {
|
|
18762
|
+
siteName: t || site.url?.replace(/^https?:\/\//, "") || "This website",
|
|
18763
|
+
frontPage: s.status === 200 ? s.data.front_page ?? null : null,
|
|
18764
|
+
maggiePress: s.status === 200 ? !!s.data.maggie_press : false
|
|
18765
|
+
};
|
|
18766
|
+
}
|
|
18767
|
+
async function guidesWrite(site, slug, files) {
|
|
18768
|
+
const out = {};
|
|
18769
|
+
for (const [k, text3] of Object.entries(files))
|
|
18770
|
+
out[GUIDE_FILES[k]] = checkGuide(GUIDE_FILES[k], text3);
|
|
18771
|
+
if (!Object.keys(out).length) throw new Error("Nothing to change.");
|
|
18772
|
+
return call(site, {
|
|
18773
|
+
method: "POST",
|
|
18774
|
+
path: "/maggie-core/v1/child-theme",
|
|
18775
|
+
body: { slug, files: out, delete: [] }
|
|
18776
|
+
});
|
|
18777
|
+
}
|
|
18778
|
+
async function guidesInit(site, slug, current) {
|
|
18779
|
+
const facts = await siteFacts(site);
|
|
18780
|
+
const files = {};
|
|
18781
|
+
if (!current.design.trim()) files.design = await designMd(site, { ...facts, child: slug });
|
|
18782
|
+
if (!current.agents.trim()) files.agents = agentsTemplate(facts.siteName);
|
|
18783
|
+
if (!current.memory.trim()) files.memory = memoryTemplate();
|
|
18784
|
+
if (!Object.keys(files).length) return null;
|
|
18785
|
+
return {
|
|
18786
|
+
...await guidesWrite(site, slug, files),
|
|
18787
|
+
created: Object.keys(files).map((k) => GUIDE_FILES[k])
|
|
18788
|
+
};
|
|
18789
|
+
}
|
|
18790
|
+
var clip2 = (t, n2 = 6e3) => t.length > n2 ? `${t.slice(0, n2)}
|
|
18791
|
+
\u2026 (${t.length - n2} more characters: wp_site_guides_get)` : t;
|
|
17254
18792
|
async function homeShape(site) {
|
|
17255
18793
|
if (!site.fetchPage || !site.url) return null;
|
|
17256
18794
|
const r = await site.fetchPage(`${site.url}/?maggie-check=${Date.now()}`).catch(() => null);
|
|
@@ -17344,9 +18882,11 @@ var childTools = [
|
|
|
17344
18882
|
path: "/maggie-core/v1/child-theme",
|
|
17345
18883
|
body: { slug: a.slug, name: a.name, from_site: a.from_site !== false }
|
|
17346
18884
|
});
|
|
18885
|
+
const guides = await guidesContext(site, out.slug).then(({ slug, current }) => guidesInit(site, slug, current)).then((r) => r ? { guides: r.created } : {}).catch((e) => ({ guides_note: e.message }));
|
|
17347
18886
|
if (!a.activate)
|
|
17348
18887
|
return {
|
|
17349
18888
|
...out,
|
|
18889
|
+
...guides,
|
|
17350
18890
|
next: "Switch to it with wp_child_theme_activate when ready (the tool checks the homepage looks the same)."
|
|
17351
18891
|
};
|
|
17352
18892
|
const before = await homeShape(site);
|
|
@@ -17355,7 +18895,7 @@ var childTools = [
|
|
|
17355
18895
|
path: "/maggie-core/v1/child-theme/activate",
|
|
17356
18896
|
body: { slug: out.slug }
|
|
17357
18897
|
});
|
|
17358
|
-
return { ...out, activated: act2, check: compareShapes(before, await homeShape(site)) };
|
|
18898
|
+
return { ...out, ...guides, activated: act2, check: compareShapes(before, await homeShape(site)) };
|
|
17359
18899
|
}
|
|
17360
18900
|
}),
|
|
17361
18901
|
tool({
|
|
@@ -17547,6 +19087,114 @@ var childTools = [
|
|
|
17547
19087
|
};
|
|
17548
19088
|
}
|
|
17549
19089
|
}),
|
|
19090
|
+
tool({
|
|
19091
|
+
name: "wp_site_guides_get",
|
|
19092
|
+
description: "The child theme's guides for agents in full: DESIGN.md (the site's look and the owner's design rules), AGENTS.md (how agents work on this site, what not to touch) and MEMORY.md (the owner's preferences and don'ts). Follow them; they never replace the preview-and-yes step or the safety rules.",
|
|
19093
|
+
parameters: { type: "object", properties: { slug: str2("The child theme (default the active one).") } },
|
|
19094
|
+
group: "site",
|
|
19095
|
+
need: READ,
|
|
19096
|
+
risk: "R0",
|
|
19097
|
+
gates: ["WP"],
|
|
19098
|
+
run: async (ctx, a) => {
|
|
19099
|
+
const { slug, current } = await guidesContext(siteOf(ctx), a.slug);
|
|
19100
|
+
return {
|
|
19101
|
+
child: slug,
|
|
19102
|
+
"DESIGN.md": current.design || null,
|
|
19103
|
+
"AGENTS.md": current.agents || null,
|
|
19104
|
+
"MEMORY.md": current.memory || null,
|
|
19105
|
+
...!current.design || !current.agents || !current.memory ? { next: "wp_site_guides_set init: true writes the missing ones." } : {}
|
|
19106
|
+
};
|
|
19107
|
+
}
|
|
19108
|
+
}),
|
|
19109
|
+
tool({
|
|
19110
|
+
name: "wp_site_guides_set",
|
|
19111
|
+
description: `Write the child theme's guides (Markdown): design_md, agents_md, memory_md replace a file; refresh_design rewrites DESIGN.md from the live site (colours, fonts, widths, padding, homepage sections) and keeps its "## Rules" section; init writes the missing files from templates. Files are public on the site, so passwords, keys and tokens are refused. Backups as for every child file (wp_child_theme_restore).`,
|
|
19112
|
+
parameters: {
|
|
19113
|
+
type: "object",
|
|
19114
|
+
properties: {
|
|
19115
|
+
slug: str2("The child theme (default the active one)."),
|
|
19116
|
+
design_md: str2("New DESIGN.md."),
|
|
19117
|
+
agents_md: str2("New AGENTS.md."),
|
|
19118
|
+
memory_md: str2("New MEMORY.md."),
|
|
19119
|
+
refresh_design: bool("Rewrite DESIGN.md from the site, keeping its Rules section."),
|
|
19120
|
+
init: bool("Write the missing guides from templates.")
|
|
19121
|
+
}
|
|
19122
|
+
},
|
|
19123
|
+
group: "site",
|
|
19124
|
+
need: WRITE,
|
|
19125
|
+
risk: "W2",
|
|
19126
|
+
gates: [...GATES],
|
|
19127
|
+
confirm: (a, lang) => {
|
|
19128
|
+
const what = [
|
|
19129
|
+
a.design_md || a.refresh_design ? "DESIGN.md" : "",
|
|
19130
|
+
a.agents_md ? "AGENTS.md" : "",
|
|
19131
|
+
a.memory_md ? "MEMORY.md" : "",
|
|
19132
|
+
a.init ? "the missing guides" : ""
|
|
19133
|
+
].filter(Boolean);
|
|
19134
|
+
return say(
|
|
19135
|
+
lang,
|
|
19136
|
+
`Write ${what.join(", ") || "the guides"} in your website's child theme (public files; a backup is kept).`,
|
|
19137
|
+
`\u5BEB\u5165\u7DB2\u7AD9\u5B50\u4E3B\u984C\u7684 ${what.join("\u3001") || "\u6307\u5357\u6A94"}\uFF08\u516C\u958B\u6A94\u6848\uFF1B\u6703\u4FDD\u7559\u5099\u4EFD\uFF09\u3002`
|
|
19138
|
+
);
|
|
19139
|
+
},
|
|
19140
|
+
run: async (ctx, a) => {
|
|
19141
|
+
const site = siteOf(ctx);
|
|
19142
|
+
const { slug, current } = await guidesContext(site, a.slug);
|
|
19143
|
+
const files = {};
|
|
19144
|
+
if (typeof a.agents_md === "string") files.agents = a.agents_md;
|
|
19145
|
+
if (typeof a.memory_md === "string") files.memory = a.memory_md;
|
|
19146
|
+
if (typeof a.design_md === "string") files.design = a.design_md;
|
|
19147
|
+
if (a.refresh_design)
|
|
19148
|
+
files.design = await designMd(site, {
|
|
19149
|
+
...await siteFacts(site),
|
|
19150
|
+
child: slug,
|
|
19151
|
+
current: files.design ?? current.design
|
|
19152
|
+
});
|
|
19153
|
+
const wrote = Object.keys(files).length ? await guidesWrite(site, slug, files) : null;
|
|
19154
|
+
const init = a.init ? await guidesInit(site, slug, { ...current, ...files }) : null;
|
|
19155
|
+
if (!wrote && !init) throw new Error(a.init ? "All three guides already exist." : "Nothing to change.");
|
|
19156
|
+
return {
|
|
19157
|
+
child: slug,
|
|
19158
|
+
changed: [...wrote?.changed ?? [], ...init?.changed ?? []],
|
|
19159
|
+
backup: wrote?.backup ?? init?.backup ?? null,
|
|
19160
|
+
...a.refresh_design ? { design_md: files.design } : {},
|
|
19161
|
+
undo: "wp_child_theme_restore with the backup"
|
|
19162
|
+
};
|
|
19163
|
+
}
|
|
19164
|
+
}),
|
|
19165
|
+
tool({
|
|
19166
|
+
name: "wp_site_memory_add",
|
|
19167
|
+
description: `Remember something the owner said about this website in the child theme's MEMORY.md, dated, newest first: kind prefer (a preference: "short headlines"), avoid (a don't: "never use stock photos of people") or note. Use it whenever the owner states a lasting preference or a don't. Public file: no passwords, keys or personal details.`,
|
|
19168
|
+
parameters: {
|
|
19169
|
+
type: "object",
|
|
19170
|
+
properties: {
|
|
19171
|
+
kind: { type: "string", enum: ["prefer", "avoid", "note"], description: "prefer, avoid (don't) or note." },
|
|
19172
|
+
text: str2("One line, in the owner's words."),
|
|
19173
|
+
slug: str2("The child theme (default the active one).")
|
|
19174
|
+
},
|
|
19175
|
+
required: ["kind", "text"]
|
|
19176
|
+
},
|
|
19177
|
+
group: "site",
|
|
19178
|
+
need: WRITE,
|
|
19179
|
+
risk: "W2",
|
|
19180
|
+
gates: [...GATES],
|
|
19181
|
+
confirm: (a, lang) => say(
|
|
19182
|
+
lang,
|
|
19183
|
+
`Remember for this website (${a.kind === "avoid" ? "don't" : a.kind === "note" ? "note" : "preference"}): ${q2(a.text, "")}.`,
|
|
19184
|
+
`\u8A18\u4F4F\u9019\u500B\u7DB2\u7AD9\u7684${a.kind === "avoid" ? "\u7981\u5FCC" : a.kind === "note" ? "\u5099\u8A3B" : "\u504F\u597D"}\uFF1A${q2(a.text, "")}\u3002`
|
|
19185
|
+
),
|
|
19186
|
+
run: async (ctx, a) => {
|
|
19187
|
+
const site = siteOf(ctx);
|
|
19188
|
+
const text3 = String(a.text ?? "").trim();
|
|
19189
|
+
if (!text3) throw new Error("text: what to remember.");
|
|
19190
|
+
if (text3.length > 500) throw new Error("text: one line, under 500 characters.");
|
|
19191
|
+
const kind = ["prefer", "avoid", "note"].includes(String(a.kind)) ? a.kind : "note";
|
|
19192
|
+
const { slug, current } = await guidesContext(site, a.slug);
|
|
19193
|
+
const next = memoryAdd(current.memory, kind, text3);
|
|
19194
|
+
const out = await guidesWrite(site, slug, { memory: next });
|
|
19195
|
+
return { child: slug, remembered: { kind, text: text3 }, backup: out.backup, "MEMORY.md": next };
|
|
19196
|
+
}
|
|
19197
|
+
}),
|
|
17550
19198
|
tool({
|
|
17551
19199
|
name: "wp_child_theme_activate",
|
|
17552
19200
|
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.",
|
|
@@ -18508,8 +20156,276 @@ var fullTools = [
|
|
|
18508
20156
|
}
|
|
18509
20157
|
})
|
|
18510
20158
|
];
|
|
20159
|
+
var securityTools = [
|
|
20160
|
+
tool({
|
|
20161
|
+
name: "wp_security_scan",
|
|
20162
|
+
description: "Security check of the connected WordPress site: plugins, themes and WordPress against known vulnerabilities (WPVulnerability), available updates and plugins closed on WordPress.org; with Maggie Core 0.14+ also PHP/database end of life, risky settings (errors shown, open registration, file editing, XML-RPC), Administrators and old application passwords, and WP-Cron jobs (late, or left by removed plugins); from outside: security headers, public .env/.git/config backups/error logs, login-name leaks. Findings are ranked p0 (fix today) \u2026 p4 (for information), each with why and the fix (a tool when one can do it). Read-only.",
|
|
20163
|
+
parameters: {
|
|
20164
|
+
type: "object",
|
|
20165
|
+
properties: {
|
|
20166
|
+
vulnerabilities: bool("Check known vulnerabilities (default true)."),
|
|
20167
|
+
outside: bool("Check from outside like a visitor (default true).")
|
|
20168
|
+
}
|
|
20169
|
+
},
|
|
20170
|
+
group: "site",
|
|
20171
|
+
need: READ,
|
|
20172
|
+
risk: "R0",
|
|
20173
|
+
gates: ["WP"],
|
|
20174
|
+
run: (ctx, a) => securityScan(siteOf(ctx), { vulnerabilities: a.vulnerabilities !== false, outside: a.outside !== false })
|
|
20175
|
+
}),
|
|
20176
|
+
tool({
|
|
20177
|
+
name: "wp_plugin_update",
|
|
20178
|
+
description: "Update one installed plugin to its latest version (plugin id from wp_plugins_list or wp_security_scan, e.g. wpforms-lite/wpforms), then check the homepage still opens. Needs Maggie Core 0.14+. WordPress keeps no copy of the old version: for a big plugin (shop, page builder) suggest a backup first.",
|
|
20179
|
+
parameters: { type: "object", properties: { plugin: str2("Plugin id (folder/file).") }, required: ["plugin"] },
|
|
20180
|
+
group: "site",
|
|
20181
|
+
need: WRITE,
|
|
20182
|
+
risk: "W3",
|
|
20183
|
+
gates: [...GATES],
|
|
20184
|
+
confirm: (a, lang) => say(
|
|
20185
|
+
lang,
|
|
20186
|
+
`Update the plugin ${q2(a.plugin, "")} on your website to its latest version (the old version is not kept).`,
|
|
20187
|
+
`\u628A\u7DB2\u7AD9\u4E0A\u7684\u63D2\u4EF6 ${q2(a.plugin, "")} \u66F4\u65B0\u5230\u6700\u65B0\u7248\uFF08\u4E0D\u4FDD\u7559\u820A\u7248\uFF09\u3002`
|
|
20188
|
+
),
|
|
20189
|
+
run: async (ctx, a) => {
|
|
20190
|
+
const site = siteOf(ctx);
|
|
20191
|
+
const r = await call(site, {
|
|
20192
|
+
method: "POST",
|
|
20193
|
+
path: "/maggie-core/v1/plugins/update",
|
|
20194
|
+
body: { plugin: String(a.plugin ?? "").replace(/\.php$/, "") }
|
|
20195
|
+
});
|
|
20196
|
+
const home = site.url ? await check(site, `${site.url}/`, "publish") : null;
|
|
20197
|
+
return {
|
|
20198
|
+
...r,
|
|
20199
|
+
check: home,
|
|
20200
|
+
...home && !home.ok ? {
|
|
20201
|
+
warning: "The homepage has a problem after the update: deactivate the plugin (wp_plugin_set inactive) and tell the owner."
|
|
20202
|
+
} : {}
|
|
20203
|
+
};
|
|
20204
|
+
}
|
|
20205
|
+
}),
|
|
20206
|
+
tool({
|
|
20207
|
+
name: "wp_cron_unschedule",
|
|
20208
|
+
description: "Remove a WP-Cron job that nothing runs any more (left by a removed plugin; wp_security_scan lists them). WordPress's own jobs and jobs a plugin still handles are refused. Needs Maggie Core 0.14+.",
|
|
20209
|
+
parameters: { type: "object", properties: { hook: str2("The job's hook name.") }, required: ["hook"] },
|
|
20210
|
+
group: "site",
|
|
20211
|
+
need: WRITE,
|
|
20212
|
+
risk: "W2",
|
|
20213
|
+
gates: [...GATES],
|
|
20214
|
+
confirm: (a, lang) => say(
|
|
20215
|
+
lang,
|
|
20216
|
+
`Remove the scheduled job ${q2(a.hook, "")} from your website (nothing runs it any more).`,
|
|
20217
|
+
`\u79FB\u9664\u7DB2\u7AD9\u4E0A\u5DF2\u7121\u7A0B\u5F0F\u8655\u7406\u7684\u6392\u7A0B\u5DE5\u4F5C ${q2(a.hook, "")}\u3002`
|
|
20218
|
+
),
|
|
20219
|
+
run: (ctx, a) => call(siteOf(ctx), {
|
|
20220
|
+
method: "POST",
|
|
20221
|
+
path: "/maggie-core/v1/cron/unschedule",
|
|
20222
|
+
body: { hook: String(a.hook ?? "") }
|
|
20223
|
+
})
|
|
20224
|
+
})
|
|
20225
|
+
];
|
|
20226
|
+
var LINK_ACTION = obj("One action from wp_internal_links_plan.", {
|
|
20227
|
+
type: { type: "string", enum: ["page_related", "post_link"], description: "page_related or post_link." },
|
|
20228
|
+
page: int2("page_related: the page id."),
|
|
20229
|
+
heading: str2(`page_related: the band's heading, e.g. "Related" or "From the blog".`),
|
|
20230
|
+
links: arr("page_related: links to list.", obj("", { url: str2(""), label: str2("") })),
|
|
20231
|
+
post: int2("post_link: the article id."),
|
|
20232
|
+
url: str2("post_link: the page to link to."),
|
|
20233
|
+
label: str2("post_link: the link text (the page's topic)."),
|
|
20234
|
+
text: str2('post_link: words before the link (default "Related:").')
|
|
20235
|
+
});
|
|
20236
|
+
function withRelated(blocks, heading2, links2) {
|
|
20237
|
+
const band2 = parse(relatedBlock(heading2, links2)).filter((b) => b.name);
|
|
20238
|
+
const isBand = (b) => String(b.attrs.className ?? "").includes("maggie-related-links") && serialize([b]).includes(`>${heading2.replace(/&/g, "&").replace(/</g, "<")}</h2>`);
|
|
20239
|
+
const at = blocks.findIndex(isBand);
|
|
20240
|
+
if (at >= 0) return [...blocks.slice(0, at), ...band2, ...blocks.slice(at + 1)];
|
|
20241
|
+
const real = blocks.map((b, i) => [b, i]).filter(([b]) => b.name);
|
|
20242
|
+
const last = real[real.length - 1];
|
|
20243
|
+
const lastType = last ? classify(last[0], "main") : "";
|
|
20244
|
+
if (last && /cta|form|map-contact|contact/.test(lastType))
|
|
20245
|
+
return [...blocks.slice(0, last[1]), ...band2, ...blocks.slice(last[1])];
|
|
20246
|
+
return [...blocks, ...band2];
|
|
20247
|
+
}
|
|
20248
|
+
var seoTechTools = [
|
|
20249
|
+
tool({
|
|
20250
|
+
name: "wp_seo_tech_audit",
|
|
20251
|
+
description: "Technical SEO check of the connected site, like a crawler: robots.txt (sitemap line, Google or AI crawlers blocked), the sitemap and every page it lists (status, redirects, noindex in the sitemap, canonicals pointing elsewhere, H1 count, missing or duplicate titles and descriptions, long titles, pictures without alt text, broken structured data, thin pages), broken and redirected internal links, generic link words, llms.txt; speed: true adds Core Web Vitals for the homepage (PageSpeed Insights). Findings ranked p0\u2026p4 with the fix and a tool when one can do it. Read-only. Internal-link layout: wp_internal_links_plan.",
|
|
20252
|
+
parameters: {
|
|
20253
|
+
type: "object",
|
|
20254
|
+
properties: {
|
|
20255
|
+
limit: int2("Pages to crawl from the sitemap (default 60, max 200)."),
|
|
20256
|
+
speed: bool("Also measure the homepage's speed on phones (slower, about 30 s).")
|
|
20257
|
+
}
|
|
20258
|
+
},
|
|
20259
|
+
group: "site",
|
|
20260
|
+
need: READ,
|
|
20261
|
+
risk: "R0",
|
|
20262
|
+
gates: ["WP"],
|
|
20263
|
+
run: (ctx, a) => techAudit(siteOf(ctx), { limit: typeof a.limit === "number" ? a.limit : void 0, speed: !!a.speed })
|
|
20264
|
+
}),
|
|
20265
|
+
tool({
|
|
20266
|
+
name: "wp_internal_links_plan",
|
|
20267
|
+
description: "Plan the site's internal links from its own pages, articles, menu, header and footer: menu items that lead nowhere, main pages missing from the menu, legal/contact/about pages missing from the footer, hub pages that don't list their sub-pages and sub-pages that don't link back, articles that don't link to the page they are about (matched by topic) and pages that don't show their articles, and pages or articles nothing links to. Returns actions ready for wp_internal_links_apply (page_related, post_link) or wp_menu_set / wp_footer_set. Read-only.",
|
|
20268
|
+
parameters: { type: "object", properties: { posts: int2("Articles to include (default 200, newest first).") } },
|
|
20269
|
+
group: "site",
|
|
20270
|
+
need: READ,
|
|
20271
|
+
risk: "R0",
|
|
20272
|
+
gates: ["WP"],
|
|
20273
|
+
run: (ctx, a) => linksPlan(siteOf(ctx), { posts: typeof a.posts === "number" ? a.posts : void 0 })
|
|
20274
|
+
}),
|
|
20275
|
+
tool({
|
|
20276
|
+
name: "wp_internal_links_apply",
|
|
20277
|
+
description: 'Apply internal-link actions from wp_internal_links_plan: page_related puts a "Related" band (heading + list of links) in a page, replacing its own earlier band with the same heading, placed before a closing call-to-action; post_link adds one "Related: <page>" line at the end of an article (replacing its earlier one). preview: true shows the changes and saves nothing. Pages: wp_page_undo; articles: their revisions.',
|
|
20278
|
+
parameters: {
|
|
20279
|
+
type: "object",
|
|
20280
|
+
properties: { actions: arr("Up to 20 actions.", LINK_ACTION), preview: PREVIEW },
|
|
20281
|
+
required: ["actions"]
|
|
20282
|
+
},
|
|
20283
|
+
group: "site",
|
|
20284
|
+
need: WRITE,
|
|
20285
|
+
risk: "W3",
|
|
20286
|
+
gates: [...GATES],
|
|
20287
|
+
confirm: (a, lang) => {
|
|
20288
|
+
const n2 = Array.isArray(a.actions) ? a.actions.length : 0;
|
|
20289
|
+
return say(
|
|
20290
|
+
lang,
|
|
20291
|
+
`Add internal links on your website (${n2} ${n2 === 1 ? "change" : "changes"}: related-links bands on pages, a related line at the end of articles).`,
|
|
20292
|
+
`\u5728\u7DB2\u7AD9\u4E0A\u52A0\u5165\u5167\u90E8\u9023\u7D50\uFF08${n2} \u9805\uFF1A\u9801\u9762\u7684\u76F8\u95DC\u9023\u7D50\u5340\u584A\u3001\u6587\u7AE0\u7D50\u5C3E\u7684\u76F8\u95DC\u9023\u7D50\uFF09\u3002`
|
|
20293
|
+
);
|
|
20294
|
+
},
|
|
20295
|
+
run: async (ctx, a) => {
|
|
20296
|
+
const site = siteOf(ctx);
|
|
20297
|
+
const acts = Array.isArray(a.actions) ? a.actions : [];
|
|
20298
|
+
if (!acts.length) throw new Error("actions: give at least one action from wp_internal_links_plan.");
|
|
20299
|
+
if (acts.length > 20) throw new Error("actions: up to 20 at a time.");
|
|
20300
|
+
const results = [];
|
|
20301
|
+
const byPage = /* @__PURE__ */ new Map();
|
|
20302
|
+
for (const x of acts) {
|
|
20303
|
+
if (x.type === "page_related") {
|
|
20304
|
+
if (!x.page || !x.links?.length) throw new Error("page_related needs page and links.");
|
|
20305
|
+
byPage.set(x.page, [...byPage.get(x.page) ?? [], x]);
|
|
20306
|
+
} else if (x.type === "post_link") {
|
|
20307
|
+
if (!x.post || !x.url || !x.label) throw new Error("post_link needs post, url and label.");
|
|
20308
|
+
} else throw new Error(`Unknown action type ${x.type}.`);
|
|
20309
|
+
}
|
|
20310
|
+
for (const [page, list3] of byPage) {
|
|
20311
|
+
const r = await changePage(ctx, { page, preview: !!a.preview }, (blocks) => {
|
|
20312
|
+
let next = blocks;
|
|
20313
|
+
for (const x of list3)
|
|
20314
|
+
next = withRelated(next, (x.heading || "Related").slice(0, 80), (x.links ?? []).slice(0, 12));
|
|
20315
|
+
return { blocks: next, note: { related: list3.map((x) => x.heading || "Related") } };
|
|
20316
|
+
});
|
|
20317
|
+
results.push({ type: "page_related", page_id: page, ...r });
|
|
20318
|
+
}
|
|
20319
|
+
for (const x of acts.filter((y) => y.type === "post_link")) {
|
|
20320
|
+
const post = await call(site, {
|
|
20321
|
+
path: `/wp/v2/posts/${Number(x.post)}`,
|
|
20322
|
+
query: { context: "edit", _fields: "id,link,content,modified" }
|
|
20323
|
+
});
|
|
20324
|
+
const kept = parse(post.content.raw).filter(
|
|
20325
|
+
(b) => !(b.name === "core/paragraph" && String(b.attrs.className ?? "").includes("maggie-related-link"))
|
|
20326
|
+
);
|
|
20327
|
+
const line = parse(postLinkBlock(x.text || "Related:", String(x.url), String(x.label))).filter((b) => b.name);
|
|
20328
|
+
const content = serialize([...kept, ...line]);
|
|
20329
|
+
if (a.preview) {
|
|
20330
|
+
results.push({
|
|
20331
|
+
type: "post_link",
|
|
20332
|
+
post: post.id,
|
|
20333
|
+
path: post.link,
|
|
20334
|
+
preview: true,
|
|
20335
|
+
adds: `${x.text || "Related:"} ${x.label} \u2192 ${x.url}`
|
|
20336
|
+
});
|
|
20337
|
+
continue;
|
|
20338
|
+
}
|
|
20339
|
+
const saved = await call(site, {
|
|
20340
|
+
method: "POST",
|
|
20341
|
+
path: `/wp/v2/posts/${post.id}`,
|
|
20342
|
+
query: { _fields: "id,link,modified" },
|
|
20343
|
+
body: { content }
|
|
20344
|
+
});
|
|
20345
|
+
results.push({
|
|
20346
|
+
type: "post_link",
|
|
20347
|
+
post: saved.id,
|
|
20348
|
+
path: saved.link,
|
|
20349
|
+
saved: true,
|
|
20350
|
+
modified: saved.modified,
|
|
20351
|
+
link: { url: x.url, label: x.label }
|
|
20352
|
+
});
|
|
20353
|
+
}
|
|
20354
|
+
return {
|
|
20355
|
+
results,
|
|
20356
|
+
...a.preview ? { preview: true } : { undo: "Pages: wp_page_undo; articles: restore the earlier revision in the editor." }
|
|
20357
|
+
};
|
|
20358
|
+
}
|
|
20359
|
+
}),
|
|
20360
|
+
tool({
|
|
20361
|
+
name: "wp_llms_txt_get",
|
|
20362
|
+
description: "The site's llms.txt (a Markdown map of the site for AI assistants, served at /llms.txt): the saved text, whether /llms.txt answers, and whether a file on the server overrides it. Needs Maggie Core 0.14+.",
|
|
20363
|
+
parameters: { type: "object", properties: {} },
|
|
20364
|
+
group: "site",
|
|
20365
|
+
need: READ,
|
|
20366
|
+
risk: "R0",
|
|
20367
|
+
gates: ["WP"],
|
|
20368
|
+
run: async (ctx) => {
|
|
20369
|
+
const site = siteOf(ctx);
|
|
20370
|
+
const saved = await call(site, { path: "/maggie-core/v1/llms" });
|
|
20371
|
+
const live = site.url ? await fetch(`${site.url}/llms.txt`, { signal: AbortSignal.timeout(1e4) }).then(async (r) => ({ status: r.status, same: r.ok && (await r.text()).trim() === saved.text.trim() })).catch(() => null) : null;
|
|
20372
|
+
return {
|
|
20373
|
+
...saved,
|
|
20374
|
+
live,
|
|
20375
|
+
...saved.file ? {
|
|
20376
|
+
note: "A real llms.txt file on the server is served instead of the saved text: delete it at the host to use this one."
|
|
20377
|
+
} : {}
|
|
20378
|
+
};
|
|
20379
|
+
}
|
|
20380
|
+
}),
|
|
20381
|
+
tool({
|
|
20382
|
+
name: "wp_llms_txt_set",
|
|
20383
|
+
description: `Write the site's llms.txt (served at /llms.txt): generate: true builds it from the site (name and summary, pages with their SEO descriptions grouped under their parents, the latest articles, sitemap); or give text (Markdown); "" turns it off. preview: true returns the text without saving. Needs Maggie Core 0.14+.`,
|
|
20384
|
+
parameters: {
|
|
20385
|
+
type: "object",
|
|
20386
|
+
properties: {
|
|
20387
|
+
generate: bool("Build it from the site."),
|
|
20388
|
+
text: str2("The llms.txt Markdown to save."),
|
|
20389
|
+
posts: int2("Articles to list when generating (default 25)."),
|
|
20390
|
+
preview: PREVIEW
|
|
20391
|
+
}
|
|
20392
|
+
},
|
|
20393
|
+
group: "site",
|
|
20394
|
+
need: WRITE,
|
|
20395
|
+
risk: "W2",
|
|
20396
|
+
gates: [...GATES],
|
|
20397
|
+
confirm: (a, lang) => say(
|
|
20398
|
+
lang,
|
|
20399
|
+
a.text === "" ? "Turn off your website's llms.txt." : "Write your website's llms.txt (the map of the site for AI assistants, at /llms.txt).",
|
|
20400
|
+
a.text === "" ? "\u95DC\u9589\u7DB2\u7AD9\u7684 llms.txt\u3002" : "\u5BEB\u5165\u7DB2\u7AD9\u7684 llms.txt\uFF08\u7D66 AI \u52A9\u7406\u7684\u7DB2\u7AD9\u5730\u5716\uFF0C\u4F4D\u65BC /llms.txt\uFF09\u3002"
|
|
20401
|
+
),
|
|
20402
|
+
run: async (ctx, a) => {
|
|
20403
|
+
const site = siteOf(ctx);
|
|
20404
|
+
const text3 = typeof a.text === "string" ? a.text : a.generate ? await llmsTxt(site, { posts: typeof a.posts === "number" ? a.posts : void 0 }) : null;
|
|
20405
|
+
if (text3 == null) throw new Error("Give text, or generate: true.");
|
|
20406
|
+
if (/<\?(php|=)?|<script\b/i.test(text3)) throw new Error("llms.txt: plain Markdown only.");
|
|
20407
|
+
if (a.preview) return { preview: true, text: text3, characters: text3.length };
|
|
20408
|
+
const r = await call(site, {
|
|
20409
|
+
method: "POST",
|
|
20410
|
+
path: "/maggie-core/v1/llms",
|
|
20411
|
+
body: { text: text3 }
|
|
20412
|
+
});
|
|
20413
|
+
return {
|
|
20414
|
+
saved: r.saved,
|
|
20415
|
+
url: r.url,
|
|
20416
|
+
characters: text3.length,
|
|
20417
|
+
before_characters: r.before.length,
|
|
20418
|
+
...r.file ? { note: "A real llms.txt file on the server is served instead: delete it at the host." } : {},
|
|
20419
|
+
undo: r.before ? "wp_llms_txt_set text with the earlier text (before is kept in this result)" : 'wp_llms_txt_set text ""',
|
|
20420
|
+
before: r.before
|
|
20421
|
+
};
|
|
20422
|
+
}
|
|
20423
|
+
})
|
|
20424
|
+
];
|
|
18511
20425
|
var wordpressTools = [
|
|
18512
20426
|
...fullTools,
|
|
20427
|
+
...securityTools,
|
|
20428
|
+
...seoTechTools,
|
|
18513
20429
|
...siteTools,
|
|
18514
20430
|
...pageTools,
|
|
18515
20431
|
...sectionTools,
|
|
@@ -21928,7 +23844,7 @@ var program = new Command();
|
|
|
21928
23844
|
// package.json
|
|
21929
23845
|
var package_default = {
|
|
21930
23846
|
name: "@cmo-so/cli",
|
|
21931
|
-
version: "0.12.
|
|
23847
|
+
version: "0.12.12",
|
|
21932
23848
|
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.",
|
|
21933
23849
|
type: "module",
|
|
21934
23850
|
license: "MIT",
|
|
@@ -22012,7 +23928,7 @@ var fileStore = () => {
|
|
|
22012
23928
|
const file = join(configDir(), "credentials.json");
|
|
22013
23929
|
return {
|
|
22014
23930
|
load: () => readJson(file),
|
|
22015
|
-
save: (
|
|
23931
|
+
save: (tokens2) => writePrivate(file, tokens2),
|
|
22016
23932
|
clear: () => rm(file, { force: true })
|
|
22017
23933
|
};
|
|
22018
23934
|
};
|
|
@@ -22168,7 +24084,7 @@ __export(util_exports, {
|
|
|
22168
24084
|
defineLazy: () => defineLazy,
|
|
22169
24085
|
defineLazyInternal: () => defineLazyInternal,
|
|
22170
24086
|
derived: () => derived,
|
|
22171
|
-
esc: () =>
|
|
24087
|
+
esc: () => esc2,
|
|
22172
24088
|
escapeRegex: () => escapeRegex,
|
|
22173
24089
|
explicitlyAborted: () => explicitlyAborted,
|
|
22174
24090
|
extend: () => extend,
|
|
@@ -22399,7 +24315,7 @@ function randomString(length = 10) {
|
|
|
22399
24315
|
}
|
|
22400
24316
|
return str3;
|
|
22401
24317
|
}
|
|
22402
|
-
function
|
|
24318
|
+
function esc2(str3) {
|
|
22403
24319
|
return JSON.stringify(str3);
|
|
22404
24320
|
}
|
|
22405
24321
|
function slugify(input) {
|
|
@@ -24548,14 +26464,14 @@ var $ZodObject = /* @__PURE__ */ $constructor("$ZodObject", (inst, def) => {
|
|
|
24548
26464
|
const desc = Object.getOwnPropertyDescriptor(def, "shape");
|
|
24549
26465
|
const sh = desc?.get ? desc.get.raw : def.shape ?? {};
|
|
24550
26466
|
if (sh) {
|
|
24551
|
-
const
|
|
26467
|
+
const get2 = () => {
|
|
24552
26468
|
const newSh = { ...sh };
|
|
24553
26469
|
Object.defineProperty(def, "shape", { value: newSh });
|
|
24554
|
-
|
|
26470
|
+
get2.raw = newSh;
|
|
24555
26471
|
return newSh;
|
|
24556
26472
|
};
|
|
24557
|
-
|
|
24558
|
-
Object.defineProperty(def, "shape", { get });
|
|
26473
|
+
get2.raw = sh;
|
|
26474
|
+
Object.defineProperty(def, "shape", { get: get2 });
|
|
24559
26475
|
}
|
|
24560
26476
|
const _normalized = cached(() => normalizeDef(def));
|
|
24561
26477
|
defineLazyInternal(inst, "propValues", (zod) => {
|
|
@@ -24654,7 +26570,7 @@ var $ZodObjectJIT = /* @__PURE__ */ $constructor("$ZodObjectJIT", (inst, def) =>
|
|
|
24654
26570
|
if (key === "__proto__")
|
|
24655
26571
|
continue;
|
|
24656
26572
|
const id = ids3[key];
|
|
24657
|
-
const k = typeof key === "symbol" ? `syms[${syms.indexOf(key)}]` :
|
|
26573
|
+
const k = typeof key === "symbol" ? `syms[${syms.indexOf(key)}]` : esc2(key);
|
|
24658
26574
|
const isPresent = `${k} in input`;
|
|
24659
26575
|
const schema = shape[key];
|
|
24660
26576
|
const optin = schema?._zod?.optin;
|
|
@@ -27626,7 +29542,7 @@ var parseAsync2 = /* @__PURE__ */ _parseAsync(ZodRealError);
|
|
|
27626
29542
|
var safeParse3 = /* @__PURE__ */ _safeParse(ZodRealError);
|
|
27627
29543
|
var safeParseAsync2 = /* @__PURE__ */ _safeParseAsync(ZodRealError);
|
|
27628
29544
|
var encode2 = /* @__PURE__ */ _encode(ZodRealError);
|
|
27629
|
-
var
|
|
29545
|
+
var decode4 = /* @__PURE__ */ _decode(ZodRealError);
|
|
27630
29546
|
var encodeAsync2 = /* @__PURE__ */ _encodeAsync(ZodRealError);
|
|
27631
29547
|
var decodeAsync2 = /* @__PURE__ */ _decodeAsync(ZodRealError);
|
|
27632
29548
|
var safeEncode2 = /* @__PURE__ */ _safeEncode(ZodRealError);
|
|
@@ -27786,7 +29702,7 @@ var ZodType = /* @__PURE__ */ $constructor("ZodType", (inst, def) => {
|
|
|
27786
29702
|
return encode2(this, data, params, { callee: _encode2 });
|
|
27787
29703
|
},
|
|
27788
29704
|
decode: function _decode2(data, params) {
|
|
27789
|
-
return
|
|
29705
|
+
return decode4(this, data, params, { callee: _decode2 });
|
|
27790
29706
|
},
|
|
27791
29707
|
encodeAsync: async function _encodeAsync2(data, params) {
|
|
27792
29708
|
return await encodeAsync2(this, data, params, { callee: _encodeAsync2 });
|
|
@@ -32989,7 +34905,36 @@ function registerWp(program3) {
|
|
|
32989
34905
|
(p, s, o, c) => run(g(c), "wp_section_move", { page: p, section: s, ...posArgs(o) })
|
|
32990
34906
|
);
|
|
32991
34907
|
section.command("remove <page> <section>").description("remove a section").action((p, s, _o, c) => run(g(c), "wp_section_remove", { page: p, section: s }));
|
|
32992
|
-
const seo = wp.command("seo").description("SEO fields, audit, import");
|
|
34908
|
+
const seo = wp.command("seo").description("SEO fields, audit, technical check, import");
|
|
34909
|
+
seo.command("tech").description(
|
|
34910
|
+
"technical SEO check: robots.txt, sitemap pages, canonicals, noindex, H1, alt text, schema, links, llms.txt"
|
|
34911
|
+
).option("--limit <n>", "pages to crawl (default 60, max 200)").option("--speed", "also measure the homepage's Core Web Vitals on phones").action(
|
|
34912
|
+
(o, c) => run(g(c), "wp_seo_tech_audit", { limit: o.limit ? Number(o.limit) : void 0, speed: !!o.speed })
|
|
34913
|
+
);
|
|
34914
|
+
const links2 = wp.command("links").description("internal links: plan and apply (pages, articles, menu, footer)");
|
|
34915
|
+
links2.command("plan", { isDefault: true }).action((_o, c) => run(g(c), "wp_internal_links_plan", {}));
|
|
34916
|
+
links2.command("apply").description("apply page_related / post_link actions from the plan (JSON array or { actions })").requiredOption("--from <file>", "JSON (- for stdin)").option("--preview", "show the changes, save nothing").action(async (o, c) => {
|
|
34917
|
+
const j = await json(o.from);
|
|
34918
|
+
const actions = Array.isArray(j) ? j : j.actions;
|
|
34919
|
+
await run(g(c), "wp_internal_links_apply", { actions, preview: !!o.preview });
|
|
34920
|
+
});
|
|
34921
|
+
const llms = wp.command("llms").description("the site's llms.txt (served at /llms.txt)");
|
|
34922
|
+
llms.command("get", { isDefault: true }).action((_o, c) => run(g(c), "wp_llms_txt_get", {}));
|
|
34923
|
+
llms.command("generate").description("build llms.txt from the site and save it").option("--preview", "show it, save nothing").option("--posts <n>", "articles to list (default 25)").action(
|
|
34924
|
+
(o, c) => run(g(c), "wp_llms_txt_set", {
|
|
34925
|
+
generate: true,
|
|
34926
|
+
preview: !!o.preview,
|
|
34927
|
+
posts: o.posts ? Number(o.posts) : void 0
|
|
34928
|
+
})
|
|
34929
|
+
);
|
|
34930
|
+
llms.command("set <file>").description("save this Markdown file as llms.txt").action(
|
|
34931
|
+
async (file, _o, c) => run(g(c), "wp_llms_txt_set", { text: await readFile2(file, "utf8") })
|
|
34932
|
+
);
|
|
34933
|
+
llms.command("off").action((_o, c) => run(g(c), "wp_llms_txt_set", { text: "" }));
|
|
34934
|
+
wp.command("security").description(
|
|
34935
|
+
"security check: vulnerable or outdated plugins, themes and WordPress, risky settings, admins, WP-Cron, what visitors can see"
|
|
34936
|
+
).option("--no-outside", "skip the checks from outside").action((o, c) => run(g(c), "wp_security_scan", { outside: o.outside !== false }));
|
|
34937
|
+
wp.command("cron-rm <hook>").description("remove a scheduled job nothing runs any more (left by a removed plugin)").action((hook, _o, c) => run(g(c), "wp_cron_unschedule", { hook }));
|
|
32993
34938
|
seo.command("get <page>").description("stored fields and what the page outputs").action((p, _o, c) => run(g(c), "wp_seo_get", { page: p }));
|
|
32994
34939
|
seo.command("set <page>").description("set a page's SEO fields").option("--title <t>").option("--description <d>").option("--keywords <k>").option("--robots <r>", '"index, follow" | "noindex, follow" | "noindex, nofollow"').option("--canonical <url>").option("--og-image <url>").option("--from <file>", "several pages: JSON { items: [{ page, title, description, \u2026 }] }").action(async (p, o, c) => {
|
|
32995
34940
|
const args = o.from ? await json(o.from) : {
|
|
@@ -33162,6 +35107,7 @@ ${table(r.issues, ["severity", "code", "path", "detail"])}`;
|
|
|
33162
35107
|
);
|
|
33163
35108
|
plugins.command("activate <plugin>").action((p, _o, c) => run(g(c), "wp_plugin_set", { plugin: p, status: "active" }));
|
|
33164
35109
|
plugins.command("deactivate <plugin>").action((p, _o, c) => run(g(c), "wp_plugin_set", { plugin: p, status: "inactive" }));
|
|
35110
|
+
plugins.command("update <plugin>").description("update an installed plugin to its latest version (Maggie Core 0.14+)").action((p, _o, c) => run(g(c), "wp_plugin_update", { plugin: p }));
|
|
33165
35111
|
plugins.command("delete <plugin>").description("remove an inactive plugin's files (reinstall from WordPress.org to undo)").action((p, _o, c) => run(g(c), "wp_plugin_set", { plugin: p, status: "deleted" }));
|
|
33166
35112
|
wp.command("api <route>").description("read any allowed WordPress REST route (GET), e.g. /wp/v2/types").option("--query <json>", "query parameters as JSON").action(
|
|
33167
35113
|
(route, o, c) => run(g(c), "wp_api_read", { route, query: o.query ? JSON.parse(o.query) : void 0 })
|
|
@@ -33192,6 +35138,21 @@ ${table(r.issues, ["severity", "code", "path", "detail"])}`;
|
|
|
33192
35138
|
);
|
|
33193
35139
|
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 }));
|
|
33194
35140
|
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 }));
|
|
35141
|
+
const guides = wp.command("guides").description("the child theme's guides for agents: DESIGN.md, AGENTS.md, MEMORY.md");
|
|
35142
|
+
guides.command("get [slug]", { isDefault: true }).action((slug, _o, c) => run(g(c), "wp_site_guides_get", { slug }));
|
|
35143
|
+
guides.command("set [slug]").option("--design-file <file>", "replace DESIGN.md").option("--agents-file <file>", "replace AGENTS.md").option("--memory-file <file>", "replace MEMORY.md").option("--refresh-design", "rewrite DESIGN.md from the site, keeping its Rules").option("--init", "write the missing guides from templates").action(
|
|
35144
|
+
async (slug, o, c) => run(g(c), "wp_site_guides_set", {
|
|
35145
|
+
slug,
|
|
35146
|
+
design_md: o.designFile ? await readFile2(o.designFile, "utf8") : void 0,
|
|
35147
|
+
agents_md: o.agentsFile ? await readFile2(o.agentsFile, "utf8") : void 0,
|
|
35148
|
+
memory_md: o.memoryFile ? await readFile2(o.memoryFile, "utf8") : void 0,
|
|
35149
|
+
refresh_design: o.refreshDesign,
|
|
35150
|
+
init: o.init
|
|
35151
|
+
})
|
|
35152
|
+
);
|
|
35153
|
+
wp.command("remember <kind> <text...>").description("add to MEMORY.md: kind prefer, avoid (a don't) or note").action(
|
|
35154
|
+
(kind, text3, _o, c) => run(g(c), "wp_site_memory_add", { kind, text: text3.join(" ") })
|
|
35155
|
+
);
|
|
33195
35156
|
wp.command("child-theme <slug>").description(
|
|
33196
35157
|
"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"
|
|
33197
35158
|
).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(
|