@storystick/astro 0.0.0-stage → 0.2.0-rc.0
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 +23 -0
- package/README.md +163 -2
- package/dist/.source-hash +1 -0
- package/dist/browser.d.ts +1 -0
- package/dist/browser.js +327 -0
- package/dist/components/File.astro +9 -0
- package/dist/components/Image.astro +18 -0
- package/dist/components/Link.astro +14 -0
- package/dist/components/PreviewHost.astro +12 -0
- package/dist/components/PublicDeliveryReceipt.astro +6 -0
- package/dist/components/PublicHost.astro +3 -0
- package/dist/components/RichText.astro +7 -0
- package/dist/components/RichTextInline.astro +6 -0
- package/dist/components/RichTextNode.astro +7 -0
- package/dist/components/Slice.astro +8 -0
- package/dist/components/SliceZone.astro +20 -0
- package/dist/components/Storystick.astro +6 -0
- package/dist/components/Text.astro +8 -0
- package/dist/components/Video.astro +13 -0
- package/dist/components/Zone.astro +11 -0
- package/dist/components/index.d.ts +12 -0
- package/dist/components/index.js +12 -0
- package/dist/control.d.ts +3 -0
- package/dist/control.js +123 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +9 -0
- package/dist/integration.d.ts +10 -0
- package/dist/integration.js +59 -0
- package/dist/middleware.d.ts +1 -0
- package/dist/middleware.js +34 -0
- package/dist/navigation.d.ts +7 -0
- package/dist/navigation.js +34 -0
- package/dist/publication.d.ts +10 -0
- package/dist/publication.js +29 -0
- package/dist/readback.d.ts +14 -0
- package/dist/readback.js +18 -0
- package/dist/render-gate.d.ts +36 -0
- package/dist/render-gate.js +84 -0
- package/dist/rich-text.d.ts +35 -0
- package/dist/rich-text.js +127 -0
- package/dist/routes/control.d.ts +3 -0
- package/dist/routes/control.js +5 -0
- package/dist/routes/error.astro +8 -0
- package/dist/routes/readback.d.ts +3 -0
- package/dist/routes/readback.js +4 -0
- package/dist/server.d.ts +128 -0
- package/dist/server.js +305 -0
- package/dist/sitemap.d.ts +5 -0
- package/dist/sitemap.js +28 -0
- package/dist/slices.d.ts +28 -0
- package/dist/slices.js +34 -0
- package/dist/text.d.ts +10 -0
- package/dist/text.js +9 -0
- package/package.json +73 -3
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
3
|
+
import { resolveImage, type StorystickImageField, type ResolveImageOptions } from '@storystick/media';
|
|
4
|
+
import { cleanPreviewAttributes } from '../text.js';
|
|
5
|
+
interface Props extends Omit<HTMLAttributes<'img'>, 'src' | 'srcset' | 'sizes' | 'width' | 'height' | 'alt'> { image: StorystickImageField | null | undefined; options?: ResolveImageOptions; responsive?: boolean; alt?: string }
|
|
6
|
+
const { image, options = {}, responsive = true, alt, ...attributes } = Astro.props;
|
|
7
|
+
const resolved = resolveImage(alt !== undefined && image ? { ...image, alt } : image, { ...options,
|
|
8
|
+
...(responsive && options.sizes === undefined ? { sizes: attributes.loading === 'eager' ? '100vw' : 'auto, 100vw' } : {}),
|
|
9
|
+
...(!responsive ? { widths: [], sizes: undefined } : {}),
|
|
10
|
+
});
|
|
11
|
+
const props = resolved.status === 'ok' ? resolved.props : null;
|
|
12
|
+
const { srcSet, style, ...native } = props ?? {};
|
|
13
|
+
const position = style && 'objectPosition' in style ? style.objectPosition : undefined;
|
|
14
|
+
const nativeStyle = typeof attributes.style === 'string' ? `${position ? `object-position:${position};` : ''}${attributes.style}` : position || attributes.style ? { ...(position ? { 'object-position': position } : {}), ...attributes.style } : undefined;
|
|
15
|
+
const output = { ...cleanPreviewAttributes(attributes), ...native, srcset: srcSet, style: nativeStyle,
|
|
16
|
+
loading: attributes.loading ?? (responsive ? props?.loading : 'eager'), decoding: attributes.decoding ?? props?.decoding };
|
|
17
|
+
---
|
|
18
|
+
{props ? <img {...output} /> : <slot name="fallback" />}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
3
|
+
import { resolveLink, resolveDocumentLink, type LinkDocument, type LinkValue, type LinkOptions } from '@storystick/types';
|
|
4
|
+
import { previewText, cleanPreviewAttributes } from '../text.js';
|
|
5
|
+
type Source = { link: (LinkValue & { text?: string; title?: string | null }) | null | undefined; document?: never; href?: never } | { document: LinkDocument | null | undefined; link?: never; href?: never } | { href: string | null | undefined; link?: never; document?: never };
|
|
6
|
+
type Props = Omit<HTMLAttributes<'a'>, 'href' | 'target' | 'rel'> & Source & { options?: LinkOptions & { fallbackLabel?: string } };
|
|
7
|
+
const { link, document, href, options, title, ...attributes } = Astro.props;
|
|
8
|
+
const resolved = document ? resolveDocumentLink(document, options) : resolveLink(link ?? (href == null ? null : { href }), options);
|
|
9
|
+
const text = previewText(link?.text ?? options?.fallbackLabel);
|
|
10
|
+
const labelAttrs = resolved.props['data-ss-e'] ? {} : text.attributes;
|
|
11
|
+
const diagnostic = ['ok', 'external', 'asset'].includes(resolved.status) ? undefined : resolved.status;
|
|
12
|
+
const selectedTitle = Object.hasOwn(Astro.props, 'title') ? title : link?.title;
|
|
13
|
+
---
|
|
14
|
+
<a {...cleanPreviewAttributes(attributes)} {...resolved.props} title={previewText(selectedTitle).text || undefined} data-ss-link-status={diagnostic} {...labelAttrs}><slot>{text.text}</slot></a>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { ClientRouter } from 'astro:transitions';
|
|
3
|
+
import { siteRuntime, type SiteHandle } from '../server.js';
|
|
4
|
+
interface Props { site: SiteHandle; debug?: boolean }
|
|
5
|
+
const { site, debug = false } = Astro.props;
|
|
6
|
+
const bootstrap = await siteRuntime(site).bootstrap(Astro);
|
|
7
|
+
---
|
|
8
|
+
{bootstrap && <>
|
|
9
|
+
<ClientRouter />
|
|
10
|
+
<div data-storystick-astro-host data-storystick-debug={debug ? '' : undefined} transition:persist="storystick-astro-host"></div>
|
|
11
|
+
<script type="application/json" data-storystick-astro-render set:html={JSON.stringify(bootstrap).replaceAll('<', '\\u003c')} />
|
|
12
|
+
</>}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { PUBLIC_RENDER_RECEIPT_ID, serializePublicRenderReceipt, type PublicRenderReceiptV1 } from '@storystick/client';
|
|
3
|
+
interface Props { receipt: PublicRenderReceiptV1 | null }
|
|
4
|
+
const { receipt } = Astro.props;
|
|
5
|
+
---
|
|
6
|
+
{receipt && <script is:inline id={PUBLIC_RENDER_RECEIPT_ID} type="application/json" set:html={serializePublicRenderReceipt(receipt)} />}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { richTextNodes, type RichTextOptions } from '../rich-text.js';
|
|
3
|
+
import RichTextNode from './RichTextNode.astro';
|
|
4
|
+
interface Props extends RichTextOptions {}
|
|
5
|
+
const nodes = richTextNodes(Astro.props);
|
|
6
|
+
---
|
|
7
|
+
{nodes.length ? nodes.map(node => <RichTextNode node={node} />) : <slot name="fallback" />}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { RenderNode } from '../rich-text.js';
|
|
3
|
+
interface Props { node: RenderNode }
|
|
4
|
+
const { node } = Astro.props;
|
|
5
|
+
const Tag = node.tag;
|
|
6
|
+
---
|
|
7
|
+
{node.text !== undefined ? node.text : Tag ? <Tag {...node.props}>{node.children?.map(child => <Astro.self node={child} />)}</Tag> : node.children?.map(child => <Astro.self node={child} />)}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { HTMLTag, HTMLAttributes } from 'astro/types';
|
|
3
|
+
import { sliceAttributes, type SliceData } from '../slices.js';
|
|
4
|
+
import { cleanPreviewAttributes } from '../text.js';
|
|
5
|
+
interface Props extends HTMLAttributes<'section'> { slice: SliceData; siblings?: readonly unknown[]; as?: HTMLTag }
|
|
6
|
+
const { slice, siblings, as: Tag = 'section', ...attributes } = Astro.props;
|
|
7
|
+
---
|
|
8
|
+
<Tag {...sliceAttributes(slice, siblings)} {...cleanPreviewAttributes(attributes)}><slot /></Tag>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { AstroComponentFactory } from 'astro/runtime/server/index.js';
|
|
3
|
+
import { prepareContentSource, prepareSlice, readContentOrigin, buildSliceProvenance, type ContentSource, type SliceData } from '../slices.js';
|
|
4
|
+
import type { SliceContractRegistry, SliceRenderFailure } from '@storystick/types';
|
|
5
|
+
interface Props { slices?: readonly SliceData[] | null; components: Record<string, AstroComponentFactory>; source?: ContentSource; context?: unknown; contracts?: SliceContractRegistry; fallback?: AstroComponentFactory; onRenderFailure?: (failure: SliceRenderFailure) => void }
|
|
6
|
+
const { slices: input, components, source, context, contracts, fallback: Fallback, onRenderFailure } = Astro.props;
|
|
7
|
+
const slices = prepareContentSource(input, source) ?? [];
|
|
8
|
+
const rendered = slices.map((value, index) => {
|
|
9
|
+
const prepared = prepareSlice(value, index, Object.keys(components), contracts);
|
|
10
|
+
if (prepared.failure) {
|
|
11
|
+
onRenderFailure?.(prepared.failure);
|
|
12
|
+
if (!Fallback) throw new TypeError(`Astro slice at index ${index} could not render (${prepared.failure.reason}).`);
|
|
13
|
+
return { Component: Fallback, props: { failure: prepared.failure } };
|
|
14
|
+
}
|
|
15
|
+
const slice = prepared.slice;
|
|
16
|
+
const content = readContentOrigin(slice);
|
|
17
|
+
return { Component: components[slice._type]!, props: { slice, siblings: slices, zones: content?.zones, bindings: slice._key ? buildSliceProvenance(content ?? source?.document, slice) : undefined, context } };
|
|
18
|
+
});
|
|
19
|
+
---
|
|
20
|
+
{rendered.map(({ Component, props }) => <Component {...props} />)}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { HTMLTag, HTMLAttributes } from 'astro/types';
|
|
3
|
+
import { previewText, cleanPreviewAttributes } from '../text.js';
|
|
4
|
+
interface Props extends Omit<HTMLAttributes<'span'>, 'children'> { value?: string | number | boolean | null; as?: HTMLTag }
|
|
5
|
+
const { value, as: Tag = 'span', ...attributes } = Astro.props;
|
|
6
|
+
const text = previewText(value);
|
|
7
|
+
---
|
|
8
|
+
<Tag {...cleanPreviewAttributes(attributes)} {...text.attributes}>{text.text}</Tag>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
3
|
+
import { resolveVideo, type StorystickVideoField, type ResolveVideoOptions } from '@storystick/media/video';
|
|
4
|
+
import { cleanPreviewAttributes } from '../text.js';
|
|
5
|
+
interface Props extends Omit<HTMLAttributes<'video'>, 'src' | 'poster' | 'width' | 'height'> { video: StorystickVideoField | null | undefined; options?: ResolveVideoOptions }
|
|
6
|
+
const { video, options, ...attributes } = Astro.props;
|
|
7
|
+
const resolved = resolveVideo(video, options);
|
|
8
|
+
const props = resolved.status === 'ok' ? resolved.props : null;
|
|
9
|
+
const { crossOrigin, playsInline, ...native } = props ?? {};
|
|
10
|
+
const output = { ...cleanPreviewAttributes(attributes), ...native, crossorigin: attributes.crossorigin ?? crossOrigin,
|
|
11
|
+
playsinline: attributes.playsinline ?? playsInline, controls: attributes.controls ?? props?.controls, preload: attributes.preload ?? props?.preload };
|
|
12
|
+
---
|
|
13
|
+
{resolved.status === 'ok' && props ? <video {...output}>{resolved.tracks.map(track => track.status === 'ok' && <track {...track.props}/>)}<slot /></video> : <slot name="fallback" />}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { readContentOrigin } from '../slices.js';
|
|
3
|
+
import type { AstroComponentFactory } from 'astro/runtime/server/index.js';
|
|
4
|
+
import RichText from './RichText.astro';
|
|
5
|
+
import SliceZone from './SliceZone.astro';
|
|
6
|
+
interface Props { zone?: { value: unknown; path: string }; components: Record<string, AstroComponentFactory>; context?: unknown }
|
|
7
|
+
const { zone, components, context } = Astro.props;
|
|
8
|
+
const value = zone?.value;
|
|
9
|
+
const rich = typeof value === 'string' || readContentOrigin(value)?.kind === 'richtext' || !!value && typeof value === 'object' && 'type' in value && value.type === 'doc';
|
|
10
|
+
---
|
|
11
|
+
{rich ? <RichText content={value} components={{ embeds: components }} context={context} /> : Array.isArray(value) ? <SliceZone slices={value} components={components} context={context} /> : <slot name="fallback" />}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { default as Storystick } from './Storystick.astro';
|
|
2
|
+
export { default as Text } from './Text.astro';
|
|
3
|
+
export { default as Image } from './Image.astro';
|
|
4
|
+
export { default as File } from './File.astro';
|
|
5
|
+
export { default as Video } from './Video.astro';
|
|
6
|
+
export { default as Link } from './Link.astro';
|
|
7
|
+
export { default as Slice } from './Slice.astro';
|
|
8
|
+
export { default as SliceZone } from './SliceZone.astro';
|
|
9
|
+
export { default as PublicDeliveryReceipt } from './PublicDeliveryReceipt.astro';
|
|
10
|
+
export { default as RichText } from './RichText.astro';
|
|
11
|
+
export { default as RichTextInline } from './RichTextInline.astro';
|
|
12
|
+
export { default as Zone } from './Zone.astro';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { default as Storystick } from './Storystick.astro';
|
|
2
|
+
export { default as Text } from './Text.astro';
|
|
3
|
+
export { default as Image } from './Image.astro';
|
|
4
|
+
export { default as File } from './File.astro';
|
|
5
|
+
export { default as Video } from './Video.astro';
|
|
6
|
+
export { default as Link } from './Link.astro';
|
|
7
|
+
export { default as Slice } from './Slice.astro';
|
|
8
|
+
export { default as SliceZone } from './SliceZone.astro';
|
|
9
|
+
export { default as PublicDeliveryReceipt } from './PublicDeliveryReceipt.astro';
|
|
10
|
+
export { default as RichText } from './RichText.astro';
|
|
11
|
+
export { default as RichTextInline } from './RichTextInline.astro';
|
|
12
|
+
export { default as Zone } from './Zone.astro';
|
package/dist/control.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { previewControlErrorResponse, previewInitialRouteErrorResponse, previewErrorResponse } from '@storystick/preview/server/control-errors';
|
|
2
|
+
import { previewControlClient } from '@storystick/preview/server/control-client';
|
|
3
|
+
import { previewProvenanceRuntimeScript } from '@storystick/preview/provenance';
|
|
4
|
+
import { createPreviewScope, writePreviewScopeToSearchParams } from '@storystick/preview/identity';
|
|
5
|
+
import { activatePreviewGrantForServer, parsePreviewActivation, assertSafePreviewRedirect, isSafePreviewSessionId, exchangePreviewAuthoringAdmissionForServer, classifyPreviewGrantError, PreviewGrantExchangeError, PreviewActivationError, PreviewRedirectError } from '@storystick/preview/server/grant';
|
|
6
|
+
import { resolvePreviewGrantAccess, readPreviewGrantCookie } from '@storystick/preview/server/extract-token';
|
|
7
|
+
import { buildPreviewGrantCookieFromActivation, setPreviewGrantCookieOnResponse } from '@storystick/preview/server/route-session-cookie';
|
|
8
|
+
import { readPreviewContextCommand, readBoundedPreviewJson } from '@storystick/preview/server/read-context-command';
|
|
9
|
+
const reader = (ctx) => ({ getAll: () => (ctx.request.headers.get('cookie') ?? '').split(';')
|
|
10
|
+
.map(part => part.slice(0, part.indexOf('=')).trim()).filter(Boolean)
|
|
11
|
+
.map(name => ({ name, value: ctx.cookies.get(name)?.value ?? '' })) });
|
|
12
|
+
const json = (body, status = 200) => Response.json(body, { status, headers: { 'Cache-Control': 'private, no-store' } });
|
|
13
|
+
function control(token, config) { return previewControlClient(config.endpoint, token); }
|
|
14
|
+
export function createControlHandler(configuration) {
|
|
15
|
+
return async function handleControl(ctx) {
|
|
16
|
+
const action = ctx.params.action;
|
|
17
|
+
const config = { ...await configuration(), requestOrigin: ctx.url.origin };
|
|
18
|
+
try {
|
|
19
|
+
const methods = { preview: ['GET'], 'preview/token': ['POST'], 'preview/context': ['GET', 'POST'], 'preview/disable': ['POST'], 'preview/authoring': ['POST'], 'provenance-runtime': ['GET'] };
|
|
20
|
+
if (!action || !methods[action])
|
|
21
|
+
return json({ code: 'PREVIEW_ROUTE_UNSUPPORTED' }, 404);
|
|
22
|
+
if (!methods[action].includes(ctx.request.method))
|
|
23
|
+
return new Response(null, { status: 405, headers: { Allow: methods[action].join(', '), 'Cache-Control': 'no-store' } });
|
|
24
|
+
if (ctx.request.method === 'POST' && ctx.request.headers.get('origin') !== ctx.url.origin)
|
|
25
|
+
return json({ code: 'PREVIEW_ORIGIN_MISMATCH' }, 403);
|
|
26
|
+
if (action === 'preview' && ctx.request.method === 'GET') {
|
|
27
|
+
const sessionId = ctx.url.searchParams.get('_ss_session');
|
|
28
|
+
if (!isSafePreviewSessionId(sessionId))
|
|
29
|
+
return json({ code: 'PREVIEW_SESSION_MISSING' }, 400);
|
|
30
|
+
const activation = parsePreviewActivation(ctx.url.searchParams.get('_ss_activation'));
|
|
31
|
+
const target = assertSafePreviewRedirect(ctx.url.searchParams.get('redirect') || '/', '');
|
|
32
|
+
const previewScope = createPreviewScope({ variantId: ctx.url.searchParams.get('_ss_variant'), releaseId: ctx.url.searchParams.get('_ss_release') });
|
|
33
|
+
const result = await activatePreviewGrantForServer({ ...config, ...activation, sessionId });
|
|
34
|
+
if (result.projectId !== config.projectId || result.previewOrigin !== ctx.url.origin ||
|
|
35
|
+
result.sessionId !== sessionId || result.grantId !== activation.grantId || result.grantExpiresAt <= Date.now())
|
|
36
|
+
throw new Error('PREVIEW_GRANT_BINDING_MISMATCH');
|
|
37
|
+
await setPreviewGrantCookieOnResponse({ response: ctx, reader: reader(ctx), scope: config,
|
|
38
|
+
requestUrl: ctx.url.href, basePath: '', sequence: result.admissionSequence,
|
|
39
|
+
cookie: buildPreviewGrantCookieFromActivation(result, Date.now(), config.applicationBuildId) });
|
|
40
|
+
const destination = new URL(target, ctx.url.origin);
|
|
41
|
+
for (const key of [...destination.searchParams.keys()])
|
|
42
|
+
if (key.startsWith('_ss_') && key !== '_ss_draft')
|
|
43
|
+
destination.searchParams.delete(key);
|
|
44
|
+
destination.searchParams.set('_ss_session', sessionId);
|
|
45
|
+
writePreviewScopeToSearchParams(destination.searchParams, previewScope);
|
|
46
|
+
return new Response(null, { status: 302, headers: { Location: destination.href, 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' } });
|
|
47
|
+
}
|
|
48
|
+
if (action === 'preview/disable' && ctx.request.method === 'POST') {
|
|
49
|
+
try {
|
|
50
|
+
const fields = await resolvePreviewGrantAccess(reader(ctx), config);
|
|
51
|
+
if (fields) {
|
|
52
|
+
const command = await readPreviewContextCommand(ctx.request);
|
|
53
|
+
if (command) {
|
|
54
|
+
if (command.perspective !== null)
|
|
55
|
+
return json({ code: 'PREVIEW_CONTEXT_INVALID' }, 400);
|
|
56
|
+
const result = await control(fields.token, config).changeContext(command);
|
|
57
|
+
if (result.status === 'conflict')
|
|
58
|
+
return json({ code: 'PREVIEW_CONTEXT_CONFLICT' }, 409);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
if (![401, 403].includes(error.status) && error.code !== 'storystick/CONTENT_SHAPE_RELOAD_REQUIRED')
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
// Exit uses the shared context command. The admission cookie remains valid for other views.
|
|
67
|
+
return json({ status: 'disabled' });
|
|
68
|
+
}
|
|
69
|
+
const fields = await resolvePreviewGrantAccess(reader(ctx), config);
|
|
70
|
+
if (!fields)
|
|
71
|
+
return json({ code: 'PREVIEW_GRANT_MISSING' }, 401);
|
|
72
|
+
const client = control(fields.token, config);
|
|
73
|
+
if (action === 'preview/token' && ctx.request.method === 'POST') {
|
|
74
|
+
const sessionId = ctx.url.searchParams.get('_ss_session');
|
|
75
|
+
if (!isSafePreviewSessionId(sessionId))
|
|
76
|
+
return json({ code: 'PREVIEW_SESSION_MISSING' }, 400);
|
|
77
|
+
await client.context({ sessionId });
|
|
78
|
+
return json({ token: fields.token, expiresAt: fields.tokenExpiresAt,
|
|
79
|
+
refreshAfter: Date.now() + Math.max(1000, (fields.tokenExpiresAt - Date.now()) * .8), sessionId, projectId: fields.projectId });
|
|
80
|
+
}
|
|
81
|
+
if (action === 'preview/context') {
|
|
82
|
+
if (ctx.request.method === 'GET')
|
|
83
|
+
return json(await client.context({}));
|
|
84
|
+
const command = await readPreviewContextCommand(ctx.request);
|
|
85
|
+
if (!command)
|
|
86
|
+
return json({ code: 'PREVIEW_CONTEXT_INVALID' }, 400);
|
|
87
|
+
const result = await client.changeContext(command);
|
|
88
|
+
return json(result, result.status === 'conflict' ? 409 : 200);
|
|
89
|
+
}
|
|
90
|
+
if (action === 'preview/authoring' && ctx.request.method === 'POST') {
|
|
91
|
+
const renewal = await readBoundedPreviewJson(ctx.request);
|
|
92
|
+
if (!renewal || Array.isArray(renewal) || typeof renewal !== 'object' ||
|
|
93
|
+
Object.keys(renewal).some(key => !['admissionId', 'fileDelegationId'].includes(key)) ||
|
|
94
|
+
Boolean(renewal.admissionId) !== Boolean(renewal.fileDelegationId) ||
|
|
95
|
+
Object.values(renewal).some(v => typeof v !== 'string' || !v.length || v.length > 256))
|
|
96
|
+
return json({ code: 'PREVIEW_AUTHORING_REQUEST_INVALID' }, 400);
|
|
97
|
+
const grant = await readPreviewGrantCookie(reader(ctx), config);
|
|
98
|
+
const result = await exchangePreviewAuthoringAdmissionForServer({ ...config,
|
|
99
|
+
grantToken: grant.grantToken, contentShapeCapability: grant.contentShapeCapability, ...renewal });
|
|
100
|
+
if (result.projectShortId !== config.projectId)
|
|
101
|
+
throw new Error('PREVIEW_PROJECT_MISMATCH');
|
|
102
|
+
return json(result);
|
|
103
|
+
}
|
|
104
|
+
if (action === 'provenance-runtime' && ctx.request.method === 'GET')
|
|
105
|
+
return new Response(previewProvenanceRuntimeScript, {
|
|
106
|
+
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/javascript', 'X-Content-Type-Options': 'nosniff' },
|
|
107
|
+
});
|
|
108
|
+
return json({ code: 'PREVIEW_ROUTE_UNSUPPORTED' }, 404);
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
console.error('[storystick/astro control]', error.code ?? error.message);
|
|
112
|
+
if (error instanceof PreviewActivationError)
|
|
113
|
+
return previewErrorResponse(400, 'PREVIEW_ACTIVATION_INVALID', 'Preview activation is missing or malformed.', { category: 'validation', recoverable: true });
|
|
114
|
+
if (error instanceof PreviewRedirectError)
|
|
115
|
+
return previewErrorResponse(400, 'PREVIEW_REDIRECT_UNSAFE', 'Unsafe Preview redirect.', { category: 'validation', recoverable: true });
|
|
116
|
+
if (error instanceof PreviewGrantExchangeError) {
|
|
117
|
+
const failure = classifyPreviewGrantError(error, action === 'preview' ? 'activate' : action === 'preview/authoring' ? 'authoring' : 'exchange');
|
|
118
|
+
return action === 'preview' ? previewInitialRouteErrorResponse(ctx.request, failure) : previewErrorResponse(failure);
|
|
119
|
+
}
|
|
120
|
+
return previewControlErrorResponse(error);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { createSite, AstroPreviewError } from './server.js';
|
|
2
|
+
export type { Site, SiteHandle, SiteOptions, RequestContext } from './server.js';
|
|
3
|
+
export { resolveImage, resolveFile } from '@storystick/media';
|
|
4
|
+
export { resolveVideo } from '@storystick/media/video';
|
|
5
|
+
export { resolveLink } from '@storystick/client';
|
|
6
|
+
export { sliceAttributes } from './slices.js';
|
|
7
|
+
export { previewText } from './text.js';
|
|
8
|
+
export { toText } from './rich-text.js';
|
|
9
|
+
export type { RichTextOptions, RichTextRegistry } from './rich-text.js';
|
|
10
|
+
export type { SliceData, SliceComponentProps } from './slices.js';
|
|
11
|
+
export type { SliceContract, SliceContractRegistry, SliceRenderFailure, LinkDocument } from '@storystick/types';
|
|
12
|
+
export { sitemapResponse, sitemapIndexResponse } from './sitemap.js';
|
|
13
|
+
export { createPublicationHandler } from './publication.js';
|
|
14
|
+
export type { PublicationBatch, PublicationHandlerOptions } from './publication.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { createSite, AstroPreviewError } from './server.js';
|
|
2
|
+
export { resolveImage, resolveFile } from '@storystick/media';
|
|
3
|
+
export { resolveVideo } from '@storystick/media/video';
|
|
4
|
+
export { resolveLink } from '@storystick/client';
|
|
5
|
+
export { sliceAttributes } from './slices.js';
|
|
6
|
+
export { previewText } from './text.js';
|
|
7
|
+
export { toText } from './rich-text.js';
|
|
8
|
+
export { sitemapResponse, sitemapIndexResponse } from './sitemap.js';
|
|
9
|
+
export { createPublicationHandler } from './publication.js';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { AstroIntegration } from 'astro';
|
|
2
|
+
export interface StorystickIntegrationOptions {
|
|
3
|
+
/** Server module exporting `site = createSite(...)`. Relative to the Astro root. */
|
|
4
|
+
site: string;
|
|
5
|
+
/** A separate SSR Preview deployment. Public builds omit its routes and JavaScript. */
|
|
6
|
+
preview?: {
|
|
7
|
+
origin: string;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
export default function storystick(options: StorystickIntegrationOptions): AstroIntegration;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
export default function storystick(options) {
|
|
3
|
+
const previewOrigin = options.preview ? new URL(options.preview.origin) : null;
|
|
4
|
+
if (previewOrigin && (!['http:', 'https:'].includes(previewOrigin.protocol) || previewOrigin.href !== previewOrigin.origin + '/')) {
|
|
5
|
+
throw new TypeError('Storystick Preview requires an exact HTTP(S) origin.');
|
|
6
|
+
}
|
|
7
|
+
const ownRoute = fileURLToPath(new URL(previewOrigin ? './routes/control.js' : './routes/readback.js', import.meta.url));
|
|
8
|
+
const errorRoute = fileURLToPath(new URL('./routes/error.astro', import.meta.url));
|
|
9
|
+
let projectRoot;
|
|
10
|
+
let hasServerRoutes = false;
|
|
11
|
+
return {
|
|
12
|
+
name: '@storystick/astro',
|
|
13
|
+
hooks: {
|
|
14
|
+
'astro:config:setup'({ config, updateConfig, addMiddleware, injectRoute, injectScript }) {
|
|
15
|
+
projectRoot = config.root;
|
|
16
|
+
if (config.base !== '/')
|
|
17
|
+
throw new TypeError('Storystick Astro currently requires base: "/".');
|
|
18
|
+
const sitePath = fileURLToPath(new URL(options.site, config.root));
|
|
19
|
+
updateConfig({
|
|
20
|
+
...(previewOrigin ? { security: { allowedDomains: [...config.security.allowedDomains, {
|
|
21
|
+
protocol: previewOrigin.protocol.slice(0, -1), hostname: previewOrigin.hostname,
|
|
22
|
+
port: previewOrigin.port || (previewOrigin.protocol === 'https:' ? '443' : '80'),
|
|
23
|
+
}] } } : {}),
|
|
24
|
+
vite: { resolve: { dedupe: ['react', 'react-dom'] }, plugins: [{
|
|
25
|
+
name: 'storystick-site',
|
|
26
|
+
resolveId(id) { if (id === 'virtual:storystick/site' || id === 'virtual:storystick/preview')
|
|
27
|
+
return '\0' + id; },
|
|
28
|
+
load(id) { if (id === '\0virtual:storystick/preview')
|
|
29
|
+
return `export { default } from ${JSON.stringify(fileURLToPath(new URL(previewOrigin ? './components/PreviewHost.astro' : './components/PublicHost.astro', import.meta.url)))};`; if (id === '\0virtual:storystick/site')
|
|
30
|
+
return `export { site } from ${JSON.stringify(sitePath)}; export const previewOrigin = ${JSON.stringify(previewOrigin?.origin ?? null)};`; },
|
|
31
|
+
}] },
|
|
32
|
+
});
|
|
33
|
+
hasServerRoutes = !!previewOrigin || config.output === 'server';
|
|
34
|
+
if (hasServerRoutes)
|
|
35
|
+
injectRoute({ pattern: '/api/storystick/[...action]', entrypoint: ownRoute, prerender: false });
|
|
36
|
+
if (!previewOrigin)
|
|
37
|
+
return;
|
|
38
|
+
addMiddleware({ entrypoint: '@storystick/astro/internal/middleware', order: 'pre' });
|
|
39
|
+
injectRoute({ pattern: '/storystick-preview-error', entrypoint: errorRoute, prerender: false });
|
|
40
|
+
injectScript('page', 'import { boot } from "@storystick/astro/browser"; boot();');
|
|
41
|
+
},
|
|
42
|
+
'astro:config:done'({ config }) {
|
|
43
|
+
if (previewOrigin && (!config.adapter || config.output !== 'server'))
|
|
44
|
+
throw new TypeError('Storystick Preview requires output: "server" and an SSR adapter.');
|
|
45
|
+
},
|
|
46
|
+
'astro:routes:resolved'({ routes }) {
|
|
47
|
+
if (!hasServerRoutes)
|
|
48
|
+
return;
|
|
49
|
+
for (const route of routes) {
|
|
50
|
+
const entrypoint = fileURLToPath(new URL(route.entrypoint, projectRoot));
|
|
51
|
+
if (entrypoint !== ownRoute && entrypoint !== errorRoute &&
|
|
52
|
+
(route.pattern?.startsWith('/api/storystick/') || route.pathname === '/storystick-preview-error')) {
|
|
53
|
+
throw new TypeError(`Route "${route.entrypoint}" (${route.origin}) collides with the reserved Storystick Preview route "${route.pattern}". Expected "${ownRoute}" or "${errorRoute}".`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const onRequest: import("astro").MiddlewareHandler;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { defineMiddleware } from 'astro:middleware';
|
|
2
|
+
import { site, previewOrigin } from 'virtual:storystick/site';
|
|
3
|
+
import { setPreviewOrigin, setRenderFailure, siteRuntime, AstroPreviewError } from './server.js';
|
|
4
|
+
export const onRequest = defineMiddleware(async (ctx, next) => {
|
|
5
|
+
if (!previewOrigin)
|
|
6
|
+
return next();
|
|
7
|
+
if (ctx.url.origin !== previewOrigin)
|
|
8
|
+
return new Response('Preview origin mismatch', { status: 403, headers: { 'Cache-Control': 'no-store' } });
|
|
9
|
+
setPreviewOrigin(ctx, previewOrigin);
|
|
10
|
+
const control = ctx.url.pathname.startsWith('/api/storystick/');
|
|
11
|
+
let response;
|
|
12
|
+
try {
|
|
13
|
+
if (!control && ctx.url.pathname !== '/storystick-preview-error')
|
|
14
|
+
await siteRuntime(site).requestContext(ctx);
|
|
15
|
+
response = await next();
|
|
16
|
+
if (!control && ctx.request.method === 'GET' && response.headers.get('content-type')?.includes('text/html')) {
|
|
17
|
+
if (response.status >= 500 && ctx.url.pathname !== '/storystick-preview-error')
|
|
18
|
+
throw new Error('Preview render failed');
|
|
19
|
+
// A failed stream cannot carry a successful render receipt.
|
|
20
|
+
response = new Response(await response.text(), response);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (control || ctx.url.pathname === '/storystick-preview-error')
|
|
25
|
+
throw error;
|
|
26
|
+
console.error('[storystick/astro render]', error instanceof Error ? error.message : 'Unknown render failure');
|
|
27
|
+
setRenderFailure(ctx, error);
|
|
28
|
+
response = await ctx.rewrite('/storystick-preview-error');
|
|
29
|
+
response = new Response(await response.text(), { status: error instanceof AstroPreviewError ? error.status : 500, headers: response.headers });
|
|
30
|
+
}
|
|
31
|
+
response.headers.set('Cache-Control', 'private, no-store');
|
|
32
|
+
response.headers.set('X-Robots-Tag', 'noindex, nofollow');
|
|
33
|
+
return response;
|
|
34
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Context admission can finish after another gesture. Only the latest intent
|
|
2
|
+
* may start a native navigation; Astro owns cancellation once it has started. */
|
|
3
|
+
export declare function createAstroNavigation(transition: (destination: string, history: 'push' | 'replace', commandId?: string) => Promise<void>): {
|
|
4
|
+
navigate: (pathname: string, options?: import("@storystick/preview/nav-handler").NavigateHandlerOptions | undefined) => Promise<void>;
|
|
5
|
+
invalidate(): void;
|
|
6
|
+
dispose(): void;
|
|
7
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { previewRuntime } from '@storystick/preview/runtime/store';
|
|
2
|
+
import { writePreviewScopeToSearchParams } from '@storystick/preview/identity';
|
|
3
|
+
/** Context admission can finish after another gesture. Only the latest intent
|
|
4
|
+
* may start a native navigation; Astro owns cancellation once it has started. */
|
|
5
|
+
export function createAstroNavigation(transition) {
|
|
6
|
+
let revision = 0;
|
|
7
|
+
let disposed = false;
|
|
8
|
+
const navigate = async (...[path, options]) => {
|
|
9
|
+
if (disposed)
|
|
10
|
+
return;
|
|
11
|
+
const intent = ++revision;
|
|
12
|
+
let perspective = previewRuntime.getState().session?.readContext.perspective;
|
|
13
|
+
if (options?.contextCommand && options.commandId) {
|
|
14
|
+
const context = await previewRuntime.changePerspective({ ...options.contextCommand,
|
|
15
|
+
commandId: options.commandId, perspective: options.scope }, '/api/storystick/preview/context');
|
|
16
|
+
if (!context || disposed || intent !== revision)
|
|
17
|
+
return;
|
|
18
|
+
perspective = context.perspective;
|
|
19
|
+
}
|
|
20
|
+
const url = new URL(path, location.href);
|
|
21
|
+
// Template prototypes use admitted view routing; native links leave it.
|
|
22
|
+
const session = previewRuntime.getState().session;
|
|
23
|
+
if (session?.draftTarget?.kind === 'template')
|
|
24
|
+
url.searchParams.set('_ss_session', session.sessionId);
|
|
25
|
+
if (perspective)
|
|
26
|
+
writePreviewScopeToSearchParams(url.searchParams, perspective);
|
|
27
|
+
await transition(url.href, options?.history ?? 'replace', options?.commandId);
|
|
28
|
+
};
|
|
29
|
+
return {
|
|
30
|
+
navigate,
|
|
31
|
+
invalidate() { revision++; },
|
|
32
|
+
dispose() { disposed = true; revision++; },
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type PublicationEventV2 } from '@storystick/types/publication';
|
|
2
|
+
export type PublicationBatch = PublicationEventV2;
|
|
3
|
+
export interface PublicationHandlerOptions {
|
|
4
|
+
projectId: string;
|
|
5
|
+
secret: string | (() => string | undefined);
|
|
6
|
+
/** Enqueue returns accepted; completed means the invalidation/build activation
|
|
7
|
+
* finished. Neither certifies HTML: the publisher checks the renderer receipt. */
|
|
8
|
+
onPublish: (event: PublicationEventV2) => Promise<'accepted' | 'completed'>;
|
|
9
|
+
}
|
|
10
|
+
export declare function createPublicationHandler(options: PublicationHandlerOptions): (request: Request) => Promise<Response>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readSignedWebhook } from '@storystick/client/adapter';
|
|
2
|
+
import { isPublicationEvent } from '@storystick/types/publication';
|
|
3
|
+
export function createPublicationHandler(options) {
|
|
4
|
+
if (!options.projectId)
|
|
5
|
+
throw new TypeError('A publication handler requires projectId.');
|
|
6
|
+
return async (request) => {
|
|
7
|
+
const json = (value, status = 200) => Response.json(value, { status, headers: { 'Cache-Control': 'no-store' } });
|
|
8
|
+
if (request.method !== 'POST')
|
|
9
|
+
return new Response(null, { status: 405, headers: { Allow: 'POST', 'Cache-Control': 'no-store' } });
|
|
10
|
+
const secret = typeof options.secret === 'function' ? options.secret() : options.secret;
|
|
11
|
+
if (!secret)
|
|
12
|
+
return json({ error: 'webhook_secret_not_configured' }, 500);
|
|
13
|
+
const result = await readSignedWebhook(request, { secret });
|
|
14
|
+
if (!result.ok)
|
|
15
|
+
return json({ error: result.error }, result.status);
|
|
16
|
+
const event = result.payload;
|
|
17
|
+
if (!isPublicationEvent(event) || event.projectId !== options.projectId)
|
|
18
|
+
return json({ error: 'publication_invalid' }, 400);
|
|
19
|
+
try {
|
|
20
|
+
const effect = await options.onPublish(event);
|
|
21
|
+
if (effect !== 'accepted' && effect !== 'completed')
|
|
22
|
+
throw Error('Invalid effect acknowledgement');
|
|
23
|
+
return json({ version: 2, projectId: event.projectId, id: event.id, effect }, effect === 'accepted' ? 202 : 200);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return json({ error: 'publication_not_accepted' }, 503);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { APIContext } from 'astro';
|
|
2
|
+
export interface MigrationReadback {
|
|
3
|
+
projectId: string;
|
|
4
|
+
artifactId: string;
|
|
5
|
+
artifactDigest: string;
|
|
6
|
+
schemaVersion: string | null;
|
|
7
|
+
querySchemaFingerprint: string;
|
|
8
|
+
publication?: {
|
|
9
|
+
version: 2;
|
|
10
|
+
rendering: 'request' | 'static' | 'unqualified';
|
|
11
|
+
receipts: boolean;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export declare function createReadbackHandler(readback: () => Promise<MigrationReadback | null>): (ctx: Pick<APIContext, "params" | "request">) => Promise<Response>;
|
package/dist/readback.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function createReadbackHandler(readback) {
|
|
2
|
+
return async (ctx) => {
|
|
3
|
+
const headers = { 'Cache-Control': 'no-store' };
|
|
4
|
+
if (!['health', 'migration-readback'].includes(ctx.params.action ?? ''))
|
|
5
|
+
return Response.json({ ok: false, error: 'not_found' }, { status: 404, headers });
|
|
6
|
+
if (ctx.request.method !== 'GET')
|
|
7
|
+
return new Response(null, { status: 405, headers: { ...headers, Allow: 'GET' } });
|
|
8
|
+
if (ctx.params.action === 'health')
|
|
9
|
+
return Response.json({ ok: true }, { headers });
|
|
10
|
+
try {
|
|
11
|
+
const migration = await readback();
|
|
12
|
+
return Response.json(migration ? { ok: true, migration } : { ok: false, error: 'migration_readback_not_configured' }, { status: migration ? 200 : 503, headers });
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return Response.json({ ok: false, error: 'migration_readback_unavailable' }, { status: 503, headers });
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type PreviewReadContext } from '@storystick/types/preview-query-targeting';
|
|
2
|
+
export interface AstroRenderReceipt {
|
|
3
|
+
readContext: PreviewReadContext;
|
|
4
|
+
renderId: string;
|
|
5
|
+
renderedUrl: string;
|
|
6
|
+
projectionCheckpoint?: unknown;
|
|
7
|
+
}
|
|
8
|
+
/** The view/session and refresh nonce select transport, not customer content. */
|
|
9
|
+
export declare function renderLocation(value: string | URL): string;
|
|
10
|
+
export declare function isAstroRenderReceipt(value: unknown): value is AstroRenderReceipt;
|
|
11
|
+
type Swap<Document> = {
|
|
12
|
+
signal: AbortSignal;
|
|
13
|
+
from: URL;
|
|
14
|
+
to: URL;
|
|
15
|
+
newDocument: Document;
|
|
16
|
+
swap: () => void;
|
|
17
|
+
info?: unknown;
|
|
18
|
+
};
|
|
19
|
+
/** Astro owns navigation, cancellation and DOM replacement. This gate owns
|
|
20
|
+
* only the proof that its particular response was installed in this context.
|
|
21
|
+
* Checking inside swap matters: context can change during the old-page animation.
|
|
22
|
+
*/
|
|
23
|
+
export declare function createAstroRenderGate<Document>(options: {
|
|
24
|
+
readReceipt: (document?: Document) => unknown;
|
|
25
|
+
readContext: () => PreviewReadContext | null | undefined;
|
|
26
|
+
readUrl: () => string;
|
|
27
|
+
commit: (receipt: AstroRenderReceipt, info?: unknown) => void;
|
|
28
|
+
reject: (reason: 'context' | 'receipt' | 'destination', destination: URL) => void;
|
|
29
|
+
}): {
|
|
30
|
+
prepare(signal: AbortSignal): void;
|
|
31
|
+
beforeSwap(event: Swap<Document>): void;
|
|
32
|
+
afterSwap(): boolean;
|
|
33
|
+
initial(): boolean;
|
|
34
|
+
dispose(): void;
|
|
35
|
+
};
|
|
36
|
+
export {};
|