@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
package/src/core/csv.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSV for exports and imports (RFC 4180): quoted fields, doubled quotes,
|
|
3
|
+
* commas and line breaks inside quotes. No imports, so the admin bundle and
|
|
4
|
+
* `node --test` can both load it.
|
|
5
|
+
*
|
|
6
|
+
* Spreadsheet apps run a cell that starts with = + - @ (or a tab or carriage
|
|
7
|
+
* return) as a formula, so exports meant for spreadsheets pass `guard: true`:
|
|
8
|
+
* such cells get a leading apostrophe, which spreadsheets hide. Cells that
|
|
9
|
+
* start with apostrophes followed by one of those characters get one more, so
|
|
10
|
+
* `unguardCell` can always undo the guard exactly and a guarded export still
|
|
11
|
+
* imports back to the same values.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type CsvCell = string | number | boolean | null | undefined;
|
|
15
|
+
|
|
16
|
+
const FORMULA = /^'*[=+\-@\t\r]/;
|
|
17
|
+
const GUARDED = /^'+[=+\-@\t\r]/;
|
|
18
|
+
|
|
19
|
+
/** Neutralize a cell a spreadsheet would treat as a formula. Reversible with unguardCell. */
|
|
20
|
+
export function guardCell(value: string): string {
|
|
21
|
+
return FORMULA.test(value) ? `'${value}` : value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Undo guardCell. Values that weren't guarded come back unchanged. */
|
|
25
|
+
export function unguardCell(value: string): string {
|
|
26
|
+
return GUARDED.test(value) ? value.slice(1) : value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function cellText(value: CsvCell): string {
|
|
30
|
+
if (value === null || value === undefined) return "";
|
|
31
|
+
if (typeof value === "boolean") return value ? "1" : "0";
|
|
32
|
+
return String(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Quote a field when it holds a comma, quote, tab or line break (or leading/trailing spaces, which some readers trim). */
|
|
36
|
+
function quote(text: string): string {
|
|
37
|
+
return /[",\t\r\n]|^\s|\s$/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Rows to CSV text (CRLF line ends, as RFC 4180 and Excel expect). */
|
|
41
|
+
export function toCsv(rows: CsvCell[][], options: { guard?: boolean } = {}): string {
|
|
42
|
+
return `${rows
|
|
43
|
+
.map((row) =>
|
|
44
|
+
row
|
|
45
|
+
.map((value) => {
|
|
46
|
+
const text = cellText(value);
|
|
47
|
+
return quote(options.guard && typeof value === "string" ? guardCell(text) : text);
|
|
48
|
+
})
|
|
49
|
+
.join(","),
|
|
50
|
+
)
|
|
51
|
+
.join("\r\n")}\r\n`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parse CSV (or tab-separated) text into rows. The delimiter defaults to a
|
|
56
|
+
* tab when the first line has one (toCsv quotes tabs, so its header never
|
|
57
|
+
* does), else a comma. A leading
|
|
58
|
+
* byte-order mark is dropped, and blank lines are skipped.
|
|
59
|
+
*/
|
|
60
|
+
export function parseCsv(text: string, delimiter?: string): string[][] {
|
|
61
|
+
const source = text.replace(/^/, "");
|
|
62
|
+
const firstLine = source.split(/\r?\n/, 1)[0] ?? "";
|
|
63
|
+
const sep = delimiter ?? (firstLine.includes("\t") ? "\t" : ",");
|
|
64
|
+
const rows: string[][] = [];
|
|
65
|
+
let row: string[] = [];
|
|
66
|
+
let field = "";
|
|
67
|
+
let quoted = false;
|
|
68
|
+
let i = 0;
|
|
69
|
+
const endRow = () => {
|
|
70
|
+
row.push(field);
|
|
71
|
+
if (row.length > 1 || row[0] !== "") rows.push(row);
|
|
72
|
+
row = [];
|
|
73
|
+
field = "";
|
|
74
|
+
};
|
|
75
|
+
while (i < source.length) {
|
|
76
|
+
const c = source[i];
|
|
77
|
+
if (quoted) {
|
|
78
|
+
if (c === '"') {
|
|
79
|
+
if (source[i + 1] === '"') {
|
|
80
|
+
field += '"';
|
|
81
|
+
i += 2;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
quoted = false;
|
|
85
|
+
i++;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
field += c;
|
|
89
|
+
i++;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (c === '"' && field.trim() === "") {
|
|
93
|
+
// A quote opens a quoted field only at its start (spaces before it are dropped).
|
|
94
|
+
field = "";
|
|
95
|
+
quoted = true;
|
|
96
|
+
i++;
|
|
97
|
+
} else if (c === sep) {
|
|
98
|
+
row.push(field);
|
|
99
|
+
field = "";
|
|
100
|
+
i++;
|
|
101
|
+
} else if (c === "\r" || c === "\n") {
|
|
102
|
+
endRow();
|
|
103
|
+
i += c === "\r" && source[i + 1] === "\n" ? 2 : 1;
|
|
104
|
+
} else {
|
|
105
|
+
field += c;
|
|
106
|
+
i++;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (field !== "" || row.length) endRow();
|
|
110
|
+
return rows;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** CSV rows to objects keyed by the (lowercased, trimmed) header row. */
|
|
114
|
+
export function csvRecords(text: string, delimiter?: string): Array<Record<string, string>> {
|
|
115
|
+
const [header, ...rows] = parseCsv(text, delimiter);
|
|
116
|
+
if (!header) return [];
|
|
117
|
+
const keys = header.map((h) => h.trim().toLowerCase());
|
|
118
|
+
return rows.map((row) => Object.fromEntries(keys.map((key, i) => [key, row[i] ?? ""])));
|
|
119
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Same-tick batching for the pack's own D1 reads. The pack reads the site
|
|
3
|
+
* database through the raw binding (env.DB), which EmDash's per-request
|
|
4
|
+
* session dialect never sees, so its reads were never coalesced: each was its
|
|
5
|
+
* own round trip. Reads issued through here in the same tick (before a
|
|
6
|
+
* zero-delay timer fires) go to D1 as one `db.batch()`: one round trip.
|
|
7
|
+
*
|
|
8
|
+
* A statement that fails fails the whole batch in D1, so on a batch error each
|
|
9
|
+
* statement is retried on its own, and only the failing ones reject. Reads of
|
|
10
|
+
* different requests are never batched together (Workers ties I/O to the
|
|
11
|
+
* request that started it).
|
|
12
|
+
*/
|
|
13
|
+
import { requestStore } from "./request-memo.js";
|
|
14
|
+
|
|
15
|
+
interface Pending {
|
|
16
|
+
statement: D1PreparedStatement;
|
|
17
|
+
resolve(result: D1Result<unknown>): void;
|
|
18
|
+
reject(error: unknown): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Request context (or OUTSIDE) → database → reads waiting for this tick's batch. */
|
|
22
|
+
const queues = new WeakMap<object, Map<D1Database, Pending[]>>();
|
|
23
|
+
const OUTSIDE = {};
|
|
24
|
+
|
|
25
|
+
function flush(scope: Map<D1Database, Pending[]>, db: D1Database): void {
|
|
26
|
+
const queue = scope.get(db) ?? [];
|
|
27
|
+
scope.delete(db);
|
|
28
|
+
if (!queue.length) return;
|
|
29
|
+
if (queue.length === 1 || typeof db.batch !== "function") {
|
|
30
|
+
for (const p of queue) p.statement.all().then(p.resolve, p.reject);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
db.batch(queue.map((p) => p.statement)).then(
|
|
34
|
+
(results) => queue.forEach((p, i) => p.resolve(results[i] as D1Result<unknown>)),
|
|
35
|
+
() => {
|
|
36
|
+
// One bad statement fails a D1 batch: run them one by one so the others still answer.
|
|
37
|
+
for (const p of queue) p.statement.all().then(p.resolve, p.reject);
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Run a read with the other reads of this tick, in one D1 batch. Resolves to its rows. */
|
|
43
|
+
export function batchedAll<T = Record<string, unknown>>(db: D1Database, statement: D1PreparedStatement): Promise<T[]> {
|
|
44
|
+
return new Promise<D1Result<unknown>>((resolve, reject) => {
|
|
45
|
+
const owner = requestStore() ?? OUTSIDE;
|
|
46
|
+
let scope = queues.get(owner);
|
|
47
|
+
if (!scope) {
|
|
48
|
+
scope = new Map();
|
|
49
|
+
queues.set(owner, scope);
|
|
50
|
+
}
|
|
51
|
+
let queue = scope.get(db);
|
|
52
|
+
if (!queue) {
|
|
53
|
+
queue = [];
|
|
54
|
+
scope.set(db, queue);
|
|
55
|
+
const pending = scope;
|
|
56
|
+
setTimeout(() => flush(pending, db), 0);
|
|
57
|
+
}
|
|
58
|
+
queue.push({ statement, resolve, reject });
|
|
59
|
+
}).then((result) => (result.results ?? []) as T[]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Like batchedAll, the first row (null when none). */
|
|
63
|
+
export async function batchedFirst<T = Record<string, unknown>>(db: D1Database, statement: D1PreparedStatement): Promise<T | null> {
|
|
64
|
+
return (await batchedAll<T>(db, statement))[0] ?? null;
|
|
65
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything the pack stores about one entry, read for a page render in one
|
|
3
|
+
* D1 round trip: its plugin-storage docs in several collections (Schema
|
|
4
|
+
* overrides, the Videos embed index, AI entities…, all keyed
|
|
5
|
+
* "<collection>:<entry id>") in a single query, plus, when asked, the
|
|
6
|
+
* entry's own row (for blocks the theme didn't hand over), batched with it.
|
|
7
|
+
*
|
|
8
|
+
* Before, each module read its doc on its own (three to five queries per
|
|
9
|
+
* page). Without a D1 binding (tests, other databases) it falls back to the
|
|
10
|
+
* plugin context's storage and content APIs, one read each, at once.
|
|
11
|
+
*/
|
|
12
|
+
import type { PluginContext, StorageCollection } from "emdash";
|
|
13
|
+
|
|
14
|
+
import { batchedAll, batchedFirst } from "./d1-batch.js";
|
|
15
|
+
import { PLUGIN_ID } from "./features.js";
|
|
16
|
+
import { noteReadFailure } from "./read-failures.js";
|
|
17
|
+
import { peekRequestMemo, seedRequestMemo } from "./request-memo.js";
|
|
18
|
+
import { workerEnv } from "../shared.js";
|
|
19
|
+
|
|
20
|
+
export interface EntryDocsRequest {
|
|
21
|
+
collection: string;
|
|
22
|
+
id: string;
|
|
23
|
+
/** Plugin-storage collections whose doc for this entry (id "<collection>:<entry id>") is wanted. */
|
|
24
|
+
collections: readonly string[];
|
|
25
|
+
/** Also read the entry's own data (its row in ec_<collection>). */
|
|
26
|
+
data?: boolean;
|
|
27
|
+
/** D1 binding of the site database. Default "DB". */
|
|
28
|
+
database?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface EntryDocs {
|
|
32
|
+
/** Storage collection → this entry's doc (null when there's none). */
|
|
33
|
+
docs: Map<string, unknown>;
|
|
34
|
+
/** The entry's fields (JSON fields parsed), when asked for; null when the entry doesn't exist. */
|
|
35
|
+
data?: Record<string, unknown> | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** ec_<collection> names EmDash accepts (validateIdentifier). */
|
|
39
|
+
const IDENTIFIER = /^[a-z][a-z0-9_]*$/;
|
|
40
|
+
|
|
41
|
+
type Row = Record<string, unknown>;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* An entry row's fields: JSON text columns (Portable Text, objects, lists)
|
|
45
|
+
* parsed. Only columns that look like JSON are parsed, and `mustContain`
|
|
46
|
+
* skips parsing large columns that can't hold what the caller looks for.
|
|
47
|
+
*/
|
|
48
|
+
export function rowData(row: Row | null | undefined, mustContain?: string): Record<string, unknown> | null {
|
|
49
|
+
if (!row) return null;
|
|
50
|
+
const out: Record<string, unknown> = {};
|
|
51
|
+
for (const [key, value] of Object.entries(row)) {
|
|
52
|
+
if (typeof value === "string" && (value.startsWith("[") || value.startsWith("{"))) {
|
|
53
|
+
if (mustContain && !value.includes(mustContain)) continue;
|
|
54
|
+
try {
|
|
55
|
+
out[key] = JSON.parse(value);
|
|
56
|
+
continue;
|
|
57
|
+
} catch {
|
|
58
|
+
// Not JSON after all: keep the text.
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
out[key] = value;
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function readFromD1(db: D1Database, req: EntryDocsRequest, mustContain?: string): Promise<EntryDocs> {
|
|
67
|
+
const key = `${req.collection}:${req.id}`;
|
|
68
|
+
const wantData = req.data && IDENTIFIER.test(req.collection);
|
|
69
|
+
// Both go out with the page's other pack reads of this tick (one D1 batch).
|
|
70
|
+
const [rows, entry] = await Promise.all([
|
|
71
|
+
req.collections.length
|
|
72
|
+
? batchedAll<{ collection: string; data: string | null }>(
|
|
73
|
+
db,
|
|
74
|
+
db
|
|
75
|
+
.prepare(`SELECT collection, data FROM _plugin_storage WHERE plugin_id = ? AND id = ? AND collection IN (${req.collections.map(() => "?").join(",")})`)
|
|
76
|
+
.bind(PLUGIN_ID, key, ...req.collections),
|
|
77
|
+
)
|
|
78
|
+
: [],
|
|
79
|
+
wantData ? batchedFirst<Row>(db, db.prepare(`SELECT * FROM "ec_${req.collection}" WHERE id = ? AND deleted_at IS NULL LIMIT 1`).bind(req.id)) : null,
|
|
80
|
+
]);
|
|
81
|
+
const docs = new Map<string, unknown>(req.collections.map((c) => [c, null]));
|
|
82
|
+
for (const row of rows) {
|
|
83
|
+
try {
|
|
84
|
+
docs.set(row.collection, row.data ? JSON.parse(row.data) : null);
|
|
85
|
+
} catch {
|
|
86
|
+
docs.set(row.collection, null);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const out: EntryDocs = { docs };
|
|
90
|
+
if (req.data) out.data = wantData ? rowData(entry, mustContain) : null;
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function readFromContext(ctx: PluginContext, req: EntryDocsRequest): Promise<EntryDocs> {
|
|
95
|
+
const key = `${req.collection}:${req.id}`;
|
|
96
|
+
const storage = ctx.storage as Record<string, StorageCollection | undefined>;
|
|
97
|
+
const [values, entry] = await Promise.all([
|
|
98
|
+
Promise.all(req.collections.map((c) => storage[c]?.get(key) ?? null)),
|
|
99
|
+
req.data && ctx.content
|
|
100
|
+
? ctx.content.get(req.collection, req.id).catch(() => {
|
|
101
|
+
noteReadFailure("entry data");
|
|
102
|
+
return null;
|
|
103
|
+
})
|
|
104
|
+
: null,
|
|
105
|
+
]);
|
|
106
|
+
const out: EntryDocs = { docs: new Map(req.collections.map((c, i) => [c, values[i] ?? null])) };
|
|
107
|
+
if (req.data) out.data = (entry?.data as Record<string, unknown> | undefined) ?? null;
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Read an entry's docs (and data). One D1 batch when the site database is
|
|
113
|
+
* bound; otherwise the plugin context's reads. `mustContain` (optional): only
|
|
114
|
+
* JSON columns containing it are parsed from a D1 row.
|
|
115
|
+
*/
|
|
116
|
+
async function readUncached(ctx: PluginContext, req: EntryDocsRequest, mustContain?: string): Promise<EntryDocs> {
|
|
117
|
+
let db: D1Database | undefined;
|
|
118
|
+
try {
|
|
119
|
+
db = (await workerEnv())[req.database ?? "DB"] as D1Database | undefined;
|
|
120
|
+
} catch {
|
|
121
|
+
db = undefined; // Not on Workers.
|
|
122
|
+
}
|
|
123
|
+
if (db && typeof db.prepare === "function") {
|
|
124
|
+
try {
|
|
125
|
+
return await readFromD1(db, req, mustContain);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
ctx.log?.warn?.("coywolf-pack: entry docs read failed; reading them one by one", { error: String(error) });
|
|
128
|
+
// Without content access the fallback can't read the entry's data (it reads as null).
|
|
129
|
+
if (req.data && !ctx.content) noteReadFailure("entry data");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return readFromContext(ctx, req);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const docKey = (req: EntryDocsRequest, collection: string) => `coywolf-entry-doc:${collection}:${req.collection}:${req.id}`;
|
|
136
|
+
const dataKey = (req: EntryDocsRequest, mustContain?: string) => `coywolf-entry-data:${req.collection}:${req.id}:${mustContain ?? ""}`;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Read an entry's docs (and data), each at most once per request: callers
|
|
140
|
+
* that ask for the same entry (the page:metadata hook run more than once,
|
|
141
|
+
* modules' own hooks) share the reads. What isn't read yet in this request
|
|
142
|
+
* is one D1 batch when the site database is bound; otherwise the plugin
|
|
143
|
+
* context's reads. `mustContain` (optional): only JSON columns containing it
|
|
144
|
+
* are parsed from a D1 row.
|
|
145
|
+
*/
|
|
146
|
+
export async function readEntryDocs(ctx: PluginContext, req: EntryDocsRequest, mustContain?: string): Promise<EntryDocs> {
|
|
147
|
+
const known = new Map<string, Promise<unknown>>();
|
|
148
|
+
const missing: string[] = [];
|
|
149
|
+
for (const c of req.collections) {
|
|
150
|
+
const hit = peekRequestMemo<unknown>(docKey(req, c));
|
|
151
|
+
if (hit) known.set(c, hit);
|
|
152
|
+
else missing.push(c);
|
|
153
|
+
}
|
|
154
|
+
const dataHit = req.data ? peekRequestMemo<Record<string, unknown> | null>(dataKey(req, mustContain)) : undefined;
|
|
155
|
+
const wantData = Boolean(req.data && !dataHit);
|
|
156
|
+
let data: Promise<Record<string, unknown> | null> | undefined = dataHit;
|
|
157
|
+
if (missing.length || wantData) {
|
|
158
|
+
const read = readUncached(ctx, { ...req, collections: missing, data: wantData }, mustContain);
|
|
159
|
+
for (const c of missing) {
|
|
160
|
+
const one = read.then((d) => d.docs.get(c) ?? null);
|
|
161
|
+
seedRequestMemo(docKey(req, c), one);
|
|
162
|
+
known.set(c, one);
|
|
163
|
+
}
|
|
164
|
+
if (wantData) {
|
|
165
|
+
data = read.then((d) => d.data ?? null);
|
|
166
|
+
seedRequestMemo(dataKey(req, mustContain), data);
|
|
167
|
+
}
|
|
168
|
+
await read;
|
|
169
|
+
}
|
|
170
|
+
const collections = [...known];
|
|
171
|
+
const values = await Promise.all(collections.map(([, v]) => v));
|
|
172
|
+
const out: EntryDocs = { docs: new Map(collections.map(([c], i) => [c, values[i]])) };
|
|
173
|
+
if (req.data) out.data = (await data) ?? null;
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Routes behind the Features admin page: list every feature with its state,
|
|
3
|
+
* and save the switches.
|
|
4
|
+
*/
|
|
5
|
+
import { definePluginRoute } from "emdash";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
import { parseInput } from "../shared.js";
|
|
9
|
+
import { ensureTasks } from "./compose.js";
|
|
10
|
+
import { FEATURES_SETTING, type FeatureMap, ctxFeatures, featureCatalog, invalidateFeatures } from "./features.js";
|
|
11
|
+
import type { PackModule, TaskDef } from "./module.js";
|
|
12
|
+
|
|
13
|
+
export function featuresRoutes(modules: PackModule[], tasks: TaskDef[]) {
|
|
14
|
+
return {
|
|
15
|
+
"features/list": {
|
|
16
|
+
permission: "plugins:manage" as const,
|
|
17
|
+
// biome-ignore lint/suspicious/noExplicitAny: plugin context.
|
|
18
|
+
handler: async (ctx: any) => {
|
|
19
|
+
await ensureTasks(ctx, tasks);
|
|
20
|
+
const state = await ctxFeatures(ctx);
|
|
21
|
+
const known = new Set(featureCatalog().map((f) => f.id));
|
|
22
|
+
return {
|
|
23
|
+
modules: modules.map((m) => ({
|
|
24
|
+
id: m.id,
|
|
25
|
+
label: m.label,
|
|
26
|
+
features: m.features.filter((f) => known.has(f.id)).map((f) => ({ ...f, enabled: state[f.id] ?? false })),
|
|
27
|
+
})),
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
"features/save": definePluginRoute({
|
|
33
|
+
permission: "plugins:manage",
|
|
34
|
+
methods: ["POST"],
|
|
35
|
+
request: { body: "json" },
|
|
36
|
+
handler: async (ctx) => {
|
|
37
|
+
const { features } = parseInput(z.object({ features: z.record(z.string(), z.boolean()) }), ctx.input);
|
|
38
|
+
const known = new Set(featureCatalog().map((f) => f.id));
|
|
39
|
+
const stored = ((await ctx.settings.get<FeatureMap>(FEATURES_SETTING)) ?? {}) as FeatureMap;
|
|
40
|
+
for (const [id, on] of Object.entries(features)) if (known.has(id)) stored[id] = on;
|
|
41
|
+
await ctx.settings.set(FEATURES_SETTING, stored);
|
|
42
|
+
invalidateFeatures();
|
|
43
|
+
ctx.log.info("Features saved", stored);
|
|
44
|
+
return { features: await ctxFeatures(ctx) };
|
|
45
|
+
},
|
|
46
|
+
}),
|
|
47
|
+
};
|
|
48
|
+
}
|