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
6 changes: 6 additions & 0 deletions .changeset/locale-paths.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions apps/studio/bundle-budget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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$/,
Expand Down
2 changes: 1 addition & 1 deletion apps/studio/src/components/ActivityDetailDialog.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 (
Expand Down
22 changes: 22 additions & 0 deletions apps/studio/src/components/CodeHighlighted.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof KumoCodeHighlighted>) {
const t = useT();
return (
<ShikiProvider
engine="javascript"
languages={["json", "bash"]}
labels={{ copy: t("common.copy"), copied: t("common.copied") }}
>
<KumoCodeHighlighted {...props} />
</ShikiProvider>
);
}
20 changes: 0 additions & 20 deletions apps/studio/src/components/CodeProvider.tsx

This file was deleted.

88 changes: 65 additions & 23 deletions apps/studio/src/components/LiveAddress.tsx
Original file line number Diff line number Diff line change
@@ -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
* `/<locale>` 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<LiveItem>(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 (
<div className="grid min-w-0 gap-1.5" data-testid="live-address" data-live={live}>
<Text size="sm" bold as="span">
Expand All @@ -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<ContentEntry>(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 <LiveAddress path={path} origin={known ? origin : undefined} live={live} />;
return <LiveAddress {...useItemAddress(siteId, entry)} />;
}

/** 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<Page>(siteId, page.id);
const live = servesAt(liveNode, (n) => n.path, page.path);
return <LiveAddress path={page.path} origin={origin} live={live} />;
return <LiveAddress {...useItemAddress(siteId, page)} />;
}

/**
* 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 (
<Tooltip content={url}>
<LinkButton
href={url}
external
variant="ghost"
icon={OpenOutside}
aria-label={t("liveSite.viewLive")}
data-testid="view-live"
>
<span className="hidden lg:inline">{t("liveSite.viewLive")}</span>
</LinkButton>
</Tooltip>
);
}
39 changes: 11 additions & 28 deletions apps/studio/src/components/LiveRowItems.tsx
Original file line number Diff line number Diff line change
@@ -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 `/<locale>`
* 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 (
<>
<DropdownMenu.Item icon={OpenOutside} onClick={() => openLive(url)}>
Expand All @@ -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 <LiveLinkItems siteId={siteId} path={page.path} published={page.status === "published"} />;
return <LiveLinkItems siteId={siteId} item={page} />;
}

/** 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 (
<LiveLinkItems
siteId={siteId}
path={known ? entryPath(site, entry) : undefined}
published={entry.status === "published"}
/>
);
return <LiveLinkItems siteId={siteId} item={entry} />;
}
2 changes: 1 addition & 1 deletion apps/studio/src/components/McpClientSetup.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/studio/src/components/SkillDetailDialog.tsx
Original file line number Diff line number Diff line change
@@ -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 }) {
Expand Down
4 changes: 3 additions & 1 deletion apps/studio/src/components/page-editor/PageEditorActions.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -27,6 +28,7 @@ export function PageEditorActions({
return (
<>
<PreviewToggle visible={ed.previewVisible} onChange={ed.setPreviewVisible} />
<ViewLiveButton siteId={siteId} item={draft} />
<Tooltip content={t("editor.buildLiveHint")}>
<LinkButton
href={`/sites/${siteId}/pages/${pageId}/build`}
Expand Down
21 changes: 21 additions & 0 deletions apps/studio/src/lib/item-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* An item's public address (RFC-0033 §4): its graph path in its locale, through core-ts
* `itemUrl` — the `/<locale>` 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;
}
4 changes: 3 additions & 1 deletion apps/studio/src/lib/live-site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends { status: string }>(
liveNode: T | undefined,
Expand Down
9 changes: 3 additions & 6 deletions apps/studio/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -31,11 +30,9 @@ const start = (initialLocale?: Locale) => {
<QueryClientProvider client={queryClient}>
<LinkProvider component={AppLink}>
<TooltipProvider>
<CodeProvider>
<Toasty>
<RouterProvider router={router} />
</Toasty>
</CodeProvider>
<Toasty>
<RouterProvider router={router} />
</Toasty>
</TooltipProvider>
</LinkProvider>
</QueryClientProvider>
Expand Down
2 changes: 1 addition & 1 deletion apps/studio/src/routes/ApprovalsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
Text,
Tooltip,
} from "@cloudhal/ui";
import { CodeHighlighted } from "@cloudhal/ui/code";
import {
CheckCircle,
ClockCountdown,
Expand All @@ -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 {
Expand Down
Loading
Loading