@notionx/create-notionx-app 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/cli-notionx.js +25 -1
  2. package/dist/cli-notionx.js.map +1 -1
  3. package/dist/locale-add/persist.js +113 -0
  4. package/dist/locale-add/persist.js.map +1 -0
  5. package/dist/locale-add/plan.js +202 -21
  6. package/dist/locale-add/plan.js.map +1 -1
  7. package/dist/metadata.js.map +1 -1
  8. package/dist/notion-translation-sources/apply.js +11 -26
  9. package/dist/notion-translation-sources/apply.js.map +1 -1
  10. package/dist/notion-translation-sources/plan.js +25 -0
  11. package/dist/notion-translation-sources/plan.js.map +1 -1
  12. package/dist/provision/__tests__/translation-properties.test.js +86 -0
  13. package/dist/provision/__tests__/translation-properties.test.js.map +1 -0
  14. package/dist/provision/credentials.js +67 -0
  15. package/dist/provision/credentials.js.map +1 -0
  16. package/dist/provision/index.js +188 -11
  17. package/dist/provision/index.js.map +1 -1
  18. package/dist/provision/notion.js +422 -269
  19. package/dist/provision/notion.js.map +1 -1
  20. package/dist/provision/notion.test.js +143 -116
  21. package/dist/provision/notion.test.js.map +1 -1
  22. package/dist/provision/wire.js +16 -0
  23. package/dist/provision/wire.js.map +1 -1
  24. package/dist/registry/install.test.js +2 -0
  25. package/dist/registry/install.test.js.map +1 -1
  26. package/dist/registry/project-meta.js +4 -2
  27. package/dist/registry/project-meta.js.map +1 -1
  28. package/dist/registry/registry-types.js.map +1 -1
  29. package/dist/registry/render-content-source-files.js +1 -0
  30. package/dist/registry/render-content-source-files.js.map +1 -1
  31. package/dist/registry/render-multi-source.js +72 -28
  32. package/dist/registry/render-multi-source.js.map +1 -1
  33. package/dist/registry/update.test.js +2 -0
  34. package/dist/registry/update.test.js.map +1 -1
  35. package/dist/render.js +2 -0
  36. package/dist/render.js.map +1 -1
  37. package/dist/render.test.js +18 -12
  38. package/dist/render.test.js.map +1 -1
  39. package/dist/templates/.dev.vars.example.tmpl +4 -0
  40. package/dist/templates/__tests__/middleware-integration.test.ts +58 -0
  41. package/dist/templates/app/{{contentSourceListPath}}/[slug]/page.tsx.tmpl +8 -4
  42. package/dist/templates/app/{{contentSourceListPath}}/page.tsx.tmpl +8 -4
  43. package/dist/templates/components/page-blocks/feature-grid-block.tsx.tmpl +4 -56
  44. package/dist/templates/components/page-blocks/hero-block.tsx.tmpl +6 -67
  45. package/dist/templates/components/page-blocks/latest-posts-block.tsx.tmpl +11 -19
  46. package/dist/templates/components/page-blocks/story-block.tsx.tmpl +4 -62
  47. package/dist/templates/components/page-blocks.tsx.tmpl +5 -5
  48. package/dist/templates/components/site/site-header.tsx.tmpl +5 -3
  49. package/dist/templates/env.d.ts.tmpl +8 -0
  50. package/dist/templates/lib/blocks/translations.ts.tmpl +22 -1
  51. package/dist/templates/lib/blog/translations.ts.tmpl +18 -2
  52. package/dist/templates/lib/content/models.ts.tmpl +6 -0
  53. package/dist/templates/lib/pages/source.ts.tmpl +136 -334
  54. package/dist/templates/lib/pages/translations.ts.tmpl +23 -1
  55. package/dist/templates/lib/site/request-env.ts.tmpl +16 -0
  56. package/dist/templates/lib/site/settings.ts.tmpl +96 -179
  57. package/dist/templates/lib/site/translations.ts.tmpl +34 -11
  58. package/dist/templates/middleware.ts.tmpl +56 -0
  59. package/dist/templates/worker/index.ts.tmpl +14 -5
  60. package/dist/templates/wrangler.jsonc.tmpl +5 -1
  61. package/package.json +1 -1
