From 516564f4a8b74062fe4fa59b84b4a7ccb7b2b5cb Mon Sep 17 00:00:00 2001 From: Universe Date: Sun, 8 Feb 2026 14:39:18 +0900 Subject: [PATCH 1/6] Implement universal docs routing feature - Introduced a new routing system that resolves `/_/` shorthand URLs to tenant-specific canonical paths, enhancing documentation navigation. - Added a new `UniversalRoutePicker` component to handle user context selection for projects and documents. - Updated the routing configuration in `next.config.ts` to support universal routing. - Created tests for the routing functionality to ensure unique matches and consistent path normalization. - Documented the universal routing model and its resolution algorithm in `universal-docs-routing.md` for clarity and future reference. --- docs/forms/respondent-email-notifications.md | 6 +- docs/wg/platform/index.md | 1 + docs/wg/platform/universal-docs-routing.md | 139 ++++++ .../universal/[[...path]]/page.tsx | 212 +++++++++ editor/host/url.test.ts | 32 ++ editor/host/url.ts | 444 +++++++++++------- editor/next.config.ts | 6 + 7 files changed, 675 insertions(+), 165 deletions(-) create mode 100644 docs/wg/platform/universal-docs-routing.md create mode 100644 editor/app/(workspace)/universal/[[...path]]/page.tsx create mode 100644 editor/host/url.test.ts diff --git a/docs/forms/respondent-email-notifications.md b/docs/forms/respondent-email-notifications.md index d5402858b..dc0b4999d 100644 --- a/docs/forms/respondent-email-notifications.md +++ b/docs/forms/respondent-email-notifications.md @@ -25,15 +25,15 @@ Practically, this means: ### How to enable respondent email notifications 1. Open your **Form** in the Grida editor. -2. In the left sidebar, click **Connect**. -3. Click **Channels**. +2. In the left sidebar, click [**Connect**](https://grida.co/_/connect). +3. Click [**Channels**](https://grida.co/_/connect/channels). 4. Under **Email Notifications**, find **Respondent email notifications**. 5. Toggle **Enable** on. 6. Click **Save**. ### How to customize the email -1. Open **Connect → Channels → Email Notifications** (same as above). +1. Open [**Connect → Channels**](https://grida.co/_/connect/channels) → **Email Notifications** (same as above). 2. Configure the email fields: - **Reply-To** (optional): where replies should go (e.g. `support@yourdomain.com`) - **Subject**: the email subject template diff --git a/docs/wg/platform/index.md b/docs/wg/platform/index.md index c197bb158..db7d9b8cb 100644 --- a/docs/wg/platform/index.md +++ b/docs/wg/platform/index.md @@ -9,3 +9,4 @@ Working group documents for Grida platform and infrastructure topics. ## Documents - [Multi-tenant Custom Domains on Vercel](./multi-tenant-custom-domain-vercel) +- [Universal Docs Routing](./universal-docs-routing) diff --git a/docs/wg/platform/universal-docs-routing.md b/docs/wg/platform/universal-docs-routing.md new file mode 100644 index 000000000..830c1d4a1 --- /dev/null +++ b/docs/wg/platform/universal-docs-routing.md @@ -0,0 +1,139 @@ +--- +title: Universal Docs Routing (WG) +--- + +# Universal Docs Routing + +## Summary + +We need a **universal routing system** for user-facing docs and links where the +actual path depends on the user’s **org**, **project**, and sometimes the +**document context** (id + type). Docs should be written with a stable, concise +path like: + +``` +https://grida.co/_/connect/share +``` + +and resolved at runtime to the user-specific canonical path, for example: + +``` +https://grida.co/acme/project/00000000-0000-0000-0000-000000001234/connect/share +``` + +This keeps documentation readable while preserving correct routing for any +tenant and document. + +--- + +## Terminology + +- **Context path**: the org/project/doc portion of the URL. +- **Canonical path**: the fully expanded, tenant-specific path. +- **Universal route**: a shorthand path that starts with `/_/`. +- **Route registry**: the explicit list of shorthand routes and how they expand. + +--- + +## Canonical path model + +Canonical paths include the full tenant and document context: + +- Project-level pages: + - `/:org/:project/dash` + - `/:org/:project/ciam` +- Document-level pages (example: forms): + - `/:org/:project/:docId/connect/share` + +The doc type is **not** encoded in the universal path; it is resolved from +document context (id + type) at runtime. + +--- + +## Universal path model + +Universal routes replace the context path with a single reserved segment: + +``` +/_/ +``` + +Examples: + +- `/_/dash` → `/:org/:project/dash` +- `/_/ciam` → `/:org/:project/ciam` +- `/_/connect/share` → `/:org/:project/:docId/connect/share` + +`/_/` is **never canonical**. It is only an alias for documentation and +context-aware navigation. + +--- + +## Resolution algorithm (runtime) + +1. Detect the universal prefix (`/_/`). +2. Resolve **current context**: + - `org`, `project` from the active workspace/session. + - `docId` + `docType` from the active document (if required). +3. Match the remainder against the **route registry**. +4. Expand to the canonical path using the resolved context. +5. Route/redirect to the canonical path. + +If a required context is missing, the router must halt with a context selection +flow (or a clear error) instead of guessing. + +--- + +## Invariants + +- `_` is a **reserved segment** and must never be used as an org or project id. +- Universal routes are **explicitly registered**, not inferred. +- Doc type must **not** be encoded into `/_/` paths (no `/forms/`, no query + params like `?doctype=forms`). +- Any universal path must resolve to **exactly one** canonical route. + +--- + +## Uniqueness test (collision prevention) + +Because routing is **not strictly rule-based**, shorthand names can collide. +We must enforce uniqueness with a looped test over the route registry. + +**Requirement** + +For every defined shorthand route, the matcher must return **exactly one** +result (itself). + +**Sketch** + +``` +for (const route of universalRoutes) { + const matches = matchUniversalRoute(route.samplePath) + assert(matches.length === 1) + assert(matches[0].id === route.id) +} +``` + +Notes: + +- Each route definition must include a `samplePath` that exercises its matcher. +- The test must run in CI to catch collisions early. + +--- + +## Examples + +| universal path | canonical path (example) | +| ----------------------- | ----------------------------------------------------------------------- | +| `/_/dash` | `/acme/project/dash` | +| `/_/ciam` | `/acme/project/ciam` | +| `/_/connect/share` | `/acme/project/00000000-0000-0000-0000-000000001234/connect/share` | + +--- + +## Non-goals + +- No implicit guessing or inference beyond the route registry. +- No alternate universal prefixes (`/_/_/`, `/__/`). +- No doc type leakage in the universal path. + diff --git a/editor/app/(workspace)/universal/[[...path]]/page.tsx b/editor/app/(workspace)/universal/[[...path]]/page.tsx new file mode 100644 index 000000000..d3ba3931b --- /dev/null +++ b/editor/app/(workspace)/universal/[[...path]]/page.tsx @@ -0,0 +1,212 @@ +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import { createClient } from "@/lib/supabase/server"; +import { + buildUniversalDestination, + matchUniversalRoute, + normalizeUniversalPath, +} from "@/host/url"; + +type Params = { + path?: string[]; +}; + +type ProjectInfo = { + organizationName: string; + projectName: string; +}; + +type DocumentInfo = ProjectInfo & { + id: string; + title: string; + doctype: string; +}; + +export default async function UniversalRoutePicker({ + params, +}: { + params: Promise; +}) { + const { path } = await params; + const pathString = Array.isArray(path) ? path.join("/") : ""; + const universalPath = normalizeUniversalPath(pathString); + + const matches = matchUniversalRoute(universalPath); + if (matches.length !== 1) { + return notFound(); + } + + const route = matches[0]!; + const client = await createClient(); + const { data: auth } = await client.auth.getUser(); + + if (!auth.user) { + const nextPath = universalPath ? `/_/${universalPath}` : "/_/"; + return redirect(`/sign-in?next=${encodeURIComponent(nextPath)}`); + } + + if (route.scope === "project") { + const { data: memberships } = await client + .from("organization_member") + .select( + ` + organization:organization( + name, + projects:project(name) + ) + ` + ) + .eq("user_id", auth.user.id); + + const projects: ProjectInfo[] = []; + for (const membership of memberships ?? []) { + const organization = membership.organization as + | { name: string; projects: { name: string }[] } + | null; + if (!organization?.name) continue; + for (const project of organization.projects ?? []) { + if (!project?.name) continue; + projects.push({ + organizationName: organization.name, + projectName: project.name, + }); + } + } + + return ( +
+
+

