@kubohiroya/turbowarp-structured-data 0.4.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 +73 -0
- package/README.md +245 -0
- package/THIRD_PARTY_NOTICES.md +25 -0
- package/dist/extension-manifest.json +426 -0
- package/dist/structured-data.js +7071 -0
- package/docs/architecture.ja.md +32 -0
- package/docs/architecture.md +30 -0
- package/docs/index.html +26 -0
- package/docs/named-data-integration-plan.ja.md +375 -0
- package/package.json +77 -0
- package/schemas/extension-manifest.schema.json +103 -0
- package/src/block-definitions.json +98 -0
- package/src/codecs.ts +204 -0
- package/src/config.ts +31 -0
- package/src/core.ts +309 -0
- package/src/extension-manifest.ts +176 -0
- package/src/extension.ts +361 -0
- package/src/globals.d.ts +38 -0
- package/src/index.ts +8 -0
- package/src/structured-provider.ts +188 -0
- package/templates/README.ja.md +183 -0
- package/templates/README.md +198 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import type {Plugin} from 'vite';
|
|
2
|
+
|
|
3
|
+
export const EXTENSION_MANIFEST_FORMAT_VERSION = 3 as const;
|
|
4
|
+
|
|
5
|
+
export interface ExtensionManifestArgument {
|
|
6
|
+
id: string;
|
|
7
|
+
type: string;
|
|
8
|
+
normalizesTo?: 'pathSegments';
|
|
9
|
+
staticLiteral?: boolean;
|
|
10
|
+
minimum?: number;
|
|
11
|
+
maximum?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ExtensionManifestBlock {
|
|
15
|
+
opcode: string;
|
|
16
|
+
blockType: string;
|
|
17
|
+
arguments: ExtensionManifestArgument[];
|
|
18
|
+
resultType: string;
|
|
19
|
+
effect: 'pure' | 'immutable' | 'state' | 'control';
|
|
20
|
+
immutable: boolean;
|
|
21
|
+
errors: string[];
|
|
22
|
+
server: {supported: boolean; irOperation: string};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ExtensionManifest {
|
|
26
|
+
formatVersion: typeof EXTENSION_MANIFEST_FORMAT_VERSION;
|
|
27
|
+
id: string;
|
|
28
|
+
pathSegmentType: {
|
|
29
|
+
kind: 'discriminatedUnion';
|
|
30
|
+
variants: Array<{kind: 'key' | 'index'; valueType: 'string' | 'nonNegativeInteger'}>;
|
|
31
|
+
};
|
|
32
|
+
dataReferenceType: {
|
|
33
|
+
kind: 'named';
|
|
34
|
+
scope: 'target';
|
|
35
|
+
lifetime: 'untilProjectStop';
|
|
36
|
+
valueType: 'jsonValue';
|
|
37
|
+
};
|
|
38
|
+
blocks: ExtensionManifestBlock[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ExtensionManifestPluginOptions {
|
|
42
|
+
id: string;
|
|
43
|
+
definitions: unknown;
|
|
44
|
+
fileName?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createExtensionManifest(id: string, definitions: unknown): ExtensionManifest {
|
|
48
|
+
if (!/^[a-z0-9]+$/.test(id)) {
|
|
49
|
+
throw new TypeError('Extension manifest ID must contain only lowercase letters and numbers.');
|
|
50
|
+
}
|
|
51
|
+
const source = requireRecord(definitions, 'Block definitions');
|
|
52
|
+
if (!Array.isArray(source.blocks)) {
|
|
53
|
+
throw new TypeError('Block definitions must contain a blocks array.');
|
|
54
|
+
}
|
|
55
|
+
const seenOpcodes = new Set<string>();
|
|
56
|
+
const blocks = source.blocks.map((block, index) => {
|
|
57
|
+
const normalized = normalizeBlock(block, index);
|
|
58
|
+
if (seenOpcodes.has(normalized.opcode)) {
|
|
59
|
+
throw new TypeError(`Duplicate block opcode: ${normalized.opcode}`);
|
|
60
|
+
}
|
|
61
|
+
seenOpcodes.add(normalized.opcode);
|
|
62
|
+
return normalized;
|
|
63
|
+
});
|
|
64
|
+
return {
|
|
65
|
+
formatVersion: EXTENSION_MANIFEST_FORMAT_VERSION,
|
|
66
|
+
id,
|
|
67
|
+
pathSegmentType: {
|
|
68
|
+
kind: 'discriminatedUnion',
|
|
69
|
+
variants: [
|
|
70
|
+
{kind: 'key', valueType: 'string'},
|
|
71
|
+
{kind: 'index', valueType: 'nonNegativeInteger'}
|
|
72
|
+
]
|
|
73
|
+
},
|
|
74
|
+
dataReferenceType: {
|
|
75
|
+
kind: 'named',
|
|
76
|
+
scope: 'target',
|
|
77
|
+
lifetime: 'untilProjectStop',
|
|
78
|
+
valueType: 'jsonValue'
|
|
79
|
+
},
|
|
80
|
+
blocks: blocks.sort((left, right) => compareIds(left.opcode, right.opcode))
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function serializeExtensionManifest(id: string, definitions: unknown): string {
|
|
85
|
+
return `${JSON.stringify(createExtensionManifest(id, definitions), null, 2)}\n`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function extensionManifestPlugin(options: ExtensionManifestPluginOptions): Plugin {
|
|
89
|
+
return {
|
|
90
|
+
name: 'extension-api-manifest',
|
|
91
|
+
apply: 'build',
|
|
92
|
+
enforce: 'post',
|
|
93
|
+
generateBundle() {
|
|
94
|
+
this.emitFile({
|
|
95
|
+
type: 'asset',
|
|
96
|
+
fileName: options.fileName ?? 'extension-manifest.json',
|
|
97
|
+
source: serializeExtensionManifest(options.id, options.definitions)
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function normalizeBlock(value: unknown, index: number): ExtensionManifestBlock {
|
|
104
|
+
const block = requireRecord(value, `Block at index ${index}`);
|
|
105
|
+
const opcode = requireNonEmptyString(block.opcode, `Block at index ${index} opcode`);
|
|
106
|
+
const argumentRecord = requireRecord(block.arguments ?? {}, `Block ${opcode} arguments`);
|
|
107
|
+
const argumentsList = Object.entries(argumentRecord).map(([id, value]) => {
|
|
108
|
+
const argument = requireRecord(value, `Block ${opcode} argument ${id}`);
|
|
109
|
+
const result: ExtensionManifestArgument = {
|
|
110
|
+
id: requireNonEmptyString(id, `Block ${opcode} argument ID`),
|
|
111
|
+
type: requireNonEmptyString(argument.type, `Block ${opcode} argument ${id} type`)
|
|
112
|
+
};
|
|
113
|
+
if (argument.normalizesTo !== undefined) {
|
|
114
|
+
if (argument.normalizesTo !== 'pathSegments') {
|
|
115
|
+
throw new TypeError(`Block ${opcode} argument ${id} has an unknown normalized type.`);
|
|
116
|
+
}
|
|
117
|
+
result.normalizesTo = argument.normalizesTo;
|
|
118
|
+
}
|
|
119
|
+
if (argument.staticLiteral !== undefined) result.staticLiteral = requireBoolean(argument.staticLiteral, `${opcode}.${id}.staticLiteral`);
|
|
120
|
+
if (argument.minimum !== undefined) result.minimum = requireNumber(argument.minimum, `${opcode}.${id}.minimum`);
|
|
121
|
+
if (argument.maximum !== undefined) result.maximum = requireNumber(argument.maximum, `${opcode}.${id}.maximum`);
|
|
122
|
+
return result;
|
|
123
|
+
});
|
|
124
|
+
const server = requireRecord(block.server, `Block ${opcode} server contract`);
|
|
125
|
+
const effect = block.effect;
|
|
126
|
+
if (
|
|
127
|
+
effect !== 'pure' &&
|
|
128
|
+
effect !== 'immutable' &&
|
|
129
|
+
effect !== 'state' &&
|
|
130
|
+
effect !== 'control'
|
|
131
|
+
) {
|
|
132
|
+
throw new TypeError(`Block ${opcode} must define a supported effect.`);
|
|
133
|
+
}
|
|
134
|
+
if (!Array.isArray(block.errors) || !block.errors.every((code) => typeof code === 'string')) {
|
|
135
|
+
throw new TypeError(`Block ${opcode} errors must be an array of strings.`);
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
opcode,
|
|
139
|
+
blockType: requireNonEmptyString(block.blockType, `Block ${opcode} blockType`),
|
|
140
|
+
arguments: argumentsList.sort((left, right) => compareIds(left.id, right.id)),
|
|
141
|
+
resultType: requireNonEmptyString(block.resultType, `Block ${opcode} resultType`),
|
|
142
|
+
effect,
|
|
143
|
+
immutable: requireBoolean(block.immutable, `Block ${opcode} immutable`),
|
|
144
|
+
errors: [...block.errors],
|
|
145
|
+
server: {
|
|
146
|
+
supported: requireBoolean(server.supported, `Block ${opcode} server.supported`),
|
|
147
|
+
irOperation: requireNonEmptyString(server.irOperation, `Block ${opcode} server.irOperation`)
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
|
153
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
154
|
+
throw new TypeError(`${label} must be an object.`);
|
|
155
|
+
}
|
|
156
|
+
return value as Record<string, unknown>;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function requireNonEmptyString(value: unknown, label: string): string {
|
|
160
|
+
if (typeof value !== 'string' || value.length === 0) throw new TypeError(`${label} must be a non-empty string.`);
|
|
161
|
+
return value;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function requireBoolean(value: unknown, label: string): boolean {
|
|
165
|
+
if (typeof value !== 'boolean') throw new TypeError(`${label} must be a boolean.`);
|
|
166
|
+
return value;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function requireNumber(value: unknown, label: string): number {
|
|
170
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) throw new TypeError(`${label} must be a finite number.`);
|
|
171
|
+
return value;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function compareIds(left: string, right: string): number {
|
|
175
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
176
|
+
}
|
package/src/extension.ts
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import definitions from './block-definitions.json';
|
|
2
|
+
import {
|
|
3
|
+
bindNamedDataRegistryLifecycle,
|
|
4
|
+
installNamedDataRegistry,
|
|
5
|
+
type NamedDataProviderRegistration,
|
|
6
|
+
type NamedDataRegistryService
|
|
7
|
+
} from '@kubohiroya/turbowarp-named-data/composition';
|
|
8
|
+
import {
|
|
9
|
+
extensionConfig,
|
|
10
|
+
isNamedDataRegistryMvpEnabled,
|
|
11
|
+
isStructuredDataMvpEnabled
|
|
12
|
+
} from './config.js';
|
|
13
|
+
import {
|
|
14
|
+
parseJsonDocument,
|
|
15
|
+
parseYamlDocument,
|
|
16
|
+
renderJsonDocument,
|
|
17
|
+
renderYamlDocument
|
|
18
|
+
} from './codecs.js';
|
|
19
|
+
import {
|
|
20
|
+
deleteAtPath,
|
|
21
|
+
getAtPath,
|
|
22
|
+
hasAtPath,
|
|
23
|
+
iterationEntries,
|
|
24
|
+
keysAtPath,
|
|
25
|
+
lengthAtPath,
|
|
26
|
+
parseJson as parseJsonValue,
|
|
27
|
+
parsePath,
|
|
28
|
+
setAtPath,
|
|
29
|
+
stringifyJson,
|
|
30
|
+
StructuredDataError,
|
|
31
|
+
type IterationEntry,
|
|
32
|
+
type JsonValue
|
|
33
|
+
} from './core.js';
|
|
34
|
+
import {
|
|
35
|
+
StructuredDataNamedDataProvider,
|
|
36
|
+
type StructuredDataBinding
|
|
37
|
+
} from './structured-provider.js';
|
|
38
|
+
|
|
39
|
+
type BlockTypeName = 'COMMAND' | 'REPORTER' | 'BOOLEAN' | 'LOOP';
|
|
40
|
+
type ArgumentTypeName = 'STRING' | 'NUMBER';
|
|
41
|
+
|
|
42
|
+
interface DefinitionArgument {
|
|
43
|
+
type: ArgumentTypeName;
|
|
44
|
+
defaultValue: string | number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface BlockDefinition {
|
|
48
|
+
opcode: string;
|
|
49
|
+
blockType: BlockTypeName;
|
|
50
|
+
text: string;
|
|
51
|
+
description: string;
|
|
52
|
+
arguments: Record<string, DefinitionArgument>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface NamedPathArguments {
|
|
56
|
+
NAME: unknown;
|
|
57
|
+
PATH: unknown;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface LoopState {
|
|
61
|
+
entries: IterationEntry[];
|
|
62
|
+
cursor: number;
|
|
63
|
+
active: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface ActiveIteration extends IterationEntry {
|
|
67
|
+
index: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const LOOP_STATE = 'structuredDataLoopState';
|
|
71
|
+
const blockDefinitions = definitions.blocks as readonly BlockDefinition[];
|
|
72
|
+
|
|
73
|
+
export class StructuredDataExtension implements TurboWarpExtension {
|
|
74
|
+
private readonly enabled: boolean;
|
|
75
|
+
private registries = new WeakMap<object, Map<string, StructuredDataBinding>>();
|
|
76
|
+
private iterationStacks = new WeakMap<object, ActiveIteration[]>();
|
|
77
|
+
private nextRevision = 1;
|
|
78
|
+
private readonly namedDataProvider?: StructuredDataNamedDataProvider;
|
|
79
|
+
private readonly namedDataRegistry?: NamedDataRegistryService;
|
|
80
|
+
private readonly namedDataRegistration?: NamedDataProviderRegistration;
|
|
81
|
+
private readonly unbindNamedDataRegistryLifecycle?: () => void;
|
|
82
|
+
|
|
83
|
+
public constructor(
|
|
84
|
+
enabled = isStructuredDataMvpEnabled(),
|
|
85
|
+
namedDataEnabled = isNamedDataRegistryMvpEnabled()
|
|
86
|
+
) {
|
|
87
|
+
this.enabled = enabled;
|
|
88
|
+
if (namedDataEnabled) {
|
|
89
|
+
this.namedDataProvider = new StructuredDataNamedDataProvider({
|
|
90
|
+
get: (target, name) => this.registries.get(target)?.get(name)
|
|
91
|
+
});
|
|
92
|
+
const runtime = Scratch.vm?.runtime;
|
|
93
|
+
if (runtime) {
|
|
94
|
+
this.namedDataRegistry = installNamedDataRegistry(runtime);
|
|
95
|
+
this.namedDataRegistration = this.namedDataRegistry.registerProvider(
|
|
96
|
+
this.namedDataProvider,
|
|
97
|
+
{lifetime: 'persistent'}
|
|
98
|
+
);
|
|
99
|
+
this.unbindNamedDataRegistryLifecycle = bindNamedDataRegistryLifecycle(
|
|
100
|
+
runtime,
|
|
101
|
+
this.namedDataRegistry
|
|
102
|
+
);
|
|
103
|
+
runtime.on?.('RUNTIME_DISPOSED', () => {
|
|
104
|
+
void this.dispose();
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
Scratch.vm?.runtime?.on?.('PROJECT_STOP_ALL', () => this.clearRuntimeState());
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
public getNamedDataProvider(): StructuredDataNamedDataProvider | undefined {
|
|
112
|
+
return this.namedDataProvider;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
public getNamedDataRegistry(): NamedDataRegistryService | undefined {
|
|
116
|
+
return this.namedDataRegistry;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
public async dispose(): Promise<void> {
|
|
120
|
+
await this.namedDataRegistration?.unregister();
|
|
121
|
+
this.unbindNamedDataRegistryLifecycle?.();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
public getInfo(): Record<string, unknown> {
|
|
125
|
+
return {
|
|
126
|
+
id: extensionConfig.id,
|
|
127
|
+
name: Scratch.translate(definitions.extensionName),
|
|
128
|
+
docsURI: extensionConfig.docsURI,
|
|
129
|
+
blockIconURI: extensionConfig.blockIconURI,
|
|
130
|
+
blocks: this.enabled ? blockDefinitions.map((block) => this.toScratchBlock(block)) : []
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
public parseJson(args: {TEXT: unknown; NAME: unknown}, util: ScratchBlockUtility): void {
|
|
135
|
+
this.store(
|
|
136
|
+
util.target,
|
|
137
|
+
args.NAME,
|
|
138
|
+
parseJsonDocument(Scratch.Cast.toString(args.TEXT)),
|
|
139
|
+
'json'
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
public parseYaml(args: {TEXT: unknown; NAME: unknown}, util: ScratchBlockUtility): void {
|
|
144
|
+
this.store(
|
|
145
|
+
util.target,
|
|
146
|
+
args.NAME,
|
|
147
|
+
parseYamlDocument(Scratch.Cast.toString(args.TEXT)),
|
|
148
|
+
'yaml'
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
public toJson(args: {NAME: unknown}, util: ScratchBlockUtility): string {
|
|
153
|
+
return renderJsonDocument(this.requireData(util.target, args.NAME));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
public toYaml(args: {NAME: unknown}, util: ScratchBlockUtility): string {
|
|
157
|
+
return renderYamlDocument(this.requireData(util.target, args.NAME));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
public hasStructuredData(args: {NAME: unknown}, util: ScratchBlockUtility): boolean {
|
|
161
|
+
return this.registryFor(util.target).has(this.name(args.NAME));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
public deleteStructuredData(args: {NAME: unknown}, util: ScratchBlockUtility): void {
|
|
165
|
+
const registry = this.registryFor(util.target);
|
|
166
|
+
const name = this.name(args.NAME);
|
|
167
|
+
if (!registry.delete(name)) this.dataNotFound(name);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
public getJsonAtPath(args: NamedPathArguments, util: ScratchBlockUtility): string {
|
|
171
|
+
return stringifyJson(
|
|
172
|
+
getAtPath(this.requireData(util.target, args.NAME), this.path(args.PATH))
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
public hasPath(args: NamedPathArguments, util: ScratchBlockUtility): boolean {
|
|
177
|
+
return hasAtPath(this.requireData(util.target, args.NAME), this.path(args.PATH));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
public setJsonAtPath(
|
|
181
|
+
args: NamedPathArguments & {VALUE: unknown},
|
|
182
|
+
util: ScratchBlockUtility
|
|
183
|
+
): void {
|
|
184
|
+
const name = this.name(args.NAME);
|
|
185
|
+
const updated = setAtPath(
|
|
186
|
+
this.requireDataByName(util.target, name),
|
|
187
|
+
this.path(args.PATH),
|
|
188
|
+
parseJsonValue(Scratch.Cast.toString(args.VALUE))
|
|
189
|
+
);
|
|
190
|
+
this.setBinding(util.target, name, updated);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
public deleteAtPath(args: NamedPathArguments, util: ScratchBlockUtility): void {
|
|
194
|
+
const name = this.name(args.NAME);
|
|
195
|
+
const updated = deleteAtPath(
|
|
196
|
+
this.requireDataByName(util.target, name),
|
|
197
|
+
this.path(args.PATH)
|
|
198
|
+
);
|
|
199
|
+
this.setBinding(util.target, name, updated);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
public keysAtPath(args: NamedPathArguments, util: ScratchBlockUtility): string {
|
|
203
|
+
return JSON.stringify(
|
|
204
|
+
keysAtPath(this.requireData(util.target, args.NAME), this.path(args.PATH))
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
public lengthAtPath(args: NamedPathArguments, util: ScratchBlockUtility): number {
|
|
209
|
+
return lengthAtPath(this.requireData(util.target, args.NAME), this.path(args.PATH));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
public forEachAtPath(
|
|
213
|
+
args: NamedPathArguments & {MAX: unknown},
|
|
214
|
+
util: ScratchBlockUtility
|
|
215
|
+
): void {
|
|
216
|
+
let state = util.stackFrame[LOOP_STATE] as LoopState | undefined;
|
|
217
|
+
const stack = this.stackFor(util.thread);
|
|
218
|
+
|
|
219
|
+
if (!state) {
|
|
220
|
+
state = {
|
|
221
|
+
entries: iterationEntries(
|
|
222
|
+
this.requireData(util.target, args.NAME),
|
|
223
|
+
this.path(args.PATH),
|
|
224
|
+
Scratch.Cast.toNumber(args.MAX)
|
|
225
|
+
),
|
|
226
|
+
cursor: 0,
|
|
227
|
+
active: false
|
|
228
|
+
};
|
|
229
|
+
util.stackFrame[LOOP_STATE] = state;
|
|
230
|
+
} else if (state.active) {
|
|
231
|
+
if (stack.at(-1)?.index === state.cursor) stack.pop();
|
|
232
|
+
state.cursor += 1;
|
|
233
|
+
state.active = false;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const entry = state.entries[state.cursor];
|
|
237
|
+
if (!entry) {
|
|
238
|
+
delete util.stackFrame[LOOP_STATE];
|
|
239
|
+
if (stack.length === 0) this.iterationStacks.delete(util.thread);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
stack.push({...entry, index: state.cursor});
|
|
244
|
+
state.active = true;
|
|
245
|
+
util.startBranch(1, true);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
public currentKey(_args: Record<string, never>, util: ScratchBlockUtility): string {
|
|
249
|
+
return this.currentIteration(util.thread).key;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
public currentIndex(_args: Record<string, never>, util: ScratchBlockUtility): number {
|
|
253
|
+
return this.currentIteration(util.thread).index;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
public currentValueJson(_args: Record<string, never>, util: ScratchBlockUtility): string {
|
|
257
|
+
return stringifyJson(this.currentIteration(util.thread).value);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private store(
|
|
261
|
+
target: object,
|
|
262
|
+
rawName: unknown,
|
|
263
|
+
value: JsonValue,
|
|
264
|
+
nativeRepresentation: 'json' | 'yaml'
|
|
265
|
+
): void {
|
|
266
|
+
this.setBinding(target, this.name(rawName), value, nativeRepresentation);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private requireData(target: object, rawName: unknown): JsonValue {
|
|
270
|
+
return this.requireDataByName(target, this.name(rawName));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private requireDataByName(target: object, name: string): JsonValue {
|
|
274
|
+
const binding = this.registryFor(target).get(name);
|
|
275
|
+
if (binding === undefined) this.dataNotFound(name);
|
|
276
|
+
return binding.value;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private registryFor(target: object): Map<string, StructuredDataBinding> {
|
|
280
|
+
let registry = this.registries.get(target);
|
|
281
|
+
if (!registry) {
|
|
282
|
+
registry = new Map();
|
|
283
|
+
this.registries.set(target, registry);
|
|
284
|
+
}
|
|
285
|
+
return registry;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private setBinding(
|
|
289
|
+
target: object,
|
|
290
|
+
name: string,
|
|
291
|
+
value: JsonValue,
|
|
292
|
+
nativeRepresentation?: 'json' | 'yaml'
|
|
293
|
+
): void {
|
|
294
|
+
const registry = this.registryFor(target);
|
|
295
|
+
const retainedRepresentation =
|
|
296
|
+
nativeRepresentation ?? registry.get(name)?.nativeRepresentation ?? 'json';
|
|
297
|
+
registry.set(name, {
|
|
298
|
+
value,
|
|
299
|
+
revision: this.nextRevision,
|
|
300
|
+
nativeRepresentation: retainedRepresentation
|
|
301
|
+
});
|
|
302
|
+
this.nextRevision += 1;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private name(value: unknown): string {
|
|
306
|
+
const name = Scratch.Cast.toString(value).trim();
|
|
307
|
+
if (name.length === 0) {
|
|
308
|
+
throw new StructuredDataError('INVALID_NAME', 'Structured data name cannot be empty.');
|
|
309
|
+
}
|
|
310
|
+
return name;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private dataNotFound(name: string): never {
|
|
314
|
+
throw new StructuredDataError('DATA_NOT_FOUND', `Structured data does not exist: ${name}`);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private currentIteration(thread: object): ActiveIteration {
|
|
318
|
+
const current = this.iterationStacks.get(thread)?.at(-1);
|
|
319
|
+
if (!current) {
|
|
320
|
+
throw new StructuredDataError(
|
|
321
|
+
'ITERATION_CONTEXT_REQUIRED',
|
|
322
|
+
'This reporter is only valid inside an active iteration.'
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
return current;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private stackFor(thread: object): ActiveIteration[] {
|
|
329
|
+
let stack = this.iterationStacks.get(thread);
|
|
330
|
+
if (!stack) {
|
|
331
|
+
stack = [];
|
|
332
|
+
this.iterationStacks.set(thread, stack);
|
|
333
|
+
}
|
|
334
|
+
return stack;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private path(value: unknown) {
|
|
338
|
+
return parsePath(Scratch.Cast.toString(value));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
private clearRuntimeState(): void {
|
|
342
|
+
this.namedDataProvider?.clearSession();
|
|
343
|
+
this.registries = new WeakMap();
|
|
344
|
+
this.iterationStacks = new WeakMap();
|
|
345
|
+
this.nextRevision = 1;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private toScratchBlock(block: BlockDefinition): Record<string, unknown> {
|
|
349
|
+
return {
|
|
350
|
+
opcode: block.opcode,
|
|
351
|
+
blockType: Scratch.BlockType[block.blockType],
|
|
352
|
+
text: Scratch.translate(block.text),
|
|
353
|
+
arguments: Object.fromEntries(
|
|
354
|
+
Object.entries(block.arguments).map(([name, argument]) => [
|
|
355
|
+
name,
|
|
356
|
+
{type: Scratch.ArgumentType[argument.type], defaultValue: argument.defaultValue}
|
|
357
|
+
])
|
|
358
|
+
)
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
}
|
package/src/globals.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
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 ScratchBlockUtility {
|
|
11
|
+
thread: object;
|
|
12
|
+
target: object;
|
|
13
|
+
stackFrame: Record<string, unknown>;
|
|
14
|
+
startBranch(branchNumber: number, isLoop: boolean): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ScratchApi {
|
|
18
|
+
extensions: {
|
|
19
|
+
unsandboxed: boolean;
|
|
20
|
+
register(extension: TurboWarpExtension): void;
|
|
21
|
+
};
|
|
22
|
+
BlockType: Record<'COMMAND' | 'REPORTER' | 'BOOLEAN' | 'HAT' | 'LOOP', string>;
|
|
23
|
+
ArgumentType: Record<'STRING' | 'NUMBER' | 'BOOLEAN', string>;
|
|
24
|
+
Cast: {
|
|
25
|
+
toString(value: unknown): string;
|
|
26
|
+
toNumber(value: unknown): number;
|
|
27
|
+
toBoolean(value: unknown): boolean;
|
|
28
|
+
};
|
|
29
|
+
translate: ScratchTranslate;
|
|
30
|
+
vm?: {
|
|
31
|
+
runtime?: {
|
|
32
|
+
on(event: string, listener: () => void): void;
|
|
33
|
+
off?(event: string, listener: () => void): void;
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
declare const Scratch: ScratchApi;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import {extensionConfig} from './config.js';
|
|
2
|
+
import {StructuredDataExtension} 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 StructuredDataExtension());
|