@@ -563,133 +563,239 @@ function sampleBlocks(input) {
563
563
  if (input.locale?.toLowerCase().startsWith("zh")) {
564
564
  return [
565
565
  {
566
- title: "首页 Hero",
566
+ name: "首页 Hero",
567
567
  slug: "home-hero",
568
568
  type: "hero",
569
- description: "首页顶部主视觉区块,适合放标题、副标题与主行动按钮。",
570
- pageKeys: ["home"],
571
569
  order: 10,
572
570
  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",
571
+ children: [
572
+ {
573
+ type: "heading_1",
574
+ heading_1: {
575
+ rich_text: [{ text: { content: "从一个可以持续编辑的首页开始" } }],
576
+ },
577
+ },
578
+ {
579
+ type: "paragraph",
580
+ paragraph: {
581
+ rich_text: [
582
+ {
583
+ text: {
584
+ content: "把首页的一句话价值、介绍文案和主行动按钮交给 Notion,站点布局继续由代码稳定控制。",
585
+ },
586
+ },
587
+ ],
588
+ },
589
+ },
590
+ {
591
+ type: "callout",
592
+ callout: {
593
+ rich_text: [
594
+ { text: { content: "CTA: 查看内容列表 → /blog" } },
595
+ ],
596
+ },
597
+ },
598
+ ],
582
599
  },
583
600
  {
584
- title: "首页功能展示",
601
+ name: "首页功能展示",
585
602
  slug: "home-feature-grid",
586
603
  type: "feature-grid",
587
- description: "用于首页中段的功能/能力展示区块。",
588
- pageKeys: ["home"],
589
604
  order: 20,
590
605
  coverSeed: "home-feature-grid-zh",
591
- headline: "把内容、运行时和发布流程串成一个清晰系统",
592
- body: "这个区块默认用三列卡片展示项目能力,适合介绍内容工作流、部署基础设施和持续发布能力。",
593
- columns: 3,
594
- items: [
606
+ children: [
607
+ {
608
+ type: "heading_2",
609
+ heading_2: {
610
+ rich_text: [
611
+ { text: { content: "把内容、运行时和发布流程串成一个清晰系统" } },
612
+ ],
613
+ },
614
+ },
595
615
  {
596
- title: "内容编辑",
597
- description: "让编辑直接在 Notion 中维护页面与内容,不需要改代码。",
598
- icon: "pen-square",
599
- href: "/about",
616
+ type: "paragraph",
617
+ paragraph: {
618
+ rich_text: [
619
+ {
620
+ text: {
621
+ content: "这个区块默认用三列卡片展示项目能力,适合介绍内容工作流、部署基础设施和持续发布能力。",
622
+ },
623
+ },
624
+ ],
625
+ },
600
626
  },
601
627
  {
602
- title: "云端运行",
603
- description: "基于 Cloudflare Workers、D1 和 KV 提供轻量稳定的运行时能力。",
604
- icon: "cloud",
628
+ type: "bulleted_list_item",
629
+ bulleted_list_item: {
630
+ rich_text: [
631
+ { text: { content: "内容编辑:让编辑直接在 Notion 中维护页面与内容。" } },
632
+ ],
633
+ },
605
634
  },
606
635
  {
607
- title: "持续更新",
608
- description: `${input.contentSourceTitle} 列表可以持续发布新内容,并自动进入站点路由。`,
609
- icon: "newspaper",
610
- href: "/blog",
636
+ type: "bulleted_list_item",
637
+ bulleted_list_item: {
638
+ rich_text: [
639
+ { text: { content: "云端运行:基于 Cloudflare Workers、D1 和 KV 提供轻量稳定的运行时。" } },
640
+ ],
641
+ },
642
+ },
643
+ {
644
+ type: "bulleted_list_item",
645
+ bulleted_list_item: {
646
+ rich_text: [
647
+ { text: { content: `持续更新:${input.contentSourceTitle} 列表可以持续发布新内容。` } },
648
+ ],
649
+ },
611
650
  },
612
651
  ],
613
652
  },
614
653
  {
615
- title: "首页最新文章",
654
+ name: "首页最新文章",
616
655
  slug: "home-latest-posts",
617
656
  type: "latest-posts",
618
- description: "在首页展示最近发布内容的文章卡片区块。",
619
- pageKeys: ["home"],
620
657
  order: 30,
621
658
  coverSeed: "home-latest-posts-zh",
622
- headline: "看看最近更新了什么",
623
- body: "默认展示最新发布的 6 篇内容,既能丰富首页,也能直接验证博客内容链路是否生效。",
624
- count: 6,
625
- primaryCtaLabel: "查看全部文章",
626
- primaryCtaHref: "/blog",
659
+ children: [
660
+ {
661
+ type: "heading_2",
662
+ heading_2: {
663
+ rich_text: [{ text: { content: "看看最近更新了什么" } }],
664
+ },
665
+ },
666
+ {
667
+ type: "paragraph",
668
+ paragraph: {
669
+ rich_text: [
670
+ {
671
+ text: {
672
+ content: "默认展示最新发布的 6 篇内容,既能丰富首页,也能直接验证博客内容链路是否生效。",
673
+ },
674
+ },
675
+ ],
676
+ },
677
+ },
678
+ ],
627
679
  },
628
680
  ];
629
681
  }
630
682
  return [
631
683
  {
632
- title: "Homepage Hero",
684
+ name: "Homepage Hero",
633
685
  slug: "home-hero",
634
686
  type: "hero",
635
- description: "Homepage hero module for headline, supporting copy, and primary CTA.",
636
- pageKeys: ["home"],
637
687
  order: 10,
638
688
  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",
689
+ children: [
690
+ {
691
+ type: "heading_1",
692
+ heading_1: {
693
+ rich_text: [
694
+ { text: { content: "Start with a homepage you can keep editing" } },
695
+ ],
696
+ },
697
+ },
698
+ {
699
+ type: "paragraph",
700
+ paragraph: {
701
+ rich_text: [
702
+ {
703
+ text: {
704
+ content: "Keep the layout stable in code while the hero copy, positioning, and primary call to action evolve in Notion.",
705
+ },
706
+ },
707
+ ],
708
+ },
709
+ },
710
+ {
711
+ type: "callout",
712
+ callout: {
713
+ rich_text: [{ text: { content: "CTA: Explore the blog → /blog" } }],
714
+ },
715
+ },
716
+ ],
648
717
  },
649
718
  {
650
- title: "Homepage Feature Grid",
719
+ name: "Homepage Feature Grid",
651
720
  slug: "home-feature-grid",
652
721
  type: "feature-grid",
653
- description: "Mid-page feature section for capabilities, benefits, or service pillars.",
654
- pageKeys: ["home"],
655
722
  order: 20,
656
723
  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: [
724
+ children: [
725
+ {
726
+ type: "heading_2",
727
+ heading_2: {
728
+ rich_text: [
729
+ { text: { content: "Show the system working together" } },
730
+ ],
731
+ },
732
+ },
733
+ {
734
+ type: "paragraph",
735
+ paragraph: {
736
+ rich_text: [
737
+ {
738
+ text: {
739
+ content: "Use this grid to explain how editing, infrastructure, and publishing fit together without overwhelming the homepage.",
740
+ },
741
+ },
742
+ ],
743
+ },
744
+ },
661
745
  {
662
- title: "Editorial workflows",
663
- description: "Use Notion as the editor for pages, posts, and reusable sections.",
664
- icon: "pen-square",
665
- href: "/about",
746
+ type: "bulleted_list_item",
747
+ bulleted_list_item: {
748
+ rich_text: [
749
+ { text: { content: "Editorial workflows: Use Notion as the editor for pages, posts, and reusable sections." } },
750
+ ],
751
+ },
666
752
  },
667
753
  {
668
- title: "Cloudflare runtime",
669
- description: "Ship on Workers with storage and caching primitives ready to grow.",
670
- icon: "cloud",
754
+ type: "bulleted_list_item",
755
+ bulleted_list_item: {
756
+ rich_text: [
757
+ { text: { content: "Cloudflare runtime: Ship on Workers with storage and caching primitives ready to grow." } },
758
+ ],
759
+ },
671
760
  },
672
761
  {
673
- title: `${input.contentSourceTitle} updates`,
674
- description: "Publish new entries and surface them through the generated routes automatically.",
675
- icon: "newspaper",
676
- href: "/blog",
762
+ type: "bulleted_list_item",
763
+ bulleted_list_item: {
764
+ rich_text: [
765
+ { text: { content: `${input.contentSourceTitle} updates: Publish new entries and surface them through the generated routes automatically.` } },
766
+ ],
767
+ },
677
768
  },
678
769
  ],
679
770
  },
680
771
  {
681
- title: "Homepage Latest Posts",
772
+ name: "Homepage Latest Posts",
682
773
  slug: "home-latest-posts",
683
774
  type: "latest-posts",
684
- description: "Homepage latest-posts block for previewing the newest published content.",
685
- pageKeys: ["home"],
686
775
  order: 30,
687
776
  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",
777
+ children: [
778
+ {
779
+ type: "heading_2",
780
+ heading_2: {
781
+ rich_text: [
782
+ { text: { content: "Read the latest from the blog" } },
783
+ ],
784
+ },
785
+ },
786
+ {
787
+ type: "paragraph",
788
+ paragraph: {
789
+ rich_text: [
790
+ {
791
+ text: {
792
+ content: "Use this section to prove the content model is working with a grid of recent published posts right on the homepage.",
793
+ },
794
+ },
795
+ ],
796
+ },
797
+ },
798
+ ],
693
799
  },
694
800
  ];
695
801
  }
@@ -1058,7 +1164,7 @@ function findMatchingField(properties, fields, key, fallback) {
1058
1164
  return fallback;
1059
1165
  return configured;
1060
1166
  }
1061
- async function createDatabaseWithProperties(input) {
1167
+ export async function createDatabaseWithProperties(input) {
1062
1168
  const titleProp = Object.entries(input.properties).find(([, value]) => notionPropertyDefinitionType(value) === "title");
1063
1169
  const dbTitlePropName = titleProp ? titleProp[0] : "Name";
1064
1170
  const body = {
@@ -1210,7 +1316,7 @@ function buildSamplePage(input) {
1210
1316
  ],
1211
1317
  };
1212
1318
  }
1213
- function buildPageProperties() {
1319
+ function buildPageProperties(blocksDatabaseId) {
1214
1320
  return {
1215
1321
  Name: { title: {} },
1216
1322
  Key: { rich_text: {} },
@@ -1230,7 +1336,12 @@ function buildPageProperties() {
1230
1336
  "Footer Group": { select: {} },
1231
1337
  "Footer Order": { number: {} },
1232
1338
  "Content Source": { rich_text: {} },
1233
- Blocks: { rich_text: {} },
1339
+ // Blocks is a Notion relation to the Blocks database. The
1340
+ // relation array order (set by drag-and-drop in Notion) is
1341
+ // the native sort order for page blocks.
1342
+ Blocks: blocksDatabaseId
1343
+ ? { relation: { single_property: { database_id: blocksDatabaseId } } }
1344
+ : { relation: { database_property: {} } },
1234
1345
  Cover: { files: {} },
1235
1346
  };
1236
1347
  }
@@ -1238,46 +1349,22 @@ function buildBlocksProperties() {
1238
1349
  return {
1239
1350
  Name: { title: {} },
1240
1351
  Slug: { rich_text: {} },
1241
- Status: { select: {} },
1242
1352
  Type: { select: {} },
1243
- Description: { rich_text: {} },
1244
- "Page Keys": { rich_text: {} },
1245
1353
  Order: { number: {} },
1246
1354
  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: {} },
1355
+ Published: { checkbox: {} },
1264
1356
  };
1265
1357
  }
1266
1358
  function richText(content) {
1267
1359
  return content ? [{ text: { content } }] : [];
1268
1360
  }
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
1361
  function buildSitePagePayload(input) {
1279
- const { databaseId, page, projectName } = input;
1362
+ const { databaseId, page, projectName, blockPageIdsBySlug } = input;
1280
1363
  const coverUrl = `https://picsum.photos/seed/${slugify(projectName)}-${page.coverSeed}/1200/600`;
1364
+ const blockRelationIds = (page.blocks ?? [])
1365
+ .map((ref) => blockPageIdsBySlug?.[ref.slug])
1366
+ .filter((id) => Boolean(id))
1367
+ .map((id) => ({ id }));
1281
1368
  return {
1282
1369
  parent: { type: "database_id", database_id: databaseId },
1283
1370
  cover: {
@@ -1303,9 +1390,9 @@ function buildSitePagePayload(input) {
1303
1390
  "Footer Group": { select: { name: page.footerGroup } },
1304
1391
  "Footer Order": { number: page.footerOrder },
1305
1392
  "Content Source": { rich_text: richText(page.contentSource ?? "") },
1306
- Blocks: {
1307
- rich_text: richText(JSON.stringify(page.blocks ?? [])),
1308
- },
1393
+ ...(blockRelationIds.length > 0
1394
+ ? { Blocks: { relation: blockRelationIds } }
1395
+ : {}),
1309
1396
  Cover: {
1310
1397
  files: [
1311
1398
  {
@@ -1351,65 +1438,11 @@ function buildSiteBlockPayload(input) {
1351
1438
  external: { url: coverUrl },
1352
1439
  },
1353
1440
  properties: {
1354
- Name: { title: richText(block.title) },
1441
+ Name: { title: richText(block.name) },
1355
1442
  Slug: { rich_text: richText(block.slug) },
1356
- Status: { select: { name: "Published" } },
1357
1443
  Type: { select: { name: block.type } },
1358
- Description: { rich_text: richText(block.description) },
1359
- "Page Keys": { rich_text: richText(JSON.stringify(block.pageKeys)) },
1360
1444
  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),
1445
+ Published: { checkbox: true },
1413
1446
  Cover: {
1414
1447
  files: [
1415
1448
  {
@@ -1420,7 +1453,7 @@ function buildSiteBlockPayload(input) {
1420
1453
  ],
1421
1454
  },
1422
1455
  },
1423
- children: [],
1456
+ children: block.children,
1424
1457
  };
1425
1458
  }
1426
1459
  /** Probe `ntn` — returns true if it's installed. */
@@ -1537,7 +1570,7 @@ export async function ensureNotionDatabase(input) {
1537
1570
  export async function ensurePagesDatabase(input) {
1538
1571
  const title = `${input.projectName} Pages`;
1539
1572
  const stableKey = "pages:default";
1540
- const properties = buildPageProperties();
1573
+ const properties = buildPageProperties(input.blocksDatabaseId);
1541
1574
  const existingByStableKey = await findExistingDatabaseByStableKey({
1542
1575
  apiToken: input.apiToken,
1543
1576
  parentPageId: input.parentPageId,
@@ -1590,6 +1623,7 @@ export async function ensurePagesDatabase(input) {
1590
1623
  databaseId,
1591
1624
  projectName: input.projectName,
1592
1625
  page,
1626
+ blockPageIdsBySlug: input.blockPageIdsBySlug,
1593
1627
  });
1594
1628
  const result = await runNtn(["api", "v1/pages", "-d", JSON.stringify(body)], {
1595
1629
  env: { NOTION_API_TOKEN: input.apiToken },
@@ -1661,6 +1695,7 @@ export async function ensureBlocksDatabase(input) {
1661
1695
  stableKey,
1662
1696
  });
1663
1697
  let seeded = 0;
1698
+ const seededPageIdsBySlug = {};
1664
1699
  for (const block of sampleBlocks(input)) {
1665
1700
  const body = buildSiteBlockPayload({
1666
1701
  databaseId,
@@ -1672,6 +1707,17 @@ export async function ensureBlocksDatabase(input) {
1672
1707
  });
1673
1708
  if (result.code === 0) {
1674
1709
  seeded++;
1710
+ // Capture the block page ID so the Pages database can link
1711
+ // to it via the Blocks relation during seeding.
1712
+ try {
1713
+ const created = JSON.parse(result.stdout);
1714
+ if (created.id) {
1715
+ seededPageIdsBySlug[block.slug] = created.id;
1716
+ }
1717
+ }
1718
+ catch {
1719
+ // Non-fatal: relation seeding is best-effort.
1720
+ }
1675
1721
  }
1676
1722
  else {
1677
1723
  const detail = (result.stderr || result.stdout).trim().slice(0, 500);
@@ -1684,6 +1730,7 @@ export async function ensureBlocksDatabase(input) {
1684
1730
  url,
1685
1731
  created: true,
1686
1732
  seeded,
1733
+ seededPageIdsBySlug,
1687
1734
  };
1688
1735
  }
1689
1736
  async function seedPlaceholderPages(apiToken, databaseId, dataSourceId, title, locale, fields, schema, count) {
@@ -1749,74 +1796,58 @@ export const _internal = {
1749
1796
  mergeDescriptionWithScaffoldMarker,
1750
1797
  missingPropertiesForPatch,
1751
1798
  buildSiteSettingsProperties,
1752
- buildSiteSettingsSeedPage,
1799
+ buildSiteSettingsSeedPages,
1753
1800
  };
1754
1801
  // ---------------------------------------------------------------------------
1755
- // Site settings (singleton row)
1802
+ // Site settings (multi-row key-value)
1756
1803
  //
1757
1804
  // The generated project reads site-level config (name, tagline, description,
1758
- // default locale, social image) from a dedicated Notion data source. The
1805
+ // SEO, navigation, theme, footer) from a dedicated Notion data source. The
1759
1806
  // 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.
1807
+ // loader knows how to read, and seeds multiple rows one per setting item —
1808
+ // pre-populated with the project name and sensible defaults. Operators can
1809
+ // edit rows in Notion after scaffolding; changes show up within 5 minutes
1810
+ // (KV cache TTL) or immediately via the admin revalidate endpoint.
1764
1811
  // ---------------------------------------------------------------------------
1765
1812
  /** Field names the runtime loader reads in `lib/site/settings.ts`. */
1766
1813
  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
1814
+ "Name", // title
1815
+ "Section", // select
1816
+ "Key", // rich_text
1817
+ "Value", // rich_text
1818
+ "Type", // select
1819
+ "Published", // checkbox
1772
1820
  ];
1773
1821
  /**
1774
1822
  * Build the Notion `properties` object for the site-settings data source.
1775
1823
  *
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
1824
+ * Multi-row key-value design: each row is one setting item grouped by
1825
+ * `Section`. This is more intuitive for operators to edit in Notion —
1826
+ * they see a clean table grouped by section instead of one wide row
1827
+ * with 17 columns.
1783
1828
  *
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.
1829
+ * Fields:
1830
+ * - `Name` (title) human-readable label, e.g. "Site Name"
1831
+ * - `Section` (select) — grouping: branding/seo/theme/nav/footer
1832
+ * - `Key` (rich_text) — machine key: name/tagline/description/...
1833
+ * - `Value` (rich_text) — the setting value (text, JSON, etc.)
1834
+ * - `Type` (select) — text/url/json/color/select
1835
+ * - `Published` (checkbox) — whether this setting is active
1786
1836
  */
1787
1837
  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: {} },
1838
+ return {
1839
+ Name: { title: {} },
1840
+ Section: { select: {} },
1841
+ Key: { rich_text: {} },
1842
+ Value: { rich_text: {} },
1843
+ Type: { select: {} },
1844
+ Published: { checkbox: {} },
1808
1845
  };
1809
- return props;
1810
1846
  }
1811
1847
  /**
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.
1848
+ * Seed rows for the site-settings data source — one page per setting
1849
+ * item. Each row carries a Section/Key/Value/Type tuple so the
1850
+ * runtime loader can aggregate them into a single `SiteConfig`.
1820
1851
  *
1821
1852
  * `parent` uses `data_source_id` (Notion's 2025-09-03 schema).
1822
1853
  * Passing the legacy `database_id` here silently fails with
@@ -1824,7 +1855,25 @@ export function buildSiteSettingsProperties() {
1824
1855
  * is exactly the bug we hit when the Notion API started requiring
1825
1856
  * data sources for page parents.
1826
1857
  */
1827
- export function buildSiteSettingsSeedPage(input) {
1858
+ const SETTINGS_SEED_ROWS = [
1859
+ { name: "Site Name", section: "branding", key: "name", value: "My NotionX Site", type: "text" },
1860
+ { name: "Tagline", section: "branding", key: "tagline", value: "Built with NotionX", type: "text" },
1861
+ { name: "Description", section: "branding", key: "description", value: "", type: "text" },
1862
+ { name: "Meta Title", section: "seo", key: "metaTitle", value: "", type: "text" },
1863
+ { name: "Meta Description", section: "seo", key: "metaDescription", value: "", type: "text" },
1864
+ { name: "Social Image", section: "seo", key: "socialImage", value: "", type: "url" },
1865
+ { name: "OG Image", section: "seo", key: "ogImage", value: "", type: "url" },
1866
+ { name: "Primary Color", section: "theme", key: "primaryColor", value: "blue", type: "select" },
1867
+ { name: "Accent Color", section: "theme", key: "accentColor", value: "green", type: "select" },
1868
+ { name: "Font Family", section: "theme", key: "fontFamily", value: "Inter", type: "select" },
1869
+ { name: "Nav Items", section: "nav", key: "items", value: "[]", type: "json" },
1870
+ { name: "Nav CTA", section: "nav", key: "cta", value: "{}", type: "json" },
1871
+ { name: "Footer Columns", section: "footer", key: "columns", value: "[]", type: "json" },
1872
+ { name: "Footer Copyright", section: "footer", key: "copyright", value: "", type: "text" },
1873
+ { name: "Footer Social Links", section: "footer", key: "socialLinks", value: "[]", type: "json" },
1874
+ { name: "Footer Tagline", section: "footer", key: "footerTagline", value: "", type: "text" },
1875
+ ];
1876
+ export function buildSiteSettingsSeedPages(input) {
1828
1877
  const defaultNav = JSON.stringify([
1829
1878
  { label: "Home", href: "/" },
1830
1879
  { label: "About", href: "/about" },
@@ -1850,57 +1899,53 @@ export function buildSiteSettingsSeedPage(input) {
1850
1899
  const footerCopyright = `© ${new Date().getFullYear()} ${input.projectName}`;
1851
1900
  const socialImageUrl = `https://picsum.photos/seed/${slugify(input.projectName)}-social/1200/630`;
1852
1901
  const tagline = `${input.projectName} on Notion and Cloudflare`;
1853
- return {
1902
+ // Override seed defaults with project-specific values where
1903
+ // available so the very first request after scaffolding already
1904
+ // shows the right site name everywhere.
1905
+ const valueOverrides = {
1906
+ name: input.projectName,
1907
+ tagline,
1908
+ description: input.description,
1909
+ metaTitle: input.projectName,
1910
+ metaDescription: input.description,
1911
+ socialImage: socialImageUrl,
1912
+ ogImage: socialImageUrl,
1913
+ items: defaultNav,
1914
+ cta: "{}",
1915
+ columns: defaultFooterColumns,
1916
+ copyright: footerCopyright,
1917
+ socialLinks: "[]",
1918
+ footerTagline: tagline,
1919
+ };
1920
+ return SETTINGS_SEED_ROWS.map((row) => ({
1854
1921
  parent: { type: "data_source_id", data_source_id: input.dataSourceId },
1855
1922
  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 } }],
1923
+ Name: {
1924
+ title: [{ text: { content: row.name } }],
1870
1925
  },
1871
- "Meta Description": {
1872
- rich_text: [{ text: { content: input.description } }],
1926
+ Section: {
1927
+ select: { name: row.section },
1873
1928
  },
1874
- "Social Image": { url: socialImageUrl },
1875
- "OG Image": { url: socialImageUrl },
1876
- Nav: {
1877
- rich_text: [{ text: { content: defaultNav } }],
1929
+ Key: {
1930
+ rich_text: [{ text: { content: row.key } }],
1878
1931
  },
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: "[]" } }],
1932
+ Value: {
1933
+ rich_text: [
1934
+ { text: { content: valueOverrides[row.key] ?? row.value } },
1935
+ ],
1891
1936
  },
1892
- "Footer Tagline": {
1893
- rich_text: [{ text: { content: tagline } }],
1937
+ Type: {
1938
+ select: { name: row.type },
1894
1939
  },
1940
+ Published: { checkbox: true },
1895
1941
  },
1896
- };
1942
+ }));
1897
1943
  }
1898
1944
  /**
1899
1945
  * 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.
1946
+ * insert the seed rows — one page per setting item. Same Notion API
1947
+ * dance as `ensureNotionDatabase`, but with multi-row seeding so the
1948
+ * home page works before the operator has opened Notion.
1904
1949
  */
1905
1950
  export async function ensureSiteSettingsDatabase(input) {
1906
1951
  const stableKey = "site-settings";
@@ -1920,7 +1965,7 @@ export async function ensureSiteSettingsDatabase(input) {
1920
1965
  title,
1921
1966
  }));
1922
1967
  if (existing) {
1923
- // Make sure the schema has every property the 0.5.4+ schema
1968
+ // Make sure the schema has every property the current schema
1924
1969
  // expects. Notion adds new properties with no destructive
1925
1970
  // effect on existing rows.
1926
1971
  await ensureDataSourceProperties({
@@ -1958,24 +2003,132 @@ export async function ensureSiteSettingsDatabase(input) {
1958
2003
  existingDescription: "",
1959
2004
  stableKey,
1960
2005
  });
1961
- // 4) Seed the singleton row.
1962
- const seed = buildSiteSettingsSeedPage({
2006
+ // 4) Seed one page per setting item.
2007
+ const seeds = buildSiteSettingsSeedPages({
1963
2008
  projectName: input.projectName,
1964
2009
  description: input.description,
1965
2010
  defaultLocale: input.defaultLocale,
1966
2011
  dataSourceId,
1967
2012
  });
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}`);
2013
+ let seeded = 0;
2014
+ for (const seed of seeds) {
2015
+ const seedResult = await runNtn(["api", "v1/pages", "-d", JSON.stringify(seed)], { env: { NOTION_API_TOKEN: input.apiToken } });
2016
+ if (seedResult.code !== 0) {
2017
+ const detail = (seedResult.stderr || seedResult.stdout).trim().slice(0, 500);
2018
+ console.warn(`[notion site-settings seed] failed (code ${seedResult.code}): ${detail}`);
2019
+ }
2020
+ else {
2021
+ seeded++;
2022
+ }
1972
2023
  }
1973
2024
  return {
1974
2025
  dataSourceId,
1975
2026
  databaseId,
1976
2027
  url,
1977
2028
  reused: false,
1978
- seeded: seedResult.code === 0 ? 1 : 0,
2029
+ seeded,
2030
+ };
2031
+ }
2032
+ /**
2033
+ * Build the Notion `properties` object for the blog-translations
2034
+ * data source. Mirrors `blogTranslationFields` in
2035
+ * `@notionx/core/locale-contract/built-in`.
2036
+ *
2037
+ * `Source` is a relation to the base blog data source so each
2038
+ * translation row points at its base post.
2039
+ */
2040
+ export function buildBlogTranslationProperties(baseDatabaseId) {
2041
+ return {
2042
+ Title: { title: {} },
2043
+ Source: baseDatabaseId
2044
+ ? { relation: { single_property: { database_id: baseDatabaseId } } }
2045
+ : { relation: { database_property: {} } },
2046
+ Locale: { select: {} },
2047
+ Slug: { rich_text: {} },
2048
+ Description: { rich_text: {} },
2049
+ "SEO Title": { rich_text: {} },
2050
+ "SEO Description": { rich_text: {} },
2051
+ // Body content lives in the translation page's children blocks,
2052
+ // not a rich_text field — removes the 2000-char limit.
2053
+ Published: { checkbox: {} },
1979
2054
  };
1980
2055
  }
2056
+ export function buildPageTranslationProperties(baseDatabaseId) {
2057
+ return {
2058
+ Title: { title: {} },
2059
+ Source: baseDatabaseId
2060
+ ? { relation: { single_property: { database_id: baseDatabaseId } } }
2061
+ : { relation: { database_property: {} } },
2062
+ Locale: { select: {} },
2063
+ Slug: { rich_text: {} },
2064
+ Description: { rich_text: {} },
2065
+ "SEO Title": { rich_text: {} },
2066
+ "SEO Description": { rich_text: {} },
2067
+ "Nav Label": { rich_text: {} },
2068
+ "Footer Label": { rich_text: {} },
2069
+ // Body content lives in the translation page's children blocks,
2070
+ // not a rich_text field — removes the 2000-char limit.
2071
+ Published: { checkbox: {} },
2072
+ };
2073
+ }
2074
+ export function buildBlockTranslationProperties(baseDatabaseId) {
2075
+ return {
2076
+ Title: { title: {} },
2077
+ Source: baseDatabaseId
2078
+ ? { relation: { single_property: { database_id: baseDatabaseId } } }
2079
+ : { relation: { database_property: {} } },
2080
+ Locale: { select: {} },
2081
+ // Body content lives in the translation page's children blocks,
2082
+ // not a rich_text field — removes the 2000-char limit.
2083
+ Published: { checkbox: {} },
2084
+ };
2085
+ }
2086
+ export function buildSiteSettingsTranslationProperties(baseDatabaseId) {
2087
+ return {
2088
+ Title: { title: {} },
2089
+ Source: baseDatabaseId
2090
+ ? { relation: { single_property: { database_id: baseDatabaseId } } }
2091
+ : { relation: { database_property: {} } },
2092
+ Locale: { select: {} },
2093
+ Value: { rich_text: {} },
2094
+ Published: { checkbox: {} },
2095
+ };
2096
+ }
2097
+ export async function ensureTranslationDatabase(input) {
2098
+ const title = titleForTranslationModel(input.modelId);
2099
+ const properties = propertiesForTranslationModel(input.modelId, input.baseDatabaseId);
2100
+ const result = await createDatabaseWithProperties({
2101
+ apiToken: input.apiToken,
2102
+ parentPageId: input.parentPageId,
2103
+ title,
2104
+ properties,
2105
+ });
2106
+ return {
2107
+ databaseId: result.databaseId,
2108
+ dataSourceId: result.dataSourceId,
2109
+ url: result.url,
2110
+ reused: false,
2111
+ };
2112
+ }
2113
+ function titleForTranslationModel(modelId) {
2114
+ const map = {
2115
+ "blog-translations": "Blog Translations",
2116
+ "page-translations": "Page Translations",
2117
+ "block-translations": "Block Translations",
2118
+ "site-settings-translations": "Site Settings Translations",
2119
+ };
2120
+ return map[modelId];
2121
+ }
2122
+ function propertiesForTranslationModel(modelId, baseDatabaseId) {
2123
+ switch (modelId) {
2124
+ case "blog-translations":
2125
+ return buildBlogTranslationProperties(baseDatabaseId);
2126
+ case "page-translations":
2127
+ return buildPageTranslationProperties(baseDatabaseId);
2128
+ case "block-translations":
2129
+ return buildBlockTranslationProperties(baseDatabaseId);
2130
+ case "site-settings-translations":
2131
+ return buildSiteSettingsTranslationProperties(baseDatabaseId);
2132
+ }
2133
+ }
1981
2134
  //# sourceMappingURL=notion.js.map