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
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// The content API (spec 2026-09-30 tester findings §1): the admin's
|
|
2
|
+
// operations with no signed-in editor, as a few verbs that throw. Scripts
|
|
3
|
+
// and route handlers write content through this, never through the stores,
|
|
4
|
+
// so validation, the code-owned address guards and publish checks apply.
|
|
5
|
+
import { createAdminOps } from "../admin/ops-impl.js";
|
|
6
|
+
import { imageDimensions } from "../image-dimensions.js";
|
|
7
|
+
const MIME = {
|
|
8
|
+
jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", webp: "image/webp", gif: "image/gif", avif: "image/avif",
|
|
9
|
+
svg: "image/svg+xml", mp4: "video/mp4", webm: "video/webm", pdf: "application/pdf",
|
|
10
|
+
};
|
|
11
|
+
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
12
|
+
export class SmoodlyContentError extends Error {
|
|
13
|
+
constructor(error) {
|
|
14
|
+
const list = (error.fields ?? []).map((f) => `\n ${f.field}: ${f.message}`).join("");
|
|
15
|
+
super(error.message + list);
|
|
16
|
+
this.name = "SmoodlyContentError";
|
|
17
|
+
this.code = error.code;
|
|
18
|
+
this.fields = error.fields ?? [];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async function must(result) {
|
|
22
|
+
const r = await result;
|
|
23
|
+
if (!r.ok)
|
|
24
|
+
throw new SmoodlyContentError(r);
|
|
25
|
+
return r.data;
|
|
26
|
+
}
|
|
27
|
+
/** With a password: the account is created (or an existing one kept) and made an admin. Without: an invite mail,
|
|
28
|
+
* whose link goes to `redirectTo` or the Auth project's Site URL. Running it again changes nothing.
|
|
29
|
+
* Its own function because `smoodly add-admin` has a member store and no config. */
|
|
30
|
+
export async function addAdmin(members, email, options = {}) {
|
|
31
|
+
if (!members)
|
|
32
|
+
throw new SmoodlyContentError({ code: "internal", message: "smoodly: no member store — pass `members` to createSmoodlyContent." });
|
|
33
|
+
if (!EMAIL.test(email))
|
|
34
|
+
throw new SmoodlyContentError({ code: "validation", message: `smoodly: "${email}" is not an email address.` });
|
|
35
|
+
if (options.password !== undefined && options.password.length < 8) {
|
|
36
|
+
throw new SmoodlyContentError({ code: "validation", message: "smoodly: the password needs at least 8 characters." });
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
// Where smoodly.io manages the members, nothing here writes them — as the admin's Members page refuses.
|
|
40
|
+
if ((await members.consoleUrl()) !== null) {
|
|
41
|
+
throw new SmoodlyContentError({ code: "blocked", message: "smoodly: this site's members are managed at smoodly.io." });
|
|
42
|
+
}
|
|
43
|
+
if (options.password !== undefined) {
|
|
44
|
+
await members.createAccount({ email, password: options.password, role: "admin" });
|
|
45
|
+
return { email, invited: false };
|
|
46
|
+
}
|
|
47
|
+
const { invited } = await members.invite({ email, role: "admin", redirectTo: options.redirectTo ?? "" });
|
|
48
|
+
return { email, invited };
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
if (e instanceof SmoodlyContentError)
|
|
52
|
+
throw e;
|
|
53
|
+
throw new SmoodlyContentError({ code: "internal", message: e instanceof Error ? e.message : String(e) });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export function createSmoodlyContent(deps) {
|
|
57
|
+
const ops = createAdminOps(deps);
|
|
58
|
+
const def = deps.config.locales.default;
|
|
59
|
+
async function findPage(locale, path) {
|
|
60
|
+
const hit = await deps.pages.resolvePath(locale, path);
|
|
61
|
+
if (hit?.kind === "page" && !hit.redirect)
|
|
62
|
+
return deps.pages.getPage(hit.id);
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
async function updateEntry(collection, id, locale, fields) {
|
|
66
|
+
return must(ops.entries.save(collection, id, locale, fields));
|
|
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
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
pages: {
|
|
89
|
+
create: (input) => must(ops.pages.create(input)),
|
|
90
|
+
async setTree(id, locale, tree) {
|
|
91
|
+
await must(ops.pages.saveDraft(id, locale, tree));
|
|
92
|
+
},
|
|
93
|
+
/** The locale's draft tree — what `setTree` replaces; null for a folder or a locale the page lacks.
|
|
94
|
+
* A seed reads it to tell a code-owned page nobody has filled yet. */
|
|
95
|
+
async getTree(id, locale) {
|
|
96
|
+
return (await must(ops.pages.get(id, locale))).draft;
|
|
97
|
+
},
|
|
98
|
+
async addLocale(id, locale, options) {
|
|
99
|
+
const added = await must(ops.pages.addLocale(id, locale, { from: options.from }));
|
|
100
|
+
if (options.slug === undefined && options.title === undefined)
|
|
101
|
+
return added;
|
|
102
|
+
return must(ops.pages.rename(id, {
|
|
103
|
+
locale,
|
|
104
|
+
...(options.slug !== undefined ? { slug: options.slug } : {}),
|
|
105
|
+
...(options.title !== undefined ? { title: options.title } : {}),
|
|
106
|
+
redirect: false,
|
|
107
|
+
}));
|
|
108
|
+
},
|
|
109
|
+
publish: (id, locale) => must(ops.pages.publish(id, locale)),
|
|
110
|
+
/** A code-owned address with no record gets it created the way the admin's Pages list does (spec §2). */
|
|
111
|
+
async find(locale, path) {
|
|
112
|
+
const found = await findPage(locale, path);
|
|
113
|
+
if (found)
|
|
114
|
+
return found;
|
|
115
|
+
await must(ops.pages.list());
|
|
116
|
+
return findPage(locale, path);
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
entries: {
|
|
120
|
+
create: (collection, input) => must(ops.entries.create(collection, input)),
|
|
121
|
+
update: updateEntry,
|
|
122
|
+
async addLocale(collection, id, locale, options) {
|
|
123
|
+
const added = await must(ops.entries.addLocale(collection, id, locale, { from: options.from }));
|
|
124
|
+
return options.fields ? updateEntry(collection, id, locale, options.fields) : added;
|
|
125
|
+
},
|
|
126
|
+
publish: (collection, id, locale) => must(ops.entries.setStatus(collection, id, locale, "published")),
|
|
127
|
+
list: listEntries,
|
|
128
|
+
async find(collection, match) {
|
|
129
|
+
return (await listEntries(collection, { where: { slug: match.slug }, locale: match.locale }))[0] ?? null;
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
globals: {
|
|
133
|
+
async set(name, locale, fields) {
|
|
134
|
+
await must(ops.globals.list()); // materializes the item (ensureGlobalItems), as opening Globals does
|
|
135
|
+
return must(ops.globals.save(name, locale, fields));
|
|
136
|
+
},
|
|
137
|
+
publish: (name, locale) => must(ops.globals.setStatus(name, locale, "published")),
|
|
138
|
+
},
|
|
139
|
+
media: {
|
|
140
|
+
/** A file path or bytes in, a ready asset and the value an image field stores out. */
|
|
141
|
+
async upload(file, meta = {}) {
|
|
142
|
+
if (!deps.assets)
|
|
143
|
+
throw new SmoodlyContentError({ code: "internal", message: "smoodly: no media store — pass `assets` to createSmoodlyContent." });
|
|
144
|
+
// Loaded here, not at the top: this module is reachable from `smoodly/admin`, which a bundler may walk.
|
|
145
|
+
const bytes = typeof file === "string" ? new Uint8Array(await (await import("node:fs/promises")).readFile(file)) : file;
|
|
146
|
+
const filename = meta.filename ?? (typeof file === "string" ? file.split(/[\\/]/).pop() : "upload");
|
|
147
|
+
const mime = meta.mime ?? MIME[filename.split(".").pop().toLowerCase()] ?? "application/octet-stream";
|
|
148
|
+
// The media library's own rules, as the admin's upload applies them (config.media).
|
|
149
|
+
const { accept, maxSize } = deps.config.media;
|
|
150
|
+
if (!accept.includes(mime))
|
|
151
|
+
throw new SmoodlyContentError({ code: "validation", message: `smoodly: "${filename}" is ${mime}, not an accepted file type.` });
|
|
152
|
+
if (bytes.byteLength > maxSize) {
|
|
153
|
+
throw new SmoodlyContentError({ code: "validation", message: `smoodly: "${filename}" is larger than ${Math.round(maxSize / 1024 / 1024)} MB.` });
|
|
154
|
+
}
|
|
155
|
+
const { alt, title, caption, credit } = meta;
|
|
156
|
+
let asset;
|
|
157
|
+
try {
|
|
158
|
+
asset = await deps.assets.create({ filename, mime, size: bytes.byteLength, bytes, alt, title, caption, credit, ...(imageDimensions(bytes) ?? {}) });
|
|
159
|
+
}
|
|
160
|
+
catch (e) {
|
|
161
|
+
throw new SmoodlyContentError({ code: "internal", message: e instanceof Error ? e.message : String(e) });
|
|
162
|
+
}
|
|
163
|
+
return { asset: asset.id, ...(alt !== undefined ? { alt } : {}) };
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
members: {
|
|
167
|
+
addAdmin: (email, options = {}) => addAdmin(deps.members, email, options),
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { BuilderMap, SectionSchema, StoredValues } from "../schema.ts";
|
|
2
|
+
import type { PairedComponent } from "../pairing.tsx";
|
|
3
|
+
import type { TreeNode } from "../render.tsx";
|
|
4
|
+
export declare function node<F extends BuilderMap, St extends BuilderMap>(section: PairedComponent<SectionSchema<F, St>, F, St>, fields: Partial<StoredValues<F>>, options?: {
|
|
5
|
+
settings?: Partial<StoredValues<St>>;
|
|
6
|
+
id?: string;
|
|
7
|
+
}): TreeNode;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { newNodeId } from "../admin/tree-ops.js";
|
|
2
|
+
export function node(section, fields, options = {}) {
|
|
3
|
+
const type = section.schema.name;
|
|
4
|
+
return {
|
|
5
|
+
id: options.id ?? newNodeId(type),
|
|
6
|
+
type,
|
|
7
|
+
fields: fields,
|
|
8
|
+
...(options.settings ? { settings: options.settings } : {}),
|
|
9
|
+
};
|
|
10
|
+
}
|
package/dist/env.d.ts
CHANGED
|
@@ -4,17 +4,20 @@ export type SmoodlyEnv = {
|
|
|
4
4
|
url: string;
|
|
5
5
|
/** Server-only: the service-role key (self-host) or the environment's write key (cloud). */
|
|
6
6
|
secretKey: string;
|
|
7
|
-
/** What a browser may hold for the content project: anon/publishable key, or the cloud's per-environment key. It reads no content (sign-in and presence only). */
|
|
7
|
+
/** What a browser may hold for the content project: anon/publishable key, or the cloud's per-environment key. It reads no content (sign-in and presence only). "" on a live site that runs without the admin. */
|
|
8
8
|
publishableKey: string;
|
|
9
9
|
/** Where editors sign in. Defaults to `url`. */
|
|
10
10
|
authUrl: string;
|
|
11
11
|
/** That project's publishable key. Defaults to `publishableKey`. */
|
|
12
12
|
authKey: string;
|
|
13
13
|
};
|
|
14
|
-
|
|
14
|
+
/** The live site reads with the URL and the secret key; the admin and presence also need the
|
|
15
|
+
* publishable key, so their callers pass "admin" (spec 2026-09-30 tester findings §4). A key
|
|
16
|
+
* in the wrong variable or of no known form is named here, not by a failed read later. */
|
|
17
|
+
export declare function resolveSmoodlyEnv(env: Record<string, string | undefined>, need?: "site" | "admin"): SmoodlyEnv;
|
|
15
18
|
/** `resolveSmoodlyEnv(process.env)` — call lazily, never at module load,
|
|
16
19
|
* so `next build` without env still succeeds. */
|
|
17
|
-
export declare const smoodlyEnv: () => SmoodlyEnv;
|
|
20
|
+
export declare const smoodlyEnv: (need?: "site" | "admin") => SmoodlyEnv;
|
|
18
21
|
/** What the browser needs to join the admin's presence channel: the content project's URL, publishable key and space, public values only — never for content reads (spec 2026-09-29 content API contract §8). */
|
|
19
22
|
export type PresenceConfig = {
|
|
20
23
|
url: string;
|
package/dist/env.js
CHANGED
|
@@ -1,27 +1,51 @@
|
|
|
1
1
|
// The one config shape (spec 2026-09-04 §4/§6). Self-host sets three
|
|
2
|
-
// names; the cloud sets five. The two defaults are what keep a single
|
|
2
|
+
// names (the live site alone needs two); the cloud sets five. The two defaults are what keep a single
|
|
3
3
|
// code path: a default for an absent value is not a mode branch.
|
|
4
4
|
import { createSmoodlyClient } from "./client.js";
|
|
5
5
|
import { readCurrentSpace } from "./current-space.js";
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
const SITE_NEEDS = ["SMOODLY_URL", "SMOODLY_SECRET_KEY"];
|
|
7
|
+
const ADMIN_NEEDS = [...SITE_NEEDS, "SMOODLY_PUBLISHABLE_KEY"];
|
|
8
|
+
const DEPLOYING = "https://smoodly.io/docs/getting-started/deploying";
|
|
9
|
+
const isJwt = (v) => v.split(".").length === 3;
|
|
10
|
+
/** A legacy JWT key's role claim ("anon", "service_role"; "authenticated" for a cloud space key); null when it cannot be read. */
|
|
11
|
+
function jwtRole(v) {
|
|
12
|
+
if (!isJwt(v))
|
|
13
|
+
return null;
|
|
14
|
+
try {
|
|
15
|
+
const role = JSON.parse(atob(v.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))).role;
|
|
16
|
+
return typeof role === "string" ? role : null;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** The live site reads with the URL and the secret key; the admin and presence also need the
|
|
23
|
+
* publishable key, so their callers pass "admin" (spec 2026-09-30 tester findings §4). A key
|
|
24
|
+
* in the wrong variable or of no known form is named here, not by a failed read later. */
|
|
25
|
+
export function resolveSmoodlyEnv(env, need = "site") {
|
|
26
|
+
const missing = (need === "admin" ? ADMIN_NEEDS : SITE_NEEDS).filter((name) => !env[name]);
|
|
27
|
+
if (missing.length > 0)
|
|
28
|
+
throw new Error(`smoodly: set ${missing.join(", ")} in .env.local (Deploying: ${DEPLOYING}).`);
|
|
29
|
+
const secretKey = env.SMOODLY_SECRET_KEY;
|
|
30
|
+
const publishableKey = env.SMOODLY_PUBLISHABLE_KEY ?? "";
|
|
31
|
+
if (secretKey.startsWith("sb_publishable_") || jwtRole(secretKey) === "anon") {
|
|
32
|
+
throw new Error("smoodly: SMOODLY_SECRET_KEY holds a publishable key — copy the secret (service-role) key from your Supabase project.");
|
|
33
|
+
}
|
|
34
|
+
if (!secretKey.startsWith("sb_secret_") && !isJwt(secretKey)) {
|
|
35
|
+
throw new Error("smoodly: SMOODLY_SECRET_KEY is neither an sb_secret_ key nor a JWT — copy the secret (service-role) key from your Supabase project.");
|
|
36
|
+
}
|
|
37
|
+
if (publishableKey.startsWith("sb_secret_") || jwtRole(publishableKey) === "service_role") {
|
|
38
|
+
throw new Error("smoodly: SMOODLY_PUBLISHABLE_KEY holds a secret key — it reaches the browser; copy the publishable (anon) key from your Supabase project.");
|
|
39
|
+
}
|
|
40
|
+
if (publishableKey && !publishableKey.startsWith("sb_publishable_") && !isJwt(publishableKey)) {
|
|
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.");
|
|
11
42
|
}
|
|
12
43
|
const url = env.SMOODLY_URL;
|
|
13
|
-
|
|
14
|
-
return {
|
|
15
|
-
url,
|
|
16
|
-
secretKey: env.SMOODLY_SECRET_KEY,
|
|
17
|
-
publishableKey,
|
|
18
|
-
authUrl: env.SMOODLY_AUTH_URL || url,
|
|
19
|
-
authKey: env.SMOODLY_AUTH_KEY || publishableKey,
|
|
20
|
-
};
|
|
44
|
+
return { url, secretKey, publishableKey, authUrl: env.SMOODLY_AUTH_URL || url, authKey: env.SMOODLY_AUTH_KEY || publishableKey };
|
|
21
45
|
}
|
|
22
46
|
/** `resolveSmoodlyEnv(process.env)` — call lazily, never at module load,
|
|
23
47
|
* so `next build` without env still succeeds. */
|
|
24
|
-
export const smoodlyEnv = () => resolveSmoodlyEnv(process.env);
|
|
48
|
+
export const smoodlyEnv = (need = "site") => resolveSmoodlyEnv(process.env, need);
|
|
25
49
|
/** One lookup per process per content project and key: the space never
|
|
26
50
|
* changes for a key. A failed lookup is not cached, so the next call retries. */
|
|
27
51
|
const spaces = new Map();
|
package/dist/index.d.ts
CHANGED
|
@@ -66,7 +66,8 @@ export type { PageStore, PageRecord, PageLocale, PageVersion, PageScan, PageStor
|
|
|
66
66
|
export { pathOptions, pageErrors, localeRow, pageRefEdges, DEFAULT_LOCALES } from "./store.ts";
|
|
67
67
|
export type { SmoodlyConfig, ResolvedConfig } from "./config.ts";
|
|
68
68
|
export type { FieldBuilder, Descriptor, ValueOf, Option, OptionInput, OptionValue } from "./fields.ts";
|
|
69
|
-
export type { SectionSchema, ElementSchema, BuilderMap, Values } from "./schema.ts";
|
|
69
|
+
export type { SectionSchema, ElementSchema, BuilderMap, Values, StoredValues } from "./schema.ts";
|
|
70
|
+
export { node } from "./content/node.ts";
|
|
70
71
|
export type { CollectionSeo } from "./seo.ts";
|
|
71
72
|
export type { EntryRef, EntryItem, PageItem, GlobalItem, VisualGlobalItem, Seo } from "./read-shapes.ts";
|
|
72
73
|
export type { RefTo } from "./fields.ts";
|
|
@@ -97,7 +98,7 @@ export declare const smoodly: {
|
|
|
97
98
|
omit: string[];
|
|
98
99
|
};
|
|
99
100
|
css?: false;
|
|
100
|
-
sample?: Partial<
|
|
101
|
+
sample?: Partial<import("./schema.ts").StoredValues<F> & import("./schema.ts").StoredValues<S>> | undefined;
|
|
101
102
|
}): import("./schema.ts").SectionSchema<F, S>;
|
|
102
103
|
element<F extends import("./schema.ts").BuilderMap = {}, S extends import("./schema.ts").BuilderMap = {}>(input: {
|
|
103
104
|
name: string;
|
|
@@ -111,7 +112,7 @@ export declare const smoodly: {
|
|
|
111
112
|
omit: string[];
|
|
112
113
|
};
|
|
113
114
|
css?: false;
|
|
114
|
-
sample?: Partial<
|
|
115
|
+
sample?: Partial<import("./schema.ts").StoredValues<F> & import("./schema.ts").StoredValues<S>> | undefined;
|
|
115
116
|
} & {
|
|
116
117
|
contexts: ("richtext" | "column")[];
|
|
117
118
|
}): import("./schema.ts").ElementSchema<F, S>;
|
package/dist/index.js
CHANGED
|
@@ -46,6 +46,7 @@ export { fixedPathFor } from "./localize.js";
|
|
|
46
46
|
export { addressMap, assertAddress, codeAddresses, collectionKey, lastPart, ownerCollection, pageKey } from "./addresses.js";
|
|
47
47
|
export { anchorUrl, anchorsFor, candidates, fullPath, pickHit } from "./anchors.js";
|
|
48
48
|
export { pathOptions, pageErrors, localeRow, pageRefEdges, DEFAULT_LOCALES } from "./store.js";
|
|
49
|
+
export { node } from "./content/node.js";
|
|
49
50
|
export { DEFAULT_TOOLSET } from "./toolset.js";
|
|
50
51
|
export { collectionOrder, collectionDepth, DEFAULT_ORDER } from "./collections.js";
|
|
51
52
|
export { GLOBAL_NODE_TYPE, SETTINGS_KEY, STYLES_KEY, CSS_KEY, globalLink } from "./global.js";
|
package/dist/member-store.d.ts
CHANGED
|
@@ -21,6 +21,13 @@ export interface MemberStore {
|
|
|
21
21
|
consoleUrl(): Promise<string | null>;
|
|
22
22
|
/** Creates or finds the Auth user for the email, mails the invite when it created one, upserts the row (spec 2026-09-25 members management §3). */
|
|
23
23
|
invite(input: InviteInput): Promise<InviteResult>;
|
|
24
|
+
/** Creates the Auth user with a password (confirmed, no mail) and upserts the row — the installer's first editor, `add-admin`.
|
|
25
|
+
* An existing account keeps its password and only gains the membership. */
|
|
26
|
+
createAccount(input: {
|
|
27
|
+
email: string;
|
|
28
|
+
password: string;
|
|
29
|
+
role: MemberRole;
|
|
30
|
+
}): Promise<MemberRecord>;
|
|
24
31
|
setRole(userId: string, role: MemberRole): Promise<MemberRecord>;
|
|
25
32
|
/** Deletes the member row only; the Auth account stays. */
|
|
26
33
|
remove(userId: string): Promise<void>;
|
|
@@ -44,6 +51,11 @@ export declare class MemoryMemberStore implements MemberStore {
|
|
|
44
51
|
list(): Promise<MemberRecord[]>;
|
|
45
52
|
consoleUrl(): Promise<string | null>;
|
|
46
53
|
invite(input: InviteInput): Promise<InviteResult>;
|
|
54
|
+
createAccount(input: {
|
|
55
|
+
email: string;
|
|
56
|
+
password: string;
|
|
57
|
+
role: MemberRole;
|
|
58
|
+
}): Promise<MemberRecord>;
|
|
47
59
|
setRole(userId: string, role: MemberRole): Promise<MemberRecord>;
|
|
48
60
|
remove(userId: string): Promise<void>;
|
|
49
61
|
}
|
package/dist/member-store.js
CHANGED
|
@@ -44,6 +44,14 @@ export class MemoryMemberStore {
|
|
|
44
44
|
this.rows = [...this.rows.filter((r) => r.userId !== userId), member];
|
|
45
45
|
return { member, invited };
|
|
46
46
|
}
|
|
47
|
+
async createAccount(input) {
|
|
48
|
+
const key = input.email.toLowerCase();
|
|
49
|
+
const userId = this.accounts[key] ?? this.rows.find((r) => r.email?.toLowerCase() === key)?.userId ?? crypto.randomUUID();
|
|
50
|
+
this.accounts[key] = userId;
|
|
51
|
+
const member = { userId, email: input.email, role: input.role };
|
|
52
|
+
this.rows = [...this.rows.filter((r) => r.userId !== userId), member];
|
|
53
|
+
return member;
|
|
54
|
+
}
|
|
47
55
|
async setRole(userId, role) {
|
|
48
56
|
const row = this.rows.find((r) => r.userId === userId);
|
|
49
57
|
if (!row)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function modelKey(model: unknown): string;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// A short, stable hash of the registered model (spec 2026-09-30 tester findings §3):
|
|
2
|
+
// object keys sorted, functions dropped, cycles cut — two processes agree.
|
|
3
|
+
// Plain arithmetic, no node:crypto: the root entry is imported by client sections too.
|
|
4
|
+
function canonical(value, seen) {
|
|
5
|
+
if (typeof value === "function" || value === undefined)
|
|
6
|
+
return undefined;
|
|
7
|
+
if (value === null || typeof value !== "object")
|
|
8
|
+
return value;
|
|
9
|
+
if (seen.has(value))
|
|
10
|
+
return "[cycle]";
|
|
11
|
+
seen.add(value);
|
|
12
|
+
const out = Array.isArray(value)
|
|
13
|
+
? value.map((v) => canonical(v, seen) ?? null)
|
|
14
|
+
: Object.fromEntries(Object.keys(value).sort().flatMap((k) => {
|
|
15
|
+
const v = canonical(value[k], seen);
|
|
16
|
+
return v === undefined ? [] : [[k, v]];
|
|
17
|
+
}));
|
|
18
|
+
seen.delete(value);
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
/** Two 32-bit FNV-1a style runs with different seeds (cyrb53's mixing): 64 bits, of which 48 are kept. */
|
|
22
|
+
function hash(text) {
|
|
23
|
+
let h1 = 0xdeadbeef, h2 = 0x41c6ce57;
|
|
24
|
+
for (let i = 0; i < text.length; i++) {
|
|
25
|
+
const c = text.charCodeAt(i);
|
|
26
|
+
h1 = Math.imul(h1 ^ c, 2654435761);
|
|
27
|
+
h2 = Math.imul(h2 ^ c, 1597334677);
|
|
28
|
+
}
|
|
29
|
+
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
|
30
|
+
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
|
31
|
+
const hex = (n) => (n >>> 0).toString(16).padStart(8, "0");
|
|
32
|
+
return (hex(h2) + hex(h1)).slice(0, 12);
|
|
33
|
+
}
|
|
34
|
+
export function modelKey(model) {
|
|
35
|
+
return hash(JSON.stringify(canonical(model, new WeakSet())));
|
|
36
|
+
}
|
|
@@ -39,7 +39,7 @@ export function createPageRenderer(options) {
|
|
|
39
39
|
// Keyed by what the unit holds, not only where: an address that changes
|
|
40
40
|
// owner — a deploy handing it to a code record, which fires no tag —
|
|
41
41
|
// can never serve the previous occupant (spec 2026-09-29 pre-install fixes §5).
|
|
42
|
-
return unstable_cache(() => site.load(target, locale, path), ["smoodly-site", locale, path, target.kind, target.id], {
|
|
42
|
+
return unstable_cache(() => site.load(target, locale, path), ["smoodly-site", site.cacheKey, locale, path, target.kind, target.id], {
|
|
43
43
|
tags: [target.kind === "page" ? pageTag(target.id) : entryTag(target.id)],
|
|
44
44
|
})();
|
|
45
45
|
}
|
|
@@ -114,7 +114,8 @@ export function createPageRenderer(options) {
|
|
|
114
114
|
const target = await site.locate(locale, path);
|
|
115
115
|
if (target?.redirect)
|
|
116
116
|
await redirectTo(target, locale, params);
|
|
117
|
-
|
|
117
|
+
// A code-owned address with no record yet is served from code (spec 2026-09-30 tester findings §2).
|
|
118
|
+
const hit = target ? await load(target, locale, path, draft, version) : site.codePage(locale, path);
|
|
118
119
|
if (!hit || !accept(hit))
|
|
119
120
|
notFound();
|
|
120
121
|
// The draft marker tells the page editor's canvas diagnosis that Smoodly
|
|
@@ -129,9 +130,9 @@ export function createPageRenderer(options) {
|
|
|
129
130
|
const path = pathFor(locale);
|
|
130
131
|
const { isEnabled: draft } = await draftMode();
|
|
131
132
|
const target = await site.locate(locale, path);
|
|
132
|
-
if (
|
|
133
|
+
if (target?.redirect)
|
|
133
134
|
return {};
|
|
134
|
-
const hit = await load(target, locale, path, draft);
|
|
135
|
+
const hit = target ? await load(target, locale, path, draft) : site.codePage(locale, path);
|
|
135
136
|
if (!hit || !accept(hit))
|
|
136
137
|
return {};
|
|
137
138
|
// A page type without seo: true still gets its page title as <title>; seo: true adds the panel's values on top.
|
|
@@ -153,7 +154,7 @@ export function createPageRenderer(options) {
|
|
|
153
154
|
const { isEnabled: draft } = await draftMode();
|
|
154
155
|
if (draft)
|
|
155
156
|
return site.getGlobal(item, { locale, draft: true });
|
|
156
|
-
return unstable_cache(() => site.getGlobal(item, { locale }), ["smoodly-global", item.name, locale], {
|
|
157
|
+
return unstable_cache(() => site.getGlobal(item, { locale }), ["smoodly-global", site.cacheKey, item.name, locale], {
|
|
157
158
|
tags: [globalTag(item.name)],
|
|
158
159
|
})();
|
|
159
160
|
}
|
package/dist/next/queries.js
CHANGED
|
@@ -36,7 +36,7 @@ export function createSmoodlyQueries(options) {
|
|
|
36
36
|
const { isEnabled: draft } = await draftMode();
|
|
37
37
|
if (draft)
|
|
38
38
|
return read(true);
|
|
39
|
-
return unstable_cache(() => read(false), ["smoodly-q", ...key], { tags })();
|
|
39
|
+
return unstable_cache(() => read(false), ["smoodly-q", site.cacheKey, ...key], { tags })();
|
|
40
40
|
}
|
|
41
41
|
/** The tag needs the id: a path is located first, uncached, as the renderer does. */
|
|
42
42
|
async function idOf(kind, locale, pathOrId) {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
2
|
+
/** The policies show `smoodly_meta` to a write key only, so no visible row means the key is not a secret key (spec §5). */
|
|
3
|
+
export declare function readSchemaVersion(db: SupabaseClient): Promise<{
|
|
4
|
+
version: number;
|
|
5
|
+
} | {
|
|
6
|
+
missing: "key" | "schema";
|
|
7
|
+
}>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { SCHEMA_VERSION_KEY } from "./schema-version.js";
|
|
2
|
+
/** The policies show `smoodly_meta` to a write key only, so no visible row means the key is not a secret key (spec §5). */
|
|
3
|
+
export async function readSchemaVersion(db) {
|
|
4
|
+
const res = await db.from("smoodly_meta").select("value").eq("key", SCHEMA_VERSION_KEY).maybeSingle();
|
|
5
|
+
if (res.error) {
|
|
6
|
+
// PGRST205: not in PostgREST's schema cache; 42P01: undefined table; 42501: no grant.
|
|
7
|
+
if (res.error.code === "PGRST205" || res.error.code === "42P01")
|
|
8
|
+
return { missing: "schema" };
|
|
9
|
+
if (res.error.code === "42501")
|
|
10
|
+
return { missing: "key" };
|
|
11
|
+
throw new Error(`smoodly: reading the schema version: ${res.error.message}`);
|
|
12
|
+
}
|
|
13
|
+
const n = Number(res.data?.value);
|
|
14
|
+
return res.data && Number.isInteger(n) ? { version: n } : { missing: "key" };
|
|
15
|
+
}
|
package/dist/schema-version.d.ts
CHANGED
|
@@ -15,3 +15,5 @@ export declare function schemaVersionSQL(version: number): string;
|
|
|
15
15
|
* bump, and is fine. */
|
|
16
16
|
export declare const schemaBehind: (database: number | null, required?: number) => boolean;
|
|
17
17
|
export declare function schemaBehindMessage(database: number | null, required?: number): string;
|
|
18
|
+
/** The version row is hidden from anything but a secret key, so an invisible row means the wrong key, not an old schema. */
|
|
19
|
+
export declare const secretKeyRefusedMessage = "smoodly: SMOODLY_SECRET_KEY cannot read the schema version \u2014 it is not the project's secret (service-role) key; the publishable key there is the usual mix-up.";
|
package/dist/schema-version.js
CHANGED
|
@@ -28,5 +28,7 @@ export function schemaVersionSQL(version) {
|
|
|
28
28
|
export const schemaBehind = (database, required = SCHEMA_VERSION) => database === null || database < required;
|
|
29
29
|
export function schemaBehindMessage(database, required = SCHEMA_VERSION) {
|
|
30
30
|
return (`smoodly: the database schema is at version ${database ?? "none"}; this Smoodly needs version ${required}. ` +
|
|
31
|
-
"Run `npx smoodly migrate`, then `supabase db push` (hosted) or `supabase
|
|
31
|
+
"Run `npx smoodly migrate`, then apply it: `supabase db push` (hosted) or `supabase migration up` (local).");
|
|
32
32
|
}
|
|
33
|
+
/** The version row is hidden from anything but a secret key, so an invisible row means the wrong key, not an old schema. */
|
|
34
|
+
export const secretKeyRefusedMessage = "smoodly: SMOODLY_SECRET_KEY cannot read the schema version — it is not the project's secret (service-role) key; the publishable key there is the usual mix-up.";
|
package/dist/schema.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { type Descriptor, type FieldBuilder, type ValueOf } from "./fields.ts";
|
|
|
2
2
|
import { pageSchema } from "./page.ts";
|
|
3
3
|
import type { RefTo } from "./fields.ts";
|
|
4
4
|
import type { EntryRef } from "./read-shapes.ts";
|
|
5
|
+
import type { FileValue, ImageValue, ResolvedFile, ResolvedImage, ResolvedVideo, VideoValue } from "./media.ts";
|
|
5
6
|
export type BuilderMap = Record<string, FieldBuilder<any, any>>;
|
|
6
7
|
export type SectionSchema<F extends BuilderMap = BuilderMap, S extends BuilderMap = BuilderMap> = {
|
|
7
8
|
kind: "section";
|
|
@@ -54,9 +55,14 @@ export type Materialize<V> = V extends RefTo<infer T, infer M> ? EntryRef<ShapeO
|
|
|
54
55
|
export type Values<M extends BuilderMap> = {
|
|
55
56
|
[K in keyof NamespaceValues<M>]: Materialize<NamespaceValues<M>[K]>;
|
|
56
57
|
};
|
|
57
|
-
/**
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
/** One value as storage holds it: a ref is the entry's id, media is the `{ asset }` value, and lists and
|
|
59
|
+
* objects hold the same inside. Richtext (`unknown`) and scalars pass through. */
|
|
60
|
+
export type StoredOf<V> = unknown extends V ? V : V extends RefTo<any, any> ? string : V extends ResolvedImage ? ImageValue : V extends ResolvedVideo ? VideoValue : V extends ResolvedFile ? FileValue : V extends readonly (infer I)[] ? StoredOf<I>[] : V extends Record<string, unknown> ? {
|
|
61
|
+
[K in keyof V]: StoredOf<V[K]>;
|
|
62
|
+
} : V;
|
|
63
|
+
/** What storage holds for a field map (a sample, a node built from code). */
|
|
64
|
+
export type StoredValues<M extends BuilderMap> = {
|
|
65
|
+
[K in keyof NamespaceValues<M>]: StoredOf<NamespaceValues<M>[K]>;
|
|
60
66
|
};
|
|
61
67
|
type SectionInput<F extends BuilderMap, S extends BuilderMap> = {
|
|
62
68
|
name: string;
|
|
@@ -71,7 +77,7 @@ type SectionInput<F extends BuilderMap, S extends BuilderMap> = {
|
|
|
71
77
|
omit: string[];
|
|
72
78
|
};
|
|
73
79
|
css?: false;
|
|
74
|
-
sample?: Partial<
|
|
80
|
+
sample?: Partial<StoredValues<F> & StoredValues<S>>;
|
|
75
81
|
};
|
|
76
82
|
export declare const schema: {
|
|
77
83
|
page: typeof pageSchema;
|
package/dist/site.d.ts
CHANGED
|
@@ -108,6 +108,9 @@ export type LoadOptions = {
|
|
|
108
108
|
export interface SmoodlySite {
|
|
109
109
|
readonly locales: LocaleSet;
|
|
110
110
|
readonly homePageSlug: string;
|
|
111
|
+
/** The package version and a hash of the registered model: a part of every cache key, so an upgrade or a
|
|
112
|
+
* model change never serves a unit cached in the old shape (spec 2026-09-30 tester findings §3). */
|
|
113
|
+
readonly cacheKey: string;
|
|
111
114
|
/** The resolved styles values the renderer and `getGlobal` hand the wrapper. */
|
|
112
115
|
readonly styles: StyleValues;
|
|
113
116
|
/** The custom section's resolved options the renderer hands renderPage. */
|
|
@@ -119,6 +122,11 @@ export interface SmoodlySite {
|
|
|
119
122
|
/** Everything the renderer needs for one target: null when it is a folder, unpublished (outside draft), or gone. */
|
|
120
123
|
load(target: PathTarget, locale: string, path: string, options?: LoadOptions): Promise<SiteHit | null>;
|
|
121
124
|
resolve(locale: string, path: string, options?: LoadOptions): Promise<SiteHit | null>;
|
|
125
|
+
/** A fixed page type (or a collection's index page) as code alone describes it, when `path` is its address in
|
|
126
|
+
* `locale`: an empty tree, the page fields' defaults, the registration's title. The renderer serves it where
|
|
127
|
+
* the index has no row — a page type added after install, a locale added to the config — until the admin
|
|
128
|
+
* makes the record (spec 2026-09-30 tester findings §2). Not in `getPagesTree`: that lists records. */
|
|
129
|
+
codePage(locale: string, path: string): SitePage | null;
|
|
122
130
|
getPagesTree(options?: {
|
|
123
131
|
locale?: string;
|
|
124
132
|
draft?: boolean;
|