smoodly 0.0.38 → 0.0.40

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.
@@ -1,5 +1,5 @@
1
1
  import { entryErrors, entryFieldsIn, entryTitleIn } from "../entry-store.js";
2
- import { chainOf, isSegment, pagePathIn } from "../paths.js";
2
+ import { chainOf, isSegment, pagePathIn, slugify } from "../paths.js";
3
3
  import { servedAnchor } from "../anchors.js";
4
4
  import { canonicalTags, distinctTags } from "../tags.js";
5
5
  import { can, isMemberRole, normalizeRole } from "../permissions.js";
@@ -112,6 +112,18 @@ const classify = (e) => {
112
112
  return err("conflict", message);
113
113
  return err("internal", message);
114
114
  };
115
+ /** A new entry without a slug gets one from the field `f.slug({ from })` follows, as the admin's form
116
+ * fills it (spec 2026-09-28 ASCII slugs §5), so a script needs no slug of its own. A given slug stays. */
117
+ function followSlug(fields, descriptors) {
118
+ const d = descriptors.slug;
119
+ if (d?.type !== "slug" || d.from === undefined)
120
+ return fields;
121
+ if (typeof fields.slug === "string" && fields.slug !== "")
122
+ return fields;
123
+ const source = fields[d.from];
124
+ const slug = typeof source === "string" ? slugify(source) : "";
125
+ return slug === "" ? fields : { ...fields, slug };
126
+ }
115
127
  export function createAdminOps(deps) {
116
128
  const { config, pages, entries, effects } = deps;
117
129
  const href = deps.href ?? ((path) => path);
@@ -783,7 +795,7 @@ export function createAdminOps(deps) {
783
795
  const descriptors = collectionFields(collection);
784
796
  if (!descriptors)
785
797
  return err("not_found", `smoodly: no collection named "${collection}".`);
786
- const created = await entries.create(collection, withDefaults(await foldTags(collection, input.locale, input.fields, descriptors), descriptors), { locale: input.locale, parentId: input.parentId ?? null }, by);
798
+ const created = await entries.create(collection, withDefaults(await foldTags(collection, input.locale, followSlug(input.fields, descriptors), descriptors), descriptors), { locale: input.locale, parentId: input.parentId ?? null }, by);
787
799
  expireCollection(collection);
788
800
  return ok(created);
789
801
  }),
