@marvalt/digivalt-core 0.2.6 → 0.2.9

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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.2.9] - 2026-04-03
9
+
10
+ ### Added
11
+ - **Template + init:** **`functions/sitemap.xml.ts`** — Cloudflare Pages handler for **`/sitemap.xml`** using **`VITE_SEO_SITE_URL`** / **`SITE_URL`** and **`public/sitemap-paths.json`** (no WordPress). Default **`template/public/sitemap-paths.json`**. Listed in **`TEMPLATE_MANAGED_FILES`** for `--force-template` refresh of the handler only.
12
+
13
+ ### Changed
14
+ - **Docs:** `DIGIVALT_SETUP.md`, **`docs/SEO-PERFORMANCE.md`** — sitemap section.
15
+
16
+ ## [0.2.8] - 2026-04-03
17
+
18
+ ### Changed
19
+ - **Init env template:** `.env.example` managed SEO block comments now point to **`react-helmet-async` / `HelmetProvider`**, **`DigiValtSeoHead`**, and **`node_modules/@marvalt/digivalt-core/docs/SEO-PERFORMANCE.md`**.
20
+
21
+ ## [0.2.7] - 2026-04-03
22
+
23
+ ### Added
24
+ - **`digivalt-init`:** Creates or reconciles **`.env.example`** and **`.env.local.example`** via a DigiValt-managed marker block (`bin/env-example-fragments.cjs`); preserves custom lines outside the block; appends with a warning when an existing file has no markers.
25
+
26
+ ### Changed
27
+ - **Template `scripts/deploy-secrets.js`:** Allowlist extended with **`VITE_SEO_*`** and **`TURNSTILE_*`** (e.g. server-only `TURNSTILE_SECRET_KEY`).
28
+ - **Docs:** `DIGIVALT_SETUP.md` (template + landing), `README.md` init / deploy-secrets notes updated for env templates and SEO secrets.
29
+
8
30
  ## [0.2.6] - 2026-04-09
9
31
 
10
32
  ### Changed (breaking)
package/README.md CHANGED
@@ -43,6 +43,7 @@ Useful flags:
43
43
  What init now does:
44
44
 
45
45
  - copies missing template files into the app
46
+ - creates or updates **`.env.example`** and **`.env.local.example`** using a marker-wrapped DigiValt-managed block (safe to rerun; custom lines outside the block are preserved)
46
47
  - adds or updates `generate`, `build`, and `build:dev` scripts when the app is still on plain Vite wiring
47
48
  - refreshes `scripts/generate.ts` when it matches an older DigiVAlt-managed version
48
49
  - warns instead of overwriting custom build logic or custom generator scripts
@@ -64,6 +65,6 @@ Use `node scripts/deploy-secrets.js` after `digivalt-init`.
64
65
 
65
66
  The helper now:
66
67
 
67
- - uploads only DigiVAlt-related environment variables
68
+ - uploads only DigiVAlt-related environment variables (including **`VITE_SEO_*`** and server-only **`TURNSTILE_SECRET_KEY`** when present)
68
69
  - skips known local-only variables such as `VITE_LOCAL_DEVELOPMENT`
