@marvalt/digivalt-core 0.2.30 → 0.2.33

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 (59) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/GLOBAL-SHORTCODES.md +7 -7
  3. package/README.md +10 -8
  4. package/bin/env-example-fragments.cjs +6 -0
  5. package/bin/init.cjs +44 -5
  6. package/dist/generators/index.d.ts +3 -0
  7. package/dist/generators/prerender-seo.d.ts +6 -0
  8. package/dist/generators/seo-manifest.d.ts +3 -0
  9. package/dist/generators/seo-urls.d.ts +29 -0
  10. package/dist/generators/sitemap-paths.d.ts +5 -9
  11. package/dist/generators.cjs +1005 -155
  12. package/dist/generators.cjs.map +1 -1
  13. package/dist/generators.d.ts +86 -6
  14. package/dist/generators.esm.js +992 -156
  15. package/dist/generators.esm.js.map +1 -1
  16. package/dist/index.cjs +1019 -156
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.d.ts +103 -9
  19. package/dist/index.esm.js +1006 -157
  20. package/dist/index.esm.js.map +1 -1
  21. package/dist/registry/index.d.ts +20 -0
  22. package/dist/registry.cjs +1528 -0
  23. package/dist/registry.cjs.map +1 -0
  24. package/dist/registry.d.ts +26 -0
  25. package/dist/registry.esm.js +1523 -0
  26. package/dist/registry.esm.js.map +1 -0
  27. package/dist/seo/DigiValtRouteSeo.d.ts +12 -0
  28. package/dist/seo/DigiValtSeoHead.d.ts +2 -0
  29. package/dist/seo/buildPageJsonLd.d.ts +11 -0
  30. package/dist/seo/html.d.ts +8 -0
  31. package/dist/seo/index.d.ts +7 -0
  32. package/dist/seo/resolvePageSeo.d.ts +17 -0
  33. package/dist/seo/seoTypes.d.ts +57 -0
  34. package/dist/seo.cjs +307 -86
  35. package/dist/seo.cjs.map +1 -1
  36. package/dist/seo.d.ts +105 -2
  37. package/dist/seo.esm.js +300 -87
  38. package/dist/seo.esm.js.map +1 -1
  39. package/dist/static/wpStatic.d.ts +16 -0
  40. package/dist/static.cjs +10 -0
  41. package/dist/static.cjs.map +1 -1
  42. package/dist/static.d.ts +38 -2
  43. package/dist/static.esm.js +10 -1
  44. package/dist/static.esm.js.map +1 -1
  45. package/dist/utils/notifications.d.ts +3 -0
  46. package/docs/AI-LOCAL-WORDPRESS-BOOTSTRAP.md +7 -3
  47. package/docs/AI-MASTER-PROMPT.md +37 -0
  48. package/docs/ENVIRONMENT.md +3 -3
  49. package/docs/README.md +6 -4
  50. package/docs/SEO-AIO.md +59 -0
  51. package/docs/SEO-PERFORMANCE.md +44 -11
  52. package/docs/SHORTCODES.md +4 -5
  53. package/package.json +17 -6
  54. package/template/DIGIVALT_SETUP.md +14 -8
  55. package/template/functions/robots.txt.ts +27 -0
  56. package/template/functions/sitemap.xml.ts +143 -65
  57. package/template/scripts/generate.ts +3 -10
  58. package/template/scripts/prerender-seo.ts +10 -0
  59. package/docs/LOVABLE-MASTER-PROMPT.md +0 -140
@@ -1,6 +1,8 @@
1
1
  # SEO and PageSpeed with `@marvalt/digivalt-core`
2
2
 
3
- This guide is for **Vite + React** apps (including Lovable exports) that already use DigiValt static generation. It complements WordPress content with **document metadata**, **structured data**, and **performance-minded image/font patterns**.
3
+ This guide is for **Vite + React** apps that use DigiValt static generation. It complements WordPress content with **document metadata**, **structured data**, and **performance-minded image/font patterns**.
4
+
5
+ **How the system fits together** (WP vs env, prerender, editor workflow): **[SEO-AIO.md](./SEO-AIO.md)**. Team handbook: `digivalt-collaboration-docs/DIGIVALT_SEO_AIO.md`.
4
6
 
5
7
  ## Prerequisites
6
8
 
@@ -30,6 +32,10 @@ Use the **`VITE_SEO_*`** prefix so SEO-related keys are easy to copy into a chec
30
32
  | `VITE_SEO_FACEBOOK_APP_ID` | Optional `fb:app_id`. |
