caracms 0.3.2 → 0.3.4
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/bin/caracms.mjs +30 -6
- package/bin/gate.mjs +157 -6
- package/package.json +1 -1
- package/src/editor/client/documents/DocMenu.tsx +7 -0
- package/src/editor/client/documents/PagesUnder.tsx +41 -0
- package/src/editor/client/documents/dialogs.tsx +55 -0
- package/src/editor/client/editor.ts +2 -2
- package/src/editor/client/fields/HiddenToggle.tsx +1 -1
- package/src/editor/client/fields/MediaField.tsx +1 -1
- package/src/editor/client/fields/PageFields.tsx +2 -2
- package/src/editor/client/fields/SeoGroup.tsx +3 -3
- package/src/editor/client/nav/DocRow.tsx +0 -3
- package/src/editor/client/pane/levels/CollectionLevel.tsx +1 -1
- package/src/editor/client/pane/levels/DocumentLevel.tsx +8 -5
- package/src/editor/client/pane/levels/DocumentsLevel.tsx +5 -5
- package/src/editor/client/pane/levels/FormLevel.tsx +7 -8
- package/src/editor/client/pane/levels/SettingsM6.tsx +2 -2
- package/src/editor/client/pane/parts.tsx +6 -7
- package/src/editor/client/shell/App.tsx +2 -0
- package/src/editor/client/shell/Bar.tsx +2 -16
- package/src/editor/client/shell/Icons.tsx +2 -0
- package/src/editor/client/shell/SideToggle.tsx +26 -0
- package/src/editor/styles/editor.css +24 -18
- package/src/editor/styles/levels/m6.css +8 -3
- package/src/editor/styles/tokens.css +2 -2
- package/src/redirect-middleware.ts +3 -0
package/bin/caracms.mjs
CHANGED
|
@@ -12,20 +12,22 @@
|
|
|
12
12
|
// The Node adapter's standalone server sends prerendered pages and static files
|
|
13
13
|
// before Astro's middleware runs, so a password or a `noindex` header can't be added
|
|
14
14
|
// there. This serves the adapter's own handler (static files, then Astro) behind the
|
|
15
|
-
// gate (gate.mjs): robots.txt, the pre-launch password,
|
|
15
|
+
// gate (gate.mjs): robots.txt, the pre-launch password, `X-Robots-Tag`, a page beside a
|
|
16
|
+
// folder of the same name, then Site Settings' redirects.
|
|
16
17
|
//
|
|
17
18
|
// It runs from the site's root (the directory with `dist/` and `content/`) and reads,
|
|
18
19
|
// once at start:
|
|
19
20
|
// PORT (default 8080), HOST (default 0.0.0.0), the site's address (Astro's `site` from
|
|
20
21
|
// dist/server/caracms-build.json, else SITE_URL, else RAILWAY_PUBLIC_DOMAIN),
|
|
21
22
|
// CARACMS_PRELAUNCH (`user:password`), CARACMS_CLIENT_IP_HEADER (else the detected
|
|
22
|
-
// host's default; with neither, it exits with the fix), and content/site-settings.json →
|
|
23
|
+
// host's default; with neither, it exits with the fix), and content/site-settings.json →
|
|
24
|
+
// brand.name and redirects.
|
|
23
25
|
|
|
24
26
|
import { existsSync, readFileSync } from 'node:fs';
|
|
25
27
|
import { createServer } from 'node:http';
|
|
26
|
-
import { join } from 'node:path';
|
|
27
|
-
import { pathToFileURL } from 'node:url';
|
|
28
|
-
import { createGate, describeClientIpSource, markProcessGated, parsePrelaunch, parseSiteUrl, publicAddress, readBuildInfo, readClientIpSource } from './gate.mjs';
|
|
28
|
+
import { join, relative, resolve } from 'node:path';
|
|
29
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
30
|
+
import { createGate, describeClientIpSource, markProcessGated, parsePrelaunch, parseSiteUrl, publicAddress, readBuildInfo, readClientIpSource, readRedirects } from './gate.mjs';
|
|
29
31
|
|
|
30
32
|
const USAGE = `Usage: caracms <command>, from the site's root
|
|
31
33
|
|
|
@@ -56,6 +58,20 @@ function readBrandName(root) {
|
|
|
56
58
|
}
|
|
57
59
|
}
|
|
58
60
|
|
|
61
|
+
/**
|
|
62
|
+
* The adapter's static files folder, as it finds it (@astrojs/node → resolveClientDir):
|
|
63
|
+
* the built server-to-client path from its options, applied to `dist/server` here.
|
|
64
|
+
* `dist/client` when the options don't say.
|
|
65
|
+
*/
|
|
66
|
+
function clientDirOf(options, root) {
|
|
67
|
+
const serverDir = join(root, 'dist/server');
|
|
68
|
+
try {
|
|
69
|
+
return resolve(serverDir, relative(fileURLToPath(new URL(options.server)), fileURLToPath(new URL(options.client))));
|
|
70
|
+
} catch {
|
|
71
|
+
return join(root, 'dist/client');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
59
75
|
async function start() {
|
|
60
76
|
const root = process.cwd();
|
|
61
77
|
const entry = join(root, 'dist/server/entry.mjs');
|
|
@@ -87,7 +103,15 @@ async function start() {
|
|
|
87
103
|
const publicSite = publicAddress(process.env, readBuildInfo(root));
|
|
88
104
|
const siteUrl = publicSite?.value;
|
|
89
105
|
const prelaunch = process.env.CARACMS_PRELAUNCH;
|
|
90
|
-
const gate = createGate({
|
|
106
|
+
const gate = createGate({
|
|
107
|
+
handler: built.handler,
|
|
108
|
+
clientIpSource: ipSource.source,
|
|
109
|
+
siteUrl,
|
|
110
|
+
prelaunch,
|
|
111
|
+
brandName: readBrandName(root),
|
|
112
|
+
clientDir: clientDirOf(built.options, root),
|
|
113
|
+
redirects: readRedirects(root),
|
|
114
|
+
});
|
|
91
115
|
|
|
92
116
|
const server = createServer(gate);
|
|
93
117
|
server.on('error', (err) => fatal(err.message));
|
package/bin/gate.mjs
CHANGED
|
@@ -12,12 +12,17 @@
|
|
|
12
12
|
// 2. The pre-launch password (`CARACMS_PRELAUNCH`), except for the editor and its
|
|
13
13
|
// API, `/_astro/…` and `/health`.
|
|
14
14
|
// 3. `X-Robots-Tag` on the way out, kept whatever the adapter writes.
|
|
15
|
-
// 4.
|
|
15
|
+
// 4. A page beside a folder of the same name (`services/advisory.html` and
|
|
16
|
+
// `services/advisory/`) is asked for by its file: the adapter would look in the
|
|
17
|
+
// folder for an index.html and answer 404.
|
|
18
|
+
// 5. Site Settings' redirects, on what would be a 404: a prerendered 404 page never
|
|
19
|
+
// reaches Astro's middleware (redirect-middleware.ts, which serves them in dev).
|
|
20
|
+
// 6. The adapter's own handler (static files first, then Astro).
|
|
16
21
|
|
|
17
22
|
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
18
|
-
import { readFileSync } from 'node:fs';
|
|
23
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
19
24
|
import { isIP } from 'node:net';
|
|
20
|
-
import { join } from 'node:path';
|
|
25
|
+
import { join, resolve, sep } from 'node:path';
|
|
21
26
|
import { CLOUDFLARE_IPV4, CLOUDFLARE_IPV6 } from './cloudflare-ips.mjs';
|
|
22
27
|
|
|
23
28
|
/** The header the gate owns. */
|
|
@@ -199,6 +204,88 @@ export function parseSiteUrl(value) {
|
|
|
199
204
|
return { host: url.host.toLowerCase(), origin: url.origin, indexable };
|
|
200
205
|
}
|
|
201
206
|
|
|
207
|
+
// ── Pages beside a folder, and redirects ────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
const isFile = (path) => {
|
|
210
|
+
try {
|
|
211
|
+
return statSync(path).isFile();
|
|
212
|
+
} catch {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
const isDirectory = (path) => {
|
|
217
|
+
try {
|
|
218
|
+
return statSync(path).isDirectory();
|
|
219
|
+
} catch {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The file to serve for a page whose address is also a folder: with `build.format:
|
|
226
|
+
* 'file'`, `/services/advisory` is `services/advisory.html`, beside a `services/advisory/`
|
|
227
|
+
* folder of the pages under it. The adapter sees the folder, looks for its index.html
|
|
228
|
+
* and answers 404, so the gate asks for the `.html` itself. Null for anything else: a
|
|
229
|
+
* path the two views don't agree on, `/`, a trailing slash, a file extension, a folder
|
|
230
|
+
* with its own index.html, or no such file under `clientDir`.
|
|
231
|
+
*/
|
|
232
|
+
export function pageBesideFolder(clientDir, views) {
|
|
233
|
+
const path = views.full;
|
|
234
|
+
if (!clientDir || path === null || views.app !== path || path === '/' || path.endsWith('/')) return null;
|
|
235
|
+
if (/\.[^/]*$/.test(path.slice(path.lastIndexOf('/')))) return null;
|
|
236
|
+
const root = resolve(clientDir);
|
|
237
|
+
const folder = resolve(root, '.' + path);
|
|
238
|
+
if (!folder.startsWith(root + sep)) return null;
|
|
239
|
+
if (!isDirectory(folder) || isFile(join(folder, 'index.html')) || !isFile(folder + '.html')) return null;
|
|
240
|
+
return path + '.html';
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Site Settings' redirects, as the package reads them (src/redirects.ts; kept in step by
|
|
245
|
+
* gate.test.ts): `/path` addresses without a trailing slash; an entry that isn't two of
|
|
246
|
+
* them, goes nowhere or repeats a `from` is dropped.
|
|
247
|
+
*/
|
|
248
|
+
export function redirectAddress(value) {
|
|
249
|
+
if (typeof value !== 'string') return null;
|
|
250
|
+
const v = value.trim();
|
|
251
|
+
if (!/^\/(?!\/)[^\s?#]*$/.test(v)) return null;
|
|
252
|
+
const trimmed = v.replace(/\/+$/, '');
|
|
253
|
+
return trimmed === '' ? '/' : trimmed;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function parseRedirects(raw) {
|
|
257
|
+
if (!Array.isArray(raw)) return [];
|
|
258
|
+
const same = (a, b) => a.toLowerCase() === b.toLowerCase();
|
|
259
|
+
const out = [];
|
|
260
|
+
for (const entry of raw) {
|
|
261
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
|
|
262
|
+
const from = redirectAddress(entry.from);
|
|
263
|
+
const to = redirectAddress(entry.to);
|
|
264
|
+
if (!from || !to || same(from, to) || out.some((r) => same(r.from, from))) continue;
|
|
265
|
+
out.push({ from, to });
|
|
266
|
+
}
|
|
267
|
+
return out;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Where a request for `pathname` (as sent) goes, or null: matched decoded, without a trailing slash; letter case counts. */
|
|
271
|
+
export function redirectFor(list, pathname) {
|
|
272
|
+
let path = pathname;
|
|
273
|
+
try {
|
|
274
|
+
path = decodeURI(pathname);
|
|
275
|
+
} catch {}
|
|
276
|
+
path = path.replace(/\/+$/, '') || '/';
|
|
277
|
+
return list.find((r) => r.from === path)?.to ?? null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** `content/site-settings.json` → `redirects` under `root`, read once at start (the file is the build's). Empty when it's missing or unreadable. */
|
|
281
|
+
export function readRedirects(root) {
|
|
282
|
+
try {
|
|
283
|
+
return parseRedirects(JSON.parse(readFileSync(join(root, 'content/site-settings.json'), 'utf8'))?.redirects);
|
|
284
|
+
} catch {
|
|
285
|
+
return [];
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
202
289
|
// ── The visitor's IP (Decision 8) ───────────────────────────────────────────────
|
|
203
290
|
|
|
204
291
|
/** The header the gate sets on every request; `clientIp()` (src/live/auth/request.ts) reads only this in production. */
|
|
@@ -406,9 +493,9 @@ const sha256 = (buf) => createHash('sha256').update(buf).digest();
|
|
|
406
493
|
* The gate, as `(req, res) => void` around the adapter's `handler`.
|
|
407
494
|
* @param {{ handler: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => unknown,
|
|
408
495
|
* clientIpSource: string, siteUrl?: string, prelaunch?: string, brandName?: string,
|
|
409
|
-
* log?: (line: string) => void }} options
|
|
496
|
+
* clientDir?: string, redirects?: { from: string, to: string }[], log?: (line: string) => void }} options
|
|
410
497
|
*/
|
|
411
|
-
export function createGate({ handler, clientIpSource, siteUrl, prelaunch, brandName, log = (line) => console.error(line) }) {
|
|
498
|
+
export function createGate({ handler, clientIpSource, siteUrl, prelaunch, brandName, clientDir, redirects = [], log = (line) => console.error(line) }) {
|
|
412
499
|
if (!CLIENT_IP_SOURCES.includes(clientIpSource)) throw new Error(`createGate: clientIpSource must be one of ${CLIENT_IP_SOURCES.join(', ')}.`);
|
|
413
500
|
let warnedMissing = false;
|
|
414
501
|
const site = parseSiteUrl(siteUrl);
|
|
@@ -481,7 +568,24 @@ export function createGate({ handler, clientIpSource, siteUrl, prelaunch, brandN
|
|
|
481
568
|
const robots = guarded ? NOINDEX_NOFOLLOW : indexable ? null : NOINDEX;
|
|
482
569
|
if (robots) holdHeader(res, ROBOTS_HEADER, robots);
|
|
483
570
|
|
|
484
|
-
|
|
571
|
+
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
572
|
+
// 4. A page beside a folder of the same name, by its file.
|
|
573
|
+
// (As sent, plus `.html`: the views are decoded, the request target mustn't be.)
|
|
574
|
+
const file = guarded ? null : pageBesideFolder(clientDir, views);
|
|
575
|
+
const raw = rawPathOf(req.url);
|
|
576
|
+
if (file && raw !== null) {
|
|
577
|
+
req.url = raw + '.html' + req.url.slice(raw.length);
|
|
578
|
+
} else if (!guarded && redirects.length) {
|
|
579
|
+
// 5. A redirect, only where the site answers 404: a real page always wins.
|
|
580
|
+
const to = raw === null ? null : redirectFor(redirects, raw);
|
|
581
|
+
if (to) {
|
|
582
|
+
const query = req.url.indexOf('?');
|
|
583
|
+
redirectOn404(res, to + (query === -1 ? '' : req.url.slice(query)));
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// 6. The adapter.
|
|
485
589
|
try {
|
|
486
590
|
const result = handler(req, res);
|
|
487
591
|
if (result && typeof result.catch === 'function') result.catch((err) => fail(res, err));
|
|
@@ -492,6 +596,53 @@ export function createGate({ handler, clientIpSource, siteUrl, prelaunch, brandN
|
|
|
492
596
|
return gate;
|
|
493
597
|
}
|
|
494
598
|
|
|
599
|
+
/**
|
|
600
|
+
* Turns the response into a 301 to `location` if the handler answers 404, however it
|
|
601
|
+
* writes it (`writeHead`, or a status and an implicit head on the first write or end).
|
|
602
|
+
* The 404's own headers and body are dropped; the held robots header stays.
|
|
603
|
+
*/
|
|
604
|
+
function redirectOn404(res, location) {
|
|
605
|
+
const writeHead = res.writeHead.bind(res);
|
|
606
|
+
const write = res.write.bind(res);
|
|
607
|
+
const end = res.end.bind(res);
|
|
608
|
+
let redirected = false;
|
|
609
|
+
const redirect = () => {
|
|
610
|
+
redirected = true;
|
|
611
|
+
for (const name of res.getHeaderNames()) res.removeHeader(name);
|
|
612
|
+
writeHead(301, { Location: location, 'Cache-Control': 'no-cache', 'Content-Length': 0 });
|
|
613
|
+
end();
|
|
614
|
+
};
|
|
615
|
+
const callback = (args) => {
|
|
616
|
+
const cb = args.find((a) => typeof a === 'function');
|
|
617
|
+
if (cb) queueMicrotask(cb);
|
|
618
|
+
};
|
|
619
|
+
const is404 = () => !res.headersSent && res.statusCode === 404;
|
|
620
|
+
res.writeHead = (status, ...rest) => {
|
|
621
|
+
if (redirected) return res;
|
|
622
|
+
if (status === 404 && !res.headersSent) {
|
|
623
|
+
redirect();
|
|
624
|
+
return res;
|
|
625
|
+
}
|
|
626
|
+
return writeHead(status, ...rest);
|
|
627
|
+
};
|
|
628
|
+
res.write = (...args) => {
|
|
629
|
+
if (!redirected && is404()) redirect();
|
|
630
|
+
if (redirected) {
|
|
631
|
+
callback(args);
|
|
632
|
+
return true;
|
|
633
|
+
}
|
|
634
|
+
return write(...args);
|
|
635
|
+
};
|
|
636
|
+
res.end = (...args) => {
|
|
637
|
+
if (!redirected && is404()) redirect();
|
|
638
|
+
if (redirected) {
|
|
639
|
+
callback(args);
|
|
640
|
+
return res;
|
|
641
|
+
}
|
|
642
|
+
return end(...args);
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
495
646
|
function fail(res, err) {
|
|
496
647
|
console.error('caracms start: the request failed:', err);
|
|
497
648
|
if (!res.headersSent) {
|
package/package.json
CHANGED
|
@@ -111,6 +111,13 @@ export function DocMenu({ label }: { label: string }) {
|
|
|
111
111
|
</button>
|
|
112
112
|
{open ? (
|
|
113
113
|
<div ref={menu} className="menu" role="menu" style={{ right: 0, top: 'calc(100% + 4px)' }} onKeyDown={onKey}>
|
|
114
|
+
{/* A fields document's title is the first field of its form; the rest are renamed here. */}
|
|
115
|
+
{collection.kind === 'fields' ? null : (
|
|
116
|
+
<button type="button" role="menuitem" data-act="rename-document" disabled={access.readOnly} onClick={() => (setOpen(false), openDialog(editor, { kind: 'rename' }))}>
|
|
117
|
+
<Icon name="pencil" />
|
|
118
|
+
Rename…
|
|
119
|
+
</button>
|
|
120
|
+
)}
|
|
114
121
|
{noPage ? null : (
|
|
115
122
|
<>
|
|
116
123
|
<button
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A page's own pages, under its SEO group: the pages whose addresses start
|
|
3
|
+
* with its own, indented as in the Pages tree and in its order, each opening as it
|
|
4
|
+
* does there. Open to begin with; a page with none shows nothing.
|
|
5
|
+
*/
|
|
6
|
+
import { useState } from 'react';
|
|
7
|
+
import { useEditor, useStore } from '../context.tsx';
|
|
8
|
+
import { DocRow } from '../nav/DocRow.tsx';
|
|
9
|
+
import { Icon } from '../shell/Icons.tsx';
|
|
10
|
+
import { openDialog } from './dialogs.tsx';
|
|
11
|
+
import { pagesUnder } from './actions.ts';
|
|
12
|
+
|
|
13
|
+
export function PagesUnder() {
|
|
14
|
+
const editor = useEditor();
|
|
15
|
+
const ref = useStore(editor.doc.state, (s) => s.ref);
|
|
16
|
+
const label = useStore(editor.doc.state, (s) => s.document.title || s.entry.label);
|
|
17
|
+
const documents = useStore(editor.documents);
|
|
18
|
+
const [open, setOpen] = useState(true);
|
|
19
|
+
const collection = editor.site.collections.find((c) => c.name === ref.collection);
|
|
20
|
+
if (!collection?.nested || ref.path === 'index') return null;
|
|
21
|
+
const under = pagesUnder(collection.name, ref.path, documents);
|
|
22
|
+
if (!under.length) return null;
|
|
23
|
+
const top = ref.path.split('/').length + 1;
|
|
24
|
+
return (
|
|
25
|
+
<details className="fgroup pages-under" open={open} data-pages-under={under.length} onToggle={(e) => setOpen((e.currentTarget as HTMLDetailsElement).open)}>
|
|
26
|
+
<summary>
|
|
27
|
+
Pages under {label} <span className="s">· {under.length}</span>
|
|
28
|
+
<Icon name="chev-r" size="sm" />
|
|
29
|
+
</summary>
|
|
30
|
+
<div className="in">
|
|
31
|
+
{under.map((d) => (
|
|
32
|
+
<DocRow key={d.path} doc={d} className="t" style={{ ['--depth' as string]: d.path.split('/').length - top }} />
|
|
33
|
+
))}
|
|
34
|
+
<button type="button" className="linkbtn" data-act="new-page-under" onClick={() => openDialog(editor, { kind: 'new', collection: collection.name, inside: ref.path })}>
|
|
35
|
+
<Icon name="plus" size="xs" />
|
|
36
|
+
New page under {label}
|
|
37
|
+
</button>
|
|
38
|
+
</div>
|
|
39
|
+
</details>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* openDialog(editor, { kind: 'delete', ref }) Delete page… (the confirmation)
|
|
10
10
|
* openDialog(editor, { kind: 'in-use', ref, usedOn? }) a shared block still used
|
|
11
11
|
* openDialog(editor, { kind: 'move', ref }) Change address… (pre-M7 item 8)
|
|
12
|
+
* openDialog(editor, { kind: 'rename' }) Rename… on the open document
|
|
12
13
|
* openDialog(editor, { kind: 'replace', key }) Replace… on a file (M6c G4, media/ReplaceDialog.tsx)
|
|
13
14
|
*
|
|
14
15
|
* Esc closes the dialog only (the editor's own Esc, going up a level, doesn't run).
|
|
@@ -37,6 +38,8 @@ export type Dialog =
|
|
|
37
38
|
| { kind: 'in-use'; ref: DocRef; usedOn?: CreateDeleteRefusal['usedOn'] }
|
|
38
39
|
/** Pre-M7 item 8: Change address…, in the language of New page. */
|
|
39
40
|
| { kind: 'move'; ref: DocRef }
|
|
41
|
+
/** Rename… on the open document: its title, the name the editor lists it by. */
|
|
42
|
+
| { kind: 'rename' }
|
|
40
43
|
/** M6 Decision 9: a submission's Delete…, permanent, so it asks first (the mockup's #submission-delete). */
|
|
41
44
|
| { kind: 'sub-delete'; id: string; who: string; files: number }
|
|
42
45
|
/** M6c G4 Decision 9: Replace… on a file in the library (its key). */
|
|
@@ -71,6 +74,8 @@ export function DocumentDialogs() {
|
|
|
71
74
|
return <InUse docRef={dialog.ref} usedOn={dialog.usedOn} />;
|
|
72
75
|
case 'move':
|
|
73
76
|
return <MoveDocument key={`${dialog.ref.collection}/${dialog.ref.path}`} docRef={dialog.ref} />;
|
|
77
|
+
case 'rename':
|
|
78
|
+
return <RenameDocument />;
|
|
74
79
|
case 'sub-delete':
|
|
75
80
|
return <DeleteSubmission key={dialog.id} {...dialog} />;
|
|
76
81
|
case 'replace':
|
|
@@ -421,6 +426,56 @@ function MoveDocument({ docRef }: { docRef: DocRef }) {
|
|
|
421
426
|
);
|
|
422
427
|
}
|
|
423
428
|
|
|
429
|
+
// ── Rename… ──────────────────────────────────────────────────────────────────
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* The open document's title: an ordinary edit (kept, and in live mode published, like
|
|
433
|
+
* any other), so it goes through the document store. The address doesn't change:
|
|
434
|
+
* that's Change address….
|
|
435
|
+
*/
|
|
436
|
+
function RenameDocument() {
|
|
437
|
+
const editor = useEditor();
|
|
438
|
+
const current = useStore(editor.doc.state, (s) => s.document.title || s.entry.label);
|
|
439
|
+
// Taken by someone else, signed out or deleted since the menu opened: setTitle would do nothing.
|
|
440
|
+
const readOnly = useStore(editor.access, (a) => a.readOnly);
|
|
441
|
+
const [name, setName] = useState(current);
|
|
442
|
+
const next = name.replace(/\s+/g, ' ').trim();
|
|
443
|
+
const blocked = !next || next === current || readOnly;
|
|
444
|
+
const confirm = () => {
|
|
445
|
+
if (blocked) return;
|
|
446
|
+
editor.doc.setTitle(next);
|
|
447
|
+
editor.announce(`Renamed to ${next}`);
|
|
448
|
+
closeDialog(editor);
|
|
449
|
+
};
|
|
450
|
+
return (
|
|
451
|
+
<Frame label="rename">
|
|
452
|
+
<form
|
|
453
|
+
onSubmit={(e) => {
|
|
454
|
+
e.preventDefault();
|
|
455
|
+
confirm();
|
|
456
|
+
}}
|
|
457
|
+
>
|
|
458
|
+
<h2 id="dlg-h">Rename {current}</h2>
|
|
459
|
+
<div className="field">
|
|
460
|
+
<label className="field-label" htmlFor="rn-name">
|
|
461
|
+
Name
|
|
462
|
+
</label>
|
|
463
|
+
<input className="input" id="rn-name" data-autofocus="" value={name} autoComplete="off" onChange={(e) => setName(e.target.value)} />
|
|
464
|
+
<p className="field-help">What the editor calls it. Its address stays as it is.</p>
|
|
465
|
+
</div>
|
|
466
|
+
<div className="acts">
|
|
467
|
+
<button type="button" className="btn" onClick={() => closeDialog(editor)}>
|
|
468
|
+
Cancel
|
|
469
|
+
</button>
|
|
470
|
+
<button type="submit" className="btn ink" data-act="confirm-rename" disabled={blocked}>
|
|
471
|
+
Rename
|
|
472
|
+
</button>
|
|
473
|
+
</div>
|
|
474
|
+
</form>
|
|
475
|
+
</Frame>
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
424
479
|
// ── + New shared block ───────────────────────────────────────────────────────
|
|
425
480
|
|
|
426
481
|
function NewShared() {
|
|
@@ -722,9 +722,9 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
|
|
|
722
722
|
|
|
723
723
|
setSideHidden(hidden) {
|
|
724
724
|
if ((ui.get().side === 'hidden') === hidden) return;
|
|
725
|
-
// Hiding it takes focus with it (display:none drops it to the body): the
|
|
725
|
+
// Hiding it takes focus with it (display:none drops it to the body): the sidebar's button, which stays to bring it back.
|
|
726
726
|
if (hidden && typeof document !== 'undefined' && document.getElementById('side')?.contains(document.activeElement)) {
|
|
727
|
-
document.querySelector<HTMLElement>('#
|
|
727
|
+
document.querySelector<HTMLElement>('#editor > .side-toggle')?.focus();
|
|
728
728
|
}
|
|
729
729
|
ui.set((state) => withSide(state, hidden));
|
|
730
730
|
editor.announce(hidden ? 'Sidebar hidden.' : 'Sidebar shown.');
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* M9 Decision 7: the page's "Hidden from the site" switch, at the document level under its
|
|
3
|
-
* page fields and above
|
|
3
|
+
* page fields and above SEO. It's the toggle field's switch (SimpleFields.tsx →
|
|
4
4
|
* ToggleField), set through `editor.doc.setHidden`, so it saves and publishes like any edit.
|
|
5
5
|
*
|
|
6
6
|
* - Only on a section builder's page with an address, and never on the home page (`index`),
|
|
@@ -120,7 +120,7 @@ function endAltAsk(editor: Editor, slot: string, next: boolean): AltAsk {
|
|
|
120
120
|
export function ownerOf(editor: Editor, scope: string): { owner: string; back: 'block' | 'document' } {
|
|
121
121
|
const session = editor.doc.get();
|
|
122
122
|
// M6: a page's own share image (SEO_SCOPE), at its document level.
|
|
123
|
-
if (scope === 'seo') return { owner: '
|
|
123
|
+
if (scope === 'seo') return { owner: 'SEO', back: 'document' };
|
|
124
124
|
// M6c (G2): a section builder's page fields, at its document level too.
|
|
125
125
|
if (scope === PAGE_SCOPE) {
|
|
126
126
|
const collection = editor.site.collections.find((c) => c.name === session.ref.collection);
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A section builder's page fields (M6c G2): the collection's own `fields`, beside the
|
|
3
3
|
* page's blocks (a game's tile details, the header's "side"). One group at the document
|
|
4
|
-
* level, under the block list and above
|
|
5
|
-
* page's content, where
|
|
4
|
+
* level, under the block list and above SEO, open by default (they're the
|
|
5
|
+
* page's content, where SEO falls back to the site's defaults). Sidebar
|
|
6
6
|
* only: nothing here is on the page, so nothing is picked from it.
|
|
7
7
|
*
|
|
8
8
|
* Closed, it carries a warning mark when any page field (a group's fields too) is warned
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* SEO (M6 Decision 13; the mockup's #seo-page, #seo-long, #seo-defaults,
|
|
3
3
|
* #seo-case): a document's own `seo` key, as one collapsed group that says what's set.
|
|
4
4
|
* At the foot of a section-builder page's block list (below Add block), and at the end of
|
|
5
5
|
* a fields document's form. Only on collections with a URL.
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* The values live in the document store beside the blocks (`SEO_SCOPE`, document-store.ts):
|
|
15
15
|
* clearing the last part removes `seo` from the file, and a file with none is untouched.
|
|
16
16
|
*
|
|
17
|
-
* `SoftText` is also Site Settings' (its
|
|
17
|
+
* `SoftText` is also Site Settings' (its SEO defaults).
|
|
18
18
|
*/
|
|
19
19
|
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
20
20
|
import { media as mediaField } from '../../../fields.ts';
|
|
@@ -167,7 +167,7 @@ export function SeoGroup() {
|
|
|
167
167
|
return (
|
|
168
168
|
<details className="fgroup seo" open={open} data-seo="" onToggle={(e) => setOpen((e.currentTarget as HTMLDetailsElement).open)}>
|
|
169
169
|
<summary>
|
|
170
|
-
|
|
170
|
+
SEO <span className="s">· {seoSummary(session.document.seo)}</span>
|
|
171
171
|
<Icon name="chev-r" size="sm" />
|
|
172
172
|
</summary>
|
|
173
173
|
<div className="in">
|
|
@@ -31,8 +31,6 @@ export function DocRow(props: {
|
|
|
31
31
|
crumb?: string;
|
|
32
32
|
className?: string;
|
|
33
33
|
style?: CSSProperties;
|
|
34
|
-
/** Before the icon: the tree's chevron column. */
|
|
35
|
-
lead?: ReactNode;
|
|
36
34
|
/** After the meta: a bubbled mark for a closed parent. */
|
|
37
35
|
extra?: ReactNode;
|
|
38
36
|
/** Called after the document starts opening (the switcher closes itself). */
|
|
@@ -72,7 +70,6 @@ export function DocRow(props: {
|
|
|
72
70
|
title={doc.warning?.message}
|
|
73
71
|
onClick={open}
|
|
74
72
|
>
|
|
75
|
-
{props.lead}
|
|
76
73
|
<Icon name={docIcon(collection)} />
|
|
77
74
|
<span className="nm">
|
|
78
75
|
{props.crumb ? <span className="crumb">{props.crumb} / </span> : null}
|
|
@@ -52,7 +52,7 @@ export function CollectionLevel() {
|
|
|
52
52
|
back={{ label: 'Back to documents', onClick: () => editor.goTo('documents') }}
|
|
53
53
|
title={collection.label}
|
|
54
54
|
chips={<span className="n-chip">{docs.length}</span>}
|
|
55
|
-
sub={shared ? undefined : collection.urlBase === null ? PAGELESS_SUB : <span className="mono">{`${
|
|
55
|
+
sub={shared ? undefined : collection.urlBase === null ? PAGELESS_SUB : <span className="mono">{`${collection.urlBase}…`}</span>}
|
|
56
56
|
menu={
|
|
57
57
|
<button type="button" className="btn sm" data-act="new-document" disabled={readOnly} onClick={create}>
|
|
58
58
|
<Icon name="plus" size="sm" />
|
|
@@ -18,6 +18,7 @@ import { Picker } from '../../block-list/Picker.tsx';
|
|
|
18
18
|
import { CurrentHead, DocumentsTab, GlobalNotes, parentLabel, RecentStrip, Trail } from '../parts.tsx';
|
|
19
19
|
import { DocNotices, ReadOnlyChip } from '../../live/notices.tsx';
|
|
20
20
|
import { DocMenu } from '../../documents/DocMenu.tsx';
|
|
21
|
+
import { PagesUnder } from '../../documents/PagesUnder.tsx';
|
|
21
22
|
import { FieldsDocumentLevel, SettingsLevel, SharedDocumentLevel } from './FormLevel.tsx';
|
|
22
23
|
import { SeoGroup } from '../../fields/SeoGroup.tsx';
|
|
23
24
|
import { HiddenToggle } from '../../fields/HiddenToggle.tsx';
|
|
@@ -55,14 +56,15 @@ function SectionsLevel() {
|
|
|
55
56
|
back={{ label: 'Back to documents', onClick: () => editor.goTo('documents') }}
|
|
56
57
|
crumb={collection?.nested ? parentLabel(ref, documents) : collection?.label}
|
|
57
58
|
title={title}
|
|
58
|
-
|
|
59
|
+
sub={
|
|
59
60
|
<>
|
|
60
|
-
|
|
61
|
-
<ReadOnlyChip />
|
|
61
|
+
{/* The crumb and the tabs say what kind of document it is; only its state gets a chip, under the title so it never clips. */}
|
|
62
62
|
{isNew ? <span className="chip waiting">New</span> : null}
|
|
63
|
+
<ReadOnlyChip />
|
|
64
|
+
{/* The path alone: the host is the same on every page. */}
|
|
65
|
+
<span className="mono">{url || '/'}</span>
|
|
63
66
|
</>
|
|
64
67
|
}
|
|
65
|
-
sub={<span className="mono">{`${editor.site.host}${url === '/' ? '' : url}`}</span>}
|
|
66
68
|
menu={<DocMenu label="Page actions" />}
|
|
67
69
|
/>
|
|
68
70
|
</Trail>
|
|
@@ -84,8 +86,9 @@ function SectionsLevel() {
|
|
|
84
86
|
<PageFields />
|
|
85
87
|
{/* M9 Decision 7: hidden from the site, or not. */}
|
|
86
88
|
<HiddenToggle />
|
|
87
|
-
{/* M6 Decision 13: the page's own
|
|
89
|
+
{/* M6 Decision 13: the page's own SEO, under its blocks. */}
|
|
88
90
|
<SeoGroup />
|
|
91
|
+
<PagesUnder />
|
|
89
92
|
</div>
|
|
90
93
|
<RecentStrip />
|
|
91
94
|
</>
|
|
@@ -167,15 +167,16 @@ function TreeNode({ row, collection }: { row: TreeRow; collection: EditorCollect
|
|
|
167
167
|
title={`${row.label} has no page of its own`}
|
|
168
168
|
onClick={() => editor.toggleGroup(row.key)}
|
|
169
169
|
>
|
|
170
|
-
<span className="tw" aria-hidden="true">
|
|
171
|
-
<Icon name="chev-r" size="xs" />
|
|
172
|
-
</span>
|
|
173
170
|
<Icon name="folder" />
|
|
174
171
|
<span className="nm">{row.label}</span>
|
|
175
172
|
<span className="meta">
|
|
176
173
|
{/* M4: presence and waiting dots inside, bubbled up while it's closed. */}
|
|
177
174
|
{editor.mode === 'live' && !row.open ? <Bubble files={row.under.map((d) => d.file)} /> : <span data-slot="bubble" hidden />}
|
|
178
175
|
</span>
|
|
176
|
+
{/* The twisty at the row's end, as the parent pages' is: icons line up with the rest of the sidebar. */}
|
|
177
|
+
<span className="tw" aria-hidden="true">
|
|
178
|
+
<Icon name="chev-r" size="xs" />
|
|
179
|
+
</span>
|
|
179
180
|
</button>
|
|
180
181
|
<GroupMenu row={row} collection={collection} />
|
|
181
182
|
</div>
|
|
@@ -183,12 +184,11 @@ function TreeNode({ row, collection }: { row: TreeRow; collection: EditorCollect
|
|
|
183
184
|
}
|
|
184
185
|
return (
|
|
185
186
|
<div className="tnode">
|
|
186
|
-
<DocRow doc={row.doc} className=
|
|
187
|
+
<DocRow doc={row.doc} className={row.parent ? 't has-kids' : 't'} style={depth} extra={row.parent && !row.open ? editor.mode === 'live' ? <Bubble files={row.under.map((d) => d.file)} /> : <span data-slot="bubble" hidden /> : null} />
|
|
187
188
|
{row.parent ? (
|
|
188
189
|
<button
|
|
189
190
|
type="button"
|
|
190
191
|
className="twisty"
|
|
191
|
-
style={depth}
|
|
192
192
|
data-group={row.key}
|
|
193
193
|
aria-expanded={row.open}
|
|
194
194
|
aria-label={`${row.open ? 'Hide' : 'Show'} the pages under ${row.doc.label}`}
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* no alt and no focal point), then the site's own settings.
|
|
20
20
|
* No page of its own: the stage keeps the last page, dimmed (shell/Stage.tsx).
|
|
21
21
|
*
|
|
22
|
-
* M6: the case study's "
|
|
22
|
+
* M6: the case study's "SEO" group ends its form (fields/SeoGroup.tsx), and
|
|
23
23
|
* Site Settings gains its defaults, the Google tag and Form emails (settings-m6.tsx).
|
|
24
24
|
*/
|
|
25
25
|
import { Fragment, useMemo, useState, type ReactNode } from 'react';
|
|
@@ -159,7 +159,6 @@ export function FieldsDocumentLevel() {
|
|
|
159
159
|
const what = singular(collection);
|
|
160
160
|
const chips = (
|
|
161
161
|
<>
|
|
162
|
-
<span className="chip kind">{titleCase(what)}</span>
|
|
163
162
|
{isNew ? <span className="chip waiting">New</span> : null}
|
|
164
163
|
<ReadOnlyChip />
|
|
165
164
|
</>
|
|
@@ -178,7 +177,7 @@ export function FieldsDocumentLevel() {
|
|
|
178
177
|
<>
|
|
179
178
|
{chips}
|
|
180
179
|
{/* M6c (G1): a document with no page of its own has no address to show. */}
|
|
181
|
-
{collection.urlBase === null ? null : <span className="mono">{
|
|
180
|
+
{collection.urlBase === null ? null : <span className="mono">{url || '/'}</span>}
|
|
182
181
|
</>
|
|
183
182
|
}
|
|
184
183
|
menu={<DocMenu label={`${titleCase(what)} actions`} />}
|
|
@@ -203,7 +202,7 @@ export function FieldsDocumentLevel() {
|
|
|
203
202
|
);
|
|
204
203
|
})}
|
|
205
204
|
</FieldHostProvider>
|
|
206
|
-
{/* M6 Decision 13:
|
|
205
|
+
{/* M6 Decision 13: SEO ends a fields document's form. */}
|
|
207
206
|
<SeoGroup />
|
|
208
207
|
</FormFields>
|
|
209
208
|
</div>
|
|
@@ -245,14 +244,13 @@ export function SharedDocumentLevel() {
|
|
|
245
244
|
back={{ label: 'Back to shared blocks', onClick: () => editor.openCollection('shared') }}
|
|
246
245
|
crumb="Shared blocks"
|
|
247
246
|
title={title}
|
|
248
|
-
|
|
247
|
+
sub={
|
|
249
248
|
<>
|
|
250
|
-
<span className="chip shared">Shared</span>
|
|
251
249
|
{isNew ? <span className="chip waiting">New</span> : null}
|
|
252
250
|
<ReadOnlyChip />
|
|
251
|
+
<span>{`${schema?.label ?? 'Block'} · ${usedLabel(n)}`}</span>
|
|
253
252
|
</>
|
|
254
253
|
}
|
|
255
|
-
sub={`${schema?.label ?? 'Block'} · ${usedLabel(n)}`}
|
|
256
254
|
menu={<DocMenu label="Shared block actions" />}
|
|
257
255
|
/>
|
|
258
256
|
</Trail>
|
|
@@ -435,7 +433,8 @@ export function SettingsLevel() {
|
|
|
435
433
|
</span>
|
|
436
434
|
<span className="t">
|
|
437
435
|
<b>{value}</b>
|
|
438
|
-
{
|
|
436
|
+
{/* The tile already says which kind it is: only a domain gets a line, saying who it lets in. */}
|
|
437
|
+
{why ? <small style={{ color: 'var(--status-warning)' }}>Lets nobody in</small> : domain ? <small>Everyone at {value.trim().slice(1)}</small> : null}
|
|
439
438
|
</span>
|
|
440
439
|
<button type="button" className="ib" aria-label={`Remove ${value}`} disabled={readOnly} onClick={() => remove(i)}>
|
|
441
440
|
<Icon name="x" size="sm" />
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Site Settings' publish-time keys (M6 Decision 14; the mockup's #settings-m6), after
|
|
3
3
|
* Editor branding. Unlike the sections above they act on the live site.
|
|
4
4
|
*
|
|
5
|
-
* - **
|
|
5
|
+
* - **Default SEO**: the defaults (`seo`): a title (the home page's, and the
|
|
6
6
|
* fallback's brand), a description, a share image with no alt input. M5a's soft limits.
|
|
7
7
|
* - **Google tag** (`googleTag`, Decision 16): one ID, checked as it's typed with the
|
|
8
8
|
* server's own rule (seo.ts → googleTagKind). A pasted Google script, or anything else
|
|
@@ -37,7 +37,7 @@ export function SettingsM6({ data }: { data: Record<string, unknown> }) {
|
|
|
37
37
|
return (
|
|
38
38
|
<>
|
|
39
39
|
<section className="set-sec" data-sec="seo">
|
|
40
|
-
<h3>
|
|
40
|
+
<h3>Default SEO</h3>
|
|
41
41
|
<p className="lead">Defaults for pages that don’t set their own.</p>
|
|
42
42
|
<SoftText
|
|
43
43
|
scope={host.scope}
|
|
@@ -23,22 +23,21 @@ export function Trail({ children }: { children: ReactNode }) {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
/** A stacked level tab: the level you came from (`.lv`). */
|
|
26
|
-
export function LevelTab({ name, kind, count, onClick }: { name: string; kind
|
|
26
|
+
export function LevelTab({ name, kind, count, onClick }: { name: string; kind?: string; count?: string; onClick: () => void }) {
|
|
27
27
|
return (
|
|
28
28
|
<button type="button" className="lv" onClick={onClick}>
|
|
29
29
|
<b>{name}</b>
|
|
30
|
-
<span>· {kind}</span>
|
|
30
|
+
{kind ? <span>· {kind}</span> : null}
|
|
31
31
|
{count !== undefined ? <span className="n">{count}</span> : null}
|
|
32
32
|
</button>
|
|
33
33
|
);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
/** The documents level's tab, for the levels below it. */
|
|
36
|
+
/** The documents level's tab, for the levels below it: the site's name is enough. */
|
|
37
37
|
export function DocumentsTab() {
|
|
38
38
|
const editor = useEditor();
|
|
39
|
-
const count = useStore(editor.documents, (d) => d.length);
|
|
40
39
|
const name = useStore(editor.brand, (b) => b.name);
|
|
41
|
-
return <LevelTab name={name}
|
|
40
|
+
return <LevelTab name={name} onClick={() => editor.goTo('documents')} />;
|
|
42
41
|
}
|
|
43
42
|
|
|
44
43
|
/** The current level's header (`.cur`): back, a crumb, the title, chips, a sub line, a menu and extras. */
|
|
@@ -179,8 +178,8 @@ export function RecentStrip() {
|
|
|
179
178
|
.slice(0, 5);
|
|
180
179
|
return (
|
|
181
180
|
<div className="side-foot">
|
|
182
|
-
|
|
183
|
-
<div className="rchips">
|
|
181
|
+
{/* No visible label: the chips speak for themselves. Screen readers still hear "Recent". */}
|
|
182
|
+
<div className="rchips" role="group" aria-label="Recent">
|
|
184
183
|
{items.map((d) => (
|
|
185
184
|
<button
|
|
186
185
|
key={docId(d)}
|
|
@@ -17,6 +17,7 @@ import { IconSprite } from './Icons.tsx';
|
|
|
17
17
|
import { NarrowNote } from './NarrowNote.tsx';
|
|
18
18
|
import { Popovers } from './Popovers.tsx';
|
|
19
19
|
import { SignedOutModal } from '../live/session.tsx';
|
|
20
|
+
import { SideToggle } from './SideToggle.tsx';
|
|
20
21
|
import { Stage } from './Stage.tsx';
|
|
21
22
|
import { DocumentDialogs } from '../documents/dialogs.tsx';
|
|
22
23
|
import { Toast } from './Toast.tsx';
|
|
@@ -71,6 +72,7 @@ export function EditorApp({ editor }: { editor: Editor }) {
|
|
|
71
72
|
<IconSprite />
|
|
72
73
|
<div id="app" data-screen="editor">
|
|
73
74
|
<div className="editor" id="editor" data-side={sideHidden ? 'hidden' : undefined}>
|
|
75
|
+
<SideToggle />
|
|
74
76
|
<Pane />
|
|
75
77
|
<Stage />
|
|
76
78
|
<Bar />
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The one bar, dark, along the bottom (the mockup's renderBar): the brand slots and
|
|
3
|
-
* the document switcher's trigger on the left
|
|
3
|
+
* the document switcher's trigger on the left (the site's name is the sidebar's,
|
|
4
|
+
* at its top; so is the sidebar's button, SideToggle.tsx); the width toggle with its scale
|
|
4
5
|
* under the preview; the keep state on the right. In live mode (M4) the waiting
|
|
5
6
|
* count with Publish (live/publish.tsx) and the signed-in avatar (live/session.tsx)
|
|
6
7
|
* follow it, and a locked document's trigger has a lock; in local mode their places
|
|
@@ -31,7 +32,6 @@ export function Bar() {
|
|
|
31
32
|
const documents = useStore(editor.documents);
|
|
32
33
|
const scale = useFrameScale(width);
|
|
33
34
|
const veil = useStore(editor.access, (a) => a.veil);
|
|
34
|
-
const sideHidden = useStore(editor.ui, (u) => u.side === 'hidden');
|
|
35
35
|
const tooLong = warnings.filter((w) => w.tooLong).length;
|
|
36
36
|
const opening = viewOpening ?? renderOpening;
|
|
37
37
|
const openingLabel = opening ? (documents.find((d) => docId(d) === docId(opening))?.label ?? opening.path) : null;
|
|
@@ -63,8 +63,6 @@ export function Bar() {
|
|
|
63
63
|
<span className="logo-slot" title={brand.name}>
|
|
64
64
|
<BrandMark />
|
|
65
65
|
</span>
|
|
66
|
-
<span className="bar-name">{brand.name}</span>
|
|
67
|
-
<span className="bar-sep" aria-hidden="true" />
|
|
68
66
|
<button
|
|
69
67
|
type="button"
|
|
70
68
|
className="docbtn"
|
|
@@ -78,18 +76,6 @@ export function Bar() {
|
|
|
78
76
|
<Icon name="chev-u" size="xs" />
|
|
79
77
|
<kbd>⌘K</kbd>
|
|
80
78
|
</button>
|
|
81
|
-
<button
|
|
82
|
-
type="button"
|
|
83
|
-
className="ib side-toggle"
|
|
84
|
-
aria-controls="side"
|
|
85
|
-
aria-expanded={!sideHidden}
|
|
86
|
-
aria-keyshortcuts="Meta+Backslash Control+Backslash"
|
|
87
|
-
aria-label={sideHidden ? 'Show the sidebar' : 'Hide the sidebar'}
|
|
88
|
-
title={sideHidden ? 'Show the sidebar (⌘\\)' : 'Hide the sidebar (⌘\\)'}
|
|
89
|
-
onClick={() => editor.setSideHidden(!sideHidden)}
|
|
90
|
-
>
|
|
91
|
-
<Icon name="sidebar" />
|
|
92
|
-
</button>
|
|
93
79
|
</div>
|
|
94
80
|
<div className="bar-r">
|
|
95
81
|
<div className="bar-a">{note}</div>
|
|
@@ -40,6 +40,8 @@ const SYMBOLS = [
|
|
|
40
40
|
'<symbol id="i-target" viewBox="0 0 16 16"><circle cx="8" cy="8" r="5.5"/><circle cx="8" cy="8" r="1.6" fill="currentColor"/></symbol>',
|
|
41
41
|
'<symbol id="i-history" viewBox="0 0 16 16"><path d="M2.5 8a5.5 5.5 0 1 0 1.6-3.9L2.5 5.7"/><path d="M2.5 2.5v3.2h3.2M8 5v3l2 1.3"/></symbol>',
|
|
42
42
|
'<symbol id="i-signout" viewBox="0 0 16 16"><path d="M6 2.5H3v11h3M10 5l3 3-3 3M13 8H6"/></symbol>',
|
|
43
|
+
// Rename….
|
|
44
|
+
'<symbol id="i-pencil" viewBox="0 0 16 16"><path d="M10.8 2.7a1.6 1.6 0 0 1 2.3 2.3L5.5 12.6l-3 .9.9-3z"/><path d="m9.6 3.9 2.5 2.5"/></symbol>',
|
|
43
45
|
// Pre-M7 item 8: Change address…, and a redirect's "from → to".
|
|
44
46
|
'<symbol id="i-arrow-r" viewBox="0 0 16 16"><path d="M2.5 8h11M9.5 4l4 4-4 4"/></symbol>',
|
|
45
47
|
'<symbol id="i-copy" viewBox="0 0 16 16"><rect x="5.5" y="5.5" width="8" height="8" rx="1.5"/><path d="M10.5 5.5v-2a1 1 0 0 0-1-1h-6a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2"/></symbol>',
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sidebar's hide and show button (⌘\): one button for both states, so focus stays
|
|
3
|
+
* on it either way. Shown, it sits in the sidebar's top row at its right end; hidden,
|
|
4
|
+
* it sits at the top left of the preview (editor.css places it).
|
|
5
|
+
*/
|
|
6
|
+
import { useEditor, useStore } from '../context.tsx';
|
|
7
|
+
import { Icon } from './Icons.tsx';
|
|
8
|
+
|
|
9
|
+
export function SideToggle() {
|
|
10
|
+
const editor = useEditor();
|
|
11
|
+
const sideHidden = useStore(editor.ui, (u) => u.side === 'hidden');
|
|
12
|
+
return (
|
|
13
|
+
<button
|
|
14
|
+
type="button"
|
|
15
|
+
className="ib side-toggle"
|
|
16
|
+
aria-controls="side"
|
|
17
|
+
aria-expanded={!sideHidden}
|
|
18
|
+
aria-keyshortcuts="Meta+Backslash Control+Backslash"
|
|
19
|
+
aria-label={sideHidden ? 'Show the sidebar' : 'Hide the sidebar'}
|
|
20
|
+
title={sideHidden ? 'Show the sidebar (⌘\\)' : 'Hide the sidebar (⌘\\)'}
|
|
21
|
+
onClick={() => editor.setSideHidden(!sideHidden)}
|
|
22
|
+
>
|
|
23
|
+
<Icon name="sidebar" />
|
|
24
|
+
</button>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -29,7 +29,7 @@ kbd{display:inline-block;min-width:18px;padding:2px 4px;border:1px solid var(--b
|
|
|
29
29
|
|
|
30
30
|
/* ══ Editor shell: one left pane, the preview, one bar along the bottom ══ */
|
|
31
31
|
.editor{--side-col:var(--sidebar-w);position:absolute;inset:0;display:grid;grid-template-columns:var(--side-col) minmax(0,1fr);grid-template-rows:minmax(0,1fr) var(--bar-h)}
|
|
32
|
-
/* The sidebar hidden (
|
|
32
|
+
/* The sidebar hidden (its button, ⌘\\): its column goes, the preview takes the width (the stage rescales itself). The bar keeps its shape. */
|
|
33
33
|
.editor[data-side="hidden"]{--side-col:0px}
|
|
34
34
|
.editor[data-side="hidden"]>.side{display:none}
|
|
35
35
|
@container app (min-width:1680px){.editor{--sidebar-w:360px}}
|
|
@@ -42,6 +42,17 @@ kbd{display:inline-block;min-width:18px;padding:2px 4px;border:1px solid var(--b
|
|
|
42
42
|
.lv:hover{background:var(--surface-hover);color:var(--text)}
|
|
43
43
|
.lv b{color:var(--text);font-weight:var(--weight-semibold);overflow:hidden;text-overflow:ellipsis}
|
|
44
44
|
.lv .n{margin-left:auto;font-size:var(--text-xs);color:var(--text-subtle)}
|
|
45
|
+
/* The sidebar's button (SideToggle.tsx): one button, at the right end of the sidebar's top row, so the first tab (or a header with no tabs above) leaves room for it. Hidden, it moves to the preview's top left. */
|
|
46
|
+
.side-toggle{position:absolute;top:var(--space-3);left:calc(var(--side-col) - var(--space-4) - 28px);z-index:3}
|
|
47
|
+
/* Not .ib's pressed look: shown is its resting state. */
|
|
48
|
+
.editor>.side-toggle[aria-expanded="true"]{background:none;color:var(--icon)}
|
|
49
|
+
.editor>.side-toggle[aria-expanded="true"]:hover{background:var(--surface-hover);color:var(--text)}
|
|
50
|
+
.editor:not([data-side="hidden"]):has(.side-level:not([hidden]) > .trail > .cur:first-child)>.side-toggle{top:var(--space-2)}
|
|
51
|
+
.editor:not([data-side="hidden"]):has(.side-level:not([hidden]) > .trail > .cur:first-child .sub)>.side-toggle{top:calc(var(--space-2) + 3px)}
|
|
52
|
+
.trail > .lv:first-child{width:calc(100% - 2 * var(--space-4) - 28px - var(--space-2))}
|
|
53
|
+
.trail > .cur:first-child .cur-row{padding-right:calc(28px + var(--space-2))}
|
|
54
|
+
.editor[data-side="hidden"]>.side-toggle{top:var(--space-2);left:var(--space-2);z-index:var(--z-bar);border-color:var(--border-strong);background:var(--surface);box-shadow:var(--shadow-pop)}
|
|
55
|
+
.editor[data-side="hidden"]>.side-toggle:hover{background:var(--surface-hover)}
|
|
45
56
|
.cur{position:relative;padding:var(--space-2) var(--space-4) var(--space-2-5);background:var(--surface);border-top:1px solid var(--border)}
|
|
46
57
|
/* The header and the body are one surface: the header spans the pane, with one hairline (the trail's) under it; with no tabs above, no strip either. */
|
|
47
58
|
.trail:has(> .cur:first-child){padding-top:0}
|
|
@@ -52,10 +63,10 @@ kbd{display:inline-block;min-width:18px;padding:2px 4px;border:1px solid var(--b
|
|
|
52
63
|
.cur-title .t span{overflow:hidden;text-overflow:ellipsis}
|
|
53
64
|
.cur-title .t .crumbp{color:var(--text-muted);font-weight:var(--weight-medium)}
|
|
54
65
|
.cur-title .sub{margin-top:2px;font-size:var(--text-xs);color:var(--text-subtle);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
55
|
-
/* A sub line with chips (
|
|
66
|
+
/* A sub line with chips (New, Read-only): the chips stay whole, the address gives way. */
|
|
56
67
|
.cur-title .sub:has(> .chip){display:flex;align-items:center;gap:var(--space-1-5)}
|
|
57
68
|
.cur-title .sub > .chip{flex:none}
|
|
58
|
-
.cur-title .sub > .
|
|
69
|
+
.cur-title .sub > :not(.chip){min-width:0;overflow:hidden;text-overflow:ellipsis}
|
|
59
70
|
.ib{display:inline-grid;place-items:center;width:28px;height:28px;flex:none;padding:0;border:1px solid transparent;border-radius:var(--radius-md);background:none;color:var(--icon);transition:background var(--dur-fast),color var(--dur-fast)}
|
|
60
71
|
.ib:hover{background:var(--surface-hover);color:var(--text)}
|
|
61
72
|
.ib.boxed{border-color:var(--border-strong);background:var(--surface)}
|
|
@@ -66,7 +77,6 @@ kbd{display:inline-block;min-width:18px;padding:2px 4px;border:1px solid var(--b
|
|
|
66
77
|
.side-body:hover,.side-body[data-scrolling]{scrollbar-color:var(--border-hover) transparent}
|
|
67
78
|
.side-body.flush{padding-top:0}
|
|
68
79
|
.side-foot{flex:none;display:flex;align-items:center;gap:var(--space-2);padding:var(--space-2) var(--space-3);border-top:1px solid var(--border);background:var(--surface-sunken);overflow:hidden}
|
|
69
|
-
.side-foot .lbl{flex:none;font-size:var(--text-xs);font-weight:var(--weight-semibold);color:var(--text-subtle)}
|
|
70
80
|
.rchips{display:flex;gap:var(--space-1);overflow-x:auto;scrollbar-width:none}
|
|
71
81
|
.rchip{display:inline-flex;align-items:center;gap:var(--space-1-5);height:24px;padding:0 var(--space-2);border:1px solid var(--border);border-radius:var(--radius-md);background:var(--surface);font-size:var(--text-sm);color:var(--text);white-space:nowrap;flex:none}
|
|
72
82
|
.rchip:hover{border-color:var(--border-hover)}
|
|
@@ -99,15 +109,16 @@ kbd{display:inline-block;min-width:18px;padding:2px 4px;border:1px solid var(--b
|
|
|
99
109
|
.drow .nm .crumb{color:var(--text-subtle);font-weight:var(--weight-medium)}
|
|
100
110
|
/* Pages tree (M3 Decision 8): children indent one step; no connecting lines and no rails (Decision 10) */
|
|
101
111
|
.tnode{position:relative}
|
|
112
|
+
/* No twisty column: a row's icon sits where the site-wide rows' do, and the twisty is at the row's end (the parent page's own button; inside a group's row). A group's menu shows to its left on hover. */
|
|
102
113
|
.drow.t{padding-left:calc(var(--space-2) + var(--depth,0) * var(--tree-indent))}
|
|
103
|
-
.drow
|
|
104
|
-
.
|
|
105
|
-
.twisty
|
|
114
|
+
.drow.t.has-kids{padding-right:calc(var(--space-1) + var(--tree-twisty) + 6px)}
|
|
115
|
+
.drow.is-group{cursor:default;padding-right:calc(var(--space-1) + var(--tree-twisty) + 6px + 26px)}
|
|
116
|
+
.twisty,.drow.is-group .tw{position:absolute;top:calc(var(--row-h) / 2);right:var(--space-1);translate:0 -50%;display:grid;place-items:center;width:calc(var(--tree-twisty) + 6px);height:calc(var(--tree-twisty) + 6px);padding:0;border:0;border-radius:var(--radius-sm);background:none;color:var(--icon);transition:background var(--dur-fast),color var(--dur-fast)}
|
|
117
|
+
.twisty:hover,.drow.is-group:hover .tw{background:var(--surface-pressed);color:var(--text)}
|
|
106
118
|
.twisty .i,.drow.is-group .tw .i{transition:rotate var(--dur-fast) var(--ease-out)}
|
|
107
119
|
.twisty[aria-expanded="true"] .i,.drow.is-group[aria-expanded="true"] .tw .i{rotate:90deg}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
.row-more{position:absolute;right:3px;top:15px;translate:0 -50%;width:26px;height:26px;opacity:0;transition:opacity var(--dur-fast)}
|
|
120
|
+
/* A group row's own menu (M5a: "Create a page at /services"), left of its twisty */
|
|
121
|
+
.row-more{position:absolute;right:calc(var(--space-1) + var(--tree-twisty) + 6px);top:calc(var(--row-h) / 2);translate:0 -50%;width:26px;height:26px;opacity:0;transition:opacity var(--dur-fast)}
|
|
111
122
|
.tnode:hover>.row-more,.tnode:focus-within>.row-more,.row-more[aria-expanded="true"]{opacity:1}
|
|
112
123
|
.tnode>.menu{right:0;top:calc(var(--row-h) + 2px)}
|
|
113
124
|
/* A row per collection on the documents level (M3 Decision 9): name, bubbled dots, count, chevron */
|
|
@@ -215,7 +226,7 @@ details.fgroup[open]>summary .i{rotate:90deg}
|
|
|
215
226
|
details.fgroup>.in{padding:0 var(--space-4) var(--space-2)}
|
|
216
227
|
details.fgroup>summary .wmark{margin-left:var(--space-1)}
|
|
217
228
|
details.fgroup>summary .wmark .i{margin-left:0;rotate:none}
|
|
218
|
-
/* M9 Decision 7: the page's "Hidden from the site" switch, between its page fields and
|
|
229
|
+
/* M9 Decision 7: the page's "Hidden from the site" switch, between its page fields and SEO. */
|
|
219
230
|
.page-hidden{margin-top:var(--space-4)}
|
|
220
231
|
/* A group field (group()): Page details' section, among a form's fields; inside a list item it keeps to the item's width. */
|
|
221
232
|
details.fgroup.group-field{margin-block:var(--space-3)}
|
|
@@ -289,7 +300,6 @@ details.fgroup.group-field+details.fgroup.group-field{margin-top:calc(-1 * var(-
|
|
|
289
300
|
.btn.ghost{background:none;border-color:transparent;box-shadow:none}
|
|
290
301
|
.btn:disabled{background:var(--surface-sunken);border-color:var(--border);color:var(--text-subtle);box-shadow:none;cursor:not-allowed;filter:none}
|
|
291
302
|
.chip{display:inline-flex;align-items:center;gap:var(--space-1);height:20px;padding:0 var(--space-1-5);border-radius:var(--radius-pill);background:var(--surface-pressed);color:var(--text-muted);font-size:var(--text-xs);font-weight:var(--weight-semibold);white-space:nowrap}
|
|
292
|
-
.chip.kind{height:18px;border-radius:var(--radius-xs);padding:0 5px;font-size:10px;letter-spacing:var(--tracking-caps);text-transform:uppercase}
|
|
293
303
|
.chip.select{background:var(--select-wash);color:var(--select-strong);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--select) 40%,transparent)}
|
|
294
304
|
.chip.phone{background:var(--phone-wash);color:var(--phone-strong);box-shadow:inset 0 0 0 1px var(--phone-border)}
|
|
295
305
|
.chip.warn{background:var(--status-warning-wash);color:var(--status-warning);box-shadow:inset 0 0 0 1px var(--status-warning-border)}
|
|
@@ -331,18 +341,14 @@ details.fgroup.group-field+details.fgroup.group-field{margin-top:calc(-1 * var(-
|
|
|
331
341
|
/* ── The one bar, dark, along the bottom (4d) ── */
|
|
332
342
|
.bar{grid-row:2;grid-column:1/-1;display:grid;grid-template-columns:var(--sidebar-w) minmax(0,1fr);background:var(--bar-bg);color:var(--bar-text);border-top:1px solid var(--bar-border);z-index:var(--z-bar);font-size:var(--text-base);--ring:var(--bar-bg)}
|
|
333
343
|
.bar :focus-visible{outline-color:var(--focus-on-dark)}
|
|
334
|
-
.bar-l{display:flex;align-items:center;gap:var(--space-2);min-width:0;padding:0 var(--space-3)
|
|
344
|
+
.bar-l{display:flex;align-items:center;gap:var(--space-2);min-width:0;padding:0 var(--space-3)}
|
|
335
345
|
.logo-slot{display:grid;place-items:center;width:26px;height:26px;flex:none;padding:2px;border-radius:var(--radius-sm);background:var(--bar-control)}
|
|
336
346
|
.logo-slot svg,.si-logo svg{display:block;width:100%;height:100%}
|
|
337
|
-
.bar-name{font-weight:var(--weight-semibold);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:120px}
|
|
338
|
-
.bar-sep{width:1px;height:18px;flex:none;background:var(--bar-border)}
|
|
339
347
|
.docbtn{display:inline-flex;align-items:center;gap:var(--space-1-5);min-width:0;height:30px;padding:0 var(--space-2);border:0;border-radius:var(--radius-md);background:var(--bar-raised);color:var(--bar-text);font-weight:var(--weight-semibold);transition:background var(--dur-fast)}
|
|
340
348
|
.docbtn:hover,.docbtn[aria-expanded="true"]{background:var(--bar-raised-hover)}
|
|
341
349
|
.docbtn .t{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
342
350
|
.docbtn .i{color:var(--bar-muted)}
|
|
343
351
|
.docbtn kbd{margin-left:var(--space-1);border-color:var(--bar-border-strong);background:none;color:var(--bar-muted)}
|
|
344
|
-
.side-toggle{flex:none;margin-left:auto;color:var(--bar-muted)}
|
|
345
|
-
.side-toggle:hover{background:var(--bar-raised);color:var(--bar-text)}
|
|
346
352
|
.bar-r{display:grid;grid-template-columns:minmax(0,1fr) auto minmax(max-content,1fr);align-items:center;gap:var(--space-3);min-width:0;padding:0 var(--space-3)}
|
|
347
353
|
.bar-a{display:flex;justify-content:flex-end;min-width:0;overflow:hidden}
|
|
348
354
|
.bar-c{display:flex;align-items:center;gap:var(--space-2)}
|
|
@@ -392,7 +398,7 @@ details.fgroup.group-field+details.fgroup.group-field{margin-top:calc(-1 * var(-
|
|
|
392
398
|
.me{display:inline-grid;place-items:center;width:30px;height:30px;padding:0;border:0;border-radius:50%;background:none}
|
|
393
399
|
.me .av{width:26px;height:26px;font-size:11px}
|
|
394
400
|
.me[aria-expanded="true"] .av,.me:hover .av{box-shadow:0 0 0 2px var(--bar-bg),0 0 0 4px var(--bar-border-hover)}
|
|
395
|
-
@container app (max-width:1199px){.seg .w,.pub .wt .avs
|
|
401
|
+
@container app (max-width:1199px){.seg .w,.pub .wt .avs{display:none}}
|
|
396
402
|
@media (prefers-reduced-motion:reduce){.keep .pulse,.pub .prog::after{animation:none}}
|
|
397
403
|
|
|
398
404
|
/* ── Popovers (they open upward from the bar) ── */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* M6: the inbox (Site-wide → Submissions, a submission on a sheet over the dimmed page),
|
|
1
|
+
/* M6: the inbox (Site-wide → Submissions, a submission on a sheet over the dimmed page), SEO, and
|
|
2
2
|
Site Settings' publish-time fields (notes/mockups/m2-editor.html, lifted from its M6 CSS as is). */
|
|
3
3
|
/* ── M6: Submissions. A level of its own under Site-wide; a submission opens in the stage, on a sheet over the dimmed
|
|
4
4
|
page. Unread is a dot and a heavier name; the open one is the selection's wash and ring. Never a side rail. ── */
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
.frow:hover>.i{color:var(--text)}
|
|
64
64
|
@container (max-width:600px){.sub-fields{grid-template-columns:1fr;gap:var(--space-0-5) 0}.sub-fields dd{margin-bottom:var(--space-2-5)}.sub-head,.sub-body{padding-left:var(--space-4);padding-right:var(--space-4)}}
|
|
65
65
|
|
|
66
|
-
/* ── M6:
|
|
66
|
+
/* ── M6: SEO, and Site Settings' publish-time fields. A soft limit is amber and blocks nothing ── */
|
|
67
67
|
.field-meta.soft-over{color:var(--status-warning);font-weight:var(--weight-semibold)}
|
|
68
68
|
.field-ok{display:flex;align-items:center;gap:var(--space-1);margin-top:var(--space-1-5);font-size:var(--text-xs);color:var(--text-muted)}
|
|
69
69
|
.field-ok .i{color:var(--status-live)}
|
|
@@ -73,10 +73,15 @@
|
|
|
73
73
|
.gtag .field-warn>span{display:flex;flex-direction:column;align-items:flex-start}
|
|
74
74
|
.rcpt .ed.is-confirm .tile{background:var(--surface);color:var(--status-warning-dot)}
|
|
75
75
|
details.fgroup.seo{margin-top:var(--space-5)}
|
|
76
|
-
/* M6c (G2): a section builder's page fields, above
|
|
76
|
+
/* M6c (G2): a section builder's page fields, above SEO */
|
|
77
77
|
details.fgroup.page-fields{margin-top:var(--space-5)}
|
|
78
78
|
details.fgroup.page-fields+details.fgroup.seo{margin-top:0}
|
|
79
79
|
details.fgroup.seo>.in{padding-top:var(--space-1)}
|
|
80
80
|
details.fgroup.seo>summary{white-space:nowrap}
|
|
81
81
|
details.fgroup.seo>summary .s{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis}
|
|
82
|
+
/* A page's own pages (PagesUnder.tsx), under SEO: the tree's rows, then New page under it */
|
|
83
|
+
details.fgroup.pages-under>summary{white-space:nowrap}
|
|
84
|
+
details.fgroup.pages-under>summary .s{flex:1;min-width:0}
|
|
85
|
+
details.fgroup.pages-under>.in{display:flex;flex-direction:column;align-items:stretch;padding:0 var(--space-4) var(--space-3)}
|
|
86
|
+
details.fgroup.pages-under>.in>.linkbtn{align-self:flex-start;margin:var(--space-2) 0 0 var(--space-2)}
|
|
82
87
|
|
|
@@ -97,8 +97,8 @@
|
|
|
97
97
|
--ease-out:cubic-bezier(.2,.8,.2,1); --ease-in-out:cubic-bezier(.45,0,.25,1);
|
|
98
98
|
/* Layout */
|
|
99
99
|
--bar-h:44px; --sidebar-w:320px; --row-h:30px; --control-h:32px; --control-h-sm:26px; --pop-gap:8px; --frame-pad:16px;
|
|
100
|
-
/* Pages tree (M3 Decision 8): the twisty's
|
|
101
|
-
--tree-twisty:16px; --tree-indent:
|
|
100
|
+
/* Pages tree (M3 Decision 8): the twisty (at a row's end), and one step of indent (a child's icon starts where its parent's ends) */
|
|
101
|
+
--tree-twisty:16px; --tree-indent:16px;
|
|
102
102
|
/* Media (M5b): a slot's thumbnail, the library's grid (columns at the sidebar's width), the focal point's ring */
|
|
103
103
|
--thumb-w:64px; --thumb-h:48px; --lib-cols:3; --focal-ring:22px; --focal-max-h:220px;
|
|
104
104
|
/* The grid (M6c G5): a card's narrowest width (the stage fits as many as it can), the gaps, the card's own padding,
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
* goes out with the Publish that moves the page. Re-read when the file changes
|
|
11
11
|
* (its modification time), so local edits apply at once.
|
|
12
12
|
* - The editor's own paths (`/admin`, `/_caracms/`) are never redirected.
|
|
13
|
+
*
|
|
14
|
+
* This serves them in dev. A built site's 404 page is prerendered, sent without this
|
|
15
|
+
* middleware running, so `caracms start`'s gate (bin/gate.mjs) serves them there.
|
|
13
16
|
*/
|
|
14
17
|
import { readFile, stat } from 'node:fs/promises';
|
|
15
18
|
import { join } from 'node:path';
|