@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
@@ -1,14 +1,19 @@
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
12
  type SitePageBlockRef,
10
13
  } from "@notionx/core/pages";
11
14
  import { blocksSource, contentSources } from "@/lib/content/models";
15
+ import { getRequestLocale } from "@/lib/site/request-env";
16
+ import { i18n } from "@/lib/i18n";
12
17
  import { pageFields, pagesDataSourceEnv } from "./model";
13
18
 
14
19
  const pagesModel = {
@@ -35,167 +40,102 @@ const fallbackBlocks = (text: string): NotionBlock[] => [
35
40
  },
36
41
  ];
37
42
 
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";
98
- 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";
43
+ export type StructuredPageBlock = {
111
44
  slug: string;
112
- title: string;
113
- description: string;
114
45
  variant: string;
46
+ order: number;
115
47
  coverImage: string | null;
116
48
  editUrl: string | null;
117
49
  blocks: NotionBlock[];
118
50
  };
119
51
 
120
- export type StructuredPageBlock =
121
- | StructuredHeroBlock
122
- | StructuredFeatureGridBlock
123
- | StructuredStoryBlock
124
- | StructuredLatestPostsBlock
125
- | LegacyStructuredPageBlock;
126
-
127
52
  export type SitePage = Omit<BaseSitePage, "structuredBlocks"> & {
128
53
  structuredBlocks: StructuredPageBlock[];
129
54
  };
130
55
 
131
- const fallbackStructuredBlocks: Record<
132
- string,
133
- Exclude<StructuredPageBlock, LegacyStructuredPageBlock>
134
- > = {
56
+ const fallbackStructuredBlocks: Record<string, StructuredPageBlock> = {
135
57
  "home-hero": {
136
- type: "hero",
137
58
  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",
59
+ variant: "hero",
60
+ order: 10,
149
61
  coverImage: null,
150
62
  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: [
63
+ blocks: [
163
64
  {
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",
65
+ id: "fallback-hero-heading",
66
+ type: "heading_1",
67
+ heading_1: {
68
+ rich_text: [
69
+ {
70
+ type: "text",
71
+ text: { content: "Start with a homepage you can keep editing" },
72
+ plain_text: "Start with a homepage you can keep editing",
73
+ },
74
+ ],
75
+ },
175
76
  },
176
77
  {
177
- title: "{{contentSourceListTitle}} updates",
178
- description:
179
- "Publish new entries and surface them through the generated routes automatically.",
180
- icon: "newspaper",
181
- href: "{{contentSourceListPath}}",
78
+ id: "fallback-hero-sub",
79
+ type: "paragraph",
80
+ paragraph: {
81
+ rich_text: [
82
+ {
83
+ type: "text",
84
+ text: {
85
+ content:
86
+ "Keep the layout stable in code while the hero copy, positioning, and primary call to action evolve in Notion.",
87
+ },
88
+ plain_text:
89
+ "Keep the layout stable in code while the hero copy, positioning, and primary call to action evolve in Notion.",
90
+ },
91
+ ],
92
+ },
182
93
  },
183
94
  ],
95
+ },
96
+ "home-feature-grid": {
97
+ slug: "home-feature-grid",
98
+ variant: "feature-grid",
99
+ order: 20,
184
100
  coverImage: null,
185
101
  editUrl: null,
102
+ blocks: [
103
+ {
104
+ id: "fallback-feature-heading",
105
+ type: "heading_2",
106
+ heading_2: {
107
+ rich_text: [
108
+ {
109
+ type: "text",
110
+ text: { content: "Show the system working together" },
111
+ plain_text: "Show the system working together",
112
+ },
113
+ ],
114
+ },
115
+ },
116
+ ],
186
117
  },
187
118
  "home-latest-posts": {
188
- type: "latest-posts",
189
119
  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}}" },
120
+ variant: "latest-posts",
121
+ order: 30,
197
122
  coverImage: null,
198
123
  editUrl: null,
124
+ blocks: [
125
+ {
126
+ id: "fallback-latest-heading",
127
+ type: "heading_2",
128
+ heading_2: {
129
+ rich_text: [
130
+ {
131
+ type: "text",
132
+ text: { content: "Read the latest from the blog" },
133
+ plain_text: "Read the latest from the blog",
134
+ },
135
+ ],
136
+ },
137
+ },
138
+ ],
199
139
  },
200
140
  };