31
33
  | `VITE_SEO_GOOGLE_SITE_VERIFICATION` | Google Search Console meta content. |
32
34
  | `VITE_SEO_BING_SITE_VERIFICATION` | Bing `msvalidate.01` content. |
35
+ | `VITE_SEO_ROUTE_PREFIXES` | Optional. Comma list `rest_base:/app-prefix` for CPT sitemap/prerender paths (e.g. `eventbrite_event:/events`). Pages are always `/{slug}` except the Reading Settings front page → `/`. |
36
+ | `VITE_SEO_FRONT_PAGE_SLUG` | Optional. Force this WP page slug to `/` when Reading Settings `front_page` does not match the app homepage. |
37
+
38
+ Per-URL title, description, canonical, robots, OG image, FAQ, and schema live in the **DigiValt SEO** WordPress plugin (`digivalt_seo` on the REST object), not in env.
33
39
 
34
40
  ### Performance toggles (optional)
35
41
 
@@ -41,24 +47,48 @@ Read via `getPerformanceConfig()` from `@marvalt/digivalt-core/config`. Set to `
41
47
  - `VITE_PERF_SERVICE_WORKER` (default off)
42
48
  - `VITE_PERF_CACHING` (default on)
43
49
 
44
- ## Sitemap (`/sitemap.xml`)
50
+ ## Sitemap (`/sitemap.xml`) and robots
51
+
52
+ **Runtime:** Cloudflare Pages Functions:
53
+
54
+ - **`functions/sitemap.xml.ts`** → `/sitemap.xml` (canonical host from `VITE_SEO_SITE_URL` / `SITE_URL`)
55
+ - **`functions/robots.txt.ts`** → `/robots.txt` (`Allow: /` plus `Sitemap:`). Do not commit **`public/robots.txt`**; it would shadow the Function.
56
+
57
+ Do not add **`public/sitemap.xml`**.
58
+
59
+ **Build time:** `generateSitemapPaths` writes **`public/sitemap-paths.json`**:
60
+
61
+ ```json
62
+ { "urls": [ { "path": "/", "lastmod": "2026-09-18T00:00:00Z", "image": "https://…" } ] }
63
+ ```
64
+
65
+ A legacy `paths: string[]` array is still accepted by the Function. If there are more than 45k URLs, an index plus `sitemap-paths-N.json` shards are written; `/sitemap.xml` then emits a sitemap index.
66
+
67
+ Sources (frontend routes only — never raw CMS permalinks):
68
+
69
+ 1. **`public/sitemap-static-paths.json`** — app-owned public routes (not utility `noindex` URLs).
70
+ 2. Published **pages**: front page (Reading Settings `front_page` slug or id) → `/`; other pages → `/{slug}`. `digivalt_seo.robots_index === false` and non-`publish` statuses are omitted.
71
+ 3. Other post types only when listed in **`VITE_SEO_ROUTE_PREFIXES`**.
72
+ 4. PHPList issues from **`phplist-data.json`** under **`VITE_PHPLIST_NEWSLETTER_ROUTE_BASE`** (default `/newsletters`).
73
+ 5. Header and footer menus: URL kept only if it already maps to a collected app path.
45
74
 
46
- **Runtime:** A **Cloudflare Pages Function** at **`functions/sitemap.xml.ts`** serves **`/sitemap.xml`**. It **`fetch`es `/sitemap-paths.json`** from the deployed static assets and uses **`VITE_SEO_SITE_URL`** (or **`SITE_URL`**) for canonical `<loc>` values (fallback: request origin). Do not add **`public/sitemap.xml`**; it would shadow the Function.
75
+ `lastmod` comes from `modified_gmt` / `modified` / newsletter `sent_at`. Do not emit fake `changefreq` or `priority`.
47
76
 
48
- **Build time:** **`public/sitemap-paths.json`** is **generated** by **`@marvalt/digivalt-core/generators`** (`generateSitemapPaths`) at the end of **`generateAllData`**, and when **`VITE_IS_LOVABLE=true`** the app’s **`scripts/generate.ts`** still runs that step so paths stay in sync with committed **`wordpress-data.json`**. Sources merged:
77
+ Fixture mode (`VITE_IS_LOVABLE=true`) still regenerates sitemap + **`seo-manifest.json`** from committed JSON.
49
78
 
50
- 1. **`public/sitemap-static-paths.json`** — you maintain this for **React-only / Lovable** public routes (not inferred from code).
51
- 2. **`navigation_menu`** items in **`wordpress-data.json`** (recursive `url` fields), filtered to **`http(s)`** and the CMS host from **`VITE_WORDPRESS_API_URL`** / **`WORDPRESS_API_URL`**.
52
- 3. **`link`** on entities in top-level arrays whose keys match **`config.post_types`** (fallback: other top-level arrays minus a fixed skip list). Taxonomy archives are omitted by default.
79
+ ## Prerender (same HTML for humans and crawlers)
53
80
 
54
- Paths are normalized (no trailing slash except **`/`**), deduped, and sorted. If generation fails, a safe fallback writes **`{ "paths": ["/"] }`**.
81
+ `generateSeoManifest` writes **`public/seo-manifest.json`**. After `vite build`, **`prerenderSeoHtml`** (template `scripts/prerender-seo.ts`) copies `dist/index.html` per indexable path, injects `<head>` and a crawlable snapshot inside `#root`. Client JS hydrates as usual. This is not React SSR.
55
82
 
