diff --git a/.changeset/weak-suns-enjoy.md b/.changeset/weak-suns-enjoy.md new file mode 100644 index 00000000000..70ed7ecceaa --- /dev/null +++ b/.changeset/weak-suns-enjoy.md @@ -0,0 +1,5 @@ +--- +"@tryghost/koenig-lexical": minor +--- + +Added an embedPreviewUrl card config option that previews embed cards in a renderer served from a separate origin diff --git a/.dockerignore b/.dockerignore index f6afbac9afe..a5df51d18d8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -32,10 +32,11 @@ compose.yml .codex .cursor -# NOTE: core/built/admin is intentionally NOT ignored — the production image's -# `full` stage COPYs it from the build context, where CI injects the admin build -# artifact. The `core` stage copies from the deploy stage (which never builds -# admin), so a stray local admin build cannot leak into the core image. +# NOTE: core/built/admin and core/built/embed-renderer are intentionally NOT +# ignored — the production image's `full` stage COPYs them from the build +# context, where CI injects the admin build artifact. The `core` stage copies +# from the deploy stage (which never builds either), so stray local builds cannot +# leak into the core image. # Ignore local config files (.json and .jsonc) ghost/core/config.local.json* diff --git a/.github/embed-renderer/404.html b/.github/embed-renderer/404.html new file mode 100644 index 00000000000..c3d8bb00cb1 --- /dev/null +++ b/.github/embed-renderer/404.html @@ -0,0 +1,11 @@ + + + + + +Not found + + +

Not found. This domain serves Ghost's embed preview renderer and nothing else.

