@sutrata/editor 0.1.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.
Files changed (125) hide show
  1. package/dist/ai/VoiceConfirmDialog.d.ts +1 -0
  2. package/dist/ai/VoiceConfirmDialog.js +6 -6
  3. package/dist/ai/VoiceToolbar.d.ts +1 -0
  4. package/dist/ai/VoiceToolbar.js +22 -16
  5. package/dist/ai/clean-response.d.ts +7 -0
  6. package/dist/ai/clean-response.js +58 -0
  7. package/dist/ai/format-transcript.d.ts +3 -0
  8. package/dist/ai/format-transcript.js +7 -0
  9. package/dist/ai/prompts.d.ts +8 -1
  10. package/dist/ai/prompts.js +34 -7
  11. package/dist/autocomplete/suggestions.js +1 -1
  12. package/dist/context/DocumentContext.d.ts +34 -5
  13. package/dist/context/DocumentContext.js +152 -51
  14. package/dist/editor/EditorView.js +35 -5
  15. package/dist/editor/SelectionMenu.js +5 -3
  16. package/dist/editor/editor-bus.d.ts +3 -0
  17. package/dist/editor/editor-bus.js +13 -0
  18. package/dist/editor/external-change.d.ts +11 -0
  19. package/dist/editor/external-change.js +105 -0
  20. package/dist/editor/external-decorations-plugin.d.ts +23 -0
  21. package/dist/editor/external-decorations-plugin.js +151 -0
  22. package/dist/editor/frontmatter-field-view.js +3 -0
  23. package/dist/editor/omitted-scene-plugin.d.ts +9 -0
  24. package/dist/editor/omitted-scene-plugin.js +44 -0
  25. package/dist/editor/plugins.js +4 -0
  26. package/dist/editor/private-metadata-plugin.d.ts +13 -0
  27. package/dist/editor/private-metadata-plugin.js +100 -0
  28. package/dist/editor/prosemirror-to-sutra.js +179 -74
  29. package/dist/editor/scene-heading-view.js +2 -0
  30. package/dist/editor/schema.js +74 -13
  31. package/dist/editor/sutra-to-prosemirror.d.ts +2 -0
  32. package/dist/editor/sutra-to-prosemirror.js +32 -34
  33. package/dist/extensions/ai-provider.d.ts +44 -0
  34. package/dist/extensions/ai-provider.js +27 -0
  35. package/dist/extensions/collab.d.ts +21 -0
  36. package/dist/extensions/collab.js +6 -0
  37. package/dist/extensions/command-registry.d.ts +49 -0
  38. package/dist/extensions/command-registry.js +28 -0
  39. package/dist/extensions/decorations.d.ts +48 -0
  40. package/dist/extensions/decorations.js +6 -0
  41. package/dist/extensions/export-registry.d.ts +59 -0
  42. package/dist/extensions/export-registry.js +10 -0
  43. package/dist/extensions/panel-registry.d.ts +30 -0
  44. package/dist/extensions/panel-registry.js +16 -0
  45. package/dist/extensions/registries.d.ts +15 -0
  46. package/dist/extensions/registries.js +12 -0
  47. package/dist/extensions/registry-store.d.ts +26 -0
  48. package/dist/extensions/registry-store.js +35 -0
  49. package/dist/extensions/session.d.ts +32 -0
  50. package/dist/extensions/session.js +20 -0
  51. package/dist/extensions/speech-provider.d.ts +35 -0
  52. package/dist/extensions/speech-provider.js +17 -0
  53. package/dist/extensions/storage-adapter.d.ts +36 -1
  54. package/dist/file/builtin-exporters.d.ts +9 -0
  55. package/dist/file/builtin-exporters.js +72 -0
  56. package/dist/file/docx-exporter.js +7 -4
  57. package/dist/file/docx-importer.d.ts +9 -0
  58. package/dist/file/docx-importer.js +47 -19
  59. package/dist/file/scene-number.d.ts +5 -0
  60. package/dist/file/scene-number.js +8 -0
  61. package/dist/file/workflow-reports.js +13 -7
  62. package/dist/findreplace/FindReplace.js +5 -2
  63. package/dist/import/AIImportDialog.d.ts +11 -0
  64. package/dist/import/AIImportDialog.js +112 -0
  65. package/dist/import/ai-import.d.ts +51 -0
  66. package/dist/import/ai-import.js +200 -0
  67. package/dist/import/apply-import.d.ts +7 -0
  68. package/dist/import/apply-import.js +35 -0
  69. package/dist/import/extract.d.ts +17 -0
  70. package/dist/import/extract.js +18 -0
  71. package/dist/index.d.ts +18 -3
  72. package/dist/index.js +7 -0
  73. package/dist/locales/bn.json +8 -0
  74. package/dist/locales/en.json +8 -0
  75. package/dist/locales/gu.json +8 -0
  76. package/dist/locales/hi.json +8 -0
  77. package/dist/locales/kn.json +8 -0
  78. package/dist/locales/ml.json +8 -0
  79. package/dist/locales/mr.json +8 -0
  80. package/dist/locales/or.json +8 -0
  81. package/dist/locales/pa.json +8 -0
  82. package/dist/locales/si.json +8 -0
  83. package/dist/locales/ta.json +8 -0
  84. package/dist/locales/te.json +8 -0
  85. package/dist/navigator/SceneNavigator.js +125 -131
  86. package/dist/navigator/StatisticsDialog.js +5 -1
  87. package/dist/navigator/scene-edits.d.ts +18 -0
  88. package/dist/navigator/scene-edits.js +143 -0
  89. package/dist/navigator/scene-list.js +4 -4
  90. package/dist/navigator/scene-numbering.d.ts +37 -0
  91. package/dist/navigator/scene-numbering.js +88 -0
  92. package/dist/settings/SettingsDialog.js +8 -120
  93. package/dist/shell/AppBar.js +25 -4
  94. package/dist/shell/AppShell.js +18 -4
  95. package/dist/shell/ElementToolbar.d.ts +5 -0
  96. package/dist/shell/ElementToolbar.js +35 -25
  97. package/dist/shell/LanguageStatus.js +6 -0
  98. package/dist/shell/StatusBar.js +11 -6
  99. package/dist/shell/builtin-commands.d.ts +10 -0
  100. package/dist/shell/builtin-commands.js +43 -0
  101. package/dist/shell/icons.d.ts +6 -0
  102. package/dist/shell/icons.js +6 -0
  103. package/dist/shell/use-commands.d.ts +7 -0
  104. package/dist/shell/use-commands.js +24 -0
  105. package/dist/shell/use-panel-context.d.ts +3 -0
  106. package/dist/shell/use-panel-context.js +39 -0
  107. package/dist/source/SourceView.js +39 -3
  108. package/dist/styles/screenplay.css +114 -1
  109. package/dist/titlepage/ExportDialog.d.ts +5 -0
  110. package/dist/titlepage/ExportDialog.js +59 -96
  111. package/dist/titlepage/frontmatter-form.js +1 -1
  112. package/package.json +4 -4
  113. package/dist/ai/OnboardingDialog.d.ts +0 -6
  114. package/dist/ai/OnboardingDialog.js +0 -115
  115. package/dist/ai/ai-client.d.ts +0 -71
  116. package/dist/ai/ai-client.js +0 -494
  117. package/dist/ai/generated-providers.json +0 -3696
  118. package/dist/ai/voice-service.d.ts +0 -15
  119. package/dist/ai/voice-service.js +0 -81
  120. package/dist/editor/character-view.d.ts +0 -35
  121. package/dist/editor/character-view.js +0 -112
  122. package/dist/editor/transliterate-input.d.ts +0 -10
  123. package/dist/editor/transliterate-input.js +0 -88
  124. package/dist/extensions/types.d.ts +0 -73
  125. package/dist/extensions/types.js +0 -12
