@notionx/create-notionx-app 1.0.0 → 2.0.1

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 (63) 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 +19 -3
  52. package/dist/templates/lib/content/models.ts.tmpl +6 -0
  53. package/dist/templates/lib/locale-contract/index.ts.tmpl +1 -1
  54. package/dist/templates/lib/pages/source.ts.tmpl +140 -335
  55. package/dist/templates/lib/pages/translations.ts.tmpl +23 -1
  56. package/dist/templates/lib/search/config.ts.tmpl +17 -4
  57. package/dist/templates/lib/site/request-env.ts.tmpl +21 -3
  58. package/dist/templates/lib/site/settings.ts.tmpl +99 -179
  59. package/dist/templates/lib/site/translations.ts.tmpl +34 -11
  60. package/dist/templates/middleware.ts.tmpl +56 -0
  61. package/dist/templates/worker/index.ts.tmpl +14 -5
  62. package/dist/templates/wrangler.jsonc.tmpl +5 -1
  63. package/package.json +1 -1
@@ -1,14 +1,18 @@
1
1
  import {
2
- getGenericNotionContentBySlug,
3
2
  type GenericContentDetail,
4
3
  type NotionBlock,
5
4
  } from "@notionx/core/notion";
5
+ import {
6
+ getGenericNotionContentBySlugForLocale,
7
+ getGenericNotionContentByIdForLocale,
8
+ } from "@notionx/core";
6
9
  import {
7
10
  createSitePagesApi,
8
11
  type SitePage as BaseSitePage,
9
- type SitePageBlockRef,
10
12
  } from "@notionx/core/pages";
11
13
  import { blocksSource, contentSources } from "@/lib/content/models";
14
+ import { getRequestLocale } from "@/lib/site/request-env";
15
+ import { i18n } from "@/lib/i18n";
12
16
  import { pageFields, pagesDataSourceEnv } from "./model";
13
17
 
14
18
  const pagesModel = {
@@ -35,167 +39,106 @@ const fallbackBlocks = (text: string): NotionBlock[] => [
35
39
  },
36
40
  ];
37
41
 
