@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.
Files changed (201) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/LICENSE +21 -0
  3. package/README.md +128 -0
  4. package/package.json +72 -0
  5. package/src/application/Application.ts +1671 -0
  6. package/src/application/BootDoctor.ts +108 -0
  7. package/src/application/DevErrorPage.ts +567 -0
  8. package/src/application/ExceptionHandler.ts +183 -0
  9. package/src/application/currentApp.ts +73 -0
  10. package/src/assets/assets.ts +79 -0
  11. package/src/assets/index.ts +16 -0
  12. package/src/auth/AuthenticatedUser.ts +18 -0
  13. package/src/build/PackageLinter.ts +146 -0
  14. package/src/build/PackageScaffold.ts +127 -0
  15. package/src/build/codemod.ts +64 -0
  16. package/src/build/index.ts +12 -0
  17. package/src/command/Command.ts +254 -0
  18. package/src/command/CommandRunner.ts +593 -0
  19. package/src/command/OutputWriter.ts +61 -0
  20. package/src/command/builtin/CompileCommand.ts +46 -0
  21. package/src/command/builtin/CssBuildCommand.ts +71 -0
  22. package/src/command/builtin/KeyGenerateCommand.ts +58 -0
  23. package/src/command/builtin/LintPackagesCommand.ts +72 -0
  24. package/src/command/builtin/MakeCommandCommand.ts +85 -0
  25. package/src/command/builtin/MakeControllerCommand.ts +95 -0
  26. package/src/command/builtin/MakeEventCommand.ts +85 -0
  27. package/src/command/builtin/MakeJobCommand.ts +53 -0
  28. package/src/command/builtin/MakeListenerCommand.ts +35 -0
  29. package/src/command/builtin/MakeMiddlewareCommand.ts +63 -0
  30. package/src/command/builtin/MakeNotificationCommand.ts +48 -0
  31. package/src/command/builtin/MakeObserverCommand.ts +78 -0
  32. package/src/command/builtin/MakePackageCommand.ts +45 -0
  33. package/src/command/builtin/MakePolicyCommand.ts +66 -0
  34. package/src/command/builtin/MakeProviderCommand.ts +75 -0
  35. package/src/command/builtin/MakeRequestCommand.ts +47 -0
  36. package/src/command/builtin/MakeResourceCommand.ts +61 -0
  37. package/src/command/builtin/MakeTestCommand.ts +120 -0
  38. package/src/command/builtin/ReloadCommand.ts +52 -0
  39. package/src/command/builtin/ReplCommand.ts +174 -0
  40. package/src/command/builtin/RouteListCommand.ts +188 -0
  41. package/src/command/builtin/ServeCommand.ts +321 -0
  42. package/src/command/builtin/StartCommand.ts +3 -0
  43. package/src/command/builtin/StatusCommand.ts +71 -0
  44. package/src/command/builtin/TestCommand.ts +172 -0
  45. package/src/command/builtin/WorkerCommand.ts +27 -0
  46. package/src/command/builtin/index.ts +53 -0
  47. package/src/command/scaffold/worker.ts.txt +12 -0
  48. package/src/command/scaffold/zerotal.ts.txt +26 -0
  49. package/src/command/startZerotal.ts +55 -0
  50. package/src/config/AppConfig.ts +253 -0
  51. package/src/config/ConfigLoader.ts +117 -0
  52. package/src/config/ConfigManager.ts +169 -0
  53. package/src/config/index.ts +46 -0
  54. package/src/config/registry.ts +59 -0
  55. package/src/config/validation.ts +117 -0
  56. package/src/container/Container.ts +606 -0
  57. package/src/container/ContextualBindingBuilder.ts +57 -0
  58. package/src/container/ScopedResolver.ts +117 -0
  59. package/src/container/index.ts +32 -0
  60. package/src/container/inject.ts +55 -0
  61. package/src/container/types.ts +71 -0
  62. package/src/context/RequestContext.ts +91 -0
  63. package/src/contracts/auth.ts +24 -0
  64. package/src/contracts/index.ts +23 -0
  65. package/src/contracts/session.ts +70 -0
  66. package/src/contracts/transaction.ts +26 -0
  67. package/src/conventions/ConventionLoader.ts +128 -0
  68. package/src/conventions/builtinConcerns.ts +131 -0
  69. package/src/crypt/Crypt.ts +141 -0
  70. package/src/crypt/URLSigner.ts +96 -0
  71. package/src/datetime/Carbon.ts +1396 -0
  72. package/src/datetime/CarbonInterval.ts +421 -0
  73. package/src/datetime/clock.ts +28 -0
  74. package/src/datetime/index.ts +23 -0
  75. package/src/datetime/temporal-shim.ts +1 -0
  76. package/src/dev/BuildOutput.ts +131 -0
  77. package/src/dev/CssPlugins.ts +184 -0
  78. package/src/dev/DevBuildHook.ts +74 -0
  79. package/src/dev/DevOrchestrator.ts +213 -0
  80. package/src/dev/DevReloadMiddleware.ts +101 -0
  81. package/src/dev/DevReloadServer.ts +85 -0
  82. package/src/dev/DevWsServer.ts +45 -0
  83. package/src/dev/index.ts +19 -0
  84. package/src/dev/reloadClient.ts +39 -0
  85. package/src/env/Def.ts +232 -0
  86. package/src/env/EnvSchema.ts +105 -0
  87. package/src/env/index.ts +34 -0
  88. package/src/env/t.ts +128 -0
  89. package/src/errors/ConfigError.ts +12 -0
  90. package/src/errors/ContainerErrors.ts +143 -0
  91. package/src/errors/HttpError.ts +127 -0
  92. package/src/errors/ValidationError.ts +19 -0
  93. package/src/errors/ZerotalError.ts +25 -0
  94. package/src/errors/index.ts +46 -0
  95. package/src/events/CallQueuedListener.ts +66 -0
  96. package/src/events/Emitter.ts +280 -0
  97. package/src/events/EventFake.ts +160 -0
  98. package/src/events/FrameworkEvents.ts +252 -0
  99. package/src/facade/Facade.ts +101 -0
  100. package/src/facade/facades/App.ts +155 -0
  101. package/src/facade/facades/Artisan.ts +63 -0
  102. package/src/facade/facades/Config.ts +21 -0
  103. package/src/facade/facades/Events.ts +19 -0
  104. package/src/facade/facades/index.ts +28 -0
  105. package/src/global.d.ts +9 -0
  106. package/src/hash/Hash.ts +60 -0
  107. package/src/health/Health.ts +221 -0
  108. package/src/health/index.ts +27 -0
  109. package/src/helpers/Collection.ts +435 -0
  110. package/src/helpers/config.ts +59 -0
  111. package/src/helpers/fluent.ts +52 -0
  112. package/src/helpers/html.ts +11 -0
  113. package/src/helpers/index.ts +266 -0
  114. package/src/helpers/make.ts +35 -0
  115. package/src/helpers/markdown.ts +73 -0
  116. package/src/helpers/pageElements.ts +27 -0
  117. package/src/helpers/request.ts +62 -0
  118. package/src/helpers/response.ts +411 -0
  119. package/src/helpers/str.ts +208 -0
  120. package/src/http/Http.ts +298 -0
  121. package/src/http/HttpClient.ts +289 -0
  122. package/src/http/Resource.ts +171 -0
  123. package/src/http/UploadedFile.ts +204 -0
  124. package/src/http/Uri.ts +490 -0
  125. package/src/http/index.ts +46 -0
  126. package/src/http/negotiate.ts +213 -0
  127. package/src/http/originGuard.ts +76 -0
  128. package/src/http/sniffContentType.ts +105 -0
  129. package/src/http/url.ts +204 -0
  130. package/src/http/withHeaders.ts +24 -0
  131. package/src/index.ts +250 -0
  132. package/src/lock/LockManager.ts +228 -0
  133. package/src/lock/config.ts +49 -0
  134. package/src/lock/drivers/LockDriver.ts +32 -0
  135. package/src/lock/drivers/MemoryLockDriver.ts +52 -0
  136. package/src/lock/drivers/RedisLockDriver.ts +58 -0
  137. package/src/lock/drivers/SqliteLockDriver.ts +85 -0
  138. package/src/lock/errors.ts +20 -0
  139. package/src/lock/facades/Lock.ts +114 -0
  140. package/src/lock/index.ts +53 -0
  141. package/src/logger/Log.ts +35 -0
  142. package/src/logger/LogManager.ts +430 -0
  143. package/src/logger/LoggerMiddleware.ts +125 -0
  144. package/src/logger/channels/ConsoleChannel.ts +139 -0
  145. package/src/logger/channels/DailyChannel.ts +74 -0
  146. package/src/logger/channels/NullChannel.ts +17 -0
  147. package/src/logger/channels/SingleChannel.ts +34 -0
  148. package/src/logger/channels/StackChannel.ts +29 -0
  149. package/src/logger/config.ts +90 -0
  150. package/src/logger/format.ts +96 -0
  151. package/src/logger/frameworkLog.ts +93 -0
  152. package/src/logger/index.ts +68 -0
  153. package/src/logger/renderTable.ts +111 -0
  154. package/src/logger/types.ts +212 -0
  155. package/src/macros/config.macro.ts +50 -0
  156. package/src/metrics/HttpMetrics.ts +114 -0
  157. package/src/metrics/index.ts +18 -0
  158. package/src/middleware/BaseMiddleware.ts +72 -0
  159. package/src/middleware/CorsMiddleware.ts +152 -0
  160. package/src/middleware/RateLimiter.ts +255 -0
  161. package/src/middleware/SecureHeadersMiddleware.ts +127 -0
  162. package/src/middleware/ThrottleMiddleware.ts +252 -0
  163. package/src/middleware/WebhookMiddleware.ts +204 -0
  164. package/src/pipeline/ContextRegistry.ts +42 -0
  165. package/src/pipeline/HttpContext.ts +865 -0
  166. package/src/pipeline/Pipeline.ts +150 -0
  167. package/src/pipeline/currentPage.ts +46 -0
  168. package/src/pipeline/types.ts +80 -0
  169. package/src/provider/LockProvider.ts +64 -0
  170. package/src/provider/LogProvider.ts +137 -0
  171. package/src/provider/ServiceProvider.ts +84 -0
  172. package/src/provider/StorageProvider.ts +45 -0
  173. package/src/router/FileRouter.ts +526 -0
  174. package/src/router/Route.ts +76 -0
  175. package/src/router/RouteHandler.ts +335 -0
  176. package/src/router/Router.ts +1247 -0
  177. package/src/router/domain.ts +65 -0
  178. package/src/security/index.ts +22 -0
  179. package/src/storage/FakeDisk.ts +233 -0
  180. package/src/storage/StorageFilesMiddleware.ts +150 -0
  181. package/src/storage/StorageManager.ts +173 -0
  182. package/src/storage/config.ts +47 -0
  183. package/src/storage/drivers/LocalDriver.ts +138 -0
  184. package/src/storage/drivers/S3Driver.ts +169 -0
  185. package/src/storage/errors.ts +135 -0
  186. package/src/storage/facades/Storage.ts +3 -0
  187. package/src/storage/global.d.ts +7 -0
  188. package/src/storage/index.ts +22 -0
  189. package/src/storage/root.ts +59 -0
  190. package/src/storage/types.ts +104 -0
  191. package/src/support/appKey.ts +38 -0
  192. package/src/support/cookie.ts +72 -0
  193. package/src/support/crypto.ts +52 -0
  194. package/src/support/deepMerge.ts +117 -0
  195. package/src/support/env.ts +71 -0
  196. package/src/support/network.ts +79 -0
  197. package/src/support/port.ts +197 -0
  198. package/src/support/str.ts +122 -0
  199. package/src/view/FileRouteResolver.ts +59 -0
  200. package/src/view/index.ts +144 -0
  201. 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"];