caracms 0.1.0 → 0.3.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 +247 -3
- package/bin/caracms.mjs +32 -16
- package/bin/cloudflare-ips.mjs +38 -0
- package/bin/gate.mjs +277 -4
- package/bin/init.mjs +65 -20
- package/bin/templates/catch-all.astro.txt +3 -2
- package/package.json +1 -1
- package/skills/caracms-adopt/SKILL.md +17 -3
- package/skills/caracms-adopt/look-same.mjs +24 -11
- package/skills/caracms-blocks/SKILL.md +69 -4
- package/skills/caracms-content/SKILL.md +2 -1
- package/skills/caracms-tina/SKILL.md +431 -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/cli/validate.ts +10 -5
- package/src/components/BlockRunTree.astro +45 -0
- package/src/components/Blocks.astro +20 -1
- package/src/components/Form.astro +76 -10
- package/src/components/Head.astro +4 -2
- package/src/components/Media.astro +27 -1
- package/src/components/RichText.astro +12 -4
- package/src/components/Text.astro +32 -9
- package/src/components/form-client.ts +7 -4
- package/src/components/sitemap.xml.ts +2 -1
- package/src/config.ts +32 -1
- package/src/content-schema.ts +7 -4
- package/src/content.ts +28 -0
- package/src/document.ts +15 -2
- package/src/editor/client/block-list/summary.ts +7 -1
- package/src/editor/client/editor.ts +24 -6
- package/src/editor/client/fields/HiddenToggle.tsx +51 -0
- package/src/editor/client/fields/LinkField.tsx +17 -12
- package/src/editor/client/fields/ReferenceField.tsx +6 -0
- package/src/editor/client/fields/RichTextField.tsx +38 -32
- package/src/editor/client/fields/host.tsx +5 -0
- package/src/editor/client/fields/parts.tsx +2 -1
- package/src/editor/client/fields/rich-text-doc.ts +28 -2
- package/src/editor/client/fields/rich-text-schema.ts +41 -2
- package/src/editor/client/live/publish.tsx +20 -10
- package/src/editor/client/nav/DocRow.tsx +6 -0
- package/src/editor/client/overlays/rich-text-bar.ts +6 -6
- package/src/editor/client/pane/levels/DocumentLevel.tsx +3 -0
- package/src/editor/client/pane/parts.tsx +34 -2
- package/src/editor/client/preview/driver.ts +5 -5
- package/src/editor/client/preview/field-text.ts +40 -2
- package/src/editor/client/preview/page-edit.ts +2 -2
- package/src/editor/client/preview/page-editor-registry.ts +4 -3
- package/src/editor/client/preview/page-editor.ts +3 -3
- package/src/editor/client/state/document-store.ts +20 -2
- package/src/editor/client/state/keeper.ts +51 -6
- package/src/editor/client/state/live.ts +45 -6
- package/src/editor/middleware.ts +3 -2
- package/src/editor/routes/try-again.ts +2 -1
- package/src/editor/server/admin-props.ts +2 -1
- package/src/editor/server/mode.ts +13 -3
- package/src/editor/styles/editor.css +2 -0
- package/src/editor/styles/levels/live.css +1 -1
- package/src/editor/types.ts +27 -6
- package/src/fields.ts +63 -15
- package/src/files-route.ts +9 -4
- package/src/forms/endpoint.ts +11 -16
- package/src/forms/rules.ts +9 -1
- package/src/head.ts +17 -4
- package/src/index.ts +53 -7
- package/src/links.ts +35 -0
- package/src/live/README.md +5 -2
- package/src/live/auth/auth.ts +4 -1
- package/src/live/auth/guard.ts +3 -2
- package/src/live/auth/index.ts +6 -7
- package/src/live/auth/login.ts +17 -8
- package/src/live/auth/middleware.ts +7 -3
- package/src/live/auth/request.ts +92 -11
- package/src/live/auth/views.ts +9 -4
- package/src/live/autosaves.ts +59 -16
- package/src/live/backend.ts +54 -24
- package/src/live/branches.ts +19 -0
- package/src/live/drafts-store.ts +4 -3
- package/src/live/editor.ts +92 -67
- package/src/live/git.ts +112 -34
- package/src/live/github.ts +128 -13
- package/src/live/locks.ts +92 -26
- package/src/live/mode.ts +177 -18
- package/src/live/notices.ts +100 -0
- package/src/live/redis.ts +161 -39
- package/src/live/runtime.ts +15 -4
- package/src/live/status-cache.ts +219 -0
- package/src/live/status.ts +14 -2
- package/src/live/timings.ts +24 -0
- package/src/media.ts +40 -7
- package/src/read.ts +2 -1
- package/src/rich-text.ts +30 -5
- package/src/serialize.ts +10 -1
|
@@ -27,6 +27,11 @@
|
|
|
27
27
|
// --ignore-attr <list> optional: attributes the HTML diff drops, a comma list of names. A name
|
|
28
28
|
// ending in * matches by prefix. A Tina port, for example:
|
|
29
29
|
// --ignore-attr data-tina-field,data-tina-island or --ignore-attr 'data-tina-*'
|
|
30
|
+
// --css <rules> optional: CSS added to both pages just before each shot, for an element
|
|
31
|
+
// that varies from run to run on the old site itself (a wordmark fitted to its
|
|
32
|
+
// box while the display font loads). Hide it or pin its size on both sides, e.g.
|
|
33
|
+
// --css '[data-wordmark]{visibility:hidden!important}'. It changes the shots
|
|
34
|
+
// only, not the HTML diff.
|
|
30
35
|
// --mask-ids <regex|auto> optional: ids drawn at random per render (MEC's st-a8f3k2, React's
|
|
31
36
|
// :r1:) differ on every build, and so does every attribute that points at
|
|
32
37
|
// them. Ids whose value matches the regex (JavaScript syntax, unanchored:
|
|
@@ -105,7 +110,7 @@
|
|
|
105
110
|
// Exit codes: 0 when the report is written (whatever the scores, statuses or image caps), 1 for
|
|
106
111
|
// a usage error, 2 when the report is written but some page couldn't be fetched at all.
|
|
107
112
|
|
|
108
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
113
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
109
114
|
import { createRequire } from 'node:module';
|
|
110
115
|
import { join, resolve } from 'node:path';
|
|
111
116
|
import { pathToFileURL } from 'node:url';
|
|
@@ -118,7 +123,7 @@ const TOLERANCE = 16;
|
|
|
118
123
|
function usage(message) {
|
|
119
124
|
if (message) console.error(`look-same: ${message}`);
|
|
120
125
|
console.error(
|
|
121
|
-
'usage: node look-same.mjs --playwright <dir> --old <url> --new <url> --pages <file|a,b,c> --out <dir> [--widths 1440,390] [--image-wait 5] [--ignore-attr a,data-b-*] [--mask-ids <regex>|auto]',
|
|
126
|
+
'usage: node look-same.mjs --playwright <dir> --old <url> --new <url> --pages <file|a,b,c> --out <dir> [--widths 1440,390] [--image-wait 5] [--ignore-attr a,data-b-*] [--mask-ids <regex>|auto] [--css <rules>]',
|
|
122
127
|
);
|
|
123
128
|
process.exit(1);
|
|
124
129
|
}
|
|
@@ -137,7 +142,9 @@ function parseArgs(argv) {
|
|
|
137
142
|
}
|
|
138
143
|
|
|
139
144
|
function readPages(spec) {
|
|
140
|
-
|
|
145
|
+
// A list file only when it's a file: `--pages /` is the home page, not the disk's root.
|
|
146
|
+
const isFile = existsSync(spec) && statSync(spec).isFile();
|
|
147
|
+
const lines = isFile ? readFileSync(spec, 'utf8').split('\n') : spec.split(',');
|
|
141
148
|
return lines
|
|
142
149
|
.map((line) => line.replace(/#.*/, '').trim())
|
|
143
150
|
.filter(Boolean)
|
|
@@ -310,7 +317,7 @@ function compare(oldPng, newPng) {
|
|
|
310
317
|
|
|
311
318
|
// ---------------------------------------------------------------- taking a page
|
|
312
319
|
|
|
313
|
-
async function shoot(browser, url, width, imageWaitMs) {
|
|
320
|
+
async function shoot(browser, url, width, imageWaitMs, css) {
|
|
314
321
|
const context = await browser.newContext({
|
|
315
322
|
viewport: { width, height: width >= 768 ? 900 : 844 },
|
|
316
323
|
deviceScaleFactor: 1,
|
|
@@ -332,11 +339,13 @@ async function shoot(browser, url, width, imageWaitMs) {
|
|
|
332
339
|
const bottom = document.documentElement.scrollHeight - window.innerHeight;
|
|
333
340
|
if (y >= bottom) break;
|
|
334
341
|
y = Math.min(y + Math.round(window.innerHeight * 0.75), bottom);
|
|
335
|
-
|
|
342
|
+
// Instant, whatever the page's own `scroll-behavior`: a smooth scroll still moving at
|
|
343
|
+
// the shot puts a sticky header somewhere else each run (Urban Return port).
|
|
344
|
+
window.scrollTo({ top: y, behavior: 'instant' });
|
|
336
345
|
await pause(120);
|
|
337
346
|
}
|
|
338
347
|
await pause(300);
|
|
339
|
-
window.scrollTo(0,
|
|
348
|
+
window.scrollTo({ top: 0, behavior: 'instant' });
|
|
340
349
|
});
|
|
341
350
|
await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {});
|
|
342
351
|
await page.evaluate(() => document.fonts.ready);
|
|
@@ -371,6 +380,7 @@ async function shoot(browser, url, width, imageWaitMs) {
|
|
|
371
380
|
return { stillLoading: still.length, images: still.slice(0, 3).map((img) => img.currentSrc || img.src) };
|
|
372
381
|
}, imageWaitMs);
|
|
373
382
|
await page.waitForTimeout(300);
|
|
383
|
+
if (css) await page.addStyleTag({ content: css });
|
|
374
384
|
const png = await page.screenshot({ fullPage: true, animations: 'disabled', type: 'png' });
|
|
375
385
|
return { png, status, imageCap };
|
|
376
386
|
} finally {
|
|
@@ -815,7 +825,9 @@ function renderPage(report, rows) {
|
|
|
815
825
|
? ` Ids matching <code>${escape(report.options.maskIds)}</code> (<code>--mask-ids</code>), and the references to them, are written <code>[id1]</code>, <code>[id2]</code>, … in the order the ids come on the page (a reference to an id the page doesn't have is <code>[missing1]</code>, …), so a reference that no longer points at its element still shows.`
|
|
816
826
|
: ''
|
|
817
827
|
} It catches what a screenshot can't: an attribute, an order under a cover, a lost space.</p>
|
|
818
|
-
<p><strong>Status:</strong> every page is compared whatever it answers (a 404 page too), and its HTTP status is shown beside it; two different statuses are a difference. <strong>Images:</strong> each shot waits up to ${report.options.imageWait} s for the images that have started loading; a shot that gave up says so
|
|
828
|
+
<p><strong>Status:</strong> every page is compared whatever it answers (a 404 page too), and its HTTP status is shown beside it; two different statuses are a difference. <strong>Images:</strong> each shot waits up to ${report.options.imageWait} s for the images that have started loading; a shot that gave up says so.${
|
|
829
|
+
report.options.css ? ` <strong>CSS</strong> (<code>--css</code>) added to both pages before each shot: <code>${escape(report.options.css)}</code>.` : ''
|
|
830
|
+
}</p>
|
|
819
831
|
</header>
|
|
820
832
|
${rows}
|
|
821
833
|
</body>
|
|
@@ -843,8 +855,9 @@ async function main() {
|
|
|
843
855
|
usage(`--mask-ids isn't a regular expression: ${error instanceof Error ? error.message : error}`);
|
|
844
856
|
}
|
|
845
857
|
}
|
|
858
|
+
const css = args.css ?? '';
|
|
846
859
|
const htmlOptions = { ignore: ignoredAttributes(args['ignore-attr']), maskIds };
|
|
847
|
-
const known = new Set(['playwright', 'old', 'new', 'pages', 'out', 'widths', 'image-wait', 'ignore-attr', 'mask-ids']);
|
|
860
|
+
const known = new Set(['playwright', 'old', 'new', 'pages', 'out', 'widths', 'image-wait', 'ignore-attr', 'mask-ids', 'css']);
|
|
848
861
|
for (const key of Object.keys(args)) if (!known.has(key)) usage(`unknown option --${key}`);
|
|
849
862
|
|
|
850
863
|
const chromium = await loadPlaywright(args.playwright ?? process.env.LOOK_SAME_PLAYWRIGHT);
|
|
@@ -885,8 +898,8 @@ async function main() {
|
|
|
885
898
|
const newUrl = joinUrl(args.new, path);
|
|
886
899
|
const result = { path, width, oldUrl, newUrl, files };
|
|
887
900
|
try {
|
|
888
|
-
const oldShot = await shoot(browser, oldUrl, width, imageWait * 1000);
|
|
889
|
-
const newShot = await shoot(browser, newUrl, width, imageWait * 1000);
|
|
901
|
+
const oldShot = await shoot(browser, oldUrl, width, imageWait * 1000, css);
|
|
902
|
+
const newShot = await shoot(browser, newUrl, width, imageWait * 1000, css);
|
|
890
903
|
writeFileSync(join(out, files.old), oldShot.png);
|
|
891
904
|
writeFileSync(join(out, files.new), newShot.png);
|
|
892
905
|
const { diff, ...score } = compare(oldShot.png, newShot.png);
|
|
@@ -929,7 +942,7 @@ async function main() {
|
|
|
929
942
|
generated: new Date().toISOString(),
|
|
930
943
|
score: `differingShare: of the pixels both screenshots cover, the share differing in any colour channel by more than ${TOLERANCE}/255. heightDelta: new height minus old, in CSS pixels.`,
|
|
931
944
|
tolerance: TOLERANCE,
|
|
932
|
-
options: { widths, imageWait, ignoreAttr: args['ignore-attr'] ?? null, maskIds: maskIds ? (args['mask-ids'] === 'auto' ? `auto (${AUTO_IDS.source})` : maskIds.source) : null },
|
|
945
|
+
options: { widths, imageWait, ignoreAttr: args['ignore-attr'] ?? null, maskIds: maskIds ? (args['mask-ids'] === 'auto' ? `auto (${AUTO_IDS.source})` : maskIds.source) : null, css: css || null },
|
|
933
946
|
status: 'status: the HTTP status each side answered, { old, new }. statusDiffers: true when they differ, which is a difference, not an error. imageCap (results only, when present): the shot gave up waiting for images after imageWait seconds; per side, how many were still loading and the first few addresses.',
|
|
934
947
|
results,
|
|
935
948
|
htmlScore:
|
|
@@ -124,7 +124,18 @@ const { block, cms } = Astro.props;
|
|
|
124
124
|
width / height) for a fluid image, or a CSS `width` and/or `height` for a fixed one;
|
|
125
125
|
`fit: 'contain'` to fit instead of crop. The block's CSS sizes it through `class`.
|
|
126
126
|
It takes the element's own attributes (`id`, `title`, `draggable`, `aria-*`,
|
|
127
|
-
`data-*`) too.
|
|
127
|
+
`data-*`) too. With an empty value it draws nothing for visitors and, in a preview,
|
|
128
|
+
a dashed placeholder the editor can click to pick the image. So call it as
|
|
129
|
+
`(block.media || cms.preview) && <Media …/>`, not `block.media && …`, wherever an
|
|
130
|
+
empty slot should still be reachable on the page.
|
|
131
|
+
- **A media value reaches the block resolved, not as stored.** Where the file has
|
|
132
|
+
`{ key, alt, focal, w, h }` or `{ video, play, poster: 12.5 }`, the block gets
|
|
133
|
+
`{ kind: 'image', key, alt, decorative, focal, position, w, h }` or
|
|
134
|
+
`{ kind: 'video', playbackId, play, name, mp4, poster: <url>, duration, w, h }`
|
|
135
|
+
(`poster` is Mux's still as a URL, `w`, `h` and `duration` are numbers or `null`;
|
|
136
|
+
`block.media` is `null` when the slot is empty). A site helper that reads a media
|
|
137
|
+
value (a hero's poster, a video's `playbackId`) reads this shape; use `kind` to tell
|
|
138
|
+
the two apart.
|
|
128
139
|
- `cms` is the render context: `cms.preview` (a preview render), `cms.first`,
|
|
129
140
|
`cms.index`, `cms.read` (other documents, below). For "the first block on the page"
|
|
130
141
|
(an eager hero, a heading that opens the page) use `cms.first`, true for the first
|
|
@@ -165,6 +176,8 @@ journal: { kind: 'sections', folder: 'content/journal', urlBase: '/journal/', fi
|
|
|
165
176
|
static part is that `urlBase` (`src/pages/work/[slug].astro`, rendering with
|
|
166
177
|
`RenderDocument` from `caracms/components/RenderDocument.astro` and `listDocuments` /
|
|
167
178
|
`readDocument` from `caracms/content`, as `src/pages/[...path].astro` does for pages).
|
|
179
|
+
Its `getStaticPaths` leaves out entries with a `warning` (a file that can't be opened) and
|
|
180
|
+
`hidden` ones (a page hidden from the site, or one under it): those answer the 404 page.
|
|
168
181
|
Every other route in `src/pages/` is the site's own code, and its address can't hold a
|
|
169
182
|
document.
|
|
170
183
|
- Shared blocks (`content/shared/`) and Site Settings are built in; no config.
|
|
@@ -192,6 +205,11 @@ const settings = await siteSettings(site, Astro.locals);
|
|
|
192
205
|
---
|
|
193
206
|
```
|
|
194
207
|
|
|
208
|
+
**A block that needs Site Settings** (a form block showing the site's phone number) reads
|
|
209
|
+
them the same way, with `siteSettings(site, Astro.locals)`: in a preview it reads the
|
|
210
|
+
editor's draft, so an edit to Site Settings shows in the block too. `cms.read` is for
|
|
211
|
+
collections only; it doesn't reach Site Settings.
|
|
212
|
+
|
|
195
213
|
## Text styles and lockups
|
|
196
214
|
|
|
197
215
|
A lockup is a heading whose lines are set by the editor and fitted to their box in a
|
|
@@ -247,13 +265,33 @@ editor's preview alike. It gets `PageProps` (`caracms/document`): `document`,
|
|
|
247
265
|
home page's own `seo.title`.) The ` · ` is the
|
|
248
266
|
config's `titleSeparator`, spaces included: `titleSeparator: ' | '` beside `page` in
|
|
249
267
|
`defineSite({ … })` keeps a site's old `About | Fernhill`. It's code, not a Site
|
|
250
|
-
Settings field: the title's format is design.
|
|
268
|
+
Settings field: the title's format is design. `titleFallback: 'site'` beside it makes every
|
|
269
|
+
page without its own `seo.title` fall back as the home page does (Site Settings'
|
|
270
|
+
`seo.title`, then the brand name); the default is `'page-and-brand'`. A code route builds its title the same way
|
|
251
271
|
from `siteSettings`: `` `${title}${settings.seo.titleSeparator}${settings.brand.name}` ``. `<Blocks>` renders the blocks (or a fields document through its
|
|
252
272
|
collection's component).
|
|
253
273
|
- `document.kind === 'shared'` is a shared block's own preview: render it without the
|
|
254
274
|
header and footer.
|
|
255
|
-
-
|
|
256
|
-
|
|
275
|
+
- **An image in the page's frame** (a header logo, a mega-menu photo, a footer badge) is a
|
|
276
|
+
`media()` field in Site Settings, drawn with `settingsImageUrl` from `caracms/content`
|
|
277
|
+
(`<Media>` needs a block's `cms`, which the frame doesn't have):
|
|
278
|
+
|
|
279
|
+
```astro
|
|
280
|
+
---
|
|
281
|
+
import { settingsImageUrl, siteSettings } from 'caracms/content';
|
|
282
|
+
import { site } from 'virtual:caracms/config';
|
|
283
|
+
const settings = await siteSettings(site, Astro.locals);
|
|
284
|
+
const photo = settingsImageUrl(settings.megaPhoto, { width: 640, height: 400 });
|
|
285
|
+
---
|
|
286
|
+
<div class="mega" data-image={photo ?? '/images/placeholder.svg'}></div>
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
It gives one URL by `<Media>`'s rules: one side is a `fit`, both are a `fill` cropped to
|
|
290
|
+
the focal point, a GIF or an SVG passes through unresized, no size is a `fit` at 1920. Ask
|
|
291
|
+
for the size it's shown at (twice it for a sharp 2× screen); there's no `srcset`. It's
|
|
292
|
+
null when the field is empty or the media isn't set up, so give the frame a fallback. It
|
|
293
|
+
works in the build, in the editor's preview (the draft's image) and under
|
|
294
|
+
`CARACMS_MEDIA=memory`.
|
|
257
295
|
|
|
258
296
|
## Forms
|
|
259
297
|
|
|
@@ -274,6 +312,33 @@ Draw it in a block with `<Form form="contact" preview={cms.preview} submit="Send
|
|
|
274
312
|
`select({ options: [{ value: 'contact', label: 'Contact' }] })`. Submissions land in the
|
|
275
313
|
editor's inbox and are emailed to Site Settings' `formRecipients` for that form.
|
|
276
314
|
|
|
315
|
+
A field can also take `help`, `placeholder` (text, email, tel, textarea), `prefix` (text,
|
|
316
|
+
tel), `error` (its own error line), `autocomplete`, and on a textarea `rows` (its height in
|
|
317
|
+
lines, 1 to 50; default 6).
|
|
318
|
+
|
|
319
|
+
The markup's classes are `.cms-form`, `.cms-form-field`, `.cms-form-label`,
|
|
320
|
+
`.cms-form-help`, `.cms-form-error`, `.cms-form-submit` and `.cms-form-status`. For a
|
|
321
|
+
site that draws its own look, `<Form>` takes, each optional (left out, it renders as
|
|
322
|
+
without them):
|
|
323
|
+
|
|
324
|
+
- `class`, and any other attribute (`data-reveal`, `id`, `aria-*`), on the `<form>`. The
|
|
325
|
+
form's own attributes (`action`, `method`, its `data-caracms-*`…) can't be replaced.
|
|
326
|
+
- `submitClass`: classes on the submit button, beside `cms-form-submit`
|
|
327
|
+
(`submitClass="btn btn-primary"`).
|
|
328
|
+
- `thanks`: the line after a send, in place of "Thanks, we'll be in touch shortly."
|
|
329
|
+
`failed`: the line when a send fails, in place of "Something went wrong. Please email
|
|
330
|
+
us directly." (when the endpoint gives its own sentence, that shows instead).
|
|
331
|
+
- `errors`: each field type's error line, in place of the package's wording:
|
|
332
|
+
`errors={{ email: 'Please check your email address.' }}`. A field's own `error` still wins.
|
|
333
|
+
- `actions`: wraps the button and the status line in `<div class="cms-form-actions">`, so
|
|
334
|
+
the site's CSS can set them in a row.
|
|
335
|
+
- `labelPlaceholders`: a field that can take a placeholder and sets none uses its label.
|
|
336
|
+
|
|
337
|
+
```astro
|
|
338
|
+
<Form form="contact" preview={cms.preview} class="contact-form" data-reveal
|
|
339
|
+
submitClass="btn btn-primary" thanks="Thanks — we'll be in touch." actions labelPlaceholders />
|
|
340
|
+
```
|
|
341
|
+
|
|
277
342
|
## Scripts and the live preview
|
|
278
343
|
|
|
279
344
|
The editor shows the page in iframes, through the same page component with
|
|
@@ -58,7 +58,8 @@ A page (`sections`):
|
|
|
58
58
|
(`<title> · <brand name>`, unless `seo.title` is set; the config's `titleSeparator` can
|
|
59
59
|
make the ` · ` another separator); it isn't necessarily drawn on the page. The home page
|
|
60
60
|
is the exception: its title is its own `seo.title`, else Site Settings' `seo.title`, else
|
|
61
|
-
the brand name (the document's `title` only when all of those are empty).
|
|
61
|
+
the brand name (the document's `title` only when all of those are empty). With the
|
|
62
|
+
config's `titleFallback: 'site'`, every page falls back that way, not only the home page.
|
|
62
63
|
- Each block is `{ "type": <a key of the config's blocks>, ...its fields }`. A field left
|
|
63
64
|
out reads as empty.
|
|
64
65
|
- `{ "shared": "<name>" }` places the shared block `content/shared/<name>.json` there.
|