@taprootio/site-authoring 0.12.6 → 0.13.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/README.md +20 -2
- package/package.json +3 -2
- package/src/api.js +81 -2
- package/src/capabilities.js +1 -1
- package/src/cli.js +51 -3
- package/src/constants.js +6 -0
- package/src/content/components.js +180 -2
- package/src/content/free-form-section-registry.json +16 -1
- package/src/content/markdown.js +12 -2
- package/src/content/media-presentation.js +123 -0
- package/src/content/tiptap-prosemirror.ts +12 -64
- package/src/content/video-embed-url.js +104 -0
- package/src/content/vocabulary.js +9 -32
- package/src/field-schema.json +227 -0
- package/src/field-validation.js +721 -0
- package/src/fixture-contract.js +14 -3
- package/src/fixture-init.js +18 -0
- package/src/forms-api.js +192 -0
- package/src/forms-contract.js +274 -0
- package/src/forms-workspace.js +100 -0
- package/src/image-metadata.js +1 -1
- package/src/reference-help.js +168 -5
- package/src/session.js +22 -1
- package/src/settings-catalog.js +3 -0
- package/src/transport.js +77 -3
- package/src/typed-pages.js +83 -13
- package/src/verbs/forms-pull.js +67 -0
- package/src/verbs/forms-push.js +227 -0
- package/src/verbs/forms-validate.js +36 -0
- package/src/verbs/index.js +11 -0
- package/src/verbs/media-upload.js +352 -28
- package/src/verbs/pages-push.js +97 -2
- package/src/verbs/pull.js +8 -0
- package/src/verbs/validate.js +94 -2
- package/src/video-metadata.js +90 -0
- package/src/video-prepare.js +136 -0
- package/src/workspace.js +113 -1
package/README.md
CHANGED
|
@@ -188,7 +188,7 @@ Which verbs apply depends on the site:
|
|
|
188
188
|
`pull` snapshots its four settings documents, `theme push`, `footer push`,
|
|
189
189
|
and `media upload` author them, and `deploy` stages and promotes them.
|
|
190
190
|
Its pages, navigation, and redirects come from the Docs artifact, so
|
|
191
|
-
`pages push`, `nav push`, the `redirects` verbs, `approve`, and the
|
|
191
|
+
`pages push`, `nav push`, the `redirects` and `forms` verbs, `approve`, and the
|
|
192
192
|
`preview` verbs refuse with `surface.presentation_only` before any request.
|
|
193
193
|
- A **prebuilt Docs** site (`none`) serves its artifact unchanged and takes no
|
|
194
194
|
verb; each refuses with `surface.none`.
|
|
@@ -356,7 +356,15 @@ closed with `pages.push_conflict` when a page's stored-state revision has
|
|
|
356
356
|
moved since this workspace last reconciled with it, naming both revisions;
|
|
357
357
|
`pull` first, or push after a refused pull has shown you the site's version.
|
|
358
358
|
`media upload [path...]` uploads raster files and records component-ready
|
|
359
|
-
delivery fields
|
|
359
|
+
delivery fields; MP4, MOV, and WebM files are uploaded as videos. Taproot does not
|
|
360
|
+
encode: the command reads each file with mediabunny, rewrites an H.264 and AAC
|
|
361
|
+
file that is not an MP4 with its index first as one (copying the tracks, never
|
|
362
|
+
re-encoding), declares its codecs, and prints the server's one-line refusal as
|
|
363
|
+
it came for anything it will not accept (a non-H.264 or non-AAC file, one over
|
|
364
|
+
the licence's upload cap). A video is ready when its upload confirms; the CLI sets no poster (Node has no video decoder), so
|
|
365
|
+
choose one on the Videos page, where an upload made there gets one automatically; the command
|
|
366
|
+
reports its `videoId` for a `video` component (`help component video`), and
|
|
367
|
+
`pages push` refuses a page that places a video the site does not have. `nav push`, `theme push`, and `footer push` replace the whole
|
|
360
368
|
navigation tree, the complete light/dark theme pair with its appearance
|
|
361
369
|
settings, and the closed footer document. `pull` writes each scheme's
|
|
362
370
|
complete effective theme — the stored theme resolved over the same defaults
|
|
@@ -373,6 +381,16 @@ validates the file offline and replaces the whole map; `help redirects`
|
|
|
373
381
|
states the entry shape (`path`, `kind` redirect or gone, `target`, `status`
|
|
374
382
|
defaulting to 301), the normalization rule, and every refusal.
|
|
375
383
|
|
|
384
|
+
`forms pull` writes each live form to `forms/<key>.json` and records the
|
|
385
|
+
version it read; `forms push` validates every file, refuses a form the site
|
|
386
|
+
changed since (`forms.concurrent_modification`) and creates or updates the
|
|
387
|
+
rest, appending a version when the definition changed. `forms validate` checks
|
|
388
|
+
the files offline against the shared field schema with no credential. Both
|
|
389
|
+
verbs write one canonical key order, so a pulled file pushed back is unchanged.
|
|
390
|
+
Submissions are never pulled. `help forms` states the file shape and every
|
|
391
|
+
refusal, and placing a form on a page is the free-form `form`
|
|
392
|
+
component.
|
|
393
|
+
|
|
376
394
|
`preview page <page-path-or-id>` renders one persisted draft behind the
|
|
377
395
|
staging gate and mints a browser handoff URL, reported only in the final JSON
|
|
378
396
|
result. **Each handoff is single-use.** Opening the URL consumes it; a reused,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@taprootio/site-authoring",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Taproot site authoring CLI: sign in once in the browser, then pull, validate, push, preview, approve, and deploy a site from the terminal",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "ISC",
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
"README.md"
|
|
29
29
|
],
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@taprootio/espalier": "
|
|
31
|
+
"@taprootio/espalier": "5.0.0",
|
|
32
|
+
"mediabunny": "1.61.1"
|
|
32
33
|
},
|
|
33
34
|
"scripts": {
|
|
34
35
|
"test": "node --test",
|
package/src/api.js
CHANGED
|
@@ -801,23 +801,102 @@ export function normalizeImage(value) {
|
|
|
801
801
|
};
|
|
802
802
|
}
|
|
803
803
|
|
|
804
|
+
export async function requestVideoUpload(client, siteId, body) {
|
|
805
|
+
return requireObject(
|
|
806
|
+
await client.request(sitePath(siteId, "videos/request-upload"), { method: "POST", body }),
|
|
807
|
+
"api.video_contract",
|
|
808
|
+
"video upload request",
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
export async function confirmVideoUpload(client, siteId, uploadId) {
|
|
813
|
+
return normalizeVideo(
|
|
814
|
+
await client.request(sitePath(siteId, "videos/confirm-upload"), { method: "POST", body: { uploadId } }),
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Copies a YouTube or Vimeo thumbnail into the site's images and returns that image,
|
|
820
|
+
* which is still being processed (TR01109). `posterFromImage` shapes it for a component.
|
|
821
|
+
*/
|
|
822
|
+
export async function importVideoEmbedPoster(client, siteId, provider, videoId) {
|
|
823
|
+
const response = requireObject(
|
|
824
|
+
await client.request(sitePath(siteId, "videos/import-embed-poster"), {
|
|
825
|
+
method: "POST",
|
|
826
|
+
body: { provider, videoId },
|
|
827
|
+
}),
|
|
828
|
+
"api.video_contract",
|
|
829
|
+
"video embed poster",
|
|
830
|
+
);
|
|
831
|
+
return normalizeImage(response.image);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/** The object a component's poster field holds, from an image whose processing is complete. */
|
|
835
|
+
export function posterFromImage(image) {
|
|
836
|
+
return { imageId: image.imageId, src: image.url, urls: image.responsiveUrls, width: image.width, height: image.height, alt: "" };
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
export function normalizeVideo(value) {
|
|
840
|
+
const video = requireObject(value, "api.video_contract", "video");
|
|
841
|
+
return {
|
|
842
|
+
videoId: requireCanonicalUuid(video.videoId, "api.video_contract", "video.videoId"),
|
|
843
|
+
title: text(video.title),
|
|
844
|
+
bytes: safeCount(video.bytes),
|
|
845
|
+
durationMilliseconds: safeCount(video.durationMs),
|
|
846
|
+
width: safeCount(video.width),
|
|
847
|
+
height: safeCount(video.height),
|
|
848
|
+
sourceUrl: text(video.sourceUrl),
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* The site's videos through `ListVideos`, paged like the image library and
|
|
854
|
+
* bounded the same way. A video is ready the moment it is listed.
|
|
855
|
+
*/
|
|
856
|
+
export async function listVideos(client, siteId, requestOptions = {}) {
|
|
857
|
+
const videos = [];
|
|
858
|
+
let pageToken = "";
|
|
859
|
+
for (let request = 0; request < MAXIMUM_LIST_REQUESTS; request += 1) {
|
|
860
|
+
const search = query([["pageSize", LIST_PAGE_SIZE], ["pageToken", pageToken]]);
|
|
861
|
+
const response = requireObject(
|
|
862
|
+
await client.request(sitePath(siteId, `videos${search}`), requestOptions),
|
|
863
|
+
"api.video_contract",
|
|
864
|
+
"site videos",
|
|
865
|
+
);
|
|
866
|
+
const batch = Array.isArray(response.videos) ? response.videos : [];
|
|
867
|
+
for (const entry of batch) videos.push(normalizeVideo(entry));
|
|
868
|
+
pageToken = typeof response.nextPageToken === "string" ? response.nextPageToken : "";
|
|
869
|
+
if (pageToken === "" || batch.length === 0) return { videos, truncated: false };
|
|
870
|
+
if (videos.length >= MAXIMUM_IMAGES) break;
|
|
871
|
+
}
|
|
872
|
+
return { videos, truncated: true };
|
|
873
|
+
}
|
|
874
|
+
|
|
804
875
|
/**
|
|
805
876
|
* Processing is observed through `ListSiteImages`; `GetImageById` is
|
|
806
877
|
* session-only under TR00602's read list, so a key-authorized client watches
|
|
807
878
|
* the library rather than one image.
|
|
808
879
|
*
|
|
880
|
+
* `imageIds` (at most 100) reads exactly those images instead of the library, in any
|
|
881
|
+
* purpose: the way to watch an image the library never lists, such as the embed
|
|
882
|
+
* poster the server copied for a push.
|
|
883
|
+
*
|
|
809
884
|
* `requestOptions` carries a poller's `{ deadline, now }` into *every* page of
|
|
810
885
|
* the listing. Without it a single read of a large library could spend far more
|
|
811
886
|
* than the caller's whole budget — the page bound alone permits hundreds of
|
|
812
887
|
* requests, each with its own request timeout and retry budget — and the
|
|
813
888
|
* deadline would only be noticed once the read finally returned.
|
|
814
889
|
*/
|
|
815
|
-
export async function listSiteImages(client, siteId, requestOptions = {}) {
|
|
890
|
+
export async function listSiteImages(client, siteId, requestOptions = {}, { imageIds = [] } = {}) {
|
|
816
891
|
const images = [];
|
|
817
892
|
let pageToken = "";
|
|
818
893
|
let summary = { totalImages: 0, processingImages: 0 };
|
|
819
894
|
for (let request = 0; request < MAXIMUM_LIST_REQUESTS; request += 1) {
|
|
820
|
-
|
|
895
|
+
// Asking by id reads exactly those images, whatever their purpose. The library lists the
|
|
896
|
+
// owner's own images only, so a copied embed poster is never in it.
|
|
897
|
+
const search = imageIds.length > 0
|
|
898
|
+
? query([["imageIds", imageIds]])
|
|
899
|
+
: query([["pageSize", LIST_PAGE_SIZE], ["pageToken", pageToken]]);
|
|
821
900
|
const response = requireObject(
|
|
822
901
|
await client.request(sitePath(siteId, `images${search}`), requestOptions),
|
|
823
902
|
"api.image_contract",
|
package/src/capabilities.js
CHANGED
|
@@ -23,7 +23,7 @@ export const SITE_AUTHORING_CAPABILITIES = Object.freeze([
|
|
|
23
23
|
|
|
24
24
|
/** What each capability lets an exchanged credential do, for help and `whoami`. */
|
|
25
25
|
export const SITE_AUTHORING_CAPABILITY_SUMMARIES = Object.freeze({
|
|
26
|
-
"delegation.content": "write pages and
|
|
26
|
+
"delegation.content": "write pages, media, and form definitions",
|
|
27
27
|
"delegation.design": "set the theme, navigation, and footer",
|
|
28
28
|
"delegation.deployments": "stage and deploy",
|
|
29
29
|
});
|
package/src/cli.js
CHANGED
|
@@ -20,6 +20,9 @@ import {
|
|
|
20
20
|
VERB_STAGING_REVIEW,
|
|
21
21
|
VERB_ENV,
|
|
22
22
|
VERB_FOOTER_PUSH,
|
|
23
|
+
VERB_FORMS_PULL,
|
|
24
|
+
VERB_FORMS_PUSH,
|
|
25
|
+
VERB_FORMS_VALIDATE,
|
|
23
26
|
VERB_HELP,
|
|
24
27
|
VERB_LOGIN,
|
|
25
28
|
VERB_LOGOUT,
|
|
@@ -71,14 +74,14 @@ import { VERB_HANDLERS } from "./verbs/index.js";
|
|
|
71
74
|
/**
|
|
72
75
|
* The verbs the local version gate applies to (TR00703).
|
|
73
76
|
*
|
|
74
|
-
* Exactly the
|
|
77
|
+
* Exactly the ones that make no request. Every other verb reaches Taproot,
|
|
75
78
|
* whose own refusal is authoritative and better informed than this recording,
|
|
76
79
|
* so gating them here would only duplicate an answer — and gating `login`,
|
|
77
80
|
* `logout`, or `env` would take away the commands an operator needs while they
|
|
78
81
|
* are outdated. `--help` and `--version` are outside it for the same reason:
|
|
79
82
|
* they are how someone finds out what they have.
|
|
80
83
|
*/
|
|
81
|
-
const VERSION_GATED_OFFLINE_VERBS = Object.freeze([VERB_HELP, VERB_VALIDATE, VERB_WHOAMI]);
|
|
84
|
+
const VERSION_GATED_OFFLINE_VERBS = Object.freeze([VERB_HELP, VERB_VALIDATE, VERB_FORMS_VALIDATE, VERB_WHOAMI]);
|
|
82
85
|
|
|
83
86
|
// One entry per verb, in help order. `tokens` is the exact leading positional
|
|
84
87
|
// sequence; matching prefers the longest sequence, so a two-token family can
|
|
@@ -281,6 +284,47 @@ const VERBS = Object.freeze([
|
|
|
281
284
|
+ "goes live with that deploy: staging first, production when promoted. "
|
|
282
285
|
+ "See 'taproot-site help redirects'.",
|
|
283
286
|
},
|
|
287
|
+
{
|
|
288
|
+
name: VERB_FORMS_PULL,
|
|
289
|
+
surface: SURFACE_STANDARD,
|
|
290
|
+
// Forms are authored with the pages that place them, so they ride the
|
|
291
|
+
// content capability; the site's forms permissions decide what it may do.
|
|
292
|
+
capabilities: [CAPABILITY_CONTENT],
|
|
293
|
+
tokens: ["forms", "pull"],
|
|
294
|
+
summary: "Write each live form to forms/<key>.json and record the versions read.",
|
|
295
|
+
note: "Needs a pulled workspace ('pull' first). It replaces a form's file with the site's copy and records the "
|
|
296
|
+
+ "version, which 'forms push' uses to refuse a change made on the site since. An archived form is listed "
|
|
297
|
+
+ "and not written. Submissions are never pulled: they are visitors' personal data and stay on the site. "
|
|
298
|
+
+ "See 'taproot-site help forms'.",
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
name: VERB_FORMS_PUSH,
|
|
302
|
+
surface: SURFACE_STANDARD,
|
|
303
|
+
capabilities: [CAPABILITY_CONTENT],
|
|
304
|
+
tokens: ["forms", "push"],
|
|
305
|
+
positionals: "formKeys",
|
|
306
|
+
summary: "Validate forms/<key>.json and create or update each form on the site.",
|
|
307
|
+
note: "Every file is validated and compared with the site before anything is written. A form whose site "
|
|
308
|
+
+ "version moved since the last pull is refused (forms.concurrent_modification), and a form the site has "
|
|
309
|
+
+ "that this workspace never pulled is refused (forms.pull_required). Editing a definition appends a version; "
|
|
310
|
+
+ "a page keeps the version it was published with until the next deploy. An after_submit page_path is looked "
|
|
311
|
+
+ "up on the site, and a path that names no page is refused (forms.page_not_found), as is a generated, 404, legal, profile or integration page (forms.page_not_allowed). Name form keys to narrow "
|
|
312
|
+
+ "the push. See 'taproot-site help forms'.",
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
name: VERB_FORMS_VALIDATE,
|
|
316
|
+
tokens: ["forms", "validate"],
|
|
317
|
+
offline: true,
|
|
318
|
+
// Offline, but it reads taproot-site.json to find the workspace.
|
|
319
|
+
readsLocalState: true,
|
|
320
|
+
positionals: "formKeys",
|
|
321
|
+
summary: "Validate the workspace's form files offline, against the shared field schema.",
|
|
322
|
+
note: "Reads forms/<key>.json and checks the file contract and every field definition with the same validator "
|
|
323
|
+
+ "'forms push' and the published form use. Name form keys to narrow it; with none, every file under forms/ "
|
|
324
|
+
+ "is checked. It cannot prove that the site accepts the key, that an after_submit page_path names a page, or "
|
|
325
|
+
+ "that the caller may write forms. "
|
|
326
|
+
+ "See 'taproot-site help forms'.",
|
|
327
|
+
},
|
|
284
328
|
{
|
|
285
329
|
name: VERB_THEME_PUSH,
|
|
286
330
|
surface: SURFACE_DOCS_PRESENTATION,
|
|
@@ -323,7 +367,7 @@ const VERBS = Object.freeze([
|
|
|
323
367
|
// (TR00790).
|
|
324
368
|
surfaceCapabilities: { [SURFACE_DOCS_PRESENTATION]: [CAPABILITY_DESIGN] },
|
|
325
369
|
tokens: ["media", "upload"],
|
|
326
|
-
summary: "Upload
|
|
370
|
+
summary: "Upload images and videos; images are waited on until processed, and a video is ready when it confirms.",
|
|
327
371
|
positionals: "paths",
|
|
328
372
|
note: "Positional arguments name the files or directories to upload;"
|
|
329
373
|
+ " with none, the workspace's media/ directory is walked.",
|
|
@@ -556,6 +600,8 @@ function verbHelp(verb) {
|
|
|
556
600
|
? " [production | local | <url>]"
|
|
557
601
|
: verb.positionals === "siteSelector"
|
|
558
602
|
? " <site-name-or-id>"
|
|
603
|
+
: verb.positionals === "formKeys"
|
|
604
|
+
? " [form-key...]"
|
|
559
605
|
: verb.positionals
|
|
560
606
|
? ` [${verb.positionals === "paths" ? "path" : "page-path"}...]`
|
|
561
607
|
: "";
|
|
@@ -706,6 +752,7 @@ function parseReferenceArguments(arguments_) {
|
|
|
706
752
|
|| topic === "footer"
|
|
707
753
|
|| topic === "fixture"
|
|
708
754
|
|| topic === "redirects"
|
|
755
|
+
|| topic === "forms"
|
|
709
756
|
|| topic === "walkthrough"
|
|
710
757
|
|| topic === "delivery"
|
|
711
758
|
|| topic === "import"
|
|
@@ -773,6 +820,7 @@ function referenceResult(parsed) {
|
|
|
773
820
|
}
|
|
774
821
|
case "nav":
|
|
775
822
|
case "redirects":
|
|
823
|
+
case "forms":
|
|
776
824
|
case "media":
|
|
777
825
|
case "preview":
|
|
778
826
|
case "fixture":
|
package/src/constants.js
CHANGED
|
@@ -87,6 +87,9 @@ export const VERB_NAV_PUSH = "nav push";
|
|
|
87
87
|
export const VERB_REDIRECTS_CHECK = "redirects check";
|
|
88
88
|
export const VERB_REDIRECTS_PULL = "redirects pull";
|
|
89
89
|
export const VERB_REDIRECTS_PUSH = "redirects push";
|
|
90
|
+
export const VERB_FORMS_PULL = "forms pull";
|
|
91
|
+
export const VERB_FORMS_PUSH = "forms push";
|
|
92
|
+
export const VERB_FORMS_VALIDATE = "forms validate";
|
|
90
93
|
export const VERB_THEME_PUSH = "theme push";
|
|
91
94
|
export const VERB_FOOTER_PUSH = "footer push";
|
|
92
95
|
export const VERB_MEDIA_UPLOAD = "media upload";
|
|
@@ -222,6 +225,9 @@ export const LIMITS = Object.freeze({
|
|
|
222
225
|
redirectMapResponseBytes: 8 * 1024 * 1024,
|
|
223
226
|
requestMilliseconds: 60_000,
|
|
224
227
|
uploadMilliseconds: 5 * 60_000,
|
|
228
|
+
// A video is up to the licence's upload cap (at most 250 MB) over one PUT, which
|
|
229
|
+
// takes minutes where an image takes seconds.
|
|
230
|
+
videoUploadMilliseconds: 2 * 60 * 60_000,
|
|
225
231
|
deploymentMilliseconds: 30 * 60_000,
|
|
226
232
|
previewMilliseconds: 5 * 60_000,
|
|
227
233
|
pollIntervalMilliseconds: 2_000,
|
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
isUuid,
|
|
16
16
|
pointerSegment,
|
|
17
17
|
} from "./vocabulary.js";
|
|
18
|
+
import { normalizeCssLengthOverride } from "./media-presentation.js";
|
|
19
|
+
import { isVideoEmbedId, parseVideoEmbedUrl, VIDEO_EMBED_REFUSAL } from "./video-embed-url.js";
|
|
18
20
|
|
|
19
21
|
/**
|
|
20
22
|
* The eight canonical `componentBlock` data shapes. Seven remain mirrored
|
|
@@ -54,6 +56,8 @@ const LATEST_POST_PAGE_TYPES = Object.freeze([
|
|
|
54
56
|
const REGISTERED_TEXTURE_NAME_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
|
|
55
57
|
|
|
56
58
|
const str = (options = {}) => Object.freeze({ kind: "string", ...options });
|
|
59
|
+
/** A string bounded in length, optionally constrained to a pattern the published renderer also applies. */
|
|
60
|
+
const boundedStr = (maximumLength, options = {}) => str({ maximumLength, ...options });
|
|
57
61
|
const bool = () => Object.freeze({ kind: "boolean" });
|
|
58
62
|
const num = (options = {}) => Object.freeze({ kind: "number", ...options });
|
|
59
63
|
const enumOf = (...values) => Object.freeze({ kind: "enum", values: Object.freeze(values) });
|
|
@@ -61,6 +65,8 @@ const list = (item) => Object.freeze({ kind: "array", item });
|
|
|
61
65
|
const shape = (fields, options = {}) => Object.freeze({ kind: "object", fields: Object.freeze(fields), ...options });
|
|
62
66
|
const image = () => Object.freeze({ kind: "image" });
|
|
63
67
|
const safeUrl = (options = {}) => Object.freeze({ kind: "safe-url", ...options });
|
|
68
|
+
/** A CSS length the published renderer accepts as a per-block override; `allowNone` adds `none` (a max height). */
|
|
69
|
+
const cssLength = (options = {}) => Object.freeze({ kind: "css-length", ...options });
|
|
64
70
|
const textureName = () => Object.freeze({ kind: "texture-name", maximumLength: 64 });
|
|
65
71
|
const withOmission = (spec, whenOmitted) => Object.freeze({ ...spec, whenOmitted: Object.freeze(whenOmitted) });
|
|
66
72
|
|
|
@@ -279,6 +285,52 @@ const IMAGE_BANNER = Object.freeze({
|
|
|
279
285
|
imageMotion: enumOf("none", "slow-zoom", "drift-start", "drift-end", "parallax-subtle", "parallax"),
|
|
280
286
|
});
|
|
281
287
|
|
|
288
|
+
/** The `W:H` form of a video's aspect ratio, as the published renderer and the editor accept it. */
|
|
289
|
+
const VIDEO_ASPECT_RATIO_PATTERN = "^[1-9][0-9]{0,2}:[1-9][0-9]{0,2}$";
|
|
290
|
+
const VIDEO_ASPECT_RATIO_RE = new RegExp(VIDEO_ASPECT_RATIO_PATTERN, "u");
|
|
291
|
+
|
|
292
|
+
/** Shown with both video blocks: the two optional fields follow the same site settings images use. */
|
|
293
|
+
const MEDIA_PRESENTATION_HELP =
|
|
294
|
+
"Leave maxHeight and borderWidth out to follow the site's image max height and border; set maxHeight (a viewport or fixed height such as 50vh or 30rem, or none) or borderWidth (such as 4px, or 0 for no border) to override them for this block. The frame is never wider than the video's own pixel size.";
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* `VideoData`. The page names a library video by id and nothing about where it
|
|
298
|
+
* is served: the generator resolves the id at publish time and refuses a video
|
|
299
|
+
* that is not `ready` or not this site's own. Delivery URLs are not authorable
|
|
300
|
+
* here, so a `delivery` key is refused like any other unknown field.
|
|
301
|
+
*/
|
|
302
|
+
const VIDEO = Object.freeze({
|
|
303
|
+
videoId: Object.freeze({ kind: "uuid", required: true }),
|
|
304
|
+
poster: image(),
|
|
305
|
+
caption: boundedStr(300),
|
|
306
|
+
aspectRatio: str({
|
|
307
|
+
pattern: VIDEO_ASPECT_RATIO_RE,
|
|
308
|
+
patternSource: VIDEO_ASPECT_RATIO_PATTERN,
|
|
309
|
+
patternDescription: "two whole numbers of at most three digits joined by a colon, such as 16:9",
|
|
310
|
+
}),
|
|
311
|
+
maxHeight: cssLength({ allowNone: true }),
|
|
312
|
+
borderWidth: cssLength(),
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* `VideoEmbedData`. A YouTube or Vimeo video is stored as its provider and id,
|
|
317
|
+
* never as a link: the published page builds the player URL from the pair. A
|
|
318
|
+
* Markdown component fence may name the video by `url` instead, and
|
|
319
|
+
* `expandComponentAuthoringShorthand` reduces it to the pair before validation.
|
|
320
|
+
*/
|
|
321
|
+
const VIDEO_EMBED = Object.freeze({
|
|
322
|
+
provider: Object.freeze({ ...enumOf("youtube", "vimeo"), required: true }),
|
|
323
|
+
videoId: boundedStr(32, { required: true, nonWhitespace: true }),
|
|
324
|
+
title: boundedStr(200, { required: true, nonWhitespace: true }),
|
|
325
|
+
poster: image(),
|
|
326
|
+
aspectRatio: str({
|
|
327
|
+
pattern: VIDEO_ASPECT_RATIO_RE,
|
|
328
|
+
patternSource: VIDEO_ASPECT_RATIO_PATTERN,
|
|
329
|
+
patternDescription: "two whole numbers of at most three digits joined by a colon, such as 16:9",
|
|
330
|
+
}),
|
|
331
|
+
maxHeight: cssLength({ allowNone: true }),
|
|
332
|
+
borderWidth: cssLength(),
|
|
333
|
+
});
|
|
282
334
|
const SPACER = Object.freeze({
|
|
283
335
|
height: enumOf("small", "medium", "large"),
|
|
284
336
|
showDivider: bool(),
|
|
@@ -286,14 +338,31 @@ const SPACER = Object.freeze({
|
|
|
286
338
|
dividerWidth: num({ integer: true, minimum: 1 }),
|
|
287
339
|
});
|
|
288
340
|
|
|
341
|
+
/**
|
|
342
|
+
* A form defined under Site → Forms (or with `forms push`), placed by key. The
|
|
343
|
+
* key is checked against the site's forms when the page is published, not here:
|
|
344
|
+
* this table only knows the shape.
|
|
345
|
+
*/
|
|
346
|
+
const FORM = Object.freeze({
|
|
347
|
+
formKey: str(),
|
|
348
|
+
});
|
|
349
|
+
|
|
289
350
|
function deepFreeze(value) {
|
|
290
351
|
if (value === null || typeof value !== "object" || Object.isFrozen(value)) return value;
|
|
291
352
|
for (const child of Object.values(value)) deepFreeze(child);
|
|
292
353
|
return Object.freeze(value);
|
|
293
354
|
}
|
|
294
355
|
|
|
295
|
-
function component(fields, displayName, summary, defaultData, accessibility, example) {
|
|
296
|
-
return deepFreeze({
|
|
356
|
+
function component(fields, displayName, summary, defaultData, accessibility, example, authoring) {
|
|
357
|
+
return deepFreeze({
|
|
358
|
+
fields,
|
|
359
|
+
displayName,
|
|
360
|
+
summary,
|
|
361
|
+
defaultData,
|
|
362
|
+
accessibility,
|
|
363
|
+
example,
|
|
364
|
+
...(authoring ? { authoring } : {}),
|
|
365
|
+
});
|
|
297
366
|
}
|
|
298
367
|
|
|
299
368
|
/**
|
|
@@ -555,6 +624,62 @@ const COMPONENT_DEFINITIONS = Object.freeze({
|
|
|
555
624
|
imageMotion: "parallax-subtle",
|
|
556
625
|
},
|
|
557
626
|
),
|
|
627
|
+
"form": component(
|
|
628
|
+
FORM,
|
|
629
|
+
"Form",
|
|
630
|
+
"A form from Site → Forms that visitors fill in and submit.",
|
|
631
|
+
{ formKey: "" },
|
|
632
|
+
[
|
|
633
|
+
"Set formKey to the key of an existing form ('forms push' creates it); an unknown or archived key fails the publish.",
|
|
634
|
+
"The button text and what happens after a response is sent belong to the form, not the page: set submit_label and after_submit in the form's file (see 'help forms').",
|
|
635
|
+
"The form's field labels are its accessible names; write each so it makes sense out of context.",
|
|
636
|
+
],
|
|
637
|
+
{ formKey: "contact" },
|
|
638
|
+
),
|
|
639
|
+
"video": component(
|
|
640
|
+
VIDEO,
|
|
641
|
+
"Video",
|
|
642
|
+
"A video from the site's library that plays when the visitor presses play; nothing loads before then.",
|
|
643
|
+
{},
|
|
644
|
+
[
|
|
645
|
+
"Add a caption that tells a visitor what the video is about; the caption is also the player's accessible name.",
|
|
646
|
+
"A video with spoken content needs captions or a transcript in the page text; the player does not generate them.",
|
|
647
|
+
"Leave aspectRatio out to follow the video's own shape; set it only to crop the frame to a different one.",
|
|
648
|
+
],
|
|
649
|
+
{
|
|
650
|
+
videoId: "7c5e2b1a-9d3f-4a68-b0c4-1e2f3a4b5c6d",
|
|
651
|
+
caption: "A walk through the studio before your first class.",
|
|
652
|
+
aspectRatio: "16:9",
|
|
653
|
+
},
|
|
654
|
+
{ notes: [MEDIA_PRESENTATION_HELP] },
|
|
655
|
+
),
|
|
656
|
+
"video-embed": component(
|
|
657
|
+
VIDEO_EMBED,
|
|
658
|
+
"Video embed",
|
|
659
|
+
"A YouTube or Vimeo video shown as a poster and a play button; nothing from the provider loads until the visitor presses play.",
|
|
660
|
+
{},
|
|
661
|
+
[
|
|
662
|
+
"Set title to what the video is about; it names the play button and the player for assistive technology.",
|
|
663
|
+
"A video with spoken content needs captions turned on at the provider or a transcript in the page text.",
|
|
664
|
+
"Leave poster out and 'pages push' copies the provider's thumbnail into the site's images and sets it (once per video), or set poster to one of the site's images. The published page never loads anything from the provider before the visitor presses play; if the copy is refused the block is sent with no poster.",
|
|
665
|
+
],
|
|
666
|
+
{
|
|
667
|
+
provider: "youtube",
|
|
668
|
+
videoId: "dQw4w9WgXcQ",
|
|
669
|
+
title: "A walk through the studio before your first class.",
|
|
670
|
+
aspectRatio: "16:9",
|
|
671
|
+
},
|
|
672
|
+
{
|
|
673
|
+
notes: [
|
|
674
|
+
MEDIA_PRESENTATION_HELP,
|
|
675
|
+
"In a Markdown fence, give the video as url (a YouTube or Vimeo link) and title instead of provider and videoId; the CLI reduces the link to provider and videoId before it validates or sends the page.",
|
|
676
|
+
"A link that is not an accepted YouTube or Vimeo video gets a one-line refusal that names the links it accepts.",
|
|
677
|
+
"url together with provider or videoId is refused: give the video one way.",
|
|
678
|
+
"Stored blocks and .pm.json sources hold only provider and videoId; url is never stored.",
|
|
679
|
+
],
|
|
680
|
+
markdownExample: "```component:video-embed\n{\"url\": \"https://youtu.be/dQw4w9WgXcQ\", \"title\": \"A walk through the studio before your first class.\"}\n```",
|
|
681
|
+
},
|
|
682
|
+
),
|
|
558
683
|
});
|
|
559
684
|
|
|
560
685
|
export const COMPONENT_TYPES = Object.freeze(Object.keys(COMPONENT_DEFINITIONS));
|
|
@@ -576,6 +701,10 @@ function referenceSchema(spec) {
|
|
|
576
701
|
return {
|
|
577
702
|
type: "string",
|
|
578
703
|
...(spec.nonWhitespace ? { minLength: 1, description: "Must contain non-whitespace text." } : {}),
|
|
704
|
+
...(spec.maximumLength !== undefined ? { maxLength: spec.maximumLength } : {}),
|
|
705
|
+
...(spec.patternSource !== undefined
|
|
706
|
+
? { pattern: spec.patternSource, description: `Must be ${spec.patternDescription}.` }
|
|
707
|
+
: {}),
|
|
579
708
|
};
|
|
580
709
|
case "safe-url":
|
|
581
710
|
return {
|
|
@@ -585,6 +714,8 @@ function referenceSchema(spec) {
|
|
|
585
714
|
spec.allowEmpty === false ? "An" : "Empty, or an"
|
|
586
715
|
} explicit HTTP(S), mailto, or tel URL; a root-relative path that does not begin with //; a fragment; a query; or a relative URL. Backslashes and ASCII control characters are rejected. Contact URLs require a non-empty body and refuse tel:// and mailto:// authority forms.`,
|
|
587
716
|
};
|
|
717
|
+
case "css-length":
|
|
718
|
+
return { type: "string", description: `Must be ${describe(spec)}.` };
|
|
588
719
|
case "texture-name":
|
|
589
720
|
return {
|
|
590
721
|
type: "string",
|
|
@@ -689,6 +820,10 @@ function describe(spec) {
|
|
|
689
820
|
return `${
|
|
690
821
|
spec.allowEmpty === false ? "a" : "an empty string or a"
|
|
691
822
|
} safe HTTP(S), mailto, tel, non-protocol-relative root path, fragment, query, or relative URL; backslashes and ASCII control characters are rejected`;
|
|
823
|
+
case "css-length":
|
|
824
|
+
return spec.allowNone
|
|
825
|
+
? "a CSS height such as 60vh or 32rem, or none"
|
|
826
|
+
: "a CSS width such as 2px or 0.25rem, or 0 for no border";
|
|
692
827
|
case "texture-name":
|
|
693
828
|
return "a lowercase hyphenated texture identifier of at most 64 characters";
|
|
694
829
|
default:
|
|
@@ -709,6 +844,9 @@ function validateValue(spec, value, path, errors) {
|
|
|
709
844
|
case "string":
|
|
710
845
|
if (typeof value !== "string") fail("a string");
|
|
711
846
|
else if (spec.nonWhitespace && value.trim() === "") fail("a string containing non-whitespace text");
|
|
847
|
+
else if (spec.maximumLength !== undefined && value.length > spec.maximumLength) {
|
|
848
|
+
fail(`a string of at most ${spec.maximumLength} characters`);
|
|
849
|
+
} else if (spec.pattern !== undefined && !spec.pattern.test(value)) fail(spec.patternDescription);
|
|
712
850
|
return;
|
|
713
851
|
case "safe-url":
|
|
714
852
|
if (
|
|
@@ -723,6 +861,10 @@ function validateValue(spec, value, path, errors) {
|
|
|
723
861
|
fail(describe(spec));
|
|
724
862
|
}
|
|
725
863
|
return;
|
|
864
|
+
case "css-length":
|
|
865
|
+
// The renderer drops a value outside this grammar silently, so it is refused here instead.
|
|
866
|
+
if (normalizeCssLengthOverride(value, { allowNone: spec.allowNone === true }) === null) fail(describe(spec));
|
|
867
|
+
return;
|
|
726
868
|
case "texture-name":
|
|
727
869
|
if (
|
|
728
870
|
typeof value !== "string"
|
|
@@ -826,6 +968,31 @@ function validateFields(fields, value, path, errors) {
|
|
|
826
968
|
}
|
|
827
969
|
}
|
|
828
970
|
|
|
971
|
+
/**
|
|
972
|
+
* Lets a Markdown component fence name a video embed by link. Returns the
|
|
973
|
+
* fence's JSON source with `url` replaced by the `provider` and `videoId` it
|
|
974
|
+
* reduces to, or `{ error }` with the one-line reason a link is refused. Any
|
|
975
|
+
* other component, and a source that is not a JSON object, comes back as it was
|
|
976
|
+
* so validation reports it in its usual words.
|
|
977
|
+
*/
|
|
978
|
+
export function expandComponentAuthoringShorthand(componentType, source) {
|
|
979
|
+
if (componentType !== "video-embed") return { source };
|
|
980
|
+
let parsed;
|
|
981
|
+
try {
|
|
982
|
+
parsed = JSON.parse(source);
|
|
983
|
+
} catch {
|
|
984
|
+
return { source };
|
|
985
|
+
}
|
|
986
|
+
if (!isPlainObject(parsed) || !Object.hasOwn(parsed, "url")) return { source };
|
|
987
|
+
if (Object.hasOwn(parsed, "provider") || Object.hasOwn(parsed, "videoId")) {
|
|
988
|
+
return { error: "Give the video as url, or as provider and videoId, not both." };
|
|
989
|
+
}
|
|
990
|
+
const target = parseVideoEmbedUrl(parsed.url);
|
|
991
|
+
if (!target) return { error: VIDEO_EMBED_REFUSAL };
|
|
992
|
+
const { url: _url, ...rest } = parsed;
|
|
993
|
+
return { source: JSON.stringify({ provider: target.provider, videoId: target.videoId, ...rest }) };
|
|
994
|
+
}
|
|
995
|
+
|
|
829
996
|
/**
|
|
830
997
|
* Validates a `componentBlock`'s `componentType` and `componentData` pair.
|
|
831
998
|
*
|
|
@@ -894,6 +1061,17 @@ export function validateComponentBlock(componentType, componentData, basePath) {
|
|
|
894
1061
|
}
|
|
895
1062
|
}
|
|
896
1063
|
}
|
|
1064
|
+
if (
|
|
1065
|
+
componentType === "video-embed" && typeof parsed.provider === "string" && typeof parsed.videoId === "string"
|
|
1066
|
+
&& (parsed.provider === "youtube" || parsed.provider === "vimeo")
|
|
1067
|
+
&& !isVideoEmbedId(parsed.provider, parsed.videoId)
|
|
1068
|
+
) {
|
|
1069
|
+
errors.push(contentError(
|
|
1070
|
+
`${dataPath}/videoId`,
|
|
1071
|
+
CODES.componentData,
|
|
1072
|
+
`Component data field 'videoId' must be a valid ${parsed.provider} video id. ${VIDEO_EMBED_REFUSAL}`,
|
|
1073
|
+
));
|
|
1074
|
+
}
|
|
897
1075
|
return errors;
|
|
898
1076
|
}
|
|
899
1077
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version":
|
|
2
|
+
"version": 12,
|
|
3
3
|
"section": {
|
|
4
4
|
"nodeType": "section",
|
|
5
5
|
"label": "Section",
|
|
@@ -554,6 +554,21 @@
|
|
|
554
554
|
"label": "Image Banner",
|
|
555
555
|
"measure": "wide",
|
|
556
556
|
"rootPlacement": "root-band"
|
|
557
|
+
},
|
|
558
|
+
"form": {
|
|
559
|
+
"label": "Form",
|
|
560
|
+
"measure": "wide",
|
|
561
|
+
"rootPlacement": "flow"
|
|
562
|
+
},
|
|
563
|
+
"video": {
|
|
564
|
+
"label": "Video",
|
|
565
|
+
"measure": "wide",
|
|
566
|
+
"rootPlacement": "flow"
|
|
567
|
+
},
|
|
568
|
+
"video-embed": {
|
|
569
|
+
"label": "Video embed",
|
|
570
|
+
"measure": "wide",
|
|
571
|
+
"rootPlacement": "flow"
|
|
557
572
|
}
|
|
558
573
|
},
|
|
559
574
|
"integrationPlacement": {
|
package/src/content/markdown.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { SiteAuthoringError } from "../errors.js";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
canonicalizeComponentData,
|
|
4
|
+
expandComponentAuthoringShorthand,
|
|
5
|
+
isComponentType,
|
|
6
|
+
validateComponentBlock,
|
|
7
|
+
} from "./components.js";
|
|
3
8
|
import {
|
|
4
9
|
FREE_FORM_SECTION_REGISTRY,
|
|
5
10
|
normalizeFreeFormSectionBackground,
|
|
@@ -869,7 +874,12 @@ function componentNode(componentType, body, line) {
|
|
|
869
874
|
line.number,
|
|
870
875
|
);
|
|
871
876
|
}
|
|
872
|
-
const
|
|
877
|
+
const authored = body.trim() === "" ? "{}" : body;
|
|
878
|
+
const expanded = expandComponentAuthoringShorthand(componentType, authored);
|
|
879
|
+
if (expanded.error !== undefined) {
|
|
880
|
+
throw fail(CODES.componentData, "component block", expanded.error, line.number);
|
|
881
|
+
}
|
|
882
|
+
const source = expanded.source;
|
|
873
883
|
const errors = validateComponentBlock(componentType, source, "");
|
|
874
884
|
if (errors.length > 0) {
|
|
875
885
|
throw fail(errors[0].code, "component block", errors[0].message, line.number);
|