caracms 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +57 -2
- package/bin/caracms.mjs +174 -0
- package/bin/gate.mjs +275 -0
- package/bin/init.mjs +712 -0
- package/bin/media.mjs +662 -0
- package/bin/site-runner.mjs +61 -0
- package/bin/templates/Page.astro.txt +31 -0
- package/bin/templates/TextBlock.astro.txt +13 -0
- package/bin/templates/caracms.config.ts.txt +22 -0
- package/bin/templates/catch-all.astro.txt +25 -0
- package/bin/templates/health.ts.txt +6 -0
- package/bin/templates/railway.ts.txt +147 -0
- package/package.json +66 -4
- package/skills/caracms-adopt/SKILL.md +508 -0
- package/skills/caracms-adopt/look-same.mjs +957 -0
- package/skills/caracms-blocks/SKILL.md +376 -0
- package/skills/caracms-content/SKILL.md +201 -0
- package/skills/caracms-tina/SKILL.md +427 -0
- package/skills/caracms-tina/field-kinds.md +88 -0
- package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
- package/src/block-runs.ts +84 -0
- package/src/cards.ts +158 -0
- package/src/cli/schemas.ts +122 -0
- package/src/cli/site.ts +66 -0
- package/src/cli/validate.ts +471 -0
- package/src/code-routes-disk.ts +67 -0
- package/src/code-routes.ts +134 -0
- package/src/columns.ts +39 -0
- package/src/components/Block.astro +46 -0
- package/src/components/BlockRunTree.astro +45 -0
- package/src/components/Blocks.astro +139 -0
- package/src/components/Buttons.astro +116 -0
- package/src/components/Fonts.astro +14 -0
- package/src/components/Form.astro +221 -0
- package/src/components/Head.astro +62 -0
- package/src/components/Link.astro +68 -0
- package/src/components/Lockup.astro +116 -0
- package/src/components/Media.astro +124 -0
- package/src/components/MediaPlayer.astro +102 -0
- package/src/components/RenderDocument.astro +64 -0
- package/src/components/RichText.astro +59 -0
- package/src/components/Text.astro +81 -0
- package/src/components/attributes.ts +33 -0
- package/src/components/descriptor.ts +45 -0
- package/src/components/form-client.ts +182 -0
- package/src/components/media-player.ts +46 -0
- package/src/components/media-props.ts +35 -0
- package/src/components/sitemap.xml.ts +36 -0
- package/src/config.ts +825 -0
- package/src/content-schema.ts +166 -0
- package/src/content.ts +422 -0
- package/src/document.ts +579 -0
- package/src/editor/README.md +405 -0
- package/src/editor/addresses.ts +61 -0
- package/src/editor/brand.ts +118 -0
- package/src/editor/client/block-list/BlockList.tsx +342 -0
- package/src/editor/client/block-list/Picker.tsx +105 -0
- package/src/editor/client/block-list/UnknownBlock.tsx +119 -0
- package/src/editor/client/block-list/insert-points.ts +56 -0
- package/src/editor/client/block-list/link.ts +71 -0
- package/src/editor/client/block-list/reorder.ts +105 -0
- package/src/editor/client/block-list/summary.ts +118 -0
- package/src/editor/client/collections/CollectionGrid.tsx +143 -0
- package/src/editor/client/collections/CollectionTable.tsx +406 -0
- package/src/editor/client/collections/table.ts +270 -0
- package/src/editor/client/context.tsx +27 -0
- package/src/editor/client/documents/DocMenu.tsx +154 -0
- package/src/editor/client/documents/actions.ts +192 -0
- package/src/editor/client/documents/dialogs.tsx +687 -0
- package/src/editor/client/editor.ts +995 -0
- package/src/editor/client/fields/ButtonsField.tsx +87 -0
- package/src/editor/client/fields/DateField.tsx +200 -0
- package/src/editor/client/fields/FieldControl.tsx +61 -0
- package/src/editor/client/fields/FieldFrame.tsx +109 -0
- package/src/editor/client/fields/GroupField.tsx +113 -0
- package/src/editor/client/fields/HiddenToggle.tsx +51 -0
- package/src/editor/client/fields/LinkField.tsx +307 -0
- package/src/editor/client/fields/ListField.tsx +642 -0
- package/src/editor/client/fields/LockupField.tsx +365 -0
- package/src/editor/client/fields/MediaField.tsx +823 -0
- package/src/editor/client/fields/PageFields.tsx +82 -0
- package/src/editor/client/fields/ReferenceField.tsx +209 -0
- package/src/editor/client/fields/RichTextField.tsx +306 -0
- package/src/editor/client/fields/SelectField.tsx +63 -0
- package/src/editor/client/fields/SeoGroup.tsx +282 -0
- package/src/editor/client/fields/SimpleFields.tsx +89 -0
- package/src/editor/client/fields/TextField.tsx +51 -0
- package/src/editor/client/fields/date.ts +73 -0
- package/src/editor/client/fields/host.tsx +172 -0
- package/src/editor/client/fields/list-summary.ts +85 -0
- package/src/editor/client/fields/lockup-rules.ts +181 -0
- package/src/editor/client/fields/parts.tsx +458 -0
- package/src/editor/client/fields/rich-text-commands.ts +183 -0
- package/src/editor/client/fields/rich-text-doc.ts +156 -0
- package/src/editor/client/fields/rich-text-schema.ts +224 -0
- package/src/editor/client/fields/stale.ts +58 -0
- package/src/editor/client/inbox/InboxStage.tsx +272 -0
- package/src/editor/client/inbox/store.ts +311 -0
- package/src/editor/client/live/format.ts +77 -0
- package/src/editor/client/live/marks.tsx +90 -0
- package/src/editor/client/live/notices.tsx +213 -0
- package/src/editor/client/live/publish.tsx +617 -0
- package/src/editor/client/live/session.tsx +162 -0
- package/src/editor/client/main.tsx +12 -0
- package/src/editor/client/media/LibraryStage.tsx +75 -0
- package/src/editor/client/media/ReplaceDialog.tsx +355 -0
- package/src/editor/client/media/store.ts +618 -0
- package/src/editor/client/nav/DocRow.tsx +122 -0
- package/src/editor/client/nav/search.ts +71 -0
- package/src/editor/client/nav/tree.ts +93 -0
- package/src/editor/client/overlays/OverlayMount.tsx +26 -0
- package/src/editor/client/overlays/editor-marks.ts +159 -0
- package/src/editor/client/overlays/index.ts +50 -0
- package/src/editor/client/overlays/layer.ts +437 -0
- package/src/editor/client/overlays/overlay-css.ts +177 -0
- package/src/editor/client/overlays/rich-text-bar.ts +717 -0
- package/src/editor/client/pane/Pane.tsx +91 -0
- package/src/editor/client/pane/levels/BlockLevel.tsx +123 -0
- package/src/editor/client/pane/levels/CollectionLevel.tsx +115 -0
- package/src/editor/client/pane/levels/DocumentLevel.tsx +93 -0
- package/src/editor/client/pane/levels/DocumentsLevel.tsx +390 -0
- package/src/editor/client/pane/levels/FileLevel.tsx +132 -0
- package/src/editor/client/pane/levels/FormLevel.tsx +550 -0
- package/src/editor/client/pane/levels/MediaLevel.tsx +657 -0
- package/src/editor/client/pane/levels/SettingsM6.tsx +299 -0
- package/src/editor/client/pane/levels/SubmissionsLevel.tsx +185 -0
- package/src/editor/client/pane/parts.tsx +179 -0
- package/src/editor/client/pane/switcher.tsx +165 -0
- package/src/editor/client/preview/driver.ts +640 -0
- package/src/editor/client/preview/field-text.ts +71 -0
- package/src/editor/client/preview/follow-links.ts +234 -0
- package/src/editor/client/preview/layout.ts +37 -0
- package/src/editor/client/preview/link-target.ts +50 -0
- package/src/editor/client/preview/markers.ts +86 -0
- package/src/editor/client/preview/page-edit.ts +632 -0
- package/src/editor/client/preview/page-editor-registry.ts +63 -0
- package/src/editor/client/preview/page-editor.ts +235 -0
- package/src/editor/client/preview/render-sequencer.ts +113 -0
- package/src/editor/client/preview/selection.ts +116 -0
- package/src/editor/client/preview/warnings.ts +61 -0
- package/src/editor/client/shell/App.tsx +101 -0
- package/src/editor/client/shell/Bar.tsx +197 -0
- package/src/editor/client/shell/Brand.tsx +33 -0
- package/src/editor/client/shell/Icons.tsx +87 -0
- package/src/editor/client/shell/NarrowNote.tsx +47 -0
- package/src/editor/client/shell/Popovers.tsx +47 -0
- package/src/editor/client/shell/Stage.tsx +132 -0
- package/src/editor/client/shell/Toast.tsx +61 -0
- package/src/editor/client/state/document-store.ts +465 -0
- package/src/editor/client/state/keeper.ts +713 -0
- package/src/editor/client/state/live.ts +257 -0
- package/src/editor/client/state/schema.ts +42 -0
- package/src/editor/client/state/store.ts +39 -0
- package/src/editor/fonts/OFL-GeistMono.txt +93 -0
- package/src/editor/fonts/OFL-InstrumentSans.txt +93 -0
- package/src/editor/fonts/geist-mono-latin-ext-wght-normal.woff2 +0 -0
- package/src/editor/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
- package/src/editor/fonts/instrument-sans-latin-ext-wght-normal.woff2 +0 -0
- package/src/editor/fonts/instrument-sans-latin-wght-normal.woff2 +0 -0
- package/src/editor/middleware.ts +62 -0
- package/src/editor/routes/_live.ts +26 -0
- package/src/editor/routes/admin.astro +80 -0
- package/src/editor/routes/create.ts +19 -0
- package/src/editor/routes/delete.ts +19 -0
- package/src/editor/routes/document.ts +39 -0
- package/src/editor/routes/documents.ts +30 -0
- package/src/editor/routes/github.ts +23 -0
- package/src/editor/routes/history.ts +9 -0
- package/src/editor/routes/lock.ts +9 -0
- package/src/editor/routes/login/assets.d.ts +10 -0
- package/src/editor/routes/login/login.ts +43 -0
- package/src/editor/routes/media.ts +16 -0
- package/src/editor/routes/move.ts +17 -0
- package/src/editor/routes/notice.ts +9 -0
- package/src/editor/routes/preview.astro +77 -0
- package/src/editor/routes/publish.ts +9 -0
- package/src/editor/routes/status.ts +9 -0
- package/src/editor/routes/submissions.ts +16 -0
- package/src/editor/routes/try-again.ts +9 -0
- package/src/editor/routes/write.ts +18 -0
- package/src/editor/server/admin-props.ts +151 -0
- package/src/editor/server/cards.ts +61 -0
- package/src/editor/server/create.ts +414 -0
- package/src/editor/server/drafts-store.ts +127 -0
- package/src/editor/server/media/alt.ts +107 -0
- package/src/editor/server/media/bucket.ts +295 -0
- package/src/editor/server/media/config.ts +78 -0
- package/src/editor/server/media/handler.ts +221 -0
- package/src/editor/server/media/library.ts +254 -0
- package/src/editor/server/media/memory.ts +184 -0
- package/src/editor/server/media/mux.ts +241 -0
- package/src/editor/server/media/sniff.ts +599 -0
- package/src/editor/server/media/upload.ts +175 -0
- package/src/editor/server/mode.ts +63 -0
- package/src/editor/server/move.ts +295 -0
- package/src/editor/server/render.tsx +9 -0
- package/src/editor/server/replace.ts +72 -0
- package/src/editor/server/settings.ts +35 -0
- package/src/editor/server/write.ts +366 -0
- package/src/editor/styles/editor.css +502 -0
- package/src/editor/styles/fonts.css +40 -0
- package/src/editor/styles/levels/README.md +4 -0
- package/src/editor/styles/levels/collections.css +99 -0
- package/src/editor/styles/levels/document-level.css +10 -0
- package/src/editor/styles/levels/documents.css +9 -0
- package/src/editor/styles/levels/fields.css +171 -0
- package/src/editor/styles/levels/files.css +95 -0
- package/src/editor/styles/levels/live.css +10 -0
- package/src/editor/styles/levels/m5a-documents.css +51 -0
- package/src/editor/styles/levels/m6.css +82 -0
- package/src/editor/styles/levels/media.css +175 -0
- package/src/editor/styles/levels/switcher.css +3 -0
- package/src/editor/styles/tokens.css +114 -0
- package/src/editor/tsconfig.json +16 -0
- package/src/editor/types.ts +881 -0
- package/src/editor/ui-state.ts +113 -0
- package/src/email.ts +67 -0
- package/src/fields.ts +1129 -0
- package/src/files-route.ts +201 -0
- package/src/files.ts +116 -0
- package/src/forms/email.ts +149 -0
- package/src/forms/endpoint.ts +347 -0
- package/src/forms/inbox.ts +273 -0
- package/src/forms/rate-limit.ts +130 -0
- package/src/forms/route.ts +24 -0
- package/src/forms/rules.ts +278 -0
- package/src/forms/store.ts +486 -0
- package/src/head.ts +125 -0
- package/src/index.ts +434 -0
- package/src/json-source.ts +116 -0
- package/src/links.ts +121 -0
- package/src/live/README.md +43 -0
- package/src/live/auth/allow-rules.ts +70 -0
- package/src/live/auth/allow.ts +128 -0
- package/src/live/auth/auth.ts +155 -0
- package/src/live/auth/email.ts +84 -0
- package/src/live/auth/guard.ts +111 -0
- package/src/live/auth/index.ts +68 -0
- package/src/live/auth/links.ts +101 -0
- package/src/live/auth/login.ts +245 -0
- package/src/live/auth/middleware.ts +53 -0
- package/src/live/auth/request.ts +56 -0
- package/src/live/auth/routes.ts +9 -0
- package/src/live/auth/session.ts +181 -0
- package/src/live/auth/views.ts +355 -0
- package/src/live/autosaves.ts +306 -0
- package/src/live/backend.ts +652 -0
- package/src/live/branches.ts +19 -0
- package/src/live/drafts-store.ts +591 -0
- package/src/live/editor.ts +465 -0
- package/src/live/git.ts +1021 -0
- package/src/live/github.ts +271 -0
- package/src/live/locals.ts +24 -0
- package/src/live/locks.ts +273 -0
- package/src/live/merge.ts +197 -0
- package/src/live/messages.ts +151 -0
- package/src/live/mode.ts +210 -0
- package/src/live/redis.ts +533 -0
- package/src/live/runtime.ts +164 -0
- package/src/live/status.ts +109 -0
- package/src/live/sweep.ts +228 -0
- package/src/live/timings.ts +47 -0
- package/src/live/webhook.ts +39 -0
- package/src/lockup-css.ts +58 -0
- package/src/measure.ts +145 -0
- package/src/media-value.ts +239 -0
- package/src/media.ts +255 -0
- package/src/read.ts +256 -0
- package/src/redirect-middleware.ts +49 -0
- package/src/redirects.ts +76 -0
- package/src/relink.ts +293 -0
- package/src/reserved.ts +24 -0
- package/src/rich-text.ts +690 -0
- package/src/schema-key.ts +63 -0
- package/src/seo.ts +124 -0
- package/src/serialize.ts +697 -0
- package/src/skipped.ts +35 -0
- package/src/slug.ts +121 -0
- package/src/text-styles.ts +62 -0
- package/src/virtual.d.ts +28 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Images with no alt text in a document (M5b Decision 9's server half): what Publish's
|
|
3
|
+
* summary names for each waiting document (`WaitingView.missingAlt`). It warns and never
|
|
4
|
+
* blocks.
|
|
5
|
+
*
|
|
6
|
+
* - **Read from the text it's given**: the caller passes the document as it will publish
|
|
7
|
+
* (live/editor.ts: the autosave's text, else the `drafts` copy's).
|
|
8
|
+
* - **Only rendered slots count**: a known block's media fields (a page's, or a shared
|
|
9
|
+
* block's own document), a fields document's or Site Settings' own fields, a section
|
|
10
|
+
* builder's page fields (M6c G2). Not an
|
|
11
|
+
* unknown block, nor a page's shared entry (its shared block lists its own images when
|
|
12
|
+
* it's waiting), nor a value of a kind the slot doesn't take (it isn't shown), nor the
|
|
13
|
+
* brand's logo and icon (Decision 11: they have no alt; the brand's name is it).
|
|
14
|
+
* - **A decorative image never counts.** The rule is the field's own warning
|
|
15
|
+
* (fields.ts → `fieldWarnings`, code `missing-alt`), so the two never disagree, and its
|
|
16
|
+
* list indexes are the editor's rows.
|
|
17
|
+
* - **The slot's label** (Decision 9): the block type's name plus "image" ("Hero image",
|
|
18
|
+
* "Text and media image"), with its position in a list ("Gallery image 3", and in an inner list "Partners image 2, logos 1"); in a fields
|
|
19
|
+
* document, the field's own label ("Cover image"). A block with more than one media
|
|
20
|
+
* field says which ("Hero background").
|
|
21
|
+
*/
|
|
22
|
+
import type { ResolvedCollection, ResolvedSite } from '../../../config.ts';
|
|
23
|
+
import { own, parseAnyDocument } from '../../../document.ts';
|
|
24
|
+
import { fieldAt, fieldHoldsMedia, fieldWarnings, isIndexPart, splitPath, titleCase, type Field, type Fields } from '../../../fields.ts';
|
|
25
|
+
import { parseSource } from '../../../json-source.ts';
|
|
26
|
+
import type { MissingAltView } from '../../types.ts';
|
|
27
|
+
|
|
28
|
+
type Site = Pick<ResolvedSite, 'blocks' | 'collections'>;
|
|
29
|
+
|
|
30
|
+
const labelOf = (key: string, field: Field) => field.label?.trim() || titleCase(key);
|
|
31
|
+
const lowerFirst = (s: string) => s.charAt(0).toLowerCase() + s.slice(1);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The list rows a path is in, outermost first: the index after each list on the way down
|
|
35
|
+
* (`gallery.2.alt` → [2], through a group too: `left.gallery.0` → [0]; M7, an inner list:
|
|
36
|
+
* `partners.1.logos.0.alt` → [1, 0]), each with its list's field.
|
|
37
|
+
*/
|
|
38
|
+
function rowsOf(fields: Fields, path: string): { row: number; list: Field; key: string }[] {
|
|
39
|
+
const parts = splitPath(path);
|
|
40
|
+
const out: { row: number; list: Field; key: string }[] = [];
|
|
41
|
+
for (let i = 1; i < parts.length; i++) {
|
|
42
|
+
const field = fieldAt(fields, parts.slice(0, i).join('.'));
|
|
43
|
+
if (field?.kind === 'list' && isIndexPart(parts[i]!)) out.push({ row: Number(parts[i]), list: field, key: parts[i - 1]! });
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The images with no alt text in one form's fields (a block's, or a fields document's). */
|
|
49
|
+
function slots(fields: Fields, data: Record<string, unknown>, block: number | null, owner: string | null): MissingAltView[] {
|
|
50
|
+
const mediaFields = Object.entries(fields).filter(([, f]) => fieldHoldsMedia(f));
|
|
51
|
+
const out: MissingAltView[] = [];
|
|
52
|
+
for (const [key, field] of mediaFields) {
|
|
53
|
+
let base: string;
|
|
54
|
+
if (owner !== null) base = mediaFields.length === 1 ? `${owner} image` : `${owner} ${lowerFirst(labelOf(key, field))}`;
|
|
55
|
+
else {
|
|
56
|
+
const label = labelOf(key, field);
|
|
57
|
+
base = /\bimage\b/i.test(label) ? label : `${label} image`;
|
|
58
|
+
}
|
|
59
|
+
for (const warning of fieldWarnings(field, data[key])) {
|
|
60
|
+
if (warning.code !== 'missing-alt') continue;
|
|
61
|
+
const path = `${key}.${warning.path}`;
|
|
62
|
+
const [outer, inner] = rowsOf(fields, path);
|
|
63
|
+
// M7: an inner list's image adds its own list and row ("Partners image 2, logos 1").
|
|
64
|
+
const label = !outer ? base : `${base} ${outer.row + 1}${inner ? `, ${lowerFirst(labelOf(inner.key, inner.list))} ${inner.row + 1}` : ''}`;
|
|
65
|
+
out.push({ block, field: key, path, row: outer ? outer.row : null, label });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Every image with no alt text that isn't decorative, in a document's text (any kind),
|
|
73
|
+
* in document order. Empty for text that isn't a document (not JSON, a file no
|
|
74
|
+
* collection holds).
|
|
75
|
+
*/
|
|
76
|
+
export function missingAltIn(site: Site, collectionName: string | null, text: string | null): MissingAltView[] {
|
|
77
|
+
if (!collectionName || text === null) return [];
|
|
78
|
+
const collection: ResolvedCollection | undefined = own(site.collections, collectionName);
|
|
79
|
+
if (!collection) return [];
|
|
80
|
+
let raw: unknown;
|
|
81
|
+
try {
|
|
82
|
+
raw = parseSource(text).value;
|
|
83
|
+
} catch {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
const document = parseAnyDocument(raw, site, collection);
|
|
87
|
+
switch (document.kind) {
|
|
88
|
+
case 'fields':
|
|
89
|
+
return slots(collection.fields ?? {}, document.data, null, null);
|
|
90
|
+
case 'settings':
|
|
91
|
+
// Only the site's own fields: `brand` (the logo and the icon) isn't one of them.
|
|
92
|
+
return slots(collection.fields ?? {}, document.data, null, null);
|
|
93
|
+
case 'shared': {
|
|
94
|
+
const block = document.block;
|
|
95
|
+
const schema = block.known ? own(site.blocks, block.type) : undefined;
|
|
96
|
+
return schema ? slots(schema.fields, block.data, 0, schema.label) : [];
|
|
97
|
+
}
|
|
98
|
+
default:
|
|
99
|
+
// M6c (G2): the page's own fields first (they come before the blocks), `block` null as a form's.
|
|
100
|
+
return [...slots(collection.fields ?? {}, document.data ?? {}, null, null), ...document.blocks.flatMap((block, index) => {
|
|
101
|
+
// An unknown block, or a shared entry (its own document carries its images), isn't counted here.
|
|
102
|
+
if (!block.known || block.shared !== undefined) return [];
|
|
103
|
+
const schema = own(site.blocks, block.type);
|
|
104
|
+
return schema ? slots(schema.fields, block.data, index, schema.label) : [];
|
|
105
|
+
})];
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The media bucket (M5b Decision 6), behind one small seam: list, head, get, put. **No
|
|
3
|
+
* delete and no overwrite anywhere** (Spec → Media): `put` is only ever called after the
|
|
4
|
+
* upload route has checked that nothing exists under the key's `uploads/<hash>/`.
|
|
5
|
+
*
|
|
6
|
+
* - **`s3Bucket`**: S3's REST API over `fetch`, signed with **aws4fetch** (a standard,
|
|
7
|
+
* dependency-free SigV4 signer, ~6 KB). Path-style requests (`<endpoint>/<bucket>/<key>`),
|
|
8
|
+
* which Railway's endpoint (`https://t3.storageapi.dev`, region `ams`) takes. The
|
|
9
|
+
* payload hash is signed (the route has the bytes in memory anyway).
|
|
10
|
+
* - **`MemoryBucket`**: the in-memory stand-in behind `CARACMS_MEDIA=memory` (memory.ts).
|
|
11
|
+
*
|
|
12
|
+
* The object metadata's names are fixed (M5b → Resolved decisions, Phase 0's script):
|
|
13
|
+
* `x-amz-meta-w`, `-h`, `-name` (URI-encoded, so any file name fits a header) and `-by`.
|
|
14
|
+
*/
|
|
15
|
+
import { AwsClient } from 'aws4fetch';
|
|
16
|
+
import type { BucketSettings } from './config.ts';
|
|
17
|
+
|
|
18
|
+
export interface BucketObject {
|
|
19
|
+
key: string;
|
|
20
|
+
/** ISO: S3's LastModified. */
|
|
21
|
+
lastModified: string | null;
|
|
22
|
+
size: number | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** An object's metadata, as stored (strings decoded). */
|
|
26
|
+
export interface ObjectMeta {
|
|
27
|
+
w: number | null;
|
|
28
|
+
h: number | null;
|
|
29
|
+
/** The original file name, or null. */
|
|
30
|
+
name: string | null;
|
|
31
|
+
/** Who uploaded it, or null (local mode). */
|
|
32
|
+
by: string | null;
|
|
33
|
+
contentType: string | null;
|
|
34
|
+
lastModified: string | null;
|
|
35
|
+
size: number | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface PutObject {
|
|
39
|
+
key: string;
|
|
40
|
+
body: Uint8Array;
|
|
41
|
+
contentType: string;
|
|
42
|
+
/** Hex SHA-256 of `body` (the route has it already). */
|
|
43
|
+
sha256: string;
|
|
44
|
+
/** `w` and `h` are an image's; a file (M6c G4) has neither. */
|
|
45
|
+
meta: { w?: number; h?: number; name: string; by: string | null };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** An object's bytes, streamed (M6c G4: the public file route), and its length when the bucket says. */
|
|
49
|
+
export interface ObjectBody {
|
|
50
|
+
body: ReadableStream<Uint8Array>;
|
|
51
|
+
size: number | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface Bucket {
|
|
55
|
+
/** Every object under `prefix`, following S3's continuation tokens. */
|
|
56
|
+
list(prefix: string): Promise<BucketObject[]>;
|
|
57
|
+
/** The first key under `prefix`, or null: the upload's existence check. */
|
|
58
|
+
first(prefix: string): Promise<string | null>;
|
|
59
|
+
/** An object's metadata, or null when there's no such object. */
|
|
60
|
+
head(key: string): Promise<ObjectMeta | null>;
|
|
61
|
+
/**
|
|
62
|
+
* An object's bytes as a stream, or null when there's no such object. Only the public
|
|
63
|
+
* file route (files-route.ts) calls it, and only for a key its allow-list passed.
|
|
64
|
+
*/
|
|
65
|
+
get(key: string): Promise<ObjectBody | null>;
|
|
66
|
+
/** Write a new object. Never called for a key that exists. */
|
|
67
|
+
put(object: PutObject): Promise<void>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A bucket call that failed: its message is for the server log, never the browser. */
|
|
71
|
+
export class BucketError extends Error {
|
|
72
|
+
/** The bucket's HTTP status when it answered with a refusal; null when it couldn't be reached. */
|
|
73
|
+
readonly status: number | null;
|
|
74
|
+
constructor(message: string, status: number | null = null) {
|
|
75
|
+
super(message);
|
|
76
|
+
this.name = 'BucketError';
|
|
77
|
+
this.status = status;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const metaNumber = (v: string | null | undefined) => {
|
|
82
|
+
const n = Number(v);
|
|
83
|
+
return v && Number.isFinite(n) && n > 0 ? Math.round(n) : null;
|
|
84
|
+
};
|
|
85
|
+
const decodeName = (v: string | null | undefined) => {
|
|
86
|
+
if (!v) return null;
|
|
87
|
+
try {
|
|
88
|
+
return decodeURIComponent(v);
|
|
89
|
+
} catch {
|
|
90
|
+
return v;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const XML_ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
|
|
95
|
+
const unxml = (s: string) =>
|
|
96
|
+
s.replace(/&(#x[0-9a-fA-F]+|#\d+|amp|lt|gt|quot|apos);/g, (_, e: string) =>
|
|
97
|
+
e[0] === '#' ? String.fromCodePoint(e[1] === 'x' ? parseInt(e.slice(2), 16) : Number(e.slice(1))) : XML_ENTITIES[e]!,
|
|
98
|
+
);
|
|
99
|
+
const tag = (xml: string, name: string) => {
|
|
100
|
+
const m = new RegExp(`<${name}>([\\s\\S]*?)</${name}>`).exec(xml);
|
|
101
|
+
return m ? unxml(m[1]!) : null;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const S3_TIMEOUT_MS = 20_000;
|
|
105
|
+
/** A runaway-token guard: 1000 pages of 1000 is far past one site's library. */
|
|
106
|
+
const MAX_PAGES = 1000;
|
|
107
|
+
|
|
108
|
+
export function s3Bucket(settings: BucketSettings, fetchImpl: typeof fetch = (...args) => globalThis.fetch(...args)): Bucket {
|
|
109
|
+
const client = new AwsClient({ accessKeyId: settings.accessKeyId, secretAccessKey: settings.secretAccessKey, service: 's3', region: settings.region });
|
|
110
|
+
const base = `${settings.endpoint}/${encodeURIComponent(settings.bucket)}`;
|
|
111
|
+
const objectUrl = (key: string) => `${base}/${key.split('/').map(encodeURIComponent).join('/')}`;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* One signed request. The timeout covers the whole exchange, except with `stream`
|
|
115
|
+
* (`get`'s body, up to 40 MB to a slow visitor), where it covers only the wait for the
|
|
116
|
+
* bucket's headers.
|
|
117
|
+
*/
|
|
118
|
+
async function call(method: string, url: string, init: { headers?: Record<string, string>; body?: Uint8Array; stream?: boolean } = {}): Promise<Response> {
|
|
119
|
+
const request = await client.sign(url, {
|
|
120
|
+
method,
|
|
121
|
+
headers: { 'X-Amz-Content-Sha256': init.headers?.['X-Amz-Content-Sha256'] ?? 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', ...init.headers },
|
|
122
|
+
body: init.body as BodyInit | undefined,
|
|
123
|
+
});
|
|
124
|
+
const controller = init.stream ? new AbortController() : null;
|
|
125
|
+
const timer = controller ? setTimeout(() => controller.abort(new Error(`no answer in ${S3_TIMEOUT_MS / 1000} s`)), S3_TIMEOUT_MS) : null;
|
|
126
|
+
try {
|
|
127
|
+
return await fetchImpl(request, { signal: controller ? controller.signal : AbortSignal.timeout(S3_TIMEOUT_MS) });
|
|
128
|
+
} catch (error) {
|
|
129
|
+
throw new BucketError(`${method} ${new URL(url).pathname}: unreachable (${error instanceof Error ? error.message : String(error)})`);
|
|
130
|
+
} finally {
|
|
131
|
+
if (timer) clearTimeout(timer);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function page(prefix: string, token: string | null, max?: number) {
|
|
136
|
+
const query = new URLSearchParams({ 'list-type': '2', prefix });
|
|
137
|
+
if (max) query.set('max-keys', String(max));
|
|
138
|
+
if (token) query.set('continuation-token', token);
|
|
139
|
+
const res = await call('GET', `${base}?${query}`);
|
|
140
|
+
const text = await res.text();
|
|
141
|
+
if (!res.ok) throw new BucketError(`list ${prefix}: ${res.status} ${text.slice(0, 300)}`, res.status);
|
|
142
|
+
const objects: BucketObject[] = [];
|
|
143
|
+
for (const m of text.matchAll(/<Contents>([\s\S]*?)<\/Contents>/g)) {
|
|
144
|
+
const key = tag(m[1]!, 'Key');
|
|
145
|
+
if (!key) continue;
|
|
146
|
+
const modified = tag(m[1]!, 'LastModified');
|
|
147
|
+
const size = Number(tag(m[1]!, 'Size'));
|
|
148
|
+
objects.push({ key, lastModified: modified && !Number.isNaN(Date.parse(modified)) ? new Date(modified).toISOString() : null, size: Number.isFinite(size) ? size : null });
|
|
149
|
+
}
|
|
150
|
+
const truncated = tag(text, 'IsTruncated') === 'true';
|
|
151
|
+
return { objects, next: truncated ? tag(text, 'NextContinuationToken') : null };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
async list(prefix) {
|
|
156
|
+
const out: BucketObject[] = [];
|
|
157
|
+
const seen = new Set<string>();
|
|
158
|
+
let token: string | null = null;
|
|
159
|
+
for (let n = 0; n < MAX_PAGES; n++) {
|
|
160
|
+
const { objects, next } = await page(prefix, token);
|
|
161
|
+
out.push(...objects);
|
|
162
|
+
if (!next || seen.has(next)) break;
|
|
163
|
+
seen.add(next);
|
|
164
|
+
token = next;
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
},
|
|
168
|
+
async first(prefix) {
|
|
169
|
+
const { objects } = await page(prefix, null, 1);
|
|
170
|
+
return objects[0]?.key ?? null;
|
|
171
|
+
},
|
|
172
|
+
async head(key) {
|
|
173
|
+
const res = await call('HEAD', objectUrl(key));
|
|
174
|
+
if (res.status === 404) return null;
|
|
175
|
+
if (!res.ok) throw new BucketError(`head ${key}: ${res.status}`, res.status);
|
|
176
|
+
const h = res.headers;
|
|
177
|
+
const modified = h.get('last-modified');
|
|
178
|
+
const size = Number(h.get('content-length'));
|
|
179
|
+
return {
|
|
180
|
+
w: metaNumber(h.get('x-amz-meta-w')),
|
|
181
|
+
h: metaNumber(h.get('x-amz-meta-h')),
|
|
182
|
+
name: decodeName(h.get('x-amz-meta-name')),
|
|
183
|
+
by: decodeName(h.get('x-amz-meta-by')),
|
|
184
|
+
contentType: h.get('content-type'),
|
|
185
|
+
lastModified: modified && !Number.isNaN(Date.parse(modified)) ? new Date(modified).toISOString() : null,
|
|
186
|
+
size: Number.isFinite(size) ? size : null,
|
|
187
|
+
};
|
|
188
|
+
},
|
|
189
|
+
async get(key) {
|
|
190
|
+
const res = await call('GET', objectUrl(key), { stream: true });
|
|
191
|
+
if (res.status === 404) {
|
|
192
|
+
await res.body?.cancel().catch(() => {});
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
if (!res.ok || !res.body) {
|
|
196
|
+
const text = await res.text().catch(() => '');
|
|
197
|
+
throw new BucketError(`get ${key}: ${res.status} ${text.slice(0, 300)}`, res.status);
|
|
198
|
+
}
|
|
199
|
+
const size = Number(res.headers.get('content-length'));
|
|
200
|
+
return { body: res.body, size: res.headers.has('content-length') && Number.isFinite(size) ? size : null };
|
|
201
|
+
},
|
|
202
|
+
async put({ key, body, contentType, sha256, meta }) {
|
|
203
|
+
const headers: Record<string, string> = {
|
|
204
|
+
'Content-Type': contentType,
|
|
205
|
+
'X-Amz-Content-Sha256': sha256,
|
|
206
|
+
...(meta.w !== undefined ? { 'x-amz-meta-w': String(meta.w) } : {}),
|
|
207
|
+
...(meta.h !== undefined ? { 'x-amz-meta-h': String(meta.h) } : {}),
|
|
208
|
+
'x-amz-meta-name': encodeURIComponent(meta.name),
|
|
209
|
+
};
|
|
210
|
+
// An address as it is (Phase 0 wrote `setup` so); anything a header can't carry, URI-encoded as `name` is.
|
|
211
|
+
if (meta.by) headers['x-amz-meta-by'] = /^[\x21-\x7e]+$/.test(meta.by) && !meta.by.includes('%') ? meta.by : encodeURIComponent(meta.by);
|
|
212
|
+
const res = await call('PUT', objectUrl(key), { headers, body });
|
|
213
|
+
if (!res.ok) throw new BucketError(`put ${key}: ${res.status} ${(await res.text().catch(() => '')).slice(0, 300)}`, res.status);
|
|
214
|
+
await res.body?.cancel().catch(() => {});
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── The in-memory bucket (CARACMS_MEDIA=memory) ────────────────────────────────
|
|
220
|
+
|
|
221
|
+
export interface MemoryObject {
|
|
222
|
+
body: Uint8Array | null;
|
|
223
|
+
meta: ObjectMeta;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** A bucket in memory, for tests and local runs. `seed` puts objects in as Phase 0's script or another place would. */
|
|
227
|
+
export class MemoryBucket implements Bucket {
|
|
228
|
+
readonly objects = new Map<string, MemoryObject>();
|
|
229
|
+
/** Every call, in order (`list:<prefix>`, `head:<key>`, `get:<key>`, `put:<key>`), for tests that count them. */
|
|
230
|
+
readonly calls: string[] = [];
|
|
231
|
+
now: () => number = () => Date.now();
|
|
232
|
+
|
|
233
|
+
async list(prefix: string): Promise<BucketObject[]> {
|
|
234
|
+
this.calls.push(`list:${prefix}`);
|
|
235
|
+
return [...this.objects]
|
|
236
|
+
.filter(([key]) => key.startsWith(prefix))
|
|
237
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
238
|
+
.map(([key, o]) => ({ key, lastModified: o.meta.lastModified, size: o.meta.size }));
|
|
239
|
+
}
|
|
240
|
+
async first(prefix: string): Promise<string | null> {
|
|
241
|
+
this.calls.push(`first:${prefix}`);
|
|
242
|
+
return [...this.objects.keys()].filter((k) => k.startsWith(prefix)).sort()[0] ?? null;
|
|
243
|
+
}
|
|
244
|
+
async head(key: string): Promise<ObjectMeta | null> {
|
|
245
|
+
this.calls.push(`head:${key}`);
|
|
246
|
+
const o = this.objects.get(key);
|
|
247
|
+
return o ? { ...o.meta } : null;
|
|
248
|
+
}
|
|
249
|
+
/** A seeded object with no bytes (the library's tests seed most so) answers as missing. */
|
|
250
|
+
async get(key: string): Promise<ObjectBody | null> {
|
|
251
|
+
this.calls.push(`get:${key}`);
|
|
252
|
+
const bytes = this.objects.get(key)?.body;
|
|
253
|
+
if (!bytes) return null;
|
|
254
|
+
const copy = new Uint8Array(bytes);
|
|
255
|
+
return {
|
|
256
|
+
body: new ReadableStream<Uint8Array>({
|
|
257
|
+
start(controller) {
|
|
258
|
+
controller.enqueue(copy);
|
|
259
|
+
controller.close();
|
|
260
|
+
},
|
|
261
|
+
}),
|
|
262
|
+
size: copy.byteLength,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
async put({ key, body, contentType, meta }: PutObject): Promise<void> {
|
|
266
|
+
this.calls.push(`put:${key}`);
|
|
267
|
+
if (this.objects.has(key)) throw new BucketError(`put ${key}: the key exists, and nothing in caracms overwrites`);
|
|
268
|
+
this.objects.set(key, {
|
|
269
|
+
body: new Uint8Array(body),
|
|
270
|
+
meta: { w: meta.w ?? null, h: meta.h ?? null, name: meta.name, by: meta.by, contentType, lastModified: new Date(this.now()).toISOString(), size: body.byteLength },
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Put an object straight in (a test's or a browser test's library): no bytes needed.
|
|
275
|
+
* `uploadedAt` (ISO) orders the library; default now.
|
|
276
|
+
*/
|
|
277
|
+
seed(key: string, meta: { w?: number | null; h?: number | null; name?: string | null; by?: string | null; uploadedAt?: string; size?: number; contentType?: string } = {}, body: Uint8Array | null = null): void {
|
|
278
|
+
this.objects.set(key, {
|
|
279
|
+
body,
|
|
280
|
+
meta: {
|
|
281
|
+
w: meta.w ?? null,
|
|
282
|
+
h: meta.h ?? null,
|
|
283
|
+
name: meta.name ?? null,
|
|
284
|
+
by: meta.by ?? null,
|
|
285
|
+
contentType: meta.contentType ?? null,
|
|
286
|
+
lastModified: meta.uploadedAt ?? new Date(this.now()).toISOString(),
|
|
287
|
+
size: meta.size ?? body?.byteLength ?? null,
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
clear(): void {
|
|
292
|
+
this.objects.clear();
|
|
293
|
+
this.calls.length = 0;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the media routes are set up (M5b Decision 5), images and clips apart: **one
|
|
3
|
+
* missing never disables the other**. Read from `process.env` on each request (cheap),
|
|
4
|
+
* so `astro build` needs none of them and the Mux token is first read by the first
|
|
5
|
+
* request that needs it.
|
|
6
|
+
*
|
|
7
|
+
* - **`CARACMS_MEDIA=memory` always wins**: the in-memory bucket and the fake Mux
|
|
8
|
+
* (memory.ts), whatever else is set, so nothing in a test can touch the real bucket or
|
|
9
|
+
* Mux. **Refused on a production server**, as `CARACMS_GITHUB=memory` is.
|
|
10
|
+
* - Images need imgproxy's (`CARACMS_MEDIA_URL`, `IMGPROXY_KEY`, `IMGPROXY_SALT`, which
|
|
11
|
+
* sign the thumbnails) and the bucket's (`S3_BUCKET`, `S3_ENDPOINT`, `S3_REGION`,
|
|
12
|
+
* `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`). Clips need `MUX_TOKEN_ID` and `MUX_TOKEN_SECRET`.
|
|
13
|
+
*/
|
|
14
|
+
import { isProductionBuild, readMediaConfig, type MediaSettings } from '../../../media.ts';
|
|
15
|
+
|
|
16
|
+
type Env = Record<string, string | undefined>;
|
|
17
|
+
|
|
18
|
+
export interface BucketSettings {
|
|
19
|
+
endpoint: string;
|
|
20
|
+
region: string;
|
|
21
|
+
bucket: string;
|
|
22
|
+
accessKeyId: string;
|
|
23
|
+
secretAccessKey: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type ImagesConfig =
|
|
27
|
+
| { kind: 'memory'; signing: MediaSettings }
|
|
28
|
+
| { kind: 's3'; signing: MediaSettings; bucket: BucketSettings }
|
|
29
|
+
| { kind: 'unset'; message: string; missing: string[] };
|
|
30
|
+
|
|
31
|
+
export type MuxConfig = { kind: 'memory' } | { kind: 'mux'; tokenId: string; tokenSecret: string } | { kind: 'unset'; message: string; missing: string[] };
|
|
32
|
+
|
|
33
|
+
export const MEMORY_IN_PRODUCTION = 'CARACMS_MEDIA=memory is for tests and local runs; a production server never uses the in-memory media.';
|
|
34
|
+
|
|
35
|
+
const list = (names: string[]) => (names.length <= 1 ? names.join('') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`);
|
|
36
|
+
const get = (env: Env, name: string) => {
|
|
37
|
+
const v = env[name]?.trim();
|
|
38
|
+
return v ? v : null;
|
|
39
|
+
};
|
|
40
|
+
const memory = (env: Env) => get(env, 'CARACMS_MEDIA') === 'memory';
|
|
41
|
+
|
|
42
|
+
/** The image routes' settings: the bucket, and imgproxy's for the thumbnails. */
|
|
43
|
+
export function readImagesConfig(env: Env = process.env, production = isProductionBuild()): ImagesConfig {
|
|
44
|
+
if (memory(env)) {
|
|
45
|
+
if (production) return { kind: 'unset', message: MEMORY_IN_PRODUCTION, missing: [] };
|
|
46
|
+
const signing = readMediaConfig(env, production);
|
|
47
|
+
if (signing.kind !== 'ready') return { kind: 'unset', message: MEMORY_IN_PRODUCTION, missing: [] };
|
|
48
|
+
return { kind: 'memory', signing: signing.settings };
|
|
49
|
+
}
|
|
50
|
+
const signing = readMediaConfig(env, production);
|
|
51
|
+
const missing = signing.kind === 'unset' ? [...signing.missing] : [];
|
|
52
|
+
const problems = signing.kind === 'unset' ? [...signing.problems] : [];
|
|
53
|
+
const need = (name: string) => {
|
|
54
|
+
const v = get(env, name);
|
|
55
|
+
if (!v && !missing.includes(name)) missing.push(name);
|
|
56
|
+
return v ?? '';
|
|
57
|
+
};
|
|
58
|
+
const endpoint = need('S3_ENDPOINT').replace(/\/+$/, '');
|
|
59
|
+
const region = need('S3_REGION');
|
|
60
|
+
const accessKeyId = need('S3_ACCESS_KEY_ID');
|
|
61
|
+
const secretAccessKey = need('S3_SECRET_ACCESS_KEY');
|
|
62
|
+
if (endpoint && !/^https?:\/\/[^/]+$/.test(endpoint)) problems.push(`S3_ENDPOINT must be an origin like https://t3.storageapi.dev (got "${endpoint}").`);
|
|
63
|
+
if (missing.length || problems.length || signing.kind !== 'ready') {
|
|
64
|
+
const sentence = missing.length ? `The media library isn't set up here: set ${list(missing)} to the site's media bucket and imgproxy.` : '';
|
|
65
|
+
return { kind: 'unset', message: [sentence, ...problems].filter(Boolean).join(' '), missing };
|
|
66
|
+
}
|
|
67
|
+
return { kind: 's3', signing: signing.settings, bucket: { endpoint, region, bucket: signing.settings.bucket, accessKeyId, secretAccessKey } };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The clip routes' settings: the site's Mux environment's token. */
|
|
71
|
+
export function readMuxConfig(env: Env = process.env, production = isProductionBuild()): MuxConfig {
|
|
72
|
+
if (memory(env)) return production ? { kind: 'unset', message: MEMORY_IN_PRODUCTION, missing: [] } : { kind: 'memory' };
|
|
73
|
+
const tokenId = get(env, 'MUX_TOKEN_ID');
|
|
74
|
+
const tokenSecret = get(env, 'MUX_TOKEN_SECRET');
|
|
75
|
+
const missing = [...(tokenId ? [] : ['MUX_TOKEN_ID']), ...(tokenSecret ? [] : ['MUX_TOKEN_SECRET'])];
|
|
76
|
+
if (missing.length) return { kind: 'unset', message: `Video isn't set up here: set ${list(missing)} to the site's Mux environment's token.`, missing };
|
|
77
|
+
return { kind: 'mux', tokenId: tokenId!, tokenSecret: tokenSecret! };
|
|
78
|
+
}
|