From 28647e4760c38da30957f93a2cd11ecbf9f0f00c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Wed, 15 Jul 2026 19:12:11 -0300 Subject: [PATCH] Add Post and Tag collections for news and publications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Post + Tag collections (notícias/publicações) with tag-based "Esconder" visibility control for electoral-law compliance - Add static SEO routes /[type]/[category]/[slug] plus /[type] and /[type]/[category] listings, with shared header/breadcrumb and PostCard - Recreate the homepage and refresh the site color palette (styles.css) - Add posts/slug/seo utilities and revalidation wiring (documents.ts) - Add live-fetch seed script (scripts/seed-posts.mjs + seed-loader.mjs, db:seed:posts) - Upgrade Payload 3.79.0 -> 3.82.0 (fixes admin relationship-filter crash) - Remove obsolete Header component; add shadcn card/badge/breadcrumb; update AGENTS.md and components rule docs --- .cursor/rules/components.md | 2 + AGENTS.md | 1 + package.json | 15 +- pnpm-lock.yaml | 321 +- scripts/seed-loader.mjs | 38 + scripts/seed-posts.mjs | 561 +++ src/app/(frontend)/(home)/layout.tsx | 11 +- src/app/(frontend)/(home)/page.tsx | 84 +- .../[type]/[category]/[slug]/page.tsx | 262 ++ src/app/(frontend)/[type]/[category]/page.tsx | 103 + src/app/(frontend)/[type]/layout.tsx | 21 + src/app/(frontend)/[type]/page.tsx | 79 + src/app/(frontend)/styles.css | 31 +- src/collections/Post.ts | 112 + src/collections/Tag.ts | 79 + src/components/Header.tsx | 13 - src/components/PostCard.tsx | 51 + src/components/SiteHeader.tsx | 98 + src/components/ui/badge.tsx | 49 + src/components/ui/breadcrumb.tsx | 122 + src/components/ui/card.tsx | 103 + .../20260715_181058_add_post_and_tag.json | 4146 +++++++++++++++++ .../20260715_181058_add_post_and_tag.ts | 136 + ...5_215834_rename_tag_visible_to_hidden.json | 4146 +++++++++++++++++ ...715_215834_rename_tag_visible_to_hidden.ts | 24 + src/migrations/index.ts | 18 +- src/payload-types.ts | 108 +- src/payload.config.ts | 4 +- src/utilities/documents.ts | 19 +- src/utilities/posts.ts | 118 + src/utilities/seo.ts | 12 + src/utilities/slug.ts | 8 + tests/int/petition-page-layout.int.spec.ts | 2 +- 33 files changed, 10690 insertions(+), 207 deletions(-) create mode 100644 scripts/seed-loader.mjs create mode 100644 scripts/seed-posts.mjs create mode 100644 src/app/(frontend)/[type]/[category]/[slug]/page.tsx create mode 100644 src/app/(frontend)/[type]/[category]/page.tsx create mode 100644 src/app/(frontend)/[type]/layout.tsx create mode 100644 src/app/(frontend)/[type]/page.tsx create mode 100644 src/collections/Post.ts create mode 100644 src/collections/Tag.ts delete mode 100644 src/components/Header.tsx create mode 100644 src/components/PostCard.tsx create mode 100644 src/components/SiteHeader.tsx create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/breadcrumb.tsx create mode 100644 src/components/ui/card.tsx create mode 100644 src/migrations/20260715_181058_add_post_and_tag.json create mode 100644 src/migrations/20260715_181058_add_post_and_tag.ts create mode 100644 src/migrations/20260715_215834_rename_tag_visible_to_hidden.json create mode 100644 src/migrations/20260715_215834_rename_tag_visible_to_hidden.ts create mode 100644 src/utilities/posts.ts create mode 100644 src/utilities/seo.ts create mode 100644 src/utilities/slug.ts diff --git a/.cursor/rules/components.md b/.cursor/rules/components.md index 224a166c..1fd732b7 100644 --- a/.cursor/rules/components.md +++ b/.cursor/rules/components.md @@ -44,6 +44,8 @@ These conventions apply to all first-party components we author. The only except const [open, setOpen] = React.useState(false) ``` +3. **All code identifiers are in English.** This covers variable, function, parameter, type/interface names, local constants, and Next.js dynamic route segment folder names used as param keys (e.g. `[type]/[category]/[slug]`, not `[tipo]/[categoria]`). Portuguese is allowed ONLY in: user-visible string literals (JSX text, button/labels), image `alt` text, SEO/metadata values (title/description/keywords), Payload admin config text (`labels`/`singular`/`plural`/`admin.description`/field `label`), and intentional URL slug/enum VALUES kept in Portuguese for SEO (e.g. `post.type` values `noticia|campanha|artigo|evento`, tag/category slugs like `saude`). Never translate those data/URL values or admin labels. Renaming a route segment folder changes only the param KEY, never the public URL. + ## Component Types There are four main types of Custom Components: diff --git a/AGENTS.md b/AGENTS.md index 9e7fefc2..9572d44a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,7 @@ Teqo is the digital platform for deputado federal Jorge Solla (PT-BA): public si - Cache invalidation: collections/globals call `revalidateDocumentById` (`src/utilities/documents.ts`) / `revalidateGlobal` (`src/utilities/globals.ts`) in an `afterChange` hook. Add the same hook to any new collection/global that backs a public page. - Admin UI is organized via `admin.group` (e.g. `'Abaixo-assinados'`, `'Contatos'`, `'Configurações'`, `'Paginas'`) — group new collections consistently instead of leaving them ungrouped. - i18n: Payload admin defaults to `pt` (not `pt-BR` — see `payload.config.ts`, `payload/i18n/pt`). +- Language & naming conventions: ALL code identifiers are in English — variable, function, parameter, type/interface names, local constants, and Next.js dynamic route segment folder names used as param keys (e.g. `[type]/[category]/[slug]`, not `[tipo]/[categoria]`). Portuguese is allowed ONLY in: user-visible string literals (JSX text, button/labels), image `alt` text, SEO/metadata values (title/description/keywords), Payload admin config text (`labels`/`singular`/`plural`/`admin.description`/field `label`), and intentional URL slug/enum VALUES kept in Portuguese for SEO (e.g. the `post.type` enum values `noticia|campanha|artigo|evento`, tag/category slugs like `saude`, `eleitoral`). Never translate those data/URL values or admin labels. Renaming a route segment folder changes only the param KEY, never the public URL (segment values come from the data). **Database & local development (safety-critical — production is a live Neon Postgres with real citizens' PII):** diff --git a/package.json b/package.json index 05c7a79e..963d894c 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "db:start": "docker compose up -d postgres", "db:stop": "docker compose down", "db:pull": "node scripts/db-pull.mjs", + "db:seed:posts": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-posts.mjs", "dev": "node scripts/guard-dev-db.mjs && cross-env NODE_OPTIONS=--no-deprecation next dev", "devsafe": "rm -rf .next && node scripts/guard-dev-db.mjs && cross-env NODE_OPTIONS=--no-deprecation next dev", "generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap", @@ -43,12 +44,12 @@ "dependencies": { "@base-ui/react": "^1.6.0", "@hookform/resolvers": "^5.2.2", - "@payloadcms/db-postgres": "3.79.0", - "@payloadcms/live-preview-react": "3.79.0", - "@payloadcms/next": "3.79.0", - "@payloadcms/richtext-lexical": "3.79.0", - "@payloadcms/storage-vercel-blob": "3.79.0", - "@payloadcms/ui": "3.79.0", + "@payloadcms/db-postgres": "3.82.0", + "@payloadcms/live-preview-react": "3.82.0", + "@payloadcms/next": "3.82.0", + "@payloadcms/richtext-lexical": "3.82.0", + "@payloadcms/storage-vercel-blob": "3.82.0", + "@payloadcms/ui": "3.82.0", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", @@ -63,7 +64,7 @@ "lucide-react": "^0.570.0", "next": "15.4.11", "next-themes": "^0.4.6", - "payload": "3.79.0", + "payload": "3.82.0", "radix-ui": "^1.4.3", "react": "^19.2.4", "react-dom": "^19.2.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a87e1eb3..c2fc8457 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,23 +15,23 @@ importers: specifier: ^5.2.2 version: 5.2.2(react-hook-form@7.71.1(react@19.2.4)) '@payloadcms/db-postgres': - specifier: 3.79.0 - version: 3.79.0(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3)) + specifier: 3.82.0 + version: 3.82.0(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3)) '@payloadcms/live-preview-react': - specifier: 3.79.0 - version: 3.79.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 3.82.0 + version: 3.82.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@payloadcms/next': - specifier: 3.79.0 - version: 3.79.0(@types/react@19.2.14)(graphql@16.12.0)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3) + specifier: 3.82.0 + version: 3.82.0(@types/react@19.2.14)(graphql@16.12.0)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3) '@payloadcms/richtext-lexical': - specifier: 3.79.0 - version: 3.79.0(@faceless-ui/modal@3.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@faceless-ui/scroll-info@2.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@payloadcms/next@3.79.0(@types/react@19.2.14)(graphql@16.12.0)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3))(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)(yjs@13.6.29) + specifier: 3.82.0 + version: 3.82.0(@faceless-ui/modal@3.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@faceless-ui/scroll-info@2.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@payloadcms/next@3.82.0(@types/react@19.2.14)(graphql@16.12.0)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3))(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)(yjs@13.6.29) '@payloadcms/storage-vercel-blob': - specifier: 3.79.0 - version: 3.79.0(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3) + specifier: 3.82.0 + version: 3.82.0(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3) '@payloadcms/ui': - specifier: 3.79.0 - version: 3.79.0(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3) + specifier: 3.82.0 + version: 3.82.0(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3) '@radix-ui/react-label': specifier: ^2.1.8 version: 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -75,8 +75,8 @@ importers: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) payload: - specifier: 3.79.0 - version: 3.79.0(graphql@16.12.0)(typescript@5.7.3) + specifier: 3.82.0 + version: 3.82.0(graphql@16.12.0)(typescript@5.7.3) radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -504,11 +504,11 @@ packages: '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild-kit/esm-loader@2.6.5': resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} @@ -1019,10 +1019,6 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@fastify/busboy@2.1.1': - resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} - engines: {node: '>=14'} - '@floating-ui/core@1.7.4': resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} @@ -1624,73 +1620,73 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} - '@payloadcms/db-postgres@3.79.0': - resolution: {integrity: sha512-FvuKl/RSALjpXk4/f2pIEGm3jS9frndwtMei8XZtmY6VhwIwjfKFclZc2zNeUBZuQdXpLNRDXNvSr91Pw8+nQw==} + '@payloadcms/db-postgres@3.82.0': + resolution: {integrity: sha512-FzIRFpytQc8j6UjMpCYKN5Vz+sjw6EgOgsLzVtae6ihbJcHnEWeLXsV9ELd/r1349nNNqyFycRbJVpXbb+rhhw==} peerDependencies: - payload: 3.79.0 + payload: 3.82.0 - '@payloadcms/drizzle@3.79.0': - resolution: {integrity: sha512-rH34esN4W6JZi8r8tj6kwjdbkfZan1nQjr94TbxlPYPVI58R+2e8/yTw0cCchWPT6VAH4TNyk/Wvgq64UOqwkQ==} + '@payloadcms/drizzle@3.82.0': + resolution: {integrity: sha512-/LwsbBbMvrUXBzEnQ+jNwGWW/U1BiyYCsj5Y++rU3sraiA2nr8mNDXeDksRH/Um0aytkLbmJNEFcgcJwgucz7w==} peerDependencies: - payload: 3.79.0 + payload: 3.82.0 - '@payloadcms/graphql@3.79.0': - resolution: {integrity: sha512-q/fMw3JnUn+dMp7F4qsvoB5KfcdGjUVpttHI8+2o3+SbwQF2d26fHrz4D5Wz5Sm3ZTQg7XsCZzFKA3a9FL/sAg==} + '@payloadcms/graphql@3.82.0': + resolution: {integrity: sha512-RfDOOzPxqjiuOrKCBaqn2Dxiyc97uSv0DWZMQ9yvmPDmA9gTRafqkyg0BMerQxlopGGASLzfbmmdskNDuZBa8g==} hasBin: true peerDependencies: graphql: ^16.8.1 - payload: 3.79.0 + payload: 3.82.0 - '@payloadcms/live-preview-react@3.79.0': - resolution: {integrity: sha512-1JY/QzfIy6iOijpbdRdnV+TuK0B0vo8nTgH+WkfCWxayXf30FlykWW8sdBc44wn4CxmLry36cEeneqAbrasiWQ==} + '@payloadcms/live-preview-react@3.82.0': + resolution: {integrity: sha512-ibGpzaSd/fEuDiN0RsWzV29cYBZzIcEj/Hr56O19iDrE3NYvUwMWP0sIPa8nNH471soFXD0uUZ/yQGtvsX2hVA==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.1 || ^19.1.2 || ^19.2.1 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.1 || ^19.1.2 || ^19.2.1 - '@payloadcms/live-preview@3.79.0': - resolution: {integrity: sha512-Y2t6NACgeC4gZV/mwzD1L+OhJHematK0RQ8xK4P/m+8fhO+sgS4gtjpA/m6yqAkhruRTni5izZJK6SWTB9pXPw==} + '@payloadcms/live-preview@3.82.0': + resolution: {integrity: sha512-RJoc+uWY0sBz9fHwKGxd6znrIW/IzDepNAjHtHeJ0zR5KYZUm6H8AeWkGzWZRpzpe7Rkq50y4/REl2vdVGMMaQ==} - '@payloadcms/next@3.79.0': - resolution: {integrity: sha512-I3AZrITgQ6URD0onOHpLiOQeMAQ4mtCIBkUTkoc7n0EScwr4rllprjMTfZnZWSQ0kgCbbPYzxuNFTU3wzf/rsQ==} + '@payloadcms/next@3.82.0': + resolution: {integrity: sha512-5GdFrwok6VdqX5IefTO0bkmhSyLeLm52b4guYsKC7LbRbRsUJay3BebreNifBkzUGYrEi47OgBjLjA7ln7lo9A==} engines: {node: ^18.20.2 || >=20.9.0} peerDependencies: graphql: ^16.8.1 - next: '>=15.2.9 <15.3.0 || >=15.3.9 <15.4.0 || >=15.4.11 <15.5.0 || >=16.2.0-canary.10 <17.0.0' - payload: 3.79.0 + next: '>=15.2.9 <15.3.0 || >=15.3.9 <15.4.0 || >=15.4.11 <15.5.0 || >=16.2.2 <17.0.0' + payload: 3.82.0 - '@payloadcms/plugin-cloud-storage@3.79.0': - resolution: {integrity: sha512-WgQgiOamL5xJblLCqHTqspc+nl2lug8TxYeQ0VtL7ostjHwuSHJHj3lRuV5Rw3cvtW2HcMVTUqiUhBIRMEM0BA==} + '@payloadcms/plugin-cloud-storage@3.82.0': + resolution: {integrity: sha512-lWFQlMBMuc1v7Edu7oVgBKRh+Q2OfRVI5fcaYWyuXXBwog2mPfSavIT38bGCMosZZ2YlM129M5vGJseQtI8BQw==} peerDependencies: - payload: 3.79.0 + payload: 3.82.0 react: ^19.0.1 || ^19.1.2 || ^19.2.1 react-dom: ^19.0.1 || ^19.1.2 || ^19.2.1 - '@payloadcms/richtext-lexical@3.79.0': - resolution: {integrity: sha512-SPoF44Lf3SwuQtWn51VsqHHSteIoymeANF+T+iGk6g9ji2QoVMWaYu8g6lp8TjNtGd0oLvYhAuTBnKXMIpCavA==} + '@payloadcms/richtext-lexical@3.82.0': + resolution: {integrity: sha512-hdLeG7C1fXe7xUJcYf2Nwhq1OzMl7bt5qCqp6ynnXFuFlCJwUJA0lcCxNeJWCtgABFC7BiSXGnCvIYrk1DKN1Q==} engines: {node: ^18.20.2 || >=20.9.0} peerDependencies: '@faceless-ui/modal': 3.0.0 '@faceless-ui/scroll-info': 2.0.0 - '@payloadcms/next': 3.79.0 - payload: 3.79.0 + '@payloadcms/next': 3.82.0 + payload: 3.82.0 react: ^19.0.1 || ^19.1.2 || ^19.2.1 react-dom: ^19.0.1 || ^19.1.2 || ^19.2.1 - '@payloadcms/storage-vercel-blob@3.79.0': - resolution: {integrity: sha512-kgpigI+LCWkZNN00q7iVpm2p4I7c8oDsLNjnXaS298ZwM0OwgT+kqEZHv7cu7kT05K6hrefEd90qeEO0B/8Zww==} + '@payloadcms/storage-vercel-blob@3.82.0': + resolution: {integrity: sha512-Ey8MvW8ZKl54wtCTh6CmoLU/Ov9oRA4NSICzEuxNrmSJfEnyllxG6GW2G0sx/0Ta7eO2xCWeGWTTnK17u5HHKw==} engines: {node: ^18.20.2 || >=20.9.0} peerDependencies: - payload: 3.79.0 + payload: 3.82.0 - '@payloadcms/translations@3.79.0': - resolution: {integrity: sha512-xDJ5tjTDQIYebZ3HKZikktsBiWAqk80sN0EY50L990oWtW3hSVj6ervE39wlbK9prNuOiIhSS8NGV4nVYDxspg==} + '@payloadcms/translations@3.82.0': + resolution: {integrity: sha512-6mgFZE1sKGQg/dwU3wX+Yz7HLHaOcBixJ9vMQ+LlV+LPIk60fsU7LFAcbcUaCektv2/X4LTt05VFtKK0D2zufA==} - '@payloadcms/ui@3.79.0': - resolution: {integrity: sha512-wHU9zZkA9/Epyt/P8WpFv9jDY10fKUv04/ckGxVqguSEVhr1ptuZVTY3wr6TX4XU0vU2wg5Riix4IDKi6+AEEQ==} + '@payloadcms/ui@3.82.0': + resolution: {integrity: sha512-TzcYmZAvx7ILfaa7EkdybmZ0Pl0YO1wYlH9klkWjRD8wTVQTVGnR9GEE1YTgiK6hM+3q/ERjst9ZR4Hr0oBPDw==} engines: {node: ^18.20.2 || >=20.9.0} peerDependencies: - next: '>=15.2.9 <15.3.0 || >=15.3.9 <15.4.0 || >=15.4.11 <15.5.0 || >=16.2.0-canary.10 <17.0.0' - payload: 3.79.0 + next: '>=15.2.9 <15.3.0 || >=15.3.9 <15.4.0 || >=15.4.11 <15.5.0 || >=16.2.2 <17.0.0' + payload: 3.82.0 react: ^19.0.1 || ^19.1.2 || ^19.2.1 react-dom: ^19.0.1 || ^19.1.2 || ^19.2.1 @@ -2704,6 +2700,10 @@ packages: '@types/react-dom': optional: true + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} @@ -3001,9 +3001,9 @@ packages: vue-router: optional: true - '@vercel/blob@0.22.3': - resolution: {integrity: sha512-l0t2KhbOO/I8ZNOl9zypYf1NE0837aO4/CPQNGR/RAxtj8FpdYKjhyUADUXj2gERLQmnhun+teaVs/G7vZJ/TQ==} - engines: {node: '>=16.14'} + '@vercel/blob@2.3.1': + resolution: {integrity: sha512-6f9oWC+DbWxIgBLOdqjjn2/REpFrPDB7y5B5HA1ptYkzZaBgL6E34kWrptJvJ7teApJdbAs3I1a5A7z1y8SDHw==} + engines: {node: '>=20.0.0'} '@vercel/speed-insights@1.3.1': resolution: {integrity: sha512-PbEr7FrMkUrGYvlcLHGkXdCkxnylCWePx7lPxxq36DNdfo9mcUjLOmqOyPDHAOgnfqgGGdmE3XI9L/4+5fr+vQ==} @@ -3103,6 +3103,9 @@ packages: ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -4049,9 +4052,9 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} - file-type@19.3.0: - resolution: {integrity: sha512-mROwiKLZf/Kwa/2Rol+OOZQn1eyTkPB3ZTwC0ExY6OLFCbgxHYZvBm7xI77NvfZFMKBsmuXfmLJnD4eEftEhrA==} - engines: {node: '>=18'} + file-type@21.3.4: + resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} + engines: {node: '>=20'} fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} @@ -4583,8 +4586,8 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true - jose@5.9.6: - resolution: {integrity: sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==} + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} jose@6.2.1: resolution: {integrity: sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==} @@ -5203,17 +5206,13 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - payload@3.79.0: - resolution: {integrity: sha512-Pey2gBhFL5QkAmN2KMkzXdiBS4QOi5IiQF4Ji9hCNu7kaDcNYtgk75EeWRGP4hOcb22+dqYnw2TZm5Zs/0KBKw==} + payload@3.82.0: + resolution: {integrity: sha512-wUPyi39vxdMUAil/O8egIOAqOjSB8bTEbpWassftUkfiwYN9KufegO0YNOcIPk28fjZizzDZHzrPJaTcV3jTRQ==} engines: {node: ^18.20.2 || >=20.9.0} hasBin: true peerDependencies: graphql: ^16.8.1 - peek-readable@5.4.2: - resolution: {integrity: sha512-peBp3qZyuS6cNIJ2akRNG1uo1WJ1d0wTxg/fxMdZ0BqCVhx242bSFHM9eNqflfJVS9SsgkzgT/1UgnsurBOTMg==} - engines: {node: '>=14.16'} - pg-cloudflare@1.3.0: resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} @@ -5396,8 +5395,8 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - qs-esm@7.0.2: - resolution: {integrity: sha512-D8NAthKSD7SGn748v+GLaaO6k08Mvpoqroa35PqIQC4gtUa8/Pb/k+r0m0NnGBVbHDP1gKZ2nVywqfMisRhV5A==} + qs-esm@8.0.1: + resolution: {integrity: sha512-eZ7l+0ZVy3He9c85pEM9KEBR9DFA4jrmWvIjm9wpcHvScwc/vgZDl2TNOF0pm0JsWKw24XBUZOY0Wxn7/nvJnw==} engines: {node: '>=18'} qs@6.15.0: @@ -5862,9 +5861,9 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} - strtok3@8.1.0: - resolution: {integrity: sha512-ExzDvHYPj6F6QkSNe/JxSlBxTh3OrI6wrAIz53ulxo1c4hBJ1bT9C/JrAthEKHWG9riVH3Xzg7B03Oxty6S2Lw==} - engines: {node: '>=16'} + strtok3@10.3.5: + resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} + engines: {node: '>=18'} styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} @@ -5918,6 +5917,10 @@ packages: thread-stream@3.1.0: resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} + throttleit@2.1.0: + resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==} + engines: {node: '>=18'} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -6071,18 +6074,18 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici@5.29.0: - resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} - engines: {node: '>=14.0'} - - undici@7.18.2: - resolution: {integrity: sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==} - engines: {node: '>=20.18.1'} + undici@6.27.0: + resolution: {integrity: sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==} + engines: {node: '>=18.17'} undici@7.21.0: resolution: {integrity: sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==} engines: {node: '>=20.18.1'} + undici@7.24.4: + resolution: {integrity: sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==} + engines: {node: '>=20.18.1'} + unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} @@ -6446,7 +6449,7 @@ snapshots: dependencies: '@jsdevtools/ono': 7.1.3 '@types/json-schema': 7.0.15 - js-yaml: 4.1.1 + js-yaml: 4.3.0 '@asamuzakjp/css-color@4.1.2': dependencies: @@ -6786,7 +6789,7 @@ snapshots: '@emotion/babel-plugin@11.13.5': dependencies: '@babel/helper-module-imports': 7.28.6 - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.7 '@emotion/hash': 0.9.2 '@emotion/memoize': 0.9.0 '@emotion/serialize': 1.3.3 @@ -6813,7 +6816,7 @@ snapshots: '@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.7 '@emotion/babel-plugin': 11.13.5 '@emotion/cache': 11.14.0 '@emotion/serialize': 1.3.3 @@ -7147,8 +7150,6 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@fastify/busboy@2.1.1': {} - '@floating-ui/core@1.7.4': dependencies: '@floating-ui/utils': 0.2.10 @@ -7181,8 +7182,8 @@ snapshots: '@floating-ui/react@0.27.17(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@floating-ui/react-dom': 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@floating-ui/utils': 0.2.10 + '@floating-ui/react-dom': 2.1.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@floating-ui/utils': 0.2.12 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) tabbable: 6.4.0 @@ -7717,14 +7718,14 @@ snapshots: '@open-draft/until@2.1.0': {} - '@payloadcms/db-postgres@3.79.0(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))': + '@payloadcms/db-postgres@3.82.0(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))': dependencies: - '@payloadcms/drizzle': 3.79.0(@types/pg@8.10.2)(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(pg@8.16.3) + '@payloadcms/drizzle': 3.82.0(@types/pg@8.10.2)(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(pg@8.16.3) '@types/pg': 8.10.2 console-table-printer: 2.12.1 drizzle-kit: 0.31.7 drizzle-orm: 0.44.7(@types/pg@8.10.2)(pg@8.16.3) - payload: 3.79.0(graphql@16.12.0)(typescript@5.7.3) + payload: 3.82.0(graphql@16.12.0)(typescript@5.7.3) pg: 8.16.3 prompts: 2.4.2 to-snake-case: 1.0.0 @@ -7760,12 +7761,12 @@ snapshots: - sqlite3 - supports-color - '@payloadcms/drizzle@3.79.0(@types/pg@8.10.2)(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(pg@8.16.3)': + '@payloadcms/drizzle@3.82.0(@types/pg@8.10.2)(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(pg@8.16.3)': dependencies: console-table-printer: 2.12.1 dequal: 2.0.3 drizzle-orm: 0.44.7(@types/pg@8.10.2)(pg@8.16.3) - payload: 3.79.0(graphql@16.12.0)(typescript@5.7.3) + payload: 3.82.0(graphql@16.12.0)(typescript@5.7.3) prompts: 2.4.2 to-snake-case: 1.0.0 uuid: 9.0.0 @@ -7800,44 +7801,44 @@ snapshots: - sql.js - sqlite3 - '@payloadcms/graphql@3.79.0(graphql@16.12.0)(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(typescript@5.7.3)': + '@payloadcms/graphql@3.82.0(graphql@16.12.0)(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(typescript@5.7.3)': dependencies: graphql: 16.12.0 graphql-scalars: 1.22.2(graphql@16.12.0) - payload: 3.79.0(graphql@16.12.0)(typescript@5.7.3) + payload: 3.82.0(graphql@16.12.0)(typescript@5.7.3) pluralize: 8.0.0 ts-essentials: 10.0.3(typescript@5.7.3) tsx: 4.21.0 transitivePeerDependencies: - typescript - '@payloadcms/live-preview-react@3.79.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@payloadcms/live-preview-react@3.82.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@payloadcms/live-preview': 3.79.0 + '@payloadcms/live-preview': 3.82.0 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@payloadcms/live-preview@3.79.0': {} + '@payloadcms/live-preview@3.82.0': {} - '@payloadcms/next@3.79.0(@types/react@19.2.14)(graphql@16.12.0)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)': + '@payloadcms/next@3.82.0(@types/react@19.2.14)(graphql@16.12.0)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)': dependencies: '@dnd-kit/core': 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@dnd-kit/modifiers': 9.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) '@dnd-kit/sortable': 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) - '@payloadcms/graphql': 3.79.0(graphql@16.12.0)(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(typescript@5.7.3) - '@payloadcms/translations': 3.79.0 - '@payloadcms/ui': 3.79.0(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3) + '@payloadcms/graphql': 3.82.0(graphql@16.12.0)(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(typescript@5.7.3) + '@payloadcms/translations': 3.82.0 + '@payloadcms/ui': 3.82.0(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3) busboy: 1.6.0 dequal: 2.0.3 - file-type: 19.3.0 + file-type: 21.3.4 graphql: 16.12.0 graphql-http: 1.22.4(graphql@16.12.0) graphql-playground-html: 1.6.30 http-status: 2.1.0 next: 15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4) path-to-regexp: 6.3.0 - payload: 3.79.0(graphql@16.12.0)(typescript@5.7.3) - qs-esm: 7.0.2 + payload: 3.82.0(graphql@16.12.0)(typescript@5.7.3) + qs-esm: 8.0.1 sass: 1.77.4 uuid: 10.0.0 transitivePeerDependencies: @@ -7848,11 +7849,11 @@ snapshots: - supports-color - typescript - '@payloadcms/plugin-cloud-storage@3.79.0(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)': + '@payloadcms/plugin-cloud-storage@3.82.0(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)': dependencies: - '@payloadcms/ui': 3.79.0(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3) + '@payloadcms/ui': 3.82.0(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3) find-node-modules: 2.1.3 - payload: 3.79.0(graphql@16.12.0)(typescript@5.7.3) + payload: 3.82.0(graphql@16.12.0)(typescript@5.7.3) range-parser: 1.2.1 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) @@ -7863,7 +7864,7 @@ snapshots: - supports-color - typescript - '@payloadcms/richtext-lexical@3.79.0(@faceless-ui/modal@3.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@faceless-ui/scroll-info@2.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@payloadcms/next@3.79.0(@types/react@19.2.14)(graphql@16.12.0)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3))(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)(yjs@13.6.29)': + '@payloadcms/richtext-lexical@3.82.0(@faceless-ui/modal@3.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@faceless-ui/scroll-info@2.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@payloadcms/next@3.82.0(@types/react@19.2.14)(graphql@16.12.0)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3))(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)(yjs@13.6.29)': dependencies: '@faceless-ui/modal': 3.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@faceless-ui/scroll-info': 2.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -7878,9 +7879,9 @@ snapshots: '@lexical/selection': 0.41.0 '@lexical/table': 0.41.0 '@lexical/utils': 0.41.0 - '@payloadcms/next': 3.79.0(@types/react@19.2.14)(graphql@16.12.0)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3) - '@payloadcms/translations': 3.79.0 - '@payloadcms/ui': 3.79.0(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3) + '@payloadcms/next': 3.82.0(@types/react@19.2.14)(graphql@16.12.0)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3) + '@payloadcms/translations': 3.82.0 + '@payloadcms/ui': 3.82.0(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3) '@types/uuid': 10.0.0 acorn: 8.16.0 bson-objectid: 2.0.4 @@ -7892,8 +7893,8 @@ snapshots: mdast-util-from-markdown: 2.0.2 mdast-util-mdx-jsx: 3.1.3 micromark-extension-mdx-jsx: 3.0.1 - payload: 3.79.0(graphql@16.12.0)(typescript@5.7.3) - qs-esm: 7.0.2 + payload: 3.82.0(graphql@16.12.0)(typescript@5.7.3) + qs-esm: 8.0.1 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) react-error-boundary: 4.1.2(react@19.2.4) @@ -7909,11 +7910,11 @@ snapshots: - utf-8-validate - yjs - '@payloadcms/storage-vercel-blob@3.79.0(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)': + '@payloadcms/storage-vercel-blob@3.82.0(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)': dependencies: - '@payloadcms/plugin-cloud-storage': 3.79.0(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3) - '@vercel/blob': 0.22.3 - payload: 3.79.0(graphql@16.12.0)(typescript@5.7.3) + '@payloadcms/plugin-cloud-storage': 3.82.0(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3) + '@vercel/blob': 2.3.1 + payload: 3.82.0(graphql@16.12.0)(typescript@5.7.3) transitivePeerDependencies: - '@types/react' - monaco-editor @@ -7923,11 +7924,11 @@ snapshots: - supports-color - typescript - '@payloadcms/translations@3.79.0': + '@payloadcms/translations@3.82.0': dependencies: date-fns: 4.1.0 - '@payloadcms/ui@3.79.0(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)': + '@payloadcms/ui@3.82.0(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)': dependencies: '@date-fns/tz': 1.2.0 '@dnd-kit/core': 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -7937,15 +7938,15 @@ snapshots: '@faceless-ui/scroll-info': 2.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@faceless-ui/window-info': 3.0.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@monaco-editor/react': 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@payloadcms/translations': 3.79.0 + '@payloadcms/translations': 3.82.0 bson-objectid: 2.0.4 date-fns: 4.1.0 dequal: 2.0.3 md5: 2.3.0 next: 15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4) object-to-formdata: 4.5.1 - payload: 3.79.0(graphql@16.12.0)(typescript@5.7.3) - qs-esm: 7.0.2 + payload: 3.82.0(graphql@16.12.0)(typescript@5.7.3) + qs-esm: 8.0.1 react: 19.2.4 react-datepicker: 7.6.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react-dom: 19.2.4(react@19.2.4) @@ -8916,7 +8917,7 @@ snapshots: '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0 - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.7 '@types/aria-query': 5.0.4 aria-query: 5.3.0 dom-accessibility-api: 0.5.16 @@ -8934,6 +8935,13 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3 + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + '@tokenizer/token@0.3.0': {} '@ts-morph/common@0.27.0': @@ -9209,12 +9217,13 @@ snapshots: next: 15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4) react: 19.2.4 - '@vercel/blob@0.22.3': + '@vercel/blob@2.3.1': dependencies: async-retry: 1.3.3 - bytes: 3.1.2 is-buffer: 2.0.5 - undici: 5.29.0 + is-node-process: 1.2.0 + throttleit: 2.1.0 + undici: 6.27.0 '@vercel/speed-insights@1.3.1(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(react@19.2.4)': optionalDependencies: @@ -9313,6 +9322,13 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -9435,7 +9451,7 @@ snapshots: babel-plugin-macros@3.1.0: dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.7 cosmiconfig: 7.1.0 resolve: 1.22.11 @@ -9790,7 +9806,7 @@ snapshots: dom-helpers@5.2.1: dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.7 csstype: 3.2.3 dompurify@3.2.7: @@ -10399,11 +10415,14 @@ snapshots: dependencies: flat-cache: 4.0.1 - file-type@19.3.0: + file-type@21.3.4: dependencies: - strtok3: 8.1.0 + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.5 token-types: 6.1.2 uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color fill-range@7.1.1: dependencies: @@ -10903,7 +10922,7 @@ snapshots: jiti@2.6.1: {} - jose@5.9.6: {} + jose@5.10.0: {} jose@6.2.1: {} @@ -10957,7 +10976,7 @@ snapshots: '@types/json-schema': 7.0.15 '@types/lodash': 4.17.23 is-glob: 4.0.3 - js-yaml: 4.1.1 + js-yaml: 4.3.0 lodash: 4.17.23 minimist: 1.2.8 prettier: 3.8.1 @@ -11642,12 +11661,12 @@ snapshots: pathe@2.0.3: {} - payload@3.79.0(graphql@16.12.0)(typescript@5.7.3): + payload@3.82.0(graphql@16.12.0)(typescript@5.7.3): dependencies: '@next/env': 15.5.12 - '@payloadcms/translations': 3.79.0 + '@payloadcms/translations': 3.82.0 '@types/busboy': 1.5.4 - ajv: 8.17.1 + ajv: 8.18.0 bson-objectid: 2.0.4 busboy: 1.6.0 ci-info: 4.4.0 @@ -11655,34 +11674,33 @@ snapshots: croner: 9.1.0 dataloader: 2.2.3 deepmerge: 4.3.1 - file-type: 19.3.0 + file-type: 21.3.4 get-tsconfig: 4.8.1 graphql: 16.12.0 http-status: 2.1.0 image-size: 2.0.2 ipaddr.js: 2.2.0 - jose: 5.9.6 + jose: 5.10.0 json-schema-to-typescript: 15.0.3 minimist: 1.2.8 path-to-regexp: 6.3.0 pino: 9.14.0 pino-pretty: 13.1.2 pluralize: 8.0.0 - qs-esm: 7.0.2 + qs-esm: 8.0.1 range-parser: 1.2.1 sanitize-filename: 1.6.3 ts-essentials: 10.0.3(typescript@5.7.3) tsx: 4.21.0 - undici: 7.18.2 + undici: 7.24.4 uuid: 10.0.0 ws: 8.19.0 transitivePeerDependencies: - bufferutil + - supports-color - typescript - utf-8-validate - peek-readable@5.4.2: {} - pg-cloudflare@1.3.0: optional: true @@ -11868,7 +11886,7 @@ snapshots: punycode@2.3.1: {} - qs-esm@7.0.2: {} + qs-esm@8.0.1: {} qs@6.15.0: dependencies: @@ -11965,7 +11983,7 @@ snapshots: react-error-boundary@4.1.2(react@19.2.4): dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.7 react: 19.2.4 react-error-boundary@6.1.1(react@19.2.4): @@ -12007,10 +12025,10 @@ snapshots: react-select@5.9.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.7 '@emotion/cache': 11.14.0 '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.4) - '@floating-ui/dom': 1.7.5 + '@floating-ui/dom': 1.8.0 '@types/react-transition-group': 4.4.12(@types/react@19.2.14) memoize-one: 6.0.0 prop-types: 15.8.1 @@ -12032,7 +12050,7 @@ snapshots: react-transition-group@4.4.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.7 dom-helpers: 5.2.1 loose-envify: 1.4.0 prop-types: 15.8.1 @@ -12552,10 +12570,9 @@ snapshots: strip-json-comments@5.0.3: {} - strtok3@8.1.0: + strtok3@10.3.5: dependencies: '@tokenizer/token': 0.3.0 - peek-readable: 5.4.2 styled-jsx@5.1.6(@babel/core@7.29.0)(babel-plugin-macros@3.1.0)(react@19.2.4): dependencies: @@ -12593,6 +12610,8 @@ snapshots: dependencies: real-require: 0.2.0 + throttleit@2.1.0: {} + tiny-invariant@1.3.3: {} tinybench@2.9.0: {} @@ -12758,14 +12777,12 @@ snapshots: undici-types@6.21.0: {} - undici@5.29.0: - dependencies: - '@fastify/busboy': 2.1.1 - - undici@7.18.2: {} + undici@6.27.0: {} undici@7.21.0: {} + undici@7.24.4: {} + unicorn-magic@0.3.0: {} unist-util-is@6.0.1: diff --git a/scripts/seed-loader.mjs b/scripts/seed-loader.mjs new file mode 100644 index 00000000..1a375f51 --- /dev/null +++ b/scripts/seed-loader.mjs @@ -0,0 +1,38 @@ +/** + * Seed-only Node module loader: stubs `next/cache` with no-ops. + * + * The `post` and `tag` collections call `revalidateTag()` (via + * `src/utilities/documents.ts`) inside their `afterChange` hooks. `revalidateTag` + * throws "static generation store missing" when called outside a Next.js request + * scope — which is exactly what happens in a standalone seed script, aborting + * every `payload.create`. Rather than editing the collections (owned elsewhere), + * we replace `next/cache` with harmless no-ops for the duration of the seed. + * + * Registered via `--import=./scripts/seed-loader.mjs` in the `db:seed:posts` + * npm script; it affects only that process. + */ +import { register } from 'node:module' + +const shim = + 'data:text/javascript,' + + encodeURIComponent( + [ + 'export const revalidateTag = () => {};', + 'export const revalidatePath = () => {};', + 'export const unstable_cache = (fn) => fn;', + 'export const unstable_noStore = () => {};', + ].join('\n'), + ) + +const hooks = + 'data:text/javascript,' + + encodeURIComponent( + `export function resolve(specifier, context, nextResolve) { + if (specifier === 'next/cache') { + return { url: ${JSON.stringify(shim)}, shortCircuit: true } + } + return nextResolve(specifier, context) + }`, + ) + +register(hooks) diff --git a/scripts/seed-posts.mjs b/scripts/seed-posts.mjs new file mode 100644 index 00000000..1fecdda0 --- /dev/null +++ b/scripts/seed-posts.mjs @@ -0,0 +1,561 @@ +/** + * Seeds the `post` / `tag` / `media` collections from a LIVE fetch of + * jorgesolla.com.br, classifying each article by category + tags. + * + * Source of truth is the live site (fetched when the script runs), NOT the + * reference `noticias-jorgesolla.md`. Primary source is the public WordPress + * REST API (`/wp-json/wp/v2/posts`), with an HTML category-archive crawl as a + * fallback if the API is blocked/unavailable. + * + * Safety model (mirrors scripts/guard-dev-db.mjs and scripts/db-pull.mjs): + * - This script WRITES (creates posts/tags/media). It refuses to run against + * a non-local DATABASE_URL unless ALLOW_REMOTE_DB=true is set explicitly. + * - Idempotent: re-running looks up posts/tags/media by slug/filename and + * skips (or reuses) what already exists, so it never duplicates. + * + * HTML -> Lexical: the WordPress `content.rendered` HTML is converted to + * Payload Lexical richText with `convertHTMLToLexical` from + * `@payloadcms/richtext-lexical`, using the project's default editor config + * (via `editorConfigFactory.default`) and `jsdom` as the DOM implementation. + * The frontend renders `body` with `convertLexicalToHTML`, so this round-trips. + * + * Usage: + * pnpm db:seed:posts + * (against a remote DB, on purpose:) ALLOW_REMOTE_DB=true pnpm db:seed:posts + */ +import { config as loadEnv } from 'dotenv' +import { JSDOM } from 'jsdom' +import { convertHTMLToLexical, editorConfigFactory } from '@payloadcms/richtext-lexical' +import { getPayload } from 'payload' + +// Mirror Next.js precedence (.env.local wins over .env) without clobbering a +// DATABASE_URL already set in the real environment. +loadEnv({ path: '.env.local' }) +loadEnv({ path: '.env' }) + +const config = (await import('../src/payload.config.ts')).default + +// --------------------------------------------------------------------------- +// DB-safety guard (same shape as scripts/guard-dev-db.mjs) +// --------------------------------------------------------------------------- + +const OVERRIDE_FLAG = 'ALLOW_REMOTE_DB' +const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1', 'postgres']) + +const die = (message) => { + console.error(`\n[seed:posts] ${message}\n`) + process.exit(1) +} + +const assertLocalDatabase = () => { + const databaseUrl = process.env.DATABASE_URL + if (!databaseUrl) die('DATABASE_URL is not set. Refusing to seed.') + + if (process.env[OVERRIDE_FLAG] === 'true' || process.env[OVERRIDE_FLAG] === '1') { + console.warn( + `\n[seed:posts] ${OVERRIDE_FLAG} is set — seeding a remote database on purpose. Be careful.\n`, + ) + return + } + + let host + try { + host = new URL(databaseUrl).hostname + } catch { + die('DATABASE_URL is not a valid connection string.') + } + + if (!LOCAL_HOSTS.has(host)) { + die( + `DATABASE_URL points at a non-local host ("${host}").\n` + + 'This seed WRITES data (posts/tags/media) and is meant for the local Postgres.\n\n' + + ' 1. pnpm db:start\n' + + ' 2. set DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo in .env.local\n\n' + + `If you REALLY mean to seed a remote database, re-run with:\n ${OVERRIDE_FLAG}=true pnpm db:seed:posts`, + ) + } + + console.log(`[seed:posts] OK — using local database host "${host}".`) +} + +// --------------------------------------------------------------------------- +// Taxonomy (Section 7): the categories + control tag, and the per-article map. +// Keys are the WordPress slugs of the live articles. Every post gets exactly +// one required `category`; `tags` is optional (currently only `eleitoral`). +// The `eleitoral` tag marks pre-candidacy / campaign / PGP / party-event +// content so Francisco can hide it all with one toggle in the electoral period. +// --------------------------------------------------------------------------- + +const TAXONOMY_TAGS = [ + { slug: 'saude', name: 'Saúde' }, + { slug: 'cultura', name: 'Cultura' }, + { slug: 'desenvolvimento', name: 'Desenvolvimento' }, + { slug: 'politica', name: 'Política' }, + { slug: 'educacao', name: 'Educação' }, + { slug: 'seguranca', name: 'Segurança' }, + { slug: 'direitos-trabalhistas', name: 'Direitos trabalhistas' }, + { slug: 'eleitoral', name: 'Eleitoral' }, +] + +const DEFAULT_CATEGORY = 'politica' + +/** WP slug -> { category, tags }. See report/plan Section 7. */ +const CLASSIFICATION = { + // Health + 'lula-jeronimo-e-solla-inauguram-hospital-do-litoral-norte-da-bahia': { category: 'saude' }, + 'solla-reforca-luta-por-cumprimento-do-piso-da-enfermagem-em-audiencia-na-camara': { + category: 'saude', + }, + 'emenda-de-solla-garante-maquinas-de-costura-a-associacao-de-fibromialgia': { category: 'saude' }, + 'solla-comemora-25-anos-da-politica-nacional-de-sangue-neste-16-de-abril': { category: 'saude' }, + 'fora-trump-em-defesa-da-saude-e-da-paz': { category: 'saude' }, + 'portaria-autoriza-retorno-de-mais-de-300-servidores-a-funasa': { category: 'saude' }, + 'missao-internacional-com-solla-lula-e-jeronimo-fortalece-producao-de-medicamentos-estrategicos-e-projeta-a-bahia-como-polo-de-biofarmacos': + { category: 'saude' }, + 'solla-desembarca-na-india-em-missao-oficial-com-lula-pela-asia': { category: 'saude' }, + + // Culture + 'solla-exalta-primeiro-ano-de-salvador-como-capital-do-brasil-no-2-de-julho': { + category: 'cultura', + }, + 'audiencia-publica-debate-plano-nacional-de-cultura': { category: 'cultura' }, + + // Development + 'solla-e-jeronimo-inauguram-mercado-publico-de-ubata': { category: 'desenvolvimento' }, + 'governo-da-bahia-anuncia-pacote-de-quase-r-2-bi': { category: 'desenvolvimento' }, + 'com-solla-comissao-acompanha-retomada-da-producao-no-enseada-em-maragogipe': { + category: 'desenvolvimento', + }, + 'recomprar-a-refinaria-de-mataripe-e-defender-a-bahia-e-a-soberania-do-brasil': { + category: 'desenvolvimento', + }, + 'solla-protocola-pec-para-garantir-controle-estatal-sobre-petroleo-e-derivados-no-brasil': { + category: 'desenvolvimento', + }, + 'solla-leva-comissao-da-camara-para-nova-visita-tecnica-ao-estaleiro-enseada': { + category: 'desenvolvimento', + }, + 'entrevista-solla-fala-sobre-a-retomada-dos-investimentos-publicos-no-pais': { + category: 'desenvolvimento', + }, + 'obra-de-ampliacao-do-sistema-de-abastecimento-do-rio-paraguacu-itirucu-jaguaquara': { + category: 'desenvolvimento', + }, + 'governo-lula-corrige-erro-de-antecessor-diz-solla-sobre-suspensao-da-importacao-de-cacau': { + category: 'desenvolvimento', + }, + + // Politics (with electoral control where applicable) + 'programa-de-governo-participativo-reune-multidao-em-periperi': { + category: 'politica', + tags: ['eleitoral'], + }, + 'clube-2004-celebra-a-democracia-com-o-lancamento-da-pre-candidatura-de-jorge-solla-a-deputado-federal': + { category: 'politica', tags: ['eleitoral'] }, + 'articulada-por-solla-filiacao-de-zilar-portela-ao-pt-marca-lancamento-de-pre-candidatura-a-deputada-estadual': + { category: 'politica', tags: ['eleitoral'] }, + 'articulacao-de-solla-fortalece-pt-com-filiacao-da-lideranca-sindical-zilar-portela': { + category: 'politica', + tags: ['eleitoral'], + }, + 'federacao-brasil-esperanca-exige-defesa-do-nosso-projeto-politico': { + category: 'politica', + tags: ['eleitoral'], + }, + 'com-presenca-de-solla-e-aliados-aniversario-do-pt-de-jacobina-reune-liderancas-e-reafirma-compromisso-o-desenvolvimento-da-bahia': + { category: 'politica', tags: ['eleitoral'] }, + 'pt-de-jacobina-celebra-aniversario-com-presenca-de-jorge-solla-e-do-secretario-do-governo-lula-julio-pinheiro': + { category: 'politica', tags: ['eleitoral'] }, + 'solla-e-indicado-em-estudo-da-arko-advice-como-um-politico-da-elite-parlamentar': { + category: 'politica', + }, + 'solla-rebate-ataques-de-ze-coca-e-destaca-investimentos-do-mandato-em-itirucu-e-na-regiao-de-jequie': + { category: 'politica' }, + + // Education + 'solla-conduz-cffc-em-visita-tecnica-a-primeira-faculdade-de-medicina-do-brasil': { + category: 'educacao', + }, + 'ao-lado-de-camilo-santana-jorge-solla-acompanha-avancos-da-educacao-federal-em-barreiras-e-pocoes': + { category: 'educacao' }, + 'solla-articula-no-mec-avancos-para-expansao-da-educacao-superior-e-da-educacao-profissional-na-bahia': + { category: 'educacao' }, + 'solla-comemora-anuncio-de-construcao-de-novo-campus-federal-em-jequie': { category: 'educacao' }, + 'apresentacao-do-plano-de-restauracao-arquitetonica-da-famed-ufba': { category: 'educacao' }, + 'solla-participa-das-celebracoes-pelos-218-anos-da-faculdade-de-medicina-da-ufba': { + category: 'educacao', + }, + 'solla-articula-agenda-de-dirigentes-da-ufba-no-ministerio-da-cultura-para-viabilizar-reforma-de-alas-da-faculdade-de-medicina': + { category: 'educacao' }, + + // Security + 'camara-aprova-pec-da-seguranca-publica-e-solla-destaca-avanco-da-proposta-do-governo-lula': { + category: 'seguranca', + }, + + // Labor rights + 'fim-da-escala-6x1-tambem-e-questao-de-saude': { category: 'direitos-trabalhistas' }, + 'jorge-solla-apresenta-pec-que-eleva-pensao-por-morte': { category: 'direitos-trabalhistas' }, + 'da-asia-solla-manifesta-apoio-a-servidores-demitidos-por-bruno-reis': { + category: 'direitos-trabalhistas', + }, +} + +// --------------------------------------------------------------------------- +// Live fetch — WordPress REST API (primary) +// --------------------------------------------------------------------------- + +const BASE_URL = 'https://jorgesolla.com.br' +const USER_AGENT = 'teqo-content-migration-bot/1.0 (+https://jorgesolla.com.br)' + +const stripHtml = (html) => + new JSDOM(`${html || ''}`).window.document.body.textContent + .replace(/\s+/g, ' ') + .trim() + +/** Normalized article shape used by both the REST and HTML strategies. */ +/** @typedef {{ slug: string, title: string, date: string|null, html: string, coverUrl: string|null, coverAlt: string|null }} Article */ + +async function fetchViaRestApi() { + /** @type {Article[]} */ + const articles = [] + let page = 1 + let totalPages = 1 + + do { + const url = `${BASE_URL}/wp-json/wp/v2/posts?per_page=100&page=${page}&_embed` + const res = await fetch(url, { headers: { 'User-Agent': USER_AGENT } }) + if (!res.ok) throw new Error(`REST API returned ${res.status} for page ${page}`) + + if (page === 1) { + totalPages = Number(res.headers.get('x-wp-totalpages') || '1') || 1 + } + + const data = await res.json() + if (!Array.isArray(data) || data.length === 0) break + + for (const item of data) { + const embeddedMedia = item?._embedded?.['wp:featuredmedia']?.[0] + articles.push({ + slug: item.slug, + title: stripHtml(item?.title?.rendered) || item.slug, + date: item.date || null, + html: item?.content?.rendered || '', + coverUrl: embeddedMedia?.source_url || null, + coverAlt: embeddedMedia?.alt_text || null, + }) + } + + page += 1 + } while (page <= totalPages) + + return articles +} + +// --------------------------------------------------------------------------- +// Live fetch — HTML category-archive crawl (fallback) +// Mirrors conteudo-atual-scraping/scrape_jorgesolla.py. +// --------------------------------------------------------------------------- + +const FALLBACK_CATEGORIES = ['noticias', 'uncategorized'] +const PERMALINK_RE = new RegExp(`^${BASE_URL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/\\d{4}/\\d{2}/\\d{2}/[^/]+/?$`) + +async function fetchViaHtmlArchive() { + /** @type {Article[]} */ + const articles = [] + const seen = new Set() + + for (const category of FALLBACK_CATEGORIES) { + let pageNum = 1 + while (pageNum <= 30) { + const url = + pageNum === 1 + ? `${BASE_URL}/category/${category}/` + : `${BASE_URL}/category/${category}/page/${pageNum}/` + const res = await fetch(url, { headers: { 'User-Agent': USER_AGENT } }) + if (res.status === 404) break + if (!res.ok) throw new Error(`archive ${url} returned ${res.status}`) + + const doc = new JSDOM(await res.text()).window.document + const links = new Set() + for (const heading of doc.querySelectorAll('h1, h2, h3, h4, h5, h6')) { + const a = heading.querySelector('a[href]') + if (!a) continue + const href = a.href.replace(/\/?$/, '/') + if (PERMALINK_RE.test(href)) links.add(href) + } + + const fresh = [...links].filter((href) => !seen.has(href)) + if (fresh.length === 0) break + + for (const href of fresh) { + seen.add(href) + const article = await fetchSinglePage(href) + if (article) articles.push(article) + } + + pageNum += 1 + } + } + + return articles +} + +async function fetchSinglePage(url) { + const res = await fetch(url, { headers: { 'User-Agent': USER_AGENT } }) + if (!res.ok) return null + const doc = new JSDOM(await res.text()).window.document + + const slug = url.replace(/\/$/, '').split('/').pop() + const contentEl = + doc.querySelector('.entry-content') || doc.querySelector('article') || doc.body + const time = doc.querySelector('time[datetime]') + const firstImg = contentEl.querySelector('img') + + return { + slug, + title: stripHtml(doc.querySelector('h1')?.innerHTML || slug), + date: time?.getAttribute('datetime') || null, + html: contentEl.innerHTML || '', + coverUrl: firstImg ? new URL(firstImg.getAttribute('src'), url).href : null, + coverAlt: firstImg?.getAttribute('alt') || null, + } +} + +// --------------------------------------------------------------------------- +// Content processing: split WP content into { subtitle, coverUrl, bodyHtml } +// --------------------------------------------------------------------------- + +/** + * WordPress articles on this site follow a consistent shape: + *

dek/subtitle (optional)

+ *
+ * ...body... + * The dek and the cover image are lifted out so they don't render twice, and + * the remaining HTML becomes the Lexical body. + */ +function processContent(article) { + const dom = new JSDOM(`
${article.html}
`) + const doc = dom.window.document + const root = doc.getElementById('root') + + // Drop empty paragraphs and empty gallery figures (WP leaves placeholders). + for (const el of root.querySelectorAll('p, figure')) { + if (el.querySelector('img')) continue + if (el.textContent.replace(/\u00a0/g, ' ').trim() === '') el.remove() + } + + // Cover: prefer the REST featured media; else the first inline image. + const firstImg = root.querySelector('img') + const coverUrl = article.coverUrl || firstImg?.getAttribute('src') || null + + // Subtitle (dek): this site places the dek as a first

immediately followed + // by the cover-image figure. Only treat the first paragraph as the subtitle + // when that exact shape holds — otherwise the first paragraph is real body. + let subtitle = null + const firstBlock = root.firstElementChild + if (firstBlock && firstBlock.tagName === 'P') { + const next = firstBlock.nextElementSibling + const nextIsImage = !!next && (next.tagName === 'IMG' || !!next.querySelector?.('img')) + const text = firstBlock.textContent.replace(/\s+/g, ' ').trim() + if (nextIsImage && text && !/^por\s+jorge\s+solla/i.test(text)) { + subtitle = text + firstBlock.remove() + } + } + + // Strip ALL images (and their figure wrappers): the cover is stored separately + // on the post, and inline external images can't be resolved to `media` docs, so + // `convertHTMLToLexical` would emit invalid "pending" upload nodes. Dropping + // them keeps the body as valid text/heading/list/link/quote content. + for (const img of root.querySelectorAll('img')) { + ;(img.closest('figure') || img).remove() + } + // Remove any figures/paragraphs left empty by the image removal. + for (const el of root.querySelectorAll('figure, p')) { + if (el.querySelector('img')) continue + if (el.textContent.replace(/\u00a0/g, ' ').trim() === '') el.remove() + } + + return { subtitle, coverUrl, bodyHtml: root.innerHTML.trim() } +} + +// --------------------------------------------------------------------------- +// Media upload (idempotent by filename) +// --------------------------------------------------------------------------- + +const EXT_BY_MIME = { + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/webp': '.webp', + 'image/gif': '.gif', + 'image/avif': '.avif', +} + +/** @returns {Promise<{ id: string|number, created: boolean } | null>} */ +async function ensureCoverMedia(payload, { slug, coverUrl, alt }) { + if (!coverUrl) return null + + const res = await fetch(coverUrl, { headers: { 'User-Agent': USER_AGENT } }) + if (!res.ok) { + console.warn(`[seed:posts] cover download failed (${res.status}) for ${slug}: ${coverUrl}`) + return null + } + + const mimetype = (res.headers.get('content-type') || 'image/jpeg').split(';')[0].trim() + const urlExt = new URL(coverUrl).pathname.match(/\.[a-z0-9]+$/i)?.[0] + const ext = EXT_BY_MIME[mimetype] || urlExt || '.jpg' + const filename = `${slug}${ext}` + + // Idempotency: reuse an existing media doc with the same (stable) filename + // if it's already there (e.g. from a previous partial run). + const existing = await payload.find({ + collection: 'media', + where: { filename: { equals: filename } }, + limit: 1, + depth: 0, + }) + if (existing.docs.length > 0) return { id: existing.docs[0].id, created: false } + + const buffer = Buffer.from(await res.arrayBuffer()) + const media = await payload.create({ + collection: 'media', + data: { alt: alt || slug }, + file: { data: buffer, mimetype, name: filename, size: buffer.length }, + }) + return { id: media.id, created: true } +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + assertLocalDatabase() + + const payload = await getPayload({ config }) + const editorConfig = await editorConfigFactory.default({ config: payload.config }) + + const htmlToLexical = (html) => convertHTMLToLexical({ editorConfig, html: html || '', JSDOM }) + + // 1. Ensure taxonomy tags exist (idempotent by slug) -> slug -> id. + console.log('[seed:posts] Ensuring taxonomy tags...') + const tagIdBySlug = {} + let tagsCreated = 0 + for (const tag of TAXONOMY_TAGS) { + const found = await payload.find({ + collection: 'tag', + where: { slug: { equals: tag.slug } }, + limit: 1, + depth: 0, + }) + if (found.docs.length > 0) { + tagIdBySlug[tag.slug] = found.docs[0].id + continue + } + const created = await payload.create({ + collection: 'tag', + data: { name: tag.name, slug: tag.slug, hidden: false }, + }) + tagIdBySlug[tag.slug] = created.id + tagsCreated += 1 + } + console.log(`[seed:posts] ${tagsCreated} tag(s) created, ${TAXONOMY_TAGS.length} total.`) + + // 2. Live fetch (REST API primary, HTML crawl fallback). + console.log('[seed:posts] Fetching articles from jorgesolla.com.br (WP REST API)...') + let articles = [] + try { + articles = await fetchViaRestApi() + console.log(`[seed:posts] REST API returned ${articles.length} article(s).`) + } catch (err) { + console.warn(`[seed:posts] REST API failed (${err.message}). Falling back to HTML crawl...`) + } + if (articles.length === 0) { + articles = await fetchViaHtmlArchive() + console.log(`[seed:posts] HTML crawl returned ${articles.length} article(s).`) + } + if (articles.length === 0) die('No articles fetched from the live site. Aborting.') + + // 3. Upsert each article. + let postsCreated = 0 + let postsSkipped = 0 + let mediaCreated = 0 + const unmapped = [] + + for (const article of articles) { + const { slug, title } = article + const mapping = CLASSIFICATION[slug] + if (!mapping) unmapped.push(slug) + + const categorySlug = mapping?.category || DEFAULT_CATEGORY + const tagSlugs = mapping?.tags || [] + const categoryId = tagIdBySlug[categorySlug] + const tagIds = tagSlugs.map((s) => tagIdBySlug[s]).filter(Boolean) + + const existing = await payload.find({ + collection: 'post', + where: { slug: { equals: slug } }, + limit: 1, + depth: 0, + }) + if (existing.docs.length > 0) { + postsSkipped += 1 + continue + } + + const { subtitle, coverUrl, bodyHtml } = processContent(article) + + const cover = await ensureCoverMedia(payload, { + slug, + coverUrl, + alt: article.coverAlt || title, + }) + if (cover?.created) mediaCreated += 1 + + await payload.create({ + collection: 'post', + data: { + title, + slug, + type: 'noticia', + category: categoryId, + tags: tagIds, + subtitle: subtitle || undefined, + coverImage: cover?.id || undefined, + publishedDate: article.date || undefined, + body: htmlToLexical(bodyHtml), + _status: 'published', + }, + }) + postsCreated += 1 + console.log( + `[seed:posts] + ${slug} [${categorySlug}${tagSlugs.length ? ' | ' + tagSlugs.join(',') : ''}]`, + ) + } + + // 4. Report. + console.log('\n[seed:posts] ==================== SUMMARY ====================') + console.log(`[seed:posts] Articles fetched : ${articles.length}`) + console.log(`[seed:posts] Posts created : ${postsCreated}`) + console.log(`[seed:posts] Posts skipped : ${postsSkipped} (already existed)`) + console.log(`[seed:posts] Media created : ${mediaCreated}`) + console.log(`[seed:posts] Tags created : ${tagsCreated} (of ${TAXONOMY_TAGS.length})`) + if (unmapped.length > 0) { + console.warn( + `\n[seed:posts] WARNING: ${unmapped.length} live slug(s) not in the taxonomy map ` + + `(defaulted to "${DEFAULT_CATEGORY}"):`, + ) + for (const slug of unmapped) console.warn(`[seed:posts] - ${slug}`) + } else { + console.log('[seed:posts] All fetched slugs were mapped in the taxonomy.') + } + console.log('[seed:posts] =================================================\n') + + process.exit(0) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/src/app/(frontend)/(home)/layout.tsx b/src/app/(frontend)/(home)/layout.tsx index c310783c..f0180063 100644 --- a/src/app/(frontend)/(home)/layout.tsx +++ b/src/app/(frontend)/(home)/layout.tsx @@ -1,16 +1,19 @@ import { Footer } from '@/components/Footer' -import { Header } from '@/components/Header' +import { SiteHeader } from '@/components/SiteHeader' import React from 'react' import '../styles.css' export default async function RootLayout(props: { children: React.ReactNode }) { const { children } = props + // The root `(frontend)` layout locks the document with `overflow-hidden`, so + // the home route owns an internal scroll container. `relative` anchors the + // overlay header over the hero. return ( - <> -

+
+ {children}
- +
) } diff --git a/src/app/(frontend)/(home)/page.tsx b/src/app/(frontend)/(home)/page.tsx index c32f80d9..4a58c7b3 100644 --- a/src/app/(frontend)/(home)/page.tsx +++ b/src/app/(frontend)/(home)/page.tsx @@ -1,30 +1,82 @@ +import { PostCard } from '@/components/PostCard' import { Media } from '@/payload-types' import { getCachedDocumentById } from '@/utilities/documents' import { getCachedGlobal } from '@/utilities/globals' +import { getTypePath, getVisiblePosts } from '@/utilities/posts' import Image from 'next/image' +import Link from 'next/link' + +const LATEST_NEWS_LIMIT = 6 export default async function HomePage() { - const payload = await getCachedGlobal('home', 2)() + const home = await getCachedGlobal('home', 2)() - let image: Media - - if (typeof payload.image === 'number') { - image = await getCachedDocumentById('media', `${payload.image}`)() + let heroImage: Media | null = null + if (typeof home.image === 'number') { + heroImage = await getCachedDocumentById('media', `${home.image}`)() } else { - image = payload.image + heroImage = home.image ?? null } + const latestNews = (await getVisiblePosts()) + .filter((post) => post.type === 'noticia') + .slice(0, LATEST_NEWS_LIMIT) + return ( -
- {image?.url ? ( - {image.alt} - ) : null} +
+
+
+

+ O mandato do tamanho da Bahia +

+

+ Notícias, campanhas e a atuação do deputado Jorge Solla por uma Bahia mais justa. +

+ + Ver notícias + +
+ {heroImage?.url ? ( +
+ {heroImage.alt} +
+ ) : null} +
+ +
+
+
+

+ Últimas notícias +

+ + Ver todas + +
+ {latestNews.length ? ( +
+ {latestNews.map((post) => ( + + ))} +
+ ) : ( +

Nenhuma notícia publicada ainda.

+ )} +
+
) } diff --git a/src/app/(frontend)/[type]/[category]/[slug]/page.tsx b/src/app/(frontend)/[type]/[category]/[slug]/page.tsx new file mode 100644 index 00000000..75aae2b3 --- /dev/null +++ b/src/app/(frontend)/[type]/[category]/[slug]/page.tsx @@ -0,0 +1,262 @@ +import { getCachedDocumentById } from '@/utilities/documents' +import { getCachedGlobal } from '@/utilities/globals' +import { extractFirstImageFromLexical } from '@/utilities/extractFirstImageFromLexical' +import { stripTrailingSlash, toAbsoluteUrl, truncate } from '@/utilities/seo' +import { + POST_TYPE_LABELS, + formatPostDate, + getCachedPostBySlug, + getCategoryListingPath, + getCategoryName, + getCategorySlug, + getPostCanonicalPath, + getTypePath, + getVisiblePosts, + isPostType, + isPostVisible, +} from '@/utilities/posts' +import { SiteHeader } from '@/components/SiteHeader' +import type { Crumb } from '@/components/SiteHeader' +import { Badge } from '@/components/ui/badge' +import { convertLexicalToHTML } from '@payloadcms/richtext-lexical/html' +import type { Media, Post } from '@/payload-types' +import type { Metadata } from 'next' +import type { Article, WithContext } from 'schema-dts' +import Image from 'next/image' +import { notFound, redirect } from 'next/navigation' + +export const dynamicParams = true + +const MAX_DESCRIPTION_LENGTH = 200 + +type RouteParams = { type: string; category: string; slug: string } + +export async function generateStaticParams(): Promise { + const posts = await getVisiblePosts() + + return posts + .map((post): RouteParams | null => { + const category = getCategorySlug(post) + if (!category || !post.slug) return null + return { type: post.type, category, slug: post.slug } + }) + .filter((entry): entry is RouteParams => entry !== null) +} + +type Metadatum = Awaited>>> + +/** Resolve the OG image: cover image first, then first body image, then the global fallback. */ +async function resolveOgImage(post: Post, globalImage: Metadatum['image']): Promise { + const cover = + typeof post.coverImage === 'object' && post.coverImage !== null ? post.coverImage : null + if (cover) return cover + + const bodyImage = post.body ? extractFirstImageFromLexical(post.body) : null + if (bodyImage) return bodyImage + + if (globalImage) { + return typeof globalImage === 'number' + ? await getCachedDocumentById('media', String(globalImage))() + : globalImage + } + + return null +} + +export async function generateMetadata({ + params, +}: { + params: Promise +}): Promise { + const { slug } = await params + const post = await getCachedPostBySlug(slug)() + + if (!post || !isPostVisible(post)) return {} + + const globalMetadata = await getCachedGlobal('metadata')() + const siteUrl = stripTrailingSlash(globalMetadata.URL) + const canonicalPath = getPostCanonicalPath(post) + const canonicalUrl = canonicalPath ? `${siteUrl}${canonicalPath}` : siteUrl + + const image = await resolveOgImage(post, globalMetadata.image) + const imageUrl = image?.url ? toAbsoluteUrl(image.url, siteUrl) : undefined + + const description = post.subtitle + ? truncate(post.subtitle, MAX_DESCRIPTION_LENGTH) + : globalMetadata.description + const title = `${post.title} | ${globalMetadata.openGraph.siteName}` + const categoryName = getCategoryName(post) + + const keywords = [ + ...globalMetadata.keywords + .map((k) => (typeof k === 'string' ? k : k.keyword)) + .filter((k): k is string => typeof k === 'string'), + post.title, + ...(categoryName ? [categoryName] : []), + ] + + const ogImages = imageUrl + ? [ + { + url: imageUrl, + secureUrl: imageUrl, + width: image?.width ?? undefined, + height: image?.height ?? undefined, + alt: image?.alt, + type: image?.mimeType ?? undefined, + }, + ] + : [] + + return { + title, + description, + keywords, + alternates: { + canonical: canonicalUrl, + }, + openGraph: { + type: 'article', + locale: 'pt-BR', + url: canonicalUrl, + siteName: globalMetadata.openGraph.siteName, + title, + description, + images: ogImages, + publishedTime: post.publishedDate ?? post.createdAt, + modifiedTime: post.updatedAt, + }, + twitter: { + card: 'summary_large_image', + title, + description, + creator: globalMetadata.twitter.creator, + images: ogImages.map((img) => img.url), + }, + } +} + +export default async function Page({ params }: { params: Promise }) { + const { type, category, slug } = await params + + if (!isPostType(type)) return notFound() + + const post = await getCachedPostBySlug(slug)() + + if (!post || !isPostVisible(post)) return notFound() + + const canonicalPath = getPostCanonicalPath(post) + if (!canonicalPath) return notFound() + + // Redirect stale/mismatched URLs to the canonical type + category slug. + if (canonicalPath !== `/${type}/${category}/${slug}`) { + return redirect(canonicalPath) + } + + const globalMetadata = await getCachedGlobal('metadata')() + const siteUrl = stripTrailingSlash(globalMetadata.URL) + const canonicalUrl = `${siteUrl}${canonicalPath}` + + const ogImage = await resolveOgImage(post, globalMetadata.image) + const ogImageUrl = ogImage?.url ? toAbsoluteUrl(ogImage.url, siteUrl) : undefined + + const categoryName = getCategoryName(post) + const categorySlug = getCategorySlug(post) + const publishedDate = formatPostDate(post.publishedDate) + + const coverImage = + typeof post.coverImage === 'object' && post.coverImage !== null ? post.coverImage : null + + const breadcrumbs: Crumb[] = [ + { label: POST_TYPE_LABELS[post.type], href: getTypePath(post.type) }, + ...(categoryName && categorySlug + ? [{ label: categoryName, href: getCategoryListingPath(post.type, categorySlug) }] + : []), + { label: post.title }, + ] + + const jsonLd: WithContext
= { + '@context': 'https://schema.org', + '@type': 'Article', + headline: post.title, + ...(post.subtitle ? { description: truncate(post.subtitle, MAX_DESCRIPTION_LENGTH) } : {}), + inLanguage: 'pt-BR', + url: canonicalUrl, + mainEntityOfPage: { '@type': 'WebPage', '@id': canonicalUrl }, + datePublished: post.publishedDate ?? post.createdAt, + dateModified: post.updatedAt, + ...(ogImageUrl ? { image: [ogImageUrl] } : {}), + author: { + '@type': 'Organization', + name: globalMetadata.openGraph.siteName, + url: siteUrl, + }, + publisher: { + '@type': 'Organization', + name: globalMetadata.openGraph.siteName, + url: siteUrl, + }, + } + + const bodyHTML = post.body ? convertLexicalToHTML({ data: post.body }) : '' + // Escape "<" so no field value (e.g. a title containing "") can break out of the JSON-LD script tag. + const jsonLdString = JSON.stringify(jsonLd).replace(/ + +
+