56
83
  ## Imports
57
84
 
58
85
  ```ts
59
86
  import {
87
+ DigiValtRouteSeo,
60
88
  DigiValtSeoHead,
61
89
  JsonLd,
90
+ resolvePageSeo,
91
+ buildPageJsonLd,
62
92
  getSeoDefaultsFromEnv,
63
93
  buildEventJsonLd,
64
94
  createOrganizationSchema,
@@ -72,8 +102,9 @@ Prefer **`@marvalt/digivalt-core/seo`** so build scripts that only import `gener
72
102
 
73
103
  ## SPA behaviour
74
104
 
75
- - **`DigiValtSeoHead`** updates `<head>` on the **client** when routes change. Ensure **one instance per logical page** (or merge props in a layout) so title/description match the active route.
76
- - **Crawlers:** Many search engines execute JavaScript; for **fully static HTML per URL** you need **SSG/prerender** or SSR (separate from this package).
105
+ - **`DigiValtRouteSeo`** (preferred) sets Helmet + JSON-LD from a WordPress entity’s `digivalt_seo` field, falling back to visible title/excerpt then `VITE_SEO_*`.
106
+ - **`DigiValtSeoHead`** still updates `<head>` on the **client** for in-app navigation.
107
+ - **Crawlers:** prerendered `dist/{path}/index.html` snapshots carry title, meta, JSON-LD, and body HTML without executing JavaScript.
77
108
  - **Analytics:** Fire page views on client route changes so funnels match SPA navigation (measurement, not a ranking factor).
78
109
 
79
110
  ## Components
@@ -146,7 +177,9 @@ After `npx digivalt-init`, add:
146
177
  1. `npm install react-helmet-async`
147
178
  2. `HelmetProvider` at the app root
148
179
  3. SEO env vars (at least `VITE_SEO_SITE_URL`, `VITE_SEO_SITE_NAME`, `VITE_SEO_DEFAULT_OG_IMAGE` for production)
149
- 4. Per-route **`DigiValtSeoHead`** + optional **`JsonLd`**
180
+ 4. Per-route **`DigiValtRouteSeo`** (or `DigiValtSeoHead` + `JsonLd`)
181
+ 5. Install **DigiValt SEO** on WordPress for per-URL fields
182
+ 6. Confirm build runs generate **and** `scripts/prerender-seo.ts`
150
183
 
151
184
  ## Upgrading from older `@marvalt/digivalt-core`
152
185
 
@@ -4,7 +4,7 @@ After install: `node_modules/@marvalt/digivalt-core/docs/SHORTCODES.md`.
4
4
 
5
5
  The same list lives at the package root as `GLOBAL-SHORTCODES.md`.
6
6
 
7
- Content editors type shortcodes into WordPress. `@marvalt/wparser` finds `[name attr="value"]` in block HTML. `createAppRegistry` (in package **source** `src/utils/wpRegistry.tsx`, copied into apps by `digivalt-init`) maps names to React components.
7
+ Content editors type shortcodes into WordPress. `@marvalt/wparser` finds `[name attr="value"]` in block HTML. `createContentRegistry()` from `@marvalt/digivalt-core/registry` maps package-supported names to React components. `digivalt-init` does not copy a registry file.
8
8
 
9
9
  **Do not register these as WordPress PHP shortcodes.** They must stay literal in REST content.
10
10
 
