@scenoco-three/tools 0.0.0-stage → 0.7.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 +21 -0
- package/README.md +18 -2
- package/bin/scenoco.js +4 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +23 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/scene-edit.d.ts +54 -0
- package/dist/scene-edit.js +281 -0
- package/dist/scenes.d.ts +3 -0
- package/dist/scenes.js +188 -0
- package/dist/vite.d.ts +22 -0
- package/dist/vite.js +143 -0
- package/package.json +61 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SceNoCo contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @scenoco-three/tools
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
What works on SceNoCo scene files rather than in the game.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install -D @scenoco-three/tools
|
|
7
|
+
scenoco check src # every .tsx file is a scene, and a scene only declares
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
A scene may import, define constants of plain data, and export functions that make refs and return
|
|
11
|
+
TSX. Inside the TSX a value is a literal, a list or object of them, arithmetic on them
|
|
12
|
+
(`-Math.PI / 2`), or a name. Anything that runs — a loop, a branch, a handler, a call, a hook — is a
|
|
13
|
+
behaviour's job, in a `.ts` file, and each refusal says where and what to write instead. Types are
|
|
14
|
+
`tsc`'s job; a project's `check` script runs both: `tsc --noEmit && scenoco check src`.
|
|
15
|
+
|
|
16
|
+
The rule exists so a tool can edit a scene. `readScene`, `setAttribute`, `insertChild`,
|
|
17
|
+
`removeElement` and friends read a scene file without running it and write by splicing exactly the
|
|
18
|
+
text that changed — everything else, comments and layout included, stays byte for byte. Elements are
|
|
19
|
+
found by the line and column the runtime's `sourceOf` reports, so a picked object leads to its tag.
|
package/bin/scenoco.js
ADDED
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `scenoco check [dir...]` — every `.tsx` file under the given folders (the current one by
|
|
4
|
+
* default) is a scene, and a scene only declares. Types are `tsc`'s job; this is the other half.
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { relative } from 'node:path';
|
|
8
|
+
import process from 'node:process';
|
|
9
|
+
import { checkScene, tsxFiles } from './scenes.js';
|
|
10
|
+
const [command, ...dirs] = process.argv.slice(2);
|
|
11
|
+
if (command !== 'check') {
|
|
12
|
+
console.error('usage: scenoco check [dir...] — every .tsx file is a scene, and a scene only declares');
|
|
13
|
+
process.exit(command === undefined || command === '-h' || command === '--help' ? 0 : 1);
|
|
14
|
+
}
|
|
15
|
+
const problems = (dirs.length ? dirs : ['.'])
|
|
16
|
+
.flatMap((dir) => [...tsxFiles(dir)])
|
|
17
|
+
.flatMap((file) => checkScene(relative(process.cwd(), file), readFileSync(file, 'utf8')));
|
|
18
|
+
for (const problem of problems)
|
|
19
|
+
console.error(problem);
|
|
20
|
+
if (problems.length) {
|
|
21
|
+
console.error(`\n${problems.length} problem(s). A .tsx file is a scene: it declares, and behaviours (.ts) do the rest.`);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@scenoco-three/tools` — what works on scene files rather than in the game: the rule that a
|
|
3
|
+
* `.tsx` file only declares, and the editor's way to read and edit one without losing a byte of
|
|
4
|
+
* what was not touched.
|
|
5
|
+
*/
|
|
6
|
+
export { checkScene, tsxFiles } from './scenes.js';
|
|
7
|
+
export * from './scene-edit.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@scenoco-three/tools` — what works on scene files rather than in the game: the rule that a
|
|
3
|
+
* `.tsx` file only declares, and the editor's way to read and edit one without losing a byte of
|
|
4
|
+
* what was not touched.
|
|
5
|
+
*/
|
|
6
|
+
export { checkScene, tsxFiles } from './scenes.js';
|
|
7
|
+
export * from './scene-edit.js';
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** What an attribute holds, as far as reading it without running anything can tell. */
|
|
2
|
+
export type Value = {
|
|
3
|
+
kind: 'value';
|
|
4
|
+
value: unknown;
|
|
5
|
+
} | {
|
|
6
|
+
kind: 'reference';
|
|
7
|
+
text: string;
|
|
8
|
+
};
|
|
9
|
+
export interface Attribute {
|
|
10
|
+
name: string;
|
|
11
|
+
/** The whole attribute, `name={…}`, in the file. */
|
|
12
|
+
start: number;
|
|
13
|
+
end: number;
|
|
14
|
+
/** Its value's text as written, or null for a bare flag (`castShadow`). */
|
|
15
|
+
text: string | null;
|
|
16
|
+
value: Value;
|
|
17
|
+
}
|
|
18
|
+
export interface Element {
|
|
19
|
+
tag: string;
|
|
20
|
+
/** 1-based, at the `<` — what `jsxDEV`'s source says. */
|
|
21
|
+
line: number;
|
|
22
|
+
column: number;
|
|
23
|
+
start: number;
|
|
24
|
+
end: number;
|
|
25
|
+
/** Where a new attribute goes: just before `>` or `/>` of the opening tag. */
|
|
26
|
+
attributesEnd: number;
|
|
27
|
+
selfClosing: boolean;
|
|
28
|
+
/** Start of `</Tag>`, where a new child goes; the element's end when self-closing. */
|
|
29
|
+
closingStart: number;
|
|
30
|
+
attributes: Attribute[];
|
|
31
|
+
children: Element[];
|
|
32
|
+
}
|
|
33
|
+
export interface SceneDocument {
|
|
34
|
+
code: string;
|
|
35
|
+
/** Elements not inside another element: what each scene or prefab function returns. */
|
|
36
|
+
roots: Element[];
|
|
37
|
+
}
|
|
38
|
+
export declare function readScene(file: string, code: string): SceneDocument;
|
|
39
|
+
/** Every element of the document, depth first. */
|
|
40
|
+
export declare function elements(doc: SceneDocument): Element[];
|
|
41
|
+
/** The element written at this line and column — `sourceOf(object)` of what it made. */
|
|
42
|
+
export declare function elementAt(doc: SceneDocument, line: number, column: number): Element | undefined;
|
|
43
|
+
/** Set an attribute to a value, replacing what was written or adding it at the end of the tag. */
|
|
44
|
+
export declare function setAttribute(code: string, element: Element, name: string, value: unknown): string;
|
|
45
|
+
/** Set an attribute to text as written — `{-Math.PI / 2}`, `"red"` — or to a bare flag (null). */
|
|
46
|
+
export declare function setAttributeText(code: string, element: Element, name: string, text: string | null): string;
|
|
47
|
+
/** Remove an attribute, with the space before it. */
|
|
48
|
+
export declare function removeAttribute(code: string, element: Element, name: string): string;
|
|
49
|
+
/** Remove an element; the whole line goes with it when it stood alone there. */
|
|
50
|
+
export declare function removeElement(code: string, element: Element): string;
|
|
51
|
+
/** Add a child at the end of an element, indented like its siblings. */
|
|
52
|
+
export declare function insertChild(code: string, parent: Element, tsx: string): string;
|
|
53
|
+
/** How a value is written as an attribute: `"…"` for a string, `{…}` for everything else. */
|
|
54
|
+
export declare function attributeText(value: unknown): string;
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edit a TSX scene the way a person in an editor does — move a node, recolour a material, add or
|
|
3
|
+
* remove an element — by splicing the text of exactly what changed. Everything else in the file,
|
|
4
|
+
* comments and layout included, stays byte for byte.
|
|
5
|
+
*
|
|
6
|
+
* Reading is generous: a value is shown when it can be worked out without running anything —
|
|
7
|
+
* literals, arithmetic, `Math.PI`, a constant of this file — and as a named reference otherwise
|
|
8
|
+
* (`paint.grass`, a ref, a prefab). Writing is plain: the new value replaces the old text as a
|
|
9
|
+
* literal, so a dragged `rotation={[-Math.PI / 2, 0, 0]}` comes back as numbers. That loss is the
|
|
10
|
+
* price of a person's edit, and only of the attribute they touched.
|
|
11
|
+
*
|
|
12
|
+
* Elements are found by where they were written — the same line and column `jsxDEV` gives the
|
|
13
|
+
* runtime (see `sourceOf`), so a picked object leads straight to its tag.
|
|
14
|
+
*/
|
|
15
|
+
import { parseSync } from 'rolldown/utils';
|
|
16
|
+
export function readScene(file, code) {
|
|
17
|
+
const { program, errors } = parseSync(file, code, { lang: 'tsx' });
|
|
18
|
+
if (errors.length)
|
|
19
|
+
throw new Error(`${file}: ${errors[0].message}`);
|
|
20
|
+
const lineStarts = [0];
|
|
21
|
+
for (let i = 0; i < code.length; i++)
|
|
22
|
+
if (code[i] === '\n')
|
|
23
|
+
lineStarts.push(i + 1);
|
|
24
|
+
const position = (offset) => {
|
|
25
|
+
let lo = 0;
|
|
26
|
+
let hi = lineStarts.length - 1;
|
|
27
|
+
while (lo < hi) {
|
|
28
|
+
const mid = (lo + hi + 1) >> 1;
|
|
29
|
+
if (lineStarts[mid] <= offset)
|
|
30
|
+
lo = mid;
|
|
31
|
+
else
|
|
32
|
+
hi = mid - 1;
|
|
33
|
+
}
|
|
34
|
+
return { line: lo + 1, column: offset - lineStarts[lo] + 1 };
|
|
35
|
+
};
|
|
36
|
+
const constants = new Map();
|
|
37
|
+
const evaluate = (node) => {
|
|
38
|
+
const seen = staticValue(node, constants);
|
|
39
|
+
return seen === UNKNOWN ? { kind: 'reference', text: code.slice(node.start, node.end) } : { kind: 'value', value: seen };
|
|
40
|
+
};
|
|
41
|
+
// Constants of this file whose values can be worked out, so `position={[LANE, 0, 0]}` reads as numbers.
|
|
42
|
+
for (const statement of program.body) {
|
|
43
|
+
const declaration = (statement.type === 'ExportNamedDeclaration' ? statement.declaration : statement);
|
|
44
|
+
if (declaration?.type !== 'VariableDeclaration' || declaration.kind !== 'const')
|
|
45
|
+
continue;
|
|
46
|
+
for (const d of declaration.declarations) {
|
|
47
|
+
const id = d.id;
|
|
48
|
+
const init = d.init;
|
|
49
|
+
if (id.type === 'Identifier' && init) {
|
|
50
|
+
const value = staticValue(init, constants);
|
|
51
|
+
if (value !== UNKNOWN)
|
|
52
|
+
constants.set(id.name, value);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const element = (node) => {
|
|
57
|
+
const opening = node.openingElement;
|
|
58
|
+
const selfClosing = opening.selfClosing === true;
|
|
59
|
+
const attributes = [];
|
|
60
|
+
for (const attr of opening.attributes) {
|
|
61
|
+
if (attr.type !== 'JSXAttribute')
|
|
62
|
+
continue;
|
|
63
|
+
const name = code.slice(attr.name.start, attr.name.end);
|
|
64
|
+
const raw = attr.value;
|
|
65
|
+
if (!raw) {
|
|
66
|
+
attributes.push({ name, start: attr.start, end: attr.end, text: null, value: { kind: 'value', value: true } });
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const inner = raw.type === 'JSXExpressionContainer' ? raw.expression : raw;
|
|
70
|
+
attributes.push({ name, start: attr.start, end: attr.end, text: code.slice(raw.start, raw.end), value: evaluate(inner) });
|
|
71
|
+
}
|
|
72
|
+
const children = [];
|
|
73
|
+
for (const child of node.children) {
|
|
74
|
+
if (child.type === 'JSXElement')
|
|
75
|
+
children.push(element(child));
|
|
76
|
+
}
|
|
77
|
+
const closing = node.closingElement;
|
|
78
|
+
const at = position(node.start);
|
|
79
|
+
return {
|
|
80
|
+
tag: code.slice(opening.name.start, opening.name.end),
|
|
81
|
+
line: at.line,
|
|
82
|
+
column: at.column,
|
|
83
|
+
start: node.start,
|
|
84
|
+
end: node.end,
|
|
85
|
+
attributesEnd: code.lastIndexOf(selfClosing ? '/>' : '>', opening.end - 1),
|
|
86
|
+
selfClosing,
|
|
87
|
+
closingStart: closing ? closing.start : node.end,
|
|
88
|
+
attributes,
|
|
89
|
+
children,
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
const roots = [];
|
|
93
|
+
const visit = (node) => {
|
|
94
|
+
if (!node || typeof node !== 'object')
|
|
95
|
+
return;
|
|
96
|
+
if (Array.isArray(node))
|
|
97
|
+
return node.forEach(visit);
|
|
98
|
+
const n = node;
|
|
99
|
+
if (n.type === 'JSXElement')
|
|
100
|
+
return void roots.push(element(n));
|
|
101
|
+
for (const [key, value] of Object.entries(n))
|
|
102
|
+
if (key !== 'start' && key !== 'end')
|
|
103
|
+
visit(value);
|
|
104
|
+
};
|
|
105
|
+
visit(program);
|
|
106
|
+
return { code, roots };
|
|
107
|
+
}
|
|
108
|
+
/** Every element of the document, depth first. */
|
|
109
|
+
export function elements(doc) {
|
|
110
|
+
const out = [];
|
|
111
|
+
const walk = (e) => {
|
|
112
|
+
out.push(e);
|
|
113
|
+
e.children.forEach(walk);
|
|
114
|
+
};
|
|
115
|
+
doc.roots.forEach(walk);
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
/** The element written at this line and column — `sourceOf(object)` of what it made. */
|
|
119
|
+
export function elementAt(doc, line, column) {
|
|
120
|
+
return elements(doc).find((e) => e.line === line && e.column === column);
|
|
121
|
+
}
|
|
122
|
+
/** Set an attribute to a value, replacing what was written or adding it at the end of the tag. */
|
|
123
|
+
export function setAttribute(code, element, name, value) {
|
|
124
|
+
const existing = element.attributes.find((a) => a.name === name);
|
|
125
|
+
return setAttributeText(code, element, name, value === true && existing?.text === null ? null : attributeText(value));
|
|
126
|
+
}
|
|
127
|
+
/** Set an attribute to text as written — `{-Math.PI / 2}`, `"red"` — or to a bare flag (null). */
|
|
128
|
+
export function setAttributeText(code, element, name, text) {
|
|
129
|
+
const existing = element.attributes.find((a) => a.name === name);
|
|
130
|
+
const written = text === null ? name : `${name}=${text}`;
|
|
131
|
+
if (existing)
|
|
132
|
+
return splice(code, existing.start, existing.end, written);
|
|
133
|
+
const last = element.attributes.at(-1);
|
|
134
|
+
if (last && code.slice(last.end, element.attributesEnd).includes('\n')) {
|
|
135
|
+
// One attribute per line: a new line after the last one, indented like it.
|
|
136
|
+
const lineStart = code.lastIndexOf('\n', last.start - 1) + 1;
|
|
137
|
+
const indent = /^\s*/.exec(code.slice(lineStart, last.start))[0];
|
|
138
|
+
return splice(code, last.end, last.end, `\n${indent}${written}`);
|
|
139
|
+
}
|
|
140
|
+
// On the tag's line: `<Group>` → `<Group a={1}>`, `<Mesh b />` → `<Mesh b a={1} />`.
|
|
141
|
+
const spaced = /\s$/.test(code.slice(0, element.attributesEnd));
|
|
142
|
+
return splice(code, element.attributesEnd, element.attributesEnd, spaced ? `${written} ` : ` ${written}`);
|
|
143
|
+
}
|
|
144
|
+
/** Remove an attribute, with the space before it. */
|
|
145
|
+
export function removeAttribute(code, element, name) {
|
|
146
|
+
const existing = element.attributes.find((a) => a.name === name);
|
|
147
|
+
if (!existing)
|
|
148
|
+
return code;
|
|
149
|
+
let start = existing.start;
|
|
150
|
+
while (start > 0 && /[ \t]/.test(code[start - 1]))
|
|
151
|
+
start--;
|
|
152
|
+
if (code[start - 1] === '\n') {
|
|
153
|
+
start = existing.start;
|
|
154
|
+
while (start > 0 && code[start - 1] !== '\n')
|
|
155
|
+
start--;
|
|
156
|
+
return splice(code, start - 1, existing.end, '');
|
|
157
|
+
}
|
|
158
|
+
return splice(code, start, existing.end, '');
|
|
159
|
+
}
|
|
160
|
+
/** Remove an element; the whole line goes with it when it stood alone there. */
|
|
161
|
+
export function removeElement(code, element) {
|
|
162
|
+
let start = element.start;
|
|
163
|
+
let end = element.end;
|
|
164
|
+
const lineStart = code.lastIndexOf('\n', start - 1) + 1;
|
|
165
|
+
const lineEnd = code.indexOf('\n', end);
|
|
166
|
+
if (/^\s*$/.test(code.slice(lineStart, start)) && /^\s*$/.test(code.slice(end, lineEnd === -1 ? code.length : lineEnd))) {
|
|
167
|
+
start = lineStart;
|
|
168
|
+
end = lineEnd === -1 ? code.length : lineEnd + 1;
|
|
169
|
+
}
|
|
170
|
+
return splice(code, start, end, '');
|
|
171
|
+
}
|
|
172
|
+
/** Add a child at the end of an element, indented like its siblings. */
|
|
173
|
+
export function insertChild(code, parent, tsx) {
|
|
174
|
+
const lineStart = code.lastIndexOf('\n', parent.start - 1) + 1;
|
|
175
|
+
const indent = /^\s*/.exec(code.slice(lineStart, parent.start))[0];
|
|
176
|
+
const inner = `${indent} `;
|
|
177
|
+
if (parent.selfClosing) {
|
|
178
|
+
const open = code.slice(parent.start, parent.attributesEnd).replace(/\s+$/, '');
|
|
179
|
+
return splice(code, parent.start, parent.end, `${open}>\n${inner}${tsx}\n${indent}</${parent.tag}>`);
|
|
180
|
+
}
|
|
181
|
+
const closingLineStart = code.lastIndexOf('\n', parent.closingStart - 1) + 1;
|
|
182
|
+
const onOwnLine = /^\s*$/.test(code.slice(closingLineStart, parent.closingStart));
|
|
183
|
+
return onOwnLine
|
|
184
|
+
? splice(code, closingLineStart, closingLineStart, `${inner}${tsx}\n`)
|
|
185
|
+
: splice(code, parent.closingStart, parent.closingStart, `\n${inner}${tsx}\n${indent}`);
|
|
186
|
+
}
|
|
187
|
+
/** How a value is written as an attribute: `"…"` for a string, `{…}` for everything else. */
|
|
188
|
+
export function attributeText(value) {
|
|
189
|
+
return typeof value === 'string' ? JSON.stringify(value) : `{${expressionText(value)}}`;
|
|
190
|
+
}
|
|
191
|
+
function expressionText(value) {
|
|
192
|
+
if (typeof value === 'number')
|
|
193
|
+
return numberText(value);
|
|
194
|
+
if (typeof value === 'string' || typeof value === 'boolean' || value === null)
|
|
195
|
+
return JSON.stringify(value);
|
|
196
|
+
if (Array.isArray(value))
|
|
197
|
+
return `[${value.map(expressionText).join(', ')}]`;
|
|
198
|
+
if (value && typeof value === 'object') {
|
|
199
|
+
const entries = Object.entries(value).map(([k, v]) => `${/^[A-Za-z_$][\w$]*$/.test(k) ? k : JSON.stringify(k)}: ${expressionText(v)}`);
|
|
200
|
+
return `{ ${entries.join(', ')} }`;
|
|
201
|
+
}
|
|
202
|
+
throw new Error(`cannot write ${String(value)} into a scene`);
|
|
203
|
+
}
|
|
204
|
+
/** Four decimals at most — an editor's drag does not need more — and no `-0`. */
|
|
205
|
+
function numberText(n) {
|
|
206
|
+
const rounded = Math.round(n * 1e4) / 1e4;
|
|
207
|
+
return String(Object.is(rounded, -0) ? 0 : rounded);
|
|
208
|
+
}
|
|
209
|
+
function splice(code, start, end, text) {
|
|
210
|
+
return code.slice(0, start) + text + code.slice(end);
|
|
211
|
+
}
|
|
212
|
+
const UNKNOWN = Symbol('unknown');
|
|
213
|
+
/** A value worked out without running anything, or UNKNOWN. */
|
|
214
|
+
function staticValue(node, constants) {
|
|
215
|
+
switch (node.type) {
|
|
216
|
+
case 'Literal':
|
|
217
|
+
return node.value;
|
|
218
|
+
case 'TemplateLiteral':
|
|
219
|
+
return node.expressions.length ? UNKNOWN : node.quasis[0].value.cooked;
|
|
220
|
+
case 'ParenthesizedExpression':
|
|
221
|
+
case 'TSAsExpression':
|
|
222
|
+
case 'TSNonNullExpression':
|
|
223
|
+
return staticValue(node.expression, constants);
|
|
224
|
+
case 'Identifier':
|
|
225
|
+
return constants.has(node.name) ? constants.get(node.name) : UNKNOWN;
|
|
226
|
+
case 'UnaryExpression': {
|
|
227
|
+
const v = staticValue(node.argument, constants);
|
|
228
|
+
if (typeof v !== 'number')
|
|
229
|
+
return UNKNOWN;
|
|
230
|
+
return node.operator === '-' ? -v : node.operator === '+' ? v : UNKNOWN;
|
|
231
|
+
}
|
|
232
|
+
case 'BinaryExpression': {
|
|
233
|
+
const a = staticValue(node.left, constants);
|
|
234
|
+
const b = staticValue(node.right, constants);
|
|
235
|
+
if (typeof a !== 'number' || typeof b !== 'number')
|
|
236
|
+
return UNKNOWN;
|
|
237
|
+
switch (node.operator) {
|
|
238
|
+
case '+': return a + b;
|
|
239
|
+
case '-': return a - b;
|
|
240
|
+
case '*': return a * b;
|
|
241
|
+
case '/': return a / b;
|
|
242
|
+
case '%': return a % b;
|
|
243
|
+
default: return UNKNOWN;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
case 'MemberExpression': {
|
|
247
|
+
const object = node.object;
|
|
248
|
+
const property = node.property;
|
|
249
|
+
if (!node.computed && object.type === 'Identifier' && object.name === 'Math' && property.type === 'Identifier') {
|
|
250
|
+
const v = Math[property.name];
|
|
251
|
+
return typeof v === 'number' ? v : UNKNOWN;
|
|
252
|
+
}
|
|
253
|
+
return UNKNOWN;
|
|
254
|
+
}
|
|
255
|
+
case 'ArrayExpression': {
|
|
256
|
+
const out = [];
|
|
257
|
+
for (const item of node.elements) {
|
|
258
|
+
const v = item ? staticValue(item, constants) : UNKNOWN;
|
|
259
|
+
if (v === UNKNOWN)
|
|
260
|
+
return UNKNOWN;
|
|
261
|
+
out.push(v);
|
|
262
|
+
}
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
case 'ObjectExpression': {
|
|
266
|
+
const out = {};
|
|
267
|
+
for (const prop of node.properties) {
|
|
268
|
+
if (prop.type !== 'Property' || prop.computed)
|
|
269
|
+
return UNKNOWN;
|
|
270
|
+
const key = prop.key;
|
|
271
|
+
const v = staticValue(prop.value, constants);
|
|
272
|
+
if (v === UNKNOWN)
|
|
273
|
+
return UNKNOWN;
|
|
274
|
+
out[(key.name ?? key.value)] = v;
|
|
275
|
+
}
|
|
276
|
+
return out;
|
|
277
|
+
}
|
|
278
|
+
default:
|
|
279
|
+
return UNKNOWN;
|
|
280
|
+
}
|
|
281
|
+
}
|
package/dist/scenes.d.ts
ADDED
package/dist/scenes.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every `.tsx` file is a scene, and a scene only declares.
|
|
3
|
+
*
|
|
4
|
+
* A scene file may import, define constants of plain data, and export functions that make refs and
|
|
5
|
+
* return TSX. Inside the TSX a value is a literal, a list or object of them, arithmetic on them
|
|
6
|
+
* (`-Math.PI / 2`), or a name. Anything that runs — a loop, a branch, a handler, a call, a hook — is
|
|
7
|
+
* a component's job, in a `.ts` file. Each refusal says where and what to write instead.
|
|
8
|
+
*/
|
|
9
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { parseSync } from 'rolldown/utils';
|
|
12
|
+
const LOOP = 'a scene has no loops — to place many, spawn them from a component with instantiate(Prefab)';
|
|
13
|
+
const BRANCH = 'a scene has no branches — show or hide from a component (node.visible, or destroy)';
|
|
14
|
+
const HANDLER = 'a scene has no handlers — what happens goes in a component: a class in a .ts file, with update()';
|
|
15
|
+
const HOOK = 'there is no React here — TSX builds three.js objects once; what runs each frame is a component\'s update()';
|
|
16
|
+
const CALL = 'a scene calls nothing — a value is a literal, a list or object of them, arithmetic like -Math.PI / 2, or a name';
|
|
17
|
+
export function checkScene(file, code) {
|
|
18
|
+
const problems = [];
|
|
19
|
+
const at = (node, message) => {
|
|
20
|
+
const before = code.slice(0, node.start).split('\n');
|
|
21
|
+
problems.push(`${file}:${before.length}:${before.at(-1).length + 1} ${message}`);
|
|
22
|
+
};
|
|
23
|
+
const { program, errors } = parseSync(file, code, { lang: 'tsx' });
|
|
24
|
+
if (errors.length)
|
|
25
|
+
return [`${file} ${errors[0].message}`];
|
|
26
|
+
/** A value that is data: literals, lists and objects of them, arithmetic on them, names and their members. */
|
|
27
|
+
const value = (node) => {
|
|
28
|
+
if (!node)
|
|
29
|
+
return;
|
|
30
|
+
switch (node.type) {
|
|
31
|
+
case 'Literal':
|
|
32
|
+
case 'Identifier':
|
|
33
|
+
case 'JSXEmptyExpression':
|
|
34
|
+
return;
|
|
35
|
+
case 'TemplateLiteral':
|
|
36
|
+
if (node.expressions.length)
|
|
37
|
+
at(node, CALL);
|
|
38
|
+
return;
|
|
39
|
+
case 'ParenthesizedExpression':
|
|
40
|
+
case 'TSAsExpression':
|
|
41
|
+
case 'TSNonNullExpression':
|
|
42
|
+
return value(node.expression);
|
|
43
|
+
case 'UnaryExpression':
|
|
44
|
+
return ['-', '+'].includes(node.operator) ? value(node.argument) : at(node, CALL);
|
|
45
|
+
case 'BinaryExpression':
|
|
46
|
+
if (!['+', '-', '*', '/', '%'].includes(node.operator))
|
|
47
|
+
return at(node, BRANCH);
|
|
48
|
+
value(node.left);
|
|
49
|
+
return value(node.right);
|
|
50
|
+
case 'MemberExpression':
|
|
51
|
+
if (node.computed)
|
|
52
|
+
return at(node, CALL);
|
|
53
|
+
return value(node.object);
|
|
54
|
+
case 'ArrayExpression':
|
|
55
|
+
for (const item of node.elements)
|
|
56
|
+
item?.type === 'SpreadElement' ? at(item, CALL) : value(item);
|
|
57
|
+
return;
|
|
58
|
+
case 'ObjectExpression':
|
|
59
|
+
for (const prop of node.properties) {
|
|
60
|
+
if (prop.type !== 'Property' || prop.computed)
|
|
61
|
+
at(prop, CALL);
|
|
62
|
+
else
|
|
63
|
+
value(prop.value);
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
case 'JSXElement':
|
|
67
|
+
case 'JSXFragment':
|
|
68
|
+
return tsx(node);
|
|
69
|
+
case 'ConditionalExpression':
|
|
70
|
+
case 'LogicalExpression':
|
|
71
|
+
return at(node, BRANCH);
|
|
72
|
+
case 'ArrowFunctionExpression':
|
|
73
|
+
case 'FunctionExpression':
|
|
74
|
+
return at(node, HANDLER);
|
|
75
|
+
case 'CallExpression': {
|
|
76
|
+
const callee = node.callee;
|
|
77
|
+
const name = callee.type === 'Identifier' ? callee.name : callee.type === 'MemberExpression' ? callee.property.name : '';
|
|
78
|
+
if (/^use[A-Z]/.test(name))
|
|
79
|
+
return at(node, HOOK);
|
|
80
|
+
if (['map', 'forEach', 'from', 'flatMap', 'filter'].includes(name))
|
|
81
|
+
return at(node, LOOP);
|
|
82
|
+
return at(node, CALL);
|
|
83
|
+
}
|
|
84
|
+
default:
|
|
85
|
+
return at(node, CALL);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
/** An element: its attributes are values, its children elements, names or comments. */
|
|
89
|
+
const tsx = (node) => {
|
|
90
|
+
if (node.type === 'JSXElement') {
|
|
91
|
+
for (const attr of node.openingElement.attributes) {
|
|
92
|
+
if (attr.type === 'JSXSpreadAttribute')
|
|
93
|
+
at(attr, 'a scene spells out its attributes — no spread');
|
|
94
|
+
else if (attr.value?.type === 'JSXExpressionContainer')
|
|
95
|
+
value(attr.value.expression);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
for (const child of node.children) {
|
|
99
|
+
if (child.type === 'JSXText') {
|
|
100
|
+
const text = child.value;
|
|
101
|
+
const lead = text.length - text.trimStart().length;
|
|
102
|
+
if (text.trim())
|
|
103
|
+
at({ ...child, start: child.start + lead }, 'text in a scene is a value — an attribute, or a component\'s field');
|
|
104
|
+
}
|
|
105
|
+
else if (child.type === 'JSXExpressionContainer') {
|
|
106
|
+
const inner = child.expression;
|
|
107
|
+
if (inner.type !== 'JSXEmptyExpression' && inner.type !== 'Identifier')
|
|
108
|
+
value(inner);
|
|
109
|
+
}
|
|
110
|
+
else
|
|
111
|
+
tsx(child);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
/** A factory: `() => <Tsx/>`, or a body of `const x = ref<T>()` and plain constants, then one `return`. */
|
|
115
|
+
const factory = (fn) => {
|
|
116
|
+
const body = fn.body;
|
|
117
|
+
if (body.type !== 'BlockStatement')
|
|
118
|
+
return value(body);
|
|
119
|
+
const statements = body.body;
|
|
120
|
+
statements.forEach((statement, i) => {
|
|
121
|
+
if (statement.type === 'ReturnStatement' && i === statements.length - 1)
|
|
122
|
+
return value(statement.argument);
|
|
123
|
+
if (statement.type === 'VariableDeclaration' && statement.kind === 'const') {
|
|
124
|
+
for (const d of statement.declarations) {
|
|
125
|
+
const init = d.init;
|
|
126
|
+
if (init?.type === 'CallExpression' && init.callee.name === 'ref')
|
|
127
|
+
continue;
|
|
128
|
+
value(init);
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
at(statement, statement.type === 'IfStatement' ? BRANCH : /For|While/.test(statement.type) ? LOOP : HANDLER);
|
|
133
|
+
});
|
|
134
|
+
};
|
|
135
|
+
const declaration = (node) => {
|
|
136
|
+
if (node.type === 'FunctionDeclaration')
|
|
137
|
+
return factory(node);
|
|
138
|
+
if (node.type === 'VariableDeclaration') {
|
|
139
|
+
if (node.kind !== 'const')
|
|
140
|
+
return at(node, 'a scene declares constants only — const');
|
|
141
|
+
for (const d of node.declarations) {
|
|
142
|
+
const init = d.init;
|
|
143
|
+
if (init && /Function/.test(init.type))
|
|
144
|
+
factory(init);
|
|
145
|
+
else if (init?.type === 'CallExpression' && init.callee.name === 'ref') {
|
|
146
|
+
at(init, 'make a ref inside the function that uses it — one at module level is shared by every copy');
|
|
147
|
+
}
|
|
148
|
+
else
|
|
149
|
+
value(init);
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (node.type === 'ClassDeclaration')
|
|
154
|
+
return at(node, 'a component is a class in a .ts file — a .tsx file is a scene');
|
|
155
|
+
if (/^TS(Interface|TypeAlias)Declaration$/.test(node.type))
|
|
156
|
+
return;
|
|
157
|
+
at(node, HANDLER);
|
|
158
|
+
};
|
|
159
|
+
for (const statement of program.body) {
|
|
160
|
+
if (statement.type === 'ImportDeclaration')
|
|
161
|
+
continue;
|
|
162
|
+
if (statement.type === 'ExportNamedDeclaration' || statement.type === 'ExportDefaultDeclaration') {
|
|
163
|
+
const inner = statement.declaration;
|
|
164
|
+
if (!inner)
|
|
165
|
+
continue; // export { … } from
|
|
166
|
+
if (statement.type === 'ExportDefaultDeclaration' && !/Declaration$/.test(inner.type)) {
|
|
167
|
+
/Function/.test(inner.type) ? factory(inner) : value(inner);
|
|
168
|
+
}
|
|
169
|
+
else
|
|
170
|
+
declaration(inner);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
declaration(statement);
|
|
174
|
+
}
|
|
175
|
+
return problems;
|
|
176
|
+
}
|
|
177
|
+
/** Every `.tsx` file under `dir`, past `node_modules`, `dist` and dotted folders. */
|
|
178
|
+
export function* tsxFiles(dir) {
|
|
179
|
+
for (const entry of readdirSync(dir)) {
|
|
180
|
+
if (entry === 'node_modules' || entry === 'dist' || entry.startsWith('.'))
|
|
181
|
+
continue;
|
|
182
|
+
const path = join(dir, entry);
|
|
183
|
+
if (statSync(path).isDirectory())
|
|
184
|
+
yield* tsxFiles(path);
|
|
185
|
+
else if (path.endsWith('.tsx'))
|
|
186
|
+
yield path;
|
|
187
|
+
}
|
|
188
|
+
}
|
package/dist/vite.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { IncomingMessage } from 'node:http';
|
|
2
|
+
import { type Plugin } from 'vite';
|
|
3
|
+
import { type Value } from './scene-edit.js';
|
|
4
|
+
/** A scene file as the editor sees it: its elements, flat, in document order. */
|
|
5
|
+
export interface SceneJson {
|
|
6
|
+
file: string;
|
|
7
|
+
elements: {
|
|
8
|
+
tag: string;
|
|
9
|
+
line: number;
|
|
10
|
+
column: number;
|
|
11
|
+
/** Index of the enclosing element, or -1 for a root. */
|
|
12
|
+
parent: number;
|
|
13
|
+
attributes: {
|
|
14
|
+
name: string;
|
|
15
|
+
text: string | null;
|
|
16
|
+
value: Value;
|
|
17
|
+
}[];
|
|
18
|
+
}[];
|
|
19
|
+
}
|
|
20
|
+
export declare function scenoco(): Plugin;
|
|
21
|
+
/** @internal one editor request, answered as JSON. Exported for the test. */
|
|
22
|
+
export declare function handle(root: string, req: IncomingMessage, written: Set<string>): Promise<SceneJson>;
|
package/dist/vite.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `scenoco()` — the Vite plugin a SceNoCo project uses: TSX through core's JSX runtime, and, while
|
|
3
|
+
* serving, what the editor needs to read and write scene files.
|
|
4
|
+
*
|
|
5
|
+
* ```ts
|
|
6
|
+
* // vite.config.ts
|
|
7
|
+
* export default defineConfig({ plugins: [scenoco()] });
|
|
8
|
+
* ```
|
|
9
|
+
*
|
|
10
|
+
* The editor runs in the game's own page (`?edit`), so it asks this server, never the disk:
|
|
11
|
+
*
|
|
12
|
+
* - `GET /__scenoco/scene?file=…` — a scene file read without running it: every element in
|
|
13
|
+
* document order, with its attributes' values where they can be worked out.
|
|
14
|
+
* - `POST /__scenoco/set` — `{ file, index, expect, name, value }`: one attribute of one element,
|
|
15
|
+
* written by splicing its text (`scene-edit.ts`); everything else in the file stays byte for byte.
|
|
16
|
+
* `text` in place of `value` writes the attribute's text exactly as given — what undo puts back.
|
|
17
|
+
* - `POST /__scenoco/remove` — `{ file, index, expect, name }`.
|
|
18
|
+
*
|
|
19
|
+
* An element is addressed by its index in document order, which an attribute edit never changes.
|
|
20
|
+
* `expect` is the element as the editor last read it — `{ tag, line, column }` — so a file changed
|
|
21
|
+
* behind the editor's back (in an IDE, by an agent) is refused, never written at the wrong element.
|
|
22
|
+
* A file the editor just wrote does not reload the page: the editor has already changed the live
|
|
23
|
+
* object, and a reload would lose the camera and the selection.
|
|
24
|
+
*/
|
|
25
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
26
|
+
import { isAbsolute, relative, resolve } from 'node:path';
|
|
27
|
+
import { version } from 'vite';
|
|
28
|
+
import { elements, readScene, removeAttribute, setAttribute, setAttributeText } from './scene-edit.js';
|
|
29
|
+
export function scenoco() {
|
|
30
|
+
let root = process.cwd();
|
|
31
|
+
/** Files this server just wrote for the editor: their next change is not a reload. */
|
|
32
|
+
const written = new Set();
|
|
33
|
+
return {
|
|
34
|
+
name: 'scenoco',
|
|
35
|
+
config: () => ({
|
|
36
|
+
// Vite 8 transforms with oxc; 6 and 7 with esbuild, and 8 warns about the other's option.
|
|
37
|
+
...(Number(version.split('.')[0]) >= 8
|
|
38
|
+
? { oxc: { jsx: { runtime: 'automatic', importSource: '@scenoco-three/core' } } }
|
|
39
|
+
: { esbuild: { jsx: 'automatic', jsxImportSource: '@scenoco-three/core' } }),
|
|
40
|
+
// One `three` in the graph, or `instanceof` across two copies of it silently returns false.
|
|
41
|
+
resolve: { dedupe: ['three'] },
|
|
42
|
+
}),
|
|
43
|
+
configResolved(config) {
|
|
44
|
+
root = config.root;
|
|
45
|
+
},
|
|
46
|
+
configureServer(server) {
|
|
47
|
+
server.middlewares.use('/__scenoco/', (req, res) => {
|
|
48
|
+
handle(root, req, written)
|
|
49
|
+
.then((body) => send(res, 200, body))
|
|
50
|
+
.catch((error) => send(res, 400, { error: error instanceof Error ? error.message : String(error) }));
|
|
51
|
+
});
|
|
52
|
+
},
|
|
53
|
+
handleHotUpdate({ file }) {
|
|
54
|
+
if (written.delete(file))
|
|
55
|
+
return [];
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** @internal one editor request, answered as JSON. Exported for the test. */
|
|
60
|
+
export async function handle(root, req, written) {
|
|
61
|
+
const url = new URL(req.url ?? '/', 'http://x');
|
|
62
|
+
if (req.method === 'GET' && url.pathname === '/scene')
|
|
63
|
+
return sceneJson(sceneFile(root, url.searchParams.get('file')));
|
|
64
|
+
if (req.method !== 'POST')
|
|
65
|
+
throw new Error(`no ${req.method} ${url.pathname}`);
|
|
66
|
+
const body = JSON.parse(await text(req));
|
|
67
|
+
const file = sceneFile(root, body.file ?? null);
|
|
68
|
+
const code = readFileSync(file, 'utf8');
|
|
69
|
+
const all = elements(readScene(file, code));
|
|
70
|
+
const element = all[body.index ?? -1];
|
|
71
|
+
const expected = body.expect;
|
|
72
|
+
if (!expected)
|
|
73
|
+
throw new Error('which element? `expect: { tag, line, column }`, as the editor last read it');
|
|
74
|
+
if (!element || element.tag !== expected.tag || element.line !== expected.line || element.column !== expected.column) {
|
|
75
|
+
const now = element ? `<${element.tag}> at ${element.line}:${element.column}` : 'gone';
|
|
76
|
+
throw new Error(`${relative(root, file)} changed outside the editor: element ${body.index} is ${now}, not <${expected.tag}> at ${expected.line}:${expected.column}. Reload the page.`);
|
|
77
|
+
}
|
|
78
|
+
if (typeof body.name !== 'string' || !/^[A-Za-z_$][\w$-]*$/.test(body.name))
|
|
79
|
+
throw new Error(`not an attribute name: ${body.name}`);
|
|
80
|
+
let next;
|
|
81
|
+
if (url.pathname === '/set' && body.text !== undefined)
|
|
82
|
+
next = setAttributeText(code, element, body.name, body.text);
|
|
83
|
+
else if (url.pathname === '/set')
|
|
84
|
+
next = setAttribute(code, element, body.name, body.value);
|
|
85
|
+
else if (url.pathname === '/remove')
|
|
86
|
+
next = removeAttribute(code, element, body.name);
|
|
87
|
+
else
|
|
88
|
+
throw new Error(`no POST ${url.pathname}`);
|
|
89
|
+
// Text from the page is checked before it lands: the file must still parse, element for element.
|
|
90
|
+
let parsed;
|
|
91
|
+
try {
|
|
92
|
+
parsed = elements(readScene(file, next)).length;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
parsed = -1;
|
|
96
|
+
}
|
|
97
|
+
if (parsed !== all.length)
|
|
98
|
+
throw new Error(`not an attribute value: ${body.text ?? JSON.stringify(body.value)}`);
|
|
99
|
+
if (next !== code) {
|
|
100
|
+
written.add(file);
|
|
101
|
+
writeFileSync(file, next);
|
|
102
|
+
}
|
|
103
|
+
return sceneJson(file);
|
|
104
|
+
}
|
|
105
|
+
/** A `.tsx` file inside the project — the only thing the editor may read or write. */
|
|
106
|
+
function sceneFile(root, file) {
|
|
107
|
+
if (!file)
|
|
108
|
+
throw new Error('which file? ?file=…');
|
|
109
|
+
// What `jsxDEV` reports: an absolute path, or one from the project's root.
|
|
110
|
+
const path = isAbsolute(file) && file.startsWith(root) ? file : resolve(root, `.${file.startsWith('/') ? '' : '/'}${file}`);
|
|
111
|
+
const inside = relative(root, path);
|
|
112
|
+
if (inside.startsWith('..') || isAbsolute(inside) || !path.endsWith('.tsx'))
|
|
113
|
+
throw new Error(`${file} is not a scene file of this project`);
|
|
114
|
+
return path;
|
|
115
|
+
}
|
|
116
|
+
function sceneJson(file) {
|
|
117
|
+
const all = elements(readScene(file, readFileSync(file, 'utf8')));
|
|
118
|
+
const parents = new Map();
|
|
119
|
+
all.forEach((element, i) => element.children.forEach((child) => parents.set(child, i)));
|
|
120
|
+
return {
|
|
121
|
+
file,
|
|
122
|
+
elements: all.map((element) => ({
|
|
123
|
+
tag: element.tag,
|
|
124
|
+
line: element.line,
|
|
125
|
+
column: element.column,
|
|
126
|
+
parent: parents.get(element) ?? -1,
|
|
127
|
+
attributes: element.attributes.map(({ name, text, value }) => ({ name, text, value })),
|
|
128
|
+
})),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
function text(req) {
|
|
132
|
+
return new Promise((done, fail) => {
|
|
133
|
+
let body = '';
|
|
134
|
+
req.on('data', (chunk) => (body += chunk));
|
|
135
|
+
req.on('end', () => done(body));
|
|
136
|
+
req.on('error', fail);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
function send(res, status, body) {
|
|
140
|
+
res.statusCode = status;
|
|
141
|
+
res.setHeader('Content-Type', 'application/json');
|
|
142
|
+
res.end(JSON.stringify(body));
|
|
143
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,63 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scenoco-three/tools",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "SceNoCo's tools: `scenoco check` (a .tsx scene only declares), the `scenoco()` Vite plugin, and the read/edit layer the editor uses on scene files.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"three",
|
|
7
|
+
"threejs",
|
|
8
|
+
"scenoco",
|
|
9
|
+
"game-engine",
|
|
10
|
+
"game-framework",
|
|
11
|
+
"gamedev",
|
|
12
|
+
"web-game",
|
|
13
|
+
"webgl",
|
|
14
|
+
"typescript",
|
|
15
|
+
"unity",
|
|
16
|
+
"tsx",
|
|
17
|
+
"vite-plugin",
|
|
18
|
+
"cli",
|
|
19
|
+
"lint"
|
|
20
|
+
],
|
|
21
|
+
"homepage": "https://github.com/RomaRogov/scenoco-three/tree/main/packages/tools#readme",
|
|
22
|
+
"bugs": "https://github.com/RomaRogov/scenoco-three/issues",
|
|
23
|
+
"type": "module",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/RomaRogov/scenoco-three.git",
|
|
28
|
+
"directory": "packages/tools"
|
|
29
|
+
},
|
|
30
|
+
"bin": {
|
|
31
|
+
"scenoco": "./bin/scenoco.js"
|
|
32
|
+
},
|
|
33
|
+
"main": "./dist/index.js",
|
|
34
|
+
"types": "./dist/index.d.ts",
|
|
35
|
+
"exports": {
|
|
36
|
+
".": {
|
|
37
|
+
"types": "./dist/index.d.ts",
|
|
38
|
+
"import": "./dist/index.js"
|
|
39
|
+
},
|
|
40
|
+
"./vite": {
|
|
41
|
+
"types": "./dist/vite.d.ts",
|
|
42
|
+
"import": "./dist/vite.js"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"files": [
|
|
46
|
+
"dist",
|
|
47
|
+
"bin"
|
|
48
|
+
],
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"prebuild": "rm -rf dist",
|
|
54
|
+
"build": "tsc -p tsconfig.build.json",
|
|
55
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
56
|
+
},
|
|
57
|
+
"dependencies": {
|
|
58
|
+
"rolldown": "^1.0.3"
|
|
59
|
+
},
|
|
60
|
+
"peerDependencies": {
|
|
61
|
+
"vite": ">=6"
|
|
62
|
+
}
|
|
63
|
+
}
|