smoodly 0.0.23 → 0.0.25

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.
Files changed (135) hide show
  1. package/README.md +192 -43
  2. package/bin/smoodly.js +6 -0
  3. package/dist/admin/auth.d.ts +10 -5
  4. package/dist/admin/auth.js +14 -13
  5. package/dist/admin/client-ops.js +2 -1
  6. package/dist/admin/editor/EditorView.js +20 -6
  7. package/dist/admin/editor/InsertPicker.d.ts +14 -0
  8. package/dist/admin/editor/InsertPicker.js +37 -7
  9. package/dist/admin/editor/InspectorFields.js +1 -1
  10. package/dist/admin/editor/Outline.js +2 -2
  11. package/dist/admin/editor/PageSettings.js +6 -0
  12. package/dist/admin/editor/SeoSettings.js +1 -1
  13. package/dist/admin/editor/locale-url.d.ts +7 -4
  14. package/dist/admin/editor/locale-url.js +14 -7
  15. package/dist/admin/editor/usePageLocale.js +9 -1
  16. package/dist/admin/forms/CropEditor.d.ts +4 -2
  17. package/dist/admin/forms/CropEditor.js +6 -3
  18. package/dist/admin/forms/DatePicker.d.ts +9 -0
  19. package/dist/admin/forms/DatePicker.js +133 -0
  20. package/dist/admin/forms/FieldWidget.js +11 -5
  21. package/dist/admin/forms/ImagePreview.d.ts +9 -0
  22. package/dist/admin/forms/ImagePreview.js +11 -0
  23. package/dist/admin/forms/MediaWidget.d.ts +8 -2
  24. package/dist/admin/forms/MediaWidget.js +13 -13
  25. package/dist/admin/forms/RichtextElementCard.js +1 -1
  26. package/dist/admin/forms/RichtextImageCard.js +14 -9
  27. package/dist/admin/forms/date-picker.d.ts +44 -0
  28. package/dist/admin/forms/date-picker.js +117 -0
  29. package/dist/admin/forms/elements-context.d.ts +3 -0
  30. package/dist/admin/forms/elements-context.js +7 -2
  31. package/dist/admin/forms/time-zone-context.d.ts +7 -0
  32. package/dist/admin/forms/time-zone-context.js +15 -0
  33. package/dist/admin/forms/useRichtextEditor.js +10 -1
  34. package/dist/admin/index.d.ts +2 -0
  35. package/dist/admin/next/bridge.d.ts +2 -1
  36. package/dist/admin/next/bridge.js +35 -5
  37. package/dist/admin/next/index.d.ts +2 -0
  38. package/dist/admin/ops-impl.js +73 -1
  39. package/dist/admin/ops.d.ts +22 -2
  40. package/dist/admin/serialize.d.ts +2 -0
  41. package/dist/admin/serialize.js +1 -0
  42. package/dist/admin/shell/AdminApp.js +48 -3
  43. package/dist/admin/shell/EntriesList.js +12 -21
  44. package/dist/admin/shell/EntryForm.js +63 -5
  45. package/dist/admin/shell/GlobalForm.js +22 -2
  46. package/dist/admin/shell/GlobalList.js +4 -18
  47. package/dist/admin/shell/IconRail.d.ts +3 -1
  48. package/dist/admin/shell/IconRail.js +17 -7
  49. package/dist/admin/shell/InviteModal.d.ts +6 -0
  50. package/dist/admin/shell/InviteModal.js +28 -0
  51. package/dist/admin/shell/ListView.js +2 -2
  52. package/dist/admin/shell/LoginView.js +34 -5
  53. package/dist/admin/shell/MediaScreen.js +11 -1
  54. package/dist/admin/shell/MembersPage.d.ts +2 -1
  55. package/dist/admin/shell/MembersPage.js +90 -25
  56. package/dist/admin/shell/PageDialogs.d.ts +26 -0
  57. package/dist/admin/shell/PageDialogs.js +38 -0
  58. package/dist/admin/shell/PagesList.js +100 -70
  59. package/dist/admin/shell/RouteLink.js +1 -1
  60. package/dist/admin/shell/SchemaNotice.d.ts +4 -0
  61. package/dist/admin/shell/SchemaNotice.js +4 -0
  62. package/dist/admin/shell/SetPasswordView.d.ts +7 -0
  63. package/dist/admin/shell/SetPasswordView.js +41 -0
  64. package/dist/admin/shell/SettingsView.d.ts +5 -1
  65. package/dist/admin/shell/SettingsView.js +17 -11
  66. package/dist/admin/shell/live-url.d.ts +10 -0
  67. package/dist/admin/shell/live-url.js +19 -0
  68. package/dist/admin/shell/locale-memory.d.ts +8 -0
  69. package/dist/admin/shell/locale-memory.js +23 -0
  70. package/dist/admin/shell/members-list.d.ts +15 -1
  71. package/dist/admin/shell/members-list.js +24 -7
  72. package/dist/admin/shell/page-dialogs.d.ts +9 -0
  73. package/dist/admin/shell/page-dialogs.js +19 -0
  74. package/dist/admin/shell/router.d.ts +10 -7
  75. package/dist/admin/shell/router.js +18 -9
  76. package/dist/admin/shell/session.d.ts +6 -0
  77. package/dist/admin/shell/session.js +17 -2
  78. package/dist/admin/shell/settings.d.ts +0 -6
  79. package/dist/admin/shell/settings.js +4 -5
  80. package/dist/admin/supabase-auth.js +6 -5
  81. package/dist/admin/tree-ops.js +3 -0
  82. package/dist/admin/ui/ActionMenu.js +26 -6
  83. package/dist/admin/ui/Combobox.d.ts +5 -0
  84. package/dist/admin/ui/Combobox.js +1 -1
  85. package/dist/admin/ui/ConfirmDialog.d.ts +8 -0
  86. package/dist/admin/ui/ConfirmDialog.js +30 -0
  87. package/dist/admin/ui/Dialog.d.ts +13 -0
  88. package/dist/admin/ui/Dialog.js +54 -0
  89. package/dist/admin/ui/HistoryModal.d.ts +2 -1
  90. package/dist/admin/ui/HistoryModal.js +7 -2
  91. package/dist/admin/ui/ResizeHandle.js +4 -4
  92. package/dist/admin/ui/confirm.d.ts +35 -0
  93. package/dist/admin/ui/confirm.js +57 -0
  94. package/dist/admin/ui/primitives.d.ts +19 -4
  95. package/dist/admin/ui/primitives.js +11 -5
  96. package/dist/admin/ui/resize.d.ts +7 -4
  97. package/dist/admin/ui/resize.js +7 -4
  98. package/dist/admin/ui/theme.d.ts +1 -1
  99. package/dist/admin/ui/theme.js +368 -203
  100. package/dist/admin/validate.d.ts +4 -0
  101. package/dist/admin/validate.js +28 -0
  102. package/dist/cli/load-collections.d.ts +2 -0
  103. package/dist/cli/load-collections.js +26 -0
  104. package/dist/cli/main.d.ts +13 -0
  105. package/dist/cli/main.js +62 -0
  106. package/dist/cli/migrate.d.ts +23 -0
  107. package/dist/cli/migrate.js +64 -0
  108. package/dist/config.d.ts +4 -0
  109. package/dist/config.js +9 -0
  110. package/dist/fields.d.ts +2 -0
  111. package/dist/fields.js +2 -0
  112. package/dist/index.d.ts +6 -3
  113. package/dist/index.js +4 -2
  114. package/dist/marks.d.ts +3 -0
  115. package/dist/marks.js +3 -0
  116. package/dist/member-store.d.ts +30 -1
  117. package/dist/member-store.js +37 -6
  118. package/dist/migrations.d.ts +37 -0
  119. package/dist/migrations.js +73 -0
  120. package/dist/permissions.d.ts +7 -0
  121. package/dist/permissions.js +19 -0
  122. package/dist/schema-version.d.ts +17 -0
  123. package/dist/schema-version.js +32 -0
  124. package/dist/sql-space.js +6 -0
  125. package/dist/sql.js +10 -0
  126. package/dist/store.d.ts +3 -0
  127. package/dist/store.js +4 -0
  128. package/dist/supabase-member-store.d.ts +8 -1
  129. package/dist/supabase-member-store.js +62 -2
  130. package/dist/supabase-store.d.ts +3 -0
  131. package/dist/supabase-store.js +23 -0
  132. package/dist/time-zone.d.ts +6 -0
  133. package/dist/time-zone.js +27 -0
  134. package/migrations/20260926120000_smoodly_v1.sql +535 -0
  135. package/package.json +13 -3