@@ -15,18 +15,17 @@ Content editors type shortcodes into WordPress. `@marvalt/wparser` finds `[name
15
15
  | `[mautic_form id="1"]` | `@marvalt/madapter` `getMauticShortcodeHandlers()` | Optional `title`, `description`. See `node_modules/@marvalt/madapter/docs/SHORTCODES.md`. |
16
16
  | `[phplist_subscribe list="1" attrs="firstname,lastname"]` | `@marvalt/plister` `getPhplistShortcodeHandlers()` | Visible fields only. Optional `page`, `title`, `intro`. See `node_modules/@marvalt/plister/docs/SHORTCODES.md`. |
17
17
  | `[gf_embed id="1"]` / `[gform …]` | core | Gravity Forms inline. |
18
- | `[popupbtn_gf …]` / `[popupbtn_mautic …]` | core | App Dialog/Button popups. |
19
- | `[member_*]` / `[testimonial_*]` / `[event_*]` | `@marvalt/shcoder` | CPT cards, grids, slideshows. |
18
+ | `[popupbtn_gf …]` / `[popupbtn_mautic …]` | core | Package-local popup controls. |
20
19
 
21
20
  Full attribute list: package root [GLOBAL-SHORTCODES.md](../GLOBAL-SHORTCODES.md).
22
21
 
23
22
  ## If shortcodes stay as raw text
24
23
 
25
- 1. The shortcode is not in `registry.shortcodes` — merge adapter handlers, or recopy `wpRegistry.tsx` after upgrading.
24
+ 1. The shortcode is not in `registry.shortcodes` — use `createContentRegistry()` or merge the required custom handler.
26
25
  2. Someone registered a PHP shortcode in WordPress — undo that so the token remains in the API response.
27
26
  3. Mautic/PHPList JSON was not generated, or `QueryClientProvider` is missing.
28
27
 
29
- ## Standalone (no DigiValt `createAppRegistry`)
28
+ ## Standalone custom registry
30
29
 
31
30
  ```tsx
32
31
  import { getMauticShortcodeHandlers } from '@marvalt/madapter';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marvalt/digivalt-core",
3
- "version": "0.2.30",
3
+ "version": "0.2.33",
4
4
  "description": "Core glue logic and shared context for DigiVAlt frontend applications",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "main": "dist/index.cjs",
@@ -42,6 +42,11 @@
42
42
  "import": "./dist/seo.esm.js",
43
43
  "require": "./dist/seo.cjs"
44
44
  },
45
+ "./registry": {
46
+ "types": "./dist/registry.d.ts",
47
+ "import": "./dist/registry.esm.js",
48
+ "require": "./dist/registry.cjs"
49
+ },
45
50
  "./integrations": {
46
51
  "types": "./dist/integrations.d.ts",
47
52
  "import": "./dist/integrations.esm.js",
@@ -101,24 +106,30 @@
101
106
  "typescript": "^5.5.3"
102
107
  },
103
108
  "peerDependencies": {
109
+ "@tanstack/react-query": ">=5",
104
110
  "react": ">=18",
105
111
  "react-dom": ">=18",
106
112
  "react-helmet-async": ">=2"
107
113
  },
114
+ "peerDependenciesMeta": {
115
+ "@tanstack/react-query": {
116
+ "optional": true
117
+ }
118
+ },
108
119
  "engines": {
109
120
  "node": ">=18.0.0",
110
121
  "npm": ">=8.0.0"
111
122
  },
