diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 555c89aa0..eca1f8977 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -25,10 +25,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Initialize CodeQL - uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 with: languages: ${{ matrix.language }} queries: +security-and-quality @@ -36,10 +36,10 @@ jobs: - name: Autobuild if: matrix.language == 'javascript' - uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + uses: github/codeql-action/autobuild@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 with: category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index 56174ca1f..ebc342ec8 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -20,10 +20,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.13" cache: "pip" @@ -45,10 +45,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.13" cache: "pip" @@ -70,10 +70,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.13" cache: "pip" diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 736f0767f..9fed4c545 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -16,7 +16,7 @@ jobs: if: ${{ !startsWith(github.head_ref || github.ref_name, 'hotfix/') }} steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Dependency Review uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 diff --git a/.github/workflows/deployment-preview.yml b/.github/workflows/deployment-preview.yml index cd1c43370..ccb33d96d 100644 --- a/.github/workflows/deployment-preview.yml +++ b/.github/workflows/deployment-preview.yml @@ -50,12 +50,12 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Setup Node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: .nvmrc cache: npm diff --git a/.github/workflows/deployment-production.yml b/.github/workflows/deployment-production.yml index 775b20798..58df301fe 100644 --- a/.github/workflows/deployment-production.yml +++ b/.github/workflows/deployment-production.yml @@ -43,10 +43,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: .nvmrc cache: npm diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index fcfad1725..d9ffe60a2 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -36,10 +36,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.13" cache: "pip" @@ -48,7 +48,7 @@ jobs: run: python3 -m pip install -r requirements.txt - name: Setup Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ">=24.16.0 <25" cache: "npm" diff --git a/.github/workflows/migrations-preview.yml b/.github/workflows/migrations-preview.yml index c932e482f..97a6f980a 100644 --- a/.github/workflows/migrations-preview.yml +++ b/.github/workflows/migrations-preview.yml @@ -24,7 +24,7 @@ jobs: steps: - name: Checkout repository (trusted base) - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: main @@ -98,7 +98,7 @@ jobs: - name: Setup Node.js if: steps.gate.outputs.should_migrate == 'true' - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ">=24.16.0 <25" cache: "npm" diff --git a/.github/workflows/migrations-production.yml b/.github/workflows/migrations-production.yml index f76ee90d0..5ea16059e 100644 --- a/.github/workflows/migrations-production.yml +++ b/.github/workflows/migrations-production.yml @@ -24,7 +24,7 @@ jobs: steps: - name: Checkout repository (trusted base) - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: main @@ -77,7 +77,7 @@ jobs: - name: Setup Node.js if: steps.gate.outputs.should_migrate == 'true' - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ">=24.16.0 <25" cache: "npm" diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index c90ab4850..76bde5c68 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -45,10 +45,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ">=24.16.0 <25" cache: "npm" diff --git a/.github/workflows/search.yml b/.github/workflows/search.yml index ba57b98cb..fdb368ff8 100644 --- a/.github/workflows/search.yml +++ b/.github/workflows/search.yml @@ -26,10 +26,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.13" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 48b298bbd..7d0820c9b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -46,10 +46,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ">=24.16.0 <25" cache: "npm" @@ -68,7 +68,7 @@ jobs: - name: Report Coverage if: steps.vitest.outcome == 'success' && hashFiles('coverage/coverage-summary.json') != '' - uses: davelosert/vitest-coverage-report-action@02f3c2e641286b7fa308cd3e430783103ce6103b # v2.12.0 + uses: davelosert/vitest-coverage-report-action@8b157684c6a6b259b97d45e72b44242865c0f6a5 # v2.12.2 with: json-summary-path: "./coverage/coverage-summary.json" json-final-path: "./coverage/coverage-final.json" diff --git a/.yarnrc.yml b/.yarnrc.yml deleted file mode 100644 index febab4705..000000000 --- a/.yarnrc.yml +++ /dev/null @@ -1,2 +0,0 @@ -# Use NPM-style node_modules resolution -nodeLinker: "node-modules" diff --git a/SESSION.md b/SESSION.md deleted file mode 100644 index 139a19f5e..000000000 --- a/SESSION.md +++ /dev/null @@ -1,238 +0,0 @@ -# Remove current articles, rename deep dives to articles, and remove all deep dive functionality - -## Background - -Each `src/content/articles//` directory used to contain two content items: - -- `index.mdx` — a short-form "overview" article served at `/articles/` -- `pdf.mdx` — a long-form "deep dive" served at `/deep-dive/` (loaded into the - separate `deepDives` content collection with a custom `generateId` that stripped - the `/pdf` suffix) - -The short-form pages were marked `noindex, follow` with a canonical link pointing at -their `/deep-dive/` counterpart, and `/articles/` URLs were excluded from -the sitemap via `deepDiveAliases`. This dual-URL setup caused Google indexing -problems, so we are consolidating to a single long-form article per topic at -`/articles/`. - -## Already completed (content migration) - -Committed in `5a968d90` and `ef2e347d`: - -- Deleted the original short-form `index.mdx` files. -- Renamed every `pdf.mdx` to `index.mdx`. - -Current state of each article directory: `index.mdx` (long-form), `download.mdx`, -`cover.jpg`, `download.jpg`, `diagrams/`. Consequences already in effect: - -- The `articles` collection (glob `**/index.mdx`) now loads the long-form content. No - glob change needed for `articles`. -- The `deepDives` collection (glob `**/pdf.mdx`) now matches **zero** files — all - `getCollection('deepDives')` call sites currently return empty arrays. Every such - call site must be migrated or removed (see below). - -## Implementation plan - -### 1. Content collections — `src/content.config.ts` - -- Remove the `deepDiveArticlesCollection` definition (the `defineCollection` block - with `pattern: '**/pdf.mdx'`). -- Remove the `generateDeepDiveId` helper (only used by that loader). -- Remove `deepDives: deepDiveArticlesCollection` from `export const collections`. -- Keep `articles` as-is; it now globs the former deep-dive content. -- Keep `downloads` (`**/download.mdx`) untouched — `download.mdx` files still exist. - -### 2. Routing - -- **Delete** `src/pages/deep-dive/[...slug].astro` (whole `src/pages/deep-dive/` dir). -- **301 redirects (SEO-critical).** The `/deep-dive/` URLs are the ones Google - indexed (the `/articles/` versions were `noindex`). They must permanently - redirect to `/articles/`: - - Add to `vercel.json` a `redirects` entry: - `{ "source": "/deep-dive/:path*", "destination": "/articles/:path*", "permanent": true }` - - Also replace `src/pages/deep-dive/[...slug].astro` with a redirect stub so - dev/preview behave the same as production: `getStaticPaths()` over the `articles` - collection, then `return Astro.redirect(`/articles/${Astro.params.slug}`, 301)`. - (If preferred, this stub can be deleted once Vercel redirects are verified live.) -- **Simplify** `src/pages/articles/[...slug].astro`: - - Remove the `getCollection('deepDives', ...)` call, `deepDiveIds`, and the - `canonicalPath` prop logic (`/deep-dive/` canonical). - - Remove `robotsContent` (`noindex, follow`) — articles become indexable canonical - pages. - - Remove the now-unused `canonicalPath` from `Props`. - -### 3. Switcher component (delete entirely) - -Delete `src/components/Content/Switcher/`: - -- `index.astro`, `index.css` -- `client/index.ts`, `client/selectors.ts`, `client/__tests__/` -- `server/index.ts`, `server/__tests__/` - -Call sites and plumbing: - -- `src/components/Content/Layout/index.astro` — remove the - `import ContentSwitcher from '@components/Content/Switcher/index.astro'` and the - `
` - block. -- `src/layouts/MarkdownLayout.astro` — remove the `breadcrumb-row` slot forwarding - (``). -- `src/layouts/BaseLayout.astro` — remove `hasBreadcrumbRowSlot` and its use in - `shouldRenderBreadcrumbRow` (which becomes simply `path !== '/' && path !== ''`), - the `` render block, and the ContentSwitcher comment. -- Nanostores: **no nanostore code exists for the Switcher** — it is a LitElement with - idle-prefetch logic only; nothing in `src/components/scripts/store/` references it. - (The `toggle theme switcher` locator in e2e `BasePage.ts` is the ThemePicker and is - unrelated.) No store work needed. - -### 4. Content layout — `src/components/Content/Layout/index.astro` - -- Remove `CollectionEntry<'deepDives'>` from the `article` prop union. -- Replace the `relatedContentType` conditional - (`path.startsWith('/deep-dive/') || canonicalPath ? 'deep-dive' : 'articles'`) - with a constant `'articles'` for the Carousel `type`. -- Remove the `canonicalPath` and `robotsContent` props from `Props` and the - MarkdownLayout pass-throughs if no other caller uses them after step 2 (verify with - a grep for `canonicalPath` across pages; `MarkdownLayout`/`Head` keep their own - generic support — unit tests there just need fixture updates, see step 10). - -### 5. Collection consumers — switch `deepDives` to `articles` - -- `src/pages/articles/index.astro` — `getCollection('deepDives')` → - `getCollection('articles')`; rename `allDeepDives`/`getDeepDivesForTag` locals; - Carousel `type="deep-dive"` → `type="articles"`; update copy: page description - "Browse technical deep dives…" and the "N deep dives" badge. -- `src/pages/index.astro` (homepage "Latest Insights") — Carousel - `type="deep-dive"` → `type="articles"`. -- `src/pages/rss.xml.ts` — `getCollection('deepDives')` → `'articles'`; item link - `/deep-dive/${article.id}` → `/articles/${article.id}`. -- `src/pages/print/[...slug].astro` — `Props.article` type and - `getCollection('deepDives')` → `'articles'`. The QR/copyright link already points - at `/articles/`; keep it. -- `src/components/Layout/Print/Cover/index.astro` — prop type - `CollectionEntry<'deepDives'>` → `CollectionEntry<'articles'>`. -- `src/layouts/MarkdownLayout.astro` — remove `CollectionEntry<'deepDives'>` from - the `collectionItem` union. -- `src/components/Pages/TagPage/index.astro` — card href - `` `/deep-dive/${item.id}` `` → `` `/articles/${item.id}` `` (the tag pagination - helper `src/lib/tags/pagination.ts` already queries the `articles` collection). -- `src/components/Carousel/@types/index.ts` — remove `'deep-dive': 'deepDives'` - from `collectionMap`. Item hrefs are built as `/${type}/${item.id}`, so no other - Carousel change is needed; check `server/__fixtures__/collection.fixture.ts` and - server tests for `deepDives` references. -- `src/components/Breadcrumbs/index.astro` — remove `rewriteBreadcrumbHref` - (`/deep-dive` → `/articles`) and its two call sites (JSON-LD schema item URL and - the link `href`). -- `src/components/Search/SearchResults/client/index.ts` — remove - `'deep-dive': 'Deep Dive'` from `resultTypeLabels`. - -### 6. Sitemap — `src/integrations/sitemapSerialize/` + `astro.config.ts` - -- Delete `src/integrations/sitemapSerialize/deepDiveAliases.ts` and - `src/integrations/sitemapSerialize/__tests__/deepDiveAliases.spec.ts`. (This module - generated `/articles/` paths to *exclude* from the sitemap; after - consolidation those URLs are canonical and must be *included*.) -- `astro.config.ts` — remove the `getDeepDiveArticleAliasPaths` import, the - `deepDiveArticleAliasPaths` const, and `...deepDiveArticleAliasPaths` from the - sitemap `exclude` array. `/deep-dive/*` URLs disappear from the sitemap - automatically when the route is removed. - -### 7. PDF generation — `scripts/generate-pdfs/index.mjs` - -- `collectAllSlugs()`: existence check `join(ARTICLES_DIR, e.name, 'pdf.mdx')` → - `'index.mdx'`. -- Single-slug validation: `pdf.mdx` → `index.mdx`, plus error message text - ("No pdf.mdx found", "No deep dive articles with pdf.mdx found", header usage - comment). -- No change to the render URL (`/print/`), output dir (`public/downloads/`), - or the `pdf:generate` npm script name. - -### 8. Search indexing — `scripts/search-index.py` + `.github/workflows/search.yml` - -- `scripts/search-index.py`: remove the - `CollectionConfig(name="deep-dive", url_prefix="/deep-dive", source_dir="articles", glob_pattern="**/pdf.mdx")` - entry from `COLLECTIONS`. The `articles` entry (`**/index.mdx`) now indexes the - long-form content at the correct `/articles/` URLs. -- `.github/workflows/search.yml`: in "Index changed collections", drop - `--collection deep-dive` from the `crawl_articles` branch. -- The `determine-search-index-scope` and `prune-upstash-search` composite actions are - collection-agnostic (parameterized) — no changes needed. -- **One-time operational step after deploy:** the live Upstash index contains stale - `/deep-dive/*` documents that the incremental `--no-drop` CI run will not remove - (the prune step only covers the `articles` collection). Run a full reindex once - manually — `npm run search:reindex` (drops and rebuilds the index by default) — - with production Upstash credentials. - -### 9. GitHub workflows — other - -- Grepped all of `.github/workflows/`: only `search.yml` references deep-dive/pdf. - `cron.yml`, `deployment-*.yml`, `playwright.yml`, etc. need no changes. PDFs are - generated manually (`npm run pdf:generate`) and committed under - `public/downloads/`; no CI job generates them. - -### 10. Unit tests - -- Delete: `src/components/Content/Switcher/**/__tests__/` (covered by step 3), - `src/integrations/sitemapSerialize/__tests__/deepDiveAliases.spec.ts`. -- Update fixtures that use `/deep-dive/...` example URLs: - - `src/components/Head/__tests__/Meta.spec.ts` - (`canonicalPath: '/deep-dive/example-article'` and expected URL) - - `src/components/Head/server/__tests__/structuredData.spec.ts` (same fixture; - expects `https://www.webstackbuilders.com/deep-dive/example-article`) - - `src/components/Search/SearchBar/client/__tests__/results.spec.ts` and - `index.spec.ts` (deep-dive URLs — functionally generic, update to `/articles/...` - for consistency) - -### 11. E2E tests — `test/e2e/` - -- `specs/01-smoke/dynamic-pages.spec.ts` — `articleLinkSelector = - 'a[href*="/deep-dive/"]'` → `/articles/`; URL assertion `/\/deep-dive\/.+/` → - `/\/articles\/.+/`. -- `specs/02-pages/articles.spec.ts` — expects navigation to `/deep-dive/`; - change to `/articles/`. -- `specs/02-pages/tags.spec.ts` — `a[href^="/deep-dive/"]` → `a[href^="/articles/"]`. -- `specs/07-metadata/seo-tags.spec.ts`, `open-graph.spec.ts`, - `structured-data.spec.ts` — drop the `a[href^="/deep-dive/"]` alternative from - `articleDetailLinkSelector`. -- `helpers/pageObjectModels/BreadCrumbPage.ts` — simplify `linkSelector` and update - `notFoundMessage` ("Could not find deep-dive or article detail link…"). - -### 12. Support files / housekeeping - -- `.gitignore` — no pdf/deep-dive entries exist; `public/downloads/` PDFs are - committed intentionally. Under the existing "Planning docs" section - (`CONTENT*.md`), consider adding `SESSION.md` if this plan should stay uncommitted. -- `_TODO.md` — remove the stale line about the Switcher component bug (component is - being deleted). -- `package.json` — no script renames needed (`pdf:generate`, `search:reindex` - unchanged). -- Content files: verified no MDX links to `/deep-dive/...` exist (only image - filenames like `*_deep-dive.jpg` and prose, which are fine). - -## Validation checklist - -1. `npx astro sync` — schema/types regenerate without `deepDives`. -2. `npx tsc --noEmit -p tsconfig.json --pretty false` — no dangling - `CollectionEntry<'deepDives'>` types. -3. `npm run lint:code` and `npm run check`. -4. `npm run test:unit` — Switcher/alias specs gone; updated fixtures pass. -5. `npm run build` — confirm: no `/deep-dive/` pages emitted; `/articles/` - pages present; sitemap includes `/articles/*` and excludes nothing article-related; - `rss.xml` links to `/articles/*`. -6. `npm run test:e2e` (at least `@smoke` + the updated specs). -7. Manual spot-check: `npm run dev` → article page renders long-form content at - `/articles/`, no Switcher in breadcrumb row, related-content carousel links - to `/articles/*`, `/deep-dive/` 301s to `/articles/`. -8. Optionally regenerate one PDF: start preview server, then - `node scripts/generate-pdfs/index.mjs `. - -## Post-deploy SEO / ops - -1. Verify the Vercel 301 redirect live: - `curl -I https://www.webstackbuilders.com/deep-dive/` → `301` to - `/articles/...`. -2. Run the one-time full Upstash reindex (see step 8) to purge `/deep-dive/*` search - documents. -3. In Google Search Console: submit the updated sitemap, use URL Inspection on a few - `/deep-dive/*` URLs to confirm the redirect is picked up, and monitor coverage for - the consolidated `/articles/*` URLs. diff --git a/astro.config.ts b/astro.config.ts index ba366f66c..7386ac26b 100644 --- a/astro.config.ts +++ b/astro.config.ts @@ -188,6 +188,35 @@ export default defineConfig({ */ include: ['lit', 'lit/directives/if-defined.js'], }, + ssr: { + /** + * sanitize-html (CommonJS) loads its HTML parser with require(), but its + * dependency chain (htmlparser2 v10+, dom-serializer v3, domhandler v6, + * domutils v4, domelementtype v3, entities v8) is ESM-only. Vercel's + * serverless module loader cannot require() ES modules, which crashed + * POST /_actions/webmentions.list with ERR_REQUIRE_ESM in production. + * Bundle the sanitizer and its parser dependency chain into the ESM + * server output so no runtime require() of these packages occurs. + * + * Build-only: in the dev server, Vite's SSR module runner evaluates + * noExternal'd CommonJS packages as ESM, crashing the same action with + * "ReferenceError: require is not defined". When the chain is + * externalized in dev, Node 24 loads it natively (require of ESM is + * supported), so the bundling is only needed for build output. + */ + noExternal: + process.env['NODE_ENV'] === 'production' + ? [ + 'sanitize-html', + 'htmlparser2', + 'dom-serializer', + 'domelementtype', + 'domhandler', + 'domutils', + 'entities', + ] + : [], + }, /** * Astro 6 reads `environments.client.build.sourcemap` for client bundles * instead of the top-level `build.sourcemap` (which only affects server). diff --git a/package-lock.json b/package-lock.json index be6ec2fa1..4b404eeb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "@astrojs/preact": "^6.0.2", "@astrojs/rss": "4.0.19", "@astrojs/sitemap": "3.7.3", - "@astrojs/vercel": "^11.0.4", + "@astrojs/vercel": "^11.0.5", "@axe-core/playwright": "4.12.1", "@eslint-community/eslint-plugin-eslint-comments": "4.7.2", "@eslint/js": "10.0.1", @@ -37,7 +37,7 @@ "@semantic-ui/astro-lit": "5.3.0", "@sentry/astro": "10.69.0", "@sentry/browser": "10.69.0", - "@shikijs/transformers": "4.4.1", + "@shikijs/transformers": "4.4.2", "@tailwindcss/forms": "0.5.11", "@tailwindcss/typography": "0.5.20", "@tailwindcss/vite": "4.3.3", @@ -79,7 +79,7 @@ "@vitest/coverage-v8": "4.1.10", "@webcomponents/template-shadowroot": "0.2.1", "alex": "11.0.1", - "astro": "^7.1.6", + "astro": "^7.2.0", "astro-vtbot": "3.0.1", "baseline-browser-mapping": "2.11.12", "canvas-confetti": "1.9.4", @@ -100,7 +100,7 @@ "eslint-plugin-import-lite": "0.6.0", "eslint-plugin-jsdoc": "63.3.3", "eslint-plugin-security": "4.0.1", - "eslint-plugin-yml": "3.8.0", + "eslint-plugin-yml": "3.8.1", "focus-trap": "8.2.2", "globals": "17.9.0", "gsap": "3.15.0", @@ -112,7 +112,7 @@ "husky": "9.1.7", "install": "0.13.0", "is-whitespace-character": "2.0.1", - "isomorphic-git": "1.40.0", + "isomorphic-git": "1.40.3", "js-cookie": "3.0.8", "jsdom": "30.0.1", "libphonenumber-js": "1.13.10", @@ -124,12 +124,12 @@ "mjml": "5.4.0", "nanostores": "1.4.2", "node-html-parser": "9.0.1", - "nodemailer": "9.0.4", + "nodemailer": "9.0.5", "npm": "12.0.2", "nunjucks": "3.2.4", "pdf-lib": "1.17.1", "playwright-lighthouse": "4.0.0", - "postcss": "8.5.25", + "postcss": "8.5.26", "postcss-html": "2.0.0", "preact": "10.29.8", "prettier": "3.9.6", @@ -167,7 +167,7 @@ "sanitize-html": "2.17.6", "schema-dts": "2.0.0", "sharp": "0.35.3", - "shiki": "4.4.1", + "shiki": "4.4.2", "space-separated-tokens": "2.0.2", "stylelint": "17.14.1", "stylelint-config-standard": "40.0.0", @@ -187,8 +187,8 @@ "unist-util-is": "6.0.1", "unist-util-visit": "5.1.0", "uuid": "14.0.1", - "vercel": "58.5.1", - "vite": "8.2.0", + "vercel": "58.7.1", + "vite": "8.2.1", "vite-plugin-pwa": "1.3.0", "vitest": "4.1.10", "vitest-axe": "0.1.0", @@ -197,7 +197,7 @@ "zod": "4.4.3" }, "engines": { - "node": ">=24.0.0 <25.0.0", + "node": "24.x", "npm": ">=10.0.0" }, "optionalDependencies": { @@ -808,9 +808,9 @@ } }, "node_modules/@astrojs/vercel": { - "version": "11.0.4", - "resolved": "https://registry.npmjs.org/@astrojs/vercel/-/vercel-11.0.4.tgz", - "integrity": "sha512-8HXVXTdpjSCNrIRSY4gOMmLntYUgl+b6FsXSNPWGbvpWR/SAXRbkZR4FJOmjzeI7L4Z8teBsQLdxwdQsSnGtYg==", + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@astrojs/vercel/-/vercel-11.0.5.tgz", + "integrity": "sha512-cag6EfSclaXiya6R7TT52m+IechBXt+m57wtj1L0ijGJg8cToN3Tuhj9o+JBGk+IaH0wp1Eh/Qlc2NZKs/Q3HA==", "license": "MIT", "dependencies": { "@astrojs/internal-helpers": "0.10.2", @@ -7369,13 +7369,13 @@ } }, "node_modules/@shikijs/core": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.4.1.tgz", - "integrity": "sha512-VeR2CY6Nn9/WbisoYLOQZ7HZOnwTrpBuOw4wExjqLnBCi62BNWynBUO6K2uPIASPFJwAv7cX1fUu+LrPlSstcw==", + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.4.2.tgz", + "integrity": "sha512-StyzbAyxg2/tBGf78gwbBkGyeQ73lf8UiJArFaQhTQIDqQOCKPCQFanvrs4/Yv3Yfyc+ONInJM6K+FMIf+P+kA==", "license": "MIT", "dependencies": { - "@shikijs/primitive": "4.4.1", - "@shikijs/types": "4.4.1", + "@shikijs/primitive": "4.4.2", + "@shikijs/types": "4.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5", "hast-util-to-html": "^9.0.5" @@ -7385,12 +7385,12 @@ } }, "node_modules/@shikijs/engine-javascript": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.4.1.tgz", - "integrity": "sha512-6U4lJBh8LTvIkEVqRHv/rr3ruwtO6IweFQt1ME1ntHJMGHS+6N86vfYGO1o8c/DtOCTia2lfhdQBtBrps1sDfQ==", + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.4.2.tgz", + "integrity": "sha512-MnIkeqWdVPUWsxlx8gKLVCJFTsqrQJgpTPBPpQwaFeJ56lOnJxj5aN2LUFnfxUEcvOQuNocmbaMnVrCEln6rkw==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.4.1", + "@shikijs/types": "4.4.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" }, @@ -7399,12 +7399,12 @@ } }, "node_modules/@shikijs/engine-oniguruma": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.4.1.tgz", - "integrity": "sha512-p23RugMKss0r5DAtRJW1yAXUDl60JvhQYV20yuxei//26JyDSJefV3umyWzzwep2weblMnJGDYahuti6XkcMgA==", + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.4.2.tgz", + "integrity": "sha512-GLhowz1+jixjz+wiZ3wMnOn1jTxiFCGl2PkXufivbnwPHKuyw1AYqu5/hbWhZZ2oAb0NP05WUJhYeigY14drnw==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.4.1", + "@shikijs/types": "4.4.2", "@shikijs/vscode-textmate": "^10.0.2" }, "engines": { @@ -7412,24 +7412,24 @@ } }, "node_modules/@shikijs/langs": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.4.1.tgz", - "integrity": "sha512-xb2kCMloBCIraIy2fS5MW0t/BxVY3q2nDyQKBoeSeq6KNrQbShHetCFlw2n35fGIJ6t3+hXDLQogP5ir9O9bvA==", + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.4.2.tgz", + "integrity": "sha512-8DfeusD+Zdv/eYIDdXyJTUnSMHt+aAWjAOCXV20HNGAHRlInXpG8wh421v6B91WOm9TFwRLN+b/LG5F2NAIojg==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.4.1" + "@shikijs/types": "4.4.2" }, "engines": { "node": ">=20" } }, "node_modules/@shikijs/primitive": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.4.1.tgz", - "integrity": "sha512-ko2OfDoG89YuQ7xL5LtcQiWKb7NIv1Ephb7g48TVU198OzAMLC8lXVEwaJGHK4sUMYrfAGJDqYmNLOLiW/Kz8w==", + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.4.2.tgz", + "integrity": "sha512-l6fQQKsOMlz72n38fztmSgZ76MO6KSWuw8o+GJ+FhmqrpC9pIOJNQNXGgbb5yX2AwpzlEHwsaLPnk/8o4Fm+rA==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.4.1", + "@shikijs/types": "4.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" }, @@ -7438,34 +7438,34 @@ } }, "node_modules/@shikijs/themes": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.4.1.tgz", - "integrity": "sha512-wudOaoFro+/Zl9gQv2W1Ur5XlVduqvTuYLI483Xi0wgc1A+cy1hfB2r6ac6ufBgF+ID7KJEW7L41MHrzQ4wH+w==", + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.4.2.tgz", + "integrity": "sha512-H0CFoL07ddDC2Dd6EdrPYNkRhUR6YCkJlnuYFceYYUJJA5TIm2b5B33qqiDYryBExgbKMndFJPb2u1gTuqO37g==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.4.1" + "@shikijs/types": "4.4.2" }, "engines": { "node": ">=20" } }, "node_modules/@shikijs/transformers": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-4.4.1.tgz", - "integrity": "sha512-Sb9Eehas+5EhClpFgNuklwY3aWf354FLaKRCiAWmjdNbHAjoQUpv6WmSj+N19eTXO6GLIWh1dIOH9dxyauhVWw==", + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-4.4.2.tgz", + "integrity": "sha512-d81PJ9KkR1tVP95FH/9296HTtDo0mh76wv10u9T1YmsZq/UcXgt0OLdBszfUQ1i+umkRMCjDnFbFZU7/tCODTQ==", "license": "MIT", "dependencies": { - "@shikijs/core": "4.4.1", - "@shikijs/types": "4.4.1" + "@shikijs/core": "4.4.2", + "@shikijs/types": "4.4.2" }, "engines": { "node": ">=20" } }, "node_modules/@shikijs/types": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.4.1.tgz", - "integrity": "sha512-GOwCLQDHM5EjGUWNPrhzJbr6JP8V/Dx/CDVkWvbZ1Avw5JFnNUckrgbLmE07qtg4WlW7Q7QFndhjIkeU9XMPvw==", + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.4.2.tgz", + "integrity": "sha512-PFYitV4vpDr/iPCIhnHp+Q4ftic5N5VeNJ3KQ1O8gn3h2ar8qgwMAXF7tq4m1CWaMS60fV4VqF6vfnWH4F7vqQ==", "license": "MIT", "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", @@ -11087,9 +11087,9 @@ "license": "MIT" }, "node_modules/@vercel/python": { - "version": "6.55.1", - "resolved": "https://registry.npmjs.org/@vercel/python/-/python-6.55.1.tgz", - "integrity": "sha512-5IaUnwvmfeziC+jdT7z8JFSZBU+ItffZG+EPVpF+zkl/H7FdiFwiCqZoIacIWI7GYX415uQTV8HBi6c06SX2Lw==", + "version": "6.55.2", + "resolved": "https://registry.npmjs.org/@vercel/python/-/python-6.55.2.tgz", + "integrity": "sha512-cu1zgXkoZDMWPeUVJEaUzkm2ThvICl37EZD/EIfrVVNzAfo7ulVb0Hr/sUlz2G39CeQBkePf4zco/+DF8TDasw==", "license": "Apache-2.0", "dependencies": { "@vercel/python-analysis": "0.13.1" @@ -11407,6 +11407,58 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/@vercel/vc-native-darwin-arm64": { + "version": "58.7.1", + "resolved": "https://registry.npmjs.org/@vercel/vc-native-darwin-arm64/-/vc-native-darwin-arm64-58.7.1.tgz", + "integrity": "sha512-bNr+TPdR9lIWuPi74sQ1nKqxZcfNKzuP5w/lNZul2HiN8V4GgquwFU2RG/MhTjnFRP21HsZ4Vwg7oPLhCcPC1w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vercel/vc-native-darwin-x64": { + "version": "58.7.1", + "resolved": "https://registry.npmjs.org/@vercel/vc-native-darwin-x64/-/vc-native-darwin-x64-58.7.1.tgz", + "integrity": "sha512-03MeJZ4HXJlyYDWVABtPmfcrcBeFcDIj+DHUgfpdYJNVhamnO77NazwXSFifMbhtBdH85JfuPtY5zQLkK8w7FQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vercel/vc-native-linux-arm64": { + "version": "58.7.1", + "resolved": "https://registry.npmjs.org/@vercel/vc-native-linux-arm64/-/vc-native-linux-arm64-58.7.1.tgz", + "integrity": "sha512-4tFq+Qi0LZ1X6amhqr56OLfrx+m9qN1F15ShodFe5yguPT1aNqt+Z3WYN5VC/M3ue+rWYYA3qSHuG1729rW1ZA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vercel/vc-native-linux-x64": { + "version": "58.7.1", + "resolved": "https://registry.npmjs.org/@vercel/vc-native-linux-x64/-/vc-native-linux-x64-58.7.1.tgz", + "integrity": "sha512-fm5B5Le0nsHl/DFe3gAOmlBPDWXcv3TYdue1fiWRfzlLx5dST6+A2R6SXU97rexmJvu/9HEnYllCqFBAaSQhAQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@vitest/coverage-v8": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", @@ -13415,9 +13467,9 @@ } }, "node_modules/astro": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/astro/-/astro-7.1.6.tgz", - "integrity": "sha512-83x9rYbHazMaZkYrAFRVZXSQx2moFkz0F7cjTDUF3GWfS0a3p2vZXG1ZdhV86rStHApQCodBJW+XTD37xISIrQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/astro/-/astro-7.2.0.tgz", + "integrity": "sha512-lLTYzx3fOvCmtwD3JVBLQcbORbIOW1/j0R+3IvJx/XKwMGrk7mFnF0BYSOeRiNw1qHUR5mdA6+hRnyvyDfqrWQ==", "license": "MIT", "dependencies": { "@astrojs/compiler-rs": "^0.3.2", @@ -13427,7 +13479,6 @@ "@capsizecss/unpack": "^4.0.0", "@clack/prompts": "^1.1.0", "@oslojs/encoding": "^1.1.0", - "@rollup/pluginutils": "^5.3.0", "am-i-vibing": "^0.4.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", @@ -13898,20 +13949,6 @@ "node": "*" } }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -14580,44 +14617,6 @@ "node": ">=18" } }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "extraneous": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -17431,9 +17430,9 @@ } }, "node_modules/eslint-plugin-yml": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-yml/-/eslint-plugin-yml-3.8.0.tgz", - "integrity": "sha512-LC1mTQnjqhmmevGHsK+2daDe9FI87pZ9P2ey8vue8YSIqKxOYNWUsCMo/pW9o0ipT5KTkI4gktq/YSXk9NeFUQ==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-yml/-/eslint-plugin-yml-3.8.1.tgz", + "integrity": "sha512-E/70psRwxz5EJ8dBtzrFfqSiiInuSytbdtFCjueb/GcKjsWzPBfxfhtQePImMAPjHwMA/VDurO7DJwStDJ4wWg==", "license": "MIT", "dependencies": { "@eslint/core": "^1.0.1", @@ -20245,19 +20244,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/is-boolean-object": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", @@ -20911,9 +20897,9 @@ "license": "ISC" }, "node_modules/isomorphic-git": { - "version": "1.40.0", - "resolved": "https://registry.npmjs.org/isomorphic-git/-/isomorphic-git-1.40.0.tgz", - "integrity": "sha512-/CbnxwZqIm17y3c/z0INbkgEKSvFerXtO/NGgaRxZ8nvL3eoMtbjuAS7f4Pj7lZzj8HaultvDD1ClJTBVDl89g==", + "version": "1.40.3", + "resolved": "https://registry.npmjs.org/isomorphic-git/-/isomorphic-git-1.40.3.tgz", + "integrity": "sha512-mTcErfnr2zunpHFzB7DubOmLK2vDV4E+b7AeNgNQYdnRga6fdfhKDhAsRnN1E9BsQN4nZqYVtRodyUHqy5jzCg==", "license": "MIT", "dependencies": { "async-lock": "^1.4.1", @@ -26004,9 +25990,9 @@ } }, "node_modules/nodemailer": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.4.tgz", - "integrity": "sha512-LmJNRVRtfSCULxcZpy0Cpg4WWenlUZ9+zbmTO+S7v9wD6XreYLjXRFtDjtV/4F0HT5p1GyZfA0Ux/myxHb18CQ==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz", + "integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -29029,9 +29015,9 @@ "license": "MIT-0" }, "node_modules/postcss": { - "version": "8.5.25", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", - "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "funding": [ { "type": "opencollective", @@ -29048,7 +29034,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -30467,32 +30453,6 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "extraneous": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/recma-build-jsx": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/recma-build-jsx/-/recma-build-jsx-1.0.0.tgz", @@ -33776,17 +33736,17 @@ } }, "node_modules/shiki": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.4.1.tgz", - "integrity": "sha512-rFP+iYKzjLEIqiMiKANhARqiAbk4deDhWnBtnUO/K0D0dPxMGDH4N0FVfBY/VeI+lPrV4wNGCHQZp7EOr7NNBw==", + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.4.2.tgz", + "integrity": "sha512-P8F/dFhRevaw2uSdeIYlq/5SXZNY85DPtmXQ947gD1Zj2JqO5AkNvVVBar0Me9JkFx3uzVud/qOtP5ek9NEQGA==", "license": "MIT", "dependencies": { - "@shikijs/core": "4.4.1", - "@shikijs/engine-javascript": "4.4.1", - "@shikijs/engine-oniguruma": "4.4.1", - "@shikijs/langs": "4.4.1", - "@shikijs/themes": "4.4.1", - "@shikijs/types": "4.4.1", + "@shikijs/core": "4.4.2", + "@shikijs/engine-javascript": "4.4.2", + "@shikijs/engine-oniguruma": "4.4.2", + "@shikijs/langs": "4.4.2", + "@shikijs/themes": "4.4.2", + "@shikijs/types": "4.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" }, @@ -37613,9 +37573,9 @@ } }, "node_modules/vercel": { - "version": "58.5.1", - "resolved": "https://registry.npmjs.org/vercel/-/vercel-58.5.1.tgz", - "integrity": "sha512-1BWBLm62nohVZ8D8c5qbp3qY/tAQQwE3HAvSM/B1Ez+OHLKdgS2G/nsPZEFLTc7x92ma2v4uAEe4L0OqMy/LFw==", + "version": "58.7.1", + "resolved": "https://registry.npmjs.org/vercel/-/vercel-58.7.1.tgz", + "integrity": "sha512-m0ydcubquja7FM2xwQxKSIpPtlY8loAfb6n/uaWywSvxNYVcbZVayylzxorB0YPEF7FVXujWC8QnS/cJlCGhJw==", "license": "Apache-2.0", "dependencies": { "@vercel/backends": "0.8.32", @@ -37638,7 +37598,7 @@ "@vercel/next": "4.21.1", "@vercel/node": "5.9.5", "@vercel/prepare-flags-definitions": "0.3.0", - "@vercel/python": "6.55.1", + "@vercel/python": "6.55.2", "@vercel/redwood": "2.5.1", "@vercel/remix-builder": "5.9.2", "@vercel/ruby": "2.5.1", @@ -37660,6 +37620,12 @@ }, "engines": { "node": ">= 18" + }, + "optionalDependencies": { + "@vercel/vc-native-darwin-arm64": "58.7.1", + "@vercel/vc-native-darwin-x64": "58.7.1", + "@vercel/vc-native-linux-arm64": "58.7.1", + "@vercel/vc-native-linux-x64": "58.7.1" } }, "node_modules/vercel/node_modules/@esbuild/aix-ppc64": { @@ -38566,15 +38532,15 @@ } }, "node_modules/vite": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", - "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", - "postcss": "^8.5.23", - "rolldown": "~1.2.0", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "bin": { diff --git a/package.json b/package.json index 4af4793c6..d9ce3ae69 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "@astrojs/preact": "^6.0.2", "@astrojs/rss": "4.0.19", "@astrojs/sitemap": "3.7.3", - "@astrojs/vercel": "^11.0.4", + "@astrojs/vercel": "^11.0.5", "@axe-core/playwright": "4.12.1", "@eslint-community/eslint-plugin-eslint-comments": "4.7.2", "@eslint/js": "10.0.1", @@ -99,7 +99,7 @@ "@semantic-ui/astro-lit": "5.3.0", "@sentry/astro": "10.69.0", "@sentry/browser": "10.69.0", - "@shikijs/transformers": "4.4.1", + "@shikijs/transformers": "4.4.2", "@tailwindcss/forms": "0.5.11", "@tailwindcss/typography": "0.5.20", "@tailwindcss/vite": "4.3.3", @@ -141,7 +141,7 @@ "@vitest/coverage-v8": "4.1.10", "@webcomponents/template-shadowroot": "0.2.1", "alex": "11.0.1", - "astro": "^7.1.6", + "astro": "^7.2.0", "astro-vtbot": "3.0.1", "baseline-browser-mapping": "2.11.12", "canvas-confetti": "1.9.4", @@ -162,7 +162,7 @@ "eslint-plugin-import-lite": "0.6.0", "eslint-plugin-jsdoc": "63.3.3", "eslint-plugin-security": "4.0.1", - "eslint-plugin-yml": "3.8.0", + "eslint-plugin-yml": "3.8.1", "focus-trap": "8.2.2", "globals": "17.9.0", "gsap": "3.15.0", @@ -174,7 +174,7 @@ "husky": "9.1.7", "install": "0.13.0", "is-whitespace-character": "2.0.1", - "isomorphic-git": "1.40.0", + "isomorphic-git": "1.40.3", "js-cookie": "3.0.8", "jsdom": "30.0.1", "libphonenumber-js": "1.13.10", @@ -186,12 +186,12 @@ "mjml": "5.4.0", "nanostores": "1.4.2", "node-html-parser": "9.0.1", - "nodemailer": "9.0.4", + "nodemailer": "9.0.5", "npm": "12.0.2", "nunjucks": "3.2.4", "pdf-lib": "1.17.1", "playwright-lighthouse": "4.0.0", - "postcss": "8.5.25", + "postcss": "8.5.26", "postcss-html": "2.0.0", "preact": "10.29.8", "prettier": "3.9.6", @@ -229,7 +229,7 @@ "sanitize-html": "2.17.6", "schema-dts": "2.0.0", "sharp": "0.35.3", - "shiki": "4.4.1", + "shiki": "4.4.2", "space-separated-tokens": "2.0.2", "stylelint": "17.14.1", "stylelint-config-standard": "40.0.0", @@ -249,8 +249,8 @@ "unist-util-is": "6.0.1", "unist-util-visit": "5.1.0", "uuid": "14.0.1", - "vercel": "58.5.1", - "vite": "8.2.0", + "vercel": "58.7.1", + "vite": "8.2.1", "vite-plugin-pwa": "1.3.0", "vitest": "4.1.10", "vitest-axe": "0.1.0", diff --git a/requirements.txt b/requirements.txt index 3f15f6d53..2c7ffcd3e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,11 @@ actions-toolkit==0.1.15 libsql-client==0.3.1 PyGithub==2.9.1 -pylint==4.0.5 +pylint==4.0.6 pytest-plus==0.8.1 pytest-sugar==1.1.1 pytest-xdist==3.8.0 -pytest==9.0.3 +pytest==9.1.1 python-dotenv==1.2.2 PyYAML==6.0.3 requests==2.34.2 diff --git a/src/components/WebMentions/client/__tests__/index.spec.ts b/src/components/WebMentions/client/__tests__/index.spec.ts index 1d4371d78..3398d3a41 100644 --- a/src/components/WebMentions/client/__tests__/index.spec.ts +++ b/src/components/WebMentions/client/__tests__/index.spec.ts @@ -225,4 +225,33 @@ describe('WebMentions web component', () => { { url: 'https://example.com/forbidden-thrown-post' } ) }) + + test('reports empty action error messages with descriptive fallback details', async () => { + /** Mirrors the ActionError Astro returns for a 500 with a non-JSON body. */ + const emptyActionError = Object.assign(new Error(''), { + code: 'INTERNAL_SERVER_ERROR', + status: 500, + }) + webmentionsListMock.mockResolvedValue({ + data: undefined, + error: emptyActionError, + }) + + await runComponentRender( + async ({ element }) => { + await flushMicrotasks() + await element.updateComplete + + expect(element.querySelector('#webmentions')).toBeNull() + expect(handleScriptErrorMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Failed to load WebMentions data. (INTERNAL_SERVER_ERROR, HTTP status 500)', + cause: emptyActionError, + }), + { scriptName: 'WebMentionsElement', operation: 'load' } + ) + }, + { url: 'https://example.com/empty-message-post' } + ) + }) }) diff --git a/src/components/WebMentions/client/index.ts b/src/components/WebMentions/client/index.ts index e459161bd..1ffe730de 100644 --- a/src/components/WebMentions/client/index.ts +++ b/src/components/WebMentions/client/index.ts @@ -6,6 +6,7 @@ import { handleScriptError } from '@components/scripts/errors/handler' import { isForbiddenClientActionError, normalizeClientActionError, + type ClientActionError, } from '@components/scripts/errors/actionClient' import type { WebComponentModule } from '@components/scripts/@types/webComponentModule' import type { WebmentionDisplayItem, WebmentionsListResult } from '@actions/webmentions/@types' @@ -21,6 +22,28 @@ const emptyWebmentionsResult: WebmentionsListResult = { repostsCount: 0, } +/** + * Astro's action client surfaces unexpected server failures (such as an HTTP 500 + * with a non-JSON body) as an ActionError with an empty message, which Sentry + * would record as "Unknown error". Build a descriptive error from the normalized + * action error so handled reports stay diagnosable, keeping the original as cause. + */ +const toLoadReportError = (error: unknown, actionError?: ClientActionError): unknown => { + if (error instanceof Error && error.message.trim().length > 0) { + return error + } + + const details = [ + actionError?.code, + actionError?.status ? `HTTP status ${actionError.status}` : undefined, + ] + .filter(Boolean) + .join(', ') + const message = `Failed to load WebMentions data.${details ? ` (${details})` : ''}` + + return new Error(message, error instanceof Error ? { cause: error } : undefined) +} + const formatDate = (dateString: string): string => { const date = new Date(dateString) return new Intl.DateTimeFormat('en-US', { @@ -126,7 +149,7 @@ export class WebMentionsElement extends LitElement { return } - handleScriptError(error ?? new Error('Failed to load WebMentions data.'), { + handleScriptError(toLoadReportError(error, actionError), { scriptName, operation: 'load', }) @@ -150,7 +173,7 @@ export class WebMentionsElement extends LitElement { return } - handleScriptError(error, { scriptName, operation: 'load' }) + handleScriptError(toLoadReportError(error, actionError), { scriptName, operation: 'load' }) if (this.lastLoadedUrl === normalizedUrl) { this.lastLoadedUrl = '' diff --git a/src/components/scripts/errors/__tests__/actionClient.spec.ts b/src/components/scripts/errors/__tests__/actionClient.spec.ts new file mode 100644 index 000000000..9aeae59a9 --- /dev/null +++ b/src/components/scripts/errors/__tests__/actionClient.spec.ts @@ -0,0 +1,77 @@ +/** + * Tests for client-side Astro action error normalization + */ +import { describe, expect, test } from 'vitest' +import { + isForbiddenClientActionError, + normalizeClientActionError, +} from '@components/scripts/errors/actionClient' + +describe('normalizeClientActionError', () => { + test('returns undefined for empty values', () => { + expect(normalizeClientActionError(undefined)).toBeUndefined() + expect(normalizeClientActionError(null)).toBeUndefined() + }) + + test('normalizes string errors and extracts status codes from the message', () => { + expect(normalizeClientActionError('HTTP Client Error with status code: 500')).toEqual({ + message: 'HTTP Client Error with status code: 500', + status: 500, + }) + }) + + test('reads code and status from Error instances like Astro ActionError', () => { + const actionError = Object.assign(new Error(''), { + code: 'INTERNAL_SERVER_ERROR', + status: 500, + }) + + expect(normalizeClientActionError(actionError)).toEqual({ + code: 'INTERNAL_SERVER_ERROR', + message: '', + status: 500, + }) + }) + + test('falls back to code and status carried on the error cause', () => { + const cause = { code: 'FORBIDDEN', status: 403 } + const error = new Error('Forbidden', { cause }) + + expect(normalizeClientActionError(error)).toEqual({ + code: 'FORBIDDEN', + message: 'Forbidden', + status: 403, + }) + }) + + test('extracts the status code from the message when no metadata is present', () => { + expect( + normalizeClientActionError(new Error('HTTP Client Error with status code: 503')) + ).toEqual({ + message: 'HTTP Client Error with status code: 503', + status: 503, + }) + }) + + test('normalizes plain error objects including nested cause metadata', () => { + expect( + normalizeClientActionError({ + message: 'Request failed', + cause: { code: 'BAD_REQUEST', statusCode: 400 }, + }) + ).toEqual({ + code: 'BAD_REQUEST', + message: 'Request failed', + status: 400, + }) + }) +}) + +describe('isForbiddenClientActionError', () => { + test('detects forbidden errors by code or status', () => { + expect(isForbiddenClientActionError({ code: 'FORBIDDEN' })).toBe(true) + expect(isForbiddenClientActionError({ status: 403 })).toBe(true) + expect(isForbiddenClientActionError({ status: 500 })).toBe(false) + expect(isForbiddenClientActionError(undefined)).toBe(false) + }) +}) diff --git a/src/components/scripts/errors/actionClient.ts b/src/components/scripts/errors/actionClient.ts index c2641cb41..a4ecb8665 100644 --- a/src/components/scripts/errors/actionClient.ts +++ b/src/components/scripts/errors/actionClient.ts @@ -59,14 +59,26 @@ export const normalizeClientActionError = (value: unknown): ClientActionError | } if (value instanceof Error) { - const errorRecord = getErrorRecord(value.cause) + /** + * Astro ActionError carries code/status on the error instance itself, + * while wrapped errors may carry them on the cause. Check both. + */ + const errorRecord = getErrorRecord(value) + const causeRecord = getErrorRecord(value.cause) return createClientActionError({ - code: typeof errorRecord?.['code'] === 'string' ? errorRecord['code'] : undefined, + code: + typeof errorRecord?.['code'] === 'string' + ? errorRecord['code'] + : typeof causeRecord?.['code'] === 'string' + ? causeRecord['code'] + : undefined, message: value.message, status: parseStatusCode(errorRecord?.['status']) ?? parseStatusCode(errorRecord?.['statusCode']) ?? + parseStatusCode(causeRecord?.['status']) ?? + parseStatusCode(causeRecord?.['statusCode']) ?? parseStatusCodeFromMessage(value.message), }) } diff --git a/src/components/scripts/sentry/__tests__/helpers.spec.ts b/src/components/scripts/sentry/__tests__/helpers.spec.ts index 441c6ca17..e7aacc76a 100644 --- a/src/components/scripts/sentry/__tests__/helpers.spec.ts +++ b/src/components/scripts/sentry/__tests__/helpers.spec.ts @@ -190,14 +190,16 @@ const createMyDataRequestHttpErrorEvent = (): Parameters[0] -const createWebmentionsHttpErrorEvent = (): Parameters[0] => +const createWebmentionsHttpErrorEvent = ( + statusCode = 403 +): Parameters[0] => ({ type: 'error', request: { url: 'https://www.webstackbuilders.com/_actions/webmentions.list' }, exception: { values: [ { - value: 'HTTP Client Error with status code: 403', + value: `HTTP Client Error with status code: ${statusCode}`, mechanism: { type: 'auto.http.client.fetch', handled: false, @@ -416,6 +418,28 @@ describe('sentry helpers', () => { expect(result).toBeNull() }) + it('drops duplicate webmentions http client failures for server errors', () => { + isProdMock.mockReturnValue(true) + getConsentSnapshotMock.mockReturnValue({ analytics: true }) + + const event = createWebmentionsHttpErrorEvent(500) + + const result = beforeSendHandler(event, createHint()) + + expect(result).toBeNull() + }) + + it('keeps webmentions http client failures for unexpected client errors', () => { + isProdMock.mockReturnValue(true) + getConsentSnapshotMock.mockReturnValue({ analytics: true }) + + const event = createWebmentionsHttpErrorEvent(404) + + const result = beforeSendHandler(event, createHint()) + + expect(result).toBe(event) + }) + it('drops handled consent log retry errors', () => { isProdMock.mockReturnValue(true) getConsentSnapshotMock.mockReturnValue({ analytics: true }) diff --git a/src/components/scripts/sentry/helpers.ts b/src/components/scripts/sentry/helpers.ts index 59ca62ba7..8c1e2068f 100644 --- a/src/components/scripts/sentry/helpers.ts +++ b/src/components/scripts/sentry/helpers.ts @@ -166,7 +166,8 @@ const isHandledWebmentionsHttpError = (event: Parameters[0]): typeof requestUrl === 'string' && isWebmentionsActionRequest(requestUrl) && mechanismType === 'auto.http.client.fetch' && - statusCode === 403 + statusCode !== undefined && + (statusCode === 403 || statusCode >= 500) ) } @@ -291,9 +292,9 @@ export const beforeSendHandler: BeforeSendHandler = (event, _hint) => { return null } - // Webmentions are non-critical content enhancement. If the action is blocked - // with a 403, the component degrades to an empty state and the auto-fetch - // browser event becomes noise. + // Webmentions are a non-critical content enhancement. The component degrades + // to an empty state on 403s, and reports a handled ClientScriptError for + // server 5xx failures, so the duplicate auto-fetch browser events are noise. if (isHandledWebmentionsHttpError(event)) { return null } diff --git a/test/e2e/helpers/consoleErrors.ts b/test/e2e/helpers/consoleErrors.ts index 2b4f9a152..ca566915e 100644 --- a/test/e2e/helpers/consoleErrors.ts +++ b/test/e2e/helpers/consoleErrors.ts @@ -31,7 +31,7 @@ export function setupConsoleErrorChecker(page: Page): ConsoleErrorChecker { const failed404s: string[] = [] // Capture 404 responses with full details - page.on('response', (response) => { + page.on('response', response => { if (response.status() === 404) { const url = response.url() const requestType = response.request().resourceType() @@ -40,7 +40,7 @@ export function setupConsoleErrorChecker(page: Page): ConsoleErrorChecker { }) // Capture ALL console messages of type error - page.on('console', (msg) => { + page.on('console', msg => { if (msg.type() === 'error') { const text = msg.text() const location = msg.location() @@ -52,7 +52,7 @@ export function setupConsoleErrorChecker(page: Page): ConsoleErrorChecker { }) // Also capture page errors (uncaught exceptions) - page.on('pageerror', (error) => { + page.on('pageerror', error => { consoleErrors.push(`Uncaught: ${error.message}\n${error.stack}`) }) @@ -62,18 +62,27 @@ export function setupConsoleErrorChecker(page: Page): ConsoleErrorChecker { // Filter out ONLY known acceptable issues - computed lazily getFilteredErrors: () => { // Get list of acceptable 404 URLs (strip resource type suffix) - const acceptable404Urls = failed404s.filter((url) => { + const acceptable404Urls = failed404s.filter(url => { const urlWithoutType = url.split(' (')[0] return url.includes('favicon.ico') || urlWithoutType?.endsWith('/') }) - return consoleErrors.filter((error) => { + return consoleErrors.filter(error => { // Filter known browser quirks if (error.includes('ResizeObserver loop completed')) return false // Filter Vite dev server 504 errors (Outdated Optimize Dep) if (error.includes('504') && error.includes('Outdated Optimize Dep')) return false + // Filter Vite dev server HMR websocket noise: the Vite client only exists + // in dev, and a full reload (e.g. after dependency re-optimization) or a + // navigation mid-handshake closes the socket. Firefox surfaces that as an + // uncaught error while Chromium silently reconnects. + if (error.includes('[vite] failed to connect to websocket')) return false + if (error.includes('WebSocket closed without opened') && error.includes('/@vite/client')) { + return false + } + // Filter generic "Failed to load resource: 404" errors if we have acceptable 404s // These console errors don't include the URL, so if we filtered out 404s, // we should also filter out the corresponding console errors @@ -81,7 +90,7 @@ export function setupConsoleErrorChecker(page: Page): ConsoleErrorChecker { error.includes('Failed to load resource') && error.includes('404') && acceptable404Urls.length > 0 && - failed404s.every((url) => acceptable404Urls.includes(url)) + failed404s.every(url => acceptable404Urls.includes(url)) ) { return false } @@ -90,7 +99,7 @@ export function setupConsoleErrorChecker(page: Page): ConsoleErrorChecker { }) }, getFiltered404s: () => - failed404s.filter((url) => { + failed404s.filter(url => { // favicon 404s are acceptable in dev if (url.includes('favicon.ico')) return false @@ -117,11 +126,11 @@ export function logConsoleErrors(errorChecker: ConsoleErrorChecker): void { // Always log what we found for debugging if (errorChecker.failed404s.length > 0) { console.log('\n🔍 404 Resources:') - errorChecker.failed404s.forEach((url) => console.log(` - ${url}`)) + errorChecker.failed404s.forEach(url => console.log(` - ${url}`)) } if (errorChecker.consoleErrors.length > 0) { console.log('\n❌ Console Errors:') - errorChecker.consoleErrors.forEach((error) => { + errorChecker.consoleErrors.forEach(error => { // Log full error message for diagnosis console.log(` - ${error}`) })