38
- type BlockPropertyValue = string | string[] | number | boolean;
39
-
40
- type BlockCta = {
41
- label: string;
42
- href: string;
43
- };
44
-
45
- type FeatureGridItem = {
46
- title: string;
47
- description: string;
48
- icon: string;
49
- href?: string;
50
- };
51
-
52
- export type StructuredHeroBlock = {
53
- type: "hero";
54
- slug: string;
55
- title: string;
56
- description: string;
57
- eyebrow: string;
58
- headline: string;
59
- subheadline: string;
60
- primaryCta: BlockCta | null;
61
- secondaryCta: BlockCta | null;
62
- alignment: "left" | "center";
63
- theme: "default" | "muted" | "inverse";
64
- coverImage: string | null;
65
- editUrl: string | null;
66
- };
67
-
68
- export type StructuredFeatureGridBlock = {
69
- type: "feature-grid";
70
- slug: string;
71
- title: string;
72
- description: string;
73
- headline: string;
74
- body: string;
75
- columns: 2 | 3 | 4;
76
- items: FeatureGridItem[];
77
- coverImage: string | null;
78
- editUrl: string | null;
79
- };
80
-
81
- export type StructuredStoryBlock = {
82
- type: "story";
83
- slug: string;
84
- title: string;
85
- description: string;
86
- headline: string;
87
- body: string;
88
- quote: string;
89
- quoteAttribution: string;
90
- mediaUrl: string | null;
91
- layout: "text-left" | "media-left" | "media-right";
92
- coverImage: string | null;
93
- editUrl: string | null;
94
- };
95
-
96
- export type StructuredLatestPostsBlock = {
97
- type: "latest-posts";
42
+ export type StructuredPageBlock = {
98
43
  slug: string;
99
- title: string;
100
- description: string;
101
- headline: string;
102
- body: string;
103
- count: number;
104
- primaryCta: BlockCta | null;
105
- coverImage: string | null;
106
- editUrl: string | null;
107
- };
108
-
109
- export type LegacyStructuredPageBlock = {
110
- type: "legacy";
111
- slug: string;
112
- title: string;
113
- description: string;
114
44
  variant: string;
45
+ order: number;
115
46
  coverImage: string | null;
116
47
  editUrl: string | null;
117
48
  blocks: NotionBlock[];
118
49
  };
119
50
 
120
- export type StructuredPageBlock =
121
- | StructuredHeroBlock
122
- | StructuredFeatureGridBlock
123
- | StructuredStoryBlock
124
- | StructuredLatestPostsBlock
125
- | LegacyStructuredPageBlock;
126
-
127
51
  export type SitePage = Omit<BaseSitePage, "structuredBlocks"> & {
128
52
  structuredBlocks: StructuredPageBlock[];
129
53
  };
130
54
 
131
- const fallbackStructuredBlocks: Record<
132
- string,
133
- Exclude<StructuredPageBlock, LegacyStructuredPageBlock>
134
- > = {
55
+ // `SitePageBlockRef` is not exported from `@notionx/core/pages`,
56
+ // but it is the element type of `SitePage.structuredBlocks`.
57
+ type SitePageBlockRef = BaseSitePage["structuredBlocks"][number];
58
+
59
+ const fallbackStructuredBlocks: Record<string, StructuredPageBlock> = {
135
60
  "home-hero": {
136
- type: "hero",
137
61
  slug: "home-hero",
138
- title: "Homepage Hero",
139
- description:
140
- "Editable hero module seeded into the reusable blocks source for the homepage.",
141
- eyebrow: "Notion + Cloudflare",
142
- headline: "Start with a homepage you can keep editing",
143
- subheadline:
144
- "Keep the layout stable in code while the hero copy, positioning, and primary call to action evolve in Notion.",
145
- primaryCta: { label: "Explore the blog", href: "{{contentSourceListPath}}" },
146
- secondaryCta: { label: "Read the story", href: "/about" },
147
- alignment: "center",
148
- theme: "muted",
62
+ variant: "hero",
63
+ order: 10,
149
64
  coverImage: null,
150
65
  editUrl: null,
151
- },
152
- "home-feature-grid": {
153
- type: "feature-grid",
154
- slug: "home-feature-grid",
155
- title: "Homepage Feature Grid",
156
- description:
157
- "Reusable mid-page section for capabilities, benefits, or service pillars.",
158
- headline: "Show the system working together",
159
- body:
160
- "Use this grid to explain how editing, infrastructure, and publishing fit together without overwhelming the homepage.",
161
- columns: 3,
162
- items: [
66
+ blocks: [
163
67
  {
164
- title: "Editorial workflows",
165
- description:
166
- "Use Notion as the editor for pages, posts, and reusable sections.",
167
- icon: "pen-square",
168
- href: "/about",
169
- },
170
- {
171
- title: "Cloudflare runtime",
172
- description:
173
- "Ship on Workers with storage and caching primitives ready to grow.",
174
- icon: "cloud",
68
+ id: "fallback-hero-heading",
69
+ type: "heading_1",
70
+ heading_1: {
71
+ rich_text: [
72
+ {
73
+ type: "text",
74
+ text: { content: "Start with a homepage you can keep editing" },
75
+ plain_text: "Start with a homepage you can keep editing",
76
+ },
77
+ ],
78
+ },
175
79
  },
176
80
  {
177
- title: "{{contentSourceListTitle}} updates",
178
- description:
179
- "Publish new entries and surface them through the generated routes automatically.",
180
- icon: "newspaper",
181
- href: "{{contentSourceListPath}}",
81
+ id: "fallback-hero-sub",
82
+ type: "paragraph",
83
+ paragraph: {
84
+ rich_text: [
85
+ {
86
+ type: "text",
87
+ text: {
88
+ content:
89
+ "Keep the layout stable in code while the hero copy, positioning, and primary call to action evolve in Notion.",
90
+ },
91
+ plain_text:
92
+ "Keep the layout stable in code while the hero copy, positioning, and primary call to action evolve in Notion.",
93
+ },
94
+ ],
95
+ },
182
96
  },
183
97
  ],
98
+ },
99
+ "home-feature-grid": {
100
+ slug: "home-feature-grid",
101
+ variant: "feature-grid",
102
+ order: 20,
184
103
  coverImage: null,
185
104
  editUrl: null,
105
+ blocks: [
106
+ {
107
+ id: "fallback-feature-heading",
108
+ type: "heading_2",
109
+ heading_2: {
110
+ rich_text: [
111
+ {
112
+ type: "text",
113
+ text: { content: "Show the system working together" },
114
+ plain_text: "Show the system working together",
115
+ },
116
+ ],
117
+ },
118
+ },
119
+ ],
186
120
  },
187
121
  "home-latest-posts": {
188
- type: "latest-posts",
189
122
  slug: "home-latest-posts",
190
- title: "Homepage Latest Posts",
191
- description: "Reusable homepage section for surfacing the latest published posts.",
192
- headline: "Read the latest from the blog",
193
- body:
194
- "Use this section to prove the content model is working with a grid of recent published posts right on the homepage.",
195
- count: 6,
196
- primaryCta: { label: "View all posts", href: "{{contentSourceListPath}}" },
123
+ variant: "latest-posts",
124
+ order: 30,
197
125
  coverImage: null,
198
126
  editUrl: null,
127
+ blocks: [
128
+ {
129
+ id: "fallback-latest-heading",
130
+ type: "heading_2",
131
+ heading_2: {
132
+ rich_text: [
133
+ {
134
+ type: "text",
135
+ text: { content: "Read the latest from the blog" },
136
+ plain_text: "Read the latest from the blog",
137
+ },
138
+ ],
139
+ },
140
+ },
141
+ ],
199
142
  },
200
143
  };
201
144
 
@@ -226,9 +169,9 @@ const fallbackPages: BaseSitePage[] = [
226
169
  coverImage: null,
227
170
  editUrl: null,
228
171
  structuredBlocks: [
229
- { slug: "home-hero", variant: "hero", order: 10 },
230
- { slug: "home-feature-grid", variant: "feature-grid", order: 20 },
231
- { slug: "home-latest-posts", order: 30 },
172
+ { slug: "home-hero" },
173
+ { slug: "home-feature-grid" },
174
+ { slug: "home-latest-posts" },
232
175
  ],
233
176
  blocks: fallbackBlocks("Configure Notion Pages to edit this homepage from Notion."),
234
177
  },
@@ -320,241 +263,103 @@ const pagesApi = createSitePagesApi({
320
263
  });
321
264
 
322
265
  function fallbackPageBlock(ref: SitePageBlockRef): StructuredPageBlock | null {
266
+ if (!ref.slug) return null;
323
267
  const fallback = fallbackStructuredBlocks[ref.slug];
324
268
  if (!fallback) return null;
325
269
  return fallback;
326
270
  }
327
271
 
328
- function fallbackToLegacyNotionBlocks(
272
+ function mapGenericBlockToStructuredBlock(
329
273
  detail: GenericContentDetail,
330
- ref: SitePageBlockRef
331
- ): LegacyStructuredPageBlock | null {
332
- if (!detail.blocks.length) return null;
274
+ ref: SitePageBlockRef,
275
+ index: number
276
+ ): StructuredPageBlock {
277
+ const typeValue = detail.properties.type;
278
+ const variant =
279
+ (typeof typeValue === "string" ? typeValue.trim() : "") ||
280
+ ref.variant ||
281
+ "legacy";
282
+ const orderValue = detail.properties.order;
283
+ const order =
284
+ typeof orderValue === "number" && Number.isFinite(orderValue)
285
+ ? orderValue
286
+ : index;
333
287
  return {
334
- type: "legacy",
335
288
  slug: detail.slug,
336
- title: detail.title,
337
- description: detail.description,
338
- variant: ref.variant ?? "story",
289
+ variant,
290
+ order,
339
291
  coverImage: detail.coverImage,
340
292
  editUrl: detail.editUrl,
341
293
  blocks: detail.blocks,
342
294
  };
343
295
  }
344
296
 
345
- function readString(value: BlockPropertyValue | undefined) {
346
- if (Array.isArray(value)) return String(value[0] ?? "").trim();
347
- if (typeof value === "string") return value.trim();
348
- return "";
349
- }
350
-
351
- function readNumber(value: BlockPropertyValue | undefined) {
352
- return typeof value === "number" && Number.isFinite(value) ? value : null;
353
- }
354
-
355
- function normalizeAlignment(value: string): StructuredHeroBlock["alignment"] {
356
- return value === "left" ? "left" : "center";
357
- }
358
-
359
- function normalizeTheme(value: string): StructuredHeroBlock["theme"] {
360
- if (value === "default" || value === "inverse") return value;
361
- return "muted";
362
- }
363
-
364
- function normalizeColumns(value: number | null): StructuredFeatureGridBlock["columns"] {
365
- if (value === 2 || value === 4) return value;
366
- return 3;
367
- }
368
-
369
- function normalizeLayout(value: string): StructuredStoryBlock["layout"] {
370
- if (value === "text-left" || value === "media-left") return value;
371
- return "media-right";
372
- }
373
-
374
- function normalizeCount(value: number | null) {
375
- if (typeof value === "number" && value > 0) return Math.min(Math.floor(value), 12);
376
- return 6;
377
- }
378
-
379
- function readCta(
380
- labelValue: BlockPropertyValue | undefined,
381
- hrefValue: BlockPropertyValue | undefined
382
- ): BlockCta | null {
383
- const label = readString(labelValue);
384
- const href = readString(hrefValue);
385
- return label && href ? { label, href } : null;
386
- }
387
-
388
- function parseFeatureGridItems(
389
- value: BlockPropertyValue | undefined
390
- ): FeatureGridItem[] {
391
- const raw = readString(value);
392
- if (!raw) return [];
393
-
394
- try {
395
- const parsed = JSON.parse(raw) as unknown;
396
- if (!Array.isArray(parsed)) return [];
397
- return parsed
398
- .map((item): FeatureGridItem | null => {
399
- if (!item || typeof item !== "object") return null;
400
- const typed = item as {
401
- title?: unknown;
402
- description?: unknown;
403
- icon?: unknown;
404
- href?: unknown;
405
- };
406
- const title = typeof typed.title === "string" ? typed.title.trim() : "";
407
- const description =
408
- typeof typed.description === "string" ? typed.description.trim() : "";
409
- if (!title || !description) return null;
410
- return {
411
- title,
412
- description,
413
- icon: typeof typed.icon === "string" ? typed.icon.trim() : "sparkles",
414
- href: typeof typed.href === "string" ? typed.href.trim() : undefined,
415
- };
416
- })
417
- .filter((item): item is FeatureGridItem => Boolean(item));
418
- } catch {
419
- return [];
420
- }
421
- }
422
-
423
- function resolveBlockType(detail: GenericContentDetail, ref: SitePageBlockRef) {
424
- const fromRecord = readString(detail.properties.type);
425
- return fromRecord || ref.variant || "";
426
- }
427
-
428
297
  async function resolveStructuredBlock(
429
- ref: SitePageBlockRef
298
+ ref: SitePageBlockRef,
299
+ locale: string,
300
+ index: number
430
301
  ): Promise<StructuredPageBlock | null> {
431
- const detail = await getGenericNotionContentBySlug(blocksSource, ref.slug);
432
- if (!detail) return fallbackPageBlock(ref);
433
-
434
- return mapGenericBlockToStructuredBlock(detail, ref);
435
- }
436
-
437
- function mapGenericBlockToStructuredBlock(
438
- detail: GenericContentDetail,
439
- ref: SitePageBlockRef
440
- ): StructuredPageBlock | null {
441
- const type = resolveBlockType(detail, ref);
442
-
443
- if (type === "hero") {
444
- const headline = readString(detail.properties.headline);
445
- if (!headline) return fallbackToLegacyNotionBlocks(detail, ref);
446
- return {
447
- type: "hero",
448
- slug: detail.slug,
449
- title: detail.title,
450
- description: detail.description,
451
- eyebrow: readString(detail.properties.eyebrow),
452
- headline,
453
- subheadline: readString(detail.properties.subheadline),
454
- primaryCta: readCta(
455
- detail.properties.primaryCtaLabel,
456
- detail.properties.primaryCtaHref
457
- ),
458
- secondaryCta: readCta(
459
- detail.properties.secondaryCtaLabel,
460
- detail.properties.secondaryCtaHref
461
- ),
462
- alignment: normalizeAlignment(readString(detail.properties.alignment)),
463
- theme: normalizeTheme(readString(detail.properties.theme)),
464
- coverImage: detail.coverImage,
465
- editUrl: detail.editUrl,
466
- };
467
- }
468
-
469
- if (type === "feature-grid") {
470
- const headline = readString(detail.properties.headline);
471
- const items = parseFeatureGridItems(detail.properties.items);
472
- if (!headline || !items.length) return fallbackToLegacyNotionBlocks(detail, ref);
473
- return {
474
- type: "feature-grid",
475
- slug: detail.slug,
476
- title: detail.title,
477
- description: detail.description,
478
- headline,
479
- body: readString(detail.properties.body),
480
- columns: normalizeColumns(readNumber(detail.properties.columns)),
481
- items,
482
- coverImage: detail.coverImage,
483
- editUrl: detail.editUrl,
484
- };
485
- }
486
-
487
- if (type === "story") {
488
- const headline = readString(detail.properties.headline);
489
- const body = readString(detail.properties.body);
490
- if (!headline || !body) return fallbackToLegacyNotionBlocks(detail, ref);
491
- return {
492
- type: "story",
493
- slug: detail.slug,
494
- title: detail.title,
495
- description: detail.description,
496
- headline,
497
- body,
498
- quote: readString(detail.properties.quote),
499
- quoteAttribution: readString(detail.properties.quoteAttribution),
500
- mediaUrl: readString(detail.properties.mediaUrl) || null,
501
- layout: normalizeLayout(readString(detail.properties.layout)),
502
- coverImage: detail.coverImage,
503
- editUrl: detail.editUrl,
504
- };
302
+ // Try by pageId first (from Notion relation — preferred path).
303
+ if (ref.pageId) {
304
+ const detail = await getGenericNotionContentByIdForLocale(
305
+ blocksSource,
306
+ ref.pageId,
307
+ locale
308
+ );
309
+ if (detail) return mapGenericBlockToStructuredBlock(detail, ref, index);
505
310
  }
506
311
 
507
- if (type === "latest-posts") {
508
- const headline = readString(detail.properties.headline);
509
- if (!headline) return fallbackToLegacyNotionBlocks(detail, ref);
510
- return {
511
- type: "latest-posts",
512
- slug: detail.slug,
513
- title: detail.title,
514
- description: detail.description,
515
- headline,
516
- body: readString(detail.properties.body),
517
- count: normalizeCount(readNumber(detail.properties.count)),
518
- primaryCta: readCta(
519
- detail.properties.primaryCtaLabel,
520
- detail.properties.primaryCtaHref
521
- ),
522
- coverImage: detail.coverImage,
523
- editUrl: detail.editUrl,
524
- };
312
+ // Try by slug (fallback or legacy rich_text JSON).
313
+ if (ref.slug) {
314
+ const detail = await getGenericNotionContentBySlugForLocale(
315
+ blocksSource,
316
+ ref.slug,
317
+ locale
318
+ );
319
+ if (detail) return mapGenericBlockToStructuredBlock(detail, ref, index);
320
+ return fallbackPageBlock(ref);
525
321
  }
526
322
 
527
- return fallbackToLegacyNotionBlocks(detail, ref);
323
+ return null;
528
324
  }
529
325
 
530
- export async function getPageBlocks(page: BaseSitePage | null) {
326
+ export async function getPageBlocks(page: BaseSitePage | null, locale?: string) {
531
327
  if (!page) return [];
328
+ const resolvedLocale = locale ?? getRequestLocale(i18n.defaultLocale);
532
329
  const resolved = await Promise.all(
533
- (page.structuredBlocks ?? []).map((ref) => resolveStructuredBlock(ref))
330
+ (page.structuredBlocks ?? []).map((ref, index) =>
331
+ resolveStructuredBlock(ref, resolvedLocale, index)
332
+ )
534
333
  );
535
334
  return resolved.filter((block): block is StructuredPageBlock => Boolean(block));
536
335
  }
537
336
 
538
- async function withStructuredBlocks(page: BaseSitePage | null): Promise<SitePage | null> {
337
+ async function withStructuredBlocks(
338
+ page: BaseSitePage | null,
339
+ locale?: string
340
+ ): Promise<SitePage | null> {
539
341
  if (!page) return null;
540
342
  return {
541
343
  ...page,
542
- structuredBlocks: await getPageBlocks(page),
344
+ structuredBlocks: await getPageBlocks(page, locale),
543
345
  };
544
346
  }
545
347
 
546
- export async function listSitePages() {
547
- const pages = await pagesApi.listSitePages();
548
- const resolved = await Promise.all(pages.map((page) => withStructuredBlocks(page)));
348
+ export async function listSitePages(locale?: string) {
349
+ const resolvedLocale = locale ?? getRequestLocale(i18n.defaultLocale);
350
+ const pages = await pagesApi.listSitePages(resolvedLocale);
351
+ const resolved = await Promise.all(pages.map((page) => withStructuredBlocks(page, resolvedLocale)));
549
352
  return resolved.filter((page): page is SitePage => Boolean(page));
550
353
  }
551
354
 
552
- export async function getSitePageByKey(key: string) {
553
- return withStructuredBlocks(await pagesApi.getSitePageByKey(key));
355
+ export async function getSitePageByKey(key: string, locale?: string) {
356
+ const resolvedLocale = locale ?? getRequestLocale(i18n.defaultLocale);
357
+ return withStructuredBlocks(await pagesApi.getSitePageByKey(key, resolvedLocale), resolvedLocale);
554
358
  }
555
359
 
556
- export async function getSitePageBySlug(slug: string) {
557
- return withStructuredBlocks(await pagesApi.getSitePageBySlug(slug));
360
+ export async function getSitePageBySlug(slug: string, locale?: string) {
361
+ const resolvedLocale = locale ?? getRequestLocale(i18n.defaultLocale);
362
+ return withStructuredBlocks(await pagesApi.getSitePageBySlug(slug, resolvedLocale), resolvedLocale);
558
363
  }
559
364
  export const getSitePageForContentSource = pagesApi.getSitePageForContentSource;
560
365
  export const getSiteNavigation = pagesApi.getSiteNavigation;
@@ -2,9 +2,15 @@
2
2
  // localized page is missing in the target locale, do not pretend the
3
3
  // page exists — let the caller render a localized not-found.
4
4
 
5
- import { pickTranslation } from "@notionx/core";
5
+ import {
6
+ pickTranslation,
7
+ mapNotionPageToLocalizedContentTranslation,
8
+ listGenericNotionContentForLocale,
9
+ } from "@notionx/core";
6
10
  import { i18n } from "@/lib/i18n";
7
11
  import { pagesContract } from "@/lib/locale-contract";
12
+ import { pageTranslationsSource } from "@/lib/content/models";
13
+ import type { NotionPageLike } from "@notionx/core";
8
14
 
9
15
  export type PageTranslation = {
10
16
  pageId: string;
@@ -32,3 +38,19 @@ export function getDefaultLocalePage(
32
38
  ): PageTranslation | null {
33
39
  return pickTranslation(rows, i18n.defaultLocale, pagesContract, i18n.defaultLocale);
34
40
  }
41
+
42
+ export async function listPageTranslations(locale: string): Promise<PageTranslation[]> {
43
+ if (!pageTranslationsSource) return [];
44
+ const pages = await listGenericNotionContentForLocale(pageTranslationsSource, locale);
45
+ return pages
46
+ .map((p) => mapNotionPageToLocalizedContentTranslation<PageTranslation>(p as unknown as NotionPageLike, {
47
+ fields: {
48
+ title: "Title",
49
+ source: "Source",
50
+ locale: "Locale",
51
+ slug: "Slug",
52
+ published: "Published",
53
+ },
54
+ }))
55
+ .filter((row): row is PageTranslation => row !== null);
56
+ }
@@ -9,15 +9,28 @@
9
9
 
10
10
  import { createD1SearchAdapter } from "@notionx/core/search";
11
11
  import type { SearchAdapter } from "@notionx/core/search";
12
+ import type { SqlDatabaseAdapter } from "@notionx/core/platform";
12
13
  import { getRequestEnv } from "../site/request-env";
13
14
 
14
15
  /**
15
- * Lazily resolve the D1 database binding from the request environment.
16
- * The adapter is created once and reused across requests.
16
+ * Lazily resolve the D1 database binding from the request environment
17
+ * and wrap it in the `SqlDatabaseAdapter` shape expected by
18
+ * `@notionx/core/search`. The adapter is created once and reused
19
+ * across requests.
17
20
  */
18
- function getDatabase() {
21
+ function getDatabase(): SqlDatabaseAdapter {
19
22
  const env = getRequestEnv();
20
- return env.DB;
23
+ if (!env?.DB) {
24
+ throw new Error(
25
+ "D1 database binding DB is not available in the request environment."
26
+ );
27
+ }
28
+ return {
29
+ kind: "d1",
30
+ prepare: (query) => env.DB!.prepare(query),
31
+ batch: (statements) =>
32
+ env.DB!.batch(statements as D1PreparedStatement[]),
33
+ };
21
34
  }
22
35
 
23
36
  export const searchAdapter: SearchAdapter = createD1SearchAdapter(getDatabase);
@@ -40,12 +40,20 @@ import { AsyncLocalStorage } from "node:async_hooks";
40
40
 
41
41
  /**
42
42
  * Shape of the Cloudflare `env` parameter as far as this project
43
- * is concerned. The KV binding is the only one we read at
44
- * runtime; other bindings (D1, R2, secrets) are accessed by the
45
- * foundation worker directly and don't need ALS propagation.
43
+ * is concerned. Bindings read outside the worker entry (e.g. D1
44
+ * from the search adapter, KV from site settings) are propagated
45
+ * through ALS; others are accessed by the foundation worker
46
+ * directly and don't need to be listed here.
46
47
  */
47
48
  export interface RequestEnv {
48
49
  CONTENT_CACHE?: KVNamespace;
50
+ DB?: D1Database;
51
+ /**
52
+ * Locale resolved by the middleware from the `/{locale}` path
53
+ * prefix. Server components and data loaders read this via
54
+ * `getRequestLocale()` to pick the right translation rows.
55
+ */
56
+ NOTIONX_LOCALE?: string;
49
57
  // Extend as new runtime consumers need to read env outside the
50
58
  // worker entry (e.g. cron-triggered helpers).
51
59
  }
@@ -69,3 +77,13 @@ export function runWithRequestEnv<T>(env: RequestEnv, fn: () => T): T {
69
77
  export function getRequestEnv(): RequestEnv | undefined {
70
78
  return _envStore.getStore();
71
79
  }
80
+
81
+ /**
82
+ * Read the locale for the current request, set by the locale prefix
83
+ * middleware. Falls back to the default locale when called outside a
84
+ * request scope (build scripts, tests) or when the middleware did
85
+ * not run (e.g. single-locale projects).
86
+ */
87
+ export function getRequestLocale(defaultLocale = "en"): string {
88
+ return getRequestEnv()?.NOTIONX_LOCALE ?? defaultLocale;
89
+ }