@sandboxsonny/ace-kit 0.1.2 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # @sandboxsonny/ace-kit
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - a9d7700: `ace-kit login`, `ace-kit logout` and `ace-kit whoami`: sign in to CMS Ace from the command line.
8
+
9
+ `ace-kit login` uses the OAuth device flow: the terminal shows a short code and opens the admin app, where you type the code and approve it. No password goes into the terminal. The login is kept in the macOS Keychain, or in a file only you can read on other systems, and is what the dev-server sync for sections will use. It can do much less than a browser login and ends after 30 days unused. Needs a CMS Ace API with command-line sign-in (its migration 00020). Set `ACE_TOKEN` to use a token instead (for a build server).
10
+
11
+ `ace-kit generate` is unchanged.
12
+
13
+ ## 0.2.0
14
+
15
+ ### Minor Changes
16
+
17
+ - 801266d: Support for the CMS's new Date and Link field types.
18
+
19
+ - `ace-kit generate` types a Date field as `string` (a day written `YYYY-MM-DD`) and a Link field as the new `AceLinkData` (`{ href, label? }`). Before, both were `unknown`.
20
+ - `AceLink` draws a link field: `AceLink.astro` for Astro, `AceLink` from `@sandboxsonny/ace-kit/react`, and a version from `@sandboxsonny/ace-kit/react-router` that uses the router's `Link` for pages of the site. It shows the editor's words, or yours as children, and draws nothing for an address that can't be trusted.
21
+ - `linkAttributes(link)` is what those components use, for sites that draw links their own way.
22
+ - `formatAceDate(date, format?)` turns a Date field's day into words in any language, and never shifts the day with the server's time zone.
23
+
24
+ Needs a CMS Ace API with the Date and Link field types (the API's migration 00018). Against an older API nothing changes.
25
+
3
26
  ## 0.1.2
4
27
 
5
28
  ### Patch Changes
package/README.md CHANGED
@@ -51,6 +51,7 @@ const about = await getEntry("pages", "about");
51
51
  | [React](docs/react.md) | Components for any React framework |
52
52
  | [Live preview](docs/live-preview.md) | What a site must do for the editor's preview |
53
53
  | [Typed content](docs/typed-content.md) | `ace-kit generate` |
54
+ | [The command line](docs/command-line.md) | `ace-kit login`, `logout`, `whoami` |
54
55
  | [Rich text](docs/rich-text.md) | The document format and how it is drawn |
55
56
  | [API reference](docs/api-reference.md) | Every export |
56
57
  | [Releasing](docs/releasing.md) | Versions, the changelog and publishing |
@@ -0,0 +1,20 @@
1
+ ---
2
+ import { linkAttributes } from "../dist/index.js";
3
+ import type { AceLinkData } from "../dist/index.js";
4
+
5
+ type Props = {
6
+ /** A link field's value: `entry.values.button`. */
7
+ link: AceLinkData;
8
+ class?: string;
9
+ };
10
+
11
+ const { link, class: className } = Astro.props;
12
+ const attributes = linkAttributes(link);
13
+ const hasContent = Astro.slots.has("default");
14
+ ---
15
+
16
+ {attributes && (
17
+ <a href={attributes.href} class={className}>
18
+ {hasContent ? <slot /> : (attributes.label ?? attributes.href)}
19
+ </a>
20
+ )}
package/dist/astro.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  type AceFieldSchema = {
3
3
  handle: string;
4
4
  label: string;
5
- /** `text`, `textarea`, `select`, `number`, `checkbox`, `image` or `richtext`. */
5
+ /** `text`, `textarea`, `select`, `number`, `checkbox`, `image`, `richtext`, `date` or `link`. */
6
6
  component_key: string;
7
7
  config: { required?: boolean; options?: string[] } | null;
8
8
  };
@@ -1,4 +1,4 @@
1
- import { imageAttributes, textAreaSingleLine, textAreaParagraphs, textAreaLines, richTextImageSizes, listStart, headingLevel, linkAddress } from './chunk-URLKW2YS.js';
1
+ import { imageAttributes, linkAttributes, textAreaSingleLine, textAreaParagraphs, textAreaLines, richTextImageSizes, listStart, headingLevel, linkAddress } from './chunk-FVR55GIU.js';
2
2
  import { listenForPreviewRefresh } from './chunk-YN7LJWGI.js';
3
3
  import { jsx, Fragment, jsxs } from 'react/jsx-runtime';
4
4
  import { Fragment as Fragment$1, useEffect } from 'react';
@@ -7,7 +7,13 @@ function AceImage({ image, sizes, lazy, ...rest }) {
7
7
  const attributes = imageAttributes(image, { sizes, lazy });
8
8
  return /* @__PURE__ */ jsx("img", { ...rest, ...attributes });
9
9
  }
10
- var PlainLink = ({ href, children }) => /* @__PURE__ */ jsx("a", { href, children });
10
+ var PlainLink = ({ href, children, className }) => /* @__PURE__ */ jsx("a", { href, className, children });
11
+ function AceLink({ link, children, className, as: Link = PlainLink }) {
12
+ const attributes = linkAttributes(link);
13
+ if (!attributes) return null;
14
+ return /* @__PURE__ */ jsx(Link, { href: attributes.href, className, children: children ?? attributes.label ?? attributes.href });
15
+ }
16
+ var PlainLink2 = ({ href, children }) => /* @__PURE__ */ jsx("a", { href, children });
11
17
  function renderText(node, Link) {
12
18
  return (node.marks ?? []).reduce((inner, mark) => {
13
19
  if (mark.type === "bold") return /* @__PURE__ */ jsx("strong", { children: inner });
@@ -46,7 +52,7 @@ function Node({ node, Link }) {
46
52
  return null;
47
53
  }
48
54
  }
49
- function AceRichText({ document, link = PlainLink }) {
55
+ function AceRichText({ document, link = PlainLink2 }) {
50
56
  return /* @__PURE__ */ jsx(Fragment, { children: renderNodes(document.content, link) });
51
57
  }
52
58
  function AceTextArea({ value, multiline = true }) {
@@ -63,4 +69,4 @@ function usePreviewRefresh(adminOrigin, refresh) {
63
69
  }, [adminOrigin, refresh]);
64
70
  }
65
71
 
66
- export { AceImage, AceRichText, AceTextArea, usePreviewRefresh };
72
+ export { AceImage, AceLink, AceRichText, AceTextArea, usePreviewRefresh };
@@ -26,6 +26,10 @@ function linkAddress(address) {
26
26
  function isInternalPath(address) {
27
27
  return address.startsWith("/") && !address.startsWith("//");
28
28
  }
29
+ function linkAttributes(link) {
30
+ const href = linkAddress(link.href);
31
+ return href ? { href, label: link.label ? link.label : null } : null;
32
+ }
29
33
 
30
34
  // src/helpers/text-area.ts
31
35
  var escapes = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
@@ -104,4 +108,4 @@ function renderRichText(document) {
104
108
  return (document.content ?? []).map(renderNode).join("");
105
109
  }
106
110
 
107
- export { headingLevel, imageAttributes, isInternalPath, linkAddress, listStart, renderRichText, renderTextArea, richTextImageSizes, textAreaLines, textAreaParagraphs, textAreaSingleLine };
111
+ export { headingLevel, imageAttributes, isInternalPath, linkAddress, linkAttributes, listStart, renderRichText, renderTextArea, richTextImageSizes, textAreaLines, textAreaParagraphs, textAreaSingleLine };
package/dist/cli.js CHANGED
@@ -1,8 +1,233 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // src/generate/cli.ts
4
- import { mkdir, writeFile } from "fs/promises";
5
- import { dirname, resolve } from "path";
3
+ // src/cli/environment.ts
4
+ import { spawn } from "child_process";
5
+ import { hostname } from "os";
6
+
7
+ // src/auth/file-store.ts
8
+ import { mkdir, readFile, rename, writeFile } from "fs/promises";
9
+ import { homedir } from "os";
10
+ import { dirname, join } from "path";
11
+
12
+ // src/auth/login-store.ts
13
+ function normaliseApiUrl(address) {
14
+ let parsed;
15
+ try {
16
+ parsed = new URL(address.trim());
17
+ } catch {
18
+ throw new Error(`"${address}" is not a web address. Write the API address like http://localhost:8080.`);
19
+ }
20
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
21
+ throw new Error(`The API address must start with http:// or https:// (it was "${address}").`);
22
+ }
23
+ if (parsed.username || parsed.password) {
24
+ throw new Error("The API address must not contain a username or password.");
25
+ }
26
+ return `${parsed.origin}${parsed.pathname.replace(/\/+$/, "")}`;
27
+ }
28
+ function hasExpired(login, now) {
29
+ const endsAt = Date.parse(login.expiresAt);
30
+ return Number.isNaN(endsAt) || endsAt <= now;
31
+ }
32
+ function parseStoredLogin(value) {
33
+ if (typeof value !== "object" || value === null) return null;
34
+ const { token, expiresAt, email } = value;
35
+ if (typeof token !== "string" || token === "" || typeof expiresAt !== "string" || typeof email !== "string")
36
+ return null;
37
+ return { token, expiresAt, email };
38
+ }
39
+
40
+ // src/auth/file-store.ts
41
+ function defaultLoginFile(variables, platform) {
42
+ if (platform === "win32" && variables.APPDATA) return join(variables.APPDATA, "ace-kit", "credentials.json");
43
+ const configFolder = variables.XDG_CONFIG_HOME || join(homedir(), ".config");
44
+ return join(configFolder, "ace-kit", "credentials.json");
45
+ }
46
+ function createFileStore(path) {
47
+ async function readAll() {
48
+ let text;
49
+ try {
50
+ text = await readFile(path, "utf8");
51
+ } catch {
52
+ return {};
53
+ }
54
+ let parsed;
55
+ try {
56
+ parsed = JSON.parse(text);
57
+ } catch {
58
+ return {};
59
+ }
60
+ const found = {};
61
+ if (typeof parsed === "object" && parsed !== null) {
62
+ for (const [address, value] of Object.entries(parsed)) {
63
+ const login = parseStoredLogin(value);
64
+ if (login) found[address] = login;
65
+ }
66
+ }
67
+ return found;
68
+ }
69
+ async function writeAll(logins) {
70
+ await mkdir(dirname(path), { recursive: true, mode: 448 });
71
+ const temporary = `${path}.${process.pid}.tmp`;
72
+ await writeFile(temporary, `${JSON.stringify(logins, null, 2)}
73
+ `, { mode: 384 });
74
+ await rename(temporary, path);
75
+ }
76
+ return {
77
+ description: path,
78
+ async read(apiUrl) {
79
+ return (await readAll())[normaliseApiUrl(apiUrl)] ?? null;
80
+ },
81
+ async write(apiUrl, login) {
82
+ const logins = await readAll();
83
+ logins[normaliseApiUrl(apiUrl)] = login;
84
+ await writeAll(logins);
85
+ },
86
+ async remove(apiUrl) {
87
+ const logins = await readAll();
88
+ delete logins[normaliseApiUrl(apiUrl)];
89
+ await writeAll(logins);
90
+ }
91
+ };
92
+ }
93
+
94
+ // src/auth/keychain-store.ts
95
+ var keychainService = "ace-kit";
96
+ function createKeychainStore(run) {
97
+ async function read(apiUrl) {
98
+ const found = await run("security", [
99
+ "find-generic-password",
100
+ "-s",
101
+ keychainService,
102
+ "-a",
103
+ normaliseApiUrl(apiUrl),
104
+ "-w"
105
+ ]);
106
+ if (found.exitCode !== 0) return null;
107
+ try {
108
+ return parseStoredLogin(JSON.parse(Buffer.from(found.stdout.trim(), "base64").toString("utf8")));
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+ return {
114
+ description: "the macOS Keychain",
115
+ read,
116
+ async write(apiUrl, login) {
117
+ const account = normaliseApiUrl(apiUrl);
118
+ if (/["\\\s]/.test(account)) throw new Error("That API address can't be kept in the Keychain.");
119
+ const secret = Buffer.from(JSON.stringify(login), "utf8").toString("base64");
120
+ await run(
121
+ "security",
122
+ ["-i"],
123
+ `add-generic-password -s "${keychainService}" -a "${account}" -l "Ace Kit login" -w "${secret}" -U
124
+ `
125
+ );
126
+ const kept = await read(apiUrl);
127
+ if (kept?.token !== login.token) throw new Error("The login could not be saved in the macOS Keychain.");
128
+ },
129
+ async remove(apiUrl) {
130
+ await run("security", ["delete-generic-password", "-s", keychainService, "-a", normaliseApiUrl(apiUrl)]);
131
+ }
132
+ };
133
+ }
134
+
135
+ // src/auth/choose-store.ts
136
+ async function chooseLoginStore(options) {
137
+ const file = createFileStore(defaultLoginFile(options.variables, options.platform));
138
+ if (options.platform !== "darwin" || options.variables.ACE_KIT_CREDENTIALS === "file") return file;
139
+ try {
140
+ await options.run("security", ["help"]);
141
+ return createKeychainStore(options.run);
142
+ } catch {
143
+ return file;
144
+ }
145
+ }
146
+
147
+ // src/auth/last-api-url.ts
148
+ import { mkdir as mkdir2, readFile as readFile2, rename as rename2, writeFile as writeFile2 } from "fs/promises";
149
+ import { dirname as dirname2, join as join2 } from "path";
150
+ function defaultLastApiUrlFile(variables, platform) {
151
+ return join2(dirname2(defaultLoginFile(variables, platform)), "last-api-url");
152
+ }
153
+ function createLastApiUrl(path) {
154
+ return {
155
+ async read() {
156
+ try {
157
+ return normaliseApiUrl((await readFile2(path, "utf8")).trim());
158
+ } catch {
159
+ return null;
160
+ }
161
+ },
162
+ async write(apiUrl) {
163
+ await mkdir2(dirname2(path), { recursive: true, mode: 448 });
164
+ const temporary = `${path}.${process.pid}.tmp`;
165
+ await writeFile2(temporary, `${normaliseApiUrl(apiUrl)}
166
+ `, { mode: 384 });
167
+ await rename2(temporary, path);
168
+ }
169
+ };
170
+ }
171
+
172
+ // src/cli/version.ts
173
+ var aceKitVersion = true ? "0.3.0" : "development";
174
+
175
+ // src/cli/environment.ts
176
+ var runCommand = (command, commandArguments, input) => new Promise((resolve2, reject) => {
177
+ const child = spawn(command, commandArguments, { stdio: ["pipe", "pipe", "ignore"] });
178
+ let stdout = "";
179
+ child.stdout.on("data", (chunk) => {
180
+ stdout += chunk.toString("utf8");
181
+ });
182
+ child.on("error", reject);
183
+ child.on("close", (exitCode) => resolve2({ exitCode: exitCode ?? 1, stdout }));
184
+ child.stdin.on("error", () => {
185
+ });
186
+ child.stdin.end(input ?? "");
187
+ });
188
+ function browserCommand(platform, address) {
189
+ if (platform === "darwin") return { command: "open", commandArguments: [address] };
190
+ if (platform === "win32") return { command: "rundll32", commandArguments: ["url.dll,FileProtocolHandler", address] };
191
+ return { command: "xdg-open", commandArguments: [address] };
192
+ }
193
+ async function openBrowser(platform, address) {
194
+ const { command, commandArguments } = browserCommand(platform, address);
195
+ await new Promise((resolve2) => {
196
+ const child = spawn(command, commandArguments, { stdio: "ignore", detached: true });
197
+ child.on("error", () => resolve2());
198
+ child.on("spawn", () => {
199
+ child.unref();
200
+ resolve2();
201
+ });
202
+ });
203
+ }
204
+ async function createRealEnvironment() {
205
+ try {
206
+ process.loadEnvFile(".env");
207
+ } catch {
208
+ }
209
+ const variables = { ...process.env };
210
+ const platform = process.platform;
211
+ return {
212
+ variables,
213
+ platform,
214
+ hostname: hostname(),
215
+ version: aceKitVersion,
216
+ out: (text) => process.stdout.write(`${text}
217
+ `),
218
+ error: (text) => console.error(text),
219
+ fetchImplementation: (input, init) => fetch(input, init),
220
+ sleep: (milliseconds) => new Promise((resolve2) => setTimeout(resolve2, milliseconds)),
221
+ now: Date.now,
222
+ openBrowser: (address) => openBrowser(platform, address),
223
+ store: await chooseLoginStore({ platform, variables, run: runCommand }),
224
+ lastApiUrl: createLastApiUrl(defaultLastApiUrlFile(variables, platform))
225
+ };
226
+ }
227
+
228
+ // src/cli/generate-command.ts
229
+ import { mkdir as mkdir3, writeFile as writeFile3 } from "fs/promises";
230
+ import { dirname as dirname3, resolve } from "path";
6
231
  import { parseArgs } from "util";
7
232
 
8
233
  // src/client/errors.ts
@@ -119,6 +344,10 @@ function typeOfField(field) {
119
344
  return { type: "AceImageData", uses: "AceImageData" };
120
345
  case "richtext":
121
346
  return { type: "AceRichTextDocument", uses: "AceRichTextDocument" };
347
+ case "date":
348
+ return { type: "string" };
349
+ case "link":
350
+ return { type: "AceLinkData", uses: "AceLinkData" };
122
351
  default:
123
352
  return { type: "unknown" };
124
353
  }
@@ -156,8 +385,8 @@ export {};
156
385
  // src/helpers/package-name.ts
157
386
  var packageName = "@sandboxsonny/ace-kit";
158
387
 
159
- // src/generate/cli.ts
160
- var usage = `ace-kit generate: write TypeScript types for your CMS Ace content types.
388
+ // src/cli/generate-command.ts
389
+ var generateUsage = `ace-kit generate: write TypeScript types for your CMS Ace content types.
161
390
 
162
391
  Usage:
163
392
  ace-kit generate [--api-url <address>] [--domain <domain>] [--out <file>]
@@ -168,13 +397,10 @@ Options:
168
397
  --out Where to write the types (default: src/ace-kit.generated.d.ts).
169
398
  --help Show this message.
170
399
  `;
171
- async function main() {
172
- try {
173
- process.loadEnvFile(".env");
174
- } catch {
175
- }
176
- const { positionals, values } = parseArgs({
177
- allowPositionals: true,
400
+ async function generateCommand(commandArguments, environment) {
401
+ const { values } = parseArgs({
402
+ // eslint-disable-next-line id-denylist -- "args" is the name node's parseArgs gives this option.
403
+ args: commandArguments,
178
404
  options: {
179
405
  "api-url": { type: "string" },
180
406
  domain: { type: "string" },
@@ -182,27 +408,365 @@ async function main() {
182
408
  help: { type: "boolean" }
183
409
  }
184
410
  });
185
- if (values.help || positionals[0] !== "generate") {
186
- console.log(usage);
187
- return values.help ? 0 : 1;
411
+ if (values.help) {
412
+ environment.out(generateUsage);
413
+ return 0;
188
414
  }
189
- const apiUrl = values["api-url"] ?? process.env.ACE_API_URL;
190
- const siteDomain = values.domain ?? process.env.ACE_SITE_DOMAIN;
415
+ const apiUrl = values["api-url"] ?? environment.variables.ACE_API_URL;
416
+ const siteDomain = values.domain ?? environment.variables.ACE_SITE_DOMAIN;
191
417
  if (!apiUrl || !siteDomain) {
192
- console.error(
418
+ environment.error(
193
419
  "Give the API address and the site's domain: --api-url and --domain, or ACE_API_URL and ACE_SITE_DOMAIN."
194
420
  );
195
421
  return 1;
196
422
  }
197
- const client = createAceClient({ apiUrl, siteDomain, cacheSeconds: 0 });
423
+ const client = createAceClient({
424
+ apiUrl,
425
+ siteDomain,
426
+ cacheSeconds: 0,
427
+ fetchImplementation: environment.fetchImplementation
428
+ });
198
429
  const contentTypes = await client.getContentTypes();
199
430
  const file = resolve(values.out ?? "src/ace-kit.generated.d.ts");
200
- await mkdir(dirname(file), { recursive: true });
201
- await writeFile(file, generateTypes(contentTypes, packageName, siteDomain));
202
- console.log(`Wrote types for ${contentTypes.length} content type${contentTypes.length === 1 ? "" : "s"} to ${file}.`);
431
+ await mkdir3(dirname3(file), { recursive: true });
432
+ await writeFile3(file, generateTypes(contentTypes, packageName, siteDomain));
433
+ environment.out(
434
+ `Wrote types for ${contentTypes.length} content type${contentTypes.length === 1 ? "" : "s"} to ${file}.`
435
+ );
203
436
  return 0;
204
437
  }
205
- main().then(
438
+
439
+ // src/cli/login-commands.ts
440
+ import { parseArgs as parseArgs2 } from "util";
441
+
442
+ // src/auth/device-login.ts
443
+ var DeviceLoginError = class extends Error {
444
+ reason;
445
+ constructor(reason, message) {
446
+ super(message);
447
+ this.name = "DeviceLoginError";
448
+ this.reason = reason;
449
+ }
450
+ };
451
+ var requestTimeoutMilliseconds2 = 15e3;
452
+ var maximumFailures = 5;
453
+ async function post(dependencies, apiUrl, path, body) {
454
+ return dependencies.fetchImplementation(`${apiUrl}${path}`, {
455
+ method: "POST",
456
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
457
+ body: JSON.stringify(body),
458
+ signal: AbortSignal.timeout(requestTimeoutMilliseconds2)
459
+ });
460
+ }
461
+ async function readMessage(response) {
462
+ try {
463
+ const body = await response.json();
464
+ if (typeof body.error === "string") return body.error;
465
+ } catch {
466
+ }
467
+ return `The CMS answered with an error (${response.status}).`;
468
+ }
469
+ async function startDeviceLogin(dependencies) {
470
+ let response;
471
+ try {
472
+ response = await post(dependencies, dependencies.apiUrl, "/auth/device", { client_name: dependencies.clientName });
473
+ } catch {
474
+ throw new DeviceLoginError("unavailable", `Could not reach the CMS at ${dependencies.apiUrl}.`);
475
+ }
476
+ if (response.status === 503) throw new DeviceLoginError("unsupported", await readMessage(response));
477
+ if (response.status === 429)
478
+ throw new DeviceLoginError("unavailable", "Too many attempts. Wait a minute and try again.");
479
+ if (response.status === 404) {
480
+ throw new DeviceLoginError(
481
+ "unsupported",
482
+ `${dependencies.apiUrl} does not offer command-line sign-in. Is it a CMS Ace API, and is it up to date?`
483
+ );
484
+ }
485
+ if (!response.ok) throw new DeviceLoginError("unavailable", await readMessage(response));
486
+ const body = await response.json().catch(() => null);
487
+ const { device_code, user_code, verification_uri, expires_in, interval } = body ?? {};
488
+ if (typeof device_code !== "string" || typeof user_code !== "string" || typeof verification_uri !== "string" || typeof expires_in !== "number" || typeof interval !== "number") {
489
+ throw new DeviceLoginError(
490
+ "unavailable",
491
+ "The CMS sent a reply this version of ace-kit can't read. Update ace-kit."
492
+ );
493
+ }
494
+ return {
495
+ deviceCode: device_code,
496
+ userCode: user_code,
497
+ verificationUri: verification_uri,
498
+ expiresInSeconds: expires_in,
499
+ intervalSeconds: Math.max(1, interval)
500
+ };
501
+ }
502
+ async function waitForApproval(dependencies) {
503
+ const { apiUrl, request, sleep, now } = dependencies;
504
+ const deadline = now() + request.expiresInSeconds * 1e3;
505
+ let intervalSeconds = request.intervalSeconds;
506
+ let failures = 0;
507
+ for (; ; ) {
508
+ await sleep(intervalSeconds * 1e3);
509
+ if (now() > deadline)
510
+ throw new DeviceLoginError("expired", "The code ran out before it was approved. Run ace-kit login again.");
511
+ let response;
512
+ try {
513
+ response = await post(dependencies, apiUrl, "/auth/device/token", { device_code: request.deviceCode });
514
+ } catch {
515
+ failures += 1;
516
+ if (failures >= maximumFailures)
517
+ throw new DeviceLoginError("unavailable", `Lost contact with the CMS at ${apiUrl}.`);
518
+ continue;
519
+ }
520
+ if (response.status === 404) {
521
+ throw new DeviceLoginError("expired", "The CMS no longer knows this login. Run ace-kit login again.");
522
+ }
523
+ if (response.status === 429) {
524
+ intervalSeconds += 5;
525
+ continue;
526
+ }
527
+ if (!response.ok) {
528
+ failures += 1;
529
+ if (failures >= maximumFailures) throw new DeviceLoginError("unavailable", await readMessage(response));
530
+ continue;
531
+ }
532
+ failures = 0;
533
+ const body = await response.json().catch(() => null);
534
+ switch (body?.status) {
535
+ case "pending":
536
+ continue;
537
+ case "slow_down":
538
+ intervalSeconds += 5;
539
+ continue;
540
+ case "denied":
541
+ throw new DeviceLoginError("denied", "The login was turned down in the admin app.");
542
+ case "expired":
543
+ throw new DeviceLoginError("expired", "The code ran out before it was approved. Run ace-kit login again.");
544
+ case "approved": {
545
+ const user = body.user;
546
+ if (typeof body.token !== "string" || typeof body.expires_at !== "string" || typeof user?.email !== "string") {
547
+ throw new DeviceLoginError("unavailable", "The CMS approved the login but sent a reply that can't be read.");
548
+ }
549
+ return { token: body.token, expiresAt: body.expires_at, email: user.email };
550
+ }
551
+ default:
552
+ throw new DeviceLoginError(
553
+ "unavailable",
554
+ "The CMS sent a reply this version of ace-kit can't read. Update ace-kit."
555
+ );
556
+ }
557
+ }
558
+ }
559
+
560
+ // src/auth/resolve-login.ts
561
+ async function resolveLogin(options) {
562
+ const fromEnvironment = options.variables.ACE_TOKEN?.trim();
563
+ if (fromEnvironment) return { token: fromEnvironment, source: "the ACE_TOKEN setting" };
564
+ const stored = await options.store.read(options.apiUrl);
565
+ if (!stored || hasExpired(stored, options.now())) return null;
566
+ return { token: stored.token, email: stored.email, source: options.store.description };
567
+ }
568
+
569
+ // src/cli/login-commands.ts
570
+ var loginUsage = `ace-kit login: sign in to CMS Ace from the command line.
571
+
572
+ Usage:
573
+ ace-kit login [--api-url <address>] Sign in. Opens the admin app, where you type the code shown here.
574
+ ace-kit logout [--api-url <address>] Sign out, and forget the login kept on this machine.
575
+ ace-kit whoami [--api-url <address>] Say who you are signed in as.
576
+
577
+ Options:
578
+ --api-url Where the CMS Ace API lives (or set ACE_API_URL, in the environment or a .env file).
579
+ --help Show this message.
580
+
581
+ The login is kept in the macOS Keychain, or in a file only you can read on other systems. On a build server,
582
+ where nobody can approve a login in a browser, set ACE_TOKEN instead.
583
+ `;
584
+ var requestTimeoutMilliseconds3 = 15e3;
585
+ async function readApiUrl(commandArguments, environment) {
586
+ const { values } = parseArgs2({
587
+ // eslint-disable-next-line id-denylist -- "args" is the name node's parseArgs gives this option.
588
+ args: commandArguments,
589
+ options: { "api-url": { type: "string" }, help: { type: "boolean" } }
590
+ });
591
+ if (values.help) return { help: true };
592
+ const address = values["api-url"] ?? environment.variables.ACE_API_URL ?? await environment.lastApiUrl.read();
593
+ if (!address) {
594
+ environment.error(
595
+ "Give the API address: --api-url, or ACE_API_URL in the environment or a .env file. (Once you have signed in, that address is remembered.)"
596
+ );
597
+ return null;
598
+ }
599
+ try {
600
+ return { apiUrl: normaliseApiUrl(address) };
601
+ } catch (error) {
602
+ environment.error(error instanceof Error ? error.message : String(error));
603
+ return null;
604
+ }
605
+ }
606
+ function webAddress(address) {
607
+ try {
608
+ const parsed = new URL(address);
609
+ return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.href : null;
610
+ } catch {
611
+ return null;
612
+ }
613
+ }
614
+ var isLocal = (apiUrl) => ["localhost", "127.0.0.1", "[::1]"].includes(new URL(apiUrl).hostname);
615
+ async function loginCommand(commandArguments, environment) {
616
+ const read = await readApiUrl(commandArguments, environment);
617
+ if (!read) return 1;
618
+ if ("help" in read) {
619
+ environment.out(loginUsage);
620
+ return 0;
621
+ }
622
+ const { apiUrl } = read;
623
+ if (apiUrl.startsWith("http://") && !isLocal(apiUrl)) {
624
+ environment.error(
625
+ `Warning: ${apiUrl} is not encrypted (http://), so the login could be read on the way. Use https:// if you can.`
626
+ );
627
+ }
628
+ try {
629
+ const request = await startDeviceLogin({
630
+ fetchImplementation: environment.fetchImplementation,
631
+ apiUrl,
632
+ clientName: `ace-kit ${environment.version} on ${environment.hostname}`
633
+ });
634
+ const page = webAddress(request.verificationUri);
635
+ if (!page)
636
+ throw new DeviceLoginError(
637
+ "unsupported",
638
+ "The CMS sent an approval address that isn't a web address, so it was not opened."
639
+ );
640
+ environment.out(`Signing in to ${apiUrl}
641
+ `);
642
+ environment.out(` 1. Go to ${page}`);
643
+ environment.out(` 2. Type ${request.userCode}
644
+ `);
645
+ environment.out("Check the code on that page matches. Waiting for you to approve it (Ctrl-C to cancel)...");
646
+ await environment.openBrowser(page).catch(() => void 0);
647
+ const login = await waitForApproval({
648
+ fetchImplementation: environment.fetchImplementation,
649
+ apiUrl,
650
+ request,
651
+ sleep: environment.sleep,
652
+ now: environment.now
653
+ });
654
+ await environment.store.write(apiUrl, login);
655
+ await environment.lastApiUrl.write(apiUrl).catch(() => void 0);
656
+ environment.out(`
657
+ Signed in as ${login.email}. The login is kept in ${environment.store.description}.`);
658
+ return 0;
659
+ } catch (error) {
660
+ environment.error(`
661
+ ${error instanceof Error ? error.message : String(error)}`);
662
+ return 1;
663
+ }
664
+ }
665
+ async function logoutCommand(commandArguments, environment) {
666
+ const read = await readApiUrl(commandArguments, environment);
667
+ if (!read) return 1;
668
+ if ("help" in read) {
669
+ environment.out(loginUsage);
670
+ return 0;
671
+ }
672
+ const { apiUrl } = read;
673
+ const stored = await environment.store.read(apiUrl);
674
+ if (!stored) {
675
+ environment.out(`Not signed in to ${apiUrl}.`);
676
+ return 0;
677
+ }
678
+ const ended = await environment.fetchImplementation(`${apiUrl}/auth/logout`, {
679
+ method: "POST",
680
+ headers: { Authorization: `Bearer ${stored.token}` },
681
+ signal: AbortSignal.timeout(requestTimeoutMilliseconds3)
682
+ }).then(
683
+ (response) => response.ok || response.status === 401,
684
+ () => false
685
+ );
686
+ await environment.store.remove(apiUrl);
687
+ environment.out(
688
+ ended ? `Signed out of ${apiUrl}.` : `Forgot the login for ${apiUrl} on this machine, but could not reach the CMS to end it there. It ends by itself after 30 days unused; a super admin can also end it from the Sessions screen.`
689
+ );
690
+ return 0;
691
+ }
692
+ async function whoamiCommand(commandArguments, environment) {
693
+ const read = await readApiUrl(commandArguments, environment);
694
+ if (!read) return 1;
695
+ if ("help" in read) {
696
+ environment.out(loginUsage);
697
+ return 0;
698
+ }
699
+ const { apiUrl } = read;
700
+ const login = await resolveLogin({
701
+ apiUrl,
702
+ variables: environment.variables,
703
+ store: environment.store,
704
+ now: environment.now
705
+ });
706
+ if (!login) {
707
+ environment.out(`Not signed in to ${apiUrl}. Run: ace-kit login`);
708
+ return 1;
709
+ }
710
+ try {
711
+ const response = await environment.fetchImplementation(`${apiUrl}/auth/me`, {
712
+ headers: { Accept: "application/json", Authorization: `Bearer ${login.token}` },
713
+ signal: AbortSignal.timeout(requestTimeoutMilliseconds3)
714
+ });
715
+ if (response.status === 401) {
716
+ environment.out(`The login from ${login.source} has ended. Run: ace-kit login`);
717
+ return 1;
718
+ }
719
+ if (!response.ok) {
720
+ environment.error(`The CMS answered with an error (${response.status}).`);
721
+ return 1;
722
+ }
723
+ const me = await response.json();
724
+ environment.out(`Signed in to ${apiUrl} as ${String(me.email)}, using the login in ${login.source}.`);
725
+ return 0;
726
+ } catch {
727
+ environment.error(`Could not reach the CMS at ${apiUrl}.`);
728
+ return 1;
729
+ }
730
+ }
731
+
732
+ // src/cli/main.ts
733
+ var usage = `ace-kit: tools for sites built on CMS Ace.
734
+
735
+ Commands:
736
+ ace-kit generate Write TypeScript types for your content types.
737
+ ace-kit login Sign in from the command line (opens the admin app).
738
+ ace-kit logout Sign out.
739
+ ace-kit whoami Say who you are signed in as.
740
+
741
+ Run a command with --help for more.
742
+ `;
743
+ async function runCli(commandArguments, environment) {
744
+ const [command, ...rest] = commandArguments;
745
+ switch (command) {
746
+ case "generate":
747
+ return generateCommand(rest, environment);
748
+ case "login":
749
+ return loginCommand(rest, environment);
750
+ case "logout":
751
+ return logoutCommand(rest, environment);
752
+ case "whoami":
753
+ return whoamiCommand(rest, environment);
754
+ case "--help":
755
+ case "-h":
756
+ case "help":
757
+ case void 0:
758
+ environment.out(usage);
759
+ return command === void 0 ? 1 : 0;
760
+ default:
761
+ environment.error(`Unknown command "${command}".
762
+
763
+ ${usage}`);
764
+ return 1;
765
+ }
766
+ }
767
+
768
+ // src/cli/bin.ts
769
+ createRealEnvironment().then((environment) => runCli(process.argv.slice(2), environment)).then(
206
770
  (code) => process.exit(code),
207
771
  (error) => {
208
772
  console.error(error instanceof Error ? error.message : error);
package/dist/index.d.ts CHANGED
@@ -13,7 +13,7 @@ type AceClientOptions = {
13
13
  type AceFieldSchema = {
14
14
  handle: string;
15
15
  label: string;
16
- /** `text`, `textarea`, `select`, `number`, `checkbox`, `image` or `richtext`. */
16
+ /** `text`, `textarea`, `select`, `number`, `checkbox`, `image`, `richtext`, `date` or `link`. */
17
17
  component_key: string;
18
18
  config: { required?: boolean; options?: string[] } | null;
19
19
  };
@@ -178,6 +178,25 @@ type AceImageAttributes = {
178
178
  /** What to put on an `<img>`: a web-sized `src`, `srcset` when there are copies, and the size so the page doesn't jump. */
179
179
  declare function imageAttributes(image: AceImageData, { sizes, lazy }: AceImageOptions): AceImageAttributes;
180
180
 
181
+ type AceDateFormat = Intl.DateTimeFormatOptions & {
182
+ /** A language such as "en-GB". The default is the one the server or browser runs in. */
183
+ locale?: string;
184
+ };
185
+ /**
186
+ * A date field's day (a text such as "2026-10-13") as words, or null if it isn't a real day. A date field holds a
187
+ * day with no time of day, so this never shifts it with a time zone: 13 October is 13 October everywhere.
188
+ */
189
+ declare function formatAceDate(date: string, format?: AceDateFormat): string | null;
190
+
191
+ /**
192
+ * What a link field holds in an entry's values, as visitors get it: where it goes, already worked out (a link to a
193
+ * page of the site is its address, such as "/pages/about"), and the words it is shown with, if the editor gave any.
194
+ */
195
+ type AceLinkData = {
196
+ href: string;
197
+ label?: string;
198
+ };
199
+
181
200
  /**
182
201
  * Where a link in rich text goes, or null if it can't be trusted. The CMS only allows paths, web addresses,
183
202
  * mailto: and tel:; this checks again so the page stays safe even if something else ever gets into the text.
@@ -185,6 +204,16 @@ declare function imageAttributes(image: AceImageData, { sizes, lazy }: AceImageO
185
204
  declare function linkAddress(address: string): string | null;
186
205
  /** Whether a link stays on this site: a path such as "/pages/about", not "//elsewhere", "https://..." or "mailto:...". */
187
206
  declare function isInternalPath(address: string): boolean;
207
+ /** What to draw for a link field: where it goes and its words (null when the editor gave none). */
208
+ type AceLinkAttributes = {
209
+ href: string;
210
+ label: string | null;
211
+ };
212
+ /**
213
+ * What a link field's value is drawn with, or null if its address can't be trusted. The CMS only allows paths, web
214
+ * addresses, mailto: and tel:; this checks again so the page stays safe even if something else ever gets into the data.
215
+ */
216
+ declare function linkAttributes(link: AceLinkData): AceLinkAttributes | null;
188
217
 
189
218
  /** A CMS menu as links ready to draw. An entry link to the home entry goes to "/" so it isn't a redirect. */
190
219
  declare function linksFromMenu(menu: AceMenu): AceNavigationLink[];
@@ -308,4 +337,4 @@ declare const listStart: (node: AceRichTextNode) => number | undefined;
308
337
  */
309
338
  declare function renderRichText(document: AceRichTextDocument): string;
310
339
 
311
- export { type AceAddress, type AceClient, type AceClientOptions, type AceContentTypeHandle, type AceContentTypeSchema, type AceContentTypes, type AceEntry, type AceFieldSchema, type AceImageAttributes, type AceImageData, type AceImageOptions, type AceMenu, type AceMenuItem, type AceNavigationLink, type AcePage, AcePreviewExpiredError, type AceRichTextDocument, type AceRichTextMark, type AceRichTextNode, AceUnavailableError, type AceValuesOf, addFramingPolicy, addressFromPath, createAceClient, framingPolicy, headingLevel, homeAddress, imageAttributes, isCurrentPage, isHomeAddress, isInternalPath, isRefreshRequest, linkAddress, linksFromMenu, listStart, listenForPreviewRefresh, loadMenuLinks, loadPage, originOf, pageNotices, pathOfAddress, previewHeaders, previewParameter, previewTokenFrom, refreshMessageType, refreshPageContent, renderRichText, renderTextArea, richTextImageSizes, swapPageContent, visitablePath };
340
+ export { type AceAddress, type AceClient, type AceClientOptions, type AceContentTypeHandle, type AceContentTypeSchema, type AceContentTypes, type AceDateFormat, type AceEntry, type AceFieldSchema, type AceImageAttributes, type AceImageData, type AceImageOptions, type AceLinkAttributes, type AceLinkData, type AceMenu, type AceMenuItem, type AceNavigationLink, type AcePage, AcePreviewExpiredError, type AceRichTextDocument, type AceRichTextMark, type AceRichTextNode, AceUnavailableError, type AceValuesOf, addFramingPolicy, addressFromPath, createAceClient, formatAceDate, framingPolicy, headingLevel, homeAddress, imageAttributes, isCurrentPage, isHomeAddress, isInternalPath, isRefreshRequest, linkAddress, linkAttributes, linksFromMenu, listStart, listenForPreviewRefresh, loadMenuLinks, loadPage, originOf, pageNotices, pathOfAddress, previewHeaders, previewParameter, previewTokenFrom, refreshMessageType, refreshPageContent, renderRichText, renderTextArea, richTextImageSizes, swapPageContent, visitablePath };
package/dist/index.js CHANGED
@@ -1,6 +1,24 @@
1
1
  export { pageNotices } from './chunk-XPZY5R5A.js';
2
- export { headingLevel, imageAttributes, isInternalPath, linkAddress, listStart, renderRichText, renderTextArea, richTextImageSizes } from './chunk-URLKW2YS.js';
2
+ export { headingLevel, imageAttributes, isInternalPath, linkAddress, linkAttributes, listStart, renderRichText, renderTextArea, richTextImageSizes } from './chunk-FVR55GIU.js';
3
3
  export { listenForPreviewRefresh, refreshPageContent } from './chunk-YN7LJWGI.js';
4
4
  export { AcePreviewExpiredError, AceUnavailableError, createAceClient, isCurrentPage, linksFromMenu, loadMenuLinks, loadPage } from './chunk-XYOW63V6.js';
5
5
  export { addressFromPath, homeAddress, isHomeAddress, pathOfAddress, visitablePath } from './chunk-M5WR5TCR.js';
6
6
  export { addFramingPolicy, framingPolicy, isRefreshRequest, originOf, previewHeaders, previewParameter, previewTokenFrom, refreshMessageType, swapPageContent } from './chunk-YLO6IAXF.js';
7
+
8
+ // src/helpers/dates.ts
9
+ var defaultFormat = { dateStyle: "long" };
10
+ function formatAceDate(date, format = {}) {
11
+ const parts = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
12
+ if (!parts) return null;
13
+ const [year, month, day] = [Number(parts[1]), Number(parts[2]), Number(parts[3])];
14
+ const moment = new Date(Date.UTC(year, month - 1, day));
15
+ if (moment.getUTCFullYear() !== year || moment.getUTCMonth() !== month - 1 || moment.getUTCDate() !== day)
16
+ return null;
17
+ const { locale, ...options } = format;
18
+ return new Intl.DateTimeFormat(locale, {
19
+ ...Object.keys(options).length > 0 ? options : defaultFormat,
20
+ timeZone: "UTC"
21
+ }).format(moment);
22
+ }
23
+
24
+ export { formatAceDate };
@@ -75,7 +75,7 @@ type AceClientOptions = {
75
75
  type AceFieldSchema = {
76
76
  handle: string;
77
77
  label: string;
78
- /** `text`, `textarea`, `select`, `number`, `checkbox`, `image` or `richtext`. */
78
+ /** `text`, `textarea`, `select`, `number`, `checkbox`, `image`, `richtext`, `date` or `link`. */
79
79
  component_key: string;
80
80
  config: { required?: boolean; options?: string[] } | null;
81
81
  };
@@ -29,6 +29,25 @@ type AceImageProps = AceImageOptions & {
29
29
  /** An image from the CMS as an `<img>` with a web-sized `src`, `srcset`, its size and its alt text. */
30
30
  declare function AceImage({ image, sizes, lazy, ...rest }: AceImageProps): react.JSX.Element;
31
31
 
32
+ /**
33
+ * What a link field holds in an entry's values, as visitors get it: where it goes, already worked out (a link to a
34
+ * page of the site is its address, such as "/pages/about"), and the words it is shown with, if the editor gave any.
35
+ */
36
+ type AceLinkData = {
37
+ href: string;
38
+ label?: string;
39
+ };
40
+
41
+ /**
42
+ * A link field, drawn with React Router's `Link` for a page of this site. Everything else is as `AceLink` from
43
+ * `@sandboxsonny/ace-kit/react`: its words by default, nothing drawn for an address that can't be trusted.
44
+ */
45
+ declare function AceLink({ link, children, className, }: {
46
+ link: AceLinkData;
47
+ children?: React.ReactNode;
48
+ className?: string;
49
+ }): react.JSX.Element;
50
+
32
51
  type AceTextAreaProps = {
33
52
  /** The text area field's value. Missing or blank draws nothing. */
34
53
  value: string | undefined;
@@ -152,4 +171,4 @@ type AceRootData = {
152
171
  };
153
172
  };
154
173
 
155
- export { AceImage, AceNotice, AceRichText, type AceRootData, AceTextArea, createMeta, meta, useAceMenu, useAcePreview };
174
+ export { AceImage, AceLink, AceNotice, AceRichText, type AceRootData, AceTextArea, createMeta, meta, useAceMenu, useAcePreview };
@@ -1,14 +1,22 @@
1
1
  import { pageNotices } from './chunk-XPZY5R5A.js';
2
- import { AceRichText, usePreviewRefresh } from './chunk-KEV3YAWN.js';
3
- export { AceImage, AceTextArea } from './chunk-KEV3YAWN.js';
4
- import { isInternalPath } from './chunk-URLKW2YS.js';
2
+ import { AceLink, AceRichText, usePreviewRefresh } from './chunk-2MXND4HG.js';
3
+ export { AceImage, AceTextArea } from './chunk-2MXND4HG.js';
4
+ import { isInternalPath } from './chunk-FVR55GIU.js';
5
5
  import './chunk-YN7LJWGI.js';
6
6
  import './chunk-M5WR5TCR.js';
7
7
  import './chunk-YLO6IAXF.js';
8
8
  import { Link, useRevalidator, useRouteLoaderData } from 'react-router';
9
- import { jsxs, jsx } from 'react/jsx-runtime';
9
+ import { jsx, jsxs } from 'react/jsx-runtime';
10
10
  import { useCallback } from 'react';
11
11
 
12
+ var RouterLink = ({ href, children, className }) => isInternalPath(href) ? /* @__PURE__ */ jsx(Link, { to: href, className, children }) : /* @__PURE__ */ jsx("a", { href, className, children });
13
+ function AceLink2({
14
+ link,
15
+ children,
16
+ className
17
+ }) {
18
+ return /* @__PURE__ */ jsx(AceLink, { link, className, as: RouterLink, children });
19
+ }
12
20
  function AceNotice({ page, className }) {
13
21
  const { heading, message } = pageNotices[page.kind];
14
22
  return /* @__PURE__ */ jsxs("div", { className, children: [
@@ -17,9 +25,9 @@ function AceNotice({ page, className }) {
17
25
  page.kind === "preview-expired" ? null : /* @__PURE__ */ jsx(Link, { to: "/", children: "Back to the home page" })
18
26
  ] });
19
27
  }
20
- var RouterLink = ({ href, children }) => isInternalPath(href) ? /* @__PURE__ */ jsx(Link, { to: href, children }) : /* @__PURE__ */ jsx("a", { href, children });
28
+ var RouterLink2 = ({ href, children }) => isInternalPath(href) ? /* @__PURE__ */ jsx(Link, { to: href, children }) : /* @__PURE__ */ jsx("a", { href, children });
21
29
  function AceRichText2({ document }) {
22
- return /* @__PURE__ */ jsx(AceRichText, { document, link: RouterLink });
30
+ return /* @__PURE__ */ jsx(AceRichText, { document, link: RouterLink2 });
23
31
  }
24
32
 
25
33
  // src/react-router/meta.ts
@@ -52,4 +60,4 @@ function useAcePreview() {
52
60
  usePreviewRefresh(useAceRootData()?.previewAdminOrigin, refresh);
53
61
  }
54
62
 
55
- export { AceNotice, AceRichText2 as AceRichText, createMeta, meta, useAceMenu, useAcePreview };
63
+ export { AceLink2 as AceLink, AceNotice, AceRichText2 as AceRichText, createMeta, meta, useAceMenu, useAcePreview };
package/dist/react.d.ts CHANGED
@@ -29,6 +29,15 @@ type AceImageProps = AceImageOptions & {
29
29
  /** An image from the CMS as an `<img>` with a web-sized `src`, `srcset`, its size and its alt text. */
30
30
  declare function AceImage({ image, sizes, lazy, ...rest }: AceImageProps): react.JSX.Element;
31
31
 
32
+ /**
33
+ * What a link field holds in an entry's values, as visitors get it: where it goes, already worked out (a link to a
34
+ * page of the site is its address, such as "/pages/about"), and the words it is shown with, if the editor gave any.
35
+ */
36
+ type AceLinkData = {
37
+ href: string;
38
+ label?: string;
39
+ };
40
+
32
41
  /** A mark on a piece of text. A link's address is already worked out: an entry link comes as its path. */
33
42
  type AceRichTextMark = { type: "bold" } | { type: "italic" } | { type: "link"; attrs: { href: string } };
34
43
 
@@ -51,6 +60,7 @@ type AceRichTextDocument = { type: "doc"; content?: AceRichTextNode[] };
51
60
  type AceRichTextLink = ComponentType<{
52
61
  href: string;
53
62
  children: ReactNode;
63
+ className?: string;
54
64
  }>;
55
65
  type AceRichTextProps = {
56
66
  document: AceRichTextDocument;
@@ -63,6 +73,18 @@ type AceRichTextProps = {
63
73
  */
64
74
  declare function AceRichText({ document, link }: AceRichTextProps): react.JSX.Element;
65
75
 
76
+ type AceLinkProps = {
77
+ /** A link field's value: `entry.values.button`. */
78
+ link: AceLinkData;
79
+ /** What to show instead of the editor's words, which are the default (or the address when there are none). */
80
+ children?: ReactNode;
81
+ className?: string;
82
+ /** Draws the link; the default is a plain `<a>`. Give one that uses your router's link to keep navigation client-side. */
83
+ as?: AceRichTextLink;
84
+ };
85
+ /** A link field, drawn as a link. Nothing is drawn if the address can't be trusted. */
86
+ declare function AceLink({ link, children, className, as: Link }: AceLinkProps): react.JSX.Element | null;
87
+
66
88
  type AceTextAreaProps = {
67
89
  /** The text area field's value. Missing or blank draws nothing. */
68
90
  value: string | undefined;
@@ -85,4 +107,4 @@ declare function AceTextArea({ value, multiline }: AceTextAreaProps): react.JSX.
85
107
  */
86
108
  declare function usePreviewRefresh(adminOrigin: string | null | undefined, refresh?: () => Promise<void>): void;
87
109
 
88
- export { AceImage, AceRichText, type AceRichTextLink, AceTextArea, usePreviewRefresh };
110
+ export { AceImage, AceLink, AceRichText, type AceRichTextLink, AceTextArea, usePreviewRefresh };
package/dist/react.js CHANGED
@@ -1,5 +1,5 @@
1
- export { AceImage, AceRichText, AceTextArea, usePreviewRefresh } from './chunk-KEV3YAWN.js';
2
- import './chunk-URLKW2YS.js';
1
+ export { AceImage, AceLink, AceRichText, AceTextArea, usePreviewRefresh } from './chunk-2MXND4HG.js';
2
+ import './chunk-FVR55GIU.js';
3
3
  import './chunk-YN7LJWGI.js';
4
4
  import './chunk-M5WR5TCR.js';
5
5
  import './chunk-YLO6IAXF.js';
@@ -29,6 +29,13 @@ Everything below is exported from `@sandboxsonny/ace-kit` unless a path is given
29
29
  | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
30
30
  | `imageAttributes(image, { sizes, lazy? })` | The attributes for an `<img>`: `src`, `srcSet`, `sizes`, `width`, `height`, `alt`, `loading`, `decoding`, `fetchPriority`. |
31
31
 
32
+ ## Links and dates
33
+
34
+ | | |
35
+ | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
36
+ | `linkAttributes(link)` | What to draw for a link field: `{ href, label }` (`label` is `null` when the editor gave no words), or `null` if the address can't be trusted. |
37
+ | `formatAceDate(date, format?)` | A date field's day (`"2026-10-13"`) as words, in the language asked for (`{ locale: "en-GB" }`), or `null` if it isn't a real day. Takes the options of `Intl.DateTimeFormat`; the default is `dateStyle: "long"`. A day has no time of day, so it never shifts with a time zone. |
38
+
32
39
  ## Menus
33
40
 
34
41
  | | |
@@ -60,7 +67,7 @@ Everything below is exported from `@sandboxsonny/ace-kit` unless a path is given
60
67
 
61
68
  ## Types
62
69
 
63
- `AceEntry<Values>`, `AceImageData`, `AceMenu`, `AceMenuItem`, `AceNavigationLink`, `AceRichTextDocument`, `AceRichTextNode`, `AceRichTextMark`, `AceContentTypeSchema`, `AceFieldSchema`, `AceAddress`, `AcePage`, `AceClient`, `AceClientOptions`, `AceImageAttributes`, `AceImageOptions`, and the ones generation fills in: `AceContentTypes`, `AceContentTypeHandle`, `AceValuesOf`.
70
+ `AceEntry<Values>`, `AceImageData`, `AceLinkData`, `AceLinkAttributes`, `AceDateFormat`, `AceMenu`, `AceMenuItem`, `AceNavigationLink`, `AceRichTextDocument`, `AceRichTextNode`, `AceRichTextMark`, `AceContentTypeSchema`, `AceFieldSchema`, `AceAddress`, `AcePage`, `AceClient`, `AceClientOptions`, `AceImageAttributes`, `AceImageOptions`, and the ones generation fills in: `AceContentTypes`, `AceContentTypeHandle`, `AceValuesOf`.
64
71
 
65
72
  ## Astro
66
73
 
@@ -76,7 +83,7 @@ Everything below is exported from `@sandboxsonny/ace-kit` unless a path is given
76
83
  | `applyPage(response, page)` | Puts a page's status and headers on a response. |
77
84
  | `ace` | The client these use. |
78
85
 
79
- Components, by file: `AceImage.astro`, `AceRichText.astro`, `AceTextArea.astro`, `AceMenu.astro`, `AceNotice.astro`, `AcePreviewRefresh.astro`.
86
+ Components, by file: `AceImage.astro`, `AceLink.astro`, `AceRichText.astro`, `AceTextArea.astro`, `AceMenu.astro`, `AceNotice.astro`, `AcePreviewRefresh.astro`.
80
87
 
81
88
  ## React Router 7
82
89
 
@@ -84,11 +91,11 @@ Components, by file: `AceImage.astro`, `AceRichText.astro`, `AceTextArea.astro`,
84
91
 
85
92
  `@sandboxsonny/ace-kit/react-router` (browser-safe):
86
93
 
87
- | | |
88
- | ----------------------------------------------------- | -------------------------------------------------------------------- |
89
- | `AceRichText`, `AceTextArea`, `AceImage`, `AceNotice` | Components; rich text links are router links. |
90
- | `createMeta({ siteName? })`, `meta` | The route's `meta`. |
91
- | `useAceMenu()`, `useAcePreview()` | Read the header menu; keep a preview fresh. Need `createRootLoader`. |
94
+ | | |
95
+ | ---------------------------------------------------------------- | --------------------------------------------------------------------------------- |
96
+ | `AceRichText`, `AceTextArea`, `AceImage`, `AceLink`, `AceNotice` | Components; rich text links and `AceLink` are router links for pages of the site. |
97
+ | `createMeta({ siteName? })`, `meta` | The route's `meta`. |
98
+ | `useAceMenu()`, `useAcePreview()` | Read the header menu; keep a preview fresh. Need `createRootLoader`. |
92
99
 
93
100
  `@sandboxsonny/ace-kit/react-router/server` (server only):
94
101
 
@@ -102,4 +109,4 @@ Components, by file: `AceImage.astro`, `AceRichText.astro`, `AceTextArea.astro`,
102
109
 
103
110
  ## React (`@sandboxsonny/ace-kit/react`)
104
111
 
105
- `AceImage`, `AceRichText` (with `link`), `usePreviewRefresh(adminOrigin, refresh?)`.
112
+ `AceImage`, `AceLink` (with `as`), `AceRichText` (with `link`), `usePreviewRefresh(adminOrigin, refresh?)`.
package/docs/astro.md CHANGED
@@ -69,6 +69,7 @@ Import them by file:
69
69
  ```astro
70
70
  ---
71
71
  import AceImage from "@sandboxsonny/ace-kit/astro/AceImage.astro";
72
+ import AceLink from "@sandboxsonny/ace-kit/astro/AceLink.astro";
72
73
  import AceRichText from "@sandboxsonny/ace-kit/astro/AceRichText.astro";
73
74
  import AceTextArea from "@sandboxsonny/ace-kit/astro/AceTextArea.astro";
74
75
  import AceMenu from "@sandboxsonny/ace-kit/astro/AceMenu.astro";
@@ -78,11 +79,13 @@ import AcePreviewRefresh from "@sandboxsonny/ace-kit/astro/AcePreviewRefresh.ast
78
79
  <AceMenu handle="main-menu" />
79
80
  <AceImage image={entry.values.hero} sizes="(min-width: 64rem) 64rem, 100vw" lazy={false} />
80
81
  <AceRichText document={entry.values.body} />
82
+ <AceLink link={entry.values.button} class="button" />
81
83
  <AcePreviewRefresh />
82
84
  ```
83
85
 
84
86
  - **AceMenu** draws a whole menu as a `<nav>` with nested lists, marking the current page with `aria-current="page"`. Props: `handle`, `fallback`, `label` (what a screen reader calls it) and `class`. It is deliberately unstyled (`.ace-menu`, `.ace-menu__list`, `.ace-menu__sublist` are the hooks); to control the markup, use `getMenuLinks` and draw it yourself.
85
87
  - **AceImage** writes an `<img>` with `src`, `srcset`, `sizes`, `width`, `height`, `alt`, `loading` and `decoding`. Set `sizes` to how wide the image is shown, and `lazy={false}` for the image at the top of the page.
88
+ - **AceLink** draws a link field as an `<a>` with the editor's words (or the address when there are none; put your own words in the slot to replace them). Nothing is drawn if the address can't be trusted, so check for a missing link yourself if you need a fallback. A date field is plain text: `formatAceDate(entry.values.published, { locale: "en-GB" })` gives "13 October 2026", and `<time datetime={entry.values.published}>` takes the raw value.
86
89
  - **AceRichText** draws a rich text field as HTML. It outputs bare elements (`h2`, `p`, `ul`, `a`...); style them from a wrapping element. Astro scopes styles, so reach them with `:global`, e.g. `.rich-text :global(h2) { ... }`.
87
90
  - **AceTextArea** draws a Textarea field (plain text): `multiline` (the default) makes paragraphs of blocks and a `<br>` of a new line; `multiline={false}` is one line of text with no element around it.
88
91
  - **AcePreviewRefresh** goes once in your layout. It does nothing unless the page was opened by the CMS's editor.
@@ -0,0 +1,51 @@
1
+ # The command line
2
+
3
+ `ace-kit` is also a command. It signs you in to CMS Ace from your terminal, and writes types for your content.
4
+
5
+ ```sh
6
+ npx ace-kit login # sign in
7
+ npx ace-kit whoami # who am I signed in as?
8
+ npx ace-kit logout # sign out
9
+ npx ace-kit generate # write TypeScript types (see Typed content)
10
+ ```
11
+
12
+ Every command takes `--api-url`, or reads `ACE_API_URL` from the environment or a `.env` file in the folder you run it in. Failing both, it uses the address you last signed in to (kept in a small file beside your logins), so after `ace-kit login --api-url …` you can simply run `ace-kit whoami` from anywhere.
13
+
14
+ ## Signing in
15
+
16
+ ```sh
17
+ npx ace-kit login --api-url http://localhost:8080
18
+ ```
19
+
20
+ ```
21
+ Signing in to http://localhost:8080
22
+
23
+ 1. Go to http://localhost:5173/device
24
+ 2. Type BCDF-GHJK
25
+
26
+ Check the code on that page matches. Waiting for you to approve it (Ctrl-C to cancel)...
27
+ ```
28
+
29
+ Your browser opens the CMS admin app's approval page. **Type the code from your terminal** into it (it is deliberately not in the link), check that the tool and the address it shows are yours, and choose **Approve**. The terminal then says who you are signed in as. This is the OAuth device flow, the same shape as `gh auth login`: no password is ever typed into the terminal, and nothing secret is written in a file inside your project.
30
+
31
+ Only approve a login you started just now, from your own terminal. If the code on the page doesn't match the one in your terminal, or you didn't ask for a login, choose **Turn it down**.
32
+
33
+ ## Where the login is kept
34
+
35
+ - **On a Mac:** the macOS Keychain, as an item named `ace-kit` whose account is the API address. You can see it in Keychain Access. Ace Kit uses the `security` program that comes with macOS, so nothing native is installed, and the login is handed to it on standard input, never as an argument other users of the machine could read.
36
+ - **Anywhere else:** a file only you can read, `~/.config/ace-kit/credentials.json` (mode `0600`, in a folder of mode `0700`; `%APPDATA%\ace-kit` on Windows). Set `XDG_CONFIG_HOME` to move it.
37
+ - Set `ACE_KIT_CREDENTIALS=file` to use the file even on a Mac.
38
+
39
+ One login is kept per API address, so you can be signed in to a local CMS and a hosted one at once.
40
+
41
+ ## What a command-line login can do
42
+
43
+ Much less than you can in the browser. It can see which sites you can reach, read their content types, and read and sync their section types (the last only on a site where you are an owner, or a super admin). It cannot manage members, passwords or other logins, read or change entries and images, or approve another command-line login. It lasts 90 days at most and ends after 30 days unused. A super admin sees it on the Sessions screen, named after your machine, and can end it from there.
44
+
45
+ ## Build servers
46
+
47
+ A build server has nobody to approve a login in a browser. Set `ACE_TOKEN` to a token instead and Ace Kit uses it in place of a stored login. (Tokens for this are not made yet; for now sign in on the machine that needs it.)
48
+
49
+ ## Signing out
50
+
51
+ `npx ace-kit logout` ends the login at the CMS and forgets it on this machine. If the CMS can't be reached it still forgets it here, and says so.
@@ -22,7 +22,7 @@ npm install @sandboxsonny/ace-kit
22
22
  | `@sandboxsonny/ace-kit/react-router` | React Router 7, browser-safe: `AceRichText` (router links), `AceImage`, `AceNotice`, `createMeta`, `useAceMenu`, `useAcePreview` |
23
23
  | `@sandboxsonny/ace-kit/react-router/server` | React Router 7, server only: `createPageLoader`, `createRootLoader`, `handleRequest` (the server entry), `getEntry`, `getEntries`, `getMenuLinks`, `getAceClient` |
24
24
  | `@sandboxsonny/ace-kit/vite` | A Vite plugin: reads `ACE_` settings from `.env` in development and keeps one copy of React |
25
- | `ace-kit` (command) | `ace-kit generate`, which writes TypeScript types for your content |
25
+ | `ace-kit` (command) | `ace-kit generate` (TypeScript types for your content), `login`, `logout`, `whoami` (see [the command line](./command-line.md)) |
26
26
 
27
27
  ## The client
28
28
 
@@ -93,6 +93,7 @@ export default function App() {
93
93
  From `@sandboxsonny/ace-kit/react-router` (these are safe in the browser):
94
94
 
95
95
  - **AceRichText** draws a rich text field. Links to pages of this site are router `Link`s, so navigating doesn't reload the page. It renders bare elements; style them from a wrapper.
96
+ - **AceLink** draws a link field. A link to a page of this site is a router `Link`; any other is a plain `<a>`. It shows the editor's words (or the address), or your children instead, and draws nothing for an address that can't be trusted. Dates are text: format them with `formatAceDate` from `@sandboxsonny/ace-kit`.
96
97
  - **AceTextArea** draws a Textarea field (plain text): `multiline` (the default) makes paragraphs and line breaks, `multiline={false}` is one line of text.
97
98
  - **AceImage** is an `<img>` with `src`, `srcset`, `sizes`, size and alt text. Set `sizes` to how wide it is shown, and `lazy={false}` for the image at the top of the page.
98
99
  - **AceNotice** is the heading and message for a page that isn't an entry. Give it a `className`.
package/docs/react.md CHANGED
@@ -3,7 +3,7 @@
3
3
  Needs `react` 18 or newer. The components work with any React framework (React Router, Next.js, Remix, plain Vite). **On React Router 7, use the [React Router adapter](./react-router.md) instead of wiring this up yourself.** Fetch with the [client](./getting-started.md#the-client) on the server (a loader, a server component, `getServerSideProps`), then draw.
4
4
 
5
5
  ```tsx
6
- import { AceImage, AceRichText } from "@sandboxsonny/ace-kit/react";
6
+ import { AceImage, AceLink, AceRichText } from "@sandboxsonny/ace-kit/react";
7
7
  ```
8
8
 
9
9
  ## AceImage
@@ -14,6 +14,18 @@ import { AceImage, AceRichText } from "@sandboxsonny/ace-kit/react";
14
14
 
15
15
  An `<img>` with `src`, `srcset`, `sizes`, `width`, `height` and `alt`. Any other `<img>` prop (`className`, `style`, `onLoad`...) is passed through. `lazy` defaults to `true`; turn it off for the image at the top of the page.
16
16
 
17
+ ## AceLink
18
+
19
+ ```tsx
20
+ {
21
+ entry.values.button ? <AceLink link={entry.values.button} className="button" /> : null;
22
+ }
23
+ ```
24
+
25
+ A link field as an `<a>` with the editor's words, or the address when there are none; children replace the words. Nothing is drawn if the address can't be trusted. Give it your router's link with `as` so navigation stays in the browser (`as={({ href, children, className }) => <Link to={href} className={className}>{children}</Link>}`).
26
+
27
+ A date field is text: `formatAceDate(entry.values.published, { locale: "en-GB" })` gives "13 October 2026" whatever the server's time zone, and `<time dateTime={entry.values.published}>` takes the raw value.
28
+
17
29
  ## AceRichText
18
30
 
19
31
  ```tsx
@@ -35,15 +35,17 @@ about?.values.headline; // string
35
35
  cms.getEntry("pagez", "about"); // type error: no such content type
36
36
  ```
37
37
 
38
- | Field | Type |
39
- | -------------------------------- | --------------------------------------------------- |
40
- | Text, Textarea | `string` |
41
- | Number | `number` |
42
- | Checkbox | `boolean` |
43
- | Select | a union of its options, e.g. `"formal" \| "casual"` |
44
- | Image | `AceImageData` |
45
- | Rich text | `AceRichTextDocument` |
46
- | a kind this version doesn't know | `unknown` |
38
+ | Field | Type |
39
+ | -------------------------------- | ---------------------------------------------------------- |
40
+ | Text, Textarea | `string` |
41
+ | Number | `number` |
42
+ | Checkbox | `boolean` |
43
+ | Select | a union of its options, e.g. `"formal" \| "casual"` |
44
+ | Image | `AceImageData` |
45
+ | Rich text | `AceRichTextDocument` |
46
+ | Date | `string`, a day written `YYYY-MM-DD` (see `formatAceDate`) |
47
+ | Link | `AceLinkData`: `{ href, label? }` |
48
+ | a kind this version doesn't know | `unknown` |
47
49
 
48
50
  A field is optional unless it is marked required in the CMS. Before you generate, handles are plain `string` and values are `Record<string, unknown>`, so nothing breaks.
49
51
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sandboxsonny/ace-kit",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "Everything a website needs to use CMS Ace: a typed client, addressing, images, menus, rich text, live preview, and Astro and React adapters.",
5
5
  "keywords": [
6
6
  "cms",