@ssobig/writer-cli 0.2.2 → 0.3.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 +12 -0
- package/config.js +2 -1
- package/package.json +1 -1
- package/templates/mystery-v1/authoring-view-preference.js +22 -5
- package/templates/mystery-v1/character-perspective-preview.js +3 -3
- package/templates/mystery-v1/codemirror6-runtime.min.js +28 -0
- package/templates/mystery-v1/component-asset-operations.js +5 -4
- package/templates/mystery-v1/component-catalog-contract.js +26 -35
- package/templates/mystery-v1/component-draft-operations.js +19 -8
- package/templates/mystery-v1/component-field-contracts.js +29 -49
- package/templates/mystery-v1/component-id-policy.js +1 -1
- package/templates/mystery-v1/component-manager.js +21 -11
- package/templates/mystery-v1/component-navigation-counts.js +3 -8
- package/templates/mystery-v1/component-registry.js +11 -5
- package/templates/mystery-v1/component-renderers.js +12 -8
- package/templates/mystery-v1/markdown-live-editor.js +350 -0
- package/templates/mystery-v1/page-header.js +2 -1
- package/tools/writer-cli/package-lock.json +2 -2
- package/tools/writer-cli/package.json +1 -1
- package/tools/writer-cli/skills/ssobig-writer-cli/SKILL.md +1 -1
- package/tools/writer-cli/skills/ssobig-writer-cli/references/projects-components.md +1 -1
- package/tools/writer-cli/src/agent-service.cjs +3 -1
- package/tools/writer-cli/src/command-registry.cjs +3 -0
- package/tools/writer-cli/src/commands.cjs +85 -3
- package/tools/writer-cli/src/domain.cjs +250 -1
- package/tools/writer-cli/src/gateway.cjs +14 -0
- package/tools/writer-cli/src/mutations.cjs +167 -1
- package/tools/writer-cli/src/project-import.cjs +12 -21
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
aiResponse: root.WriterWorkbenchAiResponseRenderer,
|
|
11
11
|
timeline: root.WriterWorkbenchTimelineRenderer,
|
|
12
12
|
ending: root.WriterWorkbenchEndingRenderer,
|
|
13
|
-
postgame: root.WriterWorkbenchPostgameRenderer,
|
|
14
13
|
authorNotes: root.WriterWorkbenchAuthorNotesRenderer,
|
|
15
14
|
investigationBoard: root.WriterWorkbenchInvestigationBoardRenderer,
|
|
16
15
|
documents: root.WriterWorkbenchDocumentRenderers
|
|
@@ -26,7 +25,6 @@
|
|
|
26
25
|
aiResponse: require("./renderers/ai-response.js"),
|
|
27
26
|
timeline: require("./renderers/timeline.js"),
|
|
28
27
|
ending: require("./renderers/ending.js"),
|
|
29
|
-
postgame: require("./renderers/postgame.js"),
|
|
30
28
|
authorNotes: require("./renderers/author-notes.js"),
|
|
31
29
|
investigationBoard: require("./renderers/investigation-board.js"),
|
|
32
30
|
documents: require("./renderers/documents.js")
|
|
@@ -36,8 +34,8 @@
|
|
|
36
34
|
if (root) root.WriterWorkbenchComponentRenderers = api;
|
|
37
35
|
})(typeof globalThis !== "undefined" ? globalThis : this, function (dependencies) {
|
|
38
36
|
"use strict";
|
|
39
|
-
const { appearance, shared, basic, common, character, clues, clueCombinations, aiResponse, timeline, ending,
|
|
40
|
-
if (!appearance || !shared || !basic || !common || !character || !clues || !clueCombinations || !aiResponse || !timeline || !ending || !
|
|
37
|
+
const { appearance, shared, basic, common, character, clues, clueCombinations, aiResponse, timeline, ending, authorNotes, investigationBoard, documents } = dependencies;
|
|
38
|
+
if (!appearance || !shared || !basic || !common || !character || !clues || !clueCombinations || !aiResponse || !timeline || !ending || !authorNotes || !investigationBoard || !documents) throw new Error("Component renderer modules are incomplete");
|
|
41
39
|
|
|
42
40
|
const renderers = new Map();
|
|
43
41
|
function register(renderer) {
|
|
@@ -45,9 +43,10 @@
|
|
|
45
43
|
if (renderers.has(key)) throw new Error(`중복 Component 렌더러입니다: ${key}`);
|
|
46
44
|
renderers.set(key, Object.freeze({ ...renderer }));
|
|
47
45
|
}
|
|
48
|
-
[basic, common,
|
|
46
|
+
[basic, common, character, clues, clueCombinations, aiResponse, timeline, ending, authorNotes, investigationBoard].forEach(register);
|
|
49
47
|
Object.entries(documents.definitions).forEach(([templateId, definition]) => register({
|
|
50
48
|
templateId,
|
|
49
|
+
unifiedAuthoring: definition.unifiedAuthoring === true,
|
|
51
50
|
renderAuthoring: documents.renderAuthoring,
|
|
52
51
|
renderPreview: documents.renderPreview,
|
|
53
52
|
bind: documents.bind
|
|
@@ -71,6 +70,8 @@
|
|
|
71
70
|
return renderer;
|
|
72
71
|
}
|
|
73
72
|
function defaultView() { return "preview"; }
|
|
73
|
+
function usesUnifiedAuthoring(instance) { return resolve(instance).unifiedAuthoring === true; }
|
|
74
|
+
function usesUnifiedAuthoringView(viewModel) { return resolveView(viewModel).unifiedAuthoring === true; }
|
|
74
75
|
function context(instance, data, extra = {}) {
|
|
75
76
|
return { instance, data, ...extra, view: shared.rendererView(extra, defaultView(instance)) };
|
|
76
77
|
}
|
|
@@ -90,8 +91,9 @@
|
|
|
90
91
|
.replace(/data-renderer-view="(?:input|preview)"/, `data-renderer-view="${view}"`);
|
|
91
92
|
}
|
|
92
93
|
function renderAuthoring(instance, data, extra) {
|
|
93
|
-
const
|
|
94
|
-
|
|
94
|
+
const renderer = resolve(instance);
|
|
95
|
+
const rendererContext = context(instance, data, renderer.unifiedAuthoring === true ? { ...extra, view: "preview" } : extra);
|
|
96
|
+
return applyRendererView(renderer.renderAuthoring(rendererContext), rendererContext.view);
|
|
95
97
|
}
|
|
96
98
|
function renderPreview(instance, data = instance?.data, extra) { return resolve(instance).renderPreview(context(instance, data, extra)); }
|
|
97
99
|
function bind(instance, root, data, extra) {
|
|
@@ -102,7 +104,7 @@
|
|
|
102
104
|
}
|
|
103
105
|
function renderAuthoringView(viewModel, extra) {
|
|
104
106
|
const renderer = resolveView(viewModel);
|
|
105
|
-
const rendererContext = viewContext(viewModel, extra);
|
|
107
|
+
const rendererContext = viewContext(viewModel, renderer.unifiedAuthoring === true ? { ...extra, view: "preview" } : extra);
|
|
106
108
|
return applyRendererView(renderer.renderAuthoring(rendererContext), rendererContext.view);
|
|
107
109
|
}
|
|
108
110
|
function renderPreviewView(viewModel, extra) {
|
|
@@ -119,6 +121,8 @@
|
|
|
119
121
|
resolve,
|
|
120
122
|
resolveView,
|
|
121
123
|
defaultView,
|
|
124
|
+
usesUnifiedAuthoring,
|
|
125
|
+
usesUnifiedAuthoringView,
|
|
122
126
|
renderAuthoring,
|
|
123
127
|
renderPreview,
|
|
124
128
|
bind,
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
(function (root, factory) {
|
|
2
|
+
const api = factory(root?.WriterCodeMirror6);
|
|
3
|
+
if (typeof module === "object" && module.exports) module.exports = api;
|
|
4
|
+
if (root) root.WriterMarkdownLiveEditor = api;
|
|
5
|
+
})(typeof globalThis !== "undefined" ? globalThis : this, function (CodeMirror6) {
|
|
6
|
+
"use strict";
|
|
7
|
+
|
|
8
|
+
const INLINE_MARKERS = Object.freeze([
|
|
9
|
+
{ marker: "++", type: "brand" },
|
|
10
|
+
{ marker: "**", type: "strong" },
|
|
11
|
+
{ marker: "~~", type: "strikethrough" },
|
|
12
|
+
{ marker: "==", type: "highlight" },
|
|
13
|
+
{ marker: "`", type: "code" }
|
|
14
|
+
]);
|
|
15
|
+
function lineStarts(source) {
|
|
16
|
+
const starts = [0];
|
|
17
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
18
|
+
if (source[index] === "\n") starts.push(index + 1);
|
|
19
|
+
}
|
|
20
|
+
return starts;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function lineAt(starts, offset) {
|
|
24
|
+
let low = 0;
|
|
25
|
+
let high = starts.length - 1;
|
|
26
|
+
while (low <= high) {
|
|
27
|
+
const middle = (low + high) >> 1;
|
|
28
|
+
if (starts[middle] <= offset) low = middle + 1;
|
|
29
|
+
else high = middle - 1;
|
|
30
|
+
}
|
|
31
|
+
return Math.max(0, high);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function inlineTokens(source) {
|
|
35
|
+
const tokens = [];
|
|
36
|
+
let index = 0;
|
|
37
|
+
while (index < source.length) {
|
|
38
|
+
const definition = INLINE_MARKERS.find(item => source.startsWith(item.marker, index));
|
|
39
|
+
if (!definition || (index > 0 && source[index - 1] === "\\")) {
|
|
40
|
+
index += 1;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const contentFrom = index + definition.marker.length;
|
|
44
|
+
const closeFrom = source.indexOf(definition.marker, contentFrom);
|
|
45
|
+
const crossesBlockBoundary = closeFrom > contentFrom && /\n[ \t]*\n/.test(source.slice(contentFrom, closeFrom));
|
|
46
|
+
if (closeFrom <= contentFrom || crossesBlockBoundary || (closeFrom > 0 && source[closeFrom - 1] === "\\")) {
|
|
47
|
+
index += definition.marker.length;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const to = closeFrom + definition.marker.length;
|
|
51
|
+
tokens.push({
|
|
52
|
+
type: definition.type,
|
|
53
|
+
marker: definition.marker,
|
|
54
|
+
from: index,
|
|
55
|
+
openTo: contentFrom,
|
|
56
|
+
contentFrom,
|
|
57
|
+
contentTo: closeFrom,
|
|
58
|
+
closeFrom,
|
|
59
|
+
to
|
|
60
|
+
});
|
|
61
|
+
index = to;
|
|
62
|
+
}
|
|
63
|
+
return tokens;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function normalizeSelections(selections, sourceLength) {
|
|
67
|
+
const values = Array.isArray(selections) ? selections : [{ anchor: 0, head: 0 }];
|
|
68
|
+
return values.map(selection => {
|
|
69
|
+
const anchor = Math.max(0, Math.min(sourceLength, Number(selection?.anchor) || 0));
|
|
70
|
+
const head = Math.max(0, Math.min(sourceLength, Number(selection?.head) || 0));
|
|
71
|
+
return { anchor, head, from: Math.min(anchor, head), to: Math.max(anchor, head) };
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function selectionTouchesRange(selection, from, to) {
|
|
76
|
+
return selection.from === selection.to
|
|
77
|
+
? selection.head >= from && selection.head <= to
|
|
78
|
+
: selection.to >= from && selection.from <= to;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function lineSyntax(text) {
|
|
82
|
+
const heading = text.match(/^( {0,3})(#{1,4})(?:[ \t]+|$)/);
|
|
83
|
+
const quote = text.match(/^( {0,3})>[ \t]?/);
|
|
84
|
+
const unordered = text.match(/^([-*])[ \t]+(.+)$/);
|
|
85
|
+
const ordered = text.match(/^(\d+[.)])[ \t]+(.+)$/);
|
|
86
|
+
return {
|
|
87
|
+
headingLevel: heading ? heading[2].length : 0,
|
|
88
|
+
headingMarkerFrom: heading ? heading[1].length : -1,
|
|
89
|
+
headingMarkerTo: heading ? heading[0].length : -1,
|
|
90
|
+
quoteMarkerFrom: quote ? quote[1].length : -1,
|
|
91
|
+
quoteMarkerTo: quote ? quote[0].length : -1,
|
|
92
|
+
listType: unordered ? "unordered" : ordered ? "ordered" : "",
|
|
93
|
+
empty: !text.trim()
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function layoutLines(source, starts) {
|
|
98
|
+
const lines = source.split("\n").map((text, index) => ({
|
|
99
|
+
index,
|
|
100
|
+
text,
|
|
101
|
+
from: starts[index],
|
|
102
|
+
to: starts[index] + text.length,
|
|
103
|
+
...lineSyntax(text),
|
|
104
|
+
blockId: -1,
|
|
105
|
+
blockType: "",
|
|
106
|
+
blockStart: false,
|
|
107
|
+
blockEnd: false,
|
|
108
|
+
nestedQuote: false,
|
|
109
|
+
firstListItem: false
|
|
110
|
+
}));
|
|
111
|
+
const blocks = [];
|
|
112
|
+
let paragraph = null;
|
|
113
|
+
let quote = null;
|
|
114
|
+
let listType = "";
|
|
115
|
+
let listGroup = 0;
|
|
116
|
+
let nextBlockId = 0;
|
|
117
|
+
const openBlock = (type, line, extra = {}) => {
|
|
118
|
+
const block = { id: nextBlockId++, type, lines: [], ...extra };
|
|
119
|
+
blocks.push(block);
|
|
120
|
+
line.blockId = block.id;
|
|
121
|
+
line.blockType = type;
|
|
122
|
+
line.blockStart = true;
|
|
123
|
+
block.lines.push(line.index);
|
|
124
|
+
return block;
|
|
125
|
+
};
|
|
126
|
+
const appendBlock = (block, line) => {
|
|
127
|
+
line.blockId = block.id;
|
|
128
|
+
line.blockType = block.type;
|
|
129
|
+
block.lines.push(line.index);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
lines.forEach(line => {
|
|
133
|
+
if (line.quoteMarkerFrom >= 0) {
|
|
134
|
+
paragraph = null;
|
|
135
|
+
const nestedQuote = Boolean(listType);
|
|
136
|
+
if (!quote || quote.nestedQuote !== nestedQuote || quote.listGroup !== listGroup) {
|
|
137
|
+
quote = openBlock("quote", line, { nestedQuote, listGroup: nestedQuote ? listGroup : 0 });
|
|
138
|
+
} else appendBlock(quote, line);
|
|
139
|
+
line.nestedQuote = nestedQuote;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
quote = null;
|
|
143
|
+
if (line.empty) {
|
|
144
|
+
paragraph = null;
|
|
145
|
+
listType = "";
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (line.headingLevel) {
|
|
149
|
+
paragraph = null;
|
|
150
|
+
listType = "";
|
|
151
|
+
openBlock(`heading${line.headingLevel}`, line);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (line.listType) {
|
|
155
|
+
paragraph = null;
|
|
156
|
+
const firstListItem = line.listType !== listType;
|
|
157
|
+
if (firstListItem) listGroup += 1;
|
|
158
|
+
listType = line.listType;
|
|
159
|
+
line.firstListItem = firstListItem;
|
|
160
|
+
openBlock("listItem", line, { listGroup, firstListItem });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
listType = "";
|
|
164
|
+
if (!paragraph) paragraph = openBlock("paragraph", line);
|
|
165
|
+
else appendBlock(paragraph, line);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
blocks.forEach(block => {
|
|
169
|
+
const lastLine = lines[block.lines.at(-1)];
|
|
170
|
+
if (lastLine) lastLine.blockEnd = true;
|
|
171
|
+
});
|
|
172
|
+
const listGroups = new Map();
|
|
173
|
+
blocks.forEach(block => {
|
|
174
|
+
if (!block.listGroup) return;
|
|
175
|
+
if (!listGroups.has(block.listGroup)) listGroups.set(block.listGroup, []);
|
|
176
|
+
listGroups.get(block.listGroup).push(block);
|
|
177
|
+
});
|
|
178
|
+
listGroups.forEach(group => {
|
|
179
|
+
const last = group.at(-1);
|
|
180
|
+
if (last) last.listEnd = true;
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
return { lines, blocks };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function previewModel(value, selections = [{ anchor: 0, head: 0 }]) {
|
|
187
|
+
const source = String(value ?? "").replace(/\r\n?/g, "\n");
|
|
188
|
+
const starts = lineStarts(source);
|
|
189
|
+
const normalizedSelections = normalizeSelections(selections, source.length);
|
|
190
|
+
const activeLines = new Set();
|
|
191
|
+
normalizedSelections.forEach(selection => {
|
|
192
|
+
const first = lineAt(starts, selection.from);
|
|
193
|
+
const last = lineAt(starts, selection.to);
|
|
194
|
+
for (let line = first; line <= last; line += 1) activeLines.add(line);
|
|
195
|
+
});
|
|
196
|
+
const layout = layoutLines(source, starts);
|
|
197
|
+
layout.lines.forEach(line => { line.active = activeLines.has(line.index); });
|
|
198
|
+
const tokens = inlineTokens(source).map(token => ({
|
|
199
|
+
...token,
|
|
200
|
+
active: normalizedSelections.some(selection => selectionTouchesRange(selection, token.from, token.to))
|
|
201
|
+
}));
|
|
202
|
+
return { source, starts, lines: layout.lines, blocks: layout.blocks, tokens };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function classNames(line) {
|
|
206
|
+
const values = ["writer-md-line"];
|
|
207
|
+
if (line.empty) values.push("writer-md-empty-line");
|
|
208
|
+
if (line.active) values.push("writer-md-active-line");
|
|
209
|
+
if (line.blockStart) values.push("writer-md-block-start");
|
|
210
|
+
if (line.blockEnd) values.push("writer-md-block-end");
|
|
211
|
+
if (line.blockType) {
|
|
212
|
+
const blockClass = line.blockType
|
|
213
|
+
.replace(/([a-z])([A-Z])/g, "$1-$2")
|
|
214
|
+
.replace(/(\d+)$/, "-$1")
|
|
215
|
+
.toLowerCase();
|
|
216
|
+
values.push(`writer-md-${blockClass}`);
|
|
217
|
+
}
|
|
218
|
+
if (line.nestedQuote) values.push("writer-md-nested-quote");
|
|
219
|
+
if (line.firstListItem) values.push("writer-md-first-list-item");
|
|
220
|
+
return values.join(" ");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function mount(options = {}) {
|
|
224
|
+
const cm = CodeMirror6;
|
|
225
|
+
if (!cm?.EditorState || !cm?.StateEffect || !cm?.EditorView || !cm?.Decoration || !cm?.StateField) {
|
|
226
|
+
throw new Error("Markdown 편집기를 초기화하지 못했습니다.");
|
|
227
|
+
}
|
|
228
|
+
if (!options.parent) throw new Error("Markdown 편집기 표시 영역이 없습니다.");
|
|
229
|
+
let destroyed = false;
|
|
230
|
+
const ownerDocument = options.parent.ownerDocument || document;
|
|
231
|
+
const focusEffect = cm.StateEffect.define();
|
|
232
|
+
const focusField = cm.StateField.define({
|
|
233
|
+
create() { return false; },
|
|
234
|
+
update(focused, transaction) {
|
|
235
|
+
for (const effect of transaction.effects) {
|
|
236
|
+
if (effect.is(focusEffect)) focused = Boolean(effect.value);
|
|
237
|
+
}
|
|
238
|
+
return focused;
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
const buildDecorations = state => {
|
|
242
|
+
const selections = state.field(focusField)
|
|
243
|
+
? state.selection.ranges.map(range => ({ anchor: range.anchor, head: range.head }))
|
|
244
|
+
: [];
|
|
245
|
+
const model = previewModel(state.doc.toString(), selections);
|
|
246
|
+
const ranges = [];
|
|
247
|
+
const syntaxDecoration = active => cm.Decoration.mark({
|
|
248
|
+
class: active ? "writer-md-syntax" : "writer-md-syntax-hidden"
|
|
249
|
+
});
|
|
250
|
+
model.lines.forEach(line => {
|
|
251
|
+
ranges.push(cm.Decoration.line({ attributes: { class: classNames(line) } }).range(line.from));
|
|
252
|
+
if (line.headingMarkerFrom >= 0) {
|
|
253
|
+
const from = line.from + line.headingMarkerFrom;
|
|
254
|
+
const to = line.from + line.headingMarkerTo;
|
|
255
|
+
ranges.push(syntaxDecoration(line.active).range(from, to));
|
|
256
|
+
} else if (line.quoteMarkerFrom >= 0) {
|
|
257
|
+
const from = line.from + line.quoteMarkerFrom;
|
|
258
|
+
const to = line.from + line.quoteMarkerTo;
|
|
259
|
+
ranges.push(syntaxDecoration(line.active).range(from, to));
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
model.tokens.forEach(token => {
|
|
263
|
+
ranges.push(cm.Decoration.mark({ class: `writer-md-${token.type}` }).range(token.contentFrom, token.contentTo));
|
|
264
|
+
const markerDecoration = syntaxDecoration(token.active);
|
|
265
|
+
ranges.push(markerDecoration.range(token.from, token.openTo));
|
|
266
|
+
ranges.push(markerDecoration.range(token.closeFrom, token.to));
|
|
267
|
+
});
|
|
268
|
+
return cm.Decoration.set(ranges, true);
|
|
269
|
+
};
|
|
270
|
+
const previewField = cm.StateField.define({
|
|
271
|
+
create(state) {
|
|
272
|
+
return buildDecorations(state);
|
|
273
|
+
},
|
|
274
|
+
update(decorations, transaction) {
|
|
275
|
+
const focusChanged = transaction.effects.some(effect => effect.is(focusEffect));
|
|
276
|
+
return transaction.docChanged || transaction.selection || focusChanged ? buildDecorations(transaction.state) : decorations;
|
|
277
|
+
},
|
|
278
|
+
provide: field => cm.EditorView.decorations.from(field)
|
|
279
|
+
});
|
|
280
|
+
const state = cm.EditorState.create({
|
|
281
|
+
doc: String(options.value ?? "").replace(/\r\n?/g, "\n"),
|
|
282
|
+
extensions: [
|
|
283
|
+
cm.EditorView.editorAttributes.of({
|
|
284
|
+
class: "writer-markdown-live-editor",
|
|
285
|
+
...(options.placeholder ? { "data-placeholder": String(options.placeholder) } : {})
|
|
286
|
+
}),
|
|
287
|
+
cm.history(),
|
|
288
|
+
cm.keymap.of([
|
|
289
|
+
{ key: "Escape", run() { options.onEscape?.(); return true; } },
|
|
290
|
+
...cm.defaultKeymap,
|
|
291
|
+
...cm.historyKeymap
|
|
292
|
+
]),
|
|
293
|
+
cm.markdown(),
|
|
294
|
+
cm.EditorView.lineWrapping,
|
|
295
|
+
focusField,
|
|
296
|
+
previewField,
|
|
297
|
+
cm.EditorView.contentAttributes.of({
|
|
298
|
+
role: "textbox",
|
|
299
|
+
"aria-multiline": "true",
|
|
300
|
+
"aria-label": String(options.ariaLabel || "Markdown 편집")
|
|
301
|
+
}),
|
|
302
|
+
cm.EditorView.updateListener.of(update => {
|
|
303
|
+
if (update.docChanged) options.onChange?.(update.state.doc.toString());
|
|
304
|
+
}),
|
|
305
|
+
cm.EditorView.domEventHandlers({
|
|
306
|
+
focus() {
|
|
307
|
+
view.dispatch({ effects: focusEffect.of(true) });
|
|
308
|
+
},
|
|
309
|
+
blur() {
|
|
310
|
+
view.dispatch({ effects: focusEffect.of(false) });
|
|
311
|
+
setTimeout(() => {
|
|
312
|
+
if (!destroyed && !view.dom.contains(ownerDocument.activeElement)) options.onBlur?.();
|
|
313
|
+
}, 0);
|
|
314
|
+
}
|
|
315
|
+
})
|
|
316
|
+
]
|
|
317
|
+
});
|
|
318
|
+
const view = new cm.EditorView({ state, parent: options.parent });
|
|
319
|
+
return Object.freeze({
|
|
320
|
+
getValue: () => view.state.doc.toString(),
|
|
321
|
+
getSelection: () => view.state.selection.ranges.map(range => ({ anchor: range.anchor, head: range.head })),
|
|
322
|
+
focusAtPosition(position, options = {}) {
|
|
323
|
+
const anchor = Math.max(0, Math.min(view.state.doc.length, Number(position) || 0));
|
|
324
|
+
view.dispatch({ selection: { anchor }, scrollIntoView: options.scrollIntoView !== false });
|
|
325
|
+
view.focus();
|
|
326
|
+
return anchor;
|
|
327
|
+
},
|
|
328
|
+
focusAtEnd() {
|
|
329
|
+
view.dispatch({ selection: { anchor: view.state.doc.length }, scrollIntoView: true });
|
|
330
|
+
view.focus();
|
|
331
|
+
},
|
|
332
|
+
focusAtClientPoint(clientX, clientY) {
|
|
333
|
+
const position = Number.isFinite(clientX) && Number.isFinite(clientY)
|
|
334
|
+
? view.posAtCoords({ x: clientX, y: clientY }, false)
|
|
335
|
+
: null;
|
|
336
|
+
const anchor = position == null ? view.state.doc.length : position;
|
|
337
|
+
view.dispatch({ selection: { anchor } });
|
|
338
|
+
view.focus();
|
|
339
|
+
return anchor;
|
|
340
|
+
},
|
|
341
|
+
destroy() {
|
|
342
|
+
if (destroyed) return;
|
|
343
|
+
destroyed = true;
|
|
344
|
+
view.destroy();
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return Object.freeze({ inlineTokens, lineSyntax, layoutLines, previewModel, mount });
|
|
350
|
+
});
|
|
@@ -18,8 +18,9 @@
|
|
|
18
18
|
const title = escapeHtml(options.title || "");
|
|
19
19
|
const description = escapeHtml(options.description || "");
|
|
20
20
|
const className = ["page-header", options.className].filter(Boolean).join(" ");
|
|
21
|
+
const titleMetaMarkup = String(options.titleMetaMarkup || "");
|
|
21
22
|
const metaMarkup = String(options.metaMarkup || "");
|
|
22
|
-
return `<header class="${escapeHtml(className)}"><div class="page-header-copy"><h2>${title}</h2>${description ? `<p>${description}</p>` : ""}</div>${metaMarkup ? `<div class="page-header-meta">${metaMarkup}</div>` : ""}</header>`;
|
|
23
|
+
return `<header class="${escapeHtml(className)}"><div class="page-header-copy"><div class="page-header-title-row"><h2>${title}</h2>${titleMetaMarkup ? `<div class="page-header-title-meta">${titleMetaMarkup}</div>` : ""}</div>${description ? `<p>${description}</p>` : ""}</div>${metaMarkup ? `<div class="page-header-meta">${metaMarkup}</div>` : ""}</header>`;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
return Object.freeze({ escapeHtml, render });
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ssobig/writer-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@ssobig/writer-cli",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.3.0",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@supabase/supabase-js": "2.110.9"
|
|
12
12
|
},
|
|
@@ -35,4 +35,4 @@ For every production mutation, follow this sequence without shortcuts:
|
|
|
35
35
|
|
|
36
36
|
A general request to inspect code or explain behavior does not authorize manuscript mutation. Plan and receipt files can contain confidential content; store them in a private ignored directory and never commit them.
|
|
37
37
|
|
|
38
|
-
`E_CODE_CHANGE_REQUIRED` is a hard stop.
|
|
38
|
+
`E_CODE_CHANGE_REQUIRED` is a hard stop. Supported Component-management mutations are registered optional Component addition through `component plan-add`, archive/restore through `component plan-set-active`, and registered optional-field selection through `component plan-set-field`. Shared schemas, unregistered composition, reorder behavior, Renderer/View behavior, identity, and other boundaries remain outside the content CLI. Do not work around them.
|
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
Project commands expose identity, versions, and reviewed project-level plans. Component commands are the supported path for authoritative manuscript values. Read first, preserve the selected project/version/Instance identity, and use a dedicated `plan-*` command for changes.
|
|
4
4
|
|
|
5
|
-
Generic Component patching changes only an existing Instance's `data`.
|
|
5
|
+
Generic Component patching changes only an existing Instance's `data`. Check `component list` for `addableTemplates` and `archivedComponents`. Use `component plan-add` for a never-created registered optional Component, `component plan-set-active` for its data-preserving archive/restore toggle, and `component plan-set-field` for a registered optional field shown in the Component screen. These commands cannot edit arbitrary composition, metadata, View bindings, templates, schemas, project identity, or storage paths. If the CLI returns `E_CODE_CHANGE_REQUIRED`, stop and report that a separate code or migration review is required.
|
|
6
6
|
|
|
7
7
|
Never apply a generated plan until the user has reviewed a human-readable summary and explicitly authorized the production mutation. Read back the same Component after apply.
|
|
@@ -11,7 +11,7 @@ const {
|
|
|
11
11
|
verifyPlan,
|
|
12
12
|
validateLoadedVersion
|
|
13
13
|
} = require("./domain.cjs");
|
|
14
|
-
const { applyProjectCreationPlan, applyProjectStatusPlan, applyCatalogDemoPlan, applyComponentPlan, applyAssetPlan, applyCheckpointPlan } = require("./mutations.cjs");
|
|
14
|
+
const { applyProjectCreationPlan, applyProjectStatusPlan, applyCatalogDemoPlan, applyComponentActivePlan, applyComponentAddPlan, applyComponentPlan, applyAssetPlan, applyCheckpointPlan } = require("./mutations.cjs");
|
|
15
15
|
const { pointerSegments } = require("./json-patch.cjs");
|
|
16
16
|
const { equalJson, sha256 } = require("./json.cjs");
|
|
17
17
|
const { cliError, normalizeError } = require("./errors.cjs");
|
|
@@ -418,6 +418,8 @@ function createAgentService(options = {}) {
|
|
|
418
418
|
if (plan.operation === "project.create") receipt = await applyProjectCreationPlan(gateway, plan, context.user.email, { appliedAt: isoNow() });
|
|
419
419
|
else if (plan.operation === "project.catalog-demo") receipt = await applyCatalogDemoPlan(gateway, plan, context.user.email, { appliedAt: isoNow() });
|
|
420
420
|
else if (plan.operation.startsWith("project.")) receipt = await applyProjectStatusPlan(gateway, plan, context.user.email, { appliedAt: isoNow() });
|
|
421
|
+
else if (plan.operation === "component.set-active") receipt = await applyComponentActivePlan(gateway, plan, context.user.email, { appliedAt: isoNow() });
|
|
422
|
+
else if (plan.operation === "component.add") receipt = await applyComponentAddPlan(gateway, plan, context.user.email, { appliedAt: isoNow() });
|
|
421
423
|
else if (plan.operation === "component.patch") receipt = await applyComponentPlan(gateway, plan, context.user.email, { appliedAt: isoNow() });
|
|
422
424
|
else if (plan.operation.startsWith("checkpoint.")) receipt = await applyCheckpointPlan(gateway, plan, context.user.email, { appliedAt: isoNow() });
|
|
423
425
|
else receipt = await applyAssetPlan(gateway, plan, context.user.email, { appliedAt: isoNow(), now: Date.now });
|
|
@@ -62,6 +62,9 @@ const COMMANDS = Object.freeze([
|
|
|
62
62
|
command("project.plan-set-catalog-demo", "Create a reviewed catalog-demo status plan.", [project, option("enabled", "<true|false>", "Catalog demo state.", { required: true }), out], ["ssobig-writer project plan-set-catalog-demo --project <slug> --enabled true --out plan.json"], { ...SERVER, ...PLAN }),
|
|
63
63
|
command("component.list", "List Components in a workspace.", [project, version], ["ssobig-writer component list --project <slug>"], SERVER),
|
|
64
64
|
command("component.get", "Read one authoritative Component Instance.", [project, version, option("instance", "<id|template-id>", "Component Instance or Template identifier.", { required: true })], ["ssobig-writer component get --project <slug> --instance <template-id>"], SERVER),
|
|
65
|
+
command("component.plan-add", "Create a reviewed plan to add one registered optional Component.", [project, version, option("template", "<template-id>", "Registered optional Data Component Template.", { required: true }), option("instance", "<instance-id>", "Instance identifier; required for multi-instance Templates."), option("label", "<tab-label>", "User-facing Component label."), out], ["ssobig-writer component plan-add --project <slug> --template ssobig.timeline --out plan.json"], { ...SERVER, ...PLAN }),
|
|
66
|
+
command("component.plan-set-active", "Create a reviewed plan to toggle one optional Component.", [project, version, option("instance", "<instance-id>", "Stored optional Component Instance.", { required: true }), option("active", "<true|false>", "Use or archive the Component.", { required: true }), out], ["ssobig-writer component plan-set-active --project <slug> --instance clues --active false --out plan.json"], { ...SERVER, ...PLAN }),
|
|
67
|
+
command("component.plan-set-field", "Create a reviewed plan to toggle one optional Component field.", [project, version, option("instance", "<id|template-id>", "Active Component Instance or Template.", { required: true }), option("field", "<field-key>", "Registered optional field key.", { required: true }), option("enabled", "<true|false>", "Show or hide the optional field.", { required: true }), out, message], ["ssobig-writer component plan-set-field --project <slug> --instance clues --field image --enabled false --out plan.json"], { ...SERVER, ...PLAN }),
|
|
65
68
|
command("component.plan-patch", "Create a reviewed Component data patch plan.", [project, version, option("instance", "<id|template-id>", "Component Instance or Template identifier.", { required: true }), option("patch", "<file>", "JSON patch document.", { required: true }), out, message], ["ssobig-writer component plan-patch --project <slug> --instance <id> --patch patch.json --out plan.json"], { ...SERVER, ...PLAN }),
|
|
66
69
|
command("component.plan-layout-investigation-board", "Create a deterministic investigation-board layout plan.", [project, version, option("instance", "<id|template-id>", "Investigation board Component."), option("spec", "<layout-spec.json>", "Validated layout spec.", { required: true }), out, message], ["ssobig-writer component plan-layout-investigation-board --project <slug> --spec layout.json --out plan.json"], { ...SERVER, ...PLAN }),
|
|
67
70
|
command("component.plan-replace", "Create a reviewed indexed text replacement plan.", [option("match", "<match-id>", "Search match identifier.", { required: true }), option("old", "<text>", "Expected old text.", { required: true }), option("new", "<text>", "Replacement text.", { required: true }), out, option("occurrence", "<zero-based-index>", "Occurrence index."), message], ["ssobig-writer component plan-replace --match <id> --old old --new new --out plan.json"], { ...SERVER, ...PLAN }),
|
|
@@ -5,6 +5,8 @@ const fs = require("node:fs");
|
|
|
5
5
|
const path = require("node:path");
|
|
6
6
|
const config = require("../../../config.js");
|
|
7
7
|
const projectRuntime = require("../../../project-runtime.js");
|
|
8
|
+
const componentCatalog = require("../../../templates/mystery-v1/component-catalog-contract.js");
|
|
9
|
+
const componentRegistry = require("../../../templates/mystery-v1/component-registry.js").createDefaultRegistry();
|
|
8
10
|
const { createAuthManager } = require("./auth.cjs");
|
|
9
11
|
const { createSupabaseGateway } = require("./gateway.cjs");
|
|
10
12
|
const { inspectAssetFile, assertAssetId } = require("./asset-policy.cjs");
|
|
@@ -13,6 +15,9 @@ const {
|
|
|
13
15
|
componentContract,
|
|
14
16
|
validateLoadedVersion,
|
|
15
17
|
findInstance,
|
|
18
|
+
createComponentActivePlan,
|
|
19
|
+
createComponentAddPlan,
|
|
20
|
+
createComponentFieldPlan,
|
|
16
21
|
createComponentPlan,
|
|
17
22
|
createInvestigationBoardLayoutPlan,
|
|
18
23
|
createAssetPlan,
|
|
@@ -25,7 +30,7 @@ const {
|
|
|
25
30
|
verifyPlan
|
|
26
31
|
} = require("./domain.cjs");
|
|
27
32
|
const { loadTrueWriterCase } = require("./project-import.cjs");
|
|
28
|
-
const { applyProjectCreationPlan, applyProjectImportPlan, applyProjectStatusPlan, applyCatalogDemoPlan, applyComponentPlan, applyAssetPlan, applyCheckpointPlan } = require("./mutations.cjs");
|
|
33
|
+
const { applyProjectCreationPlan, applyProjectImportPlan, applyProjectStatusPlan, applyCatalogDemoPlan, applyComponentActivePlan, applyComponentAddPlan, applyComponentPlan, applyAssetPlan, applyCheckpointPlan } = require("./mutations.cjs");
|
|
29
34
|
const { snapshotSummary, diffSnapshots } = require("./checkpoint-diff.cjs");
|
|
30
35
|
const { cliError, normalizeError, redact } = require("./errors.cjs");
|
|
31
36
|
const { startDaemon, stopDaemon } = require("./daemon-runner.cjs");
|
|
@@ -186,10 +191,43 @@ function publicInstance(instance, includeData = false) {
|
|
|
186
191
|
required: instance.required,
|
|
187
192
|
removable: instance.removable
|
|
188
193
|
};
|
|
194
|
+
const fields = componentRegistry.get(instance.templateId)?.fieldConfiguration || [];
|
|
195
|
+
if (fields.length) {
|
|
196
|
+
const hasSelection = Array.isArray(instance.data?.enabledOptionalFields);
|
|
197
|
+
const enabled = new Set(hasSelection ? instance.data.enabledOptionalFields : fields.filter(field => !field.required).map(field => field.key));
|
|
198
|
+
result.fieldConfiguration = fields.map(field => ({
|
|
199
|
+
key: field.key,
|
|
200
|
+
label: field.label,
|
|
201
|
+
description: field.description,
|
|
202
|
+
required: field.required,
|
|
203
|
+
enabled: field.required || enabled.has(field.key)
|
|
204
|
+
}));
|
|
205
|
+
}
|
|
189
206
|
if (includeData) result.data = instance.data;
|
|
190
207
|
return result;
|
|
191
208
|
}
|
|
192
209
|
|
|
210
|
+
function publicComponentAvailability(validated) {
|
|
211
|
+
const archivedComponents = componentContract.archivedInstances(validated.componentRows);
|
|
212
|
+
const occupiedSingletonTemplates = new Set(validated.componentRows.map(row => String(row.template_id)));
|
|
213
|
+
const addableTemplates = componentCatalog.list()
|
|
214
|
+
.filter(template => !template.required && !template.internal && template.view
|
|
215
|
+
&& (template.allowMultiple || !occupiedSingletonTemplates.has(template.templateId)))
|
|
216
|
+
.map(template => ({
|
|
217
|
+
templateId: template.templateId,
|
|
218
|
+
defaultInstanceId: template.defaultInstanceId,
|
|
219
|
+
tabLabel: template.tabLabel,
|
|
220
|
+
displayLabel: template.displayLabel,
|
|
221
|
+
description: template.description,
|
|
222
|
+
allowMultiple: template.allowMultiple,
|
|
223
|
+
requiresInstanceId: template.allowMultiple
|
|
224
|
+
}));
|
|
225
|
+
return {
|
|
226
|
+
archivedComponents: archivedComponents.map(item => publicInstance(item)),
|
|
227
|
+
addableTemplates
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
193
231
|
function publicCheckpoint(row, projectId, versionNumber) {
|
|
194
232
|
return normalizeCheckpoint({
|
|
195
233
|
...row,
|
|
@@ -610,13 +648,53 @@ async function execute(argv, deps) {
|
|
|
610
648
|
return { command: "project.plan-set-catalog-demo", data: { planFile: outputPath, planId: plan.planId, digest: plan.digest, operation: plan.operation, project: plan.project, target: plan.target } };
|
|
611
649
|
}
|
|
612
650
|
|
|
613
|
-
if (command === "component" && ["list", "get", "plan-patch", "plan-layout-investigation-board"].includes(subcommand)) {
|
|
651
|
+
if (command === "component" && ["list", "get", "plan-add", "plan-set-active", "plan-set-field", "plan-patch", "plan-layout-investigation-board"].includes(subcommand)) {
|
|
614
652
|
assertAllowedOptions(options, `component.${subcommand}`);
|
|
615
653
|
const projectRef = requireOption(options, "project");
|
|
616
654
|
const validated = validateLoadedVersion(await gateway.loadLatestVersion(projectRef, optionalVersionOption(options)));
|
|
617
655
|
const version = validated.versionNumber;
|
|
618
656
|
if (subcommand === "list") {
|
|
619
|
-
return { command: "component.list", data: { project: publicProject(validated.project, validated.instances), versionNumber: version, components: validated.instances.map(item => publicInstance(item)) } };
|
|
657
|
+
return { command: "component.list", data: { project: publicProject(validated.project, validated.instances), versionNumber: version, components: validated.instances.map(item => publicInstance(item)), ...publicComponentAvailability(validated) } };
|
|
658
|
+
}
|
|
659
|
+
if (subcommand === "plan-add") {
|
|
660
|
+
const plan = createComponentAddPlan(validated, {
|
|
661
|
+
templateId: requireOption(options, "template"),
|
|
662
|
+
instanceId: options.instance,
|
|
663
|
+
tabLabel: options.label
|
|
664
|
+
}, user.email, planOptions(deps));
|
|
665
|
+
const outputPath = writePrivateJson(requireOption(options, "out"), plan, deps.fs);
|
|
666
|
+
return {
|
|
667
|
+
command: "component.plan-add",
|
|
668
|
+
data: {
|
|
669
|
+
planFile: outputPath,
|
|
670
|
+
planId: plan.planId,
|
|
671
|
+
digest: plan.digest,
|
|
672
|
+
operation: plan.operation,
|
|
673
|
+
target: plan.target,
|
|
674
|
+
initialData: plan.initialData,
|
|
675
|
+
initialDataHash: plan.initialDataHash,
|
|
676
|
+
composition: plan.composition
|
|
677
|
+
}
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
if (subcommand === "plan-set-active") {
|
|
681
|
+
const activeValue = requireOption(options, "active");
|
|
682
|
+
if (!new Set(["true", "false"]).has(activeValue)) throw cliError("E_USAGE", "--active는 true 또는 false여야 합니다.");
|
|
683
|
+
const plan = createComponentActivePlan(validated, {
|
|
684
|
+
instanceId: requireOption(options, "instance"),
|
|
685
|
+
active: activeValue === "true"
|
|
686
|
+
}, user.email, planOptions(deps));
|
|
687
|
+
const outputPath = writePrivateJson(requireOption(options, "out"), plan, deps.fs);
|
|
688
|
+
return { command: "component.plan-set-active", data: { planFile: outputPath, planId: plan.planId, digest: plan.digest, operation: plan.operation, target: plan.target, composition: plan.composition } };
|
|
689
|
+
}
|
|
690
|
+
if (subcommand === "plan-set-field") {
|
|
691
|
+
const enabledValue = requireOption(options, "enabled");
|
|
692
|
+
if (!new Set(["true", "false"]).has(enabledValue)) throw cliError("E_USAGE", "--enabled는 true 또는 false여야 합니다.");
|
|
693
|
+
const fieldPlanOptions = planOptions(deps);
|
|
694
|
+
if (options.message !== undefined) fieldPlanOptions.checkpointMessage = String(options.message);
|
|
695
|
+
const plan = createComponentFieldPlan(validated, requireOption(options, "instance"), requireOption(options, "field"), enabledValue === "true", user.email, fieldPlanOptions);
|
|
696
|
+
const outputPath = writePrivateJson(requireOption(options, "out"), plan, deps.fs);
|
|
697
|
+
return { command: "component.plan-set-field", data: { planFile: outputPath, planId: plan.planId, digest: plan.digest, operation: plan.operation, checkpointMessage: plan.checkpointMessage, target: plan.target, fieldSelection: plan.fieldSelection, beforeHash: plan.beforeHash, afterHash: plan.afterHash } };
|
|
620
698
|
}
|
|
621
699
|
if (subcommand === "plan-layout-investigation-board") {
|
|
622
700
|
const spec = readJson(requireOption(options, "spec"), "추리 보드 layout spec", deps.fs).value;
|
|
@@ -783,6 +861,10 @@ async function execute(argv, deps) {
|
|
|
783
861
|
receipt = await applyCatalogDemoPlan(gateway, plan, user.email, { appliedAt: nowIso(deps) });
|
|
784
862
|
} else if (plan.operation.startsWith("project.")) {
|
|
785
863
|
receipt = await applyProjectStatusPlan(gateway, plan, user.email, { appliedAt: nowIso(deps) });
|
|
864
|
+
} else if (plan.operation === "component.set-active") {
|
|
865
|
+
receipt = await applyComponentActivePlan(gateway, plan, user.email, { appliedAt: nowIso(deps) });
|
|
866
|
+
} else if (plan.operation === "component.add") {
|
|
867
|
+
receipt = await applyComponentAddPlan(gateway, plan, user.email, { appliedAt: nowIso(deps) });
|
|
786
868
|
} else if (plan.operation === "component.patch") {
|
|
787
869
|
receipt = await applyComponentPlan(gateway, plan, user.email, { appliedAt: nowIso(deps) });
|
|
788
870
|
} else if (plan.operation.startsWith("checkpoint.")) {
|