@@ -7,10 +7,11 @@ export type SpaceConnection = {
7
7
  export declare const MEDIA_BUCKET = "media";
8
8
  /** Rows per request. A host may cap below it, so a read stops at the first empty page, not the first short one. */
9
9
  export declare const PAGE_ROWS = 1000;
10
- /** `.env.local`, then `.env`, as Next reads them; a variable already set wins (spec §5). */
11
- export declare function loadAppEnv(cwd: string, env?: Record<string, string | undefined>): void;
12
- /** The app's content client, from its env. */
13
- export declare function connectFromEnv(cwd: string): SpaceConnection;
10
+ /** `.env.local`, then `.env`, as Next reads them; a variable already set wins (spec §5). Returns the files it read. */
11
+ export declare function loadAppEnv(cwd: string, env?: Record<string, string | undefined>): string[];
12
+ /** The app's content client, from its env. Run outside the app, the missing keys are a missing
13
+ * folder, so the error names the folder it looked in. */
14
+ export declare function connectFromEnv(cwd: string, env?: Record<string, string | undefined>): SpaceConnection;
14
15
  export { readSchemaVersion } from "../schema-read.ts";
15
16
  export declare function schemaRefusal(missing: "key" | "schema", command: "Export" | "Import"): string;
16
17
  /** Every row of one table in one space, paged in primary-key order. The space filter is explicit: a service-role key sees every space. */
@@ -13,22 +13,29 @@ import { ROW_KEY } from "../space-transfer.js";
13
13
  export const MEDIA_BUCKET = "media";
14
14
  /** Rows per request. A host may cap below it, so a read stops at the first empty page, not the first short one. */
15
15
  export const PAGE_ROWS = 1000;
16
- /** `.env.local`, then `.env`, as Next reads them; a variable already set wins (spec §5). */
16
+ /** `.env.local`, then `.env`, as Next reads them; a variable already set wins (spec §5). Returns the files it read. */
17
17
  export function loadAppEnv(cwd, env = process.env) {
18
+ const read = [];
18
19
  for (const name of [".env.local", ".env"]) {
19
20
  const file = join(cwd, name);
20
21
  if (!existsSync(file))
21
22
  continue;
23
+ read.push(name);
22
24
  for (const [key, value] of Object.entries(parseEnv(readFileSync(file, "utf8")))) {
23
25
  if (env[key] === undefined)
24
26
  env[key] = value;
25
27
  }
26
28
  }
29
+ return read;
27
30
  }
28
- /** The app's content client, from its env. */
29
- export function connectFromEnv(cwd) {
30
- loadAppEnv(cwd);
31
- return { db: createSmoodlyClient(resolveSmoodlyEnv(process.env)) };
31
+ /** The app's content client, from its env. Run outside the app, the missing keys are a missing
32
+ * folder, so the error names the folder it looked in. */
33
+ export function connectFromEnv(cwd, env = process.env) {
34
+ const read = loadAppEnv(cwd, env);
35
+ if (read.length === 0 && (!env.SMOODLY_URL || !env.SMOODLY_SECRET_KEY)) {
36
+ throw new Error(`smoodly: no .env.local or .env in ${cwd} — run this in your app's folder.`);
37
+ }
38
+ return { db: createSmoodlyClient(resolveSmoodlyEnv(env)) };
32
39
  }
33
40
  export { readSchemaVersion } from "../schema-read.js";
34
41
  export function schemaRefusal(missing, command) {
package/dist/client.js CHANGED
@@ -11,11 +11,25 @@ export function clientOptions(env) {
11
11
  // without one (spec 2026-09-30 tester findings §4): a self-host service role is a valid apikey.
12
12
  return { url: env.url, key: env.publishableKey || env.secretKey, headers: { Authorization: `Bearer ${env.secretKey}` } };
13
13
  }
14
+ /** fetch that names the address when the network fails: supabase-js reports only
15
+ * "TypeError: fetch failed", which names neither the variable nor the URL. */
16
+ function namedFetch(url) {
17
+ return async (input, init) => {
18
+ try {
19
+ return await fetch(input, init);
20
+ }
21
+ catch (e) {
22
+ if (e instanceof Error && e.name === "AbortError")
23
+ throw e;
24
+ throw new TypeError(`can't reach SMOODLY_URL (${url}): ${e instanceof Error ? e.message : String(e)}`);
25
+ }
26
+ };
27
+ }
14
28
  /** The server-side client the stores are built on. */
15
29
  export function createSmoodlyClient(env) {
16
30
  const options = clientOptions(env);
17
31
  return createClient(options.url, options.key, {
18
- global: { headers: options.headers },
32
+ global: { headers: options.headers, fetch: namedFetch(options.url) },
19
33
  auth: { persistSession: false, autoRefreshToken: false },
20
34
  });
21
35
  }
@@ -43,6 +43,12 @@ export declare function createSmoodlyContent(deps: ContentDeps): {
43
43
  title?: string;
44
44
  }): Promise<PageRecord>;
45
45
  publish: (id: string, locale: string) => Promise<PageRecord>;
46
+ /** Every page present in `locale` (the default language unless given), code-owned ones included and
47
+ * folders left out, optionally only one page type: what a script walks to change many pages. */
48
+ list(options?: {
49
+ locale?: string;
50
+ template?: string;
51
+ }): Promise<PageRecord[]>;
46
52
  /** A code-owned address with no record gets it created the way the admin's Pages list does (spec §2). */
47
53
  find(locale: string, path: string): Promise<PageRecord | null>;
48
54
  };
@@ -54,6 +60,10 @@ export declare function createSmoodlyContent(deps: ContentDeps): {
54
60
  fields?: Record<string, unknown>;
55
61
  }): Promise<EntryRecord>;
56
62
  publish: (collection: string, id: string, locale: string) => Promise<EntryRecord>;