+ + diff --git a/.github/embed-renderer/README.md b/.github/embed-renderer/README.md new file mode 100644 index 00000000000..ebc18aa32b4 --- /dev/null +++ b/.github/embed-renderer/README.md @@ -0,0 +1,36 @@ +# Embed renderer hosting + +The editor previews embed card html in +[`koenig/koenig-lexical/public/embed-renderer/`](../../koenig/koenig-lexical/public/embed-renderer/), +loaded from a domain that serves nothing else. Embed scripts run with that +domain's origin, so it must never share one with Ghost Admin, a Ghost site, or +anything holding cookies. Sites point at it with `security.embedPreviewUrl`. + +Self-hosted Ghost previews embeds from `public.ghostembeds.com`, deployed from +this repository. + +## Deploying + +CI deploys on pushes to `main` that touch the renderer. By hand: + +```bash +.github/embed-renderer/build.sh /tmp/embed-renderer +netlify deploy --prod --dir=/tmp/embed-renderer --no-build +``` + +The Netlify site has no linked repository and asset post-processing off, so the +renderer's inline script isn't rewritten. DNS: the host as a CNAME, an empty +apex, and no mail (no MX, SPF `-all`, DMARC `p=reject`). + +## Adding a version + +The renderer is versioned by its message protocol, not by Ghost release. Add +`v.html` alongside the existing files and keep every older version: editors +request the version they were built against, so each deploy ships all of them. + +## Rules for this domain + +- serve nothing but the renderer files; every other path 404s +- never set cookies, serve Ghost content, or add branding +- never list it in `security.txt`, OAuth redirects, CORS allowlists or a CSP + `script-src`: it runs arbitrary third-party code by design diff --git a/.github/embed-renderer/_headers b/.github/embed-renderer/_headers new file mode 100644 index 00000000000..caedde9343d --- /dev/null +++ b/.github/embed-renderer/_headers @@ -0,0 +1,6 @@ +/* + Content-Security-Policy: frame-ancestors https: + Referrer-Policy: strict-origin-when-cross-origin + X-Content-Type-Options: nosniff + Strict-Transport-Security: max-age=31536000; includeSubDomains + Cache-Control: public, max-age=3600 diff --git a/.github/embed-renderer/build.sh b/.github/embed-renderer/build.sh new file mode 100755 index 00000000000..b42192e0b89 --- /dev/null +++ b/.github/embed-renderer/build.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# +# Assembles the upload directory for public.ghostembeds.com. +# +# ./build.sh [output-directory] (default: ./deploy) +# +# It holds every renderer version, the _headers next to this script, and a 404 +# page. Upload the directory to Netlify, or deploy it with: +# +# netlify deploy --prod --dir= --no-build +# +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$here/../.." && pwd)" +renderer_dir="$repo_root/koenig/koenig-lexical/public/embed-renderer" +out_dir="${1:-$PWD/deploy}" + +if [ ! -d "$renderer_dir" ]; then + echo "renderer source missing: $renderer_dir" >&2 + exit 1 +fi + +rm -rf "$out_dir" +mkdir -p "$out_dir" + +cp "$renderer_dir"/v*.html "$out_dir/" +cp "$here/404.html" "$out_dir/" +cp "$here/_headers" "$out_dir/" + +echo "$out_dir" +for file in "$out_dir"/*; do + printf ' %s %s\n' "$(shasum -a 256 "$file" | cut -c1-16)" "$(basename "$file")" +done diff --git a/.github/renovate.json5 b/.github/renovate.json5 index a6fa357cab4..fe70e83ca4a 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -271,6 +271,15 @@ allowedVersions: '<9', }, + // Admin's Cmd-K search must match Ember's results, and Ember ships + // FlexSearch 0.7; 0.8 changes how titles are tokenized and ranked. + { + description: 'Cap the catalog flexsearch at 0.7 (matches Ember admin search)', + matchDepTypes: ['pnpm.catalog'], + matchPackageNames: ['flexsearch'], + allowedVersions: '<0.8', + }, + // Keep `@types/*` aligned with the runtime major they describe. Type defs // for a different major than what actually runs are silently wrong at best // (e.g. @types/express 5 vs Express 4) and build-breaking at worst @@ -283,7 +292,7 @@ // Node.js runtime declared in `engines`, not a dependency — so a hard // version cap is the only lever. Raise it when we bump the Node engine. { - description: 'Cap @types/node at the default Node major (devEngines: 22.23.1)', + description: 'Cap @types/node at the default Node major (devEngines: 22.23.3)', matchPackageNames: ['@types/node'], allowedVersions: '<23', }, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f29f7043d8a..1847befbbb2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ on: env: FORCE_COLOR: 1 HEAD_COMMIT: ${{ github.sha }} - NODE_VERSION: 22.23.1 + NODE_VERSION: 22.23.3 # Disable v8-compile-cache to prevent intermittent V8 deserializer crashes # when multiple parallel Nx workers race to read/write shared bytecode cache # files. The cache lives in /tmp and is discarded after each run anyway, @@ -228,7 +228,7 @@ jobs: # test:unit's Nx cache is keyed on `node -v` (nx.json) so each leg runs. id: node_matrix run: | - echo 'matrix=["22.23.1", "24.20.0"]' >> $GITHUB_OUTPUT + echo "matrix=[\"${NODE_VERSION}\", \"24.20.0\"]" >> $GITHUB_OUTPUT - name: Start Nx Cloud CI run run: pnpm nx start-ci-run @@ -1255,19 +1255,20 @@ jobs: echo "::error::IS_SHIPPING is set but VITE_SENTRY_AUTH_TOKEN is empty — Koenig sourcemaps would not reach Sentry" exit 1 fi - # Builds apps/admin/dist AND ghost/core/core/built/admin (asset-delivery). + # Builds apps/admin/dist, ghost/core/core/built/admin (asset-delivery), + # and ghost/core/core/built/embed-renderer (separate-origin previews). pnpm nx run @tryghost/admin:build - # The built admin (ghost/core/core/built/admin) is consumed by both job_pack - # (packed into the Ghost-CLI archive) and job_docker (COPYed into the full - # image). Ship it as a tarball to preserve file modes and speed transfer. + # The built admin and embed renderer are consumed by both job_pack (packed + # into the Ghost-CLI archive) and job_docker (COPYed into the full image). + # Ship them as a tarball to preserve file modes and speed transfer. # # --exclude '*.map': admin sourcemaps (~60MB) are uploaded to Sentry during # the build (IS_SHIPPING), not shipped in the image. ghost/core's `files` # field strips them from the Ghost-CLI archive (!core/built/**/*.map), so # excluding them here matches that for the Docker image too. - name: Pack admin build - run: tar --exclude='*.map' -czf admin-build.tar.gz -C ghost/core/core/built admin + run: tar --exclude='*.map' -czf admin-build.tar.gz -C ghost/core/core/built admin embed-renderer - name: Upload admin build artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -1346,7 +1347,8 @@ jobs: name: admin-build - name: Extract admin build - # The archive includes core/built/admin via ghost/core's files allowlist. + # The archive includes core/built/admin and core/built/embed-renderer via + # ghost/core's files allowlist. run: | mkdir -p ghost/core/core/built tar -xzf admin-build.tar.gz -C ghost/core/core/built @@ -1575,9 +1577,9 @@ jobs: path: ${{ runner.temp }}/admin-artifact - name: Extract admin build into context - # The full stage COPYs ghost/core/core/built/admin from the context; the - # deploy stage excludes core/built entirely, so admin never leaks into core - # and adding it is the only context change between the core and full builds. + # The full stage COPYs the admin and embed renderer from the context; the + # deploy stage excludes core/built entirely, so neither leaks into core and + # adding them is the only context change between the core and full builds. run: | mkdir -p ghost/core/core/built tar -xzf "${RUNNER_TEMP}/admin-artifact/admin-build.tar.gz" -C ghost/core/core/built @@ -1587,8 +1589,8 @@ jobs: env: BUILDKIT_PROGRESS: plain with: - # Same repo-root context as core (admin now present at - # ghost/core/core/built/admin, excluded by the deploy stage) so the + # Same repo-root context as core (admin and embed renderer now present + # under ghost/core/core/built, excluded by the deploy stage) so the # deploy/install/build layers cache-hit from the core build above. context: . file: Dockerfile.production diff --git a/.github/workflows/e2e-runner-image.yml b/.github/workflows/e2e-runner-image.yml index 4c8eea28117..f794ea09148 100644 --- a/.github/workflows/e2e-runner-image.yml +++ b/.github/workflows/e2e-runner-image.yml @@ -25,7 +25,7 @@ on: - 'package.json' env: - NODE_VERSION: 22.23.1 + NODE_VERSION: 22.23.3 permissions: contents: read diff --git a/.github/workflows/embed-renderer.yml b/.github/workflows/embed-renderer.yml new file mode 100644 index 00000000000..bb09a28e694 --- /dev/null +++ b/.github/workflows/embed-renderer.yml @@ -0,0 +1,59 @@ +name: Embed renderer + +# Deploys the embed renderer to public.ghostembeds.com, which serves +# self-hosted Ghost. See .github/embed-renderer/README.md. + +on: + push: + branches: [main] + paths: + - 'koenig/koenig-lexical/public/embed-renderer/**' + - '.github/embed-renderer/**' + - '.github/workflows/embed-renderer.yml' + workflow_dispatch: + +permissions: + contents: read + +env: + NODE_VERSION: 22.23.3 + +concurrency: + group: embed-renderer + cancel-in-progress: false + +jobs: + deploy: + name: Deploy public renderer + runs-on: ubuntu-latest + if: github.repository == 'TryGhost/Ghost' + steps: + - name: Checkout repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - uses: ./.github/actions/setup-node-pnpm + with: + node-version: ${{ env.NODE_VERSION }} + install: 'false' + store-cache: 'false' + + # every version ships on every deploy: a Netlify deploy replaces the site, + # and editors request the renderer version they were built against + - name: Build upload directory + run: .github/embed-renderer/build.sh "$RUNNER_TEMP/embed-renderer" + + # --ignore-scripts: a static --no-build deploy needs none of netlify-cli's + # dependency build scripts, which pnpm otherwise refuses to skip. + # Runs outside the checkout: from the repo root netlify-cli sees the pnpm + # workspace and refuses to deploy until one of its packages is picked + - name: Deploy to Netlify + working-directory: ${{ runner.temp }} + env: + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} + NETLIFY_SITE_ID: ${{ secrets.NETLIFY_EMBEDS_PUBLIC_SITE_ID }} + run: | + pnpm --ignore-scripts --package=netlify-cli@27.8.0 dlx netlify deploy \ + --prod \ + --no-build \ + --dir="$RUNNER_TEMP/embed-renderer" \ + --message "${GITHUB_SHA:0:7} via ${GITHUB_WORKFLOW}" diff --git a/.github/workflows/koenig-demo.yml b/.github/workflows/koenig-demo.yml index 71ca1d442f1..d69c050fa8e 100644 --- a/.github/workflows/koenig-demo.yml +++ b/.github/workflows/koenig-demo.yml @@ -19,7 +19,7 @@ concurrency: cancel-in-progress: true env: - NODE_VERSION: 22.23.1 + NODE_VERSION: 22.23.3 jobs: deploy: diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index b228371ccd8..b7a84938802 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -40,7 +40,7 @@ concurrency: cancel-in-progress: false env: - NODE_VERSION: 22.23.1 + NODE_VERSION: 22.23.3 jobs: publish: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0bbf7914053..f09b3bd4844 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,7 @@ on: env: FORCE_COLOR: 1 - NODE_VERSION: 22.23.1 + NODE_VERSION: 22.23.3 concurrency: group: ${{ github.workflow }} cancel-in-progress: false diff --git a/.node-version b/.node-version index f9e7451e771..de889f8c2e7 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -22.23.1 +22.23.3 diff --git a/.nvmrc b/.nvmrc index f9e7451e771..de889f8c2e7 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22.23.1 +22.23.3 diff --git a/.pnpmfile.mjs b/.pnpmfile.mjs index 511646e7b4e..2f24b8a7fa6 100644 --- a/.pnpmfile.mjs +++ b/.pnpmfile.mjs @@ -121,6 +121,11 @@ function readPackage(pkg) { */ async function updateConfig(config) { const { packages, versioning = {} } = config; + // `pnpm dlx` runs this hook without the workspace package list + if (!packages) { + return config; + } + const ignoredPackages = new Set(versioning.ignore ?? []); // step 1: enumerate all workspace packages with glob diff --git a/Dockerfile.production b/Dockerfile.production index 743b3c4ef9d..4beca238985 100644 --- a/Dockerfile.production +++ b/Dockerfile.production @@ -11,8 +11,8 @@ # Build context: the monorepo repo root. The deploy stage runs `pnpm deploy` # against the workspace to produce a self-contained app dir (source + resolved # node_modules), so this image no longer depends on the `pnpm pack` output that -# Ghost-CLI uses (scripts/pack.mjs). The `full` stage's admin build is injected -# into the context by CI at core/built/admin. +# Ghost-CLI uses (scripts/pack.mjs). The `full` stage's admin and embed renderer +# builds are injected into the context by CI under core/built. # # Ownership model: application code (node_modules, server source, admin) is owned # by nobody:nogroup so the runtime user (ghost, uid 1000) can read but not modify @@ -22,7 +22,7 @@ # duplicated node_modules (~600MB) and the admin build (~80MB) and inflated every # image pull. -ARG NODE_VERSION=22.23.1 +ARG NODE_VERSION=22.23.3 # ---- deploy: build closure + self-contained app dir (build-only, not shipped) ---- FROM node:$NODE_VERSION-bookworm-slim AS deploy @@ -35,15 +35,15 @@ RUN corepack enable # `ghost...` filter. .dockerignore prunes node_modules/build/dist/.git/.nx so the # build starts from a clean tree and rebuilds outputs in-container. # -# --exclude core/built: for the `full` target CI injects the admin build into the -# context at ghost/core/core/built/admin. Excluding the whole built dir (not just -# built/admin — the built dir itself is absent on a fresh checkout, so excluding -# only its child would still leave a differing directory entry) keeps this COPY -# layer's hash identical between the core and full builds. That lets `full` reuse -# `core`'s cached deploy/install/build layers instead of re-running the whole -# closure build, and keeps admin out of the deploy output so `core` stays -# admin-free. The server's own core/built output is regenerated in-container by the -# build steps below, so nothing is lost by excluding the host copy. +# --exclude core/built: for the `full` target CI injects the admin and embed +# renderer builds under ghost/core/core/built. Excluding the whole built dir (the +# directory itself is absent on a fresh checkout, so excluding only its children +# would still leave a differing directory entry) keeps this COPY layer's hash +# identical between the core and full builds. That lets `full` reuse `core`'s +# cached deploy/install/build layers instead of re-running the whole closure +# build, and keeps both artifacts out of the deploy output so `core` stays +# admin-free. The server's own core/built output is regenerated in-container by +# the build steps below, so nothing is lost by excluding the host copy. COPY --exclude=ghost/core/core/built . . # Install only ghost and its dependency subgraph (`ghost...`), not the whole @@ -66,8 +66,9 @@ RUN pnpm --filter-prod "ghost^..." -r run build && \ # Produce a self-contained deploy dir: ghost/core's `files` set + a fully resolved, # production-only node_modules (--prod drops devDeps, keeps optional better-sqlite3). -# Admin is never built here, so core/built/admin is absent — the core image stays -# admin-free and the full stage injects admin from the build context. +# Admin is never built here, so core/built/admin and core/built/embed-renderer are +# absent — the core image stays admin-free and the full stage injects both from +# the build context. # # inject-workspace-packages: hard-copy the workspace deps (kg-*/adapters/i18n) into # node_modules instead of linking them back to the workspace. The deploy dir is @@ -133,13 +134,14 @@ EXPOSE 2368 CMD ["node", "index.js"] -# ---- Full: core + admin ---- +# ---- Full: core + admin + embed renderer ---- FROM core AS full # COPY --chown sets ownership in the copy layer; a post-hoc `chown -R` would -# duplicate the ~80MB admin build into a second layer. Admin is injected into the -# build context by CI (downloaded from the admin build job) at -# ghost/core/core/built/admin — the deploy stage excludes that path, so this is the -# only stage that carries admin. Local `full` builds must populate that path first -# (e.g. `pnpm nx run @tryghost/admin:build`). +# duplicate the ~80MB admin build into a second layer. Admin and the embed +# renderer are injected into the build context by CI (downloaded from the admin +# build job) under ghost/core/core/built — the deploy stage excludes that path, +# so this is the only stage that carries them. Local `full` builds must populate +# both paths first (e.g. `pnpm nx run @tryghost/admin:build`). COPY --chown=nobody:nogroup ghost/core/core/built/admin core/built/admin +COPY --chown=nobody:nogroup ghost/core/core/built/embed-renderer core/built/embed-renderer diff --git a/apps/admin-x-framework/src/api/config.ts b/apps/admin-x-framework/src/api/config.ts index 210743aac12..25135a8ca26 100644 --- a/apps/admin-x-framework/src/api/config.ts +++ b/apps/admin-x-framework/src/api/config.ts @@ -58,6 +58,11 @@ export type Config = { max?: number; error?: string; }; + emails?: { + maxPeriodic?: number; + disabled?: boolean; + error?: string; + }; customThemes?: { allowlist?: string[]; error?: string; @@ -87,6 +92,9 @@ export type Config = { upgradeUrl?: string; // Destination for the banner's upgrade button }; }; + subscription?: { + start?: string; // ISO date that anchors monthly periodic limits + }; billing?: { enabled?: boolean; url?: string; @@ -135,6 +143,8 @@ export type Config = { }; security?: { staffDeviceVerification?: boolean; + // directory serving the Koenig embed renderer on a separate origin + embedPreviewUrl?: string; }; featurebase?: { enabled?: boolean; diff --git a/apps/admin-x-framework/src/api/pages.ts b/apps/admin-x-framework/src/api/pages.ts index 48ad4dafd32..416a3f4db9d 100644 --- a/apps/admin-x-framework/src/api/pages.ts +++ b/apps/admin-x-framework/src/api/pages.ts @@ -14,6 +14,7 @@ import { buildPostReadParams, serializePostPayload, } from './post-contract'; +import { tagsDataType } from './tags'; import type { CreateContentData, EditContentData, @@ -115,13 +116,14 @@ export interface EditPagePayload { sessionExpiryRedirect?: boolean; } +// A tag sent without an id is created by the save itself, so tag lists go stale too. export const useAddPage = createMutation({ method: 'POST', path: () => '/pages/', searchParams: ({ options }) => buildPageWriteParams(options), body: ({ page }) => ({ pages: [serializePostPayload(page, 'page')] }), requestOptions: ({ sessionExpiryRedirect }) => ({ sessionExpiryRedirect }), - invalidateQueries: { dataType }, + invalidateQueries: { dataType: [dataType, tagsDataType] }, }); export const useEditPage = createMutation({ @@ -130,7 +132,7 @@ export const useEditPage = createMutation({ searchParams: ({ options }) => buildPageWriteParams(options), body: ({ page }) => ({ pages: [serializePostPayload(page, 'page')] }), requestOptions: ({ sessionExpiryRedirect }) => ({ sessionExpiryRedirect }), - invalidateQueries: { dataType }, + invalidateQueries: { dataType: [dataType, tagsDataType] }, }); export interface DeletePagePayload { diff --git a/apps/admin-x-framework/src/api/posts.ts b/apps/admin-x-framework/src/api/posts.ts index 8251627ce09..ae9b86dce64 100644 --- a/apps/admin-x-framework/src/api/posts.ts +++ b/apps/admin-x-framework/src/api/posts.ts @@ -14,6 +14,7 @@ import { buildPostWriteParams, serializePostPayload, } from './post-contract'; +import { tagsDataType } from './tags'; import type { CreateContentData, EditContentData, @@ -132,13 +133,14 @@ export interface EditPostPayload { sessionExpiryRedirect?: boolean; } +// A tag sent without an id is created by the save itself, so tag lists go stale too. export const useAddPost = createMutation({ method: 'POST', path: () => '/posts/', searchParams: ({ options }) => buildPostWriteParams(options), body: ({ post }) => ({ posts: [serializePostPayload(post)] }), requestOptions: ({ sessionExpiryRedirect }) => ({ sessionExpiryRedirect }), - invalidateQueries: { dataType }, + invalidateQueries: { dataType: [dataType, tagsDataType] }, }); export const useEditPost = createMutation({ @@ -147,7 +149,7 @@ export const useEditPost = createMutation({ searchParams: ({ options }) => buildPostWriteParams(options), body: ({ post }) => ({ posts: [serializePostPayload(post)] }), requestOptions: ({ sessionExpiryRedirect }) => ({ sessionExpiryRedirect }), - invalidateQueries: { dataType }, + invalidateQueries: { dataType: [dataType, tagsDataType] }, }); export interface DeletePostPayload { diff --git a/apps/admin-x-framework/src/api/tags.ts b/apps/admin-x-framework/src/api/tags.ts index 3541dce207c..35a109c0719 100644 --- a/apps/admin-x-framework/src/api/tags.ts +++ b/apps/admin-x-framework/src/api/tags.ts @@ -39,6 +39,8 @@ export interface TagsResponseType { const dataType = 'TagsResponseType'; +export const tagsDataType = dataType; + const useBrowseTagsQuery = createInfiniteQuery({ dataType, path: '/tags/', diff --git a/apps/admin-x-framework/src/hooks/use-limiter.ts b/apps/admin-x-framework/src/hooks/use-limiter.ts index 55d070307f0..c2d1122c801 100644 --- a/apps/admin-x-framework/src/hooks/use-limiter.ts +++ b/apps/admin-x-framework/src/hooks/use-limiter.ts @@ -63,7 +63,23 @@ export const useLimiter = (): Limiter => { return noOpLimiter; } - const limits = { ...config.hostSettings.limits } as Record; + // A subscription without a start can't anchor a period, so it's treated as absent + const subscriptionStart = config.hostSettings.subscription?.start; + const subscription = subscriptionStart + ? { startDate: subscriptionStart, interval: 'month' as const } + : undefined; + + // Periodic limits need a subscription to build, and registration stops at the first + // limit that throws, so without one they're skipped to keep the rest working + const limits = Object.fromEntries( + Object.entries(config.hostSettings.limits).filter(([name, limit]) => { + if (!subscription && limit && Object.prototype.hasOwnProperty.call(limit, 'maxPeriodic')) { + console.warn(`Skipping ${name} limit: periodic limits need hostSettings.subscription`); // eslint-disable-line no-console + return false; + } + return true; + }), + ) as Record; const limiter = new LimitService(); if (limits.staff) { @@ -99,6 +115,7 @@ export const useLimiter = (): Limiter => { limiter.loadLimits({ limits, + subscription, helpLink, errors: { HostLimitError, diff --git a/apps/admin-x-framework/src/utils/api/update-queries.ts b/apps/admin-x-framework/src/utils/api/update-queries.ts index 78622fe0045..558971c715b 100644 --- a/apps/admin-x-framework/src/utils/api/update-queries.ts +++ b/apps/admin-x-framework/src/utils/api/update-queries.ts @@ -1,5 +1,11 @@ import { InfiniteData } from '@tanstack/react-query'; +// A plain Pages response is `{pages: Page[]}`, so `pages` can't identify InfiniteData; `pageParams` can. +const isInfiniteData = (data: unknown): data is InfiniteData => + typeof data === 'object' && + data !== null && + Array.isArray((data as { pageParams?: unknown }).pageParams); + export const insertToQueryCache = ( field: string, recordsToInsert?: (response: ResponseData) => unknown[], @@ -13,8 +19,8 @@ export const insertToQueryCache = ( recordsToInsert || ((response: ResponseData) => (response as Record)[field]); - if (typeof currentData === 'object' && 'pages' in currentData) { - const { pages } = currentData as InfiniteData; + if (isInfiniteData(currentData)) { + const { pages } = currentData; const lastPage = pages[pages.length - 1]; return { ...currentData, @@ -54,8 +60,8 @@ export const updateQueryCache = ( const updated = getRecords(newData); - if (typeof currentData === 'object' && 'pages' in currentData) { - const { pages } = currentData as InfiniteData; + if (isInfiniteData(currentData)) { + const { pages } = currentData; return { ...currentData, pages: pages.map((page) => ({ @@ -87,8 +93,8 @@ export const deleteFromQueryCache = ( const deletedIds = idsFromPayload?.(payload) || [payload as string]; - if (typeof currentData === 'object' && 'pages' in currentData) { - const { pages } = currentData as InfiniteData; + if (isInfiniteData(currentData)) { + const { pages } = currentData; return { ...currentData, pages: pages.map((page) => ({ diff --git a/apps/admin-x-framework/test/unit/api/pages.test.tsx b/apps/admin-x-framework/test/unit/api/pages.test.tsx index ec907df2cf8..cfb461d6b16 100644 --- a/apps/admin-x-framework/test/unit/api/pages.test.tsx +++ b/apps/admin-x-framework/test/unit/api/pages.test.tsx @@ -1,7 +1,8 @@ import { act, waitFor } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; -import { renderHookWithProviders } from '../../../src/test/test-utils'; +import { describe, expect, it, vi } from 'vitest'; +import { createTestQueryClient, renderHookWithProviders } from '../../../src/test/test-utils'; import { useAddPage, useEditPage, useEditorPage, usePage } from '../../../src/api/pages'; +import { tagsDataType } from '../../../src/api/tags'; import { withMockFetch } from '../../utils/mock-fetch'; // The Ember editor's exact include list — page writes re-request it too @@ -133,4 +134,33 @@ describe('pages api', () => { }); }); }); + + it('invalidates tag queries after a create or an edit, either of which can create a tag', async () => { + const queryClient = createTestQueryClient(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + await withMockFetch({}, async () => { + const { result } = renderHookWithProviders( + () => ({ add: useAddPage(), edit: useEditPage() }), + { + queryClient, + }, + ); + + await act(async () => { + await result.current.add.mutateAsync({ + page: { title: '(Untitled)', tags: [{ name: 'New' }] }, + }); + }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: [tagsDataType] }); + + invalidateSpy.mockClear(); + await act(async () => { + await result.current.edit.mutateAsync({ + page: { id: 'page-1', tags: [{ name: 'New' }], updated_at: '2026-01-01T00:00:00.000Z' }, + }); + }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: [tagsDataType] }); + }); + }); }); diff --git a/apps/admin-x-framework/test/unit/api/posts.test.tsx b/apps/admin-x-framework/test/unit/api/posts.test.tsx index d32dae1e46f..49583cbc3c9 100644 --- a/apps/admin-x-framework/test/unit/api/posts.test.tsx +++ b/apps/admin-x-framework/test/unit/api/posts.test.tsx @@ -8,6 +8,7 @@ import { useImportContentCSV, usePost, } from '../../../src/api/posts'; +import { tagsDataType } from '../../../src/api/tags'; import { withMockFetch } from '../../utils/mock-fetch'; // The Ember editor's exact include list — writes must re-request everything @@ -306,6 +307,35 @@ describe('posts api', () => { }); }); + it('invalidates tag queries after a create or an edit, either of which can create a tag', async () => { + const queryClient = createTestQueryClient(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + await withMockFetch({}, async () => { + const { result } = renderHookWithProviders( + () => ({ add: useAddPost(), edit: useEditPost() }), + { + queryClient, + }, + ); + + await act(async () => { + await result.current.add.mutateAsync({ + post: { title: '(Untitled)', tags: [{ name: 'New' }] }, + }); + }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: [tagsDataType] }); + + invalidateSpy.mockClear(); + await act(async () => { + await result.current.edit.mutateAsync({ + post: { id: 'post-1', tags: [{ name: 'New' }], updated_at: '2026-01-01T00:00:00.000Z' }, + }); + }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: [tagsDataType] }); + }); + }); + it('requests a mobiledoc conversion with convert_to_lexical', async () => { await withMockFetch({}, async (mock) => { const { result } = renderHookWithProviders(() => useEditPost()); diff --git a/apps/admin-x-framework/test/unit/hooks/use-limiter.test.ts b/apps/admin-x-framework/test/unit/hooks/use-limiter.test.ts new file mode 100644 index 00000000000..73d0e0075a9 --- /dev/null +++ b/apps/admin-x-framework/test/unit/hooks/use-limiter.test.ts @@ -0,0 +1,56 @@ +import { renderHook } from '@testing-library/react'; +import { useLimiter } from '../../../src/hooks/use-limiter'; + +vi.mock('../../../src/api/config', () => ({ useBrowseConfig: vi.fn() })); +vi.mock('../../../src/api/users', () => ({ + useBrowseUsers: () => ({ data: { users: [] }, isLoading: false }), +})); +vi.mock('../../../src/api/invites', () => ({ + useBrowseInvites: () => ({ data: { invites: [] }, isLoading: false }), +})); +vi.mock('../../../src/api/roles', () => ({ + useBrowseRoles: () => ({ data: { roles: [] }, isLoading: false }), +})); +vi.mock('../../../src/api/members', () => ({ useBrowseMembers: () => ({ refetch: vi.fn() }) })); +vi.mock('../../../src/api/newsletters', () => ({ + useBrowseNewsletters: () => ({ refetch: vi.fn() }), +})); + +import { useBrowseConfig } from '../../../src/api/config'; + +const withHostSettings = (hostSettings: unknown) => { + vi.mocked(useBrowseConfig).mockReturnValue({ + data: { config: { hostSettings } }, + } as ReturnType); +}; + +describe('useLimiter', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('loads periodic limits when the host sets a subscription start', () => { + withHostSettings({ + subscription: { start: '2026-09-01T00:00:00.000Z' }, + limits: { emails: { maxPeriodic: 300 }, customIntegrations: { disabled: true } }, + }); + + const { result } = renderHook(() => useLimiter()); + + expect(result.current.isLimited('emails')).toBe(true); + expect(result.current.isLimited('customIntegrations')).toBe(true); + }); + + it('skips periodic limits without a subscription and keeps the rest', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + withHostSettings({ + limits: { emails: { maxPeriodic: 300 }, customIntegrations: { disabled: true } }, + }); + + const { result } = renderHook(() => useLimiter()); + + expect(result.current.isLimited('emails')).toBe(false); + expect(result.current.isLimited('customIntegrations')).toBe(true); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('Skipping emails limit')); + }); +}); diff --git a/apps/admin-x-framework/test/unit/utils/api/update-queries.test.ts b/apps/admin-x-framework/test/unit/utils/api/update-queries.test.ts index 12b0b27e5fd..1b1b076e43f 100644 --- a/apps/admin-x-framework/test/unit/utils/api/update-queries.test.ts +++ b/apps/admin-x-framework/test/unit/utils/api/update-queries.test.ts @@ -29,12 +29,32 @@ describe('cache update functions', () => { const currentData = { pages: [{ posts: [{ id: '1' }] }, { posts: [{ id: '2' }] }], + pageParams: [undefined, 2], }; const result = insertToQueryCache('posts')(newData, currentData); expect(result).toEqual({ pages: [{ posts: [{ id: '1' }] }, { posts: [{ id: '2' }, { id: '3' }] }], + pageParams: [undefined, 2], + }); + }); + + it('appends to a non-paginated pages response', () => { + const newData = { + pages: [{ id: '2' }], + }; + + const currentData = { + pages: [{ id: '1' }], + meta: { pagination: { total: 1 } }, + }; + + const result = insertToQueryCache('pages')(newData, currentData); + + expect(result).toEqual({ + pages: [{ id: '1' }, { id: '2' }], + meta: { pagination: { total: 1 } }, }); }); }); @@ -63,12 +83,86 @@ describe('cache update functions', () => { const currentData = { pages: [{ posts: [{ id: '1' }] }, { posts: [{ id: '2', title: 'Old Title' }] }], + pageParams: [undefined, 2], }; const result = updateQueryCache('posts')(newData, currentData); expect(result).toEqual({ pages: [{ posts: [{ id: '1' }] }, { posts: [{ id: '2', title: 'New Title' }] }], + pageParams: [undefined, 2], + }); + }); + + it('updates a non-paginated users response', () => { + const newData = { + users: [{ id: '2', name: 'New Name' }], + }; + + const currentData = { + users: [ + { id: '1', name: 'Other' }, + { id: '2', name: 'Old Name' }, + ], + meta: { pagination: { total: 2 } }, + }; + + const result = updateQueryCache('users')(newData, currentData); + + expect(result).toEqual({ + users: [ + { id: '1', name: 'Other' }, + { id: '2', name: 'New Name' }, + ], + meta: { pagination: { total: 2 } }, + }); + }); + + it('updates a non-paginated pages response', () => { + const newData = { + pages: [{ id: '2', title: 'New Title' }], + }; + + const currentData = { + pages: [ + { id: '1', title: 'About' }, + { id: '2', title: 'Old Title' }, + ], + meta: { pagination: { total: 2 } }, + }; + + const result = updateQueryCache('pages')(newData, currentData); + + expect(result).toEqual({ + pages: [ + { id: '1', title: 'About' }, + { id: '2', title: 'New Title' }, + ], + meta: { pagination: { total: 2 } }, + }); + }); + + it('updates nested records in paginated pages queries', () => { + const newData = { + pages: [{ id: '2', title: 'New Title' }], + }; + + const currentData = { + pages: [ + { pages: [{ id: '1', title: 'About' }] }, + { pages: [{ id: '2', title: 'Old Title' }] }, + ], + pageParams: [undefined, 2], + }; + + const result = updateQueryCache('pages')(newData, currentData); + + expect(result).toEqual({ + pages: [ + { pages: [{ id: '1', title: 'About' }] }, + { pages: [{ id: '2', title: 'New Title' }] }, + ], + pageParams: [undefined, 2], }); }); }); @@ -89,12 +183,62 @@ describe('cache update functions', () => { it('deletes nested records in paginated queries', () => { const currentData = { pages: [{ posts: [{ id: '1' }] }, { posts: [{ id: '2' }] }], + pageParams: [undefined, 2], }; const result = deleteFromQueryCache('posts')(null, currentData, '2'); expect(result).toEqual({ pages: [{ posts: [{ id: '1' }] }, { posts: [] }], + pageParams: [undefined, 2], + }); + }); + + it('deletes from a non-paginated users response', () => { + const currentData = { + users: [{ id: '1' }, { id: '2' }], + meta: { pagination: { total: 2 } }, + }; + + const result = deleteFromQueryCache('users')(null, currentData, '2'); + + expect(result).toEqual({ + users: [{ id: '1' }], + meta: { pagination: { total: 2 } }, + }); + }); + + it('deletes from a non-paginated pages response', () => { + const currentData = { + pages: [ + { id: '1', title: 'About' }, + { id: '2', title: 'Contact' }, + ], + meta: { pagination: { total: 2 } }, + }; + + const result = deleteFromQueryCache('pages')(null, currentData, '2'); + + expect(result).toEqual({ + pages: [{ id: '1', title: 'About' }], + meta: { pagination: { total: 2 } }, + }); + }); + + it('deletes nested records in paginated pages queries', () => { + const currentData = { + pages: [ + { pages: [{ id: '1', title: 'About' }] }, + { pages: [{ id: '2', title: 'Contact' }] }, + ], + pageParams: [undefined, 2], + }; + + const result = deleteFromQueryCache('pages')(null, currentData, '2'); + + expect(result).toEqual({ + pages: [{ pages: [{ id: '1', title: 'About' }] }, { pages: [] }], + pageParams: [undefined, 2], }); }); }); diff --git a/apps/admin/package.json b/apps/admin/package.json index 3159d52840f..e47466b21f7 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -52,6 +52,7 @@ "clsx": "catalog:", "dequal": "catalog:", "dompurify": "catalog:", + "flexsearch": "catalog:", "i18n-iso-countries": "7.14.0", "jszip": "3.10.1", "lucide-react": "catalog:", @@ -155,7 +156,8 @@ ], "outputs": [ "{projectRoot}/dist", - "{workspaceRoot}/ghost/core/core/built/admin" + "{workspaceRoot}/ghost/core/core/built/admin", + "{workspaceRoot}/ghost/core/core/built/embed-renderer" ], "dependsOn": [ "^build", diff --git a/apps/admin/src/app-root.tsx b/apps/admin/src/app-root.tsx index d14bf9f1622..c64458b4e47 100644 --- a/apps/admin/src/app-root.tsx +++ b/apps/admin/src/app-root.tsx @@ -18,8 +18,7 @@ function ThemedAdminApp() { const { resolvedTheme } = useThemeContext(); const { pathname } = useLocation(); const isEmberOwnedRoute = useIsEmberOwnedRoute(pathname); - const isAdmin7 = - useFeatureFlag('admin7Pill') && !/^\/editor(?:\/|$)/.test(pathname) && !isEmberOwnedRoute; + const isAdmin7 = useFeatureFlag('admin7Pill') && !isEmberOwnedRoute; return ( = ({ value, placeholder, className const { fetchAutocompleteLinks, searchLinks } = useEmailLinkSuggestions(); const fetchEmbed = useKoenigFetchEmbed(); const klipyConfig = config?.klipy?.apiKey ? config.klipy : null; + const embedPreviewUrl = config?.security?.embedPreviewUrl || undefined; const [transistorEnabled] = getSettingValues(settings, ['transistor']); const cardConfig = useMemo( @@ -129,6 +130,7 @@ const EmailEditor: React.FC = ({ value, placeholder, className unsplash: unsplashConfig, pinturaConfig, klipy: klipyConfig, + embedPreviewUrl, fetchEmbed, fetchAutocompleteLinks, searchLinks, @@ -141,6 +143,7 @@ const EmailEditor: React.FC = ({ value, placeholder, className unsplashConfig, pinturaConfig, klipyConfig, + embedPreviewUrl, fetchEmbed, fetchAutocompleteLinks, searchLinks, diff --git a/apps/admin/src/editor/card-config.test.ts b/apps/admin/src/editor/card-config.test.ts index 46bedde77d6..e2492c486cb 100644 --- a/apps/admin/src/editor/card-config.test.ts +++ b/apps/admin/src/editor/card-config.test.ts @@ -185,6 +185,16 @@ describe('buildPostCardConfig', () => { expect(cardConfig.klipy).toEqual(klipy); }); + it('passes the embed preview url through from the security config', () => { + const embedPreviewUrl = 'https://embeds.example.net/'; + + expect(buildPostCardConfig(sources(), ports).embedPreviewUrl).toBeUndefined(); + expect( + buildPostCardConfig(sources({ config: { ...config, security: { embedPreviewUrl } } }), ports) + .embedPreviewUrl, + ).toBe(embedPreviewUrl); + }); + it('hides labels from contributors', () => { const cardConfig = buildPostCardConfig(sources({ currentUser: contributor }), ports); diff --git a/apps/admin/src/editor/card-config.ts b/apps/admin/src/editor/card-config.ts index 2b3a79fd113..c0d319ded53 100644 --- a/apps/admin/src/editor/card-config.ts +++ b/apps/admin/src/editor/card-config.ts @@ -60,6 +60,7 @@ export interface PostCardConfig extends PostCardConfigPorts { unsplash: Record | null; klipy: NonNullable | null; pinturaConfig: { jsUrl: string; cssUrl: string } | null; + embedPreviewUrl: string | undefined; renderLabels: boolean; feature: { transistor: boolean; paywallImprovements: boolean }; deprecated: { headerV1: boolean }; @@ -117,6 +118,7 @@ export function buildPostCardConfig( unsplash: getSettingValue(settings, 'unsplash') ? sources.unsplashHeaders : null, klipy: config.klipy?.apiKey ? config.klipy : null, pinturaConfig: sources.pinturaConfig, + embedPreviewUrl: config.security?.embedPreviewUrl || undefined, fetchAutocompleteLinks: ports.fetchAutocompleteLinks, fetchEmbed: ports.fetchEmbed, fetchLabels: ports.fetchLabels, diff --git a/apps/admin/src/editor/editor-header-actions.tsx b/apps/admin/src/editor/editor-header-actions.tsx index 2e71d15d5a4..f2352c79034 100644 --- a/apps/admin/src/editor/editor-header-actions.tsx +++ b/apps/admin/src/editor/editor-header-actions.tsx @@ -1,5 +1,7 @@ import { useCallback, useState } from 'react'; import { Button } from '@tryghost/shade/components'; +import { useShade } from '@tryghost/shade/app'; +import { PageHeader } from '@tryghost/shade/patterns'; import { Inline, Text } from '@tryghost/shade/primitives'; import { getSettingValue } from '@tryghost/admin-x-framework/api/settings'; import { useFeatureFlag } from '@tryghost/admin-x-framework/hooks'; @@ -62,6 +64,7 @@ export function EditorHeaderActions({ siteUrl, tkCount, }: EditorHeaderActionsProps) { + const { isAdmin7 } = useShade(); const { persistedId, publishTime, title } = session; const record = session.loadedRecord; const [previewOpen, setPreviewOpen] = useState(false); @@ -118,13 +121,17 @@ export function EditorHeaderActions({ return ( {isDraft ? ( - + ) : null} {isContributor ? ( <> - {isDraft ? : null} @@ -173,6 +180,7 @@ function PublishActions({ onOpenFlow, onPreview, }: PublishActionsProps) { + const { isAdmin7 } = useShade(); const inputs = usePublishInputs(); const { data: settingsData } = useEditorSettings(); const siteTitle = getSettingValue(settingsData?.settings ?? null, 'title') ?? undefined; @@ -214,7 +222,11 @@ function PublishActions({ <> {isDraft ? ( <> - {inputs.error ? ( @@ -227,7 +239,7 @@ function PublishActions({ > {inputs.error.message} - @@ -242,14 +254,18 @@ function PublishActions({ <> {/* Ember routes a sent post to the update flow from its status line, not the header. */} {post.status === 'sent' ? null : ( - )} diff --git a/apps/admin/src/editor/editor-screen.tsx b/apps/admin/src/editor/editor-screen.tsx index a49d084c3af..74e742240e6 100644 --- a/apps/admin/src/editor/editor-screen.tsx +++ b/apps/admin/src/editor/editor-screen.tsx @@ -3,7 +3,8 @@ import { AdminLink } from '@/shared/admin-link'; import { NotFound } from '@/shared/not-found'; import { Navigate, useNavigate, useParams } from '@tryghost/admin-x-framework'; import { Button, LoadingIndicator } from '@tryghost/shade/components'; -import { DirtyConfirmDialog } from '@tryghost/shade/patterns'; +import { useShade } from '@tryghost/shade/app'; +import { DirtyConfirmDialog, PageHeader } from '@tryghost/shade/patterns'; import { Inline, Stack, Text } from '@tryghost/shade/primitives'; import { LucideIcon } from '@tryghost/shade/utils'; import { APIError } from '@tryghost/admin-x-framework/errors'; @@ -65,11 +66,12 @@ function EditorLoadError({ message, onRetry }: { message: string; onRetry: () => } function EditorHeader({ postType, children }: { postType: PostType; children?: ReactNode }) { + const { isAdmin7 } = useShade(); const listLabel = postType === 'page' ? 'Pages' : 'Posts'; return ( - - + + { await expect.element(editorScreen.settingsTagsField()).toHaveTextContent('Culture'); }); + it('refetches the site’s tags once a save has created one', async () => { + const { saveApi } = fakeTaggablePost(); + const tagsApi = fakeTags([NEWS]); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openSidebar(); + await openTagList(); + + await editorScreen.settingsTagsInput().fill('Culture'); + // Offered only once the search for the typed name has answered. + await expect.element(editorScreen.settingsTagOption('Create “Culture”')).toBeVisible(); + const browsesBefore = tagsApi.requests.length; + await editorScreen.settingsTagOption('Create “Culture”').click(); + + await expect.poll(() => saveApi.requests.length, POLL).toBe(1); + // Without it, every tag list — the posts list's tag filter too — keeps + // serving a cache without the new tag for the five-minute staleTime. + await expect.poll(() => tagsApi.requests.length, POLL).toBeGreaterThan(browsesBefore); + }); + it('drops an uncommitted term when the list closes', async () => { fakeTaggablePost(); fakeTags([NEWS]); diff --git a/apps/admin/src/editor/preview/post-preview-modal.tsx b/apps/admin/src/editor/preview/post-preview-modal.tsx index 7e5e8178e3a..f2f50a0c0b1 100644 --- a/apps/admin/src/editor/preview/post-preview-modal.tsx +++ b/apps/admin/src/editor/preview/post-preview-modal.tsx @@ -22,6 +22,7 @@ import { usePaidMembersEnabled, } from '@tryghost/admin-x-framework/api/settings'; import { Inline } from '@tryghost/shade/primitives'; +import { PageHeader } from '@tryghost/shade/patterns'; import { LucideIcon } from '@tryghost/shade/utils'; import { toast } from 'sonner'; import { useBrowseNewsletters } from '@tryghost/admin-x-framework/api/newsletters'; @@ -325,6 +326,7 @@ export function PostPreviewModal({ )} { @@ -387,27 +389,27 @@ export function PostPreviewModal({ )} - - + {previewActionsAvailable ? ( - + ) : ( - + )} ) : null} - + } layout="header" diff --git a/apps/admin/src/global-search/search-providers.test.ts b/apps/admin/src/global-search/search-providers.test.ts new file mode 100644 index 00000000000..6758a6fb85c --- /dev/null +++ b/apps/admin/src/global-search/search-providers.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from 'vitest'; +import { + type SearchContent, + createBasicSearchProvider, + createFlexSearchProvider, + createSearchProvider, +} from './search-providers'; +import { type SearchIndexItem, type SearchResultGroup, getSearchables } from './searchables'; + +const content: SearchContent = { + user: [{ id: 'u1', slug: 'first-user', name: 'First user' }], + tag: [{ id: 't1', slug: 'first-tag', name: 'First tag' }], + post: [ + { id: 'p1', title: 'First post', status: 'published' }, + { id: 'p2', title: 'Second post', status: 'draft' }, + { id: 'p3', title: 'Third post', status: 'scheduled' }, + ], + page: [{ id: 'g1', title: 'First page', status: 'draft' }], +}; + +const billingSearch = { + groupName: 'Acme Hosting', + items: [ + { + id: 'change-plan', + title: 'Change plan', + path: '/plans', + keywords: 'billing subscription plan price', + }, + { + id: 'setup-custom-domain', + title: 'Set up a custom domain', + path: '/domain', + keywords: 'billing domain dns cname', + }, + { + id: 'request-backup', + title: 'Request backup', + path: '/backups', + keywords: 'billing backup restore data', + }, + ], +}; + +const withBilling = getSearchables({ billing: { search: billingSearch } }); + +const postsTitled = (titles: string[]): SearchIndexItem[] => + titles.map((title, index) => ({ id: `p${index}`, title, status: 'published' })); + +const groupNames = (groups: SearchResultGroup[]) => groups.map((group) => group.groupName); +const titles = (groups: SearchResultGroup[], groupName: string) => + groups.find((group) => group.groupName === groupName)?.options.map((option) => option.title); + +describe.each([ + ['flex', createFlexSearchProvider], + ['basic', createBasicSearchProvider], +])('%s search provider', (_name, create) => { + const search = (term: string) => create(getSearchables(), content).search(term); + + it('groups matches in display order', () => { + const results = search('first'); + + expect(groupNames(results)).toEqual(['Staff', 'Tags', 'Posts', 'Pages']); + expect(results.map((group) => group.options[0].id)).toEqual([ + 'user.first-user', + 'tag.first-tag', + 'post.p1', + 'page.g1', + ]); + }); + + it('orders posts scheduled, then draft, then published', () => { + expect(titles(search('post'), 'Posts')).toEqual(['Third post', 'Second post', 'First post']); + }); + + it('ignores case', () => { + expect(groupNames(search('FIRST'))).toEqual(['Staff', 'Tags', 'Posts', 'Pages']); + }); + + it.each(['', ' '])('returns nothing for the blank term %j', (term) => { + expect(search(term)).toEqual([]); + }); + + it('returns nothing when nothing matches', () => { + expect(search('nothing matches this')).toEqual([]); + }); + + it('caps each group at 100 results', () => { + const posts = postsTitled(Array.from({ length: 150 }, (_, index) => `Post ${index}`)); + const [group] = create(getSearchables(), { post: posts }).search('post'); + + expect(group.options).toHaveLength(100); + }); + + describe('billing results', () => { + const searchBilling = (term: string) => create(withBilling, content).search(term); + + it('lists billing results before content results', () => { + const groups = create(withBilling, { + post: postsTitled(['Backup post']), + }).search('backup'); + + expect(groupNames(groups)).toEqual(['Acme Hosting', 'Posts']); + expect(groups[0].groupKey).toBe('billing'); + expect(groups[0].options[0]).toMatchObject({ + id: 'pro-page.request-backup', + title: 'Request backup', + path: '/backups', + }); + }); + + it('lists billing results in the configured order', () => { + expect(titles(searchBilling('billing'), 'Acme Hosting')).toEqual([ + 'Change plan', + 'Set up a custom domain', + 'Request backup', + ]); + }); + + it('matches billing results on keywords', () => { + expect(titles(searchBilling('dns'), 'Acme Hosting')).toEqual(['Set up a custom domain']); + expect(titles(searchBilling('price'), 'Acme Hosting')).toEqual(['Change plan']); + }); + }); +}); + +describe('flex search provider matching', () => { + const search = (term: string, posts: SearchIndexItem[] = content.post ?? []) => + titles(createFlexSearchProvider(getSearchables(), { post: posts }).search(term), 'Posts'); + + it('matches the start of any word', () => { + expect(search('sec')).toEqual(['Second post']); + expect(search('pos')).toEqual(['Third post', 'Second post', 'First post']); + }); + + it('does not match inside a word', () => { + expect(search('econd')).toBeUndefined(); + }); + + it('matches every word of a multi-word term in any order', () => { + expect(search('post second')).toEqual(['Second post']); + }); + + it('keeps numbers whole', () => { + const posts = postsTitled(['Top100 tips', 'Best of 2024']); + + expect(search('top1', posts)).toEqual(['Top100 tips']); + expect(search('4', posts)).toBeUndefined(); + }); + + it('does not fold diacritics or collapse repeated letters', () => { + const posts = postsTitled(['Café', 'Coffee', 'Apple']); + + expect(search('cafe', posts)).toBeUndefined(); + expect(search('cofee', posts)).toBeUndefined(); + expect(search('aa', posts)).toBeUndefined(); + }); +}); + +describe('basic search provider matching', () => { + const search = (term: string) => + titles(createBasicSearchProvider(getSearchables(), content).search(term), 'Posts'); + + it('matches anywhere inside the title', () => { + expect(search('econd')).toEqual(['Second post']); + }); + + it('matches the term as one contiguous string', () => { + expect(search('post second')).toBeUndefined(); + expect(search('second post')).toEqual(['Second post']); + }); + + it('sorts by status before capping', () => { + const posts = [ + ...postsTitled(Array.from({ length: 150 }, (_, index) => `Post ${index}`)), + { id: 'd1', title: 'Draft post', status: 'draft' }, + ]; + const [group] = createBasicSearchProvider(getSearchables(), { post: posts }).search('post'); + + expect(group.options).toHaveLength(100); + expect(group.options[0].title).toBe('Draft post'); + }); +}); + +describe('createSearchProvider', () => { + // only the basic provider matches inside a word + const matchesInsideWords = (locale: string | null | undefined) => + createSearchProvider(getSearchables(), locale, { post: postsTitled(['Second post']) }).search( + 'econd', + ).length > 0; + + it.each(['en', 'en-GB', 'EN', null, undefined])('uses FlexSearch for the %s locale', (locale) => { + expect(matchesInsideWords(locale)).toBe(false); + }); + + it.each(['de', 'fr-CA', 'ja', ''])('uses substring matching for the %j locale', (locale) => { + expect(matchesInsideWords(locale)).toBe(true); + }); +}); diff --git a/apps/admin/src/global-search/search-providers.ts b/apps/admin/src/global-search/search-providers.ts new file mode 100644 index 00000000000..ac2998bb566 --- /dev/null +++ b/apps/admin/src/global-search/search-providers.ts @@ -0,0 +1,139 @@ +import FlexSearch from 'flexsearch'; +import { + type SearchIndexItem, + type SearchItem, + type SearchResult, + type SearchResultGroup, + type Searchable, + type SearchableModel, + createSearchResult, + sortSearchResultsByStatus, +} from './searchables'; + +/** `search-index/*` entries by model. Billing items come from config, not content. */ +export type SearchContent = Partial< + Record, SearchIndexItem[]> +>; + +export interface SearchProvider { + search(term: string): SearchResultGroup[]; +} + +// every option is rendered, so each group is capped +const RESULT_LIMIT = 100; + +function itemsFor(searchable: Searchable, content: SearchContent): SearchItem[] { + if (searchable.model === 'pro-page') { + return searchable.staticItems ?? []; + } + + return content[searchable.model] ?? []; +} + +function groupResults( + searchables: Searchable[], + match: (searchable: Searchable) => SearchResult[], +): SearchResultGroup[] { + const groups: SearchResultGroup[] = []; + + searchables.forEach((searchable) => { + const options = sortSearchResultsByStatus(match(searchable), searchable.model).slice( + 0, + RESULT_LIMIT, + ); + + if (options.length > 0) { + groups.push({ groupName: searchable.name, groupKey: searchable.key, options }); + } + }); + + return groups; +} + +/** Matches word prefixes in any order, ranked by FlexSearch. */ +export function createFlexSearchProvider( + searchables: Searchable[], + content: SearchContent, +): SearchProvider { + const indexes = new Map( + searchables.map((searchable) => { + const index = new FlexSearch.Document({ + tokenize: 'forward', + document: { id: 'id', index: searchable.index, store: true }, + }); + itemsFor(searchable, content).forEach((item) => index.add(item)); + return [searchable.model, index] as const; + }), + ); + + return { + search(term) { + return groupResults(searchables, (searchable) => { + const seen = new Set(); + const results: SearchResult[] = []; + + indexes + .get(searchable.model) + ?.search(term, RESULT_LIMIT, { enrich: true }) + .forEach((field) => { + field.result.forEach(({ doc }) => { + if (seen.has(doc.id)) { + return; + } + + seen.add(doc.id); + results.push(createSearchResult(searchable, doc)); + }); + }); + + return results; + }); + }, + }; +} + +/** Matches the term as a case-insensitive substring, in content order. */ +export function createBasicSearchProvider( + searchables: Searchable[], + content: SearchContent, +): SearchProvider { + const resultsByModel = new Map( + searchables.map((searchable) => [ + searchable.model, + itemsFor(searchable, content).map((item) => createSearchResult(searchable, item)), + ]), + ); + + return { + search(term) { + if (!term.trim()) { + return []; + } + + const needle = term.toLowerCase(); + + return groupResults(searchables, (searchable) => { + const keywordsIndexed = searchable.index.includes('keywords'); + + return (resultsByModel.get(searchable.model) ?? []).filter( + (result) => + result.title.toLowerCase().includes(needle) || + (keywordsIndexed && Boolean(result.keywords?.toLowerCase().includes(needle))), + ); + }); + }, + }; +} + +/** FlexSearch's word tokenizer only suits English; substring matching works for any language. */ +export function createSearchProvider( + searchables: Searchable[], + locale: string | null | undefined, + content: SearchContent, +): SearchProvider { + const isEnglish = locale?.toLowerCase().startsWith('en') ?? true; + + return isEnglish + ? createFlexSearchProvider(searchables, content) + : createBasicSearchProvider(searchables, content); +} diff --git a/apps/admin/src/global-search/searchables.test.ts b/apps/admin/src/global-search/searchables.test.ts new file mode 100644 index 00000000000..b8952093029 --- /dev/null +++ b/apps/admin/src/global-search/searchables.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from 'vitest'; +import { + BILLING_SEARCH_GROUP_KEY, + type SearchResult, + getSearchables, + parseSearchIndexItems, + sortSearchResultsByStatus, +} from './searchables'; + +const billingItem = (overrides: Record = {}) => ({ + id: 'change-plan', + title: 'Change plan', + path: '/plans', + keywords: 'billing subscription', + ...overrides, +}); + +const withBillingSearch = (search: unknown) => ({ billing: { search } }); + +describe('getSearchables', () => { + it('returns the built-in groups in display order', () => { + expect(getSearchables().map((searchable) => searchable.name)).toEqual([ + 'Staff', + 'Tags', + 'Posts', + 'Pages', + ]); + }); + + it('places a configured billing group between tags and posts', () => { + const searchables = getSearchables( + withBillingSearch({ groupName: 'Acme Hosting', items: [billingItem()] }), + ); + + expect(searchables.map((searchable) => searchable.name)).toEqual([ + 'Staff', + 'Tags', + 'Acme Hosting', + 'Posts', + 'Pages', + ]); + expect(searchables[2]).toMatchObject({ + key: BILLING_SEARCH_GROUP_KEY, + staticItems: [billingItem()], + }); + }); + + it('trims the billing group name', () => { + const [, , billing] = getSearchables( + withBillingSearch({ groupName: ' Acme Hosting ', items: [billingItem()] }), + ); + + expect(billing.name).toBe('Acme Hosting'); + }); + + it.each([ + ['no search config', undefined], + ['a non-object search config', 'Acme Hosting'], + ['a missing group name', { items: [billingItem()] }], + ['a blank group name', { groupName: ' ', items: [billingItem()] }], + ['a built-in group name', { groupName: 'Posts', items: [billingItem()] }], + ['a padded built-in group name', { groupName: ' Posts ', items: [billingItem()] }], + ['no items', { groupName: 'Acme Hosting', items: [] }], + ['a non-array items value', { groupName: 'Acme Hosting', items: billingItem() }], + ])('omits the billing group for %s', (_description, search) => { + const names = getSearchables(withBillingSearch(search)).map((searchable) => searchable.name); + + expect(names).toEqual(['Staff', 'Tags', 'Posts', 'Pages']); + }); + + it.each([ + ['an absolute URL', { path: 'https://example.com' }], + ['a relative path', { path: 'plans' }], + ['a query string', { path: '/plans?intent=upgrade' }], + ['a fragment', { path: '/plans#top' }], + ['whitespace', { path: '/my plans' }], + ['a trailing slash', { path: '/support/' }], + ['a missing id', { id: '' }], + ['a missing title', { title: undefined }], + ])('drops configured items with %s', (_description, overrides) => { + const [, , billing] = getSearchables( + withBillingSearch({ + groupName: 'Acme Hosting', + items: [billingItem({ id: 'valid', title: 'Valid' }), billingItem(overrides)], + }), + ); + + expect(billing.staticItems?.map((item) => item.id)).toEqual(['valid']); + }); + + it('skips configured items that are not objects', () => { + const [, , billing] = getSearchables( + withBillingSearch({ groupName: 'Acme Hosting', items: [null, 'plans', billingItem()] }), + ); + + expect(billing.staticItems?.map((item) => item.id)).toEqual(['change-plan']); + }); + + it('keeps the billing app root path', () => { + const [, , billing] = getSearchables( + withBillingSearch({ groupName: 'Acme Hosting', items: [billingItem({ path: '/' })] }), + ); + + expect(billing.staticItems?.[0].path).toBe('/'); + }); + + it('strips unknown fields and defaults missing or non-string keywords to an empty string', () => { + const [, , billing] = getSearchables( + withBillingSearch({ + groupName: 'Acme Hosting', + items: [ + billingItem({ keywords: ['billing'], extra: 'ignored' }), + billingItem({ id: 'no-keywords', keywords: undefined }), + ], + }), + ); + + expect(billing.staticItems).toEqual([ + { id: 'change-plan', title: 'Change plan', path: '/plans', keywords: '' }, + { id: 'no-keywords', title: 'Change plan', path: '/plans', keywords: '' }, + ]); + }); +}); + +describe('parseSearchIndexItems', () => { + it('keeps well-formed entries without the fields search does not use', () => { + const items = parseSearchIndexItems([ + { + id: 'p1', + uuid: 'u-1', + title: 'Hello', + slug: 'hello', + status: 'draft', + url: 'https://site.test/hello/', + visibility: 'public', + }, + { id: 't1', slug: 'news', name: 'News' }, + ]); + + expect(items).toEqual([ + { id: 'p1', title: 'Hello', slug: 'hello', status: 'draft' }, + { id: 't1', slug: 'news', name: 'News' }, + ]); + }); + + it.each([ + ['a missing id', { title: 'Hello' }], + ['a numeric id', { id: 1, title: 'Hello' }], + ['a numeric title', { id: 'p1', title: 2024 }], + ['a null name', { id: 't1', name: null }], + ['a non-object entry', 'p1'], + ])('drops entries with %s', (_description, entry) => { + expect(parseSearchIndexItems([entry, { id: 'ok', title: 'Kept' }])).toEqual([ + { id: 'ok', title: 'Kept' }, + ]); + }); + + it('returns nothing for a response that is not a list', () => { + expect(parseSearchIndexItems({ posts: [] })).toEqual([]); + }); +}); + +describe('sortSearchResultsByStatus', () => { + const result = (title: string, status?: string): SearchResult => ({ + id: title, + title, + status, + groupName: 'Posts', + }); + + it('orders posts and pages scheduled, draft, published, sent, then anything else', () => { + const results = [ + result('other', 'unknown'), + result('sent', 'sent'), + result('published', 'published'), + result('draft', 'draft'), + result('scheduled', 'scheduled'), + ]; + + for (const model of ['post', 'page'] as const) { + expect(sortSearchResultsByStatus(results, model).map(({ title }) => title)).toEqual([ + 'scheduled', + 'draft', + 'published', + 'sent', + 'other', + ]); + } + }); + + it('keeps the incoming order within a status', () => { + const results = [result('b', 'draft'), result('a', 'draft')]; + + expect(sortSearchResultsByStatus(results, 'post').map(({ title }) => title)).toEqual([ + 'b', + 'a', + ]); + }); + + it('leaves other models in their incoming order', () => { + const results = [result('published', 'published'), result('draft', 'draft')]; + + expect(sortSearchResultsByStatus(results, 'tag').map(({ title }) => title)).toEqual([ + 'published', + 'draft', + ]); + }); +}); diff --git a/apps/admin/src/global-search/searchables.ts b/apps/admin/src/global-search/searchables.ts new file mode 100644 index 00000000000..3e252db0b48 --- /dev/null +++ b/apps/admin/src/global-search/searchables.ts @@ -0,0 +1,174 @@ +import { z } from 'zod'; + +export const BILLING_SEARCH_GROUP_KEY = 'billing'; + +export type SearchableModel = 'user' | 'tag' | 'pro-page' | 'post' | 'page'; + +const searchIndexItemSchema = z.object({ + id: z.string(), + slug: z.string().optional(), + name: z.string().optional(), + title: z.string().optional(), + status: z.string().optional(), +}); + +/** An entry from a `search-index/*` endpoint. */ +export type SearchIndexItem = z.output; + +/** A search-index entry, or a configured billing item. */ +export type SearchItem = SearchIndexItem & { path?: string; keywords?: string }; + +function parseEach(schema: z.ZodType, items: unknown[]): T[] { + return items.flatMap((item) => { + const parsed = schema.safeParse(item); + return parsed.success ? [parsed.data] : []; + }); +} + +/** Keeps the entries of a `search-index/*` response that match the expected shape. */ +export function parseSearchIndexItems(items: unknown): SearchIndexItem[] { + return Array.isArray(items) ? parseEach(searchIndexItemSchema, items) : []; +} + +export interface Searchable { + name: string; + key?: string; + model: SearchableModel; + idField: 'id' | 'slug'; + titleField: 'name' | 'title'; + index: Array<'name' | 'title' | 'keywords'>; + staticItems?: SearchItem[]; +} + +export interface SearchResult { + id: string; + path?: string; + title: string; + keywords?: string; + groupName: string; + groupKey?: string; + status?: string; +} + +export interface SearchResultGroup { + groupName: string; + groupKey?: string; + options: SearchResult[]; +} + +const STAFF: Searchable = { + name: 'Staff', + model: 'user', + idField: 'slug', + titleField: 'name', + index: ['name'], +}; + +const TAGS: Searchable = { + name: 'Tags', + model: 'tag', + idField: 'slug', + titleField: 'name', + index: ['name'], +}; + +const POSTS: Searchable = { + name: 'Posts', + model: 'post', + idField: 'id', + titleField: 'title', + index: ['title'], +}; + +const PAGES: Searchable = { + name: 'Pages', + model: 'page', + idField: 'id', + titleField: 'title', + index: ['title'], +}; + +const BUILT_IN_GROUP_NAMES = [STAFF, TAGS, POSTS, PAGES].map((searchable) => searchable.name); + +/** Host config defines the billing group: `{groupName, items: [{id, title, path, keywords}]}`. */ +const billingSearchConfigSchema = z.object({ + // a built-in name would put two groups under one heading + groupName: z + .string() + .trim() + .min(1) + .refine((name) => !BUILT_IN_GROUP_NAMES.includes(name)), + items: z.array(z.unknown()), +}); + +const billingSearchItemSchema = z.object({ + id: z.string().min(1), + title: z.string().min(1), + // a billing app route: no query, fragment, whitespace, or trailing slash + path: z + .string() + .regex(/^\/[^?#\s]*$/) + .refine((path) => path === '/' || !path.endsWith('/')), + keywords: z.string().catch(''), +}); + +function getBillingSearchable(searchConfig: unknown): Searchable | null { + const config = billingSearchConfigSchema.safeParse(searchConfig); + + if (!config.success) { + return null; + } + + const staticItems = parseEach(billingSearchItemSchema, config.data.items); + + if (staticItems.length === 0) { + return null; + } + + return { + name: config.data.groupName, + key: BILLING_SEARCH_GROUP_KEY, + model: 'pro-page', + idField: 'id', + titleField: 'title', + index: ['title', 'keywords'], + staticItems, + }; +} + +export function getSearchables(hostSettings?: { billing?: { search?: unknown } }): Searchable[] { + const billing = getBillingSearchable(hostSettings?.billing?.search); + + return billing ? [STAFF, TAGS, billing, POSTS, PAGES] : [STAFF, TAGS, POSTS, PAGES]; +} + +const STATUS_PRIORITY: Record = { + scheduled: 1, + draft: 2, + published: 3, + sent: 4, +}; + +export function sortSearchResultsByStatus( + results: SearchResult[], + model: SearchableModel, +): SearchResult[] { + if (model !== 'post' && model !== 'page') { + return results; + } + + const priority = (result: SearchResult) => STATUS_PRIORITY[result.status ?? ''] ?? 5; + return [...results].sort((a, b) => priority(a) - priority(b)); +} + +export function createSearchResult(searchable: Searchable, item: SearchItem): SearchResult { + return { + id: `${searchable.model}.${item[searchable.idField]}`, + path: item.path, + title: item[searchable.titleField] ?? '', + keywords: item.keywords, + groupName: searchable.name, + groupKey: searchable.key, + status: item.status, + }; +} diff --git a/apps/admin/src/global-search/use-global-search.component.test.tsx b/apps/admin/src/global-search/use-global-search.component.test.tsx new file mode 100644 index 00000000000..a54e7ea5e94 --- /dev/null +++ b/apps/admin/src/global-search/use-global-search.component.test.tsx @@ -0,0 +1,300 @@ +import { describe, expect, it } from 'vitest'; +import { page } from 'vitest/browser'; +import { renderHook } from 'vitest-browser-react'; +import { useQueryClient } from '@tanstack/react-query'; +import { postsDataType } from '@tryghost/admin-x-framework/api/posts'; +import { type User, useEditUser } from '@tryghost/admin-x-framework/api/users'; +import type { StaffRoleName } from '@tryghost/test-data'; + +import { + InAppProviders, + configResponse, + currentUserResponse, + fakeAdminEndpoint, + settingsResponse, + staffRole, +} from '@test-utils/acceptance'; + +import { useGlobalSearch } from '@/global-search/use-global-search'; + +type IndexKey = 'posts' | 'pages' | 'tags' | 'users'; + +const DEBOUNCE_ELAPSED_MS = 300; + +const billingSearch = { + groupName: 'Acme Hosting', + items: [{ id: 'change-plan', title: 'Change plan', path: '/plans', keywords: 'billing' }], +}; + +function deferred() { + let release = () => {}; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +} + +const wait = (ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +function fakeSession({ + locale = 'en', + role = 'Owner', + hostSettings, + currentUserReady, +}: { + locale?: string; + role?: StaffRoleName; + hostSettings?: Record; + currentUserReady?: Promise; +} = {}) { + const config = configResponse(); + config.config.hostSettings = hostSettings; + fakeAdminEndpoint('GET', /^\/config\/(?:\?.*)?$/, config); + fakeAdminEndpoint('GET', /^\/settings\/\?/, settingsResponse({ settings: { locale } })); + + const me = currentUserResponse(); + me.users[0].roles = [staffRole({ name: role })]; + fakeAdminEndpoint('GET', /^\/users\/me\/\?include=roles$/, async () => { + await currentUserReady; + return me; + }); +} + +function fakeSearchIndex( + lists: Partial unknown[] | Promise>> = {}, +) { + const defaults: Record unknown[]> = { + posts: () => [ + { id: 'p1', title: 'First post', status: 'published', url: 'https://site.test/p/' }, + ], + pages: () => [{ id: 'g1', title: 'First page', status: 'draft' }], + tags: () => [{ id: 't1', slug: 'first-tag', name: 'First tag' }], + users: () => [{ id: 'u1', slug: 'first-user', name: 'First user' }], + }; + + const endpoint = (key: IndexKey) => { + const list = lists[key] ?? defaults[key]; + return fakeAdminEndpoint('GET', `/search-index/${key}/`, async () => ({ + [key]: await list(), + })); + }; + + return { + posts: endpoint('posts'), + pages: endpoint('pages'), + tags: endpoint('tags'), + users: endpoint('users'), + }; +} + +async function renderSearch(term = '') { + return await renderHook( + (props?: { term: string }) => ({ + search: useGlobalSearch(props?.term ?? ''), + queryClient: useQueryClient(), + editUser: useEditUser(), + }), + { wrapper: InAppProviders, initialProps: { term } }, + ); +} + +type SearchHook = Awaited>; + +const groupNames = (hook: SearchHook) => + hook.result.current.search.results.map((group) => group.groupName); + +async function settledGroupNames(hook: SearchHook) { + await expect.poll(() => hook.result.current.search.isLoading).toBe(false); + return groupNames(hook); +} + +describe('useGlobalSearch', () => { + it('loads the index only once a term is typed', async () => { + fakeSession(); + const index = fakeSearchIndex(); + const hook = await renderSearch(); + + await expect.poll(() => hook.result.current.queryClient.isFetching()).toBe(0); + expect(index.posts.requests).toHaveLength(0); + expect(hook.result.current.search.isLoading).toBe(false); + + await hook.rerender({ term: 'first' }); + + expect(await settledGroupNames(hook)).toEqual(['Staff', 'Tags', 'Posts', 'Pages']); + expect(index.posts.requests).toHaveLength(1); + }); + + it('keeps the previous results while the next term is debounced, then reuses the index', async () => { + fakeSession(); + const index = fakeSearchIndex(); + const hook = await renderSearch('first'); + expect(await settledGroupNames(hook)).toHaveLength(4); + + await hook.rerender({ term: 'tag' }); + + expect(groupNames(hook)).toHaveLength(4); + expect(hook.result.current.search.isLoading).toBe(true); + expect(await settledGroupNames(hook)).toEqual(['Tags']); + expect(index.posts.requests).toHaveLength(1); + expect(index.tags.requests).toHaveLength(1); + }); + + it('returns nothing as soon as the term is cleared', async () => { + fakeSession(); + fakeSearchIndex(); + const hook = await renderSearch('first'); + expect(await settledGroupNames(hook)).toHaveLength(4); + + await hook.rerender({ term: '' }); + + expect(groupNames(hook)).toEqual([]); + expect(hook.result.current.search.isLoading).toBe(false); + }); + + it('reloads a list after its resource is invalidated', async () => { + fakeSession(); + let posts = [{ id: 'p1', title: 'First post', status: 'published' }]; + const index = fakeSearchIndex({ posts: () => posts }); + const hook = await renderSearch('renamed'); + expect(await settledGroupNames(hook)).toEqual([]); + + posts = [{ id: 'p1', title: 'Renamed post', status: 'published' }]; + await hook.result.current.queryClient.invalidateQueries({ queryKey: [postsDataType] }); + + expect(await settledGroupNames(hook)).toEqual(['Posts']); + expect(index.posts.requests).toHaveLength(2); + expect(index.tags.requests).toHaveLength(1); + }); + + it('waits for a list invalidated while idle to reload before searching it', async () => { + fakeSession(); + const reload = deferred(); + let deleted = false; + const index = fakeSearchIndex({ + posts: async () => { + if (!deleted) { + return [{ id: 'p1', title: 'First post', status: 'published' }]; + } + await reload.promise; + return []; + }, + }); + const hook = await renderSearch('first'); + expect(await settledGroupNames(hook)).toContain('Posts'); + await hook.rerender({ term: '' }); + + deleted = true; + await hook.result.current.queryClient.invalidateQueries({ + queryKey: [postsDataType], + refetchType: 'none', + }); + await hook.rerender({ term: 'first' }); + await expect.poll(() => index.posts.requests.length).toBe(2); + await wait(DEBOUNCE_ELAPSED_MS); + + expect(hook.result.current.search.isLoading).toBe(true); + expect(groupNames(hook)).toEqual([]); + + reload.release(); + + expect(await settledGroupNames(hook)).toEqual(['Staff', 'Tags', 'Pages']); + }); + + it('applies a staff edit made in React without reloading the list', async () => { + fakeSession(); + const index = fakeSearchIndex(); + const renamed = { ...currentUserResponse().users[0], id: 'u1', name: 'Renamed user' }; + fakeAdminEndpoint('PUT', '/users/u1/?include=roles', { users: [renamed] }); + const hook = await renderSearch('renamed'); + expect(await settledGroupNames(hook)).toEqual([]); + + await hook.result.current.editUser.mutateAsync(renamed as unknown as User); + + await expect.poll(() => groupNames(hook)).toEqual(['Staff']); + expect(index.users.requests).toHaveLength(1); + }); + + it('reports a list that fails to load and keeps searching the others', async () => { + fakeSession(); + fakeSearchIndex(); + fakeAdminEndpoint( + 'GET', + '/search-index/posts/', + { errors: [{ message: 'Posts are offline' }] }, + { status: 500 }, + ); + const hook = await renderSearch('first'); + + expect(await settledGroupNames(hook)).toEqual(['Staff', 'Tags', 'Pages']); + await expect.element(page.getByText('Posts are offline')).toBeVisible(); + }); + + it('waits for the current user before showing results', async () => { + const currentUser = deferred(); + fakeSession({ + hostSettings: { billing: { enabled: true, search: billingSearch } }, + currentUserReady: currentUser.promise, + }); + const index = fakeSearchIndex(); + const hook = await renderSearch('plan'); + await expect.poll(() => index.tags.requests.length).toBe(1); + await wait(DEBOUNCE_ELAPSED_MS); + + expect(hook.result.current.search.isLoading).toBe(true); + expect(groupNames(hook)).toEqual([]); + + currentUser.release(); + + expect(await settledGroupNames(hook)).toEqual(['Acme Hosting']); + }); + + it('matches inside words for a non-English site', async () => { + fakeSession({ locale: 'de' }); + fakeSearchIndex(); + const hook = await renderSearch('irst pos'); + + expect(await settledGroupNames(hook)).toEqual(['Posts']); + }); + + describe('billing results', () => { + const billingEnabled = { billing: { enabled: true, search: billingSearch } }; + + it('shows them to the owner when billing is enabled', async () => { + fakeSession({ hostSettings: billingEnabled }); + fakeSearchIndex(); + const hook = await renderSearch('plan'); + + expect(await settledGroupNames(hook)).toEqual(['Acme Hosting']); + }); + + it('hides them from other staff', async () => { + fakeSession({ role: 'Administrator', hostSettings: billingEnabled }); + fakeSearchIndex(); + const hook = await renderSearch('plan'); + + expect(await settledGroupNames(hook)).toEqual([]); + }); + + it('hides them when billing is not enabled', async () => { + fakeSession({ hostSettings: { billing: { search: billingSearch } } }); + fakeSearchIndex(); + const hook = await renderSearch('plan'); + + expect(await settledGroupNames(hook)).toEqual([]); + }); + + it('shows them to any staff while the site must upgrade, even with billing off', async () => { + fakeSession({ + role: 'Administrator', + hostSettings: { billing: { search: billingSearch }, forceUpgrade: true }, + }); + fakeSearchIndex(); + const hook = await renderSearch('plan'); + + expect(await settledGroupNames(hook)).toEqual(['Acme Hosting']); + }); + }); +}); diff --git a/apps/admin/src/global-search/use-global-search.ts b/apps/admin/src/global-search/use-global-search.ts new file mode 100644 index 00000000000..15864c9de59 --- /dev/null +++ b/apps/admin/src/global-search/use-global-search.ts @@ -0,0 +1,122 @@ +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useDebounce } from 'use-debounce'; +import { useBrowseConfig } from '@tryghost/admin-x-framework/api/config'; +import { useCurrentUser, usersDataType } from '@tryghost/admin-x-framework/api/current-user'; +import { pagesDataType } from '@tryghost/admin-x-framework/api/pages'; +import { postsDataType } from '@tryghost/admin-x-framework/api/posts'; +import { getSettingValue, useBrowseSettings } from '@tryghost/admin-x-framework/api/settings'; +import { isOwnerUser } from '@tryghost/admin-x-framework/api/users'; +import { apiUrl } from '@tryghost/admin-x-framework/helpers'; +import { useFetchApi, useHandleError } from '@tryghost/admin-x-framework/hooks'; +import { useForceUpgrade } from '@/ember-bridge'; +// pulls in FlexSearch, so import this hook only from a lazily loaded module +import { createSearchProvider } from './search-providers'; +import { + type SearchIndexItem, + type SearchResultGroup, + getSearchables, + parseSearchIndexItems, +} from './searchables'; + +const SEARCH_DEBOUNCE_MS = 200; + +type SearchIndexKey = 'posts' | 'pages' | 'tags' | 'users'; + +/** + * Loads one `search-index/*` list. It's keyed under the resource's data type, so + * the invalidation that follows a save (in React or Ember) marks it stale too. + */ +function useSearchIndex(key: SearchIndexKey, dataType: string, enabled: boolean) { + const fetchApi = useFetchApi(); + const handleError = useHandleError(); + const url = apiUrl(`/search-index/${key}/`); + + const { data, isLoading, isFetching, isStale } = useQuery({ + queryKey: [dataType, url], + // `{[key]: items}` matches the resource's response, which the framework's + // cache helpers (eg. after a staff edit) update in place + queryFn: async (): Promise>> => { + try { + const response = await fetchApi>(url); + return { [key]: parseSearchIndexItems(response[key]) }; + } catch (error) { + handleError(error); + throw error; + } + }, + enabled, + // up to 10k posts: refetched after invalidation, never on a timer + staleTime: Infinity, + gcTime: Infinity, + }); + + // a refetch after invalidation would otherwise serve removed or renamed content + return { items: data?.[key], isLoading: isLoading || (isFetching && isStale) }; +} + +/** + * Searches staff, tags, posts, pages and any configured billing entries for the + * Cmd-K modal. The index loads on the first non-blank term. + */ +export function useGlobalSearch(term: string): { + results: SearchResultGroup[]; + isLoading: boolean; +} { + const [debouncedTerm] = useDebounce(term, SEARCH_DEBOUNCE_MS); + const enabled = term.trim() !== ''; + + const posts = useSearchIndex('posts', postsDataType, enabled); + const pages = useSearchIndex('pages', pagesDataType, enabled); + const tags = useSearchIndex('tags', 'TagsResponseType', enabled); + const users = useSearchIndex('users', usersDataType, enabled); + + const { data: config, isLoading: isConfigLoading } = useBrowseConfig(); + const { data: currentUser, isLoading: isUserLoading } = useCurrentUser(); + const { data: settings, isLoading: isSettingsLoading } = useBrowseSettings(); + const forceUpgrade = useForceUpgrade(); + + const hostSettings = config?.config.hostSettings; + const canAccessBilling = + Boolean(forceUpgrade) || + (Boolean(hostSettings?.billing?.enabled) && Boolean(currentUser && isOwnerUser(currentUser))); + const locale = getSettingValue(settings?.settings, 'locale'); + + // billing access and the locale decide which results exist, so wait for them too + const isContentLoading = + posts.isLoading || + pages.isLoading || + tags.isLoading || + users.isLoading || + isConfigLoading || + isUserLoading || + isSettingsLoading; + + const searchables = useMemo( + () => getSearchables(canAccessBilling ? hostSettings : undefined), + [canAccessBilling, hostSettings], + ); + + const provider = useMemo( + () => + isContentLoading + ? null + : createSearchProvider(searchables, locale, { + post: posts.items, + page: pages.items, + tag: tags.items, + user: users.items, + }), + [isContentLoading, searchables, locale, posts.items, pages.items, tags.items, users.items], + ); + + const results = useMemo( + () => (enabled && provider ? provider.search(debouncedTerm) : []), + [enabled, provider, debouncedTerm], + ); + + return { + results, + isLoading: enabled && (isContentLoading || term !== debouncedTerm), + }; +} diff --git a/apps/admin/src/layout/admin7-design.acceptance.test.tsx b/apps/admin/src/layout/admin7-design.acceptance.test.tsx index 67f9371dd2e..8b8066334cb 100644 --- a/apps/admin/src/layout/admin7-design.acceptance.test.tsx +++ b/apps/admin/src/layout/admin7-design.acceptance.test.tsx @@ -1,5 +1,10 @@ import { expect, it } from 'vitest'; -import { fakeMembers, renderAdminApp } from '@test-utils/acceptance'; +import { + fakeEditorChrome, + fakeEditorPost, + fakeMembers, + renderAdminApp, +} from '@test-utils/acceptance'; // Protect the rollout boundary without prescribing the experimental appearance. it.each<{ @@ -13,13 +18,35 @@ it.each<{ { name: 'flag enabled', route: '/members', labs: { admin7Pill: true }, enabled: true }, { name: 'Ember route excluded', route: '/site', labs: { admin7Pill: true }, enabled: false }, { - name: 'editor excluded', + name: 'Ember editor excluded', route: '/editor/post/new', labs: { admin7Pill: true }, enabled: false, }, + { + name: 'Ember editor excluded with React editor flag disabled', + route: '/editor/post/new', + labs: { admin7Pill: true, editorReact: false }, + enabled: false, + }, + { + name: 'React editor with pill flag disabled', + route: '/editor/post/abc123', + labs: { admin7Pill: false, editorReact: true }, + enabled: false, + }, + { + name: 'React editor with pill flag enabled', + route: '/editor/post/abc123', + labs: { admin7Pill: true, editorReact: true }, + enabled: true, + }, ])('selects the Admin 7 design only when allowed: $name', async ({ route, labs, enabled }) => { fakeMembers([]); + if (labs.editorReact) { + fakeEditorChrome(); + fakeEditorPost(); + } await renderAdminApp(route, { labs }); await expect diff --git a/apps/admin/src/posts/analytics/components/post-analytics-header.tsx b/apps/admin/src/posts/analytics/components/post-analytics-header.tsx index 33b7570597c..8c26e8026d9 100644 --- a/apps/admin/src/posts/analytics/components/post-analytics-header.tsx +++ b/apps/admin/src/posts/analytics/components/post-analytics-header.tsx @@ -52,10 +52,11 @@ import { useWebAnalyticsEnabled, } from '@tryghost/admin-x-framework/api/settings'; import { useCanManageGiftLink } from '@/posts/analytics/hooks/use-can-manage-gift-link'; -import { useDeletePost } from '@tryghost/admin-x-framework/api/posts'; +import { postsDataType, useDeletePost } from '@tryghost/admin-x-framework/api/posts'; import { useHandleError } from '@tryghost/admin-x-framework/hooks'; import { useEmailSendingStatusContext } from '@/posts/analytics/email-sending-status/email-sending-status-context'; import { useShade } from '@tryghost/shade/app'; +import { useQueryClient } from '@tanstack/react-query'; interface PostAnalyticsHeaderProps { currentTab?: string; @@ -67,6 +68,7 @@ const PostAnalyticsHeader: React.FC = ({ currentTab, c const navigate = useNavigate(); const webAnalyticsEnabled = useWebAnalyticsEnabled(); const membersTrackSources = useMembersTrackSources(); + const queryClient = useQueryClient(); const { mutateAsync: deletePost } = useDeletePost(); const handleError = useHandleError(); const [showDeleteDialog, setShowDeleteDialog] = useState(false); @@ -152,6 +154,8 @@ const PostAnalyticsHeader: React.FC = ({ currentTab, c } try { await deletePost({ id: postId }); + // `refetchType: 'none'`: this screen's own read of the post is still mounted. + void queryClient.invalidateQueries({ queryKey: [postsDataType], refetchType: 'none' }); setShowDeleteDialog(false); // Navigate back to posts list navigate('/posts/', { crossApp: true }); diff --git a/apps/admin/src/posts/analytics/post-analytics.acceptance.test.tsx b/apps/admin/src/posts/analytics/post-analytics.acceptance.test.tsx index f71cbbed27d..fd126a15cf2 100644 --- a/apps/admin/src/posts/analytics/post-analytics.acceptance.test.tsx +++ b/apps/admin/src/posts/analytics/post-analytics.acceptance.test.tsx @@ -8,6 +8,7 @@ import { fakeAdminEndpoint, fakeMembers, fakePosts, + fakePostsListScreen, fakeTinybirdPipe, fakeTinybirdToken, post, @@ -16,7 +17,9 @@ import { webAnalyticsBootOverrides, } from '@test-utils/acceptance'; import { membersScreen } from '@/members/members.screen'; +import { postsListScreen } from '@/posts/list/posts-list.screen'; import { sidebarScreen } from '@/layout/sidebar.screen'; +import { navigateTo } from '@/utils/navigation'; import { postAnalyticsScreen } from './post-analytics.screen'; const POST_ID = '64d623b64676110001e897d9'; @@ -670,6 +673,53 @@ describe('Post analytics overview', () => { }); }); +describe('Post analytics delete', () => { + const PUBLISHED_BUCKET = 'status:[published,sent]'; + + it('leaves for a posts list that no longer carries the deleted post', async () => { + delete document.body.dataset.externalNavigate; + fakePostsListScreen(); + const deleteApi = fakeAdminEndpoint('DELETE', `/posts/${POST_ID}/`, null, { status: 204 }); + // The list's published bucket and this screen's read by id, both of which + // stop serving the post once it is gone. + const { postsApi } = seedPostAnalyticsWorld({}, ({ filter }) => + !deleteApi.requests.length && (filter === PUBLISHED_BUCKET || filter === `id:${POST_ID}`) + ? [seededPost()] + : [], + ); + const listBrowses = () => + postsApi.requests.filter(({ filter }) => filter === PUBLISHED_BUCKET).length; + + await renderAdminApp('/posts', { + labs: { postsListReact: true }, + boot: webAnalyticsBootOverrides(), + }); + await expect + .element(postsListScreen.listItems().first()) + .toHaveTextContent('Attack of the Clones'); + + await postsListScreen.rowAction().first().click(); + await expect.element(postAnalyticsScreen.postTitle('Attack of the Clones')).toBeVisible(); + await postAnalyticsScreen.moreActionsButton().click(); + await postAnalyticsScreen.deletePostMenuItem().click(); + await postAnalyticsScreen.confirmDeleteButton().click(); + + await expect.poll(() => deleteApi.requests.length).toBe(1); + await expect.poll(() => document.body.dataset.externalNavigate).toBeDefined(); + const handoff = JSON.parse(document.body.dataset.externalNavigate!) as { route: string }; + expect(handoff.route).toBe('/posts/'); + const browsesBefore = listBrowses(); + + navigateTo(handoff.route); + + await expect.poll(currentRoute).toBe('/posts/'); + // Without the delete invalidating it, the list is served from the cache it + // was left with — within the five-minute staleTime, deleted row and all. + await expect.poll(listBrowses).toBeGreaterThan(browsesBefore); + await expect.poll(() => postsListScreen.listItems().elements().length).toBe(0); + }); +}); + describe('Post analytics web', () => { it('renders the seeded KPIs, locations and sources', async () => { const { topSourcesApi, topLocationsApi } = seedPostAnalyticsWorld(); diff --git a/apps/admin/src/posts/analytics/post-analytics.screen.ts b/apps/admin/src/posts/analytics/post-analytics.screen.ts index 6e3fd26fe20..13b440b0956 100644 --- a/apps/admin/src/posts/analytics/post-analytics.screen.ts +++ b/apps/admin/src/posts/analytics/post-analytics.screen.ts @@ -11,6 +11,12 @@ export const postAnalyticsScreen = { newsletterTab: () => page.getByRole('button', { name: sel.newsletterTab, exact: true }), growthTab: () => page.getByRole('button', { name: sel.growthTab, exact: true }), + // Header actions + moreActionsButton: () => page.getByRole('button', { name: 'More post actions' }), + deletePostMenuItem: () => page.getByRole('menuitem', { name: 'Delete post' }), + confirmDeleteButton: () => + page.getByRole('alertdialog').getByRole('button', { name: 'Delete', exact: true }), + // Overview webPerformanceCard: () => page.getByTestId(sel.webPerformance), webPerformanceViewMoreButton: () => diff --git a/apps/admin/src/posts/list/components/posts-sort-menu.tsx b/apps/admin/src/posts/list/components/posts-sort-menu.tsx index f8f6f6399bd..75887065065 100644 --- a/apps/admin/src/posts/list/components/posts-sort-menu.tsx +++ b/apps/admin/src/posts/list/components/posts-sort-menu.tsx @@ -39,6 +39,7 @@ export function PostsSortMenu({ order, onOrderChange }: PostsSortMenuProps) { aria-label={`Sort: ${getOrderLabel(order)}`} data-testid="posts-sort" label="Sort" + tooltip > {getOrderLabel(order)} diff --git a/apps/admin/src/settings/advanced/labs/private-features.tsx b/apps/admin/src/settings/advanced/labs/private-features.tsx index 962d80414cc..6726c40fd51 100644 --- a/apps/admin/src/settings/advanced/labs/private-features.tsx +++ b/apps/admin/src/settings/advanced/labs/private-features.tsx @@ -57,7 +57,7 @@ const features: Feature[] = [ { title: 'Admin 7 · Milestone 2 · Pill controls', description: - 'Preview Admin 7 controls and page headers on React pages. The editor is excluded.', + 'Preview Admin 7 controls and page headers on React pages, including the React editor.', flag: 'admin7Pill', }, { diff --git a/apps/admin/src/settings/membership/member-emails/member-email-editor.tsx b/apps/admin/src/settings/membership/member-emails/member-email-editor.tsx index 973b2779fcf..b1386f74175 100644 --- a/apps/admin/src/settings/membership/member-emails/member-email-editor.tsx +++ b/apps/admin/src/settings/membership/member-emails/member-email-editor.tsx @@ -134,6 +134,7 @@ const MemberEmailsEditor: React.FC = ({ const { fetchAutocompleteLinks, searchLinks } = useWelcomeEmailLinkSuggestions(); const fetchEmbed = useKoenigFetchEmbed(); const klipyConfig = config.klipy?.apiKey ? config.klipy : null; + const embedPreviewUrl = config.security?.embedPreviewUrl || undefined; const { darkMode } = useFocusContext(); const editorResource = useMemo(() => loadKoenig(), []); const [transistorEnabled] = getSettingValues(settings, ['transistor']); @@ -143,6 +144,7 @@ const MemberEmailsEditor: React.FC = ({ unsplash: unsplashConfig, pinturaConfig, klipy: klipyConfig, + embedPreviewUrl, fetchEmbed, fetchAutocompleteLinks, searchLinks, @@ -155,6 +157,7 @@ const MemberEmailsEditor: React.FC = ({ unsplashConfig, pinturaConfig, klipyConfig, + embedPreviewUrl, fetchEmbed, fetchAutocompleteLinks, searchLinks, diff --git a/apps/admin/vite-embed-renderer.ts b/apps/admin/vite-embed-renderer.ts new file mode 100644 index 00000000000..8f3c10cd7e4 --- /dev/null +++ b/apps/admin/vite-embed-renderer.ts @@ -0,0 +1,46 @@ +import type { PluginOption } from 'vite'; +import path from 'path'; +import fs from 'fs'; + +const RENDERER_SOURCE = path.resolve(__dirname, '../../koenig/koenig-lexical/dist/embed-renderer'); + +// Deliberately outside built/admin: Ghost serves built/admin/assets from its own +// domain, and the renderer must only ever be served from a separate domain. +// Self-hosters copy these files to that domain. +const RENDERER_DESTINATION = path.resolve(__dirname, '../../ghost/core/core/built/embed-renderer'); +const ADMIN_RENDERER_DESTINATION = path.resolve( + __dirname, + '../../ghost/core/core/built/admin/assets/koenig-lexical/embed-renderer', +); + +// Vite plugin to ship Koenig's embed renderer with Ghost +export function embedRendererPlugin() { + let isBuild = false; + + return { + name: 'embed-renderer', + configResolved(resolvedConfig) { + isBuild = resolvedConfig.command === 'build'; + }, + closeBundle() { + if (!isBuild) { + return; + } + + if (!fs.existsSync(RENDERER_SOURCE)) { + throw new Error( + `Koenig's embed renderer is missing from ${RENDERER_SOURCE}. Build @tryghost/koenig-lexical first.`, + ); + } + + if (fs.existsSync(ADMIN_RENDERER_DESTINATION)) { + throw new Error( + `Koenig's embed renderer must not be served with Admin assets from ${ADMIN_RENDERER_DESTINATION}.`, + ); + } + + fs.rmSync(RENDERER_DESTINATION, { recursive: true, force: true }); + fs.cpSync(RENDERER_SOURCE, RENDERER_DESTINATION, { recursive: true }); + }, + } as const satisfies PluginOption; +} diff --git a/apps/admin/vite.config.ts b/apps/admin/vite.config.ts index 568c9212fbd..c9cda6c6c87 100644 --- a/apps/admin/vite.config.ts +++ b/apps/admin/vite.config.ts @@ -4,6 +4,7 @@ import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; import { emberAssetsPlugin } from './vite-ember-assets'; +import { embedRendererPlugin } from './vite-embed-renderer'; import { ghostBackendProxyPlugin } from './vite-backend-proxy'; import { sharedDefine, sharedResolve } from './vite.shared'; @@ -37,7 +38,13 @@ function getBase(command: 'build' | 'serve'): string { // https://vite.dev/config/ export default defineConfig(({ command }) => ({ base: getBase(command), - plugins: [tailwindcss() as PluginOption, react(), emberAssetsPlugin(), ghostBackendProxyPlugin()], + plugins: [ + tailwindcss() as PluginOption, + react(), + emberAssetsPlugin(), + embedRendererPlugin(), + ghostBackendProxyPlugin(), + ], define: sharedDefine, server: { host: '0.0.0.0', diff --git a/apps/ember-admin/app/components/koenig-lexical-editor.js b/apps/ember-admin/app/components/koenig-lexical-editor.js index 778228e3dc3..b7514684bf8 100644 --- a/apps/ember-admin/app/components/koenig-lexical-editor.js +++ b/apps/ember-admin/app/components/koenig-lexical-editor.js @@ -494,6 +494,7 @@ export default class KoenigLexicalEditor extends Component { const defaultCardConfig = { unsplash: this.settings.unsplash ? unsplashConfig.defaultHeaders : null, klipy: this.config.klipy?.apiKey ? this.config.klipy : null, + embedPreviewUrl: this.config.security?.embedPreviewUrl || undefined, fetchAutocompleteLinks, fetchEmbed, fetchLabels, diff --git a/apps/ember-admin/app/components/modal-post-history.js b/apps/ember-admin/app/components/modal-post-history.js index 841024d10ba..f7a05202841 100644 --- a/apps/ember-admin/app/components/modal-post-history.js +++ b/apps/ember-admin/app/components/modal-post-history.js @@ -2,6 +2,7 @@ import Component from '@glimmer/component'; import DOMPurify from 'dompurify'; import RestoreRevisionModal from '../components/modals/restore-revision'; import {action, set} from '@ember/object'; +import {inject} from 'ghost-admin/decorators/inject'; import {inject as service} from '@ember/service'; import {tracked} from '@glimmer/tracking'; import {waitFor} from '@ember/test-waiters'; @@ -25,6 +26,9 @@ export default class ModalPostHistory extends Component { @service notifications; @service modals; @service ghostPaths; + + @inject config; + @tracked selectedHTML = null; @tracked selectedRevisionIndex = 0; @@ -155,7 +159,8 @@ export default class ModalPostHistory extends Component { get cardConfig() { return { - post: this.args.model + post: this.args.model, + embedPreviewUrl: this.config.security?.embedPreviewUrl || undefined }; } diff --git a/apps/ember-admin/lib/asset-delivery/index.js b/apps/ember-admin/lib/asset-delivery/index.js index 7a1ee3abaf9..8eb11e17a6d 100644 --- a/apps/ember-admin/lib/asset-delivery/index.js +++ b/apps/ember-admin/lib/asset-delivery/index.js @@ -117,7 +117,14 @@ module.exports = { const assetsKoenigLexicalPath = `${assetsOut}/assets/koenig-lexical`; if (fs.existsSync(koenigLexicalPath)) { - fs.copySync(koenigLexicalPath, assetsKoenigLexicalPath, {overwrite: true, dereference: true}); + const embedRendererPath = path.join(koenigLexicalPath, 'embed-renderer'); + fs.copySync(koenigLexicalPath, assetsKoenigLexicalPath, { + overwrite: true, + dereference: true, + // The renderer executes arbitrary embed HTML and must only + // ship in core/built/embed-renderer for separate-origin hosting. + filter: source => path.resolve(source) !== embedRendererPath + }); } else { console.log('Koenig-Lexical folder not found'); } diff --git a/apps/shade/src/components/patterns/page-header.mdx b/apps/shade/src/components/patterns/page-header.mdx index f866805c982..e2b5fc3a246 100644 --- a/apps/shade/src/components/patterns/page-header.mdx +++ b/apps/shade/src/components/patterns/page-header.mdx @@ -35,7 +35,7 @@ has no leading separation. Avoid additional margins or gaps on individual action ## Constructing controls Use `PageHeader.Action` for buttons. Set `label` to an accessible name that also -explains the action in its tooltip. Set `iconOnly` for utilities; keep labelled +explains the action when a tooltip is shown. Set `iconOnly` for utilities; keep labelled controls as icon + visible text. Put the action in `.ActionGroup.Primary` to inherit primary styling and suppress its tooltip. `primary` is available when a primary trigger must be constructed outside that slot, such as a modal trigger. @@ -93,11 +93,18 @@ trigger. Remove fallback props when the rollout ends. ## Tooltips and focus -Secondary actions use the semantic white tooltip surface with a shadow. The first +Icon-only actions and actions with keyboard shortcuts have tooltips. Static labelled +buttons omit them: repeating the visible label adds no information. Set `tooltip` +on a labelled action when its text shows a changing value and the tooltip explains +the action. Primary actions never have tooltips. + +Tooltips use the semantic white surface with a shadow. The first hover tooltip waits **1 second**. Moving between recently shown tooltips uses a **300ms skip-delay window**. Keyboard focus shows the tooltip immediately. -Primary actions have **no tooltip**. Their visible labels already explain them. +Wrap header controls in `PageHeader.ActionGroup`, including in editor and modal +headers, so they receive this timing rather than inheriting another surface's delay. + Selecting a dropdown item with the pointer closes the menu without reopening the trigger tooltip merely because focus returns. Keyboard navigation still restores focus and tooltip access. Disabled actions remain disabled and retain their size. diff --git a/apps/shade/src/components/patterns/page-header.stories.tsx b/apps/shade/src/components/patterns/page-header.stories.tsx index 567affe21b5..aba049011f5 100644 --- a/apps/shade/src/components/patterns/page-header.stories.tsx +++ b/apps/shade/src/components/patterns/page-header.stories.tsx @@ -203,7 +203,7 @@ export const Basic: Story = { - + Newest first @@ -351,7 +351,7 @@ export const Admin7Disabled: Story = { render: () => ( - + Newest first diff --git a/apps/shade/src/components/patterns/page-header.tsx b/apps/shade/src/components/patterns/page-header.tsx index 9de2e58f33c..41cbdcc92d5 100644 --- a/apps/shade/src/components/patterns/page-header.tsx +++ b/apps/shade/src/components/patterns/page-header.tsx @@ -73,6 +73,8 @@ type PageHeaderActionProps = ButtonProps & { iconOnly?: boolean; primary?: boolean; shortcut?: string; + /** Show an explanation for a changing value; static labels need no tooltip. */ + tooltip?: boolean; /** Temporary compatibility for existing screens; new headers use the defaults. */ fallbackVariant?: ButtonProps['variant']; fallbackSize?: ButtonProps['size']; @@ -85,6 +87,7 @@ const PageHeaderAction = React.forwardRef ); - return primary || !isAdmin7 ? ( + return primary || !isAdmin7 || !tooltip ? ( button ) : ( diff --git a/docker/fake-mailgun/Dockerfile b/docker/fake-mailgun/Dockerfile index 73ca747cc2c..c436427ea3d 100644 --- a/docker/fake-mailgun/Dockerfile +++ b/docker/fake-mailgun/Dockerfile @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1@sha256:ecfaec9ed6d810b56388c508f4121597bfbba70d41a6dfeee4d8cad5f295fc32 -ARG NODE_VERSION=22.23.1 +ARG NODE_VERSION=22.23.3 FROM node:$NODE_VERSION-bookworm-slim WORKDIR /home/ghost diff --git a/docker/ghost-dev/Dockerfile b/docker/ghost-dev/Dockerfile index f6cff16c155..78ee09ebe1b 100644 --- a/docker/ghost-dev/Dockerfile +++ b/docker/ghost-dev/Dockerfile @@ -2,7 +2,7 @@ # Minimal Development Dockerfile for Ghost Core # Source code is mounted at runtime for hot-reload support -ARG NODE_VERSION=22.23.1 +ARG NODE_VERSION=22.23.3 FROM node:$NODE_VERSION-bookworm-slim diff --git a/docs/contributing/development-setup.md b/docs/contributing/development-setup.md index 95ca6fa328e..a248dfaca0e 100644 --- a/docs/contributing/development-setup.md +++ b/docs/contributing/development-setup.md @@ -9,7 +9,7 @@ run on the host. Install: - [Git](https://git-scm.com/) -- Node.js `22.23.1` (the version in [`.nvmrc`](../../.nvmrc) and +- Node.js `22.23.3` (the version in [`.nvmrc`](../../.nvmrc) and [`.node-version`](../../.node-version)) - [Docker](https://docs.docker.com/get-docker/) with Docker Compose v2 - [Corepack](https://nodejs.org/api/corepack.html), included with supported diff --git a/e2e/Dockerfile.runner b/e2e/Dockerfile.runner index 61aad919355..38269bcc32c 100644 --- a/e2e/Dockerfile.runner +++ b/e2e/Dockerfile.runner @@ -19,7 +19,7 @@ # the upstream Playwright image when this tag is unavailable, so a Playwright # version bump degrades gracefully until the workflow publishes the new tag. -ARG NODE_VERSION=22.23.1 +ARG NODE_VERSION=22.23.3 FROM node:${NODE_VERSION}-slim # Must match the pinned @playwright/test version (pnpm-workspace.yaml catalog); diff --git a/koenig/koenig-lexical/demo/DemoApp.tsx b/koenig/koenig-lexical/demo/DemoApp.tsx index a70f1b866f8..f6fc2c5b6be 100644 --- a/koenig/koenig-lexical/demo/DemoApp.tsx +++ b/koenig/koenig-lexical/demo/DemoApp.tsx @@ -319,6 +319,7 @@ function DemoComposer({editorType, isMultiplayer, setWordCount, setTKCount}) { ...defaultCardConfig.feature, transistor: searchParams.get('labs')?.includes('transistor') || defaultCardConfig.feature.transistor }, + embedPreviewUrl: searchParams.get('embedPreviewUrl') || undefined, searchLinks: searchParams.get('searchLinks') === 'false' ? undefined : defaultCardConfig.searchLinks, stripeEnabled: searchParams.get('stripe') === 'false' ? false : defaultCardConfig.stripeEnabled, deprecated: { diff --git a/koenig/koenig-lexical/public/embed-renderer/v1.html b/koenig/koenig-lexical/public/embed-renderer/v1.html new file mode 100644 index 00000000000..c15c0713da1 --- /dev/null +++ b/koenig/koenig-lexical/public/embed-renderer/v1.html @@ -0,0 +1,97 @@ + + + + + + +Embed preview + + + + + diff --git a/koenig/koenig-lexical/src/components/ui/cards/EmbedCard.tsx b/koenig/koenig-lexical/src/components/ui/cards/EmbedCard.tsx index b4f81ca68b9..5c6fdaee22b 100644 --- a/koenig/koenig-lexical/src/components/ui/cards/EmbedCard.tsx +++ b/koenig/koenig-lexical/src/components/ui/cards/EmbedCard.tsx @@ -2,14 +2,34 @@ import '@tryghost/kg-simplemde/dist/simplemde.min.css'; import PropTypes from 'prop-types'; import React from 'react'; import {CardCaptionEditor} from '../CardCaptionEditor'; +import { + EMBED_READY_MESSAGE, + EMBED_RENDERER_PERMISSIONS, + EMBED_RENDERER_TIMEOUT, + EMBED_RENDERER_VERSION, + EMBED_RENDER_MESSAGE, + EMBED_RESIZE_MESSAGE, + resolveEmbedHeight +} from '../../../utils/embed-renderer'; import {UrlInput} from '../UrlInput'; -export function EmbedCard({captionEditor, captionEditorInitialState, html, isSelected, urlInputValue, urlPlaceholder, urlError, isLoading, handleUrlChange, handleUrlSubmit, handleRetry, handlePasteAsLink, handleClose}) { +export function EmbedCard({captionEditor, captionEditorInitialState, html, isSelected, rendererUrl, url, urlInputValue, urlPlaceholder, urlError, isLoading, handleUrlChange, handleUrlSubmit, handleRetry, handlePasteAsLink, handleClose}) { if (html) { return (
- + {rendererUrl === undefined ? ( + + ) : ( + + )}
(null); + const [unavailable, setUnavailable] = React.useState(!rendererUrl); + + // a layout effect listens before the iframe can run, so a cached renderer's ready message isn't missed + React.useLayoutEffect(() => { + if (!rendererUrl) { + return; + } + + const rendererOrigin = new URL(rendererUrl).origin; + let rendered = false; + + const handleMessage = (event: MessageEvent) => { + const iframe = iframeRef.current; + + if (!iframe || event.source !== iframe.contentWindow || event.origin !== rendererOrigin) { + return; + } + + if (event.data?.type === EMBED_READY_MESSAGE && !rendered) { + if (event.data.version !== EMBED_RENDERER_VERSION) { + setUnavailable(true); + return; + } + + rendered = true; + iframe.contentWindow.postMessage({type: EMBED_RENDER_MESSAGE, version: EMBED_RENDERER_VERSION, html}, rendererOrigin); + return; + } + + if (event.data?.type === EMBED_RESIZE_MESSAGE) { + const height = resolveEmbedHeight(event.data.height); + + if (height !== null) { + iframe.style.height = `${height}px`; + } + } + }; + + window.addEventListener('message', handleMessage); + + // the renderer can be blocked, offline or misconfigured + const timeout = window.setTimeout(() => { + if (!rendered) { + setUnavailable(true); + } + }, EMBED_RENDERER_TIMEOUT); + + return function cleanup() { + window.removeEventListener('message', handleMessage); + window.clearTimeout(timeout); + }; + }, [html, rendererUrl]); + + if (unavailable) { + return ; + } + + return ( + + ); +} + +function EmbedPreviewUnavailable({url}) { + return ( +
+ Embed preview unavailable + {url && {url}} +
+ ); +} + EmbedCard.propTypes = { html: PropTypes.string, + rendererUrl: PropTypes.string, + url: PropTypes.string, isSelected: PropTypes.bool, urlInputValue: PropTypes.string, urlPlaceholder: PropTypes.string, @@ -150,3 +257,14 @@ EmbedIframe.propTypes = { dataTestId: PropTypes.string, html: PropTypes.string }; + +RendererEmbedIframe.propTypes = { + dataTestId: PropTypes.string, + html: PropTypes.string, + rendererUrl: PropTypes.string, + url: PropTypes.string +}; + +EmbedPreviewUnavailable.propTypes = { + url: PropTypes.string +}; diff --git a/koenig/koenig-lexical/src/context/KoenigComposerContext.tsx b/koenig/koenig-lexical/src/context/KoenigComposerContext.tsx index 4e50f2cfeb3..4c8aea9e9b4 100644 --- a/koenig/koenig-lexical/src/context/KoenigComposerContext.tsx +++ b/koenig/koenig-lexical/src/context/KoenigComposerContext.tsx @@ -77,6 +77,8 @@ export interface CardConfigFeature { export interface CardConfig { createSnippet?: (snippet: Snippet) => void; deleteSnippet?: (snippet: {name: string}) => void; + // directory serving the embed renderer on a separate origin; embeds preview in the editor's origin when unset + embedPreviewUrl?: string; fetchEmbed?: (url: string, options: FetchEmbedOptions) => Promise; fetchLabels?: () => Promise; fetchAutocompleteLinks?: () => Promise<{value: string; label: string}[]>; diff --git a/koenig/koenig-lexical/src/nodes/EmbedNodeComponent.tsx b/koenig/koenig-lexical/src/nodes/EmbedNodeComponent.tsx index b6ef1eea7c3..c2f0a833a18 100644 --- a/koenig/koenig-lexical/src/nodes/EmbedNodeComponent.tsx +++ b/koenig/koenig-lexical/src/nodes/EmbedNodeComponent.tsx @@ -8,6 +8,7 @@ import {ActionToolbar} from '../components/ui/ActionToolbar.jsx'; import {EmbedCard} from '../components/ui/cards/EmbedCard'; import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar.jsx'; import {ToolbarMenu, ToolbarMenuItem} from '../components/ui/ToolbarMenu.jsx'; +import {getEmbedRendererUrl} from '../utils/embed-renderer'; import {useCallback} from 'react'; import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; @@ -21,6 +22,14 @@ export function EmbedNodeComponent({nodeKey, url, html, createdWithUrl, embedTyp const [urlError, setUrlError] = React.useState(false); const [showSnippetToolbar, setShowSnippetToolbar] = React.useState(false); + // undefined keeps the in-editor preview; null means the configured renderer can't be used + const rendererUrl = React.useMemo(() => { + if (!cardConfig.embedPreviewUrl) { + return undefined; + } + return getEmbedRendererUrl(cardConfig.embedPreviewUrl, window.location.origin)?.href ?? null; + }, [cardConfig.embedPreviewUrl]); + const handleUrlChange = (event) => { setUrlInputValue(event.target.value); }; @@ -139,6 +148,7 @@ export function EmbedNodeComponent({nodeKey, url, html, createdWithUrl, embedTyp isLoading={loading} isSelected={isSelected} metadata={metadata} + rendererUrl={rendererUrl} url={url} urlError={urlError} urlInputValue={urlInputValue} diff --git a/koenig/koenig-lexical/src/utils/embed-renderer.ts b/koenig/koenig-lexical/src/utils/embed-renderer.ts new file mode 100644 index 00000000000..25a0eaadcd4 --- /dev/null +++ b/koenig/koenig-lexical/src/utils/embed-renderer.ts @@ -0,0 +1,56 @@ +// The embed renderer (public/embed-renderer) shows embed card html on a separate +// origin, so embed scripts can't reach the editor or the Admin session. It's +// used when the host configures `cardConfig.embedPreviewUrl`. + +// Bump when the messages below change, and add public/embed-renderer/v.html +// alongside the old file, so editors and renderers on either side of a deploy +// keep working together. +export const EMBED_RENDERER_VERSION = 1; + +export const EMBED_READY_MESSAGE = 'kg-embed-ready'; +export const EMBED_RENDER_MESSAGE = 'kg-embed-render'; +export const EMBED_RESIZE_MESSAGE = 'kg-embed-resize'; + +// The renderer's origin holds nothing, so allow-same-origin is safe and keeps +// embed players (YouTube, Vimeo, etc.) on their own origins. +export const EMBED_RENDERER_PERMISSIONS = 'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-presentation'; + +export const EMBED_RENDERER_TIMEOUT = 10000; + +// The height comes from the embed itself, so cap what the editor will apply: +// an embed that asks for a huge preview would otherwise bury the rest of the post. +export const EMBED_RENDERER_MAX_HEIGHT = 5000; + +// Returns the height to apply, or null when the message can't be trusted. +export function resolveEmbedHeight(value: unknown): number | null { + const height = Number(value); + + if (!Number.isFinite(height) || height <= 0) { + return null; + } + + return Math.min(Math.ceil(height), EMBED_RENDERER_MAX_HEIGHT); +} + +// `previewUrl` is the directory holding the versioned renderer files. Returns +// null when it can't be used safely, so the editor fails closed. +export function getEmbedRendererUrl(previewUrl: string, editorOrigin: string): URL | null { + let base: URL; + let editor: URL; + try { + base = new URL(previewUrl); + editor = new URL(editorOrigin); + } catch { + return null; + } + + if (!['https:', 'http:'].includes(base.protocol) || base.hostname === editor.hostname) { + return null; + } + + if (!base.pathname.endsWith('/')) { + base.pathname = `${base.pathname}/`; + } + + return new URL(`v${EMBED_RENDERER_VERSION}.html`, base); +} diff --git a/koenig/koenig-lexical/test/e2e/cards/embed-card.test.ts b/koenig/koenig-lexical/test/e2e/cards/embed-card.test.ts index 76d7169c8f7..710ee29f5c6 100644 --- a/koenig/koenig-lexical/test/e2e/cards/embed-card.test.ts +++ b/koenig/koenig-lexical/test/e2e/cards/embed-card.test.ts @@ -1,5 +1,13 @@ +import fs from 'fs'; +import path from 'path'; +import {E2E_PORT} from '../../../playwright.config'; +import {EMBED_RENDERER_MAX_HEIGHT} from '../../../src/utils/embed-renderer'; import {assertHTML, createSnippet, focusEditor, html, initialize, isMac, pasteText} from '../../utils/e2e'; import {expect, test} from '@playwright/test'; +import {fileURLToPath} from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); test.describe('Embed card', async () => { const ctrlOrCmd = isMac() ? 'Meta' : 'Control'; @@ -86,6 +94,76 @@ test.describe('Embed card', async () => { `, {ignoreCardContents: false}); }); + test.describe('With an embed preview url', async () => { + // serves the real renderer from a separate origin, as a dedicated embeds domain would + const rendererDirectory = 'http://embeds.test/embed-renderer/'; + const rendererFile = path.resolve(__dirname, '../../../public/embed-renderer/v1.html'); + + test.beforeEach(async () => { + await page.route(`${rendererDirectory}**`, route => route.fulfill({path: rendererFile, contentType: 'text/html'})); + }); + + test.afterEach(async () => { + await page.unroute(`${rendererDirectory}**`); + }); + + function embedContent(embedHtml) { + return encodeURIComponent(JSON.stringify({ + root: { + children: [{ + type: 'embed', + html: embedHtml, + metadata: {}, + embedType: 'rich', + url: 'https://attacker.example/' + }], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } + })); + } + + test('renders embeds in the renderer on a separate origin', async function () { + const embedHtml = '
Embedded content
'; + + await initialize({page, uri: `/#/?embedPreviewUrl=${encodeURIComponent(rendererDirectory)}&content=${embedContent(embedHtml)}`}); + + const iframe = page.getByTestId('embed-iframe'); + await expect(iframe).toHaveAttribute('src', `${rendererDirectory}v1.html`); + + // the embed's own scripts ran inside the renderer and it reported its height + await expect(page.frameLocator('[data-testid="embed-iframe"]').locator('body[data-ran="yes"]')).toHaveCount(1); + await expect(iframe).toHaveCSS('height', '400px'); + + expect(await page.evaluate(() => (window as Window & {__embedEscaped?: boolean}).__embedEscaped)).toBeUndefined(); + }); + + test('caps the height an embed can ask for', async function () { + // the height comes from the embed itself, so a huge one must not bury the post + const oversizedRenderer = fs.readFileSync(rendererFile, 'utf-8') + + ``; + + await page.route(`${rendererDirectory}**`, route => route.fulfill({body: oversizedRenderer, contentType: 'text/html'})); + + await initialize({page, uri: `/#/?embedPreviewUrl=${encodeURIComponent(rendererDirectory)}&content=${embedContent('
Embedded content
')}`}); + + const iframe = page.getByTestId('embed-iframe'); + await expect(iframe).toHaveCSS('height', `${EMBED_RENDERER_MAX_HEIGHT}px`); + }); + + test('shows a placeholder when the renderer is on the editor origin', async function () { + const sameOriginDirectory = `http://localhost:${E2E_PORT}/embed-renderer/`; + + await initialize({page, uri: `/#/?embedPreviewUrl=${encodeURIComponent(sameOriginDirectory)}&content=${embedContent('

Embedded content

')}`}); + + await expect(page.getByTestId('embed-preview-unavailable')).toContainText('https://attacker.example/'); + await expect(page.getByTestId('embed-iframe')).toHaveCount(0); + }); + }); + test('renders embed card node', async function () { await focusEditor(page); await insertEmbedCard(page); diff --git a/koenig/koenig-lexical/test/unit/utils/embed-renderer.test.ts b/koenig/koenig-lexical/test/unit/utils/embed-renderer.test.ts new file mode 100644 index 00000000000..a8e80a17dfd --- /dev/null +++ b/koenig/koenig-lexical/test/unit/utils/embed-renderer.test.ts @@ -0,0 +1,77 @@ +import fs from 'fs'; +import path from 'path'; +import {EMBED_READY_MESSAGE, EMBED_RENDERER_MAX_HEIGHT, EMBED_RENDERER_VERSION, EMBED_RENDER_MESSAGE, EMBED_RESIZE_MESSAGE, getEmbedRendererUrl, resolveEmbedHeight} from '../../../src/utils/embed-renderer'; +import {describe, expect, test} from 'vitest'; + +const EDITOR_ORIGIN = 'https://admin.example.com'; + +describe('Utils: embed-renderer', () => { + describe('getEmbedRendererUrl', () => { + test('resolves the versioned renderer inside the configured directory', function () { + expect(getEmbedRendererUrl('https://embeds.example.net/', EDITOR_ORIGIN)?.href) + .toEqual(`https://embeds.example.net/v${EMBED_RENDERER_VERSION}.html`); + }); + + test('treats a url without a trailing slash as a directory', function () { + expect(getEmbedRendererUrl('https://embeds.example.net/ghost/renderer', EDITOR_ORIGIN)?.href) + .toEqual(`https://embeds.example.net/ghost/renderer/v${EMBED_RENDERER_VERSION}.html`); + }); + + test('allows http for local development', function () { + expect(getEmbedRendererUrl('http://127.0.0.1:5174/embed-renderer/', 'http://localhost:5174')?.href) + .toEqual(`http://127.0.0.1:5174/embed-renderer/v${EMBED_RENDERER_VERSION}.html`); + }); + + test.each([ + ['the editor origin', 'https://admin.example.com/embed-renderer/'], + ['the editor hostname on a different port', 'https://admin.example.com:8443/embed-renderer/'], + ['invalid urls', 'not a url'], + ['non-http protocols', 'javascript:alert(1)'], + ['data urls', 'data:text/html,hello'] + ])('returns null for %s', function (_description, previewUrl) { + expect(getEmbedRendererUrl(previewUrl, EDITOR_ORIGIN)).toBeNull(); + }); + }); + + describe('resolveEmbedHeight', () => { + test('rounds a reported height up to whole pixels', function () { + expect(resolveEmbedHeight(400)).toEqual(400); + expect(resolveEmbedHeight(400.2)).toEqual(401); + expect(resolveEmbedHeight('400')).toEqual(400); + }); + + test('caps heights at the maximum the editor will apply', function () { + expect(resolveEmbedHeight(100000000)).toEqual(EMBED_RENDERER_MAX_HEIGHT); + expect(resolveEmbedHeight(EMBED_RENDERER_MAX_HEIGHT + 1)).toEqual(EMBED_RENDERER_MAX_HEIGHT); + }); + + test.each([ + ['zero', 0], + ['negative heights', -100], + ['infinity', Infinity], + ['NaN', NaN], + ['non-numeric strings', 'tall'], + ['missing values', undefined], + ['objects', {height: 400}] + ])('ignores %s', function (_description, value) { + expect(resolveEmbedHeight(value)).toBeNull(); + }); + }); + + describe('renderer file', () => { + const rendererPath = path.resolve(__dirname, `../../../public/embed-renderer/v${EMBED_RENDERER_VERSION}.html`); + + test('exists for the current protocol version', function () { + expect(fs.existsSync(rendererPath)).toBe(true); + }); + + test('speaks the same protocol as the editor', function () { + const renderer = fs.readFileSync(rendererPath, 'utf-8'); + + expect(renderer).toContain(`var VERSION = ${EMBED_RENDERER_VERSION};`); + expect(renderer).toContain(`'${EMBED_READY_MESSAGE}'`); + expect(renderer).toContain(`'${EMBED_RENDER_MESSAGE}'`); + expect(renderer).toContain(`'${EMBED_RESIZE_MESSAGE}'`); + }); + }); +}); diff --git a/package.json b/package.json index 9313be3c0b7..e3ad07527ef 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,7 @@ "devEngines": { "runtime": { "name": "node", - "version": "22.23.1", + "version": "22.23.3", "onFail": "download" } }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e2c72afe1e..31d992770e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -512,6 +512,9 @@ catalogs: fastq: specifier: ^1.20.1 version: 1.20.1 + flexsearch: + specifier: 0.7.43 + version: 0.7.43 form-data: specifier: 4.0.6 version: 4.0.6 @@ -775,7 +778,7 @@ overrides: packageExtensionsChecksum: sha256-gFsvptlcVXY90ntXWh9ANUP1/LYyeqXjWoDGw6BCfOY= -pnpmfileChecksum: sha256-9fpQxhNq4IYwAcYosx14NTWliQZ6zcFZgXuXehsdSXA= +pnpmfileChecksum: sha256-fUIu4zcFUYGAp3jCvRqZ8zssQmWG4cCy4lRG7lA7cvQ= patchedDependencies: '@elastic/elasticsearch@8.19.2': 725142a9606e50dff3ed4c17fb9256d5457c7c75ce262898b87ef0ffff14dcc2 @@ -813,8 +816,8 @@ importers: specifier: 'catalog:' version: 0.23.2(supports-color@10.2.2) node: - specifier: runtime:22.23.1 - version: runtime:22.23.1 + specifier: runtime:22.23.3 + version: runtime:22.23.3 nx: specifier: 23.2.0 version: 23.2.0(@swc/core@1.15.43(@swc/helpers@0.5.23)) @@ -1041,6 +1044,9 @@ importers: dompurify: specifier: 'catalog:' version: 3.4.13 + flexsearch: + specifier: 'catalog:' + version: 0.7.43 i18n-iso-countries: specifier: 7.14.0 version: 7.14.0 @@ -18202,7 +18208,7 @@ packages: resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} engines: {node: '>=18'} - node@runtime:22.23.1: + node@runtime:22.23.3: resolution: type: variations variants: @@ -18210,9 +18216,9 @@ packages: archive: tarball bin: node: bin/node - integrity: sha256-r6sHH3cQcyNdwRkeLthyUF16CNI83/UQPGePnsK6RPk= + integrity: sha256-uF3F8NepvoA4xOc0UNkSWyQceS7H7ElSKTRfhtHj9vo= type: binary - url: https://nodejs.org/download/release/v22.23.1/node-v22.23.1-aix-ppc64.tar.gz + url: https://nodejs.org/download/release/v22.23.3/node-v22.23.3-aix-ppc64.tar.gz targets: - cpu: ppc64 os: aix @@ -18220,9 +18226,9 @@ packages: archive: tarball bin: node: bin/node - integrity: sha256-7yjY+rLA5DFFItS7G3FzJwqjk36TuSy33nnBEqwfqVM= + integrity: sha256-I7JSRdz7mvcmL4/xQuni4K8CU2gRcynnp0WKUeWSL1M= type: binary - url: https://nodejs.org/download/release/v22.23.1/node-v22.23.1-darwin-arm64.tar.gz + url: https://nodejs.org/download/release/v22.23.3/node-v22.23.3-darwin-arm64.tar.gz targets: - cpu: arm64 os: darwin @@ -18230,9 +18236,9 @@ packages: archive: tarball bin: node: bin/node - integrity: sha256-uNqYG4oLEkG3AkkgSRbadsY1c931gU29LR5BBpEFy4E= + integrity: sha256-imd7AhkXjv1usOR1RXxK+0UrUhqS9uZ4Rac72Fcn8qg= type: binary - url: https://nodejs.org/download/release/v22.23.1/node-v22.23.1-darwin-x64.tar.gz + url: https://nodejs.org/download/release/v22.23.3/node-v22.23.3-darwin-x64.tar.gz targets: - cpu: x64 os: darwin @@ -18240,9 +18246,9 @@ packages: archive: tarball bin: node: bin/node - integrity: sha256-VD+jnlfUwHhVk5RZoyP03rmnndG7RebplFiw8t4Q240= + integrity: sha256-XO0tSNHXGYc5t/hoBN4Bca77aCO2hLEjQdMyGvw8sLI= type: binary - url: https://nodejs.org/download/release/v22.23.1/node-v22.23.1-linux-arm64.tar.gz + url: https://nodejs.org/download/release/v22.23.3/node-v22.23.3-linux-arm64.tar.gz targets: - cpu: arm64 os: linux @@ -18250,9 +18256,9 @@ packages: archive: tarball bin: node: bin/node - integrity: sha256-A8VqwL0+88zpZ8L3svesIlmkrnzuqmYSkarfZXKai1M= + integrity: sha256-i5kFjsb043K5iH+Oy1d6KSrbEy1Z5RBDVkKKdY/bEmw= type: binary - url: https://nodejs.org/download/release/v22.23.1/node-v22.23.1-linux-armv7l.tar.gz + url: https://nodejs.org/download/release/v22.23.3/node-v22.23.3-linux-armv7l.tar.gz targets: - cpu: armv7l os: linux @@ -18260,9 +18266,9 @@ packages: archive: tarball bin: node: bin/node - integrity: sha256-dboK1JScyQ/gz18eDCi7fl0Xwe2uzTXb8B9YMPmztVI= + integrity: sha256-I2cS3e9z9WwOBZCixJ9pyDk6LC2Cw9YsfBZWBbRys40= type: binary - url: https://nodejs.org/download/release/v22.23.1/node-v22.23.1-linux-ppc64le.tar.gz + url: https://nodejs.org/download/release/v22.23.3/node-v22.23.3-linux-ppc64le.tar.gz targets: - cpu: ppc64le os: linux @@ -18270,9 +18276,9 @@ packages: archive: tarball bin: node: bin/node - integrity: sha256-lMfFVVOtVSAXL1v70UM4nuvR5qIApeLY0wnp7XPzcOU= + integrity: sha256-UfZHlPX2G9/ICqGGyCcwIUc5l2Bc+Q7THtFIvuZQpVQ= type: binary - url: https://nodejs.org/download/release/v22.23.1/node-v22.23.1-linux-s390x.tar.gz + url: https://nodejs.org/download/release/v22.23.3/node-v22.23.3-linux-s390x.tar.gz targets: - cpu: s390x os: linux @@ -18280,9 +18286,20 @@ packages: archive: tarball bin: node: bin/node - integrity: sha256-eoywS0od9Or0MhJTJLgbKaCI5zVwojJZqN4cZdB/wSk= + integrity: sha256-Yz9JpPOIxflWTVbZwPX/o5Q0bZLKAHb+Ic93bs4ZiLw= + type: binary + url: https://nodejs.org/download/release/v22.23.3/node-v22.23.3-linux-x64-musl.tar.gz + targets: + - cpu: x64 + os: linux + libc: musl + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-EISqNhlrukw6Xmmh7jiKbk/3KdrQlEX7zUNLKP48JK8= type: binary - url: https://nodejs.org/download/release/v22.23.1/node-v22.23.1-linux-x64.tar.gz + url: https://nodejs.org/download/release/v22.23.3/node-v22.23.3-linux-x64.tar.gz targets: - cpu: x64 os: linux @@ -18290,10 +18307,10 @@ packages: archive: zip bin: node: node.exe - integrity: sha256-tHD9/jUCwFFRZW4G1JXj9HVE8u6LHZyHBQkPLdWZa9A= - prefix: node-v22.23.1-win-arm64 + integrity: sha256-M9rSLkzvXuj5+7Gw0Df9rNDlbRKkWA8NY/aLiU3qtTU= + prefix: node-v22.23.3-win-arm64 type: binary - url: https://nodejs.org/download/release/v22.23.1/node-v22.23.1-win-arm64.zip + url: https://nodejs.org/download/release/v22.23.3/node-v22.23.3-win-arm64.zip targets: - cpu: arm64 os: win32 @@ -18301,10 +18318,10 @@ packages: archive: zip bin: node: node.exe - integrity: sha256-ffC8k3VyP0qGs6obfMczQkI9lneo30U4rKMaBJ4wnCk= - prefix: node-v22.23.1-win-x64 + integrity: sha256-Kw/1ewSc2hu86iJA7sIEZwGHE8Hv4fc2DCaBhZuQ7XE= + prefix: node-v22.23.3-win-x64 type: binary - url: https://nodejs.org/download/release/v22.23.1/node-v22.23.1-win-x64.zip + url: https://nodejs.org/download/release/v22.23.3/node-v22.23.3-win-x64.zip targets: - cpu: x64 os: win32 @@ -18312,10 +18329,10 @@ packages: archive: zip bin: node: node.exe - integrity: sha256-4pizaKrYbFcUR6NlDbPOGQYzc//TnW1z0BSl2a0x3GI= - prefix: node-v22.23.1-win-x86 + integrity: sha256-HgesAOYbQbfs8kLejjiIPSN9WtP9ytfBby3TqIZw4rY= + prefix: node-v22.23.3-win-x86 type: binary - url: https://nodejs.org/download/release/v22.23.1/node-v22.23.1-win-x86.zip + url: https://nodejs.org/download/release/v22.23.3/node-v22.23.3-win-x86.zip targets: - cpu: x86 os: win32 @@ -18323,9 +18340,9 @@ packages: archive: tarball bin: node: bin/node - integrity: sha256-6TYoUgNGHwVhS7oT+yZatk5cYwSLNVFM7GaumhZmoHs= + integrity: sha256-jOwDpJjYg3OdHcm5JP2i+GlEcFtUrHB79IpjdIN3THA= type: binary - url: https://unofficial-builds.nodejs.org/download/release/v22.23.1/node-v22.23.1-linux-arm64-musl.tar.gz + url: https://unofficial-builds.nodejs.org/download/release/v22.23.3/node-v22.23.3-linux-arm64-musl.tar.gz targets: - cpu: arm64 os: linux @@ -18334,14 +18351,14 @@ packages: archive: tarball bin: node: bin/node - integrity: sha256-sEh3zxmhslFc/ZWg6rjzV7LqoGUSjDudoHsLEO/iV3w= + integrity: sha256-XqRolPwG4JNjhG2O1lqCK9AL6jXZdOQTICcqSK8wnjo= type: binary - url: https://unofficial-builds.nodejs.org/download/release/v22.23.1/node-v22.23.1-linux-x64-musl.tar.gz + url: https://unofficial-builds.nodejs.org/download/release/v22.23.3/node-v22.23.3-linux-x64-musl.tar.gz targets: - cpu: x64 os: linux libc: musl - version: 22.23.1 + version: 22.23.3 hasBin: true nodemailer-mailgun-transport@2.1.5: @@ -41524,7 +41541,7 @@ snapshots: node-releases@2.0.53: {} - node@runtime:22.23.1: {} + node@runtime:22.23.3: {} nodemailer-mailgun-transport@2.1.5(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2): dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8f7f4710762..ec2856f9efc 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -137,6 +137,7 @@ catalog: eslint-plugin-react-hooks: 5.2.0 eslint-plugin-react-refresh: 0.5.4 eslint-plugin-tailwindcss: 4.0.4 + flexsearch: 0.7.43 fs-extra: 11.3.6 glob: 13.0.6 globals: 17.7.0