201
141
 
@@ -226,9 +166,9 @@ const fallbackPages: BaseSitePage[] = [
226
166
  coverImage: null,
227
167
  editUrl: null,
228
168
  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 },
169
+ { slug: "home-hero" },
170
+ { slug: "home-feature-grid" },
171
+ { slug: "home-latest-posts" },
232
172
  ],
233
173
  blocks: fallbackBlocks("Configure Notion Pages to edit this homepage from Notion."),
234
174
  },
@@ -320,241 +260,103 @@ const pagesApi = createSitePagesApi({
320
260
  });
321
261
 
322
262
  function fallbackPageBlock(ref: SitePageBlockRef): StructuredPageBlock | null {
263
+ if (!ref.slug) return null;
323
264
  const fallback = fallbackStructuredBlocks[ref.slug];
324
265
  if (!fallback) return null;
325
266
  return fallback;
326
267
  }
327
268
 
328
- function fallbackToLegacyNotionBlocks(
269
+ function mapGenericBlockToStructuredBlock(
329
270
  detail: GenericContentDetail,
330
- ref: SitePageBlockRef
331
- ): LegacyStructuredPageBlock | null {
332
- if (!detail.blocks.length) return null;
271
+ ref: SitePageBlockRef,
272
+ index: number
273
+ ): StructuredPageBlock {
274
+ const typeValue = detail.properties.type;
275
+ const variant =
276
+ (typeof typeValue === "string" ? typeValue.trim() : "") ||
277
+ ref.variant ||
278
+ "legacy";
279
+ const orderValue = detail.properties.order;
280
+ const order =
281
+ typeof orderValue === "number" && Number.isFinite(orderValue)
282
+ ? orderValue
283
+ : index;
333
284
  return {
334
- type: "legacy",
335
285
  slug: detail.slug,
336
- title: detail.title,
337
- description: detail.description,
338
- variant: ref.variant ?? "story",
286
+ variant,
287
+ order,
339
288
  coverImage: detail.coverImage,
340
289
  editUrl: detail.editUrl,
341
290
  blocks: detail.blocks,
342
291
  };
343
292
  }
344
293
 
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
294
  async function resolveStructuredBlock(
429
- ref: SitePageBlockRef
295
+ ref: SitePageBlockRef,
296
+ locale: string,
297
+ index: number
430
298
  ): 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
- };
299
+ // Try by pageId first (from Notion relation — preferred path).
300
+ if (ref.pageId) {
301
+ const detail = await getGenericNotionContentByIdForLocale(
302
+ blocksSource,
303
+ ref.pageId,
304
+ locale
305
+ );
306
+ if (detail) return mapGenericBlockToStructuredBlock(detail, ref, index);
505
307
  }
506
308
 
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
- };
309
+ // Try by slug (fallback or legacy rich_text JSON).
310
+ if (ref.slug) {
311
+ const detail = await getGenericNotionContentBySlugForLocale(
312
+ blocksSource,
313
+ ref.slug,
314
+ locale
315
+ );
316
+ if (detail) return mapGenericBlockToStructuredBlock(detail, ref, index);
317
+ return fallbackPageBlock(ref);
525
318
  }
526
319
 
527
- return fallbackToLegacyNotionBlocks(detail, ref);
320
+ return null;
528
321
  }
529
322
 
