@coywolf/emdash 0.0.0-stage → 0.35.14
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/LICENSE +21 -0
- package/README.md +203 -2
- package/docs/README.md +45 -0
- package/docs/ai.md +39 -0
- package/docs/backups.md +88 -0
- package/docs/breadcrumbs.md +59 -0
- package/docs/code-blocks.md +21 -0
- package/docs/content-urls.md +41 -0
- package/docs/custom-blocks.md +27 -0
- package/docs/discovery.md +32 -0
- package/docs/files.md +44 -0
- package/docs/form-uploads.md +59 -0
- package/docs/headings.md +37 -0
- package/docs/images.md +141 -0
- package/docs/internals.md +13 -0
- package/docs/links.md +34 -0
- package/docs/performance.md +72 -0
- package/docs/redirects.md +84 -0
- package/docs/releasing.md +21 -0
- package/docs/reviews.md +89 -0
- package/docs/robots.md +81 -0
- package/docs/schema.md +79 -0
- package/docs/search.md +114 -0
- package/docs/videos.md +48 -0
- package/docs/wordpress-import.md +198 -0
- package/package.json +79 -5
- package/src/admin/admin-css.ts +57 -0
- package/src/admin/ai.tsx +915 -0
- package/src/admin/backups.tsx +613 -0
- package/src/admin/breadcrumbs.tsx +542 -0
- package/src/admin/codeBlocks.tsx +204 -0
- package/src/admin/customBlocks.tsx +321 -0
- package/src/admin/dirty.ts +20 -0
- package/src/admin/discovery.tsx +463 -0
- package/src/admin/download.ts +28 -0
- package/src/admin/feature-switches.tsx +124 -0
- package/src/admin/features.tsx +142 -0
- package/src/admin/files.tsx +791 -0
- package/src/admin/formUploads.tsx +445 -0
- package/src/admin/guides.tsx +18 -0
- package/src/admin/headings.tsx +262 -0
- package/src/admin/images.tsx +825 -0
- package/src/admin/index.tsx +62 -0
- package/src/admin/links.tsx +980 -0
- package/src/admin/performance.tsx +330 -0
- package/src/admin/preview.tsx +63 -0
- package/src/admin/redirects-removed.tsx +266 -0
- package/src/admin/redirects.tsx +470 -0
- package/src/admin/reviews.tsx +232 -0
- package/src/admin/robots-bots.tsx +647 -0
- package/src/admin/robots-shared.tsx +109 -0
- package/src/admin/robots-wizard.tsx +748 -0
- package/src/admin/robots.tsx +911 -0
- package/src/admin/save-bar.tsx +134 -0
- package/src/admin/schema.tsx +1187 -0
- package/src/admin/search.tsx +355 -0
- package/src/admin/settings-ui.tsx +87 -0
- package/src/admin/trash-prompt-core.ts +135 -0
- package/src/admin/trash-prompt.tsx +156 -0
- package/src/admin/usage.tsx +301 -0
- package/src/admin/videos-display.tsx +248 -0
- package/src/admin/videos.tsx +1202 -0
- package/src/admin/wpImport.tsx +1516 -0
- package/src/ai/entities.ts +59 -0
- package/src/ai/export.ts +61 -0
- package/src/ai/logic.ts +606 -0
- package/src/ai/pack.ts +121 -0
- package/src/ai/prompts.ts +87 -0
- package/src/ai/providers.ts +276 -0
- package/src/ai/routes.ts +359 -0
- package/src/ai/store.ts +301 -0
- package/src/ai/wikidata.ts +87 -0
- package/src/ai/worker.ts +530 -0
- package/src/astro/astro-modules.d.ts +6 -0
- package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
- package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
- package/src/astro/codeBlocks/CodeBlock.astro +62 -0
- package/src/astro/codeBlocks/EmDashCode.astro +61 -0
- package/src/astro/customBlocks/CustomBlock.astro +64 -0
- package/src/astro/customBlocks/Details.astro +8 -0
- package/src/astro/customBlocks/Disclosure.astro +8 -0
- package/src/astro/customBlocks/Note.astro +8 -0
- package/src/astro/customBlocks/Podcast.astro +8 -0
- package/src/astro/customBlocks/Quote.astro +8 -0
- package/src/astro/customBlocks/Testimonial.astro +8 -0
- package/src/astro/files/FileCard.astro +103 -0
- package/src/astro/headings/HeadingBlock.astro +129 -0
- package/src/astro/headings/TableOfContents.astro +45 -0
- package/src/astro/headings/request.ts +19 -0
- package/src/astro/images/ResponsiveImage.astro +60 -0
- package/src/astro/index.ts +84 -0
- package/src/astro/reviews/CoywolfReview.astro +27 -0
- package/src/astro/reviews/Review.astro +49 -0
- package/src/astro/search/SearchBox.astro +61 -0
- package/src/astro/search/SearchBoxForm.astro +509 -0
- package/src/astro/tables/CaptionedTable.astro +72 -0
- package/src/astro/videos/CoywolfVideo.astro +448 -0
- package/src/backups/download.ts +112 -0
- package/src/backups/dump.ts +288 -0
- package/src/backups/module.ts +276 -0
- package/src/backups/pack.ts +33 -0
- package/src/backups/restore.ts +209 -0
- package/src/backups/store.ts +409 -0
- package/src/breadcrumbs/pack.ts +113 -0
- package/src/breadcrumbs/settings.ts +102 -0
- package/src/breadcrumbs/stamp.ts +40 -0
- package/src/breadcrumbs/trail.ts +123 -0
- package/src/codeBlocks/contrast.ts +179 -0
- package/src/codeBlocks/hljs.d.ts +5 -0
- package/src/codeBlocks/pack.ts +110 -0
- package/src/codeBlocks/render.ts +304 -0
- package/src/codeBlocks/settings.ts +18 -0
- package/src/codeBlocks/themes.generated.ts +304 -0
- package/src/codeBlocks/themes.ts +114 -0
- package/src/core/compose.ts +244 -0
- package/src/core/content-url.ts +888 -0
- package/src/core/csv.ts +119 -0
- package/src/core/d1-batch.ts +65 -0
- package/src/core/entry-docs.ts +175 -0
- package/src/core/features-module.ts +48 -0
- package/src/core/features.ts +392 -0
- package/src/core/generation.ts +88 -0
- package/src/core/guide-content.ts +166 -0
- package/src/core/module.ts +104 -0
- package/src/core/read-failures.ts +57 -0
- package/src/core/request-memo.ts +71 -0
- package/src/core/secrets.ts +83 -0
- package/src/core/settings-enhance.ts +74 -0
- package/src/core/site.ts +36 -0
- package/src/core/storage.ts +27 -0
- package/src/customBlocks/pack.ts +260 -0
- package/src/customBlocks/render.ts +424 -0
- package/src/customBlocks/rich.ts +313 -0
- package/src/customBlocks/settings.ts +46 -0
- package/src/discovery/build.ts +252 -0
- package/src/discovery/feed-build.ts +217 -0
- package/src/discovery/feed.ts +172 -0
- package/src/discovery/indexnow.ts +114 -0
- package/src/discovery/llms.ts +79 -0
- package/src/discovery/markdown.ts +294 -0
- package/src/discovery/memory-cache.ts +97 -0
- package/src/discovery/middleware.ts +186 -0
- package/src/discovery/module.ts +353 -0
- package/src/discovery/news.ts +99 -0
- package/src/discovery/pack.ts +58 -0
- package/src/discovery/settings.ts +91 -0
- package/src/discovery/urls.ts +35 -0
- package/src/env.d.ts +10 -0
- package/src/files/card.ts +231 -0
- package/src/files/counts.ts +60 -0
- package/src/files/export.ts +44 -0
- package/src/files/format.ts +163 -0
- package/src/files/module.ts +596 -0
- package/src/files/pack.ts +92 -0
- package/src/files/r2-rules.ts +41 -0
- package/src/files/range.ts +50 -0
- package/src/files/s3.ts +114 -0
- package/src/files/serve.ts +141 -0
- package/src/files/sigv4.ts +160 -0
- package/src/files/site.ts +137 -0
- package/src/files/walker.ts +58 -0
- package/src/formUploads/lib.ts +266 -0
- package/src/formUploads/pack.ts +55 -0
- package/src/formUploads/wrap.ts +592 -0
- package/src/forms.ts +16 -0
- package/src/headings/pack.ts +154 -0
- package/src/headings/render.ts +139 -0
- package/src/headings/settings.ts +117 -0
- package/src/headings/slug.ts +68 -0
- package/src/headings/stamp.ts +142 -0
- package/src/headings/test-hooks.mjs +14 -0
- package/src/headings/toc.ts +48 -0
- package/src/images/cloudflare.ts +329 -0
- package/src/images/lib.ts +221 -0
- package/src/images/module.ts +372 -0
- package/src/images/pack.ts +804 -0
- package/src/images/pending.ts +37 -0
- package/src/images/settings.ts +48 -0
- package/src/images/setup.ts +192 -0
- package/src/images/variants-estimate.ts +162 -0
- package/src/images/variants-store.ts +475 -0
- package/src/images/variants.ts +311 -0
- package/src/index.ts +143 -0
- package/src/links/check.ts +101 -0
- package/src/links/classify.ts +270 -0
- package/src/links/export.ts +59 -0
- package/src/links/module.ts +652 -0
- package/src/links/pack.ts +43 -0
- package/src/links/pt.ts +325 -0
- package/src/links/store.ts +704 -0
- package/src/middleware.ts +287 -0
- package/src/modules.ts +53 -0
- package/src/options.ts +66 -0
- package/src/pageCache/lib.ts +274 -0
- package/src/pageCache/pack.ts +240 -0
- package/src/pageCache/usage-core.ts +152 -0
- package/src/pageCache/usage.ts +262 -0
- package/src/pageCache/warm.ts +722 -0
- package/src/redirects/middleware.ts +169 -0
- package/src/redirects/module.ts +124 -0
- package/src/redirects/pack.ts +89 -0
- package/src/redirects/removed-core.ts +151 -0
- package/src/redirects/removed.ts +293 -0
- package/src/redirects/rules.ts +355 -0
- package/src/redirects/slug-changes.ts +207 -0
- package/src/redirects/slug-core.ts +49 -0
- package/src/redirects/transfer.ts +92 -0
- package/src/reviews/lib.ts +624 -0
- package/src/reviews/pack.ts +135 -0
- package/src/reviews/schema.ts +128 -0
- package/src/reviews/settings.ts +26 -0
- package/src/robots/bots.ts +321 -0
- package/src/robots/data/bots.json +696 -0
- package/src/robots/data/verified.json +582 -0
- package/src/robots/directory.ts +26 -0
- package/src/robots/explain.ts +191 -0
- package/src/robots/guided.ts +300 -0
- package/src/robots/importer.ts +263 -0
- package/src/robots/middleware.ts +151 -0
- package/src/robots/module.ts +446 -0
- package/src/robots/pack.ts +69 -0
- package/src/robots/radar.ts +129 -0
- package/src/robots/rep.ts +362 -0
- package/src/robots/rules.ts +883 -0
- package/src/robots/transfer.ts +45 -0
- package/src/robots/validate.ts +524 -0
- package/src/schema/catalog.ts +146 -0
- package/src/schema/graph.ts +791 -0
- package/src/schema/module.ts +1138 -0
- package/src/schema/og-image.ts +100 -0
- package/src/schema/pack.ts +58 -0
- package/src/search/engine.ts +578 -0
- package/src/search/fallback.ts +80 -0
- package/src/search/live-cache.ts +116 -0
- package/src/search/live-client.ts +770 -0
- package/src/search/live-serve.ts +282 -0
- package/src/search/module.ts +261 -0
- package/src/search/pack.ts +57 -0
- package/src/search/query.ts +132 -0
- package/src/search/ratelimit-core.ts +118 -0
- package/src/search/ratelimit.ts +79 -0
- package/src/search/snippet.ts +135 -0
- package/src/search/title-index.ts +139 -0
- package/src/search/title-match.ts +192 -0
- package/src/shared.ts +71 -0
- package/src/version.ts +2 -0
- package/src/videos/date.ts +40 -0
- package/src/videos/engagement.ts +75 -0
- package/src/videos/lib.ts +679 -0
- package/src/videos/module.ts +1160 -0
- package/src/videos/pack.ts +187 -0
- package/src/videos/pause.ts +42 -0
- package/src/videos/poster.ts +324 -0
- package/src/videos/render.ts +336 -0
- package/src/videos/store.ts +551 -0
- package/src/videos/stream.ts +134 -0
- package/src/wpImport/convert.ts +702 -0
- package/src/wpImport/guests.ts +329 -0
- package/src/wpImport/gutenberg.ts +157 -0
- package/src/wpImport/markers.ts +66 -0
- package/src/wpImport/pack.ts +451 -0
- package/src/wpImport/parents.ts +202 -0
- package/src/wpImport/prepare.ts +592 -0
- package/src/wpImport/redirects.ts +358 -0
- package/src/wpImport/sources.ts +67 -0
- package/src/wpImport/stream.ts +160 -0
- package/src/wpImport/urls.ts +354 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reviews module: the Coywolf Review block (rating badge, pros and cons),
|
|
3
|
+
* its style settings (accent color, custom CSS) on the Reviews admin page,
|
|
4
|
+
* and Review schema, folded into Schema & Social's graph or printed on its
|
|
5
|
+
* own. Rendered by src/astro/reviews/CoywolfReview.astro.
|
|
6
|
+
*/
|
|
7
|
+
import { definePluginRoute } from "emdash";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
|
|
10
|
+
import { registerFeatures } from "../core/features.js";
|
|
11
|
+
import type { PackModule } from "../core/module.js";
|
|
12
|
+
import { parseInput } from "../shared.js";
|
|
13
|
+
import {
|
|
14
|
+
BLOCK_TYPE,
|
|
15
|
+
DEFAULT_ACCENT,
|
|
16
|
+
DEFAULT_CONS_HEADING,
|
|
17
|
+
DEFAULT_PROS_HEADING,
|
|
18
|
+
ITEM_TYPES,
|
|
19
|
+
MAX_CUSTOM_CSS,
|
|
20
|
+
RATING_OPTIONS,
|
|
21
|
+
STYLE_SETTING,
|
|
22
|
+
isAccent,
|
|
23
|
+
normalizeReviewRatings,
|
|
24
|
+
normalizeStyle,
|
|
25
|
+
sanitizeCustomCss,
|
|
26
|
+
} from "./lib.js";
|
|
27
|
+
import { reviewsMetadata } from "./schema.js";
|
|
28
|
+
import { invalidateReviewStyle } from "./settings.js";
|
|
29
|
+
|
|
30
|
+
// biome-ignore lint/suspicious/noEmptyInterface: reserved for future options.
|
|
31
|
+
export interface ReviewsOptions {}
|
|
32
|
+
|
|
33
|
+
export const F = { main: "reviews", schema: "reviews.schema" } as const;
|
|
34
|
+
|
|
35
|
+
export const FEATURES = [
|
|
36
|
+
{ id: F.main, label: "Reviews", description: "The Coywolf Review block: a rating badge with what you liked and what could be better.", default: false },
|
|
37
|
+
{
|
|
38
|
+
id: F.schema,
|
|
39
|
+
label: "Review schema",
|
|
40
|
+
description: "Review structured data (rating, pros and cons) for each review, in Schema & Social's graph or on its own.",
|
|
41
|
+
default: false,
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
registerFeatures(FEATURES);
|
|
45
|
+
|
|
46
|
+
/** Store ratings as the menu's values ("4" → "4.0"), so older reviews show their rating in the editor. */
|
|
47
|
+
async function beforeSave(event: { content: Record<string, unknown> }) {
|
|
48
|
+
if (!event.content || !JSON.stringify(event.content).includes(`"${BLOCK_TYPE}"`)) return undefined;
|
|
49
|
+
return normalizeReviewRatings(event.content) ?? undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function reviewsPack(_options: ReviewsOptions): PackModule {
|
|
53
|
+
return {
|
|
54
|
+
id: "reviews",
|
|
55
|
+
label: "Reviews",
|
|
56
|
+
features: FEATURES,
|
|
57
|
+
hooks: { "page:metadata": reviewsMetadata, "content:beforeSave": beforeSave },
|
|
58
|
+
hookFeature: { "page:metadata": F.schema },
|
|
59
|
+
adminPages: [{ path: "/reviews", label: "Reviews", icon: "star" }],
|
|
60
|
+
capabilities: ["content:read"],
|
|
61
|
+
routes: {
|
|
62
|
+
"reviews/settings": {
|
|
63
|
+
permission: "plugins:manage" as const,
|
|
64
|
+
// biome-ignore lint/suspicious/noExplicitAny: plugin context.
|
|
65
|
+
handler: async (ctx: any) => ({ ...normalizeStyle(await ctx.settings.get(STYLE_SETTING)), defaultAccent: DEFAULT_ACCENT }),
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
"reviews/save": definePluginRoute({
|
|
69
|
+
permission: "plugins:manage",
|
|
70
|
+
methods: ["POST"],
|
|
71
|
+
request: { body: "json" },
|
|
72
|
+
handler: async (ctx) => {
|
|
73
|
+
const input = parseInput(
|
|
74
|
+
z.object({
|
|
75
|
+
accent: z.string().max(9).refine(isAccent, "Use a hex color like #2C8452."),
|
|
76
|
+
css: z.string().max(MAX_CUSTOM_CSS, `Custom CSS is limited to ${MAX_CUSTOM_CSS / 1000} KB.`),
|
|
77
|
+
}),
|
|
78
|
+
ctx.input,
|
|
79
|
+
);
|
|
80
|
+
const style = { accent: input.accent, css: sanitizeCustomCss(input.css) };
|
|
81
|
+
await ctx.settings.set(STYLE_SETTING, style);
|
|
82
|
+
invalidateReviewStyle();
|
|
83
|
+
ctx.log.info("Review style saved", { accent: style.accent, cssBytes: style.css.length });
|
|
84
|
+
return style;
|
|
85
|
+
},
|
|
86
|
+
}),
|
|
87
|
+
},
|
|
88
|
+
portableTextBlocks: [
|
|
89
|
+
{
|
|
90
|
+
type: BLOCK_TYPE,
|
|
91
|
+
label: "Coywolf Review",
|
|
92
|
+
icon: "star",
|
|
93
|
+
category: "Content",
|
|
94
|
+
description: "A rating badge with pros and cons, and Review schema",
|
|
95
|
+
fields: [
|
|
96
|
+
{ type: "text_input", action_id: "itemName", label: "Item name (what you're reviewing)" },
|
|
97
|
+
{
|
|
98
|
+
type: "select",
|
|
99
|
+
action_id: "itemType",
|
|
100
|
+
label: "Item type",
|
|
101
|
+
options: ITEM_TYPES.map(([value, label]) => ({ value, label })),
|
|
102
|
+
initial_value: "Product",
|
|
103
|
+
},
|
|
104
|
+
{ type: "text_input", action_id: "brand", label: "Brand (optional)" },
|
|
105
|
+
{ type: "text_input", action_id: "itemUrl", label: "Item URL (optional, https://…)" },
|
|
106
|
+
{ type: "media_picker", action_id: "image", label: "Item image (optional, for schema)", mime_type_filter: "image/" },
|
|
107
|
+
{ type: "text_input", action_id: "bookAuthor", label: "Book author (books only, for schema)" },
|
|
108
|
+
{ type: "text_input", action_id: "isbn", label: "ISBN (books only, for schema)" },
|
|
109
|
+
{ type: "text_input", action_id: "bookPublisher", label: "Publisher (books only, for schema)" },
|
|
110
|
+
{ type: "text_input", action_id: "genre", label: "Genre (books only, for schema)" },
|
|
111
|
+
{ type: "text_input", action_id: "copyrightYear", label: "Copyright year (books only, for schema)" },
|
|
112
|
+
{ type: "text_input", action_id: "operatingSystem", label: "Operating systems (software only, e.g. macOS, Windows)" },
|
|
113
|
+
{ type: "text_input", action_id: "applicationCategory", label: "App category (software only, e.g. Utilities)" },
|
|
114
|
+
{ type: "select", action_id: "rating", label: "Rating (0 to 5, in tenths)", options: RATING_OPTIONS },
|
|
115
|
+
{ type: "text_input", action_id: "prosHeading", label: "Pros heading", initial_value: DEFAULT_PROS_HEADING },
|
|
116
|
+
{ type: "text_input", action_id: "pros", label: "Pros (one per line)", multiline: true },
|
|
117
|
+
{ type: "text_input", action_id: "consHeading", label: "Cons heading", initial_value: DEFAULT_CONS_HEADING },
|
|
118
|
+
{ type: "text_input", action_id: "cons", label: "Cons (one per line)", multiline: true },
|
|
119
|
+
{ type: "text_input", action_id: "summary", label: "Summary or verdict (optional)", multiline: true },
|
|
120
|
+
{
|
|
121
|
+
type: "select",
|
|
122
|
+
action_id: "headingLevel",
|
|
123
|
+
label: "Heading level",
|
|
124
|
+
options: [
|
|
125
|
+
{ value: "2", label: "H2" },
|
|
126
|
+
{ value: "3", label: "H3" },
|
|
127
|
+
{ value: "4", label: "H4" },
|
|
128
|
+
],
|
|
129
|
+
initial_value: "2",
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Review schema for a rendered page: the `coywolf-review` blocks in the
|
|
3
|
+
* entry, plus any reviews the theme passes as `page.coywolf.reviews` (legacy
|
|
4
|
+
* WordPress markers). Schema & Social folds them into its @graph
|
|
5
|
+
* (attachPageReviews); with the graph off, the page:metadata hook here prints
|
|
6
|
+
* standalone JSON-LD instead.
|
|
7
|
+
*/
|
|
8
|
+
import type { PageMetadataContribution, PageMetadataEvent, PluginContext } from "emdash";
|
|
9
|
+
|
|
10
|
+
import { cachedCtxFeatures, isOn } from "../core/features.js";
|
|
11
|
+
import { noteReadFailure, readFailures } from "../core/read-failures.js";
|
|
12
|
+
import { siteName } from "../core/site.js";
|
|
13
|
+
import { originOf, pageUrl } from "../schema/graph.js";
|
|
14
|
+
import { BLOCK_TYPE, type Node, type Review, attachReviews, findReviewBlocks, normalizeReview, reviewDocuments } from "./lib.js";
|
|
15
|
+
|
|
16
|
+
/** Text every stored review block contains: entry columns without it can't hold one (and aren't parsed). */
|
|
17
|
+
export const REVIEW_MARKER = `"${BLOCK_TYPE}"`;
|
|
18
|
+
|
|
19
|
+
type Page = PageMetadataEvent["page"];
|
|
20
|
+
|
|
21
|
+
const TTL_MS = 5 * 60_000;
|
|
22
|
+
const MAX_CACHED = 300;
|
|
23
|
+
/** Per-isolate cache of each entry's review blocks, keyed by entry and its modified time. */
|
|
24
|
+
const memo = new Map<string, { at: number; stamp: string; reviews: Review[] }>();
|
|
25
|
+
|
|
26
|
+
function themeReviews(page: Page): Review[] {
|
|
27
|
+
const themed = (page as Page & { coywolf?: { reviews?: unknown } }).coywolf?.reviews;
|
|
28
|
+
if (!Array.isArray(themed)) return [];
|
|
29
|
+
return themed
|
|
30
|
+
.slice(0, 20)
|
|
31
|
+
.map(normalizeReview)
|
|
32
|
+
.filter((r): r is Review => r !== null);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const reviewsIn = (data: unknown): Review[] =>
|
|
36
|
+
findReviewBlocks(data ?? null)
|
|
37
|
+
.map(normalizeReview)
|
|
38
|
+
.filter((r): r is Review => r !== null);
|
|
39
|
+
|
|
40
|
+
function memoHit(page: Page): Review[] | undefined {
|
|
41
|
+
if (!page.content) return undefined;
|
|
42
|
+
const hit = memo.get(`${page.content.collection}:${page.content.id}`);
|
|
43
|
+
return hit && hit.stamp === (page.articleMeta?.modifiedTime ?? "") && Date.now() - hit.at < TTL_MS ? hit.reviews : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Whether finding the page's review blocks needs the entry's data (no recent copy in this isolate). */
|
|
47
|
+
export function needsEntryData(page: Page): boolean {
|
|
48
|
+
return Boolean(page.content) && memoHit(page) === undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Where the entry's data comes from: the entry itself (the theme passed it;
|
|
53
|
+
* nothing is read), or a function that reads it (Schema & Social's batched
|
|
54
|
+
* read). Without one, it's read through the plugin context.
|
|
55
|
+
*/
|
|
56
|
+
export type EntryDataSource = Record<string, unknown> | (() => Promise<Record<string, unknown> | null | undefined>);
|
|
57
|
+
|
|
58
|
+
async function blockReviews(ctx: PluginContext, page: Page, source?: EntryDataSource): Promise<Review[]> {
|
|
59
|
+
if (!page.content) return [];
|
|
60
|
+
if (source && typeof source === "object") return reviewsIn(source);
|
|
61
|
+
const hit = memoHit(page);
|
|
62
|
+
if (hit) return hit;
|
|
63
|
+
let data: unknown;
|
|
64
|
+
const failures = readFailures();
|
|
65
|
+
if (typeof source === "function") data = await source();
|
|
66
|
+
else if (ctx.content) data = (await ctx.content.get(page.content.collection, page.content.id))?.data;
|
|
67
|
+
else return [];
|
|
68
|
+
const reviews = reviewsIn(data);
|
|
69
|
+
// Entry data that couldn't be read (null, its failure noted) isn't remembered as "no reviews".
|
|
70
|
+
if (readFailures() !== failures) return reviews;
|
|
71
|
+
if (memo.size >= MAX_CACHED) memo.delete(memo.keys().next().value as string);
|
|
72
|
+
memo.set(`${page.content.collection}:${page.content.id}`, { at: Date.now(), stamp: page.articleMeta?.modifiedTime ?? "", reviews });
|
|
73
|
+
return reviews;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The page's reviews: blocks first, then the theme's. */
|
|
77
|
+
export async function pageReviews(ctx: PluginContext, page: Page, source?: EntryDataSource): Promise<Review[]> {
|
|
78
|
+
const [blocks, themed] = [await blockReviews(ctx, page, source), themeReviews(page)];
|
|
79
|
+
return [...blocks, ...themed];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Fold the page's reviews into Schema & Social's graph. Errors are logged, never fatal. */
|
|
83
|
+
export async function attachPageReviews(ctx: PluginContext, page: Page, graph: { "@graph": Node[] }, origin: string): Promise<void> {
|
|
84
|
+
(await pageReviewsStep(ctx, page, origin))(graph);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Load the page's reviews now and return the step that folds them into the
|
|
89
|
+
* graph later, so Schema & Social can load them alongside its other reads.
|
|
90
|
+
* Errors are logged, never fatal.
|
|
91
|
+
*/
|
|
92
|
+
export async function pageReviewsStep(ctx: PluginContext, page: Page, origin: string, source?: EntryDataSource): Promise<(graph: { "@graph": Node[] }) => void> {
|
|
93
|
+
const warn = (error: unknown) => ctx.log.warn("reviews: could not attach review schema", { error: String(error) });
|
|
94
|
+
let reviews: Review[];
|
|
95
|
+
try {
|
|
96
|
+
reviews = await pageReviews(ctx, page, source);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
warn(error);
|
|
99
|
+
noteReadFailure("reviews");
|
|
100
|
+
return () => {};
|
|
101
|
+
}
|
|
102
|
+
return (graph) => {
|
|
103
|
+
try {
|
|
104
|
+
attachReviews(graph, reviews, { origin });
|
|
105
|
+
} catch (error) {
|
|
106
|
+
warn(error);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** page:metadata (reviews.schema): standalone JSON-LD, only when Schema & Social's graph is off. */
|
|
112
|
+
export async function reviewsMetadata(event: PageMetadataEvent, ctx: PluginContext): Promise<PageMetadataContribution[] | null> {
|
|
113
|
+
if (isOn(await cachedCtxFeatures(ctx), "schema.graph")) return null;
|
|
114
|
+
const page = event.page;
|
|
115
|
+
const entry = (page as Page & { coywolf?: { entry?: { data?: unknown } } }).coywolf?.entry;
|
|
116
|
+
const data = entry?.data && typeof entry.data === "object" ? (entry.data as Record<string, unknown>) : undefined;
|
|
117
|
+
const reviews = await pageReviews(ctx, page, data && (typeof data.id !== "string" || data.id === page.content?.id) ? data : undefined);
|
|
118
|
+
if (!reviews.length) return null;
|
|
119
|
+
const origin = originOf(page.siteUrl || ctx.site.url || page.url);
|
|
120
|
+
const docs = reviewDocuments(reviews, {
|
|
121
|
+
pageUrl: pageUrl(page),
|
|
122
|
+
origin,
|
|
123
|
+
siteName: page.siteName || (await siteName(ctx)),
|
|
124
|
+
authorName: page.articleMeta?.author,
|
|
125
|
+
datePublished: page.articleMeta?.publishedTime,
|
|
126
|
+
});
|
|
127
|
+
return docs.map((graph, i) => ({ kind: "jsonld" as const, id: `coywolf-review-${i + 1}`, graph }));
|
|
128
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The review box style (accent color + custom CSS), read outside the plugin
|
|
3
|
+
* context (Astro renderer) from D1 in the feature switches' query, sharing
|
|
4
|
+
* their per-isolate cache.
|
|
5
|
+
*/
|
|
6
|
+
import { invalidateFeatures, readSiteSetting, registerSiteSetting } from "../core/features.js";
|
|
7
|
+
import { type ReviewStyle, STYLE_SETTING, normalizeStyle } from "./lib.js";
|
|
8
|
+
|
|
9
|
+
registerSiteSetting(STYLE_SETTING);
|
|
10
|
+
|
|
11
|
+
/** The last stored value and its normalized style, so a cache hit doesn't normalize again. */
|
|
12
|
+
let memo: { raw: unknown; style: ReviewStyle } | null = null;
|
|
13
|
+
|
|
14
|
+
export function invalidateReviewStyle(): void {
|
|
15
|
+
invalidateFeatures();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function siteReviewStyle(database = "DB"): Promise<ReviewStyle> {
|
|
19
|
+
const read = await readSiteSetting(STYLE_SETTING, database);
|
|
20
|
+
if (!read) console.error("coywolf-pack: could not read the review style");
|
|
21
|
+
const raw = read?.value ?? null;
|
|
22
|
+
if (memo && memo.raw === raw) return memo.style;
|
|
23
|
+
const style = normalizeStyle(raw);
|
|
24
|
+
memo = { raw, style };
|
|
25
|
+
return style;
|
|
26
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The crawler directory: a bundled baseline (src/robots/data/bots.json, built
|
|
3
|
+
* from Cloudflare Radar's bot directory with each robots.txt token checked
|
|
4
|
+
* against the operator's documentation) plus an overlay refreshed weekly
|
|
5
|
+
* from the Radar API (see ./radar.ts).
|
|
6
|
+
*
|
|
7
|
+
* Radar publishes user-agent *patterns*, not robots.txt tokens, so a bot that
|
|
8
|
+
* first appears through the API gets a token derived from its pattern and is
|
|
9
|
+
* marked unverified until a person checks it (the baseline is updated by
|
|
10
|
+
* hand with a source URL).
|
|
11
|
+
*
|
|
12
|
+
* No imports, so the tests can run this file under plain Node.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type BotStatus = "verified" | "unverified";
|
|
16
|
+
/** What a crawler is for, from the operator's documentation (src/robots/data/verified.json → purposes). */
|
|
17
|
+
export type BotPurpose = "training" | "ai-search" | "ai-assistant" | "search-engine" | "seo" | "other";
|
|
18
|
+
export const PURPOSE_LABELS: Record<BotPurpose, string> = {
|
|
19
|
+
training: "AI training",
|
|
20
|
+
"ai-search": "AI search",
|
|
21
|
+
"ai-assistant": "AI assistant (fetches for a user)",
|
|
22
|
+
"search-engine": "Search engine",
|
|
23
|
+
seo: "SEO tool",
|
|
24
|
+
other: "Other",
|
|
25
|
+
};
|
|
26
|
+
/** How the token was confirmed: operator docs, the bot's published UA string, derived from a pattern, or checked by an admin on this site. */
|
|
27
|
+
export type BotEvidence = "operator-docs" | "user-agent" | "heuristic" | "none" | "manual";
|
|
28
|
+
|
|
29
|
+
export interface BotEntry {
|
|
30
|
+
slug: string;
|
|
31
|
+
name: string;
|
|
32
|
+
operator: string;
|
|
33
|
+
/** Radar category, e.g. AI_CRAWLER. */
|
|
34
|
+
category: string;
|
|
35
|
+
/** What it's for (set for preset bots and custom bots); presets use this, never the Radar category. */
|
|
36
|
+
purpose?: BotPurpose;
|
|
37
|
+
description: string;
|
|
38
|
+
/** The robots.txt product token (`User-agent:` value). */
|
|
39
|
+
token: string;
|
|
40
|
+
aliases?: string[];
|
|
41
|
+
status: BotStatus;
|
|
42
|
+
evidence: BotEvidence;
|
|
43
|
+
sourceUrl?: string;
|
|
44
|
+
/** ISO date the token was last checked against `sourceUrl`. */
|
|
45
|
+
verifiedAt?: string;
|
|
46
|
+
note?: string;
|
|
47
|
+
/** "radar" (in Cloudflare's directory), "curated" (operator-documented token Radar doesn't list) or "custom" (added on this site). */
|
|
48
|
+
origin: "radar" | "curated" | "custom";
|
|
49
|
+
/** Who verified it on this site (admin overrides). */
|
|
50
|
+
verifiedBy?: string;
|
|
51
|
+
/** The name before an admin renamed it. */
|
|
52
|
+
originalName?: string;
|
|
53
|
+
/** Set when the bot was last seen in, or has left, the Radar directory (from the weekly sync). */
|
|
54
|
+
radarSeenAt?: string;
|
|
55
|
+
delisted?: boolean;
|
|
56
|
+
/** Other Radar slugs that share this token and were merged into this entry. */
|
|
57
|
+
mergedSlugs?: string[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A record from GET /client/v4/radar/bots. */
|
|
61
|
+
export interface RadarBot {
|
|
62
|
+
slug: string;
|
|
63
|
+
name: string;
|
|
64
|
+
operator?: string;
|
|
65
|
+
category?: string;
|
|
66
|
+
description?: string;
|
|
67
|
+
kind?: string;
|
|
68
|
+
userAgentPatterns?: string[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** What the sync stores per slug (plugin storage), over the baseline. */
|
|
72
|
+
export interface BotOverlay {
|
|
73
|
+
slug: string;
|
|
74
|
+
name?: string;
|
|
75
|
+
operator?: string;
|
|
76
|
+
category?: string;
|
|
77
|
+
description?: string;
|
|
78
|
+
/** Only for bots the baseline doesn't have. */
|
|
79
|
+
token?: string | null;
|
|
80
|
+
patterns?: string[];
|
|
81
|
+
firstSeenAt?: string;
|
|
82
|
+
radarSeenAt?: string;
|
|
83
|
+
delisted?: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const CATEGORY_LABELS: Record<string, string> = {
|
|
87
|
+
AI_CRAWLER: "AI crawler",
|
|
88
|
+
AI_ASSISTANT: "AI assistant",
|
|
89
|
+
AI_SEARCH: "AI search",
|
|
90
|
+
SEARCH_ENGINE_CRAWLER: "Search engine",
|
|
91
|
+
SEARCH_ENGINE_OPTIMIZATION: "SEO tool",
|
|
92
|
+
MONITORING_AND_ANALYTICS: "Monitoring and analytics",
|
|
93
|
+
ADVERTISING_AND_MARKETING: "Advertising and marketing",
|
|
94
|
+
SOCIAL_MEDIA_MARKETING: "Social media marketing",
|
|
95
|
+
PAGE_PREVIEW: "Link preview",
|
|
96
|
+
FEED_FETCHER: "Feed reader",
|
|
97
|
+
ACADEMIC_RESEARCH: "Academic research",
|
|
98
|
+
SECURITY: "Security",
|
|
99
|
+
ACCESSIBILITY: "Accessibility",
|
|
100
|
+
AGGREGATOR: "Aggregator",
|
|
101
|
+
ARCHIVER: "Archiver",
|
|
102
|
+
WEBHOOKS: "Webhooks",
|
|
103
|
+
OTHER: "Other",
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export function categoryLabel(category: string): string {
|
|
107
|
+
return (
|
|
108
|
+
CATEGORY_LABELS[category] ??
|
|
109
|
+
category
|
|
110
|
+
.toLowerCase()
|
|
111
|
+
.split("_")
|
|
112
|
+
.map((w, i) => (i === 0 ? w.charAt(0).toUpperCase() + w.slice(1) : w))
|
|
113
|
+
.join(" ")
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const TOKEN_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Best guess at a robots.txt token from Radar's UA patterns ("Barkrowler/",
|
|
121
|
+
* "meta-externaltest/1\.1") or the bot name. Null when nothing is token-shaped.
|
|
122
|
+
*/
|
|
123
|
+
export function deriveToken(patterns: string[] | undefined, name: string): string | null {
|
|
124
|
+
for (const p of patterns ?? []) {
|
|
125
|
+
const t = p.replace(/\\/g, "").replace(/\/.*$/, "").trim();
|
|
126
|
+
if (TOKEN_RE.test(t) && t.toLowerCase() !== "user_agent") return t;
|
|
127
|
+
}
|
|
128
|
+
const n = name.trim();
|
|
129
|
+
return TOKEN_RE.test(n) ? n : null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const clip = (s: string | undefined) => {
|
|
133
|
+
const t = (s ?? "").replace(/\s+/g, " ").trim();
|
|
134
|
+
return t.length > 240 ? `${t.slice(0, 237).trimEnd()}…` : t;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/** Baseline + overlay → the directory the admin shows. Bots without a token are left out. */
|
|
138
|
+
export function mergeDirectory(baseline: BotEntry[], overlays: BotOverlay[]): BotEntry[] {
|
|
139
|
+
const bySlug = new Map(baseline.map((b) => [b.slug, { ...b }]));
|
|
140
|
+
for (const o of overlays) {
|
|
141
|
+
const base = bySlug.get(o.slug);
|
|
142
|
+
if (base) {
|
|
143
|
+
if (o.name) base.name = o.name;
|
|
144
|
+
if (o.operator) base.operator = o.operator;
|
|
145
|
+
if (o.category) base.category = o.category;
|
|
146
|
+
if (o.description) base.description = o.description;
|
|
147
|
+
if (o.radarSeenAt) base.radarSeenAt = o.radarSeenAt;
|
|
148
|
+
if (o.delisted) base.delisted = true;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (!o.token) continue;
|
|
152
|
+
bySlug.set(o.slug, {
|
|
153
|
+
slug: o.slug,
|
|
154
|
+
name: o.name ?? o.slug,
|
|
155
|
+
operator: o.operator ?? "",
|
|
156
|
+
category: o.category ?? "OTHER",
|
|
157
|
+
description: o.description ?? "",
|
|
158
|
+
token: o.token,
|
|
159
|
+
status: "unverified",
|
|
160
|
+
evidence: "heuristic",
|
|
161
|
+
sourceUrl: `https://radar.cloudflare.com/bots/directory/${encodeURIComponent(o.slug)}`,
|
|
162
|
+
note: "Added from Cloudflare Radar; the token was derived from its user-agent pattern and hasn't been checked against the operator's documentation.",
|
|
163
|
+
origin: "radar",
|
|
164
|
+
radarSeenAt: o.radarSeenAt,
|
|
165
|
+
delisted: o.delisted,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
return [...bySlug.values()].sort((a, b) => a.name.localeCompare(b.name, "en", { sensitivity: "base" }));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface RadarMergeResult {
|
|
172
|
+
/** Overlay documents to write (only ones that changed). */
|
|
173
|
+
writes: BotOverlay[];
|
|
174
|
+
added: number;
|
|
175
|
+
updated: number;
|
|
176
|
+
delisted: number;
|
|
177
|
+
total: number;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const sameOverlay = (a: BotOverlay | undefined, b: BotOverlay) => {
|
|
181
|
+
if (!a) return false;
|
|
182
|
+
const { radarSeenAt: _x, ...ra } = a;
|
|
183
|
+
const { radarSeenAt: _y, ...rb } = b;
|
|
184
|
+
return JSON.stringify(ra, Object.keys(ra).sort()) === JSON.stringify(rb, Object.keys(rb).sort());
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Merge a Radar listing into the stored overlay. Only differences from the
|
|
189
|
+
* baseline are stored, and a document is rewritten only when its content
|
|
190
|
+
* changed (radarSeenAt alone doesn't count), so a quiet week costs no writes.
|
|
191
|
+
*/
|
|
192
|
+
export function mergeRadar(baseline: BotEntry[], existing: BotOverlay[], radar: RadarBot[], now: string): RadarMergeResult {
|
|
193
|
+
const base = new Map(baseline.map((b) => [b.slug, b]));
|
|
194
|
+
// Radar slugs merged into another entry (same token) count as that entry.
|
|
195
|
+
const primary = new Map<string, string>();
|
|
196
|
+
for (const b of baseline) for (const s of b.mergedSlugs ?? []) primary.set(s, b.slug);
|
|
197
|
+
const prev = new Map(existing.map((o) => [o.slug, o]));
|
|
198
|
+
const live = new Set<string>();
|
|
199
|
+
const writes: BotOverlay[] = [];
|
|
200
|
+
let added = 0;
|
|
201
|
+
let updated = 0;
|
|
202
|
+
let delisted = 0;
|
|
203
|
+
|
|
204
|
+
for (const bot of radar) {
|
|
205
|
+
if (!bot?.slug || typeof bot.slug !== "string") continue;
|
|
206
|
+
const merged = primary.get(bot.slug);
|
|
207
|
+
if (merged) {
|
|
208
|
+
live.add(merged);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
live.add(bot.slug);
|
|
212
|
+
const b = base.get(bot.slug);
|
|
213
|
+
const fields = {
|
|
214
|
+
name: bot.name?.trim() || undefined,
|
|
215
|
+
operator: bot.operator?.trim() || undefined,
|
|
216
|
+
category: bot.category?.trim() || undefined,
|
|
217
|
+
description: clip(bot.description) || undefined,
|
|
218
|
+
};
|
|
219
|
+
let next: BotOverlay;
|
|
220
|
+
if (b) {
|
|
221
|
+
next = { slug: bot.slug };
|
|
222
|
+
if (fields.name && fields.name !== b.name) next.name = fields.name;
|
|
223
|
+
if (fields.operator && fields.operator !== b.operator) next.operator = fields.operator;
|
|
224
|
+
if (fields.category && fields.category !== b.category) next.category = fields.category;
|
|
225
|
+
if (fields.description && fields.description !== b.description) next.description = fields.description;
|
|
226
|
+
if (Object.keys(next).length === 1 && !prev.has(bot.slug)) continue; // Matches the baseline.
|
|
227
|
+
} else {
|
|
228
|
+
const old = prev.get(bot.slug);
|
|
229
|
+
next = {
|
|
230
|
+
slug: bot.slug,
|
|
231
|
+
...fields,
|
|
232
|
+
token: deriveToken(bot.userAgentPatterns, bot.name ?? ""),
|
|
233
|
+
patterns: (bot.userAgentPatterns ?? []).slice(0, 10),
|
|
234
|
+
firstSeenAt: old?.firstSeenAt ?? now,
|
|
235
|
+
};
|
|
236
|
+
if (!old && next.token) added++;
|
|
237
|
+
}
|
|
238
|
+
next.radarSeenAt = now;
|
|
239
|
+
const old = prev.get(bot.slug);
|
|
240
|
+
if (sameOverlay(old, next)) continue;
|
|
241
|
+
if (b) updated++;
|
|
242
|
+
writes.push(next);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Radar-sourced bots that left the directory stay usable (robots.txt rules may name them) but are flagged.
|
|
246
|
+
for (const b of baseline) {
|
|
247
|
+
if (b.origin !== "radar" || live.has(b.slug)) continue;
|
|
248
|
+
const old = prev.get(b.slug);
|
|
249
|
+
if (old?.delisted) continue;
|
|
250
|
+
writes.push({ ...(old ?? { slug: b.slug }), delisted: true });
|
|
251
|
+
delisted++;
|
|
252
|
+
}
|
|
253
|
+
for (const o of existing) {
|
|
254
|
+
if (base.has(o.slug) || live.has(o.slug) || o.delisted) continue;
|
|
255
|
+
writes.push({ ...o, delisted: true });
|
|
256
|
+
delisted++;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return { writes, added, updated, delisted, total: live.size };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** An admin's change to one directory entry (or a bot they added), kept in plugin storage. */
|
|
263
|
+
export interface BotOverride {
|
|
264
|
+
/** Directory slug, or "custom-<token>" for bots added on this site. */
|
|
265
|
+
slug: string;
|
|
266
|
+
name?: string;
|
|
267
|
+
verified?: { sourceUrl: string; note?: string; at: string; by?: string };
|
|
268
|
+
custom?: {
|
|
269
|
+
token: string;
|
|
270
|
+
category: string;
|
|
271
|
+
purpose?: BotPurpose;
|
|
272
|
+
operator?: string;
|
|
273
|
+
sourceUrl?: string;
|
|
274
|
+
notes?: string;
|
|
275
|
+
createdAt: string;
|
|
276
|
+
createdBy?: string;
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export const customSlug = (token: string) => `custom-${token.toLowerCase().replace(/[^a-z0-9._-]/g, "-")}`;
|
|
281
|
+
|
|
282
|
+
/** Directory (bundled + Radar) with this site's overrides and custom bots applied. */
|
|
283
|
+
export function applyOverrides(directory: BotEntry[], overrides: BotOverride[]): BotEntry[] {
|
|
284
|
+
const bySlug = new Map(directory.map((b) => [b.slug, { ...b }]));
|
|
285
|
+
for (const o of overrides) {
|
|
286
|
+
if (o.custom) {
|
|
287
|
+
bySlug.set(o.slug, {
|
|
288
|
+
slug: o.slug,
|
|
289
|
+
name: o.name || o.custom.token,
|
|
290
|
+
operator: o.custom.operator ?? "",
|
|
291
|
+
category: o.custom.category || "OTHER",
|
|
292
|
+
...(o.custom.purpose ? { purpose: o.custom.purpose } : {}),
|
|
293
|
+
description: o.custom.notes ?? "",
|
|
294
|
+
token: o.custom.token,
|
|
295
|
+
status: o.verified ? "verified" : "unverified",
|
|
296
|
+
evidence: o.verified ? "manual" : "none",
|
|
297
|
+
sourceUrl: o.verified?.sourceUrl || o.custom.sourceUrl,
|
|
298
|
+
verifiedAt: o.verified?.at,
|
|
299
|
+
verifiedBy: o.verified?.by,
|
|
300
|
+
note: o.verified?.note ?? o.custom.notes,
|
|
301
|
+
origin: "custom",
|
|
302
|
+
});
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
const b = bySlug.get(o.slug);
|
|
306
|
+
if (!b) continue;
|
|
307
|
+
if (o.name && o.name !== b.name) {
|
|
308
|
+
b.originalName = b.name;
|
|
309
|
+
b.name = o.name;
|
|
310
|
+
}
|
|
311
|
+
if (o.verified) {
|
|
312
|
+
b.status = "verified";
|
|
313
|
+
b.evidence = "manual";
|
|
314
|
+
b.sourceUrl = o.verified.sourceUrl;
|
|
315
|
+
b.verifiedAt = o.verified.at;
|
|
316
|
+
b.verifiedBy = o.verified.by;
|
|
317
|
+
if (o.verified.note) b.note = o.verified.note;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return [...bySlug.values()].sort((a, b) => a.name.localeCompare(b.name, "en", { sensitivity: "base" }));
|
|
321
|
+
}
|