@@ -22,6 +22,10 @@ export type ElementDescs = Record<string, {
22
22
  fields: Record<string, Desc>;
23
23
  settings?: Record<string, Desc>;
24
24
  }>;
25
+ /** The errors of `previous` that a fresh validation still reports, with the
26
+ * fresh message: a form's refusal list shrinks as its fields are fixed and
27
+ * never grows from typing (2026-09-25) — only a publish adds. */
28
+ export declare function stillFailing(previous: Record<string, string>, fresh: FieldError[]): Record<string, string>;
25
29
  /** Validate one value map. Without `scope.fields` the values ARE the fields (an entry, a page, an object global). */
26
30
  export declare function validateFields(values: Record<string, unknown>, descriptors: Record<string, Desc>, elements?: ElementDescs, scope?: ValidationScope): FieldError[];
27
31
  /** A tree node (or a visual global, in node shape) against its schema: fields, settings (prefixed), css (spec 2026-09-14 §3, §4). */
@@ -12,14 +12,21 @@ import { normalizeTag, sameTag } from "../tags.js";
12
12
  import { hasContent } from "./richtext-doc.js";
13
13
  import { ELEMENT_NODE, richtextBlocks } from "../richtext-walk.js";
14
14
  import { rowsProblems } from "../custom/rows.js";
15
+ import { isCalendarDay, isInstant } from "./forms/date-picker.js";
15
16
  const subfields = (d) => d.fields && typeof d.fields === "object" ? d.fields : {};
16
17
  const itemOf = (d) => d.item && typeof d.item === "object" ? d.item : undefined;
17
18
  const isEmpty = (v) => v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0);
19
+ /** An object holding nothing but empty values, at any depth — `{}`, `{ label: "" }`. */
20
+ const isBlankObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v) &&
21
+ Object.values(v).every((x) => isEmpty(x) || isBlankObject(x));
18
22
  /** An object whose every subfield is optional — or is itself an object that
19
23
  * may be absent — may be absent altogether. */
20
24
  const mayBeAbsent = (d) => d.optional === true || d.type === "boolean" || (d.type === "object" && Object.values(subfields(d)).every(mayBeAbsent));
21
25
  const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
