@potentiajs/core 0.1.0-preview.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 +45 -0
- package/LICENSE +21 -0
- package/README.md +441 -0
- package/bin/potentia.js +14 -0
- package/examples/file-routing-basic/README.md +156 -0
- package/examples/file-routing-basic/app.js +16 -0
- package/examples/file-routing-basic/generate.js +21 -0
- package/examples/file-routing-basic/routes/health.js +3 -0
- package/examples/file-routing-basic/routes/index.js +3 -0
- package/examples/file-routing-basic/routes/users/[id].js +6 -0
- package/examples/file-routing-basic/routes/users/_routes.js +15 -0
- package/examples/form-rendering-basic/README.md +43 -0
- package/examples/form-rendering-basic/index.js +120 -0
- package/examples/full-flow-basic/README.md +141 -0
- package/examples/full-flow-basic/app.js +16 -0
- package/examples/full-flow-basic/form.js +205 -0
- package/examples/full-flow-basic/generate.js +21 -0
- package/examples/full-flow-basic/routes/index.js +5 -0
- package/examples/full-flow-basic/routes/users/[id].js +5 -0
- package/examples/full-flow-basic/routes/users/_routes.js +8 -0
- package/examples/full-flow-basic/routes/users/index.js +5 -0
- package/examples/full-flow-basic/routes/users/new.js +5 -0
- package/package.json +90 -0
- package/src/cli.js +407 -0
- package/src/dev/file-routing/diagnostics.js +24 -0
- package/src/dev/file-routing/generator.js +126 -0
- package/src/dev/file-routing/index.js +5 -0
- package/src/dev/file-routing/path-mapping.js +125 -0
- package/src/dev/file-routing/scanner.js +154 -0
- package/src/dev/file-routing/writer.js +100 -0
- package/src/file-routing.d.ts +36 -0
- package/src/file-routing.js +1 -0
- package/src/forms.d.ts +9 -0
- package/src/forms.js +334 -0
- package/src/index.d.ts +185 -0
- package/src/index.js +15 -0
- package/src/index.mjs +25 -0
- package/src/kernel/action-projection.js +35 -0
- package/src/kernel/action.js +172 -0
- package/src/kernel/app.js +144 -0
- package/src/kernel/context.js +37 -0
- package/src/kernel/contract-projection.js +129 -0
- package/src/kernel/contract.js +135 -0
- package/src/kernel/diagnostics.js +147 -0
- package/src/kernel/effect.js +119 -0
- package/src/kernel/error.js +93 -0
- package/src/kernel/form-projection.js +251 -0
- package/src/kernel/form-state.js +173 -0
- package/src/kernel/plugin.js +46 -0
- package/src/kernel/response.js +187 -0
- package/src/kernel/result.js +45 -0
- package/src/kernel/route-collection.js +148 -0
- package/src/kernel/route-id.js +11 -0
- package/src/kernel/route-manifest.js +129 -0
- package/src/kernel/route-projection.js +139 -0
- package/src/kernel/route.js +156 -0
package/src/forms.js
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
const DEFAULT_METHOD = 'POST';
|
|
2
|
+
const DEFAULT_ENCTYPE = 'application/x-www-form-urlencoded';
|
|
3
|
+
const DEFAULT_SUBMIT_LABEL = 'Submit';
|
|
4
|
+
const DEFAULT_ID_PREFIX = 'potentia-form';
|
|
5
|
+
const OPAQUE_MESSAGE = 'This form cannot be rendered from opaque metadata.';
|
|
6
|
+
const INPUT_TYPES = new Set(['text', 'email', 'url', 'tel', 'password', 'number', 'checkbox', 'textarea', 'hidden']);
|
|
7
|
+
const INPUT_ELEMENT_TYPES = new Set(['text', 'email', 'url', 'tel', 'password', 'number', 'checkbox']);
|
|
8
|
+
|
|
9
|
+
export function renderForm(formProjection, options = {}) {
|
|
10
|
+
const projection = normalizeProjection(formProjection);
|
|
11
|
+
const state = normalizeState(options.state);
|
|
12
|
+
const method = normalizeMethod(options.method || projection.method);
|
|
13
|
+
const encType = normalizeEncType(projection.encType);
|
|
14
|
+
const action = Object.prototype.hasOwnProperty.call(options, 'action') ? options.action : '#';
|
|
15
|
+
const submitLabel = optionString(options.submitLabel, DEFAULT_SUBMIT_LABEL);
|
|
16
|
+
const idPrefix = safeIdPart(optionString(options.idPrefix, optionString(projection.id, DEFAULT_ID_PREFIX)));
|
|
17
|
+
const formId = optionString(projection.id, null);
|
|
18
|
+
const attributes = {
|
|
19
|
+
method: method,
|
|
20
|
+
action: optionString(action, '#'),
|
|
21
|
+
enctype: encType
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
if (formId) attributes['data-potentia-form'] = formId;
|
|
25
|
+
|
|
26
|
+
const parts = [`<form${renderAttributes(attributes)}>`];
|
|
27
|
+
|
|
28
|
+
if (projection.opaque || !Array.isArray(projection.fields)) {
|
|
29
|
+
parts.push(` <div data-potentia-form-error="opaque">${escapeHtml(OPAQUE_MESSAGE)}</div>`);
|
|
30
|
+
parts.push('</form>');
|
|
31
|
+
return parts.join('\n');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const rootErrors = getRootErrors(projection, state);
|
|
35
|
+
if (rootErrors.length > 0) parts.push(indent(renderRootErrors(rootErrors), 2));
|
|
36
|
+
|
|
37
|
+
for (const field of projection.fields) {
|
|
38
|
+
parts.push(indent(renderField(field, { idPrefix: idPrefix, state: state }), 2));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
parts.push(` <button${renderAttributes({ type: 'submit', 'data-potentia-submit': true })}>${escapeHtml(submitLabel)}</button>`);
|
|
42
|
+
parts.push('</form>');
|
|
43
|
+
|
|
44
|
+
return parts.join('\n');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function renderField(field, context) {
|
|
48
|
+
const normalized = normalizeField(field);
|
|
49
|
+
const errors = getFieldErrors(context.state, normalized.field);
|
|
50
|
+
const values = fieldValues(normalized, context.state);
|
|
51
|
+
const firstId = fieldId(context.idPrefix, normalized.field, 0, values.length);
|
|
52
|
+
const helpId = `${firstId}-help`;
|
|
53
|
+
const errorsId = `${firstId}-errors`;
|
|
54
|
+
|
|
55
|
+
if (normalized.inputType === 'hidden') {
|
|
56
|
+
return renderHiddenControl(normalized, {
|
|
57
|
+
id: firstId,
|
|
58
|
+
value: values[0]
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const controls = values.map((value, index) => renderControl(normalized, {
|
|
63
|
+
id: fieldId(context.idPrefix, normalized.field, index, values.length),
|
|
64
|
+
value: value,
|
|
65
|
+
describedBy: describedBy(normalized.help ? helpId : null, errors.length > 0 ? errorsId : null),
|
|
66
|
+
invalid: errors.length > 0
|
|
67
|
+
}));
|
|
68
|
+
const wrapper = [`<div${renderAttributes({ 'data-potentia-field': normalized.field })}>`];
|
|
69
|
+
|
|
70
|
+
wrapper.push(` <label${renderAttributes({ 'data-potentia-label': normalized.field, for: firstId })}>${escapeHtml(normalized.label)}</label>`);
|
|
71
|
+
for (const control of controls) wrapper.push(` ${control}`);
|
|
72
|
+
if (normalized.help) wrapper.push(` <div${renderAttributes({ 'data-potentia-help': normalized.field, id: helpId })}>${escapeHtml(normalized.help)}</div>`);
|
|
73
|
+
if (errors.length > 0) wrapper.push(indent(renderFieldErrors(normalized.field, errors, { id: errorsId }), 2));
|
|
74
|
+
wrapper.push('</div>');
|
|
75
|
+
|
|
76
|
+
return wrapper.join('\n');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function renderControl(field, context) {
|
|
80
|
+
if (Array.isArray(field.options) && field.options.length > 0) return renderSelect(field, context);
|
|
81
|
+
if (field.inputType === 'textarea') return renderTextarea(field, context);
|
|
82
|
+
|
|
83
|
+
const type = INPUT_ELEMENT_TYPES.has(field.inputType) ? field.inputType : 'text';
|
|
84
|
+
const attributes = baseControlAttributes(field, context, { type: type });
|
|
85
|
+
|
|
86
|
+
if (type === 'checkbox') {
|
|
87
|
+
attributes.value = 'true';
|
|
88
|
+
if (isCheckedValue(context.value)) attributes.checked = true;
|
|
89
|
+
} else if (!field.sensitive && context.value !== undefined && context.value !== null) {
|
|
90
|
+
attributes.value = context.value;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return `<input${renderAttributes(attributes)}>`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function renderTextarea(field, context) {
|
|
97
|
+
const attributes = baseControlAttributes(field, context);
|
|
98
|
+
const value = !field.sensitive && context.value !== undefined && context.value !== null ? context.value : '';
|
|
99
|
+
return `<textarea${renderAttributes(attributes)}>${escapeHtml(value)}</textarea>`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function renderHiddenControl(field, context) {
|
|
103
|
+
const attributes = {
|
|
104
|
+
'data-potentia-control': field.field,
|
|
105
|
+
id: context.id,
|
|
106
|
+
name: field.name,
|
|
107
|
+
type: 'hidden'
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
if (!field.sensitive && context.value !== undefined && context.value !== null) attributes.value = context.value;
|
|
111
|
+
|
|
112
|
+
return `<input${renderAttributes(attributes)}>`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function renderSelect(field, context) {
|
|
116
|
+
const attributes = baseControlAttributes(field, context);
|
|
117
|
+
if (field.multiple) attributes.multiple = true;
|
|
118
|
+
|
|
119
|
+
const selected = Array.isArray(context.value) ? context.value.map(String) : [String(context.value ?? '')];
|
|
120
|
+
const options = field.options
|
|
121
|
+
.map(normalizeOption)
|
|
122
|
+
.filter(Boolean)
|
|
123
|
+
.map((option) => ` <option${renderAttributes({ value: option.value, selected: selected.includes(String(option.value)) })}>${escapeHtml(option.label)}</option>`);
|
|
124
|
+
|
|
125
|
+
return [`<select${renderAttributes(attributes)}>`, ...options, '</select>'].join('\n');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function baseControlAttributes(field, context, extra = {}) {
|
|
129
|
+
const attributes = {
|
|
130
|
+
'data-potentia-control': field.field,
|
|
131
|
+
id: context.id,
|
|
132
|
+
name: field.name,
|
|
133
|
+
...extra
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
if (field.required) attributes.required = true;
|
|
137
|
+
if (field.placeholder) attributes.placeholder = field.placeholder;
|
|
138
|
+
if (context.describedBy) attributes['aria-describedby'] = context.describedBy;
|
|
139
|
+
if (context.invalid) attributes['aria-invalid'] = 'true';
|
|
140
|
+
if (field.input && typeof field.input.autocomplete === 'string' && field.input.autocomplete.length > 0) {
|
|
141
|
+
attributes.autocomplete = field.input.autocomplete;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return attributes;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function renderRootErrors(errors) {
|
|
148
|
+
const parts = ['<div data-potentia-form-errors>'];
|
|
149
|
+
for (const error of errors) parts.push(` <p>${escapeHtml(errorMessage(error))}</p>`);
|
|
150
|
+
parts.push('</div>');
|
|
151
|
+
return parts.join('\n');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function renderFieldErrors(field, errors, options = {}) {
|
|
155
|
+
const parts = [`<div${renderAttributes({ 'data-potentia-errors-for': field, id: options.id })}>`];
|
|
156
|
+
for (const error of errors) parts.push(` <p>${escapeHtml(errorMessage(error))}</p>`);
|
|
157
|
+
parts.push('</div>');
|
|
158
|
+
return parts.join('\n');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function escapeHtml(value) {
|
|
162
|
+
return String(value ?? '')
|
|
163
|
+
.replace(/&/g, '&')
|
|
164
|
+
.replace(/</g, '<')
|
|
165
|
+
.replace(/>/g, '>')
|
|
166
|
+
.replace(/"/g, '"')
|
|
167
|
+
.replace(/'/g, ''');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function escapeAttribute(value) {
|
|
171
|
+
return escapeHtml(value);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function renderAttributes(attributes) {
|
|
175
|
+
const rendered = [];
|
|
176
|
+
for (const [name, value] of Object.entries(attributes)) {
|
|
177
|
+
if (value === false || value === null || value === undefined) continue;
|
|
178
|
+
const safeName = safeAttributeName(name);
|
|
179
|
+
if (!safeName) continue;
|
|
180
|
+
if (value === true) {
|
|
181
|
+
rendered.push(safeName);
|
|
182
|
+
} else {
|
|
183
|
+
rendered.push(`${safeName}="${escapeAttribute(value)}"`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return rendered.length > 0 ? ` ${rendered.join(' ')}` : '';
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function normalizeMethod(method) {
|
|
190
|
+
const normalized = typeof method === 'string' ? method.toUpperCase() : DEFAULT_METHOD;
|
|
191
|
+
return normalized === 'GET' || normalized === 'POST' ? normalized : DEFAULT_METHOD;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function normalizeEncType(encType) {
|
|
195
|
+
return encType === DEFAULT_ENCTYPE ? DEFAULT_ENCTYPE : DEFAULT_ENCTYPE;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function safeIdPart(value) {
|
|
199
|
+
const normalized = String(value ?? '')
|
|
200
|
+
.trim()
|
|
201
|
+
.replace(/[^a-zA-Z0-9_-]+/g, '-')
|
|
202
|
+
.replace(/^-+|-+$/g, '')
|
|
203
|
+
.toLowerCase();
|
|
204
|
+
return normalized.length > 0 ? normalized : DEFAULT_ID_PREFIX;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function safeAttributeName(value) {
|
|
208
|
+
return /^[a-zA-Z_:][a-zA-Z0-9:_.-]*$/.test(value) ? value : null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function normalizeProjection(value) {
|
|
212
|
+
if (!value || typeof value !== 'object' || value.kind !== 'form') {
|
|
213
|
+
return {
|
|
214
|
+
kind: 'form',
|
|
215
|
+
id: null,
|
|
216
|
+
method: DEFAULT_METHOD,
|
|
217
|
+
encType: DEFAULT_ENCTYPE,
|
|
218
|
+
opaque: true,
|
|
219
|
+
fields: null,
|
|
220
|
+
errors: { rootKey: '_form' }
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
return value;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function normalizeState(value) {
|
|
227
|
+
if (!value || typeof value !== 'object' || value.kind !== 'form') return { values: {}, errors: {} };
|
|
228
|
+
return {
|
|
229
|
+
values: value.values && typeof value.values === 'object' ? value.values : {},
|
|
230
|
+
errors: value.errors && typeof value.errors === 'object' ? value.errors : {}
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function normalizeField(field) {
|
|
235
|
+
const fieldName = optionString(field && (field.field || field.name), 'field');
|
|
236
|
+
const input = normalizeInput(field && field.input);
|
|
237
|
+
return {
|
|
238
|
+
name: optionString(field && field.name, fieldName),
|
|
239
|
+
field: fieldName,
|
|
240
|
+
label: optionString(field && field.label, fieldName),
|
|
241
|
+
help: optionString(field && field.help, null),
|
|
242
|
+
placeholder: optionString(field && field.placeholder, null),
|
|
243
|
+
input: input,
|
|
244
|
+
inputType: getFieldInputType(field),
|
|
245
|
+
required: Boolean(field && field.required),
|
|
246
|
+
multiple: Boolean(field && field.multiple),
|
|
247
|
+
sensitive: Boolean(field && field.sensitive),
|
|
248
|
+
options: Array.isArray(field && field.options) ? field.options : null,
|
|
249
|
+
defaultValue: field && Object.prototype.hasOwnProperty.call(field, 'defaultValue') ? field.defaultValue : undefined
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function normalizeInput(input) {
|
|
254
|
+
if (input && typeof input === 'object') return input;
|
|
255
|
+
if (typeof input === 'string' && input.length > 0) return { type: input };
|
|
256
|
+
return { type: 'text' };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function getFieldInputType(field) {
|
|
260
|
+
const input = field && field.input;
|
|
261
|
+
let type = null;
|
|
262
|
+
|
|
263
|
+
if (typeof input === 'string') type = input;
|
|
264
|
+
if (input && typeof input === 'object' && typeof input.type === 'string') type = input.type;
|
|
265
|
+
|
|
266
|
+
const normalized = typeof type === 'string' ? type.toLowerCase() : 'text';
|
|
267
|
+
return INPUT_TYPES.has(normalized) ? normalized : 'text';
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function normalizeOption(option) {
|
|
271
|
+
if (typeof option === 'string' || typeof option === 'number' || typeof option === 'boolean') {
|
|
272
|
+
return { value: option, label: option };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (option && typeof option === 'object') {
|
|
276
|
+
if (!Object.prototype.hasOwnProperty.call(option, 'value')) return null;
|
|
277
|
+
const value = option.value;
|
|
278
|
+
if (value === undefined || value === null) return null;
|
|
279
|
+
const label = Object.prototype.hasOwnProperty.call(option, 'label') ? option.label : value;
|
|
280
|
+
return { value: value, label: label ?? '' };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function fieldValues(field, state) {
|
|
287
|
+
if (field.sensitive) return [undefined];
|
|
288
|
+
const value = Object.prototype.hasOwnProperty.call(state.values, field.field) ? state.values[field.field] : field.defaultValue;
|
|
289
|
+
if (field.multiple && Array.isArray(value) && value.length > 0) return value;
|
|
290
|
+
return [value];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function getRootErrors(projection, state) {
|
|
294
|
+
const key = projection && projection.errors && typeof projection.errors.rootKey === 'string' && projection.errors.rootKey.length > 0 ? projection.errors.rootKey : '_form';
|
|
295
|
+
return arrayErrors(state.errors[key]);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function getFieldErrors(state, field) {
|
|
299
|
+
return arrayErrors(state.errors[field]);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function arrayErrors(value) {
|
|
303
|
+
if (Array.isArray(value)) return value;
|
|
304
|
+
return value ? [value] : [];
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function errorMessage(error) {
|
|
308
|
+
if (error && typeof error === 'object' && typeof error.message === 'string' && error.message.length > 0) return error.message;
|
|
309
|
+
if (typeof error === 'string') return error;
|
|
310
|
+
return 'Invalid value';
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function fieldId(prefix, field, index, count) {
|
|
314
|
+
const base = `${safeIdPart(prefix)}-${safeIdPart(field)}`;
|
|
315
|
+
return count > 1 ? `${base}-${index + 1}` : base;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function describedBy(...ids) {
|
|
319
|
+
const valid = ids.filter((id) => typeof id === 'string' && id.length > 0);
|
|
320
|
+
return valid.length > 0 ? valid.join(' ') : null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function isCheckedValue(value) {
|
|
324
|
+
return value === true || value === 'true' || value === 'on' || value === '1' || value === 1;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function optionString(value, fallback) {
|
|
328
|
+
return typeof value === 'string' && value.length > 0 ? value : fallback;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function indent(value, spaces) {
|
|
332
|
+
const padding = ' '.repeat(spaces);
|
|
333
|
+
return String(value).split('\n').map((line) => `${padding}${line}`).join('\n');
|
|
334
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Experimental TypeScript declarations for potentiajs.
|
|
2
|
+
// PotentiaJS remains a plain JavaScript ESM package and has no stable public API yet.
|
|
3
|
+
|
|
4
|
+
export type MaybePromise<T> = T | PromiseLike<T>;
|
|
5
|
+
export type Handler = (context: RequestContext) => MaybePromise<unknown>;
|
|
6
|
+
export type EffectDescriptor = {
|
|
7
|
+
kind: 'effect';
|
|
8
|
+
fn: (...args: unknown[]) => Iterator<unknown> | AsyncIterator<unknown>;
|
|
9
|
+
meta?: unknown;
|
|
10
|
+
};
|
|
11
|
+
export type RouteHandler = Handler | EffectDescriptor | ActionDescriptor;
|
|
12
|
+
export type Hook = Handler | EffectDescriptor;
|
|
13
|
+
export type Contract = unknown;
|
|
14
|
+
export type Metadata = Record<string, unknown>;
|
|
15
|
+
|
|
16
|
+
export interface RequestContext {
|
|
17
|
+
request: Request;
|
|
18
|
+
params: Record<string, string>;
|
|
19
|
+
query: Record<string, string | string[] | undefined>;
|
|
20
|
+
input?: unknown;
|
|
21
|
+
meta?: Metadata;
|
|
22
|
+
[key: string]: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RouteOptions {
|
|
26
|
+
params?: Contract;
|
|
27
|
+
query?: Contract;
|
|
28
|
+
body?: Contract;
|
|
29
|
+
response?: Contract;
|
|
30
|
+
hooks?: Hooks;
|
|
31
|
+
name?: string;
|
|
32
|
+
meta?: Metadata;
|
|
33
|
+
source?: unknown;
|
|
34
|
+
[key: string]: unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface Hooks {
|
|
38
|
+
beforeRequest?: Hook[];
|
|
39
|
+
afterResponse?: Hook[];
|
|
40
|
+
onError?: Hook[];
|
|
41
|
+
[key: string]: Hook[] | undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface RouteDescriptor {
|
|
45
|
+
kind?: string;
|
|
46
|
+
method: string;
|
|
47
|
+
path: string;
|
|
48
|
+
handler: RouteHandler;
|
|
49
|
+
options?: RouteOptions;
|
|
50
|
+
[key: string]: unknown;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface RoutesOptions {
|
|
54
|
+
prefix?: string;
|
|
55
|
+
routes?: RouteDescriptor[];
|
|
56
|
+
hooks?: Hooks;
|
|
57
|
+
params?: Contract;
|
|
58
|
+
query?: Contract;
|
|
59
|
+
body?: Contract;
|
|
60
|
+
response?: Contract;
|
|
61
|
+
meta?: Metadata;
|
|
62
|
+
source?: unknown;
|
|
63
|
+
[key: string]: unknown;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface RoutesDescriptor {
|
|
67
|
+
kind?: string;
|
|
68
|
+
prefix?: string;
|
|
69
|
+
routes: RouteDescriptor[];
|
|
70
|
+
options?: RoutesOptions;
|
|
71
|
+
[key: string]: unknown;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface MountOptions {
|
|
75
|
+
prefix?: string;
|
|
76
|
+
hooks?: Hooks;
|
|
77
|
+
meta?: Metadata;
|
|
78
|
+
source?: unknown;
|
|
79
|
+
[key: string]: unknown;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface AppOptions {
|
|
83
|
+
routes?: Array<RouteDescriptor | RoutesDescriptor>;
|
|
84
|
+
hooks?: Hooks;
|
|
85
|
+
meta?: Metadata;
|
|
86
|
+
[key: string]: unknown;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface App {
|
|
90
|
+
fetch(request: Request): MaybePromise<Response>;
|
|
91
|
+
routes?: unknown;
|
|
92
|
+
[key: string]: unknown;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface ActionOptions {
|
|
96
|
+
input?: Contract;
|
|
97
|
+
output?: Contract;
|
|
98
|
+
meta?: Metadata;
|
|
99
|
+
source?: unknown;
|
|
100
|
+
[key: string]: unknown;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface ActionDescriptor {
|
|
104
|
+
kind?: string;
|
|
105
|
+
id: string;
|
|
106
|
+
handler: RouteHandler;
|
|
107
|
+
options?: ActionOptions;
|
|
108
|
+
[key: string]: unknown;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface ResultDescriptor<T = unknown> {
|
|
112
|
+
ok: boolean;
|
|
113
|
+
value?: T;
|
|
114
|
+
error?: unknown;
|
|
115
|
+
status?: number;
|
|
116
|
+
[key: string]: unknown;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface ResponseDescriptor {
|
|
120
|
+
kind?: string;
|
|
121
|
+
body?: unknown;
|
|
122
|
+
status?: number;
|
|
123
|
+
headers?: HeadersInit;
|
|
124
|
+
location?: string;
|
|
125
|
+
[key: string]: unknown;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface EffectCommand {
|
|
129
|
+
type: string;
|
|
130
|
+
[key: string]: unknown;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface FormStateOptions {
|
|
134
|
+
ok?: false;
|
|
135
|
+
values?: unknown;
|
|
136
|
+
error?: unknown;
|
|
137
|
+
issues?: unknown[];
|
|
138
|
+
sensitiveFields?: string[];
|
|
139
|
+
[key: string]: unknown;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface ProjectionOptions {
|
|
143
|
+
packageName?: string;
|
|
144
|
+
packageVersion?: string;
|
|
145
|
+
[key: string]: unknown;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface FrameworkErrorOptions {
|
|
149
|
+
code?: string;
|
|
150
|
+
message?: string;
|
|
151
|
+
status?: number;
|
|
152
|
+
boundary?: string;
|
|
153
|
+
issues?: unknown[];
|
|
154
|
+
cause?: unknown;
|
|
155
|
+
[key: string]: unknown;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function createApp(options?: AppOptions): App;
|
|
159
|
+
export function route(method: string, path: string, handler: RouteHandler, options?: RouteOptions): RouteDescriptor;
|
|
160
|
+
export function createRoutes(options?: RoutesOptions): RoutesDescriptor;
|
|
161
|
+
export function mount(routes: RoutesDescriptor | RouteDescriptor[], options?: MountOptions): RoutesDescriptor;
|
|
162
|
+
export function action(id: string, handler: RouteHandler, options?: ActionOptions): ActionDescriptor;
|
|
163
|
+
|
|
164
|
+
export function ok<T = unknown>(value?: T, status?: number): ResultDescriptor<T>;
|
|
165
|
+
export function fail(error?: unknown, status?: number): ResultDescriptor<never>;
|
|
166
|
+
export function json(body: unknown, init?: ResponseInit): ResponseDescriptor;
|
|
167
|
+
export function text(body: string, init?: ResponseInit): ResponseDescriptor;
|
|
168
|
+
export function redirect(location: string, status?: number): ResponseDescriptor;
|
|
169
|
+
|
|
170
|
+
export function effect(fn: (...args: unknown[]) => Iterator<unknown> | AsyncIterator<unknown>, meta?: unknown): EffectDescriptor;
|
|
171
|
+
export function call(fn: (...args: unknown[]) => unknown, ...args: unknown[]): EffectCommand;
|
|
172
|
+
export function value<T = unknown>(input: T): EffectCommand;
|
|
173
|
+
export function context(key?: string): EffectCommand;
|
|
174
|
+
|
|
175
|
+
export function createFormState(input?: FormStateOptions): unknown;
|
|
176
|
+
export function composeRoutes(input: unknown, options?: unknown): RouteDescriptor[];
|
|
177
|
+
export function createPlugin(plugin: unknown): unknown;
|
|
178
|
+
|
|
179
|
+
export function projectContract(contract: Contract, options?: ProjectionOptions): unknown;
|
|
180
|
+
export function projectRoute(route: RouteDescriptor, options?: ProjectionOptions): unknown;
|
|
181
|
+
export function projectRoutes(input: unknown, options?: ProjectionOptions): unknown;
|
|
182
|
+
export function projectAction(action: ActionDescriptor, options?: ProjectionOptions): unknown;
|
|
183
|
+
export function projectForm(action: ActionDescriptor, options?: ProjectionOptions): unknown;
|
|
184
|
+
export function createRouteManifest(input: unknown, options?: ProjectionOptions): unknown;
|
|
185
|
+
export function createFrameworkError(options?: FrameworkErrorOptions): Error & Record<string, unknown>;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { createApp } from './kernel/app.js';
|
|
2
|
+
export { action } from './kernel/action.js';
|
|
3
|
+
export { projectAction } from './kernel/action-projection.js';
|
|
4
|
+
export { projectContract } from './kernel/contract-projection.js';
|
|
5
|
+
export { createFrameworkError } from './kernel/error.js';
|
|
6
|
+
export { call, context, effect, value } from './kernel/effect.js';
|
|
7
|
+
export { projectForm } from './kernel/form-projection.js';
|
|
8
|
+
export { createFormState } from './kernel/form-state.js';
|
|
9
|
+
export { createPlugin } from './kernel/plugin.js';
|
|
10
|
+
export { fail, ok } from './kernel/result.js';
|
|
11
|
+
export { createRouteManifest } from './kernel/route-manifest.js';
|
|
12
|
+
export { projectRoute, projectRoutes } from './kernel/route-projection.js';
|
|
13
|
+
export { createRoutes, composeRoutes, mount } from './kernel/route-collection.js';
|
|
14
|
+
export { route } from './kernel/route.js';
|
|
15
|
+
export { json, redirect, text } from './kernel/response.js';
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { serve } from 'bun';
|
|
2
|
+
|
|
3
|
+
import { render_UserProfile } from '../dist/app.js';
|
|
4
|
+
|
|
5
|
+
serve({
|
|
6
|
+
port: 3000,
|
|
7
|
+
fetch: (req) => {
|
|
8
|
+
const html = `
|
|
9
|
+
<!DOCTYPE html>
|
|
10
|
+
<html lang="en">
|
|
11
|
+
<head>
|
|
12
|
+
<title>TogetherJS Framework Test</title>
|
|
13
|
+
</head>
|
|
14
|
+
<meta charset="UTF-8">
|
|
15
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
16
|
+
<body>
|
|
17
|
+
${render_UserProfile({ userId: '123' })}
|
|
18
|
+
</body>
|
|
19
|
+
</html>
|
|
20
|
+
`;
|
|
21
|
+
return new Response(html, { headers: { "Content-Type": "text/html" } });
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
console.log('🟢 Server running at http://localhost:3000');
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { projectContract } from './contract-projection.js';
|
|
2
|
+
import { isAction } from './action.js';
|
|
3
|
+
|
|
4
|
+
export function projectAction(value) {
|
|
5
|
+
if (!isAction(value)) {
|
|
6
|
+
return {
|
|
7
|
+
kind: 'unknown-action',
|
|
8
|
+
id: null,
|
|
9
|
+
input: null,
|
|
10
|
+
output: null,
|
|
11
|
+
result: null,
|
|
12
|
+
source: null,
|
|
13
|
+
meta: null
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
kind: 'action',
|
|
19
|
+
id: value.id,
|
|
20
|
+
input: value.input ? projectContract(value.input) : null,
|
|
21
|
+
output: value.output ? projectContract(value.output) : null,
|
|
22
|
+
result: projectActionResultSemantics(),
|
|
23
|
+
source: value.source || null,
|
|
24
|
+
meta: value.meta || null
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function projectActionResultSemantics() {
|
|
29
|
+
return {
|
|
30
|
+
success: 'response-projection',
|
|
31
|
+
validationFailure: 'action-input-failed',
|
|
32
|
+
redirect: 'explicit',
|
|
33
|
+
domainFailure: 'fail-result'
|
|
34
|
+
};
|
|
35
|
+
}
|