smoodly 0.0.39 → 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.
@@ -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
  };
@@ -107,6 +107,12 @@ export function createSmoodlyContent(deps) {
107
107
  }));
108
108
  },
109
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
+ },
110
116
  /** A code-owned address with no record gets it created the way the admin's Pages list does (spec §2). */
111
117
  async find(locale, path) {
112
118
  const found = await findPage(locale, path);
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. */
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const SMOODLY_VERSION = "0.0.39";
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.39";
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.39",
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": {