@majordigital/create-acorn 1.3.6 → 1.4.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.
@@ -335,6 +335,144 @@ async function setupStoryblok(projectName) {
335
335
  console.log('');
336
336
  }
337
337
 
338
+ async function setupDato(projectName) {
339
+ console.log('Setting up DatoCMS...');
340
+ console.log('');
341
+
342
+ const datoProjectName = await ask('Enter your DatoCMS project name', projectName);
343
+ console.log('');
344
+
345
+ // Install DatoCMS dependencies
346
+ console.log('Installing DatoCMS dependencies...');
347
+ await runCommand('npm', ['install', '--legacy-peer-deps',
348
+ '@datocms/cda-client', 'react-datocms',
349
+ 'datocms-structured-text-to-plain-text', 'datocms-structured-text-utils',
350
+ 'graphql', 'graphql-request', 'dotenv'
351
+ ]);
352
+ await runCommand('npm', ['install', '--save-dev', '--legacy-peer-deps',
353
+ '@datocms/cli', '@graphql-codegen/cli', '@graphql-codegen/typescript',
354
+ '@graphql-codegen/typescript-operations', '@graphql-codegen/typed-document-node',
355
+ '@graphql-typed-document-node/core', 'tsconfig-paths', 'tsx', 'cross-env'
356
+ ]);
357
+ console.log('');
358
+
359
+ // Update next.config.ts — replace Prismic image patterns with DatoCMS
360
+ const nextConfigPath = join(process.cwd(), 'next.config.ts');
361
+ try {
362
+ let config = readFileSync(nextConfigPath, 'utf-8');
363
+ config = config.replace(
364
+ /remotePatterns:\s*\[[\s\S]*?\],/,
365
+ `remotePatterns: [
366
+ {
367
+ protocol: 'https',
368
+ hostname: 'www.datocms-assets.com',
369
+ port: '',
370
+ pathname: '/**',
371
+ },
372
+ ],`
373
+ );
374
+ writeFileSync(nextConfigPath, config);
375
+ console.log('Updated next.config.ts with DatoCMS image domain.');
376
+ } catch {
377
+ console.log('Warning: Could not update next.config.ts image patterns.');
378
+ }
379
+
380
+ // Copy standardised DatoCMS API layer from dato/dato-api/
381
+ const __dirname = dirname(fileURLToPath(import.meta.url));
382
+ const datoSrcDir = join(__dirname, '..', 'dato', 'dato-api');
383
+
384
+ // Copy src/lib/datocms/ (client, blocks, helpers, fetch, etc.)
385
+ const targetLibDatocms = join(process.cwd(), 'src', 'lib', 'datocms');
386
+ cpSync(join(datoSrcDir, 'src', 'lib', 'datocms'), targetLibDatocms, { recursive: true });
387
+ console.log('Copied DatoCMS API layer (lib/datocms/).');
388
+
389
+ // Copy src/lib/ utilities (config, buildCache, getMetadata, utils)
390
+ const libFiles = ['config.ts', 'buildCache.ts', 'getMetadata.ts', 'utils.ts'];
391
+ for (const file of libFiles) {
392
+ cpSync(join(datoSrcDir, 'src', 'lib', file), join(process.cwd(), 'src', 'lib', file));
393
+ }
394
+ console.log('Copied utility files (config, buildCache, getMetadata, utils).');
395
+
396
+ // Copy src/generated/ placeholder files
397
+ const targetGenerated = join(process.cwd(), 'src', 'generated');
398
+ mkdirSync(targetGenerated, { recursive: true });
399
+ cpSync(join(datoSrcDir, 'src', 'generated'), targetGenerated, { recursive: true });
400
+ console.log('Copied generated placeholder files.');
401
+
402
+ // Copy graphql/ queries directory
403
+ const targetGraphql = join(process.cwd(), 'graphql');
404
+ cpSync(join(datoSrcDir, 'graphql'), targetGraphql, { recursive: true });
405
+ console.log('Copied GraphQL queries and fragments.');
406
+
407
+ // Copy graphql.config.yml
408
+ cpSync(join(datoSrcDir, 'graphql.config.yml'), join(process.cwd(), 'graphql.config.yml'));
409
+ console.log('Copied graphql.config.yml.');
410
+
411
+ // Copy scripts/buildLinkIndex.ts
412
+ const targetScripts = join(process.cwd(), 'scripts');
413
+ mkdirSync(targetScripts, { recursive: true });
414
+ cpSync(join(datoSrcDir, 'scripts', 'buildLinkIndex.ts'), join(targetScripts, 'buildLinkIndex.ts'));
415
+ console.log('Copied build scripts.');
416
+
417
+ // Update package.json scripts for DatoCMS
418
+ const pkgPath = join(process.cwd(), 'package.json');
419
+ try {
420
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
421
+ if (!pkg.scripts) pkg.scripts = {};
422
+ pkg.scripts.dev = 'next dev --turbo';
423
+ pkg.scripts.prebuild = 'tsx -r tsconfig-paths/register scripts/buildLinkIndex.ts';
424
+ pkg.scripts.build = 'next build';
425
+ pkg.scripts['build-stats'] = 'cross-env ANALYZE=true npm run build';
426
+ pkg.scripts.start = 'next start';
427
+ pkg.scripts.preview = 'next build && next start';
428
+ pkg.scripts.prepare = 'lefthook install || true';
429
+ pkg.scripts.check = 'biome lint . --skip suspicious/noConsole && biome format .';
430
+ pkg.scripts.fix = 'biome lint . --write --skip suspicious/noConsole && biome format . --write';
431
+ pkg.scripts['check:prod'] = 'biome check .';
432
+ pkg.scripts['fix:prod'] = 'biome check . --write';
433
+ pkg.scripts['generate-types'] = 'graphql-codegen --config graphql.config.yml';
434
+ pkg.scripts['check-types'] = 'tsc --noEmit';
435
+ writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
436
+ console.log('Updated package.json scripts for DatoCMS.');
437
+ } catch {
438
+ console.log('Warning: Could not update package.json scripts.');
439
+ }
440
+
441
+ // Update .env.example with DatoCMS variables
442
+ const envExamplePath = join(process.cwd(), '.env.example');
443
+ try {
444
+ writeFileSync(envExamplePath, `NEXT_DATOCMS_API_TOKEN=
445
+ DATO_API_TOKEN=
446
+ NEXT_DATOCMS_ENVIRONMENT=draft
447
+ `);
448
+ console.log('Updated .env.example with DatoCMS variables.');
449
+ } catch {
450
+ console.log('Warning: Could not update .env.example.');
451
+ }
452
+
453
+ console.log('');
454
+ console.log('=== DatoCMS Setup Complete ===');
455
+ console.log('');
456
+ console.log('=== Next Steps ===');
457
+ console.log('');
458
+ console.log(' 1. Go to https://www.datocms.com/dashboard and create a project (or use an existing one)');
459
+ console.log('');
460
+ console.log(' 2. Get your API token from Settings > API Tokens');
461
+ console.log('');
462
+ console.log(' 3. Add your token to .env.local:');
463
+ console.log(' NEXT_DATOCMS_API_TOKEN=your_api_token');
464
+ console.log(' NEXT_DATOCMS_ENVIRONMENT=draft');
465
+ console.log('');
466
+ console.log(' 4. Create your models in the DatoCMS dashboard');
467
+ console.log('');
468
+ console.log(' 5. Generate TypeScript types from your schema:');
469
+ console.log(' npm run generate-types');
470
+ console.log('');
471
+ console.log(' 6. Start your dev server:');
472
+ console.log(' npm run dev');
473
+ console.log('');
474
+ }
475
+
338
476
  function generateReadme(projectName, cms) {
339
477
  const title = projectName || 'Project Title';
340
478
 
@@ -459,9 +597,9 @@ This website is built using the [NextJS](https://nextjs.org/) framework, utilisi
459
597
  Create \`.env.local\` file in the root directory:
460
598
 
461
599
  \`\`\`env
462
- DATOCMS_API_TOKEN=your_api_token
463
- DATOCMS_PREVIEW_SECRET=your_preview_secret
464
- SITE_URL=http://localhost:3000
600
+ NEXT_DATOCMS_API_TOKEN=your_graphql_token
601
+ DATO_API_TOKEN=your_readonly_token
602
+ NEXT_DATOCMS_ENVIRONMENT="draft"
465
603
  \`\`\`
466
604
 
467
605
  3. **Start the development server**
@@ -556,9 +694,10 @@ INTERNAL_PREVIEW_API_KEY=
556
694
  NEXT_PUBLIC_SITE_URL=
557
695
  NEXT_PUBLIC_HUBSPOT_PORTAL_ID=
558
696
  `,
559
- dato: `DATOCMS_API_TOKEN=
560
- DATOCMS_PREVIEW_SECRET=
561
- SITE_URL=
697
+ dato: `NEXT_DATOCMS_API_TOKEN=
698
+ DATO_API_TOKEN=
699
+ NEXT_DATOCMS_ENVIRONMENT=draft
700
+ NEXT_PUBLIC_SITE_URL=
562
701
  `,
563
702
  };
564
703
  writeFileSync(join(process.cwd(), '.env.example'), envExamples[selection.key]);
@@ -573,10 +712,8 @@ SITE_URL=
573
712
  await setupPrismic(projectDir);
574
713
  } else if (selection.key === 'storyblok') {
575
714
  await setupStoryblok(projectDir);
576
- } else {
577
- console.log(`CMS preset ${selection.label} scaffolding is coming next.`);
578
- console.log('This run only confirms selection for non-Prismic options.');
579
- console.log('');
715
+ } else if (selection.key === 'dato') {
716
+ await setupDato(projectDir);
580
717
  }
581
718
  }
582
719
 
@@ -0,0 +1,10 @@
1
+ import { gql } from 'graphql-request';
2
+
3
+ export const BUTTON_FRAGMENT = gql`
4
+ fragment ButtonRecordFragment on ButtonRecord {
5
+ id
6
+ text
7
+ variant
8
+ link
9
+ }
10
+ `;
@@ -0,0 +1,31 @@
1
+ import { gql } from 'graphql-request';
2
+
3
+ export const HERO_FRAGMENT = gql`
4
+ fragment HeroRecordFragment on HeroRecord {
5
+ id
6
+ heading
7
+ eyebrow
8
+ heroContent: content
9
+ layout
10
+ theme
11
+ heroImage: image {
12
+ id
13
+ url
14
+ alt
15
+ responsiveImage(imgixParams: { w: 800, ar: "7:6", fit: crop }) {
16
+ ...responsiveImageFragment
17
+ }
18
+ }
19
+ heroImageFull: image {
20
+ id
21
+ url
22
+ alt
23
+ responsiveImage(imgixParams: { w: 1920, ar: "7:5" }) {
24
+ ...responsiveImageFragment
25
+ }
26
+ }
27
+ actions {
28
+ ...ButtonRecordFragment
29
+ }
30
+ }
31
+ `;
@@ -0,0 +1,9 @@
1
+ import { gql } from 'graphql-request';
2
+
3
+ export const META_TAGS_FRAGMENT = gql`
4
+ fragment metaTagsFragment on Tag {
5
+ attributes
6
+ content
7
+ tag
8
+ }
9
+ `;
@@ -0,0 +1,16 @@
1
+ import { gql } from 'graphql-request';
2
+
3
+ export const RESPONSIVE_IMAGE_FRAGMENT = gql`
4
+ fragment responsiveImageFragment on ResponsiveImage {
5
+ srcSet
6
+ webpSrcSet
7
+ sizes
8
+ src
9
+ width
10
+ height
11
+ aspectRatio
12
+ alt
13
+ title
14
+ base64
15
+ }
16
+ `;
@@ -0,0 +1,22 @@
1
+ import { gql } from 'graphql-request';
2
+
3
+ export const TARGET_FRAGMENT = gql`
4
+ fragment targetFragment on PageRecord {
5
+ __typename
6
+ id
7
+ heading
8
+ slug
9
+ parentPage {
10
+ id
11
+ slug
12
+ parentPage {
13
+ id
14
+ slug
15
+ parentPage {
16
+ id
17
+ slug
18
+ }
19
+ }
20
+ }
21
+ }
22
+ `;
@@ -0,0 +1,43 @@
1
+ import { gql } from 'graphql-request';
2
+
3
+ import { RESPONSIVE_IMAGE_FRAGMENT } from '../fields/responsiveimage';
4
+ import { TARGET_FRAGMENT } from '../fields/target';
5
+
6
+ /**
7
+ * Layout Query
8
+ *
9
+ * Fetches global layout data (navigation, footer, etc).
10
+ * Update this query to match your DatoCMS layout model.
11
+ */
12
+ export const LAYOUT_QUERY = gql`
13
+ ${TARGET_FRAGMENT}
14
+ ${RESPONSIVE_IMAGE_FRAGMENT}
15
+
16
+ query Layout {
17
+ layout {
18
+ id
19
+ navigation {
20
+ __typename
21
+ ... on MenuItemRecord {
22
+ id
23
+ heading
24
+ target {
25
+ ... on PageRecord {
26
+ ...targetFragment
27
+ }
28
+ }
29
+ }
30
+ }
31
+ footerNavigation {
32
+ id
33
+ heading
34
+ target {
35
+ ... on PageRecord {
36
+ ...targetFragment
37
+ }
38
+ }
39
+ }
40
+ copyright
41
+ }
42
+ }
43
+ `;
@@ -0,0 +1,47 @@
1
+ import { gql } from 'graphql-request';
2
+
3
+ import { BUTTON_FRAGMENT } from '../components/button';
4
+ import { HERO_FRAGMENT } from '../components/hero';
5
+ import { META_TAGS_FRAGMENT } from '../fields/metatags';
6
+ import { RESPONSIVE_IMAGE_FRAGMENT } from '../fields/responsiveimage';
7
+ import { TARGET_FRAGMENT } from '../fields/target';
8
+
9
+ /**
10
+ * Page Query
11
+ *
12
+ * Fetches a single page by slug with all body blocks.
13
+ * Add more component fragments as you create them.
14
+ */
15
+ export const PAGE_QUERY = gql`
16
+ ${RESPONSIVE_IMAGE_FRAGMENT}
17
+ ${META_TAGS_FRAGMENT}
18
+ ${TARGET_FRAGMENT}
19
+ ${BUTTON_FRAGMENT}
20
+ ${HERO_FRAGMENT}
21
+
22
+ query Page($slug: String) {
23
+ site: _site {
24
+ favicon: faviconMetaTags {
25
+ ...metaTagsFragment
26
+ }
27
+ }
28
+ page(filter: { slug: { eq: $slug } }) {
29
+ id
30
+ heading
31
+ description
32
+ parentPage {
33
+ ...targetFragment
34
+ }
35
+ slug
36
+ body {
37
+ __typename
38
+ ... on HeroRecord {
39
+ ...HeroRecordFragment
40
+ }
41
+ }
42
+ seo: _seoMetaTags {
43
+ ...metaTagsFragment
44
+ }
45
+ }
46
+ }
47
+ `;
@@ -0,0 +1,37 @@
1
+ import { gql } from 'graphql-request';
2
+
3
+ import { TARGET_FRAGMENT } from '../fields/target';
4
+
5
+ export const SLUGS_FRAGMENT = gql`
6
+ ${TARGET_FRAGMENT}
7
+
8
+ query AllSlugs {
9
+ allPages(first: 100) {
10
+ ...targetFragment
11
+ }
12
+ allCaseStudies(first: 100) {
13
+ id
14
+ slug
15
+ }
16
+ allLegalPages(first: 100) {
17
+ id
18
+ slug
19
+ download {
20
+ id
21
+ url
22
+ }
23
+ }
24
+ allPosts(first: 100) {
25
+ id
26
+ slug
27
+ }
28
+ caseStudiesListing {
29
+ id
30
+ slug
31
+ }
32
+ postListing {
33
+ id
34
+ slug
35
+ }
36
+ }
37
+ `;
@@ -0,0 +1,25 @@
1
+ schema:
2
+ - https://graphql.datocms.com:
3
+ headers:
4
+ Authorization: "Bearer ${DATOCMS_API_TOKEN}"
5
+ X-Exclude-Invalid: true
6
+ documents: ['graphql/queries/**/*.{ts,tsx}']
7
+ generates:
8
+ graphql/generated.ts:
9
+ plugins:
10
+ - typescript
11
+ - typescript-operations
12
+ - typed-document-node
13
+ config:
14
+ strictScalars: true
15
+ scalars:
16
+ BooleanType: boolean
17
+ CustomData: Record<string, unknown>
18
+ Date: string
19
+ DateTime: string
20
+ FloatType: number
21
+ IntType: number
22
+ ItemId: string
23
+ JsonField: unknown
24
+ MetaTagAttributes: Record<string, string>
25
+ UploadId: string
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "dato-api",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "generate-types": "graphql-codegen --config graphql.config.yml",
7
+ "prebuild": "tsx -r tsconfig-paths/register scripts/buildLinkIndex.ts"
8
+ },
9
+ "dependencies": {
10
+ "@datocms/cda-client": "^0.2.7",
11
+ "datocms-structured-text-to-plain-text": "^5.1.7",
12
+ "datocms-structured-text-utils": "^5.1.7",
13
+ "graphql": "^16.12.0",
14
+ "graphql-request": "^7.4.0",
15
+ "react-datocms": "^7.2.14"
16
+ },
17
+ "devDependencies": {
18
+ "@datocms/cli": "^2.0.23",
19
+ "@graphql-codegen/cli": "^5.0.7",
20
+ "@graphql-codegen/typed-document-node": "^5.1.2",
21
+ "@graphql-codegen/typescript": "^4.1.6",
22
+ "@graphql-codegen/typescript-operations": "^4.6.1",
23
+ "@graphql-typed-document-node/core": "^3.2.0",
24
+ "tsconfig-paths": "^4.2.0",
25
+ "tsx": "^4.21.0"
26
+ }
27
+ }
@@ -0,0 +1,149 @@
1
+ import 'dotenv/config';
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+
6
+ // Import your generated types:
7
+ // import type { AllSlugsQuery } from 'graphql/generated';
8
+ import { SLUGS_FRAGMENT } from 'graphql/queries/models/slugs';
9
+
10
+ import { performRequest } from '@/lib/datocms';
11
+
12
+ type PageNode = {
13
+ id: string;
14
+ slug: string;
15
+ parentPage?: PageNode | { id: string } | null;
16
+ };
17
+
18
+ // Replace with your generated AllSlugsQuery type:
19
+ type AllSlugsQuery = {
20
+ allPages: PageNode[];
21
+ allCaseStudies: Array<{ id: string; slug: string }>;
22
+ allLegalPages: Array<{
23
+ id: string;
24
+ slug: string;
25
+ download?: { id: string; url: string } | null;
26
+ }>;
27
+ allPosts: Array<{ id: string; slug: string }>;
28
+ };
29
+
30
+ function buildPagePathMap(pages: PageNode[]) {
31
+ const byId = new Map<string, PageNode>(pages.map((p) => [p.id, p]));
32
+ const cache = new Map<string, string>();
33
+ const visiting = new Set<string>();
34
+
35
+ const resolve = (id: string): string => {
36
+ const hit = cache.get(id);
37
+ if (hit) return hit;
38
+
39
+ if (visiting.has(id)) {
40
+ const n = byId.get(id);
41
+ const fallback = `/${n?.slug ?? id}`;
42
+ cache.set(id, fallback);
43
+ return fallback;
44
+ }
45
+ visiting.add(id);
46
+
47
+ const node = byId.get(id);
48
+ if (!node) {
49
+ const unknown = `/${id}`;
50
+ cache.set(id, unknown);
51
+ visiting.delete(id);
52
+ return unknown;
53
+ }
54
+
55
+ const parts: string[] = [node.slug];
56
+
57
+ let cur: PageNode | { id: string } | null =
58
+ (node.parentPage as PageNode | { id: string } | null) ?? null;
59
+
60
+ while (cur) {
61
+ const curFull = byId.get(cur.id) ?? (cur as PageNode);
62
+ parts.unshift(curFull.slug);
63
+
64
+ const nextParent = (curFull.parentPage ??
65
+ ('parentPage' in cur && typeof cur.parentPage === 'object'
66
+ ? cur.parentPage
67
+ : null)) as PageNode | { id: string } | null;
68
+
69
+ if (!nextParent) break;
70
+ cur =
71
+ (nextParent && byId.get(nextParent.id)) ||
72
+ (nextParent as PageNode);
73
+ }
74
+
75
+ const full = `/${parts.join('/')}`;
76
+ cache.set(id, full);
77
+ visiting.delete(id);
78
+ return full;
79
+ };
80
+
81
+ for (const p of pages) {
82
+ resolve(p.id);
83
+ }
84
+
85
+ return cache as Map<string, string>;
86
+ }
87
+
88
+ (async () => {
89
+ const data = await performRequest<AllSlugsQuery>(SLUGS_FRAGMENT);
90
+
91
+ const pagePathMap = buildPagePathMap(data.allPages);
92
+
93
+ type IdKey = string;
94
+ const idIndex: Record<IdKey, string> = {};
95
+ const pathIndex: Record<string, { id: string; model: string }> = {};
96
+
97
+ // Pages (hierarchical)
98
+ Array.from(pagePathMap.entries()).forEach(([id, full]) => {
99
+ idIndex[`page:${id}`] = full;
100
+ pathIndex[full] = { id, model: 'page' };
101
+ });
102
+
103
+ // Case studies (flat)
104
+ data.allCaseStudies.forEach((cs) => {
105
+ const full = `/case-studies/${cs.slug}`;
106
+ idIndex[`caseStudy:${cs.id}`] = full;
107
+ pathIndex[full] = { id: cs.id, model: 'caseStudy' };
108
+ });
109
+
110
+ // Legal pages (flat)
111
+ data.allLegalPages.forEach((lp) => {
112
+ const full = `/legal/${lp.slug}`;
113
+ idIndex[`legal:${lp.id}`] = full;
114
+ pathIndex[full] = { id: lp.id, model: 'legal' };
115
+ });
116
+
117
+ // Blog posts (flat)
118
+ data.allPosts.forEach((post) => {
119
+ const full = `/news/${post.slug}`;
120
+ idIndex[`post:${post.id}`] = full;
121
+ pathIndex[full] = { id: post.id, model: 'post' };
122
+ });
123
+
124
+ const paths = Object.keys(pathIndex).sort();
125
+
126
+ const outDir = path.join(process.cwd(), 'src', 'generated');
127
+ fs.mkdirSync(outDir, { recursive: true });
128
+ fs.writeFileSync(
129
+ path.join(outDir, 'idIndex.json'),
130
+ JSON.stringify(idIndex, null, 2)
131
+ );
132
+ fs.writeFileSync(
133
+ path.join(outDir, 'pathIndex.json'),
134
+ JSON.stringify(pathIndex, null, 2)
135
+ );
136
+ fs.writeFileSync(
137
+ path.join(outDir, 'paths.json'),
138
+ JSON.stringify(paths, null, 2)
139
+ );
140
+
141
+ // biome-ignore lint/suspicious/noConsoleLog: Build script output
142
+ console.log(
143
+ `Wrote ${Object.keys(idIndex).length} ids and ${paths.length} paths to src/generated/*.json`
144
+ );
145
+ })().catch((err) => {
146
+ // biome-ignore lint/suspicious/noConsoleLog: Build script output
147
+ console.error('buildLinkIndex failed:', err);
148
+ process.exit(1);
149
+ });
@@ -0,0 +1 @@
1
+ []
@@ -0,0 +1,28 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ const BUILD_ID_FILE = path.join(process.cwd(), '.build-id');
5
+
6
+ /**
7
+ * Returns the build cache ID generated at the start of the build.
8
+ * Falls back to current timestamp if no build ID file exists.
9
+ */
10
+ export function getBuildCacheId(): number {
11
+ try {
12
+ if (fs.existsSync(BUILD_ID_FILE)) {
13
+ const storedId = fs.readFileSync(BUILD_ID_FILE, 'utf8').trim();
14
+ const buildId = parseInt(storedId, 10);
15
+
16
+ if (Number.isNaN(buildId)) {
17
+ throw new Error('Stored build ID is not a valid number');
18
+ }
19
+
20
+ return buildId;
21
+ }
22
+ } catch (error) {
23
+ // biome-ignore lint/suspicious/noConsole: error logging
24
+ console.warn('Error reading build ID:', error);
25
+ }
26
+
27
+ return Date.now();
28
+ }
@@ -0,0 +1,7 @@
1
+ export const siteConfig = {
2
+ siteName: 'Project Name',
3
+ title: 'Project Title',
4
+ description: 'Project description',
5
+ locale: 'en',
6
+ url: process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000',
7
+ };
@@ -0,0 +1,62 @@
1
+ /**
2
+ * DatoCMS Block Component Mapping
3
+ *
4
+ * Maps DatoCMS record types to React components using __typename.
5
+ * Add your block types and component imports as you create them.
6
+ *
7
+ * Example usage in a page:
8
+ * import Blocks from '@/lib/datocms/blocks';
9
+ * <Blocks blocks={page.body} />
10
+ */
11
+
12
+ // Import your generated types:
13
+ // import type { HeroRecord, TextGridRecord } from 'graphql/generated';
14
+
15
+ // Import your components:
16
+ // import Hero from '@/ui/sections/Hero';
17
+ // import TextGrid from '@/ui/sections/TextGrid';
18
+
19
+ // Define your block union type:
20
+ // export type Block = HeroRecord | TextGridRecord;
21
+
22
+ // biome-ignore lint/suspicious/noExplicitAny: Block type should be replaced with generated types
23
+ type Block = { __typename: string; id: string; [key: string]: any };
24
+
25
+ function MissingBlock({ __typename }: { __typename: string }) {
26
+ return (
27
+ <div
28
+ style={{
29
+ padding: '2rem',
30
+ background: '#fff3cd',
31
+ border: '1px solid #ffc107',
32
+ borderRadius: '0.5rem',
33
+ margin: '1rem 0',
34
+ }}
35
+ >
36
+ <strong>Missing block component:</strong> {__typename}
37
+ </div>
38
+ );
39
+ }
40
+
41
+ export default function Blocks({ blocks }: { blocks: Block[] }) {
42
+ return (
43
+ <>
44
+ {blocks.map((block) => {
45
+ switch (block.__typename) {
46
+ // Add your block mappings here:
47
+ // case 'HeroRecord':
48
+ // return <Hero key={block.id} {...block} />;
49
+ // case 'TextGridRecord':
50
+ // return <TextGrid key={block.id} {...block} />;
51
+ default:
52
+ return process.env.NODE_ENV === 'development' ? (
53
+ <MissingBlock
54
+ key={block.id}
55
+ __typename={block.__typename}
56
+ />
57
+ ) : null;
58
+ }
59
+ })}
60
+ </>
61
+ );
62
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * DatoCMS Path Fetching
3
+ *
4
+ * Fetches all content slugs from DatoCMS for dynamic route generation.
5
+ * Update the query and types to match your DatoCMS schema.
6
+ */
7
+
8
+ // Import your generated types:
9
+ // import type { AllSlugsQuery } from 'graphql/generated';
10
+ import { SLUGS_FRAGMENT } from 'graphql/queries/models/slugs';
11
+
12
+ import { performRequest } from '..';
13
+
14
+ export interface Paths {
15
+ slug: string[];
16
+ }
17
+
18
+ // Replace with your generated AllSlugsQuery type:
19
+ type AllSlugsQuery = {
20
+ allPages: Array<{
21
+ slug: string;
22
+ parentPage?: { slug: string } | null;
23
+ }>;
24
+ allCaseStudies: Array<{ slug: string }>;
25
+ allLegalPages: Array<{ slug: string; download?: { id: string } | null }>;
26
+ allPosts: Array<{ slug: string }>;
27
+ };
28
+
29
+ export const fetchPaths = async (
30
+ filter: 'all' | 'page' | 'case-studies' | 'legal' | 'post'
31
+ ): Promise<Paths[]> => {
32
+ const data = await performRequest<AllSlugsQuery>(SLUGS_FRAGMENT);
33
+
34
+ let paths: Paths[] = [];
35
+
36
+ if (filter === 'all' || !filter) {
37
+ paths = [
38
+ ...data.allPages.map((page) =>
39
+ page.parentPage?.slug
40
+ ? {
41
+ slug: [page.parentPage.slug, page.slug],
42
+ }
43
+ : { slug: [page.slug] }
44
+ ),
45
+ ...data.allCaseStudies.map((caseStudy) => ({
46
+ slug: ['case-studies', caseStudy.slug],
47
+ })),
48
+ ...data.allLegalPages
49
+ .filter((legalPage) => !legalPage?.download)
50
+ .map((legalPage) => ({
51
+ slug: ['legal', legalPage.slug],
52
+ })),
53
+ ...data.allPosts.map((post) => ({ slug: ['news', post.slug] })),
54
+ ];
55
+ } else if (filter === 'page') {
56
+ paths = data.allPages.map((page) =>
57
+ page.parentPage?.slug
58
+ ? {
59
+ slug: [page.parentPage.slug, page.slug],
60
+ }
61
+ : { slug: [page.slug] }
62
+ );
63
+ } else if (filter === 'case-studies') {
64
+ paths = data.allCaseStudies.map((caseStudy) => ({
65
+ slug: ['case-studies', caseStudy.slug],
66
+ }));
67
+ } else if (filter === 'legal') {
68
+ paths = data.allLegalPages
69
+ .filter((legalPage) => !legalPage?.download)
70
+ .map((legalPage) => ({
71
+ slug: ['legal', legalPage.slug],
72
+ }));
73
+ } else if (filter === 'post') {
74
+ paths = data.allPosts.map((post) => ({ slug: ['news', post.slug] }));
75
+ }
76
+
77
+ return paths;
78
+ };
@@ -0,0 +1,54 @@
1
+ /**
2
+ * DatoCMS Page Helpers
3
+ *
4
+ * Utilities for resolving hierarchical page slugs.
5
+ * Update the types below to match your generated types from graphql-codegen.
6
+ */
7
+
8
+ // Import your generated types:
9
+ // import type { PageRecord, CaseStudiesListingRecord, PostListingRecord } from 'graphql/generated';
10
+
11
+ // Replace with your actual generated types:
12
+ type PageRecord = {
13
+ __typename: 'PageRecord';
14
+ slug: string;
15
+ parentPage?: PageRecord | null;
16
+ };
17
+
18
+ type CaseStudiesListingRecord = {
19
+ __typename: 'CaseStudiesListingRecord';
20
+ slug: string;
21
+ };
22
+
23
+ type PostListingRecord = {
24
+ __typename: 'PostListingRecord';
25
+ slug: string;
26
+ };
27
+
28
+ export type PageSlugTypes =
29
+ | PageRecord
30
+ | CaseStudiesListingRecord
31
+ | PostListingRecord;
32
+
33
+ /**
34
+ * Recursively builds the full URL path for a page by walking up the parent chain.
35
+ */
36
+ export const getPageSlug = (target: PageSlugTypes) => {
37
+ if (!target) return '/';
38
+
39
+ if (target.__typename !== 'PageRecord') {
40
+ return `/${target.slug}`;
41
+ }
42
+
43
+ const parentSlugs: string[] = [];
44
+ let current = target.parentPage;
45
+ while (current?.slug) {
46
+ parentSlugs.unshift(current.slug);
47
+ current = current.parentPage;
48
+ }
49
+ if (parentSlugs.length > 0) {
50
+ return `/${parentSlugs.join('/')}/${target.slug}`;
51
+ }
52
+
53
+ return `/${target.slug}`;
54
+ };
@@ -0,0 +1,27 @@
1
+ import { executeQuery } from '@datocms/cda-client';
2
+ import { cache } from 'react';
3
+
4
+ const dedupedExecuteQuery = cache(
5
+ async ([query, variables]: [string, Record<string, unknown>]) => {
6
+ const token = process.env.NEXT_DATOCMS_API_TOKEN;
7
+
8
+ if (!token) {
9
+ throw new Error(
10
+ 'NEXT_DATOCMS_API_TOKEN environment variable is not set'
11
+ );
12
+ }
13
+
14
+ return executeQuery(query, {
15
+ token,
16
+ includeDrafts: process.env.NEXT_DATOCMS_ENVIRONMENT === 'draft',
17
+ variables,
18
+ });
19
+ }
20
+ );
21
+
22
+ export function performRequest<T, V = Record<string, unknown>>(
23
+ query: string,
24
+ variables?: V
25
+ ): Promise<T> {
26
+ return dedupedExecuteQuery([query, variables || {}]) as Promise<T>;
27
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * DatoCMS BetterLink Resolution
3
+ *
4
+ * Resolves DatoCMS BetterLink plugin fields to actual URLs using
5
+ * build-time generated indexes (idIndex, pathIndex).
6
+ *
7
+ * Requires: npm run prebuild to generate src/generated/*.json
8
+ */
9
+
10
+ import idIndex from '@/generated/idIndex.json';
11
+ import pathIndex from '@/generated/pathIndex.json';
12
+
13
+ type BetterLinkType = 'record' | 'asset' | 'url' | 'tel' | 'email';
14
+
15
+ type BetterLinkRecord = {
16
+ id: string;
17
+ title: string;
18
+ cms_url: string;
19
+ slug: string;
20
+ status: string;
21
+ url: string;
22
+ };
23
+
24
+ type BetterLinkFormatted = {
25
+ isValid: boolean;
26
+ type: BetterLinkType;
27
+ text: string;
28
+ ariaLabel: string;
29
+ url: string;
30
+ target: '_self' | '_blank';
31
+ class: string | null;
32
+ };
33
+
34
+ export type BetterLinkField = {
35
+ linkType: { label: string; value: BetterLinkType };
36
+ record?: BetterLinkRecord;
37
+ asset?: Record<string, unknown>;
38
+ url?: Record<string, unknown>;
39
+ tel?: Record<string, unknown>;
40
+ email?: Record<string, unknown>;
41
+ formatted: BetterLinkFormatted;
42
+ open_in_new_window: boolean;
43
+ isValid: boolean;
44
+ };
45
+
46
+ type ResolvedLink = {
47
+ href: string;
48
+ label: string;
49
+ target?: '_self' | '_blank';
50
+ ariaLabel?: string;
51
+ };
52
+
53
+ /**
54
+ * Try to read a path from the idIndex regardless of how it was keyed.
55
+ * Supports:
56
+ * - idIndex["model:id"] (scoped)
57
+ * - idIndex["id"] (unscoped)
58
+ */
59
+ function pathFromIdIndex(
60
+ recordId: string,
61
+ maybeModel?: string
62
+ ): string | undefined {
63
+ const idx = idIndex as Record<string, string>;
64
+
65
+ if (maybeModel) {
66
+ const scopedKey = `${maybeModel}:${recordId}`;
67
+ if (scopedKey in idx) return idx[scopedKey];
68
+ }
69
+
70
+ if (recordId in idx) return idx[recordId];
71
+
72
+ const suffix = `:${recordId}`;
73
+ const foundKey = Object.keys(idx).find((k) => k.endsWith(suffix));
74
+ if (foundKey) return idx[foundKey];
75
+
76
+ return undefined;
77
+ }
78
+
79
+ /**
80
+ * Find a path by slug if all else fails.
81
+ */
82
+ function fallbackPathBySlug(slug: string): string | undefined {
83
+ const pidx = pathIndex as Record<string, { id: string; model: string }>;
84
+ const match = Object.keys(pidx).find((full) => {
85
+ const tail = full.split('/').filter(Boolean).pop();
86
+ return tail === slug;
87
+ });
88
+ return match;
89
+ }
90
+
91
+ export function resolveBetterLink(link: BetterLinkField): ResolvedLink {
92
+ const target = link.open_in_new_window
93
+ ? '_blank'
94
+ : link.formatted?.target || '_self';
95
+ const label =
96
+ link.formatted?.text ||
97
+ link.record?.title ||
98
+ link.formatted?.ariaLabel ||
99
+ 'Read more';
100
+ const ariaLabel = link.formatted?.ariaLabel || label;
101
+
102
+ switch (link.linkType.value) {
103
+ case 'url': {
104
+ const href = link.formatted?.url || '#';
105
+ return { href, label, target, ariaLabel };
106
+ }
107
+
108
+ case 'email': {
109
+ const raw = link.formatted?.url || '';
110
+ const href = raw.startsWith('mailto:') ? raw : `mailto:${raw}`;
111
+ return { href, label, target, ariaLabel };
112
+ }
113
+
114
+ case 'tel': {
115
+ const raw = link.formatted?.url || '';
116
+ const href = raw.startsWith('tel:') ? raw : `tel:${raw}`;
117
+ return { href, label, target, ariaLabel };
118
+ }
119
+
120
+ case 'asset': {
121
+ const href = link.formatted?.url || '#';
122
+ return { href, label, target, ariaLabel };
123
+ }
124
+
125
+ case 'record': {
126
+ const rec = link.record;
127
+ if (!rec?.id) return { href: '#', label, target, ariaLabel };
128
+
129
+ const viaId = pathFromIdIndex(rec.id);
130
+ if (viaId) return { href: viaId, label, target, ariaLabel };
131
+
132
+ if (rec.slug) {
133
+ const viaSlug = fallbackPathBySlug(rec.slug);
134
+ if (viaSlug)
135
+ return { href: viaSlug, label, target, ariaLabel };
136
+ }
137
+
138
+ const fallback = link.formatted?.url || '#';
139
+ return { href: fallback, label, target, ariaLabel };
140
+ }
141
+
142
+ default:
143
+ return { href: '#', label, target, ariaLabel };
144
+ }
145
+ }
@@ -0,0 +1,124 @@
1
+ import type { Metadata } from 'next';
2
+
3
+ import { siteConfig } from '@/lib/config';
4
+
5
+ interface SeoComponent {
6
+ title: string;
7
+ description: string;
8
+ og_title: string;
9
+ og_description: string;
10
+ og_image: string;
11
+ twitter_title: string;
12
+ twitter_description: string;
13
+ twitter_image: string;
14
+ no_index?: boolean;
15
+ no_follow?: boolean;
16
+ }
17
+
18
+ interface SeoFallback {
19
+ title?: string;
20
+ description?: string;
21
+ }
22
+
23
+ interface Config {
24
+ slug?: string;
25
+ twitterCreator?: string;
26
+ googleVerificationId?: string;
27
+ siteName?: string;
28
+ fallback?: SeoFallback;
29
+ }
30
+
31
+ export const getMetaData = (
32
+ data?: SeoComponent | null,
33
+ config?: Config
34
+ ): Metadata => {
35
+ if (!config && !data) {
36
+ return {};
37
+ }
38
+
39
+ const { googleVerificationId, slug, twitterCreator, siteName, fallback } =
40
+ config || {};
41
+
42
+ const fallbackTitle = fallback?.title || '';
43
+ const fallbackDescription = fallback?.description || '';
44
+
45
+ const fallbackMetadata = {
46
+ metadataBase: new URL(
47
+ process.env.NEXT_PUBLIC_APP_URL || siteConfig.url
48
+ ),
49
+ title: fallbackTitle,
50
+ description: fallbackDescription,
51
+ alternates: {
52
+ canonical: slug,
53
+ },
54
+ openGraph: {
55
+ title: fallbackTitle,
56
+ description: fallbackDescription,
57
+ url: slug,
58
+ siteName,
59
+ type: 'website',
60
+ },
61
+ twitter: {
62
+ card: 'summary_large_image',
63
+ title: fallbackTitle,
64
+ description: fallbackDescription,
65
+ site: twitterCreator,
66
+ creator: twitterCreator,
67
+ },
68
+ ...(googleVerificationId && {
69
+ verification: {
70
+ google: `google-site-verification=${googleVerificationId}`,
71
+ },
72
+ }),
73
+ };
74
+
75
+ if (!data) {
76
+ return fallbackMetadata;
77
+ }
78
+
79
+ const {
80
+ title,
81
+ description,
82
+ og_title,
83
+ og_description,
84
+ og_image,
85
+ twitter_title,
86
+ twitter_description,
87
+ twitter_image,
88
+ } = data;
89
+
90
+ const customMetaData = {
91
+ title: title || fallbackTitle,
92
+ description: description || fallbackDescription,
93
+ openGraph: {
94
+ title: og_title || title || fallbackTitle,
95
+ description: og_description || description || fallbackDescription,
96
+ url: slug,
97
+ siteName,
98
+ ...(og_image && {
99
+ images: {
100
+ url: og_image,
101
+ },
102
+ }),
103
+ type: 'website',
104
+ },
105
+ twitter: {
106
+ card: 'summary_large_image',
107
+ title: twitter_title || og_title || title || fallbackTitle,
108
+ description:
109
+ twitter_description ||
110
+ og_description ||
111
+ description ||
112
+ fallbackDescription,
113
+ site: twitterCreator,
114
+ creator: twitterCreator,
115
+ ...(twitter_image && {
116
+ images: {
117
+ url: twitter_image || og_image,
118
+ },
119
+ }),
120
+ },
121
+ };
122
+
123
+ return { ...fallbackMetadata, ...customMetaData };
124
+ };
@@ -0,0 +1,28 @@
1
+ export function isDraftEnabled(draftMode?: boolean): boolean {
2
+ return (
3
+ draftMode ||
4
+ process.env.NODE_ENV !== 'production' ||
5
+ process.env.NEXT_DATOCMS_ENVIRONMENT === 'draft'
6
+ );
7
+ }
8
+
9
+ export const generateIDFromString = (string: string): string => {
10
+ return string
11
+ .toLowerCase()
12
+ .replace(/<[^>]+>/g, '')
13
+ .replace(/[^a-zA-Z ]/g, '')
14
+ .split(' ')
15
+ .join('-');
16
+ };
17
+
18
+ export const formatDate = (dateString: string): string => {
19
+ const date = new Date(dateString);
20
+ return date.toLocaleDateString('en-GB', {
21
+ month: 'short',
22
+ year: '2-digit',
23
+ });
24
+ };
25
+
26
+ export const getCurrentTimestamp = (): number => {
27
+ return Date.now();
28
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@majordigital/create-acorn",
3
- "version": "1.3.6",
3
+ "version": "1.4.1",
4
4
  "description": "Interactive scaffold for Acorn with Storyblok/Prismic/DatoCMS, TypeScript, and Tailwind.",
5
5
  "bin": {
6
6
  "create-acorn": "bin/create-acorn.mjs",
@@ -21,6 +21,7 @@
21
21
  "bin",
22
22
  "template",
23
23
  "storyblok",
24
+ "dato",
24
25
  "README.md"
25
26
  ],
26
27
  "engines": {