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
package/src/read.ts
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The render's read access to other documents (M5a Decision 9), and what a render
|
|
3
|
+
* needs to resolve: shared-block entries (Decision 8) and references (Decision 6).
|
|
4
|
+
*
|
|
5
|
+
* - **One source per render.** `documentReader(site, source)` reads through a
|
|
6
|
+
* content.ts ContentSource: the disk at build (`main` as deployed), and in a
|
|
7
|
+
* preview the editor's store with autosaves on top (the preview route hands its
|
|
8
|
+
* store's source to the render through `Astro.locals`: `setRenderSource`). So a
|
|
9
|
+
* preview never shows what the public page wouldn't once published, and both read
|
|
10
|
+
* through the same parsing (content.ts).
|
|
11
|
+
* - **Per render, never module state:** a reader caches what it read for its own
|
|
12
|
+
* render only, so a list block and a reference reading one collection read it once.
|
|
13
|
+
* The exception is a production build on the disk source, where every reader shares
|
|
14
|
+
* one cache (posts and tags, Decision 15: `shareBuildReads`).
|
|
15
|
+
* - **A reference to a document that doesn't exist reaches the component as `null`**
|
|
16
|
+
* (Decision 6), in a list of references too (`[null, "tinder-press"]` keeps the
|
|
17
|
+
* editor's indexes). `resolveRefs` does it, for a block's data and a fields
|
|
18
|
+
* document's alike.
|
|
19
|
+
*
|
|
20
|
+
* Server-only (it reads files through content.ts).
|
|
21
|
+
*/
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { DEFAULT_TITLE_SEPARATOR, type ResolvedSite } from './config.ts';
|
|
24
|
+
import { DocumentError, diskSource, listDocuments, readDocument, type ContentSource } from './content.ts';
|
|
25
|
+
import { own, parseFields, parseSettingsBrand, type DocumentBlock, type DocumentReader, type ReadEntry, type SharedDocument } from './document.ts';
|
|
26
|
+
import { renderData, type Field, type Fields } from './fields.ts';
|
|
27
|
+
import { renderMedia } from './media-value.ts';
|
|
28
|
+
import { SITE_SETTINGS_FILE } from './reserved.ts';
|
|
29
|
+
import { parseSeo } from './seo.ts';
|
|
30
|
+
|
|
31
|
+
type Site = Pick<ResolvedSite, 'root' | 'collections' | 'blocks'> & Partial<Pick<ResolvedSite, 'titleSeparator'>>;
|
|
32
|
+
|
|
33
|
+
const SOURCE = '__caracms_render_source';
|
|
34
|
+
|
|
35
|
+
/** The preview route's source for this request's render (Decision 9). The build sets none: the disk. */
|
|
36
|
+
export function setRenderSource(locals: object, source: ContentSource): void {
|
|
37
|
+
(locals as Record<string, unknown>)[SOURCE] = source;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The source this request's render reads other documents from: the preview's store, else the disk. */
|
|
41
|
+
export function renderSourceOf(locals: object | undefined): ContentSource {
|
|
42
|
+
return ((locals as Record<string, unknown> | undefined)?.[SOURCE] as ContentSource | undefined) ?? diskSource;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Decision 15 (posts and tags): a production build reads each document once. The integration
|
|
47
|
+
* turns this on for `astro build` only (`astro:build:start`, off again at `astro:build:done`),
|
|
48
|
+
* on globalThis because the build's prerender runs a bundled copy of this module. Never in
|
|
49
|
+
* `astro dev`, where the disk-mode editor writes files between requests.
|
|
50
|
+
*/
|
|
51
|
+
const BUILD_READS = Symbol.for('caracms.buildReads');
|
|
52
|
+
|
|
53
|
+
interface ReadCaches {
|
|
54
|
+
/** The source the reader reads through: in a build, the disk with each file's text and each folder's listing read once. */
|
|
55
|
+
source: ContentSource;
|
|
56
|
+
reads: Map<string, Promise<Awaited<ReturnType<typeof readDocument>> | null>>;
|
|
57
|
+
entries: Map<string, Promise<ReadEntry | null>>;
|
|
58
|
+
lists: Map<string, Promise<ReadEntry[]>>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const newCaches = (source: ContentSource): ReadCaches => ({ source, reads: new Map(), entries: new Map(), lists: new Map() });
|
|
62
|
+
|
|
63
|
+
/** A source that reads each file and lists each folder once (a listing and a document read the same file). */
|
|
64
|
+
function onceSource(source: ContentSource): ContentSource {
|
|
65
|
+
const texts = new Map<string, Promise<string | null>>();
|
|
66
|
+
const lists = new Map<string, Promise<string[]>>();
|
|
67
|
+
const once = <T>(map: Map<string, Promise<T>>, key: string, read: () => Promise<T>) => {
|
|
68
|
+
let found = map.get(key);
|
|
69
|
+
if (!found) map.set(key, (found = read()));
|
|
70
|
+
return found;
|
|
71
|
+
};
|
|
72
|
+
return {
|
|
73
|
+
readText: (file) => once(texts, file, () => source.readText(file)),
|
|
74
|
+
listJson: (dir, recursive) => once(lists, `${recursive}\0${dir}`, () => source.listJson(dir, recursive)),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Share the disk's reads across every reader (on), or stop and forget them (off). Called by the integration's build hooks, and by tests. */
|
|
79
|
+
export function shareBuildReads(on: boolean): void {
|
|
80
|
+
const g = globalThis as Record<symbol, unknown>;
|
|
81
|
+
if (on) g[BUILD_READS] = new Map<string, ReadCaches>();
|
|
82
|
+
else delete g[BUILD_READS];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The caches a reader uses: per site, shared across readers during a build on the disk source; else its own. */
|
|
86
|
+
function cachesFor(site: Site, source: ContentSource): ReadCaches {
|
|
87
|
+
const shared = (globalThis as Record<symbol, unknown>)[BUILD_READS] as Map<string, ReadCaches> | undefined;
|
|
88
|
+
if (!shared || source !== diskSource) return newCaches(source);
|
|
89
|
+
let caches = shared.get(site.root);
|
|
90
|
+
if (!caches) shared.set(site.root, (caches = newCaches(onceSource(diskSource))));
|
|
91
|
+
return caches;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A reader over one source. Documents that can't be opened read as missing. Its reads are its
|
|
96
|
+
* own render's, except in a production build on the disk source, where every reader shares them
|
|
97
|
+
* (Decision 15: the disk doesn't change during a build).
|
|
98
|
+
*/
|
|
99
|
+
export function documentReader(site: Site, source: ContentSource): DocumentReader {
|
|
100
|
+
const caches = cachesFor(site, source);
|
|
101
|
+
const { reads, entries, lists } = caches;
|
|
102
|
+
source = caches.source;
|
|
103
|
+
|
|
104
|
+
/** The document as read, cached; no references resolved, so nothing here waits on another document. */
|
|
105
|
+
const raw = (collection: string, path: string) => {
|
|
106
|
+
const key = `${collection}\0${path}`;
|
|
107
|
+
let found = reads.get(key);
|
|
108
|
+
if (!found) {
|
|
109
|
+
found = readDocument(site, collection, path, source).catch((error: unknown) => {
|
|
110
|
+
if (error instanceof DocumentError) return null;
|
|
111
|
+
throw error;
|
|
112
|
+
});
|
|
113
|
+
reads.set(key, found);
|
|
114
|
+
}
|
|
115
|
+
return found;
|
|
116
|
+
};
|
|
117
|
+
const exists = async (collection: string, path: unknown) =>
|
|
118
|
+
typeof path === 'string' && path !== '' && !!own(site.collections, collection) && (await raw(collection, path)) !== null;
|
|
119
|
+
|
|
120
|
+
const reader: DocumentReader = {
|
|
121
|
+
document(collection, path) {
|
|
122
|
+
if (!own(site.collections, collection) || typeof path !== 'string' || path === '') return Promise.resolve(null);
|
|
123
|
+
const key = `${collection}\0${path}`;
|
|
124
|
+
let found = entries.get(key);
|
|
125
|
+
if (!found) {
|
|
126
|
+
found = raw(collection, path).then(async (read) => {
|
|
127
|
+
if (!read) return null;
|
|
128
|
+
const fields = own(site.collections, collection)!.fields ?? {};
|
|
129
|
+
// A fields document's fields; a section builder's page fields (M6c G2), when it has them.
|
|
130
|
+
const values = read.document.kind === 'fields' ? read.document.data : read.document.kind === undefined || read.document.kind === 'sections' ? read.document.data : undefined;
|
|
131
|
+
const data = values ? await resolveRefs(fields, renderData(fields, values), exists) : {};
|
|
132
|
+
return { collection, path, url: read.entry.url, title: read.entry.title, document: read.document, data };
|
|
133
|
+
});
|
|
134
|
+
entries.set(key, found);
|
|
135
|
+
}
|
|
136
|
+
return found;
|
|
137
|
+
},
|
|
138
|
+
documents(collection) {
|
|
139
|
+
if (!own(site.collections, collection)) return Promise.resolve([]);
|
|
140
|
+
let found = lists.get(collection);
|
|
141
|
+
if (!found) {
|
|
142
|
+
found = listDocuments(site, collection, source).then(async (listed) => {
|
|
143
|
+
// M9 Decision 7: a hidden page isn't on the site, so a listing of its collection leaves it out.
|
|
144
|
+
const read = await Promise.all(listed.filter((e) => !e.warning && !e.hidden).map((e) => reader.document(collection, e.path)));
|
|
145
|
+
return read.filter((r): r is ReadEntry => r !== null);
|
|
146
|
+
});
|
|
147
|
+
lists.set(collection, found);
|
|
148
|
+
}
|
|
149
|
+
return found;
|
|
150
|
+
},
|
|
151
|
+
exists: (collection, path) => exists(collection, path),
|
|
152
|
+
};
|
|
153
|
+
return reader;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Decision 14 (posts and tags): a route's reader on the disk, as `cms.read` gives a block, so
|
|
158
|
+
* `getStaticPaths` can count and filter documents by their fields. Shared across a production
|
|
159
|
+
* build, as every disk reader is. Exported from `caracms/content`.
|
|
160
|
+
*/
|
|
161
|
+
export function contentReader(site: Site): DocumentReader {
|
|
162
|
+
return documentReader(site, diskSource);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
type Exists = (collection: string, path: unknown) => Promise<boolean>;
|
|
166
|
+
|
|
167
|
+
/** A field's render value with references resolved: a missing one is null (Decision 6). */
|
|
168
|
+
async function resolveValue(field: Field, value: unknown, exists: Exists): Promise<unknown> {
|
|
169
|
+
if (field.kind === 'reference') return (await exists(field.to, value)) ? value : null;
|
|
170
|
+
if (field.kind === 'list' && Array.isArray(value)) {
|
|
171
|
+
if (field.item) {
|
|
172
|
+
const item = field.item;
|
|
173
|
+
return Promise.all(value.map((v) => resolveValue(item, v, exists)));
|
|
174
|
+
}
|
|
175
|
+
const fields = field.fields ?? {};
|
|
176
|
+
return Promise.all(value.map((v) => (v && typeof v === 'object' ? resolveRefs(fields, v as Record<string, unknown>, exists) : v)));
|
|
177
|
+
}
|
|
178
|
+
if (field.kind === 'group' && value && typeof value === 'object' && !Array.isArray(value)) return resolveRefs(field.fields, value as Record<string, unknown>, exists);
|
|
179
|
+
return value;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Data with every reference to a missing document as null (Decision 6). */
|
|
183
|
+
export async function resolveRefs(fields: Fields, data: Record<string, unknown>, exists: Exists): Promise<Record<string, unknown>> {
|
|
184
|
+
const out: Record<string, unknown> = { ...data };
|
|
185
|
+
for (const [key, field] of Object.entries(fields)) if (Object.hasOwn(data, key)) out[key] = await resolveValue(field, data[key], exists);
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** A block's or a fields document's parsed data as its component gets it: renderData, with missing references as null. */
|
|
190
|
+
export function renderFields(fields: Fields, data: Record<string, unknown>, reader: DocumentReader): Promise<Record<string, unknown>> {
|
|
191
|
+
return resolveRefs(fields, renderData(fields, data), reader.exists);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* M7 Decision 13: Site Settings as the page component gets it (document.ts → PageProps
|
|
196
|
+
* `settings`), read from the render's source (the disk at build, the editor's store in a
|
|
197
|
+
* preview, as Head reads it). The site's own `settings: { fields }` as a block's render data
|
|
198
|
+
* (links as render values, media resolved, a reference to a missing document as null),
|
|
199
|
+
* plus the package's `brand` (`name`, `logo`, `icon`) and `seo` (`title`, `description`,
|
|
200
|
+
* `image`, and the config's `titleSeparator`, so a code route can build its title as head.ts
|
|
201
|
+
* does: `${title}${seo.titleSeparator}${brand.name}`). `brand.name` is the site's name: Site Settings' brand name, else the default
|
|
202
|
+
* search title, else "Site" (as head.ts names the site). Never `editors`, `formRecipients`
|
|
203
|
+
* or `googleTag`, nor the document's own `title`. Never throws on content: a missing or
|
|
204
|
+
* broken file reads as empty.
|
|
205
|
+
*/
|
|
206
|
+
export async function settingsRenderData(site: Site, source: ContentSource, reader: DocumentReader = documentReader(site, source)): Promise<Record<string, unknown>> {
|
|
207
|
+
let raw: Record<string, unknown> = {};
|
|
208
|
+
try {
|
|
209
|
+
const text = await source.readText(join(site.root, SITE_SETTINGS_FILE));
|
|
210
|
+
const parsed = text ? (JSON.parse(text) as unknown) : null;
|
|
211
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) raw = parsed as Record<string, unknown>;
|
|
212
|
+
} catch {
|
|
213
|
+
/* unreadable: the site's fields at their empty values, the name "Site" */
|
|
214
|
+
}
|
|
215
|
+
const fields = own(site.collections, 'settings')?.fields ?? {};
|
|
216
|
+
const data = await renderFields(fields, parseFields(fields, raw), reader);
|
|
217
|
+
const brand = parseSettingsBrand(raw.brand);
|
|
218
|
+
const seo = parseSeo(raw.seo);
|
|
219
|
+
return {
|
|
220
|
+
...data,
|
|
221
|
+
brand: { name: brand.name || seo.title || 'Site', logo: renderMedia(brand.logo, { only: 'image' }), icon: renderMedia(brand.icon, { only: 'image' }) },
|
|
222
|
+
seo: { title: seo.title ?? '', description: seo.description ?? '', image: renderMedia(seo.image ?? null, { only: 'image' }), titleSeparator: site.titleSeparator ?? DEFAULT_TITLE_SEPARATOR },
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* M8 trial (item 1): Site Settings for the site's own `.astro` routes, exactly as the page
|
|
228
|
+
* component gets `Astro.props.settings` (settingsRenderData: rich text parsed, media
|
|
229
|
+
* resolved, links as render values, `brand` and `seo`). Exported from `caracms/content`:
|
|
230
|
+
*
|
|
231
|
+
* import { siteSettings } from 'caracms/content';
|
|
232
|
+
* import { site } from 'virtual:caracms/config';
|
|
233
|
+
* const settings = await siteSettings(site, Astro.locals);
|
|
234
|
+
*
|
|
235
|
+
* Read from the render's source (`locals`, as RenderDocument reads it): the disk, as deployed,
|
|
236
|
+
* unless an editor's preview set its store. `locals` may be left out (the disk).
|
|
237
|
+
*/
|
|
238
|
+
export function siteSettings(site: Site, locals?: object): Promise<Record<string, unknown>> {
|
|
239
|
+
return settingsRenderData(site, renderSourceOf(locals));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Why a shared-block entry doesn't render (Decision 8), or null when it does. */
|
|
243
|
+
export type SharedSkip = 'missing' | 'unknown-type';
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* A page's shared entry resolved from the render's source: the shared block's block
|
|
247
|
+
* (`source` as the entry's, so markers are the page's index), or why it's skipped:
|
|
248
|
+
* the shared block doesn't exist (or can't be opened), or its type isn't in the code.
|
|
249
|
+
*/
|
|
250
|
+
export async function resolveShared(entry: DocumentBlock, reader: DocumentReader): Promise<{ block: DocumentBlock } | { skip: SharedSkip; type: string }> {
|
|
251
|
+
const found = entry.shared ? await reader.document('shared', entry.shared) : null;
|
|
252
|
+
if (!found || found.document.kind !== 'shared') return { skip: 'missing', type: '' };
|
|
253
|
+
const block = (found.document as SharedDocument).block;
|
|
254
|
+
if (!block.known) return { skip: 'unknown-type', type: block.type };
|
|
255
|
+
return { block: { ...block, source: entry.source } };
|
|
256
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Site Settings' redirects, served (pre-M7 item 8): a public GET or HEAD that would be
|
|
3
|
+
* a 404 and matches a redirect's `from` is answered 301 to its `to`, the query kept.
|
|
4
|
+
* **A real page always wins**: the redirect is only looked at once the site has
|
|
5
|
+
* answered 404, so a page made at an old address is served, redirect or not.
|
|
6
|
+
*
|
|
7
|
+
* - **Read from the file the public site is built from**, the site's own
|
|
8
|
+
* `content/site-settings.json` (local mode: the file the editor writes; a production
|
|
9
|
+
* server: `main` as built), never live mode's drafts: a redirect added by a move
|
|
10
|
+
* goes out with the Publish that moves the page. Re-read when the file changes
|
|
11
|
+
* (its modification time), so local edits apply at once.
|
|
12
|
+
* - The editor's own paths (`/admin`, `/_caracms/`) are never redirected.
|
|
13
|
+
*/
|
|
14
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import type { MiddlewareHandler } from 'astro';
|
|
17
|
+
import { site } from 'virtual:caracms/config';
|
|
18
|
+
import { SITE_SETTINGS_FILE } from './reserved.ts';
|
|
19
|
+
import { parseRedirects, redirectFor, type Redirect } from './redirects.ts';
|
|
20
|
+
|
|
21
|
+
let kept: { mtimeMs: number; list: Redirect[] } | null = null;
|
|
22
|
+
|
|
23
|
+
/** The redirects in the site's Site Settings file, re-read when it changes. Empty when it's missing or unreadable. */
|
|
24
|
+
export async function siteRedirects(root: string): Promise<Redirect[]> {
|
|
25
|
+
const file = join(root, SITE_SETTINGS_FILE);
|
|
26
|
+
const info = await stat(file).catch(() => null);
|
|
27
|
+
if (!info) return [];
|
|
28
|
+
if (kept && kept.mtimeMs === info.mtimeMs) return kept.list;
|
|
29
|
+
let list: Redirect[] = [];
|
|
30
|
+
try {
|
|
31
|
+
list = parseRedirects((JSON.parse(await readFile(file, 'utf8')) as { redirects?: unknown }).redirects);
|
|
32
|
+
} catch {
|
|
33
|
+
list = [];
|
|
34
|
+
}
|
|
35
|
+
kept = { mtimeMs: info.mtimeMs, list };
|
|
36
|
+
return list;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const editorPath = (pathname: string) => pathname === '/admin' || pathname.startsWith('/admin/') || pathname.startsWith('/_caracms/');
|
|
40
|
+
|
|
41
|
+
export const onRequest: MiddlewareHandler = async (context, next) => {
|
|
42
|
+
const { request, url } = context;
|
|
43
|
+
if ((request.method !== 'GET' && request.method !== 'HEAD') || editorPath(url.pathname)) return next();
|
|
44
|
+
const response = await next();
|
|
45
|
+
if (response.status !== 404) return response;
|
|
46
|
+
const to = redirectFor(await siteRedirects(site.root), url.pathname);
|
|
47
|
+
if (!to) return response;
|
|
48
|
+
return new Response(null, { status: 301, headers: { Location: `${to}${url.search}`, 'Cache-Control': 'no-cache' } });
|
|
49
|
+
};
|
package/src/redirects.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redirects (pre-M7 item 8): Site Settings' `redirects`, a list of `{ from, to }`
|
|
3
|
+
* site addresses. Changing a document's address adds one, from the old address to
|
|
4
|
+
* the new; the package serves them as 301s on the public site (redirect-middleware.ts).
|
|
5
|
+
* Browser-safe (no imports beyond links.ts): the Redirects section reads them too.
|
|
6
|
+
*
|
|
7
|
+
* parseRedirects(raw) the list as the file holds it, cleaned
|
|
8
|
+
* redirectsAfterMove(list, from, to, add) the list once a document moves
|
|
9
|
+
* redirectFor(list, pathname) where a request for `pathname` goes, or null
|
|
10
|
+
*
|
|
11
|
+
* - **Both ends are `/paths`**, without a trailing slash (`/` stays `/`). An entry that
|
|
12
|
+
* isn't, or that goes nowhere (`from` = `to`), is dropped; so is a second entry for
|
|
13
|
+
* the same `from` (the first wins).
|
|
14
|
+
* - **Chains collapse** when a document moves: every entry that went to its old
|
|
15
|
+
* address goes to the new one, so `/a → /b` then `/b → /c` is `/a → /c` and
|
|
16
|
+
* `/b → /c`, never two hops.
|
|
17
|
+
* - **A real page wins**: an entry whose `from` is the moved document's new address
|
|
18
|
+
* is dropped (its page is back). The server also checks: it only redirects a
|
|
19
|
+
* request that would otherwise be a 404.
|
|
20
|
+
*/
|
|
21
|
+
import { normaliseAddress } from './links.ts';
|
|
22
|
+
|
|
23
|
+
export interface Redirect {
|
|
24
|
+
from: string;
|
|
25
|
+
to: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A site address as a redirect holds it: `/path`, no trailing slash, no query or anchor. Null when it isn't one. */
|
|
29
|
+
export function redirectAddress(value: unknown): string | null {
|
|
30
|
+
if (typeof value !== 'string') return null;
|
|
31
|
+
const v = value.trim();
|
|
32
|
+
if (!/^\/(?!\/)[^\s?#]*$/.test(v)) return null;
|
|
33
|
+
return normaliseAddress(v);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const same = (a: string, b: string) => a.toLowerCase() === b.toLowerCase();
|
|
37
|
+
|
|
38
|
+
/** The list as the file holds it: entries that aren't two addresses, go nowhere, or repeat a `from` are dropped. */
|
|
39
|
+
export function parseRedirects(raw: unknown): Redirect[] {
|
|
40
|
+
if (!Array.isArray(raw)) return [];
|
|
41
|
+
const out: Redirect[] = [];
|
|
42
|
+
for (const entry of raw) {
|
|
43
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
|
|
44
|
+
const from = redirectAddress((entry as Record<string, unknown>).from);
|
|
45
|
+
const to = redirectAddress((entry as Record<string, unknown>).to);
|
|
46
|
+
if (!from || !to || same(from, to) || out.some((r) => same(r.from, from))) continue;
|
|
47
|
+
out.push({ from, to });
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The list once a document moves from `from` to `to` (both its public addresses):
|
|
54
|
+
* entries to `from` go to `to` (chains collapse), an entry from `to` goes (a page is
|
|
55
|
+
* there now), and, when `add`, `from → to` is added (replacing any entry from `from`).
|
|
56
|
+
* `add` is false for a document that was never live (made since the last Publish).
|
|
57
|
+
*/
|
|
58
|
+
export function redirectsAfterMove(list: readonly Redirect[], from: string, to: string, add: boolean): Redirect[] {
|
|
59
|
+
const next = list
|
|
60
|
+
.filter((r) => !same(r.from, to) && !(add && same(r.from, from)))
|
|
61
|
+
.map((r) => (same(r.to, from) ? { from: r.from, to } : r))
|
|
62
|
+
.filter((r) => !same(r.from, r.to));
|
|
63
|
+
if (add && !same(from, to)) next.push({ from, to });
|
|
64
|
+
return next;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Where a request for `pathname` goes, or null. Matched without a trailing slash; letter case counts, as it does for pages. */
|
|
68
|
+
export function redirectFor(list: readonly Redirect[], pathname: string): string | null {
|
|
69
|
+
let path: string;
|
|
70
|
+
try {
|
|
71
|
+
path = normaliseAddress(decodeURI(pathname));
|
|
72
|
+
} catch {
|
|
73
|
+
path = normaliseAddress(pathname);
|
|
74
|
+
}
|
|
75
|
+
return list.find((r) => r.from === path)?.to ?? null;
|
|
76
|
+
}
|
package/src/relink.ts
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rewriting links when a document's address changes (pre-M7 item 8): one document's
|
|
3
|
+
* file text in, the same text out with every link to the old address pointing at the
|
|
4
|
+
* new one. **Only those strings change**: the rest of the file is its own text, byte
|
|
5
|
+
* for byte (the change is spliced in, as serialize.ts splices an edited key).
|
|
6
|
+
*
|
|
7
|
+
What counts as a link to the old address is found **by the schema** (block types by
|
|
8
|
+
* their `type`, lists by index), so only real links change, never a text that happens
|
|
9
|
+
* to read the same, nor a key the schema doesn't know:
|
|
10
|
+
*
|
|
11
|
+
* - **A link field's or a button's `href`** that is a `/path` to it: `/studio/visit`,
|
|
12
|
+
* `/studio/visit/`, `/studio/visit#hours`, `/studio/visit?x=1`. The path is swapped;
|
|
13
|
+
* a trailing slash, query or anchor stays.
|
|
14
|
+
* - **Rich text's links** (`[text](/studio/visit)`, rich-text.ts's on-disk form), with
|
|
15
|
+
* the same rule for the href and its escapes kept.
|
|
16
|
+
* - **A reference** to the document (`reference({ to })`, stored as its path in its
|
|
17
|
+
* collection, M5a Decision 6).
|
|
18
|
+
*
|
|
19
|
+
* Shared-block uses (`{ "shared": "footer" }`) never change: a shared block has no
|
|
20
|
+
* address, so it never moves. Site Settings' own `redirects` are the caller's
|
|
21
|
+
* (redirects.ts), never rewritten here. A file that isn't valid JSON is left as it is.
|
|
22
|
+
*
|
|
23
|
+
* M6c G4: the same walk finds and rewrites **links to files** (`/files/…`, caracms/files):
|
|
24
|
+
* `fileLinksIn` lists a text's file links by schema (the library's "linked from",
|
|
25
|
+
* Decision 8), and `refileText` points every link to one file at another (Replace…,
|
|
26
|
+
* Decision 9), `?download` and a fragment kept. A plain-text mention of an address is
|
|
27
|
+
* never a link: only link fields, buttons and rich text count.
|
|
28
|
+
*/
|
|
29
|
+
import type { ResolvedCollection, ResolvedSite } from './config.ts';
|
|
30
|
+
import { own } from './document.ts';
|
|
31
|
+
import type { Field } from './fields.ts';
|
|
32
|
+
import { fileAddress, fileKeyOf } from './files.ts';
|
|
33
|
+
import { internalLinkPath, normaliseAddress } from './links.ts';
|
|
34
|
+
import { parseSource, type JsonNode } from './json-source.ts';
|
|
35
|
+
|
|
36
|
+
export interface AddressMove {
|
|
37
|
+
/** The document's collection. */
|
|
38
|
+
collection: string;
|
|
39
|
+
/** Its path in the collection, before and after. */
|
|
40
|
+
fromPath: string;
|
|
41
|
+
toPath: string;
|
|
42
|
+
/** Its public address, before and after (`/studio/visit`). */
|
|
43
|
+
fromUrl: string;
|
|
44
|
+
toUrl: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type Segment = string | number;
|
|
48
|
+
|
|
49
|
+
/** An href pointing at the old address, pointed at the new one; null when it doesn't point there. */
|
|
50
|
+
export function relinkHref(href: string, move: Pick<AddressMove, 'fromUrl' | 'toUrl'>): string | null {
|
|
51
|
+
const path = internalLinkPath(href);
|
|
52
|
+
if (path === null || path !== normaliseAddress(move.fromUrl)) return null;
|
|
53
|
+
const typed = href.trim();
|
|
54
|
+
const pathPart = typed.replace(/[?#].*$/, '');
|
|
55
|
+
const rest = typed.slice(pathPart.length);
|
|
56
|
+
// A trailing slash as typed stays (`/studio/visit/` → `/visit/`), except on `/`.
|
|
57
|
+
const slash = pathPart.length > 1 && pathPart.endsWith('/') && move.toUrl !== '/';
|
|
58
|
+
return `${move.toUrl}${slash ? '/' : ''}${rest}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Rich text's `](href)`: the href runs to the first unescaped `)`, with `\` and `)` escaped inside it. */
|
|
62
|
+
const MARKDOWN_HREF = /\]\(((?:\\[\\)]|[^\\)])*)\)/g;
|
|
63
|
+
|
|
64
|
+
/** Rewrites one href: the new one, or null to leave it. */
|
|
65
|
+
type HrefRewrite = (href: string) => string | null;
|
|
66
|
+
|
|
67
|
+
/** A rich-text string's hrefs, unescaped, in order. */
|
|
68
|
+
export function richTextHrefs(value: string): string[] {
|
|
69
|
+
if (!value.includes('](')) return [];
|
|
70
|
+
return [...value.matchAll(MARKDOWN_HREF)].map((m) => m[1]!.replace(/\\([\\)])/g, '$1'));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A rich-text string with `rewrite` applied to each link, its escapes kept; null when none changed. */
|
|
74
|
+
function rewriteRichText(value: string, rewrite: HrefRewrite): { text: string; links: number } | null {
|
|
75
|
+
if (!value.includes('](')) return null;
|
|
76
|
+
let links = 0;
|
|
77
|
+
const out = value.replace(MARKDOWN_HREF, (whole, escaped: string) => {
|
|
78
|
+
const href = escaped.replace(/\\([\\)])/g, '$1');
|
|
79
|
+
const next = rewrite(href);
|
|
80
|
+
if (next === null || next === href) return whole;
|
|
81
|
+
links++;
|
|
82
|
+
return `](${next.replace(/[\\)]/g, '\\$&')})`;
|
|
83
|
+
});
|
|
84
|
+
return links ? { text: out, links } : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A string with its rich-text links to the old address rewritten; null when none were. */
|
|
88
|
+
export function relinkRichText(value: string, move: Pick<AddressMove, 'fromUrl' | 'toUrl'>): string | null {
|
|
89
|
+
return rewriteRichText(value, (href) => relinkHref(href, move))?.text ?? null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The field a value in the file belongs to, by its path from the file's root (blocks by
|
|
94
|
+
* their `type`, into lists by index), with the rest of the path inside the field's
|
|
95
|
+
* value (`['href']` in a link, `[0, 'href']` in buttons); null where the schema has none.
|
|
96
|
+
*/
|
|
97
|
+
function fieldAt(site: Pick<ResolvedSite, 'blocks'>, collection: Pick<ResolvedCollection, 'kind' | 'fields'>, raw: unknown, path: Segment[]): { field: Field; rest: Segment[] } | null {
|
|
98
|
+
const obj = (v: unknown) => (v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null);
|
|
99
|
+
const blockField = (block: unknown, key: Segment | undefined) => {
|
|
100
|
+
const type = obj(block)?.type;
|
|
101
|
+
const config = typeof type === 'string' ? own(site.blocks, type) : undefined;
|
|
102
|
+
return typeof key === 'string' && config ? (own(config.fields, key) ?? null) : null;
|
|
103
|
+
};
|
|
104
|
+
let field: Field | null;
|
|
105
|
+
let rest: Segment[];
|
|
106
|
+
switch (collection.kind) {
|
|
107
|
+
case 'sections': {
|
|
108
|
+
// M6c (G2): a page field, a top-level key beside the blocks.
|
|
109
|
+
if (typeof path[0] === 'string' && path[0] !== 'blocks') {
|
|
110
|
+
field = path[0] !== 'seo' ? (own(collection.fields ?? {}, path[0]) ?? null) : null;
|
|
111
|
+
rest = path.slice(1);
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
if (path[0] !== 'blocks' || typeof path[1] !== 'number') return null;
|
|
115
|
+
const blocks = obj(raw)?.blocks;
|
|
116
|
+
field = blockField(Array.isArray(blocks) ? blocks[path[1]] : null, path[2]);
|
|
117
|
+
rest = path.slice(3);
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
case 'shared':
|
|
121
|
+
if (path[0] !== 'block') return null;
|
|
122
|
+
field = blockField(obj(raw)?.block, path[1]);
|
|
123
|
+
rest = path.slice(2);
|
|
124
|
+
break;
|
|
125
|
+
default:
|
|
126
|
+
field = typeof path[0] === 'string' ? (own(collection.fields ?? {}, path[0]) ?? null) : null;
|
|
127
|
+
rest = path.slice(1);
|
|
128
|
+
}
|
|
129
|
+
// Into lists (an index, then, a list of several fields, the item's key) and groups (a key).
|
|
130
|
+
while (field && (field.kind === 'list' || field.kind === 'group') && rest.length) {
|
|
131
|
+
if (field.kind === 'group') {
|
|
132
|
+
field = typeof rest[0] === 'string' ? (own(field.fields, rest[0]) ?? null) : null;
|
|
133
|
+
rest = rest.slice(1);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (typeof rest[0] !== 'number') return null;
|
|
137
|
+
if (field.item) {
|
|
138
|
+
field = field.item;
|
|
139
|
+
rest = rest.slice(1);
|
|
140
|
+
} else {
|
|
141
|
+
field = typeof rest[1] === 'string' ? (own(field.fields ?? {}, rest[1]) ?? null) : null;
|
|
142
|
+
rest = rest.slice(2);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return field ? { field, rest } : null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** A string at `rest` inside a field's value, with its links to the old address rewritten; null when it isn't a link there or doesn't point there. */
|
|
149
|
+
function relinkIn(field: Field, rest: Segment[], value: string, move: AddressMove): string | null {
|
|
150
|
+
switch (field.kind) {
|
|
151
|
+
case 'link':
|
|
152
|
+
return rest.length === 1 && rest[0] === 'href' ? relinkHref(value, move) : null;
|
|
153
|
+
case 'buttons':
|
|
154
|
+
return rest.length === 2 && typeof rest[0] === 'number' && rest[1] === 'href' ? relinkHref(value, move) : null;
|
|
155
|
+
case 'richText':
|
|
156
|
+
return relinkRichText(value, move);
|
|
157
|
+
case 'reference':
|
|
158
|
+
return rest.length === 0 && field.to === move.collection && value.trim() === move.fromPath ? move.toPath : null;
|
|
159
|
+
default:
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Every string in the file that the schema places in a field, depth first in file order,
|
|
166
|
+
* with that field and the rest of the path inside its value. Site Settings' `redirects`
|
|
167
|
+
* are skipped (redirects.ts's own). Null when the text isn't JSON.
|
|
168
|
+
*/
|
|
169
|
+
function fieldStrings(
|
|
170
|
+
text: string,
|
|
171
|
+
site: Pick<ResolvedSite, 'blocks'>,
|
|
172
|
+
collection: Pick<ResolvedCollection, 'kind' | 'fields'>,
|
|
173
|
+
visit: (field: Field, rest: Segment[], value: string, node: { start: number; end: number }) => void,
|
|
174
|
+
): boolean {
|
|
175
|
+
let source;
|
|
176
|
+
try {
|
|
177
|
+
source = parseSource(text);
|
|
178
|
+
} catch {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
const walk = (node: JsonNode, path: Segment[]) => {
|
|
182
|
+
if (node.kind === 'object') {
|
|
183
|
+
for (const m of node.members) {
|
|
184
|
+
// Site Settings' redirects are redirects.ts's to change, never a link to rewrite.
|
|
185
|
+
if (collection.kind === 'settings' && path.length === 0 && m.key === 'redirects') continue;
|
|
186
|
+
walk(m.value, [...path, m.key]);
|
|
187
|
+
}
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (node.kind === 'array') {
|
|
191
|
+
node.items.forEach((item, i) => walk(item, [...path, i]));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (typeof node.value !== 'string') return;
|
|
195
|
+
const at = fieldAt(site, collection, source.value, path);
|
|
196
|
+
if (at) visit(at.field, at.rest, node.value, node);
|
|
197
|
+
};
|
|
198
|
+
walk(source.root, []);
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** A string's links where it sits in its field: a link's or a button's href, or rich text's hrefs. */
|
|
203
|
+
function hrefsAt(field: Field, rest: Segment[], value: string): 'href' | 'rich' | null {
|
|
204
|
+
if (field.kind === 'link') return rest.length === 1 && rest[0] === 'href' ? 'href' : null;
|
|
205
|
+
if (field.kind === 'buttons') return rest.length === 2 && typeof rest[0] === 'number' && rest[1] === 'href' ? 'href' : null;
|
|
206
|
+
if (field.kind === 'richText') return value.includes('](') ? 'rich' : null;
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Splice edits into a text, latest first. */
|
|
211
|
+
function splice(text: string, edits: { start: number; end: number; text: string }[]): string {
|
|
212
|
+
let out = text;
|
|
213
|
+
for (const e of edits.sort((a, b) => b.start - a.start)) out = out.slice(0, e.start) + e.text + out.slice(e.end);
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The file's text with every link and reference to the moved document pointing at
|
|
219
|
+
* its new address (or path); `text` itself when nothing points there. `collection` is
|
|
220
|
+
* the file's own collection (for its schema).
|
|
221
|
+
*/
|
|
222
|
+
export function relinkText(
|
|
223
|
+
text: string,
|
|
224
|
+
move: AddressMove,
|
|
225
|
+
site: Pick<ResolvedSite, 'blocks'>,
|
|
226
|
+
collection: Pick<ResolvedCollection, 'kind' | 'fields'>,
|
|
227
|
+
): { text: string; links: number } {
|
|
228
|
+
const edits: { start: number; end: number; text: string }[] = [];
|
|
229
|
+
const ok = fieldStrings(text, site, collection, (field, rest, value, node) => {
|
|
230
|
+
const next = relinkIn(field, rest, value, move);
|
|
231
|
+
if (next !== null && next !== value) edits.push({ start: node.start, end: node.end, text: JSON.stringify(next) });
|
|
232
|
+
});
|
|
233
|
+
if (!ok || edits.length === 0) return { text, links: 0 };
|
|
234
|
+
return { text: splice(text, edits), links: edits.length };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* M6c G4: every file link in the file's text, by schema (link fields, buttons, rich text),
|
|
239
|
+
* as the key and download flag `fileKeyOf` reads, in file order. A text that isn't JSON has none.
|
|
240
|
+
*/
|
|
241
|
+
export function fileLinksIn(text: string, site: Pick<ResolvedSite, 'blocks'>, collection: Pick<ResolvedCollection, 'kind' | 'fields'>): { key: string; download: boolean }[] {
|
|
242
|
+
const out: { key: string; download: boolean }[] = [];
|
|
243
|
+
fieldStrings(text, site, collection, (field, rest, value) => {
|
|
244
|
+
const how = hrefsAt(field, rest, value);
|
|
245
|
+
const hrefs = how === 'href' ? [value] : how === 'rich' ? richTextHrefs(value) : [];
|
|
246
|
+
for (const href of hrefs) {
|
|
247
|
+
const file = fileKeyOf(href.trim());
|
|
248
|
+
if (file) out.push(file);
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
return out;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** A link to the file `from` pointed at `to`, `?download` and a fragment kept; null when it isn't a link to `from`. */
|
|
255
|
+
export function refileHref(href: string, from: string, to: string): string | null {
|
|
256
|
+
const typed = href.trim();
|
|
257
|
+
const file = fileKeyOf(typed);
|
|
258
|
+
if (!file || file.key !== from) return null;
|
|
259
|
+
const hash = typed.indexOf('#');
|
|
260
|
+
return `${fileAddress(to, { download: file.download })}${hash === -1 ? '' : typed.slice(hash)}`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* M6c G4 (Replace…, Decision 9): the file's text with every link to the file `from` (a
|
|
265
|
+
* key) pointing at `to`, by schema, spliced in as relinkText does; `links` counts the
|
|
266
|
+
* links rewritten (a rich text holding two counts two).
|
|
267
|
+
*/
|
|
268
|
+
export function refileText(
|
|
269
|
+
text: string,
|
|
270
|
+
from: string,
|
|
271
|
+
to: string,
|
|
272
|
+
site: Pick<ResolvedSite, 'blocks'>,
|
|
273
|
+
collection: Pick<ResolvedCollection, 'kind' | 'fields'>,
|
|
274
|
+
): { text: string; links: number } {
|
|
275
|
+
const edits: { start: number; end: number; text: string }[] = [];
|
|
276
|
+
let links = 0;
|
|
277
|
+
const ok = fieldStrings(text, site, collection, (field, rest, value, node) => {
|
|
278
|
+
const how = hrefsAt(field, rest, value);
|
|
279
|
+
if (how === 'href') {
|
|
280
|
+
const next = refileHref(value, from, to);
|
|
281
|
+
if (next === null) return;
|
|
282
|
+
links++;
|
|
283
|
+
edits.push({ start: node.start, end: node.end, text: JSON.stringify(next) });
|
|
284
|
+
} else if (how === 'rich') {
|
|
285
|
+
const next = rewriteRichText(value, (href) => refileHref(href, from, to));
|
|
286
|
+
if (!next) return;
|
|
287
|
+
links += next.links;
|
|
288
|
+
edits.push({ start: node.start, end: node.end, text: JSON.stringify(next.text) });
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
if (!ok || edits.length === 0) return { text, links: 0 };
|
|
292
|
+
return { text: splice(text, edits), links };
|
|
293
|
+
}
|