@ssobig/writer-cli 0.2.2 → 0.3.2
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 +19 -0
- package/asset-repository.js +7 -2
- 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 +19 -6
- 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 +127 -51
- 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 +9 -9
- package/templates/mystery-v1/component-registry.js +11 -5
- package/templates/mystery-v1/component-renderers.js +12 -8
- package/templates/mystery-v1/component-storage-contract.js +28 -9
- package/templates/mystery-v1/markdown-document-model.js +444 -0
- package/templates/mystery-v1/markdown-image-editor.js +320 -0
- package/templates/mystery-v1/markdown-live-editor.js +1224 -0
- package/templates/mystery-v1/page-header.js +2 -1
- package/templates/mystery-v1/timeline-model.js +235 -0
- 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 +13 -14
- package/tools/writer-cli/skills/ssobig-writer-cli/references/assets-checkpoints.md +5 -1
- package/tools/writer-cli/skills/ssobig-writer-cli/references/errors.md +4 -1
- package/tools/writer-cli/skills/ssobig-writer-cli/references/install-auth.md +3 -3
- package/tools/writer-cli/skills/ssobig-writer-cli/references/investigation-board.md +9 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/references/layout-spec.md +130 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/references/projects-components.md +8 -2
- package/tools/writer-cli/skills/ssobig-writer-cli/references/read-search.md +12 -2
- package/tools/writer-cli/src/agent-service.cjs +3 -1
- package/tools/writer-cli/src/command-registry.cjs +24 -20
- package/tools/writer-cli/src/commands.cjs +106 -3
- package/tools/writer-cli/src/domain.cjs +296 -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 +27 -22
|
@@ -10,11 +10,8 @@
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
function commonCount(data) {
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
const customIds = new Set((Array.isArray(data?.customSections) ? data.customSections : []).map(item => item?.id).filter(Boolean));
|
|
16
|
-
return (Array.isArray(data?.sectionOrder) ? data.sectionOrder : [])
|
|
17
|
-
.filter(id => !deleted.has(id) && (Object.hasOwn(sections, id) || customIds.has(id))).length;
|
|
13
|
+
const deleted = new Set(Array.isArray(data?.deletedDocumentIds) ? data.deletedDocumentIds : []);
|
|
14
|
+
return (Array.isArray(data?.documents) ? data.documents : []).filter(document => !deleted.has(document?.id)).length;
|
|
18
15
|
}
|
|
19
16
|
|
|
20
17
|
function characterCount(data) {
|
|
@@ -24,6 +21,11 @@
|
|
|
24
21
|
.filter(id => !deleted.has(id) && Object.hasOwn(characters, id)).length;
|
|
25
22
|
}
|
|
26
23
|
|
|
24
|
+
// v2는 항목이, legacy는 사건이 세는 단위다.
|
|
25
|
+
function timelineCount(data) {
|
|
26
|
+
return Array.isArray(data?.entries) ? data.entries.length : listLength(data?.events);
|
|
27
|
+
}
|
|
28
|
+
|
|
27
29
|
function endingCount(data) {
|
|
28
30
|
const conditions = Array.isArray(data?.conditions) ? data.conditions : [];
|
|
29
31
|
const outcomes = Array.isArray(data?.outcomes) ? data.outcomes : [];
|
|
@@ -38,16 +40,14 @@
|
|
|
38
40
|
function componentItemCount(instance, data = instance?.data) {
|
|
39
41
|
switch (String(instance?.templateId || "")) {
|
|
40
42
|
case "ssobig.progress": return listLength(data?.steps);
|
|
41
|
-
case "ssobig.common":
|
|
42
|
-
case "ssobig.choice": return commonCount(data);
|
|
43
|
+
case "ssobig.common": return commonCount(data);
|
|
43
44
|
case "ssobig.character": return characterCount(data);
|
|
44
45
|
case "ssobig.clues": return listLength(data?.clues);
|
|
45
46
|
case "ssobig.clue-combinations": return listLength(data?.combinations);
|
|
46
47
|
case "ssobig.ai-response": return listLength(data?.responses);
|
|
47
|
-
case "ssobig.timeline": return
|
|
48
|
+
case "ssobig.timeline": return timelineCount(data);
|
|
48
49
|
case "ssobig.investigation-board": return listLength(data?.nodes);
|
|
49
50
|
case "ssobig.ending": return endingCount(data);
|
|
50
|
-
case "ssobig.postgame": return listLength(data?.truth) + listLength(data?.epilogue);
|
|
51
51
|
case "ssobig.author-notes": return listLength(data?.notes);
|
|
52
52
|
case "ssobig.documents": return listLength(data?.blocks);
|
|
53
53
|
default: return 0;
|
|
@@ -48,8 +48,7 @@
|
|
|
48
48
|
{ key: "image", label: "이미지", description: "단서 이미지 등록과 미리보기" },
|
|
49
49
|
{ key: "location", label: "위치", description: "단서를 발견하거나 사용하는 위치" },
|
|
50
50
|
{ key: "tags", label: "태그", description: "단서 분류와 미리보기 필터" },
|
|
51
|
-
{ key: "color", label: "컬러", description: "단서 분류용 대표 색상" }
|
|
52
|
-
{ key: "secret", label: "작가 노트", description: "제작자만 확인하는 단서 메모" }
|
|
51
|
+
{ key: "color", label: "컬러", description: "단서 분류용 대표 색상" }
|
|
53
52
|
]);
|
|
54
53
|
|
|
55
54
|
const BASIC_FIELD_CONFIGURATION = deepFreeze([
|
|
@@ -73,8 +72,7 @@
|
|
|
73
72
|
{ key: "containers", label: "원고 Container", required: true, description: "캐릭터 기본정보와 원고 section의 묶음·순서" },
|
|
74
73
|
{ key: "image", label: "캐릭터 이미지", required: true, description: "캐릭터 이미지 등록과 미리보기" },
|
|
75
74
|
{ key: "color", label: "컬러", required: true, description: "캐릭터 카드의 대표 색상" },
|
|
76
|
-
{ key: "tag", label: "태그", required: true, description: "캐릭터의 역할과 분류 태그" }
|
|
77
|
-
{ key: "authorNote", label: "작가 노트", description: "연기 방향과 설계 의도 메모" }
|
|
75
|
+
{ key: "tag", label: "태그", required: true, description: "캐릭터의 역할과 분류 태그" }
|
|
78
76
|
]);
|
|
79
77
|
|
|
80
78
|
function template(input) {
|
|
@@ -113,7 +111,7 @@
|
|
|
113
111
|
|
|
114
112
|
const INITIAL_TEMPLATE_CATALOG = deepFreeze(catalogContract.list().map(descriptor => template({
|
|
115
113
|
templateId: descriptor.templateId,
|
|
116
|
-
name: descriptor.
|
|
114
|
+
name: descriptor.displayLabel,
|
|
117
115
|
required: descriptor.required,
|
|
118
116
|
internal: descriptor.internal,
|
|
119
117
|
allowMultiple: descriptor.allowMultiple,
|
|
@@ -186,12 +184,20 @@
|
|
|
186
184
|
return selected.allowMultiple || !instances.some(instance => instance?.templateId === selected.templateId);
|
|
187
185
|
}
|
|
188
186
|
|
|
187
|
+
function displayLabelFor(instance, defaultLabel = "") {
|
|
188
|
+
const selected = get(instance?.templateId);
|
|
189
|
+
const instanceLabel = String(instance?.tab || "").trim();
|
|
190
|
+
if (selected?.allowMultiple && instanceLabel) return instanceLabel;
|
|
191
|
+
return String(selected?.name || instanceLabel || defaultLabel || instance?.instanceId || instance?.templateId || "");
|
|
192
|
+
}
|
|
193
|
+
|
|
189
194
|
initialTemplates.forEach(item => register(item));
|
|
190
195
|
return Object.freeze({
|
|
191
196
|
register,
|
|
192
197
|
get,
|
|
193
198
|
has: id => Boolean(get(id)),
|
|
194
199
|
canInstantiate,
|
|
200
|
+
displayLabelFor,
|
|
195
201
|
list: () => [...templates.values()].sort((left, right) => left.templateId.localeCompare(right.templateId))
|
|
196
202
|
});
|
|
197
203
|
}
|
|
@@ -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,
|
|
@@ -123,20 +123,39 @@
|
|
|
123
123
|
|| (component.internal && instance.config?.hidden !== true ? "에셋 Component는 내부 전용으로 숨김 처리되어야 합니다." : null);
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
function legacyTimelineError(timeline, characterIds) {
|
|
127
|
+
for (const event of timeline.data.events) {
|
|
128
|
+
const missing = Object.keys(event.memories).find(id => !characterIds.has(id));
|
|
129
|
+
if (missing) return `${timeline.instanceId} timeline memory가 존재하지 않는 character(${missing})를 가리킵니다.`;
|
|
130
|
+
const unassigned = Object.keys(event.unassignedMemories || {});
|
|
131
|
+
const assigned = unassigned.find(id => characterIds.has(id));
|
|
132
|
+
if (assigned) return `${timeline.instanceId} unassigned memory(${assigned})는 현재 character에 연결할 수 있습니다.`;
|
|
133
|
+
const overlap = unassigned.find(id => Object.hasOwn(event.memories, id));
|
|
134
|
+
if (overlap) return `${timeline.instanceId} memory(${overlap})가 assigned와 unassigned에 중복 저장되어 있습니다.`;
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function timelineLaneCharacterError(timeline, characterIds) {
|
|
140
|
+
for (const entry of timeline.data.entries) {
|
|
141
|
+
if (entry.lane.kind === "character" && !characterIds.has(entry.lane.characterId)) {
|
|
142
|
+
return `${timeline.instanceId} timeline 항목이 존재하지 않는 character(${entry.lane.characterId})를 가리킵니다.`;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
126
148
|
function compositionRelationError(instances) {
|
|
127
149
|
const character = instances.find(instance => instance.templateId === "ssobig.character");
|
|
128
150
|
if (character) {
|
|
129
151
|
const characterIds = new Set(Object.keys(character.data.characters || {}));
|
|
152
|
+
// 타임라인의 모양 배타, 시간 레벨 참조, lane/when 종류별 규칙은 validateComponentData가 이미 검증한다.
|
|
153
|
+
// 여기서는 다른 Component를 봐야 판정할 수 있는 character 참조만 확인한다.
|
|
130
154
|
for (const timeline of instances.filter(instance => instance.templateId === "ssobig.timeline")) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const assigned = unassigned.find(id => characterIds.has(id));
|
|
136
|
-
if (assigned) return `${timeline.instanceId} unassigned memory(${assigned})는 현재 character에 연결할 수 있습니다.`;
|
|
137
|
-
const overlap = unassigned.find(id => Object.hasOwn(event.memories, id));
|
|
138
|
-
if (overlap) return `${timeline.instanceId} memory(${overlap})가 assigned와 unassigned에 중복 저장되어 있습니다.`;
|
|
139
|
-
}
|
|
155
|
+
const invalid = Array.isArray(timeline.data.events)
|
|
156
|
+
? legacyTimelineError(timeline, characterIds)
|
|
157
|
+
: timelineLaneCharacterError(timeline, characterIds);
|
|
158
|
+
if (invalid) return invalid;
|
|
140
159
|
}
|
|
141
160
|
}
|
|
142
161
|
const clues = instances.find(instance => instance.templateId === "ssobig.clues");
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
(function (root, factory) {
|
|
2
|
+
const api = factory();
|
|
3
|
+
if (typeof module === "object" && module.exports) module.exports = api;
|
|
4
|
+
if (root) root.WriterMarkdownDocumentModel = api;
|
|
5
|
+
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
|
|
6
|
+
"use strict";
|
|
7
|
+
|
|
8
|
+
function normalizedSource(value, preserveBlankLines = false) {
|
|
9
|
+
const source = String(value ?? "").replace(/\r\n?/g, "\n");
|
|
10
|
+
if (preserveBlankLines) return source;
|
|
11
|
+
return source
|
|
12
|
+
.replace(/^(?:[ \t]*\n)+/, "")
|
|
13
|
+
.replace(/(?:\n[ \t]*)+$/, "");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function lineStarts(source) {
|
|
17
|
+
const starts = [0];
|
|
18
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
19
|
+
if (source[index] === "\n") starts.push(index + 1);
|
|
20
|
+
}
|
|
21
|
+
return starts;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Only project-owned asset references are renderable; URLs and paths stay text.
|
|
25
|
+
function imageReference(value) {
|
|
26
|
+
const match = String(value ?? "").match(/^ {0,3}!\[([^\]\n]*)\]\(asset:([a-zA-Z0-9_-]{1,160})\)[ \t]*$/);
|
|
27
|
+
return match ? { alt: match[1], assetId: match[2] } : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function imageMarkdown(assetId, alt = "") {
|
|
31
|
+
if (!/^[a-zA-Z0-9_-]{1,160}$/.test(String(assetId))) throw new Error("올바른 이미지 에셋 ID가 아닙니다.");
|
|
32
|
+
return `![${String(alt).replace(/[\[\]\r\n]/g, " ")}](asset:${assetId})`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseLine(text, index = 0, from = 0) {
|
|
36
|
+
const source = String(text ?? "");
|
|
37
|
+
const heading = source.match(/^( {0,3})(#{1,4})(?:[ \t]+)(.*)$/);
|
|
38
|
+
const quote = source.match(/^( {0,3})>[ \t]?(.*)$/);
|
|
39
|
+
const unordered = source.match(/^( {0,3})([-*])([ \t]+)(.*)$/);
|
|
40
|
+
const ordered = source.match(/^( {0,3})(\d+)([.)])([ \t]+)(.*)$/);
|
|
41
|
+
const list = unordered || ordered;
|
|
42
|
+
const listType = unordered ? "unordered" : ordered ? "ordered" : "";
|
|
43
|
+
const marker = unordered ? unordered[2] : ordered ? `${ordered[2]}${ordered[3]}` : "";
|
|
44
|
+
const indent = list ? list[1] : "";
|
|
45
|
+
const separator = unordered ? unordered[3] : ordered ? ordered[4] : "";
|
|
46
|
+
const body = unordered ? unordered[4] : ordered ? ordered[5] : "";
|
|
47
|
+
const markerFrom = list ? indent.length : -1;
|
|
48
|
+
const markerTo = list ? markerFrom + marker.length : -1;
|
|
49
|
+
|
|
50
|
+
return Object.freeze({
|
|
51
|
+
index,
|
|
52
|
+
text: source,
|
|
53
|
+
from,
|
|
54
|
+
to: from + source.length,
|
|
55
|
+
empty: !source.trim(),
|
|
56
|
+
headingLevel: heading ? heading[2].length : 0,
|
|
57
|
+
headingMarkerFrom: heading ? heading[1].length : -1,
|
|
58
|
+
headingMarkerTo: heading ? source.length - heading[3].length : -1,
|
|
59
|
+
headingBody: heading ? heading[3] : "",
|
|
60
|
+
quoteMarkerFrom: quote ? quote[1].length : -1,
|
|
61
|
+
quoteMarkerTo: quote ? quote[0].length - quote[2].length : -1,
|
|
62
|
+
quoteBody: quote ? quote[2] : "",
|
|
63
|
+
listType,
|
|
64
|
+
listIndent: indent,
|
|
65
|
+
listIndentTo: list ? indent.length : -1,
|
|
66
|
+
listMarker: marker,
|
|
67
|
+
listMarkerFrom: markerFrom,
|
|
68
|
+
listMarkerTo: markerTo,
|
|
69
|
+
listPrefixTo: list ? markerTo + separator.length : -1,
|
|
70
|
+
listSeparator: separator,
|
|
71
|
+
listBody: body,
|
|
72
|
+
listNumberText: ordered ? ordered[2] : "",
|
|
73
|
+
listNumber: ordered ? Number.parseInt(ordered[2], 10) : null,
|
|
74
|
+
listDelimiter: ordered ? ordered[3] : unordered ? unordered[2] : ""
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function markerCharacterWidth(marker) {
|
|
79
|
+
return Math.max(1, [...String(marker || "")].length);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isEscaped(source, index) {
|
|
83
|
+
let backslashes = 0;
|
|
84
|
+
for (let cursor = index - 1; cursor >= 0 && source[cursor] === "\\"; cursor -= 1) {
|
|
85
|
+
backslashes += 1;
|
|
86
|
+
}
|
|
87
|
+
return backslashes % 2 === 1;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function backtickRunLength(source, index) {
|
|
91
|
+
let cursor = index;
|
|
92
|
+
while (source[cursor] === "`") cursor += 1;
|
|
93
|
+
return cursor - index;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function closingCodeSpan(source, from, markerLength) {
|
|
97
|
+
let cursor = from;
|
|
98
|
+
while (cursor < source.length) {
|
|
99
|
+
if (source[cursor] !== "`") {
|
|
100
|
+
cursor += 1;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const runLength = backtickRunLength(source, cursor);
|
|
104
|
+
if (runLength === markerLength) return cursor + runLength;
|
|
105
|
+
cursor += runLength;
|
|
106
|
+
}
|
|
107
|
+
return -1;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function tablePipeOffsets(value) {
|
|
111
|
+
const source = String(value ?? "");
|
|
112
|
+
const offsets = [];
|
|
113
|
+
let index = 0;
|
|
114
|
+
while (index < source.length) {
|
|
115
|
+
if (source[index] === "`" && !isEscaped(source, index)) {
|
|
116
|
+
const markerLength = backtickRunLength(source, index);
|
|
117
|
+
const closeTo = closingCodeSpan(source, index + markerLength, markerLength);
|
|
118
|
+
if (closeTo >= 0) {
|
|
119
|
+
index = closeTo;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
index += markerLength;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (source[index] === "|" && !isEscaped(source, index)) offsets.push(index);
|
|
126
|
+
index += 1;
|
|
127
|
+
}
|
|
128
|
+
return offsets;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function unescapeTableCellPipes(value) {
|
|
132
|
+
const source = String(value ?? "");
|
|
133
|
+
let result = "";
|
|
134
|
+
let index = 0;
|
|
135
|
+
while (index < source.length) {
|
|
136
|
+
if (source[index] !== "\\") {
|
|
137
|
+
result += source[index];
|
|
138
|
+
index += 1;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
let cursor = index;
|
|
142
|
+
while (source[cursor] === "\\") cursor += 1;
|
|
143
|
+
const count = cursor - index;
|
|
144
|
+
if (source[cursor] === "|" && count % 2 === 1) {
|
|
145
|
+
result += "\\".repeat(count - 1);
|
|
146
|
+
result += "|";
|
|
147
|
+
index = cursor + 1;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
result += "\\".repeat(count);
|
|
151
|
+
index = cursor;
|
|
152
|
+
}
|
|
153
|
+
return result;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function tableCell(source, lineIndex, lineFrom, rawFrom, rawTo, columnIndex) {
|
|
157
|
+
let contentFrom = rawFrom;
|
|
158
|
+
let contentTo = rawTo;
|
|
159
|
+
while (contentFrom < contentTo && /[ \t]/.test(source[contentFrom])) contentFrom += 1;
|
|
160
|
+
while (contentTo > contentFrom && /[ \t]/.test(source[contentTo - 1])) contentTo -= 1;
|
|
161
|
+
const raw = source.slice(rawFrom, rawTo);
|
|
162
|
+
return {
|
|
163
|
+
type: "tableCell",
|
|
164
|
+
columnIndex,
|
|
165
|
+
lineIndex,
|
|
166
|
+
from: lineFrom + contentFrom,
|
|
167
|
+
to: lineFrom + contentTo,
|
|
168
|
+
rawFrom: lineFrom + rawFrom,
|
|
169
|
+
rawTo: lineFrom + rawTo,
|
|
170
|
+
raw,
|
|
171
|
+
text: unescapeTableCellPipes(source.slice(contentFrom, contentTo)),
|
|
172
|
+
synthetic: false
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function parseTableRow(text, index = 0, from = 0) {
|
|
177
|
+
const source = String(text ?? "");
|
|
178
|
+
const firstContent = source.search(/[^ \t]/);
|
|
179
|
+
if (firstContent < 0 || firstContent > 3 || /\t/.test(source.slice(0, firstContent))) return null;
|
|
180
|
+
let lastContent = source.length - 1;
|
|
181
|
+
while (lastContent >= 0 && /[ \t]/.test(source[lastContent])) lastContent -= 1;
|
|
182
|
+
|
|
183
|
+
const offsets = tablePipeOffsets(source);
|
|
184
|
+
const offsetSet = new Set(offsets);
|
|
185
|
+
const hasLeadingPipe = source[firstContent] === "|" && offsetSet.has(firstContent);
|
|
186
|
+
const hasTrailingPipe = source[lastContent] === "|" && offsetSet.has(lastContent);
|
|
187
|
+
const contentFrom = hasLeadingPipe ? firstContent + 1 : 0;
|
|
188
|
+
const contentTo = hasTrailingPipe ? lastContent : source.length;
|
|
189
|
+
if (contentFrom > contentTo) return null;
|
|
190
|
+
|
|
191
|
+
const separators = offsets.filter(offset => offset >= contentFrom && offset < contentTo);
|
|
192
|
+
const cells = [];
|
|
193
|
+
let cellFrom = contentFrom;
|
|
194
|
+
separators.forEach((separator, columnIndex) => {
|
|
195
|
+
cells.push(tableCell(source, index, from, cellFrom, separator, columnIndex));
|
|
196
|
+
cellFrom = separator + 1;
|
|
197
|
+
});
|
|
198
|
+
cells.push(tableCell(source, index, from, cellFrom, contentTo, cells.length));
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
type: "tableRow",
|
|
202
|
+
role: "",
|
|
203
|
+
lineIndex: index,
|
|
204
|
+
from,
|
|
205
|
+
to: from + source.length,
|
|
206
|
+
raw: source,
|
|
207
|
+
cells,
|
|
208
|
+
pipes: offsets.map(offset => ({ from: from + offset, to: from + offset + 1 })),
|
|
209
|
+
hasLeadingPipe,
|
|
210
|
+
hasTrailingPipe
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function parseTableDelimiterCell(cell) {
|
|
215
|
+
const match = String(cell?.text ?? "").match(/^(:)?(-{3,})(:)?$/);
|
|
216
|
+
if (!match) return null;
|
|
217
|
+
const alignment = match[1] && match[3]
|
|
218
|
+
? "center"
|
|
219
|
+
: match[1]
|
|
220
|
+
? "left"
|
|
221
|
+
: match[3]
|
|
222
|
+
? "right"
|
|
223
|
+
: null;
|
|
224
|
+
return { ...cell, type: "tableDelimiterCell", alignment };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function tableRowWithRole(row, role, columnCount = row.cells.length) {
|
|
228
|
+
const cells = row.cells.slice();
|
|
229
|
+
while (cells.length < columnCount) {
|
|
230
|
+
cells.push({
|
|
231
|
+
type: "tableCell",
|
|
232
|
+
columnIndex: cells.length,
|
|
233
|
+
lineIndex: row.lineIndex,
|
|
234
|
+
from: row.to,
|
|
235
|
+
to: row.to,
|
|
236
|
+
rawFrom: row.to,
|
|
237
|
+
rawTo: row.to,
|
|
238
|
+
raw: "",
|
|
239
|
+
text: "",
|
|
240
|
+
synthetic: true
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
return { ...row, role, cells };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function parseTableBlock(lines, startIndex, source) {
|
|
247
|
+
const headerLine = lines[startIndex];
|
|
248
|
+
const delimiterLine = lines[startIndex + 1];
|
|
249
|
+
if (!headerLine || !delimiterLine) return null;
|
|
250
|
+
if (
|
|
251
|
+
headerLine.empty || headerLine.headingLevel || headerLine.quoteMarkerFrom >= 0 || headerLine.listType ||
|
|
252
|
+
delimiterLine.empty || delimiterLine.headingLevel || delimiterLine.quoteMarkerFrom >= 0 || delimiterLine.listType
|
|
253
|
+
) return null;
|
|
254
|
+
|
|
255
|
+
const parsedHeader = parseTableRow(headerLine.text, headerLine.index, headerLine.from);
|
|
256
|
+
const parsedDelimiter = parseTableRow(delimiterLine.text, delimiterLine.index, delimiterLine.from);
|
|
257
|
+
if (!parsedHeader || !parsedDelimiter) return null;
|
|
258
|
+
if (!parsedHeader.pipes.length && !parsedDelimiter.pipes.length) return null;
|
|
259
|
+
if (!parsedHeader.cells.length || parsedHeader.cells.length !== parsedDelimiter.cells.length) return null;
|
|
260
|
+
|
|
261
|
+
const delimiterCells = parsedDelimiter.cells.map(parseTableDelimiterCell);
|
|
262
|
+
if (delimiterCells.some(cell => !cell)) return null;
|
|
263
|
+
const columnCount = parsedHeader.cells.length;
|
|
264
|
+
const rows = [];
|
|
265
|
+
let nextIndex = startIndex + 2;
|
|
266
|
+
while (nextIndex < lines.length) {
|
|
267
|
+
const line = lines[nextIndex];
|
|
268
|
+
if (line.empty || line.headingLevel || line.quoteMarkerFrom >= 0 || line.listType) break;
|
|
269
|
+
const parsedRow = parseTableRow(line.text, line.index, line.from);
|
|
270
|
+
if (!parsedRow || !parsedRow.pipes.length) break;
|
|
271
|
+
if (parsedRow.cells.length > columnCount) return null;
|
|
272
|
+
rows.push(tableRowWithRole(parsedRow, "body", columnCount));
|
|
273
|
+
nextIndex += 1;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const header = tableRowWithRole(parsedHeader, "header");
|
|
277
|
+
const delimiter = {
|
|
278
|
+
...parsedDelimiter,
|
|
279
|
+
role: "delimiter",
|
|
280
|
+
cells: delimiterCells
|
|
281
|
+
};
|
|
282
|
+
const allRows = [header, delimiter, ...rows];
|
|
283
|
+
const from = header.from;
|
|
284
|
+
const to = allRows.at(-1).to;
|
|
285
|
+
return {
|
|
286
|
+
block: {
|
|
287
|
+
type: "table",
|
|
288
|
+
from,
|
|
289
|
+
to,
|
|
290
|
+
raw: source.slice(from, to),
|
|
291
|
+
lineIndices: allRows.map(row => row.lineIndex),
|
|
292
|
+
columnCount,
|
|
293
|
+
alignments: delimiterCells.map(cell => cell.alignment),
|
|
294
|
+
header,
|
|
295
|
+
delimiter,
|
|
296
|
+
rows
|
|
297
|
+
},
|
|
298
|
+
nextIndex
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function parseDocument(value, options = {}) {
|
|
303
|
+
const preserveBlankLines = options.preserveBlankLines === true;
|
|
304
|
+
const source = normalizedSource(value, preserveBlankLines);
|
|
305
|
+
if (!source && !preserveBlankLines) return Object.freeze({ source, lines: Object.freeze([]), blocks: Object.freeze([]) });
|
|
306
|
+
|
|
307
|
+
const starts = lineStarts(source);
|
|
308
|
+
const lines = source.split("\n").map((text, index) => parseLine(text, index, starts[index]));
|
|
309
|
+
const blocks = [];
|
|
310
|
+
let paragraph = null;
|
|
311
|
+
let quote = null;
|
|
312
|
+
let list = null;
|
|
313
|
+
|
|
314
|
+
const closeParagraph = () => { paragraph = null; };
|
|
315
|
+
const closeQuote = () => { quote = null; };
|
|
316
|
+
const closeList = () => {
|
|
317
|
+
if (list) {
|
|
318
|
+
list.markerWidth = Math.max(1, ...list.items.map(item => markerCharacterWidth(item.marker)));
|
|
319
|
+
}
|
|
320
|
+
list = null;
|
|
321
|
+
};
|
|
322
|
+
const closeAll = () => {
|
|
323
|
+
closeParagraph();
|
|
324
|
+
closeQuote();
|
|
325
|
+
closeList();
|
|
326
|
+
};
|
|
327
|
+
const appendParagraph = line => {
|
|
328
|
+
if (!paragraph) {
|
|
329
|
+
paragraph = { type: "paragraph", lines: [], lineIndices: [] };
|
|
330
|
+
blocks.push(paragraph);
|
|
331
|
+
}
|
|
332
|
+
paragraph.lines.push(line.text);
|
|
333
|
+
paragraph.lineIndices.push(line.index);
|
|
334
|
+
};
|
|
335
|
+
const appendQuote = (container, line) => {
|
|
336
|
+
let current = container.at(-1);
|
|
337
|
+
if (!current || current.type !== "quote") {
|
|
338
|
+
current = { type: "quote", lines: [], lineIndices: [] };
|
|
339
|
+
container.push(current);
|
|
340
|
+
}
|
|
341
|
+
current.lines.push(line.quoteBody);
|
|
342
|
+
current.lineIndices.push(line.index);
|
|
343
|
+
return current;
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
for (let lineIndex = 0; lineIndex < lines.length;) {
|
|
347
|
+
const line = lines[lineIndex];
|
|
348
|
+
const image = imageReference(line.text);
|
|
349
|
+
if (image) {
|
|
350
|
+
closeAll();
|
|
351
|
+
blocks.push({ type: "image", ...image, from: line.from, to: line.to, lineIndex });
|
|
352
|
+
lineIndex += 1;
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
const table = parseTableBlock(lines, lineIndex, source);
|
|
356
|
+
if (table) {
|
|
357
|
+
closeAll();
|
|
358
|
+
blocks.push(table.block);
|
|
359
|
+
lineIndex = table.nextIndex;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (line.quoteMarkerFrom >= 0) {
|
|
364
|
+
closeParagraph();
|
|
365
|
+
if (list?.items.length) {
|
|
366
|
+
quote = null;
|
|
367
|
+
appendQuote(list.items.at(-1).children, line);
|
|
368
|
+
} else {
|
|
369
|
+
closeList();
|
|
370
|
+
if (!quote) {
|
|
371
|
+
quote = { type: "quote", lines: [], lineIndices: [] };
|
|
372
|
+
blocks.push(quote);
|
|
373
|
+
}
|
|
374
|
+
quote.lines.push(line.quoteBody);
|
|
375
|
+
quote.lineIndices.push(line.index);
|
|
376
|
+
}
|
|
377
|
+
lineIndex += 1;
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
closeQuote();
|
|
382
|
+
if (line.empty) {
|
|
383
|
+
closeAll();
|
|
384
|
+
blocks.push({ type: "blank", lineIndex: line.index });
|
|
385
|
+
lineIndex += 1;
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (line.headingLevel) {
|
|
390
|
+
closeAll();
|
|
391
|
+
blocks.push({ type: "heading", level: line.headingLevel, text: line.headingBody, lineIndex: line.index });
|
|
392
|
+
lineIndex += 1;
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (line.listType) {
|
|
397
|
+
closeParagraph();
|
|
398
|
+
if (list && list.listType !== line.listType) closeList();
|
|
399
|
+
if (!list) {
|
|
400
|
+
list = { type: "list", listType: line.listType, items: [], lineIndices: [], markerWidth: 1 };
|
|
401
|
+
blocks.push(list);
|
|
402
|
+
}
|
|
403
|
+
list.items.push({
|
|
404
|
+
type: "listItem",
|
|
405
|
+
marker: line.listMarker,
|
|
406
|
+
number: line.listNumber,
|
|
407
|
+
delimiter: line.listDelimiter,
|
|
408
|
+
indent: line.listIndent,
|
|
409
|
+
separator: line.listSeparator,
|
|
410
|
+
body: line.listBody,
|
|
411
|
+
numberText: line.listNumberText,
|
|
412
|
+
lineIndex: line.index,
|
|
413
|
+
children: []
|
|
414
|
+
});
|
|
415
|
+
list.lineIndices.push(line.index);
|
|
416
|
+
lineIndex += 1;
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
closeList();
|
|
421
|
+
appendParagraph(line);
|
|
422
|
+
lineIndex += 1;
|
|
423
|
+
}
|
|
424
|
+
closeList();
|
|
425
|
+
|
|
426
|
+
return Object.freeze({
|
|
427
|
+
source,
|
|
428
|
+
lines: Object.freeze(lines),
|
|
429
|
+
blocks: Object.freeze(blocks)
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return Object.freeze({
|
|
434
|
+
imageReference,
|
|
435
|
+
imageMarkdown,
|
|
436
|
+
normalizedSource,
|
|
437
|
+
lineStarts,
|
|
438
|
+
parseLine,
|
|
439
|
+
parseTableRow,
|
|
440
|
+
parseTableDelimiterCell,
|
|
441
|
+
parseDocument,
|
|
442
|
+
markerCharacterWidth
|
|
443
|
+
});
|
|
444
|
+
});
|