vestrycms 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -132,6 +132,16 @@ shows both and the editor's account never needs push access.
132
132
  "d1_databases": [{ "binding": "VESTRY_DB", "database_name": "my-site-vestry" }]
133
133
  ```
134
134
 
135
+ Several sites can share one D1 database (handy on the free plan's database
136
+ limit): give each a different `auth.tablePrefix` (lowercase, e.g. `"mysite_"`), and bind
137
+ the same `database_id` as `VESTRY_DB` in each site's `wrangler.jsonc`. Each
138
+ site then gets its own tables (`mysite_user`, `mysite_session`, …), so users,
139
+ roles and sessions stay separate. Without a prefix the tables keep Better
140
+ Auth's plain names, so don't let two sites share a database unprefixed: they
141
+ would share one user list, and anyone invited to one could sign in to both.
142
+ Changing the prefix on a live site starts it with an empty user list (config
143
+ admins can still sign in and invite everyone again).
144
+
135
145
  In `astro dev` no sign-in is needed by default: the panel edits the project
136
146
  directory and you commit with git. `VESTRY_MODE=auth` keeps the local source
137
147
  but requires sign-in, with users in git-ignored `.vestry/auth.sqlite`, for
@@ -326,14 +336,39 @@ pnpm link ../vestrycms
326
336
 
327
337
  ### Tests
328
338
 
339
+ Tests use [Vitest](https://vitest.dev). Files matching `test/**/*.test.ts`
340
+ run in CI; `pnpm typecheck` covers everything under `test/`.
341
+
342
+ ```sh
343
+ pnpm test # run the suite once
344
+ pnpm test:watch # re-run on change
345
+ pnpm test mdx # only files whose path matches "mdx"
346
+ pnpm test -u # update snapshots after an intended change
347
+ ```
348
+
349
+ - `test/auth.test.ts`: sign-in, users and guards over HTTP (memory adapter).
350
+ - `test/shared-database.test.ts`: `auth.tablePrefix` keeping sites apart in one
351
+ database (`node:sqlite`, real migrations).
352
+ - `test/github-source.test.ts`: commit sequencing against a fake Git Data API.
353
+ - `test/github-app.test.ts`: GitHub App JWT and installation token cache.
354
+ - `test/mdx.test.ts`: MDX round trips. Each file in `test/fixtures/mdx` must
355
+ survive parse → serialize byte for byte, so write fixtures in the
356
+ serializer's canonical style (`_emphasis_`, `-` bullets, blank lines around
357
+ component children). Prettier skips that directory. Record a known gap as
358
+ an `it.fails` test and move it into the fixtures once fixed.
359
+ - `test/editor-schema.test.ts`: the same fixtures pass through the editor's
360
+ Tiptap schema (`src/ui/components/editor/schema.ts`) without losing node
361
+ types or attributes, so a page saved from the editor matches its source.
362
+
363
+ The auth and GitHub source tests are ordered scenarios that build on shared
364
+ state, so filter them by file, not by test name (`-t`).
365
+
366
+ `test/manual` holds scripts that run outside the suite:
367
+
329
368
  ```sh
