@avocadostudio-ai/orchestrator-core 0.3.2 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +296 -53
- 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/translation-chunking.d.ts +124 -0
- package/dist/chat/translation-chunking.js +371 -0
- package/dist/checks/field-walk.d.ts +25 -0
- package/dist/checks/field-walk.js +152 -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 +375 -0
- package/dist/checks/run-checks.d.ts +32 -0
- package/dist/checks/run-checks.js +152 -0
- package/dist/checks/session-runner.d.ts +19 -0
- package/dist/checks/session-runner.js +95 -0
- package/dist/checks/types.d.ts +65 -0
- package/dist/checks/types.js +1 -0
- 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 +67 -4
- 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 +1 -1
- 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 +4 -4
- 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 +7 -0
- package/dist/index.js +27 -0
- package/dist/nlp/deterministic-planner-context.d.ts +16 -0
- package/dist/nlp/deterministic-planner-context.js +33 -7
- package/dist/nlp/plan-normalizer.js +54 -6
- 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-target-registry.js +1 -1
- package/dist/publish/publish-target.d.ts +1 -1
- package/dist/state/session-state.js +8 -1
- package/package.json +3 -3
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Flatten a page's block props into located, kind-tagged fields.
|
|
3
|
+
*
|
|
4
|
+
* Every rule in this directory reads the manifest to find what it cares about
|
|
5
|
+
* — an image, its alt text, a heading level — rather than naming a block type.
|
|
6
|
+
* That is not tidiness. A rule containing `Hero` is a rule that silently exempts
|
|
7
|
+
* every custom block an integrator registered, which is most of the blocks on
|
|
8
|
+
* the sites where this checker would earn its keep.
|
|
9
|
+
*
|
|
10
|
+
* Paths use the editable-target grammar (`cards[0].imageAlt`) so a finding can
|
|
11
|
+
* point at a field the property panel and the preview overlay already address,
|
|
12
|
+
* and so `proposedOps` can be written against the same string.
|
|
13
|
+
*/
|
|
14
|
+
function isRecord(value) {
|
|
15
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
16
|
+
}
|
|
17
|
+
function definitionFor(manifest, type) {
|
|
18
|
+
return manifest.blocks.find((b) => b.type === type);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Which field set applies to one item of a polymorphic list.
|
|
22
|
+
*
|
|
23
|
+
* A discriminated list renders different fields per branch; using the merged
|
|
24
|
+
* union for every item would invent fields an item does not have, and a rule
|
|
25
|
+
* would then report a missing alt text on an item that has no image.
|
|
26
|
+
*/
|
|
27
|
+
function itemFieldsFor(list, item) {
|
|
28
|
+
const merged = (list.itemFields ?? {});
|
|
29
|
+
if (!list.discriminator || !list.itemFieldsByType || !isRecord(item))
|
|
30
|
+
return merged;
|
|
31
|
+
const branch = item[list.discriminator];
|
|
32
|
+
if (typeof branch !== "string")
|
|
33
|
+
return merged;
|
|
34
|
+
return list.itemFieldsByType[branch] ?? merged;
|
|
35
|
+
}
|
|
36
|
+
/** Longer than this and the panel would be showing a paragraph, not a name. */
|
|
37
|
+
const BLOCK_LABEL_MAX = 48;
|
|
38
|
+
/**
|
|
39
|
+
* What a reader would call this block: its first non-empty plain-text prop.
|
|
40
|
+
*
|
|
41
|
+
* Manifest-driven like everything else here — the first `text` field in
|
|
42
|
+
* declaration order is a block's heading on every built-in block and on every
|
|
43
|
+
* custom one we have seen, and asking for `props.title` by name would find
|
|
44
|
+
* nothing on a block that calls it `headline`.
|
|
45
|
+
*
|
|
46
|
+
* `richtext` is deliberately excluded: a body is not a name, and the first 48
|
|
47
|
+
* characters of one reads as a truncated sentence rather than a label.
|
|
48
|
+
*/
|
|
49
|
+
function labelForBlock(definition, props) {
|
|
50
|
+
for (const [key, meta] of Object.entries(definition.fields ?? {})) {
|
|
51
|
+
if (meta.kind !== "text")
|
|
52
|
+
continue;
|
|
53
|
+
const value = props[key];
|
|
54
|
+
if (typeof value !== "string")
|
|
55
|
+
continue;
|
|
56
|
+
const trimmed = value.trim();
|
|
57
|
+
if (!trimmed)
|
|
58
|
+
continue;
|
|
59
|
+
return trimmed.length > BLOCK_LABEL_MAX ? `${trimmed.slice(0, BLOCK_LABEL_MAX - 1)}…` : trimmed;
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
function entriesForBlock(block, definition) {
|
|
64
|
+
if (!definition)
|
|
65
|
+
return [];
|
|
66
|
+
const props = isRecord(block.props) ? block.props : {};
|
|
67
|
+
const out = [];
|
|
68
|
+
const blockLabel = labelForBlock(definition, props);
|
|
69
|
+
const located = blockLabel ? { blockLabel } : {};
|
|
70
|
+
for (const [key, meta] of Object.entries(definition.fields ?? {})) {
|
|
71
|
+
out.push({
|
|
72
|
+
blockId: block.id,
|
|
73
|
+
blockType: block.type,
|
|
74
|
+
...located,
|
|
75
|
+
path: key,
|
|
76
|
+
kind: meta.kind,
|
|
77
|
+
...(meta.label ? { label: meta.label } : {}),
|
|
78
|
+
value: props[key],
|
|
79
|
+
container: ""
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
for (const [key, list] of Object.entries(definition.listFields ?? {})) {
|
|
83
|
+
const items = props[key];
|
|
84
|
+
if (!Array.isArray(items))
|
|
85
|
+
continue;
|
|
86
|
+
items.forEach((item, index) => {
|
|
87
|
+
const container = `${key}[${index}]`;
|
|
88
|
+
for (const [itemKey, meta] of Object.entries(itemFieldsFor(list, item))) {
|
|
89
|
+
out.push({
|
|
90
|
+
blockId: block.id,
|
|
91
|
+
blockType: block.type,
|
|
92
|
+
...located,
|
|
93
|
+
path: `${container}.${itemKey}`,
|
|
94
|
+
kind: meta.kind,
|
|
95
|
+
...(meta.label ? { label: meta.label } : {}),
|
|
96
|
+
value: isRecord(item) ? item[itemKey] : undefined,
|
|
97
|
+
container
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
export function walkPageFields(page, manifest) {
|
|
105
|
+
return page.blocks.flatMap((block) => entriesForBlock(block, definitionFor(manifest, block.type)));
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Readable text inside a field value.
|
|
109
|
+
*
|
|
110
|
+
* A `text` field is a string. A `richtext` field is a ProseMirror document, or
|
|
111
|
+
* markdown, or — for a custom block registered with a loose schema — something
|
|
112
|
+
* else entirely. Collecting every `text` string in the tree handles all three
|
|
113
|
+
* without the caller having to know which it got, and returns "" for a shape
|
|
114
|
+
* this does not recognise rather than guessing.
|
|
115
|
+
*/
|
|
116
|
+
export function fieldText(value) {
|
|
117
|
+
if (typeof value === "string")
|
|
118
|
+
return value;
|
|
119
|
+
if (Array.isArray(value))
|
|
120
|
+
return value.map(fieldText).filter(Boolean).join(" ");
|
|
121
|
+
if (isRecord(value)) {
|
|
122
|
+
const parts = [];
|
|
123
|
+
if (typeof value.text === "string")
|
|
124
|
+
parts.push(value.text);
|
|
125
|
+
if (Array.isArray(value.content))
|
|
126
|
+
parts.push(fieldText(value.content));
|
|
127
|
+
return parts.join(" ");
|
|
128
|
+
}
|
|
129
|
+
return "";
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The page's fields, split per block, in block order.
|
|
133
|
+
*
|
|
134
|
+
* Paths and containers are *block-relative* — every block with an image has a
|
|
135
|
+
* field at `imageUrl`, every block with a list has a `cards[0]` — so any rule
|
|
136
|
+
* that indexes fields by path or container has to do it one block at a time. A
|
|
137
|
+
* page-wide index of either silently pairs one block's image with another
|
|
138
|
+
* block's alt text, which is a false negative on the page that has two heroes
|
|
139
|
+
* and a false positive with the wrong block in its evidence on the page that
|
|
140
|
+
* has two of anything else.
|
|
141
|
+
*/
|
|
142
|
+
export function groupByBlock(fields) {
|
|
143
|
+
const byBlock = new Map();
|
|
144
|
+
for (const field of fields) {
|
|
145
|
+
const list = byBlock.get(field.blockId);
|
|
146
|
+
if (list)
|
|
147
|
+
list.push(field);
|
|
148
|
+
else
|
|
149
|
+
byBlock.set(field.blockId, [field]);
|
|
150
|
+
}
|
|
151
|
+
return byBlock;
|
|
152
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { runChecksForSession, scheduleChecksAfterApply, scheduleChecksAfterPublish, cancelScheduledChecks } from "./session-runner.ts";
|
|
2
|
+
export { runDraftChecks, fingerprintFor, type RunChecksArgs } from "./run-checks.ts";
|
|
3
|
+
export { DRAFT_RULES } from "./rules-draft.ts";
|
|
4
|
+
export { walkPageFields, fieldText, groupByBlock } from "./field-walk.ts";
|
|
5
|
+
export type { CheckRule, CheckContext, RuleFinding, FieldEntry, SiteView } from "./types.ts";
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { runChecksForSession, scheduleChecksAfterApply, scheduleChecksAfterPublish, cancelScheduledChecks } from "./session-runner.js";
|
|
2
|
+
export { runDraftChecks, fingerprintFor } from "./run-checks.js";
|
|
3
|
+
export { DRAFT_RULES } from "./rules-draft.js";
|
|
4
|
+
export { walkPageFields, fieldText, groupByBlock } from "./field-walk.js";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type SiteConfig } from "@avocadostudio-ai/shared";
|
|
2
|
+
import type { FieldEntry, SiteView } from "./types.ts";
|
|
3
|
+
export type PageWeight = {
|
|
4
|
+
slug: string;
|
|
5
|
+
/** 0–1. Only meaningful as an ordering; the absolute value is not a quantity. */
|
|
6
|
+
weight: number;
|
|
7
|
+
/** Editorial inbound links, excluding site chrome and self-links. */
|
|
8
|
+
inbound: number;
|
|
9
|
+
/** Clicks from `/` over editorial links and named nav. Null when unreached. */
|
|
10
|
+
depth: number | null;
|
|
11
|
+
/** Named in `navLabels`/`navGroups`, or linked from nearly every page. */
|
|
12
|
+
named: boolean;
|
|
13
|
+
isHome: boolean;
|
|
14
|
+
/** Nothing on the site links here and nobody named it. See `named` below. */
|
|
15
|
+
isUnlinked: boolean;
|
|
16
|
+
};
|
|
17
|
+
export declare function computePageWeights(args: {
|
|
18
|
+
/** Every page on the site, not only the ones being scanned. */
|
|
19
|
+
pages: SiteView["pages"];
|
|
20
|
+
fieldsBySlug: Map<string, FieldEntry[]>;
|
|
21
|
+
config: SiteConfig;
|
|
22
|
+
}): Map<string, PageWeight>;
|
|
@@ -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
|
+
}
|