112
123
  "dependencies": {
113
124
  "@digivalt/tracker": "^0.1.1",
114
- "@marvalt/dstyler": "^0.1.51",
125
+ "@marvalt/dstyler": "^0.1.53",
115
126
  "@marvalt/madapter": "^2.3.11",
116
127
  "@marvalt/plister": "^0.3.0",
117
- "@marvalt/sadapter": "^0.1.5",
118
- "@marvalt/shcoder": "^0.1.35",
119
- "@marvalt/tadapter": "^1.0.12",
128
+ "@marvalt/sadapter": "^0.1.6",
129
+ "@marvalt/shcoder": "^0.1.37",
130
+ "@marvalt/tadapter": "^1.0.14",
120
131
  "@marvalt/wadapter": "^2.3.61",
121
- "@marvalt/wparser": "^0.1.79",
132
+ "@marvalt/wparser": "^0.1.82",
122
133
  "@radix-ui/react-toast": "^1.2.15",
123
134
  "class-variance-authority": "^0.7.1",
124
135
  "clsx": "^2.1.1",
@@ -2,9 +2,9 @@
2
2
 
3
3
  Welcome to your new **DigiVAlt** architecture project!
4
4
 
5
- Because your Lovable interface is completely decoupled from the data pipeline, the `@marvalt/digivalt-core` package securely builds the glue between your frontend and your Cloudflare Worker backend.
5
+ Your Vite interface is decoupled from the data pipeline. `@marvalt/digivalt-core` supplies static generation and runtime helpers for your Cloudflare Pages app.
6
6
 
7
- Follow these instructions strictly to hydrate a brand new Lovable project with real data.
7
+ Follow these instructions to hydrate a new Vite project with real data.
8
8
 
9
9
  ---
10
10
 
@@ -17,7 +17,7 @@ npm install -D vite-node
17
17
  ```
18
18
 
19
19
  ## 🛠️ 2. Wire the Build Scripts
20
- `digivalt-init` now preflights your app and patches safe script cases automatically. In a standard Lovable/Vite app, it should add or update the DigiVAlt build wiring for you.
20
+ `digivalt-init` preflights your app and patches safe script cases automatically. In a standard Vite app, it should add or update the DigiValt build wiring for you.
21
21
 
22
22
  Still verify your `package.json` after running init. The expected result is:
23
23
 
@@ -58,7 +58,7 @@ VITE_GRAVITY_FORMS_SECRET="your_cs_secret"
58
58
  # Local Execution Mode (Set to "direct" internally so your local UI bypasses Cloudflare)
59
59
  VITE_AUTH_MODE="direct"
60
60
  ```
61
- *⚠️ **CRITICAL WARNING:** NEVER place `VITE_IS_LOVABLE=true` inside `.env.local` locally! That variable instructs the application to mute API traffic completely, pretending the data does not exist.*
61
+ *⚠️ **CRITICAL WARNING:** Never enable fixture mode (`VITE_IS_LOVABLE=true`) in `.env.local` when you need live generation. The backwards-compatible flag skips API traffic and uses committed data.*
62
62
 
63
63
  ## 🌩️ 5. Deploy Infrastructure to Cloudflare
64
64
  Once you connect this GitHub repository to the Cloudflare Pages dashboard:
@@ -77,15 +77,21 @@ Init will:
77
77
  - refresh older DigiVAlt-managed `scripts/generate.ts` files
78
78
  - reconcile the DigiValt-managed sections inside `.env.example` and `.env.local.example`
79
79
  - warn instead of overwriting custom build logic or custom generator scripts
80
- - report stale legacy workarounds that may no longer be needed
80
+ - report stale legacy workarounds and local registry files that may no longer be needed
81
+
82
+ ## App chrome and WordPress page bodies
83
+
84
+ Use `DigiValtProviders`, `DigiValtWpPage`, and `createContentRegistry` from `@marvalt/digivalt-core/registry`. Read static chrome content with `getAppChrome()` from `@marvalt/digivalt-core/static`.
85
+
86
+ > Build Header/Footer in the app. Read menus, logo, and copy from WordPress static data (`getAppChrome()`). Apply the app’s layout and Tailwind. Do not render the WordPress header/footer template part and do not pipe `footer.blocks` / `header.blocks` through wparser.
81
87
 
82
88
  ## 7. SEO and PageSpeed (optional)
83
89
 
84
90
  1. Install **`react-helmet-async`** and wrap your app with **`HelmetProvider`** at the root (next to the router).
85
91
  2. Set at least **`VITE_SEO_SITE_URL`** (no trailing slash), **`VITE_SEO_SITE_NAME`**, **`VITE_SEO_DEFAULT_TITLE`**, **`VITE_SEO_DEFAULT_DESCRIPTION`**, and **`VITE_SEO_DEFAULT_OG_IMAGE`** in Cloudflare Pages / `.env.local`.
86
- 3. Import **`DigiValtSeoHead`** and **`JsonLd`** from **`@marvalt/digivalt-core/seo`** in your layouts or page components; pass **`pathname`** per route for correct canonical URLs.
87
- 4. **Sitemap:** **`/sitemap.xml`** is served by **`functions/sitemap.xml.ts`**. **`public/sitemap-paths.json`** is **generated** when you run **`npm run build`** (or `generate`): it merges (a) **`public/sitemap-static-paths.json`** — edit this for Lovable/SPA-only routes, (b) URLs from **`navigation_menu`** in **`wordpress-data.json`**, (c) **`link`** pathnames from CPT/post arrays listed under **`config.post_types`**. Only CMS links whose host matches **`VITE_WORDPRESS_API_URL`** / **`WORDPRESS_API_URL`** are included. Canonical `<loc>` values use **`VITE_SEO_SITE_URL`** (or **`SITE_URL`**). Do not add **`public/sitemap.xml`** (it would shadow the Function). With **`VITE_IS_LOVABLE=true`**, static fetch is skipped but sitemap paths are still regenerated from committed JSON.
88
- 5. Read the full guide shipped with the package: **`node_modules/@marvalt/digivalt-core/docs/SEO-PERFORMANCE.md`** (or in the repo: `digivalt-npm-packages/digivalt-core/docs/SEO-PERFORMANCE.md`).
92
+ 3. Import **`DigiValtRouteSeo`** (or **`DigiValtSeoHead`** / **`JsonLd`**) from **`@marvalt/digivalt-core/seo`**; pass **`pathname`** per route.
93
+ 4. **Sitemap / robots / prerender:** **`/sitemap.xml`** and **`/robots.txt`** are Pages Functions. **`public/sitemap-paths.json`** and **`seo-manifest.json`** are generated on `generate` / `build`. After `vite build`, run **`scripts/prerender-seo.ts`**. Edit **`public/sitemap-static-paths.json`** for app-only indexable routes. Do not add **`public/sitemap.xml`** or **`public/robots.txt`**. Per-URL meta lives in the **DigiValt SEO** WordPress plugin; site identity stays in **`VITE_SEO_*`**. Optional **`VITE_SEO_ROUTE_PREFIXES`** maps CPT REST slugs to app path prefixes. See **`docs/SEO-PERFORMANCE.md`**.
94
+ 5. Read the full guide shipped with the package: **`node_modules/@marvalt/digivalt-core/docs/SEO-PERFORMANCE.md`**.
89
95
 
90
96
  ## 8. Analytics (Google Analytics / Matomo)
91
97
 
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Serves /robots.txt with a Sitemap directive using VITE_SEO_SITE_URL when set.
3
+ */
4
+
5
+ function normalizeBase(raw: string | undefined): string | null {
6
+ if (!raw || typeof raw !== 'string') return null;
7
+ const t = raw.trim().replace(/\/+$/, '');
8
+ return t || null;
9
+ }
10
+
11
+ export async function onRequestGet(context: { request: Request; env: Record<string, string | undefined> }) {
12
+ const { request, env } = context;
13
+ const fromEnv = normalizeBase(env.VITE_SEO_SITE_URL || env.SITE_URL);
14
+ const origin = new URL(request.url).origin.replace(/\/+$/, '');
15
+ const base = fromEnv || origin;
16
+ const body = `User-agent: *
17
+ Allow: /
18
+
19
+ Sitemap: ${base}/sitemap.xml
20
+ `;
21
+ return new Response(body, {
22
+ headers: {
23
+ 'Content-Type': 'text/plain; charset=utf-8',
24
+ 'Cache-Control': 'public, max-age=300',
25
+ },
26
+ });
27
+ }
@@ -1,65 +1,143 @@
1
- /**
2
- * Serves /sitemap.xml from canonical site URL + public/sitemap-paths.json (generated at build).
3
- * Set VITE_SEO_SITE_URL (or SITE_URL) on Cloudflare Pages for production canonical hosts.
4
- */
5
-
6
- function normalizeBase(raw: string | undefined): string | null {
7
- if (!raw || typeof raw !== 'string') return null;
8
- const t = raw.trim().replace(/\/+$/, '');
9
- return t || null;
10
- }
11
-
12
- function escapeXml(s: string): string {
13
- return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
14
- }
15
-
16
- async function loadPaths(request: Request): Promise<string[]> {
17
- try {
18
- const jsonUrl = new URL('/sitemap-paths.json', request.url);
19
- const res = await fetch(jsonUrl.toString());
20
- if (!res.ok) return ['/'];
21
- const data: unknown = await res.json();
22
- const list: string[] = [];
23
- if (Array.isArray(data)) {
24
- for (const p of data) {
25
- if (typeof p === 'string' && p.startsWith('/')) list.push(p);
26
- }
27
- } else if (data && typeof data === 'object' && Array.isArray((data as { paths?: unknown }).paths)) {
28
- for (const p of (data as { paths: unknown[] }).paths) {
29
- if (typeof p === 'string' && p.startsWith('/')) list.push(p);
30
- }
31
- }
32
- return list.length ? list : ['/'];
33
- } catch {
34
- return ['/'];
35
- }
36
- }
37
-
38
- function buildSitemap(base: string, paths: string[]): string {
39
- const lines = paths.map((p) => {
40
- const loc = p === '/' ? base : `${base}${p.startsWith('/') ? p : `/${p}`}`;
41
- return ` <url><loc>${escapeXml(loc)}</loc></url>`;
42
- });
43
- return `<?xml version="1.0" encoding="UTF-8"?>
44
- <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
45
- ${lines.join('\n')}
46
- </urlset>`;
47
- }
48
-
49
- export async function onRequestGet(context: { request: Request; env: Record<string, string | undefined> }) {
50
- const { request, env } = context;
51
- const fromEnv = normalizeBase(env.VITE_SEO_SITE_URL || env.SITE_URL);
52
- const origin = new URL(request.url).origin.replace(/\/+$/, '');
53
- const base = fromEnv || origin;
54
-
55
- const rawPaths = await loadPaths(request);
56
- const paths = [...new Set(rawPaths.length ? rawPaths : ['/'])];
57
-
58
- const xml = buildSitemap(base, paths);
59
- return new Response(xml, {
60
- headers: {
61
- 'Content-Type': 'application/xml; charset=utf-8',
62
- 'Cache-Control': 'public, max-age=300',
63
- },
64
- });
65
- }
1
+ /**
2
+ * Serves /sitemap.xml from canonical site URL + public/sitemap-paths.json (generated at build).
3
+ * Set VITE_SEO_SITE_URL (or SITE_URL) on Cloudflare Pages for production canonical hosts.
4
+ */
5
+
6
+ const IMAGE_NS = 'http://www.google.com/schemas/sitemap-image/1.1';
7
+
8
+ function normalizeBase(raw: string | undefined): string | null {
9
+ if (!raw || typeof raw !== 'string') return null;
10
+ const t = raw.trim().replace(/\/+$/, '');
11
+ return t || null;
12
+ }
13
+
14
+ function escapeXml(s: string): string {
15
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
16
+ }
17
+
18
+ type SitemapUrl = { path: string; lastmod?: string; image?: string };
19
+
20
+ function asUrls(data: unknown): SitemapUrl[] {
21
+ if (!data || typeof data !== 'object') return [];
22
+ const rec = data as { urls?: unknown; paths?: unknown };
23
+ if (Array.isArray(rec.urls)) {
24
+ return rec.urls
25
+ .map((u) => {
26
+ if (typeof u === 'string' && u.startsWith('/')) return { path: u };
27
+ if (u && typeof u === 'object' && typeof (u as { path?: unknown }).path === 'string') {
28
+ const row = u as { path: string; lastmod?: unknown; image?: unknown };
29
+ return {
30
+ path: row.path,
31
+ lastmod: typeof row.lastmod === 'string' ? row.lastmod : undefined,
32
+ image: typeof row.image === 'string' ? row.image : undefined,
33
+ };
34
+ }
35
+ return null;
36
+ })
37
+ .filter((u): u is SitemapUrl => !!u && u.path.startsWith('/'));
38
+ }
39
+ if (Array.isArray(rec.paths)) {
40
+ return rec.paths.filter((p): p is string => typeof p === 'string' && p.startsWith('/')).map((path) => ({ path }));
41
+ }
42
+ return [];
43
+ }
44
+
45
+ async function fetchJson(request: Request, pathname: string): Promise<unknown> {
46
+ const jsonUrl = new URL(pathname, request.url);
47
+ const res = await fetch(jsonUrl.toString());
48
+ if (!res.ok) return null;
49
+ return res.json();
50
+ }
51
+
52
+ async function loadUrls(request: Request): Promise<SitemapUrl[]> {
53
+ try {
54
+ const data = await fetchJson(request, '/sitemap-paths.json');
55
+ if (!data || typeof data !== 'object') return [{ path: '/' }];
56
+ const rec = data as { index?: unknown; parts?: unknown };
57
+ if (rec.index === true && typeof rec.parts === 'number' && rec.parts > 1) {
58
+ const partParam = new URL(request.url).searchParams.get('part');
59
+ if (partParam === null) {
60
+ return [];
61
+ }
62
+ const part = Number(partParam);
63
+ if (!Number.isInteger(part) || part < 0 || part >= rec.parts) return [{ path: '/' }];
64
+ const shard = await fetchJson(request, `/sitemap-paths-${part}.json`);
65
+ const urls = asUrls(shard);
66
+ return urls.length ? urls : [{ path: '/' }];
67
+ }
68
+ const urls = asUrls(data);
69
+ return urls.length ? urls : [{ path: '/' }];
70
+ } catch {
71
+ return [{ path: '/' }];
72
+ }
73
+ }
74
+
75
+ async function loadIndexParts(request: Request): Promise<number | null> {
76
+ try {
77
+ const data = await fetchJson(request, '/sitemap-paths.json');
78
+ if (data && typeof data === 'object') {
79
+ const rec = data as { index?: unknown; parts?: unknown };
80
+ if (rec.index === true && typeof rec.parts === 'number' && rec.parts > 1) return rec.parts;
81
+ }
82
+ } catch {
83
+ /* ignore */
84
+ }
85
+ return null;
86
+ }
87
+
88
+ function locFor(base: string, p: string): string {
89
+ return p === '/' ? base : `${base}${p.startsWith('/') ? p : `/${p}`}`;
90
+ }
91
+
92
+ function buildUrlset(base: string, urls: SitemapUrl[]): string {
93
+ const lines = urls.map((u) => {
94
+ const loc = locFor(base, u.path);
95
+ const lastmod = u.lastmod ? `\n <lastmod>${escapeXml(u.lastmod)}</lastmod>` : '';
96
+ const image = u.image
97
+ ? `\n <image:image>\n <image:loc>${escapeXml(u.image)}</image:loc>\n </image:image>`
98
+ : '';
99
+ return ` <url>\n <loc>${escapeXml(loc)}</loc>${lastmod}${image}\n </url>`;
100
+ });
101
+ return `<?xml version="1.0" encoding="UTF-8"?>
102
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="${IMAGE_NS}">
103
+ ${lines.join('\n')}
104
+ </urlset>`;
105
+ }
106
+
107
+ function buildIndex(base: string, parts: number): string {
108
+ const items = [];
109
+ for (let i = 0; i < parts; i++) {
110
+ items.push(` <sitemap><loc>${escapeXml(`${base}/sitemap.xml?part=${i}`)}</loc></sitemap>`);
111
+ }
112
+ return `<?xml version="1.0" encoding="UTF-8"?>
113
+ <sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
114
+ ${items.join('\n')}
115
+ </sitemapindex>`;
116
+ }
117
+
118
+ export async function onRequestGet(context: { request: Request; env: Record<string, string | undefined> }) {
119
+ const { request, env } = context;
120
+ const fromEnv = normalizeBase(env.VITE_SEO_SITE_URL || env.SITE_URL);
121
+ const origin = new URL(request.url).origin.replace(/\/+$/, '');
122
+ const base = fromEnv || origin;
123
+ const part = new URL(request.url).searchParams.get('part');
124
+
125
+ const indexParts = await loadIndexParts(request);
126
+ if (indexParts && part === null) {
127
+ return new Response(buildIndex(base, indexParts), {
128
+ headers: {
129
+ 'Content-Type': 'application/xml; charset=utf-8',
130
+ 'Cache-Control': 'public, max-age=300',
131
+ },
132
+ });
133
+ }
134
+
135
+ const urls = await loadUrls(request);
136
+ const xml = buildUrlset(base, urls.length ? urls : [{ path: '/' }]);
137
+ return new Response(xml, {
138
+ headers: {
139
+ 'Content-Type': 'application/xml; charset=utf-8',
140
+ 'Cache-Control': 'public, max-age=300',
141
+ },
142
+ });
143
+ }
@@ -1,15 +1,7 @@
1
- import { generateAllData, generateSitemapPathsSafe } from '@marvalt/digivalt-core/generators';
1
+ import { generateAllData, generateSitemapPathsSafe, generateSeoManifestSafe } from '@marvalt/digivalt-core/generators';
2
2
 
3
3
  async function runGenerators() {
4
- console.log('🚀 Starting DigiVAlt static data generation for Landing Project...');
5
-
6
- if (process.env.VITE_IS_LOVABLE === 'true') {
7
- console.log(
8
- '⚡ Lovable Environment Detected (VITE_IS_LOVABLE). Skipping static data fetch; refreshing sitemap paths from committed JSON.'
9
- );
10
- await generateSitemapPathsSafe();
11
- return;
12
- }
4
+ console.log('🚀 Starting DigiVAlt static data generation...');
13
5
 
14
6
  try {
15
7
  await generateAllData();
@@ -17,6 +9,7 @@ async function runGenerators() {
17
9
  } catch (error) {
18
10
  console.error('❌ Data fetching encountered an error, but continuing build gracefully:', error);
19
11
  await generateSitemapPathsSafe();
12
+ await generateSeoManifestSafe();
20
13
  }
21
14
  }
22
15
 
@@ -0,0 +1,10 @@
1
+ import { prerenderSeoHtml } from '@marvalt/digivalt-core/generators';
2
+
3
+ async function run() {
4
+ await prerenderSeoHtml();
5
+ }
6
+
7
+ run().catch((e) => {
8
+ console.error('❌ SEO prerender failed:', e);
9
+ process.exit(1);
10
+ });