@@ -1,94 +1,196 @@
1
+ import { parseFrontmatter, formatAttributeBlock } from '@sutrata/parser';
1
2
  import { schema } from './schema';
3
+ import { frontmatterFields } from './sutra-to-prosemirror';
4
+ /** Plain text of a block, with hard_break nodes as '\n'. */
2
5
  function nodeText(node) {
3
- return node.textContent;
4
- }
5
- function inlineToMarkdown(node) {
6
6
  let result = '';
7
7
  node.forEach(child => {
8
- if (child.isText) {
9
- let text = child.text ?? '';
10
- if (child.marks.find(m => m.type === schema.marks['bold']))
11
- text = `**${text}**`;
12
- else if (child.marks.find(m => m.type === schema.marks['italic']))
13
- text = `*${text}*`;
14
- else if (child.marks.find(m => m.type === schema.marks['underline']))
15
- text = `_${text}_`;
16
- else if (child.marks.find(m => m.type === schema.marks['note_mark']))
17
- text = `[[${text}]]`;
18
- result += text;
19
- }
8
+ if (child.isText)
9
+ result += child.text ?? '';
10
+ else if (child.type === schema.nodes['hard_break'])
11
+ result += '\n';
20
12
  });
21
13
  return result;
22
14
  }
15
+ const DELIMITERS = {
16
+ bold: ['**', '**'],
17
+ italic: ['*', '*'],
18
+ underline: ['_', '_'],
19
+ note_mark: ['[[', ']]'],
20
+ };
21
+ /**
22
+ * Inline content → Sutra emphasis markup. Marks are opened and closed as a
23
+ * stack (ProseMirror orders them bold → italic → underline → note), so
24
+ * overlapping marks nest: bold+italic text becomes `***text***`.
25
+ *
26
+ * With `closeAtNewline`, every line is closed off on its own. Lyrics need
27
+ * that (the parser reads each `~` line separately); action does not (its
28
+ * whole paragraph is parsed as one run, so emphasis may span lines).
29
+ */
30
+ function inlineToMarkdown(node, closeAtNewline = false) {
31
+ let out = '';
32
+ let open = [];
33
+ const closeTo = (depth) => {
34
+ while (open.length > depth)
35
+ out += DELIMITERS[open.pop().type.name]?.[1] ?? '';
36
+ };
37
+ const write = (text, marks) => {
38
+ const wanted = marks.filter(m => DELIMITERS[m.type.name]);
39
+ let common = 0;
40
+ while (common < open.length && common < wanted.length && open[common].eq(wanted[common]))
41
+ common++;
42
+ closeTo(common);
43
+ for (const m of wanted.slice(common)) {
44
+ out += DELIMITERS[m.type.name][0];
45
+ open.push(m);
46
+ }
47
+ out += text;
48
+ };
49
+ node.forEach(child => {
50
+ if (child.type === schema.nodes['hard_break']) {
51
+ closeTo(0);
52
+ out += '\n';
53
+ }
54
+ else if (child.isText) {
55
+ const text = child.text ?? '';
56
+ if (!closeAtNewline) {
57
+ write(text, child.marks);
58
+ return;
59
+ }
60
+ text.split('\n').forEach((line, i) => {
61
+ if (i > 0) {
62
+ closeTo(0);
63
+ out += '\n';
64
+ }
65
+ if (line)
66
+ write(line, child.marks);
67
+ });
68
+ }
69
+ });
70
+ closeTo(0);
71
+ open = [];
72
+ return out;
73
+ }
74
+ function attributeBlock(node) {
75
+ return formatAttributeBlock(node.attrs['id'] ?? null, node.attrs['attrs'] ?? []);
76
+ }
77
+ /** `& key: value`, or `& key:` followed by ` - item` lines for a list value. */
78
+ function metadataLines(node) {
79
+ const key = node.attrs['metaKey'];
80
+ const value = nodeText(node);
81
+ if (node.attrs['list']) {
82
+ const items = value.split('\n');
83
+ return [`& ${key}:`, ...items.map(item => (item.trim() ? ` - ${item.trim()}` : ' -'))];
84
+ }
85
+ return [`& ${key}: ${value}`.trimEnd()];
86
+ }
23
87
  /**
24
88
  * Extract the text value of a frontmatter_field node, converting hard_break
25
89
  * nodes back to '\n' so multi-line values round-trip correctly.
26
90
  */
