From 68b0a12b3eecb0a85027518107e50104715e2ec7 Mon Sep 17 00:00:00 2001 From: Jasper Moelker Date: Tue, 5 Nov 2024 18:35:58 +0100 Subject: [PATCH 1/5] feature: Table of Contents - Adds unique html id slugs to heading elements. - Extracts these headings and turns them into a table of contents with links to the headings. --- package-lock.json | 37 ++++++++++++++--- package.json | 7 +++- src/blocks/TextBlock/nodes/Heading.astro | 4 +- src/blocks/index.ts | 7 ++++ src/components/Heading/Heading.astro | 20 +++++++++ .../HtmlIdSlugProvider.astro | 7 ++++ src/components/HtmlIdSlugProvider/README.md | 0 src/components/HtmlIdSlugProvider/index.ts | 18 ++++++++ src/components/TableOfContents/List.astro | 19 +++++++++ src/components/TableOfContents/README.md | 3 ++ .../TableOfContents/TableOfContents.astro | 13 ++++++ src/components/TableOfContents/index.ts | 37 +++++++++++++++++ src/layouts/Default.astro | 41 ++++++++++--------- src/lib/routing/html-id-slugger.ts | 18 ++++++++ src/pages/[locale]/[...path]/index.astro | 10 +++-- src/pages/[locale]/index.astro | 7 +++- 16 files changed, 216 insertions(+), 32 deletions(-) create mode 100644 src/blocks/index.ts create mode 100644 src/components/Heading/Heading.astro create mode 100644 src/components/HtmlIdSlugProvider/HtmlIdSlugProvider.astro create mode 100644 src/components/HtmlIdSlugProvider/README.md create mode 100644 src/components/HtmlIdSlugProvider/index.ts create mode 100644 src/components/TableOfContents/List.astro create mode 100644 src/components/TableOfContents/README.md create mode 100644 src/components/TableOfContents/TableOfContents.astro create mode 100644 src/components/TableOfContents/index.ts create mode 100644 src/lib/routing/html-id-slugger.ts diff --git a/package-lock.json b/package-lock.json index a9b582c1..8973682d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,11 @@ "datocms-listen": "^0.1.15", "datocms-structured-text-utils": "^2.0.4", "get-video-id": "^3.6.5", + "github-slugger": "^2.0.0", "globby": "^13.2.2", + "hast-util-from-html": "^2.0.3", + "hast-util-heading": "^3.0.0", + "hast-util-to-string": "^3.0.1", "hls.js": "^1.5.2", "html-validate": "^8.7.4", "jiti": "^1.20.0", @@ -29,7 +33,8 @@ "promise-all-props": "^3.0.0", "regexparam": "^3.0.0", "rosetta": "^1.1.0", - "typescript": "^5.2.2" + "typescript": "^5.2.2", + "unist-util-visit": "^5.0.0" }, "devDependencies": { "@astrojs/ts-plugin": "^1.10.2", @@ -10455,8 +10460,7 @@ "node_modules/github-slugger": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", - "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", - "license": "ISC" + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==" }, "node_modules/glob": { "version": "7.2.3", @@ -10868,7 +10872,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", - "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", @@ -10902,6 +10905,19 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-heading": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-heading/-/hast-util-heading-3.0.0.tgz", + "integrity": "sha512-SykluYSLOs7z72hUUcztJpPV20alz58pfbi8g/NckXPnJ4OFVwPidNz3XOqgSNu5MTeFvde5c0cFVUk319Qlqw==", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-is-element": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-is-element": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", @@ -10995,6 +11011,18 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-to-string": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz", + "integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-text": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", @@ -17993,7 +18021,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", - "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", diff --git a/package.json b/package.json index e76c9b6d..e85abb5d 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,11 @@ "datocms-listen": "^0.1.15", "datocms-structured-text-utils": "^2.0.4", "get-video-id": "^3.6.5", + "github-slugger": "^2.0.0", "globby": "^13.2.2", + "hast-util-from-html": "^2.0.3", + "hast-util-heading": "^3.0.0", + "hast-util-to-string": "^3.0.1", "hls.js": "^1.5.2", "html-validate": "^8.7.4", "jiti": "^1.20.0", @@ -59,7 +63,8 @@ "promise-all-props": "^3.0.0", "regexparam": "^3.0.0", "rosetta": "^1.1.0", - "typescript": "^5.2.2" + "typescript": "^5.2.2", + "unist-util-visit": "^5.0.0" }, "devDependencies": { "@astrojs/ts-plugin": "^1.10.2", diff --git a/src/blocks/TextBlock/nodes/Heading.astro b/src/blocks/TextBlock/nodes/Heading.astro index 939ebfb8..849364ed 100644 --- a/src/blocks/TextBlock/nodes/Heading.astro +++ b/src/blocks/TextBlock/nodes/Heading.astro @@ -1,5 +1,6 @@ --- import type { Heading } from 'datocms-structured-text-utils'; +import HeadingComponent from '@components/Heading/Heading.astro'; interface Props { node: Heading; @@ -12,6 +13,5 @@ const minimumLevel = 2; const defaultLevel = 2; const level = Math.max(minimumLevel, (node.level || defaultLevel)); -const Tag = `h${level}`; --- - + diff --git a/src/blocks/index.ts b/src/blocks/index.ts new file mode 100644 index 00000000..907894d9 --- /dev/null +++ b/src/blocks/index.ts @@ -0,0 +1,7 @@ +import type { AnyBlock } from './Blocks'; +import { renderToString } from '@lib/renderer'; +import Blocks from './Blocks.astro'; + +export const renderBlocks = async (blocks: AnyBlock[]) => { + return await renderToString(Blocks, { props: { blocks } }); +}; diff --git a/src/components/Heading/Heading.astro b/src/components/Heading/Heading.astro new file mode 100644 index 00000000..aaf189bb --- /dev/null +++ b/src/components/Heading/Heading.astro @@ -0,0 +1,20 @@ +--- +import type { HTMLTag } from 'astro/types'; +import type { Heading } from 'datocms-structured-text-utils'; +import { htmlIdSlug } from '@components/HtmlIdSlugProvider/'; + +interface Props { + id: string; + level: Heading['level']; +} +const { id, level = 2, ...props } = Astro.props; + +const slug = htmlIdSlug({ + id, + html: await Astro.slots.render('default') +}); + +const Tag = `h${level}` as HTMLTag; +--- + + diff --git a/src/components/HtmlIdSlugProvider/HtmlIdSlugProvider.astro b/src/components/HtmlIdSlugProvider/HtmlIdSlugProvider.astro new file mode 100644 index 00000000..ccf29a3a --- /dev/null +++ b/src/components/HtmlIdSlugProvider/HtmlIdSlugProvider.astro @@ -0,0 +1,7 @@ +--- +import { resetHtmlIdSlugger } from './index'; + +resetHtmlIdSlugger(); +--- + + diff --git a/src/components/HtmlIdSlugProvider/README.md b/src/components/HtmlIdSlugProvider/README.md new file mode 100644 index 00000000..e69de29b diff --git a/src/components/HtmlIdSlugProvider/index.ts b/src/components/HtmlIdSlugProvider/index.ts new file mode 100644 index 00000000..b40c2629 --- /dev/null +++ b/src/components/HtmlIdSlugProvider/index.ts @@ -0,0 +1,18 @@ +import GithubSlugger from 'github-slugger'; +import { fromHtml } from 'hast-util-from-html'; +import { toString } from 'hast-util-to-string'; + +const slugger = new GithubSlugger(); + +export const resetHtmlIdSlugger = () => { + slugger.reset(); +}; + +export const htmlIdSlug = ({ id, html }: { id?: string; html: string }) => { + if (id) { + return slugger.slug(id); + } + const tree = fromHtml(html, { fragment: true }); + const text = toString(tree); + return slugger.slug(text); +}; diff --git a/src/components/TableOfContents/List.astro b/src/components/TableOfContents/List.astro new file mode 100644 index 00000000..cbbeb970 --- /dev/null +++ b/src/components/TableOfContents/List.astro @@ -0,0 +1,19 @@ +--- +import type { TreeItem } from './index'; + +interface Props { + items: TreeItem[]; +} +const { items } = Astro.props; +--- + +
    + { items.map((item) => ( +
  1. + { item.text } + { item.items && ( + + ) } +
  2. + )) } +
diff --git a/src/components/TableOfContents/README.md b/src/components/TableOfContents/README.md new file mode 100644 index 00000000..a69210d1 --- /dev/null +++ b/src/components/TableOfContents/README.md @@ -0,0 +1,3 @@ +# Table of Contents + +**Render a list of links to headings in a given HTML string.** diff --git a/src/components/TableOfContents/TableOfContents.astro b/src/components/TableOfContents/TableOfContents.astro new file mode 100644 index 00000000..7e8e441a --- /dev/null +++ b/src/components/TableOfContents/TableOfContents.astro @@ -0,0 +1,13 @@ +--- +import { t } from '@lib/i18n'; +import { extractTocItemsFromHtml } from './index'; +import List from './List.astro'; + +const { html } = Astro.props; +const items = extractTocItemsFromHtml(html); +--- + + diff --git a/src/components/TableOfContents/index.ts b/src/components/TableOfContents/index.ts new file mode 100644 index 00000000..a1497f33 --- /dev/null +++ b/src/components/TableOfContents/index.ts @@ -0,0 +1,37 @@ +import { fromHtml } from 'hast-util-from-html'; +import { toString } from 'hast-util-to-string'; +import { visit } from 'unist-util-visit'; + +export type TreeItem = { + id: string; + level: number; + text: string; + items?: TreeItem[]; +}; + +export const extractTocItemsFromHtml = (html: string) => { + + const hast = fromHtml(html, { fragment: true }); + const tagNames = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']); + const items: TreeItem[] = []; + + visit(hast, 'element', (node) => { + if (tagNames.has(node.tagName) && node.properties?.id) { + const text = toString(node); + const item: TreeItem = { + id: String(node.properties.id), + level: parseInt(node.tagName.slice(1), 10), + text, + }; + const lastItem = items[items.length - 1]; + if (lastItem && lastItem.level < item.level) { + lastItem.items = lastItem.items ?? []; + lastItem.items.push(item); + } else { + items.push(item); + } + } + }); + + return items; +}; diff --git a/src/layouts/Default.astro b/src/layouts/Default.astro index a515d226..6f1b261d 100644 --- a/src/layouts/Default.astro +++ b/src/layouts/Default.astro @@ -8,6 +8,7 @@ import type { PageUrl } from '@lib/seo'; import query from './default.query.graphql'; import Breadcrumbs from '@components/Breadcrumbs/Breadcrumbs.astro'; import type { Breadcrumb } from '@components/Breadcrumbs'; +import HtmlIdSlugProvider from '@components/HtmlIdSlugProvider/HtmlIdSlugProvider.astro'; import IconSprite from '@components/Icon/IconSprite.astro'; import LocaleSelector from '@components/LocaleSelector/LocaleSelector.astro'; import PreviewModeProvider from '@components/PreviewMode/PreviewModeProvider.astro'; @@ -41,25 +42,27 @@ const mainContentId = 'content'; - -
- { /* accessible home link, inspired by https://www.gov.uk/; with Microformats rel */ } - [Logo] - -
- { (breadcrumbs.length > 0) && ( - - ) } - { /* main element requires tabindex to be focusable, see SkipLink/README.md */ } -
- -
-
-

Footer

-
+ + +
+ { /* accessible home link, inspired by https://www.gov.uk/; with Microformats rel */ } + [Logo] + +
+ { (breadcrumbs.length > 0) && ( + + ) } + { /* main element requires tabindex to be focusable, see SkipLink/README.md */ } +
+ +
+
+

Footer

+
+
diff --git a/src/lib/routing/html-id-slugger.ts b/src/lib/routing/html-id-slugger.ts new file mode 100644 index 00000000..b40c2629 --- /dev/null +++ b/src/lib/routing/html-id-slugger.ts @@ -0,0 +1,18 @@ +import GithubSlugger from 'github-slugger'; +import { fromHtml } from 'hast-util-from-html'; +import { toString } from 'hast-util-to-string'; + +const slugger = new GithubSlugger(); + +export const resetHtmlIdSlugger = () => { + slugger.reset(); +}; + +export const htmlIdSlug = ({ id, html }: { id?: string; html: string }) => { + if (id) { + return slugger.slug(id); + } + const tree = fromHtml(html, { fragment: true }); + const text = toString(tree); + return slugger.slug(text); +}; diff --git a/src/pages/[locale]/[...path]/index.astro b/src/pages/[locale]/[...path]/index.astro index 399a47ec..a8f9323d 100644 --- a/src/pages/[locale]/[...path]/index.astro +++ b/src/pages/[locale]/[...path]/index.astro @@ -1,9 +1,9 @@ --- -import { datocmsCollection, datocmsRequest } from '@lib/datocms'; import type { PageQuery, PageRecord, ParentPageFragment, SiteLocale } from '@lib/types/datocms'; import type { PageUrl } from '@lib/types/page-url'; +import { datocmsCollection, datocmsRequest } from '@lib/datocms'; import Layout from '@layouts/Default.astro'; -import Blocks from '@blocks/Blocks.astro'; +import { renderBlocks } from '@blocks/index'; import type { AnyBlock } from '@blocks/Blocks'; import { getParentPages, @@ -13,6 +13,7 @@ import { import { formatBreadcrumb } from '@components/Breadcrumbs'; import PreviewModeSubscription from '@components/PreviewMode/PreviewModeSubscription.astro'; import ShareButton from '@components/ShareButton/ShareButton.astro'; +import TableOfContents from '@components/TableOfContents/TableOfContents.astro'; import query from './_index.query.graphql'; export async function getStaticPaths() { @@ -70,6 +71,8 @@ const pageUrls = (page._allSlugLocales || []).map(({ locale }) => ({ locale: locale as SiteLocale, })}/`, })) as PageUrl[]; + +const blocksHtml = await renderBlocks(page.bodyBlocks as AnyBlock[]); --- ({ >

{page.title}

- + +
diff --git a/src/pages/[locale]/index.astro b/src/pages/[locale]/index.astro index a8bb1f4f..748f65ac 100644 --- a/src/pages/[locale]/index.astro +++ b/src/pages/[locale]/index.astro @@ -4,10 +4,11 @@ import type { HomePageQuery, HomePageRecord } from '@lib/types/datocms'; import type { PageUrl } from '@lib/types/page-url'; import { locales } from '@lib/i18n'; import Layout from '@layouts/Default.astro'; -import Blocks from '@blocks/Blocks.astro'; +import { renderBlocks } from '@blocks/index'; import type { AnyBlock } from '@blocks/Blocks'; import PreviewModeSubscription from '@components/PreviewMode/PreviewModeSubscription.astro'; import ShareButton from '@components/ShareButton/ShareButton.astro'; +import TableOfContents from '@components/TableOfContents/TableOfContents.astro'; import query from './_index.query.graphql'; export async function getStaticPaths() { @@ -24,11 +25,13 @@ const pageUrls = locales.map((locale) => ({ locale, pathname: `/${locale}/`, })) as PageUrl[]; +const blocksHtml = await renderBlocks(page.bodyBlocks as AnyBlock[]); ---

{page.title}

- + +
From 0778478956bc244cbe0a4ce0a3dd45f0d936dadb Mon Sep 17 00:00:00 2001 From: Jasper Moelker Date: Tue, 5 Nov 2024 18:41:55 +0100 Subject: [PATCH 2/5] chore: fix npm audit issues --- package-lock.json | 78 +++++++++++++++-------------------------------- package.json | 1 - 2 files changed, 25 insertions(+), 54 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8973682d..f391cdbd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,6 @@ "github-slugger": "^2.0.0", "globby": "^13.2.2", "hast-util-from-html": "^2.0.3", - "hast-util-heading": "^3.0.0", "hast-util-to-string": "^3.0.1", "hls.js": "^1.5.2", "html-validate": "^8.7.4", @@ -9225,9 +9224,9 @@ } }, "node_modules/dset": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.3.tgz", - "integrity": "sha512-20TuZZHCEZ2O71q9/+8BwKwZ0QtD9D8ObhrihJPr+vLLYlSuAU3/zL4cSlgbfeoGHTjCSJBa7NGcrF9/Bx/WJQ==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", + "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==", "engines": { "node": ">=4" } @@ -9238,9 +9237,9 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" }, "node_modules/ejs": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.9.tgz", - "integrity": "sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ==", + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", "dev": true, "dependencies": { "jake": "^10.8.5" @@ -10905,19 +10904,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/hast-util-heading": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-heading/-/hast-util-heading-3.0.0.tgz", - "integrity": "sha512-SykluYSLOs7z72hUUcztJpPV20alz58pfbi8g/NckXPnJ4OFVwPidNz3XOqgSNu5MTeFvde5c0cFVUk319Qlqw==", - "dependencies": { - "@types/hast": "^3.0.0", - "hast-util-is-element": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/hast-util-is-element": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", @@ -12690,18 +12676,6 @@ "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", "dev": true }, - "node_modules/lodash.trim": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/lodash.trim/-/lodash.trim-4.5.1.tgz", - "integrity": "sha512-nJAlRl/K+eiOehWKDzoBVrSMhK0K3A3YQsUNXHQa5yIrKBAhsZgSu3KoAFoFT+mEgiyBHddZ0pRk1ITpIp90Wg==", - "dev": true - }, - "node_modules/lodash.trimstart": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/lodash.trimstart/-/lodash.trimstart-4.5.1.tgz", - "integrity": "sha512-b/+D6La8tU76L/61/aN0jULWHkT0EeJCmVstPBn/K9MtD2qBW83AsBNrr63dKuWYwVMO7ucv13QNO/Ek/2RKaQ==", - "dev": true - }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -17283,14 +17257,14 @@ } }, "node_modules/svg-sprite": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/svg-sprite/-/svg-sprite-2.0.2.tgz", - "integrity": "sha512-vLFP/t4YCu62mvOzUt6g9bqpKrPjYsLuzegw5WsIsv3DkulAI/fRC+k7Atk//rIkUDbvKo572nJ6o4YT+FbKig==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/svg-sprite/-/svg-sprite-2.0.4.tgz", + "integrity": "sha512-kjDoATgr4k6tdtfQczpkbuFW6RE7tPUPe/rbRd1n2NV92kdwaXEZMIxJqAZfMGOMfU/Kp1u89SUYsfHCbAvVHg==", "dev": true, "dependencies": { - "@resvg/resvg-js": "^2.1.0", - "@xmldom/xmldom": "^0.8.3", - "async": "^3.2.4", + "@resvg/resvg-js": "^2.6.0", + "@xmldom/xmldom": "^0.8.10", + "async": "^3.2.5", "css-selector-parser": "^1.4.1", "csso": "^4.2.0", "cssom": "^0.5.0", @@ -17298,15 +17272,13 @@ "js-yaml": "^4.1.0", "lodash.escape": "^4.0.1", "lodash.merge": "^4.6.2", - "lodash.trim": "^4.5.1", - "lodash.trimstart": "^4.5.1", "mustache": "^4.2.0", "prettysize": "^2.0.0", "svgo": "^2.8.0", "vinyl": "^2.2.1", - "winston": "^3.8.2", - "xpath": "^0.0.32", - "yargs": "^17.5.1" + "winston": "^3.11.0", + "xpath": "^0.0.34", + "yargs": "^17.7.2" }, "bin": { "svg-sprite": "bin/svg-sprite.js" @@ -19469,9 +19441,9 @@ "license": "MIT" }, "node_modules/xpath": { - "version": "0.0.32", - "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.32.tgz", - "integrity": "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw==", + "version": "0.0.34", + "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.34.tgz", + "integrity": "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==", "dev": true, "engines": { "node": ">=0.6.0" @@ -19689,19 +19661,19 @@ } }, "node_modules/youch": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/youch/-/youch-3.3.3.tgz", - "integrity": "sha512-qSFXUk3UZBLfggAW3dJKg0BMblG5biqSF8M34E06o5CSsZtH92u9Hqmj2RzGiHDi64fhe83+4tENFP2DB6t6ZA==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/youch/-/youch-3.3.4.tgz", + "integrity": "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==", "dependencies": { - "cookie": "^0.5.0", + "cookie": "^0.7.1", "mustache": "^4.2.0", "stacktracey": "^2.1.8" } }, "node_modules/youch/node_modules/cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "engines": { "node": ">= 0.6" } diff --git a/package.json b/package.json index e85abb5d..89e9aefc 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,6 @@ "github-slugger": "^2.0.0", "globby": "^13.2.2", "hast-util-from-html": "^2.0.3", - "hast-util-heading": "^3.0.0", "hast-util-to-string": "^3.0.1", "hls.js": "^1.5.2", "html-validate": "^8.7.4", From 0c8bf8c266185795658ce006d1d61f7a6f01868e Mon Sep 17 00:00:00 2001 From: Jasper Moelker Date: Sat, 21 Dec 2024 23:19:09 +0100 Subject: [PATCH 3/5] fix: extract renderToString to resolve JSDOM conflicts However, this problem remains: ``` [ERROR] [vite] x Build failed in 1.25s [commonjs--resolver] [plugin vite:resolve] Cannot bundle Node.js built-in "node:path" imported from "node_modules/astro/dist/core/config/schema.js". Consider disabling ssr.noExternal or remove the built-in dependency. ``` --- datocms-environment.ts | 2 +- src/blocks/index.ts | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/datocms-environment.ts b/datocms-environment.ts index a1ab7bd4..c66777e8 100644 --- a/datocms-environment.ts +++ b/datocms-environment.ts @@ -3,5 +3,5 @@ * @see docs/getting-started.md on how to use this file * @see docs/decision-log/2023-10-24-datocms-env-file.md on why file is preferred over env vars */ -export const datocmsEnvironment = '148-file-downloads'; +export const datocmsEnvironment = 'action-block'; export const datocmsBuildTriggerId = '30535'; diff --git a/src/blocks/index.ts b/src/blocks/index.ts index 907894d9..9d16adaf 100644 --- a/src/blocks/index.ts +++ b/src/blocks/index.ts @@ -1,7 +1,16 @@ +import { experimental_AstroContainer as AstroContainer, type ContainerRenderOptions } from 'astro/container'; +import type { AstroComponentFactory } from 'astro/runtime/server/index.js'; import type { AnyBlock } from './Blocks'; -import { renderToString } from '@lib/renderer'; import Blocks from './Blocks.astro'; +export async function renderToString( + component: AstroComponentFactory, + options?: ContainerRenderOptions & { props?: Props } +): Promise { + const container = await AstroContainer.create(); + return container.renderToString(component, options); +} + export const renderBlocks = async (blocks: AnyBlock[]) => { return await renderToString(Blocks, { props: { blocks } }); }; From f1eab5e3f81fc16745a9e3ffd23f0629943a33d2 Mon Sep 17 00:00:00 2001 From: Jasper Moelker Date: Sun, 22 Dec 2024 13:57:20 +0100 Subject: [PATCH 4/5] fix: replace Container.render with slots.render to resolve node:path issue --- src/blocks/index.ts | 16 ------------- .../TableOfContents/TableOfContents.astro | 15 ++++++++---- src/lib/routing/html-id-slugger.ts | 18 --------------- src/pages/[locale]/[...path]/index.astro | 23 +++++++++++-------- src/pages/[locale]/index.astro | 7 ++---- 5 files changed, 26 insertions(+), 53 deletions(-) delete mode 100644 src/blocks/index.ts delete mode 100644 src/lib/routing/html-id-slugger.ts diff --git a/src/blocks/index.ts b/src/blocks/index.ts deleted file mode 100644 index 9d16adaf..00000000 --- a/src/blocks/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { experimental_AstroContainer as AstroContainer, type ContainerRenderOptions } from 'astro/container'; -import type { AstroComponentFactory } from 'astro/runtime/server/index.js'; -import type { AnyBlock } from './Blocks'; -import Blocks from './Blocks.astro'; - -export async function renderToString( - component: AstroComponentFactory, - options?: ContainerRenderOptions & { props?: Props } -): Promise { - const container = await AstroContainer.create(); - return container.renderToString(component, options); -} - -export const renderBlocks = async (blocks: AnyBlock[]) => { - return await renderToString(Blocks, { props: { blocks } }); -}; diff --git a/src/components/TableOfContents/TableOfContents.astro b/src/components/TableOfContents/TableOfContents.astro index 7e8e441a..fa1e3e05 100644 --- a/src/components/TableOfContents/TableOfContents.astro +++ b/src/components/TableOfContents/TableOfContents.astro @@ -3,11 +3,16 @@ import { t } from '@lib/i18n'; import { extractTocItemsFromHtml } from './index'; import List from './List.astro'; -const { html } = Astro.props; +const html = await Astro.slots.render('default'); const items = extractTocItemsFromHtml(html); --- - +{ + items.length > 0 && ( + + ) +} + diff --git a/src/lib/routing/html-id-slugger.ts b/src/lib/routing/html-id-slugger.ts deleted file mode 100644 index b40c2629..00000000 --- a/src/lib/routing/html-id-slugger.ts +++ /dev/null @@ -1,18 +0,0 @@ -import GithubSlugger from 'github-slugger'; -import { fromHtml } from 'hast-util-from-html'; -import { toString } from 'hast-util-to-string'; - -const slugger = new GithubSlugger(); - -export const resetHtmlIdSlugger = () => { - slugger.reset(); -}; - -export const htmlIdSlug = ({ id, html }: { id?: string; html: string }) => { - if (id) { - return slugger.slug(id); - } - const tree = fromHtml(html, { fragment: true }); - const text = toString(tree); - return slugger.slug(text); -}; diff --git a/src/pages/[locale]/[...path]/index.astro b/src/pages/[locale]/[...path]/index.astro index 9ab60deb..10a2f11b 100644 --- a/src/pages/[locale]/[...path]/index.astro +++ b/src/pages/[locale]/[...path]/index.astro @@ -1,13 +1,17 @@ --- -import { type PageRouteForPath, getPagePath, getPageSlugFromPath, getParentPages } from '@lib/routing/page'; +import { + type PageRouteForPath, + getPagePath, + getPageSlugFromPath, + getParentPages, +} from '@lib/routing/page'; import type { PageUrl } from '@lib/types/page-url'; import { datocmsCollection, datocmsRequest } from '@lib/datocms'; import { getPageHref } from '@lib/routing/'; import type { PageQuery, SiteLocale } from '@lib/datocms/types'; import Layout from '@layouts/Default.astro'; -import { renderBlocks } from '@blocks/index'; -import type { AnyBlock } from '@blocks/Blocks'; - +import type { AnyBlock } from '@blocks/Blocks.astro'; +import Blocks from '@blocks/Blocks.astro'; import { formatBreadcrumb } from '@components/Breadcrumbs'; import PreviewModeSubscription from '@components/PreviewMode/PreviewModeSubscription.astro'; import ShareButton from '@components/ShareButton/ShareButton.astro'; @@ -35,7 +39,9 @@ export async function getStaticPaths() { }); return pages.flatMap((page) => { - const locales = page._allSlugLocales?.map((slug) => slug.locale).filter(locale => !!locale); + const locales = page._allSlugLocales + ?.map((slug) => slug.locale) + .filter((locale) => !!locale); return locales?.map((locale) => { return { params: { locale, path: getPagePath({ page, locale }) } }; }); @@ -62,8 +68,6 @@ const pageUrls = (page._allSlugLocales || []).map(({ locale }) => ({ locale: locale as SiteLocale, pathname: getPageHref({ locale: locale as SiteLocale, record: page }), })) as PageUrl[]; - -const blocksHtml = await renderBlocks(page.bodyBlocks as AnyBlock[]); ---

{page.title}

- - + + +
diff --git a/src/pages/[locale]/index.astro b/src/pages/[locale]/index.astro index 7257c205..fce23d9d 100644 --- a/src/pages/[locale]/index.astro +++ b/src/pages/[locale]/index.astro @@ -5,11 +5,10 @@ import type { PageUrl } from '@lib/seo'; import { locales } from '@lib/i18n'; import { getHomeHref } from '@lib/routing'; import Layout from '@layouts/Default.astro'; -import { renderBlocks } from '@blocks/index'; import type { AnyBlock } from '@blocks/Blocks'; +import Blocks from '@blocks/Blocks.astro'; import PreviewModeSubscription from '@components/PreviewMode/PreviewModeSubscription.astro'; import ShareButton from '@components/ShareButton/ShareButton.astro'; -import TableOfContents from '@components/TableOfContents/TableOfContents.astro'; import query from './_index.query.graphql'; export async function getStaticPaths() { @@ -26,13 +25,11 @@ const pageUrls = locales.map((locale) => ({ locale, pathname: getHomeHref({ locale }), })) as PageUrl[]; -const blocksHtml = await renderBlocks(page.bodyBlocks as AnyBlock[]); ---

{page.title}

- - +
From a366605a1e7d456eeacbefa1f575416bf4456da7 Mon Sep 17 00:00:00 2001 From: Jasper Moelker Date: Sun, 22 Dec 2024 14:01:24 +0100 Subject: [PATCH 5/5] Merge main --- .gitignore | 2 +- README.md | 1 - .../migrations/1706210000_translations.ts | 1 - .../migrations/1734363388_actionBlock.ts | 263 ++++++++++++++++++ config/plop/plopfile.mjs | 24 ++ .../{Block.test.hbs => Block.test.ts.hbs} | 6 +- ...mponent.test.hbs => Component.test.ts.hbs} | 0 docs/blocks-and-components.md | 42 ++- docs/project-structure.md | 3 + docs/routing.md | 15 + docs/upgrading.md | 10 +- package.json | 2 +- scripts/icon-types.ts | 7 +- src/assets/icons/close.svg | 1 + src/blocks/ActionBlock/ActionBlock.astro | 45 +++ .../ActionBlock/ActionBlock.fragment.graphql | 12 + src/blocks/ActionBlock/ActionBlock.test.ts | 45 +++ .../ActionBlock/InternalLink.fragment.graphql | 18 ++ src/blocks/ActionBlock/README.md | 3 + src/blocks/Blocks.astro | 25 +- src/blocks/Blocks.d.ts | 2 + .../PagePartialBlock.fragment.graphql | 4 + .../TextBlock/TextBlock.fragment.graphql | 4 + src/blocks/TextBlock/nodes/ItemLink.astro | 5 +- .../TextImageBlock.fragment.graphql | 4 + src/components/Icon/Icon.astro | 3 +- src/components/Icon/index.ts | 2 +- src/components/Link/Link.astro | 4 +- src/components/LinkToFile/LinkToFile.astro | 4 +- .../LinkToRecord/LinkToRecord.astro | 3 +- .../LocaleSelector/LocaleSelector.client.ts | 13 +- src/components/ShareButton/ShareButton.astro | 14 - .../ShareButton/ShareButton.test.ts | 13 - src/lib/i18n/index.ts | 7 +- .../[locale]/[...path]/_index.query.graphql | 4 + src/pages/[locale]/[...path]/index.astro | 10 +- src/pages/[locale]/_index.query.graphql | 4 + src/pages/[locale]/index.astro | 2 - src/pages/_404.query.graphql | 4 + src/pages/api/demo/geo.ts | 31 --- src/pages/api/demo/hello.ts | 12 - 41 files changed, 553 insertions(+), 121 deletions(-) create mode 100644 config/datocms/migrations/1734363388_actionBlock.ts rename config/plop/templates/block/{Block.test.hbs => Block.test.ts.hbs} (89%) rename config/plop/templates/component/{Component.test.hbs => Component.test.ts.hbs} (100%) create mode 100644 src/assets/icons/close.svg create mode 100644 src/blocks/ActionBlock/ActionBlock.astro create mode 100644 src/blocks/ActionBlock/ActionBlock.fragment.graphql create mode 100644 src/blocks/ActionBlock/ActionBlock.test.ts create mode 100644 src/blocks/ActionBlock/InternalLink.fragment.graphql create mode 100644 src/blocks/ActionBlock/README.md delete mode 100644 src/components/ShareButton/ShareButton.astro delete mode 100644 src/components/ShareButton/ShareButton.test.ts delete mode 100644 src/pages/api/demo/geo.ts delete mode 100644 src/pages/api/demo/hello.ts diff --git a/.gitignore b/.gitignore index da339f6d..ffc6f393 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,7 @@ node_modules/ # generated files src/assets/icon-sprite.svg -src/assets/icon-sprite.d.ts +src/assets/icon-sprite.ts src/lib/datocms/types.ts src/lib/i18n/messages.json src/lib/i18n/types.ts diff --git a/README.md b/README.md index 41b3e3f3..60ab13d7 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,6 @@ - Provide functional interactivity without a JS framework (React, Vue, Svelte, etc)*. - Provide functional interactivity without specific styling ("unstyled")*. - Provide a fully accessible and highly performant baseline for every project. -- Utilise testing to ensure quality and prevent regressions. \* We'll leave the choice for a JS framework and strategy for styling to developers using Head Start for their project. diff --git a/config/datocms/migrations/1706210000_translations.ts b/config/datocms/migrations/1706210000_translations.ts index 2e9aa0a1..ff2fb34b 100644 --- a/config/datocms/migrations/1706210000_translations.ts +++ b/config/datocms/migrations/1706210000_translations.ts @@ -69,7 +69,6 @@ export default async function (client: Client) { 'select_language': 'Select language', 'consent_message_service': '{{ service }} uses cookies to show this video. This requires your permission.', - 'share_on_social': 'Share on social media', 'skip_to_content': 'Skip to content', 'not_found': 'The page you requested could not be found.', 'watch_video_on_provider': 'watch on {{provider}}', diff --git a/config/datocms/migrations/1734363388_actionBlock.ts b/config/datocms/migrations/1734363388_actionBlock.ts new file mode 100644 index 00000000..cf68e686 --- /dev/null +++ b/config/datocms/migrations/1734363388_actionBlock.ts @@ -0,0 +1,263 @@ +import { Client } from '@datocms/cli/lib/cma-client-node'; + +export default async function (client: Client) { + console.log('Create new models/block models'); + + console.log( + 'Create block model "\uD83C\uDF9B\uFE0F Action Block" (`action_block`)' + ); + await client.itemTypes.create( + { + id: 'F60ZY1wFSW2qbvh99poj3g', + name: '\uD83C\uDF9B\uFE0F Action Block', + api_key: 'action_block', + modular_block: true, + inverse_relationships_enabled: false, + }, + { + skip_menu_item_creation: true, + schema_menu_item_id: 'aV5wDaZcRLaLFiNqBpNstw', + } + ); + + console.log( + 'Create block model "\uD83D\uDD17 Internal Link" (`internal_link`)' + ); + await client.itemTypes.create( + { + id: 'GWnhoQDqQoGJj4-sQTVttw', + name: '\uD83D\uDD17 Internal Link', + api_key: 'internal_link', + modular_block: true, + inverse_relationships_enabled: false, + }, + { + skip_menu_item_creation: true, + schema_menu_item_id: 'aVOBPryiQ8O5EiHEHDEang', + } + ); + + console.log('Creating new fields/fieldsets'); + + console.log( + 'Create Modular Content (Multiple blocks) field "Items" (`items`) in block model "\uD83C\uDF9B\uFE0F Action Block" (`action_block`)' + ); + await client.fields.create('F60ZY1wFSW2qbvh99poj3g', { + id: 'dAUckF8qR0edf_f7zam6hA', + label: 'Items', + field_type: 'rich_text', + api_key: 'items', + validators: { + rich_text_blocks: { item_types: ['GWnhoQDqQoGJj4-sQTVttw'] }, + size: { min: 1 }, + }, + appearance: { + addons: [], + editor: 'rich_text', + parameters: { start_collapsed: true }, + }, + default_value: null, + }); + + console.log( + 'Create Single-line string field "Title" (`title`) in block model "\uD83D\uDD17 Internal Link" (`internal_link`)' + ); + await client.fields.create('GWnhoQDqQoGJj4-sQTVttw', { + id: 'XTl0xPRsTpWg9zFauwDl5Q', + label: 'Title', + field_type: 'string', + api_key: 'title', + validators: { required: {} }, + appearance: { + addons: [], + editor: 'single_line', + parameters: { heading: false, placeholder: null }, + }, + default_value: '', + }); + + console.log( + 'Create Single link field "Link" (`link`) in block model "\uD83D\uDD17 Internal Link" (`internal_link`)' + ); + await client.fields.create('GWnhoQDqQoGJj4-sQTVttw', { + id: 'bN5K_JObRQqv7tkzt4RG2w', + label: 'Link', + field_type: 'link', + api_key: 'link', + validators: { + item_item_type: { + on_publish_with_unpublished_references_strategy: 'fail', + on_reference_unpublish_strategy: 'delete_references', + on_reference_delete_strategy: 'delete_references', + item_types: ['LjXdkuCdQxCFT4hv8_ayew', 'X_tZn3TxQY28ltSyjZUGHQ'], + }, + required: {}, + }, + appearance: { addons: [], editor: 'link_select', parameters: {} }, + default_value: null, + }); + + console.log( + 'Create Single-line string field "Style" (`style`) in block model "\uD83D\uDD17 Internal Link" (`internal_link`)' + ); + await client.fields.create('GWnhoQDqQoGJj4-sQTVttw', { + id: 'S3JQgijhRmalePX3GeugPg', + label: 'Style', + field_type: 'string', + api_key: 'style', + validators: { + required: {}, + enum: { values: ['default', 'primary', 'secondary'] }, + }, + appearance: { + addons: [], + editor: 'string_select', + parameters: { + options: [ + { hint: '', label: 'Default action', value: 'default' }, + { hint: '', label: 'Primary action', value: 'primary' }, + { hint: '', label: 'Secondary action', value: 'secondary' }, + ], + }, + }, + default_value: 'default', + }); + + console.log('Update existing fields/fieldsets'); + + console.log( + 'Update Structured text field "Text" (`text`) in block model "\uD83D\uDCDD \uD83D\uDDBC\uFE0F Text Image Block" (`text_image_block`)' + ); + await client.fields.update('V4dMfrWsQ027JYEp6q3KhA', { + validators: { + required: {}, + structured_text_blocks: { + item_types: [ + 'F60ZY1wFSW2qbvh99poj3g', + 'ZdBokLsWRgKKjHrKeJzdpw', + 'gezG9nO7SfaiWcWnp-HNqw', + '0SxYNS2CR1it_5LHYWuEQg', + ], + }, + structured_text_links: { + on_publish_with_unpublished_references_strategy: 'fail', + on_reference_unpublish_strategy: 'delete_references', + on_reference_delete_strategy: 'delete_references', + item_types: [ + 'GjWw8t-hTFaYYWyc53FeIg', + 'LjXdkuCdQxCFT4hv8_ayew', + 'X_tZn3TxQY28ltSyjZUGHQ', + ], + }, + }, + }); + + console.log( + 'Update Modular Content (Multiple blocks) field "Body" (`blocks`) in model "\uD83D\uDCCE Page Partial" (`page_partial`)' + ); + await client.fields.update('SKLmdv71Rge0rKhJzOFQWQ', { + validators: { + rich_text_blocks: { + item_types: [ + 'BRbU6VwTRgmG5SbwUs0rBg', + 'F60ZY1wFSW2qbvh99poj3g', + 'PAk40zGjQJCcDXXPgygUrA', + 'ZdBokLsWRgKKjHrKeJzdpw', + 'gezG9nO7SfaiWcWnp-HNqw', + '0SxYNS2CR1it_5LHYWuEQg', + ], + }, + }, + }); + + console.log( + 'Update Modular Content (Multiple blocks) field "Body" (`body_blocks`) in model "\uD83D\uDCD1 Page" (`page`)' + ); + await client.fields.update('Q-z1nyMsQtC8Sr6w6J2oGw', { + validators: { + rich_text_blocks: { + item_types: [ + 'BRbU6VwTRgmG5SbwUs0rBg', + 'F60ZY1wFSW2qbvh99poj3g', + 'PAk40zGjQJCcDXXPgygUrA', + 'QYfZyBzIRWKxA1MinIR0aQ', + 'VZvVfu52RZK81WG0Dxp-FQ', + 'V80liDVtRC-UYgd3Sm-dXg', + 'ZdBokLsWRgKKjHrKeJzdpw', + 'gezG9nO7SfaiWcWnp-HNqw', + '0SxYNS2CR1it_5LHYWuEQg', + ], + }, + }, + }); + + console.log( + 'Update Structured text field "Text" (`text`) in block model "\uD83D\uDCDD Text Block" (`text_block`)' + ); + await client.fields.update('NtVXfZ6gTL2sKNffNeUf5Q', { + validators: { + required: {}, + structured_text_blocks: { + item_types: [ + 'F60ZY1wFSW2qbvh99poj3g', + 'QYfZyBzIRWKxA1MinIR0aQ', + 'ZdBokLsWRgKKjHrKeJzdpw', + 'gezG9nO7SfaiWcWnp-HNqw', + '0SxYNS2CR1it_5LHYWuEQg', + ], + }, + structured_text_links: { + on_publish_with_unpublished_references_strategy: 'fail', + on_reference_unpublish_strategy: 'delete_references', + on_reference_delete_strategy: 'delete_references', + item_types: [ + 'GjWw8t-hTFaYYWyc53FeIg', + 'LjXdkuCdQxCFT4hv8_ayew', + 'X_tZn3TxQY28ltSyjZUGHQ', + ], + }, + }, + }); + + console.log( + 'Update Modular Content (Multiple blocks) field "Body" (`body_blocks`) in model "\uD83C\uDFE0 Home" (`home_page`)' + ); + await client.fields.update('pUj2PObgTyC-8X4lvZLMBA', { + validators: { + rich_text_blocks: { + item_types: [ + 'BRbU6VwTRgmG5SbwUs0rBg', + 'F60ZY1wFSW2qbvh99poj3g', + 'PAk40zGjQJCcDXXPgygUrA', + 'QYfZyBzIRWKxA1MinIR0aQ', + 'VZvVfu52RZK81WG0Dxp-FQ', + 'V80liDVtRC-UYgd3Sm-dXg', + 'ZdBokLsWRgKKjHrKeJzdpw', + 'gezG9nO7SfaiWcWnp-HNqw', + '0SxYNS2CR1it_5LHYWuEQg', + ], + }, + }, + }); + + console.log( + 'Update Modular Content (Multiple blocks) field "Body" (`body_blocks`) in model "\uD83E\uDD37 Not found" (`not_found_page`)' + ); + await client.fields.update('Zu006Xq0TMCAvV-vyQ_Iiw', { + validators: { + rich_text_blocks: { + item_types: [ + 'BRbU6VwTRgmG5SbwUs0rBg', + 'F60ZY1wFSW2qbvh99poj3g', + 'PAk40zGjQJCcDXXPgygUrA', + 'QYfZyBzIRWKxA1MinIR0aQ', + 'VZvVfu52RZK81WG0Dxp-FQ', + 'V80liDVtRC-UYgd3Sm-dXg', + 'ZdBokLsWRgKKjHrKeJzdpw', + 'gezG9nO7SfaiWcWnp-HNqw', + '0SxYNS2CR1it_5LHYWuEQg', + ], + }, + }, + }); +} diff --git a/config/plop/plopfile.mjs b/config/plop/plopfile.mjs index 35c3200d..d5ce43b4 100644 --- a/config/plop/plopfile.mjs +++ b/config/plop/plopfile.mjs @@ -44,6 +44,12 @@ export default function (plop) { name: 'name', message: 'Block name (e.g. TextBlock)?', }, + { + type: 'confirm', + name: 'test', + message: 'Add test file (.test.ts)?', + default: false, + }, { type: 'confirm', name: 'readme', @@ -62,6 +68,12 @@ export default function (plop) { path: '../../src/blocks/{{ pascalCase name }}/{{ pascalCase name }}.fragment.graphql', templateFile: 'templates/block/Block.fragment.graphql.hbs', }, + { + type: 'add', + path: '../../src/blocks/{{ pascalCase name }}/{{ pascalCase name }}.test.ts', + templateFile: 'templates/block/Block.test.ts.hbs', + skip: (data) => !data.test && 'No test file', + }, { type: 'add', path: '../../src/blocks/{{ pascalCase name }}/README.md', @@ -91,6 +103,12 @@ export default function (plop) { message: 'Add custom element?', default: false, }, + { + type: 'confirm', + name: 'test', + message: 'Add test file (.test.ts)?', + default: false, + }, { type: 'confirm', name: 'readme', @@ -110,6 +128,12 @@ export default function (plop) { templateFile: 'templates/component/Component.client.ts.hbs', skip: (data) => !data.script && 'No client-side script', }, + { + type: 'add', + path: '../../src/components/{{ pascalCase name }}/{{ pascalCase name }}.test.ts', + templateFile: 'templates/component/Component.client.ts.hbs', + skip: (data) => !data.test && 'No test file', + }, { type: 'add', path: '../../src/components/{{ pascalCase name }}/README.md', diff --git a/config/plop/templates/block/Block.test.hbs b/config/plop/templates/block/Block.test.ts.hbs similarity index 89% rename from config/plop/templates/block/Block.test.hbs rename to config/plop/templates/block/Block.test.ts.hbs index 50e34e40..037d712c 100644 --- a/config/plop/templates/block/Block.test.hbs +++ b/config/plop/templates/block/Block.test.ts.hbs @@ -5,9 +5,9 @@ import {{ pascalCase name }}, { type Props } from './{{ pascalCase name }}.astro describe('{{ pascalCase name }}', () => { test('Block is rendered', async () => { const fragment = await renderToFragment({{ pascalCase name }}, { - props: { - block: {} - } + props: { + block: {} + } }); expect(fragment).toBeTruthy(); diff --git a/config/plop/templates/component/Component.test.hbs b/config/plop/templates/component/Component.test.ts.hbs similarity index 100% rename from config/plop/templates/component/Component.test.hbs rename to config/plop/templates/component/Component.test.ts.hbs diff --git a/docs/blocks-and-components.md b/docs/blocks-and-components.md index af636bb2..f63e9834 100644 --- a/docs/blocks-and-components.md +++ b/docs/blocks-and-components.md @@ -13,17 +13,23 @@ src/ │ ├── Blocks.d.ts │ └── SomeContentBlock/ │ ├── SomeContentBlock.astro -│ └── SomeContentBlock.fragment.graphql +│ ├── SomeContentBlock.fragment.graphql +│ ├── SomeContentBlock.client.ts +│ └── SomeContentBlock.test.ts +│ └── components/ └── SomeUiComponent/ - └── SomeUiComponent.astro + ├── SomeUiComponent.astro +│ ├── SomeContentBlock.client.ts +│ └── SomeContentBlock.test.ts ``` - [Components](https://docs.astro.build/en/core-concepts/astro-components/) are the UI elements the website is composed of. This can be Astro and framework specific components. - Blocks are a specific set of components which have a complementary content [Block](https://www.datocms.com/docs/content-modelling/blocks) in DatoCMS and therefore have a paired GraphQL Fragment file. +- Optionally blocks and components have a complementary `*.client.ts` file for client-side scripts and a `*.test.ts` file for related unit tests. > [!NOTE] -> You can use `npm run create:block` and `npm run:component` to quickly scaffold a new block or component with their associated files. +> You can use `npm run create:block` and `npm run create:component` to quickly scaffold a new block or component with their associated files. See [CMS Data Loading](./cms-data-loading.md) for documentation on the use of GraphQL Fragment files. @@ -107,3 +113,33 @@ export type AnyBlock = | SomeContentBlockFragment // and add it here (order A to Z) | TextBlockFragment; ``` + +## Client-side scripts + +Astro supports [client-side scripts inside components](https://docs.astro.build/en/guides/client-side-scripts/#client-side-scripts). Head Start uses the convention to include these as external scripts for better TypeScript intellisense and linting. To distinguish server-side files (most in Astro) from client-side scripts we use a `.client.ts` extension. So blocks and components can include these as ``. + +## Testing components + +[Head Start provides a testing setup](./testing.md). This includes helpers to make component testing easier. Astro renders components to string. The `renderToFragment` helper allows you to test components as document fragments providing most familiar DOM methods like `.querySelector` and `.getAttribute`: + +```ts +// SomeComponent.test.ts +import { describe, expect, test } from 'vitest'; +import { renderToFragment } from '@lib/renderer'; +import SomeComponent, { type Props } from './SomeComponent.astro'; + +describe('Some Component', () => { + const fragment = await renderToFragment(SomeComponent, { + someProp: 'some value', + }); + + test('uses some prop as attribute', () => { + const value = fragment.querySelector('.someSelector')?.getAttribute('some-attribute'); + expect(value).toBe('some value'); + }); + + // Add more tests here +}); +``` + +Note: test files must use the `.test.ts` extension to run. diff --git a/docs/project-structure.md b/docs/project-structure.md index cf81f4a2..0cf32b6d 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -24,6 +24,8 @@ Inside of this project, you'll see the following folders and files: │ │ └── SomeUiComponent.astro │ ├── layouts/ │ │ └── Default.astro +│ ├── lib/ +│ │ └── some-helper-function.ts │ └── pages/ │ ├── api/ | | └── some-dynamic-endpoint.ts @@ -41,6 +43,7 @@ Inside of this project, you'll see the following folders and files: - `components/` - [Components](https://docs.astro.build/en/core-concepts/astro-components/) are the elements the website is composed of. This can be Astro and framework specific components. - `blocks/` - Blocks are a specific set of components which have a complementary content [Block](https://www.datocms.com/docs/content-modelling/blocks) in DatoCMS and therefore have a paired GraphQL fragment file. - `layouts/` - [Layouts](https://docs.astro.build/en/core-concepts/layouts/) are Astro components used to provide a reusable UI structure, such as a page template. + - `lib/` - Shared logic and utility helpers, like `datocms`, `i18n` and `routing`. - `assets/icons/` - SVG icons, can be used with `` (See [`src/components/Icon/`](../src/components/Icon/)). - `public/` is for any static assets, like fonts and favicons, that should be available on the website as-is. - `config/` bundles all our configuration files (like DatoCMS migrations), so the project root doesn't become too cluttered. diff --git a/docs/routing.md b/docs/routing.md index de5b3a27..0d13572b 100644 --- a/docs/routing.md +++ b/docs/routing.md @@ -34,6 +34,10 @@ Head Start leverages [Astro's Custom 404 Error page](https://docs.astro.build/en Astro supports [API routes](https://docs.astro.build/en/core-concepts/endpoints/#server-endpoints-api-routes) (server endpoints), which can be any route in `src/pages/`. Head Start uses a convention to place all API routes in `src/pages/api/`. This way it's clear where all API routes live, they have a logical URL prefix in the browser (`/api/`) and [API routes not found](../src/pages/api/[...notFound].ts) can be caught and respond with a 404 JSON response, rather than an HTML response. +## Partial page routes + +Astro supports [Page Partials](https://docs.astro.build/en/basics/astro-pages/#page-partials) to fetch and use in conjuction with client-side scripts. As a convention Head Start uses a `.partial.astro` for these routes. An example is the [`search/results.partial.astro` route](../src/pages/[locale]/search/results.partial.astro). + ## Redirects Head Start supports redirect rules which are editable and [sortable](https://www.datocms.com/docs/content-modelling/record-ordering) in the CMS. Head Start uses [`regexparam`](https://github.com/lukeed/regexparam) to handle redirect rules with static paths, (optional) parameters and (optional) wildcards. Examples: @@ -43,3 +47,14 @@ Head Start supports redirect rules which are editable and [sortable](https://www - from `/path-with-wildcard/*` to `/new-path-with-wildcard/*` (or `/new-path-with-wildcard/:splat`) \* See [decision entry on redirects](./decision-log/2024-09-24-redirects-middleware.md) for motivation. + +## Cloudflare runtime + +Head Start uses the [Astro Cloudflare adapter](https://docs.astro.build/en/guides/integrations-guide/cloudflare/) to deploy to Cloudflare Pages. This means routes have access to the [Cloudflare runtime](https://docs.astro.build/en/guides/integrations-guide/cloudflare/#cloudflare-runtime) via `locals.runtime`. For example, each dynamic route, has access to geo information of the request: + +```ts +export function GET ({ locals }) { + const { city, latitude, longitude } = locals.runtime.cf; + return new Response(JSON.stringify({ city, latitude, longitude }, null, 2)); +} +``` diff --git a/docs/upgrading.md b/docs/upgrading.md index 15e9b895..7cff1879 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -11,7 +11,7 @@ Did you start your project using Head Start as its template? You can still apply Since Head Start doesn't have formal versioning, the best changelog is the [list of changes (commits) on the `main` branch](https://github.com/voorhoede/head-start/commits/main/) on GitHub or from the command line in the repository (`git log main --oneline`): -```bash +```shell 6790dba feature: upgrade Astro to v5 beta (#189) 39298c5 feature: Remove background image when image is loaded (#185) 822a3a2 test: Link Node, no trailing whitespace (#186) @@ -30,13 +30,13 @@ Head Start uses a squash-and-merge strategy for pull requests. So the list shoul If you only need a single change, you can use a commit's patch file. For example if you select the commit ["feature: upgrade Astro to v5 beta" (`a622bd`)](https://github.com/voorhoede/head-start/commit/a622bd), you can add `.patch` to the URL to get its patch file: [`https://github.com/voorhoede/head-start/commit/a622bd.patch`](https://github.com/voorhoede/head-start/commit/a622bd.patch). Then you can apply the patch to your project from its repository: -```bash +```shell curl https://github.com/voorhoede/head-start/commit/a622bd.patch | git am ``` Alternatively you can add Head Start as a secondary remote to your project's repository and use cherry picking to apply the change: -```bash +```shell git remote add head-start git@github.com:voorhoede/head-start.git git remote update @@ -50,7 +50,7 @@ That's it. The original commit for the change is now applied to your project. If you want to apply a range of changes from Head Start to your own project, applying patches as described above is not an option. Instead you can use cherry picking for an entire range. For example when you want to apply all the changes made to Head Start after you've used it as a template for your own project. Note the commit SHA of the first and the last change of your range. Then add Head Start as a secondary remote to your project's repository and use cherry picking to apply the range of changes: -```bash +```shell git remote add head-start git@github.com:voorhoede/head-start.git git remote update @@ -60,7 +60,7 @@ git cherry-pick --strategy recursive --strategy-option theirs 035a205^..a622bd If you encounter any merge conflicts along the way, resolve them as you normally do, then continue the cherry picking process: -```bash +```shell git cherry-pick --continue ``` diff --git a/package.json b/package.json index de82feb4..c729d511 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "prep:download-redirects": "jiti scripts/download-redirects.ts", "prep:download-site-data": "jiti scripts/download-site-data.ts", "prep:download-translations": "jiti scripts/download-translations.ts", - "prep:icons": "svg-sprite --symbol --symbol-dest='src/assets' --symbol-sprite=icon-sprite.svg src/assets/icons/*.svg && jiti scripts/icon-types.ts", + "prep:icons": "svg-sprite --svg-doctype=false --symbol --symbol-dest='src/assets' --symbol-sprite=icon-sprite.svg src/assets/icons/*.svg && jiti scripts/icon-types.ts", "preview": "wrangler pages dev ./dist", "lint": "run-s lint:* --print-label", "lint:astro": "astro check", diff --git a/scripts/icon-types.ts b/scripts/icon-types.ts index e19cb1d0..6adc386b 100644 --- a/scripts/icon-types.ts +++ b/scripts/icon-types.ts @@ -7,10 +7,11 @@ async function writeIconNameTypes() { .filter(file => file.endsWith('.svg')) .map(file => path.basename(file, '.svg')); - await fs.writeFile('./src/assets/icon-sprite.d.ts', - `export type IconName = ${iconNames.map(name => `'${name}'`).join(' | ')};` + await fs.writeFile('./src/assets/icon-sprite.ts', + `export const iconNames = [${iconNames.map(name => `'${name}'`)}] as const;\n` + + 'export type IconName = typeof iconNames[number];' ); } writeIconNameTypes() - .then(() => console.log('Icon names written to src/assets/icon-sprite.d.ts')); + .then(() => console.log('Icon names written to src/assets/icon-sprite.ts')); diff --git a/src/assets/icons/close.svg b/src/assets/icons/close.svg new file mode 100644 index 00000000..98831bdb --- /dev/null +++ b/src/assets/icons/close.svg @@ -0,0 +1 @@ + diff --git a/src/blocks/ActionBlock/ActionBlock.astro b/src/blocks/ActionBlock/ActionBlock.astro new file mode 100644 index 00000000..94b0475d --- /dev/null +++ b/src/blocks/ActionBlock/ActionBlock.astro @@ -0,0 +1,45 @@ +--- +import type { ActionBlockFragment } from '@lib/datocms/types'; +import LinkToRecord from '@components/LinkToRecord/LinkToRecord.astro'; + +export interface Props { + block: ActionBlockFragment; +} +const { block } = Astro.props; +const { items } = block; +--- + +
+ { + items.map((item) => ( + + {item.title} + + )) + } +
+ + diff --git a/src/blocks/ActionBlock/ActionBlock.fragment.graphql b/src/blocks/ActionBlock/ActionBlock.fragment.graphql new file mode 100644 index 00000000..e8a9c91d --- /dev/null +++ b/src/blocks/ActionBlock/ActionBlock.fragment.graphql @@ -0,0 +1,12 @@ +#import './InternalLink.fragment.graphql' + +fragment ActionBlock on ActionBlockRecord { + __typename + id + items { + __typename + ... on InternalLinkRecord { + ...InternalLink + } + } +} diff --git a/src/blocks/ActionBlock/ActionBlock.test.ts b/src/blocks/ActionBlock/ActionBlock.test.ts new file mode 100644 index 00000000..f5a9acba --- /dev/null +++ b/src/blocks/ActionBlock/ActionBlock.test.ts @@ -0,0 +1,45 @@ +import { renderToFragment } from '@lib/renderer'; +import { describe, expect, test } from 'vitest'; +import InlineBlock, { type Props } from './ActionBlock.astro'; +import type { ActionBlockFragment, InternalLinkFragment, SiteLocale } from '@lib/datocms/types'; +import { locales } from '@lib/i18n'; + +const createInternalLinkFragment = (title: string, slug: string, style: string) => ({ + '__typename': 'InternalLinkRecord', + 'id': `${slug}-123`, + 'title': title, + 'style': style, + 'link': { + '__typename': 'PageRecord', + 'id': `${slug}-456`, + 'title': title, // not neccessarily the same as title of the link record, but works for simplicity + 'slug': slug, // not neccessarily the same as slug of the link record, but works for simplicity + '_allSlugLocales': locales.map(locale => ({ locale: locale as SiteLocale, value: slug })), + 'parentPage': null + } +} satisfies InternalLinkFragment); + +const createActionBlockFragment = (items: InternalLinkFragment[]) => ({ + '__typename': 'ActionBlockRecord', + 'id': 'PL9XQGyWQjuyHpdDNsXCNg', + 'items': items +} satisfies ActionBlockFragment); + +describe('ActionBlock', () => { + test('Block is rendered', async () => { + const blockWithTwoItems = createActionBlockFragment([ + createInternalLinkFragment('First item', 'first-item', 'primary'), + createInternalLinkFragment('Second item', 'second-item', 'secondary'), + ]); + const fragment = await renderToFragment(InlineBlock, { + props: { + block: blockWithTwoItems, + } + }); + + expect(fragment.querySelectorAll('a.action').length).toBe(2); + expect(fragment.querySelector('a.action--primary')?.textContent).toBe('First item'); + expect(fragment.querySelector('a.action--secondary')?.textContent).toBe('Second item'); + }); + +}); diff --git a/src/blocks/ActionBlock/InternalLink.fragment.graphql b/src/blocks/ActionBlock/InternalLink.fragment.graphql new file mode 100644 index 00000000..7cfbae52 --- /dev/null +++ b/src/blocks/ActionBlock/InternalLink.fragment.graphql @@ -0,0 +1,18 @@ +#import '@lib/routing/HomeRoute.fragment.graphql' +#import '@lib/routing/PageRoute.fragment.graphql' + +fragment InternalLink on InternalLinkRecord { + __typename + id + title + style + link { + __typename + ... on HomePageRecord { + ...HomeRoute + } + ... on PageRecord { + ...PageRoute + } + } +} diff --git a/src/blocks/ActionBlock/README.md b/src/blocks/ActionBlock/README.md new file mode 100644 index 00000000..dd06dd93 --- /dev/null +++ b/src/blocks/ActionBlock/README.md @@ -0,0 +1,3 @@ +# Action Block + +**Renders a group of actions within a styleable container with a specific style per action (primary, secondary or default).** diff --git a/src/blocks/Blocks.astro b/src/blocks/Blocks.astro index 720afa47..908f5422 100644 --- a/src/blocks/Blocks.astro +++ b/src/blocks/Blocks.astro @@ -1,5 +1,6 @@ --- import type { AnyBlock } from './Blocks'; +import ActionBlock from './ActionBlock/ActionBlock.astro'; import EmbedBlock from './EmbedBlock/EmbedBlock.astro'; import ImageBlock from './ImageBlock/ImageBlock.astro'; import PagePartialBlock from './PagePartialBlock/PagePartialBlock.astro'; @@ -10,6 +11,7 @@ import VideoBlock from './VideoBlock/VideoBlock.astro'; import VideoEmbedBlock from './VideoEmbedBlock/VideoEmbedBlock.astro'; const blocksByTypename = { + ActionBlockRecord: ActionBlock, EmbedBlockRecord: EmbedBlock, ImageBlockRecord: ImageBlock, PagePartialBlockRecord: PagePartialBlock, @@ -25,13 +27,18 @@ interface Props { } const { blocks } = Astro.props; --- -{ blocks.map((block) => { - const typename = block.__typename as keyof typeof blocksByTypename; - const Block = blocksByTypename[typename]; - return Block - // @ts-ignore next line - ? - : ; -})} + + ); + }) +} diff --git a/src/blocks/Blocks.d.ts b/src/blocks/Blocks.d.ts index 88e3ae36..beb49491 100644 --- a/src/blocks/Blocks.d.ts +++ b/src/blocks/Blocks.d.ts @@ -1,4 +1,5 @@ import type { + ActionBlockFragment, EmbedBlockFragment, ImageBlockFragment, PagePartialBlockFragment, @@ -10,6 +11,7 @@ import type { } from '@lib/datocms/types'; export type AnyBlock = + | ActionBlockFragment | EmbedBlockFragment | ImageBlockFragment | PagePartialBlockFragment diff --git a/src/blocks/PagePartialBlock/PagePartialBlock.fragment.graphql b/src/blocks/PagePartialBlock/PagePartialBlock.fragment.graphql index 4cfe161a..f8b22457 100644 --- a/src/blocks/PagePartialBlock/PagePartialBlock.fragment.graphql +++ b/src/blocks/PagePartialBlock/PagePartialBlock.fragment.graphql @@ -1,3 +1,4 @@ +#import '@blocks/ActionBlock/ActionBlock.fragment.graphql' #import '@blocks/ImageBlock/ImageBlock.fragment.graphql' #import '@blocks/TableBlock/TableBlock.fragment.graphql' #import '@blocks/TextBlock/TextBlock.fragment.graphql' @@ -9,6 +10,9 @@ fragment PagePartialBlock on PagePartialBlockRecord { title blocks { __typename + ... on ActionBlockRecord { + ...ActionBlock + } ... on ImageBlockRecord { ...ImageBlock } diff --git a/src/blocks/TextBlock/TextBlock.fragment.graphql b/src/blocks/TextBlock/TextBlock.fragment.graphql index ba8a1978..30886d24 100644 --- a/src/blocks/TextBlock/TextBlock.fragment.graphql +++ b/src/blocks/TextBlock/TextBlock.fragment.graphql @@ -1,3 +1,4 @@ +#import '@blocks/ActionBlock/ActionBlock.fragment.graphql' #import '@blocks/ImageBlock/ImageBlock.fragment.graphql' #import '@blocks/TableBlock/TableBlock.fragment.graphql' #import '@blocks/VideoBlock/VideoBlock.fragment.graphql' @@ -10,6 +11,9 @@ fragment TextBlock on TextBlockRecord { text { blocks { __typename + ... on ActionBlockRecord { + ...ActionBlock + } ... on ImageBlockRecord { ...ImageBlock } diff --git a/src/blocks/TextBlock/nodes/ItemLink.astro b/src/blocks/TextBlock/nodes/ItemLink.astro index 0d194e95..b955ae19 100644 --- a/src/blocks/TextBlock/nodes/ItemLink.astro +++ b/src/blocks/TextBlock/nodes/ItemLink.astro @@ -1,6 +1,7 @@ --- -import LinkToRecord from '@components/LinkToRecord/LinkToRecord.astro'; +import type { HTMLAttributes } from 'astro/types'; import type { RecordRoute } from '@lib/routing'; +import LinkToRecord from '@components/LinkToRecord/LinkToRecord.astro'; type Node = { meta?: { @@ -9,7 +10,7 @@ type Node = { }[]; }; -export type Props = { +export type Props = HTMLAttributes<'a'> & { node?: Node; link: RecordRoute; }; diff --git a/src/blocks/TextImageBlock/TextImageBlock.fragment.graphql b/src/blocks/TextImageBlock/TextImageBlock.fragment.graphql index 4a237b8b..71344c2b 100644 --- a/src/blocks/TextImageBlock/TextImageBlock.fragment.graphql +++ b/src/blocks/TextImageBlock/TextImageBlock.fragment.graphql @@ -1,3 +1,4 @@ +#import '@blocks/ActionBlock/ActionBlock.fragment.graphql' #import '@blocks/ImageBlock/ImageBlock.fragment.graphql' #import '@blocks/TableBlock/TableBlock.fragment.graphql' #import '@blocks/VideoEmbedBlock/VideoEmbedBlock.fragment.graphql' @@ -9,6 +10,9 @@ fragment TextImageBlock on TextImageBlockRecord { text { blocks { __typename + ... on ActionBlockRecord { + ...ActionBlock + } ... on ImageBlockRecord { ...ImageBlock } diff --git a/src/components/Icon/Icon.astro b/src/components/Icon/Icon.astro index 385e8747..3204d68e 100644 --- a/src/components/Icon/Icon.astro +++ b/src/components/Icon/Icon.astro @@ -1,5 +1,6 @@ --- import type { IconName } from '@assets/icon-sprite'; +export { iconNames } from '@assets/icon-sprite'; interface Props { name: IconName; @@ -27,4 +28,4 @@ const attributes = { height: 1rem; color: currentColor; } - \ No newline at end of file + diff --git a/src/components/Icon/index.ts b/src/components/Icon/index.ts index 833d8427..465e61fc 100644 --- a/src/components/Icon/index.ts +++ b/src/components/Icon/index.ts @@ -1,2 +1,2 @@ // Icon component is often used, this makes it easier to import as `path/to/components/Icon`. -export { default } from './Icon.astro'; +export { default, iconNames } from './Icon.astro'; diff --git a/src/components/Link/Link.astro b/src/components/Link/Link.astro index ff221fb3..2d273f7b 100644 --- a/src/components/Link/Link.astro +++ b/src/components/Link/Link.astro @@ -1,5 +1,7 @@ --- -export interface Props { +import type { HTMLAttributes } from 'astro/types'; + +export type Props = HTMLAttributes<'a'> & { href: string; openInNewTab?: boolean; } diff --git a/src/components/LinkToFile/LinkToFile.astro b/src/components/LinkToFile/LinkToFile.astro index 02cda273..6f8db689 100644 --- a/src/components/LinkToFile/LinkToFile.astro +++ b/src/components/LinkToFile/LinkToFile.astro @@ -1,13 +1,15 @@ --- +import type { HTMLAttributes } from 'astro/types'; import type { FileRouteFragment } from '@lib/datocms/types'; import prettyBytes from 'pretty-bytes'; import { getLocale } from '@lib/i18n'; import { getFileHref } from '@lib/routing/'; import Icon from '@components/Icon'; -export type Props = { +export type Props = HTMLAttributes<'a'> & { record: FileRouteFragment; }; + const { record, ...props } = Astro.props; const { file, locale: fileLocale, title } = record; const pageLocale = getLocale(); diff --git a/src/components/LinkToRecord/LinkToRecord.astro b/src/components/LinkToRecord/LinkToRecord.astro index b8f46fb7..5399cd74 100644 --- a/src/components/LinkToRecord/LinkToRecord.astro +++ b/src/components/LinkToRecord/LinkToRecord.astro @@ -1,11 +1,12 @@ --- +import type { HTMLAttributes } from 'astro/types'; import type { SiteLocale } from '@lib/datocms/types'; import { getLocale } from '@lib/i18n'; import { getHref, type RecordRoute } from '@lib/routing'; import Link from '@components/Link/Link.astro'; import LinkToFile from '@components/LinkToFile/LinkToFile.astro'; -export type Props = { +export type Props = Omit,'href'> & { openInNewTab?: boolean; record: RecordRoute; }; diff --git a/src/components/LocaleSelector/LocaleSelector.client.ts b/src/components/LocaleSelector/LocaleSelector.client.ts index 473966cb..c8ceba38 100644 --- a/src/components/LocaleSelector/LocaleSelector.client.ts +++ b/src/components/LocaleSelector/LocaleSelector.client.ts @@ -1,19 +1,16 @@ import { cookieName } from '../../lib/i18n'; class LocaleSelector extends HTMLElement { - constructor() { - super(); + connectedCallback() { this.addEventListener('click', (event: MouseEvent) => { - const target = event.target as HTMLAnchorElement; - if (!target || target.tagName !== 'A') { - return; - } - if (!target.hreflang) { + const target = event.target as HTMLElement; + const anchor = target?.closest('a'); + if (!anchor?.hreflang) { console.warn('LocaleSelector: missing required hreflang attribute', { target }); return; } // set the hreflang cookie to the selected locale: - document.cookie = `${cookieName}=${target.hreflang}; path=/`; + document.cookie = `${cookieName}=${anchor.hreflang}; path=/`; }); } } diff --git a/src/components/ShareButton/ShareButton.astro b/src/components/ShareButton/ShareButton.astro deleted file mode 100644 index 4ae82fd0..00000000 --- a/src/components/ShareButton/ShareButton.astro +++ /dev/null @@ -1,14 +0,0 @@ ---- -import { t } from '@lib/i18n'; -import Icon from '@components/Icon'; ---- - - - { t('share_on_social') } - - - diff --git a/src/components/ShareButton/ShareButton.test.ts b/src/components/ShareButton/ShareButton.test.ts deleted file mode 100644 index 81296061..00000000 --- a/src/components/ShareButton/ShareButton.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { renderToFragment } from '@lib/renderer'; -import { describe, expect, test } from 'vitest'; -import ShareButton from './ShareButton.astro'; - -const fragment = await renderToFragment(ShareButton); - -describe('ShareButton', () => { - test('Component is rendered', () => { - expect(fragment).toBeTruthy(); - }); - - // Add more tests here -}); diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts index 128271cb..9eb2dd3b 100644 --- a/src/lib/i18n/index.ts +++ b/src/lib/i18n/index.ts @@ -8,7 +8,12 @@ export const defaultLocale = locales[0]; export const cookieName = 'HEAD_START_LOCALE'; const i18n = rosetta(messages); -i18n.locale(defaultLocale); + +if (typeof document !== 'undefined') { + setLocale(document.documentElement.lang as SiteLocale); +} else { + i18n.locale(defaultLocale); +} export type T = typeof i18n.t & ((key: TranslationKey) => string); // we use the 'any' type since this is used in the rosetta source-code diff --git a/src/pages/[locale]/[...path]/_index.query.graphql b/src/pages/[locale]/[...path]/_index.query.graphql index 4b7ed8c6..1b5fcebc 100644 --- a/src/pages/[locale]/[...path]/_index.query.graphql +++ b/src/pages/[locale]/[...path]/_index.query.graphql @@ -1,4 +1,5 @@ #import '@lib/routing/PageRoute.fragment.graphql' +#import '@blocks/ActionBlock/ActionBlock.fragment.graphql' #import '@blocks/EmbedBlock/EmbedBlock.fragment.graphql' #import '@blocks/ImageBlock/ImageBlock.fragment.graphql' #import '@blocks/PagePartialBlock/PagePartialBlock.fragment.graphql' @@ -18,6 +19,9 @@ query Page($locale: SiteLocale!, $slug: String!) { } bodyBlocks { __typename + ... on ActionBlockRecord { + ...ActionBlock + } ... on EmbedBlockRecord { ...EmbedBlock } diff --git a/src/pages/[locale]/[...path]/index.astro b/src/pages/[locale]/[...path]/index.astro index 10a2f11b..c8d7620d 100644 --- a/src/pages/[locale]/[...path]/index.astro +++ b/src/pages/[locale]/[...path]/index.astro @@ -1,20 +1,19 @@ --- +import { datocmsCollection, datocmsRequest } from '@lib/datocms'; +import { getPageHref } from '@lib/routing/'; import { type PageRouteForPath, getPagePath, getPageSlugFromPath, getParentPages, } from '@lib/routing/page'; -import type { PageUrl } from '@lib/types/page-url'; -import { datocmsCollection, datocmsRequest } from '@lib/datocms'; -import { getPageHref } from '@lib/routing/'; import type { PageQuery, SiteLocale } from '@lib/datocms/types'; +import type { PageUrl } from '@lib/seo'; import Layout from '@layouts/Default.astro'; -import type { AnyBlock } from '@blocks/Blocks.astro'; import Blocks from '@blocks/Blocks.astro'; +import type { AnyBlock } from '@blocks/Blocks'; import { formatBreadcrumb } from '@components/Breadcrumbs'; import PreviewModeSubscription from '@components/PreviewMode/PreviewModeSubscription.astro'; -import ShareButton from '@components/ShareButton/ShareButton.astro'; import TableOfContents from '@components/TableOfContents/TableOfContents.astro'; import query from './_index.query.graphql'; @@ -80,5 +79,4 @@ const pageUrls = (page._allSlugLocales || []).map(({ locale }) => ({ - diff --git a/src/pages/[locale]/_index.query.graphql b/src/pages/[locale]/_index.query.graphql index e03687b3..23a6c203 100644 --- a/src/pages/[locale]/_index.query.graphql +++ b/src/pages/[locale]/_index.query.graphql @@ -1,3 +1,4 @@ +#import '@blocks/ActionBlock/ActionBlock.fragment.graphql' #import '@blocks/EmbedBlock/EmbedBlock.fragment.graphql' #import '@blocks/ImageBlock/ImageBlock.fragment.graphql' #import '@blocks/PagePartialBlock/PagePartialBlock.fragment.graphql' @@ -17,6 +18,9 @@ query HomePage($locale: SiteLocale!) { } bodyBlocks { __typename + ... on ActionBlockRecord { + ...ActionBlock + } ... on EmbedBlockRecord { ...EmbedBlock } diff --git a/src/pages/[locale]/index.astro b/src/pages/[locale]/index.astro index fce23d9d..40b1c244 100644 --- a/src/pages/[locale]/index.astro +++ b/src/pages/[locale]/index.astro @@ -8,7 +8,6 @@ import Layout from '@layouts/Default.astro'; import type { AnyBlock } from '@blocks/Blocks'; import Blocks from '@blocks/Blocks.astro'; import PreviewModeSubscription from '@components/PreviewMode/PreviewModeSubscription.astro'; -import ShareButton from '@components/ShareButton/ShareButton.astro'; import query from './_index.query.graphql'; export async function getStaticPaths() { @@ -31,5 +30,4 @@ const pageUrls = locales.map((locale) => ({

{page.title}

- diff --git a/src/pages/_404.query.graphql b/src/pages/_404.query.graphql index 50bb4fc0..54b843b3 100644 --- a/src/pages/_404.query.graphql +++ b/src/pages/_404.query.graphql @@ -1,3 +1,4 @@ +#import '@blocks/ActionBlock/ActionBlock.fragment.graphql' #import '@blocks/EmbedBlock/EmbedBlock.fragment.graphql' #import '@blocks/ImageBlock/ImageBlock.fragment.graphql' #import '@blocks/PagePartialBlock/PagePartialBlock.fragment.graphql' @@ -12,6 +13,9 @@ query NotFoundPage($locale: SiteLocale!) { title bodyBlocks { __typename + ... on ActionBlockRecord { + ...ActionBlock + } ... on EmbedBlockRecord { ...EmbedBlock } diff --git a/src/pages/api/demo/geo.ts b/src/pages/api/demo/geo.ts deleted file mode 100644 index 91242c30..00000000 --- a/src/pages/api/demo/geo.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { Runtime } from '@astrojs/cloudflare'; - -export const prerender = false; - -interface GeoData { - asn?: string; - city?: string; - continent?: string; - country?: string; - ip?: string; - latitude?: string; - longitude?: string; - postalCode?: string; - region?: string; - regionCode?: string; - timezone?: string; -} - -/** - * Demo endpoint to show how to access Cloudflare runtime properties, like geo data. - */ -export function GET ({ locals }: { locals: Runtime }) { - const geoProps: Array = ['asn', 'city', 'continent', 'country', 'ip', 'latitude', 'longitude', 'postalCode', 'region', 'regionCode', 'timezone']; - const geoData: GeoData = geoProps.reduce((acc, prop) => { - if (locals.runtime.cf && locals.runtime.cf[prop]) { - acc[prop] = locals.runtime.cf[prop] as GeoData[keyof GeoData]; - } - return acc; - }, {} as GeoData); - return new Response(JSON.stringify(geoData, null, 2)); -} diff --git a/src/pages/api/demo/hello.ts b/src/pages/api/demo/hello.ts deleted file mode 100644 index 24f36679..00000000 --- a/src/pages/api/demo/hello.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { APIRoute } from 'astro'; - -export const prerender = false; - -/** - * Demo endpoint to test dynamic use of query parameters in an API route. - */ -export const GET: APIRoute = ({ request }) => { - return new Response(JSON.stringify({ - hello: new URL(request.url).searchParams.get('to'), - })); -};