redweb 0.12.0 → 0.13.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/CHANGELOG.md +166 -9
- package/README.md +177 -523
- package/bin/redweb.js +11 -20
- package/client.d.ts +7 -2
- package/config/tsconfig.json +14 -14
- package/contract.d.ts +45 -0
- package/contract.js +5 -0
- package/docs/ACTION_INPUT_VERIFICATION.md +96 -0
- package/docs/ADMISSION_TIMEOUT_VERIFICATION.md +69 -0
- package/docs/AGENT_ACCESS.md +35 -0
- package/docs/AGENT_EVALUATION.md +58 -0
- package/docs/AGENT_READY_ACCEPTANCE.md +763 -0
- package/docs/APPLICATION_RECORDER_VERIFICATION.md +50 -0
- package/docs/BENCHMARK_VERIFICATION.md +307 -0
- package/docs/BROWSER_OWNER_VERIFICATION.md +191 -0
- package/docs/CLI.md +116 -0
- package/docs/CLIENT_DEVELOPMENT.md +152 -0
- package/docs/CLIENT_POLISH_VERIFICATION.md +282 -0
- package/docs/COVERAGE_COUNTER_VALIDATION.md +109 -0
- package/docs/COVERAGE_SCOPE_AUDIT.md +1183 -0
- package/docs/DEVELOPMENT.md +79 -0
- package/docs/DIAGNOSTIC_COMPATIBILITY.md +76 -0
- package/docs/DOCUMENTATION.md +37 -0
- package/docs/FEEDBACK_COMMAND_VERIFICATION.md +228 -0
- package/docs/GETTING_STARTED.md +58 -0
- package/docs/JSX_PERFORMANCE_VERIFICATION.md +59 -0
- package/docs/LIVE_HTML.md +169 -21
- package/docs/LIVE_HTML_LOAD_VERIFICATION.md +98 -0
- package/docs/MIGRATION.md +28 -0
- package/docs/MULTIPLAYER_OPERATIONS.md +26 -4
- package/docs/ORIGINAL_RECOVERY_VERIFICATION.md +100 -0
- package/docs/PACKAGED_EXAMPLE_VERIFICATION.md +126 -0
- package/docs/POLISH_RELEASE_CHECKPOINT.md +91 -0
- package/docs/PROCESS_CLEANUP_OBSERVATION.md +61 -0
- package/docs/PROCESS_REAPING_VERIFICATION.md +30 -0
- package/docs/PRODUCTION_READINESS.md +11 -2
- package/docs/RECOVERY_CLIENT_HEAP.md +201 -0
- package/docs/RECOVERY_CODE_ATTRIBUTION.md +174 -0
- package/docs/RECOVERY_CODE_CENSUS.md +158 -0
- package/docs/RECOVERY_COMPARISON.md +103 -0
- package/docs/RECOVERY_DEOPTIMIZATION.md +169 -0
- package/docs/RECOVERY_FOLLOWUP_SPIKE.md +147 -0
- package/docs/RECOVERY_INVESTIGATION.md +229 -0
- package/docs/RECOVERY_RUNTIME_CONTROLS.md +181 -0
- package/docs/RELEASE_TRUST.md +58 -0
- package/docs/ROOM_AUTHORIZATION.md +49 -0
- package/docs/RUNTIME_DIAGNOSTICS.md +78 -0
- package/docs/SERVER_RECOVERY_CANDIDATE.md +185 -0
- package/docs/SOAK_ROTATION_OBSERVATION.md +160 -0
- package/docs/SOAK_VERIFICATION.md +154 -0
- package/docs/SOCKET_CONTRACTS.md +39 -0
- package/docs/SPLIT_RECOVERY_COVERAGE.md +83 -0
- package/docs/SPLIT_RECOVERY_ERROR_HANDLING.md +67 -0
- package/docs/STARTER_COORDINATOR_VERIFICATION.md +112 -0
- package/docs/STARTER_LIFECYCLE_VERIFICATION.md +75 -0
- package/docs/STARTER_REPORT_RETENTION.md +73 -0
- package/docs/VERIFICATION_EVIDENCE.md +2 -0
- package/docs/generated.json +2154 -0
- package/docs/guides/chatroom.md +27 -0
- package/docs/guides/http-websocket.md +28 -0
- package/docs/guides/jsx-without-react.md +26 -0
- package/docs/guides/realtime-dashboard.md +29 -0
- package/docs/guides/typed-websockets.md +26 -0
- package/docs/reference.json +1207 -0
- package/docs/snippets/room-access.tsx +51 -0
- package/docs/topics.json +21 -0
- package/examples/live-html/chatroom.js +207 -268
- package/examples/live-html/chatroom.tsx +167 -0
- package/examples/live-html/jsx-page.js +1 -1
- package/examples/live-html/jsx-page.tsx +1 -1
- package/examples/live-html/tsconfig.json +8 -7
- package/index.d.ts +170 -45
- package/index.js +2 -0
- package/jsx-dev-runtime.js +2 -2
- package/jsx-runtime.d.ts +7 -2
- package/package.json +88 -7
- package/recipes/add/artifact.test.cjs +57 -0
- package/recipes/add/live.tsx +18 -0
- package/recipes/add/socket-route.ts +24 -0
- package/recipes/chat/README.md +22 -0
- package/recipes/chat/app.test.cjs +105 -0
- package/recipes/chat/app.tsx +9 -0
- package/recipes/dashboard/README.md +43 -0
- package/recipes/dashboard/admin.ts +21 -0
- package/recipes/dashboard/app.css +16 -0
- package/recipes/dashboard/app.test.cjs +450 -0
- package/recipes/dashboard/app.tsx +86 -0
- package/recipes/dashboard/auth.ts +80 -0
- package/recipes/dashboard/cards.tsx +102 -0
- package/recipes/dashboard/rate-window.test.cjs +17 -0
- package/recipes/dashboard/store.ts +120 -0
- package/recipes/http-ws/README.md +11 -0
- package/recipes/http-ws/app.test.cjs +92 -0
- package/recipes/http-ws/app.tsx +36 -0
- package/recipes/realtime/README.md +8 -0
- package/recipes/realtime/app.test.cjs +15 -0
- package/recipes/realtime/app.tsx +28 -0
- package/recipes/shared/README.md +40 -0
- package/recipes/shared/app.css +8 -0
- package/recipes/shared/copy-assets.cjs +8 -0
- package/recipes/shared/network.cjs +59 -0
- package/recipes/shared/run-app.test.cjs +158 -0
- package/recipes/shared/run-app.ts +50 -0
- package/recipes/site/README.md +4 -0
- package/recipes/site/app.test.cjs +19 -0
- package/recipes/site/app.tsx +25 -0
- package/recipes/socket/README.md +39 -0
- package/recipes/socket/app.test.cjs +85 -0
- package/recipes/socket/app.tsx +30 -0
- package/recipes/socket/contract.ts +12 -0
- package/recipes/socket/handlers.ts +40 -0
- package/src/OwnedServerLifecycle.js +66 -0
- package/src/access/AccessPolicy.js +37 -0
- package/src/access/AuthenticationFailure.js +13 -0
- package/src/access/RequestFailure.js +33 -0
- package/src/access/failure-codes.json +25 -0
- package/src/async/BoundedOperation.js +62 -0
- package/src/cli/ActionReferences.js +193 -0
- package/src/cli/AdditionLayout.js +140 -0
- package/src/cli/FilePlan.js +94 -0
- package/src/cli/ProjectAddition.js +60 -0
- package/src/cli/ProjectConfig.js +26 -0
- package/src/cli/ProjectDoctor.js +112 -0
- package/src/cli/ProjectInitializer.js +20 -30
- package/src/cli/SourceInspector.js +207 -0
- package/src/cli/StaticSource.js +192 -0
- package/src/cli/arguments.js +62 -0
- package/src/cli/formatCommand.js +10 -0
- package/src/cli/run.js +57 -0
- package/src/cli/templates.js +86 -87
- package/src/context/RequestSnapshot.js +41 -0
- package/src/dataProperty.js +11 -0
- package/src/development/DevelopmentPageManager.js +48 -0
- package/src/development/Inspection.js +104 -0
- package/src/development/ObservedRenderer.js +42 -0
- package/src/development/description.js +35 -0
- package/src/development/loopbackRequest.js +27 -0
- package/src/development/refreshBrowser.js +96 -0
- package/src/development/refreshStyles.js +9 -0
- package/src/development/settings.js +17 -0
- package/src/docs/Documentation.js +182 -0
- package/src/htmx/ActionDefinition.js +44 -0
- package/src/htmx/Jsx.js +24 -8
- package/src/htmx/LiveHtmlServer.js +41 -19
- package/src/htmx/LivePage.js +63 -13
- package/src/htmx/PageIdentity.js +32 -0
- package/src/htmx/PageLifetime.js +37 -0
- package/src/htmx/PageManager.js +203 -74
- package/src/htmx/ReactiveRenderer.js +241 -0
- package/src/htmx/StaticExporter.js +1 -1
- package/src/htmx/TemplateRenderer.js +13 -7
- package/src/htmx/browserRuntime.js +2 -93
- package/src/htmx/metadata.js +19 -7
- package/src/validation/ActionInputError.js +12 -0
- package/src/validation/SchemaValidator.js +38 -0
- package/src/ws/AdmissionPolicy.js +24 -23
- package/src/ws/BaseSocketServer.js +53 -38
- package/src/ws/ContractValidationError.js +12 -0
- package/src/ws/HeartbeatMonitor.js +19 -7
- package/src/ws/ProtocolPolicy.js +1 -1
- package/src/ws/RoomAccess.js +82 -0
- package/src/ws/RoomRegistry.js +56 -6
- package/src/ws/RouteRuntime.js +56 -10
- package/src/ws/SocketContract.js +112 -0
- package/src/ws/SocketRoute.js +18 -0
- package/src/ws/protocol-schema.json +6 -1
- package/examples/live-html/chatroom.ts +0 -217
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
class OperationInterrupted extends Error {
|
|
4
|
+
constructor(reason) {
|
|
5
|
+
super('Operation did not complete within its lifetime.');
|
|
6
|
+
this.reason = reason;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Shared, browser-safe lifetime boundary; it cannot preempt synchronous JS. */
|
|
11
|
+
class BoundedOperation {
|
|
12
|
+
constructor(timeoutMs = 5000) {
|
|
13
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2147483647) throw new TypeError('Timeout must be an integer between 1 and 2147483647.');
|
|
14
|
+
this.timeoutMs = timeoutMs;
|
|
15
|
+
Object.freeze(this);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async run(operation, signal) {
|
|
19
|
+
if (signal?.aborted) throw new OperationInterrupted('cancelled');
|
|
20
|
+
let timer, abort, interruption;
|
|
21
|
+
const controller = new AbortController();
|
|
22
|
+
const started = performance.now();
|
|
23
|
+
const checkpoint = () => {
|
|
24
|
+
if (interruption) throw interruption;
|
|
25
|
+
if (signal?.aborted) throw (interruption = new OperationInterrupted('cancelled'));
|
|
26
|
+
if (performance.now() - started >= this.timeoutMs) throw (interruption = new OperationInterrupted('timeout'));
|
|
27
|
+
};
|
|
28
|
+
try {
|
|
29
|
+
const interrupted = new Promise((_, reject) => {
|
|
30
|
+
const interrupt = reason => {
|
|
31
|
+
// Timer delivery is itself terminal, even if clock sampling
|
|
32
|
+
// has not yet reached the nominal deadline. Latch before
|
|
33
|
+
// abort listeners can reenter a checkpoint.
|
|
34
|
+
interruption ||= new OperationInterrupted(reason);
|
|
35
|
+
reject(interruption);
|
|
36
|
+
controller.abort();
|
|
37
|
+
};
|
|
38
|
+
timer = setTimeout(() => interrupt('timeout'), this.timeoutMs);
|
|
39
|
+
abort = () => interrupt('cancelled');
|
|
40
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
41
|
+
});
|
|
42
|
+
const work = Promise.resolve().then(async () => {
|
|
43
|
+
checkpoint();
|
|
44
|
+
const value = await operation(controller.signal, checkpoint);
|
|
45
|
+
checkpoint();
|
|
46
|
+
return value;
|
|
47
|
+
});
|
|
48
|
+
return await Promise.race([work, interrupted]);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (error instanceof OperationInterrupted) {
|
|
51
|
+
interruption ||= error;
|
|
52
|
+
controller.abort();
|
|
53
|
+
}
|
|
54
|
+
throw error;
|
|
55
|
+
} finally {
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
signal?.removeEventListener('abort', abort);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { BoundedOperation, OperationInterrupted };
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { UNKNOWN } = require('./StaticSource');
|
|
4
|
+
const TemplateRenderer = require('../htmx/TemplateRenderer');
|
|
5
|
+
|
|
6
|
+
const ACTIONS = new Set(['rw-click', 'rw-submit']);
|
|
7
|
+
const DIRECTIVES = new Set([...ACTIONS, 'data-rw-component']);
|
|
8
|
+
|
|
9
|
+
/** Static names only. Application constructors, decorators and render functions never run. */
|
|
10
|
+
class ActionReferences {
|
|
11
|
+
constructor(inspector) {
|
|
12
|
+
this.inspector = inspector;
|
|
13
|
+
this.ts = inspector.ts;
|
|
14
|
+
this.read = inspector.syntax;
|
|
15
|
+
this.descriptions = new Map();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
unknown(node, reason) {
|
|
19
|
+
this.inspector.finding('ACTION_REFERENCE_UNRESOLVED', node, reason,
|
|
20
|
+
'Use a literal action name on an intrinsic HTML element, or verify this dynamic binding with a real browser test. Doctor does not execute render code.', 'warning');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
decorators(node) {
|
|
24
|
+
return (this.ts.getDecorators(node) || []).map(decorator => {
|
|
25
|
+
const expression = decorator.expression;
|
|
26
|
+
return this.ts.isCallExpression(expression) ? this.read.api(expression.expression) : null;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe(node, seen = new Set()) {
|
|
31
|
+
if (this.descriptions.has(node)) return this.descriptions.get(node);
|
|
32
|
+
if (node === UNKNOWN || seen.has(node) || seen.size >= 64 || !this.read.spend() || !this.ts.isClassDeclaration(node)) return { names: new Set(), members: new Map(), uncertain: true };
|
|
33
|
+
seen.add(node);
|
|
34
|
+
const ts = this.ts;
|
|
35
|
+
const base = this.read.base(node);
|
|
36
|
+
const inherited = base === UNKNOWN || this.read.api(base) === 'LivePage'
|
|
37
|
+
? { names: new Set(), members: new Map(), uncertain: false } : this.describe(this.read.resolve(base), seen);
|
|
38
|
+
const names = new Set(inherited.names);
|
|
39
|
+
const members = new Map(inherited.members);
|
|
40
|
+
let uncertain = inherited.uncertain || this.read.resolve(node) === UNKNOWN || this.decorators(node).some(name => !['page', 'site.page', 'component'].includes(name));
|
|
41
|
+
for (const member of node.members) {
|
|
42
|
+
if (ts.isClassStaticBlockDeclaration(member)) uncertain = true;
|
|
43
|
+
if (ts.isConstructorDeclaration(member)) {
|
|
44
|
+
for (const parameter of member.parameters) if (parameter.modifiers?.length) {
|
|
45
|
+
const name = parameter.name.getText();
|
|
46
|
+
names.delete(name);
|
|
47
|
+
members.set(name, parameter);
|
|
48
|
+
uncertain = true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (!member.name) continue;
|
|
52
|
+
const name = ts.isComputedPropertyName(member.name) ? this.read.text(member.name.expression) : member.name.text;
|
|
53
|
+
if (name === UNKNOWN) { uncertain = true; continue; }
|
|
54
|
+
if (member.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.StaticKeyword)) continue;
|
|
55
|
+
members.set(name, member);
|
|
56
|
+
names.delete(name);
|
|
57
|
+
const decorators = this.decorators(member);
|
|
58
|
+
if (decorators.some(api => !['action', 'state', 'view'].includes(api))) uncertain = true;
|
|
59
|
+
if (ts.isMethodDeclaration(member) && decorators.length === 1 && decorators[0] === 'action' && !ts.isPrivateIdentifier(member.name)) names.add(name);
|
|
60
|
+
}
|
|
61
|
+
// Assignments or escaping `this` can replace decorator-exposed methods after construction.
|
|
62
|
+
const pending = [...node.members];
|
|
63
|
+
while (pending.length && this.read.spend()) {
|
|
64
|
+
const current = pending.pop();
|
|
65
|
+
if (ts.isClassDeclaration(current) || ts.isClassExpression(current)) continue;
|
|
66
|
+
if (current.kind === ts.SyntaxKind.ThisKeyword && !((ts.isPropertyAccessExpression(current.parent) || ts.isElementAccessExpression(current.parent)) && current.parent.expression === current)) uncertain = true;
|
|
67
|
+
if (ts.isBinaryExpression(current) && current.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && current.operatorToken.kind <= ts.SyntaxKind.LastAssignment) {
|
|
68
|
+
const left = current.left;
|
|
69
|
+
if (ts.isObjectLiteralExpression(left) || ts.isArrayLiteralExpression(left)) uncertain = true;
|
|
70
|
+
if (ts.isElementAccessExpression(left) && left.expression.kind === ts.SyntaxKind.ThisKeyword) uncertain = true;
|
|
71
|
+
if (ts.isPropertyAccessExpression(left) && left.expression.kind === ts.SyntaxKind.ThisKeyword && names.has(left.name.text)) uncertain = true;
|
|
72
|
+
}
|
|
73
|
+
if (ts.isDeleteExpression(current) || ts.isPrefixUnaryExpression(current) || ts.isPostfixUnaryExpression(current)) {
|
|
74
|
+
const target = current.expression || current.operand;
|
|
75
|
+
if ((ts.isPropertyAccessExpression(target) || ts.isElementAccessExpression(target)) && target.expression.kind === ts.SyntaxKind.ThisKeyword && (ts.isElementAccessExpression(target) || names.has(target.name.text))) uncertain = true;
|
|
76
|
+
}
|
|
77
|
+
ts.forEachChild(current, child => { pending.push(child); });
|
|
78
|
+
}
|
|
79
|
+
const result = { names, members, uncertain };
|
|
80
|
+
this.descriptions.set(node, result);
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
check(owner, name, node, ambiguous) {
|
|
85
|
+
const description = this.describe(owner);
|
|
86
|
+
if (name === UNKNOWN || ambiguous || description.uncertain || (typeof name === 'string' && name.includes('&'))) {
|
|
87
|
+
this.unknown(node, 'The action name or its owning class cannot be established statically.');
|
|
88
|
+
} else if (typeof name !== 'string' || !name || name.length > 128 || ['__proto__', 'prototype', 'constructor'].includes(name)) {
|
|
89
|
+
this.inspector.finding('ACTION_REFERENCE_INVALID', node, 'Action names must be non-empty strings of at most 128 characters and cannot use reserved member names.', 'Use the exact name of a public @action() method.');
|
|
90
|
+
} else if (!description.names.has(name)) {
|
|
91
|
+
this.inspector.finding('ACTION_NOT_EXPOSED', node, `No public @action() method named "${name}" is exposed by this class.`, 'Correct the binding name or explicitly decorate the intended instance method with @action().');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
jsx(owner, element) {
|
|
96
|
+
const ts = this.ts;
|
|
97
|
+
const attributes = element.attributes.properties;
|
|
98
|
+
let ambiguous = !ts.isIdentifier(element.tagName) || !/^[a-z]/.test(element.tagName.text);
|
|
99
|
+
for (let parent = element; parent && parent !== owner; parent = parent.parent) {
|
|
100
|
+
const opening = ts.isJsxElement(parent) ? parent.openingElement : (ts.isJsxOpeningElement(parent) || ts.isJsxSelfClosingElement(parent)) ? parent : null;
|
|
101
|
+
if (opening && (!ts.isIdentifier(opening.tagName) || !/^[a-z]/.test(opening.tagName.text) || opening.attributes.properties.some(attribute => ts.isJsxSpreadAttribute(attribute) || attribute.name.getText().toLowerCase() === 'data-rw-component'))) ambiguous = true;
|
|
102
|
+
}
|
|
103
|
+
for (const attribute of attributes) {
|
|
104
|
+
if (ts.isJsxSpreadAttribute(attribute)) {
|
|
105
|
+
this.unknown(attribute, 'Spread JSX attributes may introduce or replace an action binding or component scope.');
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const key = attribute.name.getText().toLowerCase();
|
|
109
|
+
if (!ACTIONS.has(key)) continue;
|
|
110
|
+
const value = attribute.initializer;
|
|
111
|
+
const name = !value ? null : this.read.text(ts.isJsxExpression(value) ? value.expression : value);
|
|
112
|
+
this.check(owner, name, attribute, ambiguous);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
markup(owner, markup, node, filename) {
|
|
117
|
+
if (markup.length > 1024 * 1024) { this.unknown(node, 'HTML action inspection exceeds the 1 MiB per-template limit.'); return; }
|
|
118
|
+
const source = filename ? this.ts.createSourceFile(filename, markup, this.ts.ScriptTarget.Latest, true) : null;
|
|
119
|
+
const bindings = [];
|
|
120
|
+
let scoped = false;
|
|
121
|
+
try {
|
|
122
|
+
TemplateRenderer.mapTags(markup, (tag, nameEnd, position) => {
|
|
123
|
+
const fields = TemplateRenderer.attributes(tag, nameEnd, DIRECTIVES);
|
|
124
|
+
if (fields.has('data-rw-component')) scoped = true;
|
|
125
|
+
for (const [key, name] of fields) if (ACTIONS.has(key)) bindings.push({ name, position });
|
|
126
|
+
return tag;
|
|
127
|
+
});
|
|
128
|
+
} catch {
|
|
129
|
+
this.unknown(node, 'Malformed or interpolated HTML attributes prevent reliable action inspection.');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
for (const { name, position } of bindings) {
|
|
133
|
+
const location = source ? { getSourceFile: () => source, getStart: () => position } : node;
|
|
134
|
+
this.check(owner, name, location, scoped);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
inspect(owner) {
|
|
139
|
+
if (!this.decorators(owner).some(name => ['page', 'site.page', 'component'].includes(name))) return;
|
|
140
|
+
const ts = this.ts;
|
|
141
|
+
const description = this.describe(owner);
|
|
142
|
+
const pending = [...description.members.values()];
|
|
143
|
+
const visited = new Set();
|
|
144
|
+
if (description.uncertain && !description.members.has('render') && this.read.base(owner) !== UNKNOWN) {
|
|
145
|
+
this.unknown(owner, 'Inherited render code is not statically available.');
|
|
146
|
+
}
|
|
147
|
+
const renderer = description.members.get('render');
|
|
148
|
+
let renderFunction = renderer;
|
|
149
|
+
if (renderer && !ts.isMethodDeclaration(renderer)) {
|
|
150
|
+
const initializer = this.read.resolve(renderer.initializer);
|
|
151
|
+
if (initializer === UNKNOWN || !(ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer))) {
|
|
152
|
+
this.unknown(renderer, 'The render implementation is not a statically available method or function.');
|
|
153
|
+
visited.add(renderer);
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
renderFunction = initializer;
|
|
157
|
+
pending.push(initializer);
|
|
158
|
+
if (!ts.isBlock(initializer.body)) this.output(owner, initializer.body, pending);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
while (pending.length && this.read.spend()) {
|
|
162
|
+
const node = pending.pop();
|
|
163
|
+
if (visited.has(node)) continue;
|
|
164
|
+
visited.add(node);
|
|
165
|
+
if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) continue;
|
|
166
|
+
if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) this.jsx(owner, node);
|
|
167
|
+
if (ts.isTaggedTemplateExpression(node) && this.read.api(node.tag) === 'html') {
|
|
168
|
+
if (ts.isNoSubstitutionTemplateLiteral(node.template)) this.markup(owner, node.template.text, node);
|
|
169
|
+
else this.unknown(node, 'Interpolated HTML can introduce markup or change action ownership.');
|
|
170
|
+
}
|
|
171
|
+
if (ts.isReturnStatement(node) && node.expression) {
|
|
172
|
+
let method = node.parent;
|
|
173
|
+
while (method && !ts.isFunctionLike(method)) method = method.parent;
|
|
174
|
+
if (method === renderFunction) this.output(owner, node.expression, pending);
|
|
175
|
+
}
|
|
176
|
+
ts.forEachChild(node, child => { pending.push(child); });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
output(owner, expression, pending) {
|
|
181
|
+
const ts = this.ts;
|
|
182
|
+
const node = this.read.resolve(expression);
|
|
183
|
+
if (node !== UNKNOWN) {
|
|
184
|
+
if (ts.isStringLiteralLike(node)) { this.markup(owner, node.text, expression); return; }
|
|
185
|
+
if (ts.isJsxElement(node) || ts.isJsxFragment(node) || ts.isJsxSelfClosingElement(node) || (ts.isTaggedTemplateExpression(node) && this.read.api(node.tag) === 'html')) { pending.push(node); return; }
|
|
186
|
+
if (ts.isConditionalExpression(node)) { this.output(owner, node.whenTrue, pending); this.output(owner, node.whenFalse, pending); return; }
|
|
187
|
+
if ([ts.SyntaxKind.NullKeyword, ts.SyntaxKind.TrueKeyword, ts.SyntaxKind.FalseKeyword, ts.SyntaxKind.NumericLiteral].includes(node.kind)) return;
|
|
188
|
+
}
|
|
189
|
+
this.unknown(expression, 'Dynamic render output may introduce action bindings or change their ownership.');
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
module.exports = ActionReferences;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { resolveDependency, readConfig } = require('./ProjectConfig');
|
|
6
|
+
const { assertSafePath } = require('./FilePlan');
|
|
7
|
+
|
|
8
|
+
/** Read TypeScript's effective layout without evaluating application modules. */
|
|
9
|
+
class AdditionLayout {
|
|
10
|
+
constructor(root, { configFile = 'tsconfig.json', sourceDir } = {}) {
|
|
11
|
+
this.root = root;
|
|
12
|
+
this.configFile = configFile;
|
|
13
|
+
assertSafePath(path.resolve(root, configFile), root);
|
|
14
|
+
const compiler = resolveDependency(root, 'typescript');
|
|
15
|
+
if (!compiler) throw new Error('TypeScript is missing. Run npm install --save-dev typescript.');
|
|
16
|
+
this.ts = require(compiler);
|
|
17
|
+
if (Number(this.ts.version.split('.')[0]) < 5) throw new Error('Add requires TypeScript 5 or newer.');
|
|
18
|
+
this.config = this.read();
|
|
19
|
+
const options = this.config.options;
|
|
20
|
+
if (this.config.projectReferences?.length || options.outFile || options.noEmit || options.emitDeclarationOnly) {
|
|
21
|
+
throw new Error('Select a single emitting TypeScript project with --config; project references, outFile and disabled JavaScript emission are unsupported.');
|
|
22
|
+
}
|
|
23
|
+
const { ModuleKind, ModuleResolutionKind } = this.ts;
|
|
24
|
+
if (![ModuleKind.CommonJS, ModuleKind.Node16, ModuleKind.NodeNext].includes(options.module) ||
|
|
25
|
+
options.moduleResolution === ModuleResolutionKind.Bundler) {
|
|
26
|
+
throw new Error('Add requires Node-compatible TypeScript emission (CommonJS, Node16 or NodeNext), not a custom bundler.');
|
|
27
|
+
}
|
|
28
|
+
if (!options.rootDir && !sourceDir) throw new Error('Source layout is ambiguous. Set rootDir or pass --source-dir relative to the project.');
|
|
29
|
+
this.sourceDir = path.resolve(root, sourceDir || options.rootDir);
|
|
30
|
+
assertSafePath(path.join(this.sourceDir, '__redweb_source_check.ts'), root);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
read(host = this.ts.sys) {
|
|
34
|
+
const { config, syntaxError } = readConfig(this.ts, this.root, this.configFile, host);
|
|
35
|
+
const errors = syntaxError ? [syntaxError] : config.errors.filter(error => error.code !== 18003);
|
|
36
|
+
if (errors.length) throw new Error(errors.map(error => this.ts.flattenDiagnosticMessageText(error.messageText, '\n')).join('\n'));
|
|
37
|
+
return config;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
prospective(filename) {
|
|
41
|
+
const { ts, root } = this;
|
|
42
|
+
// TypeScript owns include/exclude glob semantics. Its runtime matcher is
|
|
43
|
+
// version-checked rather than approximated with a second glob language.
|
|
44
|
+
const matchFiles = requireMatcher(ts.matchFiles);
|
|
45
|
+
const absolute = path.resolve(root, filename).replaceAll('\\', '/');
|
|
46
|
+
const virtualDirectories = new Map();
|
|
47
|
+
for (let current = absolute; path.dirname(current) !== current; current = path.dirname(current)) {
|
|
48
|
+
virtualDirectories.set(path.dirname(current), current);
|
|
49
|
+
}
|
|
50
|
+
const host = {
|
|
51
|
+
...ts.sys,
|
|
52
|
+
readDirectory: (directory, extensions, excludes, includes, depth) => [...new Set([
|
|
53
|
+
...ts.sys.readDirectory(directory, extensions, excludes, includes, depth),
|
|
54
|
+
...matchFiles(directory, extensions, excludes, includes, ts.sys.useCaseSensitiveFileNames, root, depth,
|
|
55
|
+
entry => {
|
|
56
|
+
const added = virtualDirectories.get(path.resolve(entry).replaceAll('\\', '/'));
|
|
57
|
+
return { files: added === absolute ? [path.basename(added)] : [],
|
|
58
|
+
directories: added && added !== absolute ? [path.basename(added)] : [] };
|
|
59
|
+
}, ts.sys.realpath),
|
|
60
|
+
])],
|
|
61
|
+
};
|
|
62
|
+
const config = this.read(host);
|
|
63
|
+
return { config, absolute, virtualDirectories };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
includes(filename) {
|
|
67
|
+
const { config, absolute } = this.prospective(filename);
|
|
68
|
+
return config.fileNames.some(value => path.resolve(value) === path.resolve(absolute));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
outputFor(filename, content) {
|
|
72
|
+
const { ts, root } = this;
|
|
73
|
+
const { config, absolute, virtualDirectories } = this.prospective(filename);
|
|
74
|
+
if (!config.fileNames.some(value => path.resolve(value) === path.resolve(absolute))) {
|
|
75
|
+
throw new Error(`${filename} is excluded from ${this.configFile}. Adjust the configuration yourself or choose --source-dir.`);
|
|
76
|
+
}
|
|
77
|
+
// Compiler options and syntactic diagnostics require no application execution.
|
|
78
|
+
const compilerHost = ts.createCompilerHost(config.options);
|
|
79
|
+
const readSource = compilerHost.readFile;
|
|
80
|
+
const fileExists = compilerHost.fileExists;
|
|
81
|
+
const directoryExists = compilerHost.directoryExists;
|
|
82
|
+
compilerHost.getCurrentDirectory = () => root;
|
|
83
|
+
compilerHost.readFile = name => path.resolve(name) === path.resolve(absolute) ? content : readSource(name);
|
|
84
|
+
compilerHost.fileExists = name => path.resolve(name) === path.resolve(absolute) || fileExists(name);
|
|
85
|
+
compilerHost.directoryExists = name => virtualDirectories.has(path.resolve(name).replaceAll('\\', '/')) || directoryExists(name);
|
|
86
|
+
const program = ts.createProgram(config.fileNames, config.options, compilerHost);
|
|
87
|
+
const sourceFile = program.getSourceFile(absolute);
|
|
88
|
+
const errors = [...program.getOptionsDiagnostics(), ...program.getSyntacticDiagnostics(sourceFile), ...program.getSemanticDiagnostics(sourceFile)];
|
|
89
|
+
if (errors.length) throw new Error(errors.map(error => ts.flattenDiagnosticMessageText(error.messageText, '\n')).join('\n'));
|
|
90
|
+
const emitted = emissionMap(ts, program);
|
|
91
|
+
const output = emitted.get(absolute);
|
|
92
|
+
if (!output) throw new Error('Expected one Node JavaScript output for the new source file.');
|
|
93
|
+
assertSafePath(path.resolve(output), root);
|
|
94
|
+
if (!config.options.rootDir) {
|
|
95
|
+
const previousHost = ts.createCompilerHost(this.config.options);
|
|
96
|
+
previousHost.getCurrentDirectory = () => root;
|
|
97
|
+
const previous = ts.createProgram(this.config.fileNames, this.config.options, previousHost);
|
|
98
|
+
for (const [file, previousOutput] of emissionMap(ts, previous)) {
|
|
99
|
+
if (emitted.get(file) !== previousOutput) {
|
|
100
|
+
throw new Error('Adding this source would relocate existing compiled files. Set an explicit rootDir before adding it.');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const emittedModule = [ts.ModuleKind.Node16, ts.ModuleKind.NodeNext].includes(config.options.module)
|
|
105
|
+
? sourceFile.impliedNodeFormat === ts.ModuleKind.ESNext : false;
|
|
106
|
+
if (packageIsModule(path.dirname(output)) !== emittedModule) {
|
|
107
|
+
throw new Error('Source and output module formats disagree with package.json type. Fix the layout; add does not copy or rewrite package manifests.');
|
|
108
|
+
}
|
|
109
|
+
return path.relative(root, output).replaceAll('\\', '/');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function emissionMap(ts, program) {
|
|
114
|
+
const outputs = new Map();
|
|
115
|
+
const result = program.emit(undefined, (filename, _text, _bom, _error, sources) => {
|
|
116
|
+
if (!/\.[cm]?js$/.test(filename)) return;
|
|
117
|
+
for (const source of sources) {
|
|
118
|
+
const name = path.resolve(source.fileName).replaceAll('\\', '/');
|
|
119
|
+
outputs.set(name, filename);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
if (result.diagnostics.length) throw new Error(result.diagnostics.map(error => ts.flattenDiagnosticMessageText(error.messageText, '\n')).join('\n'));
|
|
123
|
+
return outputs;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function requireMatcher(matcher) {
|
|
127
|
+
if (typeof matcher !== 'function') throw new Error('This TypeScript compiler lacks the file matcher needed for a safe preview.');
|
|
128
|
+
return matcher;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function packageIsModule(directory) {
|
|
132
|
+
for (let current = directory; ; current = path.dirname(current)) {
|
|
133
|
+
const manifest = path.join(current, 'package.json');
|
|
134
|
+
if (fs.existsSync(manifest)) return JSON.parse(fs.readFileSync(manifest, 'utf8')).type === 'module';
|
|
135
|
+
if (path.dirname(current) === current) return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = AdditionLayout;
|
|
140
|
+
module.exports.requireMatcher = requireMatcher;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
/** Shared, exclusive-create writer. A plan is preflighted fully, never silently overwritten. */
|
|
7
|
+
class FilePlan {
|
|
8
|
+
constructor(root, files) {
|
|
9
|
+
this.root = path.resolve(root);
|
|
10
|
+
this.files = files;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
write({ dryRun = false, existing = 'skip' } = {}) {
|
|
14
|
+
if (!['skip', 'reject'].includes(existing)) throw new Error('Unknown existing-file policy.');
|
|
15
|
+
const created = [], skipped = [], planned = [], destinations = new Map();
|
|
16
|
+
for (const file of this.files) {
|
|
17
|
+
const destination = path.resolve(this.root, file.path);
|
|
18
|
+
assertSafePath(destination, this.root);
|
|
19
|
+
registerDestination(destinations, this.root, destination);
|
|
20
|
+
const entry = fs.lstatSync(destination, { throwIfNoEntry: false });
|
|
21
|
+
if (entry) {
|
|
22
|
+
if (!entry.isFile()) throw new Error(`Expected a file at ${destination}; nothing was written.`);
|
|
23
|
+
if (existing === 'reject') throw new Error(`Refusing to overwrite ${destination}; nothing was written.`);
|
|
24
|
+
skipped.push(file.path);
|
|
25
|
+
} else planned.push(file);
|
|
26
|
+
}
|
|
27
|
+
if (!dryRun) {
|
|
28
|
+
let attempted;
|
|
29
|
+
try {
|
|
30
|
+
for (const file of planned) {
|
|
31
|
+
attempted = file.path;
|
|
32
|
+
const destination = path.resolve(this.root, file.path);
|
|
33
|
+
assertSafePath(destination, this.root);
|
|
34
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
35
|
+
fs.writeFileSync(destination, file.content, { encoding: 'utf8', flag: 'wx' });
|
|
36
|
+
created.push(file.path);
|
|
37
|
+
}
|
|
38
|
+
} catch (error) {
|
|
39
|
+
throw new Error(`${error.message} Completed before failure: ${created.length ? created.join(', ') : 'none'}. Attempted: ${attempted}; that file or its directories may exist.`, { cause: error });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return Object.freeze({ root: this.root, created: Object.freeze(created), skipped: Object.freeze(skipped),
|
|
43
|
+
planned: Object.freeze(planned.map(file => file.path)) });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function registerDestination(nodes, root, destination) {
|
|
48
|
+
const parts = path.relative(root, destination).split(path.sep);
|
|
49
|
+
let current = root;
|
|
50
|
+
for (let index = 0; index < parts.length; index++) {
|
|
51
|
+
current = path.join(current, parts[index]);
|
|
52
|
+
const kind = index === parts.length - 1 ? 'file' : 'directory';
|
|
53
|
+
const key = current.toLowerCase(), previous = nodes.get(key);
|
|
54
|
+
if (previous && (previous.path !== current || previous.kind !== kind || kind === 'file')) {
|
|
55
|
+
throw new Error(`Conflicting planned destination: ${current}`);
|
|
56
|
+
}
|
|
57
|
+
nodes.set(key, { path: current, kind });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function assertSafePath(destination, root) {
|
|
62
|
+
const relative = path.relative(root, destination);
|
|
63
|
+
if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
64
|
+
throw new Error(`Destination must be a file inside the project: ${destination}`);
|
|
65
|
+
}
|
|
66
|
+
for (const part of destination.slice(path.parse(destination).root.length).split(path.sep)) {
|
|
67
|
+
if (/[<>:"\\|?*\u0000-\u001f]/.test(part) || /[. ]$/.test(part) || part.length > 255 ||
|
|
68
|
+
/^(con|conin\$|conout\$|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part.normalize('NFKC'))) {
|
|
69
|
+
throw new Error(`Nonportable destination segment: ${part}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const ancestors = [];
|
|
73
|
+
for (let current = destination; ; current = path.dirname(current)) {
|
|
74
|
+
ancestors.push(current);
|
|
75
|
+
if (path.dirname(current) === current) break;
|
|
76
|
+
}
|
|
77
|
+
// Check parents before descendants: POSIX lstat throws ENOTDIR below a file,
|
|
78
|
+
// and no descendant should be inspected through an unchecked symbolic link.
|
|
79
|
+
for (const current of ancestors.reverse()) {
|
|
80
|
+
const entry = fs.lstatSync(current, { throwIfNoEntry: false });
|
|
81
|
+
if (entry?.isSymbolicLink()) throw new Error(`Refusing to initialize through a symbolic link: ${current}`);
|
|
82
|
+
if (current !== destination && entry && !entry.isDirectory()) throw new Error(`Expected a directory at ${current}; nothing was written.`);
|
|
83
|
+
const parent = path.dirname(current);
|
|
84
|
+
if (parent === current) continue;
|
|
85
|
+
// Portable plans must not create case aliases even on a case-sensitive host.
|
|
86
|
+
if ((current === root || current.startsWith(`${root}${path.sep}`)) && fs.lstatSync(parent, { throwIfNoEntry: false })?.isDirectory()) {
|
|
87
|
+
const name = path.basename(current);
|
|
88
|
+
const alias = fs.readdirSync(parent).find(value => value.toLowerCase() === name.toLowerCase() && value !== name);
|
|
89
|
+
if (alias) throw new Error(`Case-colliding destination: ${current} conflicts with ${alias}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { FilePlan, assertSafePath };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const AdditionLayout = require('./AdditionLayout');
|
|
6
|
+
const { FilePlan } = require('./FilePlan');
|
|
7
|
+
const { resolveDependency } = require('./ProjectConfig');
|
|
8
|
+
|
|
9
|
+
const KINDS = Object.freeze(['page', 'component', 'socket-route']);
|
|
10
|
+
const relative = (root, file) => path.relative(root, file).replaceAll('\\', '/');
|
|
11
|
+
const fill = (template, values) => template.replace(/__([A-Z]+)__/g, (_, key) => values[key]);
|
|
12
|
+
|
|
13
|
+
class ProjectAddition {
|
|
14
|
+
add(target, { kind, name, dryRun = false, testDir = 'test', ...layoutOptions }) {
|
|
15
|
+
if (!KINDS.includes(kind)) throw new Error(`Addition kind must be ${KINDS.join(', ')}.`);
|
|
16
|
+
if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(name) || name.length > 64) {
|
|
17
|
+
throw new Error('Use a lowercase kebab-case name, starting with a letter (maximum 64 characters).');
|
|
18
|
+
}
|
|
19
|
+
const root = path.resolve(target);
|
|
20
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
21
|
+
for (const dependency of ['redweb', 'ws', ...(kind === 'socket-route' ? ['zod'] : [])]) {
|
|
22
|
+
const runtime = dependency !== 'ws';
|
|
23
|
+
if (!(manifest.dependencies?.[dependency] || (!runtime && manifest.devDependencies?.[dependency])) || !resolveDependency(root, dependency)) {
|
|
24
|
+
throw new Error(`${dependency} must be explicitly declared and installed. Run npm install ${runtime ? '' : '--save-dev '}${dependency}.`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const layout = new AdditionLayout(root, layoutOptions);
|
|
28
|
+
if (kind !== 'socket-route' && (layout.config.options.jsxImportSource !== 'redweb' ||
|
|
29
|
+
![layout.ts.JsxEmit.ReactJSX, layout.ts.JsxEmit.ReactJSXDev].includes(layout.config.options.jsx))) {
|
|
30
|
+
throw new Error('Use Redweb JSX settings: extend redweb/tsconfig.json.');
|
|
31
|
+
}
|
|
32
|
+
const className = name.split('-').map(part => part[0].toUpperCase() + part.slice(1)).join('') +
|
|
33
|
+
{ page: 'Page', component: 'Component', 'socket-route': 'Route' }[kind];
|
|
34
|
+
const directory = { page: 'pages', component: 'components', 'socket-route': 'socket-routes' }[kind];
|
|
35
|
+
const source = relative(root, path.join(layout.sourceDir, directory, `${name}.${kind === 'socket-route' ? 'ts' : 'tsx'}`));
|
|
36
|
+
const read = file => fs.readFileSync(path.resolve(__dirname, '../../recipes/add', file), 'utf8');
|
|
37
|
+
const values = { NAME: name, CLASS: className, TITLE: name.split('-').join(' '), KIND: kind,
|
|
38
|
+
DECORATOR: kind, DECORATION: kind === 'page' ? `@page('/${name}')` : '@component()' };
|
|
39
|
+
const content = fill(read(kind === 'socket-route' ? 'socket-route.ts' : 'live.tsx'), values);
|
|
40
|
+
const testFile = relative(root, path.resolve(root, testDir, `${name}.${kind}.test.cjs`));
|
|
41
|
+
if (layout.includes(testFile)) throw new Error('The generated CJS test would enter TypeScript compilation. Choose --test-dir outside the configured inputs or explicitly exclude that test directory.');
|
|
42
|
+
const output = layout.outputFor(source, content);
|
|
43
|
+
const files = [
|
|
44
|
+
{ path: source, content },
|
|
45
|
+
{ path: testFile, content: fill(read('artifact.test.cjs'), { ...values,
|
|
46
|
+
IMPORT: JSON.stringify(relative(path.dirname(path.resolve(root, testFile)), path.resolve(root, output))) }) },
|
|
47
|
+
];
|
|
48
|
+
const result = new FilePlan(root, files).write({ dryRun, existing: 'reject' });
|
|
49
|
+
return { ...result, kind, name, source, output, test: testFile,
|
|
50
|
+
registration: { status: 'pending', export: className,
|
|
51
|
+
importFromProjectRoot: `import { ${className} } from ${JSON.stringify('./' + source.replace(/\.tsx?$/, '.js'))};`,
|
|
52
|
+
instruction: kind === 'component' ? `Import ${className} from ${source}, create a page field such as widget = new ${className}(), and render {this.widget}.`
|
|
53
|
+
: kind === 'page' ? `Import ${className} from ${source} and add it to your start([...]) page list.`
|
|
54
|
+
: `Import ${className} from ${source} and add it to your SocketServer routes list.` },
|
|
55
|
+
verification: { build: ['npx', 'tsc', '-p', layout.configFile], test: ['node', '--test', testFile],
|
|
56
|
+
note: 'Run the build, then the test from the project root. Existing package scripts and registrations are unchanged.' } };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = { ProjectAddition, KINDS };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { createRequire } = require('module');
|
|
6
|
+
|
|
7
|
+
function resolveDependency(root, name) {
|
|
8
|
+
const requireFromProject = createRequire(path.join(root, 'package.json'));
|
|
9
|
+
let current = root;
|
|
10
|
+
while (true) {
|
|
11
|
+
const candidate = path.join(current, 'node_modules', name);
|
|
12
|
+
if (fs.existsSync(candidate)) return requireFromProject.resolve(candidate);
|
|
13
|
+
const parent = path.dirname(current);
|
|
14
|
+
if (parent === current) return null;
|
|
15
|
+
current = parent;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function readConfig(ts, root, filename = 'tsconfig.json', host = ts.sys) {
|
|
20
|
+
const configPath = path.resolve(root, filename).replaceAll('\\', '/');
|
|
21
|
+
const read = ts.readConfigFile(configPath, host.readFile);
|
|
22
|
+
if (read.error) return { syntaxError: read.error };
|
|
23
|
+
return { config: ts.parseJsonConfigFileContent(read.config, host, path.dirname(configPath), undefined, configPath) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = { resolveDependency, readConfig };
|