@coywolf/emdash 0.0.0-stage → 0.35.13
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 +201 -2
- package/package.json +70 -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.test.ts +187 -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/queue.test.ts +97 -0
- package/src/ai/routes.ts +359 -0
- package/src/ai/store.ts +301 -0
- package/src/ai/wikidata.test.ts +184 -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.test.mjs +287 -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.test.mjs +103 -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.test.ts +152 -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.test.ts +114 -0
- package/src/files/counts.ts +60 -0
- package/src/files/export.ts +44 -0
- package/src/files/format.test.ts +93 -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.test.ts +30 -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.test.ts +64 -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/headings.test.ts +159 -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.test.mjs +224 -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.test.mjs +670 -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.test.ts +50 -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.test.ts +335 -0
- package/src/robots/rep.ts +362 -0
- package/src/robots/rules.test.ts +251 -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.test.mjs +522 -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
package/src/ai/worker.ts
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI Enrichment worker: analyzes entries (entities + meta descriptions) and
|
|
3
|
+
* images (alt text + captions) from the queue. Runs on the cron tick (and the
|
|
4
|
+
* admin's "Run now"), never inside a save: hooks only enqueue.
|
|
5
|
+
*/
|
|
6
|
+
import { siteName } from "../core/site.js";
|
|
7
|
+
import type { CollectionSchemaInfo, PluginContext } from "emdash";
|
|
8
|
+
|
|
9
|
+
import { type FeatureMap, ctxFeatures, isOn } from "../core/features.js";
|
|
10
|
+
import {
|
|
11
|
+
type Entity,
|
|
12
|
+
type GroundedMention,
|
|
13
|
+
type QueueJob,
|
|
14
|
+
type StageCache,
|
|
15
|
+
applyChoices,
|
|
16
|
+
attachCandidates,
|
|
17
|
+
cleanDescription,
|
|
18
|
+
deferJob,
|
|
19
|
+
entryPlainText,
|
|
20
|
+
parseChoices,
|
|
21
|
+
parseImageText,
|
|
22
|
+
parseMentions,
|
|
23
|
+
planBatch,
|
|
24
|
+
queueId,
|
|
25
|
+
retryAt,
|
|
26
|
+
userAgent,
|
|
27
|
+
verifyEntities,
|
|
28
|
+
} from "./logic.js";
|
|
29
|
+
import { DESCRIBE_SYSTEM, DISAMBIGUATE_SYSTEM, EXTRACT_SYSTEM, IMAGE_SYSTEM, articlePrompt, disambiguatePrompt, imagePrompt } from "./prompts.js";
|
|
30
|
+
import { type ChatRequest, chat } from "./providers.js";
|
|
31
|
+
import {
|
|
32
|
+
type AiOptions,
|
|
33
|
+
type AiSettings,
|
|
34
|
+
type BulkState,
|
|
35
|
+
DailyLimitError,
|
|
36
|
+
type EntryRecord,
|
|
37
|
+
type MediaRecord,
|
|
38
|
+
type UsageRow,
|
|
39
|
+
WIKIDATA_BUSY_KEY,
|
|
40
|
+
bindings,
|
|
41
|
+
bulkKey,
|
|
42
|
+
callsToday,
|
|
43
|
+
col,
|
|
44
|
+
enqueue,
|
|
45
|
+
entryKey,
|
|
46
|
+
logUsage,
|
|
47
|
+
providerConfig,
|
|
48
|
+
pruneUsage,
|
|
49
|
+
readSettings,
|
|
50
|
+
takeCall,
|
|
51
|
+
withLock,
|
|
52
|
+
} from "./store.js";
|
|
53
|
+
import { WikidataBusyError, entityDetails, searchCandidates } from "./wikidata.js";
|
|
54
|
+
import { VERSION } from "../version.js";
|
|
55
|
+
|
|
56
|
+
type Settings = AiSettings & { apiKey: string };
|
|
57
|
+
|
|
58
|
+
/** Image bytes sent to a model: at most this many (larger images are downscaled with the Images binding, or skipped). */
|
|
59
|
+
const MAX_IMAGE_BYTES = 3_670_016;
|
|
60
|
+
const MAX_EDGE = 1568;
|
|
61
|
+
const DIRECT_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
|
|
62
|
+
|
|
63
|
+
export class SkipError extends Error {}
|
|
64
|
+
|
|
65
|
+
// ── Helpers ──────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
const schemaCache = new Map<string, { info: CollectionSchemaInfo | null; at: number }>();
|
|
68
|
+
|
|
69
|
+
async function collectionInfo(ctx: PluginContext, slug: string): Promise<CollectionSchemaInfo | null> {
|
|
70
|
+
const hit = schemaCache.get(slug);
|
|
71
|
+
if (hit && Date.now() - hit.at < 60_000) return hit.info;
|
|
72
|
+
const info = (await ctx.schema?.getCollection(slug)) ?? null;
|
|
73
|
+
schemaCache.set(slug, { info, at: Date.now() });
|
|
74
|
+
return info;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Collections the module works on: the configured list, or every routable, visible one. */
|
|
78
|
+
export async function targetCollections(ctx: PluginContext, s: Settings): Promise<string[]> {
|
|
79
|
+
if (s.collections.length) return s.collections;
|
|
80
|
+
const all = (await ctx.schema?.listCollections()) ?? [];
|
|
81
|
+
return all.filter((c) => c.routable && !c.hidden).map((c) => c.slug);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function sha256(text: string): Promise<string> {
|
|
85
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
|
86
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function signature(s: Settings, features: FeatureMap): string {
|
|
90
|
+
return `p:${s.provider}|m:${s.textModel}|ent:${isOn(features, "ai.entities") ? 1 : 0}|desc:${isOn(features, "ai.descriptions") ? 1 : 0}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface EntryText {
|
|
94
|
+
title: string;
|
|
95
|
+
text: string;
|
|
96
|
+
hash: string;
|
|
97
|
+
info: CollectionSchemaInfo;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Title, plain text, and change hash of an entry; null when it has no text worth analyzing. */
|
|
101
|
+
export async function entryText(ctx: PluginContext, collection: string, data: Record<string, unknown>, s: Settings, features: FeatureMap): Promise<EntryText | null> {
|
|
102
|
+
const info = await collectionInfo(ctx, collection);
|
|
103
|
+
if (!info) return null;
|
|
104
|
+
const titleField = info.titleField ?? "title";
|
|
105
|
+
const title = typeof data[titleField] === "string" ? (data[titleField] as string).trim() : "";
|
|
106
|
+
const fields = [...info.fields].sort((a, b) => a.sortOrder - b.sortOrder).filter((f) => f.slug !== titleField);
|
|
107
|
+
const text = entryPlainText(data, fields);
|
|
108
|
+
if (!text.trim()) return null;
|
|
109
|
+
return { title, text, info, hash: await sha256(`${title}\n${text}\n${signature(s, features)}`) };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const language = (ctx: PluginContext) => (ctx.site?.locale || "en").slice(0, 2).toLowerCase() || "en";
|
|
113
|
+
/**
|
|
114
|
+
* Outbound requests (model calls and Wikidata lookups) per invocation. The
|
|
115
|
+
* Workers Free plan allows 50 subrequests; this leaves room for the host's own.
|
|
116
|
+
*/
|
|
117
|
+
export const SUBREQUEST_BUDGET = 40;
|
|
118
|
+
/** Worst case for one job: entry = extraction + disambiguation + description + 12 searches + 1 details; image = 1 call. */
|
|
119
|
+
export const WORST_CASE_SUBREQUESTS = { entry: 16, media: 1 } as const;
|
|
120
|
+
|
|
121
|
+
export interface Budget {
|
|
122
|
+
used: number;
|
|
123
|
+
max: number;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const fetcher = (ctx: PluginContext, budget: Budget) => (url: string, init?: RequestInit) => {
|
|
127
|
+
budget.used++;
|
|
128
|
+
return ctx.http ? ctx.http.fetch(url, init) : fetch(url, init);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
function base64(bytes: Uint8Array): string {
|
|
132
|
+
let binary = "";
|
|
133
|
+
for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
134
|
+
return btoa(binary);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** One model call: counted against the daily cap and written to the usage log. */
|
|
138
|
+
async function callModel(
|
|
139
|
+
ctx: PluginContext,
|
|
140
|
+
options: AiOptions,
|
|
141
|
+
s: Settings,
|
|
142
|
+
feature: UsageRow["feature"],
|
|
143
|
+
req: ChatRequest,
|
|
144
|
+
ref: string,
|
|
145
|
+
budget: Budget,
|
|
146
|
+
): Promise<string> {
|
|
147
|
+
if (!(await takeCall(ctx, s.maxCallsPerDay))) throw new DailyLimitError();
|
|
148
|
+
budget.used++;
|
|
149
|
+
const cfg = await providerConfig(ctx, options, s, Boolean(req.image));
|
|
150
|
+
try {
|
|
151
|
+
const result = await chat(cfg, req);
|
|
152
|
+
await logUsage(ctx, { feature, provider: s.provider, model: cfg.model, inputTokens: result.inputTokens, outputTokens: result.outputTokens, ok: true, ref });
|
|
153
|
+
return result.text;
|
|
154
|
+
} catch (error) {
|
|
155
|
+
await logUsage(ctx, { feature, provider: s.provider, model: cfg.model, inputTokens: 0, outputTokens: 0, ok: false, error: String((error as Error).message ?? error).slice(0, 300), ref });
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── Entries ──────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
async function groundEntities(ctx: PluginContext, options: AiOptions, s: Settings, t: EntryText, ref: string, cache: StageCache, budget: Budget): Promise<Entity[]> {
|
|
163
|
+
// Stage 1 (cached on the job, so a retry doesn't pay for it twice).
|
|
164
|
+
cache.mentions ??= parseMentions(
|
|
165
|
+
await callModel(ctx, options, s, "entities", { system: EXTRACT_SYSTEM, user: articlePrompt(t.title, t.text), maxTokens: 2000 }, ref, budget),
|
|
166
|
+
);
|
|
167
|
+
const mentions = cache.mentions;
|
|
168
|
+
if (!mentions.length) return [];
|
|
169
|
+
const lang = language(ctx);
|
|
170
|
+
const f = fetcher(ctx, budget);
|
|
171
|
+
const ua = userAgent(VERSION, ctx.site?.url);
|
|
172
|
+
// Stage 2: real candidates, one lookup at a time (Wikimedia's API etiquette).
|
|
173
|
+
const candidates = [];
|
|
174
|
+
for (const m of mentions) candidates.push(await searchCandidates(f, m.name, lang, ua));
|
|
175
|
+
let { mentions: grounded, ambiguous } = attachCandidates(mentions, candidates);
|
|
176
|
+
// Stage 3: the model chooses among real candidates only.
|
|
177
|
+
if (ambiguous.length) {
|
|
178
|
+
const subset: GroundedMention[] = ambiguous.map((i) => grounded[i]);
|
|
179
|
+
cache.choices ??= parseChoices(
|
|
180
|
+
await callModel(ctx, options, s, "entities", { system: DISAMBIGUATE_SYSTEM, user: disambiguatePrompt(subset, t.title, t.text), maxTokens: 1000 }, ref, budget),
|
|
181
|
+
);
|
|
182
|
+
grounded = applyChoices(grounded, ambiguous, cache.choices);
|
|
183
|
+
}
|
|
184
|
+
// Stage 4: type verification against P31.
|
|
185
|
+
const resolved = grounded.filter((m) => m.qid);
|
|
186
|
+
if (!resolved.length) return [];
|
|
187
|
+
const details = await entityDetails(f, resolved.map((m) => m.qid), lang, ua);
|
|
188
|
+
if (!details) throw new Error("Wikidata didn't answer, so the entities couldn't be verified. Retrying later.");
|
|
189
|
+
return verifyEntities(resolved, details);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function analyzeEntry(ctx: PluginContext, options: AiOptions, s: Settings, features: FeatureMap, job: QueueJob, budget: Budget): Promise<"done" | "skipped"> {
|
|
193
|
+
const collection = job.collection!;
|
|
194
|
+
const id = job.entryId!;
|
|
195
|
+
const item = await ctx.content?.get(collection, id);
|
|
196
|
+
if (!item || item.status !== "published") return "skipped";
|
|
197
|
+
const t = await entryText(ctx, collection, item.data, s, features);
|
|
198
|
+
if (!t) return "skipped";
|
|
199
|
+
|
|
200
|
+
const records = col<EntryRecord>(ctx, "aiEntries");
|
|
201
|
+
const key = entryKey(collection, id);
|
|
202
|
+
const previous = await records.get(key);
|
|
203
|
+
if (!job.force && previous?.status === "ok" && previous.hash === t.hash) return "skipped";
|
|
204
|
+
|
|
205
|
+
const ref = key;
|
|
206
|
+
// Outputs from an earlier failed attempt on the same text are reused (the job is saved with them on failure).
|
|
207
|
+
if (job.cache?.hash !== t.hash) job.cache = { hash: t.hash };
|
|
208
|
+
const cache = job.cache;
|
|
209
|
+
const wantEntities = isOn(features, "ai.entities");
|
|
210
|
+
const hasDescription = Boolean(item.seo?.description?.trim());
|
|
211
|
+
const wantDescription = isOn(features, "ai.descriptions") && t.info.hasSeo && !hasDescription;
|
|
212
|
+
|
|
213
|
+
const entities = wantEntities ? await groundEntities(ctx, options, s, t, ref, cache, budget) : (previous?.entities ?? []);
|
|
214
|
+
|
|
215
|
+
let description = previous?.description ?? "";
|
|
216
|
+
let descriptionStatus: EntryRecord["descriptionStatus"] = hasDescription && previous?.descriptionStatus !== "applied" ? "none" : (previous?.descriptionStatus ?? "none");
|
|
217
|
+
if (wantDescription) {
|
|
218
|
+
cache.description ??= cleanDescription(
|
|
219
|
+
await callModel(ctx, options, s, "descriptions", { system: DESCRIBE_SYSTEM, user: articlePrompt(t.title, t.text), maxTokens: 300 }, ref, budget),
|
|
220
|
+
);
|
|
221
|
+
description = cache.description;
|
|
222
|
+
descriptionStatus = description ? "suggested" : "none";
|
|
223
|
+
if (description && s.descriptionsMode === "apply") {
|
|
224
|
+
descriptionStatus = (await applyDescription(ctx, collection, id, description, false)) ? "applied" : "none";
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
await records.put(key, {
|
|
229
|
+
collection,
|
|
230
|
+
entryId: id,
|
|
231
|
+
title: t.title,
|
|
232
|
+
hash: t.hash,
|
|
233
|
+
status: "ok",
|
|
234
|
+
error: "",
|
|
235
|
+
entities,
|
|
236
|
+
description,
|
|
237
|
+
descriptionStatus,
|
|
238
|
+
updated: new Date().toISOString(),
|
|
239
|
+
});
|
|
240
|
+
return "done";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Write a meta description into EmDash's SEO panel (a SEO-only update touches
|
|
245
|
+
* nothing else). Unless `replace`, only when the entry still has none, so a
|
|
246
|
+
* description an editor wrote meanwhile is never overwritten.
|
|
247
|
+
*/
|
|
248
|
+
export async function applyDescription(ctx: PluginContext, collection: string, id: string, description: string, replace: boolean): Promise<boolean> {
|
|
249
|
+
const content = ctx.content;
|
|
250
|
+
if (!content?.update) throw new Error("Content write access is unavailable.");
|
|
251
|
+
const fresh = await content.get(collection, id);
|
|
252
|
+
if (!fresh || fresh.seo === undefined) return false;
|
|
253
|
+
if (!replace && fresh.seo.description?.trim()) return false;
|
|
254
|
+
await content.update(collection, id, { seo: { description } });
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── Images ───────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
async function imagePayload(ctx: PluginContext, options: AiOptions, media: { id: string; mimeType: string; width?: number | null; height?: number | null }) {
|
|
261
|
+
if (!ctx.media?.readBytes) throw new Error("Media byte access is unavailable.");
|
|
262
|
+
const { images } = await bindings(options);
|
|
263
|
+
const bigEdge = Math.max(media.width ?? 0, media.height ?? 0) > MAX_EDGE * 2;
|
|
264
|
+
let bytes: Uint8Array;
|
|
265
|
+
try {
|
|
266
|
+
bytes = (await ctx.media.readBytes(media.id, { maxBytes: images ? 16 * 1024 * 1024 : MAX_IMAGE_BYTES })).bytes;
|
|
267
|
+
} catch (error) {
|
|
268
|
+
throw new SkipError(images ? `The image couldn't be read: ${(error as Error).message}` : "The image is larger than 3.5 MB. Add an Images binding (see README) to analyze large images.");
|
|
269
|
+
}
|
|
270
|
+
let mimeType = media.mimeType;
|
|
271
|
+
if (images && (bytes.byteLength > 1_000_000 || bigEdge || !DIRECT_MIMES.has(mimeType))) {
|
|
272
|
+
const stream = new Blob([bytes as Uint8Array<ArrayBuffer>]).stream();
|
|
273
|
+
const result = await images
|
|
274
|
+
.input(stream)
|
|
275
|
+
.transform({ width: MAX_EDGE, height: MAX_EDGE, fit: "scale-down" })
|
|
276
|
+
.output({ format: "image/jpeg", quality: 85 });
|
|
277
|
+
bytes = new Uint8Array(await result.response().arrayBuffer());
|
|
278
|
+
mimeType = "image/jpeg";
|
|
279
|
+
}
|
|
280
|
+
if (!DIRECT_MIMES.has(mimeType)) throw new SkipError(`${mimeType} images can't be analyzed without an Images binding.`);
|
|
281
|
+
if (bytes.byteLength > MAX_IMAGE_BYTES) throw new SkipError("The image is too large to analyze.");
|
|
282
|
+
return { mimeType, base64: base64(bytes) };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export async function analyzeMedia(ctx: PluginContext, options: AiOptions, s: Settings, job: QueueJob, budget: Budget): Promise<"done" | "skipped"> {
|
|
286
|
+
const id = job.mediaId!;
|
|
287
|
+
const media = await ctx.media?.get(id);
|
|
288
|
+
const records = col<MediaRecord>(ctx, "aiMedia");
|
|
289
|
+
if (!media) {
|
|
290
|
+
await records.delete(id).catch(() => false);
|
|
291
|
+
return "skipped";
|
|
292
|
+
}
|
|
293
|
+
const base: Omit<MediaRecord, "status" | "suggestion" | "written" | "error"> = { mediaId: id, filename: media.filename, url: media.url, updated: new Date().toISOString() };
|
|
294
|
+
if (!media.mimeType.startsWith("image/") || media.mimeType === "image/svg+xml") {
|
|
295
|
+
await records.put(id, { ...base, suggestion: null, status: "skipped", written: [], error: "Only raster images get image text." });
|
|
296
|
+
return "skipped";
|
|
297
|
+
}
|
|
298
|
+
const needsAlt = !media.alt?.trim();
|
|
299
|
+
const needsCaption = s.imageCaption && !media.caption?.trim();
|
|
300
|
+
if (!job.force && !s.imageOverwrite && !needsAlt && !needsCaption) return "skipped";
|
|
301
|
+
|
|
302
|
+
let payload: { mimeType: string; base64: string };
|
|
303
|
+
try {
|
|
304
|
+
payload = await imagePayload(ctx, options, media);
|
|
305
|
+
} catch (error) {
|
|
306
|
+
if (!(error instanceof SkipError)) throw error;
|
|
307
|
+
await records.put(id, { ...base, suggestion: null, status: "skipped", written: [], error: error.message });
|
|
308
|
+
return "skipped";
|
|
309
|
+
}
|
|
310
|
+
const raw = await callModel(
|
|
311
|
+
ctx,
|
|
312
|
+
options,
|
|
313
|
+
s,
|
|
314
|
+
"imageText",
|
|
315
|
+
{
|
|
316
|
+
system: IMAGE_SYSTEM,
|
|
317
|
+
user: imagePrompt({ filename: media.filename, site: await siteName(ctx), locale: ctx.site?.locale || "en", extra: s.imageInstructions }),
|
|
318
|
+
image: payload,
|
|
319
|
+
maxTokens: 1024,
|
|
320
|
+
},
|
|
321
|
+
`media:${id}`,
|
|
322
|
+
budget,
|
|
323
|
+
);
|
|
324
|
+
const suggestion = parseImageText(raw);
|
|
325
|
+
|
|
326
|
+
let written: string[] = [];
|
|
327
|
+
if (s.imageMode === "apply") written = await applyImageText(ctx, id, suggestion, { caption: s.imageCaption, overwrite: s.imageOverwrite });
|
|
328
|
+
await records.put(id, { ...base, suggestion, status: written.length ? "applied" : "suggested", written, error: "" });
|
|
329
|
+
return "done";
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Write alt text (and optionally the caption) to a media item. Human-entered values are kept unless `overwrite`. */
|
|
333
|
+
export async function applyImageText(
|
|
334
|
+
ctx: PluginContext,
|
|
335
|
+
id: string,
|
|
336
|
+
text: { alt?: string; caption?: string },
|
|
337
|
+
opts: { caption: boolean; overwrite: boolean },
|
|
338
|
+
): Promise<string[]> {
|
|
339
|
+
if (!ctx.media?.updateMetadata) throw new Error("Media metadata write access is unavailable.");
|
|
340
|
+
const fresh = await ctx.media.get(id);
|
|
341
|
+
if (!fresh) return [];
|
|
342
|
+
const patch: { alt?: string; caption?: string } = {};
|
|
343
|
+
if (text.alt && (opts.overwrite || !fresh.alt?.trim())) patch.alt = text.alt;
|
|
344
|
+
if (opts.caption && text.caption && (opts.overwrite || !fresh.caption?.trim())) patch.caption = text.caption;
|
|
345
|
+
if (!Object.keys(patch).length) return [];
|
|
346
|
+
await ctx.media.updateMetadata(id, patch);
|
|
347
|
+
return Object.keys(patch);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ── Bulk scan ────────────────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
/** Turn part of a bulk run into queue jobs (up to ~200 items per call). */
|
|
353
|
+
export async function advanceBulk(ctx: PluginContext, s: Settings, features: FeatureMap, kind: BulkState["kind"]): Promise<void> {
|
|
354
|
+
const state = await ctx.kv.get<BulkState>(bulkKey(kind));
|
|
355
|
+
if (!state || state.finished) return;
|
|
356
|
+
const now = Date.now();
|
|
357
|
+
let pages = 2;
|
|
358
|
+
if (kind === "entries") {
|
|
359
|
+
const records = col<EntryRecord>(ctx, "aiEntries");
|
|
360
|
+
while (pages-- > 0 && state.collections.length) {
|
|
361
|
+
const collection = state.collections[0];
|
|
362
|
+
const page = await ctx.content!.list(collection, { where: { status: "published" }, limit: 100, cursor: state.cursor });
|
|
363
|
+
const stored = await records.getMany(page.items.map((i) => entryKey(collection, i.id)));
|
|
364
|
+
for (const item of page.items) {
|
|
365
|
+
if (!state.force) {
|
|
366
|
+
const t = await entryText(ctx, collection, item.data, s, features);
|
|
367
|
+
const prev = stored.get(entryKey(collection, item.id));
|
|
368
|
+
if (!t || (prev?.status === "ok" && prev.hash === t.hash)) {
|
|
369
|
+
state.skipped++;
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
await enqueue(ctx, { kind: "entry", collection, entryId: item.id, due: now, force: state.force });
|
|
374
|
+
state.queued++;
|
|
375
|
+
}
|
|
376
|
+
if (page.hasMore && page.cursor) state.cursor = page.cursor;
|
|
377
|
+
else {
|
|
378
|
+
state.collections.shift();
|
|
379
|
+
state.cursor = undefined;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
if (!state.collections.length) state.finished = new Date().toISOString();
|
|
383
|
+
} else {
|
|
384
|
+
while (pages-- > 0 && !state.finished) {
|
|
385
|
+
const page = await ctx.media!.list({ mimeType: "image/", limit: 100, cursor: state.cursor });
|
|
386
|
+
for (const m of page.items) {
|
|
387
|
+
const lacking = !m.alt?.trim() || (s.imageCaption && !m.caption?.trim());
|
|
388
|
+
if (m.mimeType === "image/svg+xml" || (!state.force && !lacking)) {
|
|
389
|
+
state.skipped++;
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
await enqueue(ctx, { kind: "media", mediaId: m.id, due: now, force: state.force });
|
|
393
|
+
state.queued++;
|
|
394
|
+
}
|
|
395
|
+
if (page.hasMore && page.cursor) state.cursor = page.cursor;
|
|
396
|
+
else state.finished = new Date().toISOString();
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
await ctx.kv.set(bulkKey(kind), state);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ── Tick ─────────────────────────────────────────────────────────
|
|
403
|
+
|
|
404
|
+
export interface TickResult {
|
|
405
|
+
processed: number;
|
|
406
|
+
skipped: number;
|
|
407
|
+
failed: number;
|
|
408
|
+
stoppedForLimit: boolean;
|
|
409
|
+
busy?: boolean;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const callsPerJob = (features: FeatureMap) => (job: QueueJob) =>
|
|
413
|
+
job.kind === "media" ? 1 : (isOn(features, "ai.entities") ? 2 : 0) + (isOn(features, "ai.descriptions") ? 1 : 0) || 1;
|
|
414
|
+
|
|
415
|
+
/** Process due jobs. `maxJobs` overrides the per-tick setting (the admin's Run now uses 1). */
|
|
416
|
+
export async function tick(ctx: PluginContext, options: AiOptions, opts: { maxJobs?: number; budgetMs: number }): Promise<TickResult> {
|
|
417
|
+
const result = await withLock(ctx, opts.budgetMs + 120_000, async () => {
|
|
418
|
+
const out: TickResult = { processed: 0, skipped: 0, failed: 0, stoppedForLimit: false };
|
|
419
|
+
const started = Date.now();
|
|
420
|
+
const features = await ctxFeatures(ctx);
|
|
421
|
+
const s = await readSettings(ctx, options);
|
|
422
|
+
const entriesOn = isOn(features, "ai.entities") || isOn(features, "ai.descriptions");
|
|
423
|
+
const imagesOn = isOn(features, "ai.imageText");
|
|
424
|
+
|
|
425
|
+
for (const kind of ["entries", "media"] as const) {
|
|
426
|
+
if (kind === "entries" ? !entriesOn : !imagesOn) continue;
|
|
427
|
+
await advanceBulk(ctx, s, features, kind).catch((error) => ctx.log.warn("AI bulk scan failed", { kind, error: String(error) }));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const queue = col<QueueJob>(ctx, "aiQueue");
|
|
431
|
+
// Each enabled kind is read separately, so a backlog of one can't hide the other.
|
|
432
|
+
// Jobs for switched-off features wait in the queue until they're back on.
|
|
433
|
+
const kinds = [entriesOn && "entry", imagesOn && "media"].filter(Boolean) as Array<QueueJob["kind"]>;
|
|
434
|
+
const pages = await Promise.all(kinds.map((kind) => queue.query({ where: { kind, due: { lte: Date.now() } }, orderBy: { due: "asc" }, limit: 25 })));
|
|
435
|
+
const runnable = pages.flatMap((p) => p.items);
|
|
436
|
+
const budget: Budget = { used: 0, max: SUBREQUEST_BUDGET };
|
|
437
|
+
// While Wikidata asks to slow down (one Retry-After, shared by every entry job), entry jobs wait for a later tick.
|
|
438
|
+
let wikidataBusy = ((await ctx.kv.get<number>(WIKIDATA_BUSY_KEY)) ?? 0) > Date.now();
|
|
439
|
+
const batch = planBatch(runnable, {
|
|
440
|
+
now: Date.now(),
|
|
441
|
+
perTick: opts.maxJobs ?? s.jobsPerTick,
|
|
442
|
+
remainingCalls: s.maxCallsPerDay - (await callsToday(ctx)),
|
|
443
|
+
callsPerJob: callsPerJob(features),
|
|
444
|
+
subrequests: budget.max,
|
|
445
|
+
subrequestsPerJob: (job) => WORST_CASE_SUBREQUESTS[job.kind],
|
|
446
|
+
});
|
|
447
|
+
if (runnable.length && !batch.length) out.stoppedForLimit = true;
|
|
448
|
+
|
|
449
|
+
for (const { id, data: job } of batch) {
|
|
450
|
+
if (Date.now() - started > opts.budgetMs) break;
|
|
451
|
+
if (budget.used + WORST_CASE_SUBREQUESTS[job.kind] > budget.max) break;
|
|
452
|
+
if (wikidataBusy && job.kind === "entry") continue;
|
|
453
|
+
try {
|
|
454
|
+
const status = job.kind === "media" ? await analyzeMedia(ctx, options, s, job, budget) : await analyzeEntry(ctx, options, s, features, job, budget);
|
|
455
|
+
await queue.delete(id);
|
|
456
|
+
if (status === "done") out.processed++;
|
|
457
|
+
else out.skipped++;
|
|
458
|
+
} catch (error) {
|
|
459
|
+
if (error instanceof DailyLimitError) {
|
|
460
|
+
out.stoppedForLimit = true;
|
|
461
|
+
await queue.put(id, job); // Stays queued for tomorrow, with any outputs already paid for.
|
|
462
|
+
break;
|
|
463
|
+
}
|
|
464
|
+
if (error instanceof WikidataBusyError) {
|
|
465
|
+
wikidataBusy = true;
|
|
466
|
+
await ctx.kv.set(WIKIDATA_BUSY_KEY, Date.now() + error.retryAfterMs);
|
|
467
|
+
// Not a failure: run again once Wikidata's Retry-After has passed, without using up an attempt...
|
|
468
|
+
const deferred = deferJob(job, Date.now(), error.retryAfterMs);
|
|
469
|
+
if (deferred) {
|
|
470
|
+
ctx.log.warn("AI job deferred: Wikidata asked to slow down", { job: id, retryAfterMs: error.retryAfterMs, deferrals: deferred.deferrals });
|
|
471
|
+
await queue.put(id, { ...deferred, lastError: error.message });
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
// ...unless it has been deferred MAX_DEFERRALS times in a row: then it counts as a failed attempt.
|
|
475
|
+
}
|
|
476
|
+
out.failed++;
|
|
477
|
+
const message = String((error as Error)?.message ?? error).slice(0, 500);
|
|
478
|
+
ctx.log.warn("AI job failed", { job: id, error: message });
|
|
479
|
+
const next = retryAt(job, Date.now());
|
|
480
|
+
if (next === null) {
|
|
481
|
+
await queue.delete(id);
|
|
482
|
+
await recordFailure(ctx, job, message);
|
|
483
|
+
} else {
|
|
484
|
+
// job.cache holds the stage outputs this attempt already paid for.
|
|
485
|
+
await queue.put(id, { ...job, attempts: job.attempts + 1, deferrals: 0, due: next, lastError: message });
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
await pruneUsage(ctx).catch(() => undefined);
|
|
490
|
+
return out;
|
|
491
|
+
});
|
|
492
|
+
return result ?? { processed: 0, skipped: 0, failed: 0, stoppedForLimit: false, busy: true };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async function recordFailure(ctx: PluginContext, job: QueueJob, error: string): Promise<void> {
|
|
496
|
+
const updated = new Date().toISOString();
|
|
497
|
+
if (job.kind === "media") {
|
|
498
|
+
const records = col<MediaRecord>(ctx, "aiMedia");
|
|
499
|
+
const prev = await records.get(job.mediaId!);
|
|
500
|
+
await records.put(job.mediaId!, {
|
|
501
|
+
mediaId: job.mediaId!,
|
|
502
|
+
filename: prev?.filename ?? "",
|
|
503
|
+
url: prev?.url ?? "",
|
|
504
|
+
suggestion: prev?.suggestion ?? null,
|
|
505
|
+
status: "error",
|
|
506
|
+
written: prev?.written ?? [],
|
|
507
|
+
error,
|
|
508
|
+
updated,
|
|
509
|
+
});
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
const records = col<EntryRecord>(ctx, "aiEntries");
|
|
513
|
+
const key = entryKey(job.collection!, job.entryId!);
|
|
514
|
+
const prev = await records.get(key);
|
|
515
|
+
// Keep what earlier runs found; an empty hash makes the next save retry.
|
|
516
|
+
await records.put(key, {
|
|
517
|
+
collection: job.collection!,
|
|
518
|
+
entryId: job.entryId!,
|
|
519
|
+
title: prev?.title ?? "",
|
|
520
|
+
hash: "",
|
|
521
|
+
status: "error",
|
|
522
|
+
error,
|
|
523
|
+
entities: prev?.entities ?? [],
|
|
524
|
+
description: prev?.description ?? "",
|
|
525
|
+
descriptionStatus: prev?.descriptionStatus ?? "none",
|
|
526
|
+
updated,
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export { queueId };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Lets tsc type-check imports of .astro components (Astro compiles them; tsc only needs their shape). */
|
|
2
|
+
declare module "*.astro" {
|
|
3
|
+
// biome-ignore lint/suspicious/noExplicitAny: Astro component factory.
|
|
4
|
+
const Component: (props: any) => any;
|
|
5
|
+
export default Component;
|
|
6
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Accessible breadcrumb trail (WAI-ARIA breadcrumb pattern): <nav
|
|
4
|
+
* aria-label="Breadcrumb"> around an <ol>, links for ancestors, and the
|
|
5
|
+
* current page as plain text with aria-current="page". Separators are CSS, so
|
|
6
|
+
* screen readers skip them.
|
|
7
|
+
*
|
|
8
|
+
* The trail is, in order: `items`, `page.breadcrumbs` (the theme's
|
|
9
|
+
* PublicPageContext), the trail EmDashHead published for this path, or one
|
|
10
|
+
* derived from the URL path. Unset options use the Breadcrumb Nav settings.
|
|
11
|
+
* Renders nothing while the `breadcrumbs` feature is off.
|
|
12
|
+
*
|
|
13
|
+
* import { Breadcrumbs } from "@coywolf/emdash/astro";
|
|
14
|
+
* <Breadcrumbs page={page} />
|
|
15
|
+
*/
|
|
16
|
+
import { siteFeatureOn } from "../../core/features.js";
|
|
17
|
+
import { SEPARATORS, type SeparatorPreset, siteBreadcrumbsSettings } from "../../breadcrumbs/settings.js";
|
|
18
|
+
import { type Crumb, capturedPage, cssString, resolveTrail } from "../../breadcrumbs/trail.js";
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
items?: Crumb[];
|
|
22
|
+
page?: { breadcrumbs?: Crumb[]; pageTitle?: string | null; title?: string | null };
|
|
23
|
+
/** Current page title, when neither `items` nor `page` gives one. */
|
|
24
|
+
title?: string | null;
|
|
25
|
+
/** A preset name (slash, chevron, guillemet, bullet, arrow, gt) or any short string. */
|
|
26
|
+
separator?: string;
|
|
27
|
+
homeLabel?: string;
|
|
28
|
+
showHome?: boolean;
|
|
29
|
+
showCurrent?: boolean;
|
|
30
|
+
class?: string;
|
|
31
|
+
label?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const props = Astro.props;
|
|
35
|
+
let trail: Crumb[] = [];
|
|
36
|
+
let sepClass = "";
|
|
37
|
+
let sepStyle: string | undefined;
|
|
38
|
+
|
|
39
|
+
if (await siteFeatureOn("breadcrumbs")) {
|
|
40
|
+
const settings = await siteBreadcrumbsSettings();
|
|
41
|
+
const captured = props.items || props.page ? null : capturedPage(`${Astro.url.pathname}${Astro.url.search}`);
|
|
42
|
+
trail = resolveTrail({
|
|
43
|
+
path: Astro.url.pathname,
|
|
44
|
+
items: props.items ?? props.page?.breadcrumbs ?? captured?.items,
|
|
45
|
+
title: props.page?.pageTitle ?? props.page?.title ?? props.title ?? captured?.title,
|
|
46
|
+
homeLabel: props.homeLabel?.trim() || settings.homeLabel,
|
|
47
|
+
showHome: props.showHome ?? settings.showHome,
|
|
48
|
+
showCurrent: props.showCurrent ?? settings.showCurrent,
|
|
49
|
+
});
|
|
50
|
+
const separator = props.separator ?? (settings.customSeparator || settings.separator);
|
|
51
|
+
if (Object.hasOwn(SEPARATORS, separator)) sepClass = `cw-breadcrumbs--sep-${separator as SeparatorPreset}`;
|
|
52
|
+
else sepStyle = `--cw-bc-sep:${cssString([...separator].slice(0, 8).join(""))}`;
|
|
53
|
+
}
|
|
54
|
+
const current = Astro.url.pathname.replace(/\/+$/, "") || "/";
|
|
55
|
+
const isCurrent = (url: string) => {
|
|
56
|
+
try {
|
|
57
|
+
return (new URL(url, Astro.url).pathname.replace(/\/+$/, "") || "/") === current;
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
{trail.length > 0 && (
|
|
65
|
+
<nav class={["cw-breadcrumbs", sepClass, props.class].filter(Boolean).join(" ")} style={sepStyle} aria-label={props.label ?? "Breadcrumb"}>
|
|
66
|
+
<ol class="cw-breadcrumbs__list">
|
|
67
|
+
{trail.map((crumb, i) => (
|
|
68
|
+
<li class="cw-breadcrumbs__item">
|
|
69
|
+
{i === trail.length - 1 && isCurrent(crumb.url) ? (
|
|
70
|
+
<span class="cw-breadcrumbs__current" aria-current="page">{crumb.name}</span>
|
|
71
|
+
) : (
|
|
72
|
+
<a href={crumb.url}>{crumb.name}</a>
|
|
73
|
+
)}
|
|
74
|
+
</li>
|
|
75
|
+
))}
|
|
76
|
+
</ol>
|
|
77
|
+
</nav>
|
|
78
|
+
)}
|
|
79
|
+
|
|
80
|
+
<style is:global>
|
|
81
|
+
.cw-breadcrumbs {
|
|
82
|
+
--cw-bc-sep: "/";
|
|
83
|
+
font-size: 0.875em;
|
|
84
|
+
line-height: 1.4;
|
|
85
|
+
}
|
|
86
|
+
.cw-breadcrumbs__list {
|
|
87
|
+
display: flex;
|
|
88
|
+
flex-wrap: wrap;
|
|
89
|
+
align-items: center;
|
|
90
|
+
margin: 0;
|
|
91
|
+
padding: 0;
|
|
92
|
+
list-style: none;
|
|
93
|
+
}
|
|
94
|
+
.cw-breadcrumbs__item {
|
|
95
|
+
display: inline-flex;
|
|
96
|
+
align-items: center;
|
|
97
|
+
}
|
|
98
|
+
.cw-breadcrumbs__item + .cw-breadcrumbs__item::before {
|
|
99
|
+
content: var(--cw-bc-sep, "/");
|
|
100
|
+
padding: 0 0.5em;
|
|
101
|
+
opacity: 0.6;
|
|
102
|
+
}
|
|
103
|
+
@supports (content: "x" / "") {
|
|
104
|
+
.cw-breadcrumbs__item + .cw-breadcrumbs__item::before {
|
|
105
|
+
content: var(--cw-bc-sep, "/") / "";
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
.cw-breadcrumbs__current {
|
|
109
|
+
font-weight: 600;
|
|
110
|
+
}
|
|
111
|
+
.cw-breadcrumbs--sep-slash {
|
|
112
|
+
--cw-bc-sep: "/";
|
|
113
|
+
}
|
|
114
|
+
.cw-breadcrumbs--sep-chevron {
|
|
115
|
+
--cw-bc-sep: "\203A";
|
|
116
|
+
}
|
|
117
|
+
.cw-breadcrumbs--sep-guillemet {
|
|
118
|
+
--cw-bc-sep: "\00BB";
|
|
119
|
+
}
|
|
120
|
+
.cw-breadcrumbs--sep-bullet {
|
|
121
|
+
--cw-bc-sep: "\2022";
|
|
122
|
+
}
|
|
123
|
+
.cw-breadcrumbs--sep-arrow {
|
|
124
|
+
--cw-bc-sep: "\2192";
|
|
125
|
+
}
|
|
126
|
+
.cw-breadcrumbs--sep-gt {
|
|
127
|
+
--cw-bc-sep: ">";
|
|
128
|
+
}
|
|
129
|
+
</style>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** The `coywolf-breadcrumbs` Portable Text block: the Breadcrumbs component with the block's options. */
|
|
3
|
+
import Breadcrumbs from "./Breadcrumbs.astro";
|
|
4
|
+
|
|
5
|
+
const { node } = Astro.props as { node: Record<string, unknown> };
|
|
6
|
+
const separator = typeof node.separator === "string" && node.separator ? node.separator : undefined;
|
|
7
|
+
const homeLabel = typeof node.homeLabel === "string" && node.homeLabel.trim() ? node.homeLabel : undefined;
|
|
8
|
+
const showCurrent = node.showCurrent === "show" ? true : node.showCurrent === "hide" ? false : undefined;
|
|
9
|
+
const title = typeof node._title === "string" ? node._title : undefined;
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
<Breadcrumbs separator={separator} homeLabel={homeLabel} showCurrent={showCurrent} title={title} />
|