Select a project

+

+ Choose the org/project context for{" "} + + {formatUniversalPath(route.path)} + + . +

+
+ {projects.length === 0 ? ( +

+ No projects available. +

+ ) : ( +
    + {projects.map((project) => { + const destination = buildUniversalDestination(route.id, { + org: project.organizationName, + proj: project.projectName, + }); + return ( +
  • + +
    +
    + {project.organizationName} +
    +
    {project.projectName}
    +
    + Open → + +
  • + ); + })} +
+ )} +
+ ); + } + + const docQuery = client + .from("document") + .select( + ` + id, + title, + doctype, + updated_at, + project:project( + name, + organization:organization(name) + ) + ` + ) + .order("updated_at", { ascending: false }); + + if (route.requiredDoctypes?.length) { + docQuery.in("doctype", route.requiredDoctypes); + } + + const { data: documents } = await docQuery; + + const docs: DocumentInfo[] = []; + for (const doc of documents ?? []) { + const project = doc.project as + | { name: string; organization: { name: string } } + | null; + if (!project?.name || !project.organization?.name) continue; + docs.push({ + id: doc.id as string, + title: (doc.title as string) || "Untitled", + doctype: doc.doctype as string, + organizationName: project.organization.name, + projectName: project.name, + }); + } + + return ( +
+
+

Select a document

+

+ Choose the org/project/doc context for{" "} + + {formatUniversalPath(route.path)} + + . +

+
+ {docs.length === 0 ? ( +

+ No matching documents available. +

+ ) : ( +
    + {docs.map((doc) => { + const destination = buildUniversalDestination(route.id, { + org: doc.organizationName, + proj: doc.projectName, + docId: doc.id, + }); + return ( +
  • + +
    +
    + {doc.organizationName} / {doc.projectName} +
    +
    {doc.title}
    +
    + {doc.doctype} +
    +
    + Open → + +
  • + ); + })} +
+ )} +
+ ); +} + +function formatUniversalPath(path: string) { + const normalized = normalizeUniversalPath(path); + return normalized ? `/_/${normalized}` : "/_/"; +} diff --git a/editor/host/url.test.ts b/editor/host/url.test.ts new file mode 100644 index 000000000..c7b15aae1 --- /dev/null +++ b/editor/host/url.test.ts @@ -0,0 +1,32 @@ +import { + matchUniversalRoute, + normalizeUniversalPath, + universalRoutes, +} from "@/host/url"; + +describe("universal docs routing", () => { + it("matches each sample path uniquely", () => { + for (const route of universalRoutes) { + const matches = matchUniversalRoute(route.samplePath); + expect(matches).toHaveLength(1); + expect(matches[0]?.id).toBe(route.id); + } + }); + + it("normalizes paths consistently", () => { + expect(normalizeUniversalPath("/connect/share/")).toBe("connect/share"); + expect(normalizeUniversalPath("///dash")).toBe("dash"); + expect(normalizeUniversalPath("ciam")).toBe("ciam"); + expect(normalizeUniversalPath("/")).toBe(""); + }); + + it("returns no matches for unknown paths", () => { + expect(matchUniversalRoute("unknown")).toHaveLength(0); + }); + + it("matches project root for empty path", () => { + const matches = matchUniversalRoute(""); + expect(matches).toHaveLength(1); + expect(matches[0]?.id).toBe("project"); + }); +}); diff --git a/editor/host/url.ts b/editor/host/url.ts index eedad6340..8d10b4c17 100644 --- a/editor/host/url.ts +++ b/editor/host/url.ts @@ -1,53 +1,165 @@ +/** + * @fileoverview Route registry and URL builders for the Grida editor. + * + * This file is the **single source of truth** for all known editor routes. + * It serves two audiences: + * + * 1. **Internal navigation** — {@link editorlink} builds canonical URLs for + * in-app links (e.g. sidebar, buttons, redirects). + * + * 2. **Universal docs routing** — {@link matchUniversalRoute} and + * {@link buildUniversalDestination} resolve `/_/` shorthand URLs + * used in documentation to tenant-specific canonical paths at runtime. + * See `docs/wg/platform/universal-docs-routing.md` for the full spec. + * + * ## Adding a new route + * + * **Stable document route** (most common — no dynamic URL params): + * Add a single entry to {@link DOCUMENT_ROUTE_CONFIGS}. Done. + * It automatically appears in `EditorPageType`, `editorlink`, and the + * universal route registry. + * + * **Project-scoped route** (console/workspace pages, not tied to a document): + * Add an entry to {@link PROJECT_ROUTE_CONFIGS}. + * + * **Dynamic-segment route** (rare — needs extra URL params like `[tablename]`): + * 1. Add to {@link DOCUMENT_ROUTE_CONFIGS} as usual. + * 2. Add its param type to {@link DynamicEditorPageParams}. + * 3. Add a case to the `editorlink` switch. + */ + +import type { GDocumentType } from "@/types"; import type { FormSubmitErrorCode } from "@/types/private/api"; import * as ERR from "@/k/error"; +// #region ─── Route registry ────────────────────────────────────────────────── + +export type UniversalRouteScope = "project" | "document"; + +type UniversalRouteConfig = { + scope: UniversalRouteScope; + /** Override the URL segment (defaults to the config key). */ + path?: string; + /** When set, the universal resolver only shows documents of these types. */ + requiredDoctypes?: ReadonlyArray; +}; + +/** + * Project-scoped routes — console / workspace pages that live under + * `/:org/:project/` and do **not** require a document id. + */ +const PROJECT_ROUTE_CONFIGS = { + project: { scope: "project", path: "" }, + dash: { scope: "project" }, + ciam: { scope: "project" }, + customers: { scope: "project" }, + "customers/policies": { scope: "project" }, + "customers/policies/new": { scope: "project" }, + tags: { scope: "project" }, + domains: { scope: "project" }, + integrations: { scope: "project" }, + analytics: { scope: "project" }, + campaigns: { scope: "project" }, + www: { scope: "project" }, +} satisfies Record; + +/** + * Document-scoped routes — editor pages that live under + * `/:org/:project/:docId/`. + * + * **This is the single source of truth for stable editor pages.** + * Keys added here automatically propagate to: + * - `EditorPageType` (via the `StableDocumentRouteType` mapped type) + * - `editorlink` (via its default case) + * - The universal route registry + */ +const DOCUMENT_ROUTE_CONFIGS = { + // ── form ────────────────────────────────────────────────── + form: { scope: "document", requiredDoctypes: ["v0_form"] }, + "form/edit": { scope: "document", requiredDoctypes: ["v0_form"] }, + + // ── general ─────────────────────────────────────────────── + settings: { scope: "document" }, + design: { scope: "document" }, + canvas: { scope: "document" }, + + // ── data ────────────────────────────────────────────────── + data: { scope: "document" }, + objects: { scope: "document" }, + "data/responses": { scope: "document", requiredDoctypes: ["v0_form"] }, + "data/responses/sessions": { scope: "document", requiredDoctypes: ["v0_form"] }, + "data/analytics": { scope: "document" }, + "data/simulator": { scope: "document" }, + "data/table/~new": { scope: "document" }, + + // ── connect ─────────────────────────────────────────────── + connect: { scope: "document", requiredDoctypes: ["v0_form"] }, + "connect/share": { scope: "document", requiredDoctypes: ["v0_form"] }, + "connect/parameters": { scope: "document", requiredDoctypes: ["v0_form"] }, + "connect/customer": { scope: "document", requiredDoctypes: ["v0_form"] }, + "connect/channels": { scope: "document", requiredDoctypes: ["v0_form"] }, + "connect/store": { scope: "document", requiredDoctypes: ["v0_form"] }, + "connect/store/get-started": { scope: "document", requiredDoctypes: ["v0_form"] }, + "connect/store/products": { scope: "document", requiredDoctypes: ["v0_form"] }, + "connect/database/supabase": { scope: "document", requiredDoctypes: ["v0_form", "v0_schema"] }, +} satisfies Record; + +/** Stable document route keys — derived directly from the config above. */ +type StableDocumentRouteType = keyof typeof DOCUMENT_ROUTE_CONFIGS; + +/** Merged registry of all project + document routes. */ +const UNIVERSAL_ROUTE_CONFIGS = { + ...PROJECT_ROUTE_CONFIGS, + ...DOCUMENT_ROUTE_CONFIGS, +} satisfies Record; + +/** All registered universal route keys. */ +export type UniversalRouteType = keyof typeof UNIVERSAL_ROUTE_CONFIGS; + +// #endregion + +// #region ─── Editor page types ─────────────────────────────────────────────── + +/** + * Routes with dynamic segments that require extra URL parameters. + * These are the only routes that need manual handling in `editorlink`. + */ +type DynamicEditorPageParams = { + ".": {}; + "objects/[[...path]]": { path?: string[] }; + "data/table/[tablename]": { tablename: string }; + "data/table/[tablename]/definition": { tablename: string }; +}; + +/** Stable routes all take empty params — derived from the route registry. */ +type StableEditorPageParams = { [K in StableDocumentRouteType]: {} }; + +type EditorPageParamsMap = StableEditorPageParams & DynamicEditorPageParams; + +/** Union of every known editor page key (stable + dynamic). */ +export type EditorPageType = keyof EditorPageParamsMap; + +// #endregion + +// #region ─── editorlink ────────────────────────────────────────────────────── + type BaseEditorLinkParamsOptionalSeed = - | { - org: string; - proj: string; - } - | { - basepath: string; - }; + | { org: string; proj: string } + | { basepath: string }; type BaseEditorLinkParams = { origin?: string; document_id: string; } & BaseEditorLinkParamsOptionalSeed; -// Define a type map for each route, associating it with its required parameters -type EditorPageParamsMap = { - ".": {}; - form: {}; - "form/edit": {}; - settings: {}; - design: {}; - canvas: {}; - data: {}; - objects: {}; - "objects/[[...path]]": { - path?: string[]; - }; - "data/responses": {}; - "data/responses/sessions": {}; - "data/analytics": {}; - "data/simulator": {}; - "data/table/[tablename]": { tablename: string }; // Requires tablename in addition to shared params - "data/table/[tablename]/definition": { tablename: string }; // Requires tablename in addition to shared params - "data/table/~new": {}; - connect: {}; - "connect/share": {}; - "connect/parameters": {}; - "connect/customer": {}; - "connect/channels": {}; - "connect/store": {}; - "connect/store/get-started": {}; - "connect/store/products": {}; - "connect/database/supabase": {}; -}; - -type EditorPageType = keyof EditorPageParamsMap; - +/** + * Build a canonical editor URL for the given page. + * + * Dynamic-segment routes (`.`, `objects/[[...path]]`, `data/table/[tablename]`, + * etc.) are handled by explicit switch cases. Every other stable route is + * handled by the default `/${basepath}/${id}/${page}` pattern, so new routes + * registered in {@link DOCUMENT_ROUTE_CONFIGS} work automatically. + */ export function editorlink

