@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,1981 @@
|
|
|
1
|
+
// packages/create-notionx-app/src/provision/notion.ts
|
|
2
|
+
//
|
|
3
|
+
// Provisions a Notion data source for the project's first content
|
|
4
|
+
// source. Uses the `ntn` CLI (must be installed globally) and the
|
|
5
|
+
// Notion integration token from `NOTION_API_TOKEN`.
|
|
6
|
+
//
|
|
7
|
+
// The Notion API requires a parent (a page) for database creation —
|
|
8
|
+
// integrations normally cannot create at the workspace root — so we
|
|
9
|
+
// always prompt the user for a parent page id (or accept a flag).
|
|
10
|
+
import { runNtn, runOrThrowNtn } from "./shell.js";
|
|
11
|
+
const ENGLISH_SAMPLE_POSTS = [
|
|
12
|
+
{
|
|
13
|
+
title: "Building a Calm Publishing Workflow",
|
|
14
|
+
slug: "building-a-calm-publishing-workflow",
|
|
15
|
+
description: "A practical walkthrough of turning Notion drafts into a polished public blog without adding editorial busywork.",
|
|
16
|
+
date: "2026-06-02",
|
|
17
|
+
tags: ["Workflow", "Notion", "Publishing"],
|
|
18
|
+
coverSeed: "calm-publishing-workflow",
|
|
19
|
+
intro: "The best publishing systems stay out of the way. This starter keeps the durable parts in code while letting writers shape every article from Notion.",
|
|
20
|
+
sections: [
|
|
21
|
+
{
|
|
22
|
+
heading: "Start with a repeatable draft shape",
|
|
23
|
+
body: "Each post carries just enough structured metadata for routing, cards, feeds, and search. The longer narrative stays in Notion blocks, where editors already know how to work.",
|
|
24
|
+
bullets: [
|
|
25
|
+
"Use Slug for the public URL.",
|
|
26
|
+
"Use Published as the release switch.",
|
|
27
|
+
"Use Tags to keep future filtering simple.",
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
heading: "Keep the site predictable",
|
|
32
|
+
body: "The generated app reads the registered content source, filters unpublished entries, renders cards, and uses the page body as the canonical article content.",
|
|
33
|
+
bullets: [
|
|
34
|
+
"Change copy and imagery in Notion.",
|
|
35
|
+
"Change layout and behavior in source code.",
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
closing: "Replace this sample with your own launch note when the project is ready to meet readers.",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
title: "What Belongs in Notion, What Belongs in D1",
|
|
43
|
+
slug: "what-belongs-in-notion-what-belongs-in-d1",
|
|
44
|
+
description: "A simple storage rule for content-heavy projects: editorial data in Notion, application state in D1.",
|
|
45
|
+
date: "2026-06-04",
|
|
46
|
+
tags: ["Architecture", "D1", "Content"],
|
|
47
|
+
coverSeed: "notion-d1-architecture",
|
|
48
|
+
intro: "Notion is excellent at content modeling and human editing. D1 is better for fast, transactional product state such as users, sessions, likes, and comments.",
|
|
49
|
+
sections: [
|
|
50
|
+
{
|
|
51
|
+
heading: "Use Notion for editorial truth",
|
|
52
|
+
body: "Posts, pages, guides, portfolios, and media notes benefit from Notion's visual editing, relations, views, and database properties.",
|
|
53
|
+
bullets: [
|
|
54
|
+
"Editors can revise copy without a deploy.",
|
|
55
|
+
"Custom models can grow one database at a time.",
|
|
56
|
+
"Relations make richer content types approachable.",
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
heading: "Use D1 for app behavior",
|
|
61
|
+
body: "Auth, account settings, comment moderation, favorites, and rate limits need transactional writes and predictable query paths.",
|
|
62
|
+
bullets: [
|
|
63
|
+
"Keep user-owned state outside the editorial workspace.",
|
|
64
|
+
"Avoid coupling high-frequency actions to Notion API limits.",
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
closing: "This starter begins with blog content in Notion and leaves room for more content models as the project grows.",
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
title: "Designing a Homepage That Editors Can Evolve",
|
|
72
|
+
slug: "designing-a-homepage-that-editors-can-evolve",
|
|
73
|
+
description: "A lightweight approach to editable pages: stable layout in code, flexible copy and body sections in Notion.",
|
|
74
|
+
date: "2026-06-06",
|
|
75
|
+
tags: ["Pages", "Design", "CMS"],
|
|
76
|
+
coverSeed: "editable-homepage",
|
|
77
|
+
intro: "Homepages often need a stronger visual opinion than articles, but the words and sections still change. A Pages content model can give editors control without turning the app into a page builder.",
|
|
78
|
+
sections: [
|
|
79
|
+
{
|
|
80
|
+
heading: "Model pages by stable keys",
|
|
81
|
+
body: "A page record can use keys such as home, about, or pricing. Code decides which layout to render; Notion supplies title, SEO fields, and body blocks.",
|
|
82
|
+
bullets: [
|
|
83
|
+
"Use a Key field for stable lookup.",
|
|
84
|
+
"Use Locale when translations are enabled.",
|
|
85
|
+
"Render ordinary blocks for long-form sections.",
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
heading: "Add section mapping only where it helps",
|
|
90
|
+
body: "For special homepage modules, map named blocks or section records to components. Keep that mapping narrow until multiple pages prove the same pattern.",
|
|
91
|
+
bullets: [
|
|
92
|
+
"Start simple with page blocks.",
|
|
93
|
+
"Extract reusable sections later.",
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
closing: "That balance gives users editable pages today and a clear path toward richer page modules tomorrow.",
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
title: "Turning Starter Content Into a Real Launch Site",
|
|
101
|
+
slug: "turning-starter-content-into-a-real-launch-site",
|
|
102
|
+
description: "How to turn generated defaults into a credible launch-ready site without rewriting the whole scaffold.",
|
|
103
|
+
date: "2026-06-08",
|
|
104
|
+
tags: ["Launch", "Starter", "Design"],
|
|
105
|
+
coverSeed: "launch-site",
|
|
106
|
+
intro: "A scaffold becomes more useful when it already looks believable. The first edits should feel like refinement, not rescue work.",
|
|
107
|
+
sections: [
|
|
108
|
+
{
|
|
109
|
+
heading: "Start with defaults you can trust",
|
|
110
|
+
body: "Strong starter content gives teams something concrete to react to: rename what matters, keep what works, and replace the placeholders you outgrow.",
|
|
111
|
+
bullets: [
|
|
112
|
+
"Use semantic page titles from day one.",
|
|
113
|
+
"Seed reusable homepage sections that match the UI.",
|
|
114
|
+
"Make settings visible before customization starts.",
|
|
115
|
+
],
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
closing: "Good starter content shortens the distance between installation and a site you would actually show someone.",
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
title: "Designing With Reusable Homepage Blocks",
|
|
122
|
+
slug: "designing-with-reusable-homepage-blocks",
|
|
123
|
+
description: "Why a homepage should be assembled from named reusable sections instead of route-level one-off copy.",
|
|
124
|
+
date: "2026-06-10",
|
|
125
|
+
tags: ["Blocks", "Homepage", "UI"],
|
|
126
|
+
coverSeed: "homepage-blocks",
|
|
127
|
+
intro: "Reusable homepage blocks make the starter easier to understand: the content lives in one place, and the layout stays opinionated but editable.",
|
|
128
|
+
sections: [
|
|
129
|
+
{
|
|
130
|
+
heading: "Name blocks by purpose",
|
|
131
|
+
body: "Homepage Hero, Homepage Feature Grid, and Homepage Latest Posts are easier to edit and reuse than project-name-derived rows.",
|
|
132
|
+
bullets: [
|
|
133
|
+
"Keep labels semantic.",
|
|
134
|
+
"Use stable slugs for page composition.",
|
|
135
|
+
"Let each block demonstrate one layout idea.",
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
closing: "Once the homepage is a composition of reusable blocks, extending it becomes an additive change instead of a rewrite.",
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
title: "Making Site Settings Useful On Day One",
|
|
143
|
+
slug: "making-site-settings-useful-on-day-one",
|
|
144
|
+
description: "Why navigation, footer, SEO, and image defaults should already be present when the project is first created.",
|
|
145
|
+
date: "2026-06-12",
|
|
146
|
+
tags: ["Settings", "SEO", "Navigation"],
|
|
147
|
+
coverSeed: "site-settings-day-one",
|
|
148
|
+
intro: "Site settings should not feel like an empty shell. A seeded row helps operators understand what they can change without first reading the code.",
|
|
149
|
+
sections: [
|
|
150
|
+
{
|
|
151
|
+
heading: "Seed the whole frame",
|
|
152
|
+
body: "Navigation, footer groups, default images, and SEO copy should all exist before the first deploy so the starter looks intentional.",
|
|
153
|
+
bullets: [
|
|
154
|
+
"Show Home, About, and Blog consistently.",
|
|
155
|
+
"Include a visible image URL for social previews.",
|
|
156
|
+
"Make the footer usable immediately.",
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
closing: "A complete settings seed turns the first Notion visit into editing, not troubleshooting.",
|
|
161
|
+
},
|
|
162
|
+
];
|
|
163
|
+
const CHINESE_SAMPLE_POSTS = [
|
|
164
|
+
{
|
|
165
|
+
title: "建立一个不打扰创作的发布流程",
|
|
166
|
+
slug: "calm-publishing-workflow",
|
|
167
|
+
description: "从 Notion 草稿到公开博客的一次完整示例:把结构交给代码,把内容编辑留在 Notion。",
|
|
168
|
+
date: "2026-06-02",
|
|
169
|
+
tags: ["工作流", "Notion", "发布"],
|
|
170
|
+
coverSeed: "calm-publishing-workflow-zh",
|
|
171
|
+
intro: "好的发布系统应该安静可靠。这个脚手架把路由、权限、缓存这些稳定能力放在代码里,把文章正文和编辑体验交给 Notion。",
|
|
172
|
+
sections: [
|
|
173
|
+
{
|
|
174
|
+
heading: "先固定最少的内容结构",
|
|
175
|
+
body: "每篇文章只需要少量结构化字段来支持列表、详情页和搜索;真正的长文内容继续使用 Notion 页面块来编辑。",
|
|
176
|
+
bullets: [
|
|
177
|
+
"Slug 决定公开访问路径。",
|
|
178
|
+
"Published 控制是否发布。",
|
|
179
|
+
"Tags 为之后的筛选和聚合留出空间。",
|
|
180
|
+
],
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
heading: "让站点行为保持稳定",
|
|
184
|
+
body: "生成的项目会读取已注册的内容模型,过滤未发布内容,渲染文章卡片,并把 Notion 页面块作为正文。",
|
|
185
|
+
bullets: [
|
|
186
|
+
"文案和封面在 Notion 中修改。",
|
|
187
|
+
"页面布局和交互在源码中维护。",
|
|
188
|
+
],
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
closing: "当项目准备正式上线时,可以把这篇示例替换成自己的第一篇发布说明。",
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
title: "哪些数据放 Notion,哪些数据放 D1",
|
|
195
|
+
slug: "notion-vs-d1-content-architecture",
|
|
196
|
+
description: "一个实用的存储原则:内容和编辑模型集中在 Notion,用户行为与事务状态留在 D1。",
|
|
197
|
+
date: "2026-06-04",
|
|
198
|
+
tags: ["架构", "D1", "内容模型"],
|
|
199
|
+
coverSeed: "notion-d1-architecture-zh",
|
|
200
|
+
intro: "Notion 适合内容建模和人工编辑,D1 更适合用户、会话、评论、收藏、点赞这类需要频繁写入和权限控制的数据。",
|
|
201
|
+
sections: [
|
|
202
|
+
{
|
|
203
|
+
heading: "把编辑事实放在 Notion",
|
|
204
|
+
body: "文章、页面、指南、作品集和资料库都可以从 Notion 的数据库字段、视图、关系和页面块里受益。",
|
|
205
|
+
bullets: [
|
|
206
|
+
"编辑可以不发版就更新内容。",
|
|
207
|
+
"新的内容模型可以按数据库逐步扩展。",
|
|
208
|
+
"关系字段能承载更复杂的内容组织。",
|
|
209
|
+
],
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
heading: "把应用行为放在 D1",
|
|
213
|
+
body: "登录、账号设置、评论审核、喜欢、收藏、限流这些功能需要更可控的事务写入和查询路径。",
|
|
214
|
+
bullets: [
|
|
215
|
+
"用户自己的状态不应该绑死在编辑工作区里。",
|
|
216
|
+
"高频行为也不适合依赖 Notion API 限额。",
|
|
217
|
+
],
|
|
218
|
+
},
|
|
219
|
+
],
|
|
220
|
+
closing: "这个脚手架先从 Notion 博客开始,后续可以继续添加电影、文档、作品集等更多内容模型。",
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
title: "设计一个可以被编辑持续更新的首页",
|
|
224
|
+
slug: "editable-homepage-with-notion",
|
|
225
|
+
description: "一个轻量的页面内容方案:稳定布局放在代码里,标题、SEO 和正文区块放在 Notion。",
|
|
226
|
+
date: "2026-06-06",
|
|
227
|
+
tags: ["页面", "设计", "CMS"],
|
|
228
|
+
coverSeed: "editable-homepage-zh",
|
|
229
|
+
intro: "首页通常需要更强的设计表达,但标题、介绍和内容区块会持续变化。页面模型可以让用户编辑内容,又不会把项目变成复杂页面搭建器。",
|
|
230
|
+
sections: [
|
|
231
|
+
{
|
|
232
|
+
heading: "用稳定 key 管理页面",
|
|
233
|
+
body: "页面记录可以使用 home、about、pricing 这样的固定 key。代码根据 key 选择布局,Notion 提供标题、SEO 字段和正文块。",
|
|
234
|
+
bullets: [
|
|
235
|
+
"Key 用于稳定查找。",
|
|
236
|
+
"Locale 为多语言扩展预留位置。",
|
|
237
|
+
"普通页面块适合承载长文和说明区。",
|
|
238
|
+
],
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
heading: "只在必要时映射特殊区块",
|
|
242
|
+
body: "如果首页有特别的模块,可以把命名块或 section 记录映射到组件。先保持小范围映射,等多个页面证明模式稳定后再抽象。",
|
|
243
|
+
bullets: [
|
|
244
|
+
"先用页面块解决大部分内容编辑。",
|
|
245
|
+
"后续再提炼复用 section 组件。",
|
|
246
|
+
],
|
|
247
|
+
},
|
|
248
|
+
],
|
|
249
|
+
closing: "这样用户能立刻编辑页面内容,项目也保留继续长出复杂页面能力的空间。",
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
title: "把默认内容打磨成一个真正能展示的网站",
|
|
253
|
+
slug: "starter-content-to-real-launch-site",
|
|
254
|
+
description: "如何把脚手架初始内容逐步打磨成一个可信、可演示、可继续编辑的网站。",
|
|
255
|
+
date: "2026-06-08",
|
|
256
|
+
tags: ["上线", "默认内容", "设计"],
|
|
257
|
+
coverSeed: "launch-site-zh",
|
|
258
|
+
intro: "好的脚手架不只是能跑起来,而是应该在第一次打开时就已经像一个完整站点,让后续修改更像打磨而不是补洞。",
|
|
259
|
+
sections: [
|
|
260
|
+
{
|
|
261
|
+
heading: "先把默认体验做完整",
|
|
262
|
+
body: "首页、导航、设置和文章列表如果一开始就有合理内容,用户就更容易理解系统边界,也更愿意继续往下改。",
|
|
263
|
+
bullets: [
|
|
264
|
+
"首页标题使用固定语义名。",
|
|
265
|
+
"把展示内容集中到可复用区块里。",
|
|
266
|
+
"让设置项在 Notion 中一打开就可编辑。",
|
|
267
|
+
],
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
closing: "当默认内容已经足够可信时,脚手架就不只是起点,而是第一版成品。",
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
title: "用可复用首页区块组织默认站点",
|
|
274
|
+
slug: "reusable-homepage-blocks-for-starter-sites",
|
|
275
|
+
description: "为什么首页应该由命名清晰的可复用区块组成,而不是写在页面模板里的零散文案。",
|
|
276
|
+
date: "2026-06-10",
|
|
277
|
+
tags: ["区块", "首页", "UI"],
|
|
278
|
+
coverSeed: "homepage-blocks-zh",
|
|
279
|
+
intro: "当首页 Hero、功能展示和最新文章都来自同一套区块体系时,默认站点更清晰,也更容易被继续扩展。",
|
|
280
|
+
sections: [
|
|
281
|
+
{
|
|
282
|
+
heading: "让区块名称表达用途",
|
|
283
|
+
body: "像“首页 Hero”“首页功能展示”“首页最新文章”这样的命名,比把项目名拼进去更稳定,也更适合后续重复使用。",
|
|
284
|
+
bullets: [
|
|
285
|
+
"名称表达语义而不是品牌名。",
|
|
286
|
+
"slug 保持稳定,便于页面组合。",
|
|
287
|
+
"每个区块只负责一种展示模式。",
|
|
288
|
+
],
|
|
289
|
+
},
|
|
290
|
+
],
|
|
291
|
+
closing: "首页一旦被组织成可复用区块,后续新增样式和内容也会更从容。",
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
title: "让网站设置从第一天起就真正可用",
|
|
295
|
+
slug: "site-settings-useful-from-day-one",
|
|
296
|
+
description: "为什么导航、页脚、SEO 和默认图片应该在项目创建时就已经填好。",
|
|
297
|
+
date: "2026-06-12",
|
|
298
|
+
tags: ["设置", "SEO", "导航"],
|
|
299
|
+
coverSeed: "site-settings-day-one-zh",
|
|
300
|
+
intro: "网站设置不应该只是一个空白表。预填一行完整设置,能让用户第一次打开 Notion 就理解哪些内容可以直接改。",
|
|
301
|
+
sections: [
|
|
302
|
+
{
|
|
303
|
+
heading: "先把站点框架填起来",
|
|
304
|
+
body: "导航、页脚分组、默认图片和基础 SEO 文案都应该在首次安装后就已经存在,这样默认站点才像一个完整成品。",
|
|
305
|
+
bullets: [
|
|
306
|
+
"首页、关于、博客默认同时出现在导航里。",
|
|
307
|
+
"社交图与站点图标默认就有可见值。",
|
|
308
|
+
"页脚第一天就不是空白。",
|
|
309
|
+
],
|
|
310
|
+
},
|
|
311
|
+
],
|
|
312
|
+
closing: "完整的网站设置预设,能把第一次编辑体验从排障变成真正的内容修改。",
|
|
313
|
+
},
|
|
314
|
+
];
|
|
315
|
+
function samplePostFor(index, locale = "en") {
|
|
316
|
+
const posts = samplePosts(locale);
|
|
317
|
+
return posts[(index - 1) % posts.length];
|
|
318
|
+
}
|
|
319
|
+
function samplePosts(locale = "en") {
|
|
320
|
+
return locale.toLowerCase().startsWith("zh")
|
|
321
|
+
? CHINESE_SAMPLE_POSTS
|
|
322
|
+
: ENGLISH_SAMPLE_POSTS;
|
|
323
|
+
}
|
|
324
|
+
function sampleSitePages(input) {
|
|
325
|
+
if (input.locale?.toLowerCase().startsWith("zh")) {
|
|
326
|
+
return [
|
|
327
|
+
{
|
|
328
|
+
title: "首页",
|
|
329
|
+
key: "home",
|
|
330
|
+
slug: "",
|
|
331
|
+
layout: "home",
|
|
332
|
+
description: "一个由 Notion 内容、Cloudflare Workers、D1 和 R2 驱动的可编辑网站。",
|
|
333
|
+
seoTitle: input.projectName,
|
|
334
|
+
seoDescription: "一个由 Notion 内容、Cloudflare Workers、D1 和 R2 驱动的可编辑网站。",
|
|
335
|
+
showHeader: true,
|
|
336
|
+
showFooter: true,
|
|
337
|
+
showInNav: false,
|
|
338
|
+
navLabel: "首页",
|
|
339
|
+
navOrder: 0,
|
|
340
|
+
showInFooter: false,
|
|
341
|
+
footerLabel: "首页",
|
|
342
|
+
footerGroup: "站点",
|
|
343
|
+
footerOrder: 0,
|
|
344
|
+
blocks: [
|
|
345
|
+
{ slug: "home-hero", variant: "hero", order: 10 },
|
|
346
|
+
{ slug: "home-feature-grid", variant: "feature-grid", order: 20 },
|
|
347
|
+
{ slug: "home-latest-posts", order: 30 },
|
|
348
|
+
],
|
|
349
|
+
coverSeed: "home-page-zh",
|
|
350
|
+
body: [
|
|
351
|
+
{
|
|
352
|
+
heading: "从 Notion 开始编辑网站",
|
|
353
|
+
text: "这个首页来自 Pages 数据库。你可以在 Notion 中修改标题、描述、正文、导航和页脚显示状态。",
|
|
354
|
+
bullets: [
|
|
355
|
+
"页面结构保存在 Pages 数据库。",
|
|
356
|
+
"博客等内容列表保存在各自的数据源。",
|
|
357
|
+
"用户、评论、收藏等应用状态继续交给 D1。",
|
|
358
|
+
],
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
heading: "页面和内容分开管理",
|
|
362
|
+
text: "Pages 负责网站信息架构;Blog 负责文章内容。后续添加作品集、电影库或文档库时,也会沿用这个边界。",
|
|
363
|
+
},
|
|
364
|
+
],
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
title: "关于",
|
|
368
|
+
key: "about",
|
|
369
|
+
slug: "about",
|
|
370
|
+
layout: "default",
|
|
371
|
+
description: "介绍这个网站、团队或项目背景。",
|
|
372
|
+
seoTitle: `关于 ${input.projectName}`,
|
|
373
|
+
seoDescription: "介绍这个网站、团队或项目背景。",
|
|
374
|
+
showHeader: true,
|
|
375
|
+
showFooter: true,
|
|
376
|
+
showInNav: true,
|
|
377
|
+
navLabel: "关于",
|
|
378
|
+
navOrder: 20,
|
|
379
|
+
showInFooter: true,
|
|
380
|
+
footerLabel: "关于",
|
|
381
|
+
footerGroup: "站点",
|
|
382
|
+
footerOrder: 10,
|
|
383
|
+
coverSeed: "about-page-zh",
|
|
384
|
+
body: [
|
|
385
|
+
{
|
|
386
|
+
heading: "可以从这里开始写品牌故事",
|
|
387
|
+
text: "把这段内容替换成你的项目介绍、价值主张、联系方式或团队说明。",
|
|
388
|
+
},
|
|
389
|
+
],
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
title: input.contentSourceTitle,
|
|
393
|
+
key: input.contentSourceId,
|
|
394
|
+
slug: input.contentSourceListPath.replace(/^\//, ""),
|
|
395
|
+
layout: "content-list",
|
|
396
|
+
description: "来自 Notion 内容数据源的最新文章。",
|
|
397
|
+
seoTitle: input.contentSourceTitle,
|
|
398
|
+
seoDescription: "来自 Notion 内容数据源的最新文章。",
|
|
399
|
+
showHeader: true,
|
|
400
|
+
showFooter: true,
|
|
401
|
+
showInNav: true,
|
|
402
|
+
navLabel: input.contentSourceTitle,
|
|
403
|
+
navOrder: 30,
|
|
404
|
+
showInFooter: true,
|
|
405
|
+
footerLabel: input.contentSourceTitle,
|
|
406
|
+
footerGroup: "内容",
|
|
407
|
+
footerOrder: 10,
|
|
408
|
+
contentSource: input.contentSourceId,
|
|
409
|
+
coverSeed: "content-list-page-zh",
|
|
410
|
+
body: [
|
|
411
|
+
{
|
|
412
|
+
heading: "内容列表页面",
|
|
413
|
+
text: "这个页面记录控制列表页的标题、SEO、导航和页脚;实际文章来自对应的内容数据源。",
|
|
414
|
+
},
|
|
415
|
+
],
|
|
416
|
+
},
|
|
417
|
+
{
|
|
418
|
+
title: "隐私政策",
|
|
419
|
+
key: "privacy",
|
|
420
|
+
slug: "privacy",
|
|
421
|
+
layout: "legal",
|
|
422
|
+
description: "说明这个网站如何处理数据和隐私。",
|
|
423
|
+
seoTitle: "隐私政策",
|
|
424
|
+
seoDescription: "说明这个网站如何处理数据和隐私。",
|
|
425
|
+
showHeader: true,
|
|
426
|
+
showFooter: true,
|
|
427
|
+
showInNav: false,
|
|
428
|
+
navLabel: "隐私政策",
|
|
429
|
+
navOrder: 90,
|
|
430
|
+
showInFooter: true,
|
|
431
|
+
footerLabel: "隐私政策",
|
|
432
|
+
footerGroup: "法律",
|
|
433
|
+
footerOrder: 10,
|
|
434
|
+
coverSeed: "privacy-page-zh",
|
|
435
|
+
body: [
|
|
436
|
+
{
|
|
437
|
+
heading: "示例隐私说明",
|
|
438
|
+
text: "这是一个占位页面。正式上线前,请根据你的产品、地区和数据处理方式替换成真实隐私政策。",
|
|
439
|
+
},
|
|
440
|
+
],
|
|
441
|
+
},
|
|
442
|
+
];
|
|
443
|
+
}
|
|
444
|
+
return [
|
|
445
|
+
{
|
|
446
|
+
title: "Home",
|
|
447
|
+
key: "home",
|
|
448
|
+
slug: "",
|
|
449
|
+
layout: "home",
|
|
450
|
+
description: "An editable Notion-powered site running on Cloudflare Workers, D1, and R2.",
|
|
451
|
+
seoTitle: input.projectName,
|
|
452
|
+
seoDescription: "An editable Notion-powered site running on Cloudflare Workers, D1, and R2.",
|
|
453
|
+
showHeader: true,
|
|
454
|
+
showFooter: true,
|
|
455
|
+
showInNav: false,
|
|
456
|
+
navLabel: "Home",
|
|
457
|
+
navOrder: 0,
|
|
458
|
+
showInFooter: false,
|
|
459
|
+
footerLabel: "Home",
|
|
460
|
+
footerGroup: "Site",
|
|
461
|
+
footerOrder: 0,
|
|
462
|
+
blocks: [
|
|
463
|
+
{ slug: "home-hero", variant: "hero", order: 10 },
|
|
464
|
+
{ slug: "home-feature-grid", variant: "feature-grid", order: 20 },
|
|
465
|
+
{ slug: "home-latest-posts", order: 30 },
|
|
466
|
+
],
|
|
467
|
+
coverSeed: "home-page",
|
|
468
|
+
body: [
|
|
469
|
+
{
|
|
470
|
+
heading: "Edit the site from Notion",
|
|
471
|
+
text: "This homepage comes from the Pages database. Update title, description, body copy, navigation, and footer visibility without changing code.",
|
|
472
|
+
bullets: [
|
|
473
|
+
"Site structure lives in Pages.",
|
|
474
|
+
"Articles and other list content live in their own data sources.",
|
|
475
|
+
"Users, comments, favorites, and likes stay in D1.",
|
|
476
|
+
],
|
|
477
|
+
},
|
|
478
|
+
{
|
|
479
|
+
heading: "Pages and content stay separate",
|
|
480
|
+
text: "Pages define information architecture. Blog defines article content. Future portfolios, catalogs, or docs can follow the same boundary.",
|
|
481
|
+
},
|
|
482
|
+
],
|
|
483
|
+
},
|
|
484
|
+
{
|
|
485
|
+
title: "About",
|
|
486
|
+
key: "about",
|
|
487
|
+
slug: "about",
|
|
488
|
+
layout: "default",
|
|
489
|
+
description: "Introduce the project, company, venue, or publication.",
|
|
490
|
+
seoTitle: `About ${input.projectName}`,
|
|
491
|
+
seoDescription: "Introduce the project, company, venue, or publication.",
|
|
492
|
+
showHeader: true,
|
|
493
|
+
showFooter: true,
|
|
494
|
+
showInNav: true,
|
|
495
|
+
navLabel: "About",
|
|
496
|
+
navOrder: 20,
|
|
497
|
+
showInFooter: true,
|
|
498
|
+
footerLabel: "About",
|
|
499
|
+
footerGroup: "Site",
|
|
500
|
+
footerOrder: 10,
|
|
501
|
+
coverSeed: "about-page",
|
|
502
|
+
body: [
|
|
503
|
+
{
|
|
504
|
+
heading: "Start with the story behind the site",
|
|
505
|
+
text: "Replace this page with your project story, value proposition, contact details, or team notes.",
|
|
506
|
+
},
|
|
507
|
+
],
|
|
508
|
+
},
|
|
509
|
+
{
|
|
510
|
+
title: input.contentSourceTitle,
|
|
511
|
+
key: input.contentSourceId,
|
|
512
|
+
slug: input.contentSourceListPath.replace(/^\//, ""),
|
|
513
|
+
layout: "content-list",
|
|
514
|
+
description: "Latest articles from the Notion content data source.",
|
|
515
|
+
seoTitle: input.contentSourceTitle,
|
|
516
|
+
seoDescription: "Latest articles from the Notion content data source.",
|
|
517
|
+
showHeader: true,
|
|
518
|
+
showFooter: true,
|
|
519
|
+
showInNav: true,
|
|
520
|
+
navLabel: input.contentSourceTitle,
|
|
521
|
+
navOrder: 30,
|
|
522
|
+
showInFooter: true,
|
|
523
|
+
footerLabel: input.contentSourceTitle,
|
|
524
|
+
footerGroup: "Content",
|
|
525
|
+
footerOrder: 10,
|
|
526
|
+
contentSource: input.contentSourceId,
|
|
527
|
+
coverSeed: "content-list-page",
|
|
528
|
+
body: [
|
|
529
|
+
{
|
|
530
|
+
heading: "Content list page",
|
|
531
|
+
text: "This page controls the list page title, SEO, navigation, and footer placement. The actual articles come from the linked content source.",
|
|
532
|
+
},
|
|
533
|
+
],
|
|
534
|
+
},
|
|
535
|
+
{
|
|
536
|
+
title: "Privacy",
|
|
537
|
+
key: "privacy",
|
|
538
|
+
slug: "privacy",
|
|
539
|
+
layout: "legal",
|
|
540
|
+
description: "Explain how this site handles data and privacy.",
|
|
541
|
+
seoTitle: "Privacy",
|
|
542
|
+
seoDescription: "Explain how this site handles data and privacy.",
|
|
543
|
+
showHeader: true,
|
|
544
|
+
showFooter: true,
|
|
545
|
+
showInNav: false,
|
|
546
|
+
navLabel: "Privacy",
|
|
547
|
+
navOrder: 90,
|
|
548
|
+
showInFooter: true,
|
|
549
|
+
footerLabel: "Privacy",
|
|
550
|
+
footerGroup: "Legal",
|
|
551
|
+
footerOrder: 10,
|
|
552
|
+
coverSeed: "privacy-page",
|
|
553
|
+
body: [
|
|
554
|
+
{
|
|
555
|
+
heading: "Sample privacy notice",
|
|
556
|
+
text: "This is placeholder legal copy. Replace it before launch with a policy that matches your product, region, and data handling practices.",
|
|
557
|
+
},
|
|
558
|
+
],
|
|
559
|
+
},
|
|
560
|
+
];
|
|
561
|
+
}
|
|
562
|
+
function sampleBlocks(input) {
|
|
563
|
+
if (input.locale?.toLowerCase().startsWith("zh")) {
|
|
564
|
+
return [
|
|
565
|
+
{
|
|
566
|
+
title: "首页 Hero",
|
|
567
|
+
slug: "home-hero",
|
|
568
|
+
type: "hero",
|
|
569
|
+
description: "首页顶部主视觉区块,适合放标题、副标题与主行动按钮。",
|
|
570
|
+
pageKeys: ["home"],
|
|
571
|
+
order: 10,
|
|
572
|
+
coverSeed: "home-hero-zh",
|
|
573
|
+
eyebrow: "Notion + Cloudflare",
|
|
574
|
+
headline: "从一个可以持续编辑的首页开始",
|
|
575
|
+
subheadline: "把首页的一句话价值、介绍文案和主行动按钮交给 Notion,站点布局继续由代码稳定控制。",
|
|
576
|
+
primaryCtaLabel: "查看内容列表",
|
|
577
|
+
primaryCtaHref: "/blog",
|
|
578
|
+
secondaryCtaLabel: "了解项目",
|
|
579
|
+
secondaryCtaHref: "/about",
|
|
580
|
+
alignment: "center",
|
|
581
|
+
theme: "muted",
|
|
582
|
+
},
|
|
583
|
+
{
|
|
584
|
+
title: "首页功能展示",
|
|
585
|
+
slug: "home-feature-grid",
|
|
586
|
+
type: "feature-grid",
|
|
587
|
+
description: "用于首页中段的功能/能力展示区块。",
|
|
588
|
+
pageKeys: ["home"],
|
|
589
|
+
order: 20,
|
|
590
|
+
coverSeed: "home-feature-grid-zh",
|
|
591
|
+
headline: "把内容、运行时和发布流程串成一个清晰系统",
|
|
592
|
+
body: "这个区块默认用三列卡片展示项目能力,适合介绍内容工作流、部署基础设施和持续发布能力。",
|
|
593
|
+
columns: 3,
|
|
594
|
+
items: [
|
|
595
|
+
{
|
|
596
|
+
title: "内容编辑",
|
|
597
|
+
description: "让编辑直接在 Notion 中维护页面与内容,不需要改代码。",
|
|
598
|
+
icon: "pen-square",
|
|
599
|
+
href: "/about",
|
|
600
|
+
},
|
|
601
|
+
{
|
|
602
|
+
title: "云端运行",
|
|
603
|
+
description: "基于 Cloudflare Workers、D1 和 KV 提供轻量稳定的运行时能力。",
|
|
604
|
+
icon: "cloud",
|
|
605
|
+
},
|
|
606
|
+
{
|
|
607
|
+
title: "持续更新",
|
|
608
|
+
description: `${input.contentSourceTitle} 列表可以持续发布新内容,并自动进入站点路由。`,
|
|
609
|
+
icon: "newspaper",
|
|
610
|
+
href: "/blog",
|
|
611
|
+
},
|
|
612
|
+
],
|
|
613
|
+
},
|
|
614
|
+
{
|
|
615
|
+
title: "首页最新文章",
|
|
616
|
+
slug: "home-latest-posts",
|
|
617
|
+
type: "latest-posts",
|
|
618
|
+
description: "在首页展示最近发布内容的文章卡片区块。",
|
|
619
|
+
pageKeys: ["home"],
|
|
620
|
+
order: 30,
|
|
621
|
+
coverSeed: "home-latest-posts-zh",
|
|
622
|
+
headline: "看看最近更新了什么",
|
|
623
|
+
body: "默认展示最新发布的 6 篇内容,既能丰富首页,也能直接验证博客内容链路是否生效。",
|
|
624
|
+
count: 6,
|
|
625
|
+
primaryCtaLabel: "查看全部文章",
|
|
626
|
+
primaryCtaHref: "/blog",
|
|
627
|
+
},
|
|
628
|
+
];
|
|
629
|
+
}
|
|
630
|
+
return [
|
|
631
|
+
{
|
|
632
|
+
title: "Homepage Hero",
|
|
633
|
+
slug: "home-hero",
|
|
634
|
+
type: "hero",
|
|
635
|
+
description: "Homepage hero module for headline, supporting copy, and primary CTA.",
|
|
636
|
+
pageKeys: ["home"],
|
|
637
|
+
order: 10,
|
|
638
|
+
coverSeed: "home-hero",
|
|
639
|
+
eyebrow: "Notion + Cloudflare",
|
|
640
|
+
headline: "Start with a homepage you can keep editing",
|
|
641
|
+
subheadline: "Keep the layout stable in code while the hero copy, positioning, and primary call to action evolve in Notion.",
|
|
642
|
+
primaryCtaLabel: "Explore the blog",
|
|
643
|
+
primaryCtaHref: "/blog",
|
|
644
|
+
secondaryCtaLabel: "Read the story",
|
|
645
|
+
secondaryCtaHref: "/about",
|
|
646
|
+
alignment: "center",
|
|
647
|
+
theme: "muted",
|
|
648
|
+
},
|
|
649
|
+
{
|
|
650
|
+
title: "Homepage Feature Grid",
|
|
651
|
+
slug: "home-feature-grid",
|
|
652
|
+
type: "feature-grid",
|
|
653
|
+
description: "Mid-page feature section for capabilities, benefits, or service pillars.",
|
|
654
|
+
pageKeys: ["home"],
|
|
655
|
+
order: 20,
|
|
656
|
+
coverSeed: "home-feature-grid",
|
|
657
|
+
headline: "Show the system working together",
|
|
658
|
+
body: "Use this grid to explain how editing, infrastructure, and publishing fit together without overwhelming the homepage.",
|
|
659
|
+
columns: 3,
|
|
660
|
+
items: [
|
|
661
|
+
{
|
|
662
|
+
title: "Editorial workflows",
|
|
663
|
+
description: "Use Notion as the editor for pages, posts, and reusable sections.",
|
|
664
|
+
icon: "pen-square",
|
|
665
|
+
href: "/about",
|
|
666
|
+
},
|
|
667
|
+
{
|
|
668
|
+
title: "Cloudflare runtime",
|
|
669
|
+
description: "Ship on Workers with storage and caching primitives ready to grow.",
|
|
670
|
+
icon: "cloud",
|
|
671
|
+
},
|
|
672
|
+
{
|
|
673
|
+
title: `${input.contentSourceTitle} updates`,
|
|
674
|
+
description: "Publish new entries and surface them through the generated routes automatically.",
|
|
675
|
+
icon: "newspaper",
|
|
676
|
+
href: "/blog",
|
|
677
|
+
},
|
|
678
|
+
],
|
|
679
|
+
},
|
|
680
|
+
{
|
|
681
|
+
title: "Homepage Latest Posts",
|
|
682
|
+
slug: "home-latest-posts",
|
|
683
|
+
type: "latest-posts",
|
|
684
|
+
description: "Homepage latest-posts block for previewing the newest published content.",
|
|
685
|
+
pageKeys: ["home"],
|
|
686
|
+
order: 30,
|
|
687
|
+
coverSeed: "home-latest-posts",
|
|
688
|
+
headline: "Read the latest from the blog",
|
|
689
|
+
body: "Use this section to prove the content model is working with a grid of recent published posts right on the homepage.",
|
|
690
|
+
count: 6,
|
|
691
|
+
primaryCtaLabel: "View all posts",
|
|
692
|
+
primaryCtaHref: "/blog",
|
|
693
|
+
},
|
|
694
|
+
];
|
|
695
|
+
}
|
|
696
|
+
/** Best-effort: pick a Notion property type from a camelCase key. */
|
|
697
|
+
function notionPropertyType(key, notionName) {
|
|
698
|
+
const normalized = notionName.trim().toLowerCase();
|
|
699
|
+
if (key === "title" || normalized === "title" || normalized === "name") {
|
|
700
|
+
return "title";
|
|
701
|
+
}
|
|
702
|
+
if (key === "published")
|
|
703
|
+
return "checkbox";
|
|
704
|
+
if (key === "date")
|
|
705
|
+
return "date";
|
|
706
|
+
if (key === "tags")
|
|
707
|
+
return "multi_select";
|
|
708
|
+
if (key === "cover")
|
|
709
|
+
return "files";
|
|
710
|
+
return "rich_text";
|
|
711
|
+
}
|
|
712
|
+
/** Build the Notion `properties` object for database creation. */
|
|
713
|
+
function buildProperties(fields) {
|
|
714
|
+
const props = {};
|
|
715
|
+
for (const f of fields) {
|
|
716
|
+
const type = notionPropertyType(f.key, f.notionName);
|
|
717
|
+
props[f.notionName] = { [type]: {} };
|
|
718
|
+
}
|
|
719
|
+
// Notion requires a `title` property — if the user didn't include
|
|
720
|
+
// one, add a synthetic one (the generated models.ts will need to be
|
|
721
|
+
// adjusted to point at it).
|
|
722
|
+
if (!Object.values(props).some((p) => "title" in p)) {
|
|
723
|
+
props["Name"] = { title: {} };
|
|
724
|
+
}
|
|
725
|
+
return props;
|
|
726
|
+
}
|
|
727
|
+
function resolveTitlePropertyName(properties) {
|
|
728
|
+
const entry = Object.entries(properties).find(([, value]) => notionPropertyDefinitionType(value) === "title");
|
|
729
|
+
return entry?.[0] ?? "Name";
|
|
730
|
+
}
|
|
731
|
+
async function getDataSourceSchema(apiToken, dataSourceId) {
|
|
732
|
+
const stdout = await runOrThrowNtn(["api", `v1/data_sources/${dataSourceId}`], {
|
|
733
|
+
env: { NOTION_API_TOKEN: apiToken },
|
|
734
|
+
});
|
|
735
|
+
const raw = JSON.parse(stdout);
|
|
736
|
+
return raw.properties ?? {};
|
|
737
|
+
}
|
|
738
|
+
function normalizeTitle(value) {
|
|
739
|
+
return value.replace(/\s+/g, " ").trim().toLowerCase();
|
|
740
|
+
}
|
|
741
|
+
function compactNotionId(value) {
|
|
742
|
+
const compact = value.trim().replace(/-/g, "");
|
|
743
|
+
const matches = compact.match(/[0-9a-fA-F]{32}/g);
|
|
744
|
+
return (matches?.at(-1) ?? compact).toLowerCase();
|
|
745
|
+
}
|
|
746
|
+
function isRecord(value) {
|
|
747
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
748
|
+
}
|
|
749
|
+
function plainText(parts) {
|
|
750
|
+
if (!Array.isArray(parts))
|
|
751
|
+
return "";
|
|
752
|
+
return parts
|
|
753
|
+
.map((part) => {
|
|
754
|
+
if (!isRecord(part))
|
|
755
|
+
return "";
|
|
756
|
+
if (typeof part.plain_text === "string")
|
|
757
|
+
return part.plain_text;
|
|
758
|
+
const text = part.text;
|
|
759
|
+
if (isRecord(text) && typeof text.content === "string")
|
|
760
|
+
return text.content;
|
|
761
|
+
return "";
|
|
762
|
+
})
|
|
763
|
+
.join("")
|
|
764
|
+
.trim();
|
|
765
|
+
}
|
|
766
|
+
const SCAFFOLD_MARKER_PREFIX = "[notionx-scaffold] key=";
|
|
767
|
+
function buildScaffoldMarker(stableKey) {
|
|
768
|
+
return `${SCAFFOLD_MARKER_PREFIX}${stableKey.trim()}`;
|
|
769
|
+
}
|
|
770
|
+
function extractScaffoldKey(description) {
|
|
771
|
+
const match = description.match(/\[notionx-scaffold\] key=([^\n\r]+)/);
|
|
772
|
+
return match?.[1]?.trim() || null;
|
|
773
|
+
}
|
|
774
|
+
function mergeDescriptionWithScaffoldMarker(existingDescription, stableKey) {
|
|
775
|
+
const marker = buildScaffoldMarker(stableKey);
|
|
776
|
+
if (extractScaffoldKey(existingDescription) === stableKey) {
|
|
777
|
+
return existingDescription.trim();
|
|
778
|
+
}
|
|
779
|
+
const trimmed = existingDescription.trim();
|
|
780
|
+
return trimmed ? `${trimmed}\n${marker}` : marker;
|
|
781
|
+
}
|
|
782
|
+
function databaseTitle(input) {
|
|
783
|
+
return plainText(input.title);
|
|
784
|
+
}
|
|
785
|
+
function databaseDescription(input) {
|
|
786
|
+
return plainText(input.description);
|
|
787
|
+
}
|
|
788
|
+
function lastEditedTime(input) {
|
|
789
|
+
return typeof input.last_edited_time === "string" ? input.last_edited_time : "";
|
|
790
|
+
}
|
|
791
|
+
function parentPageId(input) {
|
|
792
|
+
const parent = input.parent;
|
|
793
|
+
if (!isRecord(parent))
|
|
794
|
+
return null;
|
|
795
|
+
// Old model: `database` objects have `parent: { type: "page_id", page_id }`
|
|
796
|
+
// directly.
|
|
797
|
+
if (typeof parent.page_id === "string")
|
|
798
|
+
return parent.page_id;
|
|
799
|
+
// New model: `data_source` objects have `parent: { type: "database_id",
|
|
800
|
+
// database_id }` — the parent is the database, not the page. We can't
|
|
801
|
+
// resolve the page id without an extra API call, so return null and
|
|
802
|
+
// let the caller fall back to integration-scope-only matching.
|
|
803
|
+
return null;
|
|
804
|
+
}
|
|
805
|
+
function firstDataSourceId(input) {
|
|
806
|
+
const dataSources = input.data_sources;
|
|
807
|
+
if (!Array.isArray(dataSources))
|
|
808
|
+
return null;
|
|
809
|
+
for (const dataSource of dataSources) {
|
|
810
|
+
if (isRecord(dataSource) && typeof dataSource.id === "string") {
|
|
811
|
+
return dataSource.id;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
return null;
|
|
815
|
+
}
|
|
816
|
+
function databaseUrl(databaseId, input) {
|
|
817
|
+
if (typeof input.url === "string" && input.url.trim())
|
|
818
|
+
return input.url;
|
|
819
|
+
return `https://www.notion.so/${databaseId.replace(/-/g, "")}`;
|
|
820
|
+
}
|
|
821
|
+
async function retrieveDatabaseInfo(apiToken, databaseId) {
|
|
822
|
+
const stdout = await runOrThrowNtn(["api", `v1/databases/${databaseId}`], {
|
|
823
|
+
env: { NOTION_API_TOKEN: apiToken },
|
|
824
|
+
});
|
|
825
|
+
const raw = JSON.parse(stdout);
|
|
826
|
+
const id = typeof raw.id === "string" ? raw.id : databaseId;
|
|
827
|
+
return {
|
|
828
|
+
databaseId: id,
|
|
829
|
+
dataSourceId: firstDataSourceId(raw) ?? id,
|
|
830
|
+
url: databaseUrl(id, raw),
|
|
831
|
+
description: databaseDescription(raw),
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* Resolve a single search result into the {databaseId, dataSourceId,
|
|
836
|
+
* url, description} shape we need to wire the project. Handles both
|
|
837
|
+
* Notion 2025-09-03+ `data_source` results and legacy `database`
|
|
838
|
+
* results, walking parent.database_id when necessary.
|
|
839
|
+
*/
|
|
840
|
+
async function readExistingDatabaseInfo(apiToken, item) {
|
|
841
|
+
const objectType = item.object;
|
|
842
|
+
if (objectType === "data_source") {
|
|
843
|
+
// New model: the result's `id` is the data_source id; the
|
|
844
|
+
// database id is on `parent.database_id` (or fetchable from
|
|
845
|
+
// the parent if not inlined).
|
|
846
|
+
const dataSourceId = typeof item.id === "string" ? item.id : null;
|
|
847
|
+
const parent = item.parent;
|
|
848
|
+
const databaseIdFromParent = isRecord(parent) && typeof parent.database_id === "string"
|
|
849
|
+
? parent.database_id
|
|
850
|
+
: null;
|
|
851
|
+
if (!dataSourceId)
|
|
852
|
+
return null;
|
|
853
|
+
if (databaseIdFromParent) {
|
|
854
|
+
return {
|
|
855
|
+
databaseId: databaseIdFromParent,
|
|
856
|
+
dataSourceId,
|
|
857
|
+
url: databaseUrl(databaseIdFromParent, item),
|
|
858
|
+
description: databaseDescription(item),
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
// Fallback: ask the data_source endpoint for its full shape.
|
|
862
|
+
const stdout = await runOrThrowNtn(["api", `v1/data_sources/${dataSourceId}`], { env: { NOTION_API_TOKEN: apiToken } });
|
|
863
|
+
const raw = JSON.parse(stdout);
|
|
864
|
+
const parentDb = raw.parent;
|
|
865
|
+
const databaseId = isRecord(parentDb) && typeof parentDb.database_id === "string"
|
|
866
|
+
? parentDb.database_id
|
|
867
|
+
: dataSourceId;
|
|
868
|
+
return {
|
|
869
|
+
databaseId,
|
|
870
|
+
dataSourceId,
|
|
871
|
+
url: databaseUrl(databaseId, raw),
|
|
872
|
+
description: databaseDescription(raw),
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
// Legacy `database` object (or anything else with an `id`).
|
|
876
|
+
const databaseId = typeof item.id === "string" ? item.id : null;
|
|
877
|
+
if (!databaseId)
|
|
878
|
+
return null;
|
|
879
|
+
return {
|
|
880
|
+
databaseId,
|
|
881
|
+
dataSourceId: firstDataSourceId(item) ??
|
|
882
|
+
(await retrieveDatabaseInfo(apiToken, databaseId)).dataSourceId,
|
|
883
|
+
url: databaseUrl(databaseId, item),
|
|
884
|
+
description: databaseDescription(item),
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
async function patchDatabaseDescription(input) {
|
|
888
|
+
const description = [
|
|
889
|
+
{
|
|
890
|
+
type: "text",
|
|
891
|
+
text: {
|
|
892
|
+
content: mergeDescriptionWithScaffoldMarker(input.existingDescription, input.stableKey),
|
|
893
|
+
},
|
|
894
|
+
},
|
|
895
|
+
];
|
|
896
|
+
await runOrThrowNtn([
|
|
897
|
+
"api",
|
|
898
|
+
`v1/databases/${input.databaseId}`,
|
|
899
|
+
"-X",
|
|
900
|
+
"PATCH",
|
|
901
|
+
"-d",
|
|
902
|
+
JSON.stringify({ description }),
|
|
903
|
+
], { env: { NOTION_API_TOKEN: input.apiToken } });
|
|
904
|
+
}
|
|
905
|
+
async function findExistingDatabaseByStableKey(input) {
|
|
906
|
+
const stdout = await runOrThrowNtn([
|
|
907
|
+
"api",
|
|
908
|
+
"v1/search",
|
|
909
|
+
"-d",
|
|
910
|
+
JSON.stringify({
|
|
911
|
+
query: input.stableKey,
|
|
912
|
+
// Notion 2025-09-03 API no longer accepts `"database"` as a
|
|
913
|
+
// search filter value; `"data_source"` covers new-style
|
|
914
|
+
// databases (which are now content-only objects whose parent
|
|
915
|
+
// is the database container). We also accept legacy
|
|
916
|
+
// `object === "database"` results below in case the
|
|
917
|
+
// integration's workspace still has any.
|
|
918
|
+
filter: { property: "object", value: "data_source" },
|
|
919
|
+
sort: { timestamp: "last_edited_time", direction: "descending" },
|
|
920
|
+
page_size: 50,
|
|
921
|
+
}),
|
|
922
|
+
], { env: { NOTION_API_TOKEN: input.apiToken } });
|
|
923
|
+
const raw = JSON.parse(stdout);
|
|
924
|
+
const expectedParent = compactNotionId(input.parentPageId);
|
|
925
|
+
const matches = (raw.results ?? [])
|
|
926
|
+
.filter((item) => {
|
|
927
|
+
if (!isRecord(item))
|
|
928
|
+
return false;
|
|
929
|
+
// Accept both the new `data_source` and the legacy `database`
|
|
930
|
+
// object types — Notion's search is supposed to filter server-
|
|
931
|
+
// side, but we keep the client check defensive.
|
|
932
|
+
if (item.object !== "data_source" && item.object !== "database")
|
|
933
|
+
return false;
|
|
934
|
+
const itemParent = parentPageId(item);
|
|
935
|
+
// data_source items return null here (the parent is a database,
|
|
936
|
+
// not a page) — trust the integration's access scope for them.
|
|
937
|
+
if (itemParent !== null && compactNotionId(itemParent) !== expectedParent)
|
|
938
|
+
return false;
|
|
939
|
+
return extractScaffoldKey(databaseDescription(item)) === input.stableKey;
|
|
940
|
+
})
|
|
941
|
+
.sort((a, b) => lastEditedTime(b).localeCompare(lastEditedTime(a)));
|
|
942
|
+
if (matches.length === 0)
|
|
943
|
+
return null;
|
|
944
|
+
if (matches.length > 1) {
|
|
945
|
+
console.warn(`[notion] found ${matches.length} databases with scaffold key "${input.stableKey}" under the parent page; reusing the most recently edited one.`);
|
|
946
|
+
}
|
|
947
|
+
return readExistingDatabaseInfo(input.apiToken, matches[0]);
|
|
948
|
+
}
|
|
949
|
+
async function findExistingDatabaseByTitle(input) {
|
|
950
|
+
const body = {
|
|
951
|
+
query: input.title,
|
|
952
|
+
// Notion 2025-09-03 API no longer accepts `"database"`; see the
|
|
953
|
+
// matching note in findExistingDatabaseByStableKey.
|
|
954
|
+
filter: { property: "object", value: "data_source" },
|
|
955
|
+
sort: { timestamp: "last_edited_time", direction: "descending" },
|
|
956
|
+
page_size: 25,
|
|
957
|
+
};
|
|
958
|
+
const stdout = await runOrThrowNtn(["api", "v1/search", "-d", JSON.stringify(body)], { env: { NOTION_API_TOKEN: input.apiToken } });
|
|
959
|
+
const raw = JSON.parse(stdout);
|
|
960
|
+
const expectedTitle = normalizeTitle(input.title);
|
|
961
|
+
const expectedParent = compactNotionId(input.parentPageId);
|
|
962
|
+
const matches = (raw.results ?? []).filter((item) => {
|
|
963
|
+
if (!isRecord(item))
|
|
964
|
+
return false;
|
|
965
|
+
if (item.object !== "data_source" && item.object !== "database")
|
|
966
|
+
return false;
|
|
967
|
+
if (normalizeTitle(databaseTitle(item)) !== expectedTitle)
|
|
968
|
+
return false;
|
|
969
|
+
const itemParent = parentPageId(item);
|
|
970
|
+
// data_source items return null here — trust the integration's
|
|
971
|
+
// access scope for them (see parentPageId's comment).
|
|
972
|
+
if (itemParent !== null && compactNotionId(itemParent) !== expectedParent)
|
|
973
|
+
return false;
|
|
974
|
+
return true;
|
|
975
|
+
});
|
|
976
|
+
if (matches.length === 0)
|
|
977
|
+
return null;
|
|
978
|
+
if (matches.length > 1) {
|
|
979
|
+
console.warn(`[notion] found ${matches.length} databases named "${input.title}" under the parent page; reusing the most recently edited one.`);
|
|
980
|
+
}
|
|
981
|
+
return readExistingDatabaseInfo(input.apiToken, matches[0]);
|
|
982
|
+
}
|
|
983
|
+
const NOTION_PROPERTY_TYPES = [
|
|
984
|
+
"title",
|
|
985
|
+
"rich_text",
|
|
986
|
+
"number",
|
|
987
|
+
"select",
|
|
988
|
+
"multi_select",
|
|
989
|
+
"status",
|
|
990
|
+
"date",
|
|
991
|
+
"people",
|
|
992
|
+
"files",
|
|
993
|
+
"checkbox",
|
|
994
|
+
"url",
|
|
995
|
+
"email",
|
|
996
|
+
"phone_number",
|
|
997
|
+
"formula",
|
|
998
|
+
"relation",
|
|
999
|
+
"rollup",
|
|
1000
|
+
"created_time",
|
|
1001
|
+
"created_by",
|
|
1002
|
+
"last_edited_time",
|
|
1003
|
+
"last_edited_by",
|
|
1004
|
+
"unique_id",
|
|
1005
|
+
];
|
|
1006
|
+
function notionPropertyDefinitionType(definition) {
|
|
1007
|
+
if (!definition)
|
|
1008
|
+
return undefined;
|
|
1009
|
+
if (typeof definition.type === "string")
|
|
1010
|
+
return definition.type;
|
|
1011
|
+
return NOTION_PROPERTY_TYPES.find((type) => type in definition);
|
|
1012
|
+
}
|
|
1013
|
+
function missingPropertiesForPatch(existing, desired) {
|
|
1014
|
+
const properties = {};
|
|
1015
|
+
const warnings = [];
|
|
1016
|
+
const existingTitle = Object.entries(existing).find(([, definition]) => notionPropertyDefinitionType(definition) === "title")?.[0];
|
|
1017
|
+
for (const [name, desiredDefinition] of Object.entries(desired)) {
|
|
1018
|
+
const desiredType = notionPropertyDefinitionType(desiredDefinition);
|
|
1019
|
+
const currentDefinition = existing[name];
|
|
1020
|
+
const currentType = notionPropertyDefinitionType(currentDefinition);
|
|
1021
|
+
if (!currentDefinition) {
|
|
1022
|
+
if (desiredType === "title" && existingTitle && existingTitle !== name) {
|
|
1023
|
+
warnings.push(`existing title property is "${existingTitle}"; expected "${name}". Leaving it unchanged.`);
|
|
1024
|
+
continue;
|
|
1025
|
+
}
|
|
1026
|
+
properties[name] = desiredDefinition;
|
|
1027
|
+
continue;
|
|
1028
|
+
}
|
|
1029
|
+
if (desiredType && currentType && desiredType !== currentType) {
|
|
1030
|
+
warnings.push(`property "${name}" is ${currentType}; expected ${desiredType}. Leaving it unchanged.`);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
return { properties, warnings };
|
|
1034
|
+
}
|
|
1035
|
+
async function ensureDataSourceProperties(input) {
|
|
1036
|
+
let schema = await getDataSourceSchema(input.apiToken, input.dataSourceId);
|
|
1037
|
+
const missing = missingPropertiesForPatch(schema, input.desired);
|
|
1038
|
+
for (const warning of missing.warnings) {
|
|
1039
|
+
console.warn(`[notion schema] ${input.title}: ${warning}`);
|
|
1040
|
+
}
|
|
1041
|
+
if (Object.keys(missing.properties).length === 0)
|
|
1042
|
+
return schema;
|
|
1043
|
+
await runOrThrowNtn([
|
|
1044
|
+
"api",
|
|
1045
|
+
`v1/data_sources/${input.dataSourceId}`,
|
|
1046
|
+
"-X",
|
|
1047
|
+
"PATCH",
|
|
1048
|
+
"-d",
|
|
1049
|
+
JSON.stringify({ properties: missing.properties }),
|
|
1050
|
+
], { env: { NOTION_API_TOKEN: input.apiToken } });
|
|
1051
|
+
return getDataSourceSchema(input.apiToken, input.dataSourceId);
|
|
1052
|
+
}
|
|
1053
|
+
function findMatchingField(properties, fields, key, fallback) {
|
|
1054
|
+
const configured = fields.find((field) => field.key === key)?.notionName;
|
|
1055
|
+
if (configured && properties[configured])
|
|
1056
|
+
return configured;
|
|
1057
|
+
if (properties[fallback])
|
|
1058
|
+
return fallback;
|
|
1059
|
+
return configured;
|
|
1060
|
+
}
|
|
1061
|
+
async function createDatabaseWithProperties(input) {
|
|
1062
|
+
const titleProp = Object.entries(input.properties).find(([, value]) => notionPropertyDefinitionType(value) === "title");
|
|
1063
|
+
const dbTitlePropName = titleProp ? titleProp[0] : "Name";
|
|
1064
|
+
const body = {
|
|
1065
|
+
parent: { type: "page_id", page_id: input.parentPageId },
|
|
1066
|
+
title: [{ type: "text", text: { content: input.title } }],
|
|
1067
|
+
properties: titleProp
|
|
1068
|
+
? { [titleProp[0]]: { title: {} } }
|
|
1069
|
+
: { [dbTitlePropName]: { title: {} } },
|
|
1070
|
+
};
|
|
1071
|
+
const stdout = await runOrThrowNtn(["api", "v1/databases", "-d", JSON.stringify(body)], { env: { NOTION_API_TOKEN: input.apiToken } });
|
|
1072
|
+
const db = JSON.parse(stdout);
|
|
1073
|
+
const dataSourceId = db.data_sources?.[0]?.id ?? db.id;
|
|
1074
|
+
const databaseId = db.id;
|
|
1075
|
+
const url = db.url ?? `https://www.notion.so/${databaseId.replace(/-/g, "")}`;
|
|
1076
|
+
const nonTitleProps = Object.fromEntries(Object.entries(input.properties).filter(([, value]) => notionPropertyDefinitionType(value) !== "title"));
|
|
1077
|
+
if (Object.keys(nonTitleProps).length > 0) {
|
|
1078
|
+
await runOrThrowNtn([
|
|
1079
|
+
"api",
|
|
1080
|
+
`v1/data_sources/${dataSourceId}`,
|
|
1081
|
+
"-X",
|
|
1082
|
+
"PATCH",
|
|
1083
|
+
"-d",
|
|
1084
|
+
JSON.stringify({ properties: nonTitleProps }),
|
|
1085
|
+
], { env: { NOTION_API_TOKEN: input.apiToken } });
|
|
1086
|
+
}
|
|
1087
|
+
return { databaseId, dataSourceId, url };
|
|
1088
|
+
}
|
|
1089
|
+
function buildSamplePage(input) {
|
|
1090
|
+
const { fieldNames, index, locale, title, titlePropertyName, databaseId } = input;
|
|
1091
|
+
const sample = samplePostFor(index, locale);
|
|
1092
|
+
const coverUrl = `https://picsum.photos/seed/${slugify(title)}-${sample.coverSeed}/1200/600`;
|
|
1093
|
+
const properties = {
|
|
1094
|
+
[titlePropertyName]: {
|
|
1095
|
+
title: [{ text: { content: sample.title } }],
|
|
1096
|
+
},
|
|
1097
|
+
};
|
|
1098
|
+
if (fieldNames.slug) {
|
|
1099
|
+
properties[fieldNames.slug] = {
|
|
1100
|
+
rich_text: [{ text: { content: sample.slug } }],
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
if (fieldNames.description) {
|
|
1104
|
+
properties[fieldNames.description] = {
|
|
1105
|
+
rich_text: [{ text: { content: sample.description } }],
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
if (fieldNames.published) {
|
|
1109
|
+
properties[fieldNames.published] = { checkbox: true };
|
|
1110
|
+
}
|
|
1111
|
+
if (fieldNames.date) {
|
|
1112
|
+
properties[fieldNames.date] = {
|
|
1113
|
+
date: { start: sample.date },
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
if (fieldNames.tags) {
|
|
1117
|
+
properties[fieldNames.tags] = {
|
|
1118
|
+
multi_select: sample.tags.map((name) => ({ name })),
|
|
1119
|
+
};
|
|
1120
|
+
}
|
|
1121
|
+
if (fieldNames.cover) {
|
|
1122
|
+
properties[fieldNames.cover] = {
|
|
1123
|
+
files: [
|
|
1124
|
+
{
|
|
1125
|
+
name: `${sample.slug}-cover`,
|
|
1126
|
+
type: "external",
|
|
1127
|
+
external: { url: coverUrl },
|
|
1128
|
+
},
|
|
1129
|
+
],
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1132
|
+
return {
|
|
1133
|
+
parent: { type: "database_id", database_id: databaseId },
|
|
1134
|
+
cover: {
|
|
1135
|
+
type: "external",
|
|
1136
|
+
external: {
|
|
1137
|
+
url: coverUrl,
|
|
1138
|
+
},
|
|
1139
|
+
},
|
|
1140
|
+
properties,
|
|
1141
|
+
children: [
|
|
1142
|
+
{
|
|
1143
|
+
object: "block",
|
|
1144
|
+
type: "heading_1",
|
|
1145
|
+
heading_1: {
|
|
1146
|
+
rich_text: [{ type: "text", text: { content: sample.title } }],
|
|
1147
|
+
},
|
|
1148
|
+
},
|
|
1149
|
+
{
|
|
1150
|
+
object: "block",
|
|
1151
|
+
type: "paragraph",
|
|
1152
|
+
paragraph: {
|
|
1153
|
+
rich_text: [
|
|
1154
|
+
{
|
|
1155
|
+
type: "text",
|
|
1156
|
+
text: {
|
|
1157
|
+
content: sample.intro,
|
|
1158
|
+
},
|
|
1159
|
+
},
|
|
1160
|
+
],
|
|
1161
|
+
},
|
|
1162
|
+
},
|
|
1163
|
+
...sample.sections.flatMap((section) => [
|
|
1164
|
+
{
|
|
1165
|
+
object: "block",
|
|
1166
|
+
type: "heading_2",
|
|
1167
|
+
heading_2: {
|
|
1168
|
+
rich_text: [{ type: "text", text: { content: section.heading } }],
|
|
1169
|
+
},
|
|
1170
|
+
},
|
|
1171
|
+
{
|
|
1172
|
+
object: "block",
|
|
1173
|
+
type: "paragraph",
|
|
1174
|
+
paragraph: {
|
|
1175
|
+
rich_text: [
|
|
1176
|
+
{
|
|
1177
|
+
type: "text",
|
|
1178
|
+
text: { content: section.body },
|
|
1179
|
+
},
|
|
1180
|
+
],
|
|
1181
|
+
},
|
|
1182
|
+
},
|
|
1183
|
+
...section.bullets.map((bullet) => ({
|
|
1184
|
+
object: "block",
|
|
1185
|
+
type: "bulleted_list_item",
|
|
1186
|
+
bulleted_list_item: {
|
|
1187
|
+
rich_text: [
|
|
1188
|
+
{
|
|
1189
|
+
type: "text",
|
|
1190
|
+
text: { content: bullet },
|
|
1191
|
+
},
|
|
1192
|
+
],
|
|
1193
|
+
},
|
|
1194
|
+
})),
|
|
1195
|
+
]),
|
|
1196
|
+
{
|
|
1197
|
+
object: "block",
|
|
1198
|
+
type: "paragraph",
|
|
1199
|
+
paragraph: {
|
|
1200
|
+
rich_text: [
|
|
1201
|
+
{
|
|
1202
|
+
type: "text",
|
|
1203
|
+
text: {
|
|
1204
|
+
content: sample.closing,
|
|
1205
|
+
},
|
|
1206
|
+
},
|
|
1207
|
+
],
|
|
1208
|
+
},
|
|
1209
|
+
},
|
|
1210
|
+
],
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
function buildPageProperties() {
|
|
1214
|
+
return {
|
|
1215
|
+
Name: { title: {} },
|
|
1216
|
+
Key: { rich_text: {} },
|
|
1217
|
+
Slug: { rich_text: {} },
|
|
1218
|
+
Status: { select: {} },
|
|
1219
|
+
Layout: { select: {} },
|
|
1220
|
+
Description: { rich_text: {} },
|
|
1221
|
+
"SEO Title": { rich_text: {} },
|
|
1222
|
+
"SEO Description": { rich_text: {} },
|
|
1223
|
+
"Show Header": { checkbox: {} },
|
|
1224
|
+
"Show Footer": { checkbox: {} },
|
|
1225
|
+
"Show in Nav": { checkbox: {} },
|
|
1226
|
+
"Nav Label": { rich_text: {} },
|
|
1227
|
+
"Nav Order": { number: {} },
|
|
1228
|
+
"Show in Footer": { checkbox: {} },
|
|
1229
|
+
"Footer Label": { rich_text: {} },
|
|
1230
|
+
"Footer Group": { select: {} },
|
|
1231
|
+
"Footer Order": { number: {} },
|
|
1232
|
+
"Content Source": { rich_text: {} },
|
|
1233
|
+
Blocks: { rich_text: {} },
|
|
1234
|
+
Cover: { files: {} },
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
function buildBlocksProperties() {
|
|
1238
|
+
return {
|
|
1239
|
+
Name: { title: {} },
|
|
1240
|
+
Slug: { rich_text: {} },
|
|
1241
|
+
Status: { select: {} },
|
|
1242
|
+
Type: { select: {} },
|
|
1243
|
+
Description: { rich_text: {} },
|
|
1244
|
+
"Page Keys": { rich_text: {} },
|
|
1245
|
+
Order: { number: {} },
|
|
1246
|
+
Cover: { files: {} },
|
|
1247
|
+
Eyebrow: { rich_text: {} },
|
|
1248
|
+
Headline: { rich_text: {} },
|
|
1249
|
+
Subheadline: { rich_text: {} },
|
|
1250
|
+
"Primary CTA Label": { rich_text: {} },
|
|
1251
|
+
"Primary CTA Href": { url: {} },
|
|
1252
|
+
"Secondary CTA Label": { rich_text: {} },
|
|
1253
|
+
"Secondary CTA Href": { url: {} },
|
|
1254
|
+
Alignment: { select: {} },
|
|
1255
|
+
Theme: { select: {} },
|
|
1256
|
+
Columns: { number: {} },
|
|
1257
|
+
Count: { number: {} },
|
|
1258
|
+
Items: { rich_text: {} },
|
|
1259
|
+
Body: { rich_text: {} },
|
|
1260
|
+
Quote: { rich_text: {} },
|
|
1261
|
+
"Quote Attribution": { rich_text: {} },
|
|
1262
|
+
"Media Url": { url: {} },
|
|
1263
|
+
Layout: { select: {} },
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
function richText(content) {
|
|
1267
|
+
return content ? [{ text: { content } }] : [];
|
|
1268
|
+
}
|
|
1269
|
+
function selectPropertyValue(name) {
|
|
1270
|
+
return name ? { select: { name } } : { select: null };
|
|
1271
|
+
}
|
|
1272
|
+
function urlPropertyValue(url) {
|
|
1273
|
+
return { url: url ?? null };
|
|
1274
|
+
}
|
|
1275
|
+
function numberPropertyValue(value) {
|
|
1276
|
+
return { number: value ?? null };
|
|
1277
|
+
}
|
|
1278
|
+
function buildSitePagePayload(input) {
|
|
1279
|
+
const { databaseId, page, projectName } = input;
|
|
1280
|
+
const coverUrl = `https://picsum.photos/seed/${slugify(projectName)}-${page.coverSeed}/1200/600`;
|
|
1281
|
+
return {
|
|
1282
|
+
parent: { type: "database_id", database_id: databaseId },
|
|
1283
|
+
cover: {
|
|
1284
|
+
type: "external",
|
|
1285
|
+
external: { url: coverUrl },
|
|
1286
|
+
},
|
|
1287
|
+
properties: {
|
|
1288
|
+
Name: { title: richText(page.title) },
|
|
1289
|
+
Key: { rich_text: richText(page.key) },
|
|
1290
|
+
Slug: { rich_text: richText(page.slug) },
|
|
1291
|
+
Status: { select: { name: "Published" } },
|
|
1292
|
+
Layout: { select: { name: page.layout } },
|
|
1293
|
+
Description: { rich_text: richText(page.description) },
|
|
1294
|
+
"SEO Title": { rich_text: richText(page.seoTitle) },
|
|
1295
|
+
"SEO Description": { rich_text: richText(page.seoDescription) },
|
|
1296
|
+
"Show Header": { checkbox: page.showHeader },
|
|
1297
|
+
"Show Footer": { checkbox: page.showFooter },
|
|
1298
|
+
"Show in Nav": { checkbox: page.showInNav },
|
|
1299
|
+
"Nav Label": { rich_text: richText(page.navLabel) },
|
|
1300
|
+
"Nav Order": { number: page.navOrder },
|
|
1301
|
+
"Show in Footer": { checkbox: page.showInFooter },
|
|
1302
|
+
"Footer Label": { rich_text: richText(page.footerLabel) },
|
|
1303
|
+
"Footer Group": { select: { name: page.footerGroup } },
|
|
1304
|
+
"Footer Order": { number: page.footerOrder },
|
|
1305
|
+
"Content Source": { rich_text: richText(page.contentSource ?? "") },
|
|
1306
|
+
Blocks: {
|
|
1307
|
+
rich_text: richText(JSON.stringify(page.blocks ?? [])),
|
|
1308
|
+
},
|
|
1309
|
+
Cover: {
|
|
1310
|
+
files: [
|
|
1311
|
+
{
|
|
1312
|
+
name: `${page.key || "page"}-cover`,
|
|
1313
|
+
type: "external",
|
|
1314
|
+
external: { url: coverUrl },
|
|
1315
|
+
},
|
|
1316
|
+
],
|
|
1317
|
+
},
|
|
1318
|
+
},
|
|
1319
|
+
children: page.body.flatMap((section) => [
|
|
1320
|
+
{
|
|
1321
|
+
object: "block",
|
|
1322
|
+
type: "heading_2",
|
|
1323
|
+
heading_2: {
|
|
1324
|
+
rich_text: [{ type: "text", text: { content: section.heading } }],
|
|
1325
|
+
},
|
|
1326
|
+
},
|
|
1327
|
+
{
|
|
1328
|
+
object: "block",
|
|
1329
|
+
type: "paragraph",
|
|
1330
|
+
paragraph: {
|
|
1331
|
+
rich_text: [{ type: "text", text: { content: section.text } }],
|
|
1332
|
+
},
|
|
1333
|
+
},
|
|
1334
|
+
...(section.bullets ?? []).map((bullet) => ({
|
|
1335
|
+
object: "block",
|
|
1336
|
+
type: "bulleted_list_item",
|
|
1337
|
+
bulleted_list_item: {
|
|
1338
|
+
rich_text: [{ type: "text", text: { content: bullet } }],
|
|
1339
|
+
},
|
|
1340
|
+
})),
|
|
1341
|
+
]),
|
|
1342
|
+
};
|
|
1343
|
+
}
|
|
1344
|
+
function buildSiteBlockPayload(input) {
|
|
1345
|
+
const { block, databaseId, projectName } = input;
|
|
1346
|
+
const coverUrl = `https://picsum.photos/seed/${slugify(projectName)}-${block.coverSeed}/1200/600`;
|
|
1347
|
+
return {
|
|
1348
|
+
parent: { type: "database_id", database_id: databaseId },
|
|
1349
|
+
cover: {
|
|
1350
|
+
type: "external",
|
|
1351
|
+
external: { url: coverUrl },
|
|
1352
|
+
},
|
|
1353
|
+
properties: {
|
|
1354
|
+
Name: { title: richText(block.title) },
|
|
1355
|
+
Slug: { rich_text: richText(block.slug) },
|
|
1356
|
+
Status: { select: { name: "Published" } },
|
|
1357
|
+
Type: { select: { name: block.type } },
|
|
1358
|
+
Description: { rich_text: richText(block.description) },
|
|
1359
|
+
"Page Keys": { rich_text: richText(JSON.stringify(block.pageKeys)) },
|
|
1360
|
+
Order: { number: block.order },
|
|
1361
|
+
Eyebrow: {
|
|
1362
|
+
rich_text: richText(block.type === "hero" ? block.eyebrow : ""),
|
|
1363
|
+
},
|
|
1364
|
+
Headline: {
|
|
1365
|
+
rich_text: richText(block.type === "hero" ||
|
|
1366
|
+
block.type === "feature-grid" ||
|
|
1367
|
+
block.type === "story" ||
|
|
1368
|
+
block.type === "latest-posts"
|
|
1369
|
+
? block.headline
|
|
1370
|
+
: ""),
|
|
1371
|
+
},
|
|
1372
|
+
Subheadline: {
|
|
1373
|
+
rich_text: richText(block.type === "hero" ? block.subheadline : ""),
|
|
1374
|
+
},
|
|
1375
|
+
"Primary CTA Label": {
|
|
1376
|
+
rich_text: richText(block.type === "hero"
|
|
1377
|
+
? block.primaryCtaLabel
|
|
1378
|
+
: block.type === "latest-posts"
|
|
1379
|
+
? block.primaryCtaLabel
|
|
1380
|
+
: ""),
|
|
1381
|
+
},
|
|
1382
|
+
"Primary CTA Href": urlPropertyValue(block.type === "hero"
|
|
1383
|
+
? block.primaryCtaHref
|
|
1384
|
+
: block.type === "latest-posts"
|
|
1385
|
+
? block.primaryCtaHref
|
|
1386
|
+
: undefined),
|
|
1387
|
+
"Secondary CTA Label": {
|
|
1388
|
+
rich_text: richText(block.type === "hero" ? block.secondaryCtaLabel ?? "" : ""),
|
|
1389
|
+
},
|
|
1390
|
+
"Secondary CTA Href": urlPropertyValue(block.type === "hero" ? block.secondaryCtaHref : undefined),
|
|
1391
|
+
Alignment: selectPropertyValue(block.type === "hero" ? block.alignment : undefined),
|
|
1392
|
+
Theme: selectPropertyValue(block.type === "hero" ? block.theme : undefined),
|
|
1393
|
+
Columns: numberPropertyValue(block.type === "feature-grid" ? block.columns : undefined),
|
|
1394
|
+
Count: numberPropertyValue(block.type === "latest-posts" ? block.count : undefined),
|
|
1395
|
+
Items: {
|
|
1396
|
+
rich_text: richText(block.type === "feature-grid" ? JSON.stringify(block.items) : ""),
|
|
1397
|
+
},
|
|
1398
|
+
Body: {
|
|
1399
|
+
rich_text: richText(block.type === "feature-grid" ||
|
|
1400
|
+
block.type === "story" ||
|
|
1401
|
+
block.type === "latest-posts"
|
|
1402
|
+
? block.body
|
|
1403
|
+
: ""),
|
|
1404
|
+
},
|
|
1405
|
+
Quote: {
|
|
1406
|
+
rich_text: richText(block.type === "story" ? block.quote ?? "" : ""),
|
|
1407
|
+
},
|
|
1408
|
+
"Quote Attribution": {
|
|
1409
|
+
rich_text: richText(block.type === "story" ? block.quoteAttribution ?? "" : ""),
|
|
1410
|
+
},
|
|
1411
|
+
"Media Url": urlPropertyValue(block.type === "story" ? block.mediaUrl : undefined),
|
|
1412
|
+
Layout: selectPropertyValue(block.type === "story" ? block.layout : undefined),
|
|
1413
|
+
Cover: {
|
|
1414
|
+
files: [
|
|
1415
|
+
{
|
|
1416
|
+
name: `${block.slug}-cover`,
|
|
1417
|
+
type: "external",
|
|
1418
|
+
external: { url: coverUrl },
|
|
1419
|
+
},
|
|
1420
|
+
],
|
|
1421
|
+
},
|
|
1422
|
+
},
|
|
1423
|
+
children: [],
|
|
1424
|
+
};
|
|
1425
|
+
}
|
|
1426
|
+
/** Probe `ntn` — returns true if it's installed. */
|
|
1427
|
+
export async function isNtnAvailable() {
|
|
1428
|
+
// `ntn --version` is read-only, but it still calls libuv's
|
|
1429
|
+
// `uv_tty_init` on startup, so we have to keep the PTY-aware
|
|
1430
|
+
// wrapper for it to actually exit 0. The cost is one extra
|
|
1431
|
+
// `unbuffer` fork per scaffolder run.
|
|
1432
|
+
const r = await runNtn(["--version"]);
|
|
1433
|
+
return r.code === 0;
|
|
1434
|
+
}
|
|
1435
|
+
/**
|
|
1436
|
+
* Verify the Notion API token is valid by fetching the bot user.
|
|
1437
|
+
*
|
|
1438
|
+
* We deliberately use `/v1/users/me` (the authenticated self-fetch),
|
|
1439
|
+
* not `/v1/users` (the full user list), because:
|
|
1440
|
+
*
|
|
1441
|
+
* - The former works for *all* Notion token types — internal
|
|
1442
|
+
* integrations (`secret_…`), OAuth public integrations, and
|
|
1443
|
+
* personal access tokens (`ntn_…`) issued by the `ntn` CLI.
|
|
1444
|
+
* - The latter requires the `user.read` capability on internal
|
|
1445
|
+
* integrations, and is **forbidden for personal access tokens**
|
|
1446
|
+
* with the message "Personal access tokens cannot list users".
|
|
1447
|
+
* That breaks the auto-detect path for users who have run
|
|
1448
|
+
* `ntn login`.
|
|
1449
|
+
*/
|
|
1450
|
+
export async function verifyNotionToken(apiToken) {
|
|
1451
|
+
const r = await runNtn(["api", "v1/users/me"], {
|
|
1452
|
+
env: { NOTION_API_TOKEN: apiToken },
|
|
1453
|
+
});
|
|
1454
|
+
if (r.code === 0)
|
|
1455
|
+
return true;
|
|
1456
|
+
// Surface the actual API error in the caller's exception so users
|
|
1457
|
+
// see *why* their token was rejected (e.g. "403 restricted_resource").
|
|
1458
|
+
const detail = r.stderr.trim() || r.stdout.trim() || `exit code ${r.code ?? "null"}`;
|
|
1459
|
+
throw new Error(`Notion token verification failed: ${detail}`);
|
|
1460
|
+
}
|
|
1461
|
+
/**
|
|
1462
|
+
* Create a Notion database (and its default data source) under the
|
|
1463
|
+
* given parent page. Optionally seed it with placeholder pages.
|
|
1464
|
+
*
|
|
1465
|
+
* The 2025-09-03 Notion API version split "database" into two
|
|
1466
|
+
* objects: a database shell (container) and one or more data
|
|
1467
|
+
* sources (the schema). `POST /v1/databases` still creates the
|
|
1468
|
+
* shell with a *default* data source, but the `properties` field
|
|
1469
|
+
* on that request is silently ignored when the data source schema
|
|
1470
|
+
* hasn't been opened for writes. To actually create properties we
|
|
1471
|
+
* have to follow up with `PATCH /v1/data_sources/{id}` to define
|
|
1472
|
+
* them. Without that second call the database ends up with the
|
|
1473
|
+
* `Name` fallback property only, and `POST /v1/pages` later fails
|
|
1474
|
+
* with "X is not a property that exists".
|
|
1475
|
+
*/
|
|
1476
|
+
export async function ensureNotionDatabase(input) {
|
|
1477
|
+
const properties = buildProperties(input.fields);
|
|
1478
|
+
const existingByStableKey = await findExistingDatabaseByStableKey({
|
|
1479
|
+
apiToken: input.apiToken,
|
|
1480
|
+
parentPageId: input.parentPageId,
|
|
1481
|
+
stableKey: input.stableKey,
|
|
1482
|
+
});
|
|
1483
|
+
const existing = existingByStableKey ??
|
|
1484
|
+
(await findExistingDatabaseByTitle({
|
|
1485
|
+
apiToken: input.apiToken,
|
|
1486
|
+
parentPageId: input.parentPageId,
|
|
1487
|
+
title: input.title,
|
|
1488
|
+
}));
|
|
1489
|
+
if (existing) {
|
|
1490
|
+
await ensureDataSourceProperties({
|
|
1491
|
+
apiToken: input.apiToken,
|
|
1492
|
+
dataSourceId: existing.dataSourceId,
|
|
1493
|
+
desired: properties,
|
|
1494
|
+
title: input.title,
|
|
1495
|
+
});
|
|
1496
|
+
if (extractScaffoldKey(existing.description) !== input.stableKey) {
|
|
1497
|
+
await patchDatabaseDescription({
|
|
1498
|
+
apiToken: input.apiToken,
|
|
1499
|
+
databaseId: existing.databaseId,
|
|
1500
|
+
existingDescription: existing.description,
|
|
1501
|
+
stableKey: input.stableKey,
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
return {
|
|
1505
|
+
dataSourceId: existing.dataSourceId,
|
|
1506
|
+
databaseId: existing.databaseId,
|
|
1507
|
+
url: existing.url,
|
|
1508
|
+
created: false,
|
|
1509
|
+
seeded: 0,
|
|
1510
|
+
};
|
|
1511
|
+
}
|
|
1512
|
+
const { databaseId, dataSourceId, url } = await createDatabaseWithProperties({
|
|
1513
|
+
apiToken: input.apiToken,
|
|
1514
|
+
parentPageId: input.parentPageId,
|
|
1515
|
+
title: input.title,
|
|
1516
|
+
properties,
|
|
1517
|
+
});
|
|
1518
|
+
await patchDatabaseDescription({
|
|
1519
|
+
apiToken: input.apiToken,
|
|
1520
|
+
databaseId,
|
|
1521
|
+
existingDescription: "",
|
|
1522
|
+
stableKey: input.stableKey,
|
|
1523
|
+
});
|
|
1524
|
+
let seeded = 0;
|
|
1525
|
+
if (input.seedCount > 0) {
|
|
1526
|
+
const schema = await getDataSourceSchema(input.apiToken, dataSourceId);
|
|
1527
|
+
seeded = await seedPlaceholderPages(input.apiToken, databaseId, dataSourceId, input.title, input.locale, input.fields, schema, input.seedCount);
|
|
1528
|
+
}
|
|
1529
|
+
return {
|
|
1530
|
+
dataSourceId,
|
|
1531
|
+
databaseId,
|
|
1532
|
+
url,
|
|
1533
|
+
created: true,
|
|
1534
|
+
seeded,
|
|
1535
|
+
};
|
|
1536
|
+
}
|
|
1537
|
+
export async function ensurePagesDatabase(input) {
|
|
1538
|
+
const title = `${input.projectName} Pages`;
|
|
1539
|
+
const stableKey = "pages:default";
|
|
1540
|
+
const properties = buildPageProperties();
|
|
1541
|
+
const existingByStableKey = await findExistingDatabaseByStableKey({
|
|
1542
|
+
apiToken: input.apiToken,
|
|
1543
|
+
parentPageId: input.parentPageId,
|
|
1544
|
+
stableKey,
|
|
1545
|
+
});
|
|
1546
|
+
const existing = existingByStableKey ??
|
|
1547
|
+
(await findExistingDatabaseByTitle({
|
|
1548
|
+
apiToken: input.apiToken,
|
|
1549
|
+
parentPageId: input.parentPageId,
|
|
1550
|
+
title,
|
|
1551
|
+
}));
|
|
1552
|
+
if (existing) {
|
|
1553
|
+
await ensureDataSourceProperties({
|
|
1554
|
+
apiToken: input.apiToken,
|
|
1555
|
+
dataSourceId: existing.dataSourceId,
|
|
1556
|
+
desired: properties,
|
|
1557
|
+
title,
|
|
1558
|
+
});
|
|
1559
|
+
if (extractScaffoldKey(existing.description) !== stableKey) {
|
|
1560
|
+
await patchDatabaseDescription({
|
|
1561
|
+
apiToken: input.apiToken,
|
|
1562
|
+
databaseId: existing.databaseId,
|
|
1563
|
+
existingDescription: existing.description,
|
|
1564
|
+
stableKey,
|
|
1565
|
+
});
|
|
1566
|
+
}
|
|
1567
|
+
return {
|
|
1568
|
+
dataSourceId: existing.dataSourceId,
|
|
1569
|
+
databaseId: existing.databaseId,
|
|
1570
|
+
url: existing.url,
|
|
1571
|
+
created: false,
|
|
1572
|
+
seeded: 0,
|
|
1573
|
+
};
|
|
1574
|
+
}
|
|
1575
|
+
const { databaseId, dataSourceId, url } = await createDatabaseWithProperties({
|
|
1576
|
+
apiToken: input.apiToken,
|
|
1577
|
+
parentPageId: input.parentPageId,
|
|
1578
|
+
title,
|
|
1579
|
+
properties,
|
|
1580
|
+
});
|
|
1581
|
+
await patchDatabaseDescription({
|
|
1582
|
+
apiToken: input.apiToken,
|
|
1583
|
+
databaseId,
|
|
1584
|
+
existingDescription: "",
|
|
1585
|
+
stableKey,
|
|
1586
|
+
});
|
|
1587
|
+
let seeded = 0;
|
|
1588
|
+
for (const page of sampleSitePages(input)) {
|
|
1589
|
+
const body = buildSitePagePayload({
|
|
1590
|
+
databaseId,
|
|
1591
|
+
projectName: input.projectName,
|
|
1592
|
+
page,
|
|
1593
|
+
});
|
|
1594
|
+
const result = await runNtn(["api", "v1/pages", "-d", JSON.stringify(body)], {
|
|
1595
|
+
env: { NOTION_API_TOKEN: input.apiToken },
|
|
1596
|
+
});
|
|
1597
|
+
if (result.code === 0) {
|
|
1598
|
+
seeded++;
|
|
1599
|
+
}
|
|
1600
|
+
else {
|
|
1601
|
+
const detail = (result.stderr || result.stdout).trim().slice(0, 500);
|
|
1602
|
+
console.warn(`[notion seed] site page "${page.key}" failed (code ${result.code}): ${detail}`);
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
return {
|
|
1606
|
+
dataSourceId,
|
|
1607
|
+
databaseId,
|
|
1608
|
+
url,
|
|
1609
|
+
created: true,
|
|
1610
|
+
seeded,
|
|
1611
|
+
};
|
|
1612
|
+
}
|
|
1613
|
+
export async function ensureBlocksDatabase(input) {
|
|
1614
|
+
const title = `${input.projectName} Blocks`;
|
|
1615
|
+
const stableKey = "blocks:default";
|
|
1616
|
+
const properties = buildBlocksProperties();
|
|
1617
|
+
const existingByStableKey = await findExistingDatabaseByStableKey({
|
|
1618
|
+
apiToken: input.apiToken,
|
|
1619
|
+
parentPageId: input.parentPageId,
|
|
1620
|
+
stableKey,
|
|
1621
|
+
});
|
|
1622
|
+
const existing = existingByStableKey ??
|
|
1623
|
+
(await findExistingDatabaseByTitle({
|
|
1624
|
+
apiToken: input.apiToken,
|
|
1625
|
+
parentPageId: input.parentPageId,
|
|
1626
|
+
title,
|
|
1627
|
+
}));
|
|
1628
|
+
if (existing) {
|
|
1629
|
+
await ensureDataSourceProperties({
|
|
1630
|
+
apiToken: input.apiToken,
|
|
1631
|
+
dataSourceId: existing.dataSourceId,
|
|
1632
|
+
desired: properties,
|
|
1633
|
+
title,
|
|
1634
|
+
});
|
|
1635
|
+
if (extractScaffoldKey(existing.description) !== stableKey) {
|
|
1636
|
+
await patchDatabaseDescription({
|
|
1637
|
+
apiToken: input.apiToken,
|
|
1638
|
+
databaseId: existing.databaseId,
|
|
1639
|
+
existingDescription: existing.description,
|
|
1640
|
+
stableKey,
|
|
1641
|
+
});
|
|
1642
|
+
}
|
|
1643
|
+
return {
|
|
1644
|
+
dataSourceId: existing.dataSourceId,
|
|
1645
|
+
databaseId: existing.databaseId,
|
|
1646
|
+
url: existing.url,
|
|
1647
|
+
created: false,
|
|
1648
|
+
seeded: 0,
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1651
|
+
const { databaseId, dataSourceId, url } = await createDatabaseWithProperties({
|
|
1652
|
+
apiToken: input.apiToken,
|
|
1653
|
+
parentPageId: input.parentPageId,
|
|
1654
|
+
title,
|
|
1655
|
+
properties,
|
|
1656
|
+
});
|
|
1657
|
+
await patchDatabaseDescription({
|
|
1658
|
+
apiToken: input.apiToken,
|
|
1659
|
+
databaseId,
|
|
1660
|
+
existingDescription: "",
|
|
1661
|
+
stableKey,
|
|
1662
|
+
});
|
|
1663
|
+
let seeded = 0;
|
|
1664
|
+
for (const block of sampleBlocks(input)) {
|
|
1665
|
+
const body = buildSiteBlockPayload({
|
|
1666
|
+
databaseId,
|
|
1667
|
+
projectName: input.projectName,
|
|
1668
|
+
block,
|
|
1669
|
+
});
|
|
1670
|
+
const result = await runNtn(["api", "v1/pages", "-d", JSON.stringify(body)], {
|
|
1671
|
+
env: { NOTION_API_TOKEN: input.apiToken },
|
|
1672
|
+
});
|
|
1673
|
+
if (result.code === 0) {
|
|
1674
|
+
seeded++;
|
|
1675
|
+
}
|
|
1676
|
+
else {
|
|
1677
|
+
const detail = (result.stderr || result.stdout).trim().slice(0, 500);
|
|
1678
|
+
console.warn(`[notion seed] block "${block.slug}" failed (code ${result.code}): ${detail}`);
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
return {
|
|
1682
|
+
dataSourceId,
|
|
1683
|
+
databaseId,
|
|
1684
|
+
url,
|
|
1685
|
+
created: true,
|
|
1686
|
+
seeded,
|
|
1687
|
+
};
|
|
1688
|
+
}
|
|
1689
|
+
async function seedPlaceholderPages(apiToken, databaseId, dataSourceId, title, locale, fields, schema, count) {
|
|
1690
|
+
let ok = 0;
|
|
1691
|
+
const titlePropertyName = resolveTitlePropertyName(schema);
|
|
1692
|
+
const fieldNames = {
|
|
1693
|
+
title: titlePropertyName,
|
|
1694
|
+
slug: findMatchingField(schema, fields, "slug", "Slug"),
|
|
1695
|
+
description: findMatchingField(schema, fields, "description", "Description"),
|
|
1696
|
+
published: findMatchingField(schema, fields, "published", "Published"),
|
|
1697
|
+
date: findMatchingField(schema, fields, "date", "Date"),
|
|
1698
|
+
tags: findMatchingField(schema, fields, "tags", "Tags"),
|
|
1699
|
+
cover: findMatchingField(schema, fields, "cover", "Cover"),
|
|
1700
|
+
};
|
|
1701
|
+
for (let i = 1; i <= count; i++) {
|
|
1702
|
+
const body = buildSamplePage({
|
|
1703
|
+
index: i,
|
|
1704
|
+
titlePropertyName,
|
|
1705
|
+
databaseId,
|
|
1706
|
+
title,
|
|
1707
|
+
locale,
|
|
1708
|
+
fieldNames,
|
|
1709
|
+
});
|
|
1710
|
+
const r = await runNtn(["api", "v1/pages", "-d", JSON.stringify(body)], {
|
|
1711
|
+
env: { NOTION_API_TOKEN: apiToken },
|
|
1712
|
+
});
|
|
1713
|
+
if (r.code === 0) {
|
|
1714
|
+
ok++;
|
|
1715
|
+
}
|
|
1716
|
+
else {
|
|
1717
|
+
// Surface the API's actual error so the operator can see why
|
|
1718
|
+
// a sample page didn't land. We log the first failure at warn
|
|
1719
|
+
// level (later ones are usually the same root cause).
|
|
1720
|
+
const detail = (r.stderr || r.stdout).trim().slice(0, 500);
|
|
1721
|
+
console.warn(`[notion seed] page #${i} for "${title}" failed (code ${r.code}): ${detail}`);
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
return ok;
|
|
1725
|
+
}
|
|
1726
|
+
/** Lowercase, ascii-only slug suitable for a picsum seed token. */
|
|
1727
|
+
function slugify(input) {
|
|
1728
|
+
return input
|
|
1729
|
+
.toLowerCase()
|
|
1730
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
1731
|
+
.replace(/^-+|-+$/g, "")
|
|
1732
|
+
.slice(0, 40) || "post";
|
|
1733
|
+
}
|
|
1734
|
+
export const _internal = {
|
|
1735
|
+
notionPropertyType,
|
|
1736
|
+
buildProperties,
|
|
1737
|
+
buildPageProperties,
|
|
1738
|
+
buildBlocksProperties,
|
|
1739
|
+
buildSitePagePayload,
|
|
1740
|
+
buildSiteBlockPayload,
|
|
1741
|
+
resolveTitlePropertyName,
|
|
1742
|
+
buildSamplePage,
|
|
1743
|
+
sampleSitePages,
|
|
1744
|
+
sampleBlocks,
|
|
1745
|
+
samplePostFor,
|
|
1746
|
+
samplePosts,
|
|
1747
|
+
buildScaffoldMarker,
|
|
1748
|
+
extractScaffoldKey,
|
|
1749
|
+
mergeDescriptionWithScaffoldMarker,
|
|
1750
|
+
missingPropertiesForPatch,
|
|
1751
|
+
buildSiteSettingsProperties,
|
|
1752
|
+
buildSiteSettingsSeedPage,
|
|
1753
|
+
};
|
|
1754
|
+
// ---------------------------------------------------------------------------
|
|
1755
|
+
// Site settings (singleton row)
|
|
1756
|
+
//
|
|
1757
|
+
// The generated project reads site-level config (name, tagline, description,
|
|
1758
|
+
// default locale, social image) from a dedicated Notion data source. The
|
|
1759
|
+
// scaffolder creates that data source here, with a fixed schema the runtime
|
|
1760
|
+
// loader knows how to read, and seeds a single row pre-populated with the
|
|
1761
|
+
// project name + a placeholder description. Operators can edit the row in
|
|
1762
|
+
// Notion after scaffolding; changes show up within 5 minutes (KV cache TTL)
|
|
1763
|
+
// or immediately via the admin revalidate endpoint.
|
|
1764
|
+
// ---------------------------------------------------------------------------
|
|
1765
|
+
/** Field names the runtime loader reads in `lib/site/settings.ts`. */
|
|
1766
|
+
export const SITE_SETTINGS_FIELDS = [
|
|
1767
|
+
"Site Name", // title
|
|
1768
|
+
"Tagline", // rich_text
|
|
1769
|
+
"Description", // rich_text
|
|
1770
|
+
"Default Locale", // select
|
|
1771
|
+
"Social Image", // url
|
|
1772
|
+
];
|
|
1773
|
+
/**
|
|
1774
|
+
* Build the Notion `properties` object for the site-settings data source.
|
|
1775
|
+
*
|
|
1776
|
+
* Mirrors `siteSettingsSource.fields` in the generated
|
|
1777
|
+
* `lib/content/models.ts`:
|
|
1778
|
+
* - `Site Name` → title (Notion's only title column)
|
|
1779
|
+
* - `Tagline` → rich_text
|
|
1780
|
+
* - `Description` → rich_text
|
|
1781
|
+
* - `Default Locale` → select
|
|
1782
|
+
* - `Social Image` → url
|
|
1783
|
+
*
|
|
1784
|
+
* Keep the `SITE_SETTINGS_FIELDS` array in sync with this map. The
|
|
1785
|
+
* scaffolder's seed row and the runtime loader both depend on it.
|
|
1786
|
+
*/
|
|
1787
|
+
export function buildSiteSettingsProperties() {
|
|
1788
|
+
const props = {
|
|
1789
|
+
// 5 pre-existing
|
|
1790
|
+
"Site Name": { title: {} },
|
|
1791
|
+
Tagline: { rich_text: {} },
|
|
1792
|
+
Description: { rich_text: {} },
|
|
1793
|
+
"Default Locale": { select: {} },
|
|
1794
|
+
"Social Image": { url: {} },
|
|
1795
|
+
// 12 new (0.5.4) — SEO, navigation, theme, footer
|
|
1796
|
+
"Meta Title": { rich_text: {} },
|
|
1797
|
+
"Meta Description": { rich_text: {} },
|
|
1798
|
+
"OG Image": { url: {} },
|
|
1799
|
+
Nav: { rich_text: {} },
|
|
1800
|
+
"Nav CTA": { rich_text: {} },
|
|
1801
|
+
"Primary Color": { select: {} },
|
|
1802
|
+
"Accent Color": { select: {} },
|
|
1803
|
+
"Font Family": { select: {} },
|
|
1804
|
+
"Footer Columns": { rich_text: {} },
|
|
1805
|
+
"Footer Copyright": { rich_text: {} },
|
|
1806
|
+
"Footer Social Links": { rich_text: {} },
|
|
1807
|
+
"Footer Tagline": { rich_text: {} },
|
|
1808
|
+
};
|
|
1809
|
+
return props;
|
|
1810
|
+
}
|
|
1811
|
+
/**
|
|
1812
|
+
* Build the single seed page for the site-settings data source.
|
|
1813
|
+
*
|
|
1814
|
+
* The page carries the project name and a placeholder description so
|
|
1815
|
+
* the home page renders something useful before the operator customizes
|
|
1816
|
+
* it in Notion. The runtime loader falls back to
|
|
1817
|
+
* `fallbackSiteConfig` if the row is missing, so an unedited seed
|
|
1818
|
+
* page is fine — but a populated one means the very first request
|
|
1819
|
+
* after scaffolding already shows the right site name everywhere.
|
|
1820
|
+
*
|
|
1821
|
+
* `parent` uses `data_source_id` (Notion's 2025-09-03 schema).
|
|
1822
|
+
* Passing the legacy `database_id` here silently fails with
|
|
1823
|
+
* `validation_error` and the seed step reports "0 page" — which
|
|
1824
|
+
* is exactly the bug we hit when the Notion API started requiring
|
|
1825
|
+
* data sources for page parents.
|
|
1826
|
+
*/
|
|
1827
|
+
export function buildSiteSettingsSeedPage(input) {
|
|
1828
|
+
const defaultNav = JSON.stringify([
|
|
1829
|
+
{ label: "Home", href: "/" },
|
|
1830
|
+
{ label: "About", href: "/about" },
|
|
1831
|
+
{ label: "Blog", href: "/blog" },
|
|
1832
|
+
]);
|
|
1833
|
+
const defaultFooterColumns = JSON.stringify([
|
|
1834
|
+
{
|
|
1835
|
+
label: "Company",
|
|
1836
|
+
items: [
|
|
1837
|
+
{ label: "Home", href: "/" },
|
|
1838
|
+
{ label: "About", href: "/about" },
|
|
1839
|
+
],
|
|
1840
|
+
},
|
|
1841
|
+
{
|
|
1842
|
+
label: "Content",
|
|
1843
|
+
items: [{ label: "Blog", href: "/blog" }],
|
|
1844
|
+
},
|
|
1845
|
+
{
|
|
1846
|
+
label: "Legal",
|
|
1847
|
+
items: [{ label: "Privacy", href: "/privacy" }],
|
|
1848
|
+
},
|
|
1849
|
+
]);
|
|
1850
|
+
const footerCopyright = `© ${new Date().getFullYear()} ${input.projectName}`;
|
|
1851
|
+
const socialImageUrl = `https://picsum.photos/seed/${slugify(input.projectName)}-social/1200/630`;
|
|
1852
|
+
const tagline = `${input.projectName} on Notion and Cloudflare`;
|
|
1853
|
+
return {
|
|
1854
|
+
parent: { type: "data_source_id", data_source_id: input.dataSourceId },
|
|
1855
|
+
properties: {
|
|
1856
|
+
"Site Name": {
|
|
1857
|
+
title: [{ text: { content: input.projectName } }],
|
|
1858
|
+
},
|
|
1859
|
+
Tagline: {
|
|
1860
|
+
rich_text: [{ text: { content: tagline } }],
|
|
1861
|
+
},
|
|
1862
|
+
Description: {
|
|
1863
|
+
rich_text: [{ text: { content: input.description } }],
|
|
1864
|
+
},
|
|
1865
|
+
"Default Locale": {
|
|
1866
|
+
select: { name: input.defaultLocale },
|
|
1867
|
+
},
|
|
1868
|
+
"Meta Title": {
|
|
1869
|
+
rich_text: [{ text: { content: input.projectName } }],
|
|
1870
|
+
},
|
|
1871
|
+
"Meta Description": {
|
|
1872
|
+
rich_text: [{ text: { content: input.description } }],
|
|
1873
|
+
},
|
|
1874
|
+
"Social Image": { url: socialImageUrl },
|
|
1875
|
+
"OG Image": { url: socialImageUrl },
|
|
1876
|
+
Nav: {
|
|
1877
|
+
rich_text: [{ text: { content: defaultNav } }],
|
|
1878
|
+
},
|
|
1879
|
+
"Nav CTA": { rich_text: [] },
|
|
1880
|
+
"Primary Color": { select: { name: "slate" } },
|
|
1881
|
+
"Accent Color": { select: { name: "blue" } },
|
|
1882
|
+
"Font Family": { select: { name: "inter" } },
|
|
1883
|
+
"Footer Columns": {
|
|
1884
|
+
rich_text: [{ text: { content: defaultFooterColumns } }],
|
|
1885
|
+
},
|
|
1886
|
+
"Footer Copyright": {
|
|
1887
|
+
rich_text: [{ text: { content: footerCopyright } }],
|
|
1888
|
+
},
|
|
1889
|
+
"Footer Social Links": {
|
|
1890
|
+
rich_text: [{ text: { content: "[]" } }],
|
|
1891
|
+
},
|
|
1892
|
+
"Footer Tagline": {
|
|
1893
|
+
rich_text: [{ text: { content: tagline } }],
|
|
1894
|
+
},
|
|
1895
|
+
},
|
|
1896
|
+
};
|
|
1897
|
+
}
|
|
1898
|
+
/**
|
|
1899
|
+
* Create the site-settings data source under the given parent page and
|
|
1900
|
+
* insert the seed row. Same Notion API dance as
|
|
1901
|
+
* `ensureNotionDatabase`, minus the multi-page seeding — the singleton
|
|
1902
|
+
* row is created up front so the home page works before the operator
|
|
1903
|
+
* has opened Notion.
|
|
1904
|
+
*/
|
|
1905
|
+
export async function ensureSiteSettingsDatabase(input) {
|
|
1906
|
+
const stableKey = "site-settings";
|
|
1907
|
+
const title = `${input.projectName} Site Settings`;
|
|
1908
|
+
const properties = buildSiteSettingsProperties();
|
|
1909
|
+
// 1) Stable-key match.
|
|
1910
|
+
const existingByStableKey = await findExistingDatabaseByStableKey({
|
|
1911
|
+
apiToken: input.apiToken,
|
|
1912
|
+
parentPageId: input.parentPageId,
|
|
1913
|
+
stableKey,
|
|
1914
|
+
});
|
|
1915
|
+
// 2) Title fallback.
|
|
1916
|
+
const existing = existingByStableKey ??
|
|
1917
|
+
(await findExistingDatabaseByTitle({
|
|
1918
|
+
apiToken: input.apiToken,
|
|
1919
|
+
parentPageId: input.parentPageId,
|
|
1920
|
+
title,
|
|
1921
|
+
}));
|
|
1922
|
+
if (existing) {
|
|
1923
|
+
// Make sure the schema has every property the 0.5.4+ schema
|
|
1924
|
+
// expects. Notion adds new properties with no destructive
|
|
1925
|
+
// effect on existing rows.
|
|
1926
|
+
await ensureDataSourceProperties({
|
|
1927
|
+
apiToken: input.apiToken,
|
|
1928
|
+
dataSourceId: existing.dataSourceId,
|
|
1929
|
+
desired: properties,
|
|
1930
|
+
title,
|
|
1931
|
+
});
|
|
1932
|
+
if (extractScaffoldKey(existing.description) !== stableKey) {
|
|
1933
|
+
await patchDatabaseDescription({
|
|
1934
|
+
apiToken: input.apiToken,
|
|
1935
|
+
databaseId: existing.databaseId,
|
|
1936
|
+
existingDescription: existing.description,
|
|
1937
|
+
stableKey,
|
|
1938
|
+
});
|
|
1939
|
+
}
|
|
1940
|
+
return {
|
|
1941
|
+
dataSourceId: existing.dataSourceId,
|
|
1942
|
+
databaseId: existing.databaseId,
|
|
1943
|
+
url: existing.url,
|
|
1944
|
+
reused: true,
|
|
1945
|
+
seeded: 0,
|
|
1946
|
+
};
|
|
1947
|
+
}
|
|
1948
|
+
// 3) Cold start — create with the scaffold marker baked in.
|
|
1949
|
+
const { databaseId, dataSourceId, url } = await createDatabaseWithProperties({
|
|
1950
|
+
apiToken: input.apiToken,
|
|
1951
|
+
parentPageId: input.parentPageId,
|
|
1952
|
+
title,
|
|
1953
|
+
properties,
|
|
1954
|
+
});
|
|
1955
|
+
await patchDatabaseDescription({
|
|
1956
|
+
apiToken: input.apiToken,
|
|
1957
|
+
databaseId,
|
|
1958
|
+
existingDescription: "",
|
|
1959
|
+
stableKey,
|
|
1960
|
+
});
|
|
1961
|
+
// 4) Seed the singleton row.
|
|
1962
|
+
const seed = buildSiteSettingsSeedPage({
|
|
1963
|
+
projectName: input.projectName,
|
|
1964
|
+
description: input.description,
|
|
1965
|
+
defaultLocale: input.defaultLocale,
|
|
1966
|
+
dataSourceId,
|
|
1967
|
+
});
|
|
1968
|
+
const seedResult = await runNtn(["api", "v1/pages", "-d", JSON.stringify(seed)], { env: { NOTION_API_TOKEN: input.apiToken } });
|
|
1969
|
+
if (seedResult.code !== 0) {
|
|
1970
|
+
const detail = (seedResult.stderr || seedResult.stdout).trim().slice(0, 500);
|
|
1971
|
+
console.warn(`[notion site-settings seed] failed (code ${seedResult.code}): ${detail}`);
|
|
1972
|
+
}
|
|
1973
|
+
return {
|
|
1974
|
+
dataSourceId,
|
|
1975
|
+
databaseId,
|
|
1976
|
+
url,
|
|
1977
|
+
reused: false,
|
|
1978
|
+
seeded: seedResult.code === 0 ? 1 : 0,
|
|
1979
|
+
};
|
|
1980
|
+
}
|
|
1981
|
+
//# sourceMappingURL=notion.js.map
|