@storystick/cli 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/CONTENT-IMPORT.md +349 -0
- package/LICENSE +112 -0
- package/PUBLIC-API.md +30 -0
- package/README.md +263 -2
- package/assets/next-v1/app/[[...pathname]]/error.tsx +10 -0
- package/assets/next-v1/app/[[...pathname]]/not-found.tsx +8 -0
- package/assets/next-v1/app/[[...pathname]]/page.tsx +38 -0
- package/assets/next-v1/app/api/storystick/[...action]/route.ts +4 -0
- package/assets/next-v1/app/layout.tsx +28 -0
- package/assets/next-v1/app/sitemap.ts +3 -0
- package/assets/next-v1/env.local.example +13 -0
- package/assets/next-v1/globals.css +17 -0
- package/assets/next-v1/lib/site.ts +10 -0
- package/assets/next-v1/next.config.ts +4 -0
- package/assets/skills/storystick-development/SKILL.md +79 -0
- package/assets/skills/storystick-development/references/setup.md +152 -0
- package/assets/skills/storystick-development/references/story-and-next.md +49 -0
- package/dist/_backfill-recommendation.d.ts +12 -0
- package/dist/_backfill-recommendation.js +18 -0
- package/dist/asset-import.d.ts +50 -0
- package/dist/asset-import.js +160 -0
- package/dist/assistant-read-client.d.ts +3 -0
- package/dist/assistant-read-client.js +8 -0
- package/dist/auth-device.d.ts +153 -0
- package/dist/auth-device.js +390 -0
- package/dist/bootstrap-client.d.ts +52 -0
- package/dist/bootstrap-client.js +19 -0
- package/dist/cli-error.d.ts +54 -0
- package/dist/cli-error.js +107 -0
- package/dist/cli.d.ts +68 -0
- package/dist/cli.js +1520 -0
- package/dist/commands/archive.d.ts +17 -0
- package/dist/commands/archive.js +86 -0
- package/dist/commands/backfill-refs.d.ts +54 -0
- package/dist/commands/backfill-refs.js +217 -0
- package/dist/commands/connect.d.ts +11 -0
- package/dist/commands/connect.js +124 -0
- package/dist/commands/connection.d.ts +57 -0
- package/dist/commands/connection.js +83 -0
- package/dist/commands/content.d.ts +11 -0
- package/dist/commands/content.js +107 -0
- package/dist/commands/delivery.d.ts +22 -0
- package/dist/commands/delivery.js +99 -0
- package/dist/commands/info.d.ts +35 -0
- package/dist/commands/info.js +62 -0
- package/dist/commands/list.d.ts +28 -0
- package/dist/commands/list.js +116 -0
- package/dist/commands/migrate.d.ts +80 -0
- package/dist/commands/migrate.js +550 -0
- package/dist/commands/release.d.ts +8 -0
- package/dist/commands/release.js +60 -0
- package/dist/commands/setup.d.ts +6 -0
- package/dist/commands/setup.js +58 -0
- package/dist/commands/slice-previews.d.ts +17 -0
- package/dist/commands/slice-previews.js +233 -0
- package/dist/commands/source.d.ts +82 -0
- package/dist/commands/source.js +192 -0
- package/dist/content-import.d.ts +78 -0
- package/dist/content-import.js +210 -0
- package/dist/generated-transaction.d.ts +22 -0
- package/dist/generated-transaction.js +330 -0
- package/dist/hub-client.d.ts +72 -0
- package/dist/hub-client.js +178 -0
- package/dist/init-operation.d.ts +98 -0
- package/dist/init-operation.js +427 -0
- package/dist/init-readiness.d.ts +10 -0
- package/dist/init-readiness.js +50 -0
- package/dist/init-scaffold.d.ts +54 -0
- package/dist/init-scaffold.js +439 -0
- package/dist/init.d.ts +51 -0
- package/dist/init.js +789 -0
- package/dist/mcp-execution.d.ts +15 -0
- package/dist/mcp-execution.js +90 -0
- package/dist/mcp.d.ts +7 -0
- package/dist/mcp.js +41 -0
- package/dist/migration-artifact.d.ts +13 -0
- package/dist/migration-artifact.js +154 -0
- package/dist/operations-client.d.ts +137 -0
- package/dist/operations-client.js +130 -0
- package/dist/output.d.ts +347 -0
- package/dist/output.js +771 -0
- package/dist/project-access.d.ts +18 -0
- package/dist/project-access.js +1 -0
- package/dist/project-connection.d.ts +62 -0
- package/dist/project-connection.js +347 -0
- package/dist/project-execution-client.d.ts +2 -0
- package/dist/project-execution-client.js +33 -0
- package/dist/push-client.d.ts +100 -0
- package/dist/push-client.js +160 -0
- package/dist/release-context.d.ts +13 -0
- package/dist/release-context.js +39 -0
- package/dist/release-schema-client.d.ts +16 -0
- package/dist/release-schema-client.js +33 -0
- package/dist/release-sync.d.ts +36 -0
- package/dist/release-sync.js +31 -0
- package/dist/resolve.d.ts +15 -0
- package/dist/resolve.js +80 -0
- package/dist/runtime-config.d.ts +18 -0
- package/dist/runtime-config.js +86 -0
- package/dist/schema-export.d.ts +10 -0
- package/dist/schema-export.js +28 -0
- package/dist/setup.d.ts +65 -0
- package/dist/setup.js +135 -0
- package/dist/source-git.d.ts +36 -0
- package/dist/source-git.js +197 -0
- package/dist/starter-resolver.d.ts +82 -0
- package/dist/starter-resolver.js +495 -0
- package/dist/sync-transaction.d.ts +29 -0
- package/dist/sync-transaction.js +159 -0
- package/dist/transport.d.ts +8 -0
- package/dist/transport.js +73 -0
- package/examples/sanity/README.md +79 -0
- package/examples/sanity/convert.mjs +228 -0
- package/examples/sanity/fixture/data.ndjson +7 -0
- package/examples/sanity/fixture/mapping.json +44 -0
- package/examples/sanity/fixture/originals/mark.svg +1 -0
- package/examples/sanity/stories/articleBody.story +23 -0
- package/examples/sanity/stories/copy.story +7 -0
- package/examples/sanity/stories/page.story +19 -0
- package/examples/sanity/stories/picture.story +7 -0
- package/examples/sanity/stories/settings.story +9 -0
- package/examples/sanity/stories/stack.story +10 -0
- package/package.json +75 -3
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { StorystickError } from './cli-error.js';
|
|
2
|
+
export const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
3
|
+
export async function fetchWithDeadline(input, init = {}, options = {}) {
|
|
4
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
5
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
6
|
+
const callerSignal = options.signal ?? init.signal ?? undefined;
|
|
7
|
+
const controller = new AbortController();
|
|
8
|
+
let timedOut = false;
|
|
9
|
+
const onCallerAbort = () => controller.abort(callerSignal?.reason);
|
|
10
|
+
if (callerSignal?.aborted)
|
|
11
|
+
onCallerAbort();
|
|
12
|
+
else
|
|
13
|
+
callerSignal?.addEventListener('abort', onCallerAbort, { once: true });
|
|
14
|
+
const timeout = setTimeout(() => {
|
|
15
|
+
timedOut = true;
|
|
16
|
+
controller.abort(new DOMException('Request deadline exceeded.', 'TimeoutError'));
|
|
17
|
+
}, timeoutMs);
|
|
18
|
+
try {
|
|
19
|
+
return await fetchImpl(input, { ...init, signal: controller.signal });
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (callerSignal?.aborted)
|
|
23
|
+
throw abortError(callerSignal.reason);
|
|
24
|
+
if (timedOut) {
|
|
25
|
+
throw new StorystickError(`Request timed out after ${timeoutMs}ms.`, {
|
|
26
|
+
code: 'REQUEST_TIMEOUT',
|
|
27
|
+
category: 'network',
|
|
28
|
+
recoverable: true,
|
|
29
|
+
retryable: true,
|
|
30
|
+
cause: error,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
if (isAbortError(error))
|
|
34
|
+
throw error;
|
|
35
|
+
throw new StorystickError(error instanceof Error ? error.message : String(error), {
|
|
36
|
+
code: 'NETWORK_ERROR',
|
|
37
|
+
category: 'network',
|
|
38
|
+
recoverable: true,
|
|
39
|
+
retryable: true,
|
|
40
|
+
cause: error,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
clearTimeout(timeout);
|
|
45
|
+
callerSignal?.removeEventListener('abort', onCallerAbort);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export function abortableSleep(ms, signal) {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
if (signal?.aborted) {
|
|
51
|
+
reject(abortError(signal.reason));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const timer = setTimeout(() => {
|
|
55
|
+
signal?.removeEventListener('abort', onAbort);
|
|
56
|
+
resolve();
|
|
57
|
+
}, ms);
|
|
58
|
+
const onAbort = () => {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
signal?.removeEventListener('abort', onAbort);
|
|
61
|
+
reject(abortError(signal?.reason));
|
|
62
|
+
};
|
|
63
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
function isAbortError(value) {
|
|
67
|
+
return value instanceof Error && value.name === 'AbortError';
|
|
68
|
+
}
|
|
69
|
+
function abortError(reason) {
|
|
70
|
+
if (reason instanceof DOMException && reason.name === 'AbortError')
|
|
71
|
+
return reason;
|
|
72
|
+
return new DOMException('The operation was aborted.', 'AbortError');
|
|
73
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Sanity → Storystick reference mapping
|
|
2
|
+
|
|
3
|
+
This is an executable, offline example distributed with `@storystick/cli`.
|
|
4
|
+
Copy it into your migration project and adapt the mapping to the actual Sanity
|
|
5
|
+
schema and the existing frontend's data contracts. It is deliberately a small
|
|
6
|
+
project converter, not an automatic CMS remodeler or a new server plugin system.
|
|
7
|
+
|
|
8
|
+
The included synthetic snapshot covers `en/fr/zh`, a singleton, cyclic document
|
|
9
|
+
references, nested slices, Portable Text marks/nested lists/image embeds,
|
|
10
|
+
image occurrence metadata, inherited fields, explicit clearing, `false`, `0`
|
|
11
|
+
and empty arrays. The Story Files put the article rich text in the first group.
|
|
12
|
+
No original Diduenjoy data or credentials are included.
|
|
13
|
+
|
|
14
|
+
## Source preparation
|
|
15
|
+
|
|
16
|
+
Export the authoritative dataset using Sanity's supported tools. Select the
|
|
17
|
+
source perspective deliberately. This converter expects raw `_ref` identities;
|
|
18
|
+
do not feed it an export that rewrote references to asset paths. Sanity documents
|
|
19
|
+
`--raw` for that purpose and `--no-drafts` for published source documents. A
|
|
20
|
+
published source is still imported as a Storystick **draft**.
|
|
21
|
+
|
|
22
|
+
Inspect export warnings: Sanity can finish an export while reporting missing
|
|
23
|
+
asset binaries. Preserve originals, including vector files; do not substitute
|
|
24
|
+
transformed CDN thumbnails. The converter performs no download or archive
|
|
25
|
+
extraction and never executes source data.
|
|
26
|
+
|
|
27
|
+
- [Sanity export contract](https://www.sanity.io/docs/content-lake/exporting-data)
|
|
28
|
+
- [Portable Text block contract](https://www.sanity.io/docs/studio/block-type)
|
|
29
|
+
|
|
30
|
+
`fixture/mapping.json` is a reviewed manifest of translation families and local
|
|
31
|
+
original paths. It is not Sanity's exported `assets.json` URL map. Each source
|
|
32
|
+
belongs to one family and locale. Families come from your source's explicit
|
|
33
|
+
translation relationships or a reviewed mapping, never matching titles/slugs.
|
|
34
|
+
Absent translated fields inherit. `clearedPaths` records explicit, reviewed
|
|
35
|
+
clearing; the converter does not infer it from missing values.
|
|
36
|
+
|
|
37
|
+
The reference selects published source IDs and refuses `drafts.*` and
|
|
38
|
+
`versions.*`. It reports excluded source documents, including unselected drafts.
|
|
39
|
+
Review `report.json` before import. Adapting draft/release precedence, additional
|
|
40
|
+
CMS types, annotations, route intent or deep containers requires an explicit
|
|
41
|
+
mapping change and a fixture; unknown editorial fields fail instead of disappearing.
|
|
42
|
+
|
|
43
|
+
## Run locally
|
|
44
|
+
|
|
45
|
+
In a project with the release artifacts of `@storystick/cli` and `@storystick/core`
|
|
46
|
+
installed, copy this directory to `migration/sanity`. Compile and push its Story
|
|
47
|
+
Files using your normal project config, then explicitly configure `en/fr/zh`
|
|
48
|
+
with `en` as default through Storystick. Use the installed schema version from
|
|
49
|
+
`storystick push --json`:
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
node migration/sanity/convert.mjs migration/sanity/fixture/data.ndjson migration/sanity/fixture/mapping.json migration/prepared <installed-schema-version>
|
|
53
|
+
storystick content import migration/prepared/manifest.json --project <short-id> --dry-run --json
|
|
54
|
+
storystick content import migration/prepared/manifest.json --project <short-id> --apply --plan <digest> --json
|
|
55
|
+
storystick content verify <run-id> --project <short-id> --json
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Preparation creates a new directory, never overwrites one, and writes the
|
|
59
|
+
manifest last. It binds the source snapshot, converter source code, mapping,
|
|
60
|
+
output records and original bytes with digests. When source asset metadata has
|
|
61
|
+
`size`/`sha1hash`, they must match the file. Files are copied unchanged and
|
|
62
|
+
checked again; large files are hashed incrementally. The small reference
|
|
63
|
+
converter caps snapshots at 32 MiB and mappings at 192 KiB. Split larger source
|
|
64
|
+
inventories explicitly; the public import limits still apply.
|
|
65
|
+
|
|
66
|
+
The output uses native Storystick fields and temporary source-ID markers. Rich
|
|
67
|
+
text uses the existing ProseMirror grammar and native keyed embed representation;
|
|
68
|
+
there is no intermediate rich-text AST in the importer. Repository regression
|
|
69
|
+
tests compile the actual Story Files and run public Core validation. Server import
|
|
70
|
+
still owns native IDs, tenancy, locale/reference/media validation, limits,
|
|
71
|
+
replay and persistence.
|
|
72
|
+
|
|
73
|
+
Keep the existing frontend components and styles. Adapt its fetching/projection,
|
|
74
|
+
image and rich-text data boundaries, then check the same pages at the same
|
|
75
|
+
viewports. This example contains no replacement frontend. Converter and native
|
|
76
|
+
protocol tests do not prove Content Plane delivery, Studio editing, preview or
|
|
77
|
+
visual parity; those remain real-deployment qualification steps. Nothing here
|
|
78
|
+
publishes content or deploys a website. The CLI capabilities are in development;
|
|
79
|
+
use matching release artifacts until the packages and service are publicly available.
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/** Reference project mapping, not a universal Sanity importer. Adapt explicitly. */
|
|
2
|
+
import { createReadStream } from 'node:fs'
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
import { readFile, writeFile, realpath, mkdir, copyFile, stat } from 'node:fs/promises'
|
|
5
|
+
import { dirname, resolve, relative, isAbsolute, basename } from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { importDigest, planImportBatches, parseImportRecord } from '@storystick/core/content-import'
|
|
8
|
+
|
|
9
|
+
const hash = value => createHash('sha256').update(value).digest('hex')
|
|
10
|
+
const fail = (path, message) => { throw new Error(`${path}: ${message}`) }
|
|
11
|
+
const object = (value, path) => value && typeof value === 'object' && !Array.isArray(value) ? value : fail(path, 'expected an object')
|
|
12
|
+
const exact = (value, keys, path) => { object(value, path); for (const key of Object.keys(value)) if (!keys.includes(key)) fail(`${path}.${key}`, 'no mapping; refusing silent data loss') }
|
|
13
|
+
const keyOf = (value, path) => typeof value === 'string' && /^[A-Za-z0-9_-]+$/.test(value) ? value : fail(path, 'expected a stable source occurrence key')
|
|
14
|
+
const array = (value, path) => Array.isArray(value) ? value : fail(path, 'expected an array')
|
|
15
|
+
const plain = (value, path, type) => typeof value === type ? value : fail(path, `expected ${type}`)
|
|
16
|
+
const optional = (input, name, convert) => Object.hasOwn(input, name) ? { [name]: convert(input[name]) } : {}
|
|
17
|
+
|
|
18
|
+
/** Output is native ProseMirror JSON plus its native keyed embed records. */
|
|
19
|
+
export function portableText(blocks, { path, reference, image }) {
|
|
20
|
+
const content = [], embeds = [], stack = [], seen = new Set()
|
|
21
|
+
const marksFor = (span, definitions, spanPath) => array(span.marks ?? [], spanPath).map(mark => {
|
|
22
|
+
const standard = { strong: 'bold', em: 'italic', underline: 'underline', 'strike-through': 'strike', code: 'code' }
|
|
23
|
+
if (Object.hasOwn(standard, mark)) return { type: standard[mark] }
|
|
24
|
+
const definition = definitions.find(d => d._key === mark)
|
|
25
|
+
if (!definition) fail(spanPath, `unknown annotation ${mark}`)
|
|
26
|
+
if (definition._type === 'link') {
|
|
27
|
+
exact(definition, ['_key', '_type', 'href'], spanPath)
|
|
28
|
+
if (typeof definition.href !== 'string' || !/^(https?:\/\/|mailto:|tel:)/.test(definition.href)) fail(spanPath, 'unsupported URL; map its routing semantics explicitly')
|
|
29
|
+
return { type: 'link', attrs: { href: definition.href } }
|
|
30
|
+
}
|
|
31
|
+
if (definition._type === 'internalLink') {
|
|
32
|
+
exact(definition, ['_key', '_type', 'reference'], spanPath)
|
|
33
|
+
return { type: 'link', attrs: { _ref: reference(definition.reference, spanPath) } }
|
|
34
|
+
}
|
|
35
|
+
fail(spanPath, `unsupported annotation ${definition._type}`)
|
|
36
|
+
})
|
|
37
|
+
for (const [index, block] of array(blocks, path).entries()) {
|
|
38
|
+
const at = `${path}[${index}]`, key = keyOf(block._key, at)
|
|
39
|
+
if (seen.has(key)) fail(at, 'duplicate block key')
|
|
40
|
+
seen.add(key)
|
|
41
|
+
if (block._type === 'image') {
|
|
42
|
+
stack.length = 0
|
|
43
|
+
const embedKey = `embed_${Buffer.from(`${path}:${key}`).toString('base64url')}`
|
|
44
|
+
embeds.push({ _key: embedKey, _type: 'picture', image: image(block, at) })
|
|
45
|
+
content.push({ type: 'sliceEmbed', attrs: { _key: embedKey, _type: 'picture' } })
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
exact(block, ['_type', '_key', 'style', 'markDefs', 'children', 'listItem', 'level'], at)
|
|
49
|
+
if (block._type !== 'block') fail(at, `unsupported block ${block._type}`)
|
|
50
|
+
const definitions = array(block.markDefs ?? [], at)
|
|
51
|
+
if (new Set(definitions.map(d => keyOf(d._key, at))).size !== definitions.length) fail(at, 'duplicate annotation key')
|
|
52
|
+
const inline = array(block.children, at).flatMap((span, n) => {
|
|
53
|
+
const spanPath = `${at}.children[${n}]`
|
|
54
|
+
exact(span, ['_type', '_key', 'text', 'marks'], spanPath)
|
|
55
|
+
if (span._type !== 'span') fail(spanPath, 'custom inline objects require an explicit mapping')
|
|
56
|
+
const marks = marksFor(span, definitions, spanPath)
|
|
57
|
+
const text = plain(span.text, spanPath, 'string')
|
|
58
|
+
// Empty spans are not legal PM text nodes; their visible content is empty.
|
|
59
|
+
return text.split('\n').flatMap((part, j) => [...(j ? [{ type: 'hardBreak' }] : []), ...(part ? [{ type: 'text', text: part, ...(marks.length ? { marks } : {}) }] : [])])
|
|
60
|
+
})
|
|
61
|
+
const style = block.style ?? 'normal'
|
|
62
|
+
let node = { type: 'paragraph', attrs: { _key: key }, content: inline }
|
|
63
|
+
if (/^h[1-6]$/.test(style)) node = { ...node, type: 'heading', attrs: { _key: key, level: Number(style[1]) } }
|
|
64
|
+
else if (style === 'blockquote') node = { type: 'blockquote', attrs: { _key: key }, content: [{ ...node, attrs: { _key: `${key}_paragraph` } }] }
|
|
65
|
+
else if (style !== 'normal') fail(at, `unsupported block style ${style}`)
|
|
66
|
+
if (block.listItem === undefined) {
|
|
67
|
+
if (block.level !== undefined) fail(at, 'list level without list type')
|
|
68
|
+
stack.length = 0; content.push(node); continue
|
|
69
|
+
}
|
|
70
|
+
if (style !== 'normal' || !['bullet', 'number'].includes(block.listItem)) fail(at, 'unsupported list style')
|
|
71
|
+
const depth = block.level ?? 1, type = block.listItem === 'bullet' ? 'bulletList' : 'orderedList'
|
|
72
|
+
if (!Number.isInteger(depth) || depth < 1 || depth > 3 || depth > stack.length + 1) fail(at, 'ambiguous or unsupported list nesting')
|
|
73
|
+
stack.length = Math.min(stack.length, depth)
|
|
74
|
+
if (stack[depth - 1]?.type !== type) {
|
|
75
|
+
const list = { type, attrs: { _key: `${key}_list` }, content: [] }
|
|
76
|
+
if (depth === 1) content.push(list)
|
|
77
|
+
else stack[depth - 2].content.at(-1).content.push(list)
|
|
78
|
+
stack[depth - 1] = list
|
|
79
|
+
}
|
|
80
|
+
stack[depth - 1].content.push({ type: 'listItem', attrs: { _key: `${key}_item` }, content: [node] })
|
|
81
|
+
}
|
|
82
|
+
return { value: { type: 'doc', content: content.length ? content : [{ type: 'paragraph', attrs: { _key: 'empty' } }] }, embeds }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Families and asset paths are reviewed source facts, never guessed from slugs. */
|
|
86
|
+
export function convertSnapshot(rows, mapping) {
|
|
87
|
+
exact(mapping, ['namespace', 'families', 'assets'], 'mapping')
|
|
88
|
+
plain(mapping.namespace, 'mapping.namespace', 'string')
|
|
89
|
+
const documents = new Map(), references = new Map(), selected = new Set(), familyKeys = new Set()
|
|
90
|
+
for (const row of rows) {
|
|
91
|
+
if (typeof row._id !== 'string' || documents.has(row._id)) fail('snapshot', 'missing or duplicate _id')
|
|
92
|
+
documents.set(row._id, row)
|
|
93
|
+
}
|
|
94
|
+
for (const family of array(mapping.families, 'families')) {
|
|
95
|
+
exact(family, ['key', 'primaryLocale', 'members'], 'family')
|
|
96
|
+
if (familyKeys.has(family.key)) fail(family.key, 'duplicate family key')
|
|
97
|
+
familyKeys.add(family.key)
|
|
98
|
+
if (!Object.hasOwn(family.members, family.primaryLocale)) fail(family.key, 'primary locale is missing')
|
|
99
|
+
for (const [locale, member] of Object.entries(family.members)) {
|
|
100
|
+
exact(member, ['sourceId', 'clearedPaths'], `${family.key}.${locale}`)
|
|
101
|
+
const id = member.sourceId
|
|
102
|
+
if (typeof id !== 'string' || /^(drafts|versions)\./.test(id)) fail(family.key, 'select an explicit published source document; draft/release mapping is separate')
|
|
103
|
+
if (!documents.has(id) || selected.has(id)) fail(id, 'missing source or source assigned twice')
|
|
104
|
+
const doc = documents.get(id)
|
|
105
|
+
if (doc.language !== undefined && doc.language !== locale) fail(id, 'language disagrees with reviewed family')
|
|
106
|
+
selected.add(id); references.set(id, family.key)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const media = object(mapping.assets, 'assets'), usedMedia = new Set()
|
|
110
|
+
const reference = (value, at) => {
|
|
111
|
+
exact(value, ['_type', '_ref', '_weak'], at)
|
|
112
|
+
if (value._type !== 'reference' || value._weak) fail(at, 'weak/cross-dataset references require a source decision')
|
|
113
|
+
const key = references.get(value._ref)
|
|
114
|
+
if (!key) fail(at, `missing source document ${value._ref}`)
|
|
115
|
+
return { _importDocument: key }
|
|
116
|
+
}
|
|
117
|
+
const image = (value, at) => {
|
|
118
|
+
exact(value, ['_type', '_key', 'asset', 'alt', 'caption', 'crop', 'hotspot'], at)
|
|
119
|
+
exact(value.asset, ['_type', '_ref'], `${at}.asset`)
|
|
120
|
+
const key = value.asset._ref, asset = documents.get(key)
|
|
121
|
+
if (value.asset._type !== 'reference' || !Object.hasOwn(media, key) || asset?._type !== 'sanity.imageAsset') fail(at, 'missing original image mapping')
|
|
122
|
+
usedMedia.add(key)
|
|
123
|
+
const occurrence = { assetId: { _importAsset: key } }
|
|
124
|
+
for (const name of ['alt', 'caption']) if (Object.hasOwn(value, name)) occurrence[name] = plain(value[name], at, 'string')
|
|
125
|
+
for (const name of ['crop', 'hotspot']) if (Object.hasOwn(value, name)) {
|
|
126
|
+
const keys = name === 'crop' ? ['top', 'bottom', 'left', 'right'] : ['x', 'y', 'height', 'width']
|
|
127
|
+
exact(value[name], ['_type', ...keys], `${at}.${name}`)
|
|
128
|
+
occurrence[name] = Object.fromEntries(keys.map(k => [k, plain(value[name][k], at, 'number')]))
|
|
129
|
+
}
|
|
130
|
+
return occurrence
|
|
131
|
+
}
|
|
132
|
+
const section = (value, at, nested = false) => {
|
|
133
|
+
const key = keyOf(value._key, at)
|
|
134
|
+
if (value._type === 'stack' && !nested) {
|
|
135
|
+
exact(value, ['_type', '_key', 'title', 'children'], at)
|
|
136
|
+
return { _key: key, _type: 'stack', ...optional(value, 'title', v => plain(v, at, 'string')), children: array(value.children, at).map((v, i) => section(v, `${at}.children[${i}]`, true)) }
|
|
137
|
+
}
|
|
138
|
+
if (value._type === 'image') return { _key: key, _type: 'picture', image: image(value, at) }
|
|
139
|
+
if (value._type === 'text') {
|
|
140
|
+
exact(value, ['_type', '_key', 'title'], at)
|
|
141
|
+
return { _key: key, _type: 'copy', title: plain(value.title, at, 'string') }
|
|
142
|
+
}
|
|
143
|
+
fail(at, `unsupported slice ${value._type}; nested containers need an explicit schema decision`)
|
|
144
|
+
}
|
|
145
|
+
const fields = id => {
|
|
146
|
+
const doc = documents.get(id)
|
|
147
|
+
exact(doc, ['_id', '_type', '_rev', '_createdAt', '_updatedAt', 'language', 'title', 'body', 'hero', 'sections', 'related', 'enabled', 'rank'], id)
|
|
148
|
+
if (!['page', 'settings'].includes(doc._type)) fail(id, `unmapped document type ${doc._type}`)
|
|
149
|
+
const result = {}
|
|
150
|
+
for (const [key, type] of [['title', 'string'], ['enabled', 'boolean'], ['rank', 'number']]) Object.assign(result, optional(doc, key, v => plain(v, `${id}.${key}`, type)))
|
|
151
|
+
Object.assign(result, optional(doc, 'related', v => reference(v, `${id}.related`)), optional(doc, 'hero', v => image(v, `${id}.hero`)),
|
|
152
|
+
optional(doc, 'sections', v => array(v, id).map((s, i) => section(s, `${id}.sections[${i}]`))))
|
|
153
|
+
if (Object.hasOwn(doc, 'body')) {
|
|
154
|
+
const body = portableText(doc.body, { path: 'body', reference, image })
|
|
155
|
+
result.body = body.value
|
|
156
|
+
if (body.embeds.length) result._richtextEmbeds = body.embeds
|
|
157
|
+
}
|
|
158
|
+
return result
|
|
159
|
+
}
|
|
160
|
+
const records = mapping.families.map(family => {
|
|
161
|
+
const primary = family.members[family.primaryLocale]
|
|
162
|
+
if (primary.clearedPaths?.length) fail(family.key, 'primary fields cannot inherit or clear fallback')
|
|
163
|
+
const storyType = documents.get(primary.sourceId)._type
|
|
164
|
+
const locales = Object.entries(family.members).filter(([locale]) => locale !== family.primaryLocale).sort(([a], [b]) => a.localeCompare(b)).map(([locale, member]) => {
|
|
165
|
+
if (documents.get(member.sourceId)._type !== storyType) fail(member.sourceId, 'family document types disagree')
|
|
166
|
+
const translated = fields(member.sourceId), clearedPaths = member.clearedPaths ?? []
|
|
167
|
+
for (const path of array(clearedPaths, member.sourceId)) if (!['title', 'body', 'hero', 'sections', 'related', 'enabled', 'rank'].includes(path) || Object.hasOwn(translated, path)) fail(member.sourceId, `ambiguous cleared path ${path}`)
|
|
168
|
+
return { locale, fields: translated, ...(clearedPaths.length ? { clearedPaths } : {}) }
|
|
169
|
+
})
|
|
170
|
+
return parseImportRecord({ key: family.key, storyType, primaryLocale: family.primaryLocale, fields: fields(primary.sourceId), ...(locales.length ? { locales } : {}) })
|
|
171
|
+
})
|
|
172
|
+
planImportBatches(records)
|
|
173
|
+
for (const key of Object.keys(media)) if (!documents.has(key) || !['sanity.imageAsset', 'sanity.fileAsset'].includes(documents.get(key)._type)) fail(key, 'asset mapping has no source asset document')
|
|
174
|
+
return { records: records.sort((a, b) => a.key.localeCompare(b.key)), assets: Object.entries(media).sort(([a], [b]) => a.localeCompare(b)).map(([key, file]) => ({ key, file, displayName: documents.get(key).originalFilename ?? basename(file), ...(documents.get(key).mimeType ? { mimeType: documents.get(key).mimeType } : {}) })),
|
|
175
|
+
report: { selectedDocuments: [...selected].sort(), excludedDocuments: rows.filter(r => !selected.has(r._id) && !Object.hasOwn(media, r._id)).map(r => r._id).sort(), unusedOriginals: Object.keys(media).filter(k => !usedMedia.has(k)).sort() } }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function fingerprint(file) {
|
|
179
|
+
const sha1 = createHash('sha1'), sha256 = createHash('sha256')
|
|
180
|
+
let size = 0
|
|
181
|
+
for await (const bytes of createReadStream(file)) { sha1.update(bytes); sha256.update(bytes); size += bytes.length }
|
|
182
|
+
return { sha1: sha1.digest('hex'), sha256: sha256.digest('hex'), size }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Reads only local source data and creates a new output directory. No network. */
|
|
186
|
+
export async function prepareSnapshot(sourcePath, mappingPath, outputPath, schemaVersion) {
|
|
187
|
+
if (!schemaVersion) throw new Error('Pass the installed target schema version explicitly.')
|
|
188
|
+
if ((await stat(sourcePath)).size > 32 * 1024 * 1024 || (await stat(mappingPath)).size > 192 * 1024) throw new Error('Reference converter input exceeds its bounded snapshot/mapping limit.')
|
|
189
|
+
const source = await readFile(sourcePath, 'utf8'), mappingBytes = await readFile(mappingPath, 'utf8'), mapping = JSON.parse(mappingBytes)
|
|
190
|
+
const rows = source.split('\n').filter(line => line.trim()).map((line, i) => { try { return JSON.parse(line) } catch { return fail(`source:${i + 1}`, 'invalid JSON') } })
|
|
191
|
+
const converted = convertSnapshot(rows, mapping), root = await realpath(dirname(mappingPath))
|
|
192
|
+
const originals = []
|
|
193
|
+
for (const asset of converted.assets) {
|
|
194
|
+
const actual = await realpath(resolve(root, asset.file)), rel = relative(root, actual)
|
|
195
|
+
if (isAbsolute(asset.file) || rel === '..' || rel.startsWith('../') || isAbsolute(rel)) fail(asset.key, 'original must be inside the mapping directory')
|
|
196
|
+
const observed = await fingerprint(actual)
|
|
197
|
+
const sourceAsset = rows.find(r => r._id === asset.key)
|
|
198
|
+
if (sourceAsset.size !== undefined && sourceAsset.size !== observed.size) fail(asset.key, 'original byte length differs from snapshot')
|
|
199
|
+
if (sourceAsset.sha1hash !== undefined && sourceAsset.sha1hash !== observed.sha1) fail(asset.key, 'original checksum differs from snapshot')
|
|
200
|
+
originals.push({ ...asset, actual, sha256: observed.sha256, size: observed.size })
|
|
201
|
+
}
|
|
202
|
+
// Validate all files before creating any output. Never overwrite a prior corpus.
|
|
203
|
+
await mkdir(outputPath)
|
|
204
|
+
const assetFiles = []
|
|
205
|
+
for (const [index, asset] of originals.entries()) {
|
|
206
|
+
const name = `${index}-${basename(asset.actual)}`, file = `originals/${name}`, manifest = `asset-${index}.json`
|
|
207
|
+
await mkdir(resolve(outputPath, 'originals'), { recursive: true })
|
|
208
|
+
await copyFile(asset.actual, resolve(outputPath, file))
|
|
209
|
+
const copied = await fingerprint(resolve(outputPath, file))
|
|
210
|
+
if (copied.sha256 !== asset.sha256) fail(asset.key, 'source changed during copy; output is incomplete')
|
|
211
|
+
await writeFile(resolve(outputPath, manifest), JSON.stringify({ namespace: mapping.namespace, key: asset.key, file, displayName: asset.displayName, ...(asset.mimeType ? { mimeType: asset.mimeType } : {}) }, null, 2))
|
|
212
|
+
assetFiles.push(manifest)
|
|
213
|
+
}
|
|
214
|
+
await writeFile(resolve(outputPath, 'documents.ndjson'), converted.records.map(r => JSON.stringify(r)).join('\n') + '\n')
|
|
215
|
+
const manifest = { version: 1, destination: 'main', namespace: mapping.namespace, sourceDigest: hash(source), converter: `sanity-reference-v1:${hash(await readFile(fileURLToPath(import.meta.url)))}`,
|
|
216
|
+
schemaVersion, policy: 'create-only', records: ['documents.ndjson'], assets: assetFiles }
|
|
217
|
+
await writeFile(resolve(outputPath, 'report.json'), JSON.stringify({ ...converted.report, mappingDigest: hash(mappingBytes), recordsDigest: await importDigest(converted.records),
|
|
218
|
+
originals: originals.map(({ key, sha256, size }) => ({ key, sha256, size })) }, null, 2))
|
|
219
|
+
// Manifest last: an interrupted preparation never looks like a ready corpus.
|
|
220
|
+
await writeFile(resolve(outputPath, 'manifest.json'), JSON.stringify(manifest, null, 2))
|
|
221
|
+
return manifest
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
225
|
+
const [, , source, mapping, output, schemaVersion] = process.argv
|
|
226
|
+
if (!source || !mapping || !output) throw new Error('Usage: node convert.mjs <raw.ndjson> <mapping.json> <new-output-directory> <installed-schema-version>')
|
|
227
|
+
await prepareSnapshot(resolve(source), resolve(mapping), resolve(output), schemaVersion)
|
|
228
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{"_id": "page.en", "_type": "page", "_rev": "fixture-en", "language": "en", "title": "Hello", "enabled": true, "rank": 4, "hero": {"_type": "image", "_key": "photo", "asset": {"_type": "reference", "_ref": "image-mark-24x24-svg"}, "alt": ""}, "related": {"_type": "reference", "_ref": "about.en"}, "body": [{"_type": "block", "_key": "intro", "style": "normal", "markDefs": [{"_type": "link", "_key": "website", "href": "https://example.com/"}], "children": [{"_type": "span", "_key": "span", "text": "Hello ", "marks": ["strong"]}, {"_type": "span", "_key": "span", "text": "world", "marks": ["website"]}]}, {"_type": "block", "_key": "bullet", "style": "normal", "markDefs": [], "children": [{"_type": "span", "_key": "span", "text": "First", "marks": []}], "listItem": "bullet", "level": 1}, {"_type": "block", "_key": "nested", "style": "normal", "markDefs": [], "children": [{"_type": "span", "_key": "span", "text": "Nested", "marks": []}], "listItem": "number", "level": 2}, {"_type": "image", "_key": "photo", "asset": {"_type": "reference", "_ref": "image-mark-24x24-svg"}, "alt": ""}], "sections": [{"_type": "stack", "_key": "stack1", "title": "Features", "children": [{"_type": "text", "_key": "copy1", "title": "Composable"}, {"_type": "image", "_key": "photo", "asset": {"_type": "reference", "_ref": "image-mark-24x24-svg"}, "alt": ""}]}]}
|
|
2
|
+
{"_id": "page.fr", "_type": "page", "language": "fr", "title": "Bonjour", "enabled": false, "rank": 0, "sections": []}
|
|
3
|
+
{"_id": "page.zh", "_type": "page", "language": "zh", "title": "你好", "body": [{"_type": "block", "_key": "intro", "style": "normal", "markDefs": [], "children": [{"_type": "span", "_key": "s", "text": "你好,世界", "marks": ["em"]}]}, {"_type": "image", "_key": "photo", "asset": {"_type": "reference", "_ref": "image-mark-24x24-svg"}, "alt": "标志"}]}
|
|
4
|
+
{"_id": "about.en", "_type": "page", "language": "en", "title": "About", "related": {"_type": "reference", "_ref": "page.en"}, "body": [{"_type": "block", "_key": "about", "style": "normal", "markDefs": [], "children": [{"_type": "span", "_key": "span", "text": "About us", "marks": []}]}]}
|
|
5
|
+
{"_id": "settings.en", "_type": "settings", "language": "en", "title": "Site", "enabled": true}
|
|
6
|
+
{"_id": "drafts.page.en", "_type": "page", "title": "Unpublished draft excluded"}
|
|
7
|
+
{"_id": "image-mark-24x24-svg", "_type": "sanity.imageAsset", "originalFilename": "mark.svg", "mimeType": "image/svg+xml", "size": 112, "sha1hash": "61dd7f31ce2baf2c747a17ff256e9d7e7422d4af"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"namespace": "sanity-reference-production",
|
|
3
|
+
"families": [
|
|
4
|
+
{
|
|
5
|
+
"key": "home",
|
|
6
|
+
"primaryLocale": "en",
|
|
7
|
+
"members": {
|
|
8
|
+
"en": {
|
|
9
|
+
"sourceId": "page.en"
|
|
10
|
+
},
|
|
11
|
+
"fr": {
|
|
12
|
+
"sourceId": "page.fr"
|
|
13
|
+
},
|
|
14
|
+
"zh": {
|
|
15
|
+
"sourceId": "page.zh",
|
|
16
|
+
"clearedPaths": [
|
|
17
|
+
"hero"
|
|
18
|
+
]
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"key": "about",
|
|
24
|
+
"primaryLocale": "en",
|
|
25
|
+
"members": {
|
|
26
|
+
"en": {
|
|
27
|
+
"sourceId": "about.en"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"key": "settings",
|
|
33
|
+
"primaryLocale": "en",
|
|
34
|
+
"members": {
|
|
35
|
+
"en": {
|
|
36
|
+
"sourceId": "settings.en"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
],
|
|
41
|
+
"assets": {
|
|
42
|
+
"image-mark-24x24-svg": "originals/mark.svg"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M4 4h16v16H4z"/></svg>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
story: articleBody
|
|
2
|
+
kind: field
|
|
3
|
+
extends: richtext
|
|
4
|
+
label: Article body
|
|
5
|
+
---
|
|
6
|
+
richtext:
|
|
7
|
+
blocks:
|
|
8
|
+
paragraph: {}
|
|
9
|
+
heading: { levels: [1, 2, 3, 4, 5, 6] }
|
|
10
|
+
blockquote: {}
|
|
11
|
+
lists:
|
|
12
|
+
bullet: { maxDepth: 3 }
|
|
13
|
+
ordered: { maxDepth: 3 }
|
|
14
|
+
inline:
|
|
15
|
+
marks:
|
|
16
|
+
bold: {}
|
|
17
|
+
italic: {}
|
|
18
|
+
underline: {}
|
|
19
|
+
strike: {}
|
|
20
|
+
code: {}
|
|
21
|
+
link: { linkTypes: [url, document] }
|
|
22
|
+
hardBreak: true
|
|
23
|
+
embeds: [picture]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
story: page
|
|
2
|
+
kind: document
|
|
3
|
+
label: Page
|
|
4
|
+
---
|
|
5
|
+
fields:
|
|
6
|
+
article:
|
|
7
|
+
body:
|
|
8
|
+
type: articleBody
|
|
9
|
+
editor:
|
|
10
|
+
zone: true
|
|
11
|
+
content:
|
|
12
|
+
title: { type: text }
|
|
13
|
+
hero: { type: image }
|
|
14
|
+
related: { type: reference, target: page }
|
|
15
|
+
enabled: { type: boolean }
|
|
16
|
+
rank: { type: number }
|
|
17
|
+
sections:
|
|
18
|
+
type: sliceZone
|
|
19
|
+
allowed: [copy, picture, stack]
|
package/package.json
CHANGED
|
@@ -1,6 +1,78 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@storystick/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.0-rc.1",
|
|
4
|
+
"license": "FSL-1.1-MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"storystick": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"assets",
|
|
12
|
+
"LICENSE",
|
|
13
|
+
"README.md",
|
|
14
|
+
"CONTENT-IMPORT.md",
|
|
15
|
+
"examples/sanity/README.md",
|
|
16
|
+
"examples/sanity/convert.mjs",
|
|
17
|
+
"examples/sanity/stories",
|
|
18
|
+
"examples/sanity/fixture",
|
|
19
|
+
"PUBLIC-API.md"
|
|
20
|
+
],
|
|
21
|
+
"exports": {
|
|
22
|
+
"./resolve": {
|
|
23
|
+
"types": "./dist/resolve.d.ts",
|
|
24
|
+
"import": "./dist/resolve.js"
|
|
25
|
+
},
|
|
26
|
+
"./content-import": {
|
|
27
|
+
"types": "./dist/content-import.d.ts",
|
|
28
|
+
"import": "./dist/content-import.js"
|
|
29
|
+
},
|
|
30
|
+
"./asset-import": {
|
|
31
|
+
"types": "./dist/asset-import.d.ts",
|
|
32
|
+
"import": "./dist/asset-import.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public",
|
|
37
|
+
"provenance": false,
|
|
38
|
+
"tag": "beta"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@inquirer/prompts": "^7.2.1",
|
|
42
|
+
"@modelcontextprotocol/server": "2.4.0",
|
|
43
|
+
"cac": "^6.7.14",
|
|
44
|
+
"esbuild": "^0.28.2",
|
|
45
|
+
"execa": "^9.6.1",
|
|
46
|
+
"open": "^11.0.0",
|
|
47
|
+
"ora": "^8.2.0",
|
|
48
|
+
"picocolors": "^1.1.1",
|
|
49
|
+
"zod": "^4.6.7",
|
|
50
|
+
"@storystick/core": "0.2.0-rc.1",
|
|
51
|
+
"@storystick/source": "0.1.0-rc.1",
|
|
52
|
+
"@storystick/types": "0.1.0-rc.1"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@modelcontextprotocol/client": "2.4.0",
|
|
56
|
+
"@types/node": "^22.13.10",
|
|
57
|
+
"typescript": "^5.8.2",
|
|
58
|
+
"vitest": "^3.1.1"
|
|
59
|
+
},
|
|
60
|
+
"engines": {
|
|
61
|
+
"node": ">=24.19.0 <25"
|
|
62
|
+
},
|
|
63
|
+
"repository": {
|
|
64
|
+
"type": "git",
|
|
65
|
+
"url": "git+https://github.com/withstorystick/storystick.git",
|
|
66
|
+
"directory": "packages/cli"
|
|
67
|
+
},
|
|
68
|
+
"homepage": "https://storystick.app",
|
|
69
|
+
"bugs": {
|
|
70
|
+
"url": "https://storystick.app"
|
|
71
|
+
},
|
|
72
|
+
"scripts": {
|
|
73
|
+
"build": "node scripts/clean-dist.mjs && tsc",
|
|
74
|
+
"test": "pnpm run test:lint && vitest run",
|
|
75
|
+
"test:lint": "node scripts/lint-output.mjs",
|
|
76
|
+
"test:watch": "vitest"
|
|
77
|
+
}
|
|
6
78
|
}
|