@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,159 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { getDurableStore } from "../durable/durable-store-singleton.js";
|
|
3
|
+
import { NEUTRAL_PAGE_WEIGHT, impactFor } from "../durable/finding-impact.js";
|
|
4
|
+
import { walkPageFields, walkPageLinks } from "./field-walk.js";
|
|
5
|
+
import { computePageWeights } from "./page-weight.js";
|
|
6
|
+
import { DRAFT_RULES } from "./rules-draft.js";
|
|
7
|
+
/**
|
|
8
|
+
* The fingerprint: identity of a problem, not of an occurrence of it.
|
|
9
|
+
*
|
|
10
|
+
* It is computed here, from `(scopeKey, slug, ruleId, key)`, and never by a
|
|
11
|
+
* rule — because the one thing that must not leak into it is the offending
|
|
12
|
+
* *value*. Include the value and half-fixing a title produces a second finding
|
|
13
|
+
* instead of an updated one, orphaning the first and silently voiding the
|
|
14
|
+
* dismissal somebody made last week.
|
|
15
|
+
*/
|
|
16
|
+
export function fingerprintFor(scopeKey, slug, ruleId, key = "") {
|
|
17
|
+
return createHash("sha256").update([scopeKey, slug, ruleId, key].join("\u0000")).digest("hex").slice(0, 32);
|
|
18
|
+
}
|
|
19
|
+
export async function runDraftChecks(args) {
|
|
20
|
+
const store = args.store ?? getDurableStore();
|
|
21
|
+
const now = args.now ?? Date.now;
|
|
22
|
+
const rules = args.rules ?? DRAFT_RULES;
|
|
23
|
+
const runId = args.runId ?? randomUUID();
|
|
24
|
+
const startedAt = now();
|
|
25
|
+
const site = {
|
|
26
|
+
slugs: args.pages.map((p) => p.slug),
|
|
27
|
+
pages: args.pages.map((p) => ({ slug: p.slug, title: p.title, ...(p.meta ? { meta: p.meta } : {}) })),
|
|
28
|
+
config: args.siteConfig ?? {},
|
|
29
|
+
// Present only when the caller could enumerate the site's documents.
|
|
30
|
+
// Passing `[]` where it could not would make every file link report broken.
|
|
31
|
+
...(args.assets ? { assets: args.assets } : {})
|
|
32
|
+
};
|
|
33
|
+
const wanted = args.slugs ? new Set(args.slugs) : null;
|
|
34
|
+
const scanned = args.pages.filter((p) => (wanted ? wanted.has(p.slug) : true));
|
|
35
|
+
/*
|
|
36
|
+
* Every page is walked, not only the scanned ones.
|
|
37
|
+
*
|
|
38
|
+
* Page weight is a property of the site's link graph, so scoring it from the
|
|
39
|
+
* scanned subset would make a finding's impact depend on which run last
|
|
40
|
+
* touched it — the same finding worth 0.6 after a full sweep and 0.1 after an
|
|
41
|
+
* incremental one, with the panel resorting itself for no visible reason.
|
|
42
|
+
*
|
|
43
|
+
* The walk is pure in-memory work over data already held, and the scanned
|
|
44
|
+
* pages reuse their entry rather than being walked a second time.
|
|
45
|
+
*/
|
|
46
|
+
const fieldsBySlug = new Map();
|
|
47
|
+
const linksBySlug = new Map();
|
|
48
|
+
for (const page of args.pages) {
|
|
49
|
+
fieldsBySlug.set(page.slug, walkPageFields(page, args.manifest));
|
|
50
|
+
linksBySlug.set(page.slug, walkPageLinks(page, args.manifest));
|
|
51
|
+
}
|
|
52
|
+
const weights = computePageWeights({ pages: site.pages, fieldsBySlug, config: site.config });
|
|
53
|
+
await store.startCheckRun({
|
|
54
|
+
id: runId,
|
|
55
|
+
scopeKey: args.scopeKey,
|
|
56
|
+
agent: rules.length === 1 ? rules[0].agent : "checks",
|
|
57
|
+
trigger: args.trigger ?? "manual",
|
|
58
|
+
startedAt
|
|
59
|
+
});
|
|
60
|
+
const findings = [];
|
|
61
|
+
const seen = new Set();
|
|
62
|
+
let error;
|
|
63
|
+
try {
|
|
64
|
+
for (const page of scanned) {
|
|
65
|
+
const pageWeight = weights.get(page.slug)?.weight ?? NEUTRAL_PAGE_WEIGHT;
|
|
66
|
+
const ctx = {
|
|
67
|
+
scopeKey: args.scopeKey,
|
|
68
|
+
page,
|
|
69
|
+
site,
|
|
70
|
+
manifest: args.manifest,
|
|
71
|
+
fields: fieldsBySlug.get(page.slug) ?? [],
|
|
72
|
+
links: linksBySlug.get(page.slug) ?? []
|
|
73
|
+
};
|
|
74
|
+
for (const rule of rules) {
|
|
75
|
+
// One rule throwing must not cost the run every other rule's findings —
|
|
76
|
+
// a checker that goes dark because a custom block had an unexpected
|
|
77
|
+
// prop shape is worse than one that reports twelve of thirteen rules.
|
|
78
|
+
let produced;
|
|
79
|
+
try {
|
|
80
|
+
produced = rule.run(ctx);
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
error ??= `${rule.id}: ${err instanceof Error ? err.message : String(err)}`;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
for (const finding of produced) {
|
|
87
|
+
const fingerprint = fingerprintFor(args.scopeKey, page.slug, rule.id, finding.key);
|
|
88
|
+
/*
|
|
89
|
+
* Two findings from one rule sharing a key are one finding as far as
|
|
90
|
+
* the store is concerned — the second upsert overwrites the first.
|
|
91
|
+
* Collapsing them here instead keeps the ledger honest (the second
|
|
92
|
+
* was being counted as an `updated` row) and makes which one survives
|
|
93
|
+
* a decision rather than an accident of iteration order.
|
|
94
|
+
*/
|
|
95
|
+
if (seen.has(fingerprint))
|
|
96
|
+
continue;
|
|
97
|
+
seen.add(fingerprint);
|
|
98
|
+
const severity = finding.severity ?? rule.severity;
|
|
99
|
+
findings.push({
|
|
100
|
+
fingerprint,
|
|
101
|
+
scopeKey: args.scopeKey,
|
|
102
|
+
slug: page.slug,
|
|
103
|
+
ruleId: rule.id,
|
|
104
|
+
agent: rule.agent,
|
|
105
|
+
severity,
|
|
106
|
+
impact: impactFor(severity, pageWeight),
|
|
107
|
+
title: finding.title,
|
|
108
|
+
...(finding.detail ? { detail: finding.detail } : {}),
|
|
109
|
+
...(finding.evidence ? { evidence: finding.evidence } : {}),
|
|
110
|
+
...(finding.proposedOps ? { proposedOps: finding.proposedOps } : {})
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const { opened } = await store.recordFindings(runId, findings);
|
|
116
|
+
// Reconcile per agent, not once for the whole run. A run of only the SEO
|
|
117
|
+
// rules that closed everything in scope would mark this morning's
|
|
118
|
+
// accessibility findings fixed without having looked at them.
|
|
119
|
+
const scannedSlugs = scanned.map((p) => p.slug);
|
|
120
|
+
let closed = 0;
|
|
121
|
+
for (const agent of new Set(rules.map((r) => r.agent))) {
|
|
122
|
+
const result = await store.reconcileFindings({
|
|
123
|
+
runId,
|
|
124
|
+
scopeKey: args.scopeKey,
|
|
125
|
+
slugs: scannedSlugs,
|
|
126
|
+
agent,
|
|
127
|
+
at: now()
|
|
128
|
+
});
|
|
129
|
+
closed += result.closed;
|
|
130
|
+
}
|
|
131
|
+
const record = {
|
|
132
|
+
id: runId,
|
|
133
|
+
scopeKey: args.scopeKey,
|
|
134
|
+
agent: rules.length === 1 ? rules[0].agent : "checks",
|
|
135
|
+
trigger: args.trigger ?? "manual",
|
|
136
|
+
startedAt,
|
|
137
|
+
finishedAt: now(),
|
|
138
|
+
pagesScanned: scanned.length,
|
|
139
|
+
findingsOpened: opened,
|
|
140
|
+
findingsClosed: closed,
|
|
141
|
+
costUsd: 0,
|
|
142
|
+
...(error ? { error } : {})
|
|
143
|
+
};
|
|
144
|
+
await store.finishCheckRun(runId, {
|
|
145
|
+
finishedAt: record.finishedAt,
|
|
146
|
+
pagesScanned: record.pagesScanned,
|
|
147
|
+
findingsOpened: record.findingsOpened,
|
|
148
|
+
findingsClosed: record.findingsClosed,
|
|
149
|
+
costUsd: 0,
|
|
150
|
+
...(error ? { error } : {})
|
|
151
|
+
});
|
|
152
|
+
return record;
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
156
|
+
await store.finishCheckRun(runId, { finishedAt: now(), pagesScanned: scanned.length, error: reason });
|
|
157
|
+
throw err;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CheckRunRecord, CheckRunTrigger } from "../durable/types.ts";
|
|
2
|
+
import type { Logger } from "../logger.ts";
|
|
3
|
+
export declare function runChecksForSession(args: {
|
|
4
|
+
scopeKey: string;
|
|
5
|
+
trigger: CheckRunTrigger;
|
|
6
|
+
slugs?: string[];
|
|
7
|
+
}): Promise<CheckRunRecord>;
|
|
8
|
+
/**
|
|
9
|
+
* Queue a draft-tier run after an apply, coalescing a burst of edits into one.
|
|
10
|
+
*
|
|
11
|
+
* Debounced rather than throttled: the useful moment is after someone stops
|
|
12
|
+
* typing, not in the middle of a streamed multi-op plan where half the ops have
|
|
13
|
+
* landed and the page is transiently wrong.
|
|
14
|
+
*/
|
|
15
|
+
export declare function scheduleChecksAfterApply(scopeKey: string, log?: Logger): void;
|
|
16
|
+
/** Run the draft tier after a successful publish. */
|
|
17
|
+
export declare function scheduleChecksAfterPublish(scopeKey: string, log?: Logger): void;
|
|
18
|
+
/** Cancel any queued run. Tests, and graceful shutdown. */
|
|
19
|
+
export declare function cancelScheduledChecks(): void;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { buildBlockManifest } from "@avocadostudio-ai/shared";
|
|
2
|
+
import { getSessionDraft, getSiteConfig } from "../state/session-state.js";
|
|
3
|
+
import { runDraftChecks } from "./run-checks.js";
|
|
4
|
+
import { getSiteAssets } from "../state/site-assets.js";
|
|
5
|
+
/*
|
|
6
|
+
* Binds the pure rules engine to session state.
|
|
7
|
+
*
|
|
8
|
+
* `run-checks.ts` deliberately takes pages and a manifest as arguments and
|
|
9
|
+
* touches no globals — it is testable against invented block types and an
|
|
10
|
+
* invented site. This is the one place that reaches for the real ones, so both
|
|
11
|
+
* the HTTP action and the triggers below run identical code.
|
|
12
|
+
*/
|
|
13
|
+
export async function runChecksForSession(args) {
|
|
14
|
+
// `undefined` when the site cannot list its documents — see `site-assets.ts`.
|
|
15
|
+
const assets = await getSiteAssets();
|
|
16
|
+
return runDraftChecks({
|
|
17
|
+
scopeKey: args.scopeKey,
|
|
18
|
+
pages: [...getSessionDraft(args.scopeKey).values()],
|
|
19
|
+
// The registry of *this* process — in library mode, the host's own
|
|
20
|
+
// `registerBlocks()`. Same reason `blocksManifestAction` uses it: a rule
|
|
21
|
+
// reading our built-ins would be blind to the site's real vocabulary.
|
|
22
|
+
manifest: buildBlockManifest(),
|
|
23
|
+
siteConfig: getSiteConfig(args.scopeKey),
|
|
24
|
+
trigger: args.trigger,
|
|
25
|
+
...(assets ? { assets } : {}),
|
|
26
|
+
...(args.slugs?.length ? { slugs: args.slugs } : {})
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Triggers
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
/*
|
|
33
|
+
* What wakes a check run, and why the defaults are what they are.
|
|
34
|
+
*
|
|
35
|
+
* `on_publish` is on by default: the draft tier is a few milliseconds of
|
|
36
|
+
* in-memory work, it costs nothing, and the moment content ships is when
|
|
37
|
+
* anybody cares whether it is broken.
|
|
38
|
+
*
|
|
39
|
+
* `on_apply` is off by default, behind `CHECKS_ON_APPLY=1`. It is the one that
|
|
40
|
+
* fires on every edit, and this repo has already paid once for a fan-out
|
|
41
|
+
* nobody intended — an ambient linter should be something an operator turns on
|
|
42
|
+
* having decided to, not something they discover in a CPU graph.
|
|
43
|
+
*
|
|
44
|
+
* Both are inert under NODE_ENV=test: the hermetic suite must not have a
|
|
45
|
+
* background task writing findings into a store its assertions are reading.
|
|
46
|
+
*/
|
|
47
|
+
const ON_APPLY_DEBOUNCE_MS = 2_000;
|
|
48
|
+
const pending = new Map();
|
|
49
|
+
function enabled(flag) {
|
|
50
|
+
if (process.env.NODE_ENV === "test")
|
|
51
|
+
return false;
|
|
52
|
+
if (flag === "apply")
|
|
53
|
+
return process.env.CHECKS_ON_APPLY === "1";
|
|
54
|
+
return process.env.CHECKS_ON_PUBLISH !== "0";
|
|
55
|
+
}
|
|
56
|
+
function runInBackground(scopeKey, trigger, log) {
|
|
57
|
+
void runChecksForSession({ scopeKey, trigger })
|
|
58
|
+
.then((run) => {
|
|
59
|
+
log?.info({ scopeKey, trigger, opened: run.findingsOpened, closed: run.findingsClosed }, "checks run complete");
|
|
60
|
+
})
|
|
61
|
+
.catch((err) => {
|
|
62
|
+
// A checker must never be able to fail the edit or the publish that
|
|
63
|
+
// triggered it. It reports and stops.
|
|
64
|
+
log?.error({ err: String(err), scopeKey, trigger }, "checks run failed");
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Queue a draft-tier run after an apply, coalescing a burst of edits into one.
|
|
69
|
+
*
|
|
70
|
+
* Debounced rather than throttled: the useful moment is after someone stops
|
|
71
|
+
* typing, not in the middle of a streamed multi-op plan where half the ops have
|
|
72
|
+
* landed and the page is transiently wrong.
|
|
73
|
+
*/
|
|
74
|
+
export function scheduleChecksAfterApply(scopeKey, log) {
|
|
75
|
+
if (!enabled("apply"))
|
|
76
|
+
return;
|
|
77
|
+
const existing = pending.get(scopeKey);
|
|
78
|
+
if (existing)
|
|
79
|
+
clearTimeout(existing);
|
|
80
|
+
const timer = setTimeout(() => {
|
|
81
|
+
pending.delete(scopeKey);
|
|
82
|
+
runInBackground(scopeKey, "on_apply", log);
|
|
83
|
+
}, ON_APPLY_DEBOUNCE_MS);
|
|
84
|
+
// Do not hold the process open for a linter.
|
|
85
|
+
timer.unref?.();
|
|
86
|
+
pending.set(scopeKey, timer);
|
|
87
|
+
}
|
|
88
|
+
/** Run the draft tier after a successful publish. */
|
|
89
|
+
export function scheduleChecksAfterPublish(scopeKey, log) {
|
|
90
|
+
if (!enabled("publish"))
|
|
91
|
+
return;
|
|
92
|
+
runInBackground(scopeKey, "on_publish", log);
|
|
93
|
+
}
|
|
94
|
+
/** Cancel any queued run. Tests, and graceful shutdown. */
|
|
95
|
+
export function cancelScheduledChecks() {
|
|
96
|
+
for (const timer of pending.values())
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
pending.clear();
|
|
99
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { BlockManifest, FieldKind, Operation, PageDoc, SiteConfig } from "@avocadostudio-ai/shared";
|
|
2
|
+
import type { FindingEvidence, FindingSeverity } from "../durable/types.ts";
|
|
3
|
+
/** One field on one block, located by the manifest rather than by block type. */
|
|
4
|
+
export type FieldEntry = {
|
|
5
|
+
blockId: string;
|
|
6
|
+
blockType: string;
|
|
7
|
+
/** Editable-path form: `title`, `cards[0].imageAlt`. */
|
|
8
|
+
path: string;
|
|
9
|
+
kind: FieldKind;
|
|
10
|
+
label?: string;
|
|
11
|
+
/**
|
|
12
|
+
* What a reader would call the block this field belongs to — its own heading,
|
|
13
|
+
* not the block type. A page with three Card Grids produces three findings
|
|
14
|
+
* reading `cards[0].imageAlt`, and the type name distinguishes none of them.
|
|
15
|
+
*/
|
|
16
|
+
blockLabel?: string;
|
|
17
|
+
value: unknown;
|
|
18
|
+
/**
|
|
19
|
+
* The container this field sits in — `""` for a top-level prop, `cards[0]`
|
|
20
|
+
* for a list item. Pairing an image with its alt text is a question about
|
|
21
|
+
* siblings, and the container is what makes "sibling" meaningful.
|
|
22
|
+
*/
|
|
23
|
+
container: string;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* One link on the page, wherever it was written.
|
|
27
|
+
*
|
|
28
|
+
* Separate from `FieldEntry` because most links are not fields. A `link`-kind
|
|
29
|
+
* prop is one source; the other — and on a content-heavy page much the larger —
|
|
30
|
+
* is prose: `[Menükarte](/downloads/menu-de.pdf)` inside a richtext body. Every
|
|
31
|
+
* link-aware rule read declared fields only, so those were never checked.
|
|
32
|
+
*
|
|
33
|
+
* A prose link has no editable path of its own, which is why `path` points at
|
|
34
|
+
* the *field that contains it* and `inProse` says so. A finding can still send
|
|
35
|
+
* you to the right control; it just cannot highlight the link itself.
|
|
36
|
+
*/
|
|
37
|
+
export type LinkEntry = {
|
|
38
|
+
blockId: string;
|
|
39
|
+
blockType: string;
|
|
40
|
+
blockLabel?: string;
|
|
41
|
+
/** The field the link is in — its own path for a link field, the containing field for a prose link. */
|
|
42
|
+
path: string;
|
|
43
|
+
label?: string;
|
|
44
|
+
/** The href as written. */
|
|
45
|
+
value: string;
|
|
46
|
+
/** True when this came out of a richtext body rather than from a link field. */
|
|
47
|
+
inProse: boolean;
|
|
48
|
+
};
|
|
49
|
+
/** A document the site can link to. Empty is not the same as absent — see `assets`. */
|
|
50
|
+
export type SiteAsset = {
|
|
51
|
+
path: string;
|
|
52
|
+
name?: string;
|
|
53
|
+
contentType?: string;
|
|
54
|
+
size?: number;
|
|
55
|
+
};
|
|
56
|
+
/** The other pages, for the rules that cannot be answered from one page. */
|
|
57
|
+
export type SiteView = {
|
|
58
|
+
slugs: string[];
|
|
59
|
+
pages: Array<Pick<PageDoc, "slug" | "title"> & {
|
|
60
|
+
meta?: PageDoc["meta"];
|
|
61
|
+
}>;
|
|
62
|
+
config: SiteConfig;
|
|
63
|
+
/**
|
|
64
|
+
* The documents the site holds, when it can enumerate them.
|
|
65
|
+
*
|
|
66
|
+
* `undefined` and `[]` mean different things and rules must keep them apart:
|
|
67
|
+
* undefined is "this site cannot list its assets", under which no rule may
|
|
68
|
+
* conclude a file link is broken; `[]` is "it listed them, and there are
|
|
69
|
+
* none", under which every file link is broken and saying so is correct.
|
|
70
|
+
* A site answers this by implementing `getMedia`; most do not, and for those
|
|
71
|
+
* the file rules stay silent rather than reporting every document on the site.
|
|
72
|
+
*/
|
|
73
|
+
assets?: SiteAsset[];
|
|
74
|
+
};
|
|
75
|
+
export type CheckContext = {
|
|
76
|
+
scopeKey: string;
|
|
77
|
+
page: PageDoc;
|
|
78
|
+
site: SiteView;
|
|
79
|
+
manifest: BlockManifest;
|
|
80
|
+
/** Every field on the page, already flattened. Rules should not re-walk. */
|
|
81
|
+
fields: FieldEntry[];
|
|
82
|
+
/** Every link on the page — from link fields and from prose alike. */
|
|
83
|
+
links: LinkEntry[];
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* What a rule returns.
|
|
87
|
+
*
|
|
88
|
+
* Deliberately *not* a `FindingInput`: the fingerprint is computed by the
|
|
89
|
+
* runner from `(scopeKey, slug, ruleId, key)`, so a rule cannot accidentally
|
|
90
|
+
* fold the offending value into it. That mistake is invisible until the day
|
|
91
|
+
* somebody half-fixes a title and gets a second finding instead of an updated
|
|
92
|
+
* one, and the dismissal they made last week stops applying.
|
|
93
|
+
*/
|
|
94
|
+
export type RuleFinding = {
|
|
95
|
+
/** Distinguishes several findings from one rule on one page. Stable, not a value. */
|
|
96
|
+
key?: string;
|
|
97
|
+
severity?: FindingSeverity;
|
|
98
|
+
title: string;
|
|
99
|
+
detail?: string;
|
|
100
|
+
evidence?: FindingEvidence;
|
|
101
|
+
proposedOps?: Operation[];
|
|
102
|
+
};
|
|
103
|
+
export type CheckRule = {
|
|
104
|
+
id: string;
|
|
105
|
+
agent: string;
|
|
106
|
+
/** Used when a finding does not override it. */
|
|
107
|
+
severity: FindingSeverity;
|
|
108
|
+
run(ctx: CheckContext): RuleFinding[];
|
|
109
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/cms/adapter.d.ts
CHANGED
|
@@ -67,14 +67,33 @@ export type CmsPublishResult = void | {
|
|
|
67
67
|
export interface CmsMediaItem {
|
|
68
68
|
/** Stable id in the source store. Used as the picker's selection key. */
|
|
69
69
|
id: string;
|
|
70
|
+
/**
|
|
71
|
+
* What this asset is.
|
|
72
|
+
*
|
|
73
|
+
* Absent means `"image"`, because every adapter written before documents
|
|
74
|
+
* existed returns images and must keep working unchanged. `"file"` is a
|
|
75
|
+
* document — a menu PDF, a price list, a consent form — which the editor
|
|
76
|
+
* offers into a `file` field or a prose link rather than an `<img>`.
|
|
77
|
+
*/
|
|
78
|
+
kind?: "image" | "file";
|
|
70
79
|
/** Human-facing filename or title, if the store has one. */
|
|
71
80
|
name?: string;
|
|
72
81
|
/** Full-size URL to insert into the page. Omitted only if the store has none. */
|
|
73
82
|
imageUrl?: string;
|
|
74
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* Grid thumbnail. May be the same URL as `imageUrl` when no derivative
|
|
85
|
+
* exists. A `file` has nothing to show, so this is `""` for one and the
|
|
86
|
+
* picker draws an icon from `contentType` instead.
|
|
87
|
+
*/
|
|
75
88
|
thumbUrl: string;
|
|
76
89
|
/** Alt text the store already holds, so the editor does not invent one. */
|
|
77
90
|
alt?: string;
|
|
91
|
+
/** For `kind: "file"`: the URL to link to. */
|
|
92
|
+
url?: string;
|
|
93
|
+
/** MIME type, when the store knows it. Drives the picker's icon and filter. */
|
|
94
|
+
contentType?: string;
|
|
95
|
+
/** Bytes, when the store knows it. Shown to a person; nothing decides on it. */
|
|
96
|
+
size?: number;
|
|
78
97
|
}
|
|
79
98
|
/** One page of a media-library search. */
|
|
80
99
|
export interface CmsMediaQuery {
|
|
@@ -84,6 +103,16 @@ export interface CmsMediaQuery {
|
|
|
84
103
|
page: number;
|
|
85
104
|
/** Items per page. */
|
|
86
105
|
limit: number;
|
|
106
|
+
/**
|
|
107
|
+
* Which kind the caller wants.
|
|
108
|
+
*
|
|
109
|
+
* Absent means "images", which is what every existing caller meant and every
|
|
110
|
+
* existing adapter answers. An adapter that does not understand this field
|
|
111
|
+
* returns images to a request for documents; the route filters the response
|
|
112
|
+
* by `kind` as well, so an old adapter degrades to an empty document tab
|
|
113
|
+
* rather than to a grid of images offered as PDFs.
|
|
114
|
+
*/
|
|
115
|
+
kind?: "image" | "file";
|
|
87
116
|
}
|
|
88
117
|
/** What `getMedia` returns: the page, and how many there are in total. */
|
|
89
118
|
export interface CmsMediaPage {
|
|
@@ -200,6 +229,43 @@ export interface CmsAdapter {
|
|
|
200
229
|
* requirement, and nothing in the route path knows it exists.
|
|
201
230
|
*/
|
|
202
231
|
getMedia?(query: CmsMediaQuery): Promise<CmsMediaPage>;
|
|
232
|
+
/**
|
|
233
|
+
* Optional media *write*, the other half of `getMedia`.
|
|
234
|
+
*
|
|
235
|
+
* Somebody has to be able to add a document, and until this existed the only
|
|
236
|
+
* way was to put the file on disk yourself and commit it — which is not a
|
|
237
|
+
* path a non-technical editor has, and is the whole reason the editor could
|
|
238
|
+
* list a site's PDFs but never gain one.
|
|
239
|
+
*
|
|
240
|
+
* The adapter decides where a file goes, for the same reason it decides where
|
|
241
|
+
* pages come from: we do not know. A static site writes into `public/`; a
|
|
242
|
+
* Sanity site uploads an asset and gets a CDN URL; a site on a read-only
|
|
243
|
+
* filesystem implements nothing and the editor offers no upload button.
|
|
244
|
+
* Owning storage here instead would mean owning it *badly* — the existing
|
|
245
|
+
* `POST /image/upload` writes to the orchestrator's local disk, which on an
|
|
246
|
+
* ephemeral container loses every upload on redeploy, and building the
|
|
247
|
+
* document path on that foundation would spread the defect rather than fix
|
|
248
|
+
* it.
|
|
249
|
+
*
|
|
250
|
+
* Implement it and the editor offers upload. Leave it off and it does not —
|
|
251
|
+
* silence means no, as everywhere else on this interface.
|
|
252
|
+
*
|
|
253
|
+
* Throwing rejects the upload with the thrown message shown to the editor, so
|
|
254
|
+
* a refusal ("we only accept PDFs", "that name is taken") is expressible
|
|
255
|
+
* without a second return shape.
|
|
256
|
+
*/
|
|
257
|
+
uploadMedia?(input: CmsMediaUpload): Promise<CmsMediaItem>;
|
|
258
|
+
}
|
|
259
|
+
/** One file on its way in. */
|
|
260
|
+
export interface CmsMediaUpload {
|
|
261
|
+
/** Original filename as the browser reported it. Treat as untrusted. */
|
|
262
|
+
filename: string;
|
|
263
|
+
/** MIME type the browser reported, or "" — also untrusted. */
|
|
264
|
+
contentType: string;
|
|
265
|
+
/** The bytes. */
|
|
266
|
+
data: Uint8Array;
|
|
267
|
+
/** What the caller says this is. The adapter may disagree and throw. */
|
|
268
|
+
kind: "image" | "file";
|
|
203
269
|
}
|
|
204
270
|
/**
|
|
205
271
|
* Per-operation capability declaration. Every field is tri-state, and the
|
|
@@ -266,6 +332,13 @@ export interface ResolvedCapabilities {
|
|
|
266
332
|
* opens onto a method nobody implemented is a worse answer than no tab.
|
|
267
333
|
*/
|
|
268
334
|
readsMedia: boolean;
|
|
335
|
+
/**
|
|
336
|
+
* Whether this site can take a new file — derived from `uploadMedia`, the
|
|
337
|
+
* same way `readsMedia` is derived from `getMedia`. The editor offers an
|
|
338
|
+
* upload control only where there is something behind it; a button that
|
|
339
|
+
* always fails is worse than no button.
|
|
340
|
+
*/
|
|
341
|
+
writesMedia: boolean;
|
|
269
342
|
/**
|
|
270
343
|
* The subset the adapter actually declared. A caller that needs to
|
|
271
344
|
* distinguish "this site says no" from "nobody said" reads this; everything
|
package/dist/cms/adapter.js
CHANGED
|
@@ -15,6 +15,7 @@ export function resolveCapabilities(adapter, override) {
|
|
|
15
15
|
// `=== true`, not truthiness: an adapter that says nothing has not said yes.
|
|
16
16
|
readsDraftPerspective: adapter?.perspectives === true,
|
|
17
17
|
readsMedia: typeof adapter?.getMedia === "function",
|
|
18
|
+
writesMedia: typeof adapter?.uploadMedia === "function",
|
|
18
19
|
declared
|
|
19
20
|
};
|
|
20
21
|
}
|
package/dist/cms/index.d.ts
CHANGED
|
@@ -3,4 +3,4 @@ export { resolveCapabilities } from "./adapter.ts";
|
|
|
3
3
|
export { jsonFileAdapter, type JsonFileAdapterOptions } from "./json-file-adapter.ts";
|
|
4
4
|
export { editorApiAdapter, type EditorApiAdapterOptions } from "./editor-api-adapter.ts";
|
|
5
5
|
export { ensureSessionBootstrapped, createCmsBootstrapCache, _resetCmsBootstrapCache, type CmsBootstrapCache, type CmsBootstrapCacheOptions, warmSessionBootstrap } from "./bootstrap.ts";
|
|
6
|
-
export { cmsMediaSource, cmsMediaLabel, mediaSourceFromUnknown, type CmsMediaSource, type CmsMediaSourceConfig } from "./media-sources.ts";
|
|
6
|
+
export { cmsMediaSource, cmsMediaUploader, cmsMediaLabel, mediaSourceFromUnknown, type CmsMediaSource, type CmsMediaUploader, type CmsMediaSourceConfig } from "./media-sources.ts";
|
package/dist/cms/index.js
CHANGED
|
@@ -2,4 +2,4 @@ export { resolveCapabilities } from "./adapter.js";
|
|
|
2
2
|
export { jsonFileAdapter } from "./json-file-adapter.js";
|
|
3
3
|
export { editorApiAdapter } from "./editor-api-adapter.js";
|
|
4
4
|
export { ensureSessionBootstrapped, createCmsBootstrapCache, _resetCmsBootstrapCache, warmSessionBootstrap } from "./bootstrap.js";
|
|
5
|
-
export { cmsMediaSource, cmsMediaLabel, mediaSourceFromUnknown } from "./media-sources.js";
|
|
5
|
+
export { cmsMediaSource, cmsMediaUploader, cmsMediaLabel, mediaSourceFromUnknown } from "./media-sources.js";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CmsMediaPage, CmsMediaQuery } from "./adapter.ts";
|
|
1
|
+
import type { CmsMediaItem, CmsMediaPage, CmsMediaQuery, CmsMediaUpload } from "./adapter.ts";
|
|
2
2
|
/** Connection details for one of the built-in media readers. */
|
|
3
3
|
export type CmsMediaSourceConfig = {
|
|
4
4
|
provider: "contentful";
|
|
@@ -17,6 +17,8 @@ export type CmsMediaSourceConfig = {
|
|
|
17
17
|
};
|
|
18
18
|
/** A media reader: the exact shape of `CmsAdapter.getMedia`. */
|
|
19
19
|
export type CmsMediaSource = (query: CmsMediaQuery) => Promise<CmsMediaPage>;
|
|
20
|
+
/** A media writer: the exact shape of `CmsAdapter.uploadMedia`. */
|
|
21
|
+
export type CmsMediaUploader = (input: CmsMediaUpload) => Promise<CmsMediaItem>;
|
|
20
22
|
/** Display name for a provider id, for the picker's tab. */
|
|
21
23
|
export declare function cmsMediaLabel(provider: CmsMediaSourceConfig["provider"]): string;
|
|
22
24
|
/**
|
|
@@ -37,6 +39,32 @@ export declare function cmsMediaLabel(provider: CmsMediaSourceConfig["provider"]
|
|
|
37
39
|
* promise inside a modal is not.
|
|
38
40
|
*/
|
|
39
41
|
export declare function cmsMediaSource(config: CmsMediaSourceConfig): CmsMediaSource;
|
|
42
|
+
/**
|
|
43
|
+
* Build a media writer from the same connection details as the reader, or
|
|
44
|
+
* `null` when this provider (or this configuration) cannot take an upload.
|
|
45
|
+
*
|
|
46
|
+
* ```ts
|
|
47
|
+
* const uploadMedia = cmsMediaUploader({ provider: "sanity", projectId, dataset, token })
|
|
48
|
+
* return { id: "sanity", getPages, getMedia, ...(uploadMedia ? { uploadMedia } : {}) }
|
|
49
|
+
* ```
|
|
50
|
+
*
|
|
51
|
+
* Null rather than a stub that throws, and spread rather than assigned,
|
|
52
|
+
* because `writesMedia` is derived from whether the method *exists*
|
|
53
|
+
* (`resolveCapabilities`) and the editor shows its upload control on the
|
|
54
|
+
* strength of that. A method that is always present and always fails is a
|
|
55
|
+
* button that is always there and never works.
|
|
56
|
+
*
|
|
57
|
+
* So a project with a read-only token gets no upload control at all, which is
|
|
58
|
+
* the truth about it: Sanity's query API answers a viewer token and its asset
|
|
59
|
+
* API does not, and that difference is invisible until somebody picks a file.
|
|
60
|
+
*
|
|
61
|
+
* **Sanity only, for now.** Uploading to Contentful is a three-step
|
|
62
|
+
* asynchronous dance — create the asset, ask for processing, poll, publish —
|
|
63
|
+
* and Strapi's is a multipart POST to its upload plugin; neither is written
|
|
64
|
+
* yet, and both answer `null` so that an adapter which spreads the result
|
|
65
|
+
* simply offers no upload rather than offering a broken one.
|
|
66
|
+
*/
|
|
67
|
+
export declare function cmsMediaUploader(config: CmsMediaSourceConfig): CmsMediaUploader | null;
|
|
40
68
|
/**
|
|
41
69
|
* Build a reader from an untrusted object, or `null` if it is not one of the
|
|
42
70
|
* three shapes.
|