@notionx/create-notionx-app 1.0.0

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 (239) hide show
  1. package/README.md +139 -0
  2. package/dist/answers.js +332 -0
  3. package/dist/answers.js.map +1 -0
  4. package/dist/cli-notionx.js +388 -0
  5. package/dist/cli-notionx.js.map +1 -0
  6. package/dist/cli-notionx.test.js +277 -0
  7. package/dist/cli-notionx.test.js.map +1 -0
  8. package/dist/diff.js +40 -0
  9. package/dist/diff.js.map +1 -0
  10. package/dist/diff.test.js +90 -0
  11. package/dist/diff.test.js.map +1 -0
  12. package/dist/index.js +99 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/locale-add/apply.js +39 -0
  15. package/dist/locale-add/apply.js.map +1 -0
  16. package/dist/locale-add/format.js +38 -0
  17. package/dist/locale-add/format.js.map +1 -0
  18. package/dist/locale-add/list.js +44 -0
  19. package/dist/locale-add/list.js.map +1 -0
  20. package/dist/locale-add/list.test.js +45 -0
  21. package/dist/locale-add/list.test.js.map +1 -0
  22. package/dist/locale-add/plan.js +128 -0
  23. package/dist/locale-add/plan.js.map +1 -0
  24. package/dist/locale-add/validate.js +46 -0
  25. package/dist/locale-add/validate.js.map +1 -0
  26. package/dist/metadata.js +41 -0
  27. package/dist/metadata.js.map +1 -0
  28. package/dist/notion-translation-sources/apply.js +61 -0
  29. package/dist/notion-translation-sources/apply.js.map +1 -0
  30. package/dist/notion-translation-sources/index.js +3 -0
  31. package/dist/notion-translation-sources/index.js.map +1 -0
  32. package/dist/notion-translation-sources/plan.js +33 -0
  33. package/dist/notion-translation-sources/plan.js.map +1 -0
  34. package/dist/notionx-source.js +142 -0
  35. package/dist/notionx-source.js.map +1 -0
  36. package/dist/notionx-source.test.js +144 -0
  37. package/dist/notionx-source.test.js.map +1 -0
  38. package/dist/password.js +18 -0
  39. package/dist/password.js.map +1 -0
  40. package/dist/presets.js +83 -0
  41. package/dist/presets.js.map +1 -0
  42. package/dist/presets.test.js +50 -0
  43. package/dist/presets.test.js.map +1 -0
  44. package/dist/prompt.js +218 -0
  45. package/dist/prompt.js.map +1 -0
  46. package/dist/provision/cloudflare.js +236 -0
  47. package/dist/provision/cloudflare.js.map +1 -0
  48. package/dist/provision/dependencies.js +219 -0
  49. package/dist/provision/dependencies.js.map +1 -0
  50. package/dist/provision/index.js +681 -0
  51. package/dist/provision/index.js.map +1 -0
  52. package/dist/provision/index.test.js +54 -0
  53. package/dist/provision/index.test.js.map +1 -0
  54. package/dist/provision/inspect.js +109 -0
  55. package/dist/provision/inspect.js.map +1 -0
  56. package/dist/provision/inspect.test.js +75 -0
  57. package/dist/provision/inspect.test.js.map +1 -0
  58. package/dist/provision/notion.js +1981 -0
  59. package/dist/provision/notion.js.map +1 -0
  60. package/dist/provision/notion.test.js +542 -0
  61. package/dist/provision/notion.test.js.map +1 -0
  62. package/dist/provision/ntn-credentials.js +198 -0
  63. package/dist/provision/ntn-credentials.js.map +1 -0
  64. package/dist/provision/options.js +15 -0
  65. package/dist/provision/options.js.map +1 -0
  66. package/dist/provision/password-hash.js +78 -0
  67. package/dist/provision/password-hash.js.map +1 -0
  68. package/dist/provision/prompts.js +115 -0
  69. package/dist/provision/prompts.js.map +1 -0
  70. package/dist/provision/repair.js +48 -0
  71. package/dist/provision/repair.js.map +1 -0
  72. package/dist/provision/repair.test.js +141 -0
  73. package/dist/provision/repair.test.js.map +1 -0
  74. package/dist/provision/shell.js +84 -0
  75. package/dist/provision/shell.js.map +1 -0
  76. package/dist/provision/wire.js +78 -0
  77. package/dist/provision/wire.js.map +1 -0
  78. package/dist/registry/doctor.js +181 -0
  79. package/dist/registry/doctor.js.map +1 -0
  80. package/dist/registry/doctor.test.js +180 -0
  81. package/dist/registry/doctor.test.js.map +1 -0
  82. package/dist/registry/install.js +217 -0
  83. package/dist/registry/install.js.map +1 -0
  84. package/dist/registry/install.test.js +168 -0
  85. package/dist/registry/install.test.js.map +1 -0
  86. package/dist/registry/load-registry.js +24 -0
  87. package/dist/registry/load-registry.js.map +1 -0
  88. package/dist/registry/load-registry.test.js +59 -0
  89. package/dist/registry/load-registry.test.js.map +1 -0
  90. package/dist/registry/migration-planner.js +204 -0
  91. package/dist/registry/migration-planner.js.map +1 -0
  92. package/dist/registry/migration-planner.test.js +340 -0
  93. package/dist/registry/migration-planner.test.js.map +1 -0
  94. package/dist/registry/migrations-store.js +125 -0
  95. package/dist/registry/migrations-store.js.map +1 -0
  96. package/dist/registry/migrations-store.test.js +163 -0
  97. package/dist/registry/migrations-store.test.js.map +1 -0
  98. package/dist/registry/migrations-types.js +25 -0
  99. package/dist/registry/migrations-types.js.map +1 -0
  100. package/dist/registry/project-meta.js +84 -0
  101. package/dist/registry/project-meta.js.map +1 -0
  102. package/dist/registry/registry-items.js +354 -0
  103. package/dist/registry/registry-items.js.map +1 -0
  104. package/dist/registry/registry-items.test.js +99 -0
  105. package/dist/registry/registry-items.test.js.map +1 -0
  106. package/dist/registry/registry-store.js +232 -0
  107. package/dist/registry/registry-store.js.map +1 -0
  108. package/dist/registry/registry-store.test.js +136 -0
  109. package/dist/registry/registry-store.test.js.map +1 -0
  110. package/dist/registry/registry-types.js +18 -0
  111. package/dist/registry/registry-types.js.map +1 -0
  112. package/dist/registry/registry-types.test.js +146 -0
  113. package/dist/registry/registry-types.test.js.map +1 -0
  114. package/dist/registry/render-content-source-files.js +158 -0
  115. package/dist/registry/render-content-source-files.js.map +1 -0
  116. package/dist/registry/render-multi-source.js +296 -0
  117. package/dist/registry/render-multi-source.js.map +1 -0
  118. package/dist/registry/render-multi-source.test.js +110 -0
  119. package/dist/registry/render-multi-source.test.js.map +1 -0
  120. package/dist/registry/text-utils.js +42 -0
  121. package/dist/registry/text-utils.js.map +1 -0
  122. package/dist/registry/uninstall.js +250 -0
  123. package/dist/registry/uninstall.js.map +1 -0
  124. package/dist/registry/uninstall.test.js +264 -0
  125. package/dist/registry/uninstall.test.js.map +1 -0
  126. package/dist/registry/update.js +280 -0
  127. package/dist/registry/update.js.map +1 -0
  128. package/dist/registry/update.test.js +229 -0
  129. package/dist/registry/update.test.js.map +1 -0
  130. package/dist/render.js +549 -0
  131. package/dist/render.js.map +1 -0
  132. package/dist/render.test.js +414 -0
  133. package/dist/render.test.js.map +1 -0
  134. package/dist/templates/.dev.vars.example.tmpl +32 -0
  135. package/dist/templates/.gitignore.tmpl +58 -0
  136. package/dist/templates/README.md.tmpl +417 -0
  137. package/dist/templates/app/[slug]/page.tsx.tmpl +55 -0
  138. package/dist/templates/app/admin/account/page.tsx.tmpl +18 -0
  139. package/dist/templates/app/admin/content-models/page.tsx.tmpl +6 -0
  140. package/dist/templates/app/admin/layout.tsx.tmpl +90 -0
  141. package/dist/templates/app/admin/loading.tsx.tmpl +6 -0
  142. package/dist/templates/app/admin/page.tsx.tmpl +17 -0
  143. package/dist/templates/app/api/auth/google/callback/route.ts.tmpl +3 -0
  144. package/dist/templates/app/api/auth/google/route.ts.tmpl +3 -0
  145. package/dist/templates/app/api/auth/verify-email/route.ts.tmpl +3 -0
  146. package/dist/templates/app/api/auth/viewer/route.ts.tmpl +3 -0
  147. package/dist/templates/app/api/health/route.ts.tmpl +3 -0
  148. package/dist/templates/app/api/{{contentSourceId}}/[slug]/route.ts.tmpl +27 -0
  149. package/dist/templates/app/api/{{contentSourceId}}/route.ts.tmpl +18 -0
  150. package/dist/templates/app/globals.css.tmpl +109 -0
  151. package/dist/templates/app/layout.tsx.tmpl +56 -0
  152. package/dist/templates/app/login/page.tsx.tmpl +154 -0
  153. package/dist/templates/app/page.fallback.tsx.tmpl +31 -0
  154. package/dist/templates/app/page.tsx.tmpl +42 -0
  155. package/dist/templates/app/register/page.tsx.tmpl +138 -0
  156. package/dist/templates/app/{{contentSourceListPath}}/[slug]/page.tsx.tmpl +113 -0
  157. package/dist/templates/app/{{contentSourceListPath}}/page.tsx.tmpl +74 -0
  158. package/dist/templates/components/content/post-card.tsx.tmpl +80 -0
  159. package/dist/templates/components/notion-blocks.tsx.tmpl +668 -0
  160. package/dist/templates/components/page-blocks/feature-grid-block.tsx.tmpl +68 -0
  161. package/dist/templates/components/page-blocks/hero-block.tsx.tmpl +73 -0
  162. package/dist/templates/components/page-blocks/latest-posts-block.tsx.tmpl +59 -0
  163. package/dist/templates/components/page-blocks/story-block.tsx.tmpl +70 -0
  164. package/dist/templates/components/page-blocks.fallback.tsx.tmpl +17 -0
  165. package/dist/templates/components/page-blocks.tsx.tmpl +32 -0
  166. package/dist/templates/components/search/search-dialog.tsx.tmpl +171 -0
  167. package/dist/templates/components/site/locale-switcher.tsx.tmpl +65 -0
  168. package/dist/templates/components/site/site-footer.tsx.tmpl +106 -0
  169. package/dist/templates/components/site/site-header.tsx.tmpl +80 -0
  170. package/dist/templates/components/site/site-shell.tsx.tmpl +20 -0
  171. package/dist/templates/components/site/theme-bootstrap.tsx.tmpl +51 -0
  172. package/dist/templates/components/theme-provider.tsx.tmpl +14 -0
  173. package/dist/templates/components/theme-toggle.tsx.tmpl +38 -0
  174. package/dist/templates/components/ui/accordion.tsx.tmpl +56 -0
  175. package/dist/templates/components/ui/alert.tsx.tmpl +59 -0
  176. package/dist/templates/components/ui/aspect-ratio.tsx.tmpl +8 -0
  177. package/dist/templates/components/ui/avatar.tsx.tmpl +44 -0
  178. package/dist/templates/components/ui/badge.tsx.tmpl +33 -0
  179. package/dist/templates/components/ui/button.tsx.tmpl +56 -0
  180. package/dist/templates/components/ui/card.tsx.tmpl +61 -0
  181. package/dist/templates/components/ui/checkbox.tsx.tmpl +28 -0
  182. package/dist/templates/components/ui/dialog.tsx.tmpl +104 -0
  183. package/dist/templates/components/ui/dropdown-menu.tsx.tmpl +183 -0
  184. package/dist/templates/components/ui/input.tsx.tmpl +21 -0
  185. package/dist/templates/components/ui/label.tsx.tmpl +25 -0
  186. package/dist/templates/components/ui/popover.tsx.tmpl +30 -0
  187. package/dist/templates/components/ui/radio-group.tsx.tmpl +44 -0
  188. package/dist/templates/components/ui/select.tsx.tmpl +150 -0
  189. package/dist/templates/components/ui/separator.tsx.tmpl +30 -0
  190. package/dist/templates/components/ui/sheet.tsx.tmpl +125 -0
  191. package/dist/templates/components/ui/skeleton.tsx.tmpl +15 -0
  192. package/dist/templates/components/ui/sonner.tsx.tmpl +30 -0
  193. package/dist/templates/components/ui/switch.tsx.tmpl +29 -0
  194. package/dist/templates/components/ui/table.tsx.tmpl +107 -0
  195. package/dist/templates/components/ui/tabs.tsx.tmpl +55 -0
  196. package/dist/templates/components/ui/textarea.tsx.tmpl +24 -0
  197. package/dist/templates/components/ui/tooltip.tsx.tmpl +30 -0
  198. package/dist/templates/components.json.tmpl +21 -0
  199. package/dist/templates/env.d.ts.tmpl +32 -0
  200. package/dist/templates/lib/admin/actions.ts.tmpl +43 -0
  201. package/dist/templates/lib/admin/context.tsx.tmpl +209 -0
  202. package/dist/templates/lib/admin/nav.ts.tmpl +23 -0
  203. package/dist/templates/lib/auth.config.fallback.ts.tmpl +10 -0
  204. package/dist/templates/lib/auth.config.ts.tmpl +45 -0
  205. package/dist/templates/lib/blocks/translations.ts.tmpl +44 -0
  206. package/dist/templates/lib/blog/translations.ts.tmpl +52 -0
  207. package/dist/templates/lib/content/models.ts.tmpl +53 -0
  208. package/dist/templates/lib/i18n/config.ts.tmpl +18 -0
  209. package/dist/templates/lib/i18n/index.ts.tmpl +1 -0
  210. package/dist/templates/lib/locale-contract/built-in.ts.tmpl +19 -0
  211. package/dist/templates/lib/locale-contract/index.ts.tmpl +3 -0
  212. package/dist/templates/lib/locale-contract/paths.ts.tmpl +29 -0
  213. package/dist/templates/lib/pages/model.ts.tmpl +16 -0
  214. package/dist/templates/lib/pages/source.ts.tmpl +566 -0
  215. package/dist/templates/lib/pages/translations.ts.tmpl +34 -0
  216. package/dist/templates/lib/search/config.fallback.ts.tmpl +11 -0
  217. package/dist/templates/lib/search/config.ts.tmpl +25 -0
  218. package/dist/templates/lib/site/config.ts.tmpl +120 -0
  219. package/dist/templates/lib/site/request-env.ts.tmpl +71 -0
  220. package/dist/templates/lib/site/settings.fallback.ts.tmpl +21 -0
  221. package/dist/templates/lib/site/settings.ts.tmpl +320 -0
  222. package/dist/templates/lib/site/translations.ts.tmpl +30 -0
  223. package/dist/templates/lib/utils.ts.tmpl +9 -0
  224. package/dist/templates/migrations/0001_init.sql.tmpl +57 -0
  225. package/dist/templates/migrations/0002_admin_seed.sql.tmpl +30 -0
  226. package/dist/templates/migrations/0003_search_index.sql.tmpl +29 -0
  227. package/dist/templates/next.config.ts.tmpl +18 -0
  228. package/dist/templates/package.json.tmpl +40 -0
  229. package/dist/templates/shims/cloudflare-workers-empty.mjs +4 -0
  230. package/dist/templates/shims/next-headers-empty.mjs +4 -0
  231. package/dist/templates/tests/smoke.test.ts.tmpl +83 -0
  232. package/dist/templates/tsconfig.json.tmpl +31 -0
  233. package/dist/templates/vite.config.ts.tmpl +53 -0
  234. package/dist/templates/vitest.config.ts.tmpl +13 -0
  235. package/dist/templates/worker/index.ts.tmpl +52 -0
  236. package/dist/templates/wrangler.jsonc.tmpl +44 -0
  237. package/dist/ui-presets.js +60 -0
  238. package/dist/ui-presets.js.map +1 -0
  239. package/package.json +60 -0