22
26
  function validateValue(path, value, d, errors, elements, scope) {
27
+ // An optional object with nothing filled in is absent (drafts inserted before 2026-09-26 carry `{}`).
28
+ if (d.type === "object" && d.optional === true && isBlankObject(value))
29
+ return;
23
30
  if (isEmpty(value)) {
24
31
  if (!mayBeAbsent(d))
25
32
  errors.push({ field: path, message: "Required." });
@@ -49,6 +56,17 @@ function validateValue(path, value, d, errors, elements, scope) {
49
56
  }
50
57
  return;
51
58
  }
59
+ // Dates (spec 2026-09-25 date pickers §2): the stored formats exactly, and a day the calendar has.
60
+ if (d.type === "date") {
61
+ if (typeof value !== "string" || !isCalendarDay(value))
62
+ errors.push({ field: path, message: "Not a date (2026-09-02)." });
63
+ return;
64
+ }
65
+ if (d.type === "datetime") {
66
+ if (typeof value !== "string" || !isInstant(value))
67
+ errors.push({ field: path, message: "Not a date and time." });
68
+ return;
69
+ }
52
70
  if (d.type === "image" || d.type === "video" || d.type === "file") {
53
71
  if (typeof value !== "object" || Array.isArray(value) || typeof value.asset !== "string") {
54
72
  errors.push({ field: path, message: "Must be an uploaded file." });
@@ -151,6 +169,16 @@ function validateMap(prefix, values, descriptors, errors, elements, scope) {
151
169
  validateValue(prefix === "" ? key : `${prefix}.${key}`, values[key], d, errors, elements, scope);
152
170
  }
153
171
  }
172
+ /** The errors of `previous` that a fresh validation still reports, with the
173
+ * fresh message: a form's refusal list shrinks as its fields are fixed and
174
+ * never grows from typing (2026-09-25) — only a publish adds. */
175
+ export function stillFailing(previous, fresh) {
176
+ const out = {};
177
+ for (const e of fresh)
178
+ if (e.field in previous)
179
+ out[e.field] = e.message;
180
+ return out;
181
+ }
154
182
  /** Validate one value map. Without `scope.fields` the values ARE the fields (an entry, a page, an object global). */
155
183
  export function validateFields(values, descriptors, elements = {}, scope = {}) {
156
184
  const errors = [];
@@ -0,0 +1,2 @@
1
+ import type { CollectionSchema } from "../collections.ts";
2
+ export declare function loadCollections(file: string, cwd: string): Promise<CollectionSchema<any>[]>;
@@ -0,0 +1,26 @@
1
+ // packages/smoodly/src/cli/load-collections.ts
2
+ // Loads the app's `smoodly/collections.ts` — TypeScript, through tsx's
3
+ // import API, under the app's own tsconfig so its `@/` alias resolves.
4
+ // The barrel and not `smoodly/config.ts`: the config imports the page
5
+ // shapes and with them React and Next, which a command line has no use
6
+ // for; the barrel is what the config registers in both starters.
7
+ import { existsSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { pathToFileURL } from "node:url";
10
+ import { tsImport } from "tsx/esm/api";
11
+ export async function loadCollections(file, cwd) {
12
+ if (!existsSync(file)) {
13
+ throw new Error(`smoodly: ${file} does not exist — pass --collections <file> for the module that exports \`collections\`.`);
14
+ }
15
+ const tsconfig = join(cwd, "tsconfig.json");
16
+ const mod = (await tsImport(pathToFileURL(file).href, {
17
+ parentURL: import.meta.url,
18
+ ...(existsSync(tsconfig) ? { tsconfig } : {}),
19
+ }));
20
+ const list = mod.collections;
21
+ const isCollection = (c) => typeof c === "object" && c !== null && typeof c.name === "string";
22
+ if (!Array.isArray(list) || !list.every(isCollection)) {
23
+ throw new Error(`smoodly: ${file} must export \`collections\`, the list of smoodly.collection() definitions the config registers.`);
24
+ }
25
+ return list;
26
+ }
@@ -0,0 +1,13 @@
1
+ import { type MigrateArgs } from "./migrate.ts";
2
+ export declare const USAGE = "usage: smoodly migrate [--check] [--collections <file>] [--migrations <dir>]\n\n Copies the core schema files this package ships that supabase/migrations/\n lacks, and writes one views file for the collections whose view is new or\n changed. Prints the push or reset command; never runs it.\n\n --check write nothing; exit 1 when anything is pending\n --collections <file> the module exporting `collections` (default smoodly/collections.ts)\n --migrations <dir> the app's migration folder (default supabase/migrations)";
3
+ export type Parsed = {
4
+ command: "migrate";
5
+ args: MigrateArgs;
6
+ } | {
7
+ command: "help";
8
+ } | {
9
+ command: "error";
10
+ message: string;
11
+ };
12
+ export declare function parseArgs(argv: string[]): Parsed;
13
+ export declare function main(argv: string[], out?: (line: string) => void): Promise<number>;
@@ -0,0 +1,62 @@
1
+ // packages/smoodly/src/cli/main.ts
2
+ // `smoodly` — the package's command line (spec 2026-09-26 §2). One
3
+ // subcommand for now; `check`, `export` and the drift report land here
4
+ // later. Exit codes: 0 done or nothing to do, 1 pending (--check) or a
5
+ // refused file, 2 usage.
6
+ import { resolve } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { runMigrate } from "./migrate.js";
9
+ export const USAGE = `usage: smoodly migrate [--check] [--collections <file>] [--migrations <dir>]
10
+
11
+ Copies the core schema files this package ships that supabase/migrations/
12
+ lacks, and writes one views file for the collections whose view is new or
13
+ changed. Prints the push or reset command; never runs it.
14
+
15
+ --check write nothing; exit 1 when anything is pending
16
+ --collections <file> the module exporting \`collections\` (default smoodly/collections.ts)
17
+ --migrations <dir> the app's migration folder (default supabase/migrations)`;
18
+ export function parseArgs(argv) {
19
+ const [command, ...rest] = argv;
20
+ if (!command || command === "--help" || command === "-h" || command === "help")
21
+ return { command: "help" };
22
+ if (command !== "migrate")
23
+ return { command: "error", message: `unknown command "${command}"` };
24
+ const args = { check: false, collections: "smoodly/collections.ts", migrations: "supabase/migrations" };
25
+ for (let i = 0; i < rest.length; i++) {
26
+ const a = rest[i];
27
+ if (a === "--check")
28
+ args.check = true;
29
+ else if (a === "--collections" && rest[i + 1] !== undefined)
30
+ args.collections = rest[++i];
31
+ else if (a === "--migrations" && rest[i + 1] !== undefined)
32
+ args.migrations = rest[++i];
33
+ else
34
+ return { command: "error", message: `unexpected argument "${a}"` };
35
+ }
36
+ return { command: "migrate", args };
37
+ }
38
+ export async function main(argv, out = console.log) {
39
+ const parsed = parseArgs(argv);
40
+ if (parsed.command === "help") {
41
+ out(USAGE);
42
+ return 0;
43
+ }
44
+ if (parsed.command === "error") {
45
+ out(`smoodly: ${parsed.message}\n\n${USAGE}`);
46
+ return 2;
47
+ }
48
+ try {
49
+ return await runMigrate(parsed.args, out);
50
+ }
51
+ catch (e) {
52
+ out(e instanceof Error ? e.message : String(e));
53
+ return 1;
54
+ }
55
+ }
56
+ // Run when this file is the entry (`tsx src/cli/main.ts …`, the repo's
57
+ // `npm run db:migrate`); the published bin imports `main` and runs it itself.
58
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(new URL(import.meta.url))) {
59
+ void main(process.argv.slice(2)).then((code) => {
60
+ process.exitCode = code;
61
+ });
62
+ }
@@ -0,0 +1,23 @@
1
+ import type { CollectionSchema } from "../collections.ts";
2
+ import { type MigrationFile, type MigratePlan } from "../migrations.ts";
3
+ export type MigrateArgs = {
4
+ check: boolean;
5
+ collections: string;
6
+ migrations: string;
7
+ };
8
+ /** The folder shipped in the package: `../../migrations/` from dist/cli/ and from src/cli/ alike. */
9
+ export declare const PACKAGE_MIGRATIONS_DIR: string;
10
+ export declare const NO_FOLDER = "No supabase/migrations folder here \u2014 nothing to do. (Smoodly Cloud keeps the schema current; a self-host app gets the folder from `supabase init`.)";
11
+ export declare const NEXT_STEP = "Next: apply it \u2014 `supabase db push` (hosted) or `supabase db reset` (local).";
12
+ export declare const upToDate: (version: number, n: number) => string;
13
+ export declare const modifiedMessage: (name: string) => string;
14
+ export declare function readSqlFiles(dir: string): Promise<MigrationFile[]>;
15
+ /** One line per thing the plan does; "Pending" wording under --check. */
16
+ export declare function describePlan(plan: MigratePlan, check: boolean): string[];
17
+ export type MigrateDeps = {
18
+ cwd?: string;
19
+ /** Test seam: replaces the tsx loader. */
20
+ load?: (file: string, cwd: string) => Promise<CollectionSchema<any>[]>;
21
+ now?: () => Date;
22
+ };
23
+ export declare function runMigrate(args: MigrateArgs, out: (line: string) => void, deps?: MigrateDeps): Promise<number>;
@@ -0,0 +1,64 @@
1
+ // packages/smoodly/src/cli/migrate.ts
2
+ // The filesystem half of `smoodly migrate` (spec 2026-09-26 §2): reads
3
+ // the package's migrations/ and the app's supabase/migrations/, loads the
4
+ // app's collections, asks the pure planner (../migrations.ts) what to
5
+ // write, writes it. The message strings are what the docs quote.
6
+ import { existsSync } from "node:fs";
7
+ import { readdir, readFile, writeFile } from "node:fs/promises";
8
+ import { join, resolve } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { collectionSQL } from "../sql.js";
11
+ import { coreFileVersion, SCHEMA_VERSION } from "../schema-version.js";
12
+ import { planIsEmpty, planMigrate } from "../migrations.js";
13
+ import { loadCollections } from "./load-collections.js";
14
+ /** The folder shipped in the package: `../../migrations/` from dist/cli/ and from src/cli/ alike. */
15
+ export const PACKAGE_MIGRATIONS_DIR = fileURLToPath(new URL("../../migrations/", import.meta.url));
16
+ export const NO_FOLDER = "No supabase/migrations folder here — nothing to do. (Smoodly Cloud keeps the schema current; a self-host app gets the folder from `supabase init`.)";
17
+ export const NEXT_STEP = "Next: apply it — `supabase db push` (hosted) or `supabase db reset` (local).";
18
+ export const upToDate = (version, n) => `Up to date: core schema v${version}, every collection's view is current (${n} ${n === 1 ? "collection" : "collections"}).`;
19
+ export const modifiedMessage = (name) => `Refusing: ${name} differs from the package's copy. A published migration is never edited — restore it from the package, or move your change to a new file.`;
20
+ export async function readSqlFiles(dir) {
21
+ const names = (await readdir(dir)).filter((n) => n.endsWith(".sql")).sort();
22
+ return Promise.all(names.map(async (name) => ({ name, text: await readFile(join(dir, name), "utf8") })));
23
+ }
24
+ /** One line per thing the plan does; "Pending" wording under --check. */
25
+ export function describePlan(plan, check) {
26
+ const lines = [];
27
+ for (const name of plan.modified)
28
+ lines.push(modifiedMessage(name));
29
+ for (const f of plan.copy)
30
+ lines.push(`${check ? "Pending" : "Copied"} ${f.name} (core schema v${coreFileVersion(f.name)}).`);
31
+ if (plan.views) {
32
+ const names = [...plan.views.text.matchAll(/^-- smoodly:collection (\S+)/gm)].map((m) => m[1]);
33
+ lines.push(`${check ? "Pending" : "Wrote"} ${plan.views.name} (views: ${names.join(", ")}).`);
34
+ }
35
+ return lines;
36
+ }
37
+ export async function runMigrate(args, out, deps = {}) {
38
+ const cwd = deps.cwd ?? process.cwd();
39
+ const appDir = resolve(cwd, args.migrations);
40
+ if (!existsSync(appDir)) {
41
+ out(NO_FOLDER);
42
+ return 0;
43
+ }
44
+ const collections = await (deps.load ?? loadCollections)(resolve(cwd, args.collections), cwd);
45
+ const plan = planMigrate({
46
+ packageFiles: await readSqlFiles(PACKAGE_MIGRATIONS_DIR),
47
+ appFiles: await readSqlFiles(appDir),
48
+ collections: collections.map((c) => ({ name: c.name, sql: collectionSQL(c) })),
49
+ }, (deps.now ?? (() => new Date()))());
50
+ for (const line of describePlan(plan, args.check))
51
+ out(line);
52
+ if (plan.modified.length > 0)
53
+ return 1;
54
+ if (planIsEmpty(plan)) {
55
+ out(upToDate(SCHEMA_VERSION, collections.length));
56
+ return 0;
57
+ }
58
+ if (args.check)
59
+ return 1;
60
+ for (const f of [...plan.copy, ...(plan.views ? [plan.views] : [])])
61
+ await writeFile(join(appDir, f.name), f.text, "utf8");
62
+ out(NEXT_STEP);
63
+ return 0;
64
+ }
package/dist/config.d.ts CHANGED
@@ -32,6 +32,10 @@ export type SmoodlyConfig = {
32
32
  default: string;
33
33
  supported: string[];
34
34
  };
35
+ /** The site's time zone, an IANA name such as "Europe/Helsinki" (spec
36
+ * 2026-09-25 date pickers §5): editors pick datetimes in it, views
37
+ * format with it. Absent = the editor's browser zone. */
38
+ timeZone?: string;
35
39
  /** `tree.depth` — how deep editors may nest pages. Absent = flat. */
36
40
  pages?: {
37
41
  tree?: {
package/dist/config.js CHANGED
@@ -3,6 +3,7 @@ import { DEFAULT_MEDIA } from "./media.js";
3
3
  import { DEFAULT_ADMIN_PATH, assertAdminPath, isAdminSlug } from "./admin-path.js";
4
4
  import { resolveStyleValues, styleDescriptors } from "./styles.js";
5
5
  import { resolveCustom } from "./custom/index.js";
6
+ import { isValidZone } from "./time-zone.js";
6
7
  /** `global`: the placement node type and an admin route; `asset`: the refs
7
8
  * target of every media edge; `media`: the admin's Media screen route. */
8
9
  export const RESERVED_NAMES = ["global", "asset", "media"];
@@ -135,6 +136,13 @@ function assertLocales(config) {
135
136
  if (!supported.includes(def))
136
137
  throw new Error(`smoodly: the default locale "${def}" is not in the supported list.`);
137
138
  }
139
+ function assertTimeZone(config) {
140
+ if (config.timeZone === undefined)
141
+ return;
142
+ if (!isValidZone(config.timeZone)) {
143
+ throw new Error(`smoodly: timeZone "${config.timeZone}" is not a time zone this runtime knows — use an IANA name such as "Europe/Helsinki".`);
144
+ }
145
+ }
138
146
  /** Every element name a richtext toolset embeds, walking objects and lists. */
139
147
  function toolsetElementNames(descriptors, out) {
140
148
  for (const d of Object.values(descriptors ?? {})) {
@@ -174,6 +182,7 @@ function assertToolsetElements(config) {
174
182
  }
175
183
  export function defineConfig(config) {
176
184
  assertLocales(config);
185
+ assertTimeZone(config);
177
186
  assertUniqueNames(config);
178
187
  assertGlobalSections(config);
179
188
  assertToolsetElements(config);
package/dist/fields.d.ts CHANGED
@@ -74,6 +74,8 @@ export declare const f: {
74
74
  from?: string;
75
75
  }) => FieldBuilder<string, {}>;
76
76
  date: () => FieldBuilder<string, {}>;
77
+ /** A UTC instant to the minute, "2026-09-02T14:30:00Z" (spec 2026-09-25 date pickers §1). */
78
+ datetime: () => FieldBuilder<string, {}>;
77
79
  number: () => FieldBuilder<number, {}>;
78
80
  /** `aspect`: one `"w:h"` or several the crop tool may lock to — a per-use
79
81
  * constraint, so the component knows its box before the image loads. */
package/dist/fields.js CHANGED
@@ -81,6 +81,8 @@ export const f = {
81
81
  link: () => builder({ type: "link" }),
82
82
  slug: (opts) => builder({ type: "slug", ...(opts?.from ? { from: opts.from } : {}) }),
83
83
  date: () => builder({ type: "date" }),
84
+ /** A UTC instant to the minute, "2026-09-02T14:30:00Z" (spec 2026-09-25 date pickers §1). */
85
+ datetime: () => builder({ type: "datetime" }),
84
86
  number: () => builder({ type: "number" }),
85
87
  /** `aspect`: one `"w:h"` or several the crop tool may lock to — a per-use
86
88
  * constraint, so the component knows its box before the image loads. */
package/dist/index.d.ts CHANGED
@@ -27,15 +27,18 @@ export type { Crop, Hotspot, AssetKind, ImageValue, VideoValue, FileValue, Asset
27
27
  export { z } from "./zones.ts";
28
28
  export { defineConfig } from "./config.ts";
29
29
  export { collectionSQL, coreTablesSQL } from "./sql.ts";
30
+ export { SCHEMA_VERSION, SCHEMA_VERSION_KEY, schemaVersionSQL, coreFileVersion, schemaBehind, schemaBehindMessage } from "./schema-version.ts";
30
31
  export { MemoryPageStore } from "./store.ts";
31
32
  export { MemoryAssetStore, assetErrors, kindOf, extensionFor, objectPath, withDownload, PAGE_SIZE as ASSET_PAGE_SIZE } from "./asset-store.ts";
32
33
  export type { AssetStore, UploadTicket, FinishInput, AssetQuery, AssetPage, BeginUploadInput, CreateAssetInput } from "./asset-store.ts";
33
34
  export { imageDimensions } from "./image-dimensions.ts";
34
35
  export { SupabasePageStore } from "./supabase-store.ts";
35
36
  export { SupabaseAssetStore } from "./supabase-asset-store.ts";
36
- export { MemoryMemberStore, sortMembers } from "./member-store.ts";
37
- export type { MemberStore, MemberRecord } from "./member-store.ts";
37
+ export { MemoryMemberStore, sortMembers, memberErrors } from "./member-store.ts";
38
+ export type { MemberStore, MemberRecord, InviteInput, InviteResult } from "./member-store.ts";
38
39
  export { SupabaseMemberStore } from "./supabase-member-store.ts";
40
+ export { MEMBER_ROLES, can, normalizeRole, isMemberRole } from "./permissions.ts";
41
+ export type { MemberRole, Capability } from "./permissions.ts";
39
42
  export { collectEntryRefs, collectTreeRefs } from "./refs.ts";
40
43
  export type { RefEdge, SectionLike } from "./refs.ts";
41
44
  export { richtextBlocks, elementSchemas, IMAGE_NODE, ELEMENT_NODE } from "./richtext-walk.ts";
@@ -148,7 +151,7 @@ export declare const smoodly: {
148
151
  Section: typeof Section;
149
152
  marks: typeof marks;
150
153
  };
151
- export { NODE_ATTR, ZONE_ATTR, EMPTY_ATTR, FIELD_ATTR, ITEM_ATTR, EDITOR_ATTR, resolveMarkPath } from "./marks.ts";
154
+ export { NODE_ATTR, ZONE_ATTR, EMPTY_ATTR, BLANK_ATTR, FIELD_ATTR, ITEM_ATTR, EDITOR_ATTR, resolveMarkPath } from "./marks.ts";
152
155
  export type { MarkNode, MarkPath, Marks, Scope } from "./marks.ts";
153
156
  export { resolveSmoodlyEnv, smoodlyEnv } from "./env.ts";
154
157
  export type { SmoodlyEnv } from "./env.ts";
package/dist/index.js CHANGED
@@ -21,13 +21,15 @@ export { ASSET_COLLECTION, DEFAULT_MEDIA, MEDIA_TYPES, FULL_CROP, CENTER_HOTSPOT
21
21
  export { z } from "./zones.js";
22
22
  export { defineConfig } from "./config.js";
23
23
  export { collectionSQL, coreTablesSQL } from "./sql.js";
24
+ export { SCHEMA_VERSION, SCHEMA_VERSION_KEY, schemaVersionSQL, coreFileVersion, schemaBehind, schemaBehindMessage } from "./schema-version.js";
24
25
  export { MemoryPageStore } from "./store.js";
25
26
  export { MemoryAssetStore, assetErrors, kindOf, extensionFor, objectPath, withDownload, PAGE_SIZE as ASSET_PAGE_SIZE } from "./asset-store.js";
26
27
  export { imageDimensions } from "./image-dimensions.js";
27
28
  export { SupabasePageStore } from "./supabase-store.js";
28
29
  export { SupabaseAssetStore } from "./supabase-asset-store.js";
29
- export { MemoryMemberStore, sortMembers } from "./member-store.js";
30
+ export { MemoryMemberStore, sortMembers, memberErrors } from "./member-store.js";
30
31
  export { SupabaseMemberStore } from "./supabase-member-store.js";
32
+ export { MEMBER_ROLES, can, normalizeRole, isMemberRole } from "./permissions.js";
31
33
  export { collectEntryRefs, collectTreeRefs } from "./refs.js";
32
34
  export { richtextBlocks, elementSchemas, IMAGE_NODE, ELEMENT_NODE } from "./richtext-walk.js";
33
35
  export { MemoryRefIndex } from "./ref-index.js";
@@ -60,7 +62,7 @@ export const smoodly = {
60
62
  Section,
61
63
  marks,
62
64
  };
63
- export { NODE_ATTR, ZONE_ATTR, EMPTY_ATTR, FIELD_ATTR, ITEM_ATTR, EDITOR_ATTR, resolveMarkPath } from "./marks.js";
65
+ export { NODE_ATTR, ZONE_ATTR, EMPTY_ATTR, BLANK_ATTR, FIELD_ATTR, ITEM_ATTR, EDITOR_ATTR, resolveMarkPath } from "./marks.js";
64
66
  export { resolveSmoodlyEnv, smoodlyEnv } from "./env.js";
65
67
  export { clientOptions, createSmoodlyClient } from "./client.js";
66
68
  export { createSmoodlySite, pageContextOf } from "./site.js";
package/dist/marks.d.ts CHANGED
@@ -4,6 +4,9 @@ export declare const NODE_ATTR = "data-smd-node";
4
4
  /** An empty zone's placeholder box, from `<Zone>` in editor mode. */
5
5
  export declare const ZONE_ATTR = "data-smd-zone";
6
6
  export declare const EMPTY_ATTR = "data-smd-empty";
7
+ /** A section that renders no text and no media, stamped by the bridge in
8
+ * editor mode so the canvas can give it a height to see and select. */
9
+ export declare const BLANK_ATTR = "data-smd-blank";
7
10
  /** The element that renders a field's value verbatim. */
8
11
  export declare const FIELD_ATTR = "data-smd-field";
9
12
  /** A list item's or object field's root: scopes the field names inside it. */
package/dist/marks.js CHANGED
@@ -3,6 +3,9 @@ export const NODE_ATTR = "data-smd-node";
3
3
  /** An empty zone's placeholder box, from `<Zone>` in editor mode. */
4
4
  export const ZONE_ATTR = "data-smd-zone";
5
5
  export const EMPTY_ATTR = "data-smd-empty";
6
+ /** A section that renders no text and no media, stamped by the bridge in
7
+ * editor mode so the canvas can give it a height to see and select. */
8
+ export const BLANK_ATTR = "data-smd-blank";
6
9
  /** The element that renders a field's value verbatim. */
7
10
  export const FIELD_ATTR = "data-smd-field";
8
11
  /** A list item's or object field's root: scopes the field names inside it. */
@@ -1,20 +1,49 @@
1
+ import type { MemberRole } from "./permissions.ts";
1
2
  export type MemberRecord = {
2
3
  userId: string;
3
4
  email: string | null;
4
5
  role: string;
5
6
  };
7
+ export type InviteInput = {
8
+ email: string;
9
+ role: MemberRole;
10
+ redirectTo: string;
11
+ };
12
+ /** `invited` false = the email already had a confirmed account, so no mail was sent; the row was added. (A pending invitee is re-invited: same account, mail again, `invited` true.) */
13
+ export type InviteResult = {
14
+ member: MemberRecord;
15
+ invited: boolean;
16
+ };
6
17
  export interface MemberStore {
7
18
  /** Every member of the current space, by email then user id. */
8
19
  list(): Promise<MemberRecord[]>;
9
20
  /** The space's console URL when the control plane manages its members; null in self-host. */
10
21
  consoleUrl(): Promise<string | null>;
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
+ invite(input: InviteInput): Promise<InviteResult>;
24
+ setRole(userId: string, role: MemberRole): Promise<MemberRecord>;
25
+ /** Deletes the member row only; the Auth account stays. */
26
+ remove(userId: string): Promise<void>;
11
27
  }
28
+ export declare const memberErrors: {
29
+ notFound: (userId: string) => Error;
30
+ noAccount: (email: string) => Error;
31
+ };
12
32
  /** Email first (case-insensitive), rows without one last, ties by user id. */
13
33
  export declare function sortMembers(rows: MemberRecord[]): MemberRecord[];
14
34
  export declare class MemoryMemberStore implements MemberStore {
15
35
  private rows;
16
36
  private url;
17
- constructor(rows?: MemberRecord[], url?: string | null);
37
+ /** Auth accounts that exist without a member row: email → user id. */
38
+ private accounts;
39
+ /** Emails an invite was "sent" to, in order — what a test asserts. */
40
+ readonly sent: string[];
41
+ constructor(rows?: MemberRecord[], url?: string | null,
42
+ /** Auth accounts that exist without a member row: email → user id. */
43
+ accounts?: Record<string, string>);
18
44
  list(): Promise<MemberRecord[]>;
19
45
  consoleUrl(): Promise<string | null>;
46
+ invite(input: InviteInput): Promise<InviteResult>;
47
+ setRole(userId: string, role: MemberRole): Promise<MemberRecord>;
48
+ remove(userId: string): Promise<void>;
20
49
  }
@@ -1,8 +1,7 @@
1
- // The members contract (spec 2026-09-25 §4) and its memory adapter. A
2
- // member is one row of the admission table: the Auth user id, the email
3
- // stored beside it, and a role from the package's vocabulary. Read-only
4
- // in this cut; the console URL says whether the control plane manages
5
- // the rows (a cloud space) — no row, no URL, self-host.
1
+ export const memberErrors = {
2
+ notFound: (userId) => new Error(`smoodly: no member "${userId}".`),
3
+ noAccount: (email) => new Error(`smoodly: no Auth account for "${email}" although the invite said it exists.`),
4
+ };
6
5
  /** Email first (case-insensitive), rows without one last, ties by user id. */
7
6
  export function sortMembers(rows) {
8
7
  return [...rows].sort((a, b) => {
@@ -16,9 +15,14 @@ export function sortMembers(rows) {
16
15
  });
17
16
  }
18
17
  export class MemoryMemberStore {
19
- constructor(rows = [], url = null) {
18
+ constructor(rows = [], url = null,
19
+ /** Auth accounts that exist without a member row: email → user id. */
20
+ accounts = {}) {
20
21
  this.rows = rows;
21
22
  this.url = url;
23
+ this.accounts = accounts;
24
+ /** Emails an invite was "sent" to, in order — what a test asserts. */
25
+ this.sent = [];
22
26
  }
23
27
  async list() {
24
28
  return sortMembers(this.rows);
@@ -26,4 +30,31 @@ export class MemoryMemberStore {
26
30
  async consoleUrl() {
27
31
  return this.url;
28
32
  }
33
+ async invite(input) {
34
+ const key = input.email.toLowerCase();
35
+ let userId = this.accounts[key] ?? this.rows.find((r) => r.email?.toLowerCase() === key)?.userId;
36
+ let invited = false;
37
+ if (!userId) {
38
+ userId = crypto.randomUUID();
39
+ this.accounts[key] = userId;
40
+ this.sent.push(input.email);
41
+ invited = true;
42
+ }
43
+ const member = { userId, email: input.email, role: input.role };
44
+ this.rows = [...this.rows.filter((r) => r.userId !== userId), member];
45
+ return { member, invited };
46
+ }
47
+ async setRole(userId, role) {
48
+ const row = this.rows.find((r) => r.userId === userId);
49
+ if (!row)
50
+ throw memberErrors.notFound(userId);
51
+ const next = { ...row, role };
52
+ this.rows = this.rows.map((r) => (r.userId === userId ? next : r));
53
+ return next;
54
+ }
55
+ async remove(userId) {
56
+ if (!this.rows.some((r) => r.userId === userId))
57
+ throw memberErrors.notFound(userId);
58
+ this.rows = this.rows.filter((r) => r.userId !== userId);
59
+ }
29
60
  }
@@ -0,0 +1,37 @@
1
+ export type MigrationFile = {
2
+ name: string;
3
+ text: string;
4
+ };
5
+ export type CollectionSQL = {
6
+ name: string;
7
+ sql: string;
8
+ };
9
+ export declare const MARKER_PREFIX = "-- smoodly:collection ";
10
+ /** The first 16 hex chars of sha256 over the exact emitted SQL. */
11
+ export declare const collectionHash: (sql: string) => string;
12
+ export declare const collectionMarker: (c: CollectionSQL) => string;
13
+ /** name → hash, the LAST marker per collection across the files in name order. */
14
+ export declare function parseMarkers(files: MigrationFile[]): Map<string, string>;
15
+ /** UTC yyyyMMddHHmmss — the Supabase CLI's file-name prefix. */
16
+ export declare const stamp: (at: Date) => string;
17
+ /** A views file must sort after every core file it may depend on and
18
+ * after any earlier views file it supersedes: the later of `now` and the
19
+ * newest own stamp plus a minute. */
20
+ export declare function viewsStamp(names: string[], now: Date): Date;
21
+ /** One file with a block per new or changed collection; null when none changed. */
22
+ export declare function viewsFile(collections: CollectionSQL[], existing: Map<string, string>, at: Date): MigrationFile | null;
23
+ export type CorePending = {
24
+ copy: MigrationFile[];
25
+ modified: string[];
26
+ };
27
+ /** Package files the app lacks (copy) and same-named files whose text differs (modified — never overwritten). */
28
+ export declare function pendingCoreFiles(packageFiles: MigrationFile[], appFiles: MigrationFile[]): CorePending;
29
+ export type MigratePlan = CorePending & {
30
+ views: MigrationFile | null;
31
+ };
32
+ export declare function planMigrate(input: {
33
+ packageFiles: MigrationFile[];
34
+ appFiles: MigrationFile[];
35
+ collections: CollectionSQL[];
36
+ }, now: Date): MigratePlan;
37
+ export declare const planIsEmpty: (plan: MigratePlan) => boolean;
@@ -0,0 +1,73 @@
1
+ // packages/smoodly/src/migrations.ts
2
+ // What `npx smoodly migrate` decides, as pure functions over file lists
3
+ // (spec 2026-09-26 §2–§3): which package files the app lacks, which
4
+ // collections need a fresh view block, and the one views file to write.
5
+ // Node-only (crypto): exposed at `smoodly/migrations`, never from the
6
+ // root index, which the client bundle reaches through the config.
7
+ import { createHash } from "node:crypto";
8
+ export const MARKER_PREFIX = "-- smoodly:collection ";
9
+ const MARKER_RE = /^-- smoodly:collection (\S+) sha256:([0-9a-f]+)\s*$/gm;
10
+ const OWN_FILE_RE = /^(\d{14})_smoodly_(v\d+|views)\.sql$/;
11
+ /** The first 16 hex chars of sha256 over the exact emitted SQL. */
12
+ export const collectionHash = (sql) => createHash("sha256").update(sql).digest("hex").slice(0, 16);
13
+ export const collectionMarker = (c) => `${MARKER_PREFIX}${c.name} sha256:${collectionHash(c.sql)}`;
14
+ /** name → hash, the LAST marker per collection across the files in name order. */
15
+ export function parseMarkers(files) {
16
+ const out = new Map();
17
+ for (const file of [...files].sort((a, b) => a.name.localeCompare(b.name))) {
18
+ for (const m of file.text.matchAll(MARKER_RE))
19
+ out.set(m[1], m[2]);
20
+ }
21
+ return out;
22
+ }
23
+ /** UTC yyyyMMddHHmmss — the Supabase CLI's file-name prefix. */
24
+ export const stamp = (at) => at.toISOString().replace(/\D/g, "").slice(0, 14);
25
+ const stampDate = (s) => new Date(Date.UTC(+s.slice(0, 4), +s.slice(4, 6) - 1, +s.slice(6, 8), +s.slice(8, 10), +s.slice(10, 12), +s.slice(12, 14)));
26
+ /** A views file must sort after every core file it may depend on and
27
+ * after any earlier views file it supersedes: the later of `now` and the
28
+ * newest own stamp plus a minute. */
29
+ export function viewsStamp(names, now) {
30
+ let at = now;
31
+ for (const name of names) {
32
+ const m = OWN_FILE_RE.exec(name);
33
+ if (!m)
34
+ continue;
35
+ const after = new Date(stampDate(m[1]).getTime() + 60000);
36
+ if (after > at)
37
+ at = after;
38
+ }
39
+ return at;
40
+ }
41
+ /** One file with a block per new or changed collection; null when none changed. */
42
+ export function viewsFile(collections, existing, at) {
43
+ const changed = collections.filter((c) => existing.get(c.name) !== collectionHash(c.sql));
44
+ if (changed.length === 0)
45
+ return null;
46
+ const text = "-- Smoodly: read views and order indexes for this app's collections, written by `npx smoodly migrate`\n" +
47
+ `-- (${at.toISOString()}). One block per collection; the marker's hash is the block's SQL, compared on the next run.\n` +
48
+ "-- Never edit a written file: change the collection and run migrate again.\n\n" +
49
+ changed.map((c) => `${collectionMarker(c)}\n${c.sql.trim()}`).join("\n\n") +
50
+ "\n";
51
+ return { name: `${stamp(at)}_smoodly_views.sql`, text };
52
+ }
53
+ /** Package files the app lacks (copy) and same-named files whose text differs (modified — never overwritten). */
54
+ export function pendingCoreFiles(packageFiles, appFiles) {
55
+ const app = new Map(appFiles.map((f) => [f.name, f.text]));
56
+ const copy = [];
57
+ const modified = [];
58
+ for (const file of [...packageFiles].sort((a, b) => a.name.localeCompare(b.name))) {
59
+ const have = app.get(file.name);
60
+ if (have === undefined)
61
+ copy.push(file);
62
+ else if (have !== file.text)
63
+ modified.push(file.name);
64
+ }
65
+ return { copy, modified };
66
+ }
67
+ export function planMigrate(input, now) {
68
+ const core = pendingCoreFiles(input.packageFiles, input.appFiles);
69
+ const names = [...input.packageFiles, ...input.appFiles].map((f) => f.name);
70
+ const views = viewsFile(input.collections, parseMarkers(input.appFiles), viewsStamp(names, now));
71
+ return { ...core, views };
72
+ }
73
+ export const planIsEmpty = (plan) => plan.copy.length === 0 && plan.modified.length === 0 && plan.views === null;
@@ -0,0 +1,7 @@
1
+ export declare const MEMBER_ROLES: readonly ["admin", "editor"];
2
+ export type MemberRole = (typeof MEMBER_ROLES)[number];
3
+ export type Capability = "content.write" | "settings" | "members.manage";
4
+ export declare function can(role: MemberRole, capability: Capability): boolean;
5
+ export declare const isMemberRole: (v: unknown) => v is MemberRole;
6
+ /** What the table holds → a role: `owner` (the pre-2026-09-25 word) is admin; anything unknown reads as editor, the safer side. */
7
+ export declare function normalizeRole(stored: string): MemberRole;