@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.
- package/LICENSE +112 -0
- package/PUBLIC-API.md +22 -0
- package/README.md +180 -2
- package/dist/.source-hash +1 -0
- package/dist/_fetchers.d.ts +51 -0
- package/dist/_fetchers.js +144 -0
- package/dist/_stateMachine.d.ts +102 -0
- package/dist/_stateMachine.js +168 -0
- package/dist/_wireAuth.d.ts +48 -0
- package/dist/_wireAuth.js +52 -0
- package/dist/adapter.d.ts +14 -0
- package/dist/adapter.js +10 -0
- package/dist/content-values.d.ts +11 -0
- package/dist/content-values.js +401 -0
- package/dist/errors.d.ts +65 -0
- package/dist/errors.js +45 -0
- package/dist/hydrate-slice.d.ts +2 -0
- package/dist/hydrate-slice.js +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +9 -0
- package/dist/infra-resolver.d.ts +94 -0
- package/dist/infra-resolver.js +130 -0
- package/dist/internal.d.ts +33 -0
- package/dist/internal.js +20 -0
- package/dist/locale-routing.d.ts +5 -0
- package/dist/locale-routing.js +33 -0
- package/dist/normalize-pathname.d.ts +79 -0
- package/dist/normalize-pathname.js +103 -0
- package/dist/preview-frame-dependencies.d.ts +39 -0
- package/dist/preview-frame-dependencies.js +84 -0
- package/dist/preview-query-receipt.d.ts +3 -0
- package/dist/preview-query-receipt.js +16 -0
- package/dist/preview-read-lifecycle.d.ts +10 -0
- package/dist/preview-read-lifecycle.js +40 -0
- package/dist/preview-read-observation.d.ts +18 -0
- package/dist/preview-read-observation.js +81 -0
- package/dist/preview-scope.d.ts +3 -0
- package/dist/preview-scope.js +22 -0
- package/dist/preview-target.d.ts +73 -0
- package/dist/preview-target.js +86 -0
- package/dist/project-locales.d.ts +4 -0
- package/dist/project-locales.js +12 -0
- package/dist/public-delivery.d.ts +36 -0
- package/dist/public-delivery.js +55 -0
- package/dist/published-routes.d.ts +3 -0
- package/dist/published-routes.js +100 -0
- package/dist/query-builder.d.ts +225 -0
- package/dist/query-builder.js +294 -0
- package/dist/query-client.d.ts +54 -0
- package/dist/query-client.js +306 -0
- package/dist/query-compiler.d.ts +5 -0
- package/dist/query-compiler.js +12 -0
- package/dist/query-schema.d.ts +32 -0
- package/dist/query-schema.js +1 -0
- package/dist/redirect-search.d.ts +4 -0
- package/dist/redirect-search.js +14 -0
- package/dist/reference-resolver.d.ts +37 -0
- package/dist/reference-resolver.js +126 -0
- package/dist/resolve-route.d.ts +27 -0
- package/dist/resolve-route.js +145 -0
- package/dist/resolve.d.ts +43 -0
- package/dist/resolve.js +160 -0
- package/dist/schemas.d.ts +71 -0
- package/dist/schemas.js +75 -0
- package/dist/seo.d.ts +52 -0
- package/dist/seo.js +93 -0
- package/dist/signed-webhook.d.ts +13 -0
- package/dist/signed-webhook.js +54 -0
- package/dist/transport.d.ts +29 -0
- package/dist/transport.js +584 -0
- package/dist/typed-route.d.ts +22 -0
- package/dist/typed-route.js +1 -0
- package/dist/types.d.ts +147 -0
- package/dist/types.js +1 -0
- package/package.json +70 -3
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preview read metadata is server-render bookkeeping, not customer data.
|
|
3
|
+
*
|
|
4
|
+
* Keep it out-of-band so documents can cross a React Server Component
|
|
5
|
+
* boundary without exposing symbol-keyed properties. Next.js rejects objects
|
|
6
|
+
* carrying symbols even when those properties are non-enumerable.
|
|
7
|
+
*/
|
|
8
|
+
const observationByObject = new WeakMap();
|
|
9
|
+
export function attachPreviewReadObservation(root, observation) {
|
|
10
|
+
const seen = new WeakSet();
|
|
11
|
+
const visit = (value) => {
|
|
12
|
+
if (!isObject(value) || seen.has(value))
|
|
13
|
+
return;
|
|
14
|
+
seen.add(value);
|
|
15
|
+
observationByObject.set(value, observation);
|
|
16
|
+
if (Array.isArray(value)) {
|
|
17
|
+
for (const item of value)
|
|
18
|
+
visit(item);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
for (const child of Object.values(value))
|
|
22
|
+
visit(child);
|
|
23
|
+
};
|
|
24
|
+
visit(root);
|
|
25
|
+
return root;
|
|
26
|
+
}
|
|
27
|
+
export function getPreviewReadObservation(value) {
|
|
28
|
+
if (!isObject(value))
|
|
29
|
+
return null;
|
|
30
|
+
const candidate = observationByObject.get(value);
|
|
31
|
+
if (!isObject(candidate))
|
|
32
|
+
return null;
|
|
33
|
+
if (!Array.isArray(candidate.documentIds))
|
|
34
|
+
return null;
|
|
35
|
+
if (!Array.isArray(candidate.storyTypes))
|
|
36
|
+
return null;
|
|
37
|
+
if (typeof candidate.codeVersion !== 'string')
|
|
38
|
+
return null;
|
|
39
|
+
if (typeof candidate.observationId !== 'string')
|
|
40
|
+
return null;
|
|
41
|
+
if (typeof candidate.renderWatermark !== 'number' ||
|
|
42
|
+
!Number.isFinite(candidate.renderWatermark))
|
|
43
|
+
return null;
|
|
44
|
+
return candidate;
|
|
45
|
+
}
|
|
46
|
+
export function collectPreviewReadObservations(...roots) {
|
|
47
|
+
const observations = new Map();
|
|
48
|
+
const seen = new WeakSet();
|
|
49
|
+
const visit = (value) => {
|
|
50
|
+
if (!isObject(value) || seen.has(value))
|
|
51
|
+
return;
|
|
52
|
+
seen.add(value);
|
|
53
|
+
const observation = getPreviewReadObservation(value);
|
|
54
|
+
if (observation) {
|
|
55
|
+
observations.set(`${observation.codeVersion}\u0000${observation.observationId}\u0000${observation.renderWatermark}\u0000${observation.receiptId ?? ''}`, observation);
|
|
56
|
+
}
|
|
57
|
+
if (Array.isArray(value)) {
|
|
58
|
+
for (const item of value)
|
|
59
|
+
visit(item);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
for (const child of Object.values(value))
|
|
63
|
+
visit(child);
|
|
64
|
+
};
|
|
65
|
+
for (const root of roots)
|
|
66
|
+
visit(root);
|
|
67
|
+
return [...observations.values()];
|
|
68
|
+
}
|
|
69
|
+
export function conservativePreviewRenderWatermark(observations) {
|
|
70
|
+
let watermark;
|
|
71
|
+
for (const observation of observations) {
|
|
72
|
+
const next = observation.renderWatermark;
|
|
73
|
+
if (!Number.isFinite(next))
|
|
74
|
+
continue;
|
|
75
|
+
watermark = watermark === undefined ? next : Math.min(watermark, next);
|
|
76
|
+
}
|
|
77
|
+
return watermark;
|
|
78
|
+
}
|
|
79
|
+
function isObject(value) {
|
|
80
|
+
return (typeof value === 'object' && value !== null) || typeof value === 'function';
|
|
81
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { RequestContext } from './types.js';
|
|
2
|
+
export declare function hasUsablePreviewToken(context: RequestContext | undefined): boolean;
|
|
3
|
+
export declare function withPreviewVariantArgs(udfPath: string, args: Record<string, unknown>, context: RequestContext | undefined): Record<string, unknown>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function hasUsablePreviewToken(context) {
|
|
2
|
+
const token = context?.previewToken;
|
|
3
|
+
return typeof token === 'string' && token.length > 0;
|
|
4
|
+
}
|
|
5
|
+
const VARIANT_AWARE_CONTROL_UDFS = new Set([
|
|
6
|
+
'queries:getRouteByPathname',
|
|
7
|
+
]);
|
|
8
|
+
export function withPreviewVariantArgs(udfPath, args, context) {
|
|
9
|
+
if (!VARIANT_AWARE_CONTROL_UDFS.has(udfPath) || !hasUsablePreviewToken(context)) {
|
|
10
|
+
return args;
|
|
11
|
+
}
|
|
12
|
+
const variantId = nonEmpty(context?.variantId);
|
|
13
|
+
const releaseId = nonEmpty(context?.releaseId);
|
|
14
|
+
return {
|
|
15
|
+
...args,
|
|
16
|
+
...(args.variantId === undefined && variantId ? { variantId } : {}),
|
|
17
|
+
...(args.releaseId === undefined && releaseId ? { releaseId } : {}),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function nonEmpty(value) {
|
|
21
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
22
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `resolvePreviewTarget` — derive the URL the Studio preview iframe
|
|
3
|
+
* should open for the document being edited (ADR-095, extended by ADR-099).
|
|
4
|
+
*
|
|
5
|
+
* Pure function, JSON-serializable output, no IO. Imports only from
|
|
6
|
+
* `@storystick/client` and standard libraries so the parallel ADR-094
|
|
7
|
+
* absorption (file move into `packages/client/src/preview-target.ts`,
|
|
8
|
+
* Stage 2) is mechanical: file moves, Studio updates one import path.
|
|
9
|
+
*
|
|
10
|
+
* Resolution order:
|
|
11
|
+
*
|
|
12
|
+
* 1. `document === null` (loading) → fallback-root '/'.
|
|
13
|
+
* The bridge's `enabled` gate (ADR-095 §4.1) means this fallback
|
|
14
|
+
* target is never actually consumed by the iframe in production.
|
|
15
|
+
* 2. `document.isRoutable === false` → no-route. This authoritative
|
|
16
|
+
* schema signal blocks every stored or live pathname. `undefined`
|
|
17
|
+
* preserves the legacy path-driven behavior for pre-098 documents.
|
|
18
|
+
* 3. `liveResolvedPathname` is a string → 'self' with that pathname.
|
|
19
|
+
* Wins over the stored value so unsaved keystrokes in the pathname
|
|
20
|
+
* widget reflect in the iframe before autosave commits. The empty
|
|
21
|
+
* string `''` is a valid live value (the homepage canonical shape,
|
|
22
|
+
* ADR-056), distinct from `undefined` (no live value). Per ADR-099
|
|
23
|
+
* D1 step 2, this case takes Tier 1 even when the doc is routable
|
|
24
|
+
* so the homepage shape never regresses to the draft URL.
|
|
25
|
+
* 4. `document.resolvedPathname` is a string → 'self' with that pathname.
|
|
26
|
+
* 5. `document.isRoutable === true` → 'draft' with `pathname: '/'`
|
|
27
|
+
* (ADR-099 D1 Tier 2, Cas A — schema declares pathname, value not
|
|
28
|
+
* yet set). The customer's layout dispatches to the draft via
|
|
29
|
+
* `resolvePreviewDraft()` from `@storystick/next/server`. The
|
|
30
|
+
* `previewHomePath` knob (per-project Tier 2 destination) is
|
|
31
|
+
* deferred to a follow-up — see ADR-099 §7.
|
|
32
|
+
* 6. Else → no-route (doc has no pathname field — Cas B). The Studio
|
|
33
|
+
* may still present it through an explicit host context, but an unrelated
|
|
34
|
+
* previous iframe is never a preview target and the dispatcher must not
|
|
35
|
+
* invent a navigation to `/`.
|
|
36
|
+
*
|
|
37
|
+
* Two canonical forms cross at this seam:
|
|
38
|
+
*
|
|
39
|
+
* | Layer | Homepage | Nested |
|
|
40
|
+
* | -------------------------------------- | -------- | ------------- |
|
|
41
|
+
* | Storage / SDK / `resolvedPathname` col | `""` | `"blog/post"` |
|
|
42
|
+
* | Browser / iframe / navigate envelope | `"/"` | `"/blog/post"`|
|
|
43
|
+
*
|
|
44
|
+
* `pathnameToHref` (in `@storystick/client`, paired with `normalizePathname`)
|
|
45
|
+
* is the single conversion seam.
|
|
46
|
+
*/
|
|
47
|
+
import type { QueryResult } from './types.js';
|
|
48
|
+
export type PreviewTarget = {
|
|
49
|
+
reason: 'self';
|
|
50
|
+
pathname: string;
|
|
51
|
+
} | {
|
|
52
|
+
reason: 'fallback-root';
|
|
53
|
+
pathname: string;
|
|
54
|
+
} | {
|
|
55
|
+
reason: 'no-route';
|
|
56
|
+
pathname: string;
|
|
57
|
+
} | {
|
|
58
|
+
reason: 'draft';
|
|
59
|
+
pathname: string;
|
|
60
|
+
draftDocumentId: string;
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Resolve which URL the Studio preview iframe should open.
|
|
64
|
+
*
|
|
65
|
+
* @param document Document being edited, or `null` while the loading
|
|
66
|
+
* query has not yet returned. Reads `resolvedPathname` and `isRoutable`
|
|
67
|
+
* (formalized on `QueryResult` per ADR-094 / ADR-099). Studio's
|
|
68
|
+
* `DocumentData` is structurally compatible.
|
|
69
|
+
* @param liveResolvedPathname In-flight canonical pathname from the
|
|
70
|
+
* editor's reactive `resolvePathname` query. Wins over the stored
|
|
71
|
+
* value when present.
|
|
72
|
+
*/
|
|
73
|
+
export declare function resolvePreviewTarget(document: QueryResult | null, liveResolvedPathname: string | undefined): PreviewTarget;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `resolvePreviewTarget` — derive the URL the Studio preview iframe
|
|
3
|
+
* should open for the document being edited (ADR-095, extended by ADR-099).
|
|
4
|
+
*
|
|
5
|
+
* Pure function, JSON-serializable output, no IO. Imports only from
|
|
6
|
+
* `@storystick/client` and standard libraries so the parallel ADR-094
|
|
7
|
+
* absorption (file move into `packages/client/src/preview-target.ts`,
|
|
8
|
+
* Stage 2) is mechanical: file moves, Studio updates one import path.
|
|
9
|
+
*
|
|
10
|
+
* Resolution order:
|
|
11
|
+
*
|
|
12
|
+
* 1. `document === null` (loading) → fallback-root '/'.
|
|
13
|
+
* The bridge's `enabled` gate (ADR-095 §4.1) means this fallback
|
|
14
|
+
* target is never actually consumed by the iframe in production.
|
|
15
|
+
* 2. `document.isRoutable === false` → no-route. This authoritative
|
|
16
|
+
* schema signal blocks every stored or live pathname. `undefined`
|
|
17
|
+
* preserves the legacy path-driven behavior for pre-098 documents.
|
|
18
|
+
* 3. `liveResolvedPathname` is a string → 'self' with that pathname.
|
|
19
|
+
* Wins over the stored value so unsaved keystrokes in the pathname
|
|
20
|
+
* widget reflect in the iframe before autosave commits. The empty
|
|
21
|
+
* string `''` is a valid live value (the homepage canonical shape,
|
|
22
|
+
* ADR-056), distinct from `undefined` (no live value). Per ADR-099
|
|
23
|
+
* D1 step 2, this case takes Tier 1 even when the doc is routable
|
|
24
|
+
* so the homepage shape never regresses to the draft URL.
|
|
25
|
+
* 4. `document.resolvedPathname` is a string → 'self' with that pathname.
|
|
26
|
+
* 5. `document.isRoutable === true` → 'draft' with `pathname: '/'`
|
|
27
|
+
* (ADR-099 D1 Tier 2, Cas A — schema declares pathname, value not
|
|
28
|
+
* yet set). The customer's layout dispatches to the draft via
|
|
29
|
+
* `resolvePreviewDraft()` from `@storystick/next/server`. The
|
|
30
|
+
* `previewHomePath` knob (per-project Tier 2 destination) is
|
|
31
|
+
* deferred to a follow-up — see ADR-099 §7.
|
|
32
|
+
* 6. Else → no-route (doc has no pathname field — Cas B). The Studio
|
|
33
|
+
* may still present it through an explicit host context, but an unrelated
|
|
34
|
+
* previous iframe is never a preview target and the dispatcher must not
|
|
35
|
+
* invent a navigation to `/`.
|
|
36
|
+
*
|
|
37
|
+
* Two canonical forms cross at this seam:
|
|
38
|
+
*
|
|
39
|
+
* | Layer | Homepage | Nested |
|
|
40
|
+
* | -------------------------------------- | -------- | ------------- |
|
|
41
|
+
* | Storage / SDK / `resolvedPathname` col | `""` | `"blog/post"` |
|
|
42
|
+
* | Browser / iframe / navigate envelope | `"/"` | `"/blog/post"`|
|
|
43
|
+
*
|
|
44
|
+
* `pathnameToHref` (in `@storystick/client`, paired with `normalizePathname`)
|
|
45
|
+
* is the single conversion seam.
|
|
46
|
+
*/
|
|
47
|
+
import { pathnameToHref } from './normalize-pathname.js';
|
|
48
|
+
// Reserved for palier 2+ — commented out today; activating a variant
|
|
49
|
+
// grows the union additively without breaking call sites that switch
|
|
50
|
+
// on `reason` exhaustively (TS will flag missing cases):
|
|
51
|
+
//
|
|
52
|
+
// | { reason: 'host-page'; pathname: string; hostDocumentId: string }
|
|
53
|
+
// | { reason: 'redirect'; pathname: string; originalPathname: string }
|
|
54
|
+
const FALLBACK_ROOT = { reason: 'fallback-root', pathname: '/' };
|
|
55
|
+
const NO_ROUTE = { reason: 'no-route', pathname: '/' };
|
|
56
|
+
/**
|
|
57
|
+
* Resolve which URL the Studio preview iframe should open.
|
|
58
|
+
*
|
|
59
|
+
* @param document Document being edited, or `null` while the loading
|
|
60
|
+
* query has not yet returned. Reads `resolvedPathname` and `isRoutable`
|
|
61
|
+
* (formalized on `QueryResult` per ADR-094 / ADR-099). Studio's
|
|
62
|
+
* `DocumentData` is structurally compatible.
|
|
63
|
+
* @param liveResolvedPathname In-flight canonical pathname from the
|
|
64
|
+
* editor's reactive `resolvePathname` query. Wins over the stored
|
|
65
|
+
* value when present.
|
|
66
|
+
*/
|
|
67
|
+
export function resolvePreviewTarget(document, liveResolvedPathname) {
|
|
68
|
+
if (document === null)
|
|
69
|
+
return FALLBACK_ROOT;
|
|
70
|
+
if (document.isRoutable === false)
|
|
71
|
+
return NO_ROUTE;
|
|
72
|
+
if (typeof liveResolvedPathname === 'string') {
|
|
73
|
+
return { reason: 'self', pathname: pathnameToHref(liveResolvedPathname) };
|
|
74
|
+
}
|
|
75
|
+
if (typeof document.resolvedPathname === 'string') {
|
|
76
|
+
return { reason: 'self', pathname: pathnameToHref(document.resolvedPathname) };
|
|
77
|
+
}
|
|
78
|
+
if (document.isRoutable === true) {
|
|
79
|
+
return {
|
|
80
|
+
reason: 'draft',
|
|
81
|
+
pathname: '/',
|
|
82
|
+
draftDocumentId: document.documentId,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return NO_ROUTE;
|
|
86
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Transport, RequestContext } from './types.js';
|
|
2
|
+
import { type LocaleRoutingSettings } from './locale-routing.js';
|
|
3
|
+
/** Resolve locale settings under the same admitted generation as content. */
|
|
4
|
+
export declare function fetchProjectLocaleSettings(transport: Pick<Transport, 'request'>, requestContext?: RequestContext): Promise<LocaleRoutingSettings>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { readDocumentLocaleRouting } from './locale-routing.js';
|
|
2
|
+
/** Resolve locale settings under the same admitted generation as content. */
|
|
3
|
+
export async function fetchProjectLocaleSettings(transport, requestContext) {
|
|
4
|
+
const response = await transport.request({ udfPath: 'locales:get', args: {}, requestContext });
|
|
5
|
+
const data = response.data;
|
|
6
|
+
if (!data || typeof data !== 'object' || typeof data.synthetic !== 'boolean')
|
|
7
|
+
throw new TypeError('Storystick: malformed project locale settings.');
|
|
8
|
+
const settings = readDocumentLocaleRouting({ ...data, locale: data.defaultLocale });
|
|
9
|
+
if (!settings)
|
|
10
|
+
throw new TypeError('Storystick: invalid project locale routing.');
|
|
11
|
+
return settings;
|
|
12
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type PublicSiteManifestV1, type PublicReadIdentityV1, type PublicRenderReceiptV1 } from '@storystick/types/public-delivery';
|
|
2
|
+
import type { QueryMeta } from '@storystick/types/query-contract';
|
|
3
|
+
/** Only SDK-owned hydration may transfer an observation to its prepared value. */
|
|
4
|
+
export declare function carryPublicReadIdentity(from: object, to: object): void;
|
|
5
|
+
/** Internal transport observation for the route's SQL-rendered document. */
|
|
6
|
+
export declare function attachPublicRouteIdentity<T extends object>(document: T, identity?: PublicReadIdentityV1): T;
|
|
7
|
+
/** SDK-owned response observation, never reconstructed by a live readback. */
|
|
8
|
+
export declare function attachPublicReadIdentity<T extends object & {
|
|
9
|
+
meta: QueryMeta;
|
|
10
|
+
exactness: string;
|
|
11
|
+
}>(result: T, identity?: PublicReadIdentityV1): T;
|
|
12
|
+
/** Pass every CMS query result that actually contributed to this rendered page.
|
|
13
|
+
* Missing or cloned observations fail closed; no DOM traversal is involved. */
|
|
14
|
+
export declare function createPublicRenderReceipt(input: {
|
|
15
|
+
projectId: string;
|
|
16
|
+
buildId: string;
|
|
17
|
+
deploymentId?: string;
|
|
18
|
+
pathname: string;
|
|
19
|
+
requestToken?: string;
|
|
20
|
+
results: readonly object[];
|
|
21
|
+
}): PublicRenderReceiptV1;
|
|
22
|
+
/** Safe text for an explicit application/json script owned by the renderer. */
|
|
23
|
+
export declare function serializePublicRenderReceipt(receipt: PublicRenderReceiptV1): string;
|
|
24
|
+
/** Call in the same render/build as the route inventory and sitemap. The inputs
|
|
25
|
+
* are the actual SDK results used for that inventory, including an empty list. */
|
|
26
|
+
export declare function createPublicSiteManifest(input: {
|
|
27
|
+
projectId: string;
|
|
28
|
+
buildId: string;
|
|
29
|
+
deploymentId: string;
|
|
30
|
+
routes: {
|
|
31
|
+
pathname: string;
|
|
32
|
+
indexable: boolean;
|
|
33
|
+
}[];
|
|
34
|
+
sitemapPath: string;
|
|
35
|
+
results: readonly object[];
|
|
36
|
+
}): PublicSiteManifestV1;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { isPublicRenderReceipt, isPublicSiteManifest, PUBLIC_SITE_MANIFEST_PATH } from '@storystick/types/public-delivery';
|
|
2
|
+
const publicReads = new WeakMap();
|
|
3
|
+
/** Only SDK-owned hydration may transfer an observation to its prepared value. */
|
|
4
|
+
export function carryPublicReadIdentity(from, to) {
|
|
5
|
+
const identity = publicReads.get(from);
|
|
6
|
+
if (identity)
|
|
7
|
+
publicReads.set(to, identity);
|
|
8
|
+
}
|
|
9
|
+
/** Internal transport observation for the route's SQL-rendered document. */
|
|
10
|
+
export function attachPublicRouteIdentity(document, identity) {
|
|
11
|
+
if (identity)
|
|
12
|
+
publicReads.set(document, structuredClone(identity));
|
|
13
|
+
return document;
|
|
14
|
+
}
|
|
15
|
+
/** SDK-owned response observation, never reconstructed by a live readback. */
|
|
16
|
+
export function attachPublicReadIdentity(result, identity) {
|
|
17
|
+
if (!identity)
|
|
18
|
+
return result;
|
|
19
|
+
if (result.exactness !== 'exact' || result.meta.scope.kind !== 'activePublication' ||
|
|
20
|
+
result.meta.projectionHash !== identity.projection.projectionHash || result.meta.lensHash !== identity.projection.lensHash ||
|
|
21
|
+
result.meta.consistency.projectedThroughSeq !== identity.projection.projectedThroughSeq)
|
|
22
|
+
throw new TypeError('Public result and read identity disagree.');
|
|
23
|
+
publicReads.set(result, structuredClone(identity));
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
/** Pass every CMS query result that actually contributed to this rendered page.
|
|
27
|
+
* Missing or cloned observations fail closed; no DOM traversal is involved. */
|
|
28
|
+
export function createPublicRenderReceipt(input) {
|
|
29
|
+
const reads = input.results.map(result => {
|
|
30
|
+
const identity = publicReads.get(result);
|
|
31
|
+
if (!identity)
|
|
32
|
+
throw new TypeError('A rendered query has no authoritative public read identity. Configure an application build and public delivery.');
|
|
33
|
+
return structuredClone(identity);
|
|
34
|
+
});
|
|
35
|
+
const receipt = { version: 1, projectId: input.projectId, buildId: input.buildId,
|
|
36
|
+
deploymentId: input.deploymentId ?? null, pathname: input.pathname, reads, ...(input.requestToken ? { requestToken: input.requestToken } : {}) };
|
|
37
|
+
if (!isPublicRenderReceipt(receipt))
|
|
38
|
+
throw new TypeError('Invalid public render receipt.');
|
|
39
|
+
return receipt;
|
|
40
|
+
}
|
|
41
|
+
/** Safe text for an explicit application/json script owned by the renderer. */
|
|
42
|
+
export function serializePublicRenderReceipt(receipt) {
|
|
43
|
+
if (!isPublicRenderReceipt(receipt))
|
|
44
|
+
throw new TypeError('Invalid public render receipt.');
|
|
45
|
+
return JSON.stringify(receipt).replace(/[<>&\u2028\u2029]/g, value => `\\u${value.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
46
|
+
}
|
|
47
|
+
/** Call in the same render/build as the route inventory and sitemap. The inputs
|
|
48
|
+
* are the actual SDK results used for that inventory, including an empty list. */
|
|
49
|
+
export function createPublicSiteManifest(input) {
|
|
50
|
+
const manifest = { version: 1, routes: [...input.routes].sort((a, b) => a.pathname.localeCompare(b.pathname)), sitemapPath: input.sitemapPath,
|
|
51
|
+
receipt: createPublicRenderReceipt({ ...input, pathname: PUBLIC_SITE_MANIFEST_PATH }) };
|
|
52
|
+
if (!isPublicSiteManifest(manifest))
|
|
53
|
+
throw new TypeError('Invalid public site manifest.');
|
|
54
|
+
return manifest;
|
|
55
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { StorystickError } from './errors.js';
|
|
2
|
+
import { getInternalClientRuntime } from './query-client.js';
|
|
3
|
+
import { LOCALE_ROUTE_COLLISION } from '@storystick/types/errors/codes/locale-lifecycle';
|
|
4
|
+
import { samePublicReadIdentity } from '@storystick/types/public-delivery';
|
|
5
|
+
import { attachPublicRouteIdentity } from './public-delivery.js';
|
|
6
|
+
export async function publishedLocaleRoutes(client, storyTypes) {
|
|
7
|
+
if (storyTypes.length === 0)
|
|
8
|
+
return [];
|
|
9
|
+
const runtime = getInternalClientRuntime(client);
|
|
10
|
+
const routes = [];
|
|
11
|
+
const seenCursors = new Set();
|
|
12
|
+
let cursor = null;
|
|
13
|
+
let identity;
|
|
14
|
+
let witnessed;
|
|
15
|
+
for (let page = 0; page < 10_000; page += 1) {
|
|
16
|
+
const response = await runtime.transport.request({
|
|
17
|
+
udfPath: 'queries:listPublishedLocaleRoutes',
|
|
18
|
+
args: { storyTypes: [...storyTypes], cursor, numItems: 500 },
|
|
19
|
+
options: undefined,
|
|
20
|
+
requestContext: runtime.requestContext,
|
|
21
|
+
});
|
|
22
|
+
if (witnessed !== undefined && (witnessed !== Boolean(response.publicReadIdentity) ||
|
|
23
|
+
(identity && response.publicReadIdentity && !samePublicReadIdentity(identity, response.publicReadIdentity))))
|
|
24
|
+
throw new StorystickError('The public route inventory changed during pagination.', {
|
|
25
|
+
code: 'storystick/QUERY_GENERATION_CHANGED', category: 'system', retryable: true,
|
|
26
|
+
});
|
|
27
|
+
witnessed = Boolean(response.publicReadIdentity);
|
|
28
|
+
identity = response.publicReadIdentity;
|
|
29
|
+
const result = response.data;
|
|
30
|
+
routes.push(...result.page.map((route) => ({
|
|
31
|
+
...route,
|
|
32
|
+
seo: route.seo ?? { indexable: true },
|
|
33
|
+
})));
|
|
34
|
+
if (result.isDone)
|
|
35
|
+
break;
|
|
36
|
+
const nextCursor = result.continueCursor;
|
|
37
|
+
if (!nextCursor || seenCursors.has(nextCursor)) {
|
|
38
|
+
throw new StorystickError('Published locale route pagination returned a missing or repeated cursor.', {
|
|
39
|
+
code: 'storystick/QUERY_CURSOR_INVALID',
|
|
40
|
+
category: 'system',
|
|
41
|
+
retryable: false,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
seenCursors.add(nextCursor);
|
|
45
|
+
cursor = nextCursor;
|
|
46
|
+
if (page === 9_999) {
|
|
47
|
+
throw new StorystickError('Published locale route pagination exceeded the safety bound.', {
|
|
48
|
+
code: 'storystick/QUERY_PROTOCOL',
|
|
49
|
+
category: 'system',
|
|
50
|
+
retryable: false,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Keep the whole owning route, not just its id — the collision report has
|
|
55
|
+
// to name the locale each side published under, which is the only way a
|
|
56
|
+
// customer can tell which of the two publications to rename.
|
|
57
|
+
const owners = new Map();
|
|
58
|
+
for (const route of routes) {
|
|
59
|
+
const owner = owners.get(route.publicPathname);
|
|
60
|
+
if (owner && owner.documentId !== route.documentId) {
|
|
61
|
+
// Typed, not a message-only error: `site.params` and `site.sitemap`
|
|
62
|
+
// are the only guard on the locale-prefixed public URL namespace, and
|
|
63
|
+
// customer build fallbacks re-throw on `error.code`. A plain `Error`
|
|
64
|
+
// silently degrades a real collision to zero static params and an
|
|
65
|
+
// empty sitemap.
|
|
66
|
+
throw new StorystickError(`LOCALE_ROUTE_COLLISION — "${route.publicPathname}" is published by both ` +
|
|
67
|
+
`"${owner.documentId}" (locale "${owner.locale}") and ` +
|
|
68
|
+
`"${route.documentId}" (locale "${route.locale}"). ` +
|
|
69
|
+
'Two documents cannot own the same public URL. Rename one of the two ' +
|
|
70
|
+
'publications, or unpublish it, then rebuild.', {
|
|
71
|
+
code: LOCALE_ROUTE_COLLISION.code,
|
|
72
|
+
category: LOCALE_ROUTE_COLLISION.category,
|
|
73
|
+
recoverable: LOCALE_ROUTE_COLLISION.recoverable,
|
|
74
|
+
retryable: LOCALE_ROUTE_COLLISION.retryable,
|
|
75
|
+
// The catalogue payload for this code was minted for the backend's
|
|
76
|
+
// locale-PREFIX collisions, where `conflictingLocale` is the enabled
|
|
77
|
+
// locale code a pathname's first segment shadows. This call site has
|
|
78
|
+
// no such value — it holds two documents that resolved to the same
|
|
79
|
+
// public URL — so `conflictingLocale` is omitted rather than filled
|
|
80
|
+
// with something that means a different thing, and the second party
|
|
81
|
+
// is reported under explicit `conflictingDocument*` keys. Extending
|
|
82
|
+
// the base payload follows `releases.ts` (`releaseId`) and
|
|
83
|
+
// `locales.ts` (`collisions`, `truncated`).
|
|
84
|
+
payload: {
|
|
85
|
+
documentId: route.documentId,
|
|
86
|
+
locale: route.locale,
|
|
87
|
+
pathname: route.pathname,
|
|
88
|
+
publicPathname: route.publicPathname,
|
|
89
|
+
storyType: route.storyType,
|
|
90
|
+
conflictingDocumentId: owner.documentId,
|
|
91
|
+
conflictingDocumentLocale: owner.locale,
|
|
92
|
+
conflictingPathname: owner.pathname,
|
|
93
|
+
conflictingStoryType: owner.storyType,
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
owners.set(route.publicPathname, route);
|
|
98
|
+
}
|
|
99
|
+
return attachPublicRouteIdentity([...owners.values()], identity);
|
|
100
|
+
}
|