@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,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
 
@@ -53,64 +56,13 @@ type RawNavItem = {
53
56
  children?: RawNavItem[];
54
57
  };
55
58
 
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() ?? "";
59
+ function readString(value: unknown): string {
60
+ if (typeof value === "string") return value;
61
+ if (Array.isArray(value)) return value.join("");
62
+ return "";
106
63
  }
107
64
 
108
- function readJson<T>(
109
- properties: Record<string, unknown>,
110
- field: string,
111
- fallback: T
112
- ): T {
113
- const raw = readRichText(properties, field);
65
+ function parseJson<T>(raw: string, fallback: T): T {
114
66
  if (!raw) return fallback;
115
67
  try {
116
68
  return JSON.parse(raw) as T;
@@ -132,8 +84,8 @@ export function getStaticSiteSettings(): SiteConfig {
132
84
  * Resolve the current site settings.
133
85
  *
134
86
  * Order of precedence for each field:
135
- * 1. Notion row (if `site-settings` data source is reachable and
136
- * contains a row)
87
+ * 1. Notion rows (if `site-settings` data source is reachable and
88
+ * contains published rows)
137
89
  * 2. `fallbackSiteConfig` (so a Notion outage never breaks the
138
90
  * home page or SEO metadata)
139
91
  *
@@ -141,14 +93,14 @@ export function getStaticSiteSettings(): SiteConfig {
141
93
  * `site-settings:v1` for 5 minutes per process. Bump the cache key
142
94
  * suffix to invalidate globally after a breaking schema change.
143
95
  */
144
- export async function getSiteSettings(): Promise<SiteConfig> {
96
+ export async function getSiteSettings(locale?: string): Promise<SiteConfig> {
145
97
  const kv = readKv();
146
98
  if (kv) {
147
99
  const cached = await kv.get<SiteConfig>(CACHE_KEY, "json");
148
100
  if (cached) return cached;
149
101
  }
150
102
 
151
- const merged = await loadFromNotion();
103
+ const merged = await loadFromNotion(locale);
152
104
 
153
105
  if (kv) {
154
106
  // Best-effort write. KV failure shouldn't break the page.
@@ -160,7 +112,7 @@ export async function getSiteSettings(): Promise<SiteConfig> {
160
112
  return merged;
161
113
  }
162
114
 
163
- async function loadFromNotion(): Promise<SiteConfig> {
115
+ async function loadFromNotion(locale?: string): Promise<SiteConfig> {
164
116
  // `hasNotionModelConfig` reads the env from the active request
165
117
  // context. If Notion isn't configured we return the fallback
166
118
  // untouched — never throw, so the home page can't 500 because of
@@ -179,7 +131,9 @@ async function loadFromNotion(): Promise<SiteConfig> {
179
131
  ReturnType<typeof listGenericNotionContent<typeof siteSettingsSource.source.fields>>
180
132
  >;
181
133
  try {
182
- items = await listGenericNotionContent(siteSettingsSource);
134
+ items = locale
135
+ ? await listGenericNotionContentForLocale(siteSettingsSource, locale)
136
+ : await listGenericNotionContent(siteSettingsSource);
183
137
  } catch {
184
138
  return fallbackSiteConfig;
185
139
  }
@@ -187,124 +141,87 @@ async function loadFromNotion(): Promise<SiteConfig> {
187
141
  return fallbackSiteConfig;
188
142
  }
189
143
 
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
- };
144
+ // Aggregate rows into a key-value map keyed by the `Key` field.
145
+ // Each row carries one setting item; the runtime loader merges
146
+ // them into a single `SiteConfig`.
147
+ const settingsMap: Record<string, string> = {};
148
+ for (const item of items) {
149
+ const key = readString(item.properties.key);
150
+ const value = readString(item.properties.value);
151
+ if (key) {
152
+ settingsMap[key] = value;
231
153
  }
232
- const ogImage = readUrl(extra, "OG Image");
233
- if (ogImage) raw.ogImageUrl = ogImage;
154
+ }
234
155
 
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 };
156
+ // Map known keys to the SiteConfig structure. Missing keys fall
157
+ // back to `fallbackSiteConfig`.
158
+ const name = settingsMap.name ?? fallbackSiteConfig.name;
159
+ const tagline = settingsMap.tagline ?? fallbackSiteConfig.tagline;
160
+ const description = settingsMap.description ?? fallbackSiteConfig.description;
161
+ const socialImageUrl = settingsMap.socialImage ?? fallbackSiteConfig.socialImageUrl;
162
+ const ogImageUrl = settingsMap.ogImage ?? socialImageUrl ?? fallbackSiteConfig.ogImageUrl;
163
+
164
+ const metaTitle = settingsMap.metaTitle ?? "";
165
+ const metaDescription = settingsMap.metaDescription ?? "";
166
+ const seo = {
167
+ title: metaTitle.trim() || fallbackSiteConfig.seo.title,
168
+ description: metaDescription.trim() || fallbackSiteConfig.seo.description,
169
+ };
243
170
 
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
- }
171
+ const navItems = parseJson<RawNavItem[]>(
172
+ settingsMap.items ?? "[]",
173
+ fallbackSiteConfig.navigation.main as RawNavItem[]
174
+ );
175
+ const navCta = parseJson<{ label: string; href: string } | null>(
176
+ settingsMap.cta ?? "null",
177
+ fallbackSiteConfig.navigation.cta
178
+ );
179
+ const navigation = {
180
+ ...fallbackSiteConfig.navigation,
181
+ main: navItems.length ? navItems : fallbackSiteConfig.navigation.main,
182
+ cta: navCta ?? fallbackSiteConfig.navigation.cta,
183
+ };
251
184
 
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
- }
185
+ const primary = (settingsMap.primaryColor as SiteConfig["theme"]["primary"]) ||
186
+ fallbackSiteConfig.theme.primary;
187
+ const accent = (settingsMap.accentColor as SiteConfig["theme"]["accent"]) ||
188
+ fallbackSiteConfig.theme.accent;
189
+ const font = (settingsMap.fontFamily as SiteConfig["theme"]["font"]) ||
190
+ fallbackSiteConfig.theme.font;
191
+ const theme = { primary, accent, font };
192
+
193
+ type FooterColumn = {
194
+ label: string;
195
+ items: Array<{ label: string; href: string }>;
196
+ };
197
+ const footerColumns = parseJson<FooterColumn[]>(
198
+ settingsMap.columns ?? "[]",
199
+ fallbackSiteConfig.footer.columns as FooterColumn[]
200
+ );
201
+ const footerSocial = parseJson<Array<{ label: string; href: string }>>(
202
+ settingsMap.socialLinks ?? "[]",
203
+ fallbackSiteConfig.footer.social
204
+ );
205
+ const footerTagline = settingsMap.footerTagline ?? fallbackSiteConfig.footer.tagline;
206
+ const footerCopyright = settingsMap.copyright ?? fallbackSiteConfig.footer.copyright;
207
+ const footer = {
208
+ columns: footerColumns,
209
+ social: footerSocial,
210
+ tagline: footerTagline,
211
+ copyright: footerCopyright,
212
+ };
267
213
 
268
214
  return {
269
215
  ...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
- },
216
+ name: name.trim() || fallbackSiteConfig.name,
217
+ tagline: tagline.trim() || name.trim() || fallbackSiteConfig.tagline,
218
+ description: description.trim() || fallbackSiteConfig.description,
219
+ socialImageUrl: socialImageUrl ?? fallbackSiteConfig.socialImageUrl,
220
+ ogImageUrl: ogImageUrl ?? fallbackSiteConfig.ogImageUrl,
221
+ seo,
222
+ navigation,
223
+ theme,
224
+ footer,
308
225
  };
309
226
  }
310
227
 
@@ -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.0",
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": {