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,102 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* A clip that plays as a player (M5b Decision 4), drawn by Media.astro: its poster
|
|
4
|
+
* (Mux's WebP still) and one real `<button>`, "Play video: {name}" ("Play video" with
|
|
5
|
+
* no name). The first click imports `@mux/mux-player` and swaps in `<mux-player>`
|
|
6
|
+
* with sound (media-player.ts). Only this component carries a script, so a page
|
|
7
|
+
* without a player ships none.
|
|
8
|
+
*
|
|
9
|
+
* The caller's `class` and `style` go on the frame (`.cms-player`), which the block's
|
|
10
|
+
* CSS sizes; the poster covers it. The button's look is the site's to restyle
|
|
11
|
+
* (`.cms-player-button`); the defaults here only make it usable.
|
|
12
|
+
*/
|
|
13
|
+
import type { RenderClip } from '../media-value.ts';
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
clip: RenderClip;
|
|
17
|
+
loading: 'eager' | 'lazy';
|
|
18
|
+
fetchpriority?: 'high' | 'low' | 'auto';
|
|
19
|
+
class?: string;
|
|
20
|
+
style?: string;
|
|
21
|
+
markers?: Record<string, unknown>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const { clip, loading, fetchpriority, class: className, style, markers = {} } = Astro.props;
|
|
25
|
+
const label = clip.name ? `Play video: ${clip.name}` : 'Play video';
|
|
26
|
+
const length = (() => {
|
|
27
|
+
if (!clip.duration) return '';
|
|
28
|
+
const total = Math.round(clip.duration);
|
|
29
|
+
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
|
|
30
|
+
})();
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
<div class:list={['cms-player', className]} style={style} data-caracms-player data-playback-id={clip.playbackId} data-name={clip.name} data-poster={clip.poster} {...markers}>
|
|
34
|
+
<img class="cms-player-poster" src={clip.poster} alt="" width={clip.w ?? undefined} height={clip.h ?? undefined} loading={loading} fetchpriority={fetchpriority} decoding="async" />
|
|
35
|
+
<button type="button" class="cms-player-button" aria-label={label} data-caracms-play>
|
|
36
|
+
<span class="cms-player-icon" aria-hidden="true"></span>
|
|
37
|
+
<span aria-hidden="true">Play video</span>
|
|
38
|
+
{length && <small aria-hidden="true">{length}</small>}
|
|
39
|
+
</button>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<script>
|
|
43
|
+
import { initPlayers } from './media-player.ts';
|
|
44
|
+
|
|
45
|
+
initPlayers();
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<style is:global>
|
|
49
|
+
.cms-player {
|
|
50
|
+
position: relative;
|
|
51
|
+
overflow: hidden;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.cms-player-poster,
|
|
55
|
+
.cms-player mux-player {
|
|
56
|
+
position: absolute;
|
|
57
|
+
inset: 0;
|
|
58
|
+
display: block;
|
|
59
|
+
width: 100%;
|
|
60
|
+
height: 100%;
|
|
61
|
+
object-fit: cover;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.cms-player-button {
|
|
65
|
+
position: absolute;
|
|
66
|
+
left: 1rem;
|
|
67
|
+
bottom: 1rem;
|
|
68
|
+
display: inline-flex;
|
|
69
|
+
align-items: center;
|
|
70
|
+
gap: 0.625rem;
|
|
71
|
+
padding: 0.5rem 1.125rem 0.5rem 0.5rem;
|
|
72
|
+
border: 0;
|
|
73
|
+
border-radius: 999px;
|
|
74
|
+
background: #fff;
|
|
75
|
+
color: #111;
|
|
76
|
+
font: inherit;
|
|
77
|
+
font-weight: 600;
|
|
78
|
+
cursor: pointer;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.cms-player-icon {
|
|
82
|
+
display: grid;
|
|
83
|
+
place-items: center;
|
|
84
|
+
width: 2rem;
|
|
85
|
+
height: 2rem;
|
|
86
|
+
border-radius: 50%;
|
|
87
|
+
background: #111;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.cms-player-icon::after {
|
|
91
|
+
content: '';
|
|
92
|
+
margin-left: 3px;
|
|
93
|
+
border-left: 10px solid #fff;
|
|
94
|
+
border-top: 6px solid transparent;
|
|
95
|
+
border-bottom: 6px solid transparent;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.cms-player.is-playing .cms-player-poster,
|
|
99
|
+
.cms-player.is-playing .cms-player-button {
|
|
100
|
+
display: none;
|
|
101
|
+
}
|
|
102
|
+
</style>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* A whole page from a document, through the site's page component. Phase 2's
|
|
4
|
+
* preview route renders this with `preview` on:
|
|
5
|
+
*
|
|
6
|
+
* const document = normaliseAnyDocument(posted, site, collection); // document.ts
|
|
7
|
+
* <RenderDocument document={document} collection="pages" preview />
|
|
8
|
+
*
|
|
9
|
+
* The document is any kind a page shows (M5a): a section builder's, a list-of-fields
|
|
10
|
+
* document (rendered through its collection's component) or a shared block alone.
|
|
11
|
+
* The page component passes `collection` on to `<Blocks>`.
|
|
12
|
+
*
|
|
13
|
+
* With `preview`, the package's components emit their `data-cms-*` markers and
|
|
14
|
+
* plain text fields are contenteditable (Decision 17). Without, it's the public page.
|
|
15
|
+
*
|
|
16
|
+
* M6c (G2): the page component also gets `data`, the document's own fields' render
|
|
17
|
+
* data (a section builder's page fields, a fields document's fields), read from the
|
|
18
|
+
* render's source as a block's are. M7 (Decision 13): and `settings`, Site Settings' render
|
|
19
|
+
* data from the same source (read.ts → settingsRenderData), so a preview shows the draft's.
|
|
20
|
+
*
|
|
21
|
+
* Posts and tags (Decision 12): a route may pass `listPage`, the page of a paged listing
|
|
22
|
+
* (`/journal/page/2` renders the journal page with `listPage={2}`); 1 by default, and always 1
|
|
23
|
+
* in a preview. The page component gets it, and `read`, the reader its blocks get.
|
|
24
|
+
*
|
|
25
|
+
* MEC trial item 2: the page component gets `path`, its document's address (head.ts →
|
|
26
|
+
* documentPath): a public render's from the address it's served at, a preview's from its
|
|
27
|
+
* document (`previewPath`, the preview route's `path`; else the GET's `?path=`).
|
|
28
|
+
*/
|
|
29
|
+
import { Page } from 'virtual:caracms/page';
|
|
30
|
+
import { site } from 'virtual:caracms/config';
|
|
31
|
+
import { documentUrl } from '../content.ts';
|
|
32
|
+
import { own, type AnyDocument, type PageProps } from '../document.ts';
|
|
33
|
+
import { documentPath } from '../head.ts';
|
|
34
|
+
import { documentReader, renderFields, renderSourceOf, settingsRenderData } from '../read.ts';
|
|
35
|
+
|
|
36
|
+
/** Any document a reader gives (content.ts → readDocument); Site Settings has no page, so it's refused. */
|
|
37
|
+
type Props = Omit<PageProps, 'document' | 'data' | 'settings' | 'listPage' | 'read' | 'path'> & {
|
|
38
|
+
document: AnyDocument;
|
|
39
|
+
listPage?: number;
|
|
40
|
+
/** A preview's document path in its collection (`index`, `services/branding`): the preview route gives it, as a POST's URL has no `?path=`. */
|
|
41
|
+
previewPath?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const { document, collection, preview = false } = Astro.props;
|
|
45
|
+
// Posts and tags, Decision 12: a preview is always page 1; anything that isn't a whole number from 1 reads as 1.
|
|
46
|
+
const listPage = !preview && Number.isInteger(Astro.props.listPage) && Astro.props.listPage! >= 1 ? Astro.props.listPage! : 1;
|
|
47
|
+
if (document.kind === 'settings') throw new Error('<RenderDocument>: Site Settings has no page of its own.');
|
|
48
|
+
const fields = own(site.collections, collection)?.fields ?? null;
|
|
49
|
+
const values = document.kind === 'shared' ? undefined : document.data;
|
|
50
|
+
const source = renderSourceOf(Astro.locals);
|
|
51
|
+
const reader = documentReader(site, source);
|
|
52
|
+
const data = fields && values ? await renderFields(fields, values, reader) : {};
|
|
53
|
+
const settings = await settingsRenderData(site, source, reader);
|
|
54
|
+
const previewOf = Astro.props.previewPath ?? Astro.url.searchParams.get('path');
|
|
55
|
+
const ownCollection = own(site.collections, collection);
|
|
56
|
+
const path = documentPath({
|
|
57
|
+
preview,
|
|
58
|
+
previewUrl: preview && ownCollection && previewOf ? documentUrl(ownCollection, previewOf) : null,
|
|
59
|
+
pathname: Astro.url.pathname,
|
|
60
|
+
listPage,
|
|
61
|
+
});
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
<Page document={document} collection={collection} path={path} preview={preview} data={data} settings={settings} listPage={listPage} read={reader} />
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* A rich-text field (M5a Decision 3): semantic HTML with no classes and no script.
|
|
4
|
+
*
|
|
5
|
+
* <RichText cms={cms} field="body" value={block.body} class="text-body" />
|
|
6
|
+
*
|
|
7
|
+
* - `<p>`, `<h2>`, `<h3>`, `<ul>`/`<ol>` with `<li>`; `<strong>`, `<em>`, `<br>`;
|
|
8
|
+
* `<a href>` for a link that passes the link rule, its text otherwise (Decision 4).
|
|
9
|
+
* A heading with a break renders as Text's multi-line heading does: its lines
|
|
10
|
+
* joined by `<br>`.
|
|
11
|
+
* - A kind the field doesn't allow renders as `<p>`, and a link where the field
|
|
12
|
+
* has links off as its text (the field warns; rich-text.ts reads them so).
|
|
13
|
+
* - The blocks sit in one wrapper element, `as` (default `div`; a heading field's below), which takes
|
|
14
|
+
* `class` and any other attribute. Public render: nothing at all for an empty value.
|
|
15
|
+
* - Preview render only (M3 Decision 17): the wrapper is always there, with
|
|
16
|
+
* `data-cms-field="<path>" data-cms-kind="richText"`. A top-level field is typed
|
|
17
|
+
* on the page (on-page rich text, Decisions 1 and 5): it also gets `tabindex="0"`,
|
|
18
|
+
* so Tab and a tap can reach it, and /admin mounts its page editor on this very
|
|
19
|
+
* element (preview/page-editor.ts). Never contenteditable in the render: the page
|
|
20
|
+
* editor adds that while mounted, and takes it away again. A nested path
|
|
21
|
+
* (`items.0.body`) is edited in the sidebar only, so it has no tabindex. The public
|
|
22
|
+
* render has none of these.
|
|
23
|
+
* - `field` is the field's key, or a dotted path into a list (Decision 16).
|
|
24
|
+
* - **A heading field** (`richText({ heading: true })`, M9 Decision 11): the element is
|
|
25
|
+
* `as`, or `h1` when it holds the page's first heading and `h2` otherwise (as Text's
|
|
26
|
+
* heading), and inside it each paragraph's runs, the paragraphs joined by `<br>`: no
|
|
27
|
+
* `<p>`. Bold is `<strong>`, so the site styles it (`h1 strong { … }`).
|
|
28
|
+
*
|
|
29
|
+
* <RichText cms={cms} field="statement" value={block.statement} class="board-statement" />
|
|
30
|
+
*/
|
|
31
|
+
import { site } from 'virtual:caracms/config';
|
|
32
|
+
import type { HTMLTag } from 'astro/types';
|
|
33
|
+
import type { BlockContext } from '../document.ts';
|
|
34
|
+
import { parseField } from '../fields.ts';
|
|
35
|
+
import { richHeadingHtml, richTextHtml, type RichTextValue } from '../rich-text.ts';
|
|
36
|
+
import { isTypedOnPage, requireField } from './descriptor.ts';
|
|
37
|
+
import { passThrough } from './attributes.ts';
|
|
38
|
+
|
|
39
|
+
interface Props {
|
|
40
|
+
cms: BlockContext;
|
|
41
|
+
field: string;
|
|
42
|
+
value: RichTextValue;
|
|
43
|
+
as?: HTMLTag;
|
|
44
|
+
class?: string;
|
|
45
|
+
[attribute: string]: unknown;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const { cms, field, value, as, class: className, ...given } = Astro.props;
|
|
49
|
+
// M8 trial (item 4): a bare attribute stays bare, false is none (attributes.ts).
|
|
50
|
+
const rest = passThrough(given);
|
|
51
|
+
const descriptor = requireField(site, cms, field, 'richText', 'RichText');
|
|
52
|
+
const Tag = as ?? (descriptor.heading ? (cms.h1Field === field ? 'h1' : 'h2') : 'div');
|
|
53
|
+
// Re-read through the field, so a value from anywhere renders by the field's rules.
|
|
54
|
+
const parsed = parseField(descriptor, value) as RichTextValue;
|
|
55
|
+
const html = descriptor.heading ? richHeadingHtml(parsed) : richTextHtml(parsed, { fileLinks: true });
|
|
56
|
+
const markers = cms.preview ? { 'data-cms-field': field, 'data-cms-kind': 'richText', ...(isTypedOnPage(field) ? { tabindex: '0' } : {}) } : {};
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
{(html !== '' || cms.preview) && <Tag class={className} {...rest} {...markers} set:html={html} />}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* A text field, one line or several (Decision 12; the line-break rule: a break is
|
|
4
|
+
* stored as `\n` and rendered as `<br>`, nothing else breaks the text).
|
|
5
|
+
*
|
|
6
|
+
* <Text cms={cms} field="intro" value={block.intro} class="hero-intro" />
|
|
7
|
+
*
|
|
8
|
+
* - The element: `as`, or for a field marked `heading: true`, `h1` when it holds
|
|
9
|
+
* the page's first heading (Decision 16) and `h2` otherwise; `p` by default.
|
|
10
|
+
* - Public render: an empty value renders nothing.
|
|
11
|
+
* - Preview render (Decision 17): always rendered, so it can be typed into, with
|
|
12
|
+
* `data-cms-field="<field>" data-cms-kind="text"` and `contenteditable="plaintext-only"`
|
|
13
|
+
* (plain text fields are edited on the page, Decision 12). The block's index is
|
|
14
|
+
* on the nearest `[data-cms-block]` ancestor (Block.astro).
|
|
15
|
+
* - `field` is the field's key, or a dotted path into a list (M5a Decision 16):
|
|
16
|
+
* `<Text cms={cms} field={`credits.${i}.name`} value={credit.name} />` in the
|
|
17
|
+
* block's own loop. The marker carries the path. Text inside a list is edited in
|
|
18
|
+
* the sidebar (Decision 13), so a nested path is never `contenteditable`.
|
|
19
|
+
* - **The lines rendering** (M9 Decision 10), for a field declared `text({ lines: true })`:
|
|
20
|
+
* each line is drawn trimmed and blank lines draw nothing; with one line left it's drawn unwrapped; with more, each
|
|
21
|
+
* line is a `<span>` with `lineClass` (Share's `block text-balance`), and no `<br>`.
|
|
22
|
+
* In a preview each span also carries `data-cms-line`, so the text read back from the
|
|
23
|
+
* page (preview/field-text.ts) starts a new line at each, and the element carries
|
|
24
|
+
* `data-cms-lines` (and `data-cms-line-class`), so a sidebar edit is drawn the same way.
|
|
25
|
+
*
|
|
26
|
+
* <Text cms={cms} field="heading" value={block.heading} lineClass="block text-balance" />
|
|
27
|
+
*/
|
|
28
|
+
import { site } from 'virtual:caracms/config';
|
|
29
|
+
import type { HTMLTag } from 'astro/types';
|
|
30
|
+
import type { BlockContext } from '../document.ts';
|
|
31
|
+
import { isTypedOnPage, requireField } from './descriptor.ts';
|
|
32
|
+
import { passThrough } from './attributes.ts';
|
|
33
|
+
|
|
34
|
+
interface Props {
|
|
35
|
+
cms: BlockContext;
|
|
36
|
+
field: string;
|
|
37
|
+
value: string;
|
|
38
|
+
as?: HTMLTag;
|
|
39
|
+
class?: string;
|
|
40
|
+
/** The lines rendering's class on each line's `<span>` (M9 Decision 10). */
|
|
41
|
+
lineClass?: string;
|
|
42
|
+
/** Anything else (such as the caller's scoped-style attribute) goes on the element. */
|
|
43
|
+
[attribute: string]: unknown;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const { cms, field, value, as, class: className, lineClass, ...given } = Astro.props;
|
|
47
|
+
// M8 trial (item 4): a bare attribute stays bare, false is none (attributes.ts).
|
|
48
|
+
const rest = passThrough(given);
|
|
49
|
+
const descriptor = requireField(site, cms, field, 'text', 'Text');
|
|
50
|
+
const Tag = as ?? (descriptor.heading ? (cms.h1Field === field ? 'h1' : 'h2') : 'p');
|
|
51
|
+
const typed = value ? (descriptor.multiline ? value.split('\n') : [value]) : [];
|
|
52
|
+
// M9 Decision 10: blank lines draw nothing, and two or more lines are a span each.
|
|
53
|
+
// Each line is drawn trimmed, as Tina's toLines() did (port friction 7); the stored value keeps its spaces.
|
|
54
|
+
const lines = descriptor.lines ? typed.map((line) => line.trim()).filter((line) => line !== '') : typed;
|
|
55
|
+
const spans = descriptor.lines && lines.length > 1;
|
|
56
|
+
const lineMarker = cms.preview ? { 'data-cms-line': '' } : {};
|
|
57
|
+
// The preview writes a sidebar edit back as these lines (preview/field-text.ts → writeFieldText).
|
|
58
|
+
const linesMarkers = cms.preview && descriptor.lines ? { 'data-cms-lines': '', ...(lineClass ? { 'data-cms-line-class': lineClass } : {}) } : {};
|
|
59
|
+
const markers = cms.preview
|
|
60
|
+
? { 'data-cms-field': field, 'data-cms-kind': 'text', ...(isTypedOnPage(field) ? { contenteditable: 'plaintext-only' } : {}) }
|
|
61
|
+
: {};
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
{
|
|
65
|
+
(lines.length > 0 || cms.preview) && (
|
|
66
|
+
<Tag class={className} {...rest} {...markers} {...linesMarkers}>
|
|
67
|
+
{spans
|
|
68
|
+
? lines.map((line) => (
|
|
69
|
+
<span class={lineClass} {...lineMarker}>
|
|
70
|
+
{line}
|
|
71
|
+
</span>
|
|
72
|
+
))
|
|
73
|
+
: lines.map((line, i) => (
|
|
74
|
+
<>
|
|
75
|
+
{i > 0 && <br />}
|
|
76
|
+
{line}
|
|
77
|
+
</>
|
|
78
|
+
))}
|
|
79
|
+
</Tag>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The attributes a caller passes through a package component (`<Block data-home-hero>`,
|
|
3
|
+
* `<Text … hidden={!open}>`), as they'd render on a plain element in Astro (M8 trial,
|
|
4
|
+
* item 4). A prop written bare reaches a component as `true`, and Astro's spread writes
|
|
5
|
+
* `true` as `="true"` for most attributes; on a plain element the source's bare attribute
|
|
6
|
+
* stays bare. So:
|
|
7
|
+
*
|
|
8
|
+
* - `true` is a bare attribute (`data-home-hero`, not `data-home-hero="true"`);
|
|
9
|
+
* - `false`, `undefined` and `null` are no attribute at all (kept as `undefined`, so a
|
|
10
|
+
* caller's `download={false}` still overrides what the component would add);
|
|
11
|
+
* - anything else passes as given.
|
|
12
|
+
*
|
|
13
|
+
* Except where `"true"` and `"false"` are the attribute's own values, which Astro writes as
|
|
14
|
+
* text on a plain element too: `aria-*`, `draggable`, `contenteditable`, `spellcheck` and
|
|
15
|
+
* `writingsuggestions` pass unchanged (`aria-hidden={true}` stays `aria-hidden="true"`).
|
|
16
|
+
*
|
|
17
|
+
* Browser-safe, no imports.
|
|
18
|
+
*/
|
|
19
|
+
const TRUE_FALSE_VALUES = new Set(['draggable', 'contenteditable', 'spellcheck', 'writingsuggestions']);
|
|
20
|
+
|
|
21
|
+
const keepsBooleans = (name: string) => name.startsWith('aria-') || TRUE_FALSE_VALUES.has(name.toLowerCase());
|
|
22
|
+
|
|
23
|
+
/** The pass-through attributes with booleans as a plain element renders a bare attribute. */
|
|
24
|
+
export function passThrough(attributes: Record<string, unknown>): Record<string, unknown> {
|
|
25
|
+
const out: Record<string, unknown> = {};
|
|
26
|
+
for (const [name, value] of Object.entries(attributes)) {
|
|
27
|
+
if (typeof value === 'boolean' && keepsBooleans(name)) out[name] = value;
|
|
28
|
+
else if (value === true) out[name] = '';
|
|
29
|
+
else if (value === false || value === null) out[name] = undefined;
|
|
30
|
+
else out[name] = value;
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the package's field components find a field's descriptor from the path
|
|
3
|
+
* they're given (M5a Decision 16): a block's schema, or, in a fields document's
|
|
4
|
+
* render (`cms.collection`, Decision 7), its collection's fields. Every component
|
|
5
|
+
* looks up here, so all of them work in both.
|
|
6
|
+
*/
|
|
7
|
+
import type { ResolvedSite } from '../config.ts';
|
|
8
|
+
import { own, type BlockContext } from '../document.ts';
|
|
9
|
+
import { fieldAtPath, isKeyPath, type Field, type Fields } from '../fields.ts';
|
|
10
|
+
|
|
11
|
+
type Site = Pick<ResolvedSite, 'blocks' | 'collections'>;
|
|
12
|
+
|
|
13
|
+
/** The fields the context's paths point into: its fields document's, else its block's. */
|
|
14
|
+
function fieldsOf(site: Site, cms: BlockContext): Fields | undefined {
|
|
15
|
+
if (cms.collection) return own(site.collections, cms.collection)?.fields ?? undefined;
|
|
16
|
+
return own(site.blocks, cms.type)?.fields;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** "block "hero"" or "collection "case-studies"", for messages. */
|
|
20
|
+
const whose = (cms: BlockContext) => (cms.collection ? `collection "${cms.collection}"` : `block "${cms.type}"`);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The descriptor at a dotted path (`heading`, `credits.1.name`, `cta`) in the
|
|
24
|
+
* context's schema, with what's left of the path past it; undefined when the path
|
|
25
|
+
* names no field.
|
|
26
|
+
*/
|
|
27
|
+
export function descriptorAt(site: Site, cms: BlockContext, path: string): { field: Field; rest: string[] } | undefined {
|
|
28
|
+
const fields = fieldsOf(site, cms);
|
|
29
|
+
return fields ? fieldAtPath(fields, path) : undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The descriptor a component needs, or a plain error naming the component, the block (or collection) and the path. */
|
|
33
|
+
export function requireField<K extends Field['kind']>(site: Site, cms: BlockContext, path: string, kind: K, component: string): Extract<Field, { kind: K }> {
|
|
34
|
+
const found = descriptorAt(site, cms, path);
|
|
35
|
+
if (!found || found.rest.length > 0 || found.field.kind !== kind) {
|
|
36
|
+
throw new Error(`<${component}>: ${whose(cms)} has no ${kind} field "${path}"`);
|
|
37
|
+
}
|
|
38
|
+
return found.field as Extract<Field, { kind: K }>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Only a path with no list index is typed on the page (Decision 13): a top-level field,
|
|
43
|
+
* or one in a group (`left.heading`). A list item's fields open in the sidebar.
|
|
44
|
+
*/
|
|
45
|
+
export const isTypedOnPage = (path: string) => isKeyPath(path);
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public form's one small script (M6 Decision 17), on a page with a form only
|
|
3
|
+
* (Form.astro carries it). It enhances a form that already works without it:
|
|
4
|
+
*
|
|
5
|
+
* - submits with `fetch` (`Accept: application/json`), shows "Sending…", then the
|
|
6
|
+
* thanks line, or the endpoint's own sentence when it gave one (the thanks and the
|
|
7
|
+
* failure lines are the form's `data-thanks` / `data-failed` when Form.astro was given them);
|
|
8
|
+
* - makes a files field's picks add up: each pick is added to the files already chosen
|
|
9
|
+
* (the real list kept in a `DataTransfer` and given back to `input.files`, so the fetch
|
|
10
|
+
* and a plain submit both send every one), the same name and size never twice, listed
|
|
11
|
+
* under the input with each one's size and a Remove button. A pick that would pass a
|
|
12
|
+
* limit (5 files, 25 MB in all, the kinds) adds nothing past it and says the endpoint's
|
|
13
|
+
* own sentence in the status line;
|
|
14
|
+
* - checks the chosen files against the limits before sending (the endpoint checks
|
|
15
|
+
* them again; this only spares a visitor a refused upload);
|
|
16
|
+
* - turns `?sent=1` (the no-JS reply) into the thanks line and drops it from the address;
|
|
17
|
+
* - renders the Turnstile widget when the form has a site key (api.js loaded once,
|
|
18
|
+
* explicit render), and mints a fresh token after each send.
|
|
19
|
+
*
|
|
20
|
+
* A port of the template's Form section script (`forms.md` → The Form section). If
|
|
21
|
+
* anything here throws before `preventDefault`, the form is still a working native post.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const THANKS = "Thanks, we'll be in touch shortly.";
|
|
25
|
+
const FAILED = 'Something went wrong. Please email us directly.';
|
|
26
|
+
|
|
27
|
+
interface Turnstile {
|
|
28
|
+
render: (el: HTMLElement, opts: Record<string, unknown>) => string;
|
|
29
|
+
reset: (container?: string | HTMLElement) => void;
|
|
30
|
+
}
|
|
31
|
+
type TurnstileWindow = Window & { turnstile?: Turnstile; __caracmsTurnstile?: () => void };
|
|
32
|
+
const turnstile = () => (window as TurnstileWindow).turnstile;
|
|
33
|
+
|
|
34
|
+
let apiJs: Promise<void> | undefined;
|
|
35
|
+
function loadTurnstile(): Promise<void> {
|
|
36
|
+
if (turnstile()) return Promise.resolve();
|
|
37
|
+
apiJs ??= new Promise((resolve) => {
|
|
38
|
+
(window as TurnstileWindow).__caracmsTurnstile = resolve;
|
|
39
|
+
const s = document.createElement('script');
|
|
40
|
+
s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=__caracmsTurnstile&render=explicit';
|
|
41
|
+
s.async = true;
|
|
42
|
+
document.head.appendChild(s);
|
|
43
|
+
});
|
|
44
|
+
return apiJs;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Why the chosen files can't be sent, or ''. */
|
|
48
|
+
function filesProblem(form: HTMLFormElement): string {
|
|
49
|
+
const maxFiles = Number(form.dataset.maxFiles);
|
|
50
|
+
const maxBytes = Number(form.dataset.maxBytes);
|
|
51
|
+
if (!maxFiles || !maxBytes) return '';
|
|
52
|
+
const files = [...form.querySelectorAll<HTMLInputElement>('input[type="file"]')].flatMap((input) => [...(input.files ?? [])]);
|
|
53
|
+
if (files.length > maxFiles) return `Attach up to ${maxFiles} files (you chose ${files.length}).`;
|
|
54
|
+
const bytes = files.reduce((sum, file) => sum + file.size, 0);
|
|
55
|
+
if (bytes > maxBytes) return `That's too much to send: attachments can come to ${Math.round(maxBytes / 1024 / 1024)} MB in all. Send fewer or smaller files, or email them to us.`;
|
|
56
|
+
return '';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// No `<` here: the page's check reads this inline script as text with no `<` in it (build-output tests).
|
|
60
|
+
const size = (n: number) => (n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(n >= 10 * 1024 * 1024 ? 0 : 1)} MB` : n >= 1024 ? `${Math.round(n / 1024)} KB` : `${n} bytes`);
|
|
61
|
+
|
|
62
|
+
/** A files field's picks add up, listed with Remove (see above). Without `DataTransfer`, it stays the native input. */
|
|
63
|
+
function enhanceFiles(form: HTMLFormElement, say: (text: string, failed: boolean) => void): void {
|
|
64
|
+
const input = form.querySelector<HTMLInputElement>('input[type="file"]');
|
|
65
|
+
const list = form.querySelector<HTMLUListElement>('[data-caracms-files]');
|
|
66
|
+
if (!input || !list || typeof DataTransfer !== 'function') return;
|
|
67
|
+
const maxFiles = Number(form.dataset.maxFiles);
|
|
68
|
+
const maxBytes = Number(form.dataset.maxBytes);
|
|
69
|
+
const exts = input.accept.split(',').map((e) => e.trim().replace(/^\./, '').toLowerCase());
|
|
70
|
+
let chosen: File[] = [];
|
|
71
|
+
const sync = () => {
|
|
72
|
+
const dt = new DataTransfer();
|
|
73
|
+
for (const f of chosen) dt.items.add(f);
|
|
74
|
+
input.files = dt.files;
|
|
75
|
+
list.replaceChildren(
|
|
76
|
+
...chosen.map((f, i) => {
|
|
77
|
+
const li = document.createElement('li');
|
|
78
|
+
li.className = 'cms-form-file';
|
|
79
|
+
const name = document.createElement('span');
|
|
80
|
+
name.className = 'cms-form-file-name';
|
|
81
|
+
name.textContent = f.name;
|
|
82
|
+
const bytes = document.createElement('span');
|
|
83
|
+
bytes.className = 'cms-form-file-size';
|
|
84
|
+
bytes.textContent = size(f.size);
|
|
85
|
+
const remove = document.createElement('button');
|
|
86
|
+
remove.type = 'button';
|
|
87
|
+
remove.className = 'cms-form-file-remove';
|
|
88
|
+
remove.textContent = 'Remove';
|
|
89
|
+
remove.setAttribute('aria-label', `Remove ${f.name}`);
|
|
90
|
+
remove.addEventListener('click', () => {
|
|
91
|
+
chosen = chosen.filter((_, j) => j !== i);
|
|
92
|
+
sync();
|
|
93
|
+
(list.querySelectorAll<HTMLButtonElement>('.cms-form-file-remove')[Math.min(i, chosen.length - 1)] ?? input).focus();
|
|
94
|
+
});
|
|
95
|
+
li.append(name, ' ', bytes, ' ', remove);
|
|
96
|
+
return li;
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
list.hidden = chosen.length === 0;
|
|
100
|
+
};
|
|
101
|
+
input.addEventListener('change', () => {
|
|
102
|
+
const picked = [...(input.files ?? [])].filter((f) => !chosen.some((c) => c.name === f.name && c.size === f.size));
|
|
103
|
+
const next = [...chosen];
|
|
104
|
+
let problem = '';
|
|
105
|
+
for (const f of picked) {
|
|
106
|
+
const ext = /\.([^.]+)$/.exec(f.name)?.[1]?.toLowerCase() ?? '';
|
|
107
|
+
if (!exts.includes(ext)) problem ||= `${f.name}: we can't take that kind of file. Send ${form.dataset.fileKinds} files.`;
|
|
108
|
+
else if (next.length >= maxFiles) problem ||= `Attach up to ${maxFiles} files (you chose ${chosen.length + picked.length}).`;
|
|
109
|
+
else if (next.reduce((sum, x) => sum + x.size, f.size) > maxBytes) problem ||= `That's too much to send: attachments can come to ${Math.round(maxBytes / 1024 / 1024)} MB in all. Send fewer or smaller files, or email them to us.`;
|
|
110
|
+
else next.push(f);
|
|
111
|
+
}
|
|
112
|
+
chosen = next;
|
|
113
|
+
sync();
|
|
114
|
+
say(problem, !!problem);
|
|
115
|
+
});
|
|
116
|
+
form.addEventListener('reset', () => {
|
|
117
|
+
chosen = [];
|
|
118
|
+
// After the reset has emptied the input.
|
|
119
|
+
setTimeout(sync);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function enhance(form: HTMLFormElement): void {
|
|
124
|
+
if (form.dataset.caracmsFormReady) return;
|
|
125
|
+
form.dataset.caracmsFormReady = '1';
|
|
126
|
+
const status = form.querySelector<HTMLElement>('[data-caracms-form-status]');
|
|
127
|
+
if (!status) return;
|
|
128
|
+
const submit = form.querySelector<HTMLButtonElement>('button[type="submit"]');
|
|
129
|
+
const widget = form.querySelector<HTMLElement>('[data-caracms-turnstile]');
|
|
130
|
+
const thanks = form.dataset.thanks || THANKS;
|
|
131
|
+
const failedLine = form.dataset.failed || FAILED;
|
|
132
|
+
const say = (text: string, failed: boolean) => {
|
|
133
|
+
status.textContent = text;
|
|
134
|
+
status.classList.toggle('cms-form-failed', failed);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
enhanceFiles(form, say);
|
|
138
|
+
|
|
139
|
+
if (widget?.dataset.sitekey) {
|
|
140
|
+
const sitekey = widget.dataset.sitekey;
|
|
141
|
+
void loadTurnstile().then(() => turnstile()?.render(widget, { sitekey, appearance: 'always', theme: 'light' }));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
form.addEventListener('submit', async (event) => {
|
|
145
|
+
if (!form.reportValidity()) return;
|
|
146
|
+
event.preventDefault();
|
|
147
|
+
const tooMuch = filesProblem(form);
|
|
148
|
+
if (tooMuch) return say(tooMuch, true);
|
|
149
|
+
submit?.setAttribute('disabled', '');
|
|
150
|
+
say('Sending…', false);
|
|
151
|
+
let failure = failedLine;
|
|
152
|
+
try {
|
|
153
|
+
const res = await fetch(form.action, { method: 'POST', headers: { Accept: 'application/json' }, body: new FormData(form) });
|
|
154
|
+
if (!res.ok) {
|
|
155
|
+
const body = (await res.json().catch(() => null)) as { error?: unknown } | null;
|
|
156
|
+
if (typeof body?.error === 'string' && body.error && res.status !== 502) failure = body.error;
|
|
157
|
+
throw new Error(String(res.status));
|
|
158
|
+
}
|
|
159
|
+
form.reset();
|
|
160
|
+
say(thanks, false);
|
|
161
|
+
} catch {
|
|
162
|
+
say(failure, true);
|
|
163
|
+
} finally {
|
|
164
|
+
submit?.removeAttribute('disabled');
|
|
165
|
+
// Turnstile's tokens are single-use: a fresh one for the next send.
|
|
166
|
+
if (widget) turnstile()?.reset(widget);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// The no-JS path lands back here with ?sent=1.
|
|
171
|
+
const params = new URLSearchParams(location.search);
|
|
172
|
+
if (params.get('sent') === '1') {
|
|
173
|
+
say(thanks, false);
|
|
174
|
+
params.delete('sent');
|
|
175
|
+
const rest = params.toString();
|
|
176
|
+
history.replaceState(null, '', `${location.pathname}${rest ? `?${rest}` : ''}${location.hash}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function initForms(): void {
|
|
181
|
+
for (const form of document.querySelectorAll<HTMLFormElement>('form[data-caracms-form]')) enhance(form);
|
|
182
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The player's one behaviour (M5b Decision 4; the template's `videoPlayer.ts`): on the
|
|
3
|
+
* first click, import `@mux/mux-player` (its own chunk, which nothing loads before),
|
|
4
|
+
* mount `<mux-player playback-id autoplay>` in the same frame with sound, and move
|
|
5
|
+
* focus to it. Browser only; imported only by MediaPlayer.astro's script, so a page
|
|
6
|
+
* without a player never loads it.
|
|
7
|
+
*/
|
|
8
|
+
export function initPlayers(): void {
|
|
9
|
+
for (const host of document.querySelectorAll<HTMLElement>('[data-caracms-player]')) {
|
|
10
|
+
if (host.dataset.caracmsReady) continue;
|
|
11
|
+
host.dataset.caracmsReady = '';
|
|
12
|
+
const button = host.querySelector<HTMLButtonElement>('[data-caracms-play]');
|
|
13
|
+
const { playbackId, name = '', poster = '' } = host.dataset;
|
|
14
|
+
if (!button || !playbackId) continue;
|
|
15
|
+
let busy = false;
|
|
16
|
+
button.addEventListener('click', async () => {
|
|
17
|
+
if (busy) return;
|
|
18
|
+
busy = true;
|
|
19
|
+
button.setAttribute('aria-busy', 'true');
|
|
20
|
+
try {
|
|
21
|
+
await import('@mux/mux-player');
|
|
22
|
+
} catch {
|
|
23
|
+
// The chunk didn't arrive (offline, blocked): the clip's MP4 is the fallback.
|
|
24
|
+
busy = false;
|
|
25
|
+
button.removeAttribute('aria-busy');
|
|
26
|
+
window.location.assign(`https://stream.mux.com/${encodeURIComponent(playbackId)}/highest.mp4`);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const player = document.createElement('mux-player');
|
|
30
|
+
player.setAttribute('playback-id', playbackId);
|
|
31
|
+
player.setAttribute('stream-type', 'on-demand');
|
|
32
|
+
if (poster) player.setAttribute('poster', poster);
|
|
33
|
+
if (name) {
|
|
34
|
+
player.setAttribute('metadata-video-title', name);
|
|
35
|
+
player.setAttribute('aria-label', name);
|
|
36
|
+
}
|
|
37
|
+
player.setAttribute('playsinline', '');
|
|
38
|
+
player.setAttribute('autoplay', '');
|
|
39
|
+
host.classList.add('is-playing');
|
|
40
|
+
button.removeAttribute('aria-busy');
|
|
41
|
+
host.append(player);
|
|
42
|
+
if (!player.hasAttribute('tabindex')) player.tabIndex = -1;
|
|
43
|
+
player.focus({ preventScroll: true });
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<Media>`'s props (Media.astro), in a module of their own so a test can type-check them
|
|
3
|
+
* (test/media-props.test.ts).
|
|
4
|
+
*/
|
|
5
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
6
|
+
import type { BlockContext } from '../document.ts';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* M8 trial (item 13): the element's own attributes (`draggable`, `id`, `title`, `data-*`…)
|
|
10
|
+
* type-check and pass through, on the `<img>`, the loop's `<video>` or the player. What the
|
|
11
|
+
* component sets itself (the sources, `alt`, the size) isn't the caller's.
|
|
12
|
+
*/
|
|
13
|
+
type Passed = Omit<HTMLAttributes<'img'>, 'src' | 'srcset' | 'alt' | 'sizes' | 'width' | 'height' | 'loading' | 'fetchpriority' | 'decoding' | 'class' | 'style'>;
|
|
14
|
+
|
|
15
|
+
export interface MediaProps extends Passed {
|
|
16
|
+
cms: BlockContext;
|
|
17
|
+
/** The slot's dotted path in the block's (or fields document's) schema: its markers and its `only`/`play`. */
|
|
18
|
+
field?: string;
|
|
19
|
+
value: unknown;
|
|
20
|
+
/** Fluid: the rendered width at each breakpoint, from the block's CSS. */
|
|
21
|
+
sizes?: string;
|
|
22
|
+
/** Fluid: crop to this shape (width / height). */
|
|
23
|
+
ratio?: number;
|
|
24
|
+
minWidth?: number;
|
|
25
|
+
maxWidth?: number;
|
|
26
|
+
/** Fixed: the CSS size in px. */
|
|
27
|
+
width?: number;
|
|
28
|
+
height?: number;
|
|
29
|
+
fit?: 'cover' | 'contain';
|
|
30
|
+
loading?: 'eager' | 'lazy';
|
|
31
|
+
fetchpriority?: 'high' | 'low' | 'auto';
|
|
32
|
+
class?: string;
|
|
33
|
+
style?: string;
|
|
34
|
+
[attribute: `data-${string}`]: unknown;
|
|
35
|
+
}
|