( page: P, { @@ -57,104 +169,142 @@ export function editorlink

( }: BaseEditorLinkParams & EditorPageParamsMap[P] ) { const basepath = editorbasepath(params); + switch (page) { case ".": return `${origin}/${basepath}/${id}`; - case "form": - return `${origin}/${basepath}/${id}/form`; - case "form/edit": - return `${origin}/${basepath}/${id}/form/edit`; - case "settings": - return `${origin}/${basepath}/${id}/settings`; - // case "settings/customize": - // return `${origin}/${basepath}/${form_id}/settings/customize`; - // case "settings/general": - // return `${origin}/${basepath}/${form_id}/settings/general`; - case "objects": - return `${origin}/${basepath}/${id}/objects`; - case "objects/[[...path]]": + case "objects/[[...path]]": { const { path } = params as unknown as { path?: string[] }; if (path) return `${origin}/${basepath}/${id}/objects/${path.join("/")}`; - else return `${origin}/${basepath}/${id}/objects`; - case "design": - return `${origin}/${basepath}/${id}/design`; - case "canvas": - return `${origin}/${basepath}/${id}/canvas`; - case "data": - return `${origin}/${basepath}/${id}/data`; - case "data/responses": - return `${origin}/${basepath}/${id}/data/responses`; - case "data/responses/sessions": - return `${origin}/${basepath}/${id}/data/responses/sessions`; - case "data/analytics": - return `${origin}/${basepath}/${id}/data/analytics`; - case "data/simulator": - return `${origin}/${basepath}/${id}/data/simulator`; - case "connect": - return `${origin}/${basepath}/${id}/connect`; - case "connect/share": - return `${origin}/${basepath}/${id}/connect/share`; - case "connect/parameters": - return `${origin}/${basepath}/${id}/connect/parameters`; - case "connect/customer": - return `${origin}/${basepath}/${id}/connect/customer`; - case "connect/channels": - return `${origin}/${basepath}/${id}/connect/channels`; - case "connect/store": - return `${origin}/${basepath}/${id}/connect/store`; - case "connect/store/get-started": - return `${origin}/${basepath}/${id}/connect/store/get-started`; - case "connect/store/products": - return `${origin}/${basepath}/${id}/connect/store/products`; - case "connect/database/supabase": - return `${origin}/${basepath}/${id}/connect/database/supabase`; - case "data/table/~new": - return `${origin}/${basepath}/${id}/data/table/~new`; - case "data/table/[tablename]": + return `${origin}/${basepath}/${id}/objects`; + } + case "data/table/[tablename]": { const { tablename } = params as unknown as { tablename: string }; return `${origin}/${basepath}/${id}/data/table/${tablename}`; - case "data/table/[tablename]/definition": - const { tablename: tablename1 } = params as unknown as { - tablename: string; - }; - return `${origin}/${basepath}/${id}/data/table/${tablename1}/definition`; + } + case "data/table/[tablename]/definition": { + const { tablename } = params as unknown as { tablename: string }; + return `${origin}/${basepath}/${id}/data/table/${tablename}/definition`; + } } - return ""; + // All stable document routes follow /:org/:proj/:docId/. + return `${origin}/${basepath}/${id}/${page}`; } export function editorbasepath( - params: - | { - org: string; - proj: string; - } - | { - basepath: string; - } + params: { org: string; proj: string } | { basepath: string } ) { if ("basepath" in params) return params.basepath; return `${params.org}/${params.proj}`; } +// #endregion + +// #region ─── Universal docs routing ────────────────────────────────────────── +// +// Resolves /_/ shorthand to a canonical tenant-specific URL. +// See docs/wg/platform/universal-docs-routing.md for the full spec. +// ───────────────────────────────────────────────────────────────────────────── + +export type UniversalRouteDefinition = { + id: UniversalRouteType; + /** Route path without the `/_/` prefix. Example: `"connect/share"` */ + path: string; + scope: UniversalRouteScope; + requiredDoctypes?: ReadonlyArray; + /** Sample path used by the collision-prevention uniqueness test. */ + samplePath: string; +}; + +/** Strip leading/trailing slashes and collapse repeated slashes. */ +export function normalizeUniversalPath(path: string) { + return path + .trim() + .replace(/^\/+/, "") + .replace(/\/+$/, "") + .replace(/\/{2,}/g, "/"); +} + +export function getUniversalRouteDefinition( + id: UniversalRouteType +): UniversalRouteDefinition { + const config: UniversalRouteConfig = UNIVERSAL_ROUTE_CONFIGS[id]; + const path = config.path ?? id; + return { + id, + path, + scope: config.scope, + requiredDoctypes: config.requiredDoctypes, + samplePath: path, + }; +} + +/** Pre-built list of all universal route definitions. */ +export const universalRoutes: UniversalRouteDefinition[] = ( + Object.keys(UNIVERSAL_ROUTE_CONFIGS) as UniversalRouteType[] +).map(getUniversalRouteDefinition); + +/** Find all routes whose path matches the given universal path. */ +export function matchUniversalRoute(path: string) { + const normalized = normalizeUniversalPath(path); + return universalRoutes.filter( + (route) => normalizeUniversalPath(route.path) === normalized + ); +} + +// ── Context types ─────────────────────────────────────────── + +export type UniversalRouteContext = { + org: string; + proj: string; + docId?: string | null; +}; + +type UniversalRouteContextFor

= + (typeof UNIVERSAL_ROUTE_CONFIGS)[P]["scope"] extends "document" + ? { org: string; proj: string; docId: string } + : { org: string; proj: string }; + +/** Strict overload: when the exact route literal is known at compile time. */ +export function buildUniversalDestination

( + page: P, + context: UniversalRouteContextFor

+): string; +/** Loose overload: when the route is a dynamic `UniversalRouteType` union. */ +export function buildUniversalDestination( + page: UniversalRouteType, + context: UniversalRouteContext +): string; +export function buildUniversalDestination( + page: UniversalRouteType, + context: UniversalRouteContext +) { + const route = getUniversalRouteDefinition(page); + const base = `/${context.org}/${context.proj}`; + const suffix = normalizeUniversalPath(route.path); + + if (route.scope === "project") { + return suffix ? `${base}/${suffix}` : base; + } + + const docId = "docId" in context ? context.docId : ""; + return suffix ? `${base}/${docId}/${suffix}` : `${base}/${docId}`; +} + +// #endregion + +// #region ─── Form & error link utilities ───────────────────────────────────── + export function resolve_next( origin: string, uri?: string | null, fallback = "/" ) { if (!uri) return resolve_next(origin, fallback); - // Check if the URL is absolute const isAbsolute = /^https?:\/\//i.test(uri); - - // If the URL is absolute, return it as is - if (isAbsolute) { - return uri; - } - - // If the URL is relative, combine it with the origin - const combinedUri = new URL(uri, origin).toString(); - - return combinedUri; + if (isAbsolute) return uri; + return new URL(uri, origin).toString(); } export interface FormLinkURLParams { @@ -163,10 +313,7 @@ export interface FormLinkURLParams { customer_id?: string; session_id?: string; }; - complete: { - // response id - rid: string; - }; + complete: { rid: string }; developererror?: {}; badrequest?: {}; formclosed: { @@ -189,89 +336,62 @@ export function formlink( ...[host, form_id, state, params]: FormLinkParams ) { const q = params ? new URLSearchParams(params as any).toString() : null; - let url = _form_state_link(host, form_id, state); + let url = state + ? `${host}/d/e/${form_id}/${state}` + : `${host}/d/e/${form_id}`; if (q) url += `?${q}`; return url; } -function _form_state_link( - host: string, - form_id: string, - state?: - | "complete" - | "alreadyresponded" - | "developererror" - | "badrequest" - | "formclosed" - | "formsoldout" - | "formoptionsoldout" -) { - if (state) return `${host}/d/e/${form_id}/${state}`; - return `${host}/d/e/${form_id}`; -} - export function formerrorlink( host: string, code: FormSubmitErrorCode, - data: { - form_id: string; - [key: string]: any; - } + data: { form_id: string; [key: string]: any } ) { const { form_id } = data; switch (code) { - case "INTERNAL_SERVER_ERROR": { + case "INTERNAL_SERVER_ERROR": return formlink(host, form_id, "developererror"); - } - case "MISSING_REQUIRED_HIDDEN_FIELDS": { + case "MISSING_REQUIRED_HIDDEN_FIELDS": return formlink(host, form_id, "badrequest", { error: ERR.MISSING_REQUIRED_HIDDEN_FIELDS.code, }); - } - case "UNKNOWN_FIELDS_NOT_ALLOWED": { + case "UNKNOWN_FIELDS_NOT_ALLOWED": return formlink(host, form_id, "badrequest", { error: ERR.UNKNOWN_FIELDS_NOT_ALLOWED.code, }); - } - case "FORM_FORCE_CLOSED": { + case "FORM_FORCE_CLOSED": return formlink(host, form_id, "formclosed", { oops: ERR.FORM_CLOSED_WHILE_RESPONDING.code, }); - } - case "FORM_CLOSED_WHILE_RESPONDING": { + case "FORM_CLOSED_WHILE_RESPONDING": return formlink(host, form_id, "formclosed", { oops: ERR.FORM_CLOSED_WHILE_RESPONDING.code, }); - } - case "FORM_RESPONSE_LIMIT_REACHED": { + case "FORM_RESPONSE_LIMIT_REACHED": return formlink(host, form_id, "formclosed", { oops: ERR.FORM_CLOSED_WHILE_RESPONDING.code, }); - } - case "FORM_RESPONSE_LIMIT_BY_CUSTOMER_REACHED": { + case "FORM_RESPONSE_LIMIT_BY_CUSTOMER_REACHED": return formlink(host, form_id, "alreadyresponded", { fingerprint: data.fingerprint, customer_id: data.customer_id, session_id: data.session_id, }); - } - case "FORM_SCHEDULE_NOT_IN_RANGE": { + case "FORM_SCHEDULE_NOT_IN_RANGE": return formlink(host, form_id, "formclosed", { oops: ERR.FORM_SCHEDULE_NOT_IN_RANGE.code, }); - } - case "FORM_SOLD_OUT": { + case "FORM_SOLD_OUT": return formlink(host, form_id, "formsoldout"); - } - case "FORM_OPTION_UNAVAILABLE": { + case "FORM_OPTION_UNAVAILABLE": return formlink(host, form_id, "formoptionsoldout"); - } - case "CHALLENGE_EMAIL_NOT_VERIFIED": { - // Keep the user in the form flow; treat as a bad request with a specific error. + case "CHALLENGE_EMAIL_NOT_VERIFIED": return formlink(host, form_id, "badrequest", { error: "CHALLENGE_EMAIL_NOT_VERIFIED", } as any); - } } } + +// #endregion diff --git a/editor/next.config.ts b/editor/next.config.ts index bda90f6cf..25cb1be4b 100644 --- a/editor/next.config.ts +++ b/editor/next.config.ts @@ -136,6 +136,12 @@ const nextConfig: NextConfig = { }, rewrites: async () => { return [ + // Universal docs routing — resolves /_/ to the context-aware + // canonical route. See docs/wg/platform/universal-docs-routing.md + { + source: "/_/:path*", + destination: "/universal/:path*", + }, // docs { source: "/docs/:path*", From 87cad5750add872628708c75bfdcbb84515b9862 Mon Sep 17 00:00:00 2001 From: Universe Date: Sun, 8 Feb 2026 14:41:47 +0900 Subject: [PATCH 2/6] mv --- editor/app/(api)/(public)/v1/west/t/invite/route.ts | 2 +- editor/app/(api)/(public)/v1/west/t/refresh/route.ts | 2 +- editor/{lib => host}/tenant-url.test.ts | 5 ++--- editor/{lib => host}/tenant-url.ts | 0 4 files changed, 4 insertions(+), 5 deletions(-) rename editor/{lib => host}/tenant-url.test.ts (96%) rename editor/{lib => host}/tenant-url.ts (100%) diff --git a/editor/app/(api)/(public)/v1/west/t/invite/route.ts b/editor/app/(api)/(public)/v1/west/t/invite/route.ts index 146819d69..d2ff2d140 100644 --- a/editor/app/(api)/(public)/v1/west/t/invite/route.ts +++ b/editor/app/(api)/(public)/v1/west/t/invite/route.ts @@ -1,5 +1,5 @@ import { service_role } from "@/lib/supabase/server"; -import { buildTenantSiteBaseUrl } from "@/lib/tenant-url"; +import { buildTenantSiteBaseUrl } from "@/host/tenant-url"; import { headers } from "next/headers"; import { NextResponse, type NextRequest } from "next/server"; import { Platform } from "@/lib/platform"; diff --git a/editor/app/(api)/(public)/v1/west/t/refresh/route.ts b/editor/app/(api)/(public)/v1/west/t/refresh/route.ts index f162fd551..699892430 100644 --- a/editor/app/(api)/(public)/v1/west/t/refresh/route.ts +++ b/editor/app/(api)/(public)/v1/west/t/refresh/route.ts @@ -1,5 +1,5 @@ import { service_role } from "@/lib/supabase/server"; -import { buildTenantSiteBaseUrl } from "@/lib/tenant-url"; +import { buildTenantSiteBaseUrl } from "@/host/tenant-url"; import { headers } from "next/headers"; import { NextResponse, type NextRequest } from "next/server"; import { Platform } from "@/lib/platform"; diff --git a/editor/lib/tenant-url.test.ts b/editor/host/tenant-url.test.ts similarity index 96% rename from editor/lib/tenant-url.test.ts rename to editor/host/tenant-url.test.ts index 613c898cb..4c2cb91ea 100644 --- a/editor/lib/tenant-url.test.ts +++ b/editor/host/tenant-url.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; -import { DEFAULT_PLATFORM_APEX_DOMAIN } from "./domains"; +import { DEFAULT_PLATFORM_APEX_DOMAIN } from "@/lib/domains"; describe("lib/tenant-url", () => { const rpcMock = vi.hoisted(() => vi.fn()); @@ -74,7 +74,7 @@ describe("lib/tenant-url", () => { expect(fn).toBe("www_get_canonical_hostname"); const tenant = (args as any)?.p_www_name as string | undefined; - const canonical = tenant ? canonicalByTenant[tenant] ?? null : null; + const canonical = tenant ? (canonicalByTenant[tenant] ?? null) : null; return { data: canonical ? [{ canonical_hostname: canonical }] : [], @@ -152,4 +152,3 @@ describe("lib/tenant-url", () => { ).resolves.toBe(`https://acme.${DEFAULT_PLATFORM_APEX_DOMAIN}/west`); }); }); - diff --git a/editor/lib/tenant-url.ts b/editor/host/tenant-url.ts similarity index 100% rename from editor/lib/tenant-url.ts rename to editor/host/tenant-url.ts From bbd035d03924e019d19b61d0d77c72e895b474a4 Mon Sep 17 00:00:00 2001 From: Universe Date: Sun, 8 Feb 2026 14:46:54 +0900 Subject: [PATCH 3/6] Enhance documentation with universal routing guidelines --- AGENTS.md | 1 + docs/AGENTS.md | 9 +++++++++ editor/AGENTS.md | 7 +++++++ 3 files changed, 17 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 63af4aa45..1c2934958 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,6 +75,7 @@ Documentation files are located in the `./docs` directory. This directory contains the docs as-is, the deployment of the docs are handled by [apps/docs](./apps/docs). A docusaurus project that syncs the docs content to its directory. When writing docs, the root `./docs` directory is the source of truth. See [`docs/AGENTS.md`](./docs/AGENTS.md) for the docs contribution scope (we only actively maintain `docs/wg/**` and `docs/reference/**`). +When linking docs to editor pages, prefer **universal routing** (`https://grida.co/_/`). See `docs/wg/platform/universal-docs-routing.md`. ## `/crates/*` diff --git a/docs/AGENTS.md b/docs/AGENTS.md index f57145ce8..9a8223133 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -24,6 +24,15 @@ Other folders under `/docs` are **not actively managed**. - Unless you have a specific task, **avoid editing** content outside `docs/wg/**` and `docs/reference/**`. - Do not edit generated artifacts under `/apps/docs/docs/**`. +## Universal routing (linking to editor pages) + +When writing or updating **user-facing docs**, prefer **universal routing** links for any “open this page in the editor” instruction. + +- **Use production URLs**: links should start with `https://grida.co`. +- **Use the universal prefix**: use `https://grida.co/_/` instead of tenant-specific canonical paths. + - Example: `https://grida.co/_/connect/channels` +- **If you add a new user-facing page** that should be linkable from docs, make sure it’s registered in **universal routing** so the `/_/…` alias resolves correctly. See `docs/wg/platform/universal-docs-routing.md`. + ## Conventions ### `_history/` directories (unlisted docs) diff --git a/editor/AGENTS.md b/editor/AGENTS.md index c556871db..df4fee945 100644 --- a/editor/AGENTS.md +++ b/editor/AGENTS.md @@ -1 +1,8 @@ # `editor` + +## Universal routing (docs-friendly links) + +Grida supports **universal routing** so documentation can link to stable, tenant-agnostic URLs like `https://grida.co/_/` and have them resolved to canonical tenant/document routes at runtime. + +- When you add a **new user-facing page** that should be referenced from docs, ensure it is registered in **universal routing**. +- When debugging docs links that point to the editor, start from the universal routing spec: `docs/wg/platform/universal-docs-routing.md`. From 794b0cb26a5f166259c69c841637e8ffa63f5005 Mon Sep 17 00:00:00 2001 From: Universe Date: Sun, 8 Feb 2026 14:49:08 +0900 Subject: [PATCH 4/6] pnpm dlx shadcn@latest add item --- editor/components/ui/item.tsx | 193 ++++++++++++++++++++++++++++++++++ pnpm-lock.yaml | 52 +++------ 2 files changed, 207 insertions(+), 38 deletions(-) create mode 100644 editor/components/ui/item.tsx diff --git a/editor/components/ui/item.tsx b/editor/components/ui/item.tsx new file mode 100644 index 000000000..ef84a1b47 --- /dev/null +++ b/editor/components/ui/item.tsx @@ -0,0 +1,193 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/components/lib/utils/index"; +import { Separator } from "@/components/ui/separator"; + +function ItemGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +

+ ); +} + +function ItemSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +const itemVariants = cva( + "group/item [a]:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-ring/50 [a]:transition-colors flex flex-wrap items-center rounded-md border border-transparent text-sm outline-none transition-colors duration-100 focus-visible:ring-[3px]", + { + variants: { + variant: { + default: "bg-transparent", + outline: "border-border", + muted: "bg-muted/50", + }, + size: { + default: "gap-4 p-4 ", + sm: "gap-2.5 px-4 py-3", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +); + +function Item({ + className, + variant = "default", + size = "default", + asChild = false, + ...props +}: React.ComponentProps<"div"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "div"; + return ( + + ); +} + +const itemMediaVariants = cva( + "flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:translate-y-0.5 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none", + { + variants: { + variant: { + default: "bg-transparent", + icon: "bg-muted size-8 rounded-sm border [&_svg:not([class*='size-'])]:size-4", + image: + "size-10 overflow-hidden rounded-sm [&_img]:size-full [&_img]:object-cover", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +function ItemMedia({ + className, + variant = "default", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function ItemContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function ItemTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function ItemDescription({ className, ...props }: React.ComponentProps<"p">) { + return ( +

a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4", + className + )} + {...props} + /> + ); +} + +function ItemActions({ className, ...props }: React.ComponentProps<"div">) { + return ( +

+ ); +} + +function ItemHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function ItemFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { + Item, + ItemMedia, + ItemContent, + ItemActions, + ItemGroup, + ItemSeparator, + ItemTitle, + ItemDescription, + ItemHeader, + ItemFooter, +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d34cf056c..5b00628b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,7 +49,7 @@ importers: dependencies: '@next/third-parties': specifier: 16.1.3 - version: 16.1.3(next@16.1.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + version: 16.1.3(next@16.1.3(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) '@react-three/drei': specifier: ^10.0.7 version: 10.1.2(@react-three/fiber@9.1.2(@types/react@19.1.3)(immer@9.0.21)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(three@0.170.0))(@types/react@19.1.3)(@types/three@0.170.0)(immer@9.0.21)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(three@0.170.0) @@ -104,7 +104,7 @@ importers: version: 9.27.0(jiti@2.4.2) eslint-config-next: specifier: 16.1.3 - version: 16.1.3(@typescript-eslint/parser@8.53.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) + version: 16.1.3(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) tailwindcss: specifier: ^4 version: 4.1.8 @@ -435,7 +435,7 @@ importers: version: 16.1.3(@mdx-js/loader@3.1.0(acorn@8.15.0)(webpack@5.98.0(esbuild@0.25.4)))(@mdx-js/react@3.1.0(@types/react@19.1.3)(react@19.2.3)) '@next/third-parties': specifier: 16.1.3 - version: 16.1.3(next@16.1.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + version: 16.1.3(next@16.1.3(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) '@number-flow/react': specifier: ^0.5.7 version: 0.5.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -1054,7 +1054,7 @@ importers: version: 9.27.0(jiti@2.4.2) eslint-config-next: specifier: 16.1.3 - version: 16.1.3(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) + version: 16.1.3(@typescript-eslint/parser@8.53.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) import-in-the-middle: specifier: ^1.13.2 version: 1.14.0 @@ -13012,11 +13012,12 @@ packages: tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me tar@7.4.3: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} engines: {node: '>=18'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} @@ -17431,7 +17432,7 @@ snapshots: '@next/swc-win32-x64-msvc@16.1.3': optional: true - '@next/third-parties@16.1.3(next@16.1.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)': + '@next/third-parties@16.1.3(next@16.1.3(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)': dependencies: next: 16.1.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 @@ -22835,8 +22836,8 @@ snapshots: '@next/eslint-plugin-next': 16.1.3 eslint: 9.27.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.8.0(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.27.0(jiti@2.4.2)))(eslint@9.27.0(jiti@2.4.2)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.8.0(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.27.0(jiti@2.4.2)))(eslint@9.27.0(jiti@2.4.2)))(eslint@9.27.0(jiti@2.4.2)) + eslint-import-resolver-typescript: 3.8.0(eslint-plugin-import@2.32.0)(eslint@9.27.0(jiti@2.4.2)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.8.0)(eslint@9.27.0(jiti@2.4.2)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.27.0(jiti@2.4.2)) eslint-plugin-react: 7.37.4(eslint@9.27.0(jiti@2.4.2)) eslint-plugin-react-hooks: 7.0.1(eslint@9.27.0(jiti@2.4.2)) @@ -22878,21 +22879,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.8.0(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.27.0(jiti@2.4.2)))(eslint@9.27.0(jiti@2.4.2)): - dependencies: - '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3 - enhanced-resolve: 5.18.1 - eslint: 9.27.0(jiti@2.4.2) - get-tsconfig: 4.10.0 - is-bun-module: 1.3.0 - stable-hash: 0.0.4 - tinyglobby: 0.2.15 - optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.8.0(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.27.0(jiti@2.4.2)))(eslint@9.27.0(jiti@2.4.2)))(eslint@9.27.0(jiti@2.4.2)) - transitivePeerDependencies: - - supports-color - eslint-import-resolver-typescript@3.8.0(eslint-plugin-import@2.32.0)(eslint@9.27.0(jiti@2.4.2)): dependencies: '@nolyfill/is-core-module': 1.0.39 @@ -22904,32 +22890,22 @@ snapshots: stable-hash: 0.0.4 tinyglobby: 0.2.15 optionalDependencies: - eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.8.0)(eslint@9.27.0(jiti@2.4.2)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.8.0)(eslint@9.27.0(jiti@2.4.2)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.0(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.27.0(jiti@2.4.2)))(eslint@9.27.0(jiti@2.4.2)))(eslint@9.27.0(jiti@2.4.2)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.0)(eslint@9.27.0(jiti@2.4.2)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.53.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) - eslint: 9.27.0(jiti@2.4.2) - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.8.0(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.27.0(jiti@2.4.2)))(eslint@9.27.0(jiti@2.4.2)) - transitivePeerDependencies: - - supports-color - - eslint-module-utils@2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.0)(eslint@9.27.0(jiti@2.4.2)): - dependencies: - debug: 3.2.7 - optionalDependencies: eslint: 9.27.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.8.0(eslint-plugin-import@2.32.0)(eslint@9.27.0(jiti@2.4.2)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.8.0(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.27.0(jiti@2.4.2)))(eslint@9.27.0(jiti@2.4.2)))(eslint@9.27.0(jiti@2.4.2)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.8.0)(eslint@9.27.0(jiti@2.4.2)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -22940,7 +22916,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.27.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.0(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.27.0(jiti@2.4.2)))(eslint@9.27.0(jiti@2.4.2)))(eslint@9.27.0(jiti@2.4.2)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.0)(eslint@9.27.0(jiti@2.4.2)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -22969,7 +22945,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.27.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.0)(eslint@9.27.0(jiti@2.4.2)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.0)(eslint@9.27.0(jiti@2.4.2)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 From 37601496df3e35af1d7876777b4684a122536354 Mon Sep 17 00:00:00 2001 From: Universe Date: Sun, 8 Feb 2026 14:53:14 +0900 Subject: [PATCH 5/6] chore: style --- .../universal/[[...path]]/page.tsx | 121 ++++++++++++------ 1 file changed, 82 insertions(+), 39 deletions(-) diff --git a/editor/app/(workspace)/universal/[[...path]]/page.tsx b/editor/app/(workspace)/universal/[[...path]]/page.tsx index d3ba3931b..c444c3e73 100644 --- a/editor/app/(workspace)/universal/[[...path]]/page.tsx +++ b/editor/app/(workspace)/universal/[[...path]]/page.tsx @@ -6,6 +6,16 @@ import { matchUniversalRoute, normalizeUniversalPath, } from "@/host/url"; +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemGroup, + ItemMedia, + ItemTitle, +} from "@/components/ui/item"; +import { ArrowRight, Folder, FileText } from "lucide-react"; type Params = { path?: string[]; @@ -86,34 +96,49 @@ export default async function UniversalRoutePicker({

{projects.length === 0 ? ( -

- No projects available. -

+ + + + + + No projects available + + You don’t have access to any projects in this account. + + + ) : ( -
    + {projects.map((project) => { const destination = buildUniversalDestination(route.id, { org: project.organizationName, proj: project.projectName, }); return ( -
  • - -
    -
    - {project.organizationName} -
    -
    {project.projectName}
    -
    - Open → + + + + + + + {project.projectName} + {project.organizationName} + + + Open + + -
  • + ); })} -
+ )} ); @@ -169,11 +194,23 @@ export default async function UniversalRoutePicker({

{docs.length === 0 ? ( -

- No matching documents available. -

+ + + + + + No matching documents + + You don’t have any documents that can open{" "} + + {formatUniversalPath(route.path)} + + . + + + ) : ( -
    + {docs.map((doc) => { const destination = buildUniversalDestination(route.id, { org: doc.organizationName, @@ -181,26 +218,32 @@ export default async function UniversalRoutePicker({ docId: doc.id, }); return ( -
  • - -
    -
    - {doc.organizationName} / {doc.projectName} -
    -
    {doc.title}
    -
    - {doc.doctype} -
    -
    - Open → + + + + + + + {doc.title} + + {doc.organizationName} / {doc.projectName} · {doc.doctype} + + + + Open + + -
  • + ); })} -
+ )} ); From 283e83838e83b2843fb0facb3599a6c032531ef5 Mon Sep 17 00:00:00 2001 From: Universe Date: Sun, 8 Feb 2026 14:59:01 +0900 Subject: [PATCH 6/6] feat: add new project and document route configurations - Introduced new routes for project and document scopes, including "ciam/portal", "new/event", "new/referral", and various form-related routes. - Enhanced the connect section with additional routes for integrations and store management, improving overall routing capabilities. --- editor/host/url.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/editor/host/url.ts b/editor/host/url.ts index 8d10b4c17..d16906e09 100644 --- a/editor/host/url.ts +++ b/editor/host/url.ts @@ -52,6 +52,7 @@ const PROJECT_ROUTE_CONFIGS = { project: { scope: "project", path: "" }, dash: { scope: "project" }, ciam: { scope: "project" }, + "ciam/portal": { scope: "project" }, customers: { scope: "project" }, "customers/policies": { scope: "project" }, "customers/policies/new": { scope: "project" }, @@ -60,6 +61,8 @@ const PROJECT_ROUTE_CONFIGS = { integrations: { scope: "project" }, analytics: { scope: "project" }, campaigns: { scope: "project" }, + "new/event": { scope: "project" }, + "new/referral": { scope: "project" }, www: { scope: "project" }, } satisfies Record; @@ -77,6 +80,9 @@ const DOCUMENT_ROUTE_CONFIGS = { // ── form ────────────────────────────────────────────────── form: { scope: "document", requiredDoctypes: ["v0_form"] }, "form/edit": { scope: "document", requiredDoctypes: ["v0_form"] }, + "form/auth": { scope: "document", requiredDoctypes: ["v0_form"] }, + "form/start": { scope: "document", requiredDoctypes: ["v0_form"] }, + "form/end": { scope: "document", requiredDoctypes: ["v0_form"] }, // ── general ─────────────────────────────────────────────── settings: { scope: "document" }, @@ -86,6 +92,7 @@ const DOCUMENT_ROUTE_CONFIGS = { // ── data ────────────────────────────────────────────────── data: { scope: "document" }, objects: { scope: "document" }, + "data/customers": { scope: "document", requiredDoctypes: ["v0_form"] }, "data/responses": { scope: "document", requiredDoctypes: ["v0_form"] }, "data/responses/sessions": { scope: "document", requiredDoctypes: ["v0_form"] }, "data/analytics": { scope: "document" }, @@ -98,9 +105,14 @@ const DOCUMENT_ROUTE_CONFIGS = { "connect/parameters": { scope: "document", requiredDoctypes: ["v0_form"] }, "connect/customer": { scope: "document", requiredDoctypes: ["v0_form"] }, "connect/channels": { scope: "document", requiredDoctypes: ["v0_form"] }, + "connect/import": { scope: "document", requiredDoctypes: ["v0_form"] }, + "connect/integrations": { scope: "document" }, + "connect/webhooks": { scope: "document", requiredDoctypes: ["v0_form"] }, "connect/store": { scope: "document", requiredDoctypes: ["v0_form"] }, "connect/store/get-started": { scope: "document", requiredDoctypes: ["v0_form"] }, + "connect/store/orders": { scope: "document", requiredDoctypes: ["v0_form"] }, "connect/store/products": { scope: "document", requiredDoctypes: ["v0_form"] }, + "connect/store/products/new": { scope: "document", requiredDoctypes: ["v0_form"] }, "connect/database/supabase": { scope: "document", requiredDoctypes: ["v0_form", "v0_schema"] }, } satisfies Record;