Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .changeset/collection-permalinks-step1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@cloudhal/core-contracts": patch
"@cloudhal/core-ts": patch
"@cloudhal/astro-renderer": patch
"@cloudhal/api": patch
"@cloudhal/mcp-server": patch
"@cloudhal/cli": patch
---

RFC-0038 step 1: `Site.collections.<key>.permalink` in the contracts (a template, `/blog/{slug}`; v1 is fixed segments plus `{slug}`), and `entryPath(site, entry)` / `entryAt(site, path)` in `@cloudhal/core-ts` (also `@cloudhal/core-ts/permalinks`). Every entry URL in the renderer, starter, API, MCP server, CLI and Studio now comes from them; with no `collections` set, every URL is the same as before. The renderer's one-argument `entryPath(entry)` is replaced by core's `entryPath(site, entry)` (re-exported from `@cloudhal/astro-renderer/routing`); themes pass the site, or `MediaContext.permalinks`.
33 changes: 3 additions & 30 deletions apps/studio/src/components/AnalyticsSections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ import { RedirectDialog } from "./RedirectDialog.tsx";
import { RelativeTime } from "./RelativeTime.tsx";
import { SettingRow } from "./Settings.tsx";
import { TableSkeleton } from "./TableSkeleton.tsx";
import { taxonomyKeys, useTaxonomyData } from "./Taxonomy.tsx";
import { servedPathsInput, taxonomyKeys, useTaxonomyData } from "./Taxonomy.tsx";
import { useEditorByPath } from "./useEditorByPath.ts";
import { useGate } from "./useMe.ts";

/** What every card reads for: the site, the range (and its UTC days), the filter, polling. */
Expand Down Expand Up @@ -565,28 +566,6 @@ function statusVariant(status: number): "warning" | "secondary" | "error" | "inf
return status >= 500 ? "error" : "info";
}

/** Where a same-site path is edited: pages by path, published articles by `/<collection>/<slug>`. */
function useEditorByPath(siteId: string, enabled: boolean) {
const pages = useQuery({
queryKey: ["pages", siteId],
queryFn: () => api.pages.list(siteId),
enabled,
});
const entries = useQuery({
queryKey: ["entries", siteId, "all"],
queryFn: () => api.entries.list(siteId),
enabled,
});
return useMemo(() => {
const map = new Map<string, string>();
for (const p of pages.data ?? []) map.set(p.path, `/sites/${siteId}/pages/${p.id}`);
for (const e of entries.data ?? [])
if (e.status === "published")
map.set(`/${e.collection}/${e.slug}`, `/sites/${siteId}/articles/${e.id}`);
return map;
}, [pages.data, entries.data, siteId]);
}

