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 +42 -7
- package/dist/config/types.d.ts +9 -0
- package/dist/core/api.js +6 -1
- package/dist/core/auth.d.ts +36 -0
- package/dist/core/auth.js +35 -5
- package/dist/mdx/parse.js +70 -47
- package/dist/mdx/serialize.js +121 -32
- package/dist/mdx/types.d.ts +2 -0
- package/dist/ui/components/editor/RichTextEditor.js +5 -28
- package/dist/ui/components/editor/extensions.d.ts +11 -1
- package/dist/ui/components/editor/extensions.js +93 -1
- package/dist/ui/components/editor/schema.d.ts +12 -0
- package/dist/ui/components/editor/schema.js +37 -0
- package/dist/ui/styles.css +18 -0
- package/package.json +6 -4
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
|
|
331
|
-
node --import tsx/esm test/
|
|
332
|
-
node --import tsx/esm test/github-
|
|
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
|
package/dist/config/types.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/auth.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
203
|
+
byPrefix.delete(prefix);
|
|
174
204
|
throw e;
|
|
175
205
|
});
|
|
176
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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 (!
|
|
181
|
-
return
|
|
182
|
-
|
|
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: "
|
|
363
|
+
withMarks({ type: "footnoteReference", attrs: footnoteAttrs(node) }, marks),
|
|
341
364
|
];
|
|
342
365
|
case "mdxJsxTextElement": {
|
|
343
366
|
const converted = jsxElement(node, ctx, true);
|
package/dist/mdx/serialize.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 "
|
|
236
|
+
case "footnoteDefinition":
|
|
168
237
|
return {
|
|
169
|
-
type: "
|
|
170
|
-
|
|
238
|
+
type: "footnoteDefinition",
|
|
239
|
+
...footnoteLabel(node),
|
|
240
|
+
children: blocks(node.content ?? [], used),
|
|
171
241
|
};
|
|
172
|
-
case "
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
|
|
389
|
-
|
|
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:
|
package/dist/mdx/types.d.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
+
}
|
package/dist/ui/styles.css
CHANGED
|
@@ -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.
|
|
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": "
|
|
120
|
-
"test:
|
|
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
|
}
|