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,80 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* /admin and /admin/<collection>/<path> (addresses, Decision 19). Injected in dev and
|
|
4
|
+
* in the build (M4 Decision 1); live mode needs a session (Phase 3's guard).
|
|
5
|
+
*
|
|
6
|
+
* The shell is React, rendered here on the server (Decision 2), so the chrome is in
|
|
7
|
+
* the HTML before any script runs: the pane with the Pages groups as the cookie
|
|
8
|
+
* says (Decision 20), the stage with the iframes already loading, the bar. The
|
|
9
|
+
* browser then hydrates it (client/main.tsx) from the same props.
|
|
10
|
+
*
|
|
11
|
+
* Content is read now (Decision 5), through the mode's drafts store (server/mode.ts):
|
|
12
|
+
* the disk in local mode, the `drafts` copy with autosaves on top in live mode.
|
|
13
|
+
*/
|
|
14
|
+
import '../styles/fonts.css';
|
|
15
|
+
import '../styles/tokens.css';
|
|
16
|
+
import '../styles/editor.css';
|
|
17
|
+
// Each pane level's own CSS, if it needs any beyond the mockup's (styles/levels/<level>.css).
|
|
18
|
+
import.meta.glob('../styles/levels/*.css', { eager: true });
|
|
19
|
+
import geistMono from '../fonts/geist-mono-latin-wght-normal.woff2?url';
|
|
20
|
+
import instrumentSans from '../fonts/instrument-sans-latin-wght-normal.woff2?url';
|
|
21
|
+
import { site } from 'virtual:caracms/config';
|
|
22
|
+
import { monogramIcon } from '../brand.ts';
|
|
23
|
+
import { adminProps } from '../server/admin-props.ts';
|
|
24
|
+
import { personOf } from '../../live/locals.ts';
|
|
25
|
+
import { editorMode, editorStore } from '../server/mode.ts';
|
|
26
|
+
import { renderEditor } from '../server/render.tsx';
|
|
27
|
+
import { UI_COOKIE } from '../ui-state.ts';
|
|
28
|
+
|
|
29
|
+
export const prerender = false;
|
|
30
|
+
|
|
31
|
+
Astro.response.headers.set('Cache-Control', 'no-store');
|
|
32
|
+
|
|
33
|
+
// Defence in depth: the guard (Phase 3) signs people in; with no person here, nothing is shown.
|
|
34
|
+
if (editorMode().kind === 'live' && !personOf(Astro.locals)) {
|
|
35
|
+
return new Response('<!doctype html><meta charset="utf-8"><title>Sign in</title><p>Sign in to use the editor.</p><p><a href="/admin/login">Sign in</a></p>', {
|
|
36
|
+
status: 401,
|
|
37
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' },
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const result = await adminProps({ site, store: await editorStore(site), url: Astro.url, cookie: (name) => Astro.cookies.get(name)?.value, person: personOf(Astro.locals) });
|
|
42
|
+
if (!result.ok) {
|
|
43
|
+
const escape = (s: string) => s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]!);
|
|
44
|
+
return new Response(
|
|
45
|
+
`<!doctype html><meta charset="utf-8"><title>Not found</title><p>${escape(result.message)}</p><p><a href="/admin">Open the editor</a></p>`,
|
|
46
|
+
{ status: result.status, headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' } },
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
const { props } = result;
|
|
50
|
+
// The server's view of the per-browser state (the open document's groups opened, Recent updated) goes back in the cookie.
|
|
51
|
+
Astro.cookies.set(UI_COOKIE, JSON.stringify(props.ui), { path: '/', maxAge: 60 * 60 * 24 * 365, sameSite: 'lax' });
|
|
52
|
+
|
|
53
|
+
const html = renderEditor(props);
|
|
54
|
+
// The props travel as JSON in the page; `<` escaped so no text can close the tag.
|
|
55
|
+
const json = JSON.stringify(props).replace(/</g, '\\u003c');
|
|
56
|
+
const { brand } = props.site;
|
|
57
|
+
// M6: the inbox names itself (it has no document of its own).
|
|
58
|
+
const title = `${props.inbox ? 'Submissions' : props.opened.document.title || props.opened.entry.label} · ${brand.name}`;
|
|
59
|
+
const accent = `--accent:${brand.accent};--accent-ink:${brand.accentInk};--accent-edge:${brand.accentEdge}`;
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
<!doctype html>
|
|
63
|
+
<html lang="en" style={accent}>
|
|
64
|
+
<head>
|
|
65
|
+
<meta charset="utf-8" />
|
|
66
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
67
|
+
<meta name="robots" content="noindex" />
|
|
68
|
+
<title>{title}</title>
|
|
69
|
+
<link rel="icon" href={brand.tabIcon ?? brand.icon ?? brand.logo ?? monogramIcon(brand.name, brand.accent)} />
|
|
70
|
+
<link rel="preload" href={instrumentSans} as="font" type="font/woff2" crossorigin />
|
|
71
|
+
<link rel="preload" href={geistMono} as="font" type="font/woff2" crossorigin />
|
|
72
|
+
</head>
|
|
73
|
+
<body>
|
|
74
|
+
<div id="viewport" set:html={html} />
|
|
75
|
+
<script type="application/json" id="caracms-props" set:html={json} />
|
|
76
|
+
<script>
|
|
77
|
+
import '../client/main.tsx';
|
|
78
|
+
</script>
|
|
79
|
+
</body>
|
|
80
|
+
</html>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /_caracms/create: `{ collection, path, title, blockType? }` →
|
|
3
|
+
* `{ ok, collection, path, file, url, version }`, or `{ error, code }` (M5a Phase 3,
|
|
4
|
+
* Decision 11; shapes and codes: ../types.ts → CreateRequest, CreateDeleteRefusal).
|
|
5
|
+
* The rules, and why it's a security boundary, are in ../server/create.ts; the store is
|
|
6
|
+
* the mode's (server/mode.ts): an exclusive create on disk in local mode, the lock and
|
|
7
|
+
* an autosave with no base in live mode, where the request carries `X-Caracms-Tab`
|
|
8
|
+
* and the guard has checked the session (M4 Decision 14's path prefix).
|
|
9
|
+
*/
|
|
10
|
+
import type { APIRoute } from 'astro';
|
|
11
|
+
import { site } from 'virtual:caracms/config';
|
|
12
|
+
import { personOf } from '../../live/locals.ts';
|
|
13
|
+
import { handleCreate } from '../server/create.ts';
|
|
14
|
+
import { editorStore } from '../server/mode.ts';
|
|
15
|
+
|
|
16
|
+
export const prerender = false;
|
|
17
|
+
|
|
18
|
+
/** POST, and every other method (405). */
|
|
19
|
+
export const ALL: APIRoute = async ({ request, locals }) => handleCreate(request, await editorStore(site), { person: personOf(locals) });
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /_caracms/delete: `{ collection, path }` → `{ ok, collection, path, file }`, or
|
|
3
|
+
* `{ error, code }` (M5a Phase 3, Decision 12; shapes and codes: ../types.ts →
|
|
4
|
+
* DeleteRequest, CreateDeleteRefusal). The rules, and why it's a security boundary,
|
|
5
|
+
* are in ../server/create.ts; the store is the mode's (server/mode.ts): the file and
|
|
6
|
+
* its emptied folders on disk in local mode, the lock and a "gone" autosave in live
|
|
7
|
+
* mode, where the request carries `X-Caracms-Tab` and the guard has checked the
|
|
8
|
+
* session (M4 Decision 14's path prefix).
|
|
9
|
+
*/
|
|
10
|
+
import type { APIRoute } from 'astro';
|
|
11
|
+
import { site } from 'virtual:caracms/config';
|
|
12
|
+
import { personOf } from '../../live/locals.ts';
|
|
13
|
+
import { handleDelete } from '../server/create.ts';
|
|
14
|
+
import { editorStore } from '../server/mode.ts';
|
|
15
|
+
|
|
16
|
+
export const prerender = false;
|
|
17
|
+
|
|
18
|
+
/** POST, and every other method (405). */
|
|
19
|
+
export const ALL: APIRoute = async ({ request, locals }) => handleDelete(request, await editorStore(site), { person: personOf(locals) });
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /_caracms/document?collection=<name>&path=<path>: one document as the editor
|
|
3
|
+
* opens it, through the mode's drafts store (server/mode.ts): the file on disk in
|
|
4
|
+
* local mode; the `drafts` copy with its autosave on top in live mode (M4 Decision 4).
|
|
5
|
+
*
|
|
6
|
+
* 200 { entry, text, document, mtimeMs, version, lastChange? } (types.ts → OpenedDocument; lastChange in live mode, Decision 11)
|
|
7
|
+
* 400/404 { error, code } (content.ts → DocumentError)
|
|
8
|
+
* 401 { error: 'signed-out' } (live mode, no person); 503 { error, code: 'unavailable' } (Redis out)
|
|
9
|
+
*
|
|
10
|
+
* `text` is the serializer's base for the editing session, and what the keeper
|
|
11
|
+
* compares held edits with.
|
|
12
|
+
*/
|
|
13
|
+
import type { APIRoute } from 'astro';
|
|
14
|
+
import { site } from 'virtual:caracms/config';
|
|
15
|
+
import { DocumentError } from '../../content.ts';
|
|
16
|
+
import { personOf } from '../../live/locals.ts';
|
|
17
|
+
import { editorMode, editorStore } from '../server/mode.ts';
|
|
18
|
+
import { WriteRefused } from '../server/write.ts';
|
|
19
|
+
|
|
20
|
+
export const prerender = false;
|
|
21
|
+
|
|
22
|
+
const headers = { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' };
|
|
23
|
+
|
|
24
|
+
export const GET: APIRoute = async ({ url, locals }) => {
|
|
25
|
+
if (editorMode().kind === 'live' && !personOf(locals)) return new Response(JSON.stringify({ error: 'signed-out', code: 'signed-out' }), { status: 401, headers });
|
|
26
|
+
const collection = url.searchParams.get('collection') ?? '';
|
|
27
|
+
const path = url.searchParams.get('path') ?? '';
|
|
28
|
+
try {
|
|
29
|
+
const store = await editorStore(site);
|
|
30
|
+
return new Response(JSON.stringify(await store.read(collection, path)), { headers });
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if (error instanceof DocumentError) {
|
|
33
|
+
const status = error.code === 'not-found' ? 404 : 400;
|
|
34
|
+
return new Response(JSON.stringify({ error: error.message, code: error.code }), { status, headers });
|
|
35
|
+
}
|
|
36
|
+
if (error instanceof WriteRefused) return new Response(JSON.stringify({ error: error.message, ...error.body }), { status: error.status, headers });
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /_caracms/documents: every document, in the editor's order, with warnings and
|
|
3
|
+
* times (drafts-store.ts → list), through the mode's store (server/mode.ts). For
|
|
4
|
+
* refreshing the documents level (editor.refreshDocuments).
|
|
5
|
+
*
|
|
6
|
+
* M6 Decision 9: the inbox's unread count comes with it, in `X-Caracms-Unread` (absent
|
|
7
|
+
* when the production server has no submissions bucket, or the count couldn't be read).
|
|
8
|
+
*/
|
|
9
|
+
import type { APIRoute } from 'astro';
|
|
10
|
+
import { site } from 'virtual:caracms/config';
|
|
11
|
+
import { submissionsSetupQuick, UNREAD_HEADER } from '../../forms/inbox.ts';
|
|
12
|
+
import { personOf } from '../../live/locals.ts';
|
|
13
|
+
import { editorMode, editorStore } from '../server/mode.ts';
|
|
14
|
+
import { WriteRefused } from '../server/write.ts';
|
|
15
|
+
import { withCards } from '../server/cards.ts';
|
|
16
|
+
|
|
17
|
+
export const prerender = false;
|
|
18
|
+
|
|
19
|
+
const headers = { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' };
|
|
20
|
+
|
|
21
|
+
export const GET: APIRoute = async ({ locals }) => {
|
|
22
|
+
if (editorMode().kind === 'live' && !personOf(locals)) return new Response(JSON.stringify({ error: 'signed-out', code: 'signed-out' }), { status: 401, headers });
|
|
23
|
+
try {
|
|
24
|
+
const [documents, submissions] = await Promise.all([(await editorStore(site)).list().then((all) => withCards(site, all)), submissionsSetupQuick({ site })]);
|
|
25
|
+
return new Response(JSON.stringify(documents), { headers: { ...headers, ...(submissions.unread !== null ? { [UNREAD_HEADER]: String(submissions.unread) } : {}) } });
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (error instanceof WriteRefused) return new Response(JSON.stringify({ error: error.message, ...error.body }), { status: error.status, headers });
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /_caracms/github: GitHub's push webhook (M4 Decision 14). It only prompts the
|
|
3
|
+
* check that keeps `drafts` up to date with `main` (Decision 8); the logic, and what
|
|
4
|
+
* it refuses, is in ../../live/webhook.ts.
|
|
5
|
+
*/
|
|
6
|
+
import type { APIRoute } from 'astro';
|
|
7
|
+
import { site } from 'virtual:caracms/config';
|
|
8
|
+
import { liveEditor } from '../../live/editor.ts';
|
|
9
|
+
import { handleGithubWebhook } from '../../live/webhook.ts';
|
|
10
|
+
import { editorMode } from '../server/mode.ts';
|
|
11
|
+
|
|
12
|
+
export const prerender = false;
|
|
13
|
+
|
|
14
|
+
export const ALL: APIRoute = ({ request }) => {
|
|
15
|
+
const mode = editorMode(); // local mode: 404 (no secret)
|
|
16
|
+
if (mode.kind !== 'live') return handleGithubWebhook(request, { secret: null, trigger: async () => {} });
|
|
17
|
+
const { settings } = mode;
|
|
18
|
+
return handleGithubWebhook(request, {
|
|
19
|
+
secret: settings.webhookSecret,
|
|
20
|
+
// The editor, not just the runtime: the merge commits pending autosaves first (the sweep fills that hook).
|
|
21
|
+
trigger: async () => (await liveEditor(site, settings)).runtime.git.check({ force: true }),
|
|
22
|
+
});
|
|
23
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /_caracms/history: the newest 20 publishes (Decision 12).
|
|
3
|
+
* Live mode only (M4 Phase 2); the handler is ../../live/editor.ts → `history`. Shapes: ../types.ts.
|
|
4
|
+
*/
|
|
5
|
+
import { liveRoute } from './_live.ts';
|
|
6
|
+
|
|
7
|
+
export const prerender = false;
|
|
8
|
+
|
|
9
|
+
export const ALL = liveRoute('history');
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /_caracms/lock { file, action: 'take' | 'release' }: Edit here, and a closing tab's release (Decision 9).
|
|
3
|
+
* Live mode only (M4 Phase 2); the handler is ../../live/editor.ts → `lock`. Shapes: ../types.ts.
|
|
4
|
+
*/
|
|
5
|
+
import { liveRoute } from './_live.ts';
|
|
6
|
+
|
|
7
|
+
export const prerender = false;
|
|
8
|
+
|
|
9
|
+
export const ALL = liveRoute('lock');
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Vite's asset imports the sign-in route uses (Vite's own client types aren't in the
|
|
2
|
+
// package's tsconfig): a font's URL in the bundle, and a stylesheet's text.
|
|
3
|
+
declare module '*.woff2?url' {
|
|
4
|
+
const url: string;
|
|
5
|
+
export default url;
|
|
6
|
+
}
|
|
7
|
+
declare module '*.css?raw' {
|
|
8
|
+
const css: string;
|
|
9
|
+
export default css;
|
|
10
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every `/admin/login…` page (M4 Phase 3). The logic is ../../../live/auth/login.ts;
|
|
3
|
+
* this route hands it the site's brand, the editor's tokens and its face, and the
|
|
4
|
+
* process's Auth. In local mode there's no sign-in: every page goes to the editor.
|
|
5
|
+
*/
|
|
6
|
+
import type { APIRoute } from 'astro';
|
|
7
|
+
import { site } from 'virtual:caracms/config';
|
|
8
|
+
import instrumentSansExt from '../../fonts/instrument-sans-latin-ext-wght-normal.woff2?url';
|
|
9
|
+
import instrumentSans from '../../fonts/instrument-sans-latin-wght-normal.woff2?url';
|
|
10
|
+
import tokensCss from '../../styles/tokens.css?raw';
|
|
11
|
+
import { monogramIcon } from '../../brand.ts';
|
|
12
|
+
import { editorMode } from '../../server/mode.ts';
|
|
13
|
+
import { currentBrand, editorBrand } from '../../server/settings.ts';
|
|
14
|
+
import { authFor } from '../../../live/auth/index.ts';
|
|
15
|
+
import { handleLogin, localLoginRedirect } from '../../../live/auth/login.ts';
|
|
16
|
+
import type { Brand, PageAssets } from '../../../live/auth/views.ts';
|
|
17
|
+
|
|
18
|
+
export const prerender = false;
|
|
19
|
+
|
|
20
|
+
/** The brand as it applies now (M5a Decision 10: Site Settings' name and accent, as committed). */
|
|
21
|
+
async function brandNow(): Promise<Brand> {
|
|
22
|
+
const brand = editorBrand(await currentBrand(site));
|
|
23
|
+
return { ...brand, icon: brand.icon ?? brand.logo ?? monogramIcon(brand.name, brand.accent) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The chrome's face, as fonts.css declares it (latin, then latin-ext), with the bundle's URLs.
|
|
27
|
+
const face = (src: string, range: string) =>
|
|
28
|
+
`@font-face{font-family:'Instrument Sans';font-style:normal;font-display:block;font-weight:400 700;src:url('${src}') format('woff2-variations');unicode-range:${range}}`;
|
|
29
|
+
const assets: PageAssets = {
|
|
30
|
+
tokensCss,
|
|
31
|
+
fontsCss: [
|
|
32
|
+
face(instrumentSans, 'U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD'),
|
|
33
|
+
face(instrumentSansExt, 'U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF'),
|
|
34
|
+
].join('\n'),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const ALL: APIRoute = async (context) => {
|
|
38
|
+
const mode = editorMode();
|
|
39
|
+
if (mode.kind === 'local') return localLoginRedirect();
|
|
40
|
+
if (mode.kind !== 'live') return new Response("The editor isn't set up on this server yet.", { status: 503, headers: { 'Cache-Control': 'private, no-store' } });
|
|
41
|
+
const auth = await authFor(site, mode.settings, context.site);
|
|
42
|
+
return handleLogin(context.request, { auth, brand: await brandNow(), assets, clientAddress: () => context.clientAddress });
|
|
43
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/_caracms/media/*`: the media library, uploads and Mux (M5b Phase 2). Both modes:
|
|
3
|
+
* in live mode the guard's prefix rule has checked the session and, for a POST, the
|
|
4
|
+
* origin; local mode has no sign-in. Every route and shape: ../types.ts →
|
|
5
|
+
* MEDIA_ENDPOINTS; the rules: ../server/media/handler.ts.
|
|
6
|
+
*/
|
|
7
|
+
import type { APIRoute } from 'astro';
|
|
8
|
+
import { site } from 'virtual:caracms/config';
|
|
9
|
+
import { personOf } from '../../live/locals.ts';
|
|
10
|
+
import { handleMedia } from '../server/media/handler.ts';
|
|
11
|
+
import { editorMode, editorStore } from '../server/mode.ts';
|
|
12
|
+
|
|
13
|
+
export const prerender = false;
|
|
14
|
+
|
|
15
|
+
export const ALL: APIRoute = async ({ request, locals }) =>
|
|
16
|
+
handleMedia(request, { site, store: await editorStore(site), mode: editorMode().kind === 'live' ? 'live' : 'local', person: personOf(locals) });
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /_caracms/move: `{ collection, path, to }` → MoveResponse, or `{ error, code }`
|
|
3
|
+
* (pre-M7 item 8; shapes: ../types.ts → MoveRequest). The rules are in ../server/move.ts;
|
|
4
|
+
* the store is the mode's (server/mode.ts), as for create and delete: on disk in local
|
|
5
|
+
* mode, autosaves under this tab's locks in live mode, where the request carries
|
|
6
|
+
* `X-Caracms-Tab` and the guard has checked the session (its `/_caracms/` prefix rule).
|
|
7
|
+
*/
|
|
8
|
+
import type { APIRoute } from 'astro';
|
|
9
|
+
import { site } from 'virtual:caracms/config';
|
|
10
|
+
import { personOf } from '../../live/locals.ts';
|
|
11
|
+
import { handleMove } from '../server/move.ts';
|
|
12
|
+
import { editorStore } from '../server/mode.ts';
|
|
13
|
+
|
|
14
|
+
export const prerender = false;
|
|
15
|
+
|
|
16
|
+
/** POST, and every other method (405). */
|
|
17
|
+
export const ALL: APIRoute = async ({ request, locals }) => handleMove(request, await editorStore(site), { person: personOf(locals) });
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /_caracms/notice { id }: dismiss a notice.
|
|
3
|
+
* Live mode only (M4 Phase 2); the handler is ../../live/editor.ts → `notice`. Shapes: ../types.ts.
|
|
4
|
+
*/
|
|
5
|
+
import { liveRoute } from './_live.ts';
|
|
6
|
+
|
|
7
|
+
export const prerender = false;
|
|
8
|
+
|
|
9
|
+
export const ALL = liveRoute('notice');
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* The preview route: the real page, through the site's page
|
|
4
|
+
* component (RenderDocument), with preview markers (Decision 17).
|
|
5
|
+
*
|
|
6
|
+
* - GET ?collection=<name>&path=<path> renders the document as the mode's drafts store
|
|
7
|
+
* reads it now (disk in local mode; the `drafts` copy with its autosave in live
|
|
8
|
+
* mode): an iframe's first load, version 0.
|
|
9
|
+
* - POST { version, collection, path, document } renders the document it's sent (the
|
|
10
|
+
* editor's copy); `path` (optional) is where it is in its collection, for the page
|
|
11
|
+
* component's `path` (MEC trial item 2). The response carries the version it rendered in
|
|
12
|
+
* `X-Preview-Version` (Decision 13: latest edit wins).
|
|
13
|
+
*
|
|
14
|
+
* Any kind of document a page shows (M5a): a section builder's, a list-of-fields
|
|
15
|
+
* document through its collection's component, a shared block alone in the page's
|
|
16
|
+
* frame (Decision 8). Site Settings has no page of its own (404). The render reads
|
|
17
|
+
* other documents (Decision 9: shared blocks, a case-study list) from the mode's
|
|
18
|
+
* store with its autosaves on top (`store.source()`), never the disk under live mode.
|
|
19
|
+
*
|
|
20
|
+
* Keeps no state and writes nothing. Never cached.
|
|
21
|
+
*/
|
|
22
|
+
import RenderDocument from '../../components/RenderDocument.astro';
|
|
23
|
+
import { site } from 'virtual:caracms/config';
|
|
24
|
+
import { DocumentError } from '../../content.ts';
|
|
25
|
+
import { personOf } from '../../live/locals.ts';
|
|
26
|
+
import { editorMode, editorStore } from '../server/mode.ts';
|
|
27
|
+
import { normaliseAnyDocument, type AnyDocument, type PageDocument } from '../../document.ts';
|
|
28
|
+
import { setRenderSource } from '../../read.ts';
|
|
29
|
+
|
|
30
|
+
export const prerender = false;
|
|
31
|
+
|
|
32
|
+
const plain = (status: number, message: string) =>
|
|
33
|
+
new Response(message, { status, headers: { 'Cache-Control': 'no-store', 'Content-Type': 'text/plain; charset=utf-8' } });
|
|
34
|
+
|
|
35
|
+
if (editorMode().kind === 'live' && !personOf(Astro.locals)) return plain(401, 'signed-out');
|
|
36
|
+
|
|
37
|
+
let version = 0;
|
|
38
|
+
let collection: string;
|
|
39
|
+
let document: AnyDocument;
|
|
40
|
+
let path: string | undefined;
|
|
41
|
+
const store = await editorStore(site);
|
|
42
|
+
|
|
43
|
+
if (Astro.request.method === 'POST') {
|
|
44
|
+
let body: { version?: unknown; collection?: unknown; path?: unknown; document?: unknown };
|
|
45
|
+
try {
|
|
46
|
+
body = await Astro.request.json();
|
|
47
|
+
} catch {
|
|
48
|
+
return plain(400, 'Send JSON: { version, collection, document }.');
|
|
49
|
+
}
|
|
50
|
+
if (!Number.isSafeInteger(body.version) || (body.version as number) < 0) return plain(400, 'version must be a non-negative integer.');
|
|
51
|
+
if (typeof body.collection !== 'string' || !Object.hasOwn(site.collections, body.collection)) return plain(400, 'collection must be a declared collection.');
|
|
52
|
+
if (!body.document || typeof body.document !== 'object') return plain(400, 'document must be an object.');
|
|
53
|
+
version = body.version as number;
|
|
54
|
+
collection = body.collection;
|
|
55
|
+
if (typeof body.path === 'string') path = body.path;
|
|
56
|
+
document = normaliseAnyDocument(body.document, site, site.collections[collection]!);
|
|
57
|
+
} else if (Astro.request.method === 'GET') {
|
|
58
|
+
collection = Astro.url.searchParams.get('collection') ?? '';
|
|
59
|
+
path = Astro.url.searchParams.get('path') ?? '';
|
|
60
|
+
try {
|
|
61
|
+
document = (await store.read(collection, path)).document;
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (error instanceof DocumentError) return plain(error.code === 'not-found' ? 404 : 400, error.message);
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
} else {
|
|
67
|
+
return new Response('GET or POST.', { status: 405, headers: { Allow: 'GET, POST' } });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (document.kind === 'settings') return plain(404, 'Site Settings has no page of its own.');
|
|
71
|
+
setRenderSource(Astro.locals, await store.source());
|
|
72
|
+
|
|
73
|
+
Astro.response.headers.set('Cache-Control', 'no-store');
|
|
74
|
+
Astro.response.headers.set('X-Preview-Version', String(version));
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
<RenderDocument document={document as PageDocument} collection={collection} previewPath={path} preview />
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /_caracms/publish: Publish (Decision 10).
|
|
3
|
+
* Live mode only (M4 Phase 2); the handler is ../../live/editor.ts → `publish`. Shapes: ../types.ts.
|
|
4
|
+
*/
|
|
5
|
+
import { liveRoute } from './_live.ts';
|
|
6
|
+
|
|
7
|
+
export const prerender = false;
|
|
8
|
+
|
|
9
|
+
export const ALL = liveRoute('publish');
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /_caracms/status?file=<open document's file>: the poll (Decision 13).
|
|
3
|
+
* Live mode only (M4 Phase 2); the handler is ../../live/editor.ts → `status`. Shapes: ../types.ts.
|
|
4
|
+
*/
|
|
5
|
+
import { liveRoute } from './_live.ts';
|
|
6
|
+
|
|
7
|
+
export const prerender = false;
|
|
8
|
+
|
|
9
|
+
export const ALL = liveRoute('status');
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/_caracms/submissions/*`: the inbox (M6 Decision 9). Both modes: in live mode the
|
|
3
|
+
* guard's prefix rule has checked the session and, for a POST, the origin; local mode
|
|
4
|
+
* has no sign-in and reads `.caracms/submissions/`. Every route and shape:
|
|
5
|
+
* ../../forms/inbox.ts.
|
|
6
|
+
*/
|
|
7
|
+
import type { APIRoute } from 'astro';
|
|
8
|
+
import { site } from 'virtual:caracms/config';
|
|
9
|
+
import { handleSubmissions } from '../../forms/inbox.ts';
|
|
10
|
+
import { personOf } from '../../live/locals.ts';
|
|
11
|
+
import { editorMode } from '../server/mode.ts';
|
|
12
|
+
|
|
13
|
+
export const prerender = false;
|
|
14
|
+
|
|
15
|
+
export const ALL: APIRoute = ({ request, locals }) =>
|
|
16
|
+
handleSubmissions(request, { site, mode: editorMode().kind === 'live' ? 'live' : 'local', person: personOf(locals)?.email ?? null });
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /_caracms/try-again: redeploy a failed publish through Railway (Decision 12).
|
|
3
|
+
* Live mode only (M4 Phase 2); the handler is ../../live/editor.ts → `tryAgain`. Shapes: ../types.ts.
|
|
4
|
+
*/
|
|
5
|
+
import { liveRoute } from './_live.ts';
|
|
6
|
+
|
|
7
|
+
export const prerender = false;
|
|
8
|
+
|
|
9
|
+
export const ALL = liveRoute('tryAgain');
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /_caracms/write: `{ file, base, document }` → `{ ok, file, changed, mtimeMs, version }`,
|
|
3
|
+
* or `{ error, code? }` with 400/401/403/404/405/409/413/415/503. The logic, and why it's
|
|
4
|
+
* a security boundary, is in ../server/write.ts; the store is the mode's
|
|
5
|
+
* (server/mode.ts): the disk in local mode, the Redis autosave in live mode (M4
|
|
6
|
+
* Decision 4), where the request also carries `X-Caracms-Tab` (and
|
|
7
|
+
* `X-Caracms-Release: 1` on a closing tab's last write).
|
|
8
|
+
*/
|
|
9
|
+
import type { APIRoute } from 'astro';
|
|
10
|
+
import { site } from 'virtual:caracms/config';
|
|
11
|
+
import { personOf } from '../../live/locals.ts';
|
|
12
|
+
import { editorStore } from '../server/mode.ts';
|
|
13
|
+
import { handleWrite } from '../server/write.ts';
|
|
14
|
+
|
|
15
|
+
export const prerender = false;
|
|
16
|
+
|
|
17
|
+
/** POST, and every other method (405). */
|
|
18
|
+
export const ALL: APIRoute = async ({ request, locals }) => handleWrite(request, await editorStore(site), { person: personOf(locals) });
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What /admin server-renders from (and hydrates with): the site as the browser may
|
|
3
|
+
* see it, the document list, the open document as read now, and the per-browser
|
|
4
|
+
* sidebar state from the cookie (Decision 20).
|
|
5
|
+
*/
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { relative } from 'node:path';
|
|
8
|
+
import type { Brand, ResolvedSite } from '../../config.ts';
|
|
9
|
+
import { DocumentError } from '../../content.ts';
|
|
10
|
+
import { inboxFromAddress, refFromAddress } from '../addresses.ts';
|
|
11
|
+
import { handleSubmissions, SUBMISSIONS_PREFIX, submissionsSetupQuick, type SubmissionResponse, type SubmissionsListResponse } from '../../forms/inbox.ts';
|
|
12
|
+
import { ENDPOINTS, type AdminProps, type DocRef, type EditorSite, type ListedDocument, type LiveProps, type MediaSetup, type EditorForm, type Person, type StatusResponse } from '../types.ts';
|
|
13
|
+
import { parseUiState, UI_COOKIE, unsentCookieName, withOpenAncestors, withRecent, withSide } from '../ui-state.ts';
|
|
14
|
+
import type { DraftsStore } from './drafts-store.ts';
|
|
15
|
+
import { editorMode, liveEditorOf } from './mode.ts';
|
|
16
|
+
import { readImagesConfig, readMuxConfig } from './media/config.ts';
|
|
17
|
+
import { currentBrand, editorBrand } from './settings.ts';
|
|
18
|
+
import { withCards } from './cards.ts';
|
|
19
|
+
|
|
20
|
+
/** Home: the top `index` of the first nested collection, else the first document that opens. */
|
|
21
|
+
export function homeOf(site: Pick<ResolvedSite, 'collections'>, documents: readonly ListedDocument[]): DocRef {
|
|
22
|
+
for (const collection of Object.values(site.collections)) {
|
|
23
|
+
if (collection.nested && documents.some((d) => d.collection === collection.name && d.path === 'index' && !d.warning)) {
|
|
24
|
+
return { collection: collection.name, path: 'index' };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const first = documents.find((d) => !d.warning && d.kind === 'sections');
|
|
28
|
+
return first ? { collection: first.collection, path: first.path } : { collection: Object.keys(site.collections)[0]!, path: 'index' };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `brand`: the brand as it applies now (settings.ts → currentBrand: Site Settings as
|
|
33
|
+
* committed, M5a Decision 10).
|
|
34
|
+
*/
|
|
35
|
+
export function editorSite(site: ResolvedSite, documents: readonly ListedDocument[], host: string, brand: Brand): EditorSite {
|
|
36
|
+
// Every kind of collection (M5a Phase 5): the site's own in config order, then `shared` and `settings`.
|
|
37
|
+
const collections = Object.values(site.collections).map(({ name, label, kind, urlBase, nested, fields, columns, sort, view, blocks }) => ({ name, label, kind, urlBase, nested, fields, columns, sort: sort ?? null, view: view ?? null, blocks: blocks ?? null }));
|
|
38
|
+
return {
|
|
39
|
+
collections,
|
|
40
|
+
allCollections: collections,
|
|
41
|
+
blocks: Object.fromEntries(
|
|
42
|
+
Object.entries(site.blocks).map(([type, block]) => [type, { ...block, component: relative(site.root, block.component).split('\\').join('/') }]),
|
|
43
|
+
),
|
|
44
|
+
textStyles: Object.fromEntries(
|
|
45
|
+
Object.values(site.textStyles).map((style) => [style.name, { name: style.name, sizePx: style.sizePx, minSizePx: style.minSizePx }]),
|
|
46
|
+
),
|
|
47
|
+
brand: editorBrand(brand),
|
|
48
|
+
phoneLinesWidth: site.phoneLinesWidth,
|
|
49
|
+
home: homeOf(site, documents),
|
|
50
|
+
host,
|
|
51
|
+
storageKey: createHash('sha256').update(site.root).digest('base64url').slice(0, 10),
|
|
52
|
+
timeZone: site.timeZone,
|
|
53
|
+
codeRoutes: site.codeRoutes ?? [],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type AdminPropsResult = { ok: true; props: AdminProps } | { ok: false; status: number; message: string };
|
|
58
|
+
|
|
59
|
+
export async function adminProps(input: {
|
|
60
|
+
site: ResolvedSite;
|
|
61
|
+
store: DraftsStore;
|
|
62
|
+
url: URL;
|
|
63
|
+
/** Read a cookie by name (Astro.cookies). */
|
|
64
|
+
cookie: (name: string) => string | undefined;
|
|
65
|
+
/** Live mode: the signed-in person (the guard's, locals.ts). Absent or null in local mode. */
|
|
66
|
+
person?: Person | null;
|
|
67
|
+
}): Promise<AdminPropsResult> {
|
|
68
|
+
const { site, store, url } = input;
|
|
69
|
+
const documents = withCards(site, await store.list());
|
|
70
|
+
const editor = editorSite(site, documents, url.host, await currentBrand(site));
|
|
71
|
+
// M6 Decision 9: /admin/submissions[/<id>] opens the editor on the inbox, over Home.
|
|
72
|
+
const inbox = inboxFromAddress(url.pathname);
|
|
73
|
+
if (inbox === 'bad') return { ok: false, status: 404, message: `There's no submission at ${url.pathname}.` };
|
|
74
|
+
const ref = inbox ? editor.home : refFromAddress(url.pathname, editor.collections.map((c) => c.name), editor.home);
|
|
75
|
+
if (!ref) return { ok: false, status: 404, message: `There's no document at ${url.pathname}.` };
|
|
76
|
+
try {
|
|
77
|
+
const opened = await store.read(ref.collection, ref.path);
|
|
78
|
+
// A hidden sidebar stays hidden only for a section-builder page; any other kind of
|
|
79
|
+
// document is edited in the sidebar, so it paints shown (the client's bringSideBack).
|
|
80
|
+
const painted = withRecent(withOpenAncestors(parseUiState(input.cookie(UI_COOKIE)), ref), ref);
|
|
81
|
+
const ui = opened.entry.kind === 'sections' ? painted : withSide(painted, false);
|
|
82
|
+
const unsent = input.cookie(unsentCookieName(editor.storageKey)) === '1';
|
|
83
|
+
const live = await liveProps(site, url, opened.entry.file, input.person ?? null);
|
|
84
|
+
const submissions = await submissionsSetupQuick({ site });
|
|
85
|
+
// The inbox's first page (and the submission the address names) in the first paint: no empty list that fills in.
|
|
86
|
+
const inboxFirst = inbox && submissions.unread !== null ? await inboxFirstPaint(site, url, inbox.id, input.person ?? null) : null;
|
|
87
|
+
return { ok: true, props: { site: editor, documents, opened, ui, unsent, live, media: mediaSetup(), inbox, submissions, forms: formsOf(site), inboxFirst } };
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (error instanceof DocumentError) return { ok: false, status: error.code === 'not-found' ? 404 : 400, message: error.message };
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Live mode's start for the page (M4 Phase 4): who is signed in and the status as the
|
|
96
|
+
* poll would answer it, so the bar's waiting count, Publish, the avatar and a lock's
|
|
97
|
+
* veil are in the first paint (no skeletons). Read with no tab, so presence isn't
|
|
98
|
+
* recorded and a lock of your own reads as another tab's until the tab's first poll.
|
|
99
|
+
* Null in local mode.
|
|
100
|
+
*/
|
|
101
|
+
async function liveProps(site: ResolvedSite, url: URL, file: string, person: Person | null): Promise<LiveProps | null> {
|
|
102
|
+
const mode = editorMode();
|
|
103
|
+
if (mode.kind !== 'live' || !person) return null;
|
|
104
|
+
let status: StatusResponse | null = null;
|
|
105
|
+
try {
|
|
106
|
+
const live = await liveEditorOf(site);
|
|
107
|
+
const response = await live!.status(new Request(`${url.origin}${ENDPOINTS.status}?file=${encodeURIComponent(file)}`), person);
|
|
108
|
+
if (response.ok) status = (await response.json()) as StatusResponse;
|
|
109
|
+
} catch (error) {
|
|
110
|
+
console.warn(`[caracms] /admin couldn't read the status for its first paint (${error instanceof Error ? error.message : String(error)}); the first poll fills it`);
|
|
111
|
+
}
|
|
112
|
+
return { you: person, status, pollMs: mode.settings.timings.pollMs };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* M6: what /admin/submissions[/<id>] paints first, read through the inbox's own handler
|
|
117
|
+
* (so the same checks run): the first page, and the submission the address names. A read
|
|
118
|
+
* that fails leaves its part null, and the browser reads it again.
|
|
119
|
+
*/
|
|
120
|
+
async function inboxFirstPaint(site: ResolvedSite, url: URL, id: string | null, person: Person | null): Promise<NonNullable<AdminProps['inboxFirst']>> {
|
|
121
|
+
const ctx = { site, mode: editorMode().kind === 'live' ? ('live' as const) : ('local' as const), person: person?.email ?? null };
|
|
122
|
+
const read = async <T,>(path: string): Promise<T | null> => {
|
|
123
|
+
try {
|
|
124
|
+
const response = await handleSubmissions(new Request(`${url.origin}${SUBMISSIONS_PREFIX}${path}`), ctx);
|
|
125
|
+
return response.ok ? ((await response.json()) as T) : null;
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
const [list, open] = await Promise.all([read<SubmissionsListResponse>(''), id ? read<SubmissionResponse>(`/${encodeURIComponent(id)}`) : Promise.resolve(null)]);
|
|
131
|
+
return { list, open };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** M6: the site's forms as the editor needs them (Site Settings' Form emails, the Form block's picker): id, label and fields. */
|
|
135
|
+
export function formsOf(site: Pick<ResolvedSite, 'forms'>): EditorForm[] {
|
|
136
|
+
return Object.values(site.forms).map(({ id, label, fields }) => ({ id, label, fields }));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* M5b Decision 5: whether the media routes are set up, for the media field's first paint
|
|
141
|
+
* (the 503's sentence in place of its controls). Only the sentence and the missing names
|
|
142
|
+
* reach the browser, never a value.
|
|
143
|
+
*/
|
|
144
|
+
export function mediaSetup(): MediaSetup {
|
|
145
|
+
const images = readImagesConfig();
|
|
146
|
+
const clips = readMuxConfig();
|
|
147
|
+
return {
|
|
148
|
+
images: images.kind === 'unset' ? { error: images.message, code: 'media-unset', missing: images.missing } : null,
|
|
149
|
+
clips: clips.kind === 'unset' ? { error: clips.message, code: 'mux-unset', missing: clips.missing } : null,
|
|
150
|
+
};
|
|
151
|
+
}
|