@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,10 +1,12 @@
1
1
  // Notion-backed site settings loader.
2
2
  //
3
- // Reads the singleton row from the `site-settings` Notion data
4
- // source (declared in `lib/content/models.ts`) and merges it with
5
- // the static fallback in `./config.ts`. The result has the same
6
- // shape as `siteConfig` so call sites don't need to know which
7
- // source actually answered.
3
+ // Reads multiple rows from the `site-settings` Notion data source
4
+ // (declared in `lib/content/models.ts`) and aggregates them into a
5
+ // single `SiteConfig`. Each row is one setting item keyed by the
6
+ // `Key` field; the runtime loader builds a `Record<string, string>`
7
+ // and maps known keys to the `SiteConfig` structure. The result is
8
+ // merged with the static fallback in `./config.ts` so any missing
9
+ // row falls back gracefully.
8
10
  //
9
11
  // Caching strategy:
10
12
  // - One KV read per request. On miss, fetch from Notion, cache
@@ -20,7 +22,7 @@
20
22
  // the KV entry. The endpoint re-uses
21
23
  // `@notionx/core/auth/routes/viewer` to authorize the caller.
22
24
  //
23
- // If the Notion data source is empty or the row can't be read,
25
+ // If the Notion data source is empty or the rows can't be read,
24
26
  // `fallbackSiteConfig` from `./config.ts` is returned unchanged.
25
27
  // The fallback is also what `getStaticSiteSettings()` returns
26
28
  // synchronously — useful for places that can't `await` (e.g.
@@ -31,6 +33,7 @@ import {
31
33
  hasNotionModelConfig,
32
34
  listGenericNotionContent,
33
35
  } from "@notionx/core/notion";
36
+ import { listGenericNotionContentForLocale } from "@notionx/core";
34
37
  import { siteSettingsSource } from "../content/models";
35
38
  import { fallbackSiteConfig, type SiteConfig } from "./config";
36
39
 
@@ -47,70 +50,22 @@ function readKv(): KVNamespace | null {
47
50
  return getRequestEnv()?.CONTENT_CACHE ?? null;
48
51
  }
49
52
 
53
+ // Mirrors `SiteConfig["navigation"]["main"]`: the JSON stored in
54
+ // Notion's "Nav" field should include `modelId` for each item.
50
55
  type RawNavItem = {
51
56
  label: string;
52
57
  href: string;
58
+ modelId: string;
53
59
  children?: RawNavItem[];
54
60
  };
55
61
 
