@avocadostudio-ai/orchestrator-core 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chat/anthropic-planner.d.ts +8 -0
- package/dist/chat/anthropic-planner.js +166 -12
- package/dist/chat/chat-pipeline-translation.d.ts +13 -0
- package/dist/chat/chat-pipeline-translation.js +109 -45
- package/dist/chat/chat-pipeline.d.ts +1 -1
- package/dist/chat/chat-pipeline.js +312 -54
- package/dist/chat/gemini-planner.d.ts +2 -0
- package/dist/chat/gemini-planner.js +2 -1
- package/dist/chat/planner-types.d.ts +15 -0
- package/dist/chat/planner-types.js +2 -2
- package/dist/chat/planner.d.ts +12 -0
- package/dist/chat/planner.js +16 -2
- package/dist/chat/prompts.d.ts +5 -0
- package/dist/chat/prompts.js +92 -9
- package/dist/chat/translation-chunking.d.ts +124 -0
- package/dist/chat/translation-chunking.js +371 -0
- package/dist/checks/field-walk.d.ts +42 -0
- package/dist/checks/field-walk.js +198 -0
- package/dist/checks/index.d.ts +5 -0
- package/dist/checks/index.js +4 -0
- package/dist/checks/page-weight.d.ts +22 -0
- package/dist/checks/page-weight.js +200 -0
- package/dist/checks/rules-draft.d.ts +2 -0
- package/dist/checks/rules-draft.js +439 -0
- package/dist/checks/run-checks.d.ts +42 -0
- package/dist/checks/run-checks.js +159 -0
- package/dist/checks/session-runner.d.ts +19 -0
- package/dist/checks/session-runner.js +99 -0
- package/dist/checks/types.d.ts +109 -0
- package/dist/checks/types.js +1 -0
- package/dist/cms/adapter.d.ts +74 -1
- package/dist/cms/adapter.js +1 -0
- package/dist/cms/index.d.ts +1 -1
- package/dist/cms/index.js +1 -1
- package/dist/cms/media-sources.d.ts +29 -1
- package/dist/cms/media-sources.js +188 -7
- package/dist/durable/durable-store-singleton.d.ts +37 -0
- package/dist/durable/durable-store-singleton.js +179 -0
- package/dist/durable/finding-impact.d.ts +30 -0
- package/dist/durable/finding-impact.js +53 -0
- package/dist/durable/in-memory-durable-store.d.ts +203 -0
- package/dist/durable/in-memory-durable-store.js +363 -0
- package/dist/durable/index.d.ts +5 -0
- package/dist/durable/index.js +4 -0
- package/dist/durable/pending-plan-store.d.ts +28 -0
- package/dist/durable/pending-plan-store.js +156 -0
- package/dist/durable/sqlite-durable-store.d.ts +71 -0
- package/dist/durable/sqlite-durable-store.js +631 -0
- package/dist/durable/types.d.ts +265 -0
- package/dist/durable/types.js +1 -0
- package/dist/handler/create-orchestrator.d.ts +4 -0
- package/dist/handler/create-orchestrator.js +283 -32
- package/dist/http/audio-actions.d.ts +1 -1
- package/dist/http/checks-actions.d.ts +39 -0
- package/dist/http/checks-actions.js +122 -0
- package/dist/http/history-actions.d.ts +44 -1
- package/dist/http/history-actions.js +122 -0
- package/dist/http/image-generate-actions.d.ts +2 -2
- package/dist/http/ops-actions.d.ts +2 -2
- package/dist/http/publish-actions.d.ts +15 -4
- package/dist/http/publish-actions.js +3 -3
- package/dist/http/restore-actions.d.ts +3 -3
- package/dist/http/screenshot-actions.d.ts +2 -2
- package/dist/http/session-actions.d.ts +1 -1
- package/dist/http/telemetry-feedback-actions.d.ts +2 -2
- package/dist/http/unsplash-actions.d.ts +2 -2
- package/dist/http/variations-actions.d.ts +2 -2
- package/dist/index.d.ts +9 -2
- package/dist/index.js +28 -1
- package/dist/nlp/deterministic-planner-context.d.ts +16 -0
- package/dist/nlp/deterministic-planner-context.js +33 -7
- package/dist/nlp/intent-detection.d.ts +16 -0
- package/dist/nlp/intent-detection.js +15 -1
- package/dist/nlp/plan-normalizer.js +66 -32
- package/dist/ops/destructive-action-gate.js +7 -2
- package/dist/ops/ops-engine.d.ts +12 -1
- package/dist/ops/ops-engine.js +41 -14
- package/dist/publish/publish-helpers.d.ts +12 -2
- package/dist/publish/publish-helpers.js +10 -3
- package/dist/publish/publish-selection.d.ts +84 -0
- package/dist/publish/publish-selection.js +113 -0
- package/dist/publish/publish-target-registry.js +1 -1
- package/dist/publish/publish-target.d.ts +1 -1
- package/dist/publish/targets/git.js +2 -2
- package/dist/state/session-state.js +8 -1
- package/dist/state/site-assets.d.ts +41 -0
- package/dist/state/site-assets.js +40 -0
- package/package.json +3 -3
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { resolveLink } from "@avocadostudio-ai/shared";
|
|
2
|
+
/*
|
|
3
|
+
* How much a page matters, independently of what is wrong with it.
|
|
4
|
+
*
|
|
5
|
+
* The checker has always had severity — *how broken* — and no answer at all to
|
|
6
|
+
* *how much it matters*, so the panel could only be ordered by the one axis a
|
|
7
|
+
* reader does not have. On the live session that produced a list whose largest
|
|
8
|
+
* single source of findings was a test page, with the home page third.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here needs analytics. A site states what it thinks is important in
|
|
11
|
+
* the pages it links to and the pages it names in its nav. This reads both.
|
|
12
|
+
* When traffic data arrives it replaces `weight` with a measured share and
|
|
13
|
+
* everything downstream is unchanged — which is why this returns one number per
|
|
14
|
+
* slug rather than exposing its inputs to the rules.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* The four contributions, summed to 1. Named so the ordering can be argued
|
|
18
|
+
* with, and deliberately coarse: this is a sort key, not a measurement, and
|
|
19
|
+
* every extra decimal place is a claim the inputs cannot support.
|
|
20
|
+
*/
|
|
21
|
+
const CONTRIBUTION = {
|
|
22
|
+
/** Every routed page is a real page someone can land on. */
|
|
23
|
+
base: 0.25,
|
|
24
|
+
/** Editorial links from elsewhere on the site — the strongest free signal. */
|
|
25
|
+
inbound: 0.45,
|
|
26
|
+
/** Named by hand in the site's nav config. */
|
|
27
|
+
named: 0.15,
|
|
28
|
+
/** Clicks from the home page over those links. */
|
|
29
|
+
depth: 0.15
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Four inbound links and a fifth tells you nothing new. Without a ceiling this
|
|
33
|
+
* is really a page-count term, and the biggest site wins every time.
|
|
34
|
+
*/
|
|
35
|
+
const INBOUND_SATURATION = 4;
|
|
36
|
+
/**
|
|
37
|
+
* A link that appears on this fraction of the site is chrome — a footer, a
|
|
38
|
+
* header, a card grid repeated on every page — not an editorial link.
|
|
39
|
+
*
|
|
40
|
+
* Without it the term inverts: put a page in the footer and it collects an
|
|
41
|
+
* inbound link from all 45 pages, so *every* footer-linked page saturates and
|
|
42
|
+
* inbound stops discriminating exactly where a real site leans on it most.
|
|
43
|
+
* Chrome still counts, but as a named page, which is what it is.
|
|
44
|
+
*/
|
|
45
|
+
const CHROME_LINK_FRACTION = 0.8;
|
|
46
|
+
/** The home page is the home page. No combination of the terms should outrank it. */
|
|
47
|
+
const HOME_WEIGHT = 1;
|
|
48
|
+
const HOME_SLUG = "/";
|
|
49
|
+
function depthScore(depth) {
|
|
50
|
+
if (depth === null)
|
|
51
|
+
return 0;
|
|
52
|
+
if (depth <= 0)
|
|
53
|
+
return 1;
|
|
54
|
+
if (depth === 1)
|
|
55
|
+
return 0.7;
|
|
56
|
+
if (depth === 2)
|
|
57
|
+
return 0.4;
|
|
58
|
+
if (depth === 3)
|
|
59
|
+
return 0.2;
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Slugs the site config names by hand.
|
|
64
|
+
*
|
|
65
|
+
* Read as *deliberately named*, never as *the pages in the nav* — and the
|
|
66
|
+
* distinction is the whole reason this comment exists. `buildNavItems` in
|
|
67
|
+
* site-sdk puts **every** draft slug in the header; `navLabels` only relabels
|
|
68
|
+
* them and `navGroups` only collapses them into dropdowns. So a page absent
|
|
69
|
+
* from both is still in the site's nav, one click from home.
|
|
70
|
+
*
|
|
71
|
+
* An earlier version read these maps as membership and floored anything outside
|
|
72
|
+
* them at 0.10 as an orphan — "reachable only by someone who already knows the
|
|
73
|
+
* URL", which is simply false here. On a site with no nav config, which is the
|
|
74
|
+
* ordinary case, that is every page but the home page, and burying real
|
|
75
|
+
* findings on real pages is a worse failure than the noise this ordering exists
|
|
76
|
+
* to fix.
|
|
77
|
+
*
|
|
78
|
+
* So a named page gets a small bonus for having been thought about, and an
|
|
79
|
+
* unnamed one is not accused of anything.
|
|
80
|
+
*/
|
|
81
|
+
function namedSlugs(config) {
|
|
82
|
+
const out = new Set();
|
|
83
|
+
for (const slug of Object.keys(config.navLabels ?? {}))
|
|
84
|
+
out.add(slug);
|
|
85
|
+
for (const children of Object.values(config.navGroups ?? {})) {
|
|
86
|
+
for (const slug of children)
|
|
87
|
+
out.add(slug);
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Which pages link to which, from every link-ish field on the site.
|
|
93
|
+
*
|
|
94
|
+
* Resolution goes through `resolveLink` rather than a local `startsWith("/")`,
|
|
95
|
+
* for the reason that module was extracted: three places used to answer "is
|
|
96
|
+
* this a route, and does it exist" their own way and did not agree. It also
|
|
97
|
+
* matches a page by `meta.path`, so a locale-prefixed site's links land on the
|
|
98
|
+
* page they mean instead of counting as zero.
|
|
99
|
+
*
|
|
100
|
+
* Self-links are dropped — a page is not evidence of its own importance — and
|
|
101
|
+
* sources are a Set, because four links to `/pricing` in one block have told us
|
|
102
|
+
* one thing, not four.
|
|
103
|
+
*/
|
|
104
|
+
function buildLinkGraph(fieldsBySlug, pages) {
|
|
105
|
+
const sourcesByTarget = new Map();
|
|
106
|
+
for (const [slug, fields] of fieldsBySlug) {
|
|
107
|
+
for (const field of fields) {
|
|
108
|
+
if (field.kind !== "link" && field.kind !== "url")
|
|
109
|
+
continue;
|
|
110
|
+
const target = resolveLink(field.value, pages).page?.slug;
|
|
111
|
+
if (!target || target === slug)
|
|
112
|
+
continue;
|
|
113
|
+
const sources = sourcesByTarget.get(target);
|
|
114
|
+
if (sources)
|
|
115
|
+
sources.add(slug);
|
|
116
|
+
else
|
|
117
|
+
sourcesByTarget.set(target, new Set([slug]));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return sourcesByTarget;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Breadth-first from the home page over editorial links, plus a hop straight to
|
|
124
|
+
* anything named in the nav — a named page is one click from wherever you are.
|
|
125
|
+
*
|
|
126
|
+
* Returns an empty map for a site with no `/` at all, which a CMS-backed
|
|
127
|
+
* subtree legitimately is. Every page there scores on links alone.
|
|
128
|
+
*/
|
|
129
|
+
function depthsFromHome(slugs, sourcesByTarget, named) {
|
|
130
|
+
const outbound = new Map();
|
|
131
|
+
for (const [target, sources] of sourcesByTarget) {
|
|
132
|
+
for (const source of sources) {
|
|
133
|
+
const list = outbound.get(source);
|
|
134
|
+
if (list)
|
|
135
|
+
list.push(target);
|
|
136
|
+
else
|
|
137
|
+
outbound.set(source, [target]);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const depths = new Map();
|
|
141
|
+
if (!slugs.includes(HOME_SLUG))
|
|
142
|
+
return depths;
|
|
143
|
+
depths.set(HOME_SLUG, 0);
|
|
144
|
+
const queue = [HOME_SLUG];
|
|
145
|
+
while (queue.length > 0) {
|
|
146
|
+
const current = queue.shift();
|
|
147
|
+
const depth = depths.get(current);
|
|
148
|
+
const next = [...(outbound.get(current) ?? []), ...(current === HOME_SLUG ? named : [])];
|
|
149
|
+
for (const target of next) {
|
|
150
|
+
if (depths.has(target))
|
|
151
|
+
continue;
|
|
152
|
+
depths.set(target, depth + 1);
|
|
153
|
+
queue.push(target);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return depths;
|
|
157
|
+
}
|
|
158
|
+
export function computePageWeights(args) {
|
|
159
|
+
const { pages, fieldsBySlug, config } = args;
|
|
160
|
+
const slugs = pages.map((p) => p.slug);
|
|
161
|
+
const options = pages.map((p) => ({
|
|
162
|
+
slug: p.slug,
|
|
163
|
+
...(p.meta?.path ? { path: p.meta.path } : {}),
|
|
164
|
+
...(p.title ? { title: p.title } : {})
|
|
165
|
+
}));
|
|
166
|
+
const sourcesByTarget = buildLinkGraph(fieldsBySlug, options);
|
|
167
|
+
// A link carried on nearly every page is chrome. Fold it into the named term
|
|
168
|
+
// rather than letting it saturate inbound for every page in the footer.
|
|
169
|
+
const chromeThreshold = Math.max(2, Math.ceil(slugs.length * CHROME_LINK_FRACTION));
|
|
170
|
+
const chrome = new Set();
|
|
171
|
+
for (const [target, sources] of sourcesByTarget) {
|
|
172
|
+
if (sources.size >= chromeThreshold)
|
|
173
|
+
chrome.add(target);
|
|
174
|
+
}
|
|
175
|
+
const named = new Set([...namedSlugs(config), ...chrome]);
|
|
176
|
+
const depths = depthsFromHome(slugs, sourcesByTarget, named);
|
|
177
|
+
const weights = new Map();
|
|
178
|
+
for (const slug of slugs) {
|
|
179
|
+
const isHome = slug === HOME_SLUG;
|
|
180
|
+
const isNamed = named.has(slug);
|
|
181
|
+
const inbound = chrome.has(slug) ? 0 : (sourcesByTarget.get(slug)?.size ?? 0);
|
|
182
|
+
const depth = depths.get(slug) ?? null;
|
|
183
|
+
const weight = isHome
|
|
184
|
+
? HOME_WEIGHT
|
|
185
|
+
: Math.min(HOME_WEIGHT, CONTRIBUTION.base +
|
|
186
|
+
CONTRIBUTION.inbound * Math.min(inbound / INBOUND_SATURATION, 1) +
|
|
187
|
+
(isNamed ? CONTRIBUTION.named : 0) +
|
|
188
|
+
CONTRIBUTION.depth * depthScore(depth));
|
|
189
|
+
weights.set(slug, {
|
|
190
|
+
slug,
|
|
191
|
+
weight: Math.round(weight * 1000) / 1000,
|
|
192
|
+
inbound,
|
|
193
|
+
depth,
|
|
194
|
+
named: isNamed,
|
|
195
|
+
isHome,
|
|
196
|
+
isUnlinked: !isHome && !isNamed && inbound === 0
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
return weights;
|
|
200
|
+
}
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import { IMAGE_PLACEHOLDER, isKnownRoute, normalizeLinkPath, parseLink, toAltPath } from "@avocadostudio-ai/shared";
|
|
2
|
+
import { fieldText, groupByBlock } from "./field-walk.js";
|
|
3
|
+
/*
|
|
4
|
+
* The eleven-ish draft-tier rules. Each is a pure function; none does IO.
|
|
5
|
+
*
|
|
6
|
+
* Severity discipline, because findings fatigue is the failure mode and a panel
|
|
7
|
+
* with 300 warnings is a panel nobody opens:
|
|
8
|
+
* error — this is broken and a crawler or a screen reader sees it
|
|
9
|
+
* warning — this is very likely wrong
|
|
10
|
+
* info — worth a look, and safe to ignore forever
|
|
11
|
+
*/
|
|
12
|
+
const TITLE_MIN = 20;
|
|
13
|
+
const TITLE_MAX = 60;
|
|
14
|
+
const DESCRIPTION_MIN = 70;
|
|
15
|
+
const DESCRIPTION_MAX = 160;
|
|
16
|
+
const THIN_CONTENT_CHARS = 120;
|
|
17
|
+
function effectiveTitle(page) {
|
|
18
|
+
return (page.meta?.title ?? page.title ?? "").trim();
|
|
19
|
+
}
|
|
20
|
+
function nonEmpty(value) {
|
|
21
|
+
return fieldText(value).trim().length > 0;
|
|
22
|
+
}
|
|
23
|
+
function textFields(ctx) {
|
|
24
|
+
return ctx.fields.filter((f) => f.kind === "text" || f.kind === "richtext");
|
|
25
|
+
}
|
|
26
|
+
function evidenceFor(field, excerpt) {
|
|
27
|
+
return {
|
|
28
|
+
source: "draft",
|
|
29
|
+
blockId: field.blockId,
|
|
30
|
+
blockType: field.blockType,
|
|
31
|
+
...(field.blockLabel ? { blockLabel: field.blockLabel } : {}),
|
|
32
|
+
path: field.path,
|
|
33
|
+
...(excerpt ? { excerpt } : {})
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Page metadata
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
const titleMissing = {
|
|
40
|
+
id: "seo.title-missing",
|
|
41
|
+
agent: "seo",
|
|
42
|
+
severity: "error",
|
|
43
|
+
run: (ctx) => effectiveTitle(ctx.page)
|
|
44
|
+
? []
|
|
45
|
+
: [
|
|
46
|
+
{
|
|
47
|
+
title: "Page has no title",
|
|
48
|
+
detail: "Neither meta.title nor the page title is set, so the tab and the search result have nothing to show."
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
};
|
|
52
|
+
const titleLength = {
|
|
53
|
+
id: "seo.title-length",
|
|
54
|
+
agent: "seo",
|
|
55
|
+
severity: "info",
|
|
56
|
+
run: (ctx) => {
|
|
57
|
+
const title = effectiveTitle(ctx.page);
|
|
58
|
+
if (!title)
|
|
59
|
+
return []; // titleMissing owns that case; two findings for one fact is noise
|
|
60
|
+
if (title.length < TITLE_MIN) {
|
|
61
|
+
return [{ title: `Title is ${title.length} characters (aim for ${TITLE_MIN}–${TITLE_MAX})` }];
|
|
62
|
+
}
|
|
63
|
+
if (title.length > TITLE_MAX) {
|
|
64
|
+
return [{ title: `Title is ${title.length} characters and will be truncated (aim for ${TITLE_MIN}–${TITLE_MAX})` }];
|
|
65
|
+
}
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const titleDuplicate = {
|
|
70
|
+
id: "seo.title-duplicate",
|
|
71
|
+
agent: "seo",
|
|
72
|
+
severity: "warning",
|
|
73
|
+
run: (ctx) => {
|
|
74
|
+
const title = effectiveTitle(ctx.page);
|
|
75
|
+
if (!title)
|
|
76
|
+
return [];
|
|
77
|
+
const clash = ctx.site.pages.filter((p) => p.slug !== ctx.page.slug &&
|
|
78
|
+
(p.meta?.title ?? p.title ?? "").trim().toLowerCase() === title.toLowerCase());
|
|
79
|
+
if (clash.length === 0)
|
|
80
|
+
return [];
|
|
81
|
+
return [
|
|
82
|
+
{
|
|
83
|
+
title: "Another page has the same title",
|
|
84
|
+
detail: `Also used by ${clash.map((p) => p.slug).join(", ")}. Search engines pick one and drop the rest.`
|
|
85
|
+
}
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
const descriptionMissing = {
|
|
90
|
+
id: "seo.description-missing",
|
|
91
|
+
agent: "seo",
|
|
92
|
+
severity: "warning",
|
|
93
|
+
run: (ctx) => (ctx.page.meta?.description ?? "").trim()
|
|
94
|
+
? []
|
|
95
|
+
: [
|
|
96
|
+
{
|
|
97
|
+
title: "Page has no description",
|
|
98
|
+
// No proposedOps: writing a good one is a judgement-tier job. A
|
|
99
|
+
// rule that guessed here would ship copy under an approval button
|
|
100
|
+
// people have learned to click.
|
|
101
|
+
detail: "Search results and link previews will fall back to whatever text they can scrape."
|
|
102
|
+
}
|
|
103
|
+
]
|
|
104
|
+
};
|
|
105
|
+
const descriptionLength = {
|
|
106
|
+
id: "seo.description-length",
|
|
107
|
+
agent: "seo",
|
|
108
|
+
severity: "info",
|
|
109
|
+
run: (ctx) => {
|
|
110
|
+
const description = (ctx.page.meta?.description ?? "").trim();
|
|
111
|
+
if (!description)
|
|
112
|
+
return [];
|
|
113
|
+
if (description.length < DESCRIPTION_MIN) {
|
|
114
|
+
return [{ title: `Description is ${description.length} characters (aim for ${DESCRIPTION_MIN}–${DESCRIPTION_MAX})` }];
|
|
115
|
+
}
|
|
116
|
+
if (description.length > DESCRIPTION_MAX) {
|
|
117
|
+
return [{ title: `Description is ${description.length} characters and will be truncated (aim for ${DESCRIPTION_MIN}–${DESCRIPTION_MAX})` }];
|
|
118
|
+
}
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const ogImageMissing = {
|
|
123
|
+
id: "seo.og-image-missing",
|
|
124
|
+
agent: "seo",
|
|
125
|
+
severity: "info",
|
|
126
|
+
run: (ctx) => {
|
|
127
|
+
if ((ctx.page.meta?.ogImage ?? "").trim())
|
|
128
|
+
return [];
|
|
129
|
+
const hasImage = ctx.fields.some((f) => f.kind === "image" && nonEmpty(f.value) && f.value !== IMAGE_PLACEHOLDER);
|
|
130
|
+
return [
|
|
131
|
+
{
|
|
132
|
+
title: "No social preview image",
|
|
133
|
+
detail: hasImage
|
|
134
|
+
? "The page has images but none is set as meta.ogImage, so shares get no thumbnail."
|
|
135
|
+
: "Shared links will render without a thumbnail."
|
|
136
|
+
}
|
|
137
|
+
];
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
const slugQuality = {
|
|
141
|
+
id: "seo.slug-quality",
|
|
142
|
+
agent: "seo",
|
|
143
|
+
severity: "info",
|
|
144
|
+
run: (ctx) => {
|
|
145
|
+
const slug = ctx.page.slug;
|
|
146
|
+
const problems = [];
|
|
147
|
+
if (/[A-Z]/.test(slug))
|
|
148
|
+
problems.push("uppercase letters");
|
|
149
|
+
if (slug.includes("_"))
|
|
150
|
+
problems.push("underscores instead of hyphens");
|
|
151
|
+
if (/\s/.test(slug))
|
|
152
|
+
problems.push("spaces");
|
|
153
|
+
if (slug.split("/").filter(Boolean).length > 5)
|
|
154
|
+
problems.push("more than five segments");
|
|
155
|
+
if (problems.length === 0)
|
|
156
|
+
return [];
|
|
157
|
+
return [{ title: `Slug has ${problems.join(", ")}`, detail: slug }];
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// Structure
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
/** Heading levels in block order, from any field the manifest calls a headingLevel. */
|
|
164
|
+
function headingLevels(ctx) {
|
|
165
|
+
const out = [];
|
|
166
|
+
for (const field of ctx.fields) {
|
|
167
|
+
if (field.kind !== "headingLevel")
|
|
168
|
+
continue;
|
|
169
|
+
const level = typeof field.value === "number" ? field.value : Number(field.value);
|
|
170
|
+
if (Number.isFinite(level) && level >= 1 && level <= 6)
|
|
171
|
+
out.push({ field, level });
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
const h1Count = {
|
|
176
|
+
id: "seo.h1-count",
|
|
177
|
+
agent: "seo",
|
|
178
|
+
severity: "warning",
|
|
179
|
+
run: (ctx) => {
|
|
180
|
+
const levels = headingLevels(ctx);
|
|
181
|
+
// A page whose blocks declare no heading level at all is not making a
|
|
182
|
+
// claim about its structure — most likely the site's blocks hardcode their
|
|
183
|
+
// tags. Reporting "no h1" there is a guess dressed as a fact.
|
|
184
|
+
if (levels.length === 0)
|
|
185
|
+
return [];
|
|
186
|
+
const h1s = levels.filter((l) => l.level === 1);
|
|
187
|
+
if (h1s.length === 1)
|
|
188
|
+
return [];
|
|
189
|
+
if (h1s.length === 0) {
|
|
190
|
+
return [{ title: "Page has no top-level heading", detail: "No block on the page is set to heading level 1." }];
|
|
191
|
+
}
|
|
192
|
+
// Keyed by field, not by block: a block may declare more than one heading
|
|
193
|
+
// level (a two-column with a heading each), and two findings sharing one
|
|
194
|
+
// key collapse into one — leaving the second heading with no finding and
|
|
195
|
+
// no proposed fix.
|
|
196
|
+
return h1s.slice(1).map(({ field }) => ({
|
|
197
|
+
key: `${field.blockId}:${field.path}`,
|
|
198
|
+
title: "Page has more than one top-level heading",
|
|
199
|
+
detail: `${h1s.length} blocks are set to heading level 1.`,
|
|
200
|
+
evidence: evidenceFor(field),
|
|
201
|
+
proposedOps: [
|
|
202
|
+
{
|
|
203
|
+
op: "update_props",
|
|
204
|
+
pageSlug: ctx.page.slug,
|
|
205
|
+
blockId: field.blockId,
|
|
206
|
+
patch: { [field.path]: 2 }
|
|
207
|
+
}
|
|
208
|
+
]
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
const headingOrder = {
|
|
213
|
+
id: "seo.heading-order",
|
|
214
|
+
agent: "seo",
|
|
215
|
+
severity: "info",
|
|
216
|
+
run: (ctx) => {
|
|
217
|
+
const levels = headingLevels(ctx);
|
|
218
|
+
const out = [];
|
|
219
|
+
let previous = null;
|
|
220
|
+
for (const { field, level } of levels) {
|
|
221
|
+
if (previous !== null && level > previous + 1) {
|
|
222
|
+
out.push({
|
|
223
|
+
key: `${field.blockId}:${field.path}`,
|
|
224
|
+
title: `Heading jumps from level ${previous} to ${level}`,
|
|
225
|
+
detail: "Screen readers announce the gap as a missing section.",
|
|
226
|
+
evidence: evidenceFor(field)
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
previous = level;
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
const thinContent = {
|
|
235
|
+
id: "seo.thin-content",
|
|
236
|
+
agent: "seo",
|
|
237
|
+
severity: "info",
|
|
238
|
+
run: (ctx) => {
|
|
239
|
+
const total = textFields(ctx)
|
|
240
|
+
.map((f) => fieldText(f.value).trim())
|
|
241
|
+
.join(" ")
|
|
242
|
+
.trim();
|
|
243
|
+
if (total.length >= THIN_CONTENT_CHARS)
|
|
244
|
+
return [];
|
|
245
|
+
return [
|
|
246
|
+
{
|
|
247
|
+
title: `Page has ${total.length} characters of text`,
|
|
248
|
+
detail: `Under ${THIN_CONTENT_CHARS} characters is usually too little for a page to rank for anything.`
|
|
249
|
+
}
|
|
250
|
+
];
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
// Links, images, leftovers
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
/** A link's evidence, which points at the field the link was written in. */
|
|
257
|
+
function linkEvidence(link) {
|
|
258
|
+
return {
|
|
259
|
+
source: "draft",
|
|
260
|
+
blockId: link.blockId,
|
|
261
|
+
blockType: link.blockType,
|
|
262
|
+
...(link.blockLabel ? { blockLabel: link.blockLabel } : {}),
|
|
263
|
+
path: link.path,
|
|
264
|
+
excerpt: link.value
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
/*
|
|
268
|
+
* One finding per link, and a link written in prose needs a key that separates
|
|
269
|
+
* it from its neighbours: three PDF links in one body all sit at the same
|
|
270
|
+
* field path, so keying on the path alone collapses them into one finding and
|
|
271
|
+
* two broken links go unreported.
|
|
272
|
+
*/
|
|
273
|
+
function linkKey(link) {
|
|
274
|
+
const base = link.path === "" ? link.blockId : `${link.blockId}:${link.path}`;
|
|
275
|
+
return link.inProse ? `${base}#${link.value}` : base;
|
|
276
|
+
}
|
|
277
|
+
const internalLinkDead = {
|
|
278
|
+
id: "seo.internal-link-dead",
|
|
279
|
+
agent: "seo",
|
|
280
|
+
severity: "warning",
|
|
281
|
+
run: (ctx) => {
|
|
282
|
+
const known = new Set(ctx.site.slugs);
|
|
283
|
+
return ctx.links
|
|
284
|
+
/*
|
|
285
|
+
* `ctx.links` already carries both sources — `link`/`url`/`file` props
|
|
286
|
+
* and hrefs written into prose. It used to read `ctx.fields` and so saw
|
|
287
|
+
* only the first, which meant the links a content page actually has, the
|
|
288
|
+
* ones inside its bodies, were never checked.
|
|
289
|
+
*
|
|
290
|
+
* `parseLink` screens out mailto/tel/anchors/external, the default "/",
|
|
291
|
+
* and now documents: a `.pdf` path is `kind: "file"` and is not a route,
|
|
292
|
+
* so it is not this rule's business. `fileLinkUnknown` below answers the
|
|
293
|
+
* question that *is* right for a file.
|
|
294
|
+
*/
|
|
295
|
+
.filter((link) => {
|
|
296
|
+
const parsed = parseLink(link.value);
|
|
297
|
+
return parsed.kind === "page" && parsed.path?.startsWith("/") === true;
|
|
298
|
+
})
|
|
299
|
+
/*
|
|
300
|
+
* Absolute routes only, as before. A bare "pricing" is a relative link
|
|
301
|
+
* and broken from any page but the root, but flagging it is a separate
|
|
302
|
+
* judgement call from this rule's, and one that would light up existing
|
|
303
|
+
* sites without warning.
|
|
304
|
+
*/
|
|
305
|
+
.filter((link) => !isKnownRoute(link.value, known))
|
|
306
|
+
.map((link) => ({
|
|
307
|
+
key: linkKey(link),
|
|
308
|
+
title: `Link points at a page that does not exist`,
|
|
309
|
+
detail: link.inProse ? `${link.value} — written in ${link.label ?? link.path}` : link.value,
|
|
310
|
+
evidence: linkEvidence(link)
|
|
311
|
+
}));
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
/*
|
|
315
|
+
* A link to a document the site does not have.
|
|
316
|
+
*
|
|
317
|
+
* This is the rule that pays for the `file` kind. A menu PDF is linked by
|
|
318
|
+
* hand-typed path, nothing renders a 404 until a customer clicks it, and the
|
|
319
|
+
* filename is usually long and often misspelled — one live site links
|
|
320
|
+
* `/downloads/AadventureArenaBerm-Gruppen-DE.pdf`, and whether that is the real
|
|
321
|
+
* filename or a typo for it is not a question anyone has been able to ask.
|
|
322
|
+
*
|
|
323
|
+
* It runs only when the site can enumerate its documents. `ctx.site.assets`
|
|
324
|
+
* undefined means it cannot, and then this rule returns nothing rather than
|
|
325
|
+
* reporting every document on the site as missing — the same discipline as
|
|
326
|
+
* `resolveLink`, where "we did not check" must not read as "it is not there".
|
|
327
|
+
*/
|
|
328
|
+
const fileLinkUnknown = {
|
|
329
|
+
id: "content.file-link-unknown",
|
|
330
|
+
agent: "seo",
|
|
331
|
+
severity: "error",
|
|
332
|
+
run: (ctx) => {
|
|
333
|
+
const assets = ctx.site.assets;
|
|
334
|
+
if (!assets)
|
|
335
|
+
return [];
|
|
336
|
+
const known = new Set(assets.map((a) => normalizeLinkPath(a.path)));
|
|
337
|
+
return ctx.links
|
|
338
|
+
.filter((link) => {
|
|
339
|
+
const parsed = parseLink(link.value);
|
|
340
|
+
return parsed.kind === "file" && parsed.path?.startsWith("/") === true;
|
|
341
|
+
})
|
|
342
|
+
.filter((link) => !known.has(normalizeLinkPath(parseLink(link.value).path ?? "")))
|
|
343
|
+
.map((link) => ({
|
|
344
|
+
key: linkKey(link),
|
|
345
|
+
title: "Link points at a document the site does not have",
|
|
346
|
+
detail: link.inProse ? `${link.value} — written in ${link.label ?? link.path}` : link.value,
|
|
347
|
+
evidence: linkEvidence(link)
|
|
348
|
+
}));
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
const altMissing = {
|
|
352
|
+
id: "a11y.alt-missing",
|
|
353
|
+
agent: "a11y",
|
|
354
|
+
severity: "warning",
|
|
355
|
+
run: (ctx) => {
|
|
356
|
+
const out = [];
|
|
357
|
+
/*
|
|
358
|
+
* One block at a time. Paths and containers are block-relative, so a
|
|
359
|
+
* page-wide index of either pairs an image in one block with the alt text
|
|
360
|
+
* of another — and two blocks of the same type on one page is the ordinary
|
|
361
|
+
* case, not the exotic one.
|
|
362
|
+
*
|
|
363
|
+
* All three ways that went wrong were silent: two heroes where the first
|
|
364
|
+
* had no alt text reported nothing at all; the mirror image reported the
|
|
365
|
+
* problem twice, both times pointing "Go to" at the wrong block.
|
|
366
|
+
*/
|
|
367
|
+
for (const fields of groupByBlock(ctx.fields).values()) {
|
|
368
|
+
const alts = fields.filter((f) => f.kind === "imageAlt");
|
|
369
|
+
const altByPath = new Map(alts.map((f) => [f.path, f]));
|
|
370
|
+
const altsByContainer = new Map();
|
|
371
|
+
for (const field of alts) {
|
|
372
|
+
const list = altsByContainer.get(field.container) ?? [];
|
|
373
|
+
list.push(field);
|
|
374
|
+
altsByContainer.set(field.container, list);
|
|
375
|
+
}
|
|
376
|
+
for (const image of fields) {
|
|
377
|
+
if (image.kind !== "image" || !nonEmpty(image.value))
|
|
378
|
+
continue;
|
|
379
|
+
// The repo's own naming convention first (`imageUrl` → `imageAlt`,
|
|
380
|
+
// `.src` → `.alt`), then the structural answer: a lone alt field in the
|
|
381
|
+
// same container. A container with two images and two alts is ambiguous
|
|
382
|
+
// and is left alone rather than guessed at.
|
|
383
|
+
const byConvention = altByPath.get(toAltPath(image.path));
|
|
384
|
+
const siblings = altsByContainer.get(image.container) ?? [];
|
|
385
|
+
const alt = byConvention ?? (siblings.length === 1 ? siblings[0] : undefined);
|
|
386
|
+
if (!alt || nonEmpty(alt.value))
|
|
387
|
+
continue;
|
|
388
|
+
out.push({
|
|
389
|
+
key: `${image.blockId}:${image.path}`,
|
|
390
|
+
title: `Image has no alt text`,
|
|
391
|
+
detail: `${image.label ?? image.path} is set but its alt text is empty.`,
|
|
392
|
+
evidence: evidenceFor(alt)
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return out;
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
const unfinished = {
|
|
400
|
+
id: "content.unfinished",
|
|
401
|
+
agent: "content",
|
|
402
|
+
severity: "warning",
|
|
403
|
+
run: (ctx) => {
|
|
404
|
+
const out = [];
|
|
405
|
+
for (const field of ctx.fields) {
|
|
406
|
+
// `defaultScalarForField` writes exactly these when a block is scaffolded,
|
|
407
|
+
// so this is an equality test, not a heuristic — the product is the only
|
|
408
|
+
// thing that could have produced the string.
|
|
409
|
+
const isScaffoldText = (field.kind === "text" || field.kind === "richtext" || field.kind === "imageAlt") &&
|
|
410
|
+
fieldText(field.value).trim() === `New ${field.label ?? field.path}`;
|
|
411
|
+
const isScaffoldImage = field.kind === "image" && field.value === IMAGE_PLACEHOLDER;
|
|
412
|
+
if (!isScaffoldText && !isScaffoldImage)
|
|
413
|
+
continue;
|
|
414
|
+
out.push({
|
|
415
|
+
key: `${field.blockId}:${field.path}`,
|
|
416
|
+
title: `${field.label ?? field.path} was never filled in`,
|
|
417
|
+
detail: "This is still the placeholder the block was created with.",
|
|
418
|
+
evidence: evidenceFor(field, fieldText(field.value) || String(field.value))
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
return out;
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
export const DRAFT_RULES = [
|
|
425
|
+
titleMissing,
|
|
426
|
+
titleLength,
|
|
427
|
+
titleDuplicate,
|
|
428
|
+
descriptionMissing,
|
|
429
|
+
descriptionLength,
|
|
430
|
+
ogImageMissing,
|
|
431
|
+
slugQuality,
|
|
432
|
+
h1Count,
|
|
433
|
+
headingOrder,
|
|
434
|
+
thinContent,
|
|
435
|
+
internalLinkDead,
|
|
436
|
+
fileLinkUnknown,
|
|
437
|
+
altMissing,
|
|
438
|
+
unfinished
|
|
439
|
+
];
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { BlockManifest, PageDoc, SiteConfig } from "@avocadostudio-ai/shared";
|
|
2
|
+
import type { CheckRunRecord, CheckRunTrigger, DurableStore } from "../durable/types.ts";
|
|
3
|
+
import type { CheckRule, SiteAsset } from "./types.ts";
|
|
4
|
+
/**
|
|
5
|
+
* The fingerprint: identity of a problem, not of an occurrence of it.
|
|
6
|
+
*
|
|
7
|
+
* It is computed here, from `(scopeKey, slug, ruleId, key)`, and never by a
|
|
8
|
+
* rule — because the one thing that must not leak into it is the offending
|
|
9
|
+
* *value*. Include the value and half-fixing a title produces a second finding
|
|
10
|
+
* instead of an updated one, orphaning the first and silently voiding the
|
|
11
|
+
* dismissal somebody made last week.
|
|
12
|
+
*/
|
|
13
|
+
export declare function fingerprintFor(scopeKey: string, slug: string, ruleId: string, key?: string): string;
|
|
14
|
+
export type RunChecksArgs = {
|
|
15
|
+
scopeKey: string;
|
|
16
|
+
/** Every page in the site — not just the ones being scanned. See below. */
|
|
17
|
+
pages: PageDoc[];
|
|
18
|
+
manifest: BlockManifest;
|
|
19
|
+
siteConfig?: SiteConfig;
|
|
20
|
+
trigger?: CheckRunTrigger;
|
|
21
|
+
/**
|
|
22
|
+
* Restrict the scan to these slugs. Cross-page rules still see the whole
|
|
23
|
+
* site: an incremental run over one page must not report every *other*
|
|
24
|
+
* page's title as unique, nor every link into them as dead.
|
|
25
|
+
*/
|
|
26
|
+
slugs?: string[];
|
|
27
|
+
/**
|
|
28
|
+
* The documents the site holds, when the caller could list them.
|
|
29
|
+
*
|
|
30
|
+
* Omitted — not `[]` — when it could not. `content.file-link-unknown` is
|
|
31
|
+
* silent without this, because a site that cannot enumerate its assets has no
|
|
32
|
+
* grounds to call any of its own document links broken. Filling it is the
|
|
33
|
+
* caller's job because listing assets is IO, and every rule in this directory
|
|
34
|
+
* is a pure function by construction.
|
|
35
|
+
*/
|
|
36
|
+
assets?: SiteAsset[];
|
|
37
|
+
rules?: CheckRule[];
|
|
38
|
+
store?: DurableStore;
|
|
39
|
+
runId?: string;
|
|
40
|
+
now?: () => number;
|
|
41
|
+
};
|
|
42
|
+
export declare function runDraftChecks(args: RunChecksArgs): Promise<CheckRunRecord>;
|