From b811615f511cbdbb149808da3b4189fafcaf8726 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Mar=C4=8Di=C5=A1ovsk=C3=BD?= Date: Tue, 29 Sep 2026 17:29:01 +0200 Subject: [PATCH 1/9] fix: permanent redirects --- apps/ui/src/lib/proxies/redirects.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/apps/ui/src/lib/proxies/redirects.ts b/apps/ui/src/lib/proxies/redirects.ts index caf5387..30c3434 100644 --- a/apps/ui/src/lib/proxies/redirects.ts +++ b/apps/ui/src/lib/proxies/redirects.ts @@ -61,13 +61,10 @@ export async function redirectsProxy( return null } - // Always use 307 for CMS-managed redirects. A 308/301 is attractive for SEO - // once a move is truly final, but it is also sticky: browsers, crawlers, and - // intermediate caches can keep using it after editors fix or remove the - // redirect. A CDN can be purged, but user-agent caches cannot. These - // redirects are operational CMS content, so the safer default is a temporary - // method-preserving redirect that can be changed without leaving clients - // pinned to the old destination. + if (redirect.permanent) { + return NextResponse.redirect(destinationUrl, 301) + } + return NextResponse.redirect(destinationUrl, 307) } From 73154f87a7bd626a1c117273d0e924f25f926788 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Mar=C4=8Di=C5=A1ovsk=C3=BD?= Date: Tue, 29 Sep 2026 17:34:50 +0200 Subject: [PATCH 2/9] feat: add migration to turn off permanent flag in redirects --- packages/migration/src/index.ts | 15 ++ .../src/scripts/reset-redirect-permanent.ts | 195 ++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 packages/migration/src/scripts/reset-redirect-permanent.ts diff --git a/packages/migration/src/index.ts b/packages/migration/src/index.ts index c89b8f9..f2a891a 100644 --- a/packages/migration/src/index.ts +++ b/packages/migration/src/index.ts @@ -19,6 +19,7 @@ import { runBackfillOriginalPublishedAt } from "./scripts/backfill-original-publ import { runFixMarkdownImages } from "./scripts/fix-markdown-images.ts" import { runFixPublishedAt } from "./scripts/fix-published-at.ts" import { runFixYoutubeIframes } from "./scripts/fix-youtube-iframes.ts" +import { runResetRedirectPermanent } from "./scripts/reset-redirect-permanent.ts" import { runUnpublishWrongDrafts } from "./scripts/unpublish-wrong-drafts.ts" import { IdMap } from "./state/id-map.ts" import { MediaCache } from "./state/media-cache.ts" @@ -476,6 +477,20 @@ program }) }) +program + .command("reset-redirect-permanent") + .description( + "Set permanent=false on every Strapi redirect (draft and published). Dry-run by default." + ) + .option("--apply", "Actually PUT permanent=false (default: dry-run)") + .option("--verbose", "Debug-level logging") + .action(async (opts) => { + await runResetRedirectPermanent({ + apply: opts.apply ?? false, + verbose: opts.verbose ?? false, + }) + }) + program .command("unpublish-wrong-drafts ") .description( diff --git a/packages/migration/src/scripts/reset-redirect-permanent.ts b/packages/migration/src/scripts/reset-redirect-permanent.ts new file mode 100644 index 0000000..d90364e --- /dev/null +++ b/packages/migration/src/scripts/reset-redirect-permanent.ts @@ -0,0 +1,195 @@ +/** + * One-time patch: set `permanent` to false on every Strapi redirect. + * + * The v4 source defaulted this field to true, and those values were copied + * as-is. The site now honors the flag (true → 301). Leaving the migrated + * rows as true would turn existing redirects into permanent 301s on deploy. + * + * Redirects use draft & publish and are not localized. The site reads the + * published copy, so both the draft and the published version are updated. + * A draft-only row is updated in place and is not published. + * + * Dry-run by default. + */ + +import qs from "qs" + +import { loadEnv, type MigrationEnv } from "../config/env.ts" +import { createLogger, type Logger } from "../utils/logger.ts" +import { withRetry } from "../utils/retry.ts" + +const ENDPOINT = "redirects" + +interface RedirectRow { + documentId: string + source?: string | null + destination?: string | null + permanent?: boolean | null +} + +interface RedirectList { + data: RedirectRow[] + meta: { pagination?: { page: number; pageSize: number; pageCount: number } } +} + +export interface ResetRedirectPermanentOptions { + apply: boolean + verbose?: boolean +} + +export async function runResetRedirectPermanent( + opts: ResetRedirectPermanentOptions +): Promise { + const env = loadEnv() + const logger = createLogger(opts.verbose ?? false) + + logger.header( + `Reset redirect permanent flag${opts.apply ? "" : " (dry-run)"}` + ) + + let updated = 0 + let failures = 0 + + // Draft first, then published. A published PUT re-publishes the draft, so + // the draft must already be false or the published copy would be rewritten + // back to true. + for (const status of ["draft", "published"] as const) { + const rows = await fetchPermanentRedirects(env, logger, status) + logger.info( + `status=${status}: ${rows.length} redirect(s) with permanent=true` + ) + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]! + const label = (row.source ?? row.documentId).padEnd(50) + + if (!opts.apply) { + logger.progress( + i + 1, + rows.length, + `DRY ${label} would set permanent=false (status=${status})` + ) + updated++ + continue + } + + try { + await withRetry(() => setPermanentFalse(env, row.documentId, status), { + logger, + }) + updated++ + logger.progress( + i + 1, + rows.length, + `OK ${label} permanent=false (status=${status})` + ) + } catch (err) { + failures++ + const msg = err instanceof Error ? err.message : String(err) + logger.progress(i + 1, rows.length, `FAIL ${label} ${msg}`) + } + } + } + + logger.divider() + + if (opts.apply) { + logger.info(`Updated ${updated} redirect version(s)`) + if (failures > 0) { + logger.error(`${failures} PUT(s) failed`) + } + } else { + logger.info( + `Would update ${updated} redirect version(s) (run with --apply)` + ) + } + + logger.divider() + + if (failures > 0 && opts.apply) { + throw new Error(`reset-redirect-permanent: ${failures} write failure(s)`) + } +} + +async function fetchPermanentRedirects( + env: MigrationEnv, + logger: Logger, + status: "draft" | "published" +): Promise { + const out: RedirectRow[] = [] + const pageSize = 100 + let page = 1 + + while (true) { + const params = qs.stringify( + { + fields: ["source", "destination", "permanent"], + filters: { permanent: { $eq: true } }, + status, + pagination: { page, pageSize }, + }, + { encodeValuesOnly: true } + ) + const url = `${env.target.baseUrl}/api/${ENDPOINT}?${params}` + logger.debug(`v5 fetch: ${url}`) + + const res = await withRetry(() => getJson(env, url), { + logger, + }) + + out.push(...res.data) + + const pag = res.meta.pagination + if (!pag || page >= pag.pageCount) break + + page++ + } + + return out +} + +async function setPermanentFalse( + env: MigrationEnv, + documentId: string, + status: "draft" | "published" +): Promise { + const url = `${env.target.baseUrl}/api/${ENDPOINT}/${documentId}?status=${status}` + + const res = await fetch(url, { + method: "PUT", + headers: { + Authorization: `Bearer ${env.target.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ data: { permanent: false } }), + }) + + if (!res.ok) { + const body = await res.text().catch(() => "") + const error = new Error( + `${res.status} ${res.statusText} - ${body.slice(0, 300)}` + ) as Error & { status: number } + error.status = res.status + throw error + } +} + +async function getJson(env: MigrationEnv, url: string): Promise { + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${env.target.token}`, + "Content-Type": "application/json", + }, + }) + + if (!res.ok) { + const body = await res.text().catch(() => "") + const error = new Error( + `v5 fetch ${res.status}: ${body.slice(0, 300)}` + ) as Error & { status: number } + error.status = res.status + throw error + } + + return (await res.json()) as T +} From e1a6f389c737cf0be3e8eb67be60ba97fcec912e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Mar=C4=8Di=C5=A1ovsk=C3=BD?= Date: Tue, 29 Sep 2026 18:23:46 +0200 Subject: [PATCH 3/9] feat: enable live preview --- apps/ui/src/lib/strapi-api/content/server.ts | 35 ++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/apps/ui/src/lib/strapi-api/content/server.ts b/apps/ui/src/lib/strapi-api/content/server.ts index 0ce4715..ba3329c 100644 --- a/apps/ui/src/lib/strapi-api/content/server.ts +++ b/apps/ui/src/lib/strapi-api/content/server.ts @@ -35,10 +35,34 @@ export const STRAPI_TAGS = { user: ["strapi:plugin::users-permissions.user"] as const, } as const +/** + * Copy request headers into a plain object. + * + * `fetchAPI` merges headers with `{ ...headers }`. That keeps entries on a + * plain object, and drops them when `headers` is a `Headers` instance or a + * list of `[name, value]` pairs. + */ +function headersToRecord( + headers: HeadersInit | undefined +): Record { + if (!headers) return {} + + if (headers instanceof Headers) { + return Object.fromEntries(headers.entries()) + } + + if (Array.isArray(headers)) { + return Object.fromEntries(headers) + } + + return { ...headers } +} + /** * Build a `RequestInit` that is safe to pass to the Strapi client: * - In draft mode, opt out of Next.js caching entirely (`cache: "no-store"`) - * so editors see live changes and draft content never seeds the public tag. + * so editors see live changes and draft content never seeds the public tag, + * and ask Strapi to encode live-preview source maps. * - Otherwise tag the fetch so it can be invalidated on demand. */ function withCacheTags( @@ -47,7 +71,14 @@ function withCacheTags( base?: RequestInit ): RequestInit { if (isDraftMode) { - return { ...base, cache: "no-store" } + return { + ...base, + cache: "no-store", + headers: { + ...headersToRecord(base?.headers), + "strapi-encode-source-maps": "true", + }, + } } const callerTags = base?.next?.tags ?? [] From 1856a67c4533c7ba30c6c613a8ec00fe3ba9bb74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Mar=C4=8Di=C5=A1ovsk=C3=BD?= Date: Tue, 29 Sep 2026 19:16:43 +0200 Subject: [PATCH 4/9] fix: preview bypass cookie --- apps/ui/src/app/api/exit-preview/route.ts | 9 ++++----- apps/ui/src/app/api/preview/route.ts | 3 +++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/ui/src/app/api/exit-preview/route.ts b/apps/ui/src/app/api/exit-preview/route.ts index 82e6eee..7145b4b 100644 --- a/apps/ui/src/app/api/exit-preview/route.ts +++ b/apps/ui/src/app/api/exit-preview/route.ts @@ -15,11 +15,9 @@ export async function GET(request: Request) { dm.disable() /** - * `dm.disable()` emits the deletion cookie with `sameSite=Lax`, which - * browsers reject in the cross-origin Strapi admin iframe — the same - * shortcoming `/api/preview` works around. Re-set the deletion with - * `sameSite=None` so exiting works from the iframe too (later `.set()` - * calls for the same cookie name override earlier pending ones). + * Re-set the deletion with the same attributes `/api/preview` uses. + * A partitioned cookie is only cleared by a deletion that is also + * `SameSite=None`, `Secure`, and `Partitioned`. */ const cookieStore = await cookies() cookieStore.set({ @@ -30,6 +28,7 @@ export async function GET(request: Request) { path: "/", secure: true, sameSite: "none", + partitioned: true, }) return NextResponse.redirect(resolveRedirectTarget(request)) diff --git a/apps/ui/src/app/api/preview/route.ts b/apps/ui/src/app/api/preview/route.ts index f4bdad6..ebbb2a4 100644 --- a/apps/ui/src/app/api/preview/route.ts +++ b/apps/ui/src/app/api/preview/route.ts @@ -67,6 +67,9 @@ export async function GET(request: Request) { path: "/", secure: true, sameSite: "none", // Allow cookie in cross-origin iframes + // Without this, browsers reject the cookie inside the Strapi + // admin iframe because that iframe is a different site from the frontend. + partitioned: true, }) // -------------------------------------------------------------------- // Check if the locale in the request is a correct frontend locale From b634328a563e3cd17dcf65e11f2240eb75a39d13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Mar=C4=8Di=C5=A1ovsk=C3=BD?= Date: Tue, 29 Sep 2026 22:27:50 +0200 Subject: [PATCH 5/9] fix: pricing switcher jumping --- .../plans/strapi-plan-pricing-cards/PricingSwitcher.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ui/src/components/page-builder/components/plans/strapi-plan-pricing-cards/PricingSwitcher.tsx b/apps/ui/src/components/page-builder/components/plans/strapi-plan-pricing-cards/PricingSwitcher.tsx index 964f508..b572093 100644 --- a/apps/ui/src/components/page-builder/components/plans/strapi-plan-pricing-cards/PricingSwitcher.tsx +++ b/apps/ui/src/components/page-builder/components/plans/strapi-plan-pricing-cards/PricingSwitcher.tsx @@ -39,7 +39,7 @@ export function PricingSwitcher({ >
{component.title && ( -

+

{component.title}

)} From f72963194eb61f2aa3d8b5f81914b8c01f13e658 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Mar=C4=8Di=C5=A1ovsk=C3=BD?= Date: Tue, 29 Sep 2026 23:10:50 +0200 Subject: [PATCH 6/9] feat: update the dependabot config --- .github/dependabot.yml | 44 ++++++++--------- apps/strapi/package.json | 2 +- packages/strapi-types/package.json | 2 +- pnpm-lock.yaml | 76 +++++++----------------------- 4 files changed, 38 insertions(+), 86 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c2c81c2..8d65cfa 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,30 +1,24 @@ -# Please see the documentation for all configuration options: -# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file - version: 2 updates: - package-ecosystem: "npm" - directory: "/" + directories: + - "/" + - "/apps/*" + - "/packages/*" + - "/qa/tests/playwright" + target-branch: "main" schedule: - interval: "weekly" + interval: "monthly" allow: - - dependency-type: "direct" - # Skip regular update PRs, do only security updates - # https://docs.github.com/en/code-security/reference/supply-chain-security/dependabot-options-reference#open-pull-requests-limit- - open-pull-requests-limit: 0 - - - package-ecosystem: "npm" - directory: "/apps/ui" - schedule: - interval: "weekly" - allow: - - dependency-type: "direct" - open-pull-requests-limit: 0 - - - package-ecosystem: "npm" - directory: "/apps/strapi" - schedule: - interval: "weekly" - allow: - - dependency-type: "direct" - open-pull-requests-limit: 0 + - dependency-name: "@strapi/*" + update-types: + - "version-update:semver-minor" + - "version-update:semver-patch" + groups: + strapi: + patterns: + - "@strapi/*" + group-by: "dependency-name" + update-types: + - "minor" + - "patch" diff --git a/apps/strapi/package.json b/apps/strapi/package.json index 69a5159..2adc6c1 100644 --- a/apps/strapi/package.json +++ b/apps/strapi/package.json @@ -39,7 +39,7 @@ "@strapi/plugin-color-picker": "5.52.2", "@strapi/plugin-sentry": "5.52.2", "@strapi/plugin-users-permissions": "5.52.2", - "@strapi/strapi": "5.52.2", + "@strapi/strapi": "catalog:", "@strapi/utils": "5.52.2", "jsonwebtoken": "9.0.3", "lodash": "catalog:", diff --git a/packages/strapi-types/package.json b/packages/strapi-types/package.json index d8b4c36..bd42d2c 100644 --- a/packages/strapi-types/package.json +++ b/packages/strapi-types/package.json @@ -22,6 +22,6 @@ "sync-types": "if [ -L ./generated ]; then echo 'generated is symlinked; skipping sync'; else mkdir -p ./generated && cp -R ../../apps/strapi/types/generated/. ./generated; fi" }, "devDependencies": { - "@strapi/strapi": "5.52.2" + "@strapi/strapi": "catalog:" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b358df5..f768dc9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,9 @@ settings: catalogs: default: + '@strapi/strapi': + specifier: 5.52.2 + version: 5.52.2 '@tailwindcss/postcss': specifier: ^4.3.1 version: 4.3.1 @@ -126,7 +129,7 @@ importers: specifier: 5.52.2 version: 5.52.2(aa34c8c923a2b329891e9468222240d1) '@strapi/strapi': - specifier: 5.52.2 + specifier: 'catalog:' version: 5.52.2(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.6)(@codemirror/search@6.7.0)(@codemirror/theme-one-dark@6.1.3)(@emotion/is-prop-valid@1.4.0)(@swc/helpers@0.5.23)(@types/hoist-non-react-statics@3.3.7(@types/react@19.2.17))(@types/node@24.13.2)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(codemirror@5.65.21)(esbuild@0.28.1)(koa@2.16.4)(lightningcss@1.32.0)(pg@8.22.0)(postcss@8.5.15)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.4.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.51.2)(ts-toolbelt@9.6.0)(type-fest@4.41.0) '@strapi/utils': specifier: 5.52.2 @@ -516,7 +519,7 @@ importers: packages/strapi-types: devDependencies: '@strapi/strapi': - specifier: 5.52.2 + specifier: 'catalog:' version: 5.52.2(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.6)(@codemirror/search@6.7.0)(@codemirror/theme-one-dark@6.1.3)(@emotion/is-prop-valid@1.4.0)(@swc/helpers@0.5.23)(@types/hoist-non-react-statics@3.3.7(@types/react@19.2.17))(@types/node@26.0.0)(@types/react@19.2.17)(codemirror@5.65.21)(esbuild@0.28.1)(koa@2.16.4)(lightningcss@1.32.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react-router-dom@6.30.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(redux@4.2.1)(styled-components@6.4.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(terser@5.51.2)(ts-toolbelt@9.6.0)(type-fest@4.41.0) packages/typescript-config: {} @@ -948,9 +951,6 @@ packages: '@codemirror/search@6.7.0': resolution: {integrity: sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==} - '@codemirror/state@6.7.0': - resolution: {integrity: sha512-Zbl9NyscLMZkfXPQnNAIIAFftidrA1UbcJEIMp24C0Bukc2I5T8wJS0wsXYsnDOqCFJUeJ1BITGNs5CqPDSmSg==} - '@codemirror/state@6.7.1': resolution: {integrity: sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==} @@ -9284,9 +9284,6 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - linkify-it@5.0.1: - resolution: {integrity: sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==} - linkify-it@5.0.2: resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} @@ -9499,10 +9496,6 @@ packages: markdown-it-sup@1.0.0: resolution: {integrity: sha512-E32m0nV9iyhRR7CrhnzL5msqic7rL1juWre6TQNxsnApg7Uf+F97JOKxUijg5YwXz86lZ0mqfOnutoryyNdntQ==} - markdown-it@14.2.0: - resolution: {integrity: sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==} - hasBin: true - markdown-it@14.3.0: resolution: {integrity: sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==} hasBin: true @@ -12261,11 +12254,6 @@ packages: resolution: {integrity: sha512-d79HhZya5Djd7am0q+W4RTsSU+D/aJzM+4Y4AGJGuGlgM2L6sx5ZvOYTmZjqPhrDrV6xJTtRSm1JCLj6V6LHLQ==} engines: {node: '>=14.16'} - terser@5.48.0: - resolution: {integrity: sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==} - engines: {node: '>=10'} - hasBin: true - terser@5.51.2: resolution: {integrity: sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==} engines: {node: '>=10'} @@ -12290,8 +12278,8 @@ packages: third-party-web@0.26.7: resolution: {integrity: sha512-buUzX4sXC4efFX6xg2bw6/eZsCUh8qQwSavC4D9HpONMFlRbcHhD8Je5qwYdCpViR6q0qla2wPP+t91a2vgolg==} - third-party-web@0.29.2: - resolution: {integrity: sha512-fegtha91tq2DHphyoiBXVHjVi2YG9zFaRnboT9C28tO1en9Y3wJsfspuy40F+u5wl3hHVbw7cnd1b67kEGHb8g==} + third-party-web@0.30.0: + resolution: {integrity: sha512-p+PfyL5U0ediGzvwPzMSdD9MnC8rHW6nWJJVLIQvqN7RsSqfaZ+7n4c9GILqH36EYR0eXB9CzAeCRnKFr/9uJg==} throttle-debounce@3.0.1: resolution: {integrity: sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg==} @@ -12961,10 +12949,6 @@ packages: webpack-hot-middleware@2.26.1: resolution: {integrity: sha512-khZGfAeJx6I8K9zKohEWWYN6KDlVw2DHownoe+6Vtwj1LP9WFgegXnVMSkZ/dBEBtXFwrkkydsaPFlB7f8wU2A==} - webpack-sources@3.5.0: - resolution: {integrity: sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==} - engines: {node: '>=10.13.0'} - webpack-sources@3.5.1: resolution: {integrity: sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==} engines: {node: '>=10.13.0'} @@ -13888,7 +13872,7 @@ snapshots: '@codemirror/autocomplete@6.20.3': dependencies: '@codemirror/language': 6.12.3 - '@codemirror/state': 6.7.0 + '@codemirror/state': 6.7.1 '@codemirror/view': 6.43.2 '@lezer/common': 1.5.2 @@ -13915,20 +13899,16 @@ snapshots: '@codemirror/lint@6.9.6': dependencies: - '@codemirror/state': 6.7.0 + '@codemirror/state': 6.7.1 '@codemirror/view': 6.43.2 crelt: 1.0.6 '@codemirror/search@6.7.0': dependencies: - '@codemirror/state': 6.7.0 + '@codemirror/state': 6.7.1 '@codemirror/view': 6.43.2 crelt: 1.0.6 - '@codemirror/state@6.7.0': - dependencies: - '@marijn/find-cluster-break': 1.0.2 - '@codemirror/state@6.7.1': dependencies: '@marijn/find-cluster-break': 1.0.2 @@ -13936,7 +13916,7 @@ snapshots: '@codemirror/theme-one-dark@6.1.3': dependencies: '@codemirror/language': 6.12.3 - '@codemirror/state': 6.7.0 + '@codemirror/state': 6.7.1 '@codemirror/view': 6.43.2 '@lezer/highlight': 1.2.3 @@ -15785,7 +15765,7 @@ snapshots: '@paulirish/trace_engine@0.0.53': dependencies: legacy-javascript: 0.0.1 - third-party-web: 0.29.2 + third-party-web: 0.30.0 '@phosphor-icons/react@2.1.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: @@ -22989,7 +22969,7 @@ snapshots: get-tsconfig: 4.14.0 loader-utils: 2.0.4 webpack: 5.109.2(esbuild@0.28.1)(lightningcss@1.32.0)(postcss@8.5.15) - webpack-sources: 3.5.0 + webpack-sources: 3.5.1 esbuild-register@3.5.0(esbuild@0.20.2): dependencies: @@ -24308,7 +24288,7 @@ snapshots: he: 1.2.0 param-case: 3.0.4 relateurl: 0.2.7 - terser: 5.48.0 + terser: 5.51.2 html-url-attributes@3.0.1: {} @@ -25332,10 +25312,6 @@ snapshots: lines-and-columns@1.2.4: {} - linkify-it@5.0.1: - dependencies: - uc.micro: 2.1.0 - linkify-it@5.0.2: dependencies: uc.micro: 2.1.0 @@ -25543,15 +25519,6 @@ snapshots: markdown-it-sup@1.0.0: {} - markdown-it@14.2.0: - dependencies: - argparse: 2.0.1 - entities: 4.5.0 - linkify-it: 5.0.1 - mdurl: 2.0.0 - punycode.js: 2.3.1 - uc.micro: 2.1.0 - markdown-it@14.3.0: dependencies: argparse: 2.0.1 @@ -29108,13 +29075,6 @@ snapshots: type-fest: 2.19.0 unique-string: 3.0.0 - terser@5.48.0: - dependencies: - '@jridgewell/source-map': 0.3.11 - acorn: 8.17.0 - commander: 2.20.3 - source-map-support: 0.5.21 - terser@5.51.2: dependencies: '@jridgewell/source-map': 0.3.11 @@ -29142,7 +29102,7 @@ snapshots: third-party-web@0.26.7: {} - third-party-web@0.29.2: {} + third-party-web@0.30.0: {} throttle-debounce@3.0.1: {} @@ -29385,7 +29345,7 @@ snapshots: dependencies: '@gerrit0/mini-shiki': 3.23.0 lunr: 2.3.9 - markdown-it: 14.2.0 + markdown-it: 14.3.0 minimatch: 10.2.5 typescript: 5.9.3 yaml: 2.9.0 @@ -29831,7 +29791,7 @@ snapshots: opener: 1.5.2 picocolors: 1.1.1 sirv: 3.0.2 - ws: 8.21.0 + ws: 8.21.2 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -29852,8 +29812,6 @@ snapshots: html-entities: 2.6.0 strip-ansi: 6.0.1 - webpack-sources@3.5.0: {} - webpack-sources@3.5.1: {} webpack@5.109.2(esbuild@0.28.1)(lightningcss@1.32.0)(postcss@8.5.15): From 557f5596856e7cd2783969cdac21ef152fcc3f47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Mar=C4=8Di=C5=A1ovsk=C3=BD?= Date: Mon, 5 Oct 2026 10:48:21 +0200 Subject: [PATCH 7/9] fix: ci --- .github/workflows/ci.yml | 1 + apps/ui/src/app/[locale]/[[...rest]]/page.tsx | 8 ++++-- apps/ui/src/app/[locale]/blog/[slug]/page.tsx | 8 ++++-- .../headless-cms/comparison/[slug]/page.tsx | 8 ++++-- .../app/[locale]/user-stories/[slug]/page.tsx | 8 ++++-- apps/ui/src/lib/build.ts | 28 ++++++++++++++----- 6 files changed, 46 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3481f4..9af535c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: branches: - main - dev + - staging concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} diff --git a/apps/ui/src/app/[locale]/[[...rest]]/page.tsx b/apps/ui/src/app/[locale]/[[...rest]]/page.tsx index fd7dd49..3a138bd 100644 --- a/apps/ui/src/app/[locale]/[[...rest]]/page.tsx +++ b/apps/ui/src/app/[locale]/[[...rest]]/page.tsx @@ -3,7 +3,11 @@ import type { Locale } from "next-intl" import { use } from "react" import { StrapiPageView } from "@/components/layouts/StrapiPageView" -import { createFallbackPath, debugStaticParams } from "@/lib/build" +import { + createFallbackPath, + debugStaticParams, + withExportFallback, +} from "@/lib/build" import { isDevelopment } from "@/lib/general-helpers" import { getMetadataFromStrapi } from "@/lib/metadata" import { fetchAllPages } from "@/lib/strapi-api/content/server" @@ -49,7 +53,7 @@ export async function generateStaticParams({ rest: ["fallback"], }) - return params.length > 0 ? params : [fallbackPath] + return withExportFallback(params, fallbackPath) } export async function generateMetadata( diff --git a/apps/ui/src/app/[locale]/blog/[slug]/page.tsx b/apps/ui/src/app/[locale]/blog/[slug]/page.tsx index 16269d0..2783a60 100644 --- a/apps/ui/src/app/[locale]/blog/[slug]/page.tsx +++ b/apps/ui/src/app/[locale]/blog/[slug]/page.tsx @@ -2,7 +2,11 @@ import type { Locale } from "next-intl" import { use } from "react" import { StrapiBlogPostView } from "@/components/layouts/StrapiBlogPostView" -import { createFallbackPath, debugStaticParams } from "@/lib/build" +import { + createFallbackPath, + debugStaticParams, + withExportFallback, +} from "@/lib/build" import { isDevelopment } from "@/lib/general-helpers" import { getBlogPostMetadata } from "@/lib/metadata" import { fetchAllBlogPosts } from "@/lib/strapi-api/content/server" @@ -36,7 +40,7 @@ export async function generateStaticParams({ slug: "fallback", }) - return params.length > 0 ? params : [fallbackPath] + return withExportFallback(params, fallbackPath) } export async function generateMetadata( diff --git a/apps/ui/src/app/[locale]/headless-cms/comparison/[slug]/page.tsx b/apps/ui/src/app/[locale]/headless-cms/comparison/[slug]/page.tsx index a2f5455..f345a96 100644 --- a/apps/ui/src/app/[locale]/headless-cms/comparison/[slug]/page.tsx +++ b/apps/ui/src/app/[locale]/headless-cms/comparison/[slug]/page.tsx @@ -2,7 +2,11 @@ import type { Locale } from "next-intl" import { use } from "react" import { CmsComparisonView } from "@/components/cms-comparison/CmsComparisonView" -import { createFallbackPath, debugStaticParams } from "@/lib/build" +import { + createFallbackPath, + debugStaticParams, + withExportFallback, +} from "@/lib/build" import { isDevelopment } from "@/lib/general-helpers" import { getCmsComparisonMetadata } from "@/lib/metadata" import { fetchAllCmsComparisons } from "@/lib/strapi-api/content/server" @@ -38,7 +42,7 @@ export async function generateStaticParams({ slug: "fallback", }) - return params.length > 0 ? params : [fallbackPath] + return withExportFallback(params, fallbackPath) } export async function generateMetadata( diff --git a/apps/ui/src/app/[locale]/user-stories/[slug]/page.tsx b/apps/ui/src/app/[locale]/user-stories/[slug]/page.tsx index ee7c308..3344420 100644 --- a/apps/ui/src/app/[locale]/user-stories/[slug]/page.tsx +++ b/apps/ui/src/app/[locale]/user-stories/[slug]/page.tsx @@ -1,7 +1,11 @@ import type { Locale } from "next-intl" import { CaseStudyView } from "@/components/case-study/CaseStudyView" -import { createFallbackPath, debugStaticParams } from "@/lib/build" +import { + createFallbackPath, + debugStaticParams, + withExportFallback, +} from "@/lib/build" import { isDevelopment } from "@/lib/general-helpers" import { getCaseStudyMetadata } from "@/lib/metadata" import { fetchAllCaseStudies } from "@/lib/strapi-api/content/server" @@ -37,7 +41,7 @@ export async function generateStaticParams({ slug: "fallback", }) - return params.length > 0 ? params : [fallbackPath] + return withExportFallback(params, fallbackPath) } interface RouteProps { diff --git a/apps/ui/src/lib/build.ts b/apps/ui/src/lib/build.ts index a321ec6..e01cfaa 100644 --- a/apps/ui/src/lib/build.ts +++ b/apps/ui/src/lib/build.ts @@ -34,11 +34,25 @@ export const debugStaticParams = ( * @example createFallbackPath("en", { slug: 'fallback', page: 'fallback' }) => {locale: "en", slug: "fallback", page: "fallback"} * @example createFallbackPath("en", { rest: ['fallback'] }) => {locale: "en", rest: ["fallback"]} */ -export const createFallbackPath = ( +export function createFallbackPath>( locale: Locale, - params: Record -) => ({ - locale, - rest: [], // to satisfy type checking for [[...rest]], but can be overridden via `params` - ...params, -}) + params: T +): { locale: Locale } & T { + return { + locale, + ...params, + } +} + +/** + * Static export builds need at least one param for a dynamic segment. + * Standalone builds should stay empty when Strapi returned no entries, so CI + * does not prerender a dummy path against a stopped Strapi. + */ +export function withExportFallback(entries: T[], fallback: T): T[] { + if (entries.length > 0) return entries + + if (getEnvVar("NEXT_OUTPUT") === "export") return [fallback] + + return [] +} From c8a6ce56b54ae7d8554a50477b9919df3a9b2507 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Mar=C4=8Di=C5=A1ovsk=C3=BD?= Date: Sun, 11 Oct 2026 19:08:12 +0200 Subject: [PATCH 8/9] fix: table of contents visual bugs --- .../components/blog/BlogTableOfContents.tsx | 153 ++++++++++++++---- .../elementary/markdown/BlogMarkdown.tsx | 4 +- .../components/layouts/StrapiBlogPostView.tsx | 7 +- 3 files changed, 130 insertions(+), 34 deletions(-) diff --git a/apps/ui/src/components/blog/BlogTableOfContents.tsx b/apps/ui/src/components/blog/BlogTableOfContents.tsx index 469a712..93556b5 100644 --- a/apps/ui/src/components/blog/BlogTableOfContents.tsx +++ b/apps/ui/src/components/blog/BlogTableOfContents.tsx @@ -1,41 +1,109 @@ "use client" -import { useEffect, useMemo, useRef, useState } from "react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" -import { BLOG_HEADER_OFFSET, type TocHeading } from "@/lib/blog-utils" +import type { TocHeading } from "@/lib/blog-utils" import { cn } from "@/lib/styles" +const SITE_HEADER_SELECTOR = '[data-slot="site-header"]' +/** Clearance under the sticky site header. Matches the old top-28 gap below an 80px navbar. */ +const TOC_HEADER_GAP = 32 +const TOC_OFFSET_FALLBACK = 112 +const OFFSET_CSS_VAR = "--blog-header-offset" + +function hashIdFromLocation() { + return decodeURIComponent(window.location.hash.replace(/^#/, "")) +} + +function scrollToHeading(id: string, behavior: ScrollBehavior, offset: number) { + const el = document.getElementById(id) + if (!el) return + + window.scrollTo({ + behavior, + top: el.getBoundingClientRect().top + window.scrollY - offset, + }) +} + export function BlogTableOfContents({ headings, }: { readonly headings: TocHeading[] }) { const [activeId, setActiveId] = useState(null) + const [offset, setOffset] = useState(TOC_OFFSET_FALLBACK) const rafRef = useRef(0) const activeIdRef = useRef(null) + const offsetRef = useRef(TOC_OFFSET_FALLBACK) + const correctedHashRef = useRef(false) const h2Headings = useMemo( () => headings.filter((h) => h.level === 2), [headings] ) + const headingIds = useMemo( + () => new Set(headings.map((heading) => heading.id)), + [headings] + ) + + const updateActive = useCallback(() => { + let current: string | null = null + const threshold = offsetRef.current + + for (const heading of h2Headings) { + const el = document.getElementById(heading.id) + if (el && el.getBoundingClientRect().top <= threshold) { + current = heading.id + } + } + + if (current !== activeIdRef.current) { + activeIdRef.current = current + setActiveId(current) + } + }, [h2Headings]) useEffect(() => { - const updateActive = () => { - let current: string | null = null + const header = document.querySelector(SITE_HEADER_SELECTOR) - for (const h of h2Headings) { - const el = document.getElementById(h.id) - if (el && el.getBoundingClientRect().top <= BLOG_HEADER_OFFSET) { - current = h.id - } + const applyOffset = () => { + const headerHeight = header?.getBoundingClientRect().height ?? 0 + const next = headerHeight + TOC_HEADER_GAP + const changed = offsetRef.current !== next + + offsetRef.current = next + document.documentElement.style.setProperty(OFFSET_CSS_VAR, `${next}px`) + + if (changed) { + setOffset(next) + updateActive() } - if (current !== activeIdRef.current) { - activeIdRef.current = current - setActiveId(current) + if (!correctedHashRef.current) { + correctedHashRef.current = true + const id = hashIdFromLocation() + if (headingIds.has(id)) { + scrollToHeading(id, "auto", next) + } } } + applyOffset() + + let observer: ResizeObserver | undefined + + if (header) { + observer = new ResizeObserver(applyOffset) + observer.observe(header) + } + + return () => { + observer?.disconnect() + document.documentElement.style.removeProperty(OFFSET_CSS_VAR) + } + }, [headingIds, updateActive]) + + useEffect(() => { const onScroll = () => { cancelAnimationFrame(rafRef.current) rafRef.current = requestAnimationFrame(updateActive) @@ -48,33 +116,56 @@ export function BlogTableOfContents({ window.removeEventListener("scroll", onScroll) cancelAnimationFrame(rafRef.current) } - }, [h2Headings]) + }, [updateActive]) + + useEffect(() => { + const onPopState = () => { + const id = hashIdFromLocation() + if (!headingIds.has(id)) return + + requestAnimationFrame(() => { + scrollToHeading(id, "smooth", offsetRef.current) + }) + } + + window.addEventListener("popstate", onPopState) + + return () => window.removeEventListener("popstate", onPopState) + }, [headingIds]) if (h2Headings.length === 0) { return null } return ( -