530
- export async function getPageBlocks(page: BaseSitePage | null) {
323
+ export async function getPageBlocks(page: BaseSitePage | null, locale?: string) {
531
324
  if (!page) return [];
325
+ const resolvedLocale = locale ?? getRequestLocale(i18n.defaultLocale);
532
326
  const resolved = await Promise.all(
533
- (page.structuredBlocks ?? []).map((ref) => resolveStructuredBlock(ref))
327
+ (page.structuredBlocks ?? []).map((ref, index) =>
328
+ resolveStructuredBlock(ref, resolvedLocale, index)
329
+ )
534
330
  );
535
331
  return resolved.filter((block): block is StructuredPageBlock => Boolean(block));
536
332
  }
537
333
 
538
- async function withStructuredBlocks(page: BaseSitePage | null): Promise<SitePage | null> {
334
+ async function withStructuredBlocks(
335
+ page: BaseSitePage | null,
336
+ locale?: string
337
+ ): Promise<SitePage | null> {
539
338
  if (!page) return null;
540
339
  return {
541
340
  ...page,
542
- structuredBlocks: await getPageBlocks(page),
341
+ structuredBlocks: await getPageBlocks(page, locale),
543
342
  };
544
343
  }
545
344
 
546
- export async function listSitePages() {
547
- const pages = await pagesApi.listSitePages();
548
- const resolved = await Promise.all(pages.map((page) => withStructuredBlocks(page)));
345
+ export async function listSitePages(locale?: string) {
346
+ const resolvedLocale = locale ?? getRequestLocale(i18n.defaultLocale);
347
+ const pages = await pagesApi.listSitePages(resolvedLocale);
348
+ const resolved = await Promise.all(pages.map((page) => withStructuredBlocks(page, resolvedLocale)));
549
349
  return resolved.filter((page): page is SitePage => Boolean(page));
550
350
  }
551
351
 
552
- export async function getSitePageByKey(key: string) {
553
- return withStructuredBlocks(await pagesApi.getSitePageByKey(key));
352
+ export async function getSitePageByKey(key: string, locale?: string) {
353
+ const resolvedLocale = locale ?? getRequestLocale(i18n.defaultLocale);
354
+ return withStructuredBlocks(await pagesApi.getSitePageByKey(key, resolvedLocale), resolvedLocale);
554
355
  }
555
356
 
556
- export async function getSitePageBySlug(slug: string) {
557
- return withStructuredBlocks(await pagesApi.getSitePageBySlug(slug));
357
+ export async function getSitePageBySlug(slug: string, locale?: string) {
358
+ const resolvedLocale = locale ?? getRequestLocale(i18n.defaultLocale);
359
+ return withStructuredBlocks(await pagesApi.getSitePageBySlug(slug, resolvedLocale), resolvedLocale);
558
360
  }
559
361
  export const getSitePageForContentSource = pagesApi.getSitePageForContentSource;
560
362
  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
+ }
@@ -46,6 +46,12 @@ import { AsyncLocalStorage } from "node:async_hooks";
46
46
  */
47
47
  export interface RequestEnv {
48
48
  CONTENT_CACHE?: KVNamespace;
49
+ /**
50
+ * Locale resolved by the middleware from the `/{locale}` path
51
+ * prefix. Server components and data loaders read this via
52
+ * `getRequestLocale()` to pick the right translation rows.
53
+ */
54
+ NOTIONX_LOCALE?: string;
49
55
  // Extend as new runtime consumers need to read env outside the
50
56
  // worker entry (e.g. cron-triggered helpers).
51
57
  }
@@ -69,3 +75,13 @@ export function runWithRequestEnv<T>(env: RequestEnv, fn: () => T): T {
69
75
  export function getRequestEnv(): RequestEnv | undefined {
70
76
  return _envStore.getStore();
71
77
  }
78
+
79
+ /**
80
+ * Read the locale for the current request, set by the locale prefix
81
+ * middleware. Falls back to the default locale when called outside a
82
+ * request scope (build scripts, tests) or when the middleware did
83
+ * not run (e.g. single-locale projects).
84
+ */
85
+ export function getRequestLocale(defaultLocale = "en"): string {
86
+ return getRequestEnv()?.NOTIONX_LOCALE ?? defaultLocale;
87
+ }