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,221 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* A public form (M6 Decision 17), from the site's config (`defineSite({ forms })`):
|
|
4
|
+
*
|
|
5
|
+
* <Form form="contact" preview={cms.preview} />
|
|
6
|
+
*
|
|
7
|
+
* Plain, accessible markup with classes the site styles (`.cms-form`, `.cms-form-field`,
|
|
8
|
+
* `.cms-form-label`, `.cms-form-help`, `.cms-form-error`, `.cms-form-status`). A field
|
|
9
|
+
* with a `prefix` wraps it and its input in `.cms-form-prefixed` (style the two as one
|
|
10
|
+
* box), the prefix in `.cms-form-prefix`; the input names it in `aria-describedby`, since
|
|
11
|
+
* the package can't know whether the label already says it. A `placeholder` goes on the
|
|
12
|
+
* input; the `<label>` stays either way (a site that hides labels hides them visually,
|
|
13
|
+
* never from screen readers). **It posts
|
|
14
|
+
* without JavaScript**, files included (`multipart/form-data` only when the form has a
|
|
15
|
+
* files field), to `POST /_caracms/form` with its hidden `form` and `return` fields and
|
|
16
|
+
* the honeypot (rules.ts → HONEYPOT, the name the endpoint reads). A field's error line
|
|
17
|
+
* shows by CSS alone (`:user-invalid`).
|
|
18
|
+
*
|
|
19
|
+
* One small script (form-client.ts) enhances it: `fetch`, the thanks line, the file
|
|
20
|
+
* limits, `?sent=1`, and the Turnstile widget when `TURNSTILE_SITE_KEY` was set at build.
|
|
21
|
+
* With it, a files field's picks add up, listed under the input (`.cms-form-files`, each
|
|
22
|
+
* `.cms-form-file` with its name, size and a Remove button); without it, the native input.
|
|
23
|
+
* Only a page with a form carries it. In a preview the form is `inert`: nothing in the
|
|
24
|
+
* editor can send it, and a click falls through to the block.
|
|
25
|
+
*
|
|
26
|
+
* An id that isn't one of the site's forms renders nothing (and says so in a preview).
|
|
27
|
+
*
|
|
28
|
+
* **A site that draws its own look** (M9 port friction 10) has these hooks, each optional;
|
|
29
|
+
* left out, the form renders exactly as above:
|
|
30
|
+
*
|
|
31
|
+
* - `class` and any other attribute (`data-*`, `id`, `aria-*`…) go on the `<form>`;
|
|
32
|
+
* the ones the form sets itself (`action`, `method`, `enctype`, `inert`, its own
|
|
33
|
+
* `data-caracms-*`, `data-max-*`, `data-file-kinds`, `data-thanks`, `data-failed`) can't be replaced;
|
|
34
|
+
* - `submitClass`: classes on the submit button, beside `cms-form-submit`;
|
|
35
|
+
* - `thanks`: the line after a send (default "Thanks, we'll be in touch shortly.");
|
|
36
|
+
* `failed`: the line when a send fails without the endpoint's own sentence
|
|
37
|
+
* (default "Something went wrong. Please email us directly.");
|
|
38
|
+
* - `errors`: the error line by field type (`{ email: 'Please check your email.' }`),
|
|
39
|
+
* in place of the package's wording; a field's own `error` in the config still wins;
|
|
40
|
+
* - `actions`: wrap the submit button and the status line in `<div class="cms-form-actions">`,
|
|
41
|
+
* so site CSS can lay them out in a row;
|
|
42
|
+
* - `labelPlaceholders`: a text, email, tel or textarea field with no `placeholder` of its
|
|
43
|
+
* own uses its label as one (the `<label>` stays in the markup).
|
|
44
|
+
*
|
|
45
|
+
* A textarea's height is its field's `rows` in the config (default 6).
|
|
46
|
+
*/
|
|
47
|
+
import { site } from 'virtual:caracms/config';
|
|
48
|
+
import { build } from 'virtual:caracms/public';
|
|
49
|
+
import { cleanReturn, FILE_ACCEPT, FILE_KINDS, HONEYPOT, MAX_FILE_BYTES, MAX_FILES, PLACEHOLDER_TYPES, type FormFieldType, type ResolvedFormField } from '../forms/rules.ts';
|
|
50
|
+
import { passThrough } from './attributes.ts';
|
|
51
|
+
|
|
52
|
+
interface Props {
|
|
53
|
+
/** The form's id in the site's config. */
|
|
54
|
+
form: string;
|
|
55
|
+
/** A preview render (the block's `cms.preview`). */
|
|
56
|
+
preview?: boolean;
|
|
57
|
+
/** The submit button's text. Default "Send". */
|
|
58
|
+
submit?: string;
|
|
59
|
+
/** Classes on the `<form>`, beside `cms-form`. */
|
|
60
|
+
class?: string;
|
|
61
|
+
/** Classes on the submit button, beside `cms-form-submit`. */
|
|
62
|
+
submitClass?: string;
|
|
63
|
+
/** The line after a send. Default "Thanks, we'll be in touch shortly." */
|
|
64
|
+
thanks?: string;
|
|
65
|
+
/** The line when a send fails and the endpoint gave no sentence of its own. Default "Something went wrong. Please email us directly." */
|
|
66
|
+
failed?: string;
|
|
67
|
+
/** The error line by field type, in place of the package's wording. A field's own `error` (config) still wins. */
|
|
68
|
+
errors?: Partial<Record<FormFieldType, string>>;
|
|
69
|
+
/** Wrap the submit button and the status line in `<div class="cms-form-actions">`. */
|
|
70
|
+
actions?: boolean;
|
|
71
|
+
/** A field that takes a placeholder and sets none uses its label as one. */
|
|
72
|
+
labelPlaceholders?: boolean;
|
|
73
|
+
/** Anything else (`data-*`, `id`, `aria-*`…) goes on the `<form>`. */
|
|
74
|
+
[attribute: string]: unknown;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const {
|
|
78
|
+
form: id,
|
|
79
|
+
preview = false,
|
|
80
|
+
submit = 'Send',
|
|
81
|
+
class: className,
|
|
82
|
+
submitClass,
|
|
83
|
+
thanks,
|
|
84
|
+
failed,
|
|
85
|
+
errors,
|
|
86
|
+
actions = false,
|
|
87
|
+
labelPlaceholders = false,
|
|
88
|
+
...given
|
|
89
|
+
} = Astro.props;
|
|
90
|
+
// The form's own attributes stay its own: a pass-through can't replace them.
|
|
91
|
+
const OWN = new Set(['action', 'method', 'enctype', 'inert', 'data-max-files', 'data-max-bytes', 'data-file-kinds', 'data-thanks', 'data-failed']);
|
|
92
|
+
const rest = passThrough(Object.fromEntries(Object.entries(given).filter(([name]) => !OWN.has(name.toLowerCase()) && !name.toLowerCase().startsWith('data-caracms-'))));
|
|
93
|
+
const form = Object.hasOwn(site.forms, id) ? site.forms[id]! : null;
|
|
94
|
+
const files = form?.fields.some((f) => f.type === 'files') ?? false;
|
|
95
|
+
const back = cleanReturn(preview ? '/' : Astro.url.pathname);
|
|
96
|
+
const turnstileKey = build.turnstileSiteKey;
|
|
97
|
+
const fieldId = (name: string) => `cms-form-${id}-${name}`;
|
|
98
|
+
|
|
99
|
+
const DEFAULT_ERROR: Record<ResolvedFormField['type'], string> = {
|
|
100
|
+
text: 'Please fill this in.',
|
|
101
|
+
email: 'Please enter your email address, like name@example.com.',
|
|
102
|
+
tel: 'Please enter a phone number.',
|
|
103
|
+
textarea: 'Please write a message.',
|
|
104
|
+
select: 'Please choose one.',
|
|
105
|
+
files: 'Please attach a file.',
|
|
106
|
+
};
|
|
107
|
+
const placeholderOf = (f: ResolvedFormField) => f.placeholder ?? (labelPlaceholders && PLACEHOLDER_TYPES.includes(f.type) ? f.label : undefined);
|
|
108
|
+
const errorOf = (f: ResolvedFormField) => f.error || errors?.[f.type] || DEFAULT_ERROR[f.type];
|
|
109
|
+
const described = (f: ResolvedFormField) =>
|
|
110
|
+
[f.prefix ? `${fieldId(f.name)}-prefix` : '', f.help ? `${fieldId(f.name)}-help` : '', `${fieldId(f.name)}-error`].filter(Boolean).join(' ');
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
{
|
|
114
|
+
form ? (
|
|
115
|
+
<form
|
|
116
|
+
{...rest}
|
|
117
|
+
class:list={['cms-form', className]}
|
|
118
|
+
action="/_caracms/form"
|
|
119
|
+
method="post"
|
|
120
|
+
enctype={files ? 'multipart/form-data' : undefined}
|
|
121
|
+
data-caracms-form={id}
|
|
122
|
+
data-max-files={files ? MAX_FILES : undefined}
|
|
123
|
+
data-max-bytes={files ? MAX_FILE_BYTES : undefined}
|
|
124
|
+
data-file-kinds={files ? FILE_KINDS : undefined}
|
|
125
|
+
data-thanks={thanks || undefined}
|
|
126
|
+
data-failed={failed || undefined}
|
|
127
|
+
inert={preview || undefined}
|
|
128
|
+
>
|
|
129
|
+
<input type="hidden" name="form" value={id} />
|
|
130
|
+
<input type="hidden" name="return" value={back} />
|
|
131
|
+
<div class="cms-form-hp" aria-hidden="true">
|
|
132
|
+
<label>
|
|
133
|
+
{HONEYPOT}
|
|
134
|
+
<input type="text" name={HONEYPOT} tabindex="-1" autocomplete="off" />
|
|
135
|
+
</label>
|
|
136
|
+
</div>
|
|
137
|
+
{form.fields.map((f) => (
|
|
138
|
+
<div class="cms-form-field" data-type={f.type}>
|
|
139
|
+
<label class="cms-form-label" for={fieldId(f.name)}>
|
|
140
|
+
{f.label}
|
|
141
|
+
{f.optional && <span class="cms-form-optional"> (optional)</span>}
|
|
142
|
+
</label>
|
|
143
|
+
{f.type === 'textarea' ? (
|
|
144
|
+
<textarea id={fieldId(f.name)} name={f.name} rows={String(f.rows ?? 6)} required={!f.optional} autocomplete={f.autocomplete} placeholder={placeholderOf(f)} aria-describedby={described(f)} />
|
|
145
|
+
) : f.type === 'select' ? (
|
|
146
|
+
<select id={fieldId(f.name)} name={f.name} required={!f.optional} autocomplete={f.autocomplete} aria-describedby={described(f)}>
|
|
147
|
+
<option value="">Choose…</option>
|
|
148
|
+
{f.options!.map((o) => (
|
|
149
|
+
<option value={o.value}>{o.label}</option>
|
|
150
|
+
))}
|
|
151
|
+
</select>
|
|
152
|
+
) : f.type === 'files' ? (
|
|
153
|
+
<>
|
|
154
|
+
<input id={fieldId(f.name)} type="file" name={f.name} multiple accept={FILE_ACCEPT} required={!f.optional} aria-describedby={described(f)} />
|
|
155
|
+
<ul class="cms-form-files" data-caracms-files aria-label={`${f.label}: chosen files`} hidden />
|
|
156
|
+
</>
|
|
157
|
+
) : f.prefix ? (
|
|
158
|
+
<div class="cms-form-prefixed">
|
|
159
|
+
<span class="cms-form-prefix" id={`${fieldId(f.name)}-prefix`}>
|
|
160
|
+
{f.prefix}
|
|
161
|
+
</span>
|
|
162
|
+
<input id={fieldId(f.name)} type={f.type} name={f.name} required={!f.optional} autocomplete={f.autocomplete} placeholder={placeholderOf(f)} aria-describedby={described(f)} />
|
|
163
|
+
</div>
|
|
164
|
+
) : (
|
|
165
|
+
<input id={fieldId(f.name)} type={f.type} name={f.name} required={!f.optional} autocomplete={f.autocomplete} placeholder={placeholderOf(f)} aria-describedby={described(f)} />
|
|
166
|
+
)}
|
|
167
|
+
{f.help && (
|
|
168
|
+
<p class="cms-form-help" id={`${fieldId(f.name)}-help`}>
|
|
169
|
+
{f.help}
|
|
170
|
+
</p>
|
|
171
|
+
)}
|
|
172
|
+
<p class="cms-form-error" id={`${fieldId(f.name)}-error`}>
|
|
173
|
+
{errorOf(f)}
|
|
174
|
+
</p>
|
|
175
|
+
</div>
|
|
176
|
+
))}
|
|
177
|
+
{turnstileKey && <div class="cms-form-turnstile" data-caracms-turnstile data-sitekey={turnstileKey} />}
|
|
178
|
+
{actions ? (
|
|
179
|
+
<div class="cms-form-actions">
|
|
180
|
+
<button type="submit" class:list={['cms-form-submit', submitClass]}>
|
|
181
|
+
{submit}
|
|
182
|
+
</button>
|
|
183
|
+
<p class="cms-form-status" role="status" aria-live="polite" data-caracms-form-status />
|
|
184
|
+
</div>
|
|
185
|
+
) : (
|
|
186
|
+
<>
|
|
187
|
+
<button type="submit" class:list={['cms-form-submit', submitClass]}>
|
|
188
|
+
{submit}
|
|
189
|
+
</button>
|
|
190
|
+
<p class="cms-form-status" role="status" aria-live="polite" data-caracms-form-status />
|
|
191
|
+
</>
|
|
192
|
+
)}
|
|
193
|
+
</form>
|
|
194
|
+
) : preview ? (
|
|
195
|
+
<p class="cms-form-missing">There's no form "{id}" in the site's config.</p>
|
|
196
|
+
) : null
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
<script>
|
|
200
|
+
import { initForms } from './form-client.ts';
|
|
201
|
+
|
|
202
|
+
initForms();
|
|
203
|
+
</script>
|
|
204
|
+
|
|
205
|
+
<style is:global>
|
|
206
|
+
.cms-form-hp {
|
|
207
|
+
position: absolute;
|
|
208
|
+
left: -10000px;
|
|
209
|
+
width: 1px;
|
|
210
|
+
height: 1px;
|
|
211
|
+
overflow: hidden;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.cms-form-error {
|
|
215
|
+
display: none;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.cms-form-field:has(:user-invalid) .cms-form-error {
|
|
219
|
+
display: block;
|
|
220
|
+
}
|
|
221
|
+
</style>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* The page's head (M6 Decisions 15 and 16): the site's page component puts it in its
|
|
4
|
+
* `<head>`, as it does `<Fonts />`, and keeps its own icon links:
|
|
5
|
+
*
|
|
6
|
+
* <Head document={document} collection={collection} preview={preview} listPage={listPage} />
|
|
7
|
+
*
|
|
8
|
+
* It writes `<title>`, the description, the canonical link, `og:` and `twitter:` tags
|
|
9
|
+
* and the Google tag (the rules: ../head.ts). Site Settings is read from the file the
|
|
10
|
+
* build or the preview renders from (read.ts → renderSourceOf), so a preview shows the
|
|
11
|
+
* drafts' defaults and a build the deployed ones. The Google tag never goes into a
|
|
12
|
+
* preview or a dev build; the canonical link and `og:url` only with `SITE_URL`.
|
|
13
|
+
*/
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { site } from 'virtual:caracms/config';
|
|
16
|
+
import { build } from 'virtual:caracms/public';
|
|
17
|
+
import { documentUrl } from '../content.ts';
|
|
18
|
+
import type { PageProps } from '../document.ts';
|
|
19
|
+
import { googleTagSnippet, headOf } from '../head.ts';
|
|
20
|
+
import { readMediaConfig } from '../media.ts';
|
|
21
|
+
import { renderSourceOf } from '../read.ts';
|
|
22
|
+
import { SITE_SETTINGS_FILE } from '../reserved.ts';
|
|
23
|
+
|
|
24
|
+
/** The page component's props; `data` (its own fields) and `settings` aren't needed here. */
|
|
25
|
+
type Props = Omit<PageProps, 'data' | 'settings' | 'listPage' | 'read' | 'path'> & { data?: PageProps['data']; settings?: PageProps['settings']; listPage?: number; read?: PageProps['read']; path?: PageProps['path'] };
|
|
26
|
+
const { document, collection, preview = false } = Astro.props;
|
|
27
|
+
// Posts and tags, Decision 16: page 2 onwards says so in its title. A preview is always page 1.
|
|
28
|
+
const listPage = !preview && Number.isInteger(Astro.props.listPage) && Astro.props.listPage! >= 1 ? Astro.props.listPage! : 1;
|
|
29
|
+
|
|
30
|
+
const settingsText = await renderSourceOf(Astro.locals).readText(join(site.root, SITE_SETTINGS_FILE));
|
|
31
|
+
// A public page's path is its own address; a preview's is the document's: the page component's
|
|
32
|
+
// `path` when it's passed on, else the GET's `?path=` (for the home page's title).
|
|
33
|
+
const previewPath = () => {
|
|
34
|
+
if (Astro.props.path !== undefined) return Astro.props.path;
|
|
35
|
+
const c = Object.hasOwn(site.collections, collection) ? site.collections[collection]! : null;
|
|
36
|
+
const path = Astro.url.searchParams.get('path');
|
|
37
|
+
return c && path ? documentUrl(c, path) : null;
|
|
38
|
+
};
|
|
39
|
+
const head = headOf({
|
|
40
|
+
document,
|
|
41
|
+
settingsText,
|
|
42
|
+
path: preview ? previewPath() : Astro.url.pathname,
|
|
43
|
+
siteUrl: preview ? null : build.siteUrl,
|
|
44
|
+
media: readMediaConfig(),
|
|
45
|
+
tagAllowed: build.production && !preview,
|
|
46
|
+
listPage,
|
|
47
|
+
titleSeparator: site.titleSeparator,
|
|
48
|
+
titleFallback: site.titleFallback,
|
|
49
|
+
});
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
<title>{head.title}</title>
|
|
53
|
+
{head.description && <meta name="description" content={head.description} />}
|
|
54
|
+
{head.canonical && <link rel="canonical" href={head.canonical} />}
|
|
55
|
+
<meta property="og:title" content={head.title} />
|
|
56
|
+
{head.description && <meta property="og:description" content={head.description} />}
|
|
57
|
+
{head.canonical && <meta property="og:url" content={head.canonical} />}
|
|
58
|
+
{head.image && <meta property="og:image" content={head.image.url} />}
|
|
59
|
+
{head.image?.width && <meta property="og:image:width" content={String(head.image.width)} />}
|
|
60
|
+
{head.image?.height && <meta property="og:image:height" content={String(head.image.height)} />}
|
|
61
|
+
<meta name="twitter:card" content={head.twitterCard} />
|
|
62
|
+
{head.googleTag && <Fragment set:html={googleTagSnippet(head.googleTag)} />}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* A link field (M5a Decision 4): `{ label, href }` as an `<a>`.
|
|
4
|
+
*
|
|
5
|
+
* <Link cms={cms} field="cta" value={block.cta} class="cta-link" />
|
|
6
|
+
*
|
|
7
|
+
* - Both a label and an href that passes the link rule: `<a href>label</a>`.
|
|
8
|
+
* - A label with an href that fails the rule: the label as plain text, in a
|
|
9
|
+
* `<span>` (Decision 15: kept as typed, flagged on the field, ignored here).
|
|
10
|
+
* - Half a link (no label, or no href): nothing. The field says so.
|
|
11
|
+
* - The anchor's content is the label, or the default slot when given (an icon
|
|
12
|
+
* beside it, say).
|
|
13
|
+
* - Preview render only (M3 Decision 17): `data-cms-field="<path>.label"
|
|
14
|
+
* data-cms-kind="link"` on the element (Decision 16: `cta.label`). Edited in
|
|
15
|
+
* the sidebar (Decision 13). `field` may be a dotted path into a list.
|
|
16
|
+
*
|
|
17
|
+
* M6c (G9): an address-only link (`link({ addressOnly: true })`) has no label: its
|
|
18
|
+
* value is `{ href }` or null, and the anchor's content is the default slot (an image,
|
|
19
|
+
* an icon). Without a usable href the slot shows in a `<span>`, unlinked. Its marker
|
|
20
|
+
* is `<path>.href`.
|
|
21
|
+
*
|
|
22
|
+
* M6c Decision 11: a file link carries the site's behaviour (`fileLinkAttributes`): a
|
|
23
|
+
* PDF without `?download` gets `target="_blank" rel="noopener"` and a visually hidden
|
|
24
|
+
* " (opens in a new tab)" after the content; any other file link gets `download`. The
|
|
25
|
+
* same in a preview render; the markers don't change. A class or attribute passed in
|
|
26
|
+
* wins over these.
|
|
27
|
+
*/
|
|
28
|
+
import { site } from 'virtual:caracms/config';
|
|
29
|
+
import type { BlockContext } from '../document.ts';
|
|
30
|
+
import { parseField, usableHref, type AddressValue, type LinkValue } from '../fields.ts';
|
|
31
|
+
import { fileLinkAttributes, NEW_TAB_NOTE_HTML } from '../rich-text.ts';
|
|
32
|
+
import { requireField } from './descriptor.ts';
|
|
33
|
+
import { passThrough } from './attributes.ts';
|
|
34
|
+
|
|
35
|
+
interface Props {
|
|
36
|
+
cms: BlockContext;
|
|
37
|
+
field: string;
|
|
38
|
+
value: LinkValue | AddressValue | null;
|
|
39
|
+
class?: string;
|
|
40
|
+
[attribute: string]: unknown;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const { cms, field, value, class: className, ...given } = Astro.props;
|
|
44
|
+
// M8 trial (item 4): a bare attribute stays bare, false is none (attributes.ts).
|
|
45
|
+
const rest = passThrough(given);
|
|
46
|
+
const descriptor = requireField(site, cms, field, 'link', 'Link');
|
|
47
|
+
const addressOnly = descriptor.addressOnly === true;
|
|
48
|
+
const link = parseField(descriptor, value) as LinkValue;
|
|
49
|
+
const href = usableHref(link.href);
|
|
50
|
+
const show = addressOnly || (link.label !== '' && link.href !== '');
|
|
51
|
+
const file = href ? fileLinkAttributes(href) : { attributes: {}, newTab: false };
|
|
52
|
+
const fileAttributes = Object.fromEntries(Object.entries(file.attributes).map(([name, v]) => [name, v === '' ? true : v]));
|
|
53
|
+
const markers = cms.preview ? { 'data-cms-field': `${field}.${addressOnly ? 'href' : 'label'}`, 'data-cms-kind': 'link' } : {};
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
{
|
|
57
|
+
show &&
|
|
58
|
+
(href ? (
|
|
59
|
+
<a href={href} class={className} {...fileAttributes} {...rest} {...markers}>
|
|
60
|
+
<slot>{link.label}</slot>
|
|
61
|
+
{file.newTab && <Fragment set:html={NEW_TAB_NOTE_HTML} />}
|
|
62
|
+
</a>
|
|
63
|
+
) : (
|
|
64
|
+
<span class={className} {...rest} {...markers}>
|
|
65
|
+
<slot>{link.label}</slot>
|
|
66
|
+
</span>
|
|
67
|
+
))
|
|
68
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* A lockup field: display text set as lines that never wrap; the text gets smaller
|
|
4
|
+
* instead (Spec → Text → Lockups; lifted from M1's Lockup.astro).
|
|
5
|
+
*
|
|
6
|
+
* <Lockup cms={cms} field="heading" value={block.heading} />
|
|
7
|
+
*
|
|
8
|
+
* - One heading element. It's the page's `h1` when this field holds the page's
|
|
9
|
+
* first heading (Decision 16, from `cms.h1Field`), otherwise `h2`; `as` overrides.
|
|
10
|
+
* - Main lines, and phone lines when the value has them (Decisions 10–11): both
|
|
11
|
+
* sets inside the one heading, each its own size container with its own
|
|
12
|
+
* `--longest-line`, each measured on its own; a media query at the site's
|
|
13
|
+
* phone-lines width shows one and sets the other `display: none` (lockup-css.ts).
|
|
14
|
+
* - Measured here, during render, from the font file (measure.ts), so the
|
|
15
|
+
* prerendered page and the preview render share one code path.
|
|
16
|
+
* - The fit: `min(var(--cms-size), calc(100cqi / var(--longest-line)))`, no floor.
|
|
17
|
+
*
|
|
18
|
+
* Preview renders only (Decision 17), for the editor (Phases 3 and E7's warning):
|
|
19
|
+
* on the heading data-cms-field="<field>" data-cms-kind="lockup"
|
|
20
|
+
* data-cms-style, data-cms-size (design px), data-cms-min-size (px)
|
|
21
|
+
* on each set data-cms-lines="main" | "phone", data-cms-longest (em),
|
|
22
|
+
* data-cms-uncovered='["✨"]' (Decision 15; only when some are)
|
|
23
|
+
* The size a set computed is `getComputedStyle(set.querySelector('.cms-lockup-fit')).fontSize`
|
|
24
|
+
* (in px); compare it with `data-cms-min-size`. In the 375 preview the visible set
|
|
25
|
+
* is the phone set when there is one, the main set otherwise.
|
|
26
|
+
* A lockup is edited in the sidebar only (Decision 12), so never contenteditable.
|
|
27
|
+
* `field` may be a dotted path into a list (M5a Decision 16): the marker carries it.
|
|
28
|
+
*/
|
|
29
|
+
import 'virtual:caracms/lockup.css';
|
|
30
|
+
import { site } from 'virtual:caracms/config';
|
|
31
|
+
import { own, type BlockContext } from '../document.ts';
|
|
32
|
+
import { requireField } from './descriptor.ts';
|
|
33
|
+
import type { LockupValue } from '../fields.ts';
|
|
34
|
+
import { measureLockup } from '../measure.ts';
|
|
35
|
+
import { variationSettings } from '../text-styles.ts';
|
|
36
|
+
import { passThrough } from './attributes.ts';
|
|
37
|
+
|
|
38
|
+
interface Props {
|
|
39
|
+
cms: BlockContext;
|
|
40
|
+
/** The field's key in the block's schema, or a dotted path into a list (`team.0.name`). */
|
|
41
|
+
field: string;
|
|
42
|
+
value: LockupValue;
|
|
43
|
+
/** The heading element. Default: `h1` for the page's first heading, otherwise `h2`. */
|
|
44
|
+
as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p';
|
|
45
|
+
class?: string;
|
|
46
|
+
/** Anything else (such as the caller's scoped-style attribute) goes on the heading. */
|
|
47
|
+
[attribute: string]: unknown;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const { cms, field, value, as, class: className, ...given } = Astro.props;
|
|
51
|
+
// M8 trial (item 4): a bare attribute stays bare, false is none (attributes.ts).
|
|
52
|
+
const rest = passThrough(given);
|
|
53
|
+
const descriptor = requireField(site, cms, field, 'lockup', 'Lockup');
|
|
54
|
+
const style = own(site.textStyles, descriptor.style)!;
|
|
55
|
+
const Tag = as ?? (cms.h1Field === field ? 'h1' : 'h2');
|
|
56
|
+
|
|
57
|
+
const main = measureLockup(value?.lines ?? [], style);
|
|
58
|
+
const phone = value?.phone ? measureLockup(value.phone, style) : null;
|
|
59
|
+
const hasPhone = !!phone && phone.lines.length > 0;
|
|
60
|
+
|
|
61
|
+
/** Round the fitting width up, never down: four decimals is well under 0.01% of a line. */
|
|
62
|
+
const fitWidth = (em: number) => (Math.ceil(em * 1e4) / 1e4).toFixed(4);
|
|
63
|
+
|
|
64
|
+
const vars = [
|
|
65
|
+
`--cms-size: ${style.sizePx}px`,
|
|
66
|
+
`--cms-tracking: ${style.trackingEm}em`,
|
|
67
|
+
`--cms-leading: ${style.leading}`,
|
|
68
|
+
`--cms-transform: ${style.textTransform}`,
|
|
69
|
+
`--cms-variation: ${variationSettings(style)}`,
|
|
70
|
+
`--cms-weight: ${style.axes.wght ?? 400}`,
|
|
71
|
+
`--cms-family: var(${style.cssVariable})`,
|
|
72
|
+
].join('; ');
|
|
73
|
+
|
|
74
|
+
const p = cms.preview;
|
|
75
|
+
const headingMarkers = p
|
|
76
|
+
? {
|
|
77
|
+
'data-cms-field': field,
|
|
78
|
+
'data-cms-kind': 'lockup',
|
|
79
|
+
'data-cms-style': style.name,
|
|
80
|
+
'data-cms-size': String(style.sizePx),
|
|
81
|
+
'data-cms-min-size': String(style.minSizePx),
|
|
82
|
+
}
|
|
83
|
+
: {};
|
|
84
|
+
const setMarkers = (name: 'main' | 'phone', m: { longest: number; uncovered: string[] }) =>
|
|
85
|
+
p
|
|
86
|
+
? {
|
|
87
|
+
'data-cms-lines': name,
|
|
88
|
+
'data-cms-longest': m.longest.toFixed(4),
|
|
89
|
+
...(m.uncovered.length > 0 ? { 'data-cms-uncovered': JSON.stringify(m.uncovered) } : {}),
|
|
90
|
+
}
|
|
91
|
+
: {};
|
|
92
|
+
|
|
93
|
+
const sets = [
|
|
94
|
+
{ name: 'main' as const, m: main },
|
|
95
|
+
...(hasPhone ? [{ name: 'phone' as const, m: phone! }] : []),
|
|
96
|
+
];
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
{
|
|
100
|
+
main.lines.length > 0 && (
|
|
101
|
+
<Tag class:list={['cms-lockup', hasPhone && 'cms-lockup--has-phone', className]} style={vars} {...rest} {...headingMarkers}>
|
|
102
|
+
{sets.map(({ name, m }) => (
|
|
103
|
+
<span class={`cms-lockup-set cms-lockup-${name}`} style={`--longest-line: ${fitWidth(m.longest)}`} {...setMarkers(name, m)}>
|
|
104
|
+
<span class="cms-lockup-fit">
|
|
105
|
+
{m.lines.map((line, i) => (
|
|
106
|
+
<>
|
|
107
|
+
{i > 0 && ' '}
|
|
108
|
+
<span class="cms-lockup-line">{line.text}</span>
|
|
109
|
+
</>
|
|
110
|
+
))}
|
|
111
|
+
</span>
|
|
112
|
+
</span>
|
|
113
|
+
))}
|
|
114
|
+
</Tag>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* One media slot (M5b Decision 4): an image or a Mux clip, whichever it holds.
|
|
4
|
+
*
|
|
5
|
+
* <Media cms={cms} field="media" value={block.media} sizes="…" ratio={3 / 2} class="tm-media" />
|
|
6
|
+
*
|
|
7
|
+
* - **An image is exactly one `<img>`**: no `<picture>`, no wrapper. The caller's
|
|
8
|
+
* `class` and `style` go on it, so the block's CSS sizes the box.
|
|
9
|
+
* - Fluid (pass `sizes`, optionally `ratio`): a `w` srcset over the ladder;
|
|
10
|
+
* with a ratio, `rs:fill` at it with the focal point's gravity; without, `rs:fit`.
|
|
11
|
+
* - Fixed (pass a CSS `width` and/or `height`): 1×, 2×, 3×; both sides `rs:fill`,
|
|
12
|
+
* or `rs:fit` with `fit="contain"`.
|
|
13
|
+
* - `object-position: x% y%` only when a focal point is set (before the caller's style).
|
|
14
|
+
* - `width`/`height` from the stored size, scaled to the ratio.
|
|
15
|
+
* - `alt` from the value (`""` when decorative: decorative wins).
|
|
16
|
+
* - **A loop**: `<video autoplay muted loop playsinline>` on Mux's MP4, with its poster,
|
|
17
|
+
* `aria-hidden`, and no script. Its source carries
|
|
18
|
+
* `media="(prefers-reduced-motion: no-preference)"`, so with reduced motion no
|
|
19
|
+
* source matches and only the poster shows.
|
|
20
|
+
* - **A player**: MediaPlayer.astro (its poster and a "Play video: {name}" button);
|
|
21
|
+
* only it carries a script, so a page with no player has none.
|
|
22
|
+
* - **PNG** (M9 Decision 8): a field declared `media({ format: 'png' })` gets `@png`
|
|
23
|
+
* URLs at every width; every other image is WebP.
|
|
24
|
+
* - **Loading** (Decision 4): eager in the first two rendered blocks (`cms.eager`),
|
|
25
|
+
* `fetchpriority="high"` only on the image at `cms.priority`; lazy otherwise. Props
|
|
26
|
+
* override both.
|
|
27
|
+
* - **Preview markers**: `data-cms-field="<path>" data-cms-kind="media"` on the element,
|
|
28
|
+
* preview renders only, when `field` is given (a dotted path into a list works).
|
|
29
|
+
* Without `field` (a card showing another document's cover) there's no marker.
|
|
30
|
+
* - **An empty slot** (M9 port friction, item 12): nothing for visitors. In a preview, with
|
|
31
|
+
* `field`, a placeholder carrying the markers, so the slot can be clicked on the page
|
|
32
|
+
* (it selects the field, as a placed image does): a `<span>` (valid inside a `<p>` or a
|
|
33
|
+
* link) with the caller's `class`, at `ratio` when given (else the fixed `width`/`height`,
|
|
34
|
+
* else at least 6rem square), drawn as a dashed box in the text's own colour. So a block
|
|
35
|
+
* that wants its empty slot clickable calls `<Media>` even when the value is empty
|
|
36
|
+
* (`{(block.media || cms.preview) && <Media … />}`); the visitors' page is unchanged.
|
|
37
|
+
* - **Without the media variables** (Decision 5): a preview (and `astro dev`) renders an
|
|
38
|
+
* image slot as nothing; a build throws the one sentence naming them.
|
|
39
|
+
*
|
|
40
|
+
* Server-only: the signing happens here, in the server render.
|
|
41
|
+
*/
|
|
42
|
+
import { site } from 'virtual:caracms/config';
|
|
43
|
+
import { renderValue, type MediaField } from '../fields.ts';
|
|
44
|
+
import { failedOnce, fixedImage, fluidImage, isProductionBuild, mediaUnsetMessage, readMediaConfig } from '../media.ts';
|
|
45
|
+
import { renderMedia, type RenderMedia } from '../media-value.ts';
|
|
46
|
+
import { requireField } from './descriptor.ts';
|
|
47
|
+
import MediaPlayer from './MediaPlayer.astro';
|
|
48
|
+
import { passThrough } from './attributes.ts';
|
|
49
|
+
import type { MediaProps } from './media-props.ts';
|
|
50
|
+
|
|
51
|
+
/** Its props: media-props.ts (M8 trial, item 13: the element's own attributes type-check too). */
|
|
52
|
+
type Props = MediaProps;
|
|
53
|
+
|
|
54
|
+
const { cms, field, value, sizes, ratio, minWidth, maxWidth, width, height, fit, loading, fetchpriority, class: className, style, ...given } = Astro.props;
|
|
55
|
+
// M8 trial (item 4): a bare attribute stays bare, false is none (attributes.ts).
|
|
56
|
+
const rest = passThrough(given);
|
|
57
|
+
const descriptor: MediaField | null = field ? requireField(site, cms, field, 'media', 'Media') : null;
|
|
58
|
+
const media: RenderMedia | null = descriptor ? (renderValue(descriptor, value) as RenderMedia | null) : renderMedia(value);
|
|
59
|
+
const markers = cms.preview && field ? { 'data-cms-field': field, 'data-cms-kind': 'media' } : {};
|
|
60
|
+
const load = loading ?? (cms.eager ? 'eager' : 'lazy');
|
|
61
|
+
const priority = fetchpriority ?? (field && cms.priority === field ? 'high' : undefined);
|
|
62
|
+
const fixed = width !== undefined || height !== undefined;
|
|
63
|
+
// M9 Decision 8: the field's own format (PNG for flat artwork), WebP otherwise.
|
|
64
|
+
const format = descriptor?.format;
|
|
65
|
+
|
|
66
|
+
let img: (ReturnType<typeof fluidImage> & { alt: string; style?: string }) | null = null;
|
|
67
|
+
if (media?.kind === 'image') {
|
|
68
|
+
const config = readMediaConfig();
|
|
69
|
+
if (config.kind !== 'ready') {
|
|
70
|
+
// A missing deploy setting: the build stops with one sentence; a preview (and dev) shows nothing.
|
|
71
|
+
// Once per process: the page's other slots render in parallel, and a second throw would be an unhandled rejection.
|
|
72
|
+
if (!cms.preview && isProductionBuild() && !failedOnce()) throw new Error(`caracms: ${mediaUnsetMessage(config)}`);
|
|
73
|
+
} else {
|
|
74
|
+
if (!fixed && !sizes) throw new Error(`<Media>: a fluid image needs \`sizes\` (${cms.collection ? `collection "${cms.collection}"` : `block "${cms.type}"`}${field ? `, field "${field}"` : ''}).`);
|
|
75
|
+
const built = fixed ? fixedImage(config.settings, media, { width, height, fit, format }) : fluidImage(config.settings, media, { sizes: sizes!, ratio, minWidth, maxWidth, format });
|
|
76
|
+
const styles = media.position ? [`object-position: ${media.position}`, style].filter(Boolean).join('; ') : style;
|
|
77
|
+
img = { ...built, alt: media.alt, style: styles };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const clip = media?.kind === 'video' ? media : null;
|
|
81
|
+
// An empty slot in a preview: something to click (see above). Never in the public render.
|
|
82
|
+
const placeholderStyle = cms.preview && field && !media ? emptySlotStyle({ ratio, width, height, style }) : null;
|
|
83
|
+
|
|
84
|
+
function emptySlotStyle(size: { ratio?: number; width?: number; height?: number; style?: string }): string {
|
|
85
|
+
const box =
|
|
86
|
+
size.ratio !== undefined
|
|
87
|
+
? [`aspect-ratio: ${size.ratio}`, 'min-width: 6rem']
|
|
88
|
+
: size.width !== undefined || size.height !== undefined
|
|
89
|
+
? [size.width !== undefined ? `width: ${size.width}px` : 'min-width: 6rem', size.height !== undefined ? `height: ${size.height}px` : 'min-height: 6rem']
|
|
90
|
+
: ['min-width: 6rem', 'min-height: 6rem'];
|
|
91
|
+
const look = ['background: color-mix(in srgb, currentColor 6%, transparent)', 'outline: 1.5px dashed color-mix(in srgb, currentColor 40%, transparent)', 'outline-offset: -1.5px', 'cursor: pointer'];
|
|
92
|
+
return ['display: block', 'box-sizing: border-box', ...box, size.style, ...look].filter(Boolean).join('; ');
|
|
93
|
+
}
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
{placeholderStyle !== null && <span class={className} style={placeholderStyle} aria-hidden="true" data-cms-placeholder="" {...rest} {...markers} />}
|
|
97
|
+
|
|
98
|
+
{
|
|
99
|
+
img && (
|
|
100
|
+
<img
|
|
101
|
+
src={img.src}
|
|
102
|
+
srcset={img.srcset}
|
|
103
|
+
sizes={img.sizes}
|
|
104
|
+
width={img.width}
|
|
105
|
+
height={img.height}
|
|
106
|
+
alt={img.alt}
|
|
107
|
+
loading={load}
|
|
108
|
+
fetchpriority={priority}
|
|
109
|
+
decoding="async"
|
|
110
|
+
class={className}
|
|
111
|
+
style={img.style}
|
|
112
|
+
{...rest}
|
|
113
|
+
{...markers}
|
|
114
|
+
/>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
{
|
|
118
|
+
clip && clip.play === 'loop' && (
|
|
119
|
+
<video autoplay muted loop playsinline preload="metadata" poster={clip.poster} width={clip.w ?? undefined} height={clip.h ?? undefined} aria-hidden="true" class={className} style={style} {...rest} {...markers}>
|
|
120
|
+
<source src={clip.mp4} type="video/mp4" media="(prefers-reduced-motion: no-preference)" />
|
|
121
|
+
</video>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
{clip && clip.play === 'player' && <MediaPlayer clip={clip} loading={load} fetchpriority={priority} class={className} style={style} markers={{ ...rest, ...markers }} />}
|