@zerotal/core 1.0.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 +79 -0
- package/LICENSE +21 -0
- package/README.md +128 -0
- package/package.json +72 -0
- package/src/application/Application.ts +1671 -0
- package/src/application/BootDoctor.ts +108 -0
- package/src/application/DevErrorPage.ts +567 -0
- package/src/application/ExceptionHandler.ts +183 -0
- package/src/application/currentApp.ts +73 -0
- package/src/assets/assets.ts +79 -0
- package/src/assets/index.ts +16 -0
- package/src/auth/AuthenticatedUser.ts +18 -0
- package/src/build/PackageLinter.ts +146 -0
- package/src/build/PackageScaffold.ts +127 -0
- package/src/build/codemod.ts +64 -0
- package/src/build/index.ts +12 -0
- package/src/command/Command.ts +254 -0
- package/src/command/CommandRunner.ts +593 -0
- package/src/command/OutputWriter.ts +61 -0
- package/src/command/builtin/CompileCommand.ts +46 -0
- package/src/command/builtin/CssBuildCommand.ts +71 -0
- package/src/command/builtin/KeyGenerateCommand.ts +58 -0
- package/src/command/builtin/LintPackagesCommand.ts +72 -0
- package/src/command/builtin/MakeCommandCommand.ts +85 -0
- package/src/command/builtin/MakeControllerCommand.ts +95 -0
- package/src/command/builtin/MakeEventCommand.ts +85 -0
- package/src/command/builtin/MakeJobCommand.ts +53 -0
- package/src/command/builtin/MakeListenerCommand.ts +35 -0
- package/src/command/builtin/MakeMiddlewareCommand.ts +63 -0
- package/src/command/builtin/MakeNotificationCommand.ts +48 -0
- package/src/command/builtin/MakeObserverCommand.ts +78 -0
- package/src/command/builtin/MakePackageCommand.ts +45 -0
- package/src/command/builtin/MakePolicyCommand.ts +66 -0
- package/src/command/builtin/MakeProviderCommand.ts +75 -0
- package/src/command/builtin/MakeRequestCommand.ts +47 -0
- package/src/command/builtin/MakeResourceCommand.ts +61 -0
- package/src/command/builtin/MakeTestCommand.ts +120 -0
- package/src/command/builtin/ReloadCommand.ts +52 -0
- package/src/command/builtin/ReplCommand.ts +174 -0
- package/src/command/builtin/RouteListCommand.ts +188 -0
- package/src/command/builtin/ServeCommand.ts +321 -0
- package/src/command/builtin/StartCommand.ts +3 -0
- package/src/command/builtin/StatusCommand.ts +71 -0
- package/src/command/builtin/TestCommand.ts +172 -0
- package/src/command/builtin/WorkerCommand.ts +27 -0
- package/src/command/builtin/index.ts +53 -0
- package/src/command/scaffold/worker.ts.txt +12 -0
- package/src/command/scaffold/zerotal.ts.txt +26 -0
- package/src/command/startZerotal.ts +55 -0
- package/src/config/AppConfig.ts +253 -0
- package/src/config/ConfigLoader.ts +117 -0
- package/src/config/ConfigManager.ts +169 -0
- package/src/config/index.ts +46 -0
- package/src/config/registry.ts +59 -0
- package/src/config/validation.ts +117 -0
- package/src/container/Container.ts +606 -0
- package/src/container/ContextualBindingBuilder.ts +57 -0
- package/src/container/ScopedResolver.ts +117 -0
- package/src/container/index.ts +32 -0
- package/src/container/inject.ts +55 -0
- package/src/container/types.ts +71 -0
- package/src/context/RequestContext.ts +91 -0
- package/src/contracts/auth.ts +24 -0
- package/src/contracts/index.ts +23 -0
- package/src/contracts/session.ts +70 -0
- package/src/contracts/transaction.ts +26 -0
- package/src/conventions/ConventionLoader.ts +128 -0
- package/src/conventions/builtinConcerns.ts +131 -0
- package/src/crypt/Crypt.ts +141 -0
- package/src/crypt/URLSigner.ts +96 -0
- package/src/datetime/Carbon.ts +1396 -0
- package/src/datetime/CarbonInterval.ts +421 -0
- package/src/datetime/clock.ts +28 -0
- package/src/datetime/index.ts +23 -0
- package/src/datetime/temporal-shim.ts +1 -0
- package/src/dev/BuildOutput.ts +131 -0
- package/src/dev/CssPlugins.ts +184 -0
- package/src/dev/DevBuildHook.ts +74 -0
- package/src/dev/DevOrchestrator.ts +213 -0
- package/src/dev/DevReloadMiddleware.ts +101 -0
- package/src/dev/DevReloadServer.ts +85 -0
- package/src/dev/DevWsServer.ts +45 -0
- package/src/dev/index.ts +19 -0
- package/src/dev/reloadClient.ts +39 -0
- package/src/env/Def.ts +232 -0
- package/src/env/EnvSchema.ts +105 -0
- package/src/env/index.ts +34 -0
- package/src/env/t.ts +128 -0
- package/src/errors/ConfigError.ts +12 -0
- package/src/errors/ContainerErrors.ts +143 -0
- package/src/errors/HttpError.ts +127 -0
- package/src/errors/ValidationError.ts +19 -0
- package/src/errors/ZerotalError.ts +25 -0
- package/src/errors/index.ts +46 -0
- package/src/events/CallQueuedListener.ts +66 -0
- package/src/events/Emitter.ts +280 -0
- package/src/events/EventFake.ts +160 -0
- package/src/events/FrameworkEvents.ts +252 -0
- package/src/facade/Facade.ts +101 -0
- package/src/facade/facades/App.ts +155 -0
- package/src/facade/facades/Artisan.ts +63 -0
- package/src/facade/facades/Config.ts +21 -0
- package/src/facade/facades/Events.ts +19 -0
- package/src/facade/facades/index.ts +28 -0
- package/src/global.d.ts +9 -0
- package/src/hash/Hash.ts +60 -0
- package/src/health/Health.ts +221 -0
- package/src/health/index.ts +27 -0
- package/src/helpers/Collection.ts +435 -0
- package/src/helpers/config.ts +59 -0
- package/src/helpers/fluent.ts +52 -0
- package/src/helpers/html.ts +11 -0
- package/src/helpers/index.ts +266 -0
- package/src/helpers/make.ts +35 -0
- package/src/helpers/markdown.ts +73 -0
- package/src/helpers/pageElements.ts +27 -0
- package/src/helpers/request.ts +62 -0
- package/src/helpers/response.ts +411 -0
- package/src/helpers/str.ts +208 -0
- package/src/http/Http.ts +298 -0
- package/src/http/HttpClient.ts +289 -0
- package/src/http/Resource.ts +171 -0
- package/src/http/UploadedFile.ts +204 -0
- package/src/http/Uri.ts +490 -0
- package/src/http/index.ts +46 -0
- package/src/http/negotiate.ts +213 -0
- package/src/http/originGuard.ts +76 -0
- package/src/http/sniffContentType.ts +105 -0
- package/src/http/url.ts +204 -0
- package/src/http/withHeaders.ts +24 -0
- package/src/index.ts +250 -0
- package/src/lock/LockManager.ts +228 -0
- package/src/lock/config.ts +49 -0
- package/src/lock/drivers/LockDriver.ts +32 -0
- package/src/lock/drivers/MemoryLockDriver.ts +52 -0
- package/src/lock/drivers/RedisLockDriver.ts +58 -0
- package/src/lock/drivers/SqliteLockDriver.ts +85 -0
- package/src/lock/errors.ts +20 -0
- package/src/lock/facades/Lock.ts +114 -0
- package/src/lock/index.ts +53 -0
- package/src/logger/Log.ts +35 -0
- package/src/logger/LogManager.ts +430 -0
- package/src/logger/LoggerMiddleware.ts +125 -0
- package/src/logger/channels/ConsoleChannel.ts +139 -0
- package/src/logger/channels/DailyChannel.ts +74 -0
- package/src/logger/channels/NullChannel.ts +17 -0
- package/src/logger/channels/SingleChannel.ts +34 -0
- package/src/logger/channels/StackChannel.ts +29 -0
- package/src/logger/config.ts +90 -0
- package/src/logger/format.ts +96 -0
- package/src/logger/frameworkLog.ts +93 -0
- package/src/logger/index.ts +68 -0
- package/src/logger/renderTable.ts +111 -0
- package/src/logger/types.ts +212 -0
- package/src/macros/config.macro.ts +50 -0
- package/src/metrics/HttpMetrics.ts +114 -0
- package/src/metrics/index.ts +18 -0
- package/src/middleware/BaseMiddleware.ts +72 -0
- package/src/middleware/CorsMiddleware.ts +152 -0
- package/src/middleware/RateLimiter.ts +255 -0
- package/src/middleware/SecureHeadersMiddleware.ts +127 -0
- package/src/middleware/ThrottleMiddleware.ts +252 -0
- package/src/middleware/WebhookMiddleware.ts +204 -0
- package/src/pipeline/ContextRegistry.ts +42 -0
- package/src/pipeline/HttpContext.ts +865 -0
- package/src/pipeline/Pipeline.ts +150 -0
- package/src/pipeline/currentPage.ts +46 -0
- package/src/pipeline/types.ts +80 -0
- package/src/provider/LockProvider.ts +64 -0
- package/src/provider/LogProvider.ts +137 -0
- package/src/provider/ServiceProvider.ts +84 -0
- package/src/provider/StorageProvider.ts +45 -0
- package/src/router/FileRouter.ts +526 -0
- package/src/router/Route.ts +76 -0
- package/src/router/RouteHandler.ts +335 -0
- package/src/router/Router.ts +1247 -0
- package/src/router/domain.ts +65 -0
- package/src/security/index.ts +22 -0
- package/src/storage/FakeDisk.ts +233 -0
- package/src/storage/StorageFilesMiddleware.ts +150 -0
- package/src/storage/StorageManager.ts +173 -0
- package/src/storage/config.ts +47 -0
- package/src/storage/drivers/LocalDriver.ts +138 -0
- package/src/storage/drivers/S3Driver.ts +169 -0
- package/src/storage/errors.ts +135 -0
- package/src/storage/facades/Storage.ts +3 -0
- package/src/storage/global.d.ts +7 -0
- package/src/storage/index.ts +22 -0
- package/src/storage/root.ts +59 -0
- package/src/storage/types.ts +104 -0
- package/src/support/appKey.ts +38 -0
- package/src/support/cookie.ts +72 -0
- package/src/support/crypto.ts +52 -0
- package/src/support/deepMerge.ts +117 -0
- package/src/support/env.ts +71 -0
- package/src/support/network.ts +79 -0
- package/src/support/port.ts +197 -0
- package/src/support/str.ts +122 -0
- package/src/view/FileRouteResolver.ts +59 -0
- package/src/view/index.ts +144 -0
- package/src/view/jsx-runtime.ts +233 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal, dependency-free English inflector used by the convention layer to
|
|
3
|
+
* derive table names and concern associations. Covers the common pluralisation
|
|
4
|
+
* rules plus an irregular/uncountable table; cases beyond the table should be
|
|
5
|
+
* handled with an explicit override (e.g. `@table("...")`).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const IRREGULAR: Record<string, string> = {
|
|
9
|
+
person: "people",
|
|
10
|
+
man: "men",
|
|
11
|
+
woman: "women",
|
|
12
|
+
child: "children",
|
|
13
|
+
tooth: "teeth",
|
|
14
|
+
foot: "feet",
|
|
15
|
+
mouse: "mice",
|
|
16
|
+
goose: "geese",
|
|
17
|
+
ox: "oxen",
|
|
18
|
+
leaf: "leaves",
|
|
19
|
+
life: "lives",
|
|
20
|
+
knife: "knives",
|
|
21
|
+
wife: "wives",
|
|
22
|
+
half: "halves",
|
|
23
|
+
loaf: "loaves",
|
|
24
|
+
potato: "potatoes",
|
|
25
|
+
tomato: "tomatoes",
|
|
26
|
+
hero: "heroes",
|
|
27
|
+
cactus: "cacti",
|
|
28
|
+
focus: "foci",
|
|
29
|
+
datum: "data",
|
|
30
|
+
analysis: "analyses",
|
|
31
|
+
index: "indices",
|
|
32
|
+
matrix: "matrices",
|
|
33
|
+
vertex: "vertices",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const IRREGULAR_INVERSE: Record<string, string> = Object.fromEntries(
|
|
37
|
+
Object.entries(IRREGULAR).map(([singular, plural]) => [plural, singular]),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
// Words with no distinct plural form.
|
|
41
|
+
const UNCOUNTABLE = new Set([
|
|
42
|
+
"equipment",
|
|
43
|
+
"information",
|
|
44
|
+
"rice",
|
|
45
|
+
"money",
|
|
46
|
+
"species",
|
|
47
|
+
"series",
|
|
48
|
+
"fish",
|
|
49
|
+
"sheep",
|
|
50
|
+
"deer",
|
|
51
|
+
"aircraft",
|
|
52
|
+
"news",
|
|
53
|
+
"data",
|
|
54
|
+
"media",
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
/** Mirror the source word's leading capital onto an irregular-table result, so
|
|
58
|
+
* Title Case inputs (admin labels: `Person` → `People`) survive inflection.
|
|
59
|
+
* Table-name derivation feeds lowercase and is unaffected. */
|
|
60
|
+
function matchCase(source: string, result: string): string {
|
|
61
|
+
return /^[A-Z]/.test(source) ? result.charAt(0).toUpperCase() + result.slice(1) : result;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Pluralize a single word (English, best-effort). */
|
|
65
|
+
function pluralizeWord(word: string): string {
|
|
66
|
+
if (!word) return word;
|
|
67
|
+
const lower = word.toLowerCase();
|
|
68
|
+
if (UNCOUNTABLE.has(lower)) return word;
|
|
69
|
+
if (IRREGULAR[lower]) return matchCase(word, IRREGULAR[lower]!);
|
|
70
|
+
if (/[^aeiou]y$/.test(word)) return word.slice(0, -1) + "ies";
|
|
71
|
+
if (/(s|x|z|ch|sh)$/.test(word)) return word + "es";
|
|
72
|
+
return word + "s";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Singularize a single word (English, best-effort). */
|
|
76
|
+
function singularizeWord(word: string): string {
|
|
77
|
+
if (!word) return word;
|
|
78
|
+
const lower = word.toLowerCase();
|
|
79
|
+
if (UNCOUNTABLE.has(lower)) return word;
|
|
80
|
+
if (IRREGULAR_INVERSE[lower]) return matchCase(word, IRREGULAR_INVERSE[lower]!);
|
|
81
|
+
if (/[^aeiou]ies$/.test(word)) return word.slice(0, -3) + "y";
|
|
82
|
+
if (/(ses|xes|zes|ches|shes)$/.test(word)) return word.slice(0, -2);
|
|
83
|
+
if (/s$/.test(word) && !/ss$/.test(word)) return word.slice(0, -1);
|
|
84
|
+
return word;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Pluralize a snake_case or single word, inflecting only the final segment.
|
|
89
|
+
* `"blog_post"` → `"blog_posts"`, `"category"` → `"categories"`, `"person"` → `"people"`.
|
|
90
|
+
*/
|
|
91
|
+
export function pluralize(value: string): string {
|
|
92
|
+
const parts = value.split("_");
|
|
93
|
+
parts[parts.length - 1] = pluralizeWord(parts[parts.length - 1] ?? "");
|
|
94
|
+
return parts.join("_");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Singularize a snake_case or single word, inflecting only the final segment. */
|
|
98
|
+
export function singularize(value: string): string {
|
|
99
|
+
const parts = value.split("_");
|
|
100
|
+
parts[parts.length - 1] = singularizeWord(parts[parts.length - 1] ?? "");
|
|
101
|
+
return parts.join("_");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** `BlogPost` → `blog_post`, `userEmail` → `user_email`. */
|
|
105
|
+
export function snakeCase(value: string): string {
|
|
106
|
+
return value
|
|
107
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
108
|
+
.replace(/[\s-]+/g, "_")
|
|
109
|
+
.toLowerCase();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** `blog_post` / `blog-post` → `blogPost`. */
|
|
113
|
+
export function camelCase(value: string): string {
|
|
114
|
+
return value
|
|
115
|
+
.replace(/[_-]+(.)?/g, (_, char: string | undefined) => (char ? char.toUpperCase() : ""))
|
|
116
|
+
.replace(/^(.)/, (_, char: string) => char.toLowerCase());
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Convention table name for a model class name: snake_case + pluralized. `User` → `users`. */
|
|
120
|
+
export function tableNameFor(className: string): string {
|
|
121
|
+
return pluralize(snakeCase(className));
|
|
122
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Router } from "../router/Router.ts";
|
|
2
|
+
import {
|
|
3
|
+
registerFileRouteResolver,
|
|
4
|
+
enableFileRouteLayouts,
|
|
5
|
+
type FileRouteContext,
|
|
6
|
+
} from "../router/FileRouter.ts";
|
|
7
|
+
import type { ViewComponent, ViewLayout, FileHandler } from "../router/Route.ts";
|
|
8
|
+
import type { HttpContext } from "../pipeline/HttpContext.ts";
|
|
9
|
+
import { SafeHtml, isViewComponent } from "./jsx-runtime.ts";
|
|
10
|
+
|
|
11
|
+
function toHtml(value: unknown): string {
|
|
12
|
+
if (value instanceof SafeHtml) return value.value;
|
|
13
|
+
return value === null || value === undefined ? "" : String(value);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -- accepts any function narrowed from a route module's default export.
|
|
17
|
+
function isClass(fn: Function): boolean {
|
|
18
|
+
return /^class[\s{]/.test(Function.prototype.toString.call(fn));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Opt into rendering `.tsx`/`.jsx` file-route pages: a route file's default
|
|
23
|
+
* export that is a view component (or any function tagged via the view marker)
|
|
24
|
+
* is rendered to HTML, wrapped in the nearest `_layout`, and registered as a GET
|
|
25
|
+
* route. Call from a provider's `onRegister()` before file routes are scanned.
|
|
26
|
+
*/
|
|
27
|
+
export function registerViewFileRouteResolver(): void {
|
|
28
|
+
enableFileRouteLayouts();
|
|
29
|
+
|
|
30
|
+
registerFileRouteResolver((ctx: FileRouteContext): boolean => {
|
|
31
|
+
const mod = ctx.module as { default?: unknown; GET?: unknown; layout?: ViewComponent | null };
|
|
32
|
+
const component = mod.default;
|
|
33
|
+
|
|
34
|
+
if (typeof component !== "function") return false;
|
|
35
|
+
if (typeof mod.GET === "function") return false;
|
|
36
|
+
if (isClass(component)) return false;
|
|
37
|
+
|
|
38
|
+
const isJsxFile = /\.(tsx|jsx)$/.test(ctx.filePath);
|
|
39
|
+
if (!isJsxFile && !isViewComponent(component)) return false;
|
|
40
|
+
|
|
41
|
+
const resolvedLayout: ViewLayout | undefined =
|
|
42
|
+
mod.layout !== undefined
|
|
43
|
+
? ((mod.layout ?? undefined) as ViewLayout | undefined)
|
|
44
|
+
: (ctx.layout as ViewLayout | undefined);
|
|
45
|
+
|
|
46
|
+
const page = component as ViewComponent;
|
|
47
|
+
|
|
48
|
+
const handler: FileHandler = async (http: HttpContext): Promise<Response> => {
|
|
49
|
+
const inner = toHtml(await page(http, http.params));
|
|
50
|
+
const body = resolvedLayout
|
|
51
|
+
? toHtml(await resolvedLayout(http, { children: new SafeHtml(inner) }))
|
|
52
|
+
: inner;
|
|
53
|
+
return new Response(body, { headers: { "Content-Type": "text/html; charset=utf-8" } });
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
Router._registerFileHandler("GET", ctx.urlPath, handler, ctx.middleware, ctx.name);
|
|
57
|
+
return true;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side JSX views: the secure-by-default JSX runtime (`SafeHtml`), the
|
|
3
|
+
* page/layout authoring helpers, and the optional file-route resolver that
|
|
4
|
+
* renders `.tsx` pages automatically. All of this lives in core (the
|
|
5
|
+
* `@zerotal/core/view` subpath) so `ctx.view()` and the components it renders
|
|
6
|
+
* come from a single package. JSX children are auto-escaped by default; use
|
|
7
|
+
* {@link safe}/{@link Raw} to opt specific HTML out of escaping.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```tsx
|
|
11
|
+
* import { defineLayout } from "@zerotal/core/view";
|
|
12
|
+
*
|
|
13
|
+
* const wrap = defineLayout(AppLayout);
|
|
14
|
+
* export const Home = wrap<{ name: string }>(({ name }) => (
|
|
15
|
+
* <main><h1>Hello {name}</h1></main>
|
|
16
|
+
* ));
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* @packageDocumentation
|
|
20
|
+
*/
|
|
21
|
+
import { SafeHtml, Fragment, markViewComponent } from "./jsx-runtime.ts";
|
|
22
|
+
export { SafeHtml, Fragment };
|
|
23
|
+
export type { FC, Children } from "./jsx-runtime.ts";
|
|
24
|
+
|
|
25
|
+
export { VIEW_COMPONENT_SYMBOL, VIEW_COMPONENT_PROP } from "./jsx-runtime.ts";
|
|
26
|
+
// Tested opt-in feature (see router/ViewLayout.test.ts) awaiting docs — kept public.
|
|
27
|
+
export { registerViewFileRouteResolver } from "./FileRouteResolver.ts";
|
|
28
|
+
|
|
29
|
+
import type { HttpContext } from "../pipeline/HttpContext.ts";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Declare a page handler: a function of `(ctx, params)` returning HTML (or a
|
|
33
|
+
* string / promise thereof), tagged via {@link markViewComponent} so the router
|
|
34
|
+
* treats it as a view component. Returns the same function with its type intact.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```tsx
|
|
38
|
+
* import { definePage } from '@zerotal/core/view';
|
|
39
|
+
*
|
|
40
|
+
* export const Show = definePage<{ id: string }>((ctx, { id }) => (
|
|
41
|
+
* <article><h1>Post {id}</h1></article>
|
|
42
|
+
* ));
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export function definePage<P extends Record<string, unknown> = Record<string, unknown>>(
|
|
46
|
+
component: (ctx: HttpContext, params: P) => SafeHtml | string | Promise<SafeHtml | string>,
|
|
47
|
+
): typeof component {
|
|
48
|
+
return markViewComponent(component);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Wrap a pre-rendered HTML string as SafeHtml so it passes through
|
|
53
|
+
* renderChildren() without being escaped again.
|
|
54
|
+
*
|
|
55
|
+
* Use when you have HTML that was produced outside JSX (e.g. a Markdown
|
|
56
|
+
* renderer) and you want to embed it safely into a JSX tree.
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* import { safe } from '@zerotal/core';
|
|
60
|
+
* <article>{safe(markdownToHtml(post.body))}</article>
|
|
61
|
+
*/
|
|
62
|
+
export function safe(html: string): SafeHtml {
|
|
63
|
+
return new SafeHtml(html);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Escape a raw value for safe use in a context where SafeHtml is not
|
|
68
|
+
* available — e.g. template-literal fallbacks or attribute values built
|
|
69
|
+
* outside JSX. Not needed for normal JSX children (auto-escaped by default).
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* const attr = esc(user.name); // safe inside a raw string template
|
|
73
|
+
*/
|
|
74
|
+
export function esc(val: unknown): string {
|
|
75
|
+
// Native, SIMD-optimized escaping (same set as the JSX runtime's escHtml).
|
|
76
|
+
return Bun.escapeHTML(String(val ?? ""));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Component that injects a raw HTML string without escaping.
|
|
81
|
+
* Prefer dangerouslySetInnerHTML on intrinsic elements for single-element
|
|
82
|
+
* cases; use Raw when you need it as a composable component.
|
|
83
|
+
*
|
|
84
|
+
* @example
|
|
85
|
+
* import { Raw } from '@zerotal/core';
|
|
86
|
+
* <div><Raw html={markdownToHtml(post.body)} /></div>
|
|
87
|
+
*/
|
|
88
|
+
export function Raw({ html }: { html: string }): SafeHtml {
|
|
89
|
+
return new SafeHtml(html);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Re-export SafeHtml type for use in controller return-type annotations.
|
|
93
|
+
export type { SafeHtml as Html };
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Bind a shared layout component to page components, eliminating the need
|
|
97
|
+
* to wrap every page in `<Layout>...</Layout>` manually.
|
|
98
|
+
*
|
|
99
|
+
* Returns a `wrap` factory. Call `wrap(PageComponent)` to produce a new
|
|
100
|
+
* component that renders `PageComponent` inside the layout. All props from
|
|
101
|
+
* both the layout (except `children`) and the page are merged — TypeScript
|
|
102
|
+
* enforces that callers supply every required field.
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* // resources/views/layouts/AppLayout.tsx
|
|
106
|
+
* export function AppLayout({ children, title }: { children: unknown; title: string }) {
|
|
107
|
+
* return (
|
|
108
|
+
* <html>
|
|
109
|
+
* <head><title>{title} — My App</title></head>
|
|
110
|
+
* <body>{children}</body>
|
|
111
|
+
* </html>
|
|
112
|
+
* );
|
|
113
|
+
* }
|
|
114
|
+
*
|
|
115
|
+
* // resources/views/About.tsx
|
|
116
|
+
* import { defineLayout } from '@zerotal/core';
|
|
117
|
+
* import { AppLayout } from './layouts/AppLayout.tsx';
|
|
118
|
+
*
|
|
119
|
+
* const wrap = defineLayout(AppLayout);
|
|
120
|
+
*
|
|
121
|
+
* export const AboutPage = wrap<{ title: string }>(({ title }) => (
|
|
122
|
+
* <main>
|
|
123
|
+
* <h1>{title}</h1>
|
|
124
|
+
* <p>We build things.</p>
|
|
125
|
+
* </main>
|
|
126
|
+
* ));
|
|
127
|
+
*
|
|
128
|
+
* // In routes/index.ts:
|
|
129
|
+
* Router.view('/about', AboutPage, { title: 'About Us' });
|
|
130
|
+
*/
|
|
131
|
+
export function defineLayout<LP extends Record<string, unknown>>(
|
|
132
|
+
Layout: (props: LP & { children?: unknown }) => SafeHtml,
|
|
133
|
+
): <PP extends Record<string, unknown> = Record<string, never>>(
|
|
134
|
+
Page: (props: PP & { children?: unknown }) => SafeHtml,
|
|
135
|
+
) => (props: LP & PP) => SafeHtml {
|
|
136
|
+
return function wrap<PP extends Record<string, unknown>>(
|
|
137
|
+
Page: (props: PP & { children?: unknown }) => SafeHtml,
|
|
138
|
+
): (props: LP & PP) => SafeHtml {
|
|
139
|
+
return (props: LP & PP): SafeHtml => {
|
|
140
|
+
const children = Page(props as PP);
|
|
141
|
+
return Layout({ ...(props as unknown as LP), children });
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// ── View-component marker ──
|
|
2
|
+
|
|
3
|
+
import { escapeHtml as escHtml } from "../helpers/html.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Well-known symbol stamped onto functions that are Zerotal view components,
|
|
7
|
+
* letting the renderer distinguish page/layout components from ordinary
|
|
8
|
+
* functions. Registered via `Symbol.for` so the marker survives across module
|
|
9
|
+
* realms.
|
|
10
|
+
*/
|
|
11
|
+
export const VIEW_COMPONENT_SYMBOL = Symbol.for("zerotal.view.component");
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* String-keyed twin of {@link VIEW_COMPONENT_SYMBOL}. Set alongside the symbol
|
|
15
|
+
* as a fallback for tooling or environments that can't read symbol-keyed props.
|
|
16
|
+
*/
|
|
17
|
+
export const VIEW_COMPONENT_PROP = "__zerotalViewComponent";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Tag a function as a Zerotal view component (mutates and returns it) so
|
|
21
|
+
* {@link isViewComponent} recognises it. Marking is best-effort — a frozen
|
|
22
|
+
* function simply stays unmarked. Applied automatically by {@link definePage},
|
|
23
|
+
* {@link defineLayout}, and the JSX factory when it renders a function tag.
|
|
24
|
+
*
|
|
25
|
+
* @param fn - The component function to mark.
|
|
26
|
+
* @returns The same function, now carrying the marker.
|
|
27
|
+
*/
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -- marks any component function, regardless of its specific signature.
|
|
29
|
+
export function markViewComponent<T extends Function>(fn: T): T {
|
|
30
|
+
try {
|
|
31
|
+
(fn as unknown as Record<PropertyKey, unknown>)[VIEW_COMPONENT_SYMBOL] = true;
|
|
32
|
+
(fn as unknown as Record<string, unknown>)[VIEW_COMPONENT_PROP] = true;
|
|
33
|
+
} catch {
|
|
34
|
+
/* defining the marker is best-effort — frozen functions just stay unmarked. */
|
|
35
|
+
}
|
|
36
|
+
return fn;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whether a value was tagged by {@link markViewComponent} — i.e. it is a Zerotal
|
|
41
|
+
* view component rather than a plain function. Returns `false` for non-functions.
|
|
42
|
+
*
|
|
43
|
+
* @param fn - The value to test.
|
|
44
|
+
*/
|
|
45
|
+
export function isViewComponent(fn: unknown): boolean {
|
|
46
|
+
if (typeof fn !== "function") return false;
|
|
47
|
+
const f = fn as unknown as Record<PropertyKey, unknown>;
|
|
48
|
+
return f[VIEW_COMPONENT_SYMBOL] === true || f[VIEW_COMPONENT_PROP] === true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── SafeHtml ──────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Opaque runtime wrapper produced by every JSX expression.
|
|
55
|
+
* Signals to renderChildren() that the value is already-escaped HTML and
|
|
56
|
+
* must NOT be re-escaped. End users should not construct this directly —
|
|
57
|
+
* use JSX syntax or the safe() / Raw helpers from @zerotal/core.
|
|
58
|
+
*/
|
|
59
|
+
export class SafeHtml {
|
|
60
|
+
constructor(readonly value: string) {}
|
|
61
|
+
toString(): string {
|
|
62
|
+
return this.value;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Global JSX type declarations ──────────────────────────────────────────────
|
|
67
|
+
// When a file uses /** @jsxImportSource @zerotal/core */, TypeScript imports this
|
|
68
|
+
// module and picks up these declarations, giving full type-safe JSX with
|
|
69
|
+
// JSX.Element = SafeHtml (secure by default, never a plain string).
|
|
70
|
+
declare global {
|
|
71
|
+
// eslint-disable-next-line @typescript-eslint/no-namespace -- the JSX namespace is the only way to declare global JSX types.
|
|
72
|
+
namespace JSX {
|
|
73
|
+
type Element = SafeHtml;
|
|
74
|
+
interface ElementChildrenAttribute {
|
|
75
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- ElementChildrenAttribute names the children prop; the value type is irrelevant by JSX convention.
|
|
76
|
+
children: {};
|
|
77
|
+
}
|
|
78
|
+
interface IntrinsicElements {
|
|
79
|
+
[tag: string]: Record<string, unknown> & { children?: unknown };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Function-component type: a function taking typed props (plus optional
|
|
88
|
+
* `children`) and returning {@link SafeHtml}. Use it to annotate view
|
|
89
|
+
* components authored outside of `.tsx` files.
|
|
90
|
+
*
|
|
91
|
+
* @typeParam P - The component's own prop shape.
|
|
92
|
+
*/
|
|
93
|
+
export type FC<P extends Record<string, unknown> = Record<string, never>> = (
|
|
94
|
+
props: P & { children?: unknown },
|
|
95
|
+
) => SafeHtml;
|
|
96
|
+
|
|
97
|
+
// ── HTML element sets ─────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
const VOID_ELEMENTS = new Set([
|
|
100
|
+
"area",
|
|
101
|
+
"base",
|
|
102
|
+
"br",
|
|
103
|
+
"col",
|
|
104
|
+
"embed",
|
|
105
|
+
"hr",
|
|
106
|
+
"img",
|
|
107
|
+
"input",
|
|
108
|
+
"link",
|
|
109
|
+
"meta",
|
|
110
|
+
"param",
|
|
111
|
+
"source",
|
|
112
|
+
"track",
|
|
113
|
+
"wbr",
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
// ── Escaping ──────────────────────────────────────────────────────────────────
|
|
117
|
+
// Attribute values and auto-escaped text children both go through `escHtml`,
|
|
118
|
+
// imported from helpers/html.ts — the framework's one escaper
|
|
119
|
+
// (Bun.escapeHTML underneath).
|
|
120
|
+
|
|
121
|
+
// ── Child rendering ───────────────────────────────────────────────────────────
|
|
122
|
+
//
|
|
123
|
+
// The key security boundary:
|
|
124
|
+
// • SafeHtml → pass through as-is (already rendered by jsx() / safe())
|
|
125
|
+
// • string → AUTO-ESCAPE (treat as untrusted user data)
|
|
126
|
+
// • number → convert to string, no escaping needed (numbers are inert)
|
|
127
|
+
// • boolean / null / undefined → render nothing (conditional rendering idiom)
|
|
128
|
+
// • Array → recurse (children array from jsxs)
|
|
129
|
+
|
|
130
|
+
function renderChildren(children: unknown): string {
|
|
131
|
+
if (children instanceof SafeHtml) return children.value;
|
|
132
|
+
if (typeof children === "string") return escHtml(children);
|
|
133
|
+
if (typeof children === "number") return String(children);
|
|
134
|
+
if (children === null || children === undefined || children === false || children === true)
|
|
135
|
+
return "";
|
|
136
|
+
if (Array.isArray(children)) return children.map(renderChildren).join("");
|
|
137
|
+
// Fallback: stringify and escape unknown values
|
|
138
|
+
return escHtml(String(children));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Attribute rendering ───────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
function renderAttrs(props: Record<string, unknown>): string {
|
|
144
|
+
let out = "";
|
|
145
|
+
for (const [key, val] of Object.entries(props)) {
|
|
146
|
+
if (key === "children" || key === "key" || key === "dangerouslySetInnerHTML") continue;
|
|
147
|
+
if (val === undefined || val === null || val === false) continue;
|
|
148
|
+
if (val === true) {
|
|
149
|
+
out += ` ${key}`;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
// Map React prop-name conventions to their HTML equivalents.
|
|
153
|
+
const attr = key === "className" ? "class" : key === "htmlFor" ? "for" : key;
|
|
154
|
+
if (typeof val === "string" || typeof val === "number") {
|
|
155
|
+
out += ` ${attr}="${escHtml(String(val))}"`;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── JSX factory ───────────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The JSX factory the TypeScript runtime calls for every element. Renders an
|
|
165
|
+
* intrinsic tag to an HTML string or invokes a component function, always
|
|
166
|
+
* returning {@link SafeHtml}. Not called directly — emitted by the compiler
|
|
167
|
+
* under `@jsxImportSource @zerotal/core`.
|
|
168
|
+
*
|
|
169
|
+
* @internal
|
|
170
|
+
*/
|
|
171
|
+
export function jsx(
|
|
172
|
+
tag: string | FC<Record<string, unknown>>,
|
|
173
|
+
props: Record<string, unknown> | null,
|
|
174
|
+
_key?: unknown,
|
|
175
|
+
): SafeHtml {
|
|
176
|
+
const allProps = props ?? {};
|
|
177
|
+
const { children, dangerouslySetInnerHTML, ...rest } = allProps as Record<string, unknown> & {
|
|
178
|
+
children?: unknown;
|
|
179
|
+
dangerouslySetInnerHTML?: { __html: string };
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
if (typeof tag === "function") {
|
|
183
|
+
markViewComponent(tag);
|
|
184
|
+
return tag({ ...rest, children });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const attrs = renderAttrs(rest);
|
|
188
|
+
|
|
189
|
+
if (VOID_ELEMENTS.has(tag)) return new SafeHtml(`<${tag}${attrs}>`);
|
|
190
|
+
|
|
191
|
+
// dangerouslySetInnerHTML bypasses child rendering — explicit raw-HTML escape hatch.
|
|
192
|
+
const inner =
|
|
193
|
+
dangerouslySetInnerHTML !== undefined
|
|
194
|
+
? dangerouslySetInnerHTML.__html
|
|
195
|
+
: renderChildren(children);
|
|
196
|
+
|
|
197
|
+
return new SafeHtml(`<${tag}${attrs}>${inner}</${tag}>`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Alias of {@link jsx} — the runtime calls it when children is a static array
|
|
202
|
+
* literal. Emitted by the compiler, not called directly.
|
|
203
|
+
* @internal
|
|
204
|
+
*/
|
|
205
|
+
export const jsxs = jsx;
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Alias of {@link jsx} used by the compiler in development mode; the same
|
|
209
|
+
* implementation is correct for SSR.
|
|
210
|
+
* @internal
|
|
211
|
+
*/
|
|
212
|
+
export const jsxDEV = jsx;
|
|
213
|
+
|
|
214
|
+
// ── Fragment ──────────────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* JSX fragment component — renders its children with no wrapper element,
|
|
218
|
+
* concatenating them into a single {@link SafeHtml}. Written as `<>…</>` in JSX.
|
|
219
|
+
*
|
|
220
|
+
* @example
|
|
221
|
+
* ```tsx
|
|
222
|
+
* <>
|
|
223
|
+
* <li>One</li>
|
|
224
|
+
* <li>Two</li>
|
|
225
|
+
* </>
|
|
226
|
+
* ```
|
|
227
|
+
*/
|
|
228
|
+
export function Fragment({ children }: { children?: unknown }): SafeHtml {
|
|
229
|
+
return new SafeHtml(renderChildren(children));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The type of a component's `children` prop (any JSX-renderable value). */
|
|
233
|
+
export type Children = Parameters<typeof Fragment>[0]["children"];
|