@kubohiroya/turbowarp-title-menu 0.1.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.
@@ -0,0 +1,358 @@
1
+ /**
2
+ * Multi-file DSL storage backed by IndexedDB.
3
+ *
4
+ * The previous single-slot `localStorage` design could only remember the last opened file. A venue
5
+ * keeps several performances side by side, so records are addressed by a stable id, names are unique
6
+ * so a list stays meaningful, and the store never deletes a record on its own: exceeding the file
7
+ * limit fails loudly instead of silently dropping someone's DSL.
8
+ */
9
+ export interface DslFileRecord {
10
+ readonly id: string;
11
+ readonly name: string;
12
+ readonly source: string;
13
+ readonly byteLength: number;
14
+ readonly savedAt: string;
15
+ readonly updatedAt: string;
16
+ }
17
+
18
+ export type DslFileSummary = Omit<DslFileRecord, 'source'>;
19
+
20
+ export type DslSortField = 'name' | 'updatedAt' | 'byteLength';
21
+
22
+ export type DslSortDirection = 'asc' | 'desc';
23
+
24
+ export interface DslSort {
25
+ readonly field: DslSortField;
26
+ readonly direction: DslSortDirection;
27
+ }
28
+
29
+ export type DslStoreErrorCode =
30
+ | 'unavailable'
31
+ | 'invalid-name'
32
+ | 'invalid-source'
33
+ | 'name-taken'
34
+ | 'too-large'
35
+ | 'too-many'
36
+ | 'not-found'
37
+ | 'quota'
38
+ | 'failed';
39
+
40
+ export class DslStoreError extends Error {
41
+ public readonly code: DslStoreErrorCode;
42
+
43
+ public constructor(code: DslStoreErrorCode, message: string, cause?: unknown) {
44
+ super(message, cause === undefined ? undefined : {cause});
45
+ this.name = 'DslStoreError';
46
+ this.code = code;
47
+ }
48
+ }
49
+
50
+ export interface DslStoreOptions {
51
+ readonly indexedDB?: IDBFactory;
52
+ readonly databaseName?: string;
53
+ readonly maxSourceBytes?: number;
54
+ readonly maxFileCount?: number;
55
+ readonly now?: () => Date;
56
+ readonly createId?: () => string;
57
+ }
58
+
59
+ export interface DslStore {
60
+ readonly databaseName: string;
61
+ list(sort?: DslSort): Promise<DslFileSummary[]>;
62
+ count(): Promise<number>;
63
+ get(id: string): Promise<DslFileRecord | null>;
64
+ save(file: {name: string; source: string}): Promise<DslFileRecord>;
65
+ rename(id: string, name: string): Promise<DslFileRecord>;
66
+ remove(id: string): Promise<void>;
67
+ clear(): Promise<void>;
68
+ lastOpened(): Promise<DslFileRecord | null>;
69
+ markOpened(id: string): Promise<void>;
70
+ close(): void;
71
+ }
72
+
73
+ export const defaultDslSort: DslSort = Object.freeze({field: 'updatedAt', direction: 'desc'});
74
+
75
+ const fileStoreName = 'files';
76
+ const metaStoreName = 'meta';
77
+ const lastOpenedKey = 'last-opened';
78
+ const nameIndexName = 'by-name';
79
+ const encoder = new TextEncoder();
80
+ const maximumNameLength = 200;
81
+ /** Rejects control characters so a name cannot break the dialog's text rendering. */
82
+ function hasControlCharacter(value: string): boolean {
83
+ for (const character of value) {
84
+ const code = character.codePointAt(0) ?? 0;
85
+ if (code < 0x20 || code === 0x7f) return true;
86
+ }
87
+ return false;
88
+ }
89
+
90
+ function normalizeName(value: unknown): string {
91
+ if (typeof value !== 'string') throw new DslStoreError('invalid-name', 'DSL name must be a string.');
92
+ const name = value.trim();
93
+ if (name.length === 0) throw new DslStoreError('invalid-name', 'DSL name must not be empty.');
94
+ if (name.length > maximumNameLength) {
95
+ throw new DslStoreError('invalid-name', `DSL name must be at most ${maximumNameLength} characters.`);
96
+ }
97
+ if (hasControlCharacter(name)) {
98
+ throw new DslStoreError('invalid-name', 'DSL name must not contain control characters.');
99
+ }
100
+ return name;
101
+ }
102
+
103
+ function request<T>(input: IDBRequest<T>): Promise<T> {
104
+ return new Promise((resolve, reject) => {
105
+ input.onsuccess = () => resolve(input.result);
106
+ input.onerror = () => reject(toStoreError(input.error));
107
+ });
108
+ }
109
+
110
+ function transactionDone(transaction: IDBTransaction): Promise<void> {
111
+ return new Promise((resolve, reject) => {
112
+ transaction.oncomplete = () => resolve();
113
+ transaction.onabort = () => reject(toStoreError(transaction.error));
114
+ transaction.onerror = () => reject(toStoreError(transaction.error));
115
+ });
116
+ }
117
+
118
+ function toStoreError(cause: unknown): DslStoreError {
119
+ const name = (cause as {name?: string} | null)?.name;
120
+ if (name === 'QuotaExceededError') {
121
+ return new DslStoreError('quota', 'Browser storage is full. Delete a saved DSL file first.', cause);
122
+ }
123
+ if (name === 'ConstraintError') {
124
+ return new DslStoreError('name-taken', 'A DSL file with that name already exists.', cause);
125
+ }
126
+ return new DslStoreError('failed', 'The DSL storage operation failed.', cause);
127
+ }
128
+
129
+ function compareSummaries(sort: DslSort) {
130
+ const direction = sort.direction === 'asc' ? 1 : -1;
131
+ return (left: DslFileSummary, right: DslFileSummary): number => {
132
+ if (sort.field === 'name') {
133
+ return direction * left.name.localeCompare(right.name, undefined, {numeric: true});
134
+ }
135
+ if (sort.field === 'byteLength') {
136
+ return direction * (left.byteLength - right.byteLength) || left.name.localeCompare(right.name);
137
+ }
138
+ return direction * left.updatedAt.localeCompare(right.updatedAt) || left.name.localeCompare(right.name);
139
+ };
140
+ }
141
+
142
+ function toSummary(record: DslFileRecord): DslFileSummary {
143
+ return {
144
+ id: record.id,
145
+ name: record.name,
146
+ byteLength: record.byteLength,
147
+ savedAt: record.savedAt,
148
+ updatedAt: record.updatedAt
149
+ };
150
+ }
151
+
152
+ export function createDslStore(options: DslStoreOptions = {}): DslStore {
153
+ const factory = options.indexedDB ?? globalThis.indexedDB;
154
+ if (factory === undefined || typeof factory.open !== 'function') {
155
+ throw new DslStoreError('unavailable', 'IndexedDB is not available in this environment.');
156
+ }
157
+ const databaseName = options.databaseName ?? 'turbowarp-title-menu';
158
+ const maxSourceBytes = options.maxSourceBytes ?? 1024 * 1024;
159
+ const maxFileCount = options.maxFileCount ?? 64;
160
+ const now = options.now ?? (() => new Date());
161
+ const createId = options.createId ?? defaultCreateId;
162
+
163
+ let connection: Promise<IDBDatabase> | null = null;
164
+
165
+ function open(): Promise<IDBDatabase> {
166
+ connection ??= new Promise<IDBDatabase>((resolve, reject) => {
167
+ const opening = factory.open(databaseName, 1);
168
+ opening.onupgradeneeded = () => {
169
+ const database = opening.result;
170
+ if (!database.objectStoreNames.contains(fileStoreName)) {
171
+ const files = database.createObjectStore(fileStoreName, {keyPath: 'id'});
172
+ files.createIndex(nameIndexName, 'name', {unique: true});
173
+ }
174
+ if (!database.objectStoreNames.contains(metaStoreName)) {
175
+ database.createObjectStore(metaStoreName, {keyPath: 'key'});
176
+ }
177
+ };
178
+ opening.onsuccess = () => resolve(opening.result);
179
+ opening.onerror = () => reject(toStoreError(opening.error));
180
+ opening.onblocked = () =>
181
+ reject(new DslStoreError('failed', 'Another tab is upgrading the DSL database.'));
182
+ });
183
+ return connection;
184
+ }
185
+
186
+ async function readAll(): Promise<DslFileRecord[]> {
187
+ const database = await open();
188
+ const transaction = database.transaction(fileStoreName, 'readonly');
189
+ const records = await request<DslFileRecord[]>(
190
+ transaction.objectStore(fileStoreName).getAll() as IDBRequest<DslFileRecord[]>
191
+ );
192
+ await transactionDone(transaction);
193
+ return records;
194
+ }
195
+
196
+ async function readOne(id: string): Promise<DslFileRecord | null> {
197
+ const database = await open();
198
+ const transaction = database.transaction(fileStoreName, 'readonly');
199
+ const record = await request<DslFileRecord | undefined>(
200
+ transaction.objectStore(fileStoreName).get(id) as IDBRequest<DslFileRecord | undefined>
201
+ );
202
+ await transactionDone(transaction);
203
+ return record ?? null;
204
+ }
205
+
206
+ return Object.freeze({
207
+ databaseName,
208
+
209
+ async list(sort: DslSort = defaultDslSort) {
210
+ const records = await readAll();
211
+ return records.map(toSummary).sort(compareSummaries(sort));
212
+ },
213
+
214
+ async count() {
215
+ const database = await open();
216
+ const transaction = database.transaction(fileStoreName, 'readonly');
217
+ const total = await request(transaction.objectStore(fileStoreName).count());
218
+ await transactionDone(transaction);
219
+ return total;
220
+ },
221
+
222
+ get(id: string) {
223
+ return readOne(id);
224
+ },
225
+
226
+ async save(file: {name: string; source: string}) {
227
+ const name = normalizeName(file.name);
228
+ if (typeof file.source !== 'string') {
229
+ throw new DslStoreError('invalid-source', 'DSL source must be a string.');
230
+ }
231
+ const byteLength = encoder.encode(file.source).byteLength;
232
+ if (byteLength > maxSourceBytes) {
233
+ throw new DslStoreError('too-large', `DSL source exceeds ${maxSourceBytes} bytes.`);
234
+ }
235
+
236
+ const database = await open();
237
+ const transaction = database.transaction(fileStoreName, 'readwrite');
238
+ const files = transaction.objectStore(fileStoreName);
239
+ const existing = await request<DslFileRecord | undefined>(
240
+ files.index(nameIndexName).get(name) as IDBRequest<DslFileRecord | undefined>
241
+ );
242
+ if (existing === undefined) {
243
+ const total = await request(files.count());
244
+ if (total >= maxFileCount) {
245
+ transaction.abort();
246
+ throw new DslStoreError('too-many', `The DSL store already holds ${maxFileCount} files.`);
247
+ }
248
+ }
249
+ const timestamp = now().toISOString();
250
+ const record: DslFileRecord = {
251
+ id: existing?.id ?? createId(),
252
+ name,
253
+ source: file.source,
254
+ byteLength,
255
+ savedAt: existing?.savedAt ?? timestamp,
256
+ updatedAt: timestamp
257
+ };
258
+ await request(files.put(record));
259
+ await transactionDone(transaction);
260
+ return record;
261
+ },
262
+
263
+ async rename(id: string, nextName: string) {
264
+ const name = normalizeName(nextName);
265
+ const database = await open();
266
+ const transaction = database.transaction(fileStoreName, 'readwrite');
267
+ const files = transaction.objectStore(fileStoreName);
268
+ const existing = await request<DslFileRecord | undefined>(
269
+ files.get(id) as IDBRequest<DslFileRecord | undefined>
270
+ );
271
+ if (existing === undefined) {
272
+ transaction.abort();
273
+ throw new DslStoreError('not-found', `No DSL file with id ${id}.`);
274
+ }
275
+ if (existing.name !== name) {
276
+ const taken = await request<DslFileRecord | undefined>(
277
+ files.index(nameIndexName).get(name) as IDBRequest<DslFileRecord | undefined>
278
+ );
279
+ if (taken !== undefined) {
280
+ transaction.abort();
281
+ throw new DslStoreError('name-taken', `A DSL file named ${name} already exists.`);
282
+ }
283
+ }
284
+ const record: DslFileRecord = {...existing, name, updatedAt: now().toISOString()};
285
+ await request(files.put(record));
286
+ await transactionDone(transaction);
287
+ return record;
288
+ },
289
+
290
+ async remove(id: string) {
291
+ const database = await open();
292
+ const transaction = database.transaction([fileStoreName, metaStoreName], 'readwrite');
293
+ await request(transaction.objectStore(fileStoreName).delete(id));
294
+ const meta = transaction.objectStore(metaStoreName);
295
+ const pointer = await request<{key: string; id: string} | undefined>(
296
+ meta.get(lastOpenedKey) as IDBRequest<{key: string; id: string} | undefined>
297
+ );
298
+ if (pointer?.id === id) await request(meta.delete(lastOpenedKey));
299
+ await transactionDone(transaction);
300
+ },
301
+
302
+ async clear() {
303
+ const database = await open();
304
+ const transaction = database.transaction([fileStoreName, metaStoreName], 'readwrite');
305
+ await request(transaction.objectStore(fileStoreName).clear());
306
+ await request(transaction.objectStore(metaStoreName).clear());
307
+ await transactionDone(transaction);
308
+ },
309
+
310
+ async lastOpened() {
311
+ const database = await open();
312
+ const transaction = database.transaction(metaStoreName, 'readonly');
313
+ const pointer = await request<{key: string; id: string} | undefined>(
314
+ transaction.objectStore(metaStoreName).get(lastOpenedKey) as IDBRequest<
315
+ {key: string; id: string} | undefined
316
+ >
317
+ );
318
+ await transactionDone(transaction);
319
+ if (pointer === undefined) return null;
320
+ return readOne(pointer.id);
321
+ },
322
+
323
+ async markOpened(id: string) {
324
+ const record = await readOne(id);
325
+ if (record === null) throw new DslStoreError('not-found', `No DSL file with id ${id}.`);
326
+ const database = await open();
327
+ const transaction = database.transaction(metaStoreName, 'readwrite');
328
+ await request(transaction.objectStore(metaStoreName).put({key: lastOpenedKey, id}));
329
+ await transactionDone(transaction);
330
+ },
331
+
332
+ close() {
333
+ const pending = connection;
334
+ connection = null;
335
+ void pending?.then((database) => database.close()).catch(() => undefined);
336
+ }
337
+ });
338
+ }
339
+
340
+ function defaultCreateId(): string {
341
+ const crypto = globalThis.crypto;
342
+ if (typeof crypto?.randomUUID === 'function') return crypto.randomUUID();
343
+ return `dsl-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
344
+ }
345
+
346
+ /** Reads a picked browser file into a record shape the store accepts. */
347
+ export async function readDslFile(
348
+ file: File,
349
+ maxSourceBytes = 1024 * 1024
350
+ ): Promise<{name: string; source: string}> {
351
+ if (typeof file?.text !== 'function') {
352
+ throw new DslStoreError('invalid-source', 'file must be a browser File.');
353
+ }
354
+ if (file.size > maxSourceBytes) {
355
+ throw new DslStoreError('too-large', `DSL file exceeds ${maxSourceBytes} bytes.`);
356
+ }
357
+ return {name: file.name, source: await file.text()};
358
+ }
package/src/events.ts ADDED
@@ -0,0 +1,30 @@
1
+ import type {DslFileRecord} from './dsl-store';
2
+
3
+ export const dslOpenEventName = 'turbowarp-title-menu:dsl-open';
4
+ export const dslReloadEventName = 'turbowarp-title-menu:dsl-reload';
5
+
6
+ export interface DslSourceEventDetail {
7
+ record: DslFileRecord;
8
+ }
9
+
10
+ /**
11
+ * Announces an opened DSL source on the window.
12
+ *
13
+ * The extension also starts a Scratch hat, but a packaged host that embeds this extension may run
14
+ * its own runtime outside the VM, so the DOM event stays the transport that assumes no Scratch.
15
+ */
16
+ export function dispatchDslSourceEvent(type: string, record: DslFileRecord): void {
17
+ const target = globalThis as typeof globalThis & {
18
+ dispatchEvent?: (event: Event) => boolean;
19
+ };
20
+ if (typeof target.dispatchEvent !== 'function') return;
21
+
22
+ const event =
23
+ typeof CustomEvent === 'function'
24
+ ? new CustomEvent<DslSourceEventDetail>(type, {detail: {record}})
25
+ : new Event(type);
26
+ if (!('detail' in event)) {
27
+ Object.defineProperty(event, 'detail', {value: {record}});
28
+ }
29
+ target.dispatchEvent(event);
30
+ }
@@ -0,0 +1,149 @@
1
+ import type {Plugin} from 'vite';
2
+
3
+ export const EXTENSION_MANIFEST_FORMAT_VERSION = 1 as const;
4
+
5
+ export interface ExtensionManifestArgument {
6
+ id: string;
7
+ type: string;
8
+ menu?: string;
9
+ }
10
+
11
+ export interface ExtensionManifestBlock {
12
+ opcode: string;
13
+ blockType: string;
14
+ arguments: ExtensionManifestArgument[];
15
+ }
16
+
17
+ export interface ExtensionManifestMenu {
18
+ id: string;
19
+ acceptReporters: boolean;
20
+ }
21
+
22
+ export interface ExtensionManifest {
23
+ formatVersion: typeof EXTENSION_MANIFEST_FORMAT_VERSION;
24
+ id: string;
25
+ blocks: ExtensionManifestBlock[];
26
+ menus: ExtensionManifestMenu[];
27
+ }
28
+
29
+ export interface ExtensionManifestPluginOptions {
30
+ id: string;
31
+ definitions: unknown;
32
+ fileName?: string;
33
+ }
34
+
35
+ export function createExtensionManifest(id: string, definitions: unknown): ExtensionManifest {
36
+ if (!/^[a-z0-9]+$/.test(id)) {
37
+ throw new TypeError('Extension manifest ID must contain only lowercase letters and numbers.');
38
+ }
39
+
40
+ const source = requireRecord(definitions, 'Block definitions');
41
+ const sourceBlocks = source.blocks;
42
+ if (!Array.isArray(sourceBlocks)) {
43
+ throw new TypeError('Block definitions must contain a blocks array.');
44
+ }
45
+
46
+ const menus = normalizeMenus(source.menus);
47
+ const menuIds = new Set(menus.map((menu) => menu.id));
48
+ const seenOpcodes = new Set<string>();
49
+ const blocks = sourceBlocks.map((block, index) => {
50
+ const normalized = normalizeBlock(block, index, menuIds);
51
+ if (seenOpcodes.has(normalized.opcode)) {
52
+ throw new TypeError(`Duplicate block opcode: ${normalized.opcode}`);
53
+ }
54
+ seenOpcodes.add(normalized.opcode);
55
+ return normalized;
56
+ });
57
+
58
+ return {
59
+ formatVersion: EXTENSION_MANIFEST_FORMAT_VERSION,
60
+ id,
61
+ blocks: blocks.sort((left, right) => compareIds(left.opcode, right.opcode)),
62
+ menus
63
+ };
64
+ }
65
+
66
+ export function serializeExtensionManifest(id: string, definitions: unknown): string {
67
+ return `${JSON.stringify(createExtensionManifest(id, definitions), null, 2)}\n`;
68
+ }
69
+
70
+ export function extensionManifestPlugin(options: ExtensionManifestPluginOptions): Plugin {
71
+ return {
72
+ name: 'extension-api-manifest',
73
+ apply: 'build',
74
+ enforce: 'post',
75
+ generateBundle() {
76
+ this.emitFile({
77
+ type: 'asset',
78
+ fileName: options.fileName ?? 'extension-manifest.json',
79
+ source: serializeExtensionManifest(options.id, options.definitions)
80
+ });
81
+ }
82
+ };
83
+ }
84
+
85
+ function normalizeBlock(
86
+ value: unknown,
87
+ index: number,
88
+ menuIds: ReadonlySet<string>
89
+ ): ExtensionManifestBlock {
90
+ const block = requireRecord(value, `Block at index ${index}`);
91
+ const opcode = requireNonEmptyString(block.opcode, `Block at index ${index} opcode`);
92
+ const blockType = requireNonEmptyString(block.blockType, `Block ${opcode} blockType`);
93
+ const sourceArguments = block.arguments ?? {};
94
+ const argumentRecord = requireRecord(sourceArguments, `Block ${opcode} arguments`);
95
+ const argumentsList = Object.entries(argumentRecord).map(([argumentId, argument]) => {
96
+ requireNonEmptyString(argumentId, `Block ${opcode} argument ID`);
97
+ const definition = requireRecord(argument, `Block ${opcode} argument ${argumentId}`);
98
+ const type = requireNonEmptyString(
99
+ definition.type,
100
+ `Block ${opcode} argument ${argumentId} type`
101
+ );
102
+ const menu = definition.menu;
103
+ if (menu !== undefined && (typeof menu !== 'string' || !menuIds.has(menu))) {
104
+ throw new TypeError(`Block ${opcode} argument ${argumentId} references unknown menu: ${menu}`);
105
+ }
106
+ return menu === undefined ? {id: argumentId, type} : {id: argumentId, type, menu};
107
+ });
108
+
109
+ return {
110
+ opcode,
111
+ blockType,
112
+ arguments: argumentsList.sort((left, right) => compareIds(left.id, right.id))
113
+ };
114
+ }
115
+
116
+ function normalizeMenus(value: unknown): ExtensionManifestMenu[] {
117
+ const menuRecord = requireRecord(value ?? {}, 'Block definition menus');
118
+ return Object.entries(menuRecord)
119
+ .map(([id, menu]) => {
120
+ requireNonEmptyString(id, 'Menu ID');
121
+ const definition = requireRecord(menu, `Menu ${id}`);
122
+ const acceptReporters = definition.acceptReporters ?? false;
123
+ if (typeof acceptReporters !== 'boolean') {
124
+ throw new TypeError(`Menu ${id} acceptReporters must be a boolean.`);
125
+ }
126
+ return {id, acceptReporters};
127
+ })
128
+ .sort((left, right) => compareIds(left.id, right.id));
129
+ }
130
+
131
+ function requireRecord(value: unknown, label: string): Record<string, unknown> {
132
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
133
+ throw new TypeError(`${label} must be an object.`);
134
+ }
135
+ return value as Record<string, unknown>;
136
+ }
137
+
138
+ function requireNonEmptyString(value: unknown, label: string): string {
139
+ if (typeof value !== 'string' || value.length === 0) {
140
+ throw new TypeError(`${label} must be a non-empty string.`);
141
+ }
142
+ return value;
143
+ }
144
+
145
+ function compareIds(left: string, right: string): number {
146
+ if (left < right) return -1;
147
+ if (left > right) return 1;
148
+ return 0;
149
+ }