/** The redirect dialog, prefilled with a broken path; it loads what it checks against when opened. */
function CreateRedirect({
siteId,
Expand Down Expand Up @@ -614,13 +593,7 @@ function CreateRedirect({
queryFn: () => api.entries.list(siteId),
enabled: open,
});
const served = servedPaths({
pages: pages.data ?? [],
entries: entries.data ?? [],
defaultLocale: data.defaultLocale,
taxonomies: data.taxonomies.data ?? [],
terms: data.items.map((i) => i.term),
});
const served = servedPaths(servedPathsInput(data, pages.data, entries.data));
return (
<RedirectDialog
siteId={siteId}
Expand Down
38 changes: 38 additions & 0 deletions apps/studio/src/components/EntryPath.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Where an article lives on the site: its collection's permalink with the slug filled in
* (RFC-0038, core-ts `entryPath`; `/<collection>/<slug>` while the site sets none). The site comes
* from the shared `["site", siteId]` query; until it loads, paths use the default template.
*/
import type { ContentEntry } from "@cloudhal/core-contracts";
import { entryPath, type PermalinkSite } from "@cloudhal/core-ts/permalinks";
import { Text } from "@cloudhal/ui";
import { useQuery } from "@tanstack/react-query";
import { api } from "../lib/api.ts";
import { currentSiteId } from "./useMe.ts";

/** The site as `entryPath` reads it (undefined while loading). */
export function useSitePermalinks(siteId: string): PermalinkSite | undefined {
return useQuery({
queryKey: ["site", siteId],
queryFn: () => api.sites.get(siteId),
enabled: siteId !== "",
}).data;
}

/** An article's path in mono text; `siteId` defaults to the site the Studio URL is about. */
export function EntryPath({
siteId = currentSiteId() ?? "",
entry,
truncate = false,
}: {
siteId?: string;
entry: Pick<ContentEntry, "collection" | "slug">;
truncate?: boolean;
}) {
const site = useSitePermalinks(siteId);
return (
<Text variant="mono-secondary" as="span" truncate={truncate}>
{entryPath(site, entry)}
</Text>
);
}
5 changes: 2 additions & 3 deletions apps/studio/src/components/GovernanceDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
TERM_LIMITS,
validateSlug,
} from "../lib/taxonomy.ts";
import { EntryPath } from "./EntryPath.tsx";
import { ErrorBanner } from "./ErrorBanner.tsx";
import { Status } from "./Status.tsx";
import { invalidateTaxonomy, type TaxonomyData } from "./Taxonomy.tsx";
Expand Down Expand Up @@ -436,9 +437,7 @@ function PreviewSection({
<Text as="span" truncate>
{e.title || t("articles.untitled")}
</Text>
<Text variant="mono-secondary" as="span" truncate>
/{e.collection}/{e.slug}
</Text>
<EntryPath entry={e} truncate />
</span>
<Status status={e.status} />
</div>
Expand Down
3 changes: 2 additions & 1 deletion apps/studio/src/components/ScheduleDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* it waits in Approvals; what the items need that is not live yet joins them (the closure).
*/
import type { PublishStatus } from "@cloudhal/core-contracts";
import { entryPath } from "@cloudhal/core-ts/permalinks";
import {
Banner,
Combobox,
Expand Down Expand Up @@ -132,7 +133,7 @@ export function ScheduleDialog({
value: e.id,
label: e.title || e.slug,
kind: "contentEntry" as const,
detail: `/${e.collection}/${e.slug}`,
detail: entryPath(zone.site, e),
})),
...(pages.data ?? []).map((p) => ({
value: p.id,
Expand Down
5 changes: 4 additions & 1 deletion apps/studio/src/components/Seo.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ContentEntry, Page } from "@cloudhal/core-contracts";
import { entryPath } from "@cloudhal/core-ts/permalinks";
import {
Badge,
Banner,
Expand Down Expand Up @@ -34,6 +35,7 @@ import {
truncateForSearch,
} from "../lib/seo.ts";
import { setOptional } from "../lib/util.ts";
import { useSitePermalinks } from "./EntryPath.tsx";
import { ImageRefField } from "./MediaPicker.tsx";
import { SettingRow } from "./Settings.tsx";

Expand Down Expand Up @@ -190,7 +192,8 @@ export function ArticleSeoPreviewCard({
const title = entry.title.trim() || t("articles.untitled");
const summary = entry.summary?.trim() ?? "";
const description = summary ? truncateForSearch(summary).text : t("seo.preview.noSummary");
const path = `/${entry.collection}/${entry.slug}`;
const site = useSitePermalinks(entry.siteId);
const path = entryPath(site, entry);
const cover = entry.coverImage;
const coverUrl = cover ? imageRefPreviewUrl(entry.siteId, cover, host) : undefined;
const titleState = titleStatus(entry.title);
Expand Down
3 changes: 2 additions & 1 deletion apps/studio/src/components/SnapshotDiffDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Snapshot } from "@cloudhal/core-contracts";
import { entryPath } from "@cloudhal/core-ts/permalinks";
import {
Badge,
InlineCopyText,
Expand Down Expand Up @@ -195,7 +196,7 @@ function useNodeNames(siteId: string, enabled: boolean) {
return entry
? {
title: entry.title,
detail: `/${entry.collection}/${entry.slug}`,
detail: entryPath(site.data, entry),
href: `/sites/${siteId}/articles/${entry.id}`,
}
: {};
Expand Down
19 changes: 18 additions & 1 deletion apps/studio/src/components/Taxonomy.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Taxonomy, Term } from "@cloudhal/core-contracts";
import type { ContentEntry, Page, Taxonomy, Term } from "@cloudhal/core-contracts";
import { Badge, DirectionalIcon, Link, Text, Tooltip } from "@cloudhal/ui";
import {
ArrowElbowDownRight,
Expand All @@ -10,6 +10,7 @@ import {
import { type QueryClient, useQuery } from "@tanstack/react-query";
import { useI18n, useT } from "../i18n/index.tsx";
import { api, type TermListItem, type TermUsage } from "../lib/api.ts";
import type { servedPaths } from "../lib/redirects.ts";
import {
archivePath,
defaultTaxonomies,
Expand Down Expand Up @@ -87,6 +88,22 @@ export function useTaxonomyData(siteId: string, enabled = true) {

export type TaxonomyData = ReturnType<typeof useTaxonomyData>;

/** What `servedPaths` reads, from the taxonomy data (entries under the site's permalinks). */
export function servedPathsInput(
data: TaxonomyData,
pages: readonly Page[] = [],
entries: readonly ContentEntry[] = [],
): Parameters<typeof servedPaths>[0] {
return {
pages,
entries,
defaultLocale: data.defaultLocale,
taxonomies: data.taxonomies.data ?? [],
terms: data.items.map((i) => i.term),
site: data.site.data,
};
}

/** After a taxonomy change: terms, taxonomies, articles, redirects and snapshots may all move. */
export async function invalidateTaxonomy(qc: QueryClient, siteId: string): Promise<void> {
await Promise.all([
Expand Down
28 changes: 28 additions & 0 deletions apps/studio/src/components/useEditorByPath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { entryPath } from "@cloudhal/core-ts/permalinks";
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { api } from "../lib/api.ts";
import { useSitePermalinks } from "./EntryPath.tsx";

/** Where a same-site path is edited: pages by path, published articles by their permalink. */
export function useEditorByPath(siteId: string, enabled: boolean) {
const pages = useQuery({
queryKey: ["pages", siteId],
queryFn: () => api.pages.list(siteId),
enabled,
});
const entries = useQuery({
queryKey: ["entries", siteId, "all"],
queryFn: () => api.entries.list(siteId),
enabled,
});
const site = useSitePermalinks(siteId);
return useMemo(() => {
const map = new Map<string, string>();
for (const p of pages.data ?? []) map.set(p.path, `/sites/${siteId}/pages/${p.id}`);
for (const e of entries.data ?? [])
if (e.status === "published")
map.set(entryPath(site, e), `/sites/${siteId}/articles/${e.id}`);
return map;
}, [pages.data, entries.data, siteId, site]);
}
2 changes: 1 addition & 1 deletion apps/studio/src/components/useMe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const ME_KEY = ["me"] as const;
* The site the Studio URL is about (`/sites/<id>/…`), read from the location so the hook also works
* outside the router (dialogs in tests, the account surface).
*/
function currentSiteId(): string | undefined {
export function currentSiteId(): string | undefined {
const match = /^\/sites\/([^/]+)/.exec(globalThis.location?.pathname ?? "");
return match?.[1] ? decodeURIComponent(match[1]) : undefined;
}
Expand Down
16 changes: 7 additions & 9 deletions apps/studio/src/lib/menus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ import {
type ResolvedMenuItem,
walkMenu,
} from "@cloudhal/core-ts/menu";
import { termLabel } from "./taxonomy.ts";
import { entryPath } from "@cloudhal/core-ts/permalinks";
import { archivePath, termLabel } from "./taxonomy.ts";

export type { MenuDelta, MenuHiddenReason, ResolvedMenu, ResolvedMenuItem };
export { MENU_LIMITS, menuDelta, newMenuItemId };
Expand Down Expand Up @@ -418,7 +419,7 @@ export function hostOf(href: string): string | undefined {

/** What Studio knows about the site to name targets and offer them. */
export interface TargetData {
site: Pick<Site, "defaultLocale" | "locales">;
site: Pick<Site, "defaultLocale" | "locales" | "collections">;
pages: readonly Page[];
entries: readonly ContentEntry[];
taxonomies: readonly Taxonomy[];
Expand Down Expand Up @@ -473,9 +474,6 @@ export function choiceOf(value: string): TargetChoice | undefined {
return undefined;
}

const archivePathOf = (tax: Pick<Taxonomy, "archive">, slug: string) =>
`${tax.archive.basePath}/${slug}`;

/** Every target the picker offers, grouped: pages, articles, each taxonomy, collections, other. */
export function targetOptions(data: TargetData): TargetOption[] {
const locale = data.site.defaultLocale;
Expand All @@ -496,7 +494,7 @@ export function targetOptions(data: TargetData): TargetOption[] {
value: `contentEntry:${e.id}`,
group: "articles" as const,
label: e.title,
detail: `/${e.collection}/${e.slug}`,
detail: entryPath(data.site, e),
...(e.status !== "published" ? { status: e.status } : {}),
}));
const terms: TargetOption[] = [];
Expand All @@ -519,7 +517,7 @@ export function targetOptions(data: TargetData): TargetOption[] {
group: "terms",
taxonomy: tax.key,
label: termLabel(t, locale, ...data.site.locales),
...(tax.archive.enabled ? { detail: archivePathOf(tax, t.slug) } : {}),
...(tax.archive.enabled ? { detail: archivePath(tax, t.slug) } : {}),
...(t.status === "proposed"
? { status: "proposed" }
: t.visibility === "internal"
Expand Down Expand Up @@ -592,7 +590,7 @@ export function summarizeTarget(item: MenuItem, data: TargetData): TargetSummary
case "contentEntry": {
const e = data.entries.find((x) => x.id === item.entryId);
return e
? { kind: "contentEntry", where: `/${e.collection}/${e.slug}`, title: e.title }
? { kind: "contentEntry", where: entryPath(data.site, e), title: e.title }
: { kind: "contentEntry", missing: true };
}
case "term": {
Expand All @@ -603,7 +601,7 @@ export function summarizeTarget(item: MenuItem, data: TargetData): TargetSummary
kind: "term",
title: termLabel(t, data.site.defaultLocale),
...(tax ? { taxonomy: tax.key } : {}),
...(tax?.archive.enabled ? { where: archivePathOf(tax, t.slug) } : {}),
...(tax?.archive.enabled ? { where: archivePath(tax, t.slug) } : {}),
}
: { kind: "term", missing: true };
}
Expand Down
5 changes: 4 additions & 1 deletion apps/studio/src/lib/redirects.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ContentEntry, Page, Redirect, Taxonomy, Term } from "@cloudhal/core-contracts";
import { entryPath, type PermalinkSite } from "@cloudhal/core-ts/permalinks";

/**
* Manual redirects in Studio (RFC-0002 invariant 14). The patterns are the contract's
Expand Down Expand Up @@ -53,11 +54,13 @@ export function servedPaths(input: {
defaultLocale: string;
taxonomies: readonly Pick<Taxonomy, "id" | "archive">[];
terms: readonly Pick<Term, "taxonomyId" | "slug" | "status" | "visibility">[];
/** The site's collection permalinks (RFC-0038), for articles' paths. */
site?: PermalinkSite | undefined;
}): Set<string> {
const out = new Set(input.pages.map((p) => p.path));
for (const e of input.entries)
if (e.status === "published" && e.locale === input.defaultLocale)
out.add(`/${e.collection}/${e.slug}`);
out.add(entryPath(input.site, e));
for (const tax of input.taxonomies) {
if (!tax.archive.enabled) continue;
for (const term of input.terms)
Expand Down
10 changes: 6 additions & 4 deletions apps/studio/src/routes/ArticleEditorPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ContentEntry, PublishStatus } from "@cloudhal/core-contracts";
import { collectionPermalink, permalinkPrefix } from "@cloudhal/core-ts/permalinks";
import {
ApprovalDialog,
Button,
Expand Down Expand Up @@ -43,6 +44,7 @@ import {
ScheduleAside,
ScheduleLock,
} from "../components/EditorSchedule.tsx";
import { EntryPath } from "../components/EntryPath.tsx";
import { ErrorBanner } from "../components/ErrorBanner.tsx";
import { ViewOnlyBanner } from "../components/Gating.tsx";
import { MenuDeleteNotice, useMenuReferences } from "../components/Menus.tsx";
Expand Down Expand Up @@ -278,9 +280,7 @@ export function ArticleEditorPage() {
description={
<span className="flex flex-wrap items-center gap-2">
<Status status={draft.status} />
<Text variant="mono-secondary" as="span">
/{draft.collection}/{draft.slug}
</Text>
<EntryPath siteId={siteId} entry={draft} />
</span>
}
actions={
Expand Down Expand Up @@ -405,7 +405,9 @@ export function ArticleEditorPage() {
}
>
<InputGroup className="w-full">
<InputGroup.Addon>/{draft.collection}/</InputGroup.Addon>
<InputGroup.Addon>
{permalinkPrefix(collectionPermalink(site.data, draft.collection))}/
</InputGroup.Addon>
<InputGroup.Input
aria-label={t("editor.slug")}
value={draft.slug}
Expand Down
5 changes: 2 additions & 3 deletions apps/studio/src/routes/ArticlesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
import { useState } from "react";
import { SiteCrumbs } from "../components/Crumbs.tsx";
import { deleteResourceProps } from "../components/deleteLabels.ts";
import { EntryPath } from "../components/EntryPath.tsx";
import { ErrorBanner } from "../components/ErrorBanner.tsx";
import { ListToolbar } from "../components/ListToolbar.tsx";
import {
Expand Down Expand Up @@ -352,9 +353,7 @@ function ArticleRow({
{e.title}
</Text>
</Link>
<Text variant="mono-secondary" as="span" truncate>
/{e.collection}/{e.slug}
</Text>
<EntryPath siteId={siteId} entry={e} truncate />
</div>
</div>
</Table.Cell>
Expand Down
Loading
Loading