Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/recent-posts-widget-url.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"emdash": patch
"@emdash-cms/admin": patch
---

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] The changeset claims the old link was /posts/{id} where {id} is the ULID, but the loader exposes the slug as entry.id. The old default already linked by slug for non-i18n sites (and by locale/slug when i18n prefixing is active). The actual user-facing change is adding the optional urlTemplate prop and routing the link through buildLiveSearchResultUrl.

Please reword the changeset to describe what actually changed, e.g.:

Suggested change
---
---
"emdash": patch
---
Adds an optional `urlTemplate` prop to the `core:recent-posts` widget (e.g. `"/blog/:slug"`) using the same `:collection`, `:id`, `:slug`, and `:path` tokens as LiveSearch's `routeMap`. The default post link is now built through `buildLiveSearchResultUrl` and uses the post slug.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right — the loader exposes the slug (or locale/slug) as entry.id, so the old default was already slug-based and there was no 404. Reworded the changeset in 85bb8e6 to describe the actual change: an optional urlTemplate prop with LiveSearch's token vocabulary, default link unchanged. PR body updated too.


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.
4 changes: 3 additions & 1 deletion docs/src/content/docs/guides/widgets.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,14 @@ 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` |
| `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.
Expand Down
1 change: 1 addition & 0 deletions packages/admin/src/components/Widgets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ const CORE_WIDGET_META: Record<string, CoreWidgetMeta> = {
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": {
Expand Down
25 changes: 23 additions & 2 deletions packages/core/src/components/widgets/RecentPosts.astro
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
---
import { getEmDashCollection } from "../../query.js";
import { sanitizeHref } from "../../utils/url.js";
import { buildLiveSearchResultUrl } from "../live-search-routing.js";

interface Props {
count?: number;
showThumbnails?: boolean;
showDate?: boolean;
Comment on lines 8 to 9

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] The new urlTemplate prop is declared on the component, but it is not registered in packages/core/src/widgets/components.ts under the core:recent-posts coreWidgetComponents entry. The admin UI builds the prop form from that registry (packages/admin/src/components/Widgets.tsx iterates selectedComponent.props), so this prop is currently invisible to admin users.

Add the prop definition there, e.g.:

urlTemplate: {
  type: "string",
  label: "URL template",
},

Also update the should return core widget components test in packages/core/tests/unit/widgets/widgets.test.ts to assert that recentPosts?.props has the new urlTemplate property.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4a35b0durlTemplate is now registered in coreWidgetComponents (type string, label "URL template (e.g. /blog/:slug)") so the admin prop form renders it, and the should return core widget components test asserts the new prop.

/** 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,
Expand All @@ -27,6 +31,23 @@ function getString(data: Record<string, unknown>, 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
? sanitizeHref(
buildLiveSearchResultUrl(
{
collection: "posts",
id: getString(post.data, "id") ?? post.id,
slug: getString(post.data, "slug") ?? post.id,
},
{ posts: urlTemplate },
),
)
: `/posts/${post.id}`;
return (
<li>
{showThumbnails && featuredImage && (
Expand All @@ -36,7 +57,7 @@ function getString(data: Record<string, unknown>, key: string): string | undefin
class="widget-recent-posts__thumbnail"
/>
)}
<a href={`/posts/${post.id}`} class="widget-recent-posts__link">
<a href={href} class="widget-recent-posts__link">
{title}
</a>
{showDate && publishedAt && (
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/widgets/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ export const coreWidgetComponents: WidgetComponentDef[] = [
label: "Show date",
default: true,
},
urlTemplate: {
type: "string",
Comment on lines +28 to +29

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The urlTemplate prop label is a user-facing admin UI string ("URL template (e.g. /blog/:slug)"), but it is not routed through Lingui. This follows the existing pattern for other widget prop labels, but that pattern violates AGENTS.md's requirement that all admin UI strings be localizable. If you localize the registry later, this label will need to be included.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — this follows the existing pattern for widget prop labels in coreWidgetComponents (none are routed through Lingui today, since the registry lives server-side in core, not in the admin bundle). Localizing the registry is a systemic change across all core widgets, so I'd rather keep it out of this PR; happy to pick it up separately if the maintainers want it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right — this is pre-existing debt across the whole coreWidgetComponents registry (every widget label is a plain string, not just the new one), and your own note flags it as a follow-up migration to MessageDescriptor. Migrating the registry's i18n is out of scope for this bugfix PR; the new label follows the same pattern as the existing ones so the surface doesn't grow inconsistently. Happy to pick up the registry-wide migration as a separate PR once there's an agreed approach.

label: "URL template (e.g. /blog/:slug)",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The new admin-facing prop label is hard-coded English. AGENTS.md requires every user-facing admin string to be wrapped for Lingui. The whole coreWidgetComponents registry currently uses plain strings, so this is pre-existing debt rather than a new violation, but any new label added now increases the migration surface. Consider migrating WidgetComponentDef.label (and prop labels) to MessageDescriptor in a follow-up and extracting the new string when that infrastructure is in place.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right — this is pre-existing debt across the whole coreWidgetComponents registry (every widget label is a plain string, not just the new one), and your own note flags it as a follow-up migration to MessageDescriptor. Migrating the registry's i18n is out of scope for this bugfix PR; the new label follows the same pattern as the existing ones so the surface doesn't grow inconsistently. Happy to pick up the registry-wide migration as a separate PR once there's an agreed approach.

},
},
},
{
Expand Down
50 changes: 50 additions & 0 deletions packages/core/tests/repro/recent-posts-url.render.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
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<string, unknown>): Promise<string> {
const container = await AstroContainer.create();
const html = await container.renderToString(RecentPosts, { props, locals: {} });
const match = html.match(/<a href="([^"]*)"/);
if (!match) throw new Error(`no link in rendered widget: ${html}`);
return match[1]!;
}

describe("RecentPosts link URLs", () => {
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",
);
});

it("neutralizes a template with an unsafe URL scheme", async () => {
expect(await renderHref({ urlTemplate: "javascript:alert(1)/:slug" })).toBe("#");
});
});
1 change: 1 addition & 0 deletions packages/core/tests/unit/widgets/widgets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] This assertion verifies the prop is registered, but it does not exercise the actual link generation in RecentPosts.astro. The existing live-search-routing.test.ts covers the helper in isolation, so a regression in the arguments passed from the widget (for example :id accidentally receiving the slug) would not be caught. Consider adding a render or e2e test that creates a core:recent-posts widget with urlTemplate and asserts the rendered anchor href.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point on coverage. The :id token semantics (ULID vs slug) are exercised in live-search-routing.test.ts for the helper itself; the widget call site is a thin pass-through. A full render test here needs a seeded DB (getEmDashCollection) — I'll fold that into a follow-up rather than grow this bugfix. Tracked.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] The added assertion only checks that urlTemplate exists on the registry definition. AGENTS.md requires "TDD for bugs: failing test -> fix -> verify. A bug without a reproducing test is not fixed." This PR is framed as a fix for #1332, but there is no test that exercises the actual link generation — that :id maps to post.data.id (the content ULID), :slug maps to post.data.slug, and the default branch still emits /posts/${post.id} (preserving any locale prefix).

The existing live-search-routing.test.ts covers the generic helper, but it does not guard the RecentPosts mapping; a future regression that swapped post.data.id for post.id would not be caught. Add a behavioral test. One option is a render test in tests/repro/recent-posts-url.render.test.ts that mocks getEmDashCollection, or extract the URL builder to a small pure helper and unit-test it.

// Example: tests/repro/recent-posts-url.render.test.ts
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: async () => ({
		entries: [
			{
				id: "hello-world",
				data: {
					id: "01JABCULID",
					slug: "hello-world",
					title: "Hello World",
					publishedAt: "2026-01-01",
				},
				edit: {},
			},
		],
	}),
}));

describe("RecentPosts urlTemplate", () => {
	it("uses :slug from data.slug", async () => {
		const container = await AstroContainer.create();
		const html = await container.renderToString(RecentPosts, {
			props: { urlTemplate: "/blog/:slug", count: 1 },
			locals: {},
		});
		expect(html).toContain('href="/blog/hello-world"');
	});

	it("uses :id from data.id", async () => {
		const container = await AstroContainer.create();
		const html = await container.renderToString(RecentPosts, {
			props: { urlTemplate: "/post/:id", count: 1 },
			locals: {},
		});
		expect(html).toContain('href="/post/01JABCULID"');
	});

	it("preserves the default link shape when no template is set", async () => {
		const container = await AstroContainer.create();
		const html = await container.renderToString(RecentPosts, {
			props: { count: 1 },
			locals: {},
		});
		expect(html).toContain('href="/posts/hello-world"');
	});
});

});

it("should include categories component", () => {
Expand Down
Loading