69
70
  - fails with a non-zero exit code when Wrangler or project detection fails
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Managed inner bodies for digivalt-init (placed between start/end markers).
3
+ * Keep in sync with generators, getIntegrationConfig / vite-env, and deploy-secrets allowlist.
4
+ */
5
+
6
+ const ENV_EXAMPLE_MANAGED_BODY = `# WordPress / static generation (public URLs)
7
+ VITE_WORDPRESS_API_URL=https://cms.example.com/wp-json
8
+ # direct | cloudflare_proxy | supabase_proxy
9
+ VITE_AUTH_MODE=direct
10
+ VITE_CLOUDFLARE_WORKER_URL=
11
+ VITE_LOCAL_DEVELOPMENT=false
12
+
13
+ # Multi-frontend (digivalt-multi-frontends)
14
+ VITE_FRONTEND_ID=my-app
15
+ VITE_FRONTEND_NAME=My App
16
+
17
+ VITE_ENABLED_POST_TYPES=posts,pages
18
+ VITE_DEFAULT_MAX_ITEMS=200
19
+ VITE_USE_DSTYLER=true
20
+
21
+ # Optional: Gravity Forms API base if not derived from WordPress URL
22
+ # VITE_GRAVITY_FORMS_API_URL=
23
+
24
+ # Integration URLs only (secrets belong in .env.local)
25
+ VITE_MAUTIC_URL=
26
+ VITE_MAUTIC_PROXY_URL=
27
+ VITE_SUITECRM_URL=
28
+ VITE_SUITECRM_PROXY_URL=
29
+ VITE_SUPABASE_URL=
30
+ VITE_SUPABASE_ANON_KEY=
31
+
32
+ VITE_CHATWOOT_BASE_URL=
33
+ VITE_CHATWOOT_WEBSITE_TOKEN=
34
+
35
+ # Cloudflare account (wrangler / deploy-secrets; not a Vite embed unless duplicated as VITE_)
36
+ CLOUDFLARE_ACCOUNT_ID=
37
+
38
+ # SEO — consumed by getSEOConfig / DigiValtSeoHead (peer: react-helmet-async + root HelmetProvider).
39
+ # Guide: node_modules/@marvalt/digivalt-core/docs/SEO-PERFORMANCE.md
40
+ VITE_SEO_SITE_URL=https://www.example.com
41
+ VITE_SEO_SITE_NAME=Example Site
42
+ VITE_SEO_DEFAULT_TITLE=Example Site
43
+ VITE_SEO_DEFAULT_DESCRIPTION=A short description for search results.
44
+ VITE_SEO_DEFAULT_KEYWORDS=
45
+ VITE_SEO_DEFAULT_OG_IMAGE=https://www.example.com/og-default.jpg
46
+ VITE_SEO_TWITTER_SITE=@example
47
+ VITE_SEO_FACEBOOK_APP_ID=
48
+ VITE_SEO_GOOGLE_SITE_VERIFICATION=
49
+ VITE_SEO_BING_SITE_VERIFICATION=
50
+
51
+ # Performance toggles (optional; omit to use defaults in getPerformanceConfig)
52
+ # VITE_PERF_LAZY_LOADING=true
53
+ # VITE_PERF_IMAGE_OPTIMIZATION=true
54
+ # VITE_PERF_CODE_SPLITTING=true
55
+ # VITE_PERF_SERVICE_WORKER=false
56
+ # VITE_PERF_CACHING=true`;
57
+
58
+ const ENV_LOCAL_EXAMPLE_MANAGED_BODY = `# WordPress application password (static generation + API)
59
+ VITE_WP_API_USERNAME=
60
+ VITE_WP_APP_PASSWORD=
61
+ # Optional aliases for Node-only scripts (see digivalt-core generators)
62
+ # WP_API_USERNAME=
63
+ # WP_APP_PASSWORD=
64
+
65
+ # Cloudflare Access (WordPress behind Zero Trust)
66
+ VITE_CF_ACCESS_CLIENT_ID=
67
+ VITE_CF_ACCESS_CLIENT_SECRET=
68
+
69
+ # Mautic API credentials
70
+ VITE_MAUTIC_API_PUBLIC_KEY=
71
+ VITE_MAUTIC_API_SECRET_KEY=
72
+
73
+ # Gravity Forms REST consumer keys (if used)
74
+ VITE_GF_CONSUMER_KEY=
75
+ VITE_GF_CONSUMER_SECRET=
76
+
77
+ # SuiteCRM (when SuiteCRM static generation is enabled)
78
+ VITE_SUITECRM_CLIENT_ID=
79
+ VITE_SUITECRM_CLIENT_SECRET=
80
+ VITE_SUITECRM_USERNAME=
81
+ VITE_SUITECRM_PASSWORD=
82
+
83
+ # Supabase service role (only if needed; never expose in client bundles inappropriately)
84
+ # VITE_SUPABASE_SERVICE_ROLE_KEY=
85
+
86
+ # cf-wp-webhook-refresh + Cloudflare Pages Functions
87
+ VITE_FRONTEND_SECRET=
88
+ VITE_WP_REFRESH_SECRET=
89
+
90
+ # Turnstile — site key for browser; secret is server-only for Pages Functions
91
+ VITE_TURNSTILE_SITE_KEY=
92
+ TURNSTILE_SECRET_KEY=
93
+
94
+ # Optional: npm publish / CI
95
+ # NPM_TOKEN=
96
+ `;
97
+
98
+ module.exports = {
99
+ ENV_EXAMPLE_MANAGED_BODY,
100
+ ENV_LOCAL_EXAMPLE_MANAGED_BODY,
101
+ };
package/bin/init.cjs CHANGED
@@ -3,6 +3,15 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
 
