diff --git a/.changeset/locale-paths.md b/.changeset/locale-paths.md new file mode 100644 index 00000000..76085eac --- /dev/null +++ b/.changeset/locale-paths.md @@ -0,0 +1,6 @@ +--- +"@cloudhal/core-ts": patch +"@cloudhal/astro-renderer": patch +--- + +`@cloudhal/core-ts/locale-paths`: the locale URL helpers (`localeRouting`, `localizedPath`, `localeUrl`) move from the renderer into core-ts, browser-safe, with `itemUrl(site, origin, item)` for a page's or entry's public URL. `@cloudhal/astro-renderer/locales` re-exports them unchanged. diff --git a/apps/studio/bundle-budget.ts b/apps/studio/bundle-budget.ts index 6cf61e45..318644d6 100644 --- a/apps/studio/bundle-budget.ts +++ b/apps/studio/bundle-budget.ts @@ -63,6 +63,9 @@ export const LAZY_ONLY: readonly RegExp[] = [ // CodeMirror (Markdown mode) only with the first switch to it. /[\\/]node_modules[\\/].*[\\/](@tiptap|prosemirror-[a-z-]+|linkifyjs|orderedmap|rope-sequence|w3c-keyname|@codemirror|@lezer|@marijn|crelt|style-mod)[\\/]/, /[\\/]packages[\\/]ui[\\/]src[\\/](editor\.ts|blocks[\\/]MarkdownEditor\.tsx|blocks[\\/]editor[\\/])/, + // Kumo's highlighted code blocks (and Shiki behind them): each screen that shows code brings + // its own provider (`components/CodeHighlighted.tsx`); at the app root they cost 2.9 kB gzip. + /[\\/]@cloudflare[\\/]kumo[\\/]dist[\\/]code\.js$/, // Color tokens (RFC-0023): the expansion and the swatches load with the lazy site settings route. /[\\/]packages[\\/]core-ts[\\/]src[\\/]tokens\.ts$/, /[\\/]packages[\\/]ui[\\/]src[\\/]blocks[\\/]ColorRolePreview\.tsx$/, diff --git a/apps/studio/src/components/ActivityDetailDialog.tsx b/apps/studio/src/components/ActivityDetailDialog.tsx index d0f24c24..d1dc0413 100644 --- a/apps/studio/src/components/ActivityDetailDialog.tsx +++ b/apps/studio/src/components/ActivityDetailDialog.tsx @@ -1,5 +1,4 @@ import { Banner, InlineCopyText, LayerDialog, LinkButton, Text } from "@cloudhal/ui"; -import { CodeHighlighted } from "@cloudhal/ui/code"; import { GitDiff, HandPalm, Lightning, Prohibit } from "@phosphor-icons/react"; import { type ReactNode, useRef } from "react"; import { useI18n } from "../i18n/index.tsx"; @@ -8,6 +7,7 @@ import { autoApprovalCount, isAutoApproved } from "../lib/agents.ts"; import type { AuditRow } from "../lib/api.ts"; import { ModeBadge } from "./AgentModes.tsx"; import { ActorCell, DecisionCell, ElevationGrant, Outcome, useToolLabel } from "./Audit.tsx"; +import { CodeHighlighted } from "./CodeHighlighted.tsx"; function Row({ label, children }: { label: string; children: ReactNode }) { return ( diff --git a/apps/studio/src/components/CodeHighlighted.tsx b/apps/studio/src/components/CodeHighlighted.tsx new file mode 100644 index 00000000..5e54b25d --- /dev/null +++ b/apps/studio/src/components/CodeHighlighted.tsx @@ -0,0 +1,22 @@ +import { CodeHighlighted as KumoCodeHighlighted, ShikiProvider } from "@cloudhal/ui/code"; +import type { ComponentProps } from "react"; +import { useT } from "../i18n/index.tsx"; + +/** + * Kumo's `CodeHighlighted` (config snippets, tool input) with its own `ShikiProvider`, so Kumo's + * code module, Shiki and the grammars load with the screens that show code, never with the shell + * (bundle-budget.ts). Shiki starts when a block mounts; blocks in collapsed panels and closed + * dialogs cost nothing. The copy button speaks the active locale. + */ +export function CodeHighlighted(props: ComponentProps) { + const t = useT(); + return ( + + + + ); +} diff --git a/apps/studio/src/components/CodeProvider.tsx b/apps/studio/src/components/CodeProvider.tsx deleted file mode 100644 index b220c171..00000000 --- a/apps/studio/src/components/CodeProvider.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { ShikiProvider } from "@cloudhal/ui/code"; -import type { ReactNode } from "react"; -import { useT } from "../i18n/index.tsx"; - -/** - * Syntax highlighting for `CodeHighlighted` (config snippets, tool input). Shiki and the grammars - * load lazily on first use; the copy button speaks the active locale. - */ -export function CodeProvider({ children }: { children: ReactNode }) { - const t = useT(); - return ( - - {children} - - ); -} diff --git a/apps/studio/src/components/LiveAddress.tsx b/apps/studio/src/components/LiveAddress.tsx index 167313b4..16da2270 100644 --- a/apps/studio/src/components/LiveAddress.tsx +++ b/apps/studio/src/components/LiveAddress.tsx @@ -1,30 +1,56 @@ /** - * An editor's full address with copy: "Live at" / "Will be at" (`LiveLinks.tsx` has the shell's - * and lists' links). Without a domain only the path shows. + * An editor's full address with copy ("Live at" / "Will be at") and its header's "View live" + * (`LiveLinks.tsx` has the shell's and lists' links). Addresses carry the item's locale: the + * `/` prefix or the locale's own domain (`lib/item-url.ts`). Without a domain only the + * path shows. */ import type { ContentEntry, Page } from "@cloudhal/core-contracts"; +import { itemUrl } from "@cloudhal/core-ts/locale-paths"; import { entryPath } from "@cloudhal/core-ts/permalinks"; -import { ClipboardText, Text } from "@cloudhal/ui"; +import { ClipboardText, directional, LinkButton, Text, Tooltip } from "@cloudhal/ui"; +import { ArrowSquareOut } from "@phosphor-icons/react"; import { useT } from "../i18n/index.tsx"; -import { liveUrl, servesAt } from "../lib/live-site.ts"; +import { absoluteItemUrl, type LiveItem } from "../lib/item-url.ts"; +import { servesAt } from "../lib/live-site.ts"; import { useLiveNode, useLiveSite } from "./useLiveSite.ts"; +const OpenOutside = directional(ArrowSquareOut); + /** - * An editor's address: "Live at" when the live snapshot serves this item, published, at this very - * address; "Will be at" otherwise. The full URL is copyable; without a domain (or for an address - * Studio cannot build yet) only the path shows. + * An item's address: `url` the full URL (undefined while no domain serves its locale), `path` + * what to show without one, `live` whether the live snapshot serves this item, published, at + * this very address. The live node's query is shared by every caller (one request per item). + */ +export function useItemAddress( + siteId: string, + item: LiveItem, +): { url: string | undefined; path: string; live: boolean } { + const { origin, site } = useLiveSite(siteId); + const liveNode = useLiveNode(siteId, item.id); + const graphPath = "path" in item ? item.path : entryPath(site, item); + if (!site) return { url: undefined, path: graphPath, live: false }; + const address = itemUrl(site, origin, item); + return { + url: absoluteItemUrl(site, origin, item), + path: address, + live: servesAt(liveNode, (n) => itemUrl(site, origin, n), address), + }; +} + +/** + * "Live at" when the live snapshot serves this item, published, at this very address; "Will be + * at" otherwise. The full URL is copyable; without one only the path shows. */ export function LiveAddress({ path, - origin, + url, live, }: { path: string; - origin: string | undefined; + url: string | undefined; live: boolean; }) { const t = useT(); - const url = liveUrl(origin, path); return (
@@ -45,21 +71,37 @@ export function LiveAddress({ ); } -/** The article editor's address, from the draft's slug and the collection's permalink. */ +/** The article editor's address, from the draft's slug, collection permalink and locale. */ export function EntryAddress({ siteId, entry }: { siteId: string; entry: ContentEntry }) { - const { origin, site } = useLiveSite(siteId); - const liveNode = useLiveNode(siteId, entry.id); - const path = entryPath(site, entry); - const live = servesAt(liveNode, (n) => entryPath(site, n), path); - // Locale prefixes for articles come later: a non-default language shows only its path. - const known = site !== undefined && entry.locale === site.defaultLocale; - return ; + return ; } -/** The page editor's address (the page's path is its full site path). */ +/** The page editor's address, from the draft's path and locale. */ export function PageAddress({ siteId, page }: { siteId: string; page: Page }) { - const { origin } = useLiveSite(siteId); - const liveNode = useLiveNode(siteId, page.id); - const live = servesAt(liveNode, (n) => n.path, page.path); - return ; + return ; +} + +/** + * An editor header's "View live": opens the item on the live site in a new tab, the full URL in + * the tooltip. Only while the live snapshot serves this item at its address. Icon-only on narrow + * screens, where the header is short of room. + */ +export function ViewLiveButton({ siteId, item }: { siteId: string; item: LiveItem }) { + const t = useT(); + const { url, live } = useItemAddress(siteId, item); + if (!live || !url) return null; + return ( + + + {t("liveSite.viewLive")} + + + ); } diff --git a/apps/studio/src/components/LiveRowItems.tsx b/apps/studio/src/components/LiveRowItems.tsx index 0a690319..b305d4b3 100644 --- a/apps/studio/src/components/LiveRowItems.tsx +++ b/apps/studio/src/components/LiveRowItems.tsx @@ -1,32 +1,24 @@ /** The list rows' links to the live site: "View live" and "Copy link" (Pages, Articles). */ import type { ContentEntry, Page } from "@cloudhal/core-contracts"; -import { entryPath } from "@cloudhal/core-ts/permalinks"; import { DropdownMenu, directional } from "@cloudhal/ui"; import { ArrowSquareOut, Copy } from "@phosphor-icons/react"; import { useT } from "../i18n/index.tsx"; -import { liveUrl } from "../lib/live-site.ts"; +import { absoluteItemUrl, type LiveItem } from "../lib/item-url.ts"; import { openLive, useCopyLink, useLiveSite } from "./useLiveSite.ts"; const OpenOutside = directional(ArrowSquareOut); /** - * A list row's "View live" and "Copy link": for a published item on a site that has a domain and - * has been published. `path` undefined: the address is not known (see `EntryLiveItems`). + * A list row's "View live" and "Copy link": for a published item on a site that has been + * published, when a domain serves its locale. The URL carries the item's locale (its `/` + * prefix or the locale's own domain). */ -export function LiveLinkItems({ - siteId, - path, - published, -}: { - siteId: string; - path: string | undefined; - published: boolean; -}) { +export function LiveLinkItems({ siteId, item }: { siteId: string; item: LiveItem }) { const t = useT(); const copy = useCopyLink(); - const { origin, liveSnapshotId } = useLiveSite(siteId); - const url = path === undefined ? undefined : liveUrl(origin, path); - if (!published || !liveSnapshotId || !url) return null; + const { origin, liveSnapshotId, site } = useLiveSite(siteId); + const url = absoluteItemUrl(site, origin, item); + if (item.status !== "published" || !liveSnapshotId || !url) return null; return ( <> openLive(url)}> @@ -39,21 +31,12 @@ export function LiveLinkItems({ ); } -/** A page row's live links (a page's path is already its full site path). */ +/** A page row's live links. */ export function PageLiveItems({ siteId, page }: { siteId: string; page: Page }) { - return ; + return ; } /** An article row's live links. */ export function EntryLiveItems({ siteId, entry }: { siteId: string; entry: ContentEntry }) { - const { site } = useLiveSite(siteId); - // Locale prefixes for articles come later: a non-default language's address is not known yet. - const known = site !== undefined && entry.locale === site.defaultLocale; - return ( - - ); + return ; } diff --git a/apps/studio/src/components/McpClientSetup.tsx b/apps/studio/src/components/McpClientSetup.tsx index 57a6a91f..6873e75b 100644 --- a/apps/studio/src/components/McpClientSetup.tsx +++ b/apps/studio/src/components/McpClientSetup.tsx @@ -1,8 +1,8 @@ import { Select, Text } from "@cloudhal/ui"; -import { CodeHighlighted } from "@cloudhal/ui/code"; import { useState } from "react"; import { useT } from "../i18n/index.tsx"; import { type McpClient, mcpSnippet, REMOTE_CLIENTS, snippetLang } from "../lib/agents.ts"; +import { CodeHighlighted } from "./CodeHighlighted.tsx"; /** * Ready-to-copy setup for one MCP client at a time (docs/agents/clients.md): a client picker, the diff --git a/apps/studio/src/components/SkillDetailDialog.tsx b/apps/studio/src/components/SkillDetailDialog.tsx index f2c1be3b..13b9bfb7 100644 --- a/apps/studio/src/components/SkillDetailDialog.tsx +++ b/apps/studio/src/components/SkillDetailDialog.tsx @@ -1,11 +1,11 @@ import { Badge, ClipboardText, LayerDialog, Text } from "@cloudhal/ui"; -import { CodeHighlighted } from "@cloudhal/ui/code"; import { FileText, HandPalm } from "@phosphor-icons/react"; import { type ReactNode, useRef } from "react"; import { useT } from "../i18n/index.tsx"; import type { SkillSummary } from "../lib/api.ts"; import { isAgentScope, orderSkillScopes, skillCallSnippet } from "../lib/skills.ts"; import { useToolLabel } from "./Audit.tsx"; +import { CodeHighlighted } from "./CodeHighlighted.tsx"; import { ScopeBadge } from "./ScopeBadge.tsx"; function Row({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) { diff --git a/apps/studio/src/components/page-editor/PageEditorActions.tsx b/apps/studio/src/components/page-editor/PageEditorActions.tsx index d257b8d1..a2e1d40c 100644 --- a/apps/studio/src/components/page-editor/PageEditorActions.tsx +++ b/apps/studio/src/components/page-editor/PageEditorActions.tsx @@ -1,10 +1,11 @@ -/** The page editor's header actions: preview, Build live, status, schedule and Save. */ +/** The page editor's header actions: preview, View live, Build live, status, schedule and Save. */ import type { Page, PublishStatus } from "@cloudhal/core-contracts"; import { Button, DropdownMenu, LinkButton, Tooltip } from "@cloudhal/ui"; import { Copy, Lightning, Trash } from "@phosphor-icons/react"; import { useT } from "../../i18n/index.tsx"; import { PreviewToggle } from "../EditorLayout.tsx"; import { ScheduleAction } from "../EditorSchedule.tsx"; +import { ViewLiveButton } from "../LiveAddress.tsx"; import { StatusActions } from "../StatusActions.tsx"; import type { PageEditorState } from "./usePageEditor.ts"; @@ -27,6 +28,7 @@ export function PageEditorActions({ return ( <> + ` prefix or the locale's own domain (`localization.domains`). + * Only the editors and the list rows import this, so it loads with their lazy routes. + */ +import type { ContentEntry, Page, Site } from "@cloudhal/core-contracts"; +import { itemUrl } from "@cloudhal/core-ts/locale-paths"; + +/** A page or an article. */ +export type LiveItem = Page | ContentEntry; + +/** The full URL of the item, or undefined while no domain serves its locale. */ +export function absoluteItemUrl( + site: Site | undefined, + origin: string | undefined, + item: LiveItem, +): string | undefined { + if (!site) return undefined; + const address = itemUrl(site, origin, item); + return /^https?:\/\//.test(address) ? address : undefined; +} diff --git a/apps/studio/src/lib/live-site.ts b/apps/studio/src/lib/live-site.ts index 94c8849d..6f1366f4 100644 --- a/apps/studio/src/lib/live-site.ts +++ b/apps/studio/src/lib/live-site.ts @@ -25,7 +25,9 @@ export function originHost(origin: string): string { /** * Whether an address is live: the live snapshot holds the item as published, at the same address. - * `liveNode` is the item as the live snapshot holds it (undefined when it is not in there). + * `liveNode` is the item as the live snapshot holds it (undefined when it is not in there); + * `addressOf` gives the full localized address (core-ts `itemUrl`), so a changed path, slug, + * permalink or locale all count as a different address. */ export function servesAt( liveNode: T | undefined, diff --git a/apps/studio/src/main.tsx b/apps/studio/src/main.tsx index c5e7f39a..9b02463e 100644 --- a/apps/studio/src/main.tsx +++ b/apps/studio/src/main.tsx @@ -4,7 +4,6 @@ import { RouterProvider } from "@tanstack/react-router"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { AppLink } from "./components/AppLink.tsx"; -import { CodeProvider } from "./components/CodeProvider.tsx"; import { KumoLocale } from "./components/KumoLocale.tsx"; import { reloadForNewDeploy } from "./components/RouteStates.tsx"; import { currentLocale, I18nProvider, type Locale, loadLocale } from "./i18n/index.tsx"; @@ -31,11 +30,9 @@ const start = (initialLocale?: Locale) => { - - - - - + + + diff --git a/apps/studio/src/routes/ApprovalsPage.tsx b/apps/studio/src/routes/ApprovalsPage.tsx index 9e4c9ee4..85e4af40 100644 --- a/apps/studio/src/routes/ApprovalsPage.tsx +++ b/apps/studio/src/routes/ApprovalsPage.tsx @@ -13,7 +13,6 @@ import { Text, Tooltip, } from "@cloudhal/ui"; -import { CodeHighlighted } from "@cloudhal/ui/code"; import { CheckCircle, ClockCountdown, @@ -32,6 +31,7 @@ import { ApproveElevationDialog } from "../components/ApproveElevationDialog.tsx import { ApproveRequestDialog } from "../components/ApproveRequestDialog.tsx"; import { ActorCell, ToolCell, useToolLabel } from "../components/Audit.tsx"; import { useBodyDiffLabels } from "../components/BodyChanges.tsx"; +import { CodeHighlighted } from "../components/CodeHighlighted.tsx"; import { ApproveCommentsDialog, CommentRequestDetails } from "../components/CommentApprovals.tsx"; import { SiteCrumbs } from "../components/Crumbs.tsx"; import { diff --git a/apps/studio/src/routes/ArticleEditorPage.tsx b/apps/studio/src/routes/ArticleEditorPage.tsx index f826e57e..055e6df9 100644 --- a/apps/studio/src/routes/ArticleEditorPage.tsx +++ b/apps/studio/src/routes/ArticleEditorPage.tsx @@ -47,7 +47,7 @@ import { import { EntryPath } from "../components/EntryPath.tsx"; import { ErrorBanner } from "../components/ErrorBanner.tsx"; import { ViewOnlyBanner } from "../components/Gating.tsx"; -import { EntryAddress } from "../components/LiveAddress.tsx"; +import { EntryAddress, ViewLiveButton } from "../components/LiveAddress.tsx"; import { MenuDeleteNotice, useMenuReferences } from "../components/Menus.tsx"; import { RelativeTime } from "../components/RelativeTime.tsx"; import { ArticleSeoPreviewCard, CountedHint, CoverImageCard } from "../components/Seo.tsx"; @@ -99,6 +99,9 @@ const READ_ONLY_REASON = { published: "gating.entryPublished", } as const; +/** The Articles list, the editor's first crumb. */ +const articlesHref = (siteId: string) => `/sites/${siteId}/articles`; + export function ArticleEditorPage() { const t = useT(); const { siteId, entryId } = useParams({ from: "/sites/$siteId/articles/$entryId" }); @@ -169,10 +172,7 @@ export function ArticleEditorPage() { await navigate({ to: "/sites/$siteId/articles", params: { siteId } }); }, }); - const articlesCrumb = { - label: t("pageHeaders.articles.title"), - href: `/sites/${siteId}/articles`, - }; + const articlesCrumb = { label: t("pageHeaders.articles.title"), href: articlesHref(siteId) }; if (!draft) { return ( @@ -290,6 +290,7 @@ export function ArticleEditorPage() { actions={ <> + - - {children} - + {children} ); diff --git a/apps/studio/test/agents.test.tsx b/apps/studio/test/agents.test.tsx index 3fdeb38a..48fdbca7 100644 --- a/apps/studio/test/agents.test.tsx +++ b/apps/studio/test/agents.test.tsx @@ -4,7 +4,6 @@ import type { ReactNode } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ActivityDetailDialog } from "../src/components/ActivityDetailDialog.tsx"; import { Decision, ToolCell } from "../src/components/Audit.tsx"; -import { CodeProvider } from "../src/components/CodeProvider.tsx"; import { CreateAgentKeyDialog } from "../src/components/CreateAgentKeyDialog.tsx"; import { I18nProvider, type Locale } from "../src/i18n/index.tsx"; @@ -12,9 +11,7 @@ function wrap(children: ReactNode, locale: Locale = "en") { const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return ( - - {children} - + {children} ); } diff --git a/apps/studio/test/approvals.test.tsx b/apps/studio/test/approvals.test.tsx index 4c3f7784..ebc17927 100644 --- a/apps/studio/test/approvals.test.tsx +++ b/apps/studio/test/approvals.test.tsx @@ -4,7 +4,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import type { ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { CodeProvider } from "../src/components/CodeProvider.tsx"; import { PendingApprovalsBanner } from "../src/components/PendingApprovalsBanner.tsx"; import { I18nProvider } from "../src/i18n/index.tsx"; import type { Approval } from "../src/lib/api.ts"; @@ -22,9 +21,7 @@ function wrap(children: ReactNode) { return ( - - {children} - + {children} ); diff --git a/apps/studio/test/elevation-requests.test.tsx b/apps/studio/test/elevation-requests.test.tsx index 8f3166c7..c423dc7d 100644 --- a/apps/studio/test/elevation-requests.test.tsx +++ b/apps/studio/test/elevation-requests.test.tsx @@ -8,7 +8,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import type { ReactNode } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { CodeProvider } from "../src/components/CodeProvider.tsx"; import { I18nProvider, type Locale } from "../src/i18n/index.tsx"; import type { AgentKeySummary, Approval, AuditRow } from "../src/lib/api.ts"; import { ActivityPage } from "../src/routes/ActivityPage.tsx"; @@ -26,9 +25,7 @@ function wrap(children: ReactNode, locale: Locale = "en") { return ( - - {children} - + {children} ); diff --git a/apps/studio/test/elevation.test.tsx b/apps/studio/test/elevation.test.tsx index a79b4722..9a89658e 100644 --- a/apps/studio/test/elevation.test.tsx +++ b/apps/studio/test/elevation.test.tsx @@ -8,7 +8,6 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testi import type { ReactNode } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ChangeAgentModeDialog } from "../src/components/ChangeAgentModeDialog.tsx"; -import { CodeProvider } from "../src/components/CodeProvider.tsx"; import { ElevateAgentKeyDialog } from "../src/components/ElevateAgentKeyDialog.tsx"; import { I18nProvider, type Locale } from "../src/i18n/index.tsx"; import type { AgentKeySummary, AuditRow } from "../src/lib/api.ts"; @@ -26,9 +25,7 @@ function wrap(children: ReactNode, locale: Locale = "en") { return ( - - {children} - + {children} ); diff --git a/apps/studio/test/live-locales.test.tsx b/apps/studio/test/live-locales.test.tsx new file mode 100644 index 00000000..af67ede9 --- /dev/null +++ b/apps/studio/test/live-locales.test.tsx @@ -0,0 +1,157 @@ +/** + * Live addresses carry the item's locale (RFC-0033 §4, core-ts `itemUrl`): a `tr` page or article + * lives under `/tr` (graph paths have no prefix), a locale with its own domain at that domain's + * root. Covers the editors' "Live at" address, the rows' "View live" / "Copy link" and the + * editor header's "View live", which shows only while the live snapshot serves the item there. + */ +import type { ContentEntry, Page } from "@cloudhal/core-contracts"; +import { DropdownMenu } from "@cloudhal/ui"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EntryAddress, PageAddress, ViewLiveButton } from "../src/components/LiveAddress.tsx"; +import { LiveLinkItems } from "../src/components/LiveRowItems.tsx"; +import { on, serve, wrap } from "./roles-fixtures.tsx"; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +const siteRoute = (patch: Record = {}) => + on("GET", "/api/sites/site_1", { + status: 200, + body: { + id: "site_1", + name: "Demo", + defaultLocale: "en", + locales: ["en", "tr", "ar"], + collections: { posts: { permalink: "/blog/{slug}" } }, + domains: [{ hostname: "blog.example.com", primary: true }], + publishedSnapshotId: "snap_live", + ...patch, + }, + }); + +const page = (patch: Partial = {}): Page => ({ + schemaVersion: 1, + id: "page_tr", + siteId: "site_1", + path: "/hakkinda", + title: "Hakkında", + locale: "tr", + status: "published", + sections: [], + rev: 2, + createdAt: "2026-10-08T08:00:00Z", + updatedAt: "2026-10-08T08:00:00Z", + ...patch, +}); + +const entry = (patch: Partial = {}): ContentEntry => + ({ + schemaVersion: 1, + id: "entry_tr", + siteId: "site_1", + collection: "posts", + slug: "merhaba", + title: "Merhaba", + locale: "tr", + status: "published", + rev: 2, + createdAt: "2026-10-08T08:00:00Z", + updatedAt: "2026-10-08T08:00:00Z", + ...patch, + }) as ContentEntry; + +const liveNode = (id: string, node: object | undefined) => + on( + "GET", + `/api/sites/site_1/snapshots/snap_live/nodes/${id}`, + node ? { status: 200, body: node } : { status: 404, body: { error: "not_found" } }, + ); + +describe("a localized editor address", () => { + it("puts a tr page under /tr", async () => { + serve(siteRoute(), liveNode("page_tr", page())); + render(wrap()); + await waitFor(() => + expect(screen.getByTestId("live-address")).toHaveAttribute("data-live", "true"), + ); + expect(screen.getByText("https://blog.example.com/tr/hakkinda")).toBeInTheDocument(); + }); + + it("puts a tr article under /tr with its collection's permalink", async () => { + serve(siteRoute(), liveNode("entry_tr", entry())); + render(wrap()); + await waitFor(() => + expect(screen.getByTestId("live-address")).toHaveAttribute("data-live", "true"), + ); + expect(screen.getByText("https://blog.example.com/tr/blog/merhaba")).toBeInTheDocument(); + }); + + it("prefixes the default locale under prefix-all and uses a locale's own domain", async () => { + serve( + siteRoute({ localization: { routing: "prefix-all", domains: { ar: "ar.example.com" } } }), + liveNode("page_tr", undefined), + ); + render(wrap()); + expect(await screen.findByText("https://blog.example.com/en/about")).toBeInTheDocument(); + cleanup(); + render(wrap()); + expect(await screen.findByText("https://ar.example.com/blog/merhaba")).toBeInTheDocument(); + }); + + it("shows the localized path without a domain", async () => { + serve(siteRoute({ domains: [] }), liveNode("page_tr", undefined)); + render(wrap()); + expect(await screen.findByText("/tr/hakkinda")).toBeInTheDocument(); + }); +}); + +describe("a localized row's live links", () => { + const menu = (item: Page | ContentEntry) => + wrap( + + menu} /> + + + + , + ); + + it.each([ + ["page", page(), "https://blog.example.com/tr/hakkinda"], + ["article", entry(), "https://blog.example.com/tr/blog/merhaba"], + ])("copies a tr %s's prefixed URL", async (_kind, item, url) => { + const writeText = vi.fn(async () => undefined); + vi.stubGlobal("navigator", { ...navigator, clipboard: { writeText } }); + serve(siteRoute()); + render(menu(item)); + expect(await screen.findByRole("menuitem", { name: /View live/ })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("menuitem", { name: /Copy link/ })); + await waitFor(() => expect(writeText).toHaveBeenCalledWith(url)); + }); +}); + +describe("the editor header's View live", () => { + it("opens the live URL when the live snapshot serves the item there", async () => { + serve(siteRoute(), liveNode("entry_tr", entry())); + render(wrap()); + const link = await screen.findByTestId("view-live"); + expect(link).toHaveAttribute("href", "https://blog.example.com/tr/blog/merhaba"); + expect(link).toHaveAttribute("aria-label", "View live"); + }); + + it.each([ + ["not in the live snapshot", undefined], + ["live at another address", entry({ slug: "eski" })], + ["a draft in the live snapshot", entry({ status: "draft" })], + ])("hides when the item is %s", async (_why, live) => { + const fn = serve(siteRoute(), liveNode("entry_tr", live)); + render(wrap()); + const asked = () => fn.mock.calls.some(([u]) => String(u).includes("nodes/entry_tr")); + await waitFor(() => expect(asked()).toBe(true)); + await new Promise((r) => setTimeout(r, 50)); + expect(screen.queryByTestId("view-live")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/studio/test/notifications.test.tsx b/apps/studio/test/notifications.test.tsx index be741018..5b8d26b7 100644 --- a/apps/studio/test/notifications.test.tsx +++ b/apps/studio/test/notifications.test.tsx @@ -16,7 +16,6 @@ vi.mock("@tanstack/react-router", async (importOriginal) => ({ useNavigate: () => navigate, })); -import { CodeProvider } from "../src/components/CodeProvider.tsx"; import { NotificationSettings } from "../src/components/NotificationSettings.tsx"; import { NotificationsBell } from "../src/components/NotificationsBell.tsx"; import { I18nProvider, type Locale } from "../src/i18n/index.tsx"; @@ -38,9 +37,7 @@ function wrap(children: ReactNode, options: { locale?: Locale; qc?: QueryClient return ( - - {children} - + {children} ); diff --git a/apps/studio/test/roles-fixtures.tsx b/apps/studio/test/roles-fixtures.tsx index 4ad63e38..2339adf1 100644 --- a/apps/studio/test/roles-fixtures.tsx +++ b/apps/studio/test/roles-fixtures.tsx @@ -6,7 +6,6 @@ import { Toasty } from "@cloudhal/ui"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; import { vi } from "vitest"; -import { CodeProvider } from "../src/components/CodeProvider.tsx"; import { I18nProvider, type Locale } from "../src/i18n/index.tsx"; import type { Ability, InstanceInfo, Me, Role } from "../src/lib/api.ts"; import { FALLBACK_ABILITIES, FALLBACK_ROLE_SCOPES, holds, roleRules } from "../src/lib/roles.ts"; @@ -16,9 +15,7 @@ export function wrap(children: ReactNode, locale: Locale = "en") { return ( - - {children} - + {children} ); diff --git a/apps/studio/test/settings-screens.test.tsx b/apps/studio/test/settings-screens.test.tsx index 1060eccb..3c41d461 100644 --- a/apps/studio/test/settings-screens.test.tsx +++ b/apps/studio/test/settings-screens.test.tsx @@ -3,7 +3,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import type { ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { CodeProvider } from "../src/components/CodeProvider.tsx"; import { I18nProvider } from "../src/i18n/index.tsx"; import { AuthRequestError } from "../src/lib/auth.ts"; import { AccountPage } from "../src/routes/AccountPage.tsx"; @@ -33,9 +32,7 @@ function wrap(children: ReactNode) { return ( - - {children} - + {children} ); diff --git a/docs/design/DESIGN.md b/docs/design/DESIGN.md index e10a006b..0231cbc6 100644 --- a/docs/design/DESIGN.md +++ b/docs/design/DESIGN.md @@ -29,7 +29,7 @@ Studio mirrors this one-to-one: | Feedback | `Banner` (page/section), `Field`/`Input error` (inline), `Toasty` + `useKumoToastManager` (transient). | | Confirmation | `ApprovalDialog` (ours, on `Dialog`) for publish/rollback; `DeleteResource` (block) for typed-name deletes; `LayerDialog.Alert` for other destructive alerts. Always rendered, controlled by `open`. | | Forms & detail sheets | `LayerDialog.Root open onOpenChange` → `LayerDialog.Content` whose **direct** children are `Title`, optional `Description`, `Body`, optional `Actions` (Kumo throws otherwise; branch *inside* each part). Desktop dialog, bottom sheet on phones. | -| Code & config | `CodeHighlighted` from `@cloudhal/ui/code` (Shiki, lazy, `showCopyButton`) for snippets and JSON payloads; `ShikiProvider` lives at the app root (`CodeProvider`, localised copy labels). `Code` stays for one-line CLI hints in usage panels. | +| Code & config | `CodeHighlighted` from `@cloudhal/ui/code` (Shiki, lazy, `showCopyButton`) for snippets and JSON payloads; screens import it through `components/CodeHighlighted.tsx`, which brings its own `ShikiProvider` (localised copy labels) so Kumo's code module and Shiki stay out of the shell's initial load. `Code` stays for one-line CLI hints in usage panels. | Keyboard parity with the dashboard: `⌘K` / `/` open the palette; `t s` toggles the sidebar; `?` lists shortcuts (palette entry). diff --git a/packages/astro-renderer/src/locales.ts b/packages/astro-renderer/src/locales.ts index 1e5b54b0..a7923c10 100644 --- a/packages/astro-renderer/src/locales.ts +++ b/packages/astro-renderer/src/locales.ts @@ -8,6 +8,7 @@ */ import type { ContentEntry, Page, Site, Taxonomy, Term } from "@cloudhal/core-contracts"; import { entryPath, type PermalinkSite, termSlug } from "@cloudhal/core-ts"; +import { localeRouting, localeUrl, localizedPath } from "@cloudhal/core-ts/locale-paths"; import type { HeadMeta } from "./seo.ts"; import type { GraphSource } from "./source.ts"; @@ -36,11 +37,8 @@ export function textDirection(locale: string): "ltr" | "rtl" { return RTL.has(language) ? "rtl" : "ltr"; } -type Routing = "prefix-except-default" | "prefix-all"; - -export function localeRouting(site: Site): Routing { - return site.localization?.routing ?? "prefix-except-default"; -} +// The URL helpers live in core-ts (Studio builds the same addresses); re-exported unchanged. +export { localeRouting, localeUrl, localizedPath } from "@cloudhal/core-ts/locale-paths"; /** The locale a host serves on its own (`localization.domains`), if any. */ export function hostLocale(site: Site, host: string): string | undefined { @@ -89,19 +87,6 @@ export function localeRequest(site: Site, host: string, path: string): LocaleReq return { locale: site.defaultLocale, path }; } -/** A graph path as a URL path of `locale` on the site's own host (no domain mapping). */ -export function localizedPath(site: Site, locale: string, path: string): string { - const prefixed = localeRouting(site) === "prefix-all" || locale !== site.defaultLocale; - if (!prefixed) return path; - return path === "/" ? `/${locale}` : `/${locale}${path}`; -} - -/** The absolute URL of a graph path in `locale`: its own domain when it has one. */ -export function localeUrl(site: Site, locale: string, path: string, origin: string): string { - const domain = site.localization?.domains?.[locale]; - return domain ? `https://${domain}${path}` : `${origin}${localizedPath(site, locale, path)}`; -} - /** One language version of a page or entry for `hreflang` and the language switcher. */ export interface Alternate { /** BCP 47 locale, or `x-default`. */ diff --git a/packages/core-ts/package.json b/packages/core-ts/package.json index 4d5993c1..cb7448e6 100644 --- a/packages/core-ts/package.json +++ b/packages/core-ts/package.json @@ -19,6 +19,7 @@ "./filter": "./src/filter.ts", "./names": "./src/names.ts", "./permalinks": "./src/permalinks.ts", + "./locale-paths": "./src/locale-paths.ts", "./taxonomy-report": "./src/taxonomy-report.ts", "./menu": "./src/menu.ts", "./cache-tags": "./src/cache-tags.ts", @@ -99,6 +100,10 @@ "types": "./dist/src/permalinks.d.ts", "default": "./dist/src/permalinks.js" }, + "./locale-paths": { + "types": "./dist/src/locale-paths.d.ts", + "default": "./dist/src/locale-paths.js" + }, "./taxonomy-report": { "types": "./dist/src/taxonomy-report.d.ts", "default": "./dist/src/taxonomy-report.js" diff --git a/packages/core-ts/src/index.ts b/packages/core-ts/src/index.ts index e31a1ca4..f9b48013 100644 --- a/packages/core-ts/src/index.ts +++ b/packages/core-ts/src/index.ts @@ -37,6 +37,7 @@ export * from "./hash.ts"; export type { NodeHold } from "./holds.ts"; export * from "./hosts.ts"; export * from "./ids.ts"; +export * from "./locale-paths.ts"; export { importMediaUrls, type LoadedSource, diff --git a/packages/core-ts/src/locale-paths.ts b/packages/core-ts/src/locale-paths.ts new file mode 100644 index 00000000..a3fe527b --- /dev/null +++ b/packages/core-ts/src/locale-paths.ts @@ -0,0 +1,43 @@ +/** + * RFC-0033 §4 / ADR-0035: where a page or entry of a locale lives on the web. Graph paths carry no + * locale (`(site, locale, path)` is unique); the URL adds `/` (`prefix-except-default`: + * every locale but the default; `prefix-all`: every locale) unless the locale has its own domain + * (`localization.domains`), which serves it at the root. The renderer and Studio share these. + * + * Browser- and Worker-safe: no imports beyond types and `permalinks.ts`. + */ +import type { ContentEntry, Page, Site } from "@cloudhal/core-contracts"; +import { entryPath, type PermalinkSite } from "./permalinks.ts"; + +/** The part of the site these functions read. */ +export type LocaleSite = Pick; + +export function localeRouting(site: LocaleSite): "prefix-except-default" | "prefix-all" { + return site.localization?.routing ?? "prefix-except-default"; +} + +/** A graph path as a URL path of `locale` on the site's own host (no domain mapping). */ +export function localizedPath(site: LocaleSite, locale: string, path: string): string { + const prefixed = localeRouting(site) === "prefix-all" || locale !== site.defaultLocale; + if (!prefixed) return path; + return path === "/" ? `/${locale}` : `/${locale}${path}`; +} + +/** The absolute URL of a graph path in `locale`: its own domain when it has one. */ +export function localeUrl(site: LocaleSite, locale: string, path: string, origin: string): string { + const domain = site.localization?.domains?.[locale]; + return domain ? `https://${domain}${path}` : `${origin}${localizedPath(site, locale, path)}`; +} + +/** + * The public URL of a page or entry: its graph path (a page's `path`, an entry's permalink) in its + * locale. Absolute on `origin` or the locale's own domain; the localized path when neither exists. + */ +export function itemUrl( + site: LocaleSite & PermalinkSite, + origin: string | undefined, + item: Pick | Pick, +): string { + const path = "path" in item ? item.path : entryPath(site, item); + return localeUrl(site, item.locale, path, origin ?? ""); +} diff --git a/packages/core-ts/test/locale-paths.test.ts b/packages/core-ts/test/locale-paths.test.ts new file mode 100644 index 00000000..5b86fbc9 --- /dev/null +++ b/packages/core-ts/test/locale-paths.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { + itemUrl, + type LocaleSite, + localeRouting, + localeUrl, + localizedPath, +} from "../src/locale-paths.ts"; + +const ORIGIN = "https://example.com"; +const site = (over: Partial = {}) => ({ + defaultLocale: "en", + collections: { posts: { permalink: "/blog/{slug}" } }, + ...over, +}); +const all = site({ localization: { routing: "prefix-all" } }); +const domains = site({ localization: { domains: { ar: "ar.example.com" } } }); + +describe("locale paths (RFC-0033 §4)", () => { + it("prefixes every locale but the default, unless the routing is prefix-all", () => { + expect(localeRouting(site())).toBe("prefix-except-default"); + expect(localeRouting(all)).toBe("prefix-all"); + expect(localizedPath(site(), "en", "/about")).toBe("/about"); + expect(localizedPath(site(), "en", "/")).toBe("/"); + expect(localizedPath(site(), "tr", "/hakkinda")).toBe("/tr/hakkinda"); + expect(localizedPath(site(), "tr", "/")).toBe("/tr"); + expect(localizedPath(all, "en", "/")).toBe("/en"); + expect(localizedPath(all, "en", "/about")).toBe("/en/about"); + }); + + it("serves a locale with its own domain at that domain's root", () => { + expect(localeUrl(domains, "ar", "/about", ORIGIN)).toBe("https://ar.example.com/about"); + expect(localeUrl(domains, "ar", "/", ORIGIN)).toBe("https://ar.example.com/"); + expect(localeUrl(domains, "es", "/", ORIGIN)).toBe("https://example.com/es"); + expect(localeUrl(domains, "en", "/", ORIGIN)).toBe("https://example.com/"); + }); + + it("builds a page's and an entry's public URL in their locale", () => { + const trPage = { path: "/hakkinda", locale: "tr" }; + const trPost = { collection: "posts", slug: "merhaba", locale: "tr" }; + expect(itemUrl(site(), ORIGIN, trPage)).toBe("https://example.com/tr/hakkinda"); + expect(itemUrl(site(), ORIGIN, trPost)).toBe("https://example.com/tr/blog/merhaba"); + expect(itemUrl(site(), ORIGIN, { path: "/", locale: "en" })).toBe("https://example.com/"); + expect(itemUrl(site(), ORIGIN, { path: "/", locale: "tr" })).toBe("https://example.com/tr"); + expect(itemUrl(all, ORIGIN, { path: "/about", locale: "en" })).toBe( + "https://example.com/en/about", + ); + expect(itemUrl(domains, ORIGIN, { ...trPost, locale: "ar" })).toBe( + "https://ar.example.com/blog/merhaba", + ); + // Without an origin: the localized path, or the locale's own domain. + expect(itemUrl(site(), undefined, trPage)).toBe("/tr/hakkinda"); + expect(itemUrl(domains, undefined, { path: "/", locale: "ar" })).toBe( + "https://ar.example.com/", + ); + }); +}); diff --git a/scripts/size-baseline.json b/scripts/size-baseline.json index 157c47e3..a798f99e 100644 --- a/scripts/size-baseline.json +++ b/scripts/size-baseline.json @@ -181,7 +181,7 @@ "lines": 191 }, "apps/studio/src/routes/ArticleEditorPage.tsx::ArticleEditorPage": { - "lines": 472 + "lines": 470 }, "apps/studio/src/routes/ArticlesPage.tsx::ArticlesPage": { "lines": 214