@elytracms/studio 0.0.62 → 0.0.63
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/account/credential-panel.js +6 -1
- package/dist/components/account/credential-panel.js.map +1 -1
- package/dist/components/auth-context.d.ts +14 -0
- package/dist/components/auth-context.js +32 -2
- package/dist/components/auth-context.js.map +1 -1
- package/dist/components/builder/blocks-model.d.ts +7 -0
- package/dist/components/builder/blocks-model.js +5 -0
- package/dist/components/builder/blocks-model.js.map +1 -1
- package/dist/components/builder/blocks-panel.js +2 -2
- package/dist/components/builder/blocks-panel.js.map +1 -1
- package/dist/components/builder/fixture-thumbnail.d.ts +15 -0
- package/dist/components/builder/fixture-thumbnail.js +14 -0
- package/dist/components/builder/fixture-thumbnail.js.map +1 -1
- package/dist/components/cms/cms-index.js +6 -3
- package/dist/components/cms/cms-index.js.map +1 -1
- package/dist/components/deployment-setup-gate.js +40 -8
- package/dist/components/deployment-setup-gate.js.map +1 -1
- package/dist/components/operation-error.d.ts +7 -5
- package/dist/components/operation-error.js +19 -36
- package/dist/components/operation-error.js.map +1 -1
- package/dist/components/project-list.js +17 -1
- package/dist/components/project-list.js.map +1 -1
- package/dist/components/project-switcher.d.ts +10 -0
- package/dist/components/project-switcher.js +13 -3
- package/dist/components/project-switcher.js.map +1 -1
- package/dist/components/session-expired-overlay.d.ts +27 -0
- package/dist/components/session-expired-overlay.js +51 -0
- package/dist/components/session-expired-overlay.js.map +1 -0
- package/dist/components/sign-in-view.d.ts +13 -1
- package/dist/components/sign-in-view.js +27 -13
- package/dist/components/sign-in-view.js.map +1 -1
- package/dist/components/studio-project-screen.js +2 -2
- package/dist/components/studio-project-screen.js.map +1 -1
- package/dist/components/studio-shell.js +40 -3
- package/dist/components/studio-shell.js.map +1 -1
- package/dist/customization.d.ts +4 -1
- package/dist/customization.js +3 -0
- package/dist/customization.js.map +1 -1
- package/dist/lib/auth-errors.d.ts +58 -28
- package/dist/lib/auth-errors.js +93 -15
- package/dist/lib/auth-errors.js.map +1 -1
- package/dist/lib/i18n/index.d.ts +1 -0
- package/dist/lib/i18n/index.js.map +1 -1
- package/dist/lib/i18n/messages.de.d.ts +24 -0
- package/dist/lib/i18n/messages.de.js +1019 -0
- package/dist/lib/i18n/messages.de.js.map +1 -0
- package/dist/lib/i18n/messages.en.d.ts +880 -0
- package/dist/lib/i18n/messages.en.js +71 -3
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/operations/studio-operations.js +7 -0
- package/dist/lib/operations/studio-operations.js.map +1 -1
- package/dist/lib/persistence/deployment-readiness.d.ts +8 -1
- package/dist/lib/persistence/deployment-readiness.js +16 -1
- package/dist/lib/persistence/deployment-readiness.js.map +1 -1
- package/dist/lib/session-expiry.d.ts +67 -0
- package/dist/lib/session-expiry.js +72 -0
- package/dist/lib/session-expiry.js.map +1 -0
- package/dist/lib/tab-title.d.ts +53 -0
- package/dist/lib/tab-title.js +77 -0
- package/dist/lib/tab-title.js.map +1 -0
- package/dist/lib/workspace/index.d.ts +1 -1
- package/dist/lib/workspace/index.js +1 -1
- package/dist/lib/workspace/index.js.map +1 -1
- package/dist/lib/workspace/switcher-source.d.ts +27 -0
- package/dist/lib/workspace/switcher-source.js +30 -0
- package/dist/lib/workspace/switcher-source.js.map +1 -1
- package/dist/router/index.d.ts +6 -0
- package/dist/router/index.js +6 -0
- package/dist/router/index.js.map +1 -1
- package/dist/router/launcher-visit.d.ts +13 -0
- package/dist/router/launcher-visit.js +34 -0
- package/dist/router/launcher-visit.js.map +1 -0
- package/dist/router/root.js +12 -2
- package/dist/router/root.js.map +1 -1
- package/dist/ui/dialog.d.ts +15 -0
- package/dist/ui/dialog.js +17 -0
- package/dist/ui/dialog.js.map +1 -1
- package/package.json +6 -2
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { activeDeploymentKey, selectPersistenceAdapter } from '../persistence/index.js';
|
|
2
|
+
import { attachSessionExpiryReporting } from '../session-expiry.js';
|
|
2
3
|
import { attachConfiguredRevalidationWebhooks } from '../webhooks/index.js';
|
|
3
4
|
import { attachReferenceIndex, createChangesets, createOperations, createRoleAuthorization, } from './operations-package.js';
|
|
4
5
|
/**
|
|
@@ -30,6 +31,12 @@ export function createStudioOperations(adapter, options = {}) {
|
|
|
30
31
|
// setState, graph publish/unpublish) fires a signed revalidation webhook.
|
|
31
32
|
// No env (dev/tests) → no-op.
|
|
32
33
|
attachConfiguredRevalidationWebhooks(operations.events);
|
|
34
|
+
// EC-403: session-expiry detection rides the SAME lifecycle bus — every failed
|
|
35
|
+
// command/query/changeset operation is checked against the session-expired
|
|
36
|
+
// predicate here, so expiry raises the ONE app-level blocking re-auth state no
|
|
37
|
+
// matter which surface issued the operation (per-surface banners can't cover
|
|
38
|
+
// surfaces that render no banner).
|
|
39
|
+
attachSessionExpiryReporting(operations.events);
|
|
33
40
|
return { operations, changesets, adapter, references };
|
|
34
41
|
}
|
|
35
42
|
// One adapter + one operations bundle per backend (EC-179): keyed by the active
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"studio-operations.js","sourceRoot":"","sources":["../../../src/lib/operations/studio-operations.ts"],"names":[],"mappings":"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","sourcesContent":["import type { PersistenceAdapter } from '@elytracms/core/persistence'\nimport { activeDeploymentKey, selectPersistenceAdapter } from '../persistence'\nimport { attachConfiguredRevalidationWebhooks } from '../webhooks'\nimport {\n attachReferenceIndex,\n createChangesets,\n createOperations,\n createRoleAuthorization,\n} from './operations-package'\nimport type {\n ChangesetOperations,\n OperationsClient,\n Principal,\n ReferenceIndexAttachment,\n} from './operations-package'\n\n/**\n * The studio's single operations bootstrap (EC-141, vision AD-7).\n *\n * One `OperationsClient` + one `ChangesetOperations` instance, built over the\n * selected persistence adapter, shared by every store and surface in the app.\n * The UI never talks to a repository for writes — every mutation is a typed\n * operation command (validate → authorize → mutate → event), so the studio\n * dogfoods the same seam plugins, AI changesets, and the CLI use.\n *\n * The EC-155 reference index is attached to the shared event bus here, so any\n * document/graph/publishing command issued anywhere in the studio keeps the\n * index maintained.\n */\nexport interface StudioOperations {\n readonly operations: OperationsClient\n readonly changesets: ChangesetOperations\n /**\n * The raw adapter, exposed for *transport and read paths only*: blob byte\n * upload/download (`adapter.blobs`) and existing read stores. Studio code\n * must not call repository write methods on it — writes go through\n * `operations`.\n */\n readonly adapter: PersistenceAdapter\n /** EC-155 maintained-on-write index attachment (flushable in tests). */\n readonly references: ReferenceIndexAttachment\n}\n\nexport interface CreateStudioOperationsOptions {\n /**\n * The signed-in principal (EC-150). When present, the EC-150 role hook\n * (`createRoleAuthorization` over `adapter.members`) becomes the\n * authorization chokepoint for every command and query — viewers get\n * structured denials on mutations, editors on administration. Without an\n * actor the client runs pass-through (seeds, fixtures, tests that exercise\n * domain logic rather than permissions); the route guard keeps\n * unauthenticated users away from every operation surface.\n */\n actor?: Principal\n}\n\n/**\n * A no-op {@link ReferenceIndexAttachment} for self-maintaining adapters (EC-277):\n * nothing is subscribed, so `detach` and `flush` are trivially satisfied (the index\n * is kept current in the adapter's write path, not by a queued subscriber).\n */\nfunction noopReferenceIndexAttachment(): ReferenceIndexAttachment {\n return { detach: () => {}, flush: async () => {} }\n}\n\n/** Build a studio operations bundle over an adapter (exported for tests). */\nexport function createStudioOperations(\n adapter: PersistenceAdapter,\n options: CreateStudioOperationsOptions = {},\n): StudioOperations {\n const authorized = options.actor\n ? { actor: options.actor, authorize: createRoleAuthorization(adapter.members) }\n : {}\n const operations = createOperations({ adapter, ...authorized })\n // EC-277: when the adapter maintains the index in its own write path (AD-6 \"at the\n // source\" — the Convex provider does this server-side, so a raw `convex run` is\n // caught too), the client-side subscriber would only double the work. Attach it\n // ONLY for providers that don't self-maintain. Where it does run, extraction is a\n // schema-free, manifest-free walk over self-describing content, so it needs no\n // registry (compositions self-typed their relation/asset props on write).\n const references = adapter.maintainsReferenceIndex\n ? noopReferenceIndexAttachment()\n : attachReferenceIndex(operations.events, adapter)\n const changesets = createChangesets({ adapter, events: operations.events, ...authorized })\n // EC-146 instant publish: when a host revalidate endpoint is configured via\n // env, every successful publish-shaped command (document upsert/delete/\n // setState, graph publish/unpublish) fires a signed revalidation webhook.\n // No env (dev/tests) → no-op.\n attachConfiguredRevalidationWebhooks(operations.events)\n return { operations, changesets, adapter, references }\n}\n\n// One adapter + one operations bundle per backend (EC-179): keyed by the active\n// deployment so switching to a project on another Convex deployment selects (or\n// lazily builds) that backend's client, rather than reusing the first one.\nconst adaptersByKey = new Map<string, Promise<PersistenceAdapter>>()\nconst instancesByKey = new Map<string, Promise<StudioOperations>>()\nlet currentActor: Principal | null = null\n\n/**\n * The active backend key, tolerant of an unconfigured env: the real app never\n * reaches `getStudioOperations` while misconfigured (the `DeploymentSetupGate` renders a\n * boot error first), but tests inject a bundle directly via the seam below — so\n * a missing backend resolves to one stable key rather than throwing.\n */\nfunction deploymentKey(): string {\n try {\n return activeDeploymentKey()\n } catch {\n return '__unconfigured__'\n }\n}\n\nfunction getAdapter(key: string): Promise<PersistenceAdapter> {\n let adapter = adaptersByKey.get(key)\n if (!adapter) {\n adapter = selectPersistenceAdapter()\n adaptersByKey.set(key, adapter)\n }\n return adapter\n}\n\n/**\n * The shared studio operations client for the active backend (lazily\n * bootstrapped, cached per deployment). In-memory mode this owns the one seeded\n * adapter the whole session mutates; against Convex it owns one client per\n * deployment — the active project (EC-179) selects which.\n */\nexport function getStudioOperations(): Promise<StudioOperations> {\n const key = deploymentKey()\n let instance = instancesByKey.get(key)\n if (!instance) {\n const actor = currentActor\n instance = getAdapter(key).then((adapter) =>\n createStudioOperations(adapter, actor ? { actor } : {}),\n )\n instancesByKey.set(key, instance)\n }\n return instance\n}\n\n/**\n * Bind (or clear) the session principal (EC-150). Called by the auth context\n * on sign-in/sign-out. Rebuilds the operations clients over the *same* adapters\n * — the persisted state (and, in-memory, the whole session's edits) survives;\n * only the authorization identity changes. All cached backends are rebuilt so a\n * later switch picks up the new actor too.\n */\nexport function setStudioSession(actor: Principal | null): void {\n if (currentActor?.id === actor?.id) return // same user (or both null) — keep the clients\n currentActor = actor\n instancesByKey.clear()\n}\n\n/** Test seam: replace (or clear) the singleton for the active backend. */\nexport function setStudioOperationsForTests(next: StudioOperations | null): void {\n if (next) {\n instancesByKey.set(deploymentKey(), Promise.resolve(next))\n return\n }\n instancesByKey.clear()\n adaptersByKey.clear()\n currentActor = null\n}\n"]}
|
|
1
|
+
{"version":3,"file":"studio-operations.js","sourceRoot":"","sources":["../../../src/lib/operations/studio-operations.ts"],"names":[],"mappings":"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","sourcesContent":["import type { PersistenceAdapter } from '@elytracms/core/persistence'\nimport { activeDeploymentKey, selectPersistenceAdapter } from '../persistence'\nimport { attachSessionExpiryReporting } from '../session-expiry'\nimport { attachConfiguredRevalidationWebhooks } from '../webhooks'\nimport {\n attachReferenceIndex,\n createChangesets,\n createOperations,\n createRoleAuthorization,\n} from './operations-package'\nimport type {\n ChangesetOperations,\n OperationsClient,\n Principal,\n ReferenceIndexAttachment,\n} from './operations-package'\n\n/**\n * The studio's single operations bootstrap (EC-141, vision AD-7).\n *\n * One `OperationsClient` + one `ChangesetOperations` instance, built over the\n * selected persistence adapter, shared by every store and surface in the app.\n * The UI never talks to a repository for writes — every mutation is a typed\n * operation command (validate → authorize → mutate → event), so the studio\n * dogfoods the same seam plugins, AI changesets, and the CLI use.\n *\n * The EC-155 reference index is attached to the shared event bus here, so any\n * document/graph/publishing command issued anywhere in the studio keeps the\n * index maintained.\n */\nexport interface StudioOperations {\n readonly operations: OperationsClient\n readonly changesets: ChangesetOperations\n /**\n * The raw adapter, exposed for *transport and read paths only*: blob byte\n * upload/download (`adapter.blobs`) and existing read stores. Studio code\n * must not call repository write methods on it — writes go through\n * `operations`.\n */\n readonly adapter: PersistenceAdapter\n /** EC-155 maintained-on-write index attachment (flushable in tests). */\n readonly references: ReferenceIndexAttachment\n}\n\nexport interface CreateStudioOperationsOptions {\n /**\n * The signed-in principal (EC-150). When present, the EC-150 role hook\n * (`createRoleAuthorization` over `adapter.members`) becomes the\n * authorization chokepoint for every command and query — viewers get\n * structured denials on mutations, editors on administration. Without an\n * actor the client runs pass-through (seeds, fixtures, tests that exercise\n * domain logic rather than permissions); the route guard keeps\n * unauthenticated users away from every operation surface.\n */\n actor?: Principal\n}\n\n/**\n * A no-op {@link ReferenceIndexAttachment} for self-maintaining adapters (EC-277):\n * nothing is subscribed, so `detach` and `flush` are trivially satisfied (the index\n * is kept current in the adapter's write path, not by a queued subscriber).\n */\nfunction noopReferenceIndexAttachment(): ReferenceIndexAttachment {\n return { detach: () => {}, flush: async () => {} }\n}\n\n/** Build a studio operations bundle over an adapter (exported for tests). */\nexport function createStudioOperations(\n adapter: PersistenceAdapter,\n options: CreateStudioOperationsOptions = {},\n): StudioOperations {\n const authorized = options.actor\n ? { actor: options.actor, authorize: createRoleAuthorization(adapter.members) }\n : {}\n const operations = createOperations({ adapter, ...authorized })\n // EC-277: when the adapter maintains the index in its own write path (AD-6 \"at the\n // source\" — the Convex provider does this server-side, so a raw `convex run` is\n // caught too), the client-side subscriber would only double the work. Attach it\n // ONLY for providers that don't self-maintain. Where it does run, extraction is a\n // schema-free, manifest-free walk over self-describing content, so it needs no\n // registry (compositions self-typed their relation/asset props on write).\n const references = adapter.maintainsReferenceIndex\n ? noopReferenceIndexAttachment()\n : attachReferenceIndex(operations.events, adapter)\n const changesets = createChangesets({ adapter, events: operations.events, ...authorized })\n // EC-146 instant publish: when a host revalidate endpoint is configured via\n // env, every successful publish-shaped command (document upsert/delete/\n // setState, graph publish/unpublish) fires a signed revalidation webhook.\n // No env (dev/tests) → no-op.\n attachConfiguredRevalidationWebhooks(operations.events)\n // EC-403: session-expiry detection rides the SAME lifecycle bus — every failed\n // command/query/changeset operation is checked against the session-expired\n // predicate here, so expiry raises the ONE app-level blocking re-auth state no\n // matter which surface issued the operation (per-surface banners can't cover\n // surfaces that render no banner).\n attachSessionExpiryReporting(operations.events)\n return { operations, changesets, adapter, references }\n}\n\n// One adapter + one operations bundle per backend (EC-179): keyed by the active\n// deployment so switching to a project on another Convex deployment selects (or\n// lazily builds) that backend's client, rather than reusing the first one.\nconst adaptersByKey = new Map<string, Promise<PersistenceAdapter>>()\nconst instancesByKey = new Map<string, Promise<StudioOperations>>()\nlet currentActor: Principal | null = null\n\n/**\n * The active backend key, tolerant of an unconfigured env: the real app never\n * reaches `getStudioOperations` while misconfigured (the `DeploymentSetupGate` renders a\n * boot error first), but tests inject a bundle directly via the seam below — so\n * a missing backend resolves to one stable key rather than throwing.\n */\nfunction deploymentKey(): string {\n try {\n return activeDeploymentKey()\n } catch {\n return '__unconfigured__'\n }\n}\n\nfunction getAdapter(key: string): Promise<PersistenceAdapter> {\n let adapter = adaptersByKey.get(key)\n if (!adapter) {\n adapter = selectPersistenceAdapter()\n adaptersByKey.set(key, adapter)\n }\n return adapter\n}\n\n/**\n * The shared studio operations client for the active backend (lazily\n * bootstrapped, cached per deployment). In-memory mode this owns the one seeded\n * adapter the whole session mutates; against Convex it owns one client per\n * deployment — the active project (EC-179) selects which.\n */\nexport function getStudioOperations(): Promise<StudioOperations> {\n const key = deploymentKey()\n let instance = instancesByKey.get(key)\n if (!instance) {\n const actor = currentActor\n instance = getAdapter(key).then((adapter) =>\n createStudioOperations(adapter, actor ? { actor } : {}),\n )\n instancesByKey.set(key, instance)\n }\n return instance\n}\n\n/**\n * Bind (or clear) the session principal (EC-150). Called by the auth context\n * on sign-in/sign-out. Rebuilds the operations clients over the *same* adapters\n * — the persisted state (and, in-memory, the whole session's edits) survives;\n * only the authorization identity changes. All cached backends are rebuilt so a\n * later switch picks up the new actor too.\n */\nexport function setStudioSession(actor: Principal | null): void {\n if (currentActor?.id === actor?.id) return // same user (or both null) — keep the clients\n currentActor = actor\n instancesByKey.clear()\n}\n\n/** Test seam: replace (or clear) the singleton for the active backend. */\nexport function setStudioOperationsForTests(next: StudioOperations | null): void {\n if (next) {\n instancesByKey.set(deploymentKey(), Promise.resolve(next))\n return\n }\n instancesByKey.clear()\n adaptersByKey.clear()\n currentActor = null\n}\n"]}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DeploymentReadinessFacts, DeploymentReadinessReport } from '@elytracms/core/export-sync/deployment-ready';
|
|
1
|
+
import type { DeclaredEnvVar, DeploymentReadinessFacts, DeploymentReadinessReport } from '@elytracms/core/export-sync/deployment-ready';
|
|
2
2
|
import type { CollectionDef } from '@elytracms/core/cms-core';
|
|
3
3
|
import type { StudioBackendConfig } from './backend-mode.js';
|
|
4
4
|
import type { WorkspaceState } from '../workspace/index.js';
|
|
@@ -29,6 +29,13 @@ export interface StudioReadinessInputs {
|
|
|
29
29
|
available: boolean;
|
|
30
30
|
detail?: string;
|
|
31
31
|
};
|
|
32
|
+
/**
|
|
33
|
+
* Whether `defineStudio({ ai })` is configured and enabled (EC-399) — drives the
|
|
34
|
+
* AI provider-key expectation in the deployment-env checks.
|
|
35
|
+
*/
|
|
36
|
+
aiRouteConfigured?: boolean;
|
|
37
|
+
/** Consumer-declared deployment-env expectations (EC-399) — merged into the engine contract. */
|
|
38
|
+
declaredEnv?: readonly DeclaredEnvVar[];
|
|
32
39
|
}
|
|
33
40
|
/** Map the studio's resolved state onto the core readiness facts (pure). */
|
|
34
41
|
export declare function studioReadinessFacts(inputs: StudioReadinessInputs): DeploymentReadinessFacts;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { checkDeploymentReady } from '@elytracms/core/export-sync/deployment-ready';
|
|
1
|
+
import { checkDeploymentReady, expectedDeploymentEnv } from '@elytracms/core/export-sync/deployment-ready';
|
|
2
2
|
/** Map the studio's resolved state onto the core readiness facts (pure). */
|
|
3
3
|
export function studioReadinessFacts(inputs) {
|
|
4
4
|
const { backend, workspace, collections, reachable, effectiveUrl } = inputs;
|
|
@@ -12,11 +12,26 @@ export function studioReadinessFacts(inputs) {
|
|
|
12
12
|
: backend.kind === 'playground'
|
|
13
13
|
? { kind: 'playground' }
|
|
14
14
|
: { kind: 'unconfigured', detail: backend.detail };
|
|
15
|
+
// Expected-env facts (EC-399): the studio surfaces the SAME contract the CLI
|
|
16
|
+
// checks, but the browser can read neither the Convex deployment env nor the
|
|
17
|
+
// studio host's server env (the AI key is deliberately never VITE_-prefixed) —
|
|
18
|
+
// so no presence source is gathered and every env check surfaces as an honest
|
|
19
|
+
// `unknown` pointing at `elytra doctor`, exactly the auth-signing-keys precedent.
|
|
20
|
+
const deploymentEnv = backendFact.kind === 'convex'
|
|
21
|
+
? {
|
|
22
|
+
expected: expectedDeploymentEnv({
|
|
23
|
+
aiRouteConfigured: inputs.aiRouteConfigured ?? false,
|
|
24
|
+
...(inputs.declaredEnv ? { declared: inputs.declaredEnv } : {}),
|
|
25
|
+
}),
|
|
26
|
+
unavailableDetail: 'Not readable from the browser — run `elytra doctor` to check it (presence only).',
|
|
27
|
+
}
|
|
28
|
+
: undefined;
|
|
15
29
|
return {
|
|
16
30
|
backend: backendFact,
|
|
17
31
|
...(reachable ? { reachable } : {}),
|
|
18
32
|
collections,
|
|
19
33
|
config,
|
|
34
|
+
...(deploymentEnv ? { deploymentEnv } : {}),
|
|
20
35
|
};
|
|
21
36
|
}
|
|
22
37
|
/** Compose the studio's deployment-readiness report (pure). */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"deployment-readiness.js","sourceRoot":"","sources":["../../../src/lib/persistence/deployment-readiness.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,8CAA8C,CAAA;
|
|
1
|
+
{"version":3,"file":"deployment-readiness.js","sourceRoot":"","sources":["../../../src/lib/persistence/deployment-readiness.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,qBAAqB,EAAE,MAAM,8CAA8C,CAAA;AA2C1G,4EAA4E;AAC5E,MAAM,UAAU,oBAAoB,CAAC,MAA6B;IAChE,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,WAAW,EAAE,SAAS,EAAE,YAAY,EAAE,GAAG,MAAM,CAAA;IAE3E,MAAM,MAAM,GACV,SAAS,CAAC,IAAI,KAAK,SAAS;QAC1B,CAAC,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,CAAC,MAAM,EAAE,UAAU,EAAE,SAAS,CAAC,MAAM,CAAC,MAAM,EAAE;QACjF,CAAC,CAAC,SAAS,CAAC,IAAI,KAAK,QAAQ;YAC3B,CAAC,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,SAAS,CAAC,MAAM,EAAE;YAC3C,CAAC,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,CAAA;IAEvB,MAAM,WAAW,GACf,OAAO,CAAC,IAAI,KAAK,QAAQ;QACvB,CAAC,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,GAAG,EAAE,YAAY,IAAI,OAAO,CAAC,GAAG,EAAE;QACtD,CAAC,CAAC,OAAO,CAAC,IAAI,KAAK,YAAY;YAC7B,CAAC,CAAC,EAAE,IAAI,EAAE,YAAY,EAAE;YACxB,CAAC,CAAC,EAAE,IAAI,EAAE,cAAc,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,CAAA;IAExD,6EAA6E;IAC7E,6EAA6E;IAC7E,+EAA+E;IAC/E,8EAA8E;IAC9E,kFAAkF;IAClF,MAAM,aAAa,GACjB,WAAW,CAAC,IAAI,KAAK,QAAQ;QAC3B,CAAC,CAAC;YACE,QAAQ,EAAE,qBAAqB,CAAC;gBAC9B,iBAAiB,EAAE,MAAM,CAAC,iBAAiB,IAAI,KAAK;gBACpD,GAAG,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;aAChE,CAAC;YACF,iBAAiB,EAAE,kFAAkF;SACtG;QACH,CAAC,CAAC,SAAS,CAAA;IAEf,OAAO;QACL,OAAO,EAAE,WAAW;QACpB,GAAG,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACnC,WAAW;QACX,MAAM;QACN,GAAG,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,aAAa,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KAC5C,CAAA;AACH,CAAC;AAED,+DAA+D;AAC/D,MAAM,UAAU,yBAAyB,CAAC,MAA6B;IACrE,OAAO,oBAAoB,CAAC,oBAAoB,CAAC,MAAM,CAAC,CAAC,CAAA;AAC3D,CAAC","sourcesContent":["import { checkDeploymentReady, expectedDeploymentEnv } from '@elytracms/core/export-sync/deployment-ready'\nimport type {\n DeclaredEnvVar,\n DeploymentReadinessFacts,\n DeploymentReadinessReport,\n} from '@elytracms/core/export-sync/deployment-ready'\nimport type { CollectionDef } from '@elytracms/core/cms-core'\nimport type { StudioBackendConfig } from './backend-mode'\nimport type { WorkspaceState } from '../workspace'\n\n/**\n * Studio-side adapter for the shared `checkDeploymentReady` contract (EC-258).\n * Maps the studio's resolved state — backend mode (`resolveBackendConfig`),\n * workspace config (`getWorkspaceState`), config-owned collections\n * (`resolveDeploymentCollections`), and an optional reachability probe outcome —\n * onto the core-owned {@link DeploymentReadinessFacts}, then composes the report.\n *\n * A PURE mapper: the async I/O (the `adapter.status()` reachability probe) lives in\n * the boot `DeploymentSetupGate`, which passes the outcome in. `authKeys` is\n * deliberately left unprobed here — the deployment's JWKS endpoint is CORS-blocked\n * from the browser, so the auth-signing-keys check resolves to `unknown` at boot\n * (EC-257's honest sign-in error is the browser's actionable surface; the\n * `elytra doctor` CLI probes it definitively from node). `session` is left unset —\n * this gate runs before auth, so the per-user bucket stays `unknown` (never a false\n * fail) until a signed-in surface supplies it.\n */\nexport interface StudioReadinessInputs {\n backend: StudioBackendConfig\n /** The active project's effective deployment URL (`resolveDeploymentUrl`); defaults to `backend.url`. */\n effectiveUrl?: string\n workspace: WorkspaceState\n collections: readonly CollectionDef[]\n /** Reachability probe outcome (`adapter.status()`); omitted when not probed (playground / unconfigured). */\n reachable?: { available: boolean; detail?: string }\n /**\n * Whether `defineStudio({ ai })` is configured and enabled (EC-399) — drives the\n * AI provider-key expectation in the deployment-env checks.\n */\n aiRouteConfigured?: boolean\n /** Consumer-declared deployment-env expectations (EC-399) — merged into the engine contract. */\n declaredEnv?: readonly DeclaredEnvVar[]\n}\n\n/** Map the studio's resolved state onto the core readiness facts (pure). */\nexport function studioReadinessFacts(inputs: StudioReadinessInputs): DeploymentReadinessFacts {\n const { backend, workspace, collections, reachable, effectiveUrl } = inputs\n\n const config: DeploymentReadinessFacts['config'] =\n workspace.kind === 'invalid'\n ? { valid: false, source: workspace.source, issueCount: workspace.issues.length }\n : workspace.kind === 'config'\n ? { valid: true, source: workspace.source }\n : { valid: true }\n\n const backendFact: DeploymentReadinessFacts['backend'] =\n backend.kind === 'convex'\n ? { kind: 'convex', url: effectiveUrl ?? backend.url }\n : backend.kind === 'playground'\n ? { kind: 'playground' }\n : { kind: 'unconfigured', detail: backend.detail }\n\n // Expected-env facts (EC-399): the studio surfaces the SAME contract the CLI\n // checks, but the browser can read neither the Convex deployment env nor the\n // studio host's server env (the AI key is deliberately never VITE_-prefixed) —\n // so no presence source is gathered and every env check surfaces as an honest\n // `unknown` pointing at `elytra doctor`, exactly the auth-signing-keys precedent.\n const deploymentEnv: DeploymentReadinessFacts['deploymentEnv'] =\n backendFact.kind === 'convex'\n ? {\n expected: expectedDeploymentEnv({\n aiRouteConfigured: inputs.aiRouteConfigured ?? false,\n ...(inputs.declaredEnv ? { declared: inputs.declaredEnv } : {}),\n }),\n unavailableDetail: 'Not readable from the browser — run `elytra doctor` to check it (presence only).',\n }\n : undefined\n\n return {\n backend: backendFact,\n ...(reachable ? { reachable } : {}),\n collections,\n config,\n ...(deploymentEnv ? { deploymentEnv } : {}),\n }\n}\n\n/** Compose the studio's deployment-readiness report (pure). */\nexport function studioDeploymentReadiness(inputs: StudioReadinessInputs): DeploymentReadinessReport {\n return checkDeploymentReady(studioReadinessFacts(inputs))\n}\n"]}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { AuthStatus } from './auth/index.js';
|
|
2
|
+
import type { OperationError, OperationEvent, OperationEventBus } from './operations/index.js';
|
|
3
|
+
/**
|
|
4
|
+
* App-level session-expiry chokepoint (EC-403).
|
|
5
|
+
*
|
|
6
|
+
* Detection is reactive: an expired Convex session surfaces as a failed
|
|
7
|
+
* operation whose error matches {@link isSessionExpiredError}. Every studio
|
|
8
|
+
* command/query already flows through the operations pipeline, and every
|
|
9
|
+
* failure is emitted on the shared {@link OperationEventBus} — so ONE
|
|
10
|
+
* subscriber on that bus ({@link attachSessionExpiryReporting}, attached in
|
|
11
|
+
* `createStudioOperations`) sees every expiry, no matter which surface issued
|
|
12
|
+
* the operation. Surfaces that hold a structured `OperationError` directly
|
|
13
|
+
* (the shared error banner) report through the same funnel
|
|
14
|
+
* ({@link reportSessionExpiredError}).
|
|
15
|
+
*
|
|
16
|
+
* The consumer is the auth store (`components/auth-context.tsx`): it raises
|
|
17
|
+
* one app-wide `sessionExpired` state which mounts the blocking re-auth
|
|
18
|
+
* overlay — in place, no navigation, the React tree and all unsaved editor
|
|
19
|
+
* state stay alive. This module is deliberately framework-free (a plain
|
|
20
|
+
* listener registry + pure predicates) so the state machine is unit-testable.
|
|
21
|
+
*
|
|
22
|
+
* Proactive re-validation (window focus/interval) deliberately does NOT ship:
|
|
23
|
+
* the auth adapters' `currentSession()` treats *any* failure — including a
|
|
24
|
+
* transient network drop — as "signed out" and destroys the stored token, so
|
|
25
|
+
* polling it would sign editors out on a network blip. Distinguishing
|
|
26
|
+
* "token invalid" from "backend unreachable" needs an adapter change under
|
|
27
|
+
* the no-touch `lib/auth/**` (reported as a finding on EC-403).
|
|
28
|
+
*/
|
|
29
|
+
type SessionExpiryListener = () => void;
|
|
30
|
+
/**
|
|
31
|
+
* Subscribe to session-expiry reports (the auth store does, once). Returns the
|
|
32
|
+
* unsubscribe function.
|
|
33
|
+
*/
|
|
34
|
+
export declare function onSessionExpiry(listener: SessionExpiryListener): () => void;
|
|
35
|
+
/**
|
|
36
|
+
* Report a structured operation failure as a *possible* session expiry: runs
|
|
37
|
+
* the {@link isSessionExpiredError} predicate, dedupes per error instance, and
|
|
38
|
+
* notifies listeners. Returns whether listeners were notified. Non-expiry
|
|
39
|
+
* errors are ignored — callers can funnel every failure through unconditionally.
|
|
40
|
+
*/
|
|
41
|
+
export declare function reportSessionExpiredError(error: OperationError): boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Is this operation lifecycle event a session-expiry failure? Only `failed`
|
|
44
|
+
* events can carry the backend's unauthenticated transport blob (`rejected` is
|
|
45
|
+
* input validation, `denied` is the role hook — both structured, never expiry).
|
|
46
|
+
*/
|
|
47
|
+
export declare function isSessionExpiryEvent(event: OperationEvent): boolean;
|
|
48
|
+
/**
|
|
49
|
+
* The one reactive detection seam (EC-403): subscribe to an operations event
|
|
50
|
+
* bus and report every session-expiry failure. Attached per operations bundle
|
|
51
|
+
* in `createStudioOperations`, so commands, queries, and changeset operations
|
|
52
|
+
* are all covered. Returns the unsubscribe function.
|
|
53
|
+
*/
|
|
54
|
+
export declare function attachSessionExpiryReporting(events: OperationEventBus): () => void;
|
|
55
|
+
/**
|
|
56
|
+
* Pure raise-guard for the auth store's `sessionExpired` state: an expiry
|
|
57
|
+
* report blocks the studio only when a live session exists (a failure surfacing
|
|
58
|
+
* while signed out or still restoring must not overlay the sign-in screen),
|
|
59
|
+
* and raising is idempotent while already blocked.
|
|
60
|
+
*/
|
|
61
|
+
export declare function shouldBlockOnSessionExpiry(input: {
|
|
62
|
+
status: AuthStatus;
|
|
63
|
+
alreadyBlocked: boolean;
|
|
64
|
+
}): boolean;
|
|
65
|
+
/** Test seam: forget reported errors and listeners between test cases. */
|
|
66
|
+
export declare function resetSessionExpiryForTests(): void;
|
|
67
|
+
export {};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { isSessionExpiredError } from './auth-errors.js';
|
|
2
|
+
const listeners = new Set();
|
|
3
|
+
/**
|
|
4
|
+
* Subscribe to session-expiry reports (the auth store does, once). Returns the
|
|
5
|
+
* unsubscribe function.
|
|
6
|
+
*/
|
|
7
|
+
export function onSessionExpiry(listener) {
|
|
8
|
+
listeners.add(listener);
|
|
9
|
+
return () => {
|
|
10
|
+
listeners.delete(listener);
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Each expired-session error object is reported at most ONCE. The bus produces
|
|
15
|
+
* a fresh error object per failure, so genuine new failures always report; but
|
|
16
|
+
* a surface can keep rendering a *stale* error long after re-auth (a
|
|
17
|
+
* `persistError` only clears on the next successful save), and remounting that
|
|
18
|
+
* surface must not re-raise the overlay against a session that is valid again.
|
|
19
|
+
*/
|
|
20
|
+
let reportedErrors = new WeakSet();
|
|
21
|
+
/**
|
|
22
|
+
* Report a structured operation failure as a *possible* session expiry: runs
|
|
23
|
+
* the {@link isSessionExpiredError} predicate, dedupes per error instance, and
|
|
24
|
+
* notifies listeners. Returns whether listeners were notified. Non-expiry
|
|
25
|
+
* errors are ignored — callers can funnel every failure through unconditionally.
|
|
26
|
+
*/
|
|
27
|
+
export function reportSessionExpiredError(error) {
|
|
28
|
+
if (!isSessionExpiredError(error))
|
|
29
|
+
return false;
|
|
30
|
+
if (reportedErrors.has(error))
|
|
31
|
+
return false;
|
|
32
|
+
reportedErrors.add(error);
|
|
33
|
+
for (const listener of [...listeners])
|
|
34
|
+
listener();
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Is this operation lifecycle event a session-expiry failure? Only `failed`
|
|
39
|
+
* events can carry the backend's unauthenticated transport blob (`rejected` is
|
|
40
|
+
* input validation, `denied` is the role hook — both structured, never expiry).
|
|
41
|
+
*/
|
|
42
|
+
export function isSessionExpiryEvent(event) {
|
|
43
|
+
return event.status === 'failed' && event.error !== undefined && isSessionExpiredError(event.error);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The one reactive detection seam (EC-403): subscribe to an operations event
|
|
47
|
+
* bus and report every session-expiry failure. Attached per operations bundle
|
|
48
|
+
* in `createStudioOperations`, so commands, queries, and changeset operations
|
|
49
|
+
* are all covered. Returns the unsubscribe function.
|
|
50
|
+
*/
|
|
51
|
+
export function attachSessionExpiryReporting(events) {
|
|
52
|
+
return events.subscribe((event) => {
|
|
53
|
+
if (event.status === 'failed' && event.error !== undefined) {
|
|
54
|
+
reportSessionExpiredError(event.error);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Pure raise-guard for the auth store's `sessionExpired` state: an expiry
|
|
60
|
+
* report blocks the studio only when a live session exists (a failure surfacing
|
|
61
|
+
* while signed out or still restoring must not overlay the sign-in screen),
|
|
62
|
+
* and raising is idempotent while already blocked.
|
|
63
|
+
*/
|
|
64
|
+
export function shouldBlockOnSessionExpiry(input) {
|
|
65
|
+
return input.status === 'signed-in' && !input.alreadyBlocked;
|
|
66
|
+
}
|
|
67
|
+
/** Test seam: forget reported errors and listeners between test cases. */
|
|
68
|
+
export function resetSessionExpiryForTests() {
|
|
69
|
+
reportedErrors = new WeakSet();
|
|
70
|
+
listeners.clear();
|
|
71
|
+
}
|
|
72
|
+
//# sourceMappingURL=session-expiry.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-expiry.js","sourceRoot":"","sources":["../../src/lib/session-expiry.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAA;AAiCrD,MAAM,SAAS,GAAG,IAAI,GAAG,EAAyB,CAAA;AAElD;;;GAGG;AACH,MAAM,UAAU,eAAe,CAAC,QAA+B;IAC7D,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;IACvB,OAAO,GAAG,EAAE;QACV,SAAS,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAA;IAC5B,CAAC,CAAA;AACH,CAAC;AAED;;;;;;GAMG;AACH,IAAI,cAAc,GAAG,IAAI,OAAO,EAAkB,CAAA;AAElD;;;;;GAKG;AACH,MAAM,UAAU,yBAAyB,CAAC,KAAqB;IAC7D,IAAI,CAAC,qBAAqB,CAAC,KAAK,CAAC;QAAE,OAAO,KAAK,CAAA;IAC/C,IAAI,cAAc,CAAC,GAAG,CAAC,KAAK,CAAC;QAAE,OAAO,KAAK,CAAA;IAC3C,cAAc,CAAC,GAAG,CAAC,KAAK,CAAC,CAAA;IACzB,KAAK,MAAM,QAAQ,IAAI,CAAC,GAAG,SAAS,CAAC;QAAE,QAAQ,EAAE,CAAA;IACjD,OAAO,IAAI,CAAA;AACb,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,oBAAoB,CAAC,KAAqB;IACxD,OAAO,KAAK,CAAC,MAAM,KAAK,QAAQ,IAAI,KAAK,CAAC,KAAK,KAAK,SAAS,IAAI,qBAAqB,CAAC,KAAK,CAAC,KAAK,CAAC,CAAA;AACrG,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,4BAA4B,CAAC,MAAyB;IACpE,OAAO,MAAM,CAAC,SAAS,CAAC,CAAC,KAAK,EAAE,EAAE;QAChC,IAAI,KAAK,CAAC,MAAM,KAAK,QAAQ,IAAI,KAAK,CAAC,KAAK,KAAK,SAAS,EAAE,CAAC;YAC3D,yBAAyB,CAAC,KAAK,CAAC,KAAK,CAAC,CAAA;QACxC,CAAC;IACH,CAAC,CAAC,CAAA;AACJ,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,0BAA0B,CAAC,KAG1C;IACC,OAAO,KAAK,CAAC,MAAM,KAAK,WAAW,IAAI,CAAC,KAAK,CAAC,cAAc,CAAA;AAC9D,CAAC;AAED,0EAA0E;AAC1E,MAAM,UAAU,0BAA0B;IACxC,cAAc,GAAG,IAAI,OAAO,EAAkB,CAAA;IAC9C,SAAS,CAAC,KAAK,EAAE,CAAA;AACnB,CAAC","sourcesContent":["import { isSessionExpiredError } from './auth-errors'\nimport type { AuthStatus } from './auth'\nimport type { OperationError, OperationEvent, OperationEventBus } from './operations'\n\n/**\n * App-level session-expiry chokepoint (EC-403).\n *\n * Detection is reactive: an expired Convex session surfaces as a failed\n * operation whose error matches {@link isSessionExpiredError}. Every studio\n * command/query already flows through the operations pipeline, and every\n * failure is emitted on the shared {@link OperationEventBus} — so ONE\n * subscriber on that bus ({@link attachSessionExpiryReporting}, attached in\n * `createStudioOperations`) sees every expiry, no matter which surface issued\n * the operation. Surfaces that hold a structured `OperationError` directly\n * (the shared error banner) report through the same funnel\n * ({@link reportSessionExpiredError}).\n *\n * The consumer is the auth store (`components/auth-context.tsx`): it raises\n * one app-wide `sessionExpired` state which mounts the blocking re-auth\n * overlay — in place, no navigation, the React tree and all unsaved editor\n * state stay alive. This module is deliberately framework-free (a plain\n * listener registry + pure predicates) so the state machine is unit-testable.\n *\n * Proactive re-validation (window focus/interval) deliberately does NOT ship:\n * the auth adapters' `currentSession()` treats *any* failure — including a\n * transient network drop — as \"signed out\" and destroys the stored token, so\n * polling it would sign editors out on a network blip. Distinguishing\n * \"token invalid\" from \"backend unreachable\" needs an adapter change under\n * the no-touch `lib/auth/**` (reported as a finding on EC-403).\n */\n\ntype SessionExpiryListener = () => void\n\nconst listeners = new Set<SessionExpiryListener>()\n\n/**\n * Subscribe to session-expiry reports (the auth store does, once). Returns the\n * unsubscribe function.\n */\nexport function onSessionExpiry(listener: SessionExpiryListener): () => void {\n listeners.add(listener)\n return () => {\n listeners.delete(listener)\n }\n}\n\n/**\n * Each expired-session error object is reported at most ONCE. The bus produces\n * a fresh error object per failure, so genuine new failures always report; but\n * a surface can keep rendering a *stale* error long after re-auth (a\n * `persistError` only clears on the next successful save), and remounting that\n * surface must not re-raise the overlay against a session that is valid again.\n */\nlet reportedErrors = new WeakSet<OperationError>()\n\n/**\n * Report a structured operation failure as a *possible* session expiry: runs\n * the {@link isSessionExpiredError} predicate, dedupes per error instance, and\n * notifies listeners. Returns whether listeners were notified. Non-expiry\n * errors are ignored — callers can funnel every failure through unconditionally.\n */\nexport function reportSessionExpiredError(error: OperationError): boolean {\n if (!isSessionExpiredError(error)) return false\n if (reportedErrors.has(error)) return false\n reportedErrors.add(error)\n for (const listener of [...listeners]) listener()\n return true\n}\n\n/**\n * Is this operation lifecycle event a session-expiry failure? Only `failed`\n * events can carry the backend's unauthenticated transport blob (`rejected` is\n * input validation, `denied` is the role hook — both structured, never expiry).\n */\nexport function isSessionExpiryEvent(event: OperationEvent): boolean {\n return event.status === 'failed' && event.error !== undefined && isSessionExpiredError(event.error)\n}\n\n/**\n * The one reactive detection seam (EC-403): subscribe to an operations event\n * bus and report every session-expiry failure. Attached per operations bundle\n * in `createStudioOperations`, so commands, queries, and changeset operations\n * are all covered. Returns the unsubscribe function.\n */\nexport function attachSessionExpiryReporting(events: OperationEventBus): () => void {\n return events.subscribe((event) => {\n if (event.status === 'failed' && event.error !== undefined) {\n reportSessionExpiredError(event.error)\n }\n })\n}\n\n/**\n * Pure raise-guard for the auth store's `sessionExpired` state: an expiry\n * report blocks the studio only when a live session exists (a failure surfacing\n * while signed out or still restoring must not overlay the sign-in screen),\n * and raising is idempotent while already blocked.\n */\nexport function shouldBlockOnSessionExpiry(input: {\n status: AuthStatus\n alreadyBlocked: boolean\n}): boolean {\n return input.status === 'signed-in' && !input.alreadyBlocked\n}\n\n/** Test seam: forget reported errors and listeners between test cases. */\nexport function resetSessionExpiryForTests(): void {\n reportedErrors = new WeakSet<OperationError>()\n listeners.clear()\n}\n"]}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-view browser tab titles (EC-402). The studio mounts as ONE catch-all
|
|
3
|
+
* splat route, so per-view titles cannot come from per-route `head()` — the
|
|
4
|
+
* shell composes `document.title` from the data it already assembles for the
|
|
5
|
+
* header breadcrumb: `deepest crumb · view label · project name · base title`.
|
|
6
|
+
*
|
|
7
|
+
* This module is the pure core: the composition rule, the parts selector, and
|
|
8
|
+
* the base-title holder. The `document.title` assignment itself is a thin
|
|
9
|
+
* effect in the shell (`StudioTabTitle` in `components/studio-shell.tsx`) —
|
|
10
|
+
* effect-only, so SSR never touches the DOM.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Join title parts with ` · `, dropping empty/whitespace-only segments and
|
|
14
|
+
* adjacent duplicates (e.g. a "Settings" view whose deepest crumb is also
|
|
15
|
+
* "Settings", or a project literally named like the base title). Segments are
|
|
16
|
+
* trimmed; there are never leading, trailing, or doubled separators.
|
|
17
|
+
*/
|
|
18
|
+
export declare function composeTabTitle(parts: ReadonlyArray<string | null | undefined>): string;
|
|
19
|
+
/** The shell state the tab title is derived from — all sources the header already has. */
|
|
20
|
+
export interface TabTitleState {
|
|
21
|
+
/**
|
|
22
|
+
* The contributed breadcrumb trail below the active section, shallowest →
|
|
23
|
+
* deepest (the shell's `useBreadcrumbTrail()` value). Only the DEEPEST
|
|
24
|
+
* segment (collection → document/component title) enters the tab title.
|
|
25
|
+
*/
|
|
26
|
+
breadcrumb: ReadonlyArray<{
|
|
27
|
+
label: string;
|
|
28
|
+
}>;
|
|
29
|
+
/** The active view's resolved (localized) label; absent on unknown views. */
|
|
30
|
+
viewLabel: string | null | undefined;
|
|
31
|
+
/** The active project's display name. */
|
|
32
|
+
projectName: string | null | undefined;
|
|
33
|
+
/** The consumer's base title (see {@link studioBaseTitle}) — branding always lands. */
|
|
34
|
+
baseTitle: string | null | undefined;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The ordered tab-title parts for a shell state:
|
|
38
|
+
* `deepest crumb · view label · project name · base title`.
|
|
39
|
+
* Feed the result to {@link composeTabTitle} (which drops the empty slots).
|
|
40
|
+
*/
|
|
41
|
+
export declare function tabTitleParts(state: TabTitleState): Array<string | null | undefined>;
|
|
42
|
+
/** Register the configured base title (called by `createStudioRootRoute`). */
|
|
43
|
+
export declare function setStudioBaseTitle(title: string): void;
|
|
44
|
+
/**
|
|
45
|
+
* The base title the launcher/sign-in screens keep and composed titles end
|
|
46
|
+
* with. Prefers the registered `createStudioRootRoute` title; a hand-rolled
|
|
47
|
+
* root that never registered one falls back to capturing `document.title`
|
|
48
|
+
* once (before the shell effect first writes it). SSR-safe: never touches
|
|
49
|
+
* `document` unless it exists.
|
|
50
|
+
*/
|
|
51
|
+
export declare function studioBaseTitle(): string;
|
|
52
|
+
/** Test-only: reset both the registered and the captured base title. */
|
|
53
|
+
export declare function resetStudioBaseTitleForTests(): void;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-view browser tab titles (EC-402). The studio mounts as ONE catch-all
|
|
3
|
+
* splat route, so per-view titles cannot come from per-route `head()` — the
|
|
4
|
+
* shell composes `document.title` from the data it already assembles for the
|
|
5
|
+
* header breadcrumb: `deepest crumb · view label · project name · base title`.
|
|
6
|
+
*
|
|
7
|
+
* This module is the pure core: the composition rule, the parts selector, and
|
|
8
|
+
* the base-title holder. The `document.title` assignment itself is a thin
|
|
9
|
+
* effect in the shell (`StudioTabTitle` in `components/studio-shell.tsx`) —
|
|
10
|
+
* effect-only, so SSR never touches the DOM.
|
|
11
|
+
*/
|
|
12
|
+
const SEPARATOR = ' · ';
|
|
13
|
+
/**
|
|
14
|
+
* Join title parts with ` · `, dropping empty/whitespace-only segments and
|
|
15
|
+
* adjacent duplicates (e.g. a "Settings" view whose deepest crumb is also
|
|
16
|
+
* "Settings", or a project literally named like the base title). Segments are
|
|
17
|
+
* trimmed; there are never leading, trailing, or doubled separators.
|
|
18
|
+
*/
|
|
19
|
+
export function composeTabTitle(parts) {
|
|
20
|
+
const segments = [];
|
|
21
|
+
for (const part of parts) {
|
|
22
|
+
const trimmed = part?.trim();
|
|
23
|
+
if (!trimmed)
|
|
24
|
+
continue;
|
|
25
|
+
if (segments[segments.length - 1] === trimmed)
|
|
26
|
+
continue;
|
|
27
|
+
segments.push(trimmed);
|
|
28
|
+
}
|
|
29
|
+
return segments.join(SEPARATOR);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The ordered tab-title parts for a shell state:
|
|
33
|
+
* `deepest crumb · view label · project name · base title`.
|
|
34
|
+
* Feed the result to {@link composeTabTitle} (which drops the empty slots).
|
|
35
|
+
*/
|
|
36
|
+
export function tabTitleParts(state) {
|
|
37
|
+
const deepestCrumb = state.breadcrumb.length
|
|
38
|
+
? state.breadcrumb[state.breadcrumb.length - 1].label
|
|
39
|
+
: undefined;
|
|
40
|
+
return [deepestCrumb, state.viewLabel, state.projectName, state.baseTitle];
|
|
41
|
+
}
|
|
42
|
+
// --- Base title holder -------------------------------------------------------
|
|
43
|
+
//
|
|
44
|
+
// The base title is the `createStudioRootRoute({ title })` option — the one the
|
|
45
|
+
// root route renders via `head()`/`HeadContent`. The factory registers it here
|
|
46
|
+
// (same module-holder pattern as `setActiveProjectSlug` in `lib/workspace`), so
|
|
47
|
+
// the shell effect reads the EXACT configured value with no timing dependency:
|
|
48
|
+
// capturing `document.title` at shell mount instead would race the router's own
|
|
49
|
+
// head effect (the shell's effect fires before the root's on a client render).
|
|
50
|
+
// `StudioRootRouteOptions` stays untouched.
|
|
51
|
+
let configuredBaseTitle = null;
|
|
52
|
+
let capturedBaseTitle = null;
|
|
53
|
+
/** Register the configured base title (called by `createStudioRootRoute`). */
|
|
54
|
+
export function setStudioBaseTitle(title) {
|
|
55
|
+
configuredBaseTitle = title;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The base title the launcher/sign-in screens keep and composed titles end
|
|
59
|
+
* with. Prefers the registered `createStudioRootRoute` title; a hand-rolled
|
|
60
|
+
* root that never registered one falls back to capturing `document.title`
|
|
61
|
+
* once (before the shell effect first writes it). SSR-safe: never touches
|
|
62
|
+
* `document` unless it exists.
|
|
63
|
+
*/
|
|
64
|
+
export function studioBaseTitle() {
|
|
65
|
+
if (configuredBaseTitle !== null)
|
|
66
|
+
return configuredBaseTitle;
|
|
67
|
+
if (capturedBaseTitle === null && typeof document !== 'undefined') {
|
|
68
|
+
capturedBaseTitle = document.title;
|
|
69
|
+
}
|
|
70
|
+
return capturedBaseTitle ?? '';
|
|
71
|
+
}
|
|
72
|
+
/** Test-only: reset both the registered and the captured base title. */
|
|
73
|
+
export function resetStudioBaseTitleForTests() {
|
|
74
|
+
configuredBaseTitle = null;
|
|
75
|
+
capturedBaseTitle = null;
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=tab-title.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tab-title.js","sourceRoot":"","sources":["../../src/lib/tab-title.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,MAAM,SAAS,GAAG,KAAK,CAAA;AAEvB;;;;;GAKG;AACH,MAAM,UAAU,eAAe,CAAC,KAA+C;IAC7E,MAAM,QAAQ,GAAa,EAAE,CAAA;IAC7B,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,MAAM,OAAO,GAAG,IAAI,EAAE,IAAI,EAAE,CAAA;QAC5B,IAAI,CAAC,OAAO;YAAE,SAAQ;QACtB,IAAI,QAAQ,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAC,KAAK,OAAO;YAAE,SAAQ;QACvD,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,CAAA;IACxB,CAAC;IACD,OAAO,QAAQ,CAAC,IAAI,CAAC,SAAS,CAAC,CAAA;AACjC,CAAC;AAkBD;;;;GAIG;AACH,MAAM,UAAU,aAAa,CAAC,KAAoB;IAChD,MAAM,YAAY,GAAG,KAAK,CAAC,UAAU,CAAC,MAAM;QAC1C,CAAC,CAAC,KAAK,CAAC,UAAU,CAAC,KAAK,CAAC,UAAU,CAAC,MAAM,GAAG,CAAC,CAAE,CAAC,KAAK;QACtD,CAAC,CAAC,SAAS,CAAA;IACb,OAAO,CAAC,YAAY,EAAE,KAAK,CAAC,SAAS,EAAE,KAAK,CAAC,WAAW,EAAE,KAAK,CAAC,SAAS,CAAC,CAAA;AAC5E,CAAC;AAED,gFAAgF;AAChF,EAAE;AACF,gFAAgF;AAChF,+EAA+E;AAC/E,gFAAgF;AAChF,+EAA+E;AAC/E,gFAAgF;AAChF,+EAA+E;AAC/E,4CAA4C;AAE5C,IAAI,mBAAmB,GAAkB,IAAI,CAAA;AAC7C,IAAI,iBAAiB,GAAkB,IAAI,CAAA;AAE3C,8EAA8E;AAC9E,MAAM,UAAU,kBAAkB,CAAC,KAAa;IAC9C,mBAAmB,GAAG,KAAK,CAAA;AAC7B,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,eAAe;IAC7B,IAAI,mBAAmB,KAAK,IAAI;QAAE,OAAO,mBAAmB,CAAA;IAC5D,IAAI,iBAAiB,KAAK,IAAI,IAAI,OAAO,QAAQ,KAAK,WAAW,EAAE,CAAC;QAClE,iBAAiB,GAAG,QAAQ,CAAC,KAAK,CAAA;IACpC,CAAC;IACD,OAAO,iBAAiB,IAAI,EAAE,CAAA;AAChC,CAAC;AAED,wEAAwE;AACxE,MAAM,UAAU,4BAA4B;IAC1C,mBAAmB,GAAG,IAAI,CAAA;IAC1B,iBAAiB,GAAG,IAAI,CAAA;AAC1B,CAAC","sourcesContent":["/**\n * Per-view browser tab titles (EC-402). The studio mounts as ONE catch-all\n * splat route, so per-view titles cannot come from per-route `head()` — the\n * shell composes `document.title` from the data it already assembles for the\n * header breadcrumb: `deepest crumb · view label · project name · base title`.\n *\n * This module is the pure core: the composition rule, the parts selector, and\n * the base-title holder. The `document.title` assignment itself is a thin\n * effect in the shell (`StudioTabTitle` in `components/studio-shell.tsx`) —\n * effect-only, so SSR never touches the DOM.\n */\n\nconst SEPARATOR = ' · '\n\n/**\n * Join title parts with ` · `, dropping empty/whitespace-only segments and\n * adjacent duplicates (e.g. a \"Settings\" view whose deepest crumb is also\n * \"Settings\", or a project literally named like the base title). Segments are\n * trimmed; there are never leading, trailing, or doubled separators.\n */\nexport function composeTabTitle(parts: ReadonlyArray<string | null | undefined>): string {\n const segments: string[] = []\n for (const part of parts) {\n const trimmed = part?.trim()\n if (!trimmed) continue\n if (segments[segments.length - 1] === trimmed) continue\n segments.push(trimmed)\n }\n return segments.join(SEPARATOR)\n}\n\n/** The shell state the tab title is derived from — all sources the header already has. */\nexport interface TabTitleState {\n /**\n * The contributed breadcrumb trail below the active section, shallowest →\n * deepest (the shell's `useBreadcrumbTrail()` value). Only the DEEPEST\n * segment (collection → document/component title) enters the tab title.\n */\n breadcrumb: ReadonlyArray<{ label: string }>\n /** The active view's resolved (localized) label; absent on unknown views. */\n viewLabel: string | null | undefined\n /** The active project's display name. */\n projectName: string | null | undefined\n /** The consumer's base title (see {@link studioBaseTitle}) — branding always lands. */\n baseTitle: string | null | undefined\n}\n\n/**\n * The ordered tab-title parts for a shell state:\n * `deepest crumb · view label · project name · base title`.\n * Feed the result to {@link composeTabTitle} (which drops the empty slots).\n */\nexport function tabTitleParts(state: TabTitleState): Array<string | null | undefined> {\n const deepestCrumb = state.breadcrumb.length\n ? state.breadcrumb[state.breadcrumb.length - 1]!.label\n : undefined\n return [deepestCrumb, state.viewLabel, state.projectName, state.baseTitle]\n}\n\n// --- Base title holder -------------------------------------------------------\n//\n// The base title is the `createStudioRootRoute({ title })` option — the one the\n// root route renders via `head()`/`HeadContent`. The factory registers it here\n// (same module-holder pattern as `setActiveProjectSlug` in `lib/workspace`), so\n// the shell effect reads the EXACT configured value with no timing dependency:\n// capturing `document.title` at shell mount instead would race the router's own\n// head effect (the shell's effect fires before the root's on a client render).\n// `StudioRootRouteOptions` stays untouched.\n\nlet configuredBaseTitle: string | null = null\nlet capturedBaseTitle: string | null = null\n\n/** Register the configured base title (called by `createStudioRootRoute`). */\nexport function setStudioBaseTitle(title: string): void {\n configuredBaseTitle = title\n}\n\n/**\n * The base title the launcher/sign-in screens keep and composed titles end\n * with. Prefers the registered `createStudioRootRoute` title; a hand-rolled\n * root that never registered one falls back to capturing `document.title`\n * once (before the shell effect first writes it). SSR-safe: never touches\n * `document` unless it exists.\n */\nexport function studioBaseTitle(): string {\n if (configuredBaseTitle !== null) return configuredBaseTitle\n if (capturedBaseTitle === null && typeof document !== 'undefined') {\n capturedBaseTitle = document.title\n }\n return capturedBaseTitle ?? ''\n}\n\n/** Test-only: reset both the registered and the captured base title. */\nexport function resetStudioBaseTitleForTests(): void {\n configuredBaseTitle = null\n capturedBaseTitle = null\n}\n"]}
|
|
@@ -5,7 +5,7 @@ import type { CustomFieldRenderer, StudioConfig } from '../../components/app-vie
|
|
|
5
5
|
import type { WorkspaceState } from './workspace-source.js';
|
|
6
6
|
export { resolveWorkspaceState } from './workspace-source.js';
|
|
7
7
|
export type { WorkspaceSources, WorkspaceState } from './workspace-source.js';
|
|
8
|
-
export { persistedProjects, selectSwitcherProjects, unprovisionedProjects } from './switcher-source.js';
|
|
8
|
+
export { isSingleProjectWorkspace, launcherAutoForwardTarget, persistedProjects, selectSwitcherProjects, soleWorkspaceProject, unprovisionedProjects, } from './switcher-source.js';
|
|
9
9
|
export type { SwitcherEntry, SwitcherSourceInput } from './switcher-source.js';
|
|
10
10
|
/**
|
|
11
11
|
* Mount the studio against a project config (EC-223 mount contract) and an
|
|
@@ -2,7 +2,7 @@ import { frontendUrlForEnvironment } from '@elytracms/core/studio-core';
|
|
|
2
2
|
import { provideStudioEnv } from '../persistence/backend-mode.js';
|
|
3
3
|
import { resolveWorkspaceState } from './workspace-source.js';
|
|
4
4
|
export { resolveWorkspaceState } from './workspace-source.js';
|
|
5
|
-
export { persistedProjects, selectSwitcherProjects, unprovisionedProjects } from './switcher-source.js';
|
|
5
|
+
export { isSingleProjectWorkspace, launcherAutoForwardTarget, persistedProjects, selectSwitcherProjects, soleWorkspaceProject, unprovisionedProjects, } from './switcher-source.js';
|
|
6
6
|
let cached = null;
|
|
7
7
|
let studioConfig = null;
|
|
8
8
|
export function createStudio(config, studio, options) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/lib/workspace/index.ts"],"names":[],"mappings":"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","sourcesContent":["import { frontendUrlForEnvironment } from '@elytracms/core/studio-core'\nimport type {\n BreakpointConfig,\n ElytraConfigInput,\n ResolvedWorkspaceProject,\n} from '@elytracms/core/studio-core'\nimport type { CollectionDef, LocaleConfig, RouteRecord } from '@elytracms/core/cms-core'\nimport type { SidebarNode } from '../../components/app-views/module'\nimport type { CustomFieldRenderer, StudioConfig } from '../../components/app-views/studio-config'\nimport { provideStudioEnv } from '../persistence/backend-mode'\nimport { resolveWorkspaceState } from './workspace-source'\nimport type { WorkspaceState } from './workspace-source'\n\nexport { resolveWorkspaceState } from './workspace-source'\nexport type { WorkspaceSources, WorkspaceState } from './workspace-source'\nexport { persistedProjects, selectSwitcherProjects, unprovisionedProjects } from './switcher-source'\nexport type { SwitcherEntry, SwitcherSourceInput } from './switcher-source'\n\nlet cached: WorkspaceState | null = null\nlet studioConfig: StudioConfig | null = null\n\n/**\n * Mount the studio against a project config (EC-223 mount contract) and an\n * optional studio-only customization config (EC-236). The consumer's entry point\n * does `import config from './elytra.config'` + `import studio from\n * './studio.config'` and calls this — an explicit init, NOT an `import.meta.glob`\n * autoload that reaches into the consumer's filesystem (Oli, 2026-06-19: \"there's\n * always an entry point that can trigger a function call\").\n *\n * Two configs, deliberately: `config` (`elytra.config.ts`) is the project\n * definition, shared with the `frontend/` delivery bundle and projected to data;\n * `studio` (`studio.config.tsx` via `defineStudio`) is live builder UI code — the\n * section rail, custom field renderers, branding — that must NEVER reach delivery.\n * Keeping them separate is the customization boundary principle in the mount\n * signature. Seeds the workspace state + the studio-config holder every later\n * query (`getActiveProjectSlug`, `resolveDeployment*`) reads.\n *\n * EC-357 — `options.env`: the consumer's `import.meta.env`, captured HERE because\n * the entry module is code Vite always transforms live. The engine's own\n * `import.meta.env` reads are unreliable inside dep-optimized package code (Vite ≥8\n * omits custom `VITE_*` values there; Vite 7 froze them at optimize time), which\n * silently broke backend URL / mode / dev-login resolution in real consumers.\n * Always pass it: `createStudio(config, studioConfig, { env: import.meta.env })`.\n */\nexport interface StudioMountOptions {\n /** The consumer entry's `import.meta.env` (see `provideStudioEnv` for the contract). */\n env?: Record<string, unknown>\n}\n\nexport function createStudio(\n config: ElytraConfigInput,\n studio?: StudioConfig,\n options?: StudioMountOptions,\n): void {\n provideStudioEnv(options?.env)\n cached = resolveWorkspaceState({\n fileModule: { default: config },\n fileName: 'elytra.config.ts',\n })\n studioConfig = studio ?? null\n}\n\n/** Test seam: set/clear the studio customization config. Never used by app code. */\nexport function setStudioConfigForTests(next: StudioConfig | null): void {\n studioConfig = next\n}\n\n/**\n * The active project slug (EC-179, vision AD-8). One project per backend, so the\n * active project selects which Convex deployment the studio talks to. The root\n * layout sets this from the URL (`/<slug>`) — or, on the sign-in screen, from\n * the `redirect` target's slug — before the auth/persistence adapters are\n * selected, so the *initial* sign-in authenticates against the project the user\n * is heading for (Sanity-style: authenticate to one workspace, then the full\n * config-based switcher is available). `null` at the launcher → the default\n * (first) configured project's deployment.\n */\nlet activeProjectSlug: string | null = null\n\n/** Set the active project slug; returns whether the resolved deployment changed. */\nexport function setActiveProjectSlug(slug: string | null): boolean {\n const before = resolveDeploymentUrl('')\n activeProjectSlug = slug && slug.trim() !== '' ? slug : null\n return resolveDeploymentUrl('') !== before\n}\n\nexport function getActiveProjectSlug(): string | null {\n return activeProjectSlug\n}\n\n/** Test seam: reset the active project slug. Never used by app code. */\nexport function setActiveProjectSlugForTests(slug: string | null): void {\n activeProjectSlug = slug\n}\n\n/**\n * Reserved first-path segments that are NOT project slugs: the studio's own\n * root routes (EC-220). Project surfaces now live at `/<slug>/...` (no `/p/`\n * prefix), so the studio's root namespace is shared between project slugs and\n * these fixed routes — they must be excluded from slug extraction. Keep in\n * sync with the non-`$projectId` files in `src/routes/`; the launcher (`/`) is\n * the empty segment, excluded by the regex requiring one path char.\n */\nconst RESERVED_ROOT_SEGMENTS: ReadonlySet<string> = new Set(['sign-in', 'styleguide'])\n\n/** Extract a project slug from a studio path (`/<slug>/...`), else `null`. */\nexport function projectSlugFromPath(pathname: string): string | null {\n const match = /^\\/([^/?#]+)/.exec(pathname)\n if (!match) return null\n const segment = decodeURIComponent(match[1]!)\n return RESERVED_ROOT_SEGMENTS.has(segment) ? null : segment\n}\n\n/**\n * The project whose backend the studio should bind for a given location\n * (EC-179). A project path (`/<slug>`) binds that project; the sign-in screen\n * binds the project from its `redirect` target so the *initial* sign-in\n * authenticates against the backend the user is heading for (Sanity-style).\n * Anywhere else (the launcher) → `null`, i.e. the default/first project.\n */\nexport function activeSlugForLocation(pathname: string, redirect?: string): string | null {\n const direct = projectSlugFromPath(pathname)\n if (direct) return direct\n if (pathname === '/sign-in' && redirect) return projectSlugFromPath(redirect)\n return null\n}\n\n/** The active config project (by slug), or the first configured project. */\nfunction activeConfigProject() {\n const state = getWorkspaceState()\n if (state.kind !== 'config') return undefined\n if (activeProjectSlug) {\n const match = state.projects.find((p) => p.slug === activeProjectSlug)\n if (match) return match\n }\n return state.projects[0]\n}\n\n/**\n * The studio's resolved workspace state. Deterministic per boot: the config is\n * static input (bundled file or deployment env), so it is resolved once and\n * cached — every consumer (gate, switcher, launcher) sees the same state.\n */\nexport function getWorkspaceState(): WorkspaceState {\n // Seeded by `createStudio(config)` at the entry point. Until then — or when the\n // consumer wires no config — resolve to the empty `none` state (the single-project\n // golden path), rather than reaching for the filesystem.\n if (!cached) cached = resolveWorkspaceState({})\n return cached\n}\n\n/** Test seam: clear/replace the cached state. Never used by app code. */\nexport function setWorkspaceStateForTests(next: WorkspaceState | null): void {\n cached = next\n}\n\n/**\n * Resolve the Convex deployment URL the studio should connect to (vision AD-8:\n * the backend is wired in config). One project per backend, so the **active**\n * project (see {@link setActiveProjectSlug}) selects the deployment via its\n * `convex.deployment` (or its production environment's). At the launcher (no\n * active project) the first configured project is the default backend. Absent\n * config — or a project without a wired deployment — falls back to\n * `VITE_CONVEX_URL` (`fallbackUrl`) for local single-deployment development.\n */\nexport function resolveDeploymentUrl(fallbackUrl: string): string {\n return projectDeploymentUrl(activeConfigProject(), fallbackUrl)\n}\n\n/** The deployment URL for a specific project slug (per-backend auth checks). */\nexport function deploymentUrlForSlug(slug: string, fallbackUrl: string): string {\n const state = getWorkspaceState()\n const project = state.kind === 'config' ? state.projects.find((p) => p.slug === slug) : undefined\n return projectDeploymentUrl(project, fallbackUrl)\n}\n\n/** A project's wired deployment: per-environment ref → project ref → env fallback. */\nfunction projectDeploymentUrl(\n project: ResolvedWorkspaceProject | undefined,\n fallbackUrl: string,\n): string {\n if (!project) return fallbackUrl\n const envDeployment = project.environments.find((e) => e.convex?.deployment)?.convex?.deployment\n return envDeployment ?? project.convex?.deployment ?? fallbackUrl\n}\n\n/**\n * The slug of the project bound to the active deployment (EC-179). One project\n * per backend, so the active config project's slug identifies the deployment's\n * one project; the Convex adapter stamps it onto deployment-scoped records.\n * Absent config falls back to `fallbackId` (used only when wired by env alone).\n */\nexport function resolveDeploymentProjectId(fallbackId: string): string {\n return activeConfigProject()?.slug ?? fallbackId\n}\n\n/**\n * The CMS collections of the active config project (AD-11, EC-183). CMS\n * collection schemas are config-owned: the studio reads the model from\n * `elytra.config.ts` (served through the persistence port — the backend has no\n * `cmsSchemas` table). `[]` when the active project declares none, or when the\n * studio is wired by env alone with no config.\n */\nexport function resolveDeploymentCollections(): CollectionDef[] {\n return activeConfigProject()?.collections ?? []\n}\n\n/**\n * The canvas preview breakpoints of the active config project (EC-250).\n * Config-sourced (config-as-code, derived from the site's Tailwind `screens`);\n * `undefined` when the project declares none — the canvas then falls back to\n * Tailwind's default screens (see `resolveCanvasViewports`), never a baked-in triple.\n */\nexport function resolveDeploymentBreakpoints(): BreakpointConfig[] | undefined {\n return activeConfigProject()?.breakpoints\n}\n\n/**\n * The custom field renderers from the studio config (EC-238): control name →\n * component. Studio-only live code (from `defineStudio`, never the project schema\n * or the delivery bundle); `undefined` when none — the document editor then uses\n * its built-in control for every field. Studio-wide (not per-project): the rail and\n * renderers customize the studio, not one backend.\n */\nexport function resolveDeploymentFieldRenderers(): Record<string, CustomFieldRenderer> | undefined {\n return studioConfig?.fieldRenderers\n}\n\n/**\n * The studio-UI localization config from the studio config (EC-241): the default\n * locale + imported translation catalogs. Studio-only live code (from `defineStudio`,\n * never the project schema or the delivery bundle); `undefined` when none — the\n * studio then renders its built-in English chrome. Studio-wide, like the rail and\n * field renderers (localizes the studio, not one backend).\n */\nexport function resolveDeploymentI18n(): StudioConfig['i18n'] {\n return studioConfig?.i18n\n}\n\n/**\n * The AI platform config from the studio config (EC-331): provider, knowledge, and\n * limits. Studio-only live code (from `defineStudio`, never the delivery bundle);\n * `undefined` when no `ai` block is configured — the feature is then fully absent\n * (the AI subsystem never boots, so `@elytracms/studio/lib/ai`'s runtime — and the\n * `ai`/`@ai-sdk/*` SDK behind it — is never imported). Studio-wide, like the rail\n * and field renderers.\n */\nexport function resolveDeploymentAi(): StudioConfig['ai'] {\n return studioConfig?.ai\n}\n\n/**\n * The configurable section rail from the studio config (EC-237): an ordered list of\n * `nav.*` nodes (views / groups / separators). Studio-only live code (carries view\n * objects + icons); `undefined` when none — the studio then derives the default\n * rail from its built-in views. Invalid entries surface as visible issues at\n * resolution, never silent.\n */\nexport function resolveDeploymentSidebar(): SidebarNode[] | undefined {\n return studioConfig?.sidebar\n}\n\n/**\n * The canvas page-surface theme from the studio config (EC-389): body classes +\n * raw CSS for the preview iframe, so the canvas wears the SITE's chrome. Studio-only\n * (from `defineStudio`, never the delivery bundle); `undefined` when none — the\n * canvas keeps its built-in defaults (`bg-background text-foreground font-sans`\n * body, kit `tokensCss` only). Studio-wide, like the rail and field renderers.\n */\nexport function resolveDeploymentCanvasTheme(): StudioConfig['canvas'] {\n return studioConfig?.canvas\n}\n\n/**\n * The resolved URL routes of the active config project (EC-207, \"routes as\n * code\"). Routes are config-sourced: the studio reads the materialized\n * `RouteRecord[]` from the active `elytra.config.ts` project — never from the\n * backend (the `routes` system collection is retired). The Routes surface and\n * the per-page URL preview both read through here. `[]` when the active project\n * declares none, or when the studio is wired by env alone with no config.\n */\nexport function resolveDeploymentRoutes(): RouteRecord[] {\n return activeConfigProject()?.routes ?? []\n}\n\n// Redirects are editorial DATA now (EC-303), managed in the studio Redirects rail\n// and served dynamically — no longer resolved from config. `resolveDeploymentRedirects`\n// is retired; static/infra redirects are the dev's job via native next.config/vercel.\n\n/**\n * The configured locales of the active config project (AD-8). The same locale\n * set the host stamps onto its per-locale cache tags — used by the revalidation\n * webhook layer (EC-192) so a publish always invalidates EVERY configured locale\n * (e.g. `/de`, `/fr`), never just the default. `[]` when wired by env alone.\n */\nexport function resolveDeploymentLocales(): string[] {\n return activeConfigProject()?.locales ?? []\n}\n\n/**\n * The active config project's locales as a `LocaleConfig` (EC-357 finding #3).\n * Locales are structure and structure is code: when `elytra.config.ts` declares\n * them, they drive the studio's ambient locale switcher in EVERY mode — the CMS\n * surface must not fall back to the sample `en/de` fixture (playground, or a\n * connected first run with nothing persisted) when the config says otherwise.\n * `undefined` when no config project is active or it declares no locales (env-only\n * wiring keeps the existing kit/sample fallbacks). A missing/invalid explicit\n * default falls back to the first declared locale.\n */\nexport function resolveDeploymentLocaleConfig(): LocaleConfig | undefined {\n const project = activeConfigProject()\n if (!project || project.locales.length === 0) return undefined\n const fallback = project.locales[0]!\n const explicit = project.defaultLocale\n return {\n default: explicit && project.locales.includes(explicit) ? explicit : fallback,\n locales: [...project.locales],\n }\n}\n\n/**\n * The active project's components reference (EC-194, AD-11): where the consuming\n * repo's OWN real components live (`{ package }` or `{ dir }`). When set, the\n * studio registry composes with the repo's components instead of the built-in\n * demo fixtures — so the page builder edits the repo's actual component library.\n * `undefined` → the project ships no code components; the studio falls back to the\n * golden-path fixtures.\n */\nexport function resolveDeploymentComponents() {\n return activeConfigProject()?.components\n}\n\n/**\n * The active project's public site URL (EC-215): the live site's base URL the\n * studio links to (a document's live URL + open-live button, EC-216). Resolved\n * in lockstep with {@link resolveDeploymentUrl} — the environment bound to the\n * active deployment (the first with a wired deployment) provides the URL, falling\n * back to the project-level `frontendUrl`. No active-environment selector exists\n * yet, so the bound environment is the active one. `undefined` when the active\n * project (or env-only wiring) configures no frontend URL.\n */\nexport function resolveDeploymentFrontendUrl(): string | undefined {\n const project = activeConfigProject()\n if (!project) return undefined\n const boundEnv = project.environments.find((e) => e.convex?.deployment)\n return frontendUrlForEnvironment(project, boundEnv?.key)\n}\n"]}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/lib/workspace/index.ts"],"names":[],"mappings":"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","sourcesContent":["import { frontendUrlForEnvironment } from '@elytracms/core/studio-core'\nimport type {\n BreakpointConfig,\n ElytraConfigInput,\n ResolvedWorkspaceProject,\n} from '@elytracms/core/studio-core'\nimport type { CollectionDef, LocaleConfig, RouteRecord } from '@elytracms/core/cms-core'\nimport type { SidebarNode } from '../../components/app-views/module'\nimport type { CustomFieldRenderer, StudioConfig } from '../../components/app-views/studio-config'\nimport { provideStudioEnv } from '../persistence/backend-mode'\nimport { resolveWorkspaceState } from './workspace-source'\nimport type { WorkspaceState } from './workspace-source'\n\nexport { resolveWorkspaceState } from './workspace-source'\nexport type { WorkspaceSources, WorkspaceState } from './workspace-source'\nexport {\n isSingleProjectWorkspace,\n launcherAutoForwardTarget,\n persistedProjects,\n selectSwitcherProjects,\n soleWorkspaceProject,\n unprovisionedProjects,\n} from './switcher-source'\nexport type { SwitcherEntry, SwitcherSourceInput } from './switcher-source'\n\nlet cached: WorkspaceState | null = null\nlet studioConfig: StudioConfig | null = null\n\n/**\n * Mount the studio against a project config (EC-223 mount contract) and an\n * optional studio-only customization config (EC-236). The consumer's entry point\n * does `import config from './elytra.config'` + `import studio from\n * './studio.config'` and calls this — an explicit init, NOT an `import.meta.glob`\n * autoload that reaches into the consumer's filesystem (Oli, 2026-06-19: \"there's\n * always an entry point that can trigger a function call\").\n *\n * Two configs, deliberately: `config` (`elytra.config.ts`) is the project\n * definition, shared with the `frontend/` delivery bundle and projected to data;\n * `studio` (`studio.config.tsx` via `defineStudio`) is live builder UI code — the\n * section rail, custom field renderers, branding — that must NEVER reach delivery.\n * Keeping them separate is the customization boundary principle in the mount\n * signature. Seeds the workspace state + the studio-config holder every later\n * query (`getActiveProjectSlug`, `resolveDeployment*`) reads.\n *\n * EC-357 — `options.env`: the consumer's `import.meta.env`, captured HERE because\n * the entry module is code Vite always transforms live. The engine's own\n * `import.meta.env` reads are unreliable inside dep-optimized package code (Vite ≥8\n * omits custom `VITE_*` values there; Vite 7 froze them at optimize time), which\n * silently broke backend URL / mode / dev-login resolution in real consumers.\n * Always pass it: `createStudio(config, studioConfig, { env: import.meta.env })`.\n */\nexport interface StudioMountOptions {\n /** The consumer entry's `import.meta.env` (see `provideStudioEnv` for the contract). */\n env?: Record<string, unknown>\n}\n\nexport function createStudio(\n config: ElytraConfigInput,\n studio?: StudioConfig,\n options?: StudioMountOptions,\n): void {\n provideStudioEnv(options?.env)\n cached = resolveWorkspaceState({\n fileModule: { default: config },\n fileName: 'elytra.config.ts',\n })\n studioConfig = studio ?? null\n}\n\n/** Test seam: set/clear the studio customization config. Never used by app code. */\nexport function setStudioConfigForTests(next: StudioConfig | null): void {\n studioConfig = next\n}\n\n/**\n * The active project slug (EC-179, vision AD-8). One project per backend, so the\n * active project selects which Convex deployment the studio talks to. The root\n * layout sets this from the URL (`/<slug>`) — or, on the sign-in screen, from\n * the `redirect` target's slug — before the auth/persistence adapters are\n * selected, so the *initial* sign-in authenticates against the project the user\n * is heading for (Sanity-style: authenticate to one workspace, then the full\n * config-based switcher is available). `null` at the launcher → the default\n * (first) configured project's deployment.\n */\nlet activeProjectSlug: string | null = null\n\n/** Set the active project slug; returns whether the resolved deployment changed. */\nexport function setActiveProjectSlug(slug: string | null): boolean {\n const before = resolveDeploymentUrl('')\n activeProjectSlug = slug && slug.trim() !== '' ? slug : null\n return resolveDeploymentUrl('') !== before\n}\n\nexport function getActiveProjectSlug(): string | null {\n return activeProjectSlug\n}\n\n/** Test seam: reset the active project slug. Never used by app code. */\nexport function setActiveProjectSlugForTests(slug: string | null): void {\n activeProjectSlug = slug\n}\n\n/**\n * Reserved first-path segments that are NOT project slugs: the studio's own\n * root routes (EC-220). Project surfaces now live at `/<slug>/...` (no `/p/`\n * prefix), so the studio's root namespace is shared between project slugs and\n * these fixed routes — they must be excluded from slug extraction. Keep in\n * sync with the non-`$projectId` files in `src/routes/`; the launcher (`/`) is\n * the empty segment, excluded by the regex requiring one path char.\n */\nconst RESERVED_ROOT_SEGMENTS: ReadonlySet<string> = new Set(['sign-in', 'styleguide'])\n\n/** Extract a project slug from a studio path (`/<slug>/...`), else `null`. */\nexport function projectSlugFromPath(pathname: string): string | null {\n const match = /^\\/([^/?#]+)/.exec(pathname)\n if (!match) return null\n const segment = decodeURIComponent(match[1]!)\n return RESERVED_ROOT_SEGMENTS.has(segment) ? null : segment\n}\n\n/**\n * The project whose backend the studio should bind for a given location\n * (EC-179). A project path (`/<slug>`) binds that project; the sign-in screen\n * binds the project from its `redirect` target so the *initial* sign-in\n * authenticates against the backend the user is heading for (Sanity-style).\n * Anywhere else (the launcher) → `null`, i.e. the default/first project.\n */\nexport function activeSlugForLocation(pathname: string, redirect?: string): string | null {\n const direct = projectSlugFromPath(pathname)\n if (direct) return direct\n if (pathname === '/sign-in' && redirect) return projectSlugFromPath(redirect)\n return null\n}\n\n/** The active config project (by slug), or the first configured project. */\nfunction activeConfigProject() {\n const state = getWorkspaceState()\n if (state.kind !== 'config') return undefined\n if (activeProjectSlug) {\n const match = state.projects.find((p) => p.slug === activeProjectSlug)\n if (match) return match\n }\n return state.projects[0]\n}\n\n/**\n * The studio's resolved workspace state. Deterministic per boot: the config is\n * static input (bundled file or deployment env), so it is resolved once and\n * cached — every consumer (gate, switcher, launcher) sees the same state.\n */\nexport function getWorkspaceState(): WorkspaceState {\n // Seeded by `createStudio(config)` at the entry point. Until then — or when the\n // consumer wires no config — resolve to the empty `none` state (the single-project\n // golden path), rather than reaching for the filesystem.\n if (!cached) cached = resolveWorkspaceState({})\n return cached\n}\n\n/** Test seam: clear/replace the cached state. Never used by app code. */\nexport function setWorkspaceStateForTests(next: WorkspaceState | null): void {\n cached = next\n}\n\n/**\n * Resolve the Convex deployment URL the studio should connect to (vision AD-8:\n * the backend is wired in config). One project per backend, so the **active**\n * project (see {@link setActiveProjectSlug}) selects the deployment via its\n * `convex.deployment` (or its production environment's). At the launcher (no\n * active project) the first configured project is the default backend. Absent\n * config — or a project without a wired deployment — falls back to\n * `VITE_CONVEX_URL` (`fallbackUrl`) for local single-deployment development.\n */\nexport function resolveDeploymentUrl(fallbackUrl: string): string {\n return projectDeploymentUrl(activeConfigProject(), fallbackUrl)\n}\n\n/** The deployment URL for a specific project slug (per-backend auth checks). */\nexport function deploymentUrlForSlug(slug: string, fallbackUrl: string): string {\n const state = getWorkspaceState()\n const project = state.kind === 'config' ? state.projects.find((p) => p.slug === slug) : undefined\n return projectDeploymentUrl(project, fallbackUrl)\n}\n\n/** A project's wired deployment: per-environment ref → project ref → env fallback. */\nfunction projectDeploymentUrl(\n project: ResolvedWorkspaceProject | undefined,\n fallbackUrl: string,\n): string {\n if (!project) return fallbackUrl\n const envDeployment = project.environments.find((e) => e.convex?.deployment)?.convex?.deployment\n return envDeployment ?? project.convex?.deployment ?? fallbackUrl\n}\n\n/**\n * The slug of the project bound to the active deployment (EC-179). One project\n * per backend, so the active config project's slug identifies the deployment's\n * one project; the Convex adapter stamps it onto deployment-scoped records.\n * Absent config falls back to `fallbackId` (used only when wired by env alone).\n */\nexport function resolveDeploymentProjectId(fallbackId: string): string {\n return activeConfigProject()?.slug ?? fallbackId\n}\n\n/**\n * The CMS collections of the active config project (AD-11, EC-183). CMS\n * collection schemas are config-owned: the studio reads the model from\n * `elytra.config.ts` (served through the persistence port — the backend has no\n * `cmsSchemas` table). `[]` when the active project declares none, or when the\n * studio is wired by env alone with no config.\n */\nexport function resolveDeploymentCollections(): CollectionDef[] {\n return activeConfigProject()?.collections ?? []\n}\n\n/**\n * The canvas preview breakpoints of the active config project (EC-250).\n * Config-sourced (config-as-code, derived from the site's Tailwind `screens`);\n * `undefined` when the project declares none — the canvas then falls back to\n * Tailwind's default screens (see `resolveCanvasViewports`), never a baked-in triple.\n */\nexport function resolveDeploymentBreakpoints(): BreakpointConfig[] | undefined {\n return activeConfigProject()?.breakpoints\n}\n\n/**\n * The custom field renderers from the studio config (EC-238): control name →\n * component. Studio-only live code (from `defineStudio`, never the project schema\n * or the delivery bundle); `undefined` when none — the document editor then uses\n * its built-in control for every field. Studio-wide (not per-project): the rail and\n * renderers customize the studio, not one backend.\n */\nexport function resolveDeploymentFieldRenderers(): Record<string, CustomFieldRenderer> | undefined {\n return studioConfig?.fieldRenderers\n}\n\n/**\n * The studio-UI localization config from the studio config (EC-241): the default\n * locale + imported translation catalogs. Studio-only live code (from `defineStudio`,\n * never the project schema or the delivery bundle); `undefined` when none — the\n * studio then renders its built-in English chrome. Studio-wide, like the rail and\n * field renderers (localizes the studio, not one backend).\n */\nexport function resolveDeploymentI18n(): StudioConfig['i18n'] {\n return studioConfig?.i18n\n}\n\n/**\n * The AI platform config from the studio config (EC-331): provider, knowledge, and\n * limits. Studio-only live code (from `defineStudio`, never the delivery bundle);\n * `undefined` when no `ai` block is configured — the feature is then fully absent\n * (the AI subsystem never boots, so `@elytracms/studio/lib/ai`'s runtime — and the\n * `ai`/`@ai-sdk/*` SDK behind it — is never imported). Studio-wide, like the rail\n * and field renderers.\n */\nexport function resolveDeploymentAi(): StudioConfig['ai'] {\n return studioConfig?.ai\n}\n\n/**\n * The configurable section rail from the studio config (EC-237): an ordered list of\n * `nav.*` nodes (views / groups / separators). Studio-only live code (carries view\n * objects + icons); `undefined` when none — the studio then derives the default\n * rail from its built-in views. Invalid entries surface as visible issues at\n * resolution, never silent.\n */\nexport function resolveDeploymentSidebar(): SidebarNode[] | undefined {\n return studioConfig?.sidebar\n}\n\n/**\n * The canvas page-surface theme from the studio config (EC-389): body classes +\n * raw CSS for the preview iframe, so the canvas wears the SITE's chrome. Studio-only\n * (from `defineStudio`, never the delivery bundle); `undefined` when none — the\n * canvas keeps its built-in defaults (`bg-background text-foreground font-sans`\n * body, kit `tokensCss` only). Studio-wide, like the rail and field renderers.\n */\nexport function resolveDeploymentCanvasTheme(): StudioConfig['canvas'] {\n return studioConfig?.canvas\n}\n\n/**\n * The resolved URL routes of the active config project (EC-207, \"routes as\n * code\"). Routes are config-sourced: the studio reads the materialized\n * `RouteRecord[]` from the active `elytra.config.ts` project — never from the\n * backend (the `routes` system collection is retired). The Routes surface and\n * the per-page URL preview both read through here. `[]` when the active project\n * declares none, or when the studio is wired by env alone with no config.\n */\nexport function resolveDeploymentRoutes(): RouteRecord[] {\n return activeConfigProject()?.routes ?? []\n}\n\n// Redirects are editorial DATA now (EC-303), managed in the studio Redirects rail\n// and served dynamically — no longer resolved from config. `resolveDeploymentRedirects`\n// is retired; static/infra redirects are the dev's job via native next.config/vercel.\n\n/**\n * The configured locales of the active config project (AD-8). The same locale\n * set the host stamps onto its per-locale cache tags — used by the revalidation\n * webhook layer (EC-192) so a publish always invalidates EVERY configured locale\n * (e.g. `/de`, `/fr`), never just the default. `[]` when wired by env alone.\n */\nexport function resolveDeploymentLocales(): string[] {\n return activeConfigProject()?.locales ?? []\n}\n\n/**\n * The active config project's locales as a `LocaleConfig` (EC-357 finding #3).\n * Locales are structure and structure is code: when `elytra.config.ts` declares\n * them, they drive the studio's ambient locale switcher in EVERY mode — the CMS\n * surface must not fall back to the sample `en/de` fixture (playground, or a\n * connected first run with nothing persisted) when the config says otherwise.\n * `undefined` when no config project is active or it declares no locales (env-only\n * wiring keeps the existing kit/sample fallbacks). A missing/invalid explicit\n * default falls back to the first declared locale.\n */\nexport function resolveDeploymentLocaleConfig(): LocaleConfig | undefined {\n const project = activeConfigProject()\n if (!project || project.locales.length === 0) return undefined\n const fallback = project.locales[0]!\n const explicit = project.defaultLocale\n return {\n default: explicit && project.locales.includes(explicit) ? explicit : fallback,\n locales: [...project.locales],\n }\n}\n\n/**\n * The active project's components reference (EC-194, AD-11): where the consuming\n * repo's OWN real components live (`{ package }` or `{ dir }`). When set, the\n * studio registry composes with the repo's components instead of the built-in\n * demo fixtures — so the page builder edits the repo's actual component library.\n * `undefined` → the project ships no code components; the studio falls back to the\n * golden-path fixtures.\n */\nexport function resolveDeploymentComponents() {\n return activeConfigProject()?.components\n}\n\n/**\n * The active project's public site URL (EC-215): the live site's base URL the\n * studio links to (a document's live URL + open-live button, EC-216). Resolved\n * in lockstep with {@link resolveDeploymentUrl} — the environment bound to the\n * active deployment (the first with a wired deployment) provides the URL, falling\n * back to the project-level `frontendUrl`. No active-environment selector exists\n * yet, so the bound environment is the active one. `undefined` when the active\n * project (or env-only wiring) configures no frontend URL.\n */\nexport function resolveDeploymentFrontendUrl(): string | undefined {\n const project = activeConfigProject()\n if (!project) return undefined\n const boundEnv = project.environments.find((e) => e.convex?.deployment)\n return frontendUrlForEnvironment(project, boundEnv?.key)\n}\n"]}
|
|
@@ -35,5 +35,32 @@ export interface SwitcherSourceInput {
|
|
|
35
35
|
export declare function selectSwitcherProjects(input: SwitcherSourceInput): SwitcherEntry[];
|
|
36
36
|
/** The persisted projects among the entries (for list surfaces filtering by status). */
|
|
37
37
|
export declare function persistedProjects(entries: readonly SwitcherEntry[]): Project[];
|
|
38
|
+
/**
|
|
39
|
+
* The workspace's sole navigable project, or `null` (EC-401). Non-null exactly
|
|
40
|
+
* when the workspace has ONE entry total and it is a real `project` — a single
|
|
41
|
+
* *unprovisioned* entry keeps this `null` (the launcher's setup gate must stay
|
|
42
|
+
* visible), and a second entry of any kind does too.
|
|
43
|
+
*/
|
|
44
|
+
export declare function soleWorkspaceProject(entries: readonly SwitcherEntry[]): Project | null;
|
|
45
|
+
/**
|
|
46
|
+
* EC-401 — the ONE single-project predicate, shared by the launcher (skip the
|
|
47
|
+
* dashboard hop) and the shell header (plain project name instead of the
|
|
48
|
+
* switcher dropdown) so the two surfaces can never disagree.
|
|
49
|
+
*/
|
|
50
|
+
export declare function isSingleProjectWorkspace(entries: readonly SwitcherEntry[]): boolean;
|
|
51
|
+
/**
|
|
52
|
+
* The launcher's auto-forward decision (EC-401), pure: the sole project to
|
|
53
|
+
* redirect to, or `null` to render the launcher. A forced `?state=` verification
|
|
54
|
+
* state and an explicit "all projects" navigation (see `launcherVisitState` in
|
|
55
|
+
* the router seam) both keep the launcher visible; otherwise a single-project
|
|
56
|
+
* workspace forwards straight into its project.
|
|
57
|
+
*/
|
|
58
|
+
export declare function launcherAutoForwardTarget(input: {
|
|
59
|
+
entries: readonly SwitcherEntry[];
|
|
60
|
+
/** Whether the launcher was opened with a forced `?state=` verification state. */
|
|
61
|
+
forcedState: boolean;
|
|
62
|
+
/** Whether this location is an explicit "all projects" navigation. */
|
|
63
|
+
explicitVisit: boolean;
|
|
64
|
+
}): Project | null;
|
|
38
65
|
/** The not-yet-provisioned config entries (rendered as explicit CLI-hint states). */
|
|
39
66
|
export declare function unprovisionedProjects(entries: readonly SwitcherEntry[]): ResolvedWorkspaceProject[];
|