From cc0dfa062ff58bd0b078d94f70b99a577ac27389 Mon Sep 17 00:00:00 2001 From: swissky <30409887+swissky@users.noreply.github.com> Date: Fri, 11 Sep 2026 07:23:45 +0200 Subject: [PATCH 1/2] fix(core): RecentPosts widget links by slug and supports urlTemplate Adds an optional urlTemplate prop with the same token semantics as LiveSearch's routeMap; without a template the widget keeps its locale-prefixed default links. The admin widget form shows a localized label for the new prop. --- .changeset/recent-posts-widget-url.md | 6 +++ docs/src/content/docs/guides/widgets.mdx | 2 +- packages/admin/src/components/Widgets.tsx | 1 + .../src/components/widgets/RecentPosts.astro | 22 ++++++++- packages/core/src/widgets/components.ts | 4 ++ .../repro/recent-posts-url.render.test.ts | 46 +++++++++++++++++++ .../core/tests/unit/widgets/widgets.test.ts | 1 + 7 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 .changeset/recent-posts-widget-url.md create mode 100644 packages/core/tests/repro/recent-posts-url.render.test.ts diff --git a/.changeset/recent-posts-widget-url.md b/.changeset/recent-posts-widget-url.md new file mode 100644 index 0000000000..c1bd4a5034 --- /dev/null +++ b/.changeset/recent-posts-widget-url.md @@ -0,0 +1,6 @@ +--- +"emdash": patch +"@emdash-cms/admin": patch +--- + +Adds an optional `urlTemplate` prop to the `core:recent-posts` widget (e.g. `"/blog/:slug"` or `"/:slug"` for catch-all routes), using the same `:collection`, `:id`, `:slug`, and `:path` tokens as LiveSearch's `routeMap`, with a localized label in the admin widget form. Without a template the widget links exactly as before. diff --git a/docs/src/content/docs/guides/widgets.mdx b/docs/src/content/docs/guides/widgets.mdx index f082a04c10..9ae162dfe7 100644 --- a/docs/src/content/docs/guides/widgets.mdx +++ b/docs/src/content/docs/guides/widgets.mdx @@ -35,7 +35,7 @@ The following built-in component widgets are available: | Component | What it renders | | --------- | --------------- | -| `core:recent-posts` | Recent posts, with optional dates and thumbnails | +| `core:recent-posts` | Recent posts, with optional dates, thumbnails, and a link URL template (e.g. `/blog/:slug`) | | `core:categories` | Category links and optional entry counts | | `core:tags` | A limited list of tag links and optional counts | | `core:search` | A search form that submits to `/search` | diff --git a/packages/admin/src/components/Widgets.tsx b/packages/admin/src/components/Widgets.tsx index d2b943110f..a9aef9a3e0 100644 --- a/packages/admin/src/components/Widgets.tsx +++ b/packages/admin/src/components/Widgets.tsx @@ -129,6 +129,7 @@ const CORE_WIDGET_META: Record = { count: { label: msg`Number of posts` }, showThumbnails: { label: msg`Show thumbnails` }, showDate: { label: msg`Show date` }, + urlTemplate: { label: msg`URL template (e.g. /blog/:slug)` }, }, }, "core:categories": { diff --git a/packages/core/src/components/widgets/RecentPosts.astro b/packages/core/src/components/widgets/RecentPosts.astro index cef0090a0a..0ce08ff8fe 100644 --- a/packages/core/src/components/widgets/RecentPosts.astro +++ b/packages/core/src/components/widgets/RecentPosts.astro @@ -1,13 +1,16 @@ --- import { getEmDashCollection } from "../../query.js"; +import { buildLiveSearchResultUrl } from "../live-search-routing.js"; interface Props { count?: number; showThumbnails?: boolean; showDate?: boolean; + /** URL template for post links, e.g. "/blog/:slug" (tokens: :collection, :id, :slug, :path) */ + urlTemplate?: string; } -const { count = 5, showThumbnails = false, showDate = true } = Astro.props; +const { count = 5, showThumbnails = false, showDate = true, urlTemplate } = Astro.props; const { entries: posts } = await getEmDashCollection("posts", { limit: count, @@ -27,6 +30,21 @@ function getString(data: Record, key: string): string | undefin const publishedAt = getString(post.data, "publishedAt"); const featuredImage = getString(post.data, "featured_image"); const title = getString(post.data, "title"); + // Without a template, keep the widget's long-standing default: + // `post.id` is the loader's slug (or `locale/slug` with i18n + // prefixing), so the locale prefix is preserved. With a template, + // use the same token semantics as LiveSearch's routeMap — `:id` + // is the content ULID (`data.id`), `:slug` the bare slug. + const href = urlTemplate + ? buildLiveSearchResultUrl( + { + collection: "posts", + id: getString(post.data, "id") ?? post.id, + slug: getString(post.data, "slug") ?? post.id, + }, + { posts: urlTemplate }, + ) + : `/posts/${post.id}`; return (
  • {showThumbnails && featuredImage && ( @@ -36,7 +54,7 @@ function getString(data: Record, key: string): string | undefin class="widget-recent-posts__thumbnail" /> )} - + {title} {showDate && publishedAt && ( diff --git a/packages/core/src/widgets/components.ts b/packages/core/src/widgets/components.ts index 153aba24e7..722cf4b14e 100644 --- a/packages/core/src/widgets/components.ts +++ b/packages/core/src/widgets/components.ts @@ -25,6 +25,10 @@ export const coreWidgetComponents: WidgetComponentDef[] = [ label: "Show date", default: true, }, + urlTemplate: { + type: "string", + label: "URL template (e.g. /blog/:slug)", + }, }, }, { diff --git a/packages/core/tests/repro/recent-posts-url.render.test.ts b/packages/core/tests/repro/recent-posts-url.render.test.ts new file mode 100644 index 0000000000..c050ebfa7a --- /dev/null +++ b/packages/core/tests/repro/recent-posts-url.render.test.ts @@ -0,0 +1,46 @@ +import { experimental_AstroContainer as AstroContainer } from "astro/container"; +import { describe, expect, it, vi } from "vitest"; + +import RecentPosts from "../../src/components/widgets/RecentPosts.astro"; + +vi.mock("../../src/query.js", () => ({ + getEmDashCollection: vi.fn(async () => ({ + entries: [ + { + // The loader's entry id is the slug, or `locale/slug` with + // i18n prefixing — distinct from the content ULID in data.id. + id: "en/hello-world", + data: { + id: "01ARZ3NDEKTSV4RRFFQ69G5FAV", + slug: "hello-world", + title: "Hello World", + publishedAt: "2026-01-01T00:00:00.000Z", + }, + }, + ], + })), +})); + +async function renderHref(props: Record): Promise { + const container = await AstroContainer.create(); + const html = await container.renderToString(RecentPosts, { props, locals: {} }); + const match = html.match(/ { + it("keeps the locale-prefixed default without a template", async () => { + expect(await renderHref({})).toBe("/posts/en/hello-world"); + }); + + it("substitutes the bare slug into :slug", async () => { + expect(await renderHref({ urlTemplate: "/blog/:slug" })).toBe("/blog/hello-world"); + }); + + it("substitutes the content ULID into :id, not the slug-shaped entry id", async () => { + expect(await renderHref({ urlTemplate: "/posts/:id" })).toBe( + "/posts/01ARZ3NDEKTSV4RRFFQ69G5FAV", + ); + }); +}); diff --git a/packages/core/tests/unit/widgets/widgets.test.ts b/packages/core/tests/unit/widgets/widgets.test.ts index bd0245ae94..482fdacdb1 100644 --- a/packages/core/tests/unit/widgets/widgets.test.ts +++ b/packages/core/tests/unit/widgets/widgets.test.ts @@ -508,6 +508,7 @@ describe("Widget System", () => { expect(recentPosts?.props).toHaveProperty("count"); expect(recentPosts?.props).toHaveProperty("showThumbnails"); expect(recentPosts?.props).toHaveProperty("showDate"); + expect(recentPosts?.props).toHaveProperty("urlTemplate"); }); it("should include categories component", () => { From c837311e6492a3cb5e98967dc1684a6f8833f202 Mon Sep 17 00:00:00 2001 From: swissky <30409887+swissky@users.noreply.github.com> Date: Fri, 11 Sep 2026 07:33:46 +0200 Subject: [PATCH 2/2] fix(core): sanitize RecentPosts urlTemplate hrefs A widget-form template could carry an unsafe URL scheme into public page links; route templated URLs through sanitizeHref like menu links, and document the template tokens in the widgets guide. --- docs/src/content/docs/guides/widgets.mdx | 2 ++ .../src/components/widgets/RecentPosts.astro | 17 ++++++++++------- .../tests/repro/recent-posts-url.render.test.ts | 4 ++++ 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/docs/src/content/docs/guides/widgets.mdx b/docs/src/content/docs/guides/widgets.mdx index 9ae162dfe7..2746308097 100644 --- a/docs/src/content/docs/guides/widgets.mdx +++ b/docs/src/content/docs/guides/widgets.mdx @@ -41,6 +41,8 @@ The following built-in component widgets are available: | `core:search` | A search form that submits to `/search` | | `core:archives` | Monthly or yearly post archive links | +The `core:recent-posts` link template accepts the same tokens as LiveSearch's `routeMap`: `:collection`, `:id` (the content ULID), `:slug` (the bare slug), and `:path` (slug or ID). Without a template, links keep their existing shape, including any Astro i18n locale prefix. + ## Place the area in a template Import `WidgetArea` from `emdash/ui`. The component fetches the named area, preserves the configured order, and renders nothing when the area is missing or empty. diff --git a/packages/core/src/components/widgets/RecentPosts.astro b/packages/core/src/components/widgets/RecentPosts.astro index 0ce08ff8fe..5afd61f43e 100644 --- a/packages/core/src/components/widgets/RecentPosts.astro +++ b/packages/core/src/components/widgets/RecentPosts.astro @@ -1,5 +1,6 @@ --- import { getEmDashCollection } from "../../query.js"; +import { sanitizeHref } from "../../utils/url.js"; import { buildLiveSearchResultUrl } from "../live-search-routing.js"; interface Props { @@ -36,13 +37,15 @@ function getString(data: Record, key: string): string | undefin // use the same token semantics as LiveSearch's routeMap — `:id` // is the content ULID (`data.id`), `:slug` the bare slug. const href = urlTemplate - ? buildLiveSearchResultUrl( - { - collection: "posts", - id: getString(post.data, "id") ?? post.id, - slug: getString(post.data, "slug") ?? post.id, - }, - { posts: urlTemplate }, + ? sanitizeHref( + buildLiveSearchResultUrl( + { + collection: "posts", + id: getString(post.data, "id") ?? post.id, + slug: getString(post.data, "slug") ?? post.id, + }, + { posts: urlTemplate }, + ), ) : `/posts/${post.id}`; return ( diff --git a/packages/core/tests/repro/recent-posts-url.render.test.ts b/packages/core/tests/repro/recent-posts-url.render.test.ts index c050ebfa7a..87bcedaff8 100644 --- a/packages/core/tests/repro/recent-posts-url.render.test.ts +++ b/packages/core/tests/repro/recent-posts-url.render.test.ts @@ -43,4 +43,8 @@ describe("RecentPosts link URLs", () => { "/posts/01ARZ3NDEKTSV4RRFFQ69G5FAV", ); }); + + it("neutralizes a template with an unsafe URL scheme", async () => { + expect(await renderHref({ urlTemplate: "javascript:alert(1)/:slug" })).toBe("#"); + }); });