@kubohiroya/turbowarp-document-data 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +373 -0
- package/README.ja.md +37 -0
- package/README.md +128 -2
- package/THIRD_PARTY_NOTICES.md +9 -0
- package/assets/block-icon.svg +1 -0
- package/dist/document-data.js +19498 -0
- package/dist/extension-manifest.json +117 -0
- package/docs/architecture.ja.md +7 -0
- package/docs/architecture.md +7 -0
- package/docs/fixed-input-editors.ja.md +17 -0
- package/docs/index.html +60 -0
- package/package.json +86 -4
- package/schemas/block-editor-fixed-inputs.schema.json +22 -0
- package/src/block-definitions.json +14 -0
- package/src/config.ts +21 -0
- package/src/document-provider.ts +92 -0
- package/src/document.ts +254 -0
- package/src/extension.ts +136 -0
- package/src/fixed-input-editors.ts +116 -0
- package/src/fixed-input-schema.ts +15 -0
- package/src/globals.d.ts +28 -0
- package/src/index.ts +8 -0
package/src/document.ts
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import {fromMarkdown} from 'mdast-util-from-markdown';
|
|
2
|
+
import {toMarkdown} from 'mdast-util-to-markdown';
|
|
3
|
+
import {parseFragment} from 'parse5';
|
|
4
|
+
import type {Root as MarkdownRoot, RootContent as MarkdownNode} from 'mdast';
|
|
5
|
+
|
|
6
|
+
export const DOCUMENT_MAX_SOURCE_BYTES = 256 * 1024;
|
|
7
|
+
export const DOCUMENT_MAX_OUTPUT_BYTES = 256 * 1024;
|
|
8
|
+
export const DOCUMENT_MAX_NODES = 50_000;
|
|
9
|
+
export const DOCUMENT_MAX_DEPTH = 64;
|
|
10
|
+
|
|
11
|
+
export type DocumentKind = 'html' | 'markdown';
|
|
12
|
+
export type HtmlNode =
|
|
13
|
+
| {type: 'element'; tagName: string; attributes: Record<string, string>; children: HtmlNode[]}
|
|
14
|
+
| {type: 'text'; value: string}
|
|
15
|
+
| {type: 'comment'; value: string};
|
|
16
|
+
export interface HtmlRoot {type: 'root'; children: HtmlNode[]}
|
|
17
|
+
export type DocumentTree =
|
|
18
|
+
| {kind: 'html'; root: HtmlRoot}
|
|
19
|
+
| {kind: 'markdown'; root: MarkdownRoot};
|
|
20
|
+
|
|
21
|
+
export type DocumentErrorCode =
|
|
22
|
+
| 'INVALID_DOCUMENT'
|
|
23
|
+
| 'INVALID_NAME'
|
|
24
|
+
| 'DOCUMENT_NOT_FOUND'
|
|
25
|
+
| 'DOCUMENT_LIMIT_EXCEEDED'
|
|
26
|
+
| 'INVALID_LOCATION'
|
|
27
|
+
| 'LOCATION_NOT_FOUND'
|
|
28
|
+
| 'LOSSY_CONVERSION';
|
|
29
|
+
|
|
30
|
+
export class DocumentDataError extends Error {
|
|
31
|
+
public constructor(public readonly code: DocumentErrorCode, message: string) {
|
|
32
|
+
super(`${code}: ${message}`);
|
|
33
|
+
this.name = 'DocumentDataError';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface Parse5Node {
|
|
38
|
+
nodeName: string;
|
|
39
|
+
tagName?: string;
|
|
40
|
+
value?: string;
|
|
41
|
+
data?: string;
|
|
42
|
+
attrs?: Array<{name: string; value: string}>;
|
|
43
|
+
childNodes?: Parse5Node[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function parseHtmlDocument(source: string): DocumentTree {
|
|
47
|
+
checkSource(source);
|
|
48
|
+
let parsed: Parse5Node;
|
|
49
|
+
try {
|
|
50
|
+
parsed = parseFragment(source) as unknown as Parse5Node;
|
|
51
|
+
} catch (error) {
|
|
52
|
+
throw new DocumentDataError('INVALID_DOCUMENT', errorMessage(error, 'Invalid HTML.'));
|
|
53
|
+
}
|
|
54
|
+
const root: HtmlRoot = {
|
|
55
|
+
type: 'root',
|
|
56
|
+
children: (parsed.childNodes ?? []).map(convertHtmlNode).filter((node): node is HtmlNode => !!node)
|
|
57
|
+
};
|
|
58
|
+
checkTree(root);
|
|
59
|
+
return {kind: 'html', root};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function parseMarkdownDocument(source: string): DocumentTree {
|
|
63
|
+
checkSource(source);
|
|
64
|
+
let root: MarkdownRoot;
|
|
65
|
+
try {
|
|
66
|
+
root = stripPositions(fromMarkdown(source));
|
|
67
|
+
} catch (error) {
|
|
68
|
+
throw new DocumentDataError('INVALID_DOCUMENT', errorMessage(error, 'Invalid Markdown.'));
|
|
69
|
+
}
|
|
70
|
+
checkTree(root);
|
|
71
|
+
return {kind: 'markdown', root};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function serializeDocument(tree: DocumentTree, format: DocumentKind = tree.kind): string {
|
|
75
|
+
if (format !== tree.kind) {
|
|
76
|
+
throw new DocumentDataError(
|
|
77
|
+
'LOSSY_CONVERSION',
|
|
78
|
+
`Converting ${tree.kind} to ${format} is not supported because it may be lossy.`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
const output =
|
|
82
|
+
tree.kind === 'html'
|
|
83
|
+
? tree.root.children.map((node) => serializeHtmlNode(node)).join('')
|
|
84
|
+
: toMarkdown(tree.root, {bullet: '-', emphasis: '*', strong: '*', fences: true});
|
|
85
|
+
if (new TextEncoder().encode(output).byteLength > DOCUMENT_MAX_OUTPUT_BYTES) {
|
|
86
|
+
throw new DocumentDataError('DOCUMENT_LIMIT_EXCEEDED', 'Serialized document is too large.');
|
|
87
|
+
}
|
|
88
|
+
return output.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function getDocumentText(tree: DocumentTree, location: string): string {
|
|
92
|
+
const node = resolveLocation(tree, location);
|
|
93
|
+
return collectText(node);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function replaceDocumentText(
|
|
97
|
+
tree: DocumentTree,
|
|
98
|
+
location: string,
|
|
99
|
+
text: string
|
|
100
|
+
): DocumentTree {
|
|
101
|
+
const copy = structuredClone(tree);
|
|
102
|
+
const node = resolveLocation(copy, location);
|
|
103
|
+
if (copy.kind === 'html') replaceHtmlNodeText(node as HtmlRoot | HtmlNode, text);
|
|
104
|
+
else replaceMarkdownNodeText(node as MarkdownRoot | MarkdownNode, text);
|
|
105
|
+
checkTree(copy.root);
|
|
106
|
+
return copy;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function convertHtmlNode(node: Parse5Node): HtmlNode | undefined {
|
|
110
|
+
if (node.nodeName === '#text') return {type: 'text', value: node.value ?? ''};
|
|
111
|
+
if (node.nodeName === '#comment') return {type: 'comment', value: node.data ?? ''};
|
|
112
|
+
if (!node.tagName) return undefined;
|
|
113
|
+
return {
|
|
114
|
+
type: 'element',
|
|
115
|
+
tagName: node.tagName,
|
|
116
|
+
attributes: Object.fromEntries(
|
|
117
|
+
(node.attrs ?? [])
|
|
118
|
+
.map((attribute): [string, string] => [attribute.name, attribute.value])
|
|
119
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
120
|
+
),
|
|
121
|
+
children: (node.childNodes ?? []).map(convertHtmlNode).filter((child): child is HtmlNode => !!child)
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function resolveLocation(tree: DocumentTree, rawLocation: string): unknown {
|
|
126
|
+
const location = rawLocation.trim();
|
|
127
|
+
if (location === '$') return tree.root;
|
|
128
|
+
if (tree.kind === 'html' && /^[#.a-zA-Z][\w-]*$/.test(location)) {
|
|
129
|
+
const found = findHtml(tree.root.children, location);
|
|
130
|
+
if (!found) throw new DocumentDataError('LOCATION_NOT_FOUND', `No HTML node matches ${location}.`);
|
|
131
|
+
return found;
|
|
132
|
+
}
|
|
133
|
+
const indexes = parsePath(location);
|
|
134
|
+
let current: unknown = tree.root;
|
|
135
|
+
for (const index of indexes) {
|
|
136
|
+
const children = childrenOf(current);
|
|
137
|
+
current = children[index];
|
|
138
|
+
if (current === undefined) {
|
|
139
|
+
throw new DocumentDataError('LOCATION_NOT_FOUND', `Document location does not exist: ${location}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return current;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function parsePath(location: string): number[] {
|
|
146
|
+
if (!/^\$(?:\[\d+\])+$/.test(location)) {
|
|
147
|
+
throw new DocumentDataError('INVALID_LOCATION', `Expected $, $[0][1], or a simple HTML selector: ${location}`);
|
|
148
|
+
}
|
|
149
|
+
return [...location.matchAll(/\[(\d+)\]/g)].map((match) => Number(match[1]));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function childrenOf(value: unknown): unknown[] {
|
|
153
|
+
if (typeof value !== 'object' || value === null || !('children' in value)) return [];
|
|
154
|
+
const children = (value as {children?: unknown}).children;
|
|
155
|
+
return Array.isArray(children) ? children : [];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function findHtml(nodes: HtmlNode[], selector: string): HtmlNode | undefined {
|
|
159
|
+
for (const node of nodes) {
|
|
160
|
+
if (node.type === 'element') {
|
|
161
|
+
const matches = selector.startsWith('#')
|
|
162
|
+
? node.attributes.id === selector.slice(1)
|
|
163
|
+
: selector.startsWith('.')
|
|
164
|
+
? (node.attributes.class ?? '').split(/\s+/).includes(selector.slice(1))
|
|
165
|
+
: node.tagName === selector.toLowerCase();
|
|
166
|
+
if (matches) return node;
|
|
167
|
+
const nested = findHtml(node.children, selector);
|
|
168
|
+
if (nested) return nested;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function collectText(value: unknown): string {
|
|
175
|
+
if (typeof value !== 'object' || value === null) return '';
|
|
176
|
+
if ('type' in value && (value as {type: string}).type === 'comment') return '';
|
|
177
|
+
if ('value' in value && typeof (value as {value?: unknown}).value === 'string') {
|
|
178
|
+
return (value as {value: string}).value;
|
|
179
|
+
}
|
|
180
|
+
return childrenOf(value).map(collectText).join('');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function replaceHtmlNodeText(node: HtmlRoot | HtmlNode, text: string): void {
|
|
184
|
+
if (node.type === 'text' || node.type === 'comment') {
|
|
185
|
+
node.value = text;
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
node.children = [{type: 'text', value: text}];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function replaceMarkdownNodeText(node: MarkdownRoot | MarkdownNode, text: string): void {
|
|
192
|
+
if ('value' in node && typeof node.value === 'string') {
|
|
193
|
+
node.value = text;
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if ('children' in node && Array.isArray(node.children)) {
|
|
197
|
+
if (node.type === 'root') {
|
|
198
|
+
node.children = [{type: 'paragraph', children: [{type: 'text', value: text}]}];
|
|
199
|
+
} else {
|
|
200
|
+
node.children = [{type: 'text', value: text}];
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
throw new DocumentDataError('INVALID_LOCATION', 'The selected Markdown node has no text content.');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function serializeHtmlNode(node: HtmlNode, parentTag?: string): string {
|
|
208
|
+
if (node.type === 'text') return parentTag && RAW_TEXT_ELEMENTS.has(parentTag) ? node.value : escapeHtmlText(node.value);
|
|
209
|
+
if (node.type === 'comment') return `<!--${node.value.replaceAll('--', '- -')}-->`;
|
|
210
|
+
const attributes = Object.entries(node.attributes)
|
|
211
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
212
|
+
.map(([name, value]) => ` ${name}="${escapeHtmlAttribute(value)}"`)
|
|
213
|
+
.join('');
|
|
214
|
+
const start = `<${node.tagName}${attributes}>`;
|
|
215
|
+
if (VOID_ELEMENTS.has(node.tagName)) return start;
|
|
216
|
+
return `${start}${node.children.map((child) => serializeHtmlNode(child, node.tagName)).join('')}</${node.tagName}>`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const VOID_ELEMENTS = new Set(['area','base','br','col','embed','hr','img','input','link','meta','source','track','wbr']);
|
|
220
|
+
const RAW_TEXT_ELEMENTS = new Set(['script', 'style', 'xmp', 'iframe', 'noembed', 'noframes', 'plaintext']);
|
|
221
|
+
const escapeHtmlText = (value: string) => value.replaceAll('&', '&').replaceAll('<', '<');
|
|
222
|
+
const escapeHtmlAttribute = (value: string) => escapeHtmlText(value).replaceAll('"', '"');
|
|
223
|
+
|
|
224
|
+
function checkSource(source: string): void {
|
|
225
|
+
if (new TextEncoder().encode(source).byteLength > DOCUMENT_MAX_SOURCE_BYTES) {
|
|
226
|
+
throw new DocumentDataError('DOCUMENT_LIMIT_EXCEEDED', 'Document source is too large.');
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function checkTree(root: unknown): void {
|
|
231
|
+
let count = 0;
|
|
232
|
+
const visit = (node: unknown, depth: number): void => {
|
|
233
|
+
count += 1;
|
|
234
|
+
if (count > DOCUMENT_MAX_NODES || depth > DOCUMENT_MAX_DEPTH) {
|
|
235
|
+
throw new DocumentDataError('DOCUMENT_LIMIT_EXCEEDED', 'Document tree exceeds its node or depth limit.');
|
|
236
|
+
}
|
|
237
|
+
for (const child of childrenOf(node)) visit(child, depth + 1);
|
|
238
|
+
};
|
|
239
|
+
visit(root, 0);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function stripPositions<T>(value: T): T {
|
|
243
|
+
if (Array.isArray(value)) return value.map(stripPositions) as T;
|
|
244
|
+
if (typeof value !== 'object' || value === null) return value;
|
|
245
|
+
return Object.fromEntries(
|
|
246
|
+
Object.entries(value as Record<string, unknown>)
|
|
247
|
+
.filter(([key]) => key !== 'position')
|
|
248
|
+
.map(([key, child]) => [key, stripPositions(child)])
|
|
249
|
+
) as T;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function errorMessage(error: unknown, fallback: string): string {
|
|
253
|
+
return error instanceof Error ? error.message : fallback;
|
|
254
|
+
}
|
package/src/extension.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import {fixedInputRegistration} from './fixed-input-editors.js';
|
|
2
|
+
import definitions from './block-definitions.json';
|
|
3
|
+
import {extensionConfig, isDocumentDataMvpEnabled} from './config.js';
|
|
4
|
+
import {
|
|
5
|
+
DocumentDataError, getDocumentText, parseHtmlDocument, parseMarkdownDocument,
|
|
6
|
+
replaceDocumentText, serializeDocument, type DocumentKind, type DocumentTree
|
|
7
|
+
} from './document.js';
|
|
8
|
+
import {DocumentNamedDataProvider, type DocumentBinding} from './document-provider.js';
|
|
9
|
+
import {
|
|
10
|
+
bindNamedDataRegistryLifecycle,
|
|
11
|
+
installNamedDataRegistry,
|
|
12
|
+
type NamedDataProviderRegistration,
|
|
13
|
+
type NamedDataRegistryService
|
|
14
|
+
} from '@kubohiroya/turbowarp-named-data/composition';
|
|
15
|
+
|
|
16
|
+
type BlockTypeName = 'COMMAND' | 'REPORTER' | 'BOOLEAN';
|
|
17
|
+
type ArgumentTypeName = 'STRING';
|
|
18
|
+
interface DefinitionArgument {type: ArgumentTypeName; defaultValue: string; menu?: string}
|
|
19
|
+
interface BlockDefinition {opcode: string; blockType: BlockTypeName; text: string; description: string; arguments: Record<string, DefinitionArgument>}
|
|
20
|
+
interface MenuDefinition {acceptReporters: boolean; items: string[]}
|
|
21
|
+
const blockDefinitions = definitions.blocks as readonly BlockDefinition[];
|
|
22
|
+
|
|
23
|
+
export class DocumentDataExtension implements TurboWarpExtension {
|
|
24
|
+
private readonly fixedInputs = fixedInputRegistration(typeof Scratch === 'undefined' ? undefined : Scratch);
|
|
25
|
+
|
|
26
|
+
private registries = new WeakMap<object, Map<string, DocumentBinding>>();
|
|
27
|
+
private nextRevision = 1;
|
|
28
|
+
private readonly provider?: DocumentNamedDataProvider;
|
|
29
|
+
private readonly namedDataRegistry?: NamedDataRegistryService;
|
|
30
|
+
private readonly namedDataRegistration?: NamedDataProviderRegistration;
|
|
31
|
+
private readonly unbindNamedDataRegistryLifecycle?: () => void;
|
|
32
|
+
|
|
33
|
+
public constructor(private readonly enabled = isDocumentDataMvpEnabled()) {
|
|
34
|
+
const runtime = Scratch.vm?.runtime;
|
|
35
|
+
if (enabled) {
|
|
36
|
+
this.provider = new DocumentNamedDataProvider({
|
|
37
|
+
get: (target, name) => this.registries.get(target)?.get(name)
|
|
38
|
+
});
|
|
39
|
+
if (runtime) {
|
|
40
|
+
this.namedDataRegistry = installNamedDataRegistry(runtime);
|
|
41
|
+
this.namedDataRegistration = this.namedDataRegistry.registerProvider(
|
|
42
|
+
this.provider,
|
|
43
|
+
{lifetime: 'persistent'}
|
|
44
|
+
);
|
|
45
|
+
if (runtime.on) {
|
|
46
|
+
this.unbindNamedDataRegistryLifecycle = bindNamedDataRegistryLifecycle(
|
|
47
|
+
runtime as {on(event: string, listener: () => void): void},
|
|
48
|
+
this.namedDataRegistry
|
|
49
|
+
);
|
|
50
|
+
runtime.on('RUNTIME_DISPOSED', () => {
|
|
51
|
+
void this.dispose();
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
runtime?.on?.('PROJECT_STOP_ALL', () => this.clearRuntimeState());
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
public getNamedDataProvider(): DocumentNamedDataProvider | undefined {return this.provider;}
|
|
60
|
+
public getNamedDataRegistry(): NamedDataRegistryService | undefined {
|
|
61
|
+
return this.namedDataRegistry;
|
|
62
|
+
}
|
|
63
|
+
public async dispose(): Promise<void> {
|
|
64
|
+
this.fixedInputs.dispose();
|
|
65
|
+
await this.namedDataRegistration?.unregister();
|
|
66
|
+
this.unbindNamedDataRegistryLifecycle?.();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
public getInfo(): Record<string, unknown> {
|
|
70
|
+
this.fixedInputs.ensure(this.enabled ? blockDefinitions.map(block => `${extensionConfig.id}_${block.opcode}`) : []);
|
|
71
|
+
return {
|
|
72
|
+
id: extensionConfig.id,
|
|
73
|
+
name: Scratch.translate(definitions.extensionName),
|
|
74
|
+
docsURI: extensionConfig.docsURI,
|
|
75
|
+
blockIconURI: extensionConfig.blockIconURI,
|
|
76
|
+
blocks: this.enabled ? blockDefinitions.map((block) => this.toScratchBlock(block)) : [],
|
|
77
|
+
menus: Object.fromEntries(Object.entries(definitions.menus).map(([id, menu]) => [id, this.toScratchMenu(menu)]))
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
public parseHtml(args: {TEXT: unknown; NAME: unknown}, util: ScratchBlockUtility): void {
|
|
82
|
+
this.setBinding(util.target, this.name(args.NAME), parseHtmlDocument(Scratch.Cast.toString(args.TEXT)));
|
|
83
|
+
}
|
|
84
|
+
public parseMarkdown(args: {TEXT: unknown; NAME: unknown}, util: ScratchBlockUtility): void {
|
|
85
|
+
this.setBinding(util.target, this.name(args.NAME), parseMarkdownDocument(Scratch.Cast.toString(args.TEXT)));
|
|
86
|
+
}
|
|
87
|
+
public hasDocument(args: {NAME: unknown}, util: ScratchBlockUtility): boolean {
|
|
88
|
+
return this.registryFor(util.target).has(this.name(args.NAME));
|
|
89
|
+
}
|
|
90
|
+
public deleteDocument(args: {NAME: unknown}, util: ScratchBlockUtility): void {
|
|
91
|
+
const name = this.name(args.NAME);
|
|
92
|
+
if (!this.registryFor(util.target).delete(name)) this.notFound(name);
|
|
93
|
+
}
|
|
94
|
+
public documentKind(args: {NAME: unknown}, util: ScratchBlockUtility): DocumentKind {
|
|
95
|
+
return this.requireBinding(util.target, args.NAME).tree.kind;
|
|
96
|
+
}
|
|
97
|
+
public getDocumentText(args: {NAME: unknown; LOCATION: unknown}, util: ScratchBlockUtility): string {
|
|
98
|
+
return getDocumentText(this.requireBinding(util.target, args.NAME).tree, Scratch.Cast.toString(args.LOCATION));
|
|
99
|
+
}
|
|
100
|
+
public replaceDocumentText(args: {NAME: unknown; LOCATION: unknown; TEXT: unknown}, util: ScratchBlockUtility): void {
|
|
101
|
+
const name = this.name(args.NAME);
|
|
102
|
+
const updated = replaceDocumentText(this.requireBindingByName(util.target, name).tree, Scratch.Cast.toString(args.LOCATION), Scratch.Cast.toString(args.TEXT));
|
|
103
|
+
this.setBinding(util.target, name, updated);
|
|
104
|
+
}
|
|
105
|
+
public serializeDocument(args: {NAME: unknown; FORMAT: unknown}, util: ScratchBlockUtility): string {
|
|
106
|
+
const format = Scratch.Cast.toString(args.FORMAT);
|
|
107
|
+
if (format !== 'html' && format !== 'markdown') throw new DocumentDataError('INVALID_DOCUMENT', `Unknown document format: ${format}`);
|
|
108
|
+
return serializeDocument(this.requireBinding(util.target, args.NAME).tree, format);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private name(value: unknown): string {
|
|
112
|
+
const name = Scratch.Cast.toString(value).trim();
|
|
113
|
+
if (!name) throw new DocumentDataError('INVALID_NAME', 'Document name cannot be empty.');
|
|
114
|
+
return name;
|
|
115
|
+
}
|
|
116
|
+
private registryFor(target: object): Map<string, DocumentBinding> {
|
|
117
|
+
let registry = this.registries.get(target);
|
|
118
|
+
if (!registry) {registry = new Map(); this.registries.set(target, registry);}
|
|
119
|
+
return registry;
|
|
120
|
+
}
|
|
121
|
+
private setBinding(target: object, name: string, tree: DocumentTree): void {
|
|
122
|
+
this.registryFor(target).set(name, {tree, revision: this.nextRevision++});
|
|
123
|
+
}
|
|
124
|
+
private requireBinding(target: object, rawName: unknown): DocumentBinding {return this.requireBindingByName(target, this.name(rawName));}
|
|
125
|
+
private requireBindingByName(target: object, name: string): DocumentBinding {
|
|
126
|
+
const binding = this.registries.get(target)?.get(name);
|
|
127
|
+
if (!binding) this.notFound(name);
|
|
128
|
+
return binding;
|
|
129
|
+
}
|
|
130
|
+
private notFound(name: string): never {throw new DocumentDataError('DOCUMENT_NOT_FOUND', `Document does not exist: ${name}`);}
|
|
131
|
+
private clearRuntimeState(): void {this.registries = new WeakMap(); this.provider?.clearSession();}
|
|
132
|
+
private toScratchBlock(block: BlockDefinition): Record<string, unknown> {
|
|
133
|
+
return {opcode: block.opcode, blockType: Scratch.BlockType[block.blockType], text: Scratch.translate(block.text), arguments: Object.fromEntries(Object.entries(block.arguments).map(([name, argument]) => [name, {type: Scratch.ArgumentType[argument.type], defaultValue: argument.defaultValue, ...(argument.menu ? {menu: argument.menu} : {})}]))};
|
|
134
|
+
}
|
|
135
|
+
private toScratchMenu(menu: MenuDefinition): Record<string, unknown> {return {acceptReporters: menu.acceptReporters, items: menu.items};}
|
|
136
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import {parseFixedNumberInput} from '@kubohiroya/turbowarp-extension-data-editor/schema';
|
|
2
|
+
import type {FixedInputBinding, FixedInputEditorOptions, JsonValue, LiteralValue} from '@kubohiroya/turbowarp-extension-data-editor';
|
|
3
|
+
import {fixedInputBundle} from './fixed-input-schema.js';
|
|
4
|
+
const extensionId = "kubohiroyadocumentdata";
|
|
5
|
+
const repository = "turbowarp-document-data";
|
|
6
|
+
interface Field {opcode: string; argument: string; control: 'number' | 'enum'; choices: readonly string[];
|
|
7
|
+
normalization: string; shape: string; maxBytes: boolean}
|
|
8
|
+
const fields: readonly Field[] = [
|
|
9
|
+
{
|
|
10
|
+
"opcode": "serializeDocument",
|
|
11
|
+
"argument": "FORMAT",
|
|
12
|
+
"control": "enum",
|
|
13
|
+
"choices": [
|
|
14
|
+
"html",
|
|
15
|
+
"markdown"
|
|
16
|
+
],
|
|
17
|
+
"normalization": "identity",
|
|
18
|
+
"shape": "menu-shadow",
|
|
19
|
+
"maxBytes": false
|
|
20
|
+
}
|
|
21
|
+
];
|
|
22
|
+
function literalString(value: JsonValue | LiteralValue): string {
|
|
23
|
+
if (typeof value !== 'string') throw new TypeError('Expected a string literal.');
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
/** Legacy nonempty finite aliases are projections; opening never creates a write. */
|
|
27
|
+
export function existingFixedNumber(value: JsonValue | LiteralValue): number {
|
|
28
|
+
if ((typeof value !== 'string' && typeof value !== 'number') ||
|
|
29
|
+
(typeof value === 'string' && value.trim() === '')) throw new TypeError('Unavailable numeric projection.');
|
|
30
|
+
const number = Number(value);
|
|
31
|
+
if (!Number.isFinite(number)) throw new TypeError('Unavailable numeric projection.');
|
|
32
|
+
return number;
|
|
33
|
+
}
|
|
34
|
+
function normalizeChoice(field: Field, raw: string): string {
|
|
35
|
+
if (field.normalization === 'lowercase') return raw.toLowerCase();
|
|
36
|
+
if (field.normalization === 'method') return raw.trim().toUpperCase();
|
|
37
|
+
if (field.normalization === 'trim') return raw.trim();
|
|
38
|
+
if (field.normalization === 'parse-format') {
|
|
39
|
+
const lower = raw.trim().toLowerCase();
|
|
40
|
+
return lower === 'json' ? 'JSON' : lower === 'yaml' ? 'YAML' : lower === 'auto' ? 'auto' : raw;
|
|
41
|
+
}
|
|
42
|
+
return raw;
|
|
43
|
+
}
|
|
44
|
+
function binding(field: Field): FixedInputBinding {
|
|
45
|
+
return {argument: field.argument, schemaBundle: fixedInputBundle(field.opcode, field.argument),
|
|
46
|
+
control: {kind: field.control, ...(field.control === 'enum' ? {choices: field.choices.map(value => ({value}))} : {})},
|
|
47
|
+
read(value) {return field.control === 'number' ? existingFixedNumber(value) : normalizeChoice(field, literalString(value));},
|
|
48
|
+
encode(value) {
|
|
49
|
+
if (field.control === 'number') {
|
|
50
|
+
const parsed = parseFixedNumberInput(value);
|
|
51
|
+
if (parsed.kind !== 'valid') throw new TypeError('Invalid new numeric input.');
|
|
52
|
+
return parsed.token;
|
|
53
|
+
}
|
|
54
|
+
const raw = literalString(value), normalized = normalizeChoice(field, raw);
|
|
55
|
+
if (!field.choices.includes(normalized)) throw new TypeError('Unsupported fixed choice.');
|
|
56
|
+
return field.shape === 'menu-shadow' || field.shape === 'direct-field-dropdown' ? normalized : raw;
|
|
57
|
+
},
|
|
58
|
+
validateCandidate(value) {
|
|
59
|
+
try {
|
|
60
|
+
if (field.control === 'number') {
|
|
61
|
+
const number = existingFixedNumber(value);
|
|
62
|
+
if (field.maxBytes && (!Number.isSafeInteger(Math.trunc(number)) || Math.trunc(number) < 1)) return [{message: 'Invalid named body size.'}];
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
return field.choices.includes(normalizeChoice(field, literalString(value))) ? [] : [{message: 'Unsupported fixed choice.'}];
|
|
66
|
+
} catch {return [{message: 'Invalid fixed input.'}];}
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export const fixedInputEditors: readonly FixedInputEditorOptions[] = [...new Set(fields.map(f => f.opcode))].map(opcode => ({
|
|
71
|
+
apiVersion: 1, extensionId, editorId: `${opcode}-fixed-inputs`, opcodes: [`${extensionId}_${opcode}`],
|
|
72
|
+
contractId: `${repository}/${opcode}`, contractRevision: 'fixed-inputs-v1',
|
|
73
|
+
bindings: fields.filter(field => field.opcode === opcode).map(binding),
|
|
74
|
+
validationContext: () => ({consumerId: repository, nowMs: 0, settings: null})
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
interface OptionalFixedHost {
|
|
78
|
+
gui?: {blockEditors?: {apiVersion?: number; registerFixedInputEditor?: (definition: FixedInputEditorOptions) => () => void}};
|
|
79
|
+
vm?: {runtime?: {on?: (event: string, listener: () => void) => void; off?: (event: string, listener: () => void) => void}};
|
|
80
|
+
}
|
|
81
|
+
/** Registration follows the advertised opcode membership and the optional host capability. */
|
|
82
|
+
export function fixedInputRegistration(scratch: unknown) {
|
|
83
|
+
const host = (scratch ?? {}) as OptionalFixedHost;
|
|
84
|
+
let active: unknown;
|
|
85
|
+
let membership = '';
|
|
86
|
+
const removers: (() => void)[] = [];
|
|
87
|
+
let stopped = false;
|
|
88
|
+
const clear = () => {
|
|
89
|
+
host.vm?.runtime?.off?.('RUNTIME_DISPOSED', shutdown);
|
|
90
|
+
for (const remove of removers.splice(0).reverse()) remove();
|
|
91
|
+
active = undefined;
|
|
92
|
+
membership = '';
|
|
93
|
+
};
|
|
94
|
+
const shutdown = () => {stopped = true; clear();};
|
|
95
|
+
return {
|
|
96
|
+
ensure(visibleOpcodes: readonly string[]) {
|
|
97
|
+
const api = host.gui?.blockEditors;
|
|
98
|
+
const definitions = fixedInputEditors.filter(definition => definition.opcodes.every(opcode => visibleOpcodes.includes(opcode)));
|
|
99
|
+
const key = definitions.map(definition => definition.editorId).join(',');
|
|
100
|
+
if (stopped || (api === active && membership === key)) return;
|
|
101
|
+
clear();
|
|
102
|
+
if (api?.apiVersion !== 1 || typeof api.registerFixedInputEditor !== 'function' || !definitions.length) return;
|
|
103
|
+
try {
|
|
104
|
+
for (const definition of definitions) {
|
|
105
|
+
const remove = api.registerFixedInputEditor.call(api, definition);
|
|
106
|
+
if (typeof remove !== 'function') throw new TypeError('Invalid fixed input cleanup.');
|
|
107
|
+
removers.push(remove);
|
|
108
|
+
}
|
|
109
|
+
active = api;
|
|
110
|
+
membership = key;
|
|
111
|
+
host.vm?.runtime?.on?.('RUNTIME_DISPOSED', shutdown);
|
|
112
|
+
} catch (error) {clear(); throw error;}
|
|
113
|
+
},
|
|
114
|
+
dispose: shutdown
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type {SchemaBundle, SchemaDocument} from '@kubohiroya/turbowarp-extension-data-editor/schema';
|
|
2
|
+
import document from '../schemas/block-editor-fixed-inputs.schema.json' with {type: 'json'};
|
|
3
|
+
const repository = "turbowarp-document-data";
|
|
4
|
+
/** The owner JSON document is canonical; this selects a primitive field fragment. */
|
|
5
|
+
export function fixedInputBundle(opcode: string, argument: string): SchemaBundle {
|
|
6
|
+
const pointer = `#/$defs/${opcode}/properties/${argument}`;
|
|
7
|
+
const source = {repository, path: 'schemas/block-editor-fixed-inputs.schema.json', symbol: pointer};
|
|
8
|
+
const blocks = document.$defs as Record<string, {properties: Record<string, SchemaDocument>}>;
|
|
9
|
+
return {identity: {ownerId: repository, contractId: opcode, revision: 'fixed-inputs-v1', variantId: argument},
|
|
10
|
+
dialect: '2020-12', root: {retrievalUri: document.$id, document: blocks[opcode]!.properties[argument]!,
|
|
11
|
+
artifact: {role: 'canonical-schema', source}},
|
|
12
|
+
resources: [],
|
|
13
|
+
provenance: {canonicalSources: [source, {repository, path: 'src/extension.ts', symbol: opcode}],
|
|
14
|
+
generatedFiles: [], referenceSchemas: [], generationSources: []}};
|
|
15
|
+
}
|
package/src/globals.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
interface TurboWarpExtension {
|
|
2
|
+
getInfo(): Record<string, unknown>;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
interface ScratchTranslate {
|
|
6
|
+
(text: string): string;
|
|
7
|
+
(message: {default: string; description?: string}, placeholders?: Record<string, string | number>): string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface ScratchApi {
|
|
11
|
+
extensions: {
|
|
12
|
+
unsandboxed: boolean;
|
|
13
|
+
register(extension: TurboWarpExtension): void;
|
|
14
|
+
};
|
|
15
|
+
BlockType: Record<'COMMAND' | 'REPORTER' | 'BOOLEAN' | 'HAT', string>;
|
|
16
|
+
ArgumentType: Record<'STRING' | 'NUMBER' | 'BOOLEAN', string>;
|
|
17
|
+
Cast: {
|
|
18
|
+
toString(value: unknown): string;
|
|
19
|
+
toNumber(value: unknown): number;
|
|
20
|
+
toBoolean(value: unknown): boolean;
|
|
21
|
+
};
|
|
22
|
+
translate: ScratchTranslate;
|
|
23
|
+
vm?: {runtime?: {on?(event: string, listener: () => void): void}};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ScratchBlockUtility {target: object}
|
|
27
|
+
|
|
28
|
+
declare const Scratch: ScratchApi;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import {extensionConfig} from './config.js';
|
|
2
|
+
import {DocumentDataExtension} from './extension.js';
|
|
3
|
+
|
|
4
|
+
if (extensionConfig.unsandboxed && !Scratch.extensions.unsandboxed) {
|
|
5
|
+
throw new Error(`${extensionConfig.name} must run unsandboxed.`);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
Scratch.extensions.register(new DocumentDataExtension());
|