diff --git a/.cache/pages.json b/.cache/pages.json
index 4844728a4..f52647700 100644
--- a/.cache/pages.json
+++ b/.cache/pages.json
@@ -18,24 +18,14 @@
"us-logistics"
]
},
+ "consent",
"contact",
- "cookies",
- {
- "downloads": [
- "api-tool-consolidation-whitepaper",
- "identity-security-for-dummies",
- "lakehouse-analytics-guide",
- "observability-benefits-guide",
- "ransomware-recovery-kit"
- ]
- },
"offline",
- "privacy",
{
- "services": ["create-custom-font-sets", "overview"]
+ "privacy": ["my-data"]
},
{
- "social-shares": ["template"]
+ "services": ["create-custom-font-sets", "overview"]
},
{
"tags": [
diff --git a/.clinerules/project-standards.md b/.clinerules/project-standards.md
index be24f9658..c003a5116 100644
--- a/.clinerules/project-standards.md
+++ b/.clinerules/project-standards.md
@@ -10,7 +10,6 @@ Follow the coding standards defined in `.github/instructions/`:
Key rules:
- Use TypeScript exclusively
-- Testing with Container API and happy-dom
- Never use manual HTML fixtures
- No apologizing, be direct/concise
- Wait for permission before implementing suggestions
diff --git a/.eslintrc.js b/.eslintrc.js
deleted file mode 100644
index 184311880..000000000
--- a/.eslintrc.js
+++ /dev/null
@@ -1,119 +0,0 @@
-import eslint from "@eslint/js"
-import astroPlugin from "eslint-plugin-astro"
-import importPlugin from "eslint-plugin-import"
-import jsdocPlugin from "eslint-plugin-jsdoc"
-import securityPlugin from "eslint-plugin-security"
-import ymlPlugin from "eslint-plugin-yml"
-import tsPlugin from "typescript-eslint"
-import restrictedGlobals from "confusing-browser-globals"
-
-// eslint-disable-next-line no-undef
-const level = process.env["NODE_ENV"] === "production" ? "error" : "warn"
-
-export default [
- eslint.configs.strict,
- ...tsPlugin.configs.strict,
- ...astroPlugin.configs.recommended,
- ...astroPlugin.configs['jsx-a11y-strict'],
- importPlugin.flatConfigs.recommended,
- jsdocPlugin.configs['flat/recommended-typescript'],
- securityPlugin.configs.recommended,
- ...ymlPlugin.configs['flat/recommended'],
- {
- /** No globals are enabled for ESLint by default: 'writable', 'readonly', or 'off'. */
- globals: {
- NodeJS: 'readonly',
- },
- env: {
- browser: true,
- commonjs: true,
- es6: true,
- node: true,
- },
- languageOptions: {
- ecmaVersion: 'latest',
- sourceType: 'module',
- },
- rules: {
- /**
- * Common rule settings for all linted files
- */
- '@typescript-eslint/ban-ts-comment': 'off',
- '@typescript-eslint/ban-types': level,
- '@typescript-eslint/consistent-type-assertions': [
- level,
- { assertionStyle: 'as', objectLiteralTypeAssertions: 'allow-as-parameter' },
- ],
- /**
- * Avoid un-fixable lint errors reported within .js/.jsx files
- * https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/explicit-module-boundary-types.md
- */
- '@typescript-eslint/explicit-module-boundary-types': 'off',
- '@typescript-eslint/no-unsafe-argument': 'off',
- '@typescript-eslint/no-non-null-assertion': 'off',
- '@typescript-eslint/no-unsafe-assignment': 'off',
- '@typescript-eslint/no-unsafe-call': 'off',
- '@typescript-eslint/no-unsafe-member-access': 'off',
- '@typescript-eslint/no-unsafe-return': 'off',
- '@typescript-eslint/no-unused-vars': [
- level,
- { varsIgnorePattern: '^_', argsIgnorePattern: '^_' },
- ],
- '@typescript-eslint/no-var-requires': level,
- /** Prohibits using a type assertion that does not change the type of an expression. */
- '@typescript-eslint/no-unnecessary-type-assertion': level,
- '@typescript-eslint/restrict-template-expressions': 'off',
- /** Continue allowing triple-slash refs, TS wants to use 'import' syntax instead */
- '@typescript-eslint/triple-slash-reference': 'off',
- /** Issue with Prettier https://github.com/prettier/eslint-plugin-prettier/issues/65: */
- 'arrow-body-style': 'off',
- camelcase: [level],
- curly: [level, 'all'],
- /** eslint-plugin-comments */
- 'eslint-comments/no-unused-disable': level,
- /** eslint-plugin-import */
- 'import/no-unresolved': level,
- 'import/no-webpack-loader-syntax': level,
- 'import/order': 'off',
- /** eslint-plugin-jsdoc */
- 'jsdoc/check-indentation': level,
- 'jsdoc/check-line-alignment': level,
- 'jsdoc/check-syntax': level,
- 'jsdoc/check-tag-names': [
- level,
- {
- definedTags: ['NOTE:', 'TODO:', 'jest-environment', 'jest-environment-options'],
- jsxTags: true,
- },
- ],
- /** Applies a regex to description so that it's text-only starting with a capital */
- 'jsdoc/match-description': 'off',
- 'jsdoc/no-bad-blocks': level,
- 'jsdoc/no-defaults': level,
- 'jsdoc/no-types': 'off',
- 'jsdoc/require-jsdoc': 'off',
- 'jsdoc/require-param': 'off',
- 'jsdoc/require-param-type': 'off',
- 'jsdoc/require-returns': 'off',
- 'jsdoc/require-returns-type': 'off',
- /** JSDoc does not support import() for typedefs */
- 'jsdoc/valid-types': 'off',
- 'new-cap': [level, { newIsCap: true, capIsNew: false }],
- 'no-new': level,
- 'no-restricted-globals': ['error'].concat(restrictedGlobals),
- 'no-unused-expressions': [level, { allowShortCircuit: true, allowTernary: true }],
- 'no-unused-vars': [level, { varsIgnorePattern: '^_', argsIgnorePattern: '^_' }],
- 'no-useless-escape': 'off',
- /** Issue with Prettier https://github.com/prettier/eslint-plugin-prettier/issues/65: */
- 'prefer-arrow-callback': 'off',
- 'prefer-object-spread': level,
- 'prefer-spread': level,
- /** Getting false positives on HTMLElement.classList.add/.remove methods */
- 'security/detect-non-literal-fs-filename': 'off',
- 'security/detect-object-injection': 'off',
- 'security/detect-unsafe-regex': 'off',
- /** Too many false positives from using @TODO and no way to add add'l tags to rule */
- 'tsdoc/syntax': 'off', // eslint-disable-line jsdoc/escape-inline-tags
- }
- }
-]
diff --git a/.github/instructions/astro.instructions.md b/.github/instructions/astro.instructions.md
index c4855b80b..fc6ab723f 100644
--- a/.github/instructions/astro.instructions.md
+++ b/.github/instructions/astro.instructions.md
@@ -12,4 +12,4 @@ applyTo: "docs/**/*.astro"
- When testing Astro components, use the Astro Container API instead of manual HTML fixtures.
- This ensures tests stay in sync with component changes automatically.
- Reference: Astro Container API Documentation at docs.astro.build/en/reference/container-reference/
-- Note: The Container API does not work reliably in jsdom/vitest environments. Use happy-dom instead.
+- There is a model component and test demonstrating this pattern in the src/components/Test directory named webComponent.
diff --git a/.github/instructions/general.instructions.md b/.github/instructions/general.instructions.md
index ba63302bd..105a95ef6 100644
--- a/.github/instructions/general.instructions.md
+++ b/.github/instructions/general.instructions.md
@@ -18,27 +18,85 @@ applyTo: "**"
- Do not use run-astro-dev, always use "npm run dev".
- Always create TypeScript files, not JavaScript files.
- Prefer destructured imports over namespace imports when importing specific functions from modules (e.g., `import { resolve } from 'path'` instead of `import * as path from 'path'`).
+- Do not access nanostore observables (e.g., `$consent`) directly from components; expose helper/action methods in `@components/scripts/store` and import those instead.
+
+# Code Organization and Directory Structure
+
+## src/lib Directory Restrictions
+- **The src/lib directory is for server-side build code ONLY**
+- NO client-side code can go in src/lib (it gets bundled into server-side builds)
+- Client-side utilities should go in src/components/scripts/ or appropriate component directories
+
+## API Code Organization
+- **API endpoints** go in `src/pages/api/`
+- **Code files related to API endpoints** go in `src/pages/api/` and are prefixed with `_` (e.g., `_utils/`, `_contracts/`)
+- **API utility files** go specifically in the `_utils/` folder
+- **API contract/type files** go in `_contracts/` folder for centralized type definitions
+
+## Mixed Concern Files
+- Files that straddle server-side API and client-side concerns (like API client wrappers) require clarification
+- **Ask before placing such files** - they may need special handling or alternative organization
+- Example: gdpr.client.ts (API client wrapper) - unclear placement due to mixed server/client concerns
+
+# Astro View Transitions Navigation
+
+Components may have behavior dependent on Astro View Transitions navigation events. Choose the appropriate navigation method:
+
+- **Fresh page load**: Use `page.goto(url)` for full browser navigation (no View Transitions, triggers full page lifecycle)
+- **Client-side navigation**: Use `navigateToPage('/path')` for in-site navigation with View Transitions (triggers `astro:page-load` and other View Transition events)
+
+Always use the `navigateToPage()` method for client-side navigation - never ad-hoc `click('a[href]')` calls. This maintains centralized control.
+
+When a Playwright-native action (e.g., `page.click()`, `page.fill()`, `page.hover()`) is required, expose it through the shared `BasePage` helpers (e.g., `BasePage.click()`), then call that helper from tests instead of the raw Playwright API. This keeps all browser interactions centrally managed and makes future behavior changes (timeouts, logging, etc.) easier.
+
+# Personality
# Testing Standards
-- NEVER use manual HTML strings in test files. They get out of sync with templates and are worse than no test at all.
-- Always use Astro's Container API to create fixtures from actual .astro templates.
+## Astro Component Testing - Container API (MANDATORY)
+
+- **NEVER use manual HTML strings in test files or fixtures.** They get out of sync with templates and are worse than no test at all.
+- **ALWAYS use Astro's Container API** to create fixtures from actual .astro templates. See: https://docs.astro.build/en/reference/container-reference/
+- **Test fixtures MUST import actual components**, not duplicate HTML. Example:
+ ```astro
+ ---
+ import MyComponent from '@components/MyComponent/index.astro'
+ const { testProp } = Astro.props
+ ---
+
+ ```
+- **Hard-coded HTML fixtures are FORBIDDEN.** If you find yourself writing HTML in a fixture, STOP and use the actual component instead.
- Reference the working example in src/components/Test/container.astro and its test file.
- Use experimental_AstroContainer.create() to instantiate the container.
- Use container.renderToString(Component) to get rendered HTML from actual Astro components.
-- For DOM unit testing with Container API: use `// @vitest-environment happy-dom` for better DOM compatibility than jsdom or node.
- Configure Vitest with getViteConfig() from 'astro/config' to support Astro Container API.
- Test files should follow a client.spec.ts naming pattern or similar.
-- Fixture files should follow a componentName.fixture.ts naming pattern (e.g., newsletter.fixture.ts).
-- Use `// @vitest-environment happy-dom` as the first line of test files that need DOM support with Container API. Never include Vitest directives inside JSDoc comments.
-- happy-dom provides proper document, window, and localStorage globals without manual mocking.
-- JavaScript loading warnings from happy-dom are silenced in vitest.setup.ts for clean test output.
+- Fixture files should follow a componentName.fixture.astro naming pattern (e.g., newsletter.fixture.astro).
- A working example test using the Container API is available at /home/kevin/Repos/Webstack Builders/Corporate Website/astro.webstackbuilders.com/src/components/Test/container.spec.ts
+
+## E2E Testing Standards
+
- **NEVER hard-code content slugs in e2e tests** (e.g., `/articles/typescript-best-practices`, `/services/web-development`). Content can be deleted or renamed. Always dynamically fetch the first available item from listing pages (articles, services, case-studies, etc.) and navigate to it. This prevents test breakage when content changes.
-- **Playwright E2E Tests**: ALWAYS run with `DEBUG=1` environment variable (e.g., `DEBUG=1 npx playwright test`). This prevents the Playwright test runner from launching its own dev server. The user maintains a running dev server for development.
-- **NEVER run the full e2e test suite** unless explicitly requested by the user. The full suite is very resource intensive and takes over 10 minutes to run. Only run specific e2e test files when verification is needed (e.g., `DEBUG=1 npx playwright test test/e2e/specific-file.spec.ts`).
+- **Playwright E2E Tests**: ALWAYS run with `CI=1` and `FORCE_COLOR=1` environment variables (e.g., `CI=1 FORCE_COLOR=1 npx playwright test`). This prevents the Playwright test runner from launching its own dev server. The user maintains a running dev server for development.
+- **NEVER run the full e2e test suite** unless explicitly requested by the user. The full suite is very resource intensive and takes over 10 minutes to run. Only run specific e2e test files when verification is needed (e.g., `CI=1 FORCE_COLOR=1 npx playwright test test/e2e/specific-file.spec.ts`).
- **NEVER start a dev server yourself**. The user runs their own dev server for development. When you need a dev server running, notify the user instead of starting one.
+### Astro View Transitions Testing
+
+- **Navigation method matters**: Choose between `page.goto()` and Astro's client-side navigation based on what you're testing:
+ - Use `page.goto(url)` for testing **fresh page loads** (full browser navigation, no View Transitions)
+ - Use `BasePage.click()` on navigation links or `BasePage.navigateToPage()` for testing **View Transitions** (client-side navigation within the site) so that all clicks flow through the centralized helpers
+- **Wait for page load properly**: Use BasePage's `waitForPageLoad()` method to wait for `astro:page-load` event instead of arbitrary timeouts
+- **NEVER use `page.waitForTimeout()`** for waiting on View Transitions - it's unreliable and slows tests. Use event-based waits instead
+- **transition:persist directive**: Must be applied directly to HTML elements (including custom elements), not on Astro component wrappers. Example:
+ ```astro
+
+
+
+
+
+ ```
+
# Personality
- Do not apologize
- Do not flatter me
diff --git a/.github/instructions/testing.instructions.md b/.github/instructions/testing.instructions.md
new file mode 100644
index 000000000..fe0bc7dfb
--- /dev/null
+++ b/.github/instructions/testing.instructions.md
@@ -0,0 +1,28 @@
+---
+applyTo: "**/*.spec.ts"
+---
+
+# Testing Standards
+
+## Astro Container API (Unit Tests)
+
+- **NEVER use manual HTML strings** - use Astro's Container API with actual .astro templates
+- **Test fixtures MUST import actual components**, not duplicate HTML
+- Naming: `filename.spec.ts` for tests, `componentName.fixture.astro` for fixtures
+- Working example: `src/components/Test/__tests__/webComponent.spec.ts`
+
+## E2E Testing
+
+- **NEVER hard-code content slugs** - fetch dynamically from listing pages
+- **Always run with `CI=1` and `FORCE_COLOR=1`** - e.g., `CI=1 FORCE_COLOR=1 npx playwright test test/e2e/file.spec.ts`
+- **NEVER run full e2e suite** unless requested - it takes 10+ minutes
+- **NEVER start dev server** - user maintains running server
+- **Use `BasePage.waitForPageLoad()`** to wait for `astro:page-load` event
+- **NEVER use `waitForTimeout()`** - use event-based waits
+- **transition:persist**: Apply to HTML elements in component definition, not on component usage
+
+## View Transitions Testing
+
+- `page.goto(url)` = Full page reload (no View Transitions)
+- `page.navigateToPage('/path')` = Astro View Transitions (client-side navigation)
+- Always use `navigateToPage()` for consistency - never ad-hoc `click('a[href]')`
diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml
index 170a0af0b..91fe76892 100644
--- a/.github/workflows/build-and-test.yml
+++ b/.github/workflows/build-and-test.yml
@@ -1,4 +1,5 @@
# Runs build, unit tests, and E2E tests - gates deployment
+# @TODO: Push to a preview branch on Vercel, and run the E2E tests on that preview instead of from a dev server
name: CI - Build & Test
on:
@@ -11,14 +12,30 @@ jobs:
name: Build and Test
runs-on: ubuntu-latest
+ permissions:
+ # Required to checkout the code
+ contents: read
+ # Required to put a comment into the pull-request
+ pull-requests: write
+
# Define environment variables once at the job level
# These will be available to ALL steps in this job
+ # @TODO: Same issue with type-check.yml. These are real keys set on GitHub. But the production build only occurs on Vercel, and it's the only place that needs real keys. Once we set up a test framework using Docker containers for Suprabase, Upstash, etc., we should replace these keys with test keys.
env:
CONVERTKIT_API_KEY: ${{ secrets.CONVERTKIT_API_KEY }}
CONVERTKIT_FORM_ID: ${{ secrets.CONVERTKIT_FORM_ID }}
+ CRON_SECRET: ${{ secrets.CRON_SECRET }}
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- PUBLIC_SENTRY_DSN: ${{ secrets.PUBLIC_SENTRY_DSN }}
+ SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
+ SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
+ SUPABASE_KEY: ${{ secrets.SUPABASE_KEY }}
+ SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
+ KV_URL: ${{ secrets.KV_URL }}
+ KV_REST_API_URL: ${{ secrets.KV_REST_API_URL }}
+ KV_REST_API_TOKEN: ${{ secrets.KV_REST_API_TOKEN }}
+ KV_REST_API_READ_ONLY_TOKEN: ${{ secrets.KV_REST_API_READ_ONLY_TOKEN }}
+ REDIS_URL: ${{ secrets.REDIS_URL }}
WEBMENTION_IO_TOKEN: ${{ secrets.WEBMENTION_IO_TOKEN }}
steps:
@@ -40,8 +57,19 @@ jobs:
- name: Run lint
run: npm run lint
- - name: Run unit tests
- run: npm test -- --run
+ - name: Run unit tests (Vitest β coverage with GitHub Actions reporter)
+ run: |
+ # Run vitest with coverage. The built-in 'github-actions' reporter
+ # (configured in vitest.config.ts) will create annotations.
+ # Coverage reporters (json-summary, json) are configured in vitest.config.ts.
+ npx vitest run --coverage
+
+ - name: Report Coverage
+ uses: davelosert/vitest-coverage-report-action@v2.8.3
+ if: always()
+ with:
+ json-summary-path: './coverage/coverage-summary.json'
+ json-final-path: './coverage/coverage-final.json'
- name: Build project
run: npm run build
@@ -51,8 +79,26 @@ jobs:
- name: Install Playwright browsers
run: npx playwright install --with-deps
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Build Upstash mock image
+ run: docker buildx build --load -t wb/upstash-redis-local:test test/containers/upstash/local-proxy
+
+ - name: Start mock containers
+ run: npm run containers:up
+
+ - name: Wait for mock services
+ run: npm run containers:wait
+
+ - name: Start Supabase stack
+ run: npm run containers:supabase:start
+
- name: Run Playwright E2E tests
run: npx playwright test
+ env:
+ CI: "1"
+ FORCE_COLOR: "1"
- name: Upload Playwright report
uses: actions/upload-artifact@v4
@@ -63,35 +109,81 @@ jobs:
retention-days: 30
- name: Upload test coverage
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v5.0.0
if: always()
with:
name: test-coverage
path: coverage/
retention-days: 30
- # This job will only run after build-and-test succeeds
+ - name: Stop Supabase stack
+ if: always()
+ run: npm run containers:supabase:stop || true
+
+ - name: Stop mock containers
+ if: always()
+ run: npm run containers:down || true
+
+ # Deploy preview for pull requests
+ deploy-preview:
+ name: Deploy Preview to Vercel
+ runs-on: ubuntu-latest
+ needs: build-and-test
+ if: github.event_name == 'pull_request'
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Deploy to Vercel (Preview)
+ uses: amondnet/vercel-action@v41.1.4
+ id: vercel-preview
+ with:
+ vercel-token: ${{ secrets.VERCEL_TOKEN }}
+ vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
+ vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
+ github-comment: true
+ env:
+ VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
+
+ - name: Comment preview URL on PR
+ uses: actions/github-script@v8
+ if: github.event_name == 'pull_request'
+ with:
+ script: |
+ const previewUrl = '${{ steps.vercel-preview.outputs.preview-url }}';
+ github.rest.issues.createComment({
+ issue_number: context.issue.number,
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ body: `β Tests passed! Preview deployment ready:\n\nπ ${previewUrl}`
+ });
+
+ # This job will only run after build-and-test succeeds on main branch
deployment-ready:
- name: Deployment Gate
+ name: Production Deployment Gate
runs-on: ubuntu-latest
needs: build-and-test
- if: github.ref == 'refs/heads/main'
+ if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- name: All tests passed
- run: echo "β All tests passed. Deployment can proceed."
- deploy-to-vercel:
- name: Deploy to Vercel
+ run: echo "β All tests passed. Production deployment can proceed."
+
+ # Deploy to production when PR is merged to main
+ deploy-production:
+ name: Deploy to Production (Vercel)
runs-on: ubuntu-latest
needs: deployment-ready
- if: github.ref == 'refs/heads/main' && success()
+ if: github.ref == 'refs/heads/main' && github.event_name == 'push' && success()
steps:
- name: Checkout repository
uses: actions/checkout@v4
- - name: Deploy to Vercel
- uses: amondnet/vercel-action@v25
+ - name: Deploy to Vercel (Production)
+ uses: amondnet/vercel-action@v41.1.4
+ id: vercel-production
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-args: '--prod'
@@ -99,3 +191,8 @@ jobs:
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
+
+ - name: Log production deployment
+ run: |
+ echo "π Production deployment completed"
+ echo "Production URL: ${{ steps.vercel-production.outputs.preview-url }}"
diff --git a/.github/workflows/type-check.yml b/.github/workflows/type-check.yml
index 95d90d35b..f40ad7ab8 100644
--- a/.github/workflows/type-check.yml
+++ b/.github/workflows/type-check.yml
@@ -13,12 +13,22 @@ jobs:
# Define environment variables once at the job level
# These will be available to ALL steps in this job
+ # @TODO: Same issue with build-and-test.yml. These are real keys set on GitHub. But the production build only occurs on Vercel, and it's the only place that needs real keys. Once we set up a test framework using Docker containers for Suprabase, Upstash, etc., we should replace these keys with test keys.
env:
CONVERTKIT_API_KEY: ${{ secrets.CONVERTKIT_API_KEY }}
CONVERTKIT_FORM_ID: ${{ secrets.CONVERTKIT_FORM_ID }}
+ CRON_SECRET: ${{ secrets.CRON_SECRET }}
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- PUBLIC_SENTRY_DSN: ${{ secrets.PUBLIC_SENTRY_DSN }}
+ SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
+ SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
+ SUPABASE_KEY: ${{ secrets.SUPABASE_KEY }}
+ SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
+ KV_URL: ${{ secrets.KV_URL }}
+ KV_REST_API_URL: ${{ secrets.KV_REST_API_URL }}
+ KV_REST_API_TOKEN: ${{ secrets.KV_REST_API_TOKEN }}
+ KV_REST_API_READ_ONLY_TOKEN: ${{ secrets.KV_REST_API_READ_ONLY_TOKEN }}
+ REDIS_URL: ${{ secrets.REDIS_URL }}
WEBMENTION_IO_TOKEN: ${{ secrets.WEBMENTION_IO_TOKEN }}
steps:
diff --git a/.gitignore b/.gitignore
index f96c71ca3..3df8cf7e8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,10 +1,10 @@
# build output
dist/
+dev-dist/
# generated types
.astro/
.vercel/
-src/components/GeneratedSprites.astro
# test output
/test-results/
@@ -23,8 +23,6 @@ npm-debug.log*
# dotenv environment variable files
.env*
-# Commit default dotenv environment variable file
-!.env.local
# Optional npm cache directory
.npm
@@ -63,3 +61,4 @@ build/Release
# Dolphin-generated file
.directory
+.vercel
diff --git a/.husky/pre-commit b/.husky/pre-commit
index 9a1d77805..09d4fa44b 100755
--- a/.husky/pre-commit
+++ b/.husky/pre-commit
@@ -15,16 +15,8 @@ if [ -f "$HOME/.nvm/nvm.sh" ]; then
fi
fi
-# Run TypeScript check (fast feedback on type errors)
-npm run check
-
-# Run linters (fail fast on each)
-npm run lint:style
-npm run lint:code
-npm run lint:json
-
-# Run unit tests (fast feedback)
-npm run test:unit
+# Run lint suite (includes TypeScript check + ESLint + Stylelint + JSON formatting)
+npm run lint
# Verify branch naming convention
.husky/scripts/check-branch-name.sh
diff --git a/.husky/pre-push b/.husky/pre-push
new file mode 100755
index 000000000..2e495e9f8
--- /dev/null
+++ b/.husky/pre-push
@@ -0,0 +1,19 @@
+#!/bin/sh
+
+# Load nvm if available and use appropriate node version
+if [ -f "$HOME/.nvm/nvm.sh" ]; then
+ . "$HOME/.nvm/nvm.sh"
+ if nvm use 2>/dev/null; then
+ echo "Using current nvm node version"
+ elif nvm use node 2>/dev/null; then
+ echo "Using latest nvm node version"
+ elif nvm use --lts 2>/dev/null; then
+ echo "Using LTS node version"
+ else
+ echo "Using system node version"
+ fi
+fi
+
+# Pushes must pass lint and unit test suites
+npm run lint
+npm run test:unit
diff --git a/.vscode/dev-server-cli.mjs b/.vscode/dev-server-cli.mjs
index 405d8ff3f..26e7c25f8 100755
--- a/.vscode/dev-server-cli.mjs
+++ b/.vscode/dev-server-cli.mjs
@@ -1,101 +1,112 @@
#!/usr/bin/env node
-import { accessSync, readFileSync, unlinkSync, writeFileSync } from "fs";
-import spawn from "cross-spawn";
-import tempDir from "temp-dir"; // /tmp
-import yargs from "yargs/yargs";
-import { hideBin } from "yargs/helpers";
+/* eslint-env node */
+/* global console, process */
+import { accessSync, readFileSync, unlinkSync, writeFileSync } from 'fs'
+import spawn from 'cross-spawn'
+import tempDir from 'temp-dir' // /tmp
+import yargs from 'yargs/yargs'
+import { hideBin } from 'yargs/helpers'
-const pidFile = `${tempDir}/debug-server-hmDAr917.pid`;
-const port = 3000;
+const pidFile = `${tempDir}/debug-server-hmDAr917.pid`
+const port = 3000
+/**
+ * Ensure we do not start a duplicate dev server instance
+ */
const failOnPidFileExists = () => {
try {
- accessSync(pidFile);
- } catch (err) {
- return false;
+ accessSync(pidFile)
+ } catch {
+ return false
}
console.error(
`[error] Server appears to be running, found PID file ${pidFile}`,
- );
- return process.exit(116);
-};
+ )
+ return process.exit(116)
+}
-const writePidFile = (pid) => {
+/**
+ * Persist the child process PID to allow stop command to find it later
+ */
+const writePidFile = pid => {
try {
- writeFileSync(pidFile, pid.toString(10), { flag: "ax" });
+ writeFileSync(pidFile, pid.toString(10), { flag: 'ax' })
} catch (err) {
- console.error(`[error] Failed to write PID ${pid} PID file ${pidFile}: ${err.message}`);
- return process.exit(11);
+ console.error(`[error] Failed to write PID ${pid} PID file ${pidFile}: ${err.message}`)
+ return process.exit(11)
}
}
+/**
+ * Remove the PID file when the dev server stops
+ */
const unlinkPidFile = () => {
try {
- unlinkSync(pidFile);
+ unlinkSync(pidFile)
} catch (err) {
console.error(
`[error] Failed to delete PID file ${pidFile}: ${err.message}`,
- );
- return process.exit(11);
+ )
+ return process.exit(11)
}
-};
+}
yargs(hideBin(process.argv))
.command(
- "start",
- "Start the server",
+ 'start',
+ 'Start the server',
{},
() => {
- failOnPidFileExists();
+ failOnPidFileExists()
- const child = spawn(`${process.cwd()}/node_modules/.bin/vercel`, ["dev", "--listen", port], {
+ const child = spawn(`${process.cwd()}/node_modules/.bin/vercel`, ['dev', '--listen', port], {
detached: true,
- stdio: "pipe",
- });
+ stdio: 'pipe',
+ })
- let isPidWritten = false;
+ let isPidWritten = false
- child.on("error", (err) => {
- console.log(err);
- });
+ child.on('error', error => {
+ console.error(error)
+ })
/** Forward child stdout to VS Code Task */
- child.stdout.setEncoding("utf8");
- child.stdout.on("data", (data) => {
+ child.stdout.setEncoding('utf8')
+ child.stdout.on('data', data => {
if (!isPidWritten) {
- writePidFile(child.pid);
- isPidWritten = true;
+ writePidFile(child.pid)
+ isPidWritten = true
}
- console.log(data);
- });
+ console.log(data)
+ })
- child.stderr.setEncoding("utf8");
- child.stderr.on("data", (error) => {
- console.error(error);
- });
+ child.stderr.setEncoding('utf8')
+ child.stderr.on('data', error => {
+ console.error(error)
+ })
- child.on("close", (code) => {
- child.unref();
- return process.exit(code);
- });
+ child.on('close', code => {
+ child.unref()
+ return process.exit(code)
+ })
},
)
.command(
- "stop",
- "Stop the server",
+ 'stop',
+ 'Stop the server',
{},
() => {
try {
- const pid = readFileSync(pidFile, { encoding: "utf8", flag: "r" });
- process.kill(parseInt(pid, 10), "SIGINT");
- unlinkPidFile();
- return process.exit(0);
+ const pid = readFileSync(pidFile, { encoding: 'utf8', flag: 'r' })
+ process.kill(parseInt(pid, 10), 'SIGINT')
+ unlinkPidFile()
+ return process.exit(0)
} catch (err) {
console.error(
`[error] Failed to shut down server, reading PID file ${pidFile}: ${err.message}`,
- );
- return process.exit(11);
+ )
+ return process.exit(11)
}
},
)
- .parse();
+ .parse()
diff --git a/.vscode/instructions/web-components-unit-tests.md b/.vscode/instructions/web-components-unit-tests.md
new file mode 100644
index 000000000..54b2a7099
--- /dev/null
+++ b/.vscode/instructions/web-components-unit-tests.md
@@ -0,0 +1,3 @@
+# Web Component Unit Tests
+
+When adding or updating unit tests for web components, review `src/components/Test/README.md` for the canonical patterns (Astro container vs Lit runtime harness) before making changes.
diff --git a/.vscode/settings.json b/.vscode/settings.json
index b20da8a26..389b95cad 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -8,6 +8,7 @@
"cSpell.words": [
"Abbrs",
"Acronis",
+ "amondnet",
"Asana",
"bento",
"bezier",
@@ -21,6 +22,7 @@
"Consolas",
"dango",
"dbtable",
+ "dsar",
"Dynatrace",
"ecommerce",
"Favicons",
@@ -31,6 +33,7 @@
"Flink",
"FNAME",
"fosstodon",
+ "FOUC",
"glidejs",
"GSAP",
"hocho",
@@ -40,9 +43,10 @@
"jdbc",
"Joomla",
"jscoverage",
- "jsdom",
+ "Karamov",
"KHTML",
"koko",
+ "labelledby",
"LIMS",
"Linkify",
"liradb",
@@ -56,6 +60,7 @@
"MTTR",
"nanostores",
"navigations",
+ "Neue",
"Niklas",
"noabbr",
"octocat",
@@ -63,15 +68,19 @@
"oembed",
"oklch",
"Onest",
+ "optin",
"pids",
+ "pleroma",
"Poslovski",
"Qualys",
+ "registrator",
"repost",
"reposts",
"RGAA",
"Ryuk",
"samp",
"SCORM",
+ "Segoe",
"shiki",
"shikijs",
"shipit",
@@ -82,6 +91,7 @@
"spriter",
"squoosh",
"tanabata",
+ "TIMESTAMPTZ",
"Trino",
"TRUNC",
"Tscompile",
@@ -90,11 +100,16 @@
"tshirt",
"tydata",
"unspaced",
+ "unstub",
"uppy",
+ "uuidv",
"Veeam",
"vercel",
"virt",
"vmax",
+ "vtbot",
+ "WHATWG",
+ "wordprocessingml",
"wscript",
"youtu",
"yoyo",
diff --git a/@types/astro-vtbot.d.ts b/@types/astro-vtbot.d.ts
new file mode 100644
index 000000000..16be5fb4b
--- /dev/null
+++ b/@types/astro-vtbot.d.ts
@@ -0,0 +1,10 @@
+declare module 'astro-vtbot' {
+ import type { AstroIntegration } from 'astro'
+
+ interface VtbotOptions {
+ [key: string]: unknown
+ }
+
+ const vtbot: (_options?: VtbotOptions) => AstroIntegration
+ export default vtbot
+}
diff --git a/@types/astro.d.ts b/@types/astro.d.ts
new file mode 100644
index 000000000..00861294d
--- /dev/null
+++ b/@types/astro.d.ts
@@ -0,0 +1,5 @@
+declare module '*.astro' {
+ import type { AstroComponentFactory } from 'astro'
+ const component: AstroComponentFactory
+ export default component
+}
diff --git a/@types/cssmodule.d.ts b/@types/cssmodule.d.ts
new file mode 100644
index 000000000..8811db126
--- /dev/null
+++ b/@types/cssmodule.d.ts
@@ -0,0 +1,4 @@
+declare module '*.module.css' {
+ const classes: Record
+ export default classes
+}
diff --git a/@types/jsx.d.ts b/@types/jsx.d.ts
index 6ab599df9..90550eb93 100644
--- a/@types/jsx.d.ts
+++ b/@types/jsx.d.ts
@@ -1,7 +1,8 @@
///
-
-// Make JSX namespace available globally for MDX compatibility
-// This fixes the "Cannot find namespace 'JSX'" error from @types/mdx
+/**
+ * Make JSX namespace available globally for MDX compatibility
+ * This fixes the "Cannot find namespace 'JSX'" error from `@types/mdx`
+ */
import * as React from 'react'
declare global {
diff --git a/@types/semantic-ui__astro-lit/index.d.ts b/@types/semantic-ui__astro-lit/index.d.ts
new file mode 100644
index 000000000..4638b610c
--- /dev/null
+++ b/@types/semantic-ui__astro-lit/index.d.ts
@@ -0,0 +1,6 @@
+import type { AstroIntegration, AstroRenderer } from 'astro'
+
+declare module '@semantic-ui/astro-lit' {
+ export function getContainerRenderer(): AstroRenderer
+ export default function litIntegration(): AstroIntegration
+}
diff --git a/@types/unstorage/index.d.ts b/@types/unstorage/index.d.ts
new file mode 100644
index 000000000..d2cf85d0e
--- /dev/null
+++ b/@types/unstorage/index.d.ts
@@ -0,0 +1,34 @@
+declare module 'unstorage' {
+ const createStorage: unknown
+ export default createStorage
+}
+
+declare module 'unstorage/*' {
+ const driver: unknown
+ export default driver
+}
+
+declare module 'unstorage/*.mjs' {
+ const driver: unknown
+ export default driver
+}
+
+declare module 'unstorage/drivers' {
+ const driver: unknown
+ export default driver
+}
+
+declare module 'unstorage/drivers/*' {
+ const driver: unknown
+ export default driver
+}
+
+declare module 'unstorage/drivers/*.mjs' {
+ const driver: unknown
+ export default driver
+}
+
+declare module 'unstorage/drivers/utils/*' {
+ const driver: unknown
+ export default driver
+}
diff --git a/@types/virtual-pwa-register.d.ts b/@types/virtual-pwa-register.d.ts
new file mode 100644
index 000000000..720d338f5
--- /dev/null
+++ b/@types/virtual-pwa-register.d.ts
@@ -0,0 +1,11 @@
+declare module 'virtual:pwa-register' {
+ export interface RegisterSWOptions {
+ immediate?: boolean
+ onNeedRefresh?: () => void
+ onOfflineReady?: () => void
+ onRegistered?: (_registration: ServiceWorkerRegistration | undefined) => void
+ onRegisterError?: (_error: Error) => void
+ }
+
+ export function registerSW(_options?: RegisterSWOptions): (_reloadPage?: boolean) => Promise
+}
diff --git a/@types/window.d.ts b/@types/window.d.ts
new file mode 100644
index 000000000..dd4b7fac4
--- /dev/null
+++ b/@types/window.d.ts
@@ -0,0 +1,101 @@
+/**
+ * Global Window interface extensions
+ * Centralizes all custom properties added to the window object
+ */
+import type { registerSW } from 'virtual:pwa-register'
+import type { EvaluationError } from '@test/errors/EvaluationError'
+
+/**
+ * Meta colors object for theme color management
+ * Maps theme IDs to their respective background offset colors
+ * Used by ThemePicker and theme initialization scripts
+ */
+interface MetaColors {
+ [key: string]: string
+}
+
+interface EnvironmentClientSnapshot {
+ isUnitTest: boolean
+ isTest: boolean
+ isE2eTest: boolean
+ isDev: boolean
+ isProd: boolean
+ packageRelease: string
+ privacyPolicyVersion: string
+}
+
+interface EnvironmentApiSnapshot {
+ isUnitTest: boolean
+ isTest: boolean
+ isE2eTest: boolean
+ isDev: boolean
+ isProd: boolean
+ packageRelease: string
+ privacyPolicyVersion: string
+}
+
+interface SiteUrlSnapshot {
+ siteUrl: string
+}
+
+declare global {
+ interface Window {
+ /**
+ * Meta theme colors for browser UI theming
+ * Initialized by HeadThemeSetup in
+ * Used to update element
+ */
+ metaColors?: MetaColors
+
+ /**
+ * Test flag to indicate Playwright control
+ */
+ isPlaywrightControlled?: boolean
+
+ /**
+ * Snapshot of environment-client results for Playwright assertions
+ */
+ environmentClientSnapshot?: EnvironmentClientSnapshot
+
+ /**
+ * Snapshot of environment-api (server helper) results for Playwright assertions
+ */
+ environmentApiSnapshot?: EnvironmentApiSnapshot
+
+ /**
+ * Snapshot of client-side site URL helper
+ */
+ siteUrlClientSnapshot?: SiteUrlSnapshot
+
+ /**
+ * Snapshot of server-side site URL helper
+ */
+ siteUrlApiSnapshot?: SiteUrlSnapshot
+
+ /**
+ * Exposed store helpers that tests use to seed state when Playwright controls the browser.
+ * These are only defined when window.isPlaywrightControlled === true.
+ */
+ updateConsent?: (_category: 'analytics' | 'marketing' | 'functional', _value: boolean) => void
+ cacheEmbed?: (_key: string, _data: unknown, _ttl: number) => void
+ saveMastodonInstance?: (_domain: string) => void
+
+ /**
+ * Custom evaluation error injected during Playwright tests
+ */
+ EvaluationError?: typeof EvaluationError
+
+ /**
+ * Tracks astro:page-load events for view transition testing
+ */
+ __astroPageLoadCounter?: number
+ __astroPageLoadListenerAttached?: boolean
+
+ /**
+ * Indicates whether the window is running inside a web worker context
+ */
+ __pwaUpdateSW?: ReturnType | null
+ }
+}
+
+export {}
diff --git a/E2E_TO_IMPLEMENT.md b/E2E_TO_IMPLEMENT.md
deleted file mode 100644
index e8bf67e55..000000000
--- a/E2E_TO_IMPLEMENT.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# E2E Tests to Implement
-
-- That og: events from components/Head/Social.astro are filled out and have valid values
-- Email tests for GDPR newsletter (test sendConfirmationEmail & sendWelcomeEmail)
-- E2E Playwright tests for GDPR newsletter full double opt-in flow
diff --git a/README.md b/README.md
index 400861a8a..15ce98db8 100644
--- a/README.md
+++ b/README.md
@@ -369,27 +369,23 @@ losst.pro has a modal that pops up for fixing mistakes:
- astro-navigation
- astro-webfinger (Mastodon)
-## Sprites
+## Icons
Icons are managed through the `astro-icon` system with SVG files stored in `src/icons/`.
**Adding a new icon:**
-1. Add the SVG file to `src/icons/` (use kebab-case naming)
-2. Update `src/components/Sprite/sprites.ts` to add the icon name to the `SpriteName` union type
-3. Use the icon with the `Sprite` component
+See [docs](src/icons/README.md).
**Usage:**
```typescript
---
- import Sprite from 'components/Sprite.astro'
+ import Icon from 'components/Icon.astro'
---
-
+
```
-See `src/icons/README.md` for detailed icon documentation.
-
## Pages
Any `.astro`, `.md`, or `.mdx` file anywhere within the `src/pages/` folder automatically became a page on your site.
diff --git a/THEME_SYSTEM_WIP.md b/THEME_SYSTEM_WIP.md
deleted file mode 100644
index 855df2f14..000000000
--- a/THEME_SYSTEM_WIP.md
+++ /dev/null
@@ -1,127 +0,0 @@
-# Theme System - Work in Progress
-
-## Current Status
-
-### What's Working
-- β Theme picker tests: 9/10 @ready (63 tests passing across 7 browsers)
-- β Manual theme selection and persistence works correctly
-- β Theme switching via theme picker UI works
-- β View Transitions correctly maintain theme across page navigations
-- β CSS architecture refactored: `@theme inline` uses `var()` references instead of hard-coded values
-
-### What's Broken
-- β **System preference (prefers-color-scheme) not respected on first visit in real browser**
- - Test passes but real usage fails
- - Opening site in new incognito window with dark mode preference shows light theme
- - Google.com correctly shows dark, but our site doesn't
-
-- β **Theme picker buttons should show their own theme's colors**
- - Dark theme button should use `--dark-color-*` variables
- - Default theme button should use `--light-color-*` variables
- - Currently all buttons use light theme colors (partially fixed but needs completion)
-
-## Root Cause Analysis
-
-### System Preference Issue
-
-The problem is a **race condition** between:
-1. HEAD inline script (synchronous) - correctly sets `data-theme="dark"` based on `prefers-color-scheme`
-2. `persistentAtom` restore (asynchronous) - overwrites theme back to 'default'
-
-**Sequence of events:**
-```
-1. HEAD script runs β checks localStorage (empty) β checks prefersDark=true β sets data-theme="dark" β
-2. persistentAtom initializes with default value 'default'
-3. Our init code runs with setTimeout(100ms)
-4. persistentAtom's restore() completes (async) β fires .listen() β overwrites to 'default' β
-```
-
-**Current fix attempt:**
-- Using `isInitialized` flag to prevent `.listen()` from applying themes until init completes
-- Using `setTimeout(100ms)` to delay init until after `persistentAtom.restore()` completes
-- **Problem:** The timing is unreliable - 100ms might not be enough on slower devices
-
-**Test vs Reality:**
-- Playwright test passes because it's using `emulateMedia({ colorScheme: 'dark' })`
-- Real browser behavior is different - the race condition manifests differently
-- Test needs to be improved to catch this real-world bug
-
-## Files Modified
-
-### Theme Initialization
-- `src/components/Scripts/state/store/themes.ts` (lines 127-180)
- - Changed from `.subscribe()` to `.listen()` to avoid immediate firing
- - Added `isInitialized` flag to gate theme applications
- - Added `setTimeout(100)` to wait for `persistentAtom.restore()`
- - **HAS DEBUG LOGGING** - needs to be removed before commit
-
-### Theme Picker UI
-- `src/components/ThemePicker/Themes.astro` (lines 97-120)
- - **NOT YET FIXED** - still needs to map theme.id to color prefix
- - Should use `--${colorPrefix}-color-*` variables per button
-
-### HEAD Script
-- `src/components/Head/index.astro` (lines 56-63)
- - Correctly checks `prefers-color-scheme` and sets `data-theme`
- - Logic: stored theme (if not 'default') > system preference > 'default'
-
-### CSS Architecture
-- `src/styles/themes.css`
- - β Lines 65-105: `@theme inline` refactored to use `var()` references
- - β All theme-specific colors defined at `:root` level
- - Has `--light-color-*`, `--dark-color-*`, and base `--color-*` variables
-
-## Next Steps
-
-### High Priority
-1. **Fix system preference detection**
- - Option A: Find more reliable way to detect when `persistentAtom.restore()` completes
- - Option B: Use `MutationObserver` to watch for theme changes from restore
- - Option C: Initialize theme BEFORE importing `persistentAtom`
- - Option D: Use regular `atom` for store, manually sync to localStorage after restore completes
- - Option E: Don't rely on setTimeout - use `requestIdleCallback` or similar
-
-2. **Fix theme picker button colors**
- - Complete the Themes.astro fix to show each theme's own colors
- - Map `theme.id` to color variable prefix: 'default' β 'light', 'dark' β 'dark'
- - Update color swatches to use theme-specific variables
-
-3. **Remove debug logging**
- - `src/components/Scripts/state/store/themes.ts` has console.log statements
- - Clean these up before final commit
-
-### Test Improvements
-4. **Make test match real browser behavior**
- - Current test uses `emulateMedia()` which might not trigger same race condition
- - Consider testing with actual localStorage clearing and page reload
- - Add test that validates theme immediately on page load (before JS runs)
-
-## Technical Constraints
-
-- **MUST use `persistentAtom`** - required for View Transitions to maintain theme across navigations
-- **CANNOT use regular `atom`** - will lose persistence across page navigations
-- HEAD script must run synchronously to prevent FOUC (Flash of Unstyled Content)
-- Theme must be applied before page renders (critical for UX)
-
-## Code Locations
-
-- Theme store: `src/components/Scripts/state/store/themes.ts`
-- Theme picker UI: `src/components/ThemePicker/Themes.astro`
-- Theme picker element: `src/components/ThemePicker/theme-picker-element.ts`
-- HEAD script: `src/components/Head/index.astro` (lines 56-63)
-- CSS themes: `src/styles/themes.css`
-- E2E tests: `test/e2e/specs/04-components/theme-picker.spec.ts` (line 134 is failing test)
-
-## Questions to Answer
-
-1. When exactly does `persistentAtom.restore()` complete?
-2. Is there an event or promise we can wait for?
-3. Should we implement our own localStorage persistence instead of using `persistentAtom`?
-4. Can we leverage the `@media (prefers-color-scheme: dark)` CSS to avoid needing JS for system preference?
-
-## Useful Context
-
-- The `@media (prefers-color-scheme: dark)` CSS rule at lines 337-373 in themes.css correctly applies dark theme
-- This CSS works WITHOUT JavaScript
-- The issue is the JS is overriding this CSS by setting `data-theme="default"`
-- Maybe we should NOT set `data-theme` at all when using system preference?
diff --git a/TODO.md b/_TODO.md
similarity index 84%
rename from TODO.md
rename to _TODO.md
index c98da0dea..49117b8c0 100644
--- a/TODO.md
+++ b/_TODO.md
@@ -1,170 +1,89 @@
# TODO
-Files with Skipped Tests:
+## Performance
-social-shares.spec.ts - 12 @wip
-gdpr-consent.spec.ts - 10 @wip
+Implement mitigations in test/e2e/specs/07-performance/PERFORMANCE.md
-Blocked Categories (44 tests):
-
-Visual regression testing (18) - Needs Percy/Chromatic
-PWA functionality (12) - Service workers not implemented
-Lighthouse audits (6) - Integration pending
-Newsletter double opt-in (6) - Email testing infrastructure
-Axe accessibility (2) - axe-core integration
-
-\[color:var\(--color-(.*?)\)\]
-
-## Color vars
-
-brand primary: #001733
-brand secondary: #0062B6
-
-src/components: 427
-src/lib: 13
-src/pages: 250
-
-Applies a solid border of currentColor to an element:
-
-ring (1px), ring-2, ring-4
-accent
-
-### text-white
-
-- Button - success button text
-- Button - success button hover text
-- Button - success button focus text
-
-### --color-bg: 57 #f3f4f6
-
-- [tag] - main content background
-
-### --color-bg-offset: 48 #e5e7eb
-
-- Avatar - wrapper for fallback that just shows initials
-- Button - icon button hover background
-- Button - icon button focus background
-- Button - icon button active background
-
-### --color-bg-inverse: 2 #001a39
-
-### --color-text: 141 #374151
+## Analytics
-- Button - primary button text
-- Button - primary button focus text
-- Button - secondary button text
-- Button - secondary button hover text
-- Button - secondary button focus text
-- Button - icon button text
-- Button - twitter button text
-- [tag] - H1
+Vercel Analytics
-### --color-text-offset: 103 #9ca3af
+## Themepicker tooltips, extra themes
-- Avatar - test in wrapper for fallback that just shows initials
-- [tag] - header text
+- Add additional themes
+- @TODO: add tooltip that makes use of the description field
-### --color-border: 68 #f0f4ff
+## Sentry feedback, chat bot tying into my phone and email
-### --color-primary: 180 #006dca
+See note in src/components/scripts/sentry/client.ts - "User Feedback - allow users to report issues"
-- Button - primary button background
-- [tag] - header anchor text
+## src/pages/api next steps
-### --color-primary-offset: 14 #00386d
+E2E Starting Point
-- Button - primary button focus background
-- Hamburger Menu Icon - SVG background fill
+Stabilize infra first: run npm run containers:up, npm run containers:wait, npm run containers:supabase:start, and the dev server. Keep containers:logs and containers:supabase:logs tailing in another terminal so every mock failure is visible before Playwright runs.
-### --color-primary-bg: 7 #dbeafe
+Create a shared Playwright "mocks ready" fixture: add a helper that checks process.env.E2E_MOCKS === '1' and pings `http://127.0.0.1:8079/` plus the two WireMock endpoints before each suite. That gives quick feedback if someone forgets the setup commands.
+Implementation order
-### --color-primary-bg-hover: 1 #bfdbfe
+08-api: start with these since their success hinges entirely on the mocks. For each test, assert the HTTP response and inspect the mock's request logs (WireMock /__admin/requests) to prove the backend call happened. Adding the cron tests here makes senseβjust exercise the GET endpoints via page.request or Playwright's API testing capability so you don't need UI plumbing.
-### --color-primary-hover: 15 #0056a3
+Cron coverage: write three tests that hit cleanup-confirmations, newsletter-reminders, etc., using the mock stack. Seed Supabase/Redis with known values before each test (scripts in containers) and assert the mocks see the expected outbound traffic.
-### --color-secondary: 5 #facc15
+03-forms: once the API layer is stable, wire the UI flows. Use Playwright to submit each form, but assert success by checking the mock mappings were triggered, not just the UI toast.
+Consent Preferences (@wip): convert it to use the same helper that verifies mocked Upstash REST and Supabase responses. This test should (1) toggle UI controls, (2) check the outbound request via the mock logs, and (3) read back seeded data to confirm persistence.
-- Button - secondary button background
+## Typing client-side API calls and SSR API endpoints
-### --color-secondary-offset: 7 #ca8a04
+Shared Types vs Swagger / Keeping Docs in Sync
-- Button - secondary button hover background
-- Button - secondary button focus background
-- Button - secondary button active background
+1. Type-only sharing (current approach)
-### --color-secondary-bg: 1 #fef3c7
+- Pros: zero extra build tooling, server/client stay aligned as long as both import @pages/api/_contracts.
+- Cons: no generated docs/SDKs; discipline is required to keep manual docs current.
+- How to enforce: treat the contract files as the single source of truth, add lint rules banning request/response literal types outside _contracts, and add lightweight contract tests that instantiate each type against the endpoint handler (failing if fields diverge).
-### --color-success: 19 #16a34a
+2. Code-first OpenAPI (Zod or TS schemas β OpenAPI)
-- Button - success button background
+- Define schemas in Zod/Valibot (or ts-rest) alongside the endpoint. Generate OpenAPI JSON plus TypeScript types from those schemas. Docs (Swagger UI/Redoc) and any client SDKs come from the generated spec, so they're always in sync.
+- Guarantees: CI regenerates the spec and fails when the checked-in artifact is stale; endpoint handlers reuse the same schema for runtime validation, so a mismatch cannot compile.
-### --color-success-offset: 7 #22c55e
+3. Spec-first OpenAPI + Swagger Codegen
-- Button - success button hover background
-- Button - success button focus background
-- Button - success button active background
+- Maintain an OpenAPI YAML/JSON file as the source of truth, run Swagger Codegen (or openapi-typescript) to produce both server stubs and client SDKs.
+- Guarantees: developers edit the spec, run codegen (enforced via a pre-commit/CI task), and the generated server stubs remind you to implement every path/verb. Documentation pages (Swagger UI) are rendered straight from the same spec, so they inherently match the implementation.
-### --color-success-bg: 6 #dcfce7
+Affected components:
-### --color-info: 1 #0891b2
+- CallToAction/Newsletter
+- ContactForm
-### --color-info-bg: 2 #cffafe
+## Files with Skipped Tests
-### --color-warning: 5 #a16207
-
-- Button - warning button background
-
-### --color-warning-offset: 3 #ca8a04
-
-- Button - warning button hover background
-- Button - warning button focus background
-- Button - warning button active background
-
-### --color-warning-bg: 1 #fef3c7
-
-### --color-danger: 16 #dc2626
-
-### --color-danger-bg: 5 #fecaca
-
-### --color-accent: 10 #7c3aed
-
-### --color-accent-bg: 1 #ede9fe
-
-### --color-link-shadow: 2 #2563eb
-
---color-share-highlight-text: 1
---color-share-highlight-bg: 1
---color-share-highlight-text-active: 1
---color-share-highlight-bg-active: 1
---color-share-highlight-tooltip-text: 1
---color-share-highlight-tooltip-bg: 1
-
---shiki-theme: 0
-
-### --shadow-sm: 4
-
-- Button - primary button shadow
+Blocked Categories (44 tests):
-### --shadow-md: 1
+Visual regression testing (18) - Needs Percy/Chromatic
+Lighthouse audits (6) - Integration pending
+Newsletter double opt-in (6) - Email testing infrastructure
+Axe accessibility (2) - axe-core integration
-### --shadow-hover: 8
+## Search
-- Button - primary button focus shadow
+Add Upstash Search as a Vercel Marketplace Integration.
-### --shadow-active: 4
+## Email Templates
-- Button - secondary button active shadow
+Right now we're using string literals to define HTML email templates for site mails. We should use Nunjucks with the rule-checking for valid CSS in HTML emails like we have in the corporate email footer repo.
-### --shadow-text: 4
+## Color vars
-- Button - primary button text-shadow
+brand primary: #001733
+brand secondary: #0062B6
-### --color-twitter: 4
+ring (1px), ring-2, ring-4
+accent
-- Button - twitter button background
-- Button - twitter button border
-- Button - twitter button hover background
-- Button - twitter button focus background
+text-white, other default Tailwind colors
## Axe tags
@@ -230,7 +149,7 @@ https://www.kirilv.com/canvas-confetti/
## @TODO: Use the Page Visibility API to pause videos, image carousels, and animations
-Stop unnecessary processes when the user doesnβt see the page or inversely to perform background actions.
+Stop unnecessary processes when the user doesn't see the page or inversely to perform background actions.
## @TODO: "Add to Calendar" button
@@ -802,15 +721,15 @@ test('Critical Paths @smoke', async ({ context, page, allPaths }) => {
await expect(page.locator('main')).toBeVisible()
})
- await test.step('@ready cookie consent banner appears', async () => {
+ await test.step('@ready consent banner appears', async () => {
// Clear consent cookies to force banner to appear
- await clearConsentCookies(context)
+ await clearConsentBanner(context)
await page.goto(path)
await page.waitForLoadState('networkidle')
// Cookie modal should be visible
- await expect(page.locator('#cookie-modal-id')).toBeVisible()
+ await expect(page.locator('#consent-modal-id')).toBeVisible()
})
}
})
diff --git a/astro.config.ts b/astro.config.ts
index 8b6deae6e..98eb0551b 100644
--- a/astro.config.ts
+++ b/astro.config.ts
@@ -1,28 +1,76 @@
+import AstroPWA from '@vite-pwa/astro'
+import icon from 'astro-icon'
+import lit from '@semantic-ui/astro-lit'
import mdx from '@astrojs/mdx'
-import preact from '@astrojs/preact'
+import sentry from '@sentry/astro'
import sitemap from '@astrojs/sitemap'
-import vercelStatic from '@astrojs/vercel'
-import sentry from "@sentry/astro"
import tailwindcss from '@tailwindcss/vite'
-import AstroPWA from '@vite-pwa/astro'
-import icon from 'astro-icon'
+import vercelStatic from '@astrojs/vercel'
+import vtbot from 'astro-vtbot'
import { defineConfig } from 'astro/config'
+import { fileURLToPath } from 'node:url'
+import type { PluginOption } from 'vite'
+/**
+ * You cannot use path aliases (`@lib`, `@components`, etc.) in files that are
+ * imported by astro.config.ts, because the path alias resolution happens
+ * AFTER the config is loaded, not before. This means that adding resolve.alias
+ * paths to the vite section in astro.config.ts would not allow using path
+ * aliases because it creates a circular dependency problem since the config
+ * file itself is importing from the paths it needs to configure.
+ */
import {
environmentalVariablesConfig,
+ getSentryAuthToken,
getSiteUrl,
+ isUnitTest,
+ isVercel,
markdownConfig,
- serviceWorkerConfig,
+ pwaConfig,
vercelConfig,
} from './src/lib/config'
-import { callToActionValidator } from './src/integrations/CtaValidator/call-to-action-validator'
-import { serializeSitemapItem, writePagesJson } from './src/lib/config/sitemap-serialize'
+import { callToActionValidator } from './src/integrations/CtaValidator'
+import { faviconGenerator } from './src/integrations/FaviconGenerator'
+import { packageRelease } from './src/integrations/PackageRelease'
+import { privacyPolicyVersion } from './src/integrations/PrivacyPolicyVersion'
+import { pwaDevAssetServer } from './src/lib/plugins/pwaDevAssetServer'
+import { createSerializeFunction, pagesJsonWriter } from './src/integrations/sitemapSerialize'
-// Type guard for required environment variables (only in Vercel)
-const IS_VERCEL = process.env['VERCEL']
-const SENTRY_AUTH_TOKEN = process.env['SENTRY_AUTH_TOKEN']
-if (IS_VERCEL && !SENTRY_AUTH_TOKEN) {
- throw new Error('SENTRY_AUTH_TOKEN environment variable is required in Vercel but not set')
-}
+const sharedTestIntegrations = [
+ icon(),
+]
+
+const standardIntegrations = [
+ AstroPWA(pwaConfig),
+ ...sharedTestIntegrations,
+ mdx(markdownConfig),
+ /** Generate favicons and PWA icons from source SVG */
+ faviconGenerator(),
+ /** Verify number of call to actions included in Markdown files */
+ callToActionValidator({
+ /** Enable debug logging to see validation details */
+ debug: true,
+ }),
+ /** Integration to render Lit templates during build */
+ lit(),
+ /** Inject package release (name@version) for tracking regressions between releases */
+ packageRelease(),
+ /** Inject privacy policy version from git commit date for GDPR record keeping */
+ privacyPolicyVersion(),
+ /** Only include Sentry integration in Vercel environments */
+ ...(isVercel() ? [sentry({
+ project: "webstack-builders-corporate-website",
+ org: "webstack-builders",
+ authToken: getSentryAuthToken(),
+ })] : []),
+ sitemap({
+ serialize: createSerializeFunction({
+ exclude: ['downloads', 'social-shares', '/articles/demo', 'testing'],
+ }),
+ }),
+ pagesJsonWriter(),
+ /** Debugging tools for Astro View Transition API */
+ vtbot(),
+]
export default defineConfig({
adapter: vercelStatic(vercelConfig),
@@ -30,46 +78,42 @@ export default defineConfig({
enabled: false,
},
env: environmentalVariablesConfig,
- integrations: [
- AstroPWA(serviceWorkerConfig),
- icon(),
- mdx(markdownConfig),
- preact(),
- callToActionValidator({
- debug: true // Enable debug logging to see validation details
- }),
- // Only include Sentry integration in Vercel environments
- ...(IS_VERCEL ? [sentry({
- project: "webstack-builders-corporate-website",
- org: "webstack-builders",
- authToken: SENTRY_AUTH_TOKEN!, // Non-null assertion safe due to check above
- })] : []),
- sitemap({
- lastmod: new Date(),
- serialize: serializeSitemapItem,
- }),
- // Custom integration to write pages.json after build
- {
- name: 'pages-json-writer',
- hooks: {
- 'astro:build:done': () => {
- writePagesJson()
- },
- },
- },
- ],
- output: 'static', // Most pages are static; API routes will be marked for SSR
+ /**
+ * Astro sets substantial Vite config internally in the framework. When you use Vitest
+ * in an Astro project, you use Astro's getViteConfig helper to get the resolved internal
+ * Vite syntax along with any Vite syntax set in this astro.config.ts file. Since integrations
+ * can change config, they're ran when the helper's called. This causes problems for
+ * unit testing integrations.
+ */
+ integrations: isUnitTest() ? sharedTestIntegrations : standardIntegrations,
+ /** API routes are marked in their files for SSR */
+ output: 'static',
prefetch: true,
- site: getSiteUrl(), // Change URL between development and production environments
+ /** Change URL between development and production environments */
+ site: getSiteUrl(),
trailingSlash: 'never',
vite: {
build: {
- sourcemap: true, // Source map generation must be turned on
+ /** Source map generation must be turned on for Sentry. */
+ sourcemap: true,
+ },
+ define: {
+ /**
+ * LightningCSS exposes a WASM build via require('../pkg'), which Vite cannot
+ * resolve when bundling for the browser. Setting this flag to false at build
+ * time lets Rollup tree-shake the problematic branch.
+ */
+ 'process.env.CSS_TRANSFORMER_WASM': 'false',
+ },
+ /* @ts-expect-error - tailwindcss plugin type compatibility */
+ plugins: [
+ tailwindcss(),
+ pwaDevAssetServer(),
+ ] as PluginOption[],
+ resolve: {
+ alias: {
+ fsevents: fileURLToPath(new URL('./src/shims/fsevents.ts', import.meta.url)),
+ },
},
- // @ts-expect-error - tailwindcss plugin type compatibility
- plugins: [tailwindcss()],
- // Note: The "astro:transitions sourcemap" warning is cosmetic and can be safely ignored
- // It occurs because the transitions plugin transforms code without generating sourcemaps
- // This doesn't affect build functionality, runtime performance, or debugging capabilities
}
})
diff --git a/axe-results-incomplete.json b/axe-results-incomplete.json
index e64598465..594636518 100644
--- a/axe-results-incomplete.json
+++ b/axe-results-incomplete.json
@@ -1068,8 +1068,8 @@
"all": [],
"none": [],
"impact": "serious",
- "html": "
",
+ "target": ["#consent-modal__content"],
"failureSummary": "Fix any of the following:\n Element's background color could not be determined because it partially overlaps other elements"
}
]
diff --git a/debug-ast.mjs b/debug-ast.mjs
deleted file mode 100644
index a9c9ff641..000000000
--- a/debug-ast.mjs
+++ /dev/null
@@ -1,11 +0,0 @@
-import { unified } from 'unified'
-import remarkParse from 'remark-parse'
-import { inspect } from 'unist-util-inspect'
-
-const markdown = '*test*{style="em:4"}'
-
-const tree = unified()
- .use(remarkParse)
- .parse(markdown)
-
-console.log(inspect(tree))
diff --git a/docs/VERCEL_DEPLOYMENT_SETUP.md b/docs/DEPLOYMENT_SETUP.md
similarity index 83%
rename from docs/VERCEL_DEPLOYMENT_SETUP.md
rename to docs/DEPLOYMENT_SETUP.md
index 202811882..b11c136f1 100644
--- a/docs/VERCEL_DEPLOYMENT_SETUP.md
+++ b/docs/DEPLOYMENT_SETUP.md
@@ -11,6 +11,7 @@ The CI workflow (`.github/workflows/build-and-test.yml`) is configured with thre
3. **deploy-to-vercel** - Deploys to Vercel production only after deployment gate succeeds
This ensures Vercel deployments only occur when:
+
- Push is to the `main` branch
- All tests pass
- Build succeeds
@@ -70,17 +71,33 @@ The `.vercel/project.json` file created by `vercel link` will contain both `proj
3. Click **New repository secret**
4. Add each of these three secrets:
-| Secret Name | Value |
-|-------------|-------|
-| `VERCEL_TOKEN` | Token from step 1.1 |
-| `VERCEL_PROJECT_ID` | Project ID from step 1.2 |
-| `VERCEL_ORG_ID` | Organization ID from step 1.3 |
+ - **Name**: Exact name from the list above (case-sensitive)
+ - **Value**: The actual secret value from your local `.env` file
+ - Click **Add secret**
**Important:**
+
- Secret names must match exactly (case-sensitive)
- Values should be pasted as-is, no quotes or extra whitespace
- After saving, you cannot view the values again (only update them)
+### GitHub Security Features
+
+GitHub Actions automatically:
+
+- β **Masks secret values** in all log output
+- β **Prevents secrets from being printed** to console
+- β **Blocks secret exposure** in pull requests from forks
+- β **Encrypts secrets** at rest and in transit
+
+**Example of Masked Output**
+
+If a secret contains `abc123xyz`, GitHub will show:
+
+```bash
+***
+```
+
## Step 3: Configure Vercel to Prevent Auto-Deploy
You must disable Vercel's automatic GitHub deployments to rely exclusively on the GitHub Action.
@@ -131,6 +148,7 @@ If you want Vercel to remain connected but wait for CI:
3. Go to GitHub **Actions** tab
4. Watch the workflow run:
+
- `build-and-test` should run first (lint, tests, build, E2E)
- `deployment-ready` should run after tests pass
- `deploy-to-vercel` should run last and deploy to Vercel production
@@ -144,27 +162,47 @@ If you want Vercel to remain connected but wait for CI:
### Troubleshooting
**If deployment fails with authentication error:**
+
- Verify secrets are set correctly in GitHub (names match exactly)
- Check token hasn't expired
- Ensure token has correct scope/permissions
**If deployment doesn't trigger:**
+
- Verify push is to `main` branch
- Check that `deployment-ready` job succeeded
- Look at GitHub Actions logs for errors
**If tests pass but deployment skipped:**
+
- Check the `if: github.ref == 'refs/heads/main' && success()` condition
- Ensure previous jobs succeeded (not just "completed")
**If Vercel still auto-deploys:**
+
- Double-check Step 3 settings in Vercel dashboard
- May need to disconnect and reconnect Git integration
- Contact Vercel support if setting persists
-## Step 5: Optional - Set Up Branch Protection
+**"Context access might be invalid" warnings**
+
+These YAML lint warnings appear before secrets are added to GitHub. They will disappear once you configure the secrets in your repository settings.
+
+**Build fails with "environment variable is not set"**
+
+1. Verify the secret is added in GitHub Settings
+2. Check the secret name matches exactly (case-sensitive)
+3. Ensure the workflow file references the secret correctly: `${{ secrets.SECRET_NAME }}`
+
+**Secret not available in job**
+
+- Secrets are not passed to workflows triggered by forks
+- Check that the secret is configured at the repository level (not environment level)
+- Verify the job has access to secrets (jobs inherit by default)
+
+## Step 5: Set Up Branch Protection
-For additional safety, configure branch protection on `main`:
+Configure branch protection on `main`:
1. Go to GitHub **Settings** β **Branches**
2. Add branch protection rule for `main`
@@ -177,6 +215,7 @@ For additional safety, configure branch protection on `main`:
4. Save changes
This ensures:
+
- No direct pushes to `main` (requires PR)
- CI must pass before merge
- Forces code review workflow
@@ -201,23 +240,7 @@ Tokens should be rotated periodically (recommend yearly):
### Updating the Workflow
If you modify `.github/workflows/build-and-test.yml`:
+
- Ensure job dependencies remain correct (`needs: ...`)
- Keep the `if: github.ref == 'refs/heads/main'` conditions
- Test in a feature branch first before merging to `main`
-
-## Security Best Practices
-
-1. **Never commit secrets to the repository** (use GitHub Secrets only)
-2. **Use organization/team tokens** rather than personal tokens when possible
-3. **Set token expiration** and rotate regularly
-4. **Limit token scope** to only the necessary permissions
-5. **Use branch protection** to prevent accidental deployments
-6. **Review GitHub Actions logs** regularly for suspicious activity
-7. **Enable 2FA** on both GitHub and Vercel accounts
-
-## Resources
-
-- [Vercel CLI Documentation](https://vercel.com/docs/cli)
-- [GitHub Actions Secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets)
-- [Vercel Deployment Protection](https://vercel.com/docs/security/deployment-protection)
-- [GitHub Branch Protection](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches)
diff --git a/docs/ENVIRONMENT_VARIABLES b/docs/ENVIRONMENT_VARIABLES
new file mode 100644
index 000000000..ed51fc17b
--- /dev/null
+++ b/docs/ENVIRONMENT_VARIABLES
@@ -0,0 +1,72 @@
+# Environment variables
+
+## CI / CD
+
+Install Vercel CLI if not already done. Linking it will create a .vercel directory in the project with local configuration details. This directory is gitignored.
+
+```bash
+npm install -g vercel
+vercel link
+```
+
+Pull env vars from Vercel:
+
+```bash
+vercel env pull .env
+```
+
+See [Deployment](./DEPLOYMENT_SETUP.md) for information on adding environment variables to the GitHub repository for use in workflow Actions.
+
+## Loading
+
+Astro uses Vite to load environment variables, which in turn uses `dotenv` to load them from several `.env` files located in the project's root directory. The specific files loaded, and their priority, depend on the current mode (development or production). All `.env*` files are gitignored.
+
+- `.env`: Loaded in all cases, providing general environment variables.
+- `.env.local`: Loaded in all cases, intended for local development settings.
+- `.env.[mode]`: Loaded only in the specified mode (`.env.development` when running the Astro dev server, and `.env.production` when running `astro build`).
+- `.env.[mode].local`: Loaded only in the specified mode, providing local overrides for a specific mode.
+
+## Availability
+
+1. Auto-loaded environment variables are not available in **config files**. The files are excluded from the ESLint linting rule forbidding `process.env`. They can use Vite's `loadEnv()` method to load `.env*` files.
+2. All environment variables are available in **server-side code**.
+3. Only environment variables prefixed with `PUBLIC_` are available in **client-side code**. Vite extracts these variables from `.env*` files during loading and bundles them into the client script.
+
+## Typing
+
+There are two ways in an Astro project to type environmental variables in Astro:
+
+1. Add them to `src/lib/config/environmentalVariableValidation.ts`. This provides validation for the variables. Astro will generate a `.astro/env.d.ts` file with the types exported as `const` from `astro sync`, ran automatically on `astro dev` and `astro build`. They will be available as tokens from `astro:env/server` and `astro:env/client` respectively.
+2. Add them to `src/env.d.ts`. This will provide TypeScript typing for intellisense but no validation. Environmental variables added this way will not be made available on the `astro:env` imports.
+
+## Use in Code
+
+Import environment variables defined in `environmentalVariableValidation` directly.
+
+All variables:
+
+```typescript
+import { ENV_VAR } from 'astro:env/server'
+```
+
+Client side variables:
+
+```typescript
+import { PUBLIC_ENV_VAR } from 'astro:env/client'
+```
+
+The `process.env` and `import.meta.env` methods of accessing environment variables will generate linting errors if used outside of the root-level configuration files where they are necessary.
+
+## Determining Site URL
+
+The site URL can vary based on whether code is being ran from the Astro dev server, being ran in production (including being ran with `astro serve`), being ran from Vitest in unit files,
+
+- Use the `getSiteUrl()` method from `src/components/scripts/utils/siteUrlClient.ts` to determine the site URL in **client code**.
+- Use the `getSiteUrl()` method from `src/lib/config/siteUrlServer.ts` to determine the site URL in **server code**.
+
+## Determining Mode
+
+Use well tested methods to determine what mode (dev, prod, ci, vitest, playwright) code is operating in:
+
+- Use methods in `src/components/scripts/utils/environmentClient.ts` to determine mode in **client code**.
+- Use methods in `src/lib/config/environmentServer.ts` to determine mode in **server code**.
diff --git a/docs/GITHUB_SECRETS_SETUP.md b/docs/GITHUB_SECRETS_SETUP.md
deleted file mode 100644
index c03203899..000000000
--- a/docs/GITHUB_SECRETS_SETUP.md
+++ /dev/null
@@ -1,109 +0,0 @@
-# GitHub Secrets Setup Guide
-
-This document explains how to configure GitHub Secrets for CI/CD workflows.
-
-## Required Secrets
-
-The following secrets must be configured in your GitHub repository for the CI/CD pipelines to work:
-
-### Required for Build & Deployment
-
-- `CONVERTKIT_API_KEY` - ConvertKit API key for newsletter integration
-- `CONVERTKIT_FORM_ID` - ConvertKit form ID (numeric value)
-- `RESEND_API_KEY` - Resend API key for email functionality
-
-### Required for Vercel Deployment
-
-- `VERCEL_TOKEN` - Vercel deployment token
-- `VERCEL_PROJECT_ID` - Your Vercel project ID
-- `VERCEL_ORG_ID` - Your Vercel organization ID
-
-### Optional but Recommended
-
-- `SENTRY_AUTH_TOKEN` - Sentry authentication token (required for source map uploads)
-- `PUBLIC_SENTRY_DSN` - Sentry DSN for error tracking
-- `WEBMENTION_IO_TOKEN` - WebMention.io API token for webmentions
-
-## How to Add Secrets to GitHub
-
-1. Navigate to your repository on GitHub
-2. Click on **Settings** tab
-3. In the left sidebar, click **Secrets and variables** β **Actions**
-4. Click **New repository secret**
-5. Add each secret:
- - **Name**: Exact name from the list above (case-sensitive)
- - **Value**: The actual secret value from your local `.env` file
- - Click **Add secret**
-
-## Security Features
-
-GitHub Actions automatically:
-- β **Masks secret values** in all log output
-- β **Prevents secrets from being printed** to console
-- β **Blocks secret exposure** in pull requests from forks
-- β **Encrypts secrets** at rest and in transit
-
-### Example of Masked Output
-If a secret contains `abc123xyz`, GitHub will show:
-```
-***
-```
-
-## Workflow Configuration
-
-The secrets are injected as environment variables in the workflow files:
-
-### build-and-test.yml
-Secrets are available in these steps:
-- TypeScript check
-- Unit tests
-- Build
-- E2E tests
-
-### type-check.yml
-Secrets are available in:
-- TypeScript check
-
-## Local Development
-
-For local development, create a `.env` file in the project root:
-
-```bash
-# Copy from .env.example or create manually
-CONVERTKIT_API_KEY=your_key_here
-CONVERTKIT_FORM_ID=123456
-RESEND_API_KEY=your_key_here
-SENTRY_AUTH_TOKEN=your_token_here
-PUBLIC_SENTRY_DSN=your_dsn_here
-WEBMENTION_IO_TOKEN=your_token_here
-```
-
-**Important**: `.env` files are gitignored and should NEVER be committed to the repository.
-
-## Troubleshooting
-
-### "Context access might be invalid" warnings
-These YAML lint warnings appear before secrets are added to GitHub. They will disappear once you configure the secrets in your repository settings.
-
-### Build fails with "environment variable is not set"
-1. Verify the secret is added in GitHub Settings
-2. Check the secret name matches exactly (case-sensitive)
-3. Ensure the workflow file references the secret correctly: `${{ secrets.SECRET_NAME }}`
-
-### Secret not available in job
-- Secrets are not passed to workflows triggered by forks
-- Check that the secret is configured at the repository level (not environment level)
-- Verify the job has access to secrets (jobs inherit by default)
-
-## Best Practices
-
-1. **Rotate secrets regularly** - Update secrets periodically for security
-2. **Use different secrets** for different environments (dev/staging/prod)
-3. **Limit secret access** - Only add secrets that are necessary
-4. **Document secret requirements** - Keep this file updated
-5. **Test in PR** - Ensure workflows work before merging to main
-
-## Reference
-
-- [GitHub Actions Secrets Documentation](https://docs.github.com/en/actions/security-guides/encrypted-secrets)
-- [Astro Environment Variables](https://docs.astro.build/en/guides/environment-variables/)
diff --git a/docs/THEME_PICKER.md b/docs/THEME_PICKER.md
deleted file mode 100644
index 7b2a5bbc9..000000000
--- a/docs/THEME_PICKER.md
+++ /dev/null
@@ -1,43 +0,0 @@
-# Theme Picker info
-
-The theme picker works by setting CSS attribute matchers for the `data-theme` attribute to the group of sitewide CSS variables, like `--color-bg`. That way, when the data theme is changed on the root document `` element, the variable values in the CSS are changed as well. This is over-ridden locally in the theme picker modal, where the `data-theme` attribute is set on individual theme item cards so the card shows that theme's colors.
-
-## Code Pieces
-
-### Client script bundle for the modal and icon button
-
-`src/assets/modules/themePicker`.
-
-### Generate HTML for the Theme Picker Modal to choose different color themes
-
-`src/_layouts/components/themePicker/themes.njk` creates the theme picker HTML by looping through `themes`. Data is generated by `_data/themes.js`.
-
-### Generate HTML for the Theme Picker Icon to make the theme picker modal visible
-
-`src/_layouts/components/themePicker/icon.njk`.
-
-### Generate `
diff --git a/src/components/Avatar/index.astro b/src/components/Avatar/index.astro
index f2f3ef6c8..0d7e88748 100644
--- a/src/components/Avatar/index.astro
+++ b/src/components/Avatar/index.astro
@@ -11,9 +11,8 @@
*
* ```
*/
-
import { Image } from 'astro:assets'
-import { AvatarManager, normalizeNameToFilename } from './server'
+import { AvatarManager, normalizeNameToFilename } from '@components/Avatar/server'
export interface Props {
/** The person's name (will be normalized for filename lookup) */
@@ -50,7 +49,7 @@ const fallbackInitial = name.charAt(0).toUpperCase()
height={avatarImage.height}
/>
) : (
-
+
{fallbackInitial}
)
diff --git a/src/components/Avatar/server/__fixtures__/avatars/brian-bristol.webp b/src/components/Avatar/server/__fixtures__/avatars/brian-bristol.webp
new file mode 100644
index 000000000..324cf324c
Binary files /dev/null and b/src/components/Avatar/server/__fixtures__/avatars/brian-bristol.webp differ
diff --git a/src/components/Avatar/server/__fixtures__/avatars/chris-southam.webp b/src/components/Avatar/server/__fixtures__/avatars/chris-southam.webp
new file mode 100644
index 000000000..261247ddb
Binary files /dev/null and b/src/components/Avatar/server/__fixtures__/avatars/chris-southam.webp differ
diff --git a/src/components/Avatar/server/__fixtures__/avatars/dru-sellers.webp b/src/components/Avatar/server/__fixtures__/avatars/dru-sellers.webp
new file mode 100644
index 000000000..3f9cc5229
Binary files /dev/null and b/src/components/Avatar/server/__fixtures__/avatars/dru-sellers.webp differ
diff --git a/src/components/Avatar/server/__fixtures__/avatars/kevin-brown.jpg b/src/components/Avatar/server/__fixtures__/avatars/kevin-brown.jpg
new file mode 100644
index 000000000..b05169c43
Binary files /dev/null and b/src/components/Avatar/server/__fixtures__/avatars/kevin-brown.jpg differ
diff --git a/src/components/Avatar/server/__fixtures__/avatars/kevin-brown.webp b/src/components/Avatar/server/__fixtures__/avatars/kevin-brown.webp
new file mode 100644
index 000000000..cf918db25
Binary files /dev/null and b/src/components/Avatar/server/__fixtures__/avatars/kevin-brown.webp differ
diff --git a/src/components/Avatar/server/__fixtures__/avatars/sara-king.webp b/src/components/Avatar/server/__fixtures__/avatars/sara-king.webp
new file mode 100644
index 000000000..132037e17
Binary files /dev/null and b/src/components/Avatar/server/__fixtures__/avatars/sara-king.webp differ
diff --git a/src/components/Avatar/__tests__/server.spec.ts b/src/components/Avatar/server/__tests__/index.spec.ts
similarity index 79%
rename from src/components/Avatar/__tests__/server.spec.ts
rename to src/components/Avatar/server/__tests__/index.spec.ts
index ed104e794..7280684f5 100644
--- a/src/components/Avatar/__tests__/server.spec.ts
+++ b/src/components/Avatar/server/__tests__/index.spec.ts
@@ -1,5 +1,27 @@
+import type { ImageMetadata } from 'astro'
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
+const fixtureModules = import.meta.glob('../__fixtures__/avatars/*.{webp,jpg,png}', {
+ eager: true,
+ import: 'default',
+}) as Record
+
+const fixtureAvatarNames = Array.from(
+ new Set(
+ Object.keys(fixtureModules)
+ .map((path) => path.split('/').pop()?.replace(/\.(webp|jpg|png)$/i, ''))
+ .filter((name): name is string => Boolean(name))
+ )
+)
+
+const mockLoadAvatarModules = vi.fn(() => fixtureModules)
+
+vi.mock('../avatarImports', () => ({
+ loadAvatarModules: mockLoadAvatarModules,
+}))
+
+const primaryAvatar = 'kevin-brown'
+const fixtureAvatarCount = fixtureAvatarNames.length
/**
* Comprehensive unit tests for AvatarManager
*
@@ -10,9 +32,9 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
* - Backward-compatible deprecated functions
* - Edge cases and error handling
*
- * Note: These tests use the actual avatar files in src/assets/images/avatars/
- * Expected files: brian-bristol.webp, chris-southam.webp, dru-sellers.webp,
- * kevin-brown.jpg, kevin-brown.webp, sara-king.webp, test-red-dot.png
+ * Note: These tests load dedicated fixture avatars from
+ * src/components/Avatar/server/__fixtures__/avatars/ to keep metadata stable.
+ * Fixtures mirror the production files except for test-red-dot.png.
*/
describe('AvatarManager', () => {
@@ -22,12 +44,15 @@ describe('AvatarManager', () => {
let getAvailableAvatars: any
beforeEach(async () => {
+ mockLoadAvatarModules.mockClear()
+ vi.resetModules()
+
// Mock console methods to avoid cluttering test output
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'warn').mockImplementation(() => {})
// Import the module
- const module = await import('../server')
+ const module = await import('..')
AvatarManager = module.AvatarManager
// Get the singleton instance and extract methods for backward compatibility
@@ -50,18 +75,13 @@ describe('AvatarManager', () => {
})
it('should initialize only once', () => {
- const consoleLogSpy = vi.spyOn(console, 'log')
+ const initialCalls = mockLoadAvatarModules.mock.calls.length
+ expect(initialCalls).toBe(1)
- // Get instance multiple times
- AvatarManager.getInstance()
AvatarManager.getInstance()
AvatarManager.getInstance()
- // Constructor should only run once (if in development mode)
- // In production, console.log won't be called
- if (process.env['NODE_ENV'] === 'development') {
- expect(consoleLogSpy).toHaveBeenCalledTimes(1)
- }
+ expect(mockLoadAvatarModules).toHaveBeenCalledTimes(initialCalls)
})
it('should have a private constructor (enforced by TypeScript)', () => {
@@ -76,7 +96,7 @@ describe('AvatarManager', () => {
describe('getAvatar()', () => {
it('should return avatar metadata for existing avatar', () => {
const instance = AvatarManager.getInstance()
- const avatar = instance.getAvatar('kevin-brown')
+ const avatar = instance.getAvatar(primaryAvatar)
expect(avatar).toBeDefined()
// Avatar should be an object or a string path
@@ -95,7 +115,7 @@ describe('AvatarManager', () => {
it('should return correct metadata for kevin-brown avatar', () => {
const instance = AvatarManager.getInstance()
- const kevinAvatar = instance.getAvatar('kevin-brown')
+ const kevinAvatar = instance.getAvatar(primaryAvatar)
expect(kevinAvatar).toBeDefined()
// Check the structure of the returned value
@@ -123,7 +143,7 @@ describe('AvatarManager', () => {
it('should be case-sensitive for filenames', () => {
const instance = AvatarManager.getInstance()
- const lowerCase = instance.getAvatar('kevin-brown')
+ const lowerCase = instance.getAvatar(primaryAvatar)
const upperCase = instance.getAvatar('Kevin-Brown')
expect(lowerCase).toBeDefined()
@@ -135,7 +155,7 @@ describe('AvatarManager', () => {
it('should return true for existing avatar', () => {
const instance = AvatarManager.getInstance()
- expect(instance.hasAvatar('kevin-brown')).toBe(true)
+ expect(instance.hasAvatar(primaryAvatar)).toBe(true)
})
it('should return false for non-existent avatar', () => {
@@ -154,7 +174,7 @@ describe('AvatarManager', () => {
it('should be case-sensitive', () => {
const instance = AvatarManager.getInstance()
- expect(instance.hasAvatar('kevin-brown')).toBe(true)
+ expect(instance.hasAvatar(primaryAvatar)).toBe(true)
expect(instance.hasAvatar('Kevin-Brown')).toBe(false)
expect(instance.hasAvatar('KEVIN-BROWN')).toBe(false)
})
@@ -166,14 +186,14 @@ describe('AvatarManager', () => {
const avatars = instance.getAvailableAvatars()
expect(Array.isArray(avatars)).toBe(true)
- expect(avatars.length).toBeGreaterThan(0)
+ expect(avatars.length).toBe(fixtureAvatarCount)
})
it('should include kevin-brown avatar', () => {
const instance = AvatarManager.getInstance()
const avatars = instance.getAvailableAvatars()
- expect(avatars).toContain('kevin-brown')
+ expect(avatars).toContain(primaryAvatar)
})
it('should return filenames without extensions', () => {
@@ -208,7 +228,7 @@ describe('AvatarManager', () => {
const instance = AvatarManager.getInstance()
const allAvatars = instance.getAll()
- expect(allAvatars['kevin-brown']).toBeDefined()
+ expect(allAvatars[primaryAvatar]).toBeDefined()
})
it('should return a read-only object', () => {
@@ -224,8 +244,7 @@ describe('AvatarManager', () => {
it('should return the correct number of avatars', () => {
const instance = AvatarManager.getInstance()
- // Should match the actual number of avatar files in the directory
- expect(instance.count).toBeGreaterThan(0)
+ expect(instance.count).toBe(fixtureAvatarCount)
})
it('should be consistent with getAvailableAvatars length', () => {
@@ -261,20 +280,32 @@ describe('AvatarManager', () => {
const instance = AvatarManager.getInstance()
const allAvatars = instance.getAll()
- expect(() => {
- // Testing runtime immutability
- delete (allAvatars as any)['kevin-brown']
- }).toThrow()
+ let deleteResult: boolean | undefined
+ try {
+ deleteResult = delete (allAvatars as any)[primaryAvatar]
+ } catch (error) {
+ expect(error).toBeInstanceOf(TypeError)
+ }
+
+ if (typeof deleteResult === 'boolean') {
+ expect(deleteResult).toBe(false)
+ }
+
+ expect(allAvatars[primaryAvatar]).toBeDefined()
})
it('should not allow modification of existing avatar properties', () => {
const instance = AvatarManager.getInstance()
const allAvatars = instance.getAll()
- expect(() => {
- // Testing runtime immutability
- ;(allAvatars as any)['kevin-brown'].src = '/modified.jpg'
- }).toThrow()
+ const originalSrc = allAvatars[primaryAvatar]?.src
+ try {
+ ;(allAvatars as any)[primaryAvatar].src = '/modified.jpg'
+ } catch (error) {
+ expect(error).toBeInstanceOf(TypeError)
+ }
+
+ expect(allAvatars[primaryAvatar]?.src).toBe(originalSrc)
})
})
@@ -282,8 +313,8 @@ describe('AvatarManager', () => {
describe('getAvatarImage() (deprecated)', () => {
it('should work the same as getInstance().getAvatar()', () => {
const instance = AvatarManager.getInstance()
- const newWay = instance.getAvatar('kevin-brown')
- const oldWay = getAvatarImage('kevin-brown')
+ const newWay = instance.getAvatar(primaryAvatar)
+ const oldWay = getAvatarImage(primaryAvatar)
expect(oldWay).toEqual(newWay)
})
@@ -319,24 +350,23 @@ describe('AvatarManager', () => {
const avatars = getAvailableAvatars()
expect(Array.isArray(avatars)).toBe(true)
- expect(avatars.length).toBeGreaterThan(0)
+ expect(avatars.length).toBe(fixtureAvatarCount)
})
})
})
describe('Edge Cases', () => {
it('should handle avatar with special characters in filename', () => {
- // This would need mock data with special chars, skipping for now
- // but good to consider for real-world usage
const instance = AvatarManager.getInstance()
- expect(instance.hasAvatar('avatar-with-dash')).toBeDefined()
+ const hyphenatedName = fixtureAvatarNames.find((name) => name.includes('-')) ?? primaryAvatar
+ expect(instance.hasAvatar(hyphenatedName)).toBe(true)
})
it('should handle multiple file extensions correctly', () => {
const instance = AvatarManager.getInstance()
- // kevin-brown should be found regardless of extension
- expect(instance.hasAvatar('kevin-brown')).toBe(true)
+ // The canonical avatar should be found regardless of extension
+ expect(instance.hasAvatar(primaryAvatar)).toBe(true)
})
it('should strip file extension case-insensitively', () => {
@@ -356,7 +386,7 @@ describe('AvatarManager', () => {
describe('Type Safety', () => {
it('should return proper ImageMetadata type from getAvatar', () => {
const instance = AvatarManager.getInstance()
- const avatar = instance.getAvatar('kevin-brown')
+ const avatar = instance.getAvatar(primaryAvatar)
if (avatar && typeof avatar === 'object') {
expect(typeof avatar.src).toBe('string')
@@ -397,13 +427,13 @@ describe('AvatarManager', () => {
// Make many rapid calls
for (let i = 0; i < 100; i++) {
- instance.getAvatar('kevin-brown')
- instance.hasAvatar('kevin-brown')
+ instance.getAvatar(primaryAvatar)
+ instance.hasAvatar(primaryAvatar)
instance.getAvailableAvatars()
}
// Should still work correctly
- expect(instance.getAvatar('kevin-brown')).toBeDefined()
+ expect(instance.getAvatar(primaryAvatar)).toBeDefined()
expect(instance.count).toBeGreaterThan(0)
})
})
diff --git a/src/components/Avatar/server/avatarImports.ts b/src/components/Avatar/server/avatarImports.ts
new file mode 100644
index 000000000..a99613656
--- /dev/null
+++ b/src/components/Avatar/server/avatarImports.ts
@@ -0,0 +1,9 @@
+import type { ImageMetadata } from 'astro'
+
+export type AvatarModuleMap = Record
+
+export const loadAvatarModules = (): AvatarModuleMap =>
+ import.meta.glob('../../assets/images/avatars/*.{webp,jpg,png}', {
+ eager: true,
+ import: 'default',
+ }) as AvatarModuleMap
diff --git a/src/components/Avatar/server.ts b/src/components/Avatar/server/index.ts
similarity index 92%
rename from src/components/Avatar/server.ts
rename to src/components/Avatar/server/index.ts
index d8106c536..61c572638 100644
--- a/src/components/Avatar/server.ts
+++ b/src/components/Avatar/server/index.ts
@@ -15,6 +15,8 @@
*/
import type { ImageMetadata } from 'astro'
+import { isDev } from '@lib/config/environmentServer'
+import { loadAvatarModules } from './avatarImports'
type AvatarMap = Record
@@ -42,10 +44,7 @@ class AvatarManagerClass {
private constructor() {
// Eagerly import all avatar images during initialization
- const avatarModules = import.meta.glob('../../assets/images/avatars/*.{webp,jpg,png}', {
- eager: true,
- import: 'default',
- }) as Record
+ const avatarModules = loadAvatarModules()
// Build the avatar mapping
const map: AvatarMap = {}
@@ -60,7 +59,7 @@ class AvatarManagerClass {
if (filename && imageData) {
// Deep freeze the image metadata to prevent modifications
map[filename] = Object.freeze({ ...imageData })
- } else if (process.env['NODE_ENV'] === 'development') {
+ } else if (isDev()) {
console.warn(`[AvatarManager] Failed to process avatar at path: ${path}`)
}
}
@@ -68,7 +67,7 @@ class AvatarManagerClass {
// Freeze the map to prevent modifications
this.avatarMap = Object.freeze(map)
- if (process.env['NODE_ENV'] === 'development') {
+ if (isDev()) {
console.log(`[AvatarManager] Initialized with ${Object.keys(this.avatarMap).length} avatars`)
}
}
diff --git a/src/components/Brand/index.astro b/src/components/Brand/index.astro
index ae7d209e6..ca4fd2c96 100644
--- a/src/components/Brand/index.astro
+++ b/src/components/Brand/index.astro
@@ -2,9 +2,9 @@
/**
* Brand identity with logo and wordmark, used in the Header component
*/
-import Logo from './Logo.astro'
-import WordmarkBuilders from './WordmarkBuilders.astro'
-import WordmarkWebstack from './WordmarkWebstack.astro'
+import Logo from '@components/Brand/Logo.astro'
+import WordmarkBuilders from '@components/Brand/WordmarkBuilders.astro'
+import WordmarkWebstack from '@components/Brand/WordmarkWebstack.astro'
---
1
+ ? JSON.stringify({
+ '@context': 'https://schema.org',
+ '@type': 'BreadcrumbList',
+ itemListElement: breadcrumbs.map((item, index) => ({
+ '@type': 'ListItem',
+ position: index + 1,
+ name: item.label,
+ item: new URL(item.href, site).href,
+ })),
+ })
+ : null
---
{
breadcrumbs.length > 1 && (
-