63
+ list: (collection: string, options?: {
64
+ where?: Record<string, unknown>;
65
+ locale?: string;
66
+ }) => Promise<EntryRecord[]>;
57
67
  find(collection: string, match: {
58
68
  slug: string;
59
69
  locale?: string;
@@ -65,6 +65,25 @@ export function createSmoodlyContent(deps) {
65
65
  async function updateEntry(collection, id, locale, fields) {
66
66
  return must(ops.entries.save(collection, id, locale, fields));
67
67
  }
68
+ /** The entries present in `locale` (the default language unless given) whose fields match every `where` key:
69
+ * shared and that locale's fields and its slug, equal, or held by an array field (tags, refList). */
70
+ async function listEntries(collection, options = {}) {
71
+ if (!deps.config.registry.collections?.some((c) => c.name === collection)) {
72
+ throw new SmoodlyContentError({ code: "not_found", message: `smoodly: no collection named "${collection}".` });
73
+ }
74
+ const locale = options.locale ?? def;
75
+ const where = Object.entries(options.where ?? {});
76
+ return (await must(ops.entries.list(collection))).filter((e) => {
77
+ const row = e.locales[locale];
78
+ if (!row)
79
+ return false;
80
+ const fields = { ...e.fields, ...row.fields, slug: row.slug };
81
+ return where.every(([key, want]) => {
82
+ const value = fields[key];
83
+ return Array.isArray(value) ? value.includes(want) : value === want;
84
+ });
85
+ });
86
+ }
68
87
  return {
69
88
  pages: {
70
89
  create: (input) => must(ops.pages.create(input)),
@@ -88,6 +107,12 @@ export function createSmoodlyContent(deps) {
88
107
  }));
89
108
  },
90
109
  publish: (id, locale) => must(ops.pages.publish(id, locale)),
110
+ /** Every page present in `locale` (the default language unless given), code-owned ones included and
111
+ * folders left out, optionally only one page type: what a script walks to change many pages. */
112
+ async list(options = {}) {
113
+ const locale = options.locale ?? def;
114
+ return (await must(ops.pages.list())).filter((p) => p.template !== null && p.locales[locale] !== undefined && (options.template === undefined || p.template === options.template));
115
+ },
91
116
  /** A code-owned address with no record gets it created the way the admin's Pages list does (spec §2). */
92
117
  async find(locale, path) {
93
118
  const found = await findPage(locale, path);
@@ -105,10 +130,9 @@ export function createSmoodlyContent(deps) {
105
130
  return options.fields ? updateEntry(collection, id, locale, options.fields) : added;
106
131
  },
107
132
  publish: (collection, id, locale) => must(ops.entries.setStatus(collection, id, locale, "published")),
133
+ list: listEntries,
108
134
  async find(collection, match) {
109
- const locale = match.locale ?? def;
110
- const all = await must(ops.entries.list(collection));
111
- return all.find((e) => e.locales[locale]?.slug === match.slug) ?? null;
135
+ return (await listEntries(collection, { where: { slug: match.slug }, locale: match.locale }))[0] ?? null;
112
136
  },
113
137
  },
114
138
  globals: {
package/dist/env.d.ts CHANGED
@@ -15,6 +15,11 @@ export type SmoodlyEnv = {
15
15
  * publishable key, so their callers pass "admin" (spec 2026-09-30 tester findings §4). A key
16
16
  * in the wrong variable or of no known form is named here, not by a failed read later. */
17
17
  export declare function resolveSmoodlyEnv(env: Record<string, string | undefined>, need?: "site" | "admin"): SmoodlyEnv;
18
+ /** The site's own address for the root layout's `metadataBase`, so canonical, hreflang and social
19
+ * image links are absolute. A value without a scheme is named here, not by a bare "Invalid URL" on
20
+ * every page; an unset one falls back to localhost and warns once in production, where it would
21
+ * put localhost into every page's head. */
22
+ export declare function siteUrl(env?: Record<string, string | undefined>): URL;
18
23
  /** `resolveSmoodlyEnv(process.env)` — call lazily, never at module load,
19
24
  * so `next build` without env still succeeds. */
20
25
  export declare const smoodlyEnv: (need?: "site" | "admin") => SmoodlyEnv;
package/dist/env.js CHANGED
@@ -41,8 +41,30 @@ export function resolveSmoodlyEnv(env, need = "site") {
41
41
  throw new Error("smoodly: SMOODLY_PUBLISHABLE_KEY is neither an sb_publishable_ key nor a JWT — copy the publishable (anon) key from your Supabase project.");
42
42
  }
43
43
  const url = env.SMOODLY_URL;
44
+ if (!/^https?:\/\/[^/\s]+/.test(url)) {
45
+ throw new Error(`smoodly: SMOODLY_URL "${url}" is not an http(s) address — copy the project URL from your Supabase project.`);
46
+ }
44
47
  return { url, secretKey, publishableKey, authUrl: env.SMOODLY_AUTH_URL || url, authKey: env.SMOODLY_AUTH_KEY || publishableKey };
45
48
  }
49
+ let siteUrlWarned = false;
50
+ /** The site's own address for the root layout's `metadataBase`, so canonical, hreflang and social
51
+ * image links are absolute. A value without a scheme is named here, not by a bare "Invalid URL" on
52
+ * every page; an unset one falls back to localhost and warns once in production, where it would
53
+ * put localhost into every page's head. */
54
+ export function siteUrl(env = process.env) {
55
+ const value = env.SITE_URL;
56
+ if (!value) {
57
+ if (env.NODE_ENV === "production" && !siteUrlWarned) {
58
+ siteUrlWarned = true;
59
+ console.warn(`smoodly: SITE_URL is not set — the page head's links point at http://localhost:3000 (Deploying: ${DEPLOYING}).`);
60
+ }
61
+ return new URL("http://localhost:3000");
62
+ }
63
+ if (!/^https?:\/\/[^/\s]+/.test(value)) {
64
+ throw new Error(`smoodly: SITE_URL "${value}" is not an http(s) address — set it like https://www.example.com.`);
65
+ }
66
+ return new URL(value);
67
+ }
46
68
  /** `resolveSmoodlyEnv(process.env)` — call lazily, never at module load,
47
69
  * so `next build` without env still succeeds. */
48
70
  export const smoodlyEnv = (need = "site") => resolveSmoodlyEnv(process.env, need);
package/dist/index.d.ts CHANGED
@@ -129,7 +129,7 @@ export declare const smoodly: {
129
129
  };
130
130
  export { NODE_ATTR, ZONE_ATTR, EMPTY_ATTR, BLANK_ATTR, FIELD_ATTR, ITEM_ATTR, EDITOR_ATTR, resolveMarkPath } from "./marks.ts";
131
131
  export type { MarkNode, MarkPath, Marks, Scope } from "./marks.ts";
132
- export { resolveSmoodlyEnv, smoodlyEnv, presenceConfig } from "./env.ts";
132
+ export { resolveSmoodlyEnv, smoodlyEnv, presenceConfig, siteUrl } from "./env.ts";
133
133
  export type { SmoodlyEnv, PresenceConfig } from "./env.ts";
134
134
  export { clientOptions, createSmoodlyClient } from "./client.ts";
135
135
  export { createSmoodlySite, pageContextOf } from "./site.ts";
package/dist/index.js CHANGED
@@ -67,6 +67,6 @@ export const smoodly = {
67
67
  marks,
68
68
  };
69
69
  export { NODE_ATTR, ZONE_ATTR, EMPTY_ATTR, BLANK_ATTR, FIELD_ATTR, ITEM_ATTR, EDITOR_ATTR, resolveMarkPath } from "./marks.js";
70
- export { resolveSmoodlyEnv, smoodlyEnv, presenceConfig } from "./env.js";
70
+ export { resolveSmoodlyEnv, smoodlyEnv, presenceConfig, siteUrl } from "./env.js";
71
71
  export { clientOptions, createSmoodlyClient } from "./client.js";
72
72
  export { createSmoodlySite, pageContextOf } from "./site.js";
@@ -76,15 +76,19 @@ export function createPageRenderer(options) {
76
76
  }
77
77
  return _jsx(View, { entry: toEntryItem(hit.entry, hit.entry.fields, hit) });
78
78
  }
79
- /** hreflang from the SAME map a language switch reads (spec 2026-09-15 §3): the locales with a URL, through href. */
80
- function alternates(hit) {
79
+ /** The page's own address as its canonical, and hreflang from the SAME map a language switch reads
80
+ * (spec 2026-09-15 §3): the locales with a URL, through href. Next makes both absolute from the
81
+ * layout's `metadataBase` (tester run on 0.0.39, mission 6). */
82
+ function addresses(hit, locale) {
83
+ const own = hit.locales[locale]?.path;
84
+ const out = own ? { canonical: href(own, locale) } : {};
81
85
  if (site.locales.supported.length < 2)
82
- return {};
86
+ return out;
83
87
  const languages = {};
84
- for (const [locale, alt] of Object.entries(hit.locales))
88
+ for (const [l, alt] of Object.entries(hit.locales))
85
89
  if (alt?.path)
86
- languages[locale] = href(alt.path, locale);
87
- return { alternates: { languages } };
90
+ languages[l] = href(alt.path, l);
91
+ return { ...out, languages };
88
92
  }
89
93
  // Next's cacheComponents serves the catch-all from a fallback shell that
90
94
  // commits 200 before the page renders, so 404s and 308s are lost (spec
@@ -137,7 +141,13 @@ export function createPageRenderer(options) {
137
141
  return {};
138
142
  // A page type without seo: true still gets its page title as <title>; seo: true adds the panel's values on top.
139
143
  const seo = hit.seo ?? (hit.kind === "page" ? { title: hit.title } : undefined);
140
- return { ...metadataFromSeo(seo), ...alternates(hit) };
144
+ const meta = metadataFromSeo(seo);
145
+ const { canonical, languages } = addresses(hit, locale);
146
+ // og:url only beside Open Graph values the page already sets: an openGraph of its own would
147
+ // replace the layout's defaults (Next merges metadata one key deep).
148
+ if (canonical && meta.openGraph)
149
+ meta.openGraph = { ...meta.openGraph, url: canonical };
150
+ return canonical || languages ? { ...meta, alternates: { ...(canonical ? { canonical } : {}), ...(languages ? { languages } : {}) } } : meta;
141
151
  }
142
152
  const any = () => true;
143
153
  /** The escape hatch may only render ITS OWN registration's record. */
@@ -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
- let query = this.db
395
- .from("entries")
396
- .select(gate ? `${SELECT}, gate:entry_locales!inner(locale)` : SELECT)
397
- .eq("collection", collection)
398
- .order("locale", { referencedTable: "entry_locales", ascending: true });
399
- if (gate) {
400
- query = query.eq("gate.locale", options.locale ?? this.locales.default);
401
- if (options.status !== undefined)
402
- query = query.eq("gate.status", options.status);
403
- }
404
- if (options?.parent !== undefined) {
405
- query = options.parent === null ? query.is("parent_id", null) : query.eq("parent_id", options.parent);
406
- }
407
- for (const [key, want] of Object.entries(options?.where ?? {})) {
408
- // Any-of compares the text form (`->>`). A scalar is JSONB containment;
409
- // an array-valued field (checkboxes, refList, list, tags) contains `[value]`,
410
- // a scalar field `value` — the memory adapter's matchesWhere agrees.
411
- if (Array.isArray(want)) {
412
- query = query.in(`fields->>${key}`, want.map(String));
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
- const inArray = ARRAY_FIELD_TYPES.has(schema.fields[key]?.type ?? "");
416
- query = query.contains("fields", { [key]: inArray ? [want] : want });
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
- const order = options?.order ?? collectionOrder(schema);
420
- if (order === "manual") {
421
- query = query.order("sort", { ascending: true, nullsFirst: false }).order("created_at", { ascending: true });
422
- }
423
- else {
424
- // `fields->x`, not `->>x`: ordering by the jsonb VALUE of the SHARED
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
- query = query.limit(options.limit);
431
- const res = await query;
432
- if (res.error)
433
- this.fail(res.error.message);
434
- return res.data.map(({ gate: _gate, ...row }) => toRecord(row));
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);
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const SMOODLY_VERSION = "0.0.38";
1
+ export declare const SMOODLY_VERSION = "0.0.40";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/write-version.mjs from package.json — do not edit.
2
- export const SMOODLY_VERSION = "0.0.38";
2
+ export const SMOODLY_VERSION = "0.0.40";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoodly",
3
- "version": "0.0.38",
3
+ "version": "0.0.40",
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": {