@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.
- package/README.md +139 -0
- package/dist/answers.js +332 -0
- package/dist/answers.js.map +1 -0
- package/dist/cli-notionx.js +388 -0
- package/dist/cli-notionx.js.map +1 -0
- package/dist/cli-notionx.test.js +277 -0
- package/dist/cli-notionx.test.js.map +1 -0
- package/dist/diff.js +40 -0
- package/dist/diff.js.map +1 -0
- package/dist/diff.test.js +90 -0
- package/dist/diff.test.js.map +1 -0
- package/dist/index.js +99 -0
- package/dist/index.js.map +1 -0
- package/dist/locale-add/apply.js +39 -0
- package/dist/locale-add/apply.js.map +1 -0
- package/dist/locale-add/format.js +38 -0
- package/dist/locale-add/format.js.map +1 -0
- package/dist/locale-add/list.js +44 -0
- package/dist/locale-add/list.js.map +1 -0
- package/dist/locale-add/list.test.js +45 -0
- package/dist/locale-add/list.test.js.map +1 -0
- package/dist/locale-add/plan.js +128 -0
- package/dist/locale-add/plan.js.map +1 -0
- package/dist/locale-add/validate.js +46 -0
- package/dist/locale-add/validate.js.map +1 -0
- package/dist/metadata.js +41 -0
- package/dist/metadata.js.map +1 -0
- package/dist/notion-translation-sources/apply.js +61 -0
- package/dist/notion-translation-sources/apply.js.map +1 -0
- package/dist/notion-translation-sources/index.js +3 -0
- package/dist/notion-translation-sources/index.js.map +1 -0
- package/dist/notion-translation-sources/plan.js +33 -0
- package/dist/notion-translation-sources/plan.js.map +1 -0
- package/dist/notionx-source.js +142 -0
- package/dist/notionx-source.js.map +1 -0
- package/dist/notionx-source.test.js +144 -0
- package/dist/notionx-source.test.js.map +1 -0
- package/dist/password.js +18 -0
- package/dist/password.js.map +1 -0
- package/dist/presets.js +83 -0
- package/dist/presets.js.map +1 -0
- package/dist/presets.test.js +50 -0
- package/dist/presets.test.js.map +1 -0
- package/dist/prompt.js +218 -0
- package/dist/prompt.js.map +1 -0
- package/dist/provision/cloudflare.js +236 -0
- package/dist/provision/cloudflare.js.map +1 -0
- package/dist/provision/dependencies.js +219 -0
- package/dist/provision/dependencies.js.map +1 -0
- package/dist/provision/index.js +681 -0
- package/dist/provision/index.js.map +1 -0
- package/dist/provision/index.test.js +54 -0
- package/dist/provision/index.test.js.map +1 -0
- package/dist/provision/inspect.js +109 -0
- package/dist/provision/inspect.js.map +1 -0
- package/dist/provision/inspect.test.js +75 -0
- package/dist/provision/inspect.test.js.map +1 -0
- package/dist/provision/notion.js +1981 -0
- package/dist/provision/notion.js.map +1 -0
- package/dist/provision/notion.test.js +542 -0
- package/dist/provision/notion.test.js.map +1 -0
- package/dist/provision/ntn-credentials.js +198 -0
- package/dist/provision/ntn-credentials.js.map +1 -0
- package/dist/provision/options.js +15 -0
- package/dist/provision/options.js.map +1 -0
- package/dist/provision/password-hash.js +78 -0
- package/dist/provision/password-hash.js.map +1 -0
- package/dist/provision/prompts.js +115 -0
- package/dist/provision/prompts.js.map +1 -0
- package/dist/provision/repair.js +48 -0
- package/dist/provision/repair.js.map +1 -0
- package/dist/provision/repair.test.js +141 -0
- package/dist/provision/repair.test.js.map +1 -0
- package/dist/provision/shell.js +84 -0
- package/dist/provision/shell.js.map +1 -0
- package/dist/provision/wire.js +78 -0
- package/dist/provision/wire.js.map +1 -0
- package/dist/registry/doctor.js +181 -0
- package/dist/registry/doctor.js.map +1 -0
- package/dist/registry/doctor.test.js +180 -0
- package/dist/registry/doctor.test.js.map +1 -0
- package/dist/registry/install.js +217 -0
- package/dist/registry/install.js.map +1 -0
- package/dist/registry/install.test.js +168 -0
- package/dist/registry/install.test.js.map +1 -0
- package/dist/registry/load-registry.js +24 -0
- package/dist/registry/load-registry.js.map +1 -0
- package/dist/registry/load-registry.test.js +59 -0
- package/dist/registry/load-registry.test.js.map +1 -0
- package/dist/registry/migration-planner.js +204 -0
- package/dist/registry/migration-planner.js.map +1 -0
- package/dist/registry/migration-planner.test.js +340 -0
- package/dist/registry/migration-planner.test.js.map +1 -0
- package/dist/registry/migrations-store.js +125 -0
- package/dist/registry/migrations-store.js.map +1 -0
- package/dist/registry/migrations-store.test.js +163 -0
- package/dist/registry/migrations-store.test.js.map +1 -0
- package/dist/registry/migrations-types.js +25 -0
- package/dist/registry/migrations-types.js.map +1 -0
- package/dist/registry/project-meta.js +84 -0
- package/dist/registry/project-meta.js.map +1 -0
- package/dist/registry/registry-items.js +354 -0
- package/dist/registry/registry-items.js.map +1 -0
- package/dist/registry/registry-items.test.js +99 -0
- package/dist/registry/registry-items.test.js.map +1 -0
- package/dist/registry/registry-store.js +232 -0
- package/dist/registry/registry-store.js.map +1 -0
- package/dist/registry/registry-store.test.js +136 -0
- package/dist/registry/registry-store.test.js.map +1 -0
- package/dist/registry/registry-types.js +18 -0
- package/dist/registry/registry-types.js.map +1 -0
- package/dist/registry/registry-types.test.js +146 -0
- package/dist/registry/registry-types.test.js.map +1 -0
- package/dist/registry/render-content-source-files.js +158 -0
- package/dist/registry/render-content-source-files.js.map +1 -0
- package/dist/registry/render-multi-source.js +296 -0
- package/dist/registry/render-multi-source.js.map +1 -0
- package/dist/registry/render-multi-source.test.js +110 -0
- package/dist/registry/render-multi-source.test.js.map +1 -0
- package/dist/registry/text-utils.js +42 -0
- package/dist/registry/text-utils.js.map +1 -0
- package/dist/registry/uninstall.js +250 -0
- package/dist/registry/uninstall.js.map +1 -0
- package/dist/registry/uninstall.test.js +264 -0
- package/dist/registry/uninstall.test.js.map +1 -0
- package/dist/registry/update.js +280 -0
- package/dist/registry/update.js.map +1 -0
- package/dist/registry/update.test.js +229 -0
- package/dist/registry/update.test.js.map +1 -0
- package/dist/render.js +549 -0
- package/dist/render.js.map +1 -0
- package/dist/render.test.js +414 -0
- package/dist/render.test.js.map +1 -0
- package/dist/templates/.dev.vars.example.tmpl +32 -0
- package/dist/templates/.gitignore.tmpl +58 -0
- package/dist/templates/README.md.tmpl +417 -0
- package/dist/templates/app/[slug]/page.tsx.tmpl +55 -0
- package/dist/templates/app/admin/account/page.tsx.tmpl +18 -0
- package/dist/templates/app/admin/content-models/page.tsx.tmpl +6 -0
- package/dist/templates/app/admin/layout.tsx.tmpl +90 -0
- package/dist/templates/app/admin/loading.tsx.tmpl +6 -0
- package/dist/templates/app/admin/page.tsx.tmpl +17 -0
- package/dist/templates/app/api/auth/google/callback/route.ts.tmpl +3 -0
- package/dist/templates/app/api/auth/google/route.ts.tmpl +3 -0
- package/dist/templates/app/api/auth/verify-email/route.ts.tmpl +3 -0
- package/dist/templates/app/api/auth/viewer/route.ts.tmpl +3 -0
- package/dist/templates/app/api/health/route.ts.tmpl +3 -0
- package/dist/templates/app/api/{{contentSourceId}}/[slug]/route.ts.tmpl +27 -0
- package/dist/templates/app/api/{{contentSourceId}}/route.ts.tmpl +18 -0
- package/dist/templates/app/globals.css.tmpl +109 -0
- package/dist/templates/app/layout.tsx.tmpl +56 -0
- package/dist/templates/app/login/page.tsx.tmpl +154 -0
- package/dist/templates/app/page.fallback.tsx.tmpl +31 -0
- package/dist/templates/app/page.tsx.tmpl +42 -0
- package/dist/templates/app/register/page.tsx.tmpl +138 -0
- package/dist/templates/app/{{contentSourceListPath}}/[slug]/page.tsx.tmpl +113 -0
- package/dist/templates/app/{{contentSourceListPath}}/page.tsx.tmpl +74 -0
- package/dist/templates/components/content/post-card.tsx.tmpl +80 -0
- package/dist/templates/components/notion-blocks.tsx.tmpl +668 -0
- package/dist/templates/components/page-blocks/feature-grid-block.tsx.tmpl +68 -0
- package/dist/templates/components/page-blocks/hero-block.tsx.tmpl +73 -0
- package/dist/templates/components/page-blocks/latest-posts-block.tsx.tmpl +59 -0
- package/dist/templates/components/page-blocks/story-block.tsx.tmpl +70 -0
- package/dist/templates/components/page-blocks.fallback.tsx.tmpl +17 -0
- package/dist/templates/components/page-blocks.tsx.tmpl +32 -0
- package/dist/templates/components/search/search-dialog.tsx.tmpl +171 -0
- package/dist/templates/components/site/locale-switcher.tsx.tmpl +65 -0
- package/dist/templates/components/site/site-footer.tsx.tmpl +106 -0
- package/dist/templates/components/site/site-header.tsx.tmpl +80 -0
- package/dist/templates/components/site/site-shell.tsx.tmpl +20 -0
- package/dist/templates/components/site/theme-bootstrap.tsx.tmpl +51 -0
- package/dist/templates/components/theme-provider.tsx.tmpl +14 -0
- package/dist/templates/components/theme-toggle.tsx.tmpl +38 -0
- package/dist/templates/components/ui/accordion.tsx.tmpl +56 -0
- package/dist/templates/components/ui/alert.tsx.tmpl +59 -0
- package/dist/templates/components/ui/aspect-ratio.tsx.tmpl +8 -0
- package/dist/templates/components/ui/avatar.tsx.tmpl +44 -0
- package/dist/templates/components/ui/badge.tsx.tmpl +33 -0
- package/dist/templates/components/ui/button.tsx.tmpl +56 -0
- package/dist/templates/components/ui/card.tsx.tmpl +61 -0
- package/dist/templates/components/ui/checkbox.tsx.tmpl +28 -0
- package/dist/templates/components/ui/dialog.tsx.tmpl +104 -0
- package/dist/templates/components/ui/dropdown-menu.tsx.tmpl +183 -0
- package/dist/templates/components/ui/input.tsx.tmpl +21 -0
- package/dist/templates/components/ui/label.tsx.tmpl +25 -0
- package/dist/templates/components/ui/popover.tsx.tmpl +30 -0
- package/dist/templates/components/ui/radio-group.tsx.tmpl +44 -0
- package/dist/templates/components/ui/select.tsx.tmpl +150 -0
- package/dist/templates/components/ui/separator.tsx.tmpl +30 -0
- package/dist/templates/components/ui/sheet.tsx.tmpl +125 -0
- package/dist/templates/components/ui/skeleton.tsx.tmpl +15 -0
- package/dist/templates/components/ui/sonner.tsx.tmpl +30 -0
- package/dist/templates/components/ui/switch.tsx.tmpl +29 -0
- package/dist/templates/components/ui/table.tsx.tmpl +107 -0
- package/dist/templates/components/ui/tabs.tsx.tmpl +55 -0
- package/dist/templates/components/ui/textarea.tsx.tmpl +24 -0
- package/dist/templates/components/ui/tooltip.tsx.tmpl +30 -0
- package/dist/templates/components.json.tmpl +21 -0
- package/dist/templates/env.d.ts.tmpl +32 -0
- package/dist/templates/lib/admin/actions.ts.tmpl +43 -0
- package/dist/templates/lib/admin/context.tsx.tmpl +209 -0
- package/dist/templates/lib/admin/nav.ts.tmpl +23 -0
- package/dist/templates/lib/auth.config.fallback.ts.tmpl +10 -0
- package/dist/templates/lib/auth.config.ts.tmpl +45 -0
- package/dist/templates/lib/blocks/translations.ts.tmpl +44 -0
- package/dist/templates/lib/blog/translations.ts.tmpl +52 -0
- package/dist/templates/lib/content/models.ts.tmpl +53 -0
- package/dist/templates/lib/i18n/config.ts.tmpl +18 -0
- package/dist/templates/lib/i18n/index.ts.tmpl +1 -0
- package/dist/templates/lib/locale-contract/built-in.ts.tmpl +19 -0
- package/dist/templates/lib/locale-contract/index.ts.tmpl +3 -0
- package/dist/templates/lib/locale-contract/paths.ts.tmpl +29 -0
- package/dist/templates/lib/pages/model.ts.tmpl +16 -0
- package/dist/templates/lib/pages/source.ts.tmpl +566 -0
- package/dist/templates/lib/pages/translations.ts.tmpl +34 -0
- package/dist/templates/lib/search/config.fallback.ts.tmpl +11 -0
- package/dist/templates/lib/search/config.ts.tmpl +25 -0
- package/dist/templates/lib/site/config.ts.tmpl +120 -0
- package/dist/templates/lib/site/request-env.ts.tmpl +71 -0
- package/dist/templates/lib/site/settings.fallback.ts.tmpl +21 -0
- package/dist/templates/lib/site/settings.ts.tmpl +320 -0
- package/dist/templates/lib/site/translations.ts.tmpl +30 -0
- package/dist/templates/lib/utils.ts.tmpl +9 -0
- package/dist/templates/migrations/0001_init.sql.tmpl +57 -0
- package/dist/templates/migrations/0002_admin_seed.sql.tmpl +30 -0
- package/dist/templates/migrations/0003_search_index.sql.tmpl +29 -0
- package/dist/templates/next.config.ts.tmpl +18 -0
- package/dist/templates/package.json.tmpl +40 -0
- package/dist/templates/shims/cloudflare-workers-empty.mjs +4 -0
- package/dist/templates/shims/next-headers-empty.mjs +4 -0
- package/dist/templates/tests/smoke.test.ts.tmpl +83 -0
- package/dist/templates/tsconfig.json.tmpl +31 -0
- package/dist/templates/vite.config.ts.tmpl +53 -0
- package/dist/templates/vitest.config.ts.tmpl +13 -0
- package/dist/templates/worker/index.ts.tmpl +52 -0
- package/dist/templates/wrangler.jsonc.tmpl +44 -0
- package/dist/ui-presets.js +60 -0
- package/dist/ui-presets.js.map +1 -0
- 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);
|