@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.
- package/bin/create-acorn.mjs +147 -10
- package/dato/dato-api/graphql/queries/components/button.ts +10 -0
- package/dato/dato-api/graphql/queries/components/hero.ts +31 -0
- package/dato/dato-api/graphql/queries/fields/metatags.ts +9 -0
- package/dato/dato-api/graphql/queries/fields/responsiveimage.ts +16 -0
- package/dato/dato-api/graphql/queries/fields/target.ts +22 -0
- package/dato/dato-api/graphql/queries/models/layout.ts +43 -0
- package/dato/dato-api/graphql/queries/models/page.ts +47 -0
- package/dato/dato-api/graphql/queries/models/slugs.ts +37 -0
- package/dato/dato-api/graphql.config.yml +25 -0
- package/dato/dato-api/package.json +27 -0
- package/dato/dato-api/scripts/buildLinkIndex.ts +149 -0
- package/dato/dato-api/src/generated/idIndex.json +1 -0
- package/dato/dato-api/src/generated/pathIndex.json +1 -0
- package/dato/dato-api/src/generated/paths.json +1 -0
- package/dato/dato-api/src/lib/buildCache.ts +28 -0
- package/dato/dato-api/src/lib/config.ts +7 -0
- package/dato/dato-api/src/lib/datocms/blocks.tsx +62 -0
- package/dato/dato-api/src/lib/datocms/fetch/fetchPaths.ts +78 -0
- package/dato/dato-api/src/lib/datocms/helpers.ts +54 -0
- package/dato/dato-api/src/lib/datocms/index.ts +27 -0
- package/dato/dato-api/src/lib/datocms/resolveBetterLink.ts +145 -0
- package/dato/dato-api/src/lib/getMetadata.ts +124 -0
- package/dato/dato-api/src/lib/utils.ts +28 -0
- package/package.json +2 -1
package/bin/create-acorn.mjs
CHANGED
|
@@ -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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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: `
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
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,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,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 @@
|
|
|
1
|
+
{}
|
|
@@ -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,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
|
+
"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": {
|