@@ -0,0 +1,120 @@
1
+ // Static fallback for the Notion-backed site settings.
2
+ //
3
+ // `getSiteSettings()` (in `./settings.ts`) reads from a dedicated
4
+ // Notion data source, but every consumer in the project still uses
5
+ // the `siteConfig` shape below. We keep two copies on purpose:
6
+ //
7
+ // 1. Notion is the editor surface — operators tweak site name /
8
+ // tagline / description / SEO / navigation / theme / footer
9
+ // there without redeploying.
10
+ // 2. The values below are the *last-resort fallback* if Notion
11
+ // is unreachable or the data source is empty.
12
+ //
13
+ // If you only want the static copy (no Notion), delete
14
+ // `lib/site/settings.ts` and the `siteSettingsSource` entry in
15
+ // `lib/content/models.ts`, then point the pages at `siteConfig`
16
+ // directly again. The repo is intentionally structured so that
17
+ // removing Notion is a 3-line change.
18
+
19
+ import { contentSources } from "../content/models.ts";
20
+
21
+ export const fallbackSiteConfig = {
22
+ name: "{{projectName}}",
23
+ description:
24
+ "A Notion-powered site built on @notionx/core, running on Cloudflare Workers with D1, R2, and Cloudflare Images.",
25
+ tagline: "{{projectName}} on Notion and Cloudflare",
26
+ defaultLocale: "{{defaultLocale}}",
27
+ socialImageUrl:
28
+ "https://picsum.photos/seed/{{projectName}}-social/1200/630" as string | null,
29
+ ogImageUrl:
30
+ "https://picsum.photos/seed/{{projectName}}-social/1200/630" as string | null,
31
+ locales: {{supportedLocalesJson}},
32
+ seo: {
33
+ title: "{{projectName}}",
34
+ description:
35
+ "A Notion-powered site built on @notionx/core, running on Cloudflare Workers with D1, R2, and Cloudflare Images.",
36
+ },
37
+ navigation: {
38
+ main: [
39
+ {
40
+ label: "Home",
41
+ href: "/",
42
+ modelId: "home",
43
+ },
44
+ {
45
+ label: "About",
46
+ href: "/about",
47
+ modelId: "about",
48
+ },
49
+ {
50
+ label: "Blog",
51
+ href: "{{contentSourceListPath}}",
52
+ modelId: "{{contentSourceId}}",
53
+ },
54
+ ],
55
+ cta: null as { label: string; href: string } | null,
56
+ adminHref: "/login",
57
+ },
58
+ theme: {
59
+ primary: "slate" as
60
+ | "slate"
61
+ | "gray"
62
+ | "zinc"
63
+ | "red"
64
+ | "orange"
65
+ | "amber"
66
+ | "green"
67
+ | "blue",
68
+ accent: "blue" as
69
+ | "slate"
70
+ | "gray"
71
+ | "zinc"
72
+ | "red"
73
+ | "orange"
74
+ | "amber"
75
+ | "green"
76
+ | "blue",
77
+ font: "inter" as "inter" | "geist" | "system",
78
+ },
79
+ footer: {
80
+ columns: [
81
+ {
82
+ label: "Company",
83
+ items: [
84
+ { label: "Home", href: "/" },
85
+ { label: "About", href: "/about" },
86
+ ],
87
+ },
88
+ {
89
+ label: "Content",
90
+ items: [{ label: "Blog", href: "{{contentSourceListPath}}" }],
91
+ },
92
+ {
93
+ label: "Legal",
94
+ items: [{ label: "Privacy", href: "/privacy" }],
95
+ },
96
+ ] as Array<{
97
+ label: string;
98
+ items: Array<{ label: string; href: string }>;
99
+ }>,
100
+ social: [] as Array<{ label: string; href: string }>,
101
+ tagline: "{{projectName}} on Notion and Cloudflare",
102
+ copyright: `© ${new Date().getFullYear()} {{projectName}}`,
103
+ },
104
+ primarySourceId: "{{contentSourceId}}",
105
+ sources: contentSources,
106
+ };
107
+
108
+ export type SiteConfig = typeof fallbackSiteConfig;
109
+
110
+ /**
111
+ * `siteConfig` is preserved as an alias for `fallbackSiteConfig` so
112
+ * existing imports (`import { siteConfig } from "@/lib/site/config"`)
113
+ * keep working. New code should prefer `getSiteSettings()` from
114
+ * `./settings.ts` for runtime values, and `fallbackSiteConfig` only
115
+ * when an async read is impossible (e.g. inside `generateMetadata`
116
+ * for routes that need a synchronous title). See
117
+ * `app/layout.tsx` for the async pattern.
118
+ */
119
+ export const siteConfig: SiteConfig = fallbackSiteConfig;
120
+ export default siteConfig;
@@ -0,0 +1,71 @@
1
+ // Per-request `env` accessor for deeply-nested helpers.
2
+ //
3
+ // In a Cloudflare Worker the `env` binding is only available as the
4
+ // second argument to the `fetch` handler. To make it reachable from
5
+ // arbitrary call sites (e.g. `getSiteSettings()` invoked from a
6
+ // server component, an admin action, or a cron job), we thread it
7
+ // through Node's `AsyncLocalStorage` — once per request.
8
+ //
9
+ // Lifecycle:
10
+ // - The worker entry in `worker/index.ts` calls
11
+ // `runWithRequestEnv(env, () => handler.fetch(...))` for every
12
+ // incoming request (and for any `scheduled` invocation).
13
+ // - Any code reachable from that handler can call
14
+ // `getRequestEnv()` to read back the current request's `env`,
15
+ // without having to plumb it down the call stack.
16
+ //
17
+ // Why not `getRequestContext()` from `cloudflare:workers`?
18
+ // - `cloudflare:workers` does NOT export `getRequestContext` at
19
+ // any current compatibility date. Importing it triggers a
20
+ // `Uncaught SyntaxError: ... does not provide an export named
21
+ // 'getRequestContext'` at worker boot, which crashes the
22
+ // deploy. (Verified against `@cloudflare/workers-types`
23
+ // 4.20260613.1 and workerd 2026-06-05.)
24
+ // - Even on a runtime that *did* export it, that helper does not
25
+ // surface the user `env` bindings — only the `ExecutionContext`.
26
+ // We need the full `env` so we can read `CONTENT_CACHE` and
27
+ // other KV namespaces.
28
+ //
29
+ // Why not vinext's `unified-request-context` shim?
30
+ // - It exposes a per-request context object, but its
31
+ // `executionContext` field is the Cloudflare `ExecutionContext`
32
+ // (waitUntil/passThroughOnException), not the `env` bindings.
33
+ // - Reaching in to attach `env` to vinext's internal context
34
+ // would couple us to vinext's private shape.
35
+ //
36
+ // `AsyncLocalStorage` requires the `nodejs_compat` compatibility
37
+ // flag, which the scaffolder enables by default.
38
+
39
+ import { AsyncLocalStorage } from "node:async_hooks";
40
+
41
+ /**
42
+ * Shape of the Cloudflare `env` parameter as far as this project
43
+ * is concerned. The KV binding is the only one we read at
44
+ * runtime; other bindings (D1, R2, secrets) are accessed by the
45
+ * foundation worker directly and don't need ALS propagation.
46
+ */
47
+ export interface RequestEnv {
48
+ CONTENT_CACHE?: KVNamespace;
49
+ // Extend as new runtime consumers need to read env outside the
50
+ // worker entry (e.g. cron-triggered helpers).
51
+ }
52
+
53
+ const _envStore = new AsyncLocalStorage<RequestEnv>();
54
+
55
+ /**
56
+ * Run `fn` with `env` available to any `getRequestEnv()` call
57
+ * reachable from `fn`. Use this from the worker entry once per
58
+ * request, before delegating to vinext/foundation.
59
+ */
60
+ export function runWithRequestEnv<T>(env: RequestEnv, fn: () => T): T {
61
+ return _envStore.run(env, fn);
62
+ }
63
+
64
+ /**
65
+ * Read the current request's `env`, or `undefined` when called
66
+ * outside a `runWithRequestEnv` scope (e.g. from a build script,
67
+ * test, or one-off CLI invocation).
68
+ */
69
+ export function getRequestEnv(): RequestEnv | undefined {
70
+ return _envStore.getStore();
71
+ }
@@ -0,0 +1,21 @@
1
+ // Fallback site settings loader — used when the project opts out
2
+ // of the Notion-backed `site-settings` data source
3
+ // (`--no-site-settings` / `CREATE_NOTIONX_NO_SITE_SETTINGS=1`).
4
+ //
5
+ // Every function returns the hard-coded fallback from `./config.ts`
6
+ // unchanged. The shape matches the Notion-backed loader so call
7
+ // sites don't need to know which version is installed.
8
+
9
+ import { fallbackSiteConfig, type SiteConfig } from "./config";
10
+
11
+ export function getStaticSiteSettings(): SiteConfig {
12
+ return fallbackSiteConfig;
13
+ }
14
+
15
+ export async function getSiteSettings(): Promise<SiteConfig> {
16
+ return fallbackSiteConfig;
17
+ }
18
+
19
+ export async function invalidateSiteSettingsCache(): Promise<void> {
20
+ // No-op — there's no KV cache to clear in fallback mode.
21
+ }
@@ -0,0 +1,320 @@
1
+ // Notion-backed site settings loader.
2
+ //
3
+ // Reads the singleton row from the `site-settings` Notion data
4
+ // source (declared in `lib/content/models.ts`) and merges it with
5
+ // the static fallback in `./config.ts`. The result has the same
6
+ // shape as `siteConfig` so call sites don't need to know which
7
+ // source actually answered.
8
+ //
9
+ // Caching strategy:
10
+ // - One KV read per request. On miss, fetch from Notion, cache
11
+ // the merged result for 5 minutes, and return.
12
+ // - The first request after a Notion edit pays the Notion RTT
13
+ // (~150ms in our measurements); subsequent requests in the
14
+ // 5-minute window are KV reads (~5ms).
15
+ //
16
+ // To invalidate early after editing Notion, you have two options:
17
+ // 1. Wait up to 5 minutes (the default TTL).
18
+ // 2. Hit `POST /api/admin/site-settings/revalidate` (mounted by
19
+ // the worker when an admin session is present) which clears
20
+ // the KV entry. The endpoint re-uses
21
+ // `@notionx/core/auth/routes/viewer` to authorize the caller.
22
+ //
23
+ // If the Notion data source is empty or the row can't be read,
24
+ // `fallbackSiteConfig` from `./config.ts` is returned unchanged.
25
+ // The fallback is also what `getStaticSiteSettings()` returns
26
+ // synchronously — useful for places that can't `await` (e.g.
27
+ // a `generateMetadata` shortcut that builds an error page).
28
+
29
+ import { getRequestEnv } from "./request-env";
30
+ import {
31
+ hasNotionModelConfig,
32
+ listGenericNotionContent,
33
+ } from "@notionx/core/notion";
34
+ import { siteSettingsSource } from "../content/models";
35
+ import { fallbackSiteConfig, type SiteConfig } from "./config";
36
+
37
+ const CACHE_KEY = "site-settings:v1";
38
+ const CACHE_TTL_SECONDS = 5 * 60;
39
+
40
+ /**
41
+ * Read the `CONTENT_CACHE` KV binding from the current request, if
42
+ * any. Returns `null` when called outside a request scope (build
43
+ * scripts, tests, one-off scripts) — callers must handle that and
44
+ * skip caching rather than throwing.
45
+ */
46
+ function readKv(): KVNamespace | null {
47
+ return getRequestEnv()?.CONTENT_CACHE ?? null;
48
+ }
49
+
50
+ type RawNavItem = {
51
+ label: string;
52
+ href: string;
53
+ children?: RawNavItem[];
54
+ };
55
+
56
+ type RawSiteSettings = {
57
+ name?: string;
58
+ tagline?: string;
59
+ description?: string;
60
+ defaultLocale?: string;
61
+ socialImageUrl?: string;
62
+ ogImageUrl?: string;
63
+ seo?: { title?: string; description?: string };
64
+ navigation?: {
65
+ main?: RawNavItem[];
66
+ cta?: { label: string; href: string } | null;
67
+ };
68
+ theme?: { primary?: string; accent?: string; font?: string };
69
+ footer?: {
70
+ columns?: Array<{ label: string; items: Array<{ label: string; href: string }> }>;
71
+ social?: Array<{ label: string; href: string }>;
72
+ tagline?: string;
73
+ copyright?: string;
74
+ };
75
+ };
76
+
77
+ function readRichText(
78
+ properties: Record<string, unknown>,
79
+ field: string
80
+ ): string {
81
+ const prop = properties[field] as
82
+ | { rich_text?: Array<{ plain_text?: string }> }
83
+ | undefined;
84
+ if (!prop?.rich_text?.length) return "";
85
+ return prop.rich_text.map((t) => t.plain_text ?? "").join("").trim();
86
+ }
87
+
88
+ function readUrl(
89
+ properties: Record<string, unknown>,
90
+ field: string
91
+ ): string | null {
92
+ const prop = properties[field] as
93
+ | { url?: string | null }
94
+ | undefined;
95
+ return prop?.url ?? null;
96
+ }
97
+
98
+ function readSelect(
99
+ properties: Record<string, unknown>,
100
+ field: string
101
+ ): string {
102
+ const prop = properties[field] as
103
+ | { select?: { name?: string } | null }
104
+ | undefined;
105
+ return prop?.select?.name?.trim() ?? "";
106
+ }
107
+
108
+ function readJson<T>(
109
+ properties: Record<string, unknown>,
110
+ field: string,
111
+ fallback: T
112
+ ): T {
113
+ const raw = readRichText(properties, field);
114
+ if (!raw) return fallback;
115
+ try {
116
+ return JSON.parse(raw) as T;
117
+ } catch {
118
+ return fallback;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Synchronous build-time copy. Use this only when `await` is not
124
+ * possible (rare — prefer `getSiteSettings`). Returns the
125
+ * hard-coded fallback, no Notion I/O.
126
+ */
127
+ export function getStaticSiteSettings(): SiteConfig {
128
+ return fallbackSiteConfig;
129
+ }
130
+
131
+ /**
132
+ * Resolve the current site settings.
133
+ *
134
+ * Order of precedence for each field:
135
+ * 1. Notion row (if `site-settings` data source is reachable and
136
+ * contains a row)
137
+ * 2. `fallbackSiteConfig` (so a Notion outage never breaks the
138
+ * home page or SEO metadata)
139
+ *
140
+ * The result is cached in `CONTENT_CACHE` (KV) under
141
+ * `site-settings:v1` for 5 minutes per process. Bump the cache key
142
+ * suffix to invalidate globally after a breaking schema change.
143
+ */
144
+ export async function getSiteSettings(): Promise<SiteConfig> {
145
+ const kv = readKv();
146
+ if (kv) {
147
+ const cached = await kv.get<SiteConfig>(CACHE_KEY, "json");
148
+ if (cached) return cached;
149
+ }
150
+
151
+ const merged = await loadFromNotion();
152
+
153
+ if (kv) {
154
+ // Best-effort write. KV failure shouldn't break the page.
155
+ kv.put(CACHE_KEY, JSON.stringify(merged), {
156
+ expirationTtl: CACHE_TTL_SECONDS,
157
+ }).catch(() => {});
158
+ }
159
+
160
+ return merged;
161
+ }
162
+
163
+ async function loadFromNotion(): Promise<SiteConfig> {
164
+ // `hasNotionModelConfig` reads the env from the active request
165
+ // context. If Notion isn't configured we return the fallback
166
+ // untouched — never throw, so the home page can't 500 because of
167
+ // a CMS blip.
168
+ let configured = false;
169
+ try {
170
+ configured = await hasNotionModelConfig(siteSettingsSource);
171
+ } catch {
172
+ configured = false;
173
+ }
174
+ if (!configured) {
175
+ return fallbackSiteConfig;
176
+ }
177
+
178
+ let items: Awaited<
179
+ ReturnType<typeof listGenericNotionContent<typeof siteSettingsSource.source.fields>>
180
+ >;
181
+ try {
182
+ items = await listGenericNotionContent(siteSettingsSource);
183
+ } catch {
184
+ return fallbackSiteConfig;
185
+ }
186
+ if (!items.length) {
187
+ return fallbackSiteConfig;
188
+ }
189
+
190
+ // The first published row wins. If the operator has multiple
191
+ // rows they can pick the active one by checking `Published` in
192
+ // Notion; we deliberately don't try to be clever about which row
193
+ // is "active" beyond that to keep the model predictable.
194
+ const row = items[0];
195
+ if (!row) return fallbackSiteConfig;
196
+
197
+ const raw: RawSiteSettings = {
198
+ name: row.title || undefined,
199
+ tagline: row.properties.tagline
200
+ ? Array.isArray(row.properties.tagline)
201
+ ? row.properties.tagline[0]
202
+ : (row.properties.tagline as string)
203
+ : undefined,
204
+ description: row.description || undefined,
205
+ socialImageUrl: row.coverImage || undefined,
206
+ };
207
+
208
+ // The Notion mapper doesn't know about every field we expose
209
+ // (e.g. `defaultLocale` lives in a `Select` column). Read it
210
+ // straight off the raw Notion page object that
211
+ // `listGenericNotionContent` returns — its `properties` map
212
+ // includes everything Notion sent us, not just the mapped ones.
213
+ const extra = (row as unknown as { properties?: Record<string, unknown> })
214
+ .properties;
215
+ if (extra && typeof extra === "object") {
216
+ const defaultLocale = readSelect(extra, "Default Locale");
217
+ if (defaultLocale) raw.defaultLocale = defaultLocale;
218
+ const tagline = readRichText(extra, "Tagline");
219
+ if (tagline) raw.tagline = tagline;
220
+ const socialImage = readUrl(extra, "Social Image");
221
+ if (socialImage) raw.socialImageUrl = socialImage;
222
+
223
+ // SEO
224
+ const metaTitle = readRichText(extra, "Meta Title");
225
+ const metaDescription = readRichText(extra, "Meta Description");
226
+ if (metaTitle || metaDescription) {
227
+ raw.seo = {
228
+ title: metaTitle || raw.name,
229
+ description: metaDescription || raw.description,
230
+ };
231
+ }
232
+ const ogImage = readUrl(extra, "OG Image");
233
+ if (ogImage) raw.ogImageUrl = ogImage;
234
+
235
+ // Navigation
236
+ const nav = readJson<RawNavItem[]>(extra, "Nav", []);
237
+ const cta = readJson<{ label: string; href: string } | null>(
238
+ extra,
239
+ "Nav CTA",
240
+ null
241
+ );
242
+ raw.navigation = { main: nav, cta };
243
+
244
+ // Theme
245
+ const primary = readSelect(extra, "Primary Color");
246
+ const accent = readSelect(extra, "Accent Color");
247
+ const font = readSelect(extra, "Font Family");
248
+ if (primary || accent || font) {
249
+ raw.theme = { primary, accent, font };
250
+ }
251
+
252
+ // Footer
253
+ type FooterColumn = {
254
+ label: string;
255
+ items: Array<{ label: string; href: string }>;
256
+ };
257
+ const columns = readJson<FooterColumn[]>(extra, "Footer Columns", []);
258
+ const social = readJson<Array<{ label: string; href: string }>>(
259
+ extra,
260
+ "Footer Social Links",
261
+ []
262
+ );
263
+ const taglineFooter = readRichText(extra, "Footer Tagline");
264
+ const copyright = readRichText(extra, "Footer Copyright");
265
+ raw.footer = { columns, social, tagline: taglineFooter, copyright };
266
+ }
267
+
268
+ return {
269
+ ...fallbackSiteConfig,
270
+ name: raw.name?.trim() || fallbackSiteConfig.name,
271
+ tagline: raw.tagline?.trim() || raw.name?.trim() || fallbackSiteConfig.tagline,
272
+ description:
273
+ raw.description?.trim() || fallbackSiteConfig.description,
274
+ socialImageUrl: raw.socialImageUrl ?? fallbackSiteConfig.socialImageUrl,
275
+ ogImageUrl: raw.ogImageUrl ?? raw.socialImageUrl ?? fallbackSiteConfig.ogImageUrl,
276
+ defaultLocale:
277
+ raw.defaultLocale?.trim() || fallbackSiteConfig.defaultLocale,
278
+ seo: {
279
+ title: raw.seo?.title?.trim() || fallbackSiteConfig.seo.title,
280
+ description:
281
+ raw.seo?.description?.trim() || fallbackSiteConfig.seo.description,
282
+ },
283
+ navigation: {
284
+ ...fallbackSiteConfig.navigation,
285
+ main: raw.navigation?.main?.length
286
+ ? raw.navigation.main
287
+ : fallbackSiteConfig.navigation.main,
288
+ cta: raw.navigation?.cta ?? fallbackSiteConfig.navigation.cta,
289
+ },
290
+ theme: {
291
+ primary:
292
+ (raw.theme?.primary as SiteConfig["theme"]["primary"]) ??
293
+ fallbackSiteConfig.theme.primary,
294
+ accent:
295
+ (raw.theme?.accent as SiteConfig["theme"]["accent"]) ??
296
+ fallbackSiteConfig.theme.accent,
297
+ font:
298
+ (raw.theme?.font as SiteConfig["theme"]["font"]) ??
299
+ fallbackSiteConfig.theme.font,
300
+ },
301
+ footer: {
302
+ columns: raw.footer?.columns ?? fallbackSiteConfig.footer.columns,
303
+ social: raw.footer?.social ?? fallbackSiteConfig.footer.social,
304
+ tagline: raw.footer?.tagline ?? fallbackSiteConfig.footer.tagline,
305
+ copyright:
306
+ raw.footer?.copyright ?? fallbackSiteConfig.footer.copyright,
307
+ },
308
+ };
309
+ }
310
+
311
+ /**
312
+ * Drop the cached entry. Call this from a Notion webhook handler
313
+ * (or an admin "revalidate" button) so editors see their changes
314
+ * without waiting for the 5-minute TTL.
315
+ */
316
+ export async function invalidateSiteSettingsCache(): Promise<void> {
317
+ const kv = readKv();
318
+ if (!kv) return;
319
+ await kv.delete(CACHE_KEY);
320
+ }
@@ -0,0 +1,30 @@
1
+ // Locale-aware site settings merge. The `site-settings` Notion row
2
+ // holds the global config; the `site-settings-translations` row
3
+ // holds locale-specific copy. Missing locale copy falls back to the
4
+ // default-locale translation.
5
+
6
+ import { i18n } from "@/lib/i18n";
7
+ import { siteSettingsContract } from "@/lib/locale-contract";
8
+
9
+ export type SiteSettingsTranslation = {
10
+ pageId: string;
11
+ sourcePageId: string;
12
+ locale: string;
13
+ tagline: string;
14
+ description: string;
15
+ seoTitle: string;
16
+ seoDescription: string;
17
+ navLabels: Record<string, string>;
18
+ footerLabels: Record<string, string>;
19
+ globalFallbackCopy: string;
20
+ published: boolean;
21
+ };
22
+
23
+ export function pickSiteSettingsTranslation(
24
+ rows: readonly SiteSettingsTranslation[],
25
+ locale: string
26
+ ) {
27
+ const direct = rows.find((row) => row.locale === locale);
28
+ if (direct) return direct;
29
+ return rows.find((row) => row.locale === i18n.defaultLocale) ?? null;
30
+ }
@@ -0,0 +1,9 @@
1
+ // shadcn/ui 标准的 cn() 工具:合并 className,去重 Tailwind 冲突类。
2
+ // 不直接依赖 @notionx/core,避免在用户代码里制造隐式依赖。
3
+
4
+ import { type ClassValue, clsx } from "clsx";
5
+ import { twMerge } from "tailwind-merge";
6
+
7
+ export function cn(...inputs: ClassValue[]) {
8
+ return twMerge(clsx(inputs));
9
+ }
@@ -0,0 +1,57 @@
1
+ -- Foundation auth schema. Mirrors the `users`, `app_settings`, and
2
+ -- `auth_rate_limits` tables that `@notionx/core`'s `createAuth`,
3
+ -- `getAuthViewer`, and `runSchemaHealthChecks` rely on. Run all
4
+ -- statements with `wrangler d1 migrations apply <db-name>`; the
5
+ -- generated project ships this file as `migrations/0001_init.sql`.
6
+
7
+ CREATE TABLE IF NOT EXISTS users (
8
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
9
+ email TEXT NOT NULL UNIQUE,
10
+ name TEXT,
11
+ picture TEXT,
12
+ google_sub TEXT UNIQUE,
13
+ password_hash TEXT,
14
+ email_verified INTEGER NOT NULL DEFAULT 0,
15
+ email_verify_token TEXT,
16
+ email_verify_expires_at TEXT,
17
+ password_reset_token TEXT,
18
+ password_reset_expires_at TEXT,
19
+ session_rev INTEGER NOT NULL DEFAULT 0,
20
+ role TEXT NOT NULL DEFAULT 'user',
21
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
22
+ last_seen_at TEXT NOT NULL DEFAULT (datetime('now'))
23
+ );
24
+
25
+ CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
26
+ CREATE INDEX IF NOT EXISTS idx_users_google_sub ON users(google_sub);
27
+ CREATE INDEX IF NOT EXISTS idx_users_email_verify_token
28
+ ON users(email_verify_token);
29
+ CREATE INDEX IF NOT EXISTS idx_users_password_reset_token
30
+ ON users(password_reset_token);
31
+
32
+ -- Single-row application settings table. The schema guard
33
+ -- (`runSchemaHealthChecks` in @notionx/core) reads the
34
+ -- `turnstile_enabled` column from this table to verify the schema.
35
+ CREATE TABLE IF NOT EXISTS app_settings (
36
+ id INTEGER PRIMARY KEY CHECK (id = 1),
37
+ site_title TEXT NOT NULL DEFAULT '{{projectName}}',
38
+ google_enabled INTEGER NOT NULL DEFAULT 0,
39
+ google_client_id TEXT,
40
+ google_client_secret TEXT,
41
+ google_updated_at TEXT,
42
+ turnstile_enabled INTEGER NOT NULL DEFAULT 0,
43
+ turnstile_site_key TEXT,
44
+ turnstile_updated_at TEXT,
45
+ admin_email TEXT NOT NULL DEFAULT '',
46
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
47
+ );
48
+
49
+ INSERT OR IGNORE INTO app_settings (id) VALUES (1);
50
+
51
+ -- Rate-limit buckets for auth flows. The scope column is a string
52
+ -- key like `login:email:user@example.com` or `forgot:ip:1.2.3.4`.
53
+ CREATE TABLE IF NOT EXISTS auth_rate_limits (
54
+ scope TEXT PRIMARY KEY,
55
+ attempts INTEGER NOT NULL DEFAULT 0,
56
+ window_start INTEGER NOT NULL
57
+ );
@@ -0,0 +1,30 @@
1
+ -- Bootstrap the admin user and link the configured admin_email to it.
2
+ -- Rendered by `@notionx/create-notionx-app` with the PBKDF2-SHA256
3
+ -- hash of the password collected at scaffold time. Idempotent: safe
4
+ -- to re-run via `wrangler d1 migrations apply`.
5
+
6
+ -- 1. Make sure the singleton `app_settings` row carries the admin
7
+ -- email that the auth helpers (`isAdminEmail`) consult.
8
+ UPDATE app_settings
9
+ SET admin_email = '{{adminEmail}}',
10
+ updated_at = datetime('now')
11
+ WHERE id = 1;
12
+
13
+ -- 2. Insert (or refresh) the admin user. Re-runs are safe: the
14
+ -- `users.email` UNIQUE index makes the ON CONFLICT clause take
15
+ -- over and refresh the password hash + role instead of
16
+ -- duplicating. The role is hard-set to 'admin' so the user passes
17
+ -- role checks on the very first request.
18
+ INSERT INTO users (email, name, password_hash, email_verified, role)
19
+ VALUES (
20
+ '{{adminEmail}}',
21
+ '{{adminName}}',
22
+ '{{adminPasswordHash}}',
23
+ 1,
24
+ 'admin'
25
+ )
26
+ ON CONFLICT(email) DO UPDATE SET
27
+ name = excluded.name,
28
+ password_hash = excluded.password_hash,
29
+ role = 'admin',
30
+ email_verified = 1;
@@ -0,0 +1,29 @@
1
+ -- content_search_index: D1-backed text search index for content sources.
2
+ -- Used by the D1SearchAdapter to answer /api/search queries via
3
+ -- LIKE-based keyword matching. Each row represents one indexed
4
+ -- content page, keyed by (model_id, route_id).
5
+ --
6
+ -- This migration is rendered only when the `search` feature module
7
+ -- is installed (enableSearch = true). When search is removed, the
8
+ -- table is NOT dropped — existing index data is preserved in case
9
+ -- the user re-adds search later.
10
+
11
+ CREATE TABLE IF NOT EXISTS content_search_index (
12
+ model_id TEXT NOT NULL,
13
+ page_id TEXT NOT NULL,
14
+ route_id TEXT NOT NULL,
15
+ title TEXT NOT NULL DEFAULT '',
16
+ summary TEXT NOT NULL DEFAULT '',
17
+ body_text TEXT NOT NULL DEFAULT '',
18
+ facets TEXT NOT NULL DEFAULT '[]',
19
+ normalized_text TEXT NOT NULL DEFAULT '',
20
+ source_updated_at TEXT,
21
+ indexed_at TEXT NOT NULL DEFAULT (datetime('now')),
22
+ PRIMARY KEY (model_id, route_id)
23
+ );
24
+
25
+ CREATE INDEX IF NOT EXISTS idx_content_search_model
26
+ ON content_search_index (model_id);
27
+
28
+ CREATE INDEX IF NOT EXISTS idx_content_search_indexed_at
29
+ ON content_search_index (indexed_at DESC);