27
91
  function fieldText(node) {
28
- let result = '';
29
- node.forEach(child => {
30
- if (child.isText)
31
- result += child.text ?? '';
32
- else if (child.type === schema.nodes['hard_break'])
33
- result += '\n';
34
- });
35
- return result.trim();
92
+ return nodeText(node).trim();
36
93
  }
37
94
  /**
38
- * title_page node → frontmatter text block. Dotted keys ("title.hi") are
39
- * reassembled into one-level nested maps (2-space indent, matching the
40
- * parser's minimal-YAML frontmatter format). Fields with empty key AND
41
- * empty value are skipped; returns '' if nothing remains.
42
- * Multi-line values are serialized as YAML literal block scalars (|).
95
+ * title_page fields → one value per top-level key. Dotted keys ("title.hi")
96
+ * are reassembled into one-level nested maps. Fields without a key are skipped.
43
97
  */
44
- function serializeTitlePage(node) {
98
+ function groupFields(fields) {
45
99
  const data = new Map();
46
- node.forEach(field => {
47
- if (field.type.name !== 'frontmatter_field')
48
- return;
49
- const key = (field.attrs['fmKey'] ?? '').trim();
50
- const value = fieldText(field);
100
+ for (const [rawKey, value] of fields) {
101
+ const key = rawKey.trim();
51
102
  if (!key)
52
- return;
103
+ continue;
53
104
  const dot = key.indexOf('.');
54
105
  if (dot > 0 && dot < key.length - 1) {
55
106
  const parent = key.slice(0, dot);
56
- const sub = key.slice(dot + 1);
57
107
  const existing = data.get(parent);
58
108
  const map = existing !== undefined && typeof existing === 'object' ? existing : {};
59
- map[sub] = value;
109
+ map[key.slice(dot + 1)] = value;
60
110
  data.set(parent, map);
61
111
  }
62
112
  else {
63
113
  data.set(key, value);
64
114
  }
115
+ }
116
+ return data;
117
+ }
118
+ /**
119
+ * YAML lines for one key, in the subset the parser's minimal YAML reader
120
+ * understands: plain scalars, `|` literal blocks for multi-line values, and
121
+ * one level of nested map (2-space indent).
122
+ */
123
+ function keyLines(key, value) {
124
+ if (typeof value !== 'string') {
125
+ return [`${key}:`, ...Object.entries(value).map(([sub, sv]) => ` ${sub}: ${sv}`)];
126
+ }
127
+ if (value === '')
128
+ return [`${key}:`];
129
+ if (value.includes('\n'))
130
+ return [`${key}: |`, ...value.split('\n').map(ln => ` ${ln}`)];
131
+ return [`${key}: ${value}`];
132
+ }
133
+ function sameValue(a, b) {
134
+ return JSON.stringify(a) === JSON.stringify(b);
135
+ }
136
+ const YAML_KEY = /^([a-zA-Z0-9_-]+):/;
137
+ /**
138
+ * title_page node → frontmatter text block (ending in a blank line), or ''.
139
+ *
140
+ * When the node was built from frontmatter source (its `raw` attr), that
141
+ * source is kept: unchanged keys keep their original lines — including
142
+ * comments, lists, quoting and order the fields cannot represent — and only
143
+ * keys whose value changed are rewritten, removed, or appended.
144
+ */
145
+ function serializeTitlePage(node) {
146
+ const fields = [];
147
+ node.forEach(field => {
148
+ if (field.type.name === 'frontmatter_field')
149
+ fields.push([field.attrs['fmKey'] ?? '', fieldText(field)]);
65
150
  });
66
- if (data.size === 0)
67
- return '';
68
- const lines = ['---'];
69
- for (const [key, value] of data) {
70
- if (typeof value === 'string') {
71
- if (value === '') {
72
- lines.push(`${key}:`);
73
- }
74
- else if (value.includes('\n')) {
75
- // YAML literal block scalar: preserves newlines verbatim
76
- lines.push(`${key}: |`);
77
- for (const ln of value.split('\n'))
78
- lines.push(` ${ln}`);
79
- }
80
- else {
81
- lines.push(`${key}: ${value}`);
82
- }
151
+ const current = groupFields(fields);
152
+ const raw = node.attrs['raw'];
153
+ const parsed = raw ? parseFrontmatter(raw.endsWith('\n') ? raw : raw + '\n') : null;
154
+ if (!raw || !parsed) {
155
+ if (current.size === 0)
156
+ return '';
157
+ return ['---', ...[...current].flatMap(([k, v]) => keyLines(k, v)), '---'].join('\n') + '\n\n';
158
+ }
159
+ const original = groupFields(frontmatterFields(parsed.data).map(([k, v]) => [k, v.trim()]));
160
+ const lines = raw.replace(/\n$/, '').split('\n');
161
+ const body = lines.slice(1, -1); // between the --- fences
162
+ // Group raw lines into entries: a key line plus its continuation lines.
163
+ const entries = [];
164
+ for (const line of body) {
165
+ const m = YAML_KEY.exec(line);
166
+ if (m)
167
+ entries.push({ key: m[1], lines: [line] });
168
+ else if (entries.length > 0)
169
+ entries[entries.length - 1].lines.push(line);
170
+ else
171
+ entries.push({ key: null, lines: [line] });
172
+ }
173
+ const out = [];
174
+ const seen = new Set();
175
+ for (const entry of entries) {
176
+ const key = entry.key;
177
+ if (key === null || !original.has(key) || sameValue(original.get(key), current.get(key))) {
178
+ if (key !== null && original.has(key) && !current.has(key))
179
+ continue; // removed
180
+ out.push(...entry.lines);
83
181
  }
84
- else {
85
- lines.push(`${key}:`);
86
- for (const [sub, sv] of Object.entries(value))
87
- lines.push(` ${sub}: ${sv}`);
182
+ else if (current.has(key)) {
183
+ out.push(...keyLines(key, current.get(key)));
88
184
  }
185
+ if (key !== null)
186
+ seen.add(key);
89
187
  }
90
- lines.push('---');
91
- return lines.join('\n') + '\n\n';
188
+ for (const [key, value] of current)
189
+ if (!seen.has(key))
190
+ out.push(...keyLines(key, value));
191
+ if (!out.some(l => l.trim() !== ''))
192
+ return '';
193
+ return ['---', ...out, '---'].join('\n') + '\n\n';
92
194
  }
93
195
  export function prosemirrorToSutra(doc) {
94
196
  // Collect all top-level nodes into an array first
@@ -105,11 +207,12 @@ export function prosemirrorToSutra(doc) {
105
207
  const node = pmNodes[i];
106
208
  const type = node.type.name;
107
209
  if (type === 'character') {
108
- // Group: character cue + following dialogue/parenthetical lines (no blank line between them).
210
+ // Group: character cue + following registry metadata and
211
+ // dialogue/parenthetical lines (no blank line between them).
109
212
  // Name, extension and dual-marker are all just typed text now — see
110
213
  // character-decoration-plugin.ts — so the node's own text is the
111
214
  // whole cue line, verbatim.
112
- const lines = [`@${nodeText(node)}`];
215
+ const lines = [`@${nodeText(node)}${attributeBlock(node)}`];
113
216
  i++;
114
217
  while (i < pmNodes.length) {
115
218
  const next = pmNodes[i];
@@ -122,6 +225,11 @@ export function prosemirrorToSutra(doc) {
122
225
  lines.push(`( ${nodeText(next)} )`);
123
226
  i++;
124
227
  }
228
+ else if (nextType === 'scene_metadata') {
229
+ // Only produced after a cue for {#characters} registry entries (§8.2).
230
+ lines.push(...metadataLines(next));
231
+ i++;
232
+ }
125
233
  else {
126
234
  break;
127
235
  }
@@ -130,14 +238,12 @@ export function prosemirrorToSutra(doc) {
130
238
  continue;
131
239
  }
132
240
  if (type === 'scene_heading') {
133
- const id = node.attrs['id'] ? ` {#${node.attrs['id']}}` : '';
134
- // Group heading + optional synopsis + metadata lines with single newlines (no blank
135
- // line between them), so the parser recognises them as one heading block.
136
- const headingLines = [`## ${nodeText(node)}${id}`];
241
+ // Group heading + metadata lines with single newlines (no blank line
242
+ // between them), so the parser recognises them as one heading block.
243
+ const headingLines = [`## ${nodeText(node)}${attributeBlock(node)}`];
137
244
  i++;
138
245
  while (i < pmNodes.length && pmNodes[i].type.name === 'scene_metadata') {
139
- const m = pmNodes[i];
140
- headingLines.push(`& ${m.attrs['metaKey']}: ${nodeText(m)}`);
246
+ headingLines.push(...metadataLines(pmNodes[i]));
141
247
  i++;
142
248
  }
143
249
  blocks.push(headingLines.join('\n'));
@@ -145,13 +251,12 @@ export function prosemirrorToSutra(doc) {
145
251
  }
146
252
  if (type === 'scene_metadata') {
147
253
  // Orphan metadata (outside a heading group) — emit as-is
148
- blocks.push(`& ${node.attrs['metaKey']}: ${nodeText(node)}`);
254
+ blocks.push(metadataLines(node).join('\n'));
149
255
  i++;
150
256
  continue;
151
257
  }
152
258
  if (type === 'section') {
153
- const id = node.attrs['id'] ? ` {#${node.attrs['id']}}` : '';
154
- blocks.push(`# ${nodeText(node)}${id}`);
259
+ blocks.push(`# ${nodeText(node)}${attributeBlock(node)}`);
155
260
  i++;
156
261
  continue;
157
262
  }
@@ -173,17 +278,17 @@ export function prosemirrorToSutra(doc) {
173
278
  continue;
174
279
  }
175
280
  if (type === 'transition') {
176
- blocks.push(`>> ${nodeText(node)}`);
281
+ blocks.push(nodeText(node).split('\n').map(l => `>> ${l}`).join('\n'));
177
282
  i++;
178
283
  continue;
179
284
  }
180
285
  if (type === 'centered') {
181
- blocks.push(`>> ${nodeText(node)} <<`);
286
+ blocks.push(nodeText(node).split('\n').map(l => `>> ${l} <<`).join('\n'));
182
287
  i++;
183
288
  continue;
184
289
  }
185
290
  if (type === 'lyrics') {
186
- blocks.push(`~ ${inlineToMarkdown(node)}`);
291
+ blocks.push(inlineToMarkdown(node, true).split('\n').map(l => `~ ${l}`).join('\n'));
187
292
  i++;
188
293
  continue;
189
294
  }
@@ -34,6 +34,7 @@ export class SceneHeadingView {
34
34
  this.input.value = node.attrs['id'] ?? '';
35
35
  this.input.setAttribute('aria-label', 'Scene number');
36
36
  this.input.spellcheck = false;
37
+ this.input.readOnly = !view.editable;
37
38
  this.input.addEventListener('keydown', this.onInputKeydown);
38
39
  this.input.addEventListener('blur', this.onInputBlur);
39
40
  // Prevent PM from handling clicks inside the input
@@ -116,6 +117,7 @@ export class SceneHeadingView {
116
117
  this.dom.dataset['sceneId'] = id;
117
118
  else
118
119
  delete this.dom.dataset['sceneId'];
120
+ this.input.readOnly = !this.pmView.editable;
119
121
  // Only update input if it doesn't have focus (avoid clobbering in-progress edits)
120
122
  if (document.activeElement !== this.input) {
121
123
  this.input.value = id;
@@ -1,10 +1,34 @@
1
1
  import { Schema } from 'prosemirror-model';
2
+ /**
3
+ * Attributes (format spec §10) other than the id, kept in source order as
4
+ * `{ key, value }` pairs so unknown ones round-trip. Serialized into the DOM
5
+ * as JSON so copy/paste inside the editor keeps them.
6
+ */
7
+ function readAttrs(dom) {
8
+ try {
9
+ const parsed = JSON.parse(dom.dataset['attrs'] ?? '[]');
10
+ return Array.isArray(parsed) ? parsed : [];
11
+ }
12
+ catch {
13
+ return [];
14
+ }
15
+ }
16
+ function withAttrs(dom, node) {
17
+ const attrs = node.attrs['attrs'];
18
+ if (attrs.length > 0)
19
+ dom['data-attrs'] = JSON.stringify(attrs);
20
+ return dom;
21
+ }
2
22
  const nodes = {
3
23
  doc: { content: 'title_page? block+' },
4
24
  // Frontmatter rendered as an editable card at the top of the document.
5
25
  // Only valid as the first child of doc (not in the block group).
6
26
  title_page: {
7
27
  content: 'frontmatter_field+',
28
+ // The frontmatter source this node was built from. prosemirror-to-sutra
29
+ // re-emits it verbatim (or edits only the changed keys), so comments,
30
+ // lists, quoting and key order the fields cannot represent survive.
31
+ attrs: { raw: { default: null } },
8
32
  toDOM: () => ['div', { class: 'cs-title-page-block' }, 0],
9
33
  parseDOM: [{ tag: 'div.cs-title-page-block' }],
10
34
  },
@@ -12,20 +36,27 @@ const nodes = {
12
36
  content: '(text | hard_break)*',
13
37
  marks: '',
14
38
  attrs: { fmKey: { default: '' } },
15
- toDOM: (node) => ['div', { class: 'cs-fm-field', 'data-key': node.attrs['fmKey'] }, 0],
39
+ toDOM: (node) => {
40
+ const key = node.attrs['fmKey'];
41
+ // Tool-private `x-` keys (format spec §5, §7.1) stay in the text but are not shown.
42
+ return ['div', { class: 'cs-fm-field', 'data-key': key, ...(key.startsWith('x-') ? { 'data-private': 'true' } : {}) }, 0];
43
+ },
16
44
  parseDOM: [{ tag: 'div.cs-fm-field', getAttrs: (dom) => ({ fmKey: dom.dataset['key'] ?? '' }) }],
17
45
  },
18
46
  scene_heading: {
19
47
  group: 'block',
20
48
  content: 'inline*',
21
- attrs: { id: { default: null } },
49
+ attrs: { id: { default: null }, attrs: { default: [] } },
22
50
  toDOM: (node) => {
23
51
  const attrs = { class: 'cs-scene-heading' };
24
52
  if (node.attrs['id'])
25
53
  attrs['data-scene-id'] = node.attrs['id'];
26
- return ['div', attrs, 0];
54
+ return ['div', withAttrs(attrs, node), 0];
27
55
  },
28
- parseDOM: [{ tag: 'div.cs-scene-heading', getAttrs: (dom) => ({ id: dom.dataset['sceneId'] ?? null }) }],
56
+ parseDOM: [{
57
+ tag: 'div.cs-scene-heading',
58
+ getAttrs: (dom) => ({ id: dom.dataset['sceneId'] ?? null, attrs: readAttrs(dom) }),
59
+ }],
29
60
  },
30
61
  action: {
31
62
  group: 'block',
@@ -36,8 +67,17 @@ const nodes = {
36
67
  character: {
37
68
  group: 'block',
38
69
  content: 'inline*',
39
- toDOM: () => ['div', { class: 'cs-character' }, 0],
40
- parseDOM: [{ tag: 'div.cs-character' }],
70
+ attrs: { id: { default: null }, attrs: { default: [] } },
71
+ toDOM: (node) => {
72
+ const attrs = { class: 'cs-character' };
73
+ if (node.attrs['id'])
74
+ attrs['data-id'] = node.attrs['id'];
75
+ return ['div', withAttrs(attrs, node), 0];
76
+ },
77
+ parseDOM: [{
78
+ tag: 'div.cs-character',
79
+ getAttrs: (dom) => ({ id: dom.dataset['id'] ?? null, attrs: readAttrs(dom) }),
80
+ }],
41
81
  },
42
82
  dialogue: {
43
83
  group: 'block',
@@ -72,9 +112,25 @@ const nodes = {
72
112
  scene_metadata: {
73
113
  group: 'block',
74
114
  content: 'inline*',
75
- attrs: { metaKey: { default: '' } },
76
- toDOM: (node) => ['div', { class: 'cs-scene-metadata', 'data-key': node.attrs['metaKey'] }, 0],
77
- parseDOM: [{ tag: 'div.cs-scene-metadata', getAttrs: (dom) => ({ metaKey: dom.dataset['key'] ?? '' }) }],
115
+ // list: the value is a `- item` list (§7.1), one item per line of content.
116
+ attrs: { metaKey: { default: '' }, list: { default: false } },
117
+ toDOM: (node) => {
118
+ const attrs = { class: 'cs-scene-metadata', 'data-key': node.attrs['metaKey'] };
119
+ if (node.attrs['list'])
120
+ attrs['data-list'] = 'true';
121
+ // `x-` keys belong to a tool (format spec §7.1): kept, but not shown
122
+ // (private-metadata-plugin keeps the caret out).
123
+ if (node.attrs['metaKey'].startsWith('x-'))
124
+ attrs['data-private'] = 'true';
125
+ return ['div', attrs, 0];
126
+ },
127
+ parseDOM: [{
128
+ tag: 'div.cs-scene-metadata',
129
+ getAttrs: (dom) => ({
130
+ metaKey: dom.dataset['key'] ?? '',
131
+ list: dom.dataset['list'] === 'true',
132
+ }),
133
+ }],
78
134
  },
79
135
  note: {
80
136
  group: 'block',
@@ -96,11 +152,16 @@ const nodes = {
96
152
  section: {
97
153
  group: 'block',
98
154
  content: 'inline*',
99
- attrs: { level: { default: 1 }, id: { default: null } },
100
- toDOM: (node) => [`h${node.attrs['level'] + 1}`, { class: 'cs-section' }, 0],
155
+ attrs: { level: { default: 1 }, id: { default: null }, attrs: { default: [] } },
156
+ toDOM: (node) => {
157
+ const attrs = { class: 'cs-section' };
158
+ if (node.attrs['id'])
159
+ attrs['data-id'] = node.attrs['id'];
160
+ return [`h${node.attrs['level'] + 1}`, withAttrs(attrs, node), 0];
161
+ },
101
162
  parseDOM: [
102
- { tag: 'h2.cs-section', getAttrs: () => ({ level: 1 }) },
103
- { tag: 'h3.cs-section', getAttrs: () => ({ level: 2 }) },
163
+ { tag: 'h2.cs-section', getAttrs: (dom) => ({ level: 1, id: dom.dataset['id'] ?? null, attrs: readAttrs(dom) }) },
164
+ { tag: 'h3.cs-section', getAttrs: (dom) => ({ level: 2, id: dom.dataset['id'] ?? null, attrs: readAttrs(dom) }) },
104
165
  ],
105
166
  },
106
167
  hard_break: {
@@ -1,3 +1,5 @@
1
1
  import { Node as PmNode } from 'prosemirror-model';
2
2
  import type { DocumentNode } from '@sutrata/parser';
3
+ /** The (fmKey, value) pairs a title_page shows for parsed frontmatter data. */
4
+ export declare function frontmatterFields(data: Record<string, unknown>): [string, string][];
3
5
  export declare function sutraToProsemirror(doc: DocumentNode): PmNode;
@@ -13,7 +13,8 @@ function spansToNodes(spans) {
13
13
  const markType = schema.marks[span.type];
14
14
  if (!markType || inner.length === 0)
15
15
  return inner;
16
- return inner.map(n => n.mark([markType.create()]));
16
+ // addToSet, not a replacement: nested emphasis keeps every mark.
17
+ return inner.map(n => n.mark(markType.create().addToSet(n.marks)));
17
18
  });
18
19
  }
19
20
  function textNodes(text) {
@@ -28,6 +29,13 @@ function textNodes(text) {
28
29
  }
29
30
  return nodes;
30
31
  }
32
+ /** `& key: value` → scene_metadata; a list value becomes one line per item. */
33
+ function metadataNode(meta) {
34
+ if (meta.items) {
35
+ return schema.nodes['scene_metadata'].create({ metaKey: meta.key, list: true }, textNodes(meta.items.join('\n')));
36
+ }
37
+ return schema.nodes['scene_metadata'].create({ metaKey: meta.key }, textNodes(meta.value));
38
+ }
31
39
  function sceneContentToNodes(nodes) {
32
40
  const result = [];
33
41
  for (const node of nodes) {
@@ -43,7 +51,10 @@ function sceneContentToNodes(nodes) {
43
51
  const cueText = [node.name, node.extension, node.isDual ? '^' : null]
44
52
  .filter((part) => !!part)
45
53
  .join(' ');
46
- result.push(schema.nodes['character'].create({}, textNodes(cueText)));
54
+ result.push(schema.nodes['character'].create({ id: node.id, attrs: node.attrs }, textNodes(cueText)));
55
+ // Character-registry metadata (§8.2) follows the cue, before any dialogue.
56
+ for (const meta of node.metadata ?? [])
57
+ result.push(metadataNode(meta));
47
58
  for (const child of node.children) {
48
59
  if (child.type === 'dialogue') {
49
60
  result.push(schema.nodes['dialogue'].create({}, spansToNodes(child.spans)));
@@ -88,20 +99,25 @@ function frontmatterToNode(doc) {
88
99
  const fm = doc.frontmatter;
89
100
  if (!fm)
90
101
  return null;
102
+ const fields = frontmatterFields(fm.data).map(([key, value]) => schema.nodes['frontmatter_field'].create({ fmKey: key }, textNodes(value)));
103
+ if (fields.length === 0)
104
+ return null;
105
+ return schema.nodes['title_page'].create({ raw: fm.raw }, fields);
106
+ }
107
+ /** The (fmKey, value) pairs a title_page shows for parsed frontmatter data. */
108
+ export function frontmatterFields(data) {
91
109
  const fields = [];
92
- for (const [key, value] of Object.entries(fm.data)) {
110
+ for (const [key, value] of Object.entries(data)) {
93
111
  if (value !== null && typeof value === 'object') {
94
112
  for (const [sub, subVal] of Object.entries(value)) {
95
- fields.push(schema.nodes['frontmatter_field'].create({ fmKey: `${key}.${sub}` }, textNodes(String(subVal))));
113
+ fields.push([`${key}.${sub}`, String(subVal)]);
96
114
  }
97
115
  }
98
116
  else {
99
- fields.push(schema.nodes['frontmatter_field'].create({ fmKey: key }, textNodes(String(value ?? ''))));
117
+ fields.push([key, String(value ?? '')]);
100
118
  }
101
119
  }
102
- if (fields.length === 0)
103
- return null;
104
- return schema.nodes['title_page'].create({}, fields);
120
+ return fields;
105
121
  }
106
122
  export function sutraToProsemirror(doc) {
107
123
  const topNodes = [];
@@ -111,38 +127,20 @@ export function sutraToProsemirror(doc) {
111
127
  for (const node of doc.children) {
112
128
  switch (node.type) {
113
129
  case 'section':
114
- topNodes.push(schema.nodes['section'].create({ level: node.level, id: node.id }, textNodes(node.text)));
130
+ topNodes.push(schema.nodes['section'].create({ level: node.level, id: node.id, attrs: node.attrs }, textNodes(node.text)));
115
131
  break;
116
132
  case 'scene-heading': {
117
133
  const sceneNode = node;
118
- topNodes.push(schema.nodes['scene_heading'].create({ id: sceneNode.id }, textNodes(sceneNode.text)));
119
- for (const meta of sceneNode.metadata) {
120
- topNodes.push(schema.nodes['scene_metadata'].create({ metaKey: meta.key }, textNodes(meta.value)));
121
- }
134
+ topNodes.push(schema.nodes['scene_heading'].create({ id: sceneNode.id, attrs: sceneNode.attrs }, textNodes(sceneNode.text)));
135
+ for (const meta of sceneNode.metadata)
136
+ topNodes.push(metadataNode(meta));
122
137
  topNodes.push(...sceneContentToNodes(sceneNode.children));
123
138
  break;
124
139
  }
125
- case 'action':
126
- topNodes.push(schema.nodes['action'].create({}, spansToNodes(node.spans)));
127
- break;
128
- case 'transition':
129
- topNodes.push(schema.nodes['transition'].create({}, textNodes(node.text)));
130
- break;
131
- case 'centered':
132
- topNodes.push(schema.nodes['centered'].create({}, textNodes(node.text)));
133
- break;
134
- case 'lyrics':
135
- topNodes.push(schema.nodes['lyrics'].create({}, spansToNodes(node.spans)));
136
- break;
137
- case 'note':
138
- topNodes.push(schema.nodes['note'].create({}, textNodes(node.text)));
139
- break;
140
- case 'comment':
141
- topNodes.push(schema.nodes['comment'].create({}, textNodes(node.text)));
142
- break;
143
- case 'page-break':
144
- topNodes.push(schema.nodes['page_break'].create());
145
- break;
140
+ default:
141
+ // Everything else (including cues outside a scene, e.g. a cold open
142
+ // or the {#characters} registry) maps exactly as inside a scene.
143
+ topNodes.push(...sceneContentToNodes([node]));
146
144
  }
147
145
  }
148
146
  // doc content is 'title_page? block+' — always need at least one body block
@@ -0,0 +1,44 @@
1
+ /** Opaque JSON Schema object describing a structured response. */
2
+ export type JsonSchema = Record<string, unknown>;
3
+ export interface AIRequest {
4
+ /** System prompt. The editor's prompts (prompts.ts) describe Sutra, not a provider. */
5
+ system?: string;
6
+ prompt: string;
7
+ signal?: AbortSignal;
8
+ }
9
+ /** One in-flight AI call, for the status bar ("Calling <label>..."). */
10
+ export interface AIActivity {
11
+ label: string;
12
+ }
13
+ /**
14
+ * LLM access, injected by the embedder (OSS spec §11.5). The OSS app supplies
15
+ * a bring-your-own-key provider; Sutrata Cloud supplies its own agents.
16
+ * Optional: without one, the editor hides every AI feature.
17
+ */
18
+ export interface AIProvider {
19
+ id: string;
20
+ displayName: string;
21
+ /** Shown before any document content is sent (e.g. AI-assisted import). */
22
+ dataPolicyText: string;
23
+ /** Whether a call can succeed right now (e.g. an API key is set). */
24
+ isConfigured(): boolean | Promise<boolean>;
25
+ /** Free-text completion. */
26
+ complete(req: AIRequest): Promise<string>;
27
+ /** Completion that returns a value matching `schema`; the provider validates/parses it. */
28
+ completeStructured<T = unknown>(req: AIRequest & {
29
+ schema: JsonSchema;
30
+ }): Promise<T>;
31
+ /** Optional: in-flight calls, for the status bar. Calls `cb` immediately with the current list. */
32
+ subscribeActivity?(cb: (calls: AIActivity[]) => void): () => void;
33
+ /** Optional: open the embedder's setup UI, used when isConfigured() is false. */
34
+ openSetup?(): void;
35
+ }
36
+ export declare const AIProviderProvider: import("react").Provider<AIProvider | null>;
37
+ /**
38
+ * The AI provider, or null when AI is unavailable in this session: no
39
+ * provider was injected, the session cannot edit, or `featureFlags.ai` is off.
40
+ * Components hide their AI UI when this is null.
41
+ */
42
+ export declare function useAI(): AIProvider | null;
43
+ /** `Calling <label>...` text for the status bar; '' when idle. */
44
+ export declare function formatActivity(calls: AIActivity[]): string;