diff --git a/.env.example b/.env.example index ffeb27da7..2c9a2459a 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,9 @@ CONVERTKIT_FORM_ID="100000" # to ensure the request originated from Vercel. CRON_SECRET="local-cron-secret" +# Like it says on the label +PUBLIC_GOOGLE_MAPS_API_KEY="" + # Mock Resend API (WireMock container that backs transactional email tests # local and on GitHub Actions, production on Vercel) RESEND_HTTP_PORT="9011" diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 6084c7c99..41807fe5b 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -24,7 +24,14 @@ jobs: uses: actions/github-script@v8 with: script: | - const runId = context.payload.workflow_run.id; + const workflowRun = context.payload.workflow_run; + const headBranch = workflowRun?.head_branch; + if (typeof headBranch === 'string' && headBranch.startsWith('hotfix/')) { + core.info('hotfix/* branch detected; skipping CI job verification.'); + return; + } + + const runId = workflowRun.id; const requiredJobs = ['Lint', 'Unit Tests']; const { data } = await github.rest.actions.listJobsForWorkflowRun({ owner: context.repo.owner, @@ -53,24 +60,133 @@ jobs: github.event.workflow_run.event == 'pull_request' steps: + - name: Hotfix branch — skip preview deploy + if: ${{ startsWith(github.event.workflow_run.head_branch, 'hotfix/') }} + run: | + echo "hotfix/* branch detected; skipping preview deployment (treat as success)." + - name: Checkout repository + if: ${{ !startsWith(github.event.workflow_run.head_branch, 'hotfix/') }} uses: actions/checkout@v6 with: ref: ${{ github.event.workflow_run.head_sha }} + - name: Create GitHub deployment (Preview) + if: ${{ !startsWith(github.event.workflow_run.head_branch, 'hotfix/') }} + id: github-deployment-preview + uses: actions/github-script@v8 + with: + script: | + const workflowRun = context.payload.workflow_run; + const ref = workflowRun?.head_sha; + if (!ref) { + core.setFailed('Missing workflow_run.head_sha; cannot create GitHub deployment.'); + return; + } + + const { data } = await github.rest.repos.createDeployment({ + owner: context.repo.owner, + repo: context.repo.repo, + ref, + auto_merge: false, + required_contexts: [], + environment: 'vercel-preview', + transient_environment: true, + production_environment: false, + description: 'Vercel preview deployment' + }); + + core.setOutput('deployment_id', String(data.id)); + - name: Deploy to Vercel (Preview) + if: ${{ !startsWith(github.event.workflow_run.head_branch, 'hotfix/') }} uses: amondnet/vercel-action@v41.1.4 id: vercel-preview with: vercel-token: ${{ secrets.VERCEL_TOKEN }} + github-token: ${{ secrets.GITHUB_TOKEN }} vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} github-comment: false env: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + GITHUB_SHA: ${{ github.event.workflow_run.head_sha }} + GITHUB_REF: refs/heads/${{ github.event.workflow_run.head_branch }} + + - name: Update GitHub deployment status (Preview) + if: ${{ !startsWith(github.event.workflow_run.head_branch, 'hotfix/') && always() }} + uses: actions/github-script@v8 + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + with: + script: | + const deploymentIdRaw = '${{ steps.github-deployment-preview.outputs.deployment_id }}'; + const deploymentId = Number(deploymentIdRaw); + if (!deploymentIdRaw || Number.isNaN(deploymentId)) { + core.warning('Missing deployment id; skipping GitHub deployment status update.'); + return; + } + + const workflowRun = context.payload.workflow_run; + const rawPreviewUrl = '${{ steps.vercel-preview.outputs.preview-url }}'.trim(); + const isVercelUrl = (url) => typeof url === 'string' && /vercel\.(app|com)/.test(url); + const fallbackRunUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + + const fetchDeploymentUrl = async () => { + if (!process.env.VERCEL_TOKEN || !process.env.VERCEL_PROJECT_ID) { + return null; + } + if (typeof fetch !== 'function') { + return null; + } + const query = new URLSearchParams({ + projectId: process.env.VERCEL_PROJECT_ID, + 'meta-githubCommitSha': workflowRun?.head_sha ?? '', + limit: '1' + }); + if (process.env.VERCEL_ORG_ID) { + query.set('teamId', process.env.VERCEL_ORG_ID); + } + const response = await fetch(`https://api.vercel.com/v6/deployments?${query.toString()}`, { + headers: { + Authorization: `Bearer ${process.env.VERCEL_TOKEN}` + } + }); + if (!response.ok) { + return null; + } + const data = await response.json(); + const deployment = data?.deployments?.[0]; + if (deployment?.url) { + return `https://${deployment.url}`; + } + if (deployment?.inspectorUrl) { + return deployment.inspectorUrl.startsWith('http') + ? deployment.inspectorUrl + : `https://${deployment.inspectorUrl}`; + } + return null; + }; + + const previewUrl = isVercelUrl(rawPreviewUrl) ? rawPreviewUrl : await fetchDeploymentUrl(); + const state = '${{ steps.vercel-preview.outcome }}' === 'success' ? 'success' : 'failure'; + const targetUrl = isVercelUrl(previewUrl) ? previewUrl : fallbackRunUrl; + + await github.rest.repos.createDeploymentStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: deploymentId, + state, + environment: 'vercel-preview', + environment_url: targetUrl, + log_url: fallbackRunUrl, + description: state === 'success' ? 'Vercel preview is ready' : 'Vercel preview failed' + }); - name: Comment preview URL on PR - if: always() && steps.vercel-preview.outcome == 'success' + if: ${{ !startsWith(github.event.workflow_run.head_branch, 'hotfix/') && always() && steps.vercel-preview.outcome == 'success' }} uses: actions/github-script@v8 env: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} @@ -185,7 +301,7 @@ jobs: } - name: Comment preview failure on PR - if: always() && steps.vercel-preview.outcome != 'success' + if: ${{ !startsWith(github.event.workflow_run.head_branch, 'hotfix/') && always() && steps.vercel-preview.outcome != 'success' }} uses: actions/github-script@v8 env: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} @@ -273,16 +389,73 @@ jobs: with: ref: ${{ github.event.workflow_run.head_sha }} + - name: Create GitHub deployment (Production) + id: github-deployment-production + uses: actions/github-script@v8 + with: + script: | + const workflowRun = context.payload.workflow_run; + const ref = workflowRun?.head_sha; + if (!ref) { + core.setFailed('Missing workflow_run.head_sha; cannot create GitHub deployment.'); + return; + } + + const { data } = await github.rest.repos.createDeployment({ + owner: context.repo.owner, + repo: context.repo.repo, + ref, + auto_merge: false, + required_contexts: [], + environment: 'production', + transient_environment: false, + production_environment: true, + description: 'Vercel production deployment' + }); + + core.setOutput('deployment_id', String(data.id)); + - name: Deploy to Vercel (Production) uses: amondnet/vercel-action@v41.1.4 id: vercel-production with: vercel-token: ${{ secrets.VERCEL_TOKEN }} + github-token: ${{ secrets.GITHUB_TOKEN }} vercel-args: '--prod' vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} env: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + GITHUB_SHA: ${{ github.event.workflow_run.head_sha }} + GITHUB_REF: refs/heads/${{ github.event.workflow_run.head_branch }} + + - name: Update GitHub deployment status (Production) + if: ${{ always() }} + uses: actions/github-script@v8 + with: + script: | + const deploymentIdRaw = '${{ steps.github-deployment-production.outputs.deployment_id }}'; + const deploymentId = Number(deploymentIdRaw); + if (!deploymentIdRaw || Number.isNaN(deploymentId)) { + core.warning('Missing deployment id; skipping GitHub deployment status update.'); + return; + } + + const state = '${{ steps.vercel-production.outcome }}' === 'success' ? 'success' : 'failure'; + const rawUrl = '${{ steps.vercel-production.outputs.preview-url }}'.trim(); + const environmentUrl = rawUrl ? rawUrl : undefined; + const logUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + + await github.rest.repos.createDeploymentStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: deploymentId, + state, + environment: 'production', + environment_url: environmentUrl, + log_url: logUrl, + description: state === 'success' ? 'Production deploy completed' : 'Production deploy failed' + }); - name: Log production deployment if: always() && steps.vercel-production.outcome == 'success' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f762900dc..401c2f45f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,6 +16,7 @@ env: CONVERTKIT_API_KEY: ${{ secrets.CONVERTKIT_API_KEY }} CONVERTKIT_FORM_ID: ${{ secrets.CONVERTKIT_FORM_ID }} CRON_SECRET: ${{ secrets.CRON_SECRET }} + PUBLIC_GOOGLE_MAPS_API_KEY: ${{ secrets.PUBLIC_GOOGLE_MAPS_API_KEY }} RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }} diff --git a/_TODO.md b/_TODO.md index 5080ee638..fb16dcc5c 100644 --- a/_TODO.md +++ b/_TODO.md @@ -159,44 +159,9 @@ Needs to add real API key and test ## Astro 3rd-Party Integrations, Eleventy Migration -- **`eleventy-plugin-external-links`** +### **`eleventy-plugin-external-links`** -### Adds `target="_blank" rel="noreferrer"` to all external links - -- **`eleventy-plugin-emoji`** - -### Time to Read - -Adds filter for analyzing content input into the filter and returning a time-to-read estimate to use in text like 'This will take 3 minutes to read'. - -```typescript -{ - speed: '200 words per minute', - /** 'long': 3 minutes and 10 seconds, 'short': 3 min & 10 sec, 'narrow': 3m, 10s */ - style: 'narrow',src/layouts/BaseLayout.astro - /** Which time units to render */ - hours: false, - minutes: true, - seconds: false, - /** - * Format returned string - * - * @param {object} data - An object with various keys, see docs - * @returns {string} Returns the formatted string to return from time-to-read shortcode - */ - output: function (data) { - return data.timing - }, - } -``` - -## An accessible Emoji component. Wraps emojis in a `` with `aria-label` or `aria-hidden`, and `role` attributes - -[`astro-emoji`](https://github.com/seanmcp/astro-emoji#astro-emoji) - -### Table of Contents (ToC) generator - -[`astro-toc`](https://github.com/theisel/astro-toc#readme) +Adds `target="_blank" rel="noreferrer"` to all external links ### Astro wrapper for the `@github/clipboard-copy-element` web component. Copies element text content or input values to the clipboard @@ -218,10 +183,6 @@ Adds filter for analyzing content input into the filter and returning a time-to- ## Miscellaneous -### [`astro-auto-import`](https://www.npmjs.com/package/astro-auto-import) - -Allows you to auto-import components or other modules and access them in MDX files without importing them. - ### [`astro-directives`](https://github.com/QuentinDutot/astro-directives) Adds some custom directives: @@ -245,7 +206,8 @@ Adds some custom directives: Allows any Mastodon instance to discover your Mastodon profile directly from your own domain. -## Refactor social neworks in Authors collection to Contact collection format + +## Refactor social networks in Authors collection to Contact collection format The contact data collection uses an array of social networks, with keys: @@ -260,36 +222,6 @@ The contact data collection uses an array of social networks, with keys: The authors collection is using named entries under a "social" property, like "twitter", "github", etc. This task is to refactor that to use an array like contact data collection. We also need to add a color for the social network icon, or some other approach to setting the color of it while enabling theming. -We should also make sure the avatar key in the authors collection is being output as a responsive image tag. - -## Add Google Maps screenshot (or maps embed) to Contact Page - -src/assets/images/map.webp - -## Display a system font until font files load (Lighthouse improvements) - -Display a system font until font files load to improve FCP (First Contentful Paint) with `font-display: swap`. Need to make sure that web font doesn't render larger or smaller than the system font fallback to avoid CLS (Cumulative Layout Shift) issues. - -```css -@font-face { - font-family: 'Pacifico'; - font-style: normal; - font-weight: 400; - src: local('Pacifico Regular'), local('Pacifico-Regular'), - url(https://fonts.gstatic.com/s/pacifico/v12/FwZY7-Qmy14u9lezJ-6H6MmBp0u-.woff2) format('woff2'); - font-display: swap; -} -``` - -Preload fonts: - -```html - -``` - -TTI (Time to Interactive) measures time from when the page is painted until it becomes usefully interactive. -Interactive can only have two in-flight network requests. - ## Stuff from ZMarkdown, a prepackaged Unified config Repo is in root of Corporate Websites @@ -331,10 +263,6 @@ Repo is in root of Corporate Websites This plugin parses custom Markdown syntax to create new custom blocks. -- **remark-emoticons** - - This plugins replaces ASCII emoticons with associated image. Compatible with [rehype][rehype] - - **remark-escape--escaped** This plugin escapes HTML entities from Markdown input. diff --git a/package-lock.json b/package-lock.json index d19ebd369..dd486505e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@astrojs/vercel": "^9.0.2", "@axe-core/playwright": "^4.11.0", "@glidejs/glide": "^3.7.1", + "@googlemaps/extended-component-library": "^0.6.14", "@nanostores/lit": "^0.2.3", "@nanostores/persistent": "^1.2.0", "@semantic-ui/astro-lit": "^5.1.1", @@ -3873,6 +3874,18 @@ "integrity": "sha512-8he0pZpLSqTesSFYiuWdhBmtdlYSPWxfPUCKtkkiX6ZmT8UdMdmoFPtJaOwLBXv4p2JiGxqbuPdiRGGo2e/htQ==", "license": "MIT" }, + "node_modules/@googlemaps/extended-component-library": { + "version": "0.6.14", + "resolved": "https://registry.npmjs.org/@googlemaps/extended-component-library/-/extended-component-library-0.6.14.tgz", + "integrity": "sha512-oq8WkYIEOpi1K5+wxM1SOeko/PgX9pHx7RJTsC00Vxf6vN/UqfmXMM04F0TXB3a9kINYzMHspAD4L2Lq8P+kMQ==", + "license": "Apache-2.0", + "dependencies": { + "@lit/context": "^1.0.0", + "@lit/react": "^1.0.0", + "lit": "^3.0.0", + "tslib": "^2.4.0" + } + }, "node_modules/@happy-dom/global-registrator": { "version": "20.0.11", "resolved": "https://registry.npmjs.org/@happy-dom/global-registrator/-/global-registrator-20.0.11.tgz", @@ -4998,6 +5011,24 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/@lit/context": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@lit/context/-/context-1.1.6.tgz", + "integrity": "sha512-M26qDE6UkQbZA2mQ3RjJ3Gzd8TxP+/0obMgE5HfkfLhEEyYE3Bui4A5XHiGPjy0MUGAyxB3QgVuw2ciS0kHn6A==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit/reactive-element": "^1.6.2 || ^2.1.0" + } + }, + "node_modules/@lit/react": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@lit/react/-/react-1.0.8.tgz", + "integrity": "sha512-p2+YcF+JE67SRX3mMlJ1TKCSTsgyOVdAwd/nxp3NuV1+Cb6MWALbN6nT7Ld4tpmYofcE5kcaSY1YBB9erY+6fw==", + "license": "BSD-3-Clause", + "peerDependencies": { + "@types/react": "17 || 18 || 19" + } + }, "node_modules/@lit/reactive-element": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.1.tgz", @@ -8355,7 +8386,6 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -13348,7 +13378,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/cuss": { diff --git a/package.json b/package.json index 41d987258..c8286b25a 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "@astrojs/vercel": "^9.0.2", "@axe-core/playwright": "^4.11.0", "@glidejs/glide": "^3.7.1", + "@googlemaps/extended-component-library": "^0.6.14", "@nanostores/lit": "^0.2.3", "@nanostores/persistent": "^1.2.0", "@semantic-ui/astro-lit": "^5.1.1", diff --git a/src/components/Map/client/index.ts b/src/components/Map/client/index.ts new file mode 100644 index 000000000..c7b3890b1 --- /dev/null +++ b/src/components/Map/client/index.ts @@ -0,0 +1,82 @@ +import { addScriptBreadcrumb } from '@components/scripts/errors' +import { handleScriptError } from '@components/scripts/errors/handler' +import { getGoogleMapsApiKey } from '@components/scripts/utils/environmentClient' +import { APILoader } from '@googlemaps/extended-component-library/api_loader.js' + +type MapInitContext = { + scriptName: 'Map' + operation: string +} + +const MAP_SELECTOR = '[data-company-map]' + +async function geocodeAddress(address: string): Promise<{ lat: number; lng: number } | null> { + const geocodingLibrary = await APILoader.importLibrary('geocoding') + const Geocoder = (geocodingLibrary as unknown as { Geocoder: new () => { geocode: (_request: { address: string }) => Promise<{ results?: Array<{ geometry?: { location?: { lat: () => number; lng: () => number } } }> }> } }).Geocoder + + const geocoder = new Geocoder() + const response = await geocoder.geocode({ address }) + const firstResult = response.results?.[0] + + if (!firstResult?.geometry?.location) { + return null + } + + const location = firstResult.geometry.location + + return { + lat: location.lat(), + lng: location.lng(), + } +} + +async function initMapElement(root: HTMLElement): Promise { + const loader = root.querySelector('gmpx-api-loader') as APILoader | null + const map = root.querySelector('gmp-map') + const marker = root.querySelector('gmp-advanced-marker') + const address = root.getAttribute('data-address') ?? '' + + if (!loader || !map || !marker || !address) { + return + } + + loader.key = getGoogleMapsApiKey() + + const location = await geocodeAddress(address) + if (!location) { + return + } + + const centerValue = `${location.lat},${location.lng}` + map.setAttribute('center', centerValue) + map.setAttribute('zoom', '16') + marker.setAttribute('position', centerValue) +} + +async function initAllMaps(): Promise { + const maps = Array.from(document.querySelectorAll(MAP_SELECTOR)) + if (maps.length === 0) { + return + } + + await Promise.all(maps.map((root) => initMapElement(root))) +} + +export function registerCompanyMap(): void { + const context: MapInitContext = { scriptName: 'Map', operation: 'registerCompanyMap' } + addScriptBreadcrumb(context) + + const init = () => { + initAllMaps().catch((error) => { + handleScriptError(error, { scriptName: 'Map', operation: 'initAllMaps' }) + }) + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init, { once: true }) + } else { + init() + } + + document.addEventListener('astro:page-load', init) +} diff --git a/src/components/Map/index.astro b/src/components/Map/index.astro new file mode 100644 index 000000000..aa49f5407 --- /dev/null +++ b/src/components/Map/index.astro @@ -0,0 +1,18 @@ +--- +import contactData from '@content/contact.json' + +const address = `${contactData.company.address}, ${contactData.company.city}, ${contactData.company.state} ${contactData.company.index}` +const mapAriaLabel = `Map showing ${contactData.company.name} location` +--- + +
+ + + + +
+ + diff --git a/src/components/scripts/utils/environmentClient.ts b/src/components/scripts/utils/environmentClient.ts index 025367fbb..c9f9d18f1 100644 --- a/src/components/scripts/utils/environmentClient.ts +++ b/src/components/scripts/utils/environmentClient.ts @@ -2,7 +2,7 @@ * This file cannot be used in API endpoint code. See * src/lib/config/environmentServer.ts for details. */ -import { PACKAGE_RELEASE_VERSION, PRIVACY_POLICY_VERSION } from 'astro:env/client' +import { PACKAGE_RELEASE_VERSION, PRIVACY_POLICY_VERSION, PUBLIC_GOOGLE_MAPS_API_KEY } from 'astro:env/client' import { ClientScriptError } from '@components/scripts/errors' /** @@ -85,3 +85,17 @@ export function getPrivacyPolicyVersion(): string { } return PRIVACY_POLICY_VERSION } + +/** + * Gets the Google Maps API key injected at build time. + * @throws {ClientScriptError} If PUBLIC_GOOGLE_MAPS_API_KEY is not set + */ +export function getGoogleMapsApiKey(): string { + if (!PUBLIC_GOOGLE_MAPS_API_KEY) { + throw new ClientScriptError( + 'PUBLIC_GOOGLE_MAPS_API_KEY environment variable is not set. This is required to load Google Maps components in the browser.', + ) + } + + return PUBLIC_GOOGLE_MAPS_API_KEY +} diff --git a/src/content.config.ts b/src/content.config.ts index 63a6099a0..8c6df3af7 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -22,28 +22,30 @@ import { withBreadcrumbTitleWarning } from '@lib/helpers/breadcrumbTitleLengthRe /** Only load markdown and MDX files that do not start with an underscore */ const pattern = '**\/[^_]*.{md,mdx}' -const tagContentDir = fileURLToPath(new URL('./content/tags', import.meta.url)) -const tagSlugs = readdirSync(tagContentDir, { withFileTypes: true }) - .filter(entry => entry.isDirectory() && !entry.name.startsWith('_')) - .filter(entry => { - const indexMd = new URL(`./content/tags/${entry.name}/index.md`, import.meta.url) - const indexMdx = new URL(`./content/tags/${entry.name}/index.mdx`, import.meta.url) - return fsExists(indexMd) || fsExists(indexMdx) - }) - .map(entry => entry.name) - .sort((a, b) => a.localeCompare(b, 'en')) - -if (tagSlugs.length === 0) { - throw new Error('No tags found. Add markdown files under src/content/tags.') +function fsExists(url: URL) { + return existsSync(fileURLToPath(url)) } -type ZodEnumType = [string, ...string[]] -const validTags = tagSlugs as ZodEnumType +const getValidTags = () => { + const tagContentDir = fileURLToPath(new URL('./content/tags', import.meta.url)) + const tagSlugs = readdirSync(tagContentDir, { withFileTypes: true }) + .filter(entry => entry.isDirectory() && !entry.name.startsWith('_')) + .filter(entry => { + const indexMd = new URL(`./content/tags/${entry.name}/index.md`, import.meta.url) + const indexMdx = new URL(`./content/tags/${entry.name}/index.mdx`, import.meta.url) + return fsExists(indexMd) || fsExists(indexMdx) + }) + .map(entry => entry.name) + .sort((a, b) => a.localeCompare(b, 'en')) -function fsExists(url: URL) { - return existsSync(fileURLToPath(url)) + if (tagSlugs.length === 0) { + throw new Error('No tags found. Add markdown files under src/content/tags.') + } + return tagSlugs as [string, ...string[]] } +const validTags = getValidTags() + const createBaseCollectionSchema = ({ image }: SchemaContext) => z.object({ title: z.string(), @@ -144,22 +146,6 @@ const downloadsCollection = defineCollection({ * ================================================================================= */ -/** - * Tags - */ -const tagsCollection = defineCollection({ - loader: glob({ pattern: '**/index.{md,mdx}', base: './src/content/tags' }), - schema: ({ image }) => - z.object({ - slug: z.string(), - displayName: z.string(), - description: z.string(), - cover: image(), - coverAlt: z.string(), - featured: z.boolean().default(false), - }), -}) - /** * About */ @@ -227,6 +213,23 @@ const contactDataCollection = defineCollection({ }), }) + +/** + * Tags + */ +const tagsCollection = defineCollection({ + loader: glob({ pattern: '**/index.{md,mdx}', base: './src/content/tags' }), + schema: ({ image }) => + z.object({ + slug: z.string(), + displayName: z.string(), + description: z.string(), + cover: image(), + coverAlt: z.string(), + featured: z.boolean().default(false), + }), +}) + /** * Testimonials. These do not have pages generated for them. */ diff --git a/src/lib/config/environmentalVariableValidation.ts b/src/lib/config/environmentalVariableValidation.ts index fd0a66b9f..4f60eb6a7 100644 --- a/src/lib/config/environmentalVariableValidation.ts +++ b/src/lib/config/environmentalVariableValidation.ts @@ -63,6 +63,16 @@ export const environmentalVariablesConfig: AstroUserConfig['env'] = { default: 4321, optional: true, }), + /** + * Google Maps Platform + * + * Used by client-side map components (must be PUBLIC_ so Vite can bundle it) + */ + PUBLIC_GOOGLE_MAPS_API_KEY: envField.string({ + access: 'public', + context: 'client', + optional: false, + }), /** * Site uses ConvertKit for managing newsletter subscriptions */ diff --git a/src/pages/contact/index.astro b/src/pages/contact/index.astro index ea640f205..b6da9166e 100644 --- a/src/pages/contact/index.astro +++ b/src/pages/contact/index.astro @@ -1,6 +1,7 @@ --- import BaseLayout from '@layouts/BaseLayout.astro' import ContactForm from '@components/Forms/Contact/index.astro' +import Map from '@components/Map/index.astro' const pageTitle = 'Contact Us' const path = '/contact' @@ -27,6 +28,9 @@ const description = 'Connect with the Webstack Builders team to discuss your pro + + +