330
- pnpm test # github-mock, auth, github-app
331
- node --import tsx/esm test/auth.ts # sign-in, users and guards over HTTP (memory adapter)
332
- node --import tsx/esm test/github-app.ts # GitHub App JWT + installation token cache
333
- node --import tsx/esm test/roundtrip.ts path/to/file.mdx # MDX round-trip
334
- node --import tsx/esm test/github-mock.ts # commit sequencing against a fake API
335
- GH_TOKEN=… node --import tsx/esm test/github-read.ts # live read path
336
- GH_TOKEN=… node --import tsx/esm test/github-write.ts # live commits on a throwaway branch
369
+ pnpm test:roundtrip path/to/file.mdx # round-trip a site's content through Prettier
370
+ GH_TOKEN=… node --import tsx/esm test/manual/github-read.ts path/to/site # time the live read path
371
+ GH_TOKEN=… node --import tsx/esm test/manual/github-write.ts # live commits on a throwaway branch
337
372
  ```
338
373
 
339
374
  ### Releasing
@@ -366,6 +366,15 @@ export interface AuthConfig {
366
366
  * be removed or demoted from the control panel.
367
367
  */
368
368
  admins?: string[];
369
+ /**
370
+ * Prefix for the sign-in tables (`user`, `session`, `account`,
371
+ * `verification`, `rateLimit`), e.g. `mysite_` for `mysite_user`. Lets
372
+ * several sites share one D1 database, each with its own users and
373
+ * sessions. Lowercase letters, digits and underscores, starting with a
374
+ * letter (table names ignore case, so `Site_` would clash with `site_`).
375
+ * Changing it on a live site starts it with an empty user list.
376
+ */
377
+ tablePrefix?: string;
369
378
  }
370
379
  export interface MailConfig {
371
380
  /**
package/dist/core/api.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { isAPIError } from "better-auth/api";
2
- import { createVestryAuth, nameFromEmail, normalizeEmail, } from "./auth.js";
2
+ import { createVestryAuth, nameFromEmail, normalizeEmail, tablePrefixError, } from "./auth.js";
3
3
  import { MailError, consoleMailer, resendMailer } from "./mail.js";
4
4
  import { isRole } from "./roles.js";
5
5
  import { ContentService } from "./service.js";
@@ -115,6 +115,10 @@ export function createApi(options) {
115
115
  const secret = resolveSecret();
116
116
  if (typeof secret !== "string")
117
117
  return secret;
118
+ const tablePrefix = config.auth?.tablePrefix;
119
+ const prefixError = tablePrefixError(tablePrefix);
120
+ if (prefixError)
121
+ return { error: prefixError };
118
122
  // Invitation links point at the configured site, not whatever host the
119
123
  // inviting admin happened to use (a preview or workers.dev URL).
120
124
  const siteOrigin = !options.auth?.dev && config.siteUrl
@@ -132,6 +136,7 @@ export function createApi(options) {
132
136
  admins: config.auth?.admins ?? [],
133
137
  dev: options.auth?.dev,
134
138
  migrate: options.auth?.migrate,
139
+ tablePrefix,
135
140
  });
136
141
  if (authByOrigin.size >= MAX_ORIGINS) {
137
142
  const oldest = authByOrigin.keys().next().value;
@@ -33,12 +33,18 @@ export interface VestryAuthOptions {
33
33
  dev?: boolean;
34
34
  /** Create or update the tables on first use (Kysely-backed databases only). */
35
35
  migrate?: boolean;
36
+ /** Prefix for the table names, so several sites can share one database. */
37
+ tablePrefix?: string;
36
38
  }
37
39
  export declare const SESSION_TTL_SECONDS: number;
38
40
  export declare const CODE_TTL_SECONDS: number;
39
41
  export declare function normalizeEmail(email: string): string;
40
42
  /** Display name fallback: the part before the `@`, title-cased a little. */
41
43
  export declare function nameFromEmail(email: string): string;
44
+ /** The tables Better Auth (with Vestry's plugins) keeps. */
45
+ export declare const AUTH_TABLES: readonly ["user", "session", "account", "verification", "rateLimit"];
46
+ /** Why a `tablePrefix` is unusable, or undefined when it is fine. */
47
+ export declare function tablePrefixError(prefix: string | undefined): string | undefined;
42
48
  export declare function createVestryAuth(opts: VestryAuthOptions): {
43
49
  auth: import("better-auth").Auth<{
44
50
  appName: string;
@@ -49,10 +55,30 @@ export declare function createVestryAuth(opts: VestryAuthOptions): {
49
55
  session: {
50
56
  expiresIn: number;
51
57
  updateAge: number;
58
+ modelName: string;
59
+ } | {
60
+ expiresIn: number;
61
+ updateAge: number;
62
+ modelName?: undefined;
63
+ };
64
+ account: {
65
+ modelName: string;
66
+ } | {
67
+ modelName?: undefined;
68
+ };
69
+ verification: {
70
+ modelName: string;
71
+ } | {
72
+ modelName?: undefined;
52
73
  };
53
74
  rateLimit: {
54
75
  enabled: boolean;
55
76
  storage: "database";
77
+ modelName: string;
78
+ } | {
79
+ enabled: boolean;
80
+ storage: "database";
81
+ modelName?: undefined;
56
82
  };
57
83
  advanced: {
58
84
  cookiePrefix: string;
@@ -72,6 +98,16 @@ export declare function createVestryAuth(opts: VestryAuthOptions): {
72
98
  input: false;
73
99
  };
74
100
  };
101
+ modelName: string;
102
+ } | {
103
+ additionalFields: {
104
+ lastLoginAt: {
105
+ type: "date";
106
+ required: false;
107
+ input: false;
108
+ };
109
+ };
110
+ modelName?: undefined;
75
111
  };
76
112
  databaseHooks: {
77
113
  session: {
package/dist/core/auth.js CHANGED
@@ -41,14 +41,36 @@ export function nameFromEmail(email) {
41
41
  // Commit authors need a non-empty name; fall back to the address itself.
42
42
  return pretty || local || email;
43
43
  }
44
+ /** The tables Better Auth (with Vestry's plugins) keeps. */
45
+ export const AUTH_TABLES = [
46
+ "user",
47
+ "session",
48
+ "account",
49
+ "verification",
50
+ "rateLimit",
51
+ ];
52
+ // Lowercase only: SQLite (and so D1) table names ignore case, so `Site_`
53
+ // and `site_` would silently name the same tables.
54
+ const TABLE_PREFIX = /^[a-z][a-z0-9_]*$/;
55
+ /** Why a `tablePrefix` is unusable, or undefined when it is fine. */
56
+ export function tablePrefixError(prefix) {
57
+ if (prefix === undefined || prefix === "" || TABLE_PREFIX.test(prefix))
58
+ return undefined;
59
+ return `auth.tablePrefix ${JSON.stringify(prefix)} must be lowercase letters, digits and underscores, starting with a letter.`;
60
+ }
44
61
  /**
45
- * Migrations run once per database per isolate, whichever origin asked
46
- * first (the instances per origin share the database).
62
+ * Migrations run once per database and table prefix per isolate, whichever
63
+ * origin asked first (the instances per origin share the database).
47
64
  */
48
65
  const migratedByDatabase = new WeakMap();
49
66
  export function createVestryAuth(opts) {
50
67
  const admins = (opts.admins ?? []).map(normalizeEmail);
51
68
  const isConfigAdmin = (email) => admins.includes(normalizeEmail(email));
69
+ const prefixError = tablePrefixError(opts.tablePrefix);
70
+ if (prefixError)
71
+ throw new Error(prefixError);
72
+ const prefix = opts.tablePrefix ?? "";
73
+ const modelName = (table) => prefix ? { modelName: `${prefix}${table}` } : {};
52
74
  const auth = betterAuth({
53
75
  appName: opts.siteName,
54
76
  secret: opts.secret,
@@ -56,10 +78,14 @@ export function createVestryAuth(opts) {
56
78
  basePath: opts.basePath,
57
79
  database: opts.database,
58
80
  session: {
81
+ ...modelName("session"),
59
82
  expiresIn: SESSION_TTL_SECONDS,
60
83
  updateAge: 60 * 60 * 24,
61
84
  },
85
+ account: modelName("account"),
86
+ verification: modelName("verification"),
62
87
  rateLimit: {
88
+ ...modelName("rateLimit"),
63
89
  enabled: !opts.dev,
64
90
  storage: "database",
65
91
  },
@@ -75,6 +101,7 @@ export function createVestryAuth(opts) {
75
101
  },
76
102
  },
77
103
  user: {
104
+ ...modelName("user"),
78
105
  additionalFields: {
79
106
  lastLoginAt: { type: "date", required: false, input: false },
80
107
  },
@@ -153,7 +180,10 @@ export function createVestryAuth(opts) {
153
180
  if (opts.migrate === false)
154
181
  return Promise.resolve();
155
182
  const key = opts.database;
156
- const pending = migratedByDatabase.get(key);
183
+ let byPrefix = migratedByDatabase.get(key);
184
+ if (!byPrefix)
185
+ migratedByDatabase.set(key, (byPrefix = new Map()));
186
+ const pending = byPrefix.get(prefix);
157
187
  if (pending)
158
188
  return pending;
159
189
  const run = (async () => {
@@ -170,10 +200,10 @@ export function createVestryAuth(opts) {
170
200
  throw e;
171
201
  }
172
202
  })().catch((e) => {
173
- migratedByDatabase.delete(key);
203
+ byPrefix.delete(prefix);
174
204
  throw e;
175
205
  });
176
- migratedByDatabase.set(key, run);
206
+ byPrefix.set(prefix, run);
177
207
  return run;
178
208
  };
179
209
  /** The `PublicUser` view of a Better Auth user record. */
package/dist/mdx/parse.js CHANGED
@@ -68,13 +68,14 @@ function block(node, ctx) {
68
68
  };
69
69
  case "list": {
70
70
  const items = node.children.map((item) => listItem(item, ctx));
71
+ const spread = Boolean(node.spread);
71
72
  return node.ordered
72
73
  ? {
73
74
  type: "orderedList",
74
- attrs: { start: node.start ?? 1 },
75
+ attrs: { start: node.start ?? 1, spread },
75
76
  content: items,
76
77
  }
77
- : { type: "bulletList", content: items };
78
+ : { type: "bulletList", attrs: { spread }, content: items };
78
79
  }
79
80
  case "code":
80
81
  return {
@@ -87,8 +88,13 @@ function block(node, ctx) {
87
88
  case "table":
88
89
  return table(node, ctx);
89
90
  case "definition":
90
- case "footnoteDefinition":
91
91
  return null;
92
+ case "footnoteDefinition":
93
+ return {
94
+ type: "footnoteDefinition",
95
+ attrs: footnoteAttrs(node),
96
+ content: ensureBlocks(blocks(node.children, ctx)),
97
+ };
92
98
  case "mdxJsxFlowElement":
93
99
  return jsxElement(node, ctx, false);
94
100
  case "mdxFlowExpression":
@@ -107,6 +113,9 @@ function block(node, ctx) {
107
113
  function ensureBlocks(content) {
108
114
  return content.length ? content : [{ type: "paragraph" }];
109
115
  }
116
+ function footnoteAttrs(node) {
117
+ return { identifier: node.identifier, label: node.label ?? node.identifier };
118
+ }
110
119
  function listItem(item, ctx) {
111
120
  const content = ensureBlocks(blocks(item.children, ctx));
112
121
  return {
@@ -135,51 +144,65 @@ function table(node, ctx) {
135
144
  }
136
145
  function esm(node, ctx) {
137
146
  const program = node.data?.estree;
138
- const leftovers = [];
139
- if (program) {
140
- for (const stmt of program.body) {
141
- if (stmt.type === "ImportDeclaration" &&
142
- typeof stmt.source.value === "string") {
143
- let recognized = true;
144
- for (const spec of stmt.specifiers) {
145
- if (spec.type === "ImportDefaultSpecifier") {
146
- ctx.imports[spec.local.name] = {
147
- name: spec.local.name,
148
- from: stmt.source.value,
149
- };
150
- }
151
- else if (spec.type === "ImportSpecifier") {
152
- const imported = spec.imported.type === "Identifier"
153
- ? spec.imported.name
154
- : String(spec.imported.value);
155
- ctx.imports[spec.local.name] = {
156
- name: spec.local.name,
157
- from: stmt.source.value,
158
- named: imported === spec.local.name ? true : undefined,
159
- };
160
- if (imported !== spec.local.name)
161
- recognized = false;
162
- }
163
- else {
164
- recognized = false;
165
- }
166
- }
167
- if (recognized && stmt.specifiers.length)
168
- continue;
169
- }
170
- const start = stmt.start;
171
- const end = stmt.end;
172
- leftovers.push(typeof start === "number" && typeof end === "number"
173
- ? node.value.slice(start, end)
174
- : node.value);
175
- }
147
+ // Estree offsets count from the start of the file, not of this block.
148
+ const base = node.position?.start.offset;
149
+ if (!program || typeof base !== "number")
150
+ return { type: "mdxEsm", attrs: { value: node.value } };
151
+ // Cut recognized imports out of the block's source; everything else,
152
+ // comments and blank lines included, stays as written.
153
+ let rest = "";
154
+ let pos = 0;
155
+ for (const stmt of program.body) {
156
+ const { start, end } = stmt;
157
+ if (typeof start !== "number" || typeof end !== "number")
158
+ continue;
159
+ if (!recordImport(stmt, ctx))
160
+ continue;
161
+ rest += node.value.slice(pos, start - base);
162
+ pos = end - base;
163
+ // Drop the rest of the line when only whitespace follows the import.
164
+ const eol = /^[ \t]*(\r?\n|$)/.exec(node.value.slice(pos));
165
+ if (eol)
166
+ pos += eol[0].length;
176
167
  }
177
- else {
178
- leftovers.push(node.value);
168
+ rest = (rest + node.value.slice(pos)).trim();
169
+ return rest ? { type: "mdxEsm", attrs: { value: rest } } : null;
170
+ }
171
+ /**
172
+ * Record an import statement's bindings if all of them can be re-emitted from
173
+ * ImportSpecs; the caller then drops the statement. Otherwise it is kept
174
+ * verbatim.
175
+ */
176
+ function recordImport(stmt, ctx) {
177
+ if (stmt.type !== "ImportDeclaration" ||
178
+ typeof stmt.source.value !== "string")
179
+ return false;
180
+ const from = stmt.source.value;
181
+ const specs = [];
182
+ for (const spec of stmt.specifiers) {
183
+ if (spec.type === "ImportDefaultSpecifier") {
184
+ specs.push({ name: spec.local.name, from });
185
+ }
186
+ else if (spec.type === "ImportSpecifier") {
187
+ const imported = spec.imported.type === "Identifier"
188
+ ? spec.imported.name
189
+ : String(spec.imported.value);
190
+ specs.push({
191
+ name: spec.local.name,
192
+ from,
193
+ named: true,
194
+ ...(imported === spec.local.name ? {} : { imported }),
195
+ });
196
+ }
197
+ else {
198
+ return false;
199
+ }
179
200
  }
180
- if (!leftovers.length)
181
- return null;
182
- return { type: "mdxEsm", attrs: { value: leftovers.join("\n") } };
201
+ if (!specs.length)
202
+ return false;
203
+ for (const spec of specs)
204
+ ctx.imports[spec.name] = spec;
205
+ return true;
183
206
  }
184
207
  function attrsToProps(attributes) {
185
208
  const props = {};
@@ -337,7 +360,7 @@ function inlineNode(node, ctx, marks) {
337
360
  return [{ type: "rawInlineHtml", attrs: { value: node.value } }];
338
361
  case "footnoteReference":
339
362
  return [
340
- withMarks({ type: "text", text: `[^${node.identifier}]` }, marks),
363
+ withMarks({ type: "footnoteReference", attrs: footnoteAttrs(node) }, marks),
341
364
  ];
342
365
  case "mdxJsxTextElement": {
343
366
  const converted = jsxElement(node, ctx, true);
@@ -1,10 +1,21 @@
1
1
  import { gfmToMarkdown } from "mdast-util-gfm";
2
2
  import { mdxToMarkdown } from "mdast-util-mdx";
3
- import { toMarkdown } from "mdast-util-to-markdown";
3
+ import { defaultHandlers, toMarkdown, } from "mdast-util-to-markdown";
4
4
  import { isPlainString, valueToSource } from "./expressions.js";
5
+ /**
6
+ * Note every identifier-like word in a piece of JS source. Words inside
7
+ * strings or comments count too, which at worst keeps an unused import.
8
+ */
9
+ function scan(source, used) {
10
+ for (const m of source.matchAll(IDENTIFIERS))
11
+ used.identifiers.add(m[0]);
12
+ }
13
+ const IDENTIFIER = "[\\p{ID_Start}$_][\\p{ID_Continue}$\\u200c\\u200d]*";
14
+ const IDENTIFIERS = new RegExp(IDENTIFIER, "gu");
15
+ const IS_IDENTIFIER = new RegExp(`^${IDENTIFIER}$`, "u");
5
16
  export function serializeMdx(doc, options = {}) {
6
17
  const isMdx = options.format !== "md";
7
- const used = new Set();
18
+ const used = { components: new Set(), identifiers: new Set() };
8
19
  const root = {
9
20
  type: "root",
10
21
  children: blocks(doc.content ?? [], used),
@@ -32,6 +43,7 @@ export function serializeMdx(doc, options = {}) {
32
43
  rawHtml: (node) => node.value,
33
44
  rawInlineHtml: (node) => node.value,
34
45
  ...(libFlow ? { mdxJsxFlowElement: flowElementHandler(libFlow) } : {}),
46
+ list: listHandler(defaultHandlers.list),
35
47
  },
36
48
  });
37
49
  // Place a blank line after the import block like Prettier/MDX conventions.
@@ -72,20 +84,63 @@ function flowElementHandler(lib) {
72
84
  return `${open}\n\n${children}\n\n</${el.name ?? ""}>`;
73
85
  };
74
86
  }
87
+ /**
88
+ * A tight list is written without blank lines, but some neighbouring blocks
89
+ * need one to stay apart (two paragraphs would merge, kind 7 HTML would join
90
+ * the paragraph before it, ...), and a blank line anywhere in a list makes it
91
+ * loose when read back. Ask the serializer's own join rules whether any
92
+ * neighbours in the list or its items need a blank line, and if so write the
93
+ * whole list loose.
94
+ */
95
+ function listHandler(lib) {
96
+ return (node, parent, state, info) => {
97
+ const list = node;
98
+ if (!list.spread &&
99
+ [list, ...list.children].some((p) => needsBlankLine(p, state))) {
100
+ list.spread = true;
101
+ for (const item of list.children)
102
+ item.spread = true;
103
+ }
104
+ return lib(node, parent, state, info);
105
+ };
106
+ }
107
+ /** Mirrors mdast-util-to-markdown's `between`: tight only if a join says 0. */
108
+ function needsBlankLine(parent, state) {
109
+ const children = parent.children;
110
+ for (let i = 1; i < children.length; i++) {
111
+ let index = state.join.length;
112
+ let lines;
113
+ while (index--) {
114
+ const result = state.join[index](children[i - 1], children[i], parent, state);
115
+ if (result === true || result === 1)
116
+ break;
117
+ if (typeof result === "number" || result === false) {
118
+ lines = result === false ? 1 : result;
119
+ break;
120
+ }
121
+ }
122
+ if (lines !== 0)
123
+ return true;
124
+ }
125
+ return false;
126
+ }
75
127
  function importStatements(used, options) {
76
128
  const lines = new Map();
77
- for (const name of used) {
129
+ const add = (name, spec) => {
130
+ if (spec)
131
+ lines.set(name, importLine(spec));
132
+ };
133
+ // Components get the registry's import, falling back to the source's.
134
+ for (const name of used.components) {
78
135
  const reg = options.components?.[name];
79
- const spec = reg?.import
136
+ add(name, reg?.import
80
137
  ? { name, from: reg.import, named: reg.namedImport }
81
- : options.existingImports?.[name];
82
- if (!spec)
83
- continue;
84
- const line = spec.named
85
- ? `import { ${spec.name} } from "${spec.from}";`
86
- : `import ${spec.name} from "${spec.from}";`;
87
- lines.set(name, line);
138
+ : options.existingImports?.[name]);
88
139
  }
140
+ // Anything else the source imported stays while it is still referenced.
141
+ for (const [name, spec] of Object.entries(options.existingImports ?? {}))
142
+ if (!lines.has(name) && used.identifiers.has(name))
143
+ add(name, spec);
89
144
  const originalOrder = Object.keys(options.existingImports ?? {});
90
145
  return [...lines.entries()]
91
146
  .sort(([a], [b]) => {
@@ -101,6 +156,17 @@ function importStatements(used, options) {
101
156
  })
102
157
  .map(([, l]) => l);
103
158
  }
159
+ function importLine(spec) {
160
+ const from = JSON.stringify(spec.from);
161
+ if (!spec.named)
162
+ return `import ${spec.name} from ${from};`;
163
+ const imported = spec.imported === undefined || spec.imported === spec.name
164
+ ? ""
165
+ : IS_IDENTIFIER.test(spec.imported)
166
+ ? `${spec.imported} as `
167
+ : `${JSON.stringify(spec.imported)} as `;
168
+ return `import { ${imported}${spec.name} } from ${from};`;
169
+ }
104
170
  function blocks(nodes, used) {
105
171
  const out = [];
106
172
  for (const node of nodes) {
@@ -134,11 +200,14 @@ function block(node, used) {
134
200
  case "bulletList":
135
201
  case "orderedList": {
136
202
  const ordered = node.type === "orderedList";
203
+ // Lists that must be loose to keep their content apart are loosened
204
+ // while serializing; see listHandler.
205
+ const spread = node.attrs?.spread === true;
137
206
  const items = (node.content ?? []).map((item) => {
138
207
  const checked = item.attrs?.checked;
139
208
  return {
140
209
  type: "listItem",
141
- spread: false,
210
+ spread,
142
211
  checked: typeof checked === "boolean" ? checked : null,
143
212
  children: blocks(item.content ?? [], used),
144
213
  };
@@ -147,7 +216,7 @@ function block(node, used) {
147
216
  type: "list",
148
217
  ordered,
149
218
  start: ordered ? Number(node.attrs?.start ?? 1) : null,
150
- spread: false,
219
+ spread,
151
220
  children: items,
152
221
  };
153
222
  }
@@ -164,13 +233,22 @@ function block(node, used) {
164
233
  return table(node, used);
165
234
  case "mdxComponent":
166
235
  return jsxElement(node, used, false);
167
- case "mdxExpression":
236
+ case "footnoteDefinition":
168
237
  return {
169
- type: "mdxFlowExpression",
170
- value: String(node.attrs?.value ?? ""),
238
+ type: "footnoteDefinition",
239
+ ...footnoteLabel(node),
240
+ children: blocks(node.content ?? [], used),
171
241
  };
172
- case "mdxEsm":
173
- return { type: "mdxjsEsm", value: String(node.attrs?.value ?? "") };
242
+ case "mdxExpression": {
243
+ const value = String(node.attrs?.value ?? "");
244
+ scan(value, used);
245
+ return { type: "mdxFlowExpression", value };
246
+ }
247
+ case "mdxEsm": {
248
+ const value = String(node.attrs?.value ?? "");
249
+ scan(value, used);
250
+ return { type: "mdxjsEsm", value };
251
+ }
174
252
  case "image":
175
253
  return {
176
254
  type: "paragraph",
@@ -184,6 +262,11 @@ function block(node, used) {
184
262
  }
185
263
  }
186
264
  }
265
+ function footnoteLabel(node) {
266
+ const label = String(node.attrs?.label ?? node.attrs?.identifier ?? "");
267
+ const identifier = String(node.attrs?.identifier ?? label.toLowerCase());
268
+ return { identifier, label };
269
+ }
187
270
  function clampDepth(n) {
188
271
  return Math.min(6, Math.max(1, Math.round(n)));
189
272
  }
@@ -210,7 +293,7 @@ function table(node, used) {
210
293
  }));
211
294
  return { type: "table", align, children };
212
295
  }
213
- function propsToAttributes(node, inline = false) {
296
+ function propsToAttributes(node, used, inline = false) {
214
297
  const props = (node.attrs?.props ?? {});
215
298
  const spreads = (node.attrs?.spreads ?? []);
216
299
  const attrs = [];
@@ -224,27 +307,30 @@ function propsToAttributes(node, inline = false) {
224
307
  attrs.push({ type: "mdxJsxAttribute", name, value });
225
308
  }
226
309
  else {
310
+ // Block components print each attribute on its own line, indented
311
+ // two spaces, so nested lines are indented relative to that.
312
+ const source = valueToSource(value, inline ? "" : " ");
313
+ scan(source, used);
227
314
  attrs.push({
228
315
  type: "mdxJsxAttribute",
229
316
  name,
230
- value: {
231
- type: "mdxJsxAttributeValueExpression",
232
- // Block components print each attribute on its own line, indented
233
- // two spaces, so nested lines are indented relative to that.
234
- value: valueToSource(value, inline ? "" : " "),
235
- },
317
+ value: { type: "mdxJsxAttributeValueExpression", value: source },
236
318
  });
237
319
  }
238
320
  }
239
- for (const s of spreads)
321
+ for (const s of spreads) {
322
+ scan(s, used);
240
323
  attrs.push({ type: "mdxJsxExpressionAttribute", value: s });
324
+ }
241
325
  return attrs;
242
326
  }
243
327
  function jsxElement(node, used, inline) {
244
328
  const name = String(node.attrs?.name ?? "");
245
329
  if (name && /^[A-Z]/.test(name))
246
- used.add(name);
247
- const attributes = propsToAttributes(node, inline);
330
+ used.components.add(name);
331
+ // `<ui.Card />` needs the import of `ui`.
332
+ scan(name, used);
333
+ const attributes = propsToAttributes(node, used, inline);
248
334
  if (inline) {
249
335
  return {
250
336
  type: "mdxJsxTextElement",
@@ -384,10 +470,13 @@ function leaf(node, used) {
384
470
  return [{ type: "html", value: String(node.attrs?.value ?? "") }];
385
471
  case "mdxInlineComponent":
386
472
  return [jsxElement(node, used, true)];
387
- case "mdxInlineExpression":
388
- return [
389
- { type: "mdxTextExpression", value: String(node.attrs?.value ?? "") },
390
- ];
473
+ case "mdxInlineExpression": {
474
+ const value = String(node.attrs?.value ?? "");
475
+ scan(value, used);
476
+ return [{ type: "mdxTextExpression", value }];
477
+ }
478
+ case "footnoteReference":
479
+ return [{ type: "footnoteReference", ...footnoteLabel(node) }];
391
480
  case "mdxComponent":
392
481
  return [jsxElement(node, used, true)];
393
482
  default:
@@ -23,6 +23,8 @@ export interface ImportSpec {
23
23
  from: string;
24
24
  /** Default or named import. */
25
25
  named?: boolean;
26
+ /** Exported name of a named import when it differs from `name` (`import { imported as name }`). */
27
+ imported?: string;
26
28
  }
27
29
  export interface ParsedDocument {
28
30
  doc: PMNode;
@@ -1,10 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { TableKit } from "@tiptap/extension-table";
3
- import Subscript from "@tiptap/extension-subscript";
4
- import Superscript from "@tiptap/extension-superscript";
5
- import { Placeholder } from "@tiptap/extensions";
6
2
  import { EditorContent, useEditor, useEditorState, } from "@tiptap/react";
7
- import StarterKit from "@tiptap/starter-kit";
8
3
  import { Bold, Code, Code2, Eye, Heading1, Heading2, Heading3, Image as ImageIcon, Italic, Link as LinkIcon, List, ListOrdered, Minus, Plus, Quote, Redo2, Strikethrough, Subscript as SubIcon, Superscript as SupIcon, Table as TableIcon, Undo2, Unlink, } from "lucide-react";
9
4
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
10
5
  import { parseMdx, serializeMdx } from "../../../mdx/index.js";
@@ -14,7 +9,7 @@ import { Icon } from "../Icon.js";
14
9
  import { CodeInput } from "../fields/CodeInput.js";
15
10
  import { Button, Menu, Modal } from "../ui.js";
16
11
  import { EditorEnvContext, useEditorEnv, } from "./EditorContext.js";
17
- import { MdxComponent, MdxEsm, MdxExpression, MdxImage, MdxInlineComponent, MdxInlineExpression, RawHtml, RawInlineHtml, } from "./extensions.js";
12
+ import { editorExtensions } from "./schema.js";
18
13
  /**
19
14
  * Tiptap editor that reads and writes MDX. The document is parsed once on
20
15
  * load; edits are serialized back (debounced) and reported through `onChange`.
@@ -58,28 +53,10 @@ function EditorInner({ value, onChange, components, env, format = "mdx", placeho
58
53
  existingImports: importsRef.current,
59
54
  }), [format, registry]);
60
55
  const initialDoc = useMemo(() => parse(value), []); // eslint-disable-line react-hooks/exhaustive-deps
61
- const extensions = useMemo(() => [
62
- StarterKit.configure({
63
- heading: { levels: [1, 2, 3, 4] },
64
- link: { openOnClick: false, autolink: true, defaultProtocol: "https" },
65
- codeBlock: { languageClassPrefix: "language-" },
66
- dropcursor: { color: "var(--v-primary)", width: 2 },
67
- }),
68
- Superscript,
69
- Subscript,
70
- MdxImage.configure({
71
- resolveSrc: (src) => env.resolveImage?.(src) ?? src,
72
- }),
73
- TableKit.configure({ table: { resizable: false } }),
74
- Placeholder.configure({ placeholder: placeholder ?? "Start writing…" }),
75
- MdxComponent,
76
- MdxInlineComponent,
77
- MdxExpression,
78
- MdxInlineExpression,
79
- MdxEsm,
80
- RawHtml,
81
- RawInlineHtml,
82
- ], [env, placeholder]);
56
+ const extensions = useMemo(() => editorExtensions({
57
+ resolveSrc: (src) => env.resolveImage?.(src) ?? src,
58
+ placeholder,
59
+ }), [env, placeholder]);
83
60
  const timer = useRef(null);
84
61
  const editor = useEditor({
85
62
  extensions,
@@ -1,4 +1,4 @@
1
- import { Node } from "@tiptap/core";
1
+ import { Extension, Node } from "@tiptap/core";
2
2
  /** A block-level MDX component, e.g. `<Highlights items={[...]} />`. */
3
3
  export declare const MdxComponent: Node<any, any>;
4
4
  /** An inline MDX component, e.g. `<Abbr title="...">WELS</Abbr>`. */
@@ -18,3 +18,13 @@ export declare const RawInlineHtml: Node<any, any>;
18
18
  export declare const MdxImage: Node<{
19
19
  resolveSrc: (src: string) => string;
20
20
  } & Record<string, unknown>, any>;
21
+ /**
22
+ * List attributes from Markdown that StarterKit's lists lack: whether a list
23
+ * is loose (blank lines between items; tightening it changes the rendered
24
+ * HTML) and a task item's checkbox (`- [ ]` / `- [x]`).
25
+ */
26
+ export declare const ListAttributes: Extension<any, any>;
27
+ /** A GFM footnote definition, `[^label]: ...`. */
28
+ export declare const FootnoteDefinition: Node<any, any>;
29
+ /** A GFM footnote reference, `[^label]`. */
30
+ export declare const FootnoteReference: Node<any, any>;
@@ -1,4 +1,4 @@
1
- import { Node, mergeAttributes } from "@tiptap/core";
1
+ import { Extension, Node, mergeAttributes } from "@tiptap/core";
2
2
  import Image from "@tiptap/extension-image";
3
3
  import { ReactNodeViewRenderer } from "@tiptap/react";
4
4
  import { ComponentNodeView, InlineComponentNodeView, RawBlockNodeView, } from "./NodeViews.js";
@@ -154,3 +154,95 @@ export const MdxImage = Image.extend({
154
154
  ];
155
155
  },
156
156
  });
157
+ /**
158
+ * List attributes from Markdown that StarterKit's lists lack: whether a list
159
+ * is loose (blank lines between items; tightening it changes the rendered
160
+ * HTML) and a task item's checkbox (`- [ ]` / `- [x]`).
161
+ */
162
+ export const ListAttributes = Extension.create({
163
+ name: "listAttributes",
164
+ addGlobalAttributes() {
165
+ return [
166
+ {
167
+ types: ["bulletList", "orderedList"],
168
+ attributes: {
169
+ spread: {
170
+ default: false,
171
+ parseHTML: (el) => el.hasAttribute("data-spread"),
172
+ renderHTML: (attrs) => (attrs.spread ? { "data-spread": "" } : {}),
173
+ },
174
+ },
175
+ },
176
+ {
177
+ types: ["listItem"],
178
+ attributes: {
179
+ checked: {
180
+ default: null,
181
+ parseHTML: (el) => {
182
+ const v = el.getAttribute("data-checked");
183
+ return v === null ? null : v === "true";
184
+ },
185
+ renderHTML: (attrs) => typeof attrs.checked === "boolean"
186
+ ? { "data-checked": String(attrs.checked) }
187
+ : {},
188
+ },
189
+ },
190
+ },
191
+ ];
192
+ },
193
+ });
194
+ const footnoteAttributes = {
195
+ identifier: { default: "" },
196
+ label: { default: "" },
197
+ };
198
+ /** A GFM footnote definition, `[^label]: ...`. */
199
+ export const FootnoteDefinition = Node.create({
200
+ name: "footnoteDefinition",
201
+ group: "block",
202
+ content: "block+",
203
+ defining: true,
204
+ isolating: true,
205
+ addAttributes() {
206
+ return footnoteAttributes;
207
+ },
208
+ parseHTML() {
209
+ return [{ tag: "div[data-footnote-definition]" }];
210
+ },
211
+ renderHTML({ HTMLAttributes, node }) {
212
+ return [
213
+ "div",
214
+ mergeAttributes(HTMLAttributes, {
215
+ "data-footnote-definition": "",
216
+ "data-label": `[^${node.attrs.label}]`,
217
+ class: "v-footnote-definition",
218
+ }),
219
+ 0,
220
+ ];
221
+ },
222
+ });
223
+ /** A GFM footnote reference, `[^label]`. */
224
+ export const FootnoteReference = Node.create({
225
+ name: "footnoteReference",
226
+ group: "inline",
227
+ inline: true,
228
+ atom: true,
229
+ selectable: true,
230
+ addAttributes() {
231
+ return footnoteAttributes;
232
+ },
233
+ parseHTML() {
234
+ // Ahead of Superscript's `sup` rule, or a pasted reference turns into
235
+ // superscript text.
236
+ return [{ tag: "sup[data-footnote-reference]", priority: 60 }];
237
+ },
238
+ renderHTML({ HTMLAttributes, node }) {
239
+ return [
240
+ "sup",
241
+ mergeAttributes(HTMLAttributes, {
242
+ "data-footnote-reference": "",
243
+ class: "v-footnote-reference",
244
+ }),
245
+ `[^${node.attrs.label}]`,
246
+ ];
247
+ },
248
+ });
@@ -0,0 +1,12 @@
1
+ export interface EditorExtensionOptions {
2
+ /** Maps a stored image path to a URL the browser can load. */
3
+ resolveSrc?: (src: string) => string;
4
+ placeholder?: string;
5
+ }
6
+ /**
7
+ * The rich text editor's extensions. Its schema must accept every node type
8
+ * and attribute `parseMdx` produces, or they are lost when a page is saved.
9
+ */
10
+ export declare function editorExtensions(options?: EditorExtensionOptions): (import("@tiptap/core").Node<any, any> | import("@tiptap/core").Node<{
11
+ resolveSrc: (src: string) => string;
12
+ } & Record<string, unknown>, any> | import("@tiptap/core").Extension<any, any> | import("@tiptap/core").Extension<import("@tiptap/starter-kit").StarterKitOptions, any> | import("@tiptap/core").Mark<import("@tiptap/extension-superscript").SuperscriptExtensionOptions, any> | import("@tiptap/core").Extension<import("@tiptap/extension-table").TableKitOptions, any> | import("@tiptap/core").Extension<import("@tiptap/extensions").PlaceholderOptions, any>)[];
@@ -0,0 +1,37 @@
1
+ import { TableKit } from "@tiptap/extension-table";
2
+ import Subscript from "@tiptap/extension-subscript";
3
+ import Superscript from "@tiptap/extension-superscript";
4
+ import { Placeholder } from "@tiptap/extensions";
5
+ import StarterKit from "@tiptap/starter-kit";
6
+ import { FootnoteDefinition, FootnoteReference, ListAttributes, MdxComponent, MdxEsm, MdxExpression, MdxImage, MdxInlineComponent, MdxInlineExpression, RawHtml, RawInlineHtml, } from "./extensions.js";
7
+ /**
8
+ * The rich text editor's extensions. Its schema must accept every node type
9
+ * and attribute `parseMdx` produces, or they are lost when a page is saved.
10
+ */
11
+ export function editorExtensions(options = {}) {
12
+ return [
13
+ StarterKit.configure({
14
+ heading: { levels: [1, 2, 3, 4] },
15
+ link: { openOnClick: false, autolink: true, defaultProtocol: "https" },
16
+ codeBlock: { languageClassPrefix: "language-" },
17
+ dropcursor: { color: "var(--v-primary)", width: 2 },
18
+ }),
19
+ Superscript,
20
+ Subscript,
21
+ MdxImage.configure({ resolveSrc: options.resolveSrc ?? ((src) => src) }),
22
+ TableKit.configure({ table: { resizable: false } }),
23
+ Placeholder.configure({
24
+ placeholder: options.placeholder ?? "Start writing…",
25
+ }),
26
+ MdxComponent,
27
+ MdxInlineComponent,
28
+ MdxExpression,
29
+ MdxInlineExpression,
30
+ MdxEsm,
31
+ RawHtml,
32
+ RawInlineHtml,
33
+ ListAttributes,
34
+ FootnoteDefinition,
35
+ FootnoteReference,
36
+ ];
37
+ }
@@ -1696,6 +1696,24 @@ a.v-chrome__chip:hover {
1696
1696
  padding: 0 4px;
1697
1697
  border-radius: 3px;
1698
1698
  }
1699
+ .v-footnote-reference {
1700
+ font-family: var(--v-mono);
1701
+ font-size: 0.7em;
1702
+ color: #4338ca;
1703
+ }
1704
+ .v-footnote-definition {
1705
+ position: relative;
1706
+ margin: 0.75em 0;
1707
+ padding-left: 1em;
1708
+ border-left: 2px solid #c7d2fe;
1709
+ }
1710
+ .v-footnote-definition::before {
1711
+ content: attr(data-label);
1712
+ display: block;
1713
+ font-family: var(--v-mono);
1714
+ font-size: 0.75em;
1715
+ color: #4338ca;
1716
+ }
1699
1717
 
1700
1718
  /* source editor */
1701
1719
  .v-source-editor .cm-editor {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vestrycms",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "A git-backed CMS for Astro. Content, assets and configuration live in your repository; the control panel runs as an Astro integration.",
5
5
  "license": "MIT",
6
6
  "author": "Michael Spencer",
@@ -107,7 +107,8 @@
107
107
  "prettier-plugin-astro": "^1.1.2",
108
108
  "tsx": "^4.23.15",
109
109
  "typescript": "^6.0.3",
110
- "vite": "^8.3.5"
110
+ "vite": "^8.3.5",
111
+ "vitest": "^5.1.0"
111
112
  },
112
113
  "publishConfig": {
113
114
  "access": "public"
@@ -116,8 +117,9 @@
116
117
  "build": "node scripts/build.mjs",
117
118
  "dev": "node scripts/build.mjs --watch",
118
119
  "typecheck": "tsc --noEmit -p tsconfig.json",
119
- "test": "node --import tsx/esm test/github-mock.ts && node --import tsx/esm test/auth.ts && node --import tsx/esm test/github-app.ts",
120
- "test:roundtrip": "node --import tsx/esm test/roundtrip.ts",
120
+ "test": "vitest run",
121
+ "test:watch": "vitest",
122
+ "test:roundtrip": "node --import tsx/esm test/manual/roundtrip.ts",
121
123
  "format": "prettier --write .",
122
124
  "format:check": "prettier --check ."
123
125
  }