@backmatter/core 0.0.22 → 0.0.24

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@backmatter/core",
3
- "version": "0.0.22",
3
+ "version": "0.0.24",
4
4
  "description": "Experimental Backmatter SDK. APIs will break. Not ready for production.",
5
5
  "type": "module",
6
6
  "publishConfig": {
package/src/fields.ts CHANGED
@@ -4,6 +4,7 @@ export type FieldKind =
4
4
  | "text"
5
5
  | "locale"
6
6
  | "image"
7
+ | "media"
7
8
  | "relation"
8
9
  | "number"
9
10
  | "boolean"
@@ -78,6 +79,7 @@ export const field = {
78
79
  text: <const R extends boolean = false>(opts?: Opts<R>) => base("text", opts),
79
80
  locale: <const R extends boolean = false>(opts?: Opts<R>) => base("locale", opts),
80
81
  image: <const R extends boolean = false>(opts?: Opts<R>) => base("image", opts),
82
+ media: <const R extends boolean = false>(opts?: Opts<R>) => base("media", opts),
81
83
  number: <const R extends boolean = false>(opts?: Opts<R>) => base("number", opts),
82
84
  boolean: <const R extends boolean = false>(opts?: Opts<R>) => base("boolean", opts),
83
85
  relation: <To extends string, const R extends boolean = false>(to: To, opts?: Opts<R>) =>
@@ -117,6 +119,7 @@ export function parseFieldSnapshot(input: unknown): FieldSnapshot {
117
119
  "text",
118
120
  "locale",
119
121
  "image",
122
+ "media",
120
123
  "relation",
121
124
  "number",
122
125
  "boolean",
package/src/index.ts CHANGED
@@ -8,6 +8,19 @@ export type {
8
8
  FieldSnapshot,
9
9
  } from "./fields";
10
10
  export { field, serializeField, parseFieldSnapshot } from "./fields";
11
+ export {
12
+ MAX_MEDIA_BYTES,
13
+ MEDIA_CONTENT_TYPES,
14
+ MEDIA_WIDTHS,
15
+ isMediaContentType,
16
+ isMediaId,
17
+ isMediaWidth,
18
+ mediaIdFromUrl,
19
+ mediaOriginalUrl,
20
+ mediaVariantUrl,
21
+ } from "./media-url";
22
+ export type { MediaContentType, MediaWidth } from "./media-url";
23
+ export { mediaIdsInDocument } from "./media-fields";
11
24
 
12
25
  export type { ObjectDef, ObjectSnapshot } from "./object";
13
26
  export { defineObject, serializeObject, parseObjectSnapshot } from "./object";
@@ -136,6 +149,7 @@ export {
136
149
  PREVIEW_ENABLE_PATH,
137
150
  PREVIEW_QUERY,
138
151
  PREVIEW_CHECKOUT_QUERY,
152
+ PREVIEW_LOCALE_QUERY,
139
153
  previewLocationWithCheckout,
140
154
  isSafeRedirectPath,
141
155
  mintPreviewSession,
@@ -0,0 +1,55 @@
1
+ // packages/core/src/media-fields.ts
2
+ import type { FieldSnapshot } from "./fields";
3
+ import { mediaIdFromUrl } from "./media-url";
4
+
5
+ type BlockCatalog = Array<{ name: string; fields: Record<string, FieldSnapshot> }>;
6
+
7
+ /** Media ids referenced by media fields in a document or block props. */
8
+ export function mediaIdsInDocument(
9
+ fields: Record<string, FieldSnapshot>,
10
+ data: Record<string, unknown>,
11
+ blocks: BlockCatalog = [],
12
+ ): string[] {
13
+ const ids: string[] = [];
14
+ collect(fields, data, blocks, ids);
15
+ return ids;
16
+ }
17
+
18
+ function collect(
19
+ fields: Record<string, FieldSnapshot>,
20
+ data: Record<string, unknown>,
21
+ blocks: BlockCatalog,
22
+ ids: string[],
23
+ ) {
24
+ for (const [name, field] of Object.entries(fields)) {
25
+ const value = data[name];
26
+ if (value == null) continue;
27
+ if (field.kind === "media") {
28
+ if (typeof value === "string") {
29
+ const id = mediaIdFromUrl(value);
30
+ if (id) ids.push(id);
31
+ }
32
+ continue;
33
+ }
34
+ if (field.kind === "blocks" && Array.isArray(value)) {
35
+ for (const item of value) {
36
+ if (!item || typeof item !== "object") continue;
37
+ const row = item as { type?: unknown; props?: unknown };
38
+ const def = blocks.find((block) => block.name === row.type);
39
+ if (!def || !row.props || typeof row.props !== "object") continue;
40
+ collect(def.fields, row.props as Record<string, unknown>, blocks, ids);
41
+ }
42
+ continue;
43
+ }
44
+ if (field.kind === "object" && field.fields && value && typeof value === "object") {
45
+ collect(field.fields, value as Record<string, unknown>, blocks, ids);
46
+ }
47
+ if (field.kind === "array" && field.fields && Array.isArray(value)) {
48
+ for (const item of value) {
49
+ if (item && typeof item === "object") {
50
+ collect(field.fields, item as Record<string, unknown>, blocks, ids);
51
+ }
52
+ }
53
+ }
54
+ }
55
+ }
@@ -0,0 +1,67 @@
1
+ // packages/core/src/media-url.ts
2
+
3
+ /** Widths the catalog lists for the gallery. A public URL may use any positive width. */
4
+ export const MEDIA_WIDTHS = [400, 800, 1200, 2000] as const;
5
+
6
+ export type MediaWidth = (typeof MEDIA_WIDTHS)[number];
7
+
8
+ /** Bytes the catalog accepts for one file. */
9
+ export const MAX_MEDIA_BYTES = 20 * 1024 * 1024;
10
+
11
+ export const MEDIA_CONTENT_TYPES = [
12
+ "image/jpeg",
13
+ "image/png",
14
+ "image/webp",
15
+ "image/gif",
16
+ "image/avif",
17
+ ] as const;
18
+
19
+ export type MediaContentType = (typeof MEDIA_CONTENT_TYPES)[number];
20
+
21
+ const MEDIA_ID =
22
+ /^med_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
23
+
24
+ export function isMediaId(value: string): boolean {
25
+ return MEDIA_ID.test(value);
26
+ }
27
+
28
+ export function isMediaContentType(value: string): value is MediaContentType {
29
+ return (MEDIA_CONTENT_TYPES as readonly string[]).includes(value);
30
+ }
31
+
32
+ export function mediaOriginalUrl(origin: string, id: string): string {
33
+ return `${origin.replace(/\/$/, "")}/img/${id}`;
34
+ }
35
+
36
+ /** A width variant, or null when the id or width cannot be requested. */
37
+ export function mediaVariantUrl(
38
+ origin: string,
39
+ id: string,
40
+ width: number,
41
+ ): string | null {
42
+ if (!isMediaId(id) || !Number.isInteger(width) || width < 1) return null;
43
+ return `${origin.replace(/\/$/, "")}/img/${id}/width=${width},format=auto`;
44
+ }
45
+
46
+ export function isMediaWidth(width: number): width is MediaWidth {
47
+ return (MEDIA_WIDTHS as readonly number[]).includes(width);
48
+ }
49
+
50
+ /**
51
+ * The id inside an original media URL (`/img/med_…`, no options segment).
52
+ * A transform URL and any other URL return null.
53
+ */
54
+ export function mediaIdFromUrl(value: string): string | null {
55
+ let url: URL;
56
+ try {
57
+ url = new URL(value);
58
+ } catch {
59
+ return null;
60
+ }
61
+ if (url.protocol !== "https:" && url.protocol !== "http:") return null;
62
+ if (url.search || url.hash) return null;
63
+ const parts = url.pathname.split("/").filter(Boolean);
64
+ if (parts.length !== 2 || parts[0] !== "img") return null;
65
+ const id = parts[1]!;
66
+ return isMediaId(id) ? id : null;
67
+ }
package/src/node/cli.ts CHANGED
@@ -18,6 +18,7 @@ import { discoverModels, discoverBlocks } from "./discover";
18
18
  import { walkContentFiles } from "./fs-loader";
19
19
  import { detectSdk, findConfigFile, generate, type GenerateResult } from "./generate";
20
20
  import { ensureRegistryTsconfig, REGISTRY_SPECIFIER } from "./registry-import";
21
+ import { MEDIA_USAGE, runMediaCli } from "./media-cli";
21
22
 
22
23
  export type CliArgs = {
23
24
  command?: string;
@@ -33,6 +34,10 @@ const USAGE = `Usage:
33
34
  backmatter generate [--root <dir>] write .backmatter/index.ts (typed cms), blocks.ts, mdx.ts, layouts.ts and sources.ts
34
35
  backmatter validate [--root <dir>] [--models <file>] [--json]
35
36
  backmatter schema [--root <dir>] [--models <file>] [--out <dir>]
37
+ backmatter login
38
+ backmatter media upload|list|url
39
+
40
+ ${MEDIA_USAGE}
36
41
 
37
42
  Defaults: --root . --out .backmatter/schemas
38
43
  Models are discovered from models/*.ts (one \`export default defineModel(...)\` per
@@ -550,6 +555,9 @@ export async function runInit(args: CliArgs): Promise<string[]> {
550
555
  }
551
556
 
552
557
  async function main(argv: string[]): Promise<number> {
558
+ if (argv[0] === "login" || argv[0] === "media") {
559
+ return runMediaCli(argv);
560
+ }
553
561
  let args: CliArgs;
554
562
  try {
555
563
  args = parseArgs(argv);
@@ -0,0 +1,211 @@
1
+ // packages/core/src/node/media-cli.ts
2
+ import { spawn } from "node:child_process";
3
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { mediaOriginalUrl, mediaVariantUrl } from "../media-url";
7
+
8
+ export const MEDIA_USAGE = `Usage:
9
+ backmatter login [--api <url>] [--web <url>]
10
+ backmatter media upload <file...> --workspace <id> [--api <url>]
11
+ backmatter media list [--q <text>] --workspace <id> [--api <url>]
12
+ backmatter media url <id> [--width <px>] [--origin <url>]
13
+
14
+ login opens the browser and stores a personal token in ~/.backmatter/credentials.json.
15
+ media url prints a CDN url. --width <px> appends width and format=auto.`;
16
+
17
+ type Credentials = { api: string; token: string; origin?: string };
18
+
19
+ export async function runMediaCli(
20
+ argv: string[],
21
+ io: {
22
+ log?: (line: string) => void;
23
+ error?: (line: string) => void;
24
+ open?: (url: string) => void;
25
+ fetch?: typeof fetch;
26
+ home?: string;
27
+ env?: Record<string, string | undefined>;
28
+ } = {},
29
+ ): Promise<number> {
30
+ const log = io.log ?? ((line) => console.log(line));
31
+ const error = io.error ?? ((line) => console.error(line));
32
+ const home = io.home ?? os.homedir();
33
+ const env = io.env ?? process.env;
34
+ try {
35
+ const command = argv[0];
36
+ if (command === "login") return await login(argv.slice(1), { log, error, open: io.open, fetch: io.fetch, home, env });
37
+ if (command === "media") return await media(argv.slice(1), { log, error, fetch: io.fetch, home, env });
38
+ error(MEDIA_USAGE);
39
+ return 2;
40
+ } catch (err) {
41
+ error(`backmatter: ${err instanceof Error ? err.message : String(err)}`);
42
+ return 2;
43
+ }
44
+ }
45
+
46
+ async function login(
47
+ argv: string[],
48
+ io: {
49
+ log: (line: string) => void;
50
+ error: (line: string) => void;
51
+ open?: (url: string) => void;
52
+ fetch?: typeof fetch;
53
+ home: string;
54
+ env: Record<string, string | undefined>;
55
+ },
56
+ ): Promise<number> {
57
+ const flags = readFlags(argv);
58
+ const api = (flags.api || io.env.BACKMATTER_API_URL || "http://localhost:3001").replace(/\/$/, "");
59
+ const web = (flags.web || io.env.BACKMATTER_WEB_ORIGIN || "http://localhost:5173").replace(/\/$/, "");
60
+ const pollSecret = crypto.randomUUID() + crypto.randomUUID();
61
+ const fetchImpl = io.fetch ?? fetch;
62
+ const started = await fetchImpl(`${api}/cli/login`, {
63
+ method: "POST",
64
+ headers: { "content-type": "application/json" },
65
+ body: JSON.stringify({ pollSecret }),
66
+ });
67
+ if (!started.ok) throw new Error(`login failed: ${started.status}`);
68
+ const body = (await started.json()) as { id: string; authorizeUrl: string };
69
+ const authorizeUrl = body.authorizeUrl || `${web}/cli/authorize?id=${encodeURIComponent(body.id)}`;
70
+ (io.open ?? openBrowser)(authorizeUrl);
71
+ io.error(`Open ${authorizeUrl} and approve the CLI.`);
72
+ const deadline = Date.now() + 10 * 60 * 1000;
73
+ while (Date.now() < deadline) {
74
+ await delay(1000);
75
+ const polled = await fetchImpl(`${api}/cli/login/${body.id}`, {
76
+ headers: { "x-backmatter-poll": pollSecret },
77
+ });
78
+ if (!polled.ok) continue;
79
+ const status = (await polled.json()) as { status: string; token?: string };
80
+ if (status.status === "ready" && status.token) {
81
+ await writeCredentials(io.home, { api, token: status.token });
82
+ io.log("Logged in.");
83
+ return 0;
84
+ }
85
+ }
86
+ throw new Error("login timed out");
87
+ }
88
+
89
+ async function media(
90
+ argv: string[],
91
+ io: {
92
+ log: (line: string) => void;
93
+ error: (line: string) => void;
94
+ fetch?: typeof fetch;
95
+ home: string;
96
+ env: Record<string, string | undefined>;
97
+ },
98
+ ): Promise<number> {
99
+ const sub = argv[0];
100
+ const rest = argv.slice(1);
101
+ const flags = readFlags(rest);
102
+ const positionals = rest.filter((arg, index) => {
103
+ if (arg.startsWith("--")) return false;
104
+ const prev = rest[index - 1];
105
+ return prev !== "--workspace" && prev !== "--api" && prev !== "--q" && prev !== "--width" && prev !== "--origin";
106
+ });
107
+ if (sub === "url") {
108
+ const id = positionals[0];
109
+ if (!id) throw new Error("usage: backmatter media url <id>");
110
+ const origin = flags.origin || "https://cdn.backmatter.app";
111
+ if (flags.width) {
112
+ const width = Number(flags.width);
113
+ const url = mediaVariantUrl(origin, id, width);
114
+ if (!url) throw new Error("width must be a positive integer");
115
+ io.log(url);
116
+ return 0;
117
+ }
118
+ io.log(mediaOriginalUrl(origin, id));
119
+ return 0;
120
+ }
121
+ const creds = await readCredentials(io.home);
122
+ const api = (flags.api || creds?.api || io.env.BACKMATTER_API_URL || "").replace(/\/$/, "");
123
+ const token = creds?.token;
124
+ const workspace = flags.workspace || io.env.BACKMATTER_WORKSPACE;
125
+ if (!api || !token) throw new Error("run backmatter login first");
126
+ if (!workspace) throw new Error("pass --workspace <id>");
127
+ const fetchImpl = io.fetch ?? fetch;
128
+ if (sub === "list") {
129
+ const params = new URLSearchParams();
130
+ if (flags.q) params.set("q", flags.q);
131
+ const query = params.size ? `?${params}` : "";
132
+ const res = await fetchImpl(`${api}/workspaces/${workspace}/media${query}`, {
133
+ headers: { authorization: `Bearer ${token}` },
134
+ });
135
+ if (!res.ok) throw new Error(`list failed: ${res.status}`);
136
+ const page = (await res.json()) as { total: number; media: Array<{ id: string; filename: string; url: string }> };
137
+ io.log(`${page.total} image${page.total === 1 ? "" : "s"}`);
138
+ for (const item of page.media) io.log(`${item.filename}\t${item.url}`);
139
+ return 0;
140
+ }
141
+ if (sub === "upload") {
142
+ if (positionals.length === 0) throw new Error("usage: backmatter media upload <file...>");
143
+ for (const file of positionals) {
144
+ const bytes = await readFile(file);
145
+ const body = new FormData();
146
+ body.set("file", new File([bytes], path.basename(file), { type: contentTypeFor(file) }));
147
+ const res = await fetchImpl(`${api}/workspaces/${workspace}/media`, {
148
+ method: "POST",
149
+ headers: { authorization: `Bearer ${token}` },
150
+ body,
151
+ });
152
+ if (!res.ok) throw new Error(`upload failed for ${file}: ${res.status}`);
153
+ const payload = (await res.json()) as { media: { url: string; filename: string } };
154
+ io.log(`${payload.media.filename}\t${payload.media.url}`);
155
+ }
156
+ return 0;
157
+ }
158
+ io.error(MEDIA_USAGE);
159
+ return 2;
160
+ }
161
+
162
+ function contentTypeFor(file: string): string {
163
+ const ext = path.extname(file).toLowerCase();
164
+ if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
165
+ if (ext === ".webp") return "image/webp";
166
+ if (ext === ".gif") return "image/gif";
167
+ if (ext === ".avif") return "image/avif";
168
+ return "image/png";
169
+ }
170
+
171
+ function readFlags(argv: string[]): Record<string, string> {
172
+ const flags: Record<string, string> = {};
173
+ for (let i = 0; i < argv.length; i++) {
174
+ const arg = argv[i]!;
175
+ if (!arg.startsWith("--")) continue;
176
+ const key = arg.slice(2);
177
+ const value = argv[i + 1];
178
+ if (value === undefined || value.startsWith("--")) throw new Error(`usage: missing value for --${key}`);
179
+ flags[key] = value;
180
+ i += 1;
181
+ }
182
+ return flags;
183
+ }
184
+
185
+ function credentialsPath(home: string): string {
186
+ return path.join(home, ".backmatter", "credentials.json");
187
+ }
188
+
189
+ async function readCredentials(home: string): Promise<Credentials | null> {
190
+ try {
191
+ return JSON.parse(await readFile(credentialsPath(home), "utf8")) as Credentials;
192
+ } catch {
193
+ return null;
194
+ }
195
+ }
196
+
197
+ async function writeCredentials(home: string, creds: Credentials): Promise<void> {
198
+ const file = credentialsPath(home);
199
+ await mkdir(path.dirname(file), { recursive: true });
200
+ await writeFile(file, JSON.stringify(creds, null, 2));
201
+ }
202
+
203
+ function openBrowser(url: string) {
204
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
205
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
206
+ spawn(command, args, { stdio: "ignore", detached: true }).unref();
207
+ }
208
+
209
+ function delay(ms: number): Promise<void> {
210
+ return new Promise((resolve) => setTimeout(resolve, ms));
211
+ }
@@ -7,6 +7,8 @@ export const PREVIEW_SESSION_QUERY = "backmatter_session";
7
7
  export const PREVIEW_SESSION_STORAGE_KEY = "backmatter_preview_session";
8
8
  export const PREVIEW_QUERY = "backmatter_preview";
9
9
  export const PREVIEW_CHECKOUT_QUERY = "backmatter_checkout";
10
+ /** File locale for routes whose template has no `{locale}`. `-` means none. */
11
+ export const PREVIEW_LOCALE_QUERY = "backmatter_locale";
10
12
  export const PREVIEW_ENABLE_PATH = "/api/backmatter/preview";
11
13
  export const PREVIEW_TICKET_TTL_MS = 10 * 60 * 1000;
12
14
  export const PREVIEW_COOKIE_TTL_MS = 12 * 60 * 60 * 1000;
package/src/preview.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  // packages/core/src/preview.ts
2
2
  import type { BackmatterDoc, BackmatterDocSummary } from "./doc";
3
+ import { encodeDocLocale } from "./path";
3
4
 
4
5
  export type PreviewDocRef = {
5
6
  locale: string;
@@ -106,6 +107,7 @@ export async function fetchHostPreviewSession(origin = ""): Promise<HostPreviewS
106
107
  if (!res.ok) return { enabled: false, reason: "unavailable" };
107
108
  const data = (await res.json()) as {
108
109
  mode?: string;
110
+ signedIn?: boolean;
109
111
  branch?: string;
110
112
  tree?: string;
111
113
  reason?: unknown;
@@ -196,7 +198,10 @@ function previewParams(options: PreviewClientOptions, ref: PreviewDocRef) {
196
198
  }
197
199
 
198
200
  function docPath(options: PreviewClientOptions, ref: PreviewDocRef) {
199
- return `${baseUrl(options.apiUrl)}/preview/workspaces/${options.workspaceId}/docs/${ref.locale}/${ref.type}/${ref.slug}`;
201
+ const locale = encodeURIComponent(encodeDocLocale(ref.locale));
202
+ const type = encodeURIComponent(ref.type);
203
+ const slug = encodeURIComponent(ref.slug);
204
+ return `${baseUrl(options.apiUrl)}/preview/workspaces/${options.workspaceId}/docs/${locale}/${type}/${slug}`;
200
205
  }
201
206
 
202
207
  export function previewDocRequestUrl(
package/src/validate.ts CHANGED
@@ -33,6 +33,8 @@ export function validateFrontmatter(
33
33
  const missing = data[name] === undefined;
34
34
  if (issue.code === "too_small" || (issue.code === "invalid_type" && missing)) {
35
35
  errors.push(`${name} is required`);
36
+ } else if (field.kind === "media" && !missing) {
37
+ errors.push(`${name} must be a media URL`);
36
38
  } else {
37
39
  errors.push(`${name} must be a ${expectedTypeFor(field.kind)}`);
38
40
  }
package/src/zod.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // packages/core/src/zod.ts
2
2
  import { z } from "zod";
3
3
  import type { FieldKind, FieldSnapshot } from "./fields";
4
+ import { mediaIdFromUrl } from "./media-url";
4
5
  import type { Frontmatter } from "./mdx";
5
6
  import type { ModelSnapshot } from "./model";
6
7
 
@@ -21,6 +22,7 @@ export function expectedTypeFor(
21
22
  case "text":
22
23
  case "locale":
23
24
  case "image":
25
+ case "media":
24
26
  case "relation":
25
27
  return "string";
26
28
  default: {
@@ -44,6 +46,8 @@ export function exampleValueFor(kind: FieldKind): unknown {
44
46
  return {};
45
47
  case "image":
46
48
  return "https://example.com/image.jpg";
49
+ case "media":
50
+ return "https://cdn.backmatter.app/img/med_018f6b3e-7c2a-7b1d-8e4f-9a0b1c2d3e4f";
47
51
  case "locale":
48
52
  return "en";
49
53
  case "relation":
@@ -84,6 +88,15 @@ function fieldZod(field: FieldSnapshot): z.ZodType {
84
88
  const arr = z.array(objectZod(field));
85
89
  return field.required ? arr : arr.optional();
86
90
  }
91
+ if (field.kind === "media") {
92
+ const url = z.string().refine((value) => value.trim() === "" || mediaIdFromUrl(value) !== null);
93
+ if (!field.required) return url.optional();
94
+ return z
95
+ .string()
96
+ .trim()
97
+ .min(1)
98
+ .refine((value) => mediaIdFromUrl(value) !== null);
99
+ }
87
100
  let base: z.ZodType;
88
101
  switch (expectedTypeFor(field.kind)) {
89
102
  case "number":