smoodly 0.0.37 → 0.0.39
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/dist/admin/fixed-nodes.d.ts +1 -1
- package/dist/admin/fixed-nodes.js +26 -1
- package/dist/admin/index.d.ts +1 -0
- package/dist/admin/index.js +1 -0
- package/dist/admin/ops-impl.js +34 -9
- package/dist/admin/shell/SchemaNotice.js +1 -1
- package/dist/admin/supabase-auth.js +10 -4
- package/dist/cli/add-admin.d.ts +11 -0
- package/dist/cli/add-admin.js +25 -0
- package/dist/cli/main.d.ts +5 -1
- package/dist/cli/main.js +23 -3
- package/dist/cli/migrate.d.ts +1 -1
- package/dist/cli/migrate.js +6 -1
- package/dist/cli/space-io.d.ts +1 -7
- package/dist/cli/space-io.js +2 -16
- package/dist/client.js +3 -1
- package/dist/content/content-api.d.ts +84 -0
- package/dist/content/content-api.js +170 -0
- package/dist/content/node.d.ts +7 -0
- package/dist/content/node.js +10 -0
- package/dist/env.d.ts +6 -3
- package/dist/env.js +39 -15
- package/dist/index.d.ts +4 -3
- package/dist/index.js +1 -0
- package/dist/member-store.d.ts +12 -0
- package/dist/member-store.js +8 -0
- package/dist/model-key.d.ts +1 -0
- package/dist/model-key.js +36 -0
- package/dist/next/page-renderer.js +6 -5
- package/dist/next/queries.js +1 -1
- package/dist/schema-read.d.ts +7 -0
- package/dist/schema-read.js +15 -0
- package/dist/schema-version.d.ts +2 -0
- package/dist/schema-version.js +3 -1
- package/dist/schema.d.ts +10 -4
- package/dist/site.d.ts +8 -0
- package/dist/site.js +40 -2
- package/dist/supabase-entry-store.js +53 -37
- package/dist/supabase-member-store.d.ts +6 -0
- package/dist/supabase-member-store.js +20 -2
- package/dist/supabase-store.js +16 -14
- package/dist/tags.d.ts +3 -0
- package/dist/tags.js +16 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +2 -0
- package/package.json +16 -13
package/dist/site.js
CHANGED
|
@@ -6,9 +6,11 @@ import { globalNodeId, SETTINGS_KEY, STYLES_KEY, CSS_KEY } from "./global.js";
|
|
|
6
6
|
import { SMD_KEY } from "./section-wrapper.js";
|
|
7
7
|
import { resolveStyleValues } from "./styles.js";
|
|
8
8
|
import { resolveCustom } from "./custom/index.js";
|
|
9
|
-
import { addressMap, codeAddresses } from "./addresses.js";
|
|
9
|
+
import { addressMap, codeAddresses, lastPart } from "./addresses.js";
|
|
10
10
|
import { fullPath, servedAnchor } from "./anchors.js";
|
|
11
11
|
import { withDefaults } from "./field-defaults.js";
|
|
12
|
+
import { modelKey } from "./model-key.js";
|
|
13
|
+
import { SMOODLY_VERSION } from "./version.js";
|
|
12
14
|
import { toEntryItem, toPageItem } from "./read-shapes.js";
|
|
13
15
|
import { entrySeo, pageSeo, SEO_KEY } from "./seo.js";
|
|
14
16
|
/** The `page` prop for a loaded page. `href` is the app's seam from a
|
|
@@ -36,11 +38,22 @@ export function createSmoodlySite(options) {
|
|
|
36
38
|
const custom = resolveCustom(options.custom);
|
|
37
39
|
const elements = elementSchemas(options.elements);
|
|
38
40
|
// Code-owned addresses from the site's own registry (spec 2026-09-29 nested paths §3): a code-owned root's path.
|
|
39
|
-
const
|
|
41
|
+
const codeOwned = codeAddresses({ locales, pages: options.pages, collections: options.collections });
|
|
42
|
+
const addresses = addressMap(codeOwned);
|
|
40
43
|
const pathOpts = { locales, homePageSlug, addresses };
|
|
41
44
|
/** The same addresses as the path index sees them: an entry's stored part joined to its collection's (spec 2026-09-29 anchored paths §2). */
|
|
42
45
|
const anchors = { addresses, homePageSlug, defaultLocale: locales.default };
|
|
43
46
|
const mounted = options.collections.filter((c) => c.path !== undefined);
|
|
47
|
+
// Every cached read keys on it (spec 2026-09-30 tester findings §3): an upgrade or a model change starts a fresh cache.
|
|
48
|
+
const cacheKey = `${SMOODLY_VERSION}:${modelKey({
|
|
49
|
+
// Whole schemas: a collection's order, tree or title field changes a read as its fields do (functions drop out in the hash).
|
|
50
|
+
collections: options.collections,
|
|
51
|
+
globals: (options.globals ?? []).map((g) => ({ name: g.name, fields: g.fields, section: g.section })),
|
|
52
|
+
sections: options.sections.map((x) => ("schema" in x ? x.schema : x)),
|
|
53
|
+
elements: (options.elements ?? []).map((x) => ("schema" in x ? x.schema : x)),
|
|
54
|
+
pages: options.pages.map((x) => x.schema),
|
|
55
|
+
locales, homePageSlug, styles, custom,
|
|
56
|
+
})}`;
|
|
44
57
|
/** The resolver's fetcher, bound to a locale: the merged fields of that
|
|
45
58
|
* locale — published ones (a versioned collection's snapshot) on the
|
|
46
59
|
* public path, the live rows in draft mode. */
|
|
@@ -336,9 +349,33 @@ export function createSmoodlySite(options) {
|
|
|
336
349
|
};
|
|
337
350
|
},
|
|
338
351
|
};
|
|
352
|
+
const codePath = (address) => (address === homePageSlug ? "/" : `/${address}`);
|
|
353
|
+
function codePage(locale, path) {
|
|
354
|
+
const spec = codeOwned.find((a) => a.template !== null && a.segments[locale] !== undefined && codePath(a.segments[locale]) === path);
|
|
355
|
+
if (!spec || !locales.supported.includes(locale))
|
|
356
|
+
return null;
|
|
357
|
+
const template = spec.template;
|
|
358
|
+
const withSeo = options.pages.some((x) => x.schema.kind === "page" && x.schema.name === template && x.schema.seo === true);
|
|
359
|
+
return {
|
|
360
|
+
kind: "page",
|
|
361
|
+
locale,
|
|
362
|
+
// No record yet: the id only has to be stable and never a stored one.
|
|
363
|
+
record: { id: `code:${template}`, parentId: null, template, fixed: spec.key, locales: {}, sort: null },
|
|
364
|
+
template,
|
|
365
|
+
tree: { zones: {} },
|
|
366
|
+
title: spec.title,
|
|
367
|
+
path,
|
|
368
|
+
fields: withDefaults(undefined, pageFields(template)),
|
|
369
|
+
ancestors: [],
|
|
370
|
+
children: [],
|
|
371
|
+
locales: Object.fromEntries(Object.entries(spec.segments).map(([l, address]) => [l, { slug: lastPart(address), path: codePath(address) }])),
|
|
372
|
+
...(withSeo ? { seo: pageSeo(undefined, spec.title) } : {}),
|
|
373
|
+
};
|
|
374
|
+
}
|
|
339
375
|
const site = {
|
|
340
376
|
locales,
|
|
341
377
|
homePageSlug,
|
|
378
|
+
cacheKey,
|
|
342
379
|
styles,
|
|
343
380
|
custom,
|
|
344
381
|
locate: (locale, path) => options.stores().pages.resolvePath(locale, path),
|
|
@@ -352,6 +389,7 @@ export function createSmoodlySite(options) {
|
|
|
352
389
|
const target = await site.locate(locale, path);
|
|
353
390
|
return target && !target.redirect ? site.load(target, locale, path, opts) : null;
|
|
354
391
|
},
|
|
392
|
+
codePage,
|
|
355
393
|
...api,
|
|
356
394
|
};
|
|
357
395
|
return site;
|
|
@@ -48,6 +48,8 @@ function toRecord(row) {
|
|
|
48
48
|
function toVersion(row) {
|
|
49
49
|
return { id: row.id, entryId: row.entry_id, locale: row.locale, fields: row.fields, savedAt: Date.parse(row.saved_at) };
|
|
50
50
|
}
|
|
51
|
+
/** Rows per request when `list` reads a whole collection: under PostgREST's default cap. */
|
|
52
|
+
const LIST_PAGE = 1000;
|
|
51
53
|
export class SupabaseEntryStore {
|
|
52
54
|
constructor(db, collections, options = {}) {
|
|
53
55
|
this.db = db;
|
|
@@ -391,47 +393,61 @@ export class SupabaseEntryStore {
|
|
|
391
393
|
// EVERY locale row of each record (spec 2026-09-11 §3), ordered by locale
|
|
392
394
|
// so a record's key order never depends on physical row order.
|
|
393
395
|
const gate = options?.status !== undefined || options?.locale !== undefined;
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
.
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
396
|
+
// Built per request: without a `limit` the list is read in pages, because
|
|
397
|
+
// PostgREST caps one response (1000 rows by default) without saying so.
|
|
398
|
+
const build = () => {
|
|
399
|
+
let query = this.db
|
|
400
|
+
.from("entries")
|
|
401
|
+
.select(gate ? `${SELECT}, gate:entry_locales!inner(locale)` : SELECT)
|
|
402
|
+
.eq("collection", collection)
|
|
403
|
+
.order("locale", { referencedTable: "entry_locales", ascending: true });
|
|
404
|
+
if (gate) {
|
|
405
|
+
query = query.eq("gate.locale", options.locale ?? this.locales.default);
|
|
406
|
+
if (options.status !== undefined)
|
|
407
|
+
query = query.eq("gate.status", options.status);
|
|
408
|
+
}
|
|
409
|
+
if (options?.parent !== undefined) {
|
|
410
|
+
query = options.parent === null ? query.is("parent_id", null) : query.eq("parent_id", options.parent);
|
|
411
|
+
}
|
|
412
|
+
for (const [key, want] of Object.entries(options?.where ?? {})) {
|
|
413
|
+
// Any-of compares the text form (`->>`). A scalar is JSONB containment;
|
|
414
|
+
// an array-valued field (checkboxes, refList, list, tags) contains `[value]`,
|
|
415
|
+
// a scalar field `value` — the memory adapter's matchesWhere agrees.
|
|
416
|
+
if (Array.isArray(want)) {
|
|
417
|
+
query = query.in(`fields->>${key}`, want.map(String));
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
const inArray = ARRAY_FIELD_TYPES.has(schema.fields[key]?.type ?? "");
|
|
421
|
+
query = query.contains("fields", { [key]: inArray ? [want] : want });
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
const order = options?.order ?? collectionOrder(schema);
|
|
425
|
+
if (order === "manual") {
|
|
426
|
+
// `id` last in both orders: a stable order, so no row falls between two pages.
|
|
427
|
+
query = query.order("sort", { ascending: true, nullsFirst: false }).order("created_at", { ascending: true }).order("id", { ascending: true });
|
|
413
428
|
}
|
|
414
429
|
else {
|
|
415
|
-
|
|
416
|
-
|
|
430
|
+
// `fields->x`, not `->>x`: ordering by the jsonb VALUE of the SHARED
|
|
431
|
+
// field (an order field is never localized), matching the memory comparator.
|
|
432
|
+
const column = order.by === "createdAt" ? "created_at" : order.by === "updatedAt" ? "updated_at" : `fields->${order.by}`;
|
|
433
|
+
query = query.order(column, { ascending: order.direction === "asc", nullsFirst: false }).order("id", { ascending: true });
|
|
417
434
|
}
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
// field (an order field is never localized), matching the memory comparator.
|
|
426
|
-
const column = order.by === "createdAt" ? "created_at" : order.by === "updatedAt" ? "updated_at" : `fields->${order.by}`;
|
|
427
|
-
query = query.order(column, { ascending: order.direction === "asc", nullsFirst: false }).order("id", { ascending: true });
|
|
428
|
-
}
|
|
435
|
+
return query;
|
|
436
|
+
};
|
|
437
|
+
const rows = (res) => {
|
|
438
|
+
if (res.error)
|
|
439
|
+
this.fail(res.error.message);
|
|
440
|
+
return res.data.map(({ gate: _gate, ...row }) => toRecord(row));
|
|
441
|
+
};
|
|
429
442
|
if (typeof options?.limit === "number")
|
|
430
|
-
|
|
431
|
-
const
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
443
|
+
return rows(await build().limit(options.limit));
|
|
444
|
+
const all = [];
|
|
445
|
+
for (let from = 0;; from += LIST_PAGE) {
|
|
446
|
+
const page = rows(await build().range(from, from + LIST_PAGE - 1));
|
|
447
|
+
all.push(...page);
|
|
448
|
+
if (page.length < LIST_PAGE)
|
|
449
|
+
return all;
|
|
450
|
+
}
|
|
435
451
|
}
|
|
436
452
|
async query(collection, q) {
|
|
437
453
|
const schema = this.schema(collection);
|
|
@@ -10,6 +10,12 @@ export declare class SupabaseMemberStore implements MemberStore {
|
|
|
10
10
|
/** The admin API has no lookup by email: page through the users (tens, not thousands). */
|
|
11
11
|
private userIdByEmail;
|
|
12
12
|
invite(input: InviteInput): Promise<InviteResult>;
|
|
13
|
+
private upsertRow;
|
|
14
|
+
createAccount(input: {
|
|
15
|
+
email: string;
|
|
16
|
+
password: string;
|
|
17
|
+
role: MemberRole;
|
|
18
|
+
}): Promise<MemberRecord>;
|
|
13
19
|
setRole(userId: string, role: MemberRole): Promise<MemberRecord>;
|
|
14
20
|
remove(userId: string): Promise<void>;
|
|
15
21
|
}
|
|
@@ -51,14 +51,32 @@ export class SupabaseMemberStore {
|
|
|
51
51
|
else {
|
|
52
52
|
throw new Error(`smoodly: ${sent.error.message}`);
|
|
53
53
|
}
|
|
54
|
+
return { member: await this.upsertRow(userId, input.email, input.role), invited };
|
|
55
|
+
}
|
|
56
|
+
async upsertRow(userId, email, role) {
|
|
54
57
|
const res = await this.db
|
|
55
58
|
.from("members")
|
|
56
|
-
.upsert({ user_id: userId, email
|
|
59
|
+
.upsert({ user_id: userId, email, role }, { onConflict: "space_id,user_id" })
|
|
57
60
|
.select("user_id, email, role")
|
|
58
61
|
.single();
|
|
59
62
|
if (res.error)
|
|
60
63
|
throw new Error(`smoodly: ${res.error.message}`);
|
|
61
|
-
return
|
|
64
|
+
return this.toRecord(res.data);
|
|
65
|
+
}
|
|
66
|
+
async createAccount(input) {
|
|
67
|
+
const created = await this.db.auth.admin.createUser({ email: input.email, password: input.password, email_confirm: true });
|
|
68
|
+
let userId;
|
|
69
|
+
if (!created.error) {
|
|
70
|
+
userId = created.data.user.id;
|
|
71
|
+
}
|
|
72
|
+
else if (created.error.code === "email_exists" || /already (been )?registered/i.test(created.error.message)) {
|
|
73
|
+
// An existing account keeps its password; it only gains the membership.
|
|
74
|
+
userId = await this.userIdByEmail(input.email);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
throw new Error(`smoodly: ${created.error.message}`);
|
|
78
|
+
}
|
|
79
|
+
return this.upsertRow(userId, input.email, input.role);
|
|
62
80
|
}
|
|
63
81
|
async setRole(userId, role) {
|
|
64
82
|
const res = await this.db.from("members").update({ role }).eq("user_id", userId).select("user_id, email, role").maybeSingle();
|
package/dist/supabase-store.js
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
// rewritten through ONE RPC per write, so a collision rolls back.
|
|
9
9
|
import { assertPublishable, assertSupportedLocale, localeRow, pageErrors, pageRefEdges, pathOptions, refSections, storeAnchors, } from "./store.js";
|
|
10
10
|
import { SupabasePathIndex } from "./supabase-path-index.js";
|
|
11
|
-
import {
|
|
11
|
+
import { schemaBehind, schemaBehindMessage, secretKeyRefusedMessage } from "./schema-version.js";
|
|
12
|
+
import { readSchemaVersion } from "./schema-read.js";
|
|
12
13
|
import { groupRedirects } from "./store.js";
|
|
13
14
|
import { assertFresh, draftToken, StaleDraftError } from "./draft-token.js";
|
|
14
15
|
import { assertDepth, assertSegment, isHomeNode, pagePathRows, rewriteTargets } from "./paths.js";
|
|
@@ -381,25 +382,26 @@ export class SupabasePageStore {
|
|
|
381
382
|
resolvePath(locale, path) {
|
|
382
383
|
if (!this.schemaChecked) {
|
|
383
384
|
this.schemaChecked = true;
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
385
|
+
// Once per store: a wrong key is named as a wrong key, not as an old schema (spec 2026-09-30 tester findings §4).
|
|
386
|
+
void readSchemaVersion(this.db)
|
|
387
|
+
.then((read) => {
|
|
388
|
+
if ("missing" in read)
|
|
389
|
+
console.warn(read.missing === "key" ? secretKeyRefusedMessage : schemaBehindMessage(null));
|
|
390
|
+
else if (schemaBehind(read.version))
|
|
391
|
+
console.warn(schemaBehindMessage(read.version));
|
|
388
392
|
})
|
|
389
393
|
.catch(() => { });
|
|
390
394
|
}
|
|
391
395
|
return this.paths.resolve(locale, path);
|
|
392
396
|
}
|
|
393
397
|
async schemaVersion() {
|
|
394
|
-
const
|
|
395
|
-
if (
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
const n = Number(res.data?.value);
|
|
402
|
-
return Number.isInteger(n) ? n : null;
|
|
398
|
+
const read = await readSchemaVersion(this.db);
|
|
399
|
+
if ("version" in read)
|
|
400
|
+
return read.version;
|
|
401
|
+
// The table exists and the row is hidden: the key is not a secret key.
|
|
402
|
+
if (read.missing === "key")
|
|
403
|
+
throw new Error(secretKeyRefusedMessage);
|
|
404
|
+
return null;
|
|
403
405
|
}
|
|
404
406
|
pathsOf(id) {
|
|
405
407
|
return UUID_RE.test(id) ? this.paths.pathsOf({ kind: "page", id }) : Promise.resolve({});
|
package/dist/tags.d.ts
CHANGED
|
@@ -17,3 +17,6 @@ export declare function addTags(current: readonly string[], input: string, opts?
|
|
|
17
17
|
* entries disagree on a tag's casing the most used one wins, a tie going to plain string
|
|
18
18
|
* order — never to the order the values arrived in, which is the store's business. */
|
|
19
19
|
export declare function distinctTags(values: readonly unknown[]): string[];
|
|
20
|
+
/** A tags value as stored (spec 2026-09-30 tester findings §5): normalized, one per
|
|
21
|
+
* case-folded tag, each taking the spelling already used in the collection. */
|
|
22
|
+
export declare function canonicalTags(values: readonly unknown[], known: readonly string[]): string[];
|
package/dist/tags.js
CHANGED
|
@@ -48,3 +48,19 @@ export function distinctTags(values) {
|
|
|
48
48
|
const pick = (counts) => [...counts].sort(([a, n], [b, m]) => m - n || (a < b ? -1 : 1))[0][0];
|
|
49
49
|
return [...variants.values()].map(pick).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
|
|
50
50
|
}
|
|
51
|
+
/** A tags value as stored (spec 2026-09-30 tester findings §5): normalized, one per
|
|
52
|
+
* case-folded tag, each taking the spelling already used in the collection. */
|
|
53
|
+
export function canonicalTags(values, known) {
|
|
54
|
+
const out = [];
|
|
55
|
+
for (const value of values) {
|
|
56
|
+
if (typeof value !== "string")
|
|
57
|
+
continue;
|
|
58
|
+
const typed = normalizeTag(value);
|
|
59
|
+
if (typed === "")
|
|
60
|
+
continue;
|
|
61
|
+
const tag = known.find((k) => sameTag(k, typed)) ?? typed;
|
|
62
|
+
if (!out.some((t) => sameTag(t, tag)))
|
|
63
|
+
out.push(tag);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const SMOODLY_VERSION = "0.0.39";
|
package/dist/version.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "smoodly",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.39",
|
|
4
4
|
"description": "Open-source, code-first CMS for Next.js on Supabase: define a component once, it becomes a typed, editable building block.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -54,8 +54,9 @@
|
|
|
54
54
|
"bin"
|
|
55
55
|
],
|
|
56
56
|
"scripts": {
|
|
57
|
-
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
57
|
+
"build": "node scripts/write-version.mjs && rm -rf dist && tsc -p tsconfig.build.json",
|
|
58
58
|
"prepublishOnly": "npm run build",
|
|
59
|
+
"pretest": "node scripts/write-version.mjs",
|
|
59
60
|
"test": "vitest run",
|
|
60
61
|
"test:watch": "vitest",
|
|
61
62
|
"typecheck": "tsc --noEmit",
|
|
@@ -82,17 +83,19 @@
|
|
|
82
83
|
},
|
|
83
84
|
"dependencies": {
|
|
84
85
|
"@supabase/supabase-js": "^2.113.0",
|
|
85
|
-
"@tiptap/core": "3.31.
|
|
86
|
-
"@tiptap/extension-blockquote": "3.31.
|
|
87
|
-
"@tiptap/extension-bold": "3.31.
|
|
88
|
-
"@tiptap/extension-
|
|
89
|
-
"@tiptap/extension-
|
|
90
|
-
"@tiptap/extension-
|
|
91
|
-
"@tiptap/extension-
|
|
92
|
-
"@tiptap/
|
|
93
|
-
"@tiptap/
|
|
94
|
-
"@tiptap/
|
|
95
|
-
"@tiptap/
|
|
86
|
+
"@tiptap/core": "3.31.4",
|
|
87
|
+
"@tiptap/extension-blockquote": "3.31.4",
|
|
88
|
+
"@tiptap/extension-bold": "3.31.4",
|
|
89
|
+
"@tiptap/extension-bubble-menu": "3.31.4",
|
|
90
|
+
"@tiptap/extension-floating-menu": "3.31.4",
|
|
91
|
+
"@tiptap/extension-heading": "3.31.4",
|
|
92
|
+
"@tiptap/extension-italic": "3.31.4",
|
|
93
|
+
"@tiptap/extension-link": "3.31.4",
|
|
94
|
+
"@tiptap/extension-list": "3.31.4",
|
|
95
|
+
"@tiptap/pm": "3.31.4",
|
|
96
|
+
"@tiptap/react": "3.31.4",
|
|
97
|
+
"@tiptap/starter-kit": "3.31.4",
|
|
98
|
+
"@tiptap/suggestion": "3.31.4",
|
|
96
99
|
"lucide-react": "^1.41.0",
|
|
97
100
|
"tsx": "^4.23.15"
|
|
98
101
|
}
|