@storystick/client 0.0.0-stage → 0.2.0-rc.1

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.
Files changed (75) hide show
  1. package/LICENSE +112 -0
  2. package/PUBLIC-API.md +22 -0
  3. package/README.md +180 -2
  4. package/dist/.source-hash +1 -0
  5. package/dist/_fetchers.d.ts +51 -0
  6. package/dist/_fetchers.js +144 -0
  7. package/dist/_stateMachine.d.ts +102 -0
  8. package/dist/_stateMachine.js +168 -0
  9. package/dist/_wireAuth.d.ts +48 -0
  10. package/dist/_wireAuth.js +52 -0
  11. package/dist/adapter.d.ts +14 -0
  12. package/dist/adapter.js +10 -0
  13. package/dist/content-values.d.ts +11 -0
  14. package/dist/content-values.js +401 -0
  15. package/dist/errors.d.ts +65 -0
  16. package/dist/errors.js +45 -0
  17. package/dist/hydrate-slice.d.ts +2 -0
  18. package/dist/hydrate-slice.js +1 -0
  19. package/dist/index.d.ts +23 -0
  20. package/dist/index.js +9 -0
  21. package/dist/infra-resolver.d.ts +94 -0
  22. package/dist/infra-resolver.js +130 -0
  23. package/dist/internal.d.ts +33 -0
  24. package/dist/internal.js +20 -0
  25. package/dist/locale-routing.d.ts +5 -0
  26. package/dist/locale-routing.js +33 -0
  27. package/dist/normalize-pathname.d.ts +79 -0
  28. package/dist/normalize-pathname.js +103 -0
  29. package/dist/preview-frame-dependencies.d.ts +39 -0
  30. package/dist/preview-frame-dependencies.js +84 -0
  31. package/dist/preview-query-receipt.d.ts +3 -0
  32. package/dist/preview-query-receipt.js +16 -0
  33. package/dist/preview-read-lifecycle.d.ts +10 -0
  34. package/dist/preview-read-lifecycle.js +40 -0
  35. package/dist/preview-read-observation.d.ts +18 -0
  36. package/dist/preview-read-observation.js +81 -0
  37. package/dist/preview-scope.d.ts +3 -0
  38. package/dist/preview-scope.js +22 -0
  39. package/dist/preview-target.d.ts +73 -0
  40. package/dist/preview-target.js +86 -0
  41. package/dist/project-locales.d.ts +4 -0
  42. package/dist/project-locales.js +12 -0
  43. package/dist/public-delivery.d.ts +36 -0
  44. package/dist/public-delivery.js +55 -0
  45. package/dist/published-routes.d.ts +3 -0
  46. package/dist/published-routes.js +100 -0
  47. package/dist/query-builder.d.ts +225 -0
  48. package/dist/query-builder.js +294 -0
  49. package/dist/query-client.d.ts +54 -0
  50. package/dist/query-client.js +306 -0
  51. package/dist/query-compiler.d.ts +5 -0
  52. package/dist/query-compiler.js +12 -0
  53. package/dist/query-schema.d.ts +32 -0
  54. package/dist/query-schema.js +1 -0
  55. package/dist/redirect-search.d.ts +4 -0
  56. package/dist/redirect-search.js +14 -0
  57. package/dist/reference-resolver.d.ts +37 -0
  58. package/dist/reference-resolver.js +126 -0
  59. package/dist/resolve-route.d.ts +27 -0
  60. package/dist/resolve-route.js +145 -0
  61. package/dist/resolve.d.ts +43 -0
  62. package/dist/resolve.js +160 -0
  63. package/dist/schemas.d.ts +71 -0
  64. package/dist/schemas.js +75 -0
  65. package/dist/seo.d.ts +52 -0
  66. package/dist/seo.js +93 -0
  67. package/dist/signed-webhook.d.ts +13 -0
  68. package/dist/signed-webhook.js +54 -0
  69. package/dist/transport.d.ts +29 -0
  70. package/dist/transport.js +584 -0
  71. package/dist/typed-route.d.ts +22 -0
  72. package/dist/typed-route.js +1 -0
  73. package/dist/types.d.ts +147 -0
  74. package/dist/types.js +1 -0
  75. package/package.json +70 -3
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Infrastructure URL resolution — ADR-131 C9.
3
+ *
4
+ * Centralizes derivation of the three Convex-flavoured URLs the customer-
5
+ * bundled SDK packages need at runtime :
6
+ *
7
+ * - `endpoint` — gateway HTTP root, `.convex.site` form. Hosts the
8
+ * custom HTTP routes (JWKS, Pattern-D queries).
9
+ * - `convexUrl` — standard Convex RPC + WebSocket sync URL, `.convex.cloud`
10
+ * form. Used by `ConvexReactClient` (WS) and the server-
11
+ * side `ConvexHttpClient` (preview session lookup,
12
+ * observation tap mutation).
13
+ * - `jwksUrl` — `${endpoint}/api/jwks.json` by default.
14
+ *
15
+ * Cascade (per URL, independent) :
16
+ *
17
+ * 1. Explicit `options.*Url` — call-site override (props, env vars read
18
+ * by the adapter and forwarded as options, factory args).
19
+ * 2. Auto-derive from `resolveProject(projectId).endpoint`.
20
+ * 3. Fail-loud — `resolveProject` throws a typed `StorystickError` if
21
+ * the gateway returns 404/503/network error.
22
+ *
23
+ * The single `resolveProject` round-trip is shared across the three URLs ;
24
+ * subsequent calls hit the per-process cache (server) or sessionStorage
25
+ * cache (browser).
26
+ *
27
+ * **Env vars are NOT read here.** The bundler-replace behaviour of
28
+ * `process.env.NEXT_PUBLIC_*` requires literal property access in the
29
+ * adapter package (Next/React) so the static replacement at build time
30
+ * resolves the value into the browser bundle. Adapters call this helper
31
+ * after reading their respective env vars and forwarding them as options.
32
+ *
33
+ * Direction produit (owner 2026-05-26 PM) :
34
+ *
35
+ * > "Convex doit rester transparent au dev. Hors de question qu'une env
36
+ * > var mentionnant convex.cloud / .site / etc apparaisse côté customer."
37
+ *
38
+ * The 5-var customer allowlist is `STORYSTICK_PROJECT_ID`, `STORYSTICK_API_KEY`,
39
+ * `NEXT_PUBLIC_STORYSTICK_PROJECT_ID`, `NEXT_PUBLIC_STORYSTICK_PUBLISHABLE_KEY`,
40
+ * `NEXT_PUBLIC_STORYSTICK_STUDIO_ORIGIN`. All Convex-flavoured URLs are
41
+ * derived from `projectId` via this module.
42
+ * Resource-storage bucket credentials belong exclusively to the server-side
43
+ * storage resolver; they are never customer env vars or resolver options.
44
+ */
45
+ import { resolveProject } from './resolve.js';
46
+ const JWKS_PATH = '/api/jwks.json';
47
+ /**
48
+ * Swap `.convex.site` → `.convex.cloud` on a Convex deployment URL. Returns
49
+ * the input unchanged for non-Convex-Cloud deployments (self-host) — the
50
+ * customer sets `convexUrl` explicitly in that case.
51
+ *
52
+ * **CSO trade-off (codex R2 P1, accepted per ADR-131 C9 brief direction
53
+ * produit) :** the default `convexUrl` resolves to a raw `.convex.cloud`
54
+ * deployment URL, bypassing the sub-gateway Worker (`services/sub-gateway/`).
55
+ * Pre-C9, the default was `http://localhost:8787` (dev Worker URL) and
56
+ * customers in prod were expected to set
57
+ * `NEXT_PUBLIC_STORYSTICK_GATEWAY_SYNC_URL` explicitly — usually to the
58
+ * Worker public URL (e.g. `https://gateway.storystick.app`). The Worker
59
+ * is not deployed yet (5 Path B manual steps in
60
+ * `services/sub-gateway/wrangler.toml`), so the spike-Vercel Fix 2 already
61
+ * documents `.convex.cloud` direct as the only working posture today.
62
+ *
63
+ * Once the Worker is deployed, replace this default with the Worker
64
+ * public URL constant ; the rest of the cascade is unchanged. The
65
+ * explicit `options.convexUrl` override remains the escape hatch for
66
+ * customers needing to bypass the constant (self-host, internal Vercel
67
+ * deploys, etc.).
68
+ *
69
+ * See `storystick-hq/notes/adr-131-chantier/c9-hidden-vars-audit.md` §9
70
+ * for the deferred-follow-up list.
71
+ */
72
+ function gatewayToConvex(endpoint) {
73
+ if (endpoint.includes('.convex.site')) {
74
+ return endpoint.replace('.convex.site', '.convex.cloud');
75
+ }
76
+ return endpoint;
77
+ }
78
+ function stripTrailingSlash(url) {
79
+ return url.replace(/\/$/, '');
80
+ }
81
+ /**
82
+ * Synchronously derive `ResolvedInfra` when `endpoint` is already known
83
+ * (explicit prop or env override). Used by adapters that need an
84
+ * initial value during render — e.g. `<StorystickQueryProvider>`
85
+ * synchronously seeds its `infra` state when all required values are
86
+ * explicit, so generated clients can execute on the first render
87
+ * inside an immediately-available `ConvexProvider` (codex R2 P2 fix).
88
+ *
89
+ * Returns `null` when `endpoint` is missing (gateway round-trip needed).
90
+ */
91
+ export function resolveInfraSync(options = {}) {
92
+ if (options.endpoint === undefined)
93
+ return null;
94
+ const endpoint = stripTrailingSlash(options.endpoint);
95
+ const convexUrl = stripTrailingSlash(options.convexUrl ?? gatewayToConvex(endpoint));
96
+ const jwksUrl = options.jwksUrl !== undefined ? options.jwksUrl : `${endpoint}${JWKS_PATH}`;
97
+ return { endpoint, convexUrl, jwksUrl };
98
+ }
99
+ /**
100
+ * Resolve the three infrastructure URLs for `projectId`.
101
+ *
102
+ * Idempotent + cache-aware — repeated calls with the same `projectId` reuse
103
+ * the `resolveProject` cache (per-process Map server-side, sessionStorage
104
+ * browser-side). Safe to call eagerly at module init.
105
+ *
106
+ * The `resolveProject` round-trip happens only when no explicit endpoint
107
+ * override is supplied — `convexUrl` + `jwksUrl` are pure transforms on
108
+ * `endpoint`. If `endpoint` is provided (prop or env escape hatch), the
109
+ * helper completes without any network call ; gateway failures cannot
110
+ * stall the boot path in that case.
111
+ *
112
+ * Throws (propagates) the `StorystickError` from `resolveProject` when the
113
+ * gateway returns 404/503/network error AND no explicit `endpoint` is set.
114
+ */
115
+ export async function resolveInfra(projectId, options = {}) {
116
+ // The gateway round-trip is needed only when we don't already know the
117
+ // endpoint — convexUrl + jwksUrl are pure transforms on it. Explicit
118
+ // endpoint prop alone is enough to skip the round-trip.
119
+ let endpointSource = options.endpoint;
120
+ if (endpointSource === undefined) {
121
+ const config = await resolveProject(projectId, options.gatewayUrl);
122
+ endpointSource = config.endpoint;
123
+ }
124
+ const endpoint = stripTrailingSlash(endpointSource);
125
+ const convexUrl = stripTrailingSlash(options.convexUrl ?? gatewayToConvex(endpoint));
126
+ const jwksUrl = options.jwksUrl !== undefined
127
+ ? options.jwksUrl
128
+ : `${endpoint}${JWKS_PATH}`;
129
+ return { endpoint, convexUrl, jwksUrl };
130
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Internal entry point — Storystick surfaces only (ADR-045 §11.2).
3
+ *
4
+ * Consumers outside `apps/studio`, `apps/ops`, `apps/hub` should NOT
5
+ * import from here. This file exposes the user-session fetcher (which
6
+ * the public SDK doesn't need) and direct access to the state machine.
7
+ */
8
+ export { wireConvexAuth } from './_wireAuth.js';
9
+ export { assertExactQueryResult } from './errors.js';
10
+ export type { ConvexClientWithSetAuth, WireAuthOptions, WireAuthResult, } from './_wireAuth.js';
11
+ export { userSessionFetcher, apiKeyFetcher } from './_fetchers.js';
12
+ export type { UserSessionFetcherOptions, ApiKeyFetcherOptions, } from './_fetchers.js';
13
+ export { createTokenStateMachine, isHardAuthError, isRefreshableError, } from './_stateMachine.js';
14
+ export type { SdkError, StateMachineErrorCode, TokenFetcher, TokenFetchResult, TokenStateMachine, } from './_stateMachine.js';
15
+ export { getExecutableQueryDescriptor } from './query-builder.js';
16
+ export type { InternalExecutableQueryDescriptor } from './query-builder.js';
17
+ export { createInternalClient, bindClientSchemaToAuthority, clientMatchesPreviewAuthority, getInternalClientRuntime, } from './query-client.js';
18
+ export type { InternalClientOptions, InternalClientRuntime, InternalPreviewAuthorityScope, } from './query-client.js';
19
+ /** Internal Studio wire knob. Public SDK reads always use backend `explicit`. */
20
+ export interface InternalLocaleWireOptions {
21
+ locale?: string;
22
+ fallbackPolicy?: 'none' | 'explicit';
23
+ }
24
+ export { attachPreviewReadObservation, collectPreviewReadObservations, conservativePreviewRenderWatermark, getPreviewReadObservation, } from './preview-read-observation.js';
25
+ export type { PreviewReadObservation } from './preview-read-observation.js';
26
+ export { getPreviewQueryReceiptId } from './preview-query-receipt.js';
27
+ export { resolvePreviewTarget } from './preview-target.js';
28
+ export type { PreviewTarget } from './preview-target.js';
29
+ export { previewAssetDependenciesFromValue } from './preview-frame-dependencies.js';
30
+ export * from './hydrate-slice.js';
31
+ export * from './content-values.js';
32
+ export { attachPreviewQueryReceipt } from './preview-query-receipt.js';
33
+ export type { RequestContext, PreviewReadScopeEcho, PreviewProvenanceReadLifecycle, PreviewProvenanceReadLifecycleInput, Transport, TransportRequest, } from './types.js';
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Internal entry point — Storystick surfaces only (ADR-045 §11.2).
3
+ *
4
+ * Consumers outside `apps/studio`, `apps/ops`, `apps/hub` should NOT
5
+ * import from here. This file exposes the user-session fetcher (which
6
+ * the public SDK doesn't need) and direct access to the state machine.
7
+ */
8
+ export { wireConvexAuth } from './_wireAuth.js';
9
+ export { assertExactQueryResult } from './errors.js';
10
+ export { userSessionFetcher, apiKeyFetcher } from './_fetchers.js';
11
+ export { createTokenStateMachine, isHardAuthError, isRefreshableError, } from './_stateMachine.js';
12
+ export { getExecutableQueryDescriptor } from './query-builder.js';
13
+ export { createInternalClient, bindClientSchemaToAuthority, clientMatchesPreviewAuthority, getInternalClientRuntime, } from './query-client.js';
14
+ export { attachPreviewReadObservation, collectPreviewReadObservations, conservativePreviewRenderWatermark, getPreviewReadObservation, } from './preview-read-observation.js';
15
+ export { getPreviewQueryReceiptId } from './preview-query-receipt.js';
16
+ export { resolvePreviewTarget } from './preview-target.js';
17
+ export { previewAssetDependenciesFromValue } from './preview-frame-dependencies.js';
18
+ export * from './hydrate-slice.js';
19
+ export * from './content-values.js';
20
+ export { attachPreviewQueryReceipt } from './preview-query-receipt.js';
@@ -0,0 +1,5 @@
1
+ export { publicHrefForPublication, publicHrefForLocale, publishedLocaleHrefs, normalizePublicLocaleOrigins, prefixHref, publicPathnameForLocale, resolveLocalePathname, readDocumentLocaleRouting } from '@storystick/types/internal/locale-routing';
2
+ export type { PublicLocaleOrigins, LocalePathnameResolution, LocaleRouting, LocaleRoutingSettings, RoutingLocale, DocumentLocaleRouting } from '@storystick/types/internal/locale-routing';
3
+ import type { LocaleRoutingSettings } from '@storystick/types/internal/locale-routing';
4
+ /** @internal Attach only after validating the wire response. No credentials or view identity. */
5
+ export declare function attachDocumentLocaleRouting<T>(value: T, settings: LocaleRoutingSettings): T;
@@ -0,0 +1,33 @@
1
+ export { publicHrefForPublication, publicHrefForLocale, publishedLocaleHrefs, normalizePublicLocaleOrigins, prefixHref, publicPathnameForLocale, resolveLocalePathname, readDocumentLocaleRouting } from '@storystick/types/internal/locale-routing';
2
+ /** @internal Attach only after validating the wire response. No credentials or view identity. */
3
+ export function attachDocumentLocaleRouting(value, settings) {
4
+ const seen = new WeakSet();
5
+ const visit = (item) => {
6
+ if (typeof item !== 'object' || item === null || seen.has(item))
7
+ return;
8
+ seen.add(item);
9
+ if (Array.isArray(item)) {
10
+ item.forEach(visit);
11
+ return;
12
+ }
13
+ const record = item;
14
+ if (typeof record.documentId === 'string' && typeof record.storyType === 'string' &&
15
+ isRecord(record.fields) && isRecord(record._locale) && typeof record._locale.code === 'string') {
16
+ record._localeRouting = {
17
+ locale: record._locale.code,
18
+ defaultLocale: settings.defaultLocale,
19
+ locales: settings.locales.map(({ code, enabled }) => ({ code, enabled })),
20
+ localeRouting: { ...settings.localeRouting },
21
+ ...(settings.publicOrigins ? { publicOrigins: { ...settings.publicOrigins } } : {}),
22
+ ...(settings.synthetic === undefined ? {} : { synthetic: settings.synthetic }),
23
+ };
24
+ }
25
+ for (const [key, child] of Object.entries(record)) {
26
+ if (key !== '_localeRouting')
27
+ visit(child);
28
+ }
29
+ };
30
+ visit(value);
31
+ return value;
32
+ }
33
+ function isRecord(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Pathname normalization helpers (ADR-090 §3).
3
+ *
4
+ * The public SDK accepts all developer-natural forms for a pathname:
5
+ *
6
+ * - `undefined` (catch-all root in Next.js: `params.segments` for `/`)
7
+ * - `""`, `"/"` (empty / root)
8
+ * - `"accueil"`, `"/accueil"` (single segment, optional leading slash)
9
+ * - `["blog", "post"]` (array of segments, e.g. Next.js `params.segments`)
10
+ * - `[]` (empty array — homepage)
11
+ *
12
+ * All forms normalize to a canonical internal form: a single string of
13
+ * slug-like segments joined by `"/"`, with no leading or trailing slash.
14
+ * The empty string `""` represents the homepage.
15
+ *
16
+ * | Input | Canonical form |
17
+ * |--------------------|----------------|
18
+ * | `undefined` | `""` |
19
+ * | `""` / `"/"` | `""` |
20
+ * | `"accueil"` | `"accueil"` |
21
+ * | `"/accueil"` | `"accueil"` |
22
+ * | `"/blog/post"` | `"blog/post"` |
23
+ * | `["blog", "post"]` | `"blog/post"` |
24
+ * | `[]` | `""` |
25
+ *
26
+ * The canonical form is what a compiled Query V2 pathname address stores when
27
+ * `.by({ pathname })` is called, and what backend `getByPathname` will
28
+ * eventually accept on the wire (currently routed via list + filter
29
+ * fallback on the `resolvedPathname` allowlist column from ADR-088).
30
+ *
31
+ * These helpers are framework-agnostic and shared by all framework
32
+ * adapters so that normalization behavior is identical across Next.js,
33
+ * Astro, SvelteKit, and any future SDK.
34
+ */
35
+ /**
36
+ * Normalize a developer-supplied pathname input to its canonical form.
37
+ *
38
+ * normalizePathname(undefined) // ""
39
+ * normalizePathname("/") // ""
40
+ * normalizePathname("/accueil") // "accueil"
41
+ * normalizePathname(["blog", "post"]) // "blog/post"
42
+ * normalizePathname([]) // ""
43
+ */
44
+ export declare function normalizePathname(input: string | string[] | undefined): string;
45
+ /**
46
+ * Convert a canonical pathname into segment array form.
47
+ * Inverse of {@link segmentsToPathname}.
48
+ *
49
+ * pathnameToSegments("") // []
50
+ * pathnameToSegments("accueil") // ["accueil"]
51
+ * pathnameToSegments("blog/post") // ["blog", "post"]
52
+ */
53
+ export declare function pathnameToSegments(pathname: string): string[];
54
+ /**
55
+ * Convert a segment array into canonical pathname string form.
56
+ * Inverse of {@link pathnameToSegments}.
57
+ *
58
+ * segmentsToPathname([]) // ""
59
+ * segmentsToPathname(["accueil"]) // "accueil"
60
+ * segmentsToPathname(["blog", "post"]) // "blog/post"
61
+ */
62
+ export declare function segmentsToPathname(segments: string[]): string;
63
+ /**
64
+ * Convert a canonical pathname (`'blog/post-1'` or `''` for homepage) to a
65
+ * browser-displayable path with leading slash. Inverse of {@link normalizePathname}.
66
+ *
67
+ * pathnameToHref('') → '/'
68
+ * pathnameToHref('accueil') → '/accueil'
69
+ * pathnameToHref('blog/post-1') → '/blog/post-1'
70
+ *
71
+ * Trusts canonical-form input (no leading slash, `''` for homepage). Producers
72
+ * are expected to call `normalizePathname` first if they hold non-canonical
73
+ * input. This keeps the helper minimal and the canonicalization rule single-sourced.
74
+ *
75
+ * Use cases: ADR-094 catch-all routing (companion utility consumed by customer
76
+ * renderers + the Studio iframe routing in ADR-095), future sitemap generation,
77
+ * AI Builder doc→URL pass, "share my draft" link generators.
78
+ */
79
+ export declare function pathnameToHref(canonical: string): string;
@@ -0,0 +1,103 @@
1
+ import { normalizeQueryPathname } from '@storystick/types/pathname-query';
2
+ /**
3
+ * Pathname normalization helpers (ADR-090 §3).
4
+ *
5
+ * The public SDK accepts all developer-natural forms for a pathname:
6
+ *
7
+ * - `undefined` (catch-all root in Next.js: `params.segments` for `/`)
8
+ * - `""`, `"/"` (empty / root)
9
+ * - `"accueil"`, `"/accueil"` (single segment, optional leading slash)
10
+ * - `["blog", "post"]` (array of segments, e.g. Next.js `params.segments`)
11
+ * - `[]` (empty array — homepage)
12
+ *
13
+ * All forms normalize to a canonical internal form: a single string of
14
+ * slug-like segments joined by `"/"`, with no leading or trailing slash.
15
+ * The empty string `""` represents the homepage.
16
+ *
17
+ * | Input | Canonical form |
18
+ * |--------------------|----------------|
19
+ * | `undefined` | `""` |
20
+ * | `""` / `"/"` | `""` |
21
+ * | `"accueil"` | `"accueil"` |
22
+ * | `"/accueil"` | `"accueil"` |
23
+ * | `"/blog/post"` | `"blog/post"` |
24
+ * | `["blog", "post"]` | `"blog/post"` |
25
+ * | `[]` | `""` |
26
+ *
27
+ * The canonical form is what a compiled Query V2 pathname address stores when
28
+ * `.by({ pathname })` is called, and what backend `getByPathname` will
29
+ * eventually accept on the wire (currently routed via list + filter
30
+ * fallback on the `resolvedPathname` allowlist column from ADR-088).
31
+ *
32
+ * These helpers are framework-agnostic and shared by all framework
33
+ * adapters so that normalization behavior is identical across Next.js,
34
+ * Astro, SvelteKit, and any future SDK.
35
+ */
36
+ /**
37
+ * Normalize a developer-supplied pathname input to its canonical form.
38
+ *
39
+ * normalizePathname(undefined) // ""
40
+ * normalizePathname("/") // ""
41
+ * normalizePathname("/accueil") // "accueil"
42
+ * normalizePathname(["blog", "post"]) // "blog/post"
43
+ * normalizePathname([]) // ""
44
+ */
45
+ export function normalizePathname(input) {
46
+ if (input === undefined)
47
+ return '';
48
+ if (Array.isArray(input)) {
49
+ // Filter out empty / falsy segments so `["", "blog"]` doesn't produce
50
+ // a leading-slash canonical form. This also makes `normalizePathname([""])`
51
+ // correctly produce `""` (homepage).
52
+ const filtered = input.filter((s) => typeof s === 'string' && s.length > 0);
53
+ return filtered.join('/');
54
+ }
55
+ // String input: strip leading and trailing slashes.
56
+ // "/" → "" (after strip), "/accueil" → "accueil", "/blog/post/" → "blog/post"
57
+ return normalizeQueryPathname(input);
58
+ }
59
+ /**
60
+ * Convert a canonical pathname into segment array form.
61
+ * Inverse of {@link segmentsToPathname}.
62
+ *
63
+ * pathnameToSegments("") // []
64
+ * pathnameToSegments("accueil") // ["accueil"]
65
+ * pathnameToSegments("blog/post") // ["blog", "post"]
66
+ */
67
+ export function pathnameToSegments(pathname) {
68
+ if (pathname === '')
69
+ return [];
70
+ return pathname.split('/');
71
+ }
72
+ /**
73
+ * Convert a segment array into canonical pathname string form.
74
+ * Inverse of {@link pathnameToSegments}.
75
+ *
76
+ * segmentsToPathname([]) // ""
77
+ * segmentsToPathname(["accueil"]) // "accueil"
78
+ * segmentsToPathname(["blog", "post"]) // "blog/post"
79
+ */
80
+ export function segmentsToPathname(segments) {
81
+ return segments.join('/');
82
+ }
83
+ /**
84
+ * Convert a canonical pathname (`'blog/post-1'` or `''` for homepage) to a
85
+ * browser-displayable path with leading slash. Inverse of {@link normalizePathname}.
86
+ *
87
+ * pathnameToHref('') → '/'
88
+ * pathnameToHref('accueil') → '/accueil'
89
+ * pathnameToHref('blog/post-1') → '/blog/post-1'
90
+ *
91
+ * Trusts canonical-form input (no leading slash, `''` for homepage). Producers
92
+ * are expected to call `normalizePathname` first if they hold non-canonical
93
+ * input. This keeps the helper minimal and the canonicalization rule single-sourced.
94
+ *
95
+ * Use cases: ADR-094 catch-all routing (companion utility consumed by customer
96
+ * renderers + the Studio iframe routing in ADR-095), future sitemap generation,
97
+ * AI Builder doc→URL pass, "share my draft" link generators.
98
+ */
99
+ export function pathnameToHref(canonical) {
100
+ if (canonical === '')
101
+ return '/';
102
+ return `/${canonical}`;
103
+ }
@@ -0,0 +1,39 @@
1
+ export type PreviewFrameDependencyLike = {
2
+ readonly kind: 'document';
3
+ readonly documentId: string;
4
+ readonly storyType?: string;
5
+ readonly reason?: string;
6
+ } | {
7
+ readonly kind: 'asset';
8
+ readonly assetId: string;
9
+ } | {
10
+ readonly kind: 'list';
11
+ readonly storyType: string;
12
+ readonly where?: unknown;
13
+ readonly orderBy?: {
14
+ readonly field: string;
15
+ readonly direction: 'asc' | 'desc';
16
+ };
17
+ readonly limit?: number;
18
+ } | {
19
+ readonly kind: 'route-lookup';
20
+ readonly pathname: string;
21
+ readonly storyTypes?: readonly string[];
22
+ } | {
23
+ readonly kind: 'get-by-slug';
24
+ readonly storyType: string;
25
+ readonly slug: string;
26
+ } | {
27
+ readonly kind: 'get-by-pathname';
28
+ readonly storyType: string;
29
+ readonly pathname: string;
30
+ } | {
31
+ readonly kind: 'batch-get';
32
+ readonly documentIds: readonly string[];
33
+ };
34
+ export declare function attachPreviewFrameDependencies<T>(target: T, dependencies: readonly PreviewFrameDependencyLike[]): T;
35
+ export declare function getAttachedPreviewFrameDependencies(value: unknown): readonly PreviewFrameDependencyLike[];
36
+ /** Build precise Preview dependencies from public `_assets` sidecars. */
37
+ export declare function previewAssetDependenciesFromValue(value: unknown): readonly Extract<PreviewFrameDependencyLike, {
38
+ readonly kind: 'asset';
39
+ }>[];
@@ -0,0 +1,84 @@
1
+ import { getPreviewReadDependencies, setPreviewReadDependencies } from '@storystick/types';
2
+ export function attachPreviewFrameDependencies(target, dependencies) {
3
+ if (!isObjectLike(target) || dependencies.length === 0)
4
+ return target;
5
+ const next = dedupePreviewFrameDependencies([
6
+ ...getAttachedPreviewFrameDependencies(target),
7
+ ...dependencies,
8
+ ]);
9
+ setPreviewReadDependencies(target, next);
10
+ return target;
11
+ }
12
+ export function getAttachedPreviewFrameDependencies(value) {
13
+ if (!isObjectLike(value))
14
+ return [];
15
+ return getPreviewReadDependencies(value).filter(isPreviewDependencyLike);
16
+ }
17
+ /** Build precise Preview dependencies from public `_assets` sidecars. */
18
+ export function previewAssetDependenciesFromValue(value) {
19
+ const assetIds = new Set();
20
+ const seen = new WeakSet();
21
+ const visit = (current) => {
22
+ if (!isObjectLike(current) || seen.has(current))
23
+ return;
24
+ seen.add(current);
25
+ if (Array.isArray(current)) {
26
+ for (const item of current)
27
+ visit(item);
28
+ return;
29
+ }
30
+ const record = current;
31
+ const sidecar = isPlainRecord(record._assets) ? record._assets : undefined;
32
+ for (const key of ['byId', 'unresolved']) {
33
+ const entries = sidecar && isPlainRecord(sidecar[key])
34
+ ? sidecar[key]
35
+ : undefined;
36
+ if (entries) {
37
+ for (const assetId of Object.keys(entries)) {
38
+ if (assetId.length > 0)
39
+ assetIds.add(assetId);
40
+ }
41
+ }
42
+ }
43
+ for (const child of Object.values(record))
44
+ visit(child);
45
+ };
46
+ visit(value);
47
+ return [...assetIds]
48
+ .sort()
49
+ .map((assetId) => ({ kind: 'asset', assetId }));
50
+ }
51
+ function dedupePreviewFrameDependencies(dependencies) {
52
+ const seen = new Set();
53
+ const out = [];
54
+ for (const dependency of dependencies) {
55
+ const key = stableStringify(dependency);
56
+ if (seen.has(key))
57
+ continue;
58
+ seen.add(key);
59
+ out.push(dependency);
60
+ }
61
+ return out;
62
+ }
63
+ function isPreviewDependencyLike(value) {
64
+ return isPlainRecord(value) && typeof value.kind === 'string';
65
+ }
66
+ function stableStringify(value) {
67
+ if (Array.isArray(value)) {
68
+ return `[${value.map((item) => stableStringify(item)).join(',')}]`;
69
+ }
70
+ if (isPlainRecord(value)) {
71
+ return `{${Object.keys(value)
72
+ .sort()
73
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
74
+ .join(',')}}`;
75
+ }
76
+ return JSON.stringify(value);
77
+ }
78
+ function isObjectLike(value) {
79
+ return (typeof value === 'object' || typeof value === 'function') &&
80
+ value !== null;
81
+ }
82
+ function isPlainRecord(value) {
83
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
84
+ }
@@ -0,0 +1,3 @@
1
+ import type { PreviewQueryReceiptId } from '@storystick/types/preview-query-targeting';
2
+ export declare function attachPreviewQueryReceipt<T>(result: T, receiptId: string | undefined): T;
3
+ export declare function getPreviewQueryReceiptId(result: unknown): PreviewQueryReceiptId | null;
@@ -0,0 +1,16 @@
1
+ const receiptByResult = new WeakMap();
2
+ export function attachPreviewQueryReceipt(result, receiptId) {
3
+ if (result !== null &&
4
+ (typeof result === 'object' || typeof result === 'function') &&
5
+ typeof receiptId === 'string' &&
6
+ /^pqr_[a-f0-9]{64}$/.test(receiptId)) {
7
+ receiptByResult.set(result, receiptId);
8
+ }
9
+ return result;
10
+ }
11
+ export function getPreviewQueryReceiptId(result) {
12
+ if (result === null ||
13
+ (typeof result !== 'object' && typeof result !== 'function'))
14
+ return null;
15
+ return receiptByResult.get(result) ?? null;
16
+ }
@@ -0,0 +1,10 @@
1
+ import type { PreviewProvenanceReadLifecycleInput, RequestContext } from './types.js';
2
+ /** @internal */
3
+ export declare function runPreviewProvenanceReadLifecycle<T>(requestContext: RequestContext | undefined, input: PreviewProvenanceReadLifecycleInput<T>): Promise<T>;
4
+ /**
5
+ * Stable, non-secret JSON key used only by request-local adapter caches.
6
+ * Functions, undefined values, and AbortSignals are intentionally omitted.
7
+ *
8
+ * @internal
9
+ */
10
+ export declare function createPreviewProvenanceReadCacheKey(kind: PreviewProvenanceReadLifecycleInput<unknown>['kind'], value: unknown): string;
@@ -0,0 +1,40 @@
1
+ import { prepareContentValues } from './content-values.js';
2
+ /** @internal */
3
+ export function runPreviewProvenanceReadLifecycle(requestContext, input) {
4
+ const lifecycle = requestContext?.unstable_previewProvenanceReadLifecycle;
5
+ return (lifecycle ? lifecycle(input) : input.executeRaw()).then(value => prepareContentValues(value, true, Boolean(requestContext?.previewToken)));
6
+ }
7
+ /**
8
+ * Stable, non-secret JSON key used only by request-local adapter caches.
9
+ * Functions, undefined values, and AbortSignals are intentionally omitted.
10
+ *
11
+ * @internal
12
+ */
13
+ export function createPreviewProvenanceReadCacheKey(kind, value) {
14
+ return JSON.stringify({ contentVersion: 1, kind, value: canonicalize(value) });
15
+ }
16
+ function canonicalize(value) {
17
+ if (value === null ||
18
+ typeof value === 'string' ||
19
+ typeof value === 'number' ||
20
+ typeof value === 'boolean') {
21
+ return value;
22
+ }
23
+ if (Array.isArray(value))
24
+ return value.map(canonicalize);
25
+ if (typeof value !== 'object')
26
+ return undefined;
27
+ if (isAbortSignal(value))
28
+ return undefined;
29
+ const result = {};
30
+ for (const key of Object.keys(value).sort()) {
31
+ const next = canonicalize(value[key]);
32
+ if (next !== undefined)
33
+ result[key] = next;
34
+ }
35
+ return result;
36
+ }
37
+ function isAbortSignal(value) {
38
+ return (typeof value.aborted === 'boolean' &&
39
+ typeof value.addEventListener === 'function');
40
+ }
@@ -0,0 +1,18 @@
1
+ import type { PreviewReadContext } from '@storystick/types/preview-query-targeting';
2
+ export interface PreviewReadObservation {
3
+ readonly readContext?: PreviewReadContext;
4
+ readonly receiptId?: string;
5
+ readonly documentIds: readonly string[];
6
+ readonly storyTypes: readonly string[];
7
+ readonly codeVersion: string;
8
+ readonly observationId: string;
9
+ /**
10
+ * Clock sampled before the gateway content read. Multiple reads in one RSC
11
+ * render compose with MIN, never max: equal clocks are conservatively stale.
12
+ */
13
+ readonly renderWatermark: number;
14
+ }
15
+ export declare function attachPreviewReadObservation<T>(root: T, observation: PreviewReadObservation): T;
16
+ export declare function getPreviewReadObservation(value: unknown): PreviewReadObservation | null;
17
+ export declare function collectPreviewReadObservations(...roots: readonly unknown[]): readonly PreviewReadObservation[];
18
+ export declare function conservativePreviewRenderWatermark(observations: readonly Pick<PreviewReadObservation, 'renderWatermark'>[]): number | undefined;