@ssobig/writer-cli 0.2.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/README.md +35 -0
- package/asset-repository.js +278 -0
- package/config.js +14 -0
- package/package.json +28 -0
- package/project-runtime.js +102 -0
- package/storage-path.js +110 -0
- package/templates/mystery-v1/authoring-view-preference.js +34 -0
- package/templates/mystery-v1/character-perspective-preview.js +61 -0
- package/templates/mystery-v1/component-asset-operations.js +103 -0
- package/templates/mystery-v1/component-autosave.js +121 -0
- package/templates/mystery-v1/component-catalog-contract.js +340 -0
- package/templates/mystery-v1/component-checkpoint-history.js +145 -0
- package/templates/mystery-v1/component-contract.js +90 -0
- package/templates/mystery-v1/component-draft-operations.js +313 -0
- package/templates/mystery-v1/component-field-contracts.js +595 -0
- package/templates/mystery-v1/component-id-policy.js +64 -0
- package/templates/mystery-v1/component-manager.js +396 -0
- package/templates/mystery-v1/component-navigation-counts.js +64 -0
- package/templates/mystery-v1/component-registry.js +205 -0
- package/templates/mystery-v1/component-renderers.js +139 -0
- package/templates/mystery-v1/component-storage-contract.js +237 -0
- package/templates/mystery-v1/external-update-coordinator.js +91 -0
- package/templates/mystery-v1/output-clue-card-layout.js +46 -0
- package/templates/mystery-v1/page-header.js +26 -0
- package/templates/mystery-v1/render-ui-state.js +76 -0
- package/templates/mystery-v1/runtime-snapshot-reconciler.js +40 -0
- package/templates/mystery-v1/tab-bar.js +87 -0
- package/templates/mystery-v1/view-component-contract.js +152 -0
- package/templates/mystery-v1/view-component-registry.js +44 -0
- package/templates/mystery-v1/view-component-runtime.js +95 -0
- package/tools/writer-cli/bin/ssobig-writer-daemon.cjs +34 -0
- package/tools/writer-cli/bin/ssobig-writer.cjs +12 -0
- package/tools/writer-cli/package-lock.json +121 -0
- package/tools/writer-cli/package.json +22 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/SKILL.md +38 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/agents/openai.yaml +4 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/references/assets-checkpoints.md +5 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/references/errors.md +10 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/references/install-auth.md +7 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/references/projects-components.md +7 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/references/read-search.md +5 -0
- package/tools/writer-cli/src/agent-paths.cjs +114 -0
- package/tools/writer-cli/src/agent-service.cjs +496 -0
- package/tools/writer-cli/src/asset-policy.cjs +113 -0
- package/tools/writer-cli/src/auth.cjs +655 -0
- package/tools/writer-cli/src/checkpoint-diff.cjs +128 -0
- package/tools/writer-cli/src/command-registry.cjs +152 -0
- package/tools/writer-cli/src/commands.cjs +841 -0
- package/tools/writer-cli/src/corpus.cjs +83 -0
- package/tools/writer-cli/src/daemon-app.cjs +106 -0
- package/tools/writer-cli/src/daemon-client.cjs +187 -0
- package/tools/writer-cli/src/daemon-protocol.cjs +184 -0
- package/tools/writer-cli/src/daemon-runner.cjs +97 -0
- package/tools/writer-cli/src/daemon-server.cjs +378 -0
- package/tools/writer-cli/src/diagnostics.cjs +235 -0
- package/tools/writer-cli/src/domain.cjs +731 -0
- package/tools/writer-cli/src/errors.cjs +47 -0
- package/tools/writer-cli/src/gateway.cjs +357 -0
- package/tools/writer-cli/src/investigation-board-layout.cjs +328 -0
- package/tools/writer-cli/src/json-patch.cjs +98 -0
- package/tools/writer-cli/src/json.cjs +26 -0
- package/tools/writer-cli/src/local-index-cache.cjs +139 -0
- package/tools/writer-cli/src/local-index-lookup.cjs +98 -0
- package/tools/writer-cli/src/local-index-query.cjs +304 -0
- package/tools/writer-cli/src/local-index-snapshot.cjs +235 -0
- package/tools/writer-cli/src/local-index-storage.cjs +284 -0
- package/tools/writer-cli/src/local-index.cjs +199 -0
- package/tools/writer-cli/src/mutations.cjs +722 -0
- package/tools/writer-cli/src/platform-runner.cjs +55 -0
- package/tools/writer-cli/src/project-import.cjs +485 -0
- package/tools/writer-cli/src/skill-manager.cjs +255 -0
- package/tools/writer-cli/src/source-fingerprint.cjs +90 -0
- package/tools/writer-cli/src/update-gate.cjs +102 -0
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
(function (root, factory) {
|
|
2
|
+
const api = factory();
|
|
3
|
+
if (typeof module === "object" && module.exports) module.exports = api;
|
|
4
|
+
if (root) root.WriterWorkbenchComponentFieldContracts = api;
|
|
5
|
+
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
|
|
6
|
+
"use strict";
|
|
7
|
+
|
|
8
|
+
const IDENTITY = Object.freeze({
|
|
9
|
+
UUID: Object.freeze({ field: "id", format: "uuid", visibility: "hidden" }),
|
|
10
|
+
STABLE_KEY: Object.freeze({ source: "object-key", format: "stable-key", visibility: "hidden" })
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
function field(type, options = {}) {
|
|
14
|
+
return { type, ...options };
|
|
15
|
+
}
|
|
16
|
+
function shape(required = {}, optional = {}, options = {}) {
|
|
17
|
+
return { required, optional, additionalProperties: false, ...options };
|
|
18
|
+
}
|
|
19
|
+
function component(input) {
|
|
20
|
+
return {
|
|
21
|
+
templateId: input.templateId,
|
|
22
|
+
identityPolicy: input.identityPolicy || null,
|
|
23
|
+
root: input.root,
|
|
24
|
+
items: input.items || {},
|
|
25
|
+
maps: input.maps || {}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function deepFreeze(value) {
|
|
29
|
+
if (!value || typeof value !== "object" || Object.isFrozen(value)) return value;
|
|
30
|
+
Object.values(value).forEach(deepFreeze);
|
|
31
|
+
return Object.freeze(value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const SECTION_ITEM = shape({
|
|
35
|
+
id: field("uuid", { role: "technical-identity", visibility: "hidden" }),
|
|
36
|
+
title: field("non-empty-string", { role: "display-title" }),
|
|
37
|
+
body: field("string", { role: "content" })
|
|
38
|
+
});
|
|
39
|
+
const CONTRACTS = deepFreeze([
|
|
40
|
+
component({
|
|
41
|
+
templateId: "ssobig.basic",
|
|
42
|
+
root: shape({ title: field("non-empty-string", { role: "display-title" }) }, {
|
|
43
|
+
intro: field("string"), detail: field("string"), minPlayers: field("non-negative-integer"),
|
|
44
|
+
maxPlayers: field("non-negative-integer"), brandColor: field("hex-color"),
|
|
45
|
+
backgroundColor: field("hex-color"), theme: field("enum", { values: ["light", "dark"] }), font: field("string")
|
|
46
|
+
})
|
|
47
|
+
}),
|
|
48
|
+
component({
|
|
49
|
+
templateId: "ssobig.progress",
|
|
50
|
+
identityPolicy: IDENTITY.UUID,
|
|
51
|
+
root: shape({ steps: field("array", { item: "progressStep" }) }),
|
|
52
|
+
items: {
|
|
53
|
+
progressStep: shape({
|
|
54
|
+
id: field("uuid", { role: "technical-identity", visibility: "hidden" }),
|
|
55
|
+
name: field("non-empty-string", { role: "display-name" })
|
|
56
|
+
}, {
|
|
57
|
+
time: field("string"), description: field("string"), actions: field("string")
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
}),
|
|
61
|
+
...["ssobig.common", "ssobig.choice"].map(templateId => component({
|
|
62
|
+
templateId,
|
|
63
|
+
root: shape({
|
|
64
|
+
sections: field("string-map"), customSections: field("array", { item: "section" }),
|
|
65
|
+
sectionOrder: field("unique-string-array"), baseTitles: field("string-map"),
|
|
66
|
+
deletedBaseSections: field("unique-string-array")
|
|
67
|
+
}),
|
|
68
|
+
items: { section: SECTION_ITEM },
|
|
69
|
+
maps: {
|
|
70
|
+
sections: field("string-map", { keyRole: "base-section-id", valueRole: "body" }),
|
|
71
|
+
baseTitles: field("string-map", { keyRole: "base-section-id", valueRole: "title" })
|
|
72
|
+
}
|
|
73
|
+
})),
|
|
74
|
+
component({
|
|
75
|
+
templateId: "ssobig.character",
|
|
76
|
+
identityPolicy: IDENTITY.STABLE_KEY,
|
|
77
|
+
root: shape({
|
|
78
|
+
characters: field("object-map", { values: "character", keyFormat: "stable-key" }), names: field("non-empty-string-map", { keyFormat: "stable-key" }),
|
|
79
|
+
order: field("unique-string-array"), customCharacters: field("array", { item: "customCharacterRef" }),
|
|
80
|
+
deletedColumns: field("unique-string-array", { keyFormat: "stable-key" })
|
|
81
|
+
}, {
|
|
82
|
+
enabledOptionalFields: field("unique-string-array")
|
|
83
|
+
}),
|
|
84
|
+
items: {
|
|
85
|
+
character: shape({
|
|
86
|
+
isPlayable: field("boolean", { role: "player-character-discriminator" }),
|
|
87
|
+
customSections: field("array", { item: "section" }),
|
|
88
|
+
containers: field("array", { item: "characterContainer" })
|
|
89
|
+
}, {
|
|
90
|
+
color: field("hex-color"), tag: field("string"), authorNote: field("string")
|
|
91
|
+
}),
|
|
92
|
+
section: SECTION_ITEM,
|
|
93
|
+
characterContainer: shape({
|
|
94
|
+
id: field("uuid", { role: "technical-identity", visibility: "hidden" }),
|
|
95
|
+
role: field("enum", { values: ["primary", "content"] }),
|
|
96
|
+
title: field("non-empty-string", { role: "display-title" }),
|
|
97
|
+
sectionIds: field("unique-uuid-array")
|
|
98
|
+
}),
|
|
99
|
+
customCharacterRef: shape({ id: field("stable-key", { references: "characters key" }) }),
|
|
100
|
+
}
|
|
101
|
+
}),
|
|
102
|
+
component({
|
|
103
|
+
templateId: "ssobig.clues",
|
|
104
|
+
identityPolicy: IDENTITY.UUID,
|
|
105
|
+
root: shape({
|
|
106
|
+
clues: field("array", { item: "clue" }),
|
|
107
|
+
enabledOptionalFields: field("unique-string-array")
|
|
108
|
+
}, { writerNote: field("string") }),
|
|
109
|
+
items: {
|
|
110
|
+
clue: shape({
|
|
111
|
+
id: field("uuid", { role: "technical-identity", visibility: "hidden" }),
|
|
112
|
+
name: field("non-empty-string", { role: "display-name" }),
|
|
113
|
+
title: field("string", { role: "content-title" }),
|
|
114
|
+
description: field("string", { role: "content" })
|
|
115
|
+
}, {
|
|
116
|
+
confirmed: field("boolean"), location: field("string"),
|
|
117
|
+
color: field("hex-color-or-empty"), tags: field("unique-non-empty-string-array"), public: field("string"),
|
|
118
|
+
secret: field("string"), hint: field("string"), room: field("string"), round: field("integer"),
|
|
119
|
+
kind: field("string"), point: field("string"), spot: field("string"), character: field("string"),
|
|
120
|
+
related: field("string"), sourceId: field("string"), useTargetType: field("string"), onlyPositiveAllowed: field("boolean")
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
}),
|
|
124
|
+
component({
|
|
125
|
+
templateId: "ssobig.clue-combinations",
|
|
126
|
+
identityPolicy: IDENTITY.UUID,
|
|
127
|
+
root: shape({ combinations: field("array", { item: "combination" }) }),
|
|
128
|
+
items: {
|
|
129
|
+
combination: shape({
|
|
130
|
+
id: field("uuid", { role: "technical-identity", visibility: "hidden" }),
|
|
131
|
+
name: field("non-empty-string", { role: "display-name" }),
|
|
132
|
+
conditions: field("array", { item: "combinationCondition" }),
|
|
133
|
+
results: field("array", { item: "combinationResult" }),
|
|
134
|
+
hint: field("string")
|
|
135
|
+
}),
|
|
136
|
+
combinationCondition: shape({
|
|
137
|
+
kind: field("enum", { values: ["clue", "output", "literal"] })
|
|
138
|
+
}, {
|
|
139
|
+
clueId: field("uuid", { references: "ssobig.clues[].id" }),
|
|
140
|
+
outputId: field("uuid", { references: "combination result id" }),
|
|
141
|
+
value: field("non-empty-string")
|
|
142
|
+
}),
|
|
143
|
+
combinationResult: shape({
|
|
144
|
+
id: field("uuid", { role: "technical-identity", visibility: "hidden" }),
|
|
145
|
+
kind: field("enum", { values: ["clue", "literal"] })
|
|
146
|
+
}, {
|
|
147
|
+
clueId: field("uuid", { references: "ssobig.clues[].id" }),
|
|
148
|
+
value: field("non-empty-string")
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
}),
|
|
152
|
+
component({
|
|
153
|
+
templateId: "ssobig.ai-response",
|
|
154
|
+
identityPolicy: IDENTITY.UUID,
|
|
155
|
+
root: shape({ responses: field("array", { item: "response" }) }),
|
|
156
|
+
items: {
|
|
157
|
+
response: shape({
|
|
158
|
+
id: field("uuid", { visibility: "hidden" }), trigger: field("non-empty-string", { role: "display-name" }),
|
|
159
|
+
answer: field("string")
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
}),
|
|
163
|
+
component({
|
|
164
|
+
templateId: "ssobig.timeline",
|
|
165
|
+
identityPolicy: IDENTITY.UUID,
|
|
166
|
+
root: shape({ events: field("array", { item: "event" }) }),
|
|
167
|
+
items: {
|
|
168
|
+
event: shape({
|
|
169
|
+
id: field("uuid", { visibility: "hidden" }), time: field("non-empty-string", { role: "display-name" }),
|
|
170
|
+
truth: field("string"), memories: field("object-map", { values: "memory", keyFormat: "stable-key", keyReferences: "ssobig.character key" })
|
|
171
|
+
}, {
|
|
172
|
+
location: field("string"),
|
|
173
|
+
unassignedMemories: field("object-map", {
|
|
174
|
+
values: "memory", keyFormat: "stable-key", keyRole: "unresolved-character-key"
|
|
175
|
+
})
|
|
176
|
+
}),
|
|
177
|
+
memory: shape({ included: field("boolean"), text: field("string") })
|
|
178
|
+
}
|
|
179
|
+
}),
|
|
180
|
+
component({
|
|
181
|
+
templateId: "ssobig.ending",
|
|
182
|
+
identityPolicy: IDENTITY.UUID,
|
|
183
|
+
root: shape({ conditions: field("array", { item: "condition" }), outcomes: field("array", { item: "outcome" }) }),
|
|
184
|
+
items: {
|
|
185
|
+
condition: shape({
|
|
186
|
+
id: field("uuid", { visibility: "hidden" }), condition: field("string")
|
|
187
|
+
}, { code: field("string") }),
|
|
188
|
+
outcome: shape({
|
|
189
|
+
id: field("uuid", { visibility: "hidden", references: "condition.id" }),
|
|
190
|
+
title: field("non-empty-string", { role: "display-title" }), body: field("string")
|
|
191
|
+
}, { customSections: field("array", { item: "section" }) }),
|
|
192
|
+
section: SECTION_ITEM
|
|
193
|
+
}
|
|
194
|
+
}),
|
|
195
|
+
component({
|
|
196
|
+
templateId: "ssobig.postgame",
|
|
197
|
+
identityPolicy: IDENTITY.UUID,
|
|
198
|
+
root: shape({
|
|
199
|
+
truth: field("array", { item: "document" }), epilogue: field("array", { item: "document" }),
|
|
200
|
+
intro: field("object", { item: "intro" })
|
|
201
|
+
}),
|
|
202
|
+
items: {
|
|
203
|
+
intro: shape({ truth: field("string"), epilogue: field("string") }),
|
|
204
|
+
document: shape({
|
|
205
|
+
id: field("uuid", { visibility: "hidden" }), title: field("non-empty-string", { role: "display-title" }),
|
|
206
|
+
body: field("string")
|
|
207
|
+
}, {
|
|
208
|
+
displayId: field("string"), customSections: field("array", { item: "section" })
|
|
209
|
+
}),
|
|
210
|
+
section: SECTION_ITEM
|
|
211
|
+
}
|
|
212
|
+
}),
|
|
213
|
+
component({
|
|
214
|
+
templateId: "ssobig.author-notes",
|
|
215
|
+
identityPolicy: IDENTITY.UUID,
|
|
216
|
+
root: shape({ notes: field("array", { item: "note" }) }),
|
|
217
|
+
items: {
|
|
218
|
+
note: shape({
|
|
219
|
+
id: field("uuid", { visibility: "hidden" }),
|
|
220
|
+
title: field("string", { role: "display-title" }), body: field("string")
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
}),
|
|
224
|
+
component({
|
|
225
|
+
templateId: "ssobig.investigation-board",
|
|
226
|
+
identityPolicy: IDENTITY.STABLE_KEY,
|
|
227
|
+
root: shape({
|
|
228
|
+
viewport: field("object", { item: "boardViewport" }),
|
|
229
|
+
nodes: field("array", { item: "boardNode" }),
|
|
230
|
+
edges: field("array", { item: "boardEdge" })
|
|
231
|
+
}),
|
|
232
|
+
items: {
|
|
233
|
+
boardViewport: shape({
|
|
234
|
+
width: field("integer", { minimum: 800, maximum: 10000 }),
|
|
235
|
+
height: field("integer", { minimum: 600, maximum: 10000 })
|
|
236
|
+
}),
|
|
237
|
+
boardNode: shape({
|
|
238
|
+
id: field("stable-key", { visibility: "hidden" }),
|
|
239
|
+
type: field("enum", { values: ["reference", "sticky"] }),
|
|
240
|
+
x: field("integer", { minimum: 0, maximum: 10000 }),
|
|
241
|
+
y: field("integer", { minimum: 0, maximum: 10000 }),
|
|
242
|
+
width: field("integer", { minimum: 140, maximum: 10000 }),
|
|
243
|
+
height: field("integer", { minimum: 100, maximum: 10000 }),
|
|
244
|
+
color: field("hex-color")
|
|
245
|
+
}, {
|
|
246
|
+
title: field("string"), text: field("string"),
|
|
247
|
+
referenceType: field("enum", { values: ["character", "clue"] }),
|
|
248
|
+
referenceId: field("non-empty-string")
|
|
249
|
+
}),
|
|
250
|
+
boardEdge: shape({
|
|
251
|
+
id: field("stable-key", { visibility: "hidden" }),
|
|
252
|
+
fromNodeId: field("stable-key"), toNodeId: field("stable-key"),
|
|
253
|
+
direction: field("enum", { values: ["none", "forward", "both"] }),
|
|
254
|
+
label: field("string"), color: field("hex-color")
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
}),
|
|
258
|
+
component({
|
|
259
|
+
templateId: "ssobig.assets",
|
|
260
|
+
root: shape({ assets: field("object-map", { values: ["asset", "tombstone"] }) }),
|
|
261
|
+
items: {
|
|
262
|
+
asset: shape({
|
|
263
|
+
path: field("safe-storage-relative-path"), name: field("non-empty-string"), type: field("supported-mime"),
|
|
264
|
+
contentHash: field("non-empty-string"), operationId: field("non-empty-string"), updatedAt: field("non-negative-integer")
|
|
265
|
+
}, { size: field("integer", { minimum: 0, maximum: 15728640 }) }),
|
|
266
|
+
tombstone: shape({ deleted: field("literal", { value: true }), operationId: field("non-empty-string"), updatedAt: field("non-negative-integer") })
|
|
267
|
+
}
|
|
268
|
+
}),
|
|
269
|
+
component({
|
|
270
|
+
templateId: "ssobig.documents",
|
|
271
|
+
identityPolicy: IDENTITY.UUID,
|
|
272
|
+
root: shape({ blocks: field("array", { item: "document" }) }),
|
|
273
|
+
items: {
|
|
274
|
+
document: shape({
|
|
275
|
+
id: field("uuid", { visibility: "hidden" }), title: field("non-empty-string", { role: "display-title" }), body: field("string")
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
})
|
|
279
|
+
]);
|
|
280
|
+
|
|
281
|
+
const BY_ID = new Map(CONTRACTS.map(contract => [contract.templateId, contract]));
|
|
282
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
283
|
+
const STABLE_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
284
|
+
const HEX_COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
|
|
285
|
+
const SUPPORTED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif", "application/json"];
|
|
286
|
+
|
|
287
|
+
function isObject(value) { return Boolean(value && typeof value === "object" && !Array.isArray(value)); }
|
|
288
|
+
function clone(value) {
|
|
289
|
+
if (Array.isArray(value)) return value.map(clone);
|
|
290
|
+
if (isObject(value)) return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, clone(child)]));
|
|
291
|
+
return value;
|
|
292
|
+
}
|
|
293
|
+
function schemaForShape(shapeDefinition, contract) {
|
|
294
|
+
const properties = Object.fromEntries([...Object.entries(shapeDefinition.required), ...Object.entries(shapeDefinition.optional)]
|
|
295
|
+
.map(([name, definition]) => [name, schemaForField(definition, contract)]));
|
|
296
|
+
return {
|
|
297
|
+
type: "object",
|
|
298
|
+
additionalProperties: shapeDefinition.additionalProperties === true,
|
|
299
|
+
required: Object.keys(shapeDefinition.required),
|
|
300
|
+
properties
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
function schemaForNamedItem(name, contract) {
|
|
304
|
+
const item = contract.items[name];
|
|
305
|
+
if (!item) throw new Error(`${contract.templateId}에 등록되지 않은 item 계약입니다: ${name}`);
|
|
306
|
+
return schemaForShape(item, contract);
|
|
307
|
+
}
|
|
308
|
+
function schemaForAlternatives(values, contract) {
|
|
309
|
+
const definitions = Array.isArray(values) ? values : [values];
|
|
310
|
+
const schemas = definitions.map(value => contract.items[value]
|
|
311
|
+
? schemaForNamedItem(value, contract)
|
|
312
|
+
: schemaForField(field(value), contract));
|
|
313
|
+
return schemas.length === 1 ? schemas[0] : { oneOf: schemas };
|
|
314
|
+
}
|
|
315
|
+
function schemaForField(definition, contract) {
|
|
316
|
+
switch (definition.type) {
|
|
317
|
+
case "string": return { type: "string" };
|
|
318
|
+
case "non-empty-string": return { type: "string", minLength: 1, pattern: "\\S" };
|
|
319
|
+
case "stable-key": return { type: "string", pattern: STABLE_KEY_PATTERN.source };
|
|
320
|
+
case "uuid": return { type: "string", format: "uuid" };
|
|
321
|
+
case "hex-color": return { type: "string", pattern: HEX_COLOR_PATTERN.source };
|
|
322
|
+
case "hex-color-or-empty": return { type: "string", pattern: `^(?:|#[0-9a-fA-F]{6})$` };
|
|
323
|
+
case "safe-storage-relative-path": return { type: "string", minLength: 1 };
|
|
324
|
+
case "supported-mime": return { type: "string", enum: SUPPORTED_MIME_TYPES.slice() };
|
|
325
|
+
case "number": return { type: "number" };
|
|
326
|
+
case "positive-number": return { type: "number", exclusiveMinimum: 0 };
|
|
327
|
+
case "integer": {
|
|
328
|
+
const schema = { type: "integer" };
|
|
329
|
+
if (definition.minimum !== undefined) schema.minimum = definition.minimum;
|
|
330
|
+
if (definition.maximum !== undefined) schema.maximum = definition.maximum;
|
|
331
|
+
return schema;
|
|
332
|
+
}
|
|
333
|
+
case "non-negative-integer": return { type: "integer", minimum: 0 };
|
|
334
|
+
case "boolean": return { type: "boolean" };
|
|
335
|
+
case "null": return { type: "null" };
|
|
336
|
+
case "literal": return { const: definition.value };
|
|
337
|
+
case "enum": return { enum: definition.values.slice() };
|
|
338
|
+
case "array": return { type: "array", items: schemaForNamedItem(definition.item, contract) };
|
|
339
|
+
case "string-array": return { type: "array", items: { type: "string", ...(definition.keyFormat === "stable-key" ? { pattern: STABLE_KEY_PATTERN.source } : {}) } };
|
|
340
|
+
case "unique-string-array": return { type: "array", uniqueItems: true, items: { type: "string", ...(definition.keyFormat === "stable-key" ? { pattern: STABLE_KEY_PATTERN.source } : {}) } };
|
|
341
|
+
case "unique-uuid-array": return { type: "array", uniqueItems: true, items: { type: "string", format: "uuid" } };
|
|
342
|
+
case "unique-non-empty-string-array": return {
|
|
343
|
+
type: "array",
|
|
344
|
+
uniqueItems: true,
|
|
345
|
+
"x-ssobig-normalized-unique": true,
|
|
346
|
+
items: { type: "string", minLength: 1, pattern: definition.keyFormat === "stable-key" ? STABLE_KEY_PATTERN.source : "\\S" }
|
|
347
|
+
};
|
|
348
|
+
case "object": return definition.item ? schemaForNamedItem(definition.item, contract) : { type: "object" };
|
|
349
|
+
case "string-map":
|
|
350
|
+
case "non-empty-string-map":
|
|
351
|
+
return {
|
|
352
|
+
type: "object",
|
|
353
|
+
...(definition.keyFormat === "stable-key" ? { propertyNames: { pattern: STABLE_KEY_PATTERN.source } } : {}),
|
|
354
|
+
additionalProperties: { type: "string", ...(definition.type === "non-empty-string-map" ? { minLength: 1, pattern: "\\S" } : {}) }
|
|
355
|
+
};
|
|
356
|
+
case "object-map": return {
|
|
357
|
+
type: "object",
|
|
358
|
+
...(definition.keyFormat === "stable-key" ? { propertyNames: { pattern: STABLE_KEY_PATTERN.source } } : {}),
|
|
359
|
+
additionalProperties: schemaForAlternatives(definition.values, contract)
|
|
360
|
+
};
|
|
361
|
+
default: throw new Error(`${contract.templateId}에서 지원하지 않는 field type입니다: ${definition.type}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
function jsonSchemaFor(templateId) {
|
|
365
|
+
const contract = getContract(templateId);
|
|
366
|
+
return contract ? clone(schemaForShape(contract.root, contract)) : null;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function validateShape(value, shapeDefinition, contract, path) {
|
|
370
|
+
if (!isObject(value)) return `${path} 값은 객체여야 합니다.`;
|
|
371
|
+
const allowed = new Set([...Object.keys(shapeDefinition.required), ...Object.keys(shapeDefinition.optional)]);
|
|
372
|
+
const unsupported = Object.keys(value).find(key => !allowed.has(key));
|
|
373
|
+
if (unsupported) return `${path}.${unsupported} 필드는 지원되지 않습니다.`;
|
|
374
|
+
for (const [name, definition] of Object.entries(shapeDefinition.required)) {
|
|
375
|
+
if (!Object.hasOwn(value, name)) return `${path}.${name} 필드는 필수입니다.`;
|
|
376
|
+
const invalid = validateField(value[name], definition, contract, `${path}.${name}`);
|
|
377
|
+
if (invalid) return invalid;
|
|
378
|
+
}
|
|
379
|
+
for (const [name, definition] of Object.entries(shapeDefinition.optional)) {
|
|
380
|
+
if (!Object.hasOwn(value, name)) continue;
|
|
381
|
+
const invalid = validateField(value[name], definition, contract, `${path}.${name}`);
|
|
382
|
+
if (invalid) return invalid;
|
|
383
|
+
}
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
function validateNamedItem(value, name, contract, path) {
|
|
387
|
+
const definition = contract.items[name];
|
|
388
|
+
return definition ? validateShape(value, definition, contract, path) : `${path}의 item 계약(${name})이 등록되지 않았습니다.`;
|
|
389
|
+
}
|
|
390
|
+
function validateAlternatives(value, values, contract, path) {
|
|
391
|
+
const definitions = Array.isArray(values) ? values : [values];
|
|
392
|
+
for (const candidate of definitions) {
|
|
393
|
+
const invalid = contract.items[candidate]
|
|
394
|
+
? validateNamedItem(value, candidate, contract, path)
|
|
395
|
+
: validateField(value, field(candidate), contract, path);
|
|
396
|
+
if (!invalid) return null;
|
|
397
|
+
}
|
|
398
|
+
return `${path} 값이 허용된 형식과 일치하지 않습니다.`;
|
|
399
|
+
}
|
|
400
|
+
function validateField(value, definition, contract, path) {
|
|
401
|
+
switch (definition.type) {
|
|
402
|
+
case "string": return typeof value === "string" ? null : `${path} 값은 문자열이어야 합니다.`;
|
|
403
|
+
case "non-empty-string": return typeof value === "string" && value.trim() ? null : `${path} 값은 비어 있지 않은 문자열이어야 합니다.`;
|
|
404
|
+
case "stable-key": return typeof value === "string" && STABLE_KEY_PATTERN.test(value) ? null : `${path} 값은 안정 식별자 형식이어야 합니다.`;
|
|
405
|
+
case "uuid": return typeof value === "string" && UUID_PATTERN.test(value) ? null : `${path} 값은 UUID여야 합니다.`;
|
|
406
|
+
case "hex-color": return typeof value === "string" && HEX_COLOR_PATTERN.test(value) ? null : `${path} 값은 #RRGGBB 색상이어야 합니다.`;
|
|
407
|
+
case "hex-color-or-empty": return typeof value === "string" && (!value || HEX_COLOR_PATTERN.test(value)) ? null : `${path} 값은 빈 문자열 또는 #RRGGBB 색상이어야 합니다.`;
|
|
408
|
+
case "safe-storage-relative-path": return typeof value === "string" && value && !/^(?:[a-z]+:|\/|\\)/i.test(value) && !value.split(/[\\/]/).some(segment => !segment || segment === "." || segment === "..") ? null : `${path} 값은 안전한 상대 경로여야 합니다.`;
|
|
409
|
+
case "supported-mime": return typeof value === "string" && SUPPORTED_MIME_TYPES.includes(value.toLowerCase()) ? null : `${path} 값은 지원하는 MIME 형식이어야 합니다.`;
|
|
410
|
+
case "number": return typeof value === "number" && Number.isFinite(value) ? null : `${path} 값은 유한한 숫자여야 합니다.`;
|
|
411
|
+
case "positive-number": return typeof value === "number" && Number.isFinite(value) && value > 0 ? null : `${path} 값은 0보다 큰 숫자여야 합니다.`;
|
|
412
|
+
case "integer": return Number.isSafeInteger(value) && (definition.minimum === undefined || value >= definition.minimum) && (definition.maximum === undefined || value <= definition.maximum) ? null : `${path} 값은 허용 범위의 정수여야 합니다.`;
|
|
413
|
+
case "non-negative-integer": return Number.isSafeInteger(value) && value >= 0 ? null : `${path} 값은 0 이상의 정수여야 합니다.`;
|
|
414
|
+
case "boolean": return typeof value === "boolean" ? null : `${path} 값은 boolean이어야 합니다.`;
|
|
415
|
+
case "null": return value === null ? null : `${path} 값은 null이어야 합니다.`;
|
|
416
|
+
case "literal": return value === definition.value ? null : `${path} 값은 ${String(definition.value)}여야 합니다.`;
|
|
417
|
+
case "enum": return definition.values.includes(value) ? null : `${path} 값은 ${definition.values.join(", ")} 중 하나여야 합니다.`;
|
|
418
|
+
case "array": {
|
|
419
|
+
if (!Array.isArray(value)) return `${path} 값은 배열이어야 합니다.`;
|
|
420
|
+
const ids = new Set();
|
|
421
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
422
|
+
const invalid = validateNamedItem(value[index], definition.item, contract, `${path}.${index}`);
|
|
423
|
+
if (invalid) return invalid;
|
|
424
|
+
const id = isObject(value[index]) ? value[index].id : undefined;
|
|
425
|
+
if (typeof id === "string") {
|
|
426
|
+
const normalized = id.toLowerCase();
|
|
427
|
+
if (ids.has(normalized)) return `${path}에 중복 id가 있습니다: ${id}`;
|
|
428
|
+
ids.add(normalized);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
case "string-array": return Array.isArray(value) && value.every(item => typeof item === "string") ? null : `${path} 값은 문자열 배열이어야 합니다.`;
|
|
434
|
+
case "unique-string-array":
|
|
435
|
+
case "unique-uuid-array":
|
|
436
|
+
case "unique-non-empty-string-array": {
|
|
437
|
+
if (!Array.isArray(value) || value.some(item => typeof item !== "string"
|
|
438
|
+
|| (definition.type === "unique-uuid-array" && !UUID_PATTERN.test(item))
|
|
439
|
+
|| (definition.type === "unique-non-empty-string-array" && !item.trim())
|
|
440
|
+
|| (definition.keyFormat === "stable-key" && !STABLE_KEY_PATTERN.test(item)))) {
|
|
441
|
+
return `${path} 값은 ${definition.type === "unique-uuid-array" ? "UUID " : definition.keyFormat === "stable-key" ? "안정 식별자 " : definition.type === "unique-non-empty-string-array" ? "비어 있지 않은 " : ""}문자열 배열이어야 합니다.`;
|
|
442
|
+
}
|
|
443
|
+
const comparable = definition.type === "unique-non-empty-string-array" ? value.map(item => item.trim()) : value;
|
|
444
|
+
return new Set(comparable).size === value.length ? null : `${path} 값에 중복이 있습니다.`;
|
|
445
|
+
}
|
|
446
|
+
case "object": return definition.item ? validateNamedItem(value, definition.item, contract, path) : isObject(value) ? null : `${path} 값은 객체여야 합니다.`;
|
|
447
|
+
case "string-map":
|
|
448
|
+
case "non-empty-string-map": {
|
|
449
|
+
if (!isObject(value)) return `${path} 값은 객체 map이어야 합니다.`;
|
|
450
|
+
const invalidKey = Object.keys(value).find(key => !key
|
|
451
|
+
|| ["__proto__", "prototype", "constructor"].includes(key)
|
|
452
|
+
|| (definition.keyFormat === "stable-key" && !STABLE_KEY_PATTERN.test(key)));
|
|
453
|
+
if (invalidKey) return `${path}.${invalidKey} key는 사용할 수 없습니다.`;
|
|
454
|
+
const invalidValue = Object.entries(value).find(([, item]) => typeof item !== "string" || (definition.type === "non-empty-string-map" && !item.trim()));
|
|
455
|
+
return invalidValue ? `${path}.${invalidValue[0]} 값은 ${definition.type === "non-empty-string-map" ? "비어 있지 않은 " : ""}문자열이어야 합니다.` : null;
|
|
456
|
+
}
|
|
457
|
+
case "object-map": {
|
|
458
|
+
if (!isObject(value)) return `${path} 값은 객체 map이어야 합니다.`;
|
|
459
|
+
for (const [key, item] of Object.entries(value)) {
|
|
460
|
+
if (!key || ["__proto__", "prototype", "constructor"].includes(key) || (definition.keyFormat === "stable-key" && !STABLE_KEY_PATTERN.test(key))) return `${path}.${key} key는 사용할 수 없습니다.`;
|
|
461
|
+
const invalid = validateAlternatives(item, definition.values, contract, `${path}.${key}`);
|
|
462
|
+
if (invalid) return invalid;
|
|
463
|
+
}
|
|
464
|
+
return null;
|
|
465
|
+
}
|
|
466
|
+
default: return `${path}에 지원되지 않는 field type(${definition.type})이 있습니다.`;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
function idSet(items) { return new Set(items.map(item => String(item.id || "").toLowerCase())); }
|
|
470
|
+
function validateRelations(contract, data) {
|
|
471
|
+
switch (contract.templateId) {
|
|
472
|
+
case "ssobig.basic":
|
|
473
|
+
return data.minPlayers !== undefined && data.maxPlayers !== undefined && data.minPlayers > data.maxPlayers
|
|
474
|
+
? "$.minPlayers는 $.maxPlayers보다 클 수 없습니다." : null;
|
|
475
|
+
case "ssobig.common":
|
|
476
|
+
case "ssobig.choice": {
|
|
477
|
+
const customIds = idSet(data.customSections);
|
|
478
|
+
const baseIds = new Set(Object.keys(data.sections));
|
|
479
|
+
const deleted = new Set(data.deletedBaseSections);
|
|
480
|
+
if (Object.keys(data.sections).some(id => typeof data.baseTitles[id] !== "string")) return "$.baseTitles는 모든 base section의 제목을 가져야 합니다.";
|
|
481
|
+
if ([...customIds].some(id => baseIds.has(id))) return "$.customSections id는 base section id와 중복될 수 없습니다.";
|
|
482
|
+
if (data.deletedBaseSections.some(id => !baseIds.has(id))) return "$.deletedBaseSections에 존재하지 않는 base section이 있습니다.";
|
|
483
|
+
if (data.sectionOrder.some(id => deleted.has(id) || (!baseIds.has(id) && !customIds.has(id)))) return "$.sectionOrder에 활성 section을 가리키지 않는 값이 있습니다.";
|
|
484
|
+
const activeIds = new Set([...baseIds].filter(id => !deleted.has(id)).concat([...customIds]));
|
|
485
|
+
if (data.sectionOrder.length !== activeIds.size || [...activeIds].some(id => !data.sectionOrder.includes(id))) return "$.sectionOrder는 모든 활성 section을 정확히 한 번 포함해야 합니다.";
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
case "ssobig.character": {
|
|
489
|
+
const allowedOptionalFields = new Set(["image", "color", "tag", "authorNote"]);
|
|
490
|
+
if (Array.isArray(data.enabledOptionalFields) && data.enabledOptionalFields.some(name => !allowedOptionalFields.has(name))) return "$.enabledOptionalFields에 지원하지 않는 캐릭터 선택 속성이 있습니다.";
|
|
491
|
+
const characterIds = new Set(Object.keys(data.characters));
|
|
492
|
+
if ([...characterIds].some(id => typeof data.names[id] !== "string" || !data.names[id].trim())) return "$.names는 모든 character의 표시 이름을 가져야 합니다.";
|
|
493
|
+
if (Object.keys(data.names).some(id => !characterIds.has(id))) return "$.names에 존재하지 않는 character가 있습니다.";
|
|
494
|
+
if (data.deletedColumns.some(id => characterIds.has(id))) return "$.deletedColumns는 현재 character가 아닌 삭제 이력 ID만 가져야 합니다.";
|
|
495
|
+
if (data.order.some(id => !characterIds.has(id) || data.deletedColumns.includes(id))) return "$.order에 활성 character를 가리키지 않는 값이 있습니다.";
|
|
496
|
+
if (data.customCharacters.some(item => !characterIds.has(item.id))) return "$.customCharacters에 존재하지 않는 character가 있습니다.";
|
|
497
|
+
const activeIds = [...characterIds];
|
|
498
|
+
if (data.order.length !== activeIds.length || activeIds.some(id => !data.order.includes(id))) return "$.order는 모든 활성 character를 정확히 한 번 포함해야 합니다.";
|
|
499
|
+
for (const [characterId, character] of Object.entries(data.characters)) {
|
|
500
|
+
if (!character.containers.length || character.containers[0].role !== "primary") return `$.characters.${characterId}.containers의 첫 항목은 primary Container여야 합니다.`;
|
|
501
|
+
if (character.containers.slice(1).some(container => container.role !== "content")) return `$.characters.${characterId}.containers에는 primary Container가 정확히 하나만 있어야 합니다.`;
|
|
502
|
+
const sectionIds = idSet(character.customSections);
|
|
503
|
+
const placedIds = character.containers.flatMap(container => container.sectionIds.map(id => id.toLowerCase()));
|
|
504
|
+
if (placedIds.some(id => !sectionIds.has(id))) return `$.characters.${characterId}.containers가 존재하지 않는 custom section을 참조합니다.`;
|
|
505
|
+
if (new Set(placedIds).size !== placedIds.length) return `$.characters.${characterId}.customSections는 하나의 Container에만 배치해야 합니다.`;
|
|
506
|
+
if (placedIds.length !== sectionIds.size || [...sectionIds].some(id => !placedIds.includes(id))) return `$.characters.${characterId}.containers는 모든 custom section을 정확히 한 번 배치해야 합니다.`;
|
|
507
|
+
}
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
case "ssobig.clues": {
|
|
511
|
+
const allowed = new Set(["confirmed", "image", "location", "tags", "color", "secret"]);
|
|
512
|
+
return data.enabledOptionalFields.some(name => !allowed.has(name))
|
|
513
|
+
? "$.enabledOptionalFields에 지원하지 않는 단서 선택 속성이 있습니다." : null;
|
|
514
|
+
}
|
|
515
|
+
case "ssobig.clue-combinations": {
|
|
516
|
+
const fieldsByConditionKind = {
|
|
517
|
+
clue: ["clueId"],
|
|
518
|
+
output: ["outputId"],
|
|
519
|
+
literal: ["value"]
|
|
520
|
+
};
|
|
521
|
+
const fieldsByResultKind = {
|
|
522
|
+
clue: ["clueId"],
|
|
523
|
+
literal: ["value"]
|
|
524
|
+
};
|
|
525
|
+
const discriminatorError = (item, expected, fields, path) => {
|
|
526
|
+
if (!expected.every(fieldName => Object.hasOwn(item, fieldName))) return `${path}에 ${item.kind} 종류의 필수 값이 없습니다.`;
|
|
527
|
+
const unexpected = fields.find(fieldName => !expected.includes(fieldName) && Object.hasOwn(item, fieldName));
|
|
528
|
+
return unexpected ? `${path}.${unexpected} 필드는 ${item.kind} 종류에서 사용할 수 없습니다.` : null;
|
|
529
|
+
};
|
|
530
|
+
for (let combinationIndex = 0; combinationIndex < data.combinations.length; combinationIndex += 1) {
|
|
531
|
+
const combination = data.combinations[combinationIndex];
|
|
532
|
+
for (let conditionIndex = 0; conditionIndex < combination.conditions.length; conditionIndex += 1) {
|
|
533
|
+
const condition = combination.conditions[conditionIndex];
|
|
534
|
+
const invalid = discriminatorError(
|
|
535
|
+
condition,
|
|
536
|
+
fieldsByConditionKind[condition.kind],
|
|
537
|
+
["clueId", "outputId", "value"],
|
|
538
|
+
`$.combinations.${combinationIndex}.conditions.${conditionIndex}`
|
|
539
|
+
);
|
|
540
|
+
if (invalid) return invalid;
|
|
541
|
+
}
|
|
542
|
+
for (let resultIndex = 0; resultIndex < combination.results.length; resultIndex += 1) {
|
|
543
|
+
const result = combination.results[resultIndex];
|
|
544
|
+
const invalid = discriminatorError(
|
|
545
|
+
result,
|
|
546
|
+
fieldsByResultKind[result.kind],
|
|
547
|
+
["clueId", "value"],
|
|
548
|
+
`$.combinations.${combinationIndex}.results.${resultIndex}`
|
|
549
|
+
);
|
|
550
|
+
if (invalid) return invalid;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return null;
|
|
554
|
+
}
|
|
555
|
+
case "ssobig.investigation-board": {
|
|
556
|
+
const nodeIds = new Set();
|
|
557
|
+
for (const node of data.nodes) {
|
|
558
|
+
if (nodeIds.has(node.id)) return `$.nodes에 중복된 보드 node id가 있습니다: ${node.id}`;
|
|
559
|
+
nodeIds.add(node.id);
|
|
560
|
+
if (node.type === "sticky") {
|
|
561
|
+
if (typeof node.title !== "string" || typeof node.text !== "string" || Object.hasOwn(node, "referenceType") || Object.hasOwn(node, "referenceId")) return "$.nodes sticky 필드가 올바르지 않습니다.";
|
|
562
|
+
} else if (!["character", "clue"].includes(node.referenceType) || typeof node.referenceId !== "string" || !node.referenceId.trim() || Object.hasOwn(node, "title") || Object.hasOwn(node, "text")) return "$.nodes reference 필드가 올바르지 않습니다.";
|
|
563
|
+
}
|
|
564
|
+
const edgeIds = new Set();
|
|
565
|
+
for (const edge of data.edges) {
|
|
566
|
+
if (edgeIds.has(edge.id)) return `$.edges에 중복된 보드 edge id가 있습니다: ${edge.id}`;
|
|
567
|
+
edgeIds.add(edge.id);
|
|
568
|
+
if (!nodeIds.has(edge.fromNodeId) || !nodeIds.has(edge.toNodeId) || edge.fromNodeId === edge.toNodeId) return "$.edges 연결 node가 올바르지 않습니다.";
|
|
569
|
+
}
|
|
570
|
+
return null;
|
|
571
|
+
}
|
|
572
|
+
case "ssobig.ending": {
|
|
573
|
+
const conditions = idSet(data.conditions), outcomes = idSet(data.outcomes);
|
|
574
|
+
if (conditions.size !== outcomes.size || [...conditions].some(id => !outcomes.has(id))) return "$.conditions와 $.outcomes는 같은 id로 정확히 1:1 연결되어야 합니다.";
|
|
575
|
+
return null;
|
|
576
|
+
}
|
|
577
|
+
case "ssobig.postgame": {
|
|
578
|
+
const ids = [...data.truth, ...data.epilogue].map(item => item.id.toLowerCase());
|
|
579
|
+
return new Set(ids).size === ids.length ? null : "$.truth와 $.epilogue에 중복 document id가 있습니다.";
|
|
580
|
+
}
|
|
581
|
+
default: return null;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
function validateTargetData(templateId, data) {
|
|
585
|
+
const contract = getContract(templateId);
|
|
586
|
+
if (!contract) return `지원하지 않는 Component 계약입니다: ${templateId}`;
|
|
587
|
+
const invalid = validateShape(data, contract.root, contract, "$");
|
|
588
|
+
return invalid || validateRelations(contract, data);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function getContract(templateId) { return BY_ID.get(String(templateId || "")) || null; }
|
|
592
|
+
function listContracts() { return CONTRACTS.slice(); }
|
|
593
|
+
|
|
594
|
+
return Object.freeze({ IDENTITY, CONTRACTS, getContract, listContracts, jsonSchemaFor, validateTargetData });
|
|
595
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
(function (root, factory) {
|
|
2
|
+
const api = factory();
|
|
3
|
+
if (typeof module === "object" && module.exports) module.exports = api;
|
|
4
|
+
if (root) root.WriterWorkbenchComponentIdPolicy = api;
|
|
5
|
+
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
|
|
6
|
+
"use strict";
|
|
7
|
+
|
|
8
|
+
function getAt(root, path) {
|
|
9
|
+
return path.reduce((value, key) => value?.[key], root);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function setAt(root, path, value) {
|
|
13
|
+
if (!path.length) throw new Error("Component field path가 비어 있습니다.");
|
|
14
|
+
let cursor = root;
|
|
15
|
+
for (const key of path.slice(0, -1)) {
|
|
16
|
+
if (!cursor || typeof cursor !== "object") throw new Error("Component field path를 찾을 수 없습니다.");
|
|
17
|
+
cursor = cursor[key];
|
|
18
|
+
}
|
|
19
|
+
cursor[path.at(-1)] = value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalizedComponentId(value, now = Date.now) {
|
|
23
|
+
const normalized = String(value || "").trim().toLowerCase()
|
|
24
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
25
|
+
.replace(/^-+|-+$/g, "")
|
|
26
|
+
.replace(/-+/g, "-");
|
|
27
|
+
return /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(normalized) ? normalized : `document-${now().toString(36)}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function normalizedAssetId(value, fileName = "", now = Date.now) {
|
|
31
|
+
const requested = String(value || "").trim();
|
|
32
|
+
const fileStem = String(fileName || "").replace(/\.[^.]+$/, "");
|
|
33
|
+
let normalized = (requested || fileStem).normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
34
|
+
if (!normalized || !/^[a-z]/.test(normalized)) normalized = `asset-${normalized || now().toString(36)}`;
|
|
35
|
+
normalized = normalized.slice(0, 120).replace(/-+$/g, "");
|
|
36
|
+
return /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(normalized) ? normalized : "";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function uniqueId(prefix, occupied, now = Date.now) {
|
|
40
|
+
let id = `${prefix}-${now().toString(36)}`;
|
|
41
|
+
let suffix = 2;
|
|
42
|
+
while (occupied.has(id)) id = `${prefix}-${now().toString(36)}-${suffix++}`;
|
|
43
|
+
return id;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function technicalId(instance, prefix, occupied, options = {}) {
|
|
47
|
+
const uuidContract = ["ssobig.character", "ssobig.common", "ssobig.choice"].includes(instance.templateId);
|
|
48
|
+
if (!uuidContract) return uniqueId(prefix, occupied, options.now || Date.now);
|
|
49
|
+
const randomUUID = options.randomUUID;
|
|
50
|
+
if (typeof randomUUID !== "function") throw new Error("UUID 생성 기능을 사용할 수 없습니다.");
|
|
51
|
+
let id = randomUUID();
|
|
52
|
+
while (occupied.has(id)) id = randomUUID();
|
|
53
|
+
return id;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return Object.freeze({
|
|
57
|
+
getAt,
|
|
58
|
+
setAt,
|
|
59
|
+
normalizedComponentId,
|
|
60
|
+
normalizedAssetId,
|
|
61
|
+
uniqueId,
|
|
62
|
+
technicalId
|
|
63
|
+
});
|
|
64
|
+
});
|