56
- type RawSiteSettings = {
57
- name?: string;
58
- tagline?: string;
59
- description?: string;
60
- defaultLocale?: string;
61
- socialImageUrl?: string;
62
- ogImageUrl?: string;
63
- seo?: { title?: string; description?: string };
64
- navigation?: {
65
- main?: RawNavItem[];
66
- cta?: { label: string; href: string } | null;
67
- };
68
- theme?: { primary?: string; accent?: string; font?: string };
69
- footer?: {
70
- columns?: Array<{ label: string; items: Array<{ label: string; href: string }> }>;
71
- social?: Array<{ label: string; href: string }>;
72
- tagline?: string;
73
- copyright?: string;
74
- };
75
- };
76
-
77
- function readRichText(
78
- properties: Record<string, unknown>,
79
- field: string
80
- ): string {
81
- const prop = properties[field] as
82
- | { rich_text?: Array<{ plain_text?: string }> }
83
- | undefined;
84
- if (!prop?.rich_text?.length) return "";
85
- return prop.rich_text.map((t) => t.plain_text ?? "").join("").trim();
86
- }
87
-
88
- function readUrl(
89
- properties: Record<string, unknown>,
90
- field: string
91
- ): string | null {
92
- const prop = properties[field] as
93
- | { url?: string | null }
94
- | undefined;
95
- return prop?.url ?? null;
96
- }
97
-
98
- function readSelect(
99
- properties: Record<string, unknown>,
100
- field: string
101
- ): string {
102
- const prop = properties[field] as
103
- | { select?: { name?: string } | null }
104
- | undefined;
105
- return prop?.select?.name?.trim() ?? "";
62
+ function readString(value: unknown): string {
63
+ if (typeof value === "string") return value;
64
+ if (Array.isArray(value)) return value.join("");
65
+ return "";
106
66
  }
107
67
 
108
- function readJson<T>(
109
- properties: Record<string, unknown>,
110
- field: string,
111
- fallback: T
112
- ): T {
113
- const raw = readRichText(properties, field);
68
+ function parseJson<T>(raw: string, fallback: T): T {
114
69
  if (!raw) return fallback;
115
70
  try {
116
71
  return JSON.parse(raw) as T;
@@ -132,8 +87,8 @@ export function getStaticSiteSettings(): SiteConfig {
132
87
  * Resolve the current site settings.
133
88
  *
134
89
  * Order of precedence for each field:
135
- * 1. Notion row (if `site-settings` data source is reachable and
136
- * contains a row)
90
+ * 1. Notion rows (if `site-settings` data source is reachable and
91
+ * contains published rows)
137
92
  * 2. `fallbackSiteConfig` (so a Notion outage never breaks the
138
93
  * home page or SEO metadata)
139
94
  *
@@ -141,14 +96,14 @@ export function getStaticSiteSettings(): SiteConfig {
141
96
  * `site-settings:v1` for 5 minutes per process. Bump the cache key
142
97
  * suffix to invalidate globally after a breaking schema change.
143
98
  */
144
- export async function getSiteSettings(): Promise<SiteConfig> {
99
+ export async function getSiteSettings(locale?: string): Promise<SiteConfig> {
145
100
  const kv = readKv();
146
101
  if (kv) {
147
102
  const cached = await kv.get<SiteConfig>(CACHE_KEY, "json");
148
103
  if (cached) return cached;
149
104
  }
150
105
 
151
- const merged = await loadFromNotion();
106
+ const merged = await loadFromNotion(locale);
152
107
 
153
108
  if (kv) {
154
109
  // Best-effort write. KV failure shouldn't break the page.
@@ -160,7 +115,7 @@ export async function getSiteSettings(): Promise<SiteConfig> {
160
115
  return merged;
161
116
  }
162
117
 
163
- async function loadFromNotion(): Promise<SiteConfig> {
118
+ async function loadFromNotion(locale?: string): Promise<SiteConfig> {
164
119
  // `hasNotionModelConfig` reads the env from the active request
165
120
  // context. If Notion isn't configured we return the fallback
166
121
  // untouched — never throw, so the home page can't 500 because of
@@ -179,7 +134,9 @@ async function loadFromNotion(): Promise<SiteConfig> {
179
134
  ReturnType<typeof listGenericNotionContent<typeof siteSettingsSource.source.fields>>
180
135
  >;
181
136
  try {
182
- items = await listGenericNotionContent(siteSettingsSource);
137
+ items = locale
138
+ ? await listGenericNotionContentForLocale(siteSettingsSource, locale)
139
+ : await listGenericNotionContent(siteSettingsSource);
183
140
  } catch {
184
141
  return fallbackSiteConfig;
185
142
  }
@@ -187,124 +144,87 @@ async function loadFromNotion(): Promise<SiteConfig> {
187
144
  return fallbackSiteConfig;
188
145
  }
189
146
 
190
- // The first published row wins. If the operator has multiple
191
- // rows they can pick the active one by checking `Published` in
192
- // Notion; we deliberately don't try to be clever about which row
193
- // is "active" beyond that to keep the model predictable.
194
- const row = items[0];
195
- if (!row) return fallbackSiteConfig;
196
-
197
- const raw: RawSiteSettings = {
198
- name: row.title || undefined,
199
- tagline: row.properties.tagline
200
- ? Array.isArray(row.properties.tagline)
201
- ? row.properties.tagline[0]
202
- : (row.properties.tagline as string)
203
- : undefined,
204
- description: row.description || undefined,
205
- socialImageUrl: row.coverImage || undefined,
206
- };
207
-
208
- // The Notion mapper doesn't know about every field we expose
209
- // (e.g. `defaultLocale` lives in a `Select` column). Read it
210
- // straight off the raw Notion page object that
211
- // `listGenericNotionContent` returns — its `properties` map
212
- // includes everything Notion sent us, not just the mapped ones.
213
- const extra = (row as unknown as { properties?: Record<string, unknown> })
214
- .properties;
215
- if (extra && typeof extra === "object") {
216
- const defaultLocale = readSelect(extra, "Default Locale");
217
- if (defaultLocale) raw.defaultLocale = defaultLocale;
218
- const tagline = readRichText(extra, "Tagline");
219
- if (tagline) raw.tagline = tagline;
220
- const socialImage = readUrl(extra, "Social Image");
221
- if (socialImage) raw.socialImageUrl = socialImage;
222
-
223
- // SEO
224
- const metaTitle = readRichText(extra, "Meta Title");
225
- const metaDescription = readRichText(extra, "Meta Description");
226
- if (metaTitle || metaDescription) {
227
- raw.seo = {
228
- title: metaTitle || raw.name,
229
- description: metaDescription || raw.description,
230
- };
147
+ // Aggregate rows into a key-value map keyed by the `Key` field.
148
+ // Each row carries one setting item; the runtime loader merges
149
+ // them into a single `SiteConfig`.
150
+ const settingsMap: Record<string, string> = {};
151
+ for (const item of items) {
152
+ const key = readString(item.properties.key);
153
+ const value = readString(item.properties.value);
154
+ if (key) {
155
+ settingsMap[key] = value;
231
156
  }
232
- const ogImage = readUrl(extra, "OG Image");
233
- if (ogImage) raw.ogImageUrl = ogImage;
157
+ }
234
158
 
235
- // Navigation
236
- const nav = readJson<RawNavItem[]>(extra, "Nav", []);
237
- const cta = readJson<{ label: string; href: string } | null>(
238
- extra,
239
- "Nav CTA",
240
- null
241
- );
242
- raw.navigation = { main: nav, cta };
159
+ // Map known keys to the SiteConfig structure. Missing keys fall
160
+ // back to `fallbackSiteConfig`.
161
+ const name = settingsMap.name ?? fallbackSiteConfig.name;
162
+ const tagline = settingsMap.tagline ?? fallbackSiteConfig.tagline;
163
+ const description = settingsMap.description ?? fallbackSiteConfig.description;
164
+ const socialImageUrl = settingsMap.socialImage ?? fallbackSiteConfig.socialImageUrl;
165
+ const ogImageUrl = settingsMap.ogImage ?? socialImageUrl ?? fallbackSiteConfig.ogImageUrl;
166
+
167
+ const metaTitle = settingsMap.metaTitle ?? "";
168
+ const metaDescription = settingsMap.metaDescription ?? "";
169
+ const seo = {
170
+ title: metaTitle.trim() || fallbackSiteConfig.seo.title,
171
+ description: metaDescription.trim() || fallbackSiteConfig.seo.description,
172
+ };
243
173
 
244
- // Theme
245
- const primary = readSelect(extra, "Primary Color");
246
- const accent = readSelect(extra, "Accent Color");
247
- const font = readSelect(extra, "Font Family");
248
- if (primary || accent || font) {
249
- raw.theme = { primary, accent, font };
250
- }
174
+ const navItems = parseJson<RawNavItem[]>(
175
+ settingsMap.items ?? "[]",
176
+ fallbackSiteConfig.navigation.main as RawNavItem[]
177
+ );
178
+ const navCta = parseJson<{ label: string; href: string } | null>(
179
+ settingsMap.cta ?? "null",
180
+ fallbackSiteConfig.navigation.cta
181
+ );
182
+ const navigation = {
183
+ ...fallbackSiteConfig.navigation,
184
+ main: navItems.length ? navItems : fallbackSiteConfig.navigation.main,
185
+ cta: navCta ?? fallbackSiteConfig.navigation.cta,
186
+ };
251
187
 
252
- // Footer
253
- type FooterColumn = {
254
- label: string;
255
- items: Array<{ label: string; href: string }>;
256
- };
257
- const columns = readJson<FooterColumn[]>(extra, "Footer Columns", []);
258
- const social = readJson<Array<{ label: string; href: string }>>(
259
- extra,
260
- "Footer Social Links",
261
- []
262
- );
263
- const taglineFooter = readRichText(extra, "Footer Tagline");
264
- const copyright = readRichText(extra, "Footer Copyright");
265
- raw.footer = { columns, social, tagline: taglineFooter, copyright };
266
- }
188
+ const primary = (settingsMap.primaryColor as SiteConfig["theme"]["primary"]) ||
189
+ fallbackSiteConfig.theme.primary;
190
+ const accent = (settingsMap.accentColor as SiteConfig["theme"]["accent"]) ||
191
+ fallbackSiteConfig.theme.accent;
192
+ const font = (settingsMap.fontFamily as SiteConfig["theme"]["font"]) ||
193
+ fallbackSiteConfig.theme.font;
194
+ const theme = { primary, accent, font };
195
+
196
+ type FooterColumn = {
197
+ label: string;
198
+ items: Array<{ label: string; href: string }>;
199
+ };
200
+ const footerColumns = parseJson<FooterColumn[]>(
201
+ settingsMap.columns ?? "[]",
202
+ fallbackSiteConfig.footer.columns as FooterColumn[]
203
+ );
204
+ const footerSocial = parseJson<Array<{ label: string; href: string }>>(
205
+ settingsMap.socialLinks ?? "[]",
206
+ fallbackSiteConfig.footer.social
207
+ );
208
+ const footerTagline = settingsMap.footerTagline ?? fallbackSiteConfig.footer.tagline;
209
+ const footerCopyright = settingsMap.copyright ?? fallbackSiteConfig.footer.copyright;
210
+ const footer = {
211
+ columns: footerColumns,
212
+ social: footerSocial,
213
+ tagline: footerTagline,
214
+ copyright: footerCopyright,
215
+ };
267
216
 
268
217
  return {
269
218
  ...fallbackSiteConfig,
270
- name: raw.name?.trim() || fallbackSiteConfig.name,
271
- tagline: raw.tagline?.trim() || raw.name?.trim() || fallbackSiteConfig.tagline,
272
- description:
273
- raw.description?.trim() || fallbackSiteConfig.description,
274
- socialImageUrl: raw.socialImageUrl ?? fallbackSiteConfig.socialImageUrl,
275
- ogImageUrl: raw.ogImageUrl ?? raw.socialImageUrl ?? fallbackSiteConfig.ogImageUrl,
276
- defaultLocale:
277
- raw.defaultLocale?.trim() || fallbackSiteConfig.defaultLocale,
278
- seo: {
279
- title: raw.seo?.title?.trim() || fallbackSiteConfig.seo.title,
280
- description:
281
- raw.seo?.description?.trim() || fallbackSiteConfig.seo.description,
282
- },
283
- navigation: {
284
- ...fallbackSiteConfig.navigation,
285
- main: raw.navigation?.main?.length
286
- ? raw.navigation.main
287
- : fallbackSiteConfig.navigation.main,
288
- cta: raw.navigation?.cta ?? fallbackSiteConfig.navigation.cta,
289
- },
290
- theme: {
291
- primary:
292
- (raw.theme?.primary as SiteConfig["theme"]["primary"]) ??
293
- fallbackSiteConfig.theme.primary,
294
- accent:
295
- (raw.theme?.accent as SiteConfig["theme"]["accent"]) ??
296
- fallbackSiteConfig.theme.accent,
297
- font:
298
- (raw.theme?.font as SiteConfig["theme"]["font"]) ??
299
- fallbackSiteConfig.theme.font,
300
- },
301
- footer: {
302
- columns: raw.footer?.columns ?? fallbackSiteConfig.footer.columns,
303
- social: raw.footer?.social ?? fallbackSiteConfig.footer.social,
304
- tagline: raw.footer?.tagline ?? fallbackSiteConfig.footer.tagline,
305
- copyright:
306
- raw.footer?.copyright ?? fallbackSiteConfig.footer.copyright,
307
- },
219
+ name: name.trim() || fallbackSiteConfig.name,
220
+ tagline: tagline.trim() || name.trim() || fallbackSiteConfig.tagline,
221
+ description: description.trim() || fallbackSiteConfig.description,
222
+ socialImageUrl: socialImageUrl ?? fallbackSiteConfig.socialImageUrl,
223
+ ogImageUrl: ogImageUrl ?? fallbackSiteConfig.ogImageUrl,
224
+ seo,
225
+ navigation,
226
+ theme,
227
+ footer,
308
228
  };
309
229
  }
310
230
 
@@ -1,22 +1,24 @@
1
- // Locale-aware site settings merge. The `site-settings` Notion row
2
- // holds the global config; the `site-settings-translations` row
3
- // holds locale-specific copy. Missing locale copy falls back to the
4
- // default-locale translation.
1
+ // Locale-aware site settings merge. The `site-settings` Notion rows
2
+ // hold the global config (key-value pairs grouped by section); the
3
+ // `site-settings-translations` rows hold locale-specific `Value`
4
+ // overrides. `Section`, `Key`, and `Type` are the same across
5
+ // locales — only `Value` is translated. Missing locale copy falls
6
+ // back to the default-locale translation.
5
7
 
8
+ import {
9
+ mapNotionPageToLocalizedContentTranslation,
10
+ listGenericNotionContentForLocale,
11
+ } from "@notionx/core";
6
12
  import { i18n } from "@/lib/i18n";
7
13
  import { siteSettingsContract } from "@/lib/locale-contract";
14
+ import { siteSettingsTranslationsSource } from "@/lib/content/models";
15
+ import type { NotionPageLike } from "@notionx/core";
8
16
 
9
17
  export type SiteSettingsTranslation = {
10
18
  pageId: string;
11
19
  sourcePageId: string;
12
20
  locale: string;
13
- tagline: string;
14
- description: string;
15
- seoTitle: string;
16
- seoDescription: string;
17
- navLabels: Record<string, string>;
18
- footerLabels: Record<string, string>;
19
- globalFallbackCopy: string;
21
+ value: string;
20
22
  published: boolean;
21
23
  };
22
24
 
@@ -28,3 +30,24 @@ export function pickSiteSettingsTranslation(
28
30
  if (direct) return direct;
29
31
  return rows.find((row) => row.locale === i18n.defaultLocale) ?? null;
30
32
  }
33
+
34
+ export async function listSiteSettingsTranslations(
35
+ locale: string
36
+ ): Promise<SiteSettingsTranslation[]> {
37
+ if (!siteSettingsTranslationsSource) return [];
38
+ const pages = await listGenericNotionContentForLocale(siteSettingsTranslationsSource, locale);
39
+ return pages
40
+ .map((p) => mapNotionPageToLocalizedContentTranslation<SiteSettingsTranslation>(p as unknown as NotionPageLike, {
41
+ fields: {
42
+ title: "Title",
43
+ source: "Source",
44
+ locale: "Locale",
45
+ slug: "Title",
46
+ published: "Published",
47
+ },
48
+ extraFields: {
49
+ value: "Value",
50
+ },
51
+ }))
52
+ .filter((row): row is SiteSettingsTranslation => row !== null);
53
+ }
@@ -0,0 +1,56 @@
1
+ // Locale prefix middleware. Parses the `/{locale}` path prefix for
2
+ // non-default locales, sets `x-notionx-locale` on the request so
3
+ // server components and data loaders can read it, and rewrites the
4
+ // URL to strip the prefix so the App Router matches the same route
5
+ // handlers for every locale.
6
+ //
7
+ // Default locale (`{{defaultLocale}}`) stays unprefixed — `/blog`
8
+ // serves the default locale, `/{{defaultLocale}}/blog` redirects to
9
+ // `/blog` to avoid duplicate-content URLs.
10
+
11
+ import { NextResponse, type NextRequest } from "next/server";
12
+
13
+ const SUPPORTED_LOCALES = {{supportedLocalesJson}} as readonly string[];
14
+ const DEFAULT_LOCALE = "{{defaultLocale}}";
15
+
16
+ export function middleware(request: NextRequest) {
17
+ const { pathname } = request.nextUrl;
18
+ const segments = pathname.split("/").filter(Boolean);
19
+ const firstSegment = segments[0];
20
+
21
+ // No locale prefix — default locale.
22
+ if (!firstSegment || !SUPPORTED_LOCALES.includes(firstSegment)) {
23
+ const requestHeaders = new Headers(request.headers);
24
+ requestHeaders.set("x-notionx-locale", DEFAULT_LOCALE);
25
+ return NextResponse.next({
26
+ request: { headers: requestHeaders },
27
+ });
28
+ }
29
+
30
+ // Default locale with explicit prefix — redirect to unprefixed URL
31
+ // to keep canonical URLs clean.
32
+ if (firstSegment === DEFAULT_LOCALE) {
33
+ const url = request.nextUrl.clone();
34
+ url.pathname = pathname.replace(`/${DEFAULT_LOCALE}`, "") || "/";
35
+ return NextResponse.redirect(url, 308);
36
+ }
37
+
38
+ // Non-default locale — strip the prefix and pass the locale via header.
39
+ const locale = firstSegment;
40
+ const stripped = pathname.replace(`/${locale}`, "") || "/";
41
+ const url = request.nextUrl.clone();
42
+ url.pathname = stripped;
43
+ const requestHeaders = new Headers(request.headers);
44
+ requestHeaders.set("x-notionx-locale", locale);
45
+ return NextResponse.rewrite(url, {
46
+ request: { headers: requestHeaders },
47
+ });
48
+ }
49
+
50
+ export const config = {
51
+ // Match all paths except static assets, API routes, Next.js
52
+ // internals, and admin (admin is single-locale by design).
53
+ matcher: [
54
+ "/((?!api|_next/static|_next/image|favicon.ico|admin|.*\\.).*)",
55
+ ],
56
+ };
@@ -43,10 +43,19 @@ export default {
43
43
  // `getRequestEnv()`. See `lib/site/request-env.ts` for the
44
44
  // rationale — we cannot use `cloudflare:workers.getRequestContext`
45
45
  // because that module does not export it.
46
- return runWithRequestEnv(env, async () => {
47
- const notionxResponse = await notionx.fetch(request, env, ctx);
48
- if (notionxResponse) return notionxResponse;
49
- return handler.fetch(request, env, ctx);
50
- });
46
+ //
47
+ // The locale prefix middleware (Next.js middleware) sets the
48
+ // `x-notionx-locale` header on every request. Thread it into
49
+ // the request env so data loaders can read it via
50
+ // `getRequestLocale()` without reaching into headers themselves.
51
+ const locale = request.headers.get("x-notionx-locale") ?? undefined;
52
+ return runWithRequestEnv(
53
+ { ...env, ...(locale ? { NOTIONX_LOCALE: locale } : {}) },
54
+ async () => {
55
+ const notionxResponse = await notionx.fetch(request, env, ctx);
56
+ if (notionxResponse) return notionxResponse;
57
+ return handler.fetch(request, env, ctx);
58
+ }
59
+ );
51
60
  },
52
61
  };
@@ -39,6 +39,10 @@
39
39
  "vars": {
40
40
  "SITE_URL": "https://{{projectNameLower}}.example.com",
41
41
  "NOTION_BLOCKS_DATA_SOURCE_ID": "REPLACE_WITH_NOTION_BLOCKS_DATA_SOURCE_ID",
42
- "NOTION_SITE_SETTINGS_DATA_SOURCE_ID": "REPLACE_WITH_NOTION_SITE_SETTINGS_DATA_SOURCE_ID"
42
+ "NOTION_SITE_SETTINGS_DATA_SOURCE_ID": "REPLACE_WITH_NOTION_SITE_SETTINGS_DATA_SOURCE_ID",
43
+ "NOTION_BLOG_TRANSLATIONS_DATA_SOURCE_ID": "REPLACE_WITH_NOTION_BLOG_TRANSLATIONS_DATA_SOURCE_ID",
44
+ "NOTION_PAGES_TRANSLATIONS_DATA_SOURCE_ID": "REPLACE_WITH_NOTION_PAGES_TRANSLATIONS_DATA_SOURCE_ID",
45
+ "NOTION_BLOCKS_TRANSLATIONS_DATA_SOURCE_ID": "REPLACE_WITH_NOTION_BLOCKS_TRANSLATIONS_DATA_SOURCE_ID",
46
+ "NOTION_SITE_SETTINGS_TRANSLATIONS_DATA_SOURCE_ID": "REPLACE_WITH_NOTION_SITE_SETTINGS_TRANSLATIONS_DATA_SOURCE_ID"
43
47
  }
44
48
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@notionx/create-notionx-app",
3
- "version": "1.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "Scaffold a new vinext (Next.js on Cloudflare Workers + D1 + R2) project backed by @notionx/core, with one-step provisioning of D1 / KV / R2 / Turnstile / Notion / Resend / Google OAuth.",
5
5
  "type": "module",
6
6
  "bin": {