6
+ const {
7
+ ENV_EXAMPLE_MANAGED_BODY,
8
+ ENV_LOCAL_EXAMPLE_MANAGED_BODY,
9
+ } = require('./env-example-fragments.cjs');
10
+
11
+ const ENV_MANAGED_START =
12
+ '# --- DigiValt: managed by digivalt-init (do not edit this block by hand; rerun init to refresh) ---';
13
+ const ENV_MANAGED_END = '# --- End DigiValt ---';
14
+
6
15
  const sourceDir = path.join(__dirname, '..', 'template');
7
16
  const args = process.argv.slice(2);
8
17
 
@@ -15,6 +24,7 @@ const TEMPLATE_MANAGED_FILES = new Set([
15
24
  '.dev.vars.example',
16
25
  'DIGIVALT_SETUP.md',
17
26
  'functions/api/fetch-with-access.ts',
27
+ 'functions/sitemap.xml.ts',
18
28
  'functions/api/gravity-forms-submit.ts',
19
29
  'functions/api/mautic-submit.ts',
20
30
  'functions/api/webhook.js',
@@ -332,6 +342,57 @@ function reconcileGenerateScript(status, templateGenerateScript) {
332
342
  }
333
343
  }
334
344
 
345
+ function buildEnvManagedBlock(managedBody) {
346
+ return `${ENV_MANAGED_START}\n${managedBody}\n${ENV_MANAGED_END}`;
347
+ }
348
+
349
+ function reconcileEnvExampleFile(relativePath, managedBody) {
350
+ const fullPath = path.join(targetDir, relativePath);
351
+ const block = buildEnvManagedBlock(managedBody);
352
+ const existing = readTextIfExists(fullPath);
353
+
354
+ const header =
355
+ relativePath === '.env.example'
356
+ ? '# DigiValt — public / team template (safe to commit). Use `.env` locally if your tooling expects `.env` only.'
357
+ : '# DigiValt — secrets template (commit as `.env.local.example`). Copy to `.env.local`; never commit real secrets.';
358
+
359
+ if (!existing) {
360
+ const out = `${header}\n\n${block}\n\n# Add project-specific variables below.\n`;
361
+ writeFileWithMode(fullPath, out);
362
+ report.refreshedFiles.push(
363
+ dryRun ? `Would create ${relativePath} with DigiValt env template.` : `Created ${relativePath} with DigiValt env template.`
364
+ );
365
+ return;
366
+ }
367
+
368
+ const startIndex = existing.indexOf(ENV_MANAGED_START);
369
+ const endIndex = existing.indexOf(ENV_MANAGED_END);
370
+ if (startIndex !== -1 && endIndex !== -1 && endIndex > startIndex) {
371
+ const before = existing.slice(0, startIndex).replace(/\s*$/, '');
372
+ const after = existing.slice(endIndex + ENV_MANAGED_END.length);
373
+ const out = `${before}\n\n${block}${after}`;
374
+ writeFileWithMode(fullPath, out);
375
+ report.refreshedFiles.push(
376
+ dryRun ? `Would update DigiValt block in ${relativePath}.` : `Updated DigiValt block in ${relativePath}.`
377
+ );
378
+ return;
379
+ }
380
+
381
+ report.warnings.push(
382
+ `${relativePath} had no DigiValt managed block; appended at end. Move custom content or merge manually once.`
383
+ );
384
+ const out = `${existing.replace(/\s*$/, '')}\n\n${block}\n`;
385
+ writeFileWithMode(fullPath, out);
386
+ report.refreshedFiles.push(
387
+ dryRun ? `Would append DigiValt block to ${relativePath}.` : `Appended DigiValt block to ${relativePath}.`
388
+ );
389
+ }
390
+
391
+ function reconcileEnvExampleFiles() {
392
+ reconcileEnvExampleFile('.env.example', ENV_EXAMPLE_MANAGED_BODY);
393
+ reconcileEnvExampleFile('.env.local.example', ENV_LOCAL_EXAMPLE_MANAGED_BODY);
394
+ }
395
+
335
396
  function copyTemplateFiles() {
336
397
  const templateFiles = listTemplateFiles(sourceDir);
337
398
 
@@ -399,10 +460,11 @@ function printSummary() {
399
460
  console.log('\n🎉 DigiVAlt init completed.');
400
461
  console.log('Next steps:');
401
462
  console.log('1. Review any warnings or manual follow-up items above.');
402
- console.log('2. Copy `.dev.vars.example` to `.dev.vars` if you need local Wrangler secrets.');
403
- console.log('3. Confirm `wrangler.toml` matches your Cloudflare Pages project name.');
404
- console.log('4. Run `npm run build` to verify static generation now runs before `vite build`.');
405
- console.log('5. Run `node scripts/deploy-secrets.js` when you are ready to sync allowed env vars to Cloudflare.');
463
+ console.log('2. Copy `.env.local.example` to `.env.local`, fill secrets, and keep `.env.example` as the team-facing public template.');
464
+ console.log('3. Copy `.dev.vars.example` to `.dev.vars` if you need local Wrangler secrets.');
465
+ console.log('4. Confirm `wrangler.toml` matches your Cloudflare Pages project name.');
466
+ console.log('5. Run `npm run build` to verify static generation now runs before `vite build`.');
467
+ console.log('6. Run `node scripts/deploy-secrets.js` when you are ready to sync allowed env vars to Cloudflare Pages.');
406
468
  }
407
469
 
408
470
  try {
@@ -417,6 +479,7 @@ try {
417
479
  patchPackageJson(status);
418
480
  reconcileGenerateScript(status, templateGenerateScript);
419
481
  copyTemplateFiles();
482
+ reconcileEnvExampleFiles();
420
483
  printSummary();
421
484
  } catch (error) {
422
485
  console.error('❌ Failed to construct templates:', error);
@@ -41,6 +41,10 @@ Read via `getPerformanceConfig()` from `@marvalt/digivalt-core/config`. Set to `
41
41
  - `VITE_PERF_SERVICE_WORKER` (default off)
42
42
  - `VITE_PERF_CACHING` (default on)
43
43
 
44
+ ## Sitemap (`/sitemap.xml`)
45
+
46
+ DigiValt apps ship a **Cloudflare Pages Function** at **`functions/sitemap.xml.ts`** that responds at **`/sitemap.xml`**. It reads **`VITE_SEO_SITE_URL`** (or **`SITE_URL`**) for canonical `<loc>` values and loads path slugs from **`public/sitemap-paths.json`** (shape: `{ "paths": ["/", "/pricing"] }`). Update that JSON when you add marketing routes; avoid a static **`public/sitemap.xml`** file, which would override the Function.
47
+
44
48
  ## Imports
45
49
 
46
50
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marvalt/digivalt-core",
3
- "version": "0.2.6",
3
+ "version": "0.2.9",
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",
@@ -30,7 +30,15 @@ Change them exactly to this:
30
30
  }
31
31
  ```
32
32
 
33
- ## 🔐 3. Define Secrets (`.env.local`)
33
+ ## 📄 3. Env templates (`.env.example` / `.env.local.example`)
34
+
35
+ After you run `digivalt-init`, the repo root should include **`.env.example`** (public placeholders, safe to commit) and **`.env.local.example`** (secret keys with empty values—also safe to commit as a template).
36
+
37
+ - Copy **`.env.local.example`** → **`.env.local`** and fill in real credentials. Never commit **`.env.local`**.
38
+ - Keep **`.env.example`** as the team-facing reference for non-secret `VITE_*` defaults and URLs.
39
+ - Variables between the **DigiValt-managed** marker comments are refreshed when you rerun init; add app-specific lines **outside** that block (or merge manually once if init appended a block to an older file without markers).
40
+
41
+ ## 🔐 4. Define Secrets (`.env.local`)
34
42
  Create a completely ignored `.env.local` file in the root of your project and populate all of these tightly coupled secrets:
35
43
 
36
44
  ```env
@@ -52,7 +60,7 @@ VITE_AUTH_MODE="direct"
52
60
  ```
53
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.*
54
62
 
55
- ## 🌩️ 4. Deploy Infrastructure to Cloudflare
63
+ ## 🌩️ 5. Deploy Infrastructure to Cloudflare
56
64
  Once you connect this GitHub repository to the Cloudflare Pages dashboard:
57
65
  1. Open `wrangler.toml` and change `name="digivalt-landing-api"` to match the exact name of your Cloudflare Pages project.
58
66
  2. Run this automated script to inject your allowed DigiVAlt `.env` / `.env.local` variables into Cloudflare Pages:
@@ -61,20 +69,22 @@ node scripts/deploy-secrets.js
61
69
  ```
62
70
  3. If the script fails, fix the reported Wrangler or project-name issue before deploying.
63
71
 
64
- ## 🔁 5. Rerunning Init Later
72
+ ## 🔁 6. Rerunning Init Later
65
73
  Rerun `npx digivalt-init` after upgrading `@marvalt/digivalt-core` when DigiVAlt-managed scaffolding changes.
66
74
 
67
75
  Init will:
68
76
  - patch safe `package.json` script cases automatically
69
77
  - refresh older DigiVAlt-managed `scripts/generate.ts` files
78
+ - reconcile the DigiValt-managed sections inside `.env.example` and `.env.local.example`
70
79
  - warn instead of overwriting custom build logic or custom generator scripts
71
80
  - report stale legacy workarounds that may no longer be needed
72
81
 
73
- ## 6. SEO and PageSpeed (optional)
82
+ ## 7. SEO and PageSpeed (optional)
74
83
 
75
84
  1. Install **`react-helmet-async`** and wrap your app with **`HelmetProvider`** at the root (next to the router).
76
85
  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`.
77
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.
78
- 4. 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`).
87
+ 4. **Sitemap:** After init, **`/sitemap.xml`** is served by **`functions/sitemap.xml.ts`** (Cloudflare Pages). Canonical URLs use **`VITE_SEO_SITE_URL`** (or **`SITE_URL`**); edit **`public/sitemap-paths.json`** (`{ "paths": ["/", "/about"] }`) when you add public routes. Do not add a static **`public/sitemap.xml`** (it would shadow the Function).
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`).
79
89
 
80
90
  You are fully onboarded and ready to develop offline!
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Serves /sitemap.xml from canonical site URL + public/sitemap-paths.json (no WordPress).
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
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "paths": ["/"]
3
+ }
@@ -17,9 +17,13 @@ const excludedKeys = new Set([
17
17
  'VITE_LOCAL_DEVELOPMENT',
18
18
  ]);
19
19
 
20
+ // VITE_SEO_* are often public (verification meta, titles); allow upload when teams store them as Pages secrets.
21
+ // TURNSTILE_SECRET_KEY is server-only (Pages Functions), not a VITE_ prefix.
20
22
  const allowedPatterns = [
21
23
  /^CLOUDFLARE_/,
22
24
  /^CF_ACCESS_/,
25
+ /^TURNSTILE_/,
26
+ /^VITE_SEO_/,
23
27
  /^VITE_(WORDPRESS|WP|GF|GRAVITY_FORMS|FRONTEND|FRONTENDS|MAUTIC|SUITECRM|SUPABASE|CLOUDFLARE|AUTH|TURNSTILE|CHATWOOT|THEME|GOOGLE_|FACEBOOK_|LINKEDIN_)/,
24
28
  ];
25
29