@c9up/station 0.1.5

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 (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +3 -0
  3. package/dist/ResourceRegistry.d.ts +18 -0
  4. package/dist/ResourceRegistry.d.ts.map +1 -0
  5. package/dist/ResourceRegistry.js +38 -0
  6. package/dist/ResourceRegistry.js.map +1 -0
  7. package/dist/StationProvider.d.ts +109 -0
  8. package/dist/StationProvider.d.ts.map +1 -0
  9. package/dist/StationProvider.js +1144 -0
  10. package/dist/StationProvider.js.map +1 -0
  11. package/dist/casing.d.ts +27 -0
  12. package/dist/casing.d.ts.map +1 -0
  13. package/dist/casing.js +75 -0
  14. package/dist/casing.js.map +1 -0
  15. package/dist/defineResource.d.ts +9 -0
  16. package/dist/defineResource.d.ts.map +1 -0
  17. package/dist/defineResource.js +84 -0
  18. package/dist/defineResource.js.map +1 -0
  19. package/dist/index.d.ts +7 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +6 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/services/main.d.ts +18 -0
  24. package/dist/services/main.d.ts.map +1 -0
  25. package/dist/services/main.js +31 -0
  26. package/dist/services/main.js.map +1 -0
  27. package/dist/types.d.ts +85 -0
  28. package/dist/types.d.ts.map +1 -0
  29. package/dist/types.js +8 -0
  30. package/dist/types.js.map +1 -0
  31. package/dist/views/errors/404.d.ts +12 -0
  32. package/dist/views/errors/404.d.ts.map +1 -0
  33. package/dist/views/errors/404.js +19 -0
  34. package/dist/views/errors/404.js.map +1 -0
  35. package/dist/views/escape.d.ts +30 -0
  36. package/dist/views/escape.d.ts.map +1 -0
  37. package/dist/views/escape.js +34 -0
  38. package/dist/views/escape.js.map +1 -0
  39. package/dist/views/form.d.ts +34 -0
  40. package/dist/views/form.d.ts.map +1 -0
  41. package/dist/views/form.js +139 -0
  42. package/dist/views/form.js.map +1 -0
  43. package/dist/views/layout.d.ts +24 -0
  44. package/dist/views/layout.d.ts.map +1 -0
  45. package/dist/views/layout.js +85 -0
  46. package/dist/views/layout.js.map +1 -0
  47. package/dist/views/list.d.ts +22 -0
  48. package/dist/views/list.d.ts.map +1 -0
  49. package/dist/views/list.js +85 -0
  50. package/dist/views/list.js.map +1 -0
  51. package/dist/views/login.d.ts +25 -0
  52. package/dist/views/login.d.ts.map +1 -0
  53. package/dist/views/login.js +44 -0
  54. package/dist/views/login.js.map +1 -0
  55. package/dist/views/show.d.ts +17 -0
  56. package/dist/views/show.d.ts.map +1 -0
  57. package/dist/views/show.js +24 -0
  58. package/dist/views/show.js.map +1 -0
  59. package/package.json +63 -0
  60. package/src/ResourceRegistry.ts +49 -0
  61. package/src/StationProvider.ts +1579 -0
  62. package/src/casing.ts +86 -0
  63. package/src/defineResource.ts +126 -0
  64. package/src/index.ts +14 -0
  65. package/src/services/main.ts +39 -0
  66. package/src/types.ts +108 -0
  67. package/src/views/errors/404.ts +27 -0
  68. package/src/views/escape.ts +46 -0
  69. package/src/views/form.ts +191 -0
  70. package/src/views/layout.ts +90 -0
  71. package/src/views/list.ts +121 -0
  72. package/src/views/login.ts +65 -0
  73. package/src/views/show.ts +37 -0
package/src/casing.ts ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Convert an identifier (camelCase / PascalCase / digits / already-lowercase)
3
+ * to kebab-case.
4
+ *
5
+ * BlogPost -> blog-post
6
+ * URLParser -> url-parser (consecutive uppercase = one segment)
7
+ * My2024Post -> my-2024-post (digit boundaries split too)
8
+ * user -> user (idempotent on already-lower input)
9
+ */
10
+ export function kebabCase(input: string): string {
11
+ const upperAfterLowerOrDigit = input.replace(/([a-z0-9])([A-Z])/g, "$1-$2");
12
+ const upperAfterUpperBeforeLower = upperAfterLowerOrDigit.replace(
13
+ /([A-Z])([A-Z][a-z])/g,
14
+ "$1-$2",
15
+ );
16
+ const digitAfterLetter = upperAfterUpperBeforeLower.replace(
17
+ /([A-Za-z])([0-9])/g,
18
+ "$1-$2",
19
+ );
20
+ return digitAfterLetter.toLowerCase();
21
+ }
22
+
23
+ /**
24
+ * Convert kebab-case to space-separated Title Case.
25
+ *
26
+ * blog-posts -> Blog Posts
27
+ */
28
+ export function titleCase(input: string): string {
29
+ return input
30
+ .split("-")
31
+ .map((part) =>
32
+ part.length === 0 ? part : part[0].toUpperCase() + part.slice(1),
33
+ )
34
+ .join(" ");
35
+ }
36
+
37
+ /**
38
+ * Irregular plural table. Lookup is on the LAST kebab segment so
39
+ * `super-man` -> `super-men` works while `human` falls through.
40
+ */
41
+ const IRREGULAR_PLURALS: ReadonlyMap<string, string> = new Map([
42
+ ["person", "people"],
43
+ ["child", "children"],
44
+ ["man", "men"],
45
+ ["woman", "women"],
46
+ ["mouse", "mice"],
47
+ ["goose", "geese"],
48
+ ["tooth", "teeth"],
49
+ ["foot", "feet"],
50
+ ["analysis", "analyses"],
51
+ ["crisis", "crises"],
52
+ ["phenomenon", "phenomena"],
53
+ ["criterion", "criteria"],
54
+ ["datum", "data"],
55
+ ["medium", "media"],
56
+ ]);
57
+
58
+ function pluraliseWord(word: string): string {
59
+ const irregular = IRREGULAR_PLURALS.get(word);
60
+ if (irregular !== undefined) {
61
+ return irregular;
62
+ }
63
+ if (/[^aeiou]y$/.test(word)) {
64
+ return `${word.slice(0, -1)}ies`;
65
+ }
66
+ if (/(s|x|z|ch|sh)$/.test(word)) {
67
+ return `${word}es`;
68
+ }
69
+ return `${word}s`;
70
+ }
71
+
72
+ /**
73
+ * Minimal English pluraliser. Hand-rolled (zero runtime deps).
74
+ *
75
+ * Multi-word kebab inputs only have their LAST segment pluralised:
76
+ *
77
+ * user -> users
78
+ * blog-post -> blog-posts
79
+ * super-man -> super-men
80
+ */
81
+ export function pluralise(singular: string): string {
82
+ const segments = singular.split("-");
83
+ const last = segments[segments.length - 1];
84
+ segments[segments.length - 1] = pluraliseWord(last);
85
+ return segments.join("-");
86
+ }
@@ -0,0 +1,126 @@
1
+ import { kebabCase, pluralise, titleCase } from "./casing.js";
2
+ import {
3
+ type AuditEvent,
4
+ type AuditSink,
5
+ type FormFieldOverride,
6
+ RESOURCE_ACTIONS,
7
+ type Resource,
8
+ type ResourceAction,
9
+ type ResourceOptions,
10
+ } from "./types.js";
11
+
12
+ const NAME_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
13
+
14
+ const ACTION_LOOKUP: ReadonlySet<string> = new Set(RESOURCE_ACTIONS);
15
+
16
+ function isResourceAction(value: unknown): value is ResourceAction {
17
+ return typeof value === "string" && ACTION_LOOKUP.has(value);
18
+ }
19
+
20
+ /**
21
+ * Build a frozen `Resource` from a developer's declaration.
22
+ *
23
+ * Validation is fail-fast and pre-HTTP: a typo in `actions` or `name` throws
24
+ * here, not at request time, so the offending key surfaces in boot logs.
25
+ */
26
+ export function defineResource<T>(options: ResourceOptions<T>): Resource<T> {
27
+ if (typeof options.entity !== "function") {
28
+ throw new TypeError(
29
+ "[station] defineResource: 'entity' must be a class constructor",
30
+ );
31
+ }
32
+
33
+ if (options.actions !== undefined) {
34
+ if (!Array.isArray(options.actions) || options.actions.length === 0) {
35
+ throw new Error(
36
+ "[station] defineResource: 'actions' must contain at least one action",
37
+ );
38
+ }
39
+ for (const action of options.actions) {
40
+ if (!isResourceAction(action)) {
41
+ throw new Error(
42
+ `[station] defineResource: unknown action '${String(action)}' (allowed: list, show, create, edit, destroy)`,
43
+ );
44
+ }
45
+ }
46
+ }
47
+
48
+ if (options.name !== undefined && !NAME_PATTERN.test(options.name)) {
49
+ throw new Error(
50
+ `[station] defineResource: 'name' must be lowercase kebab-case (got: '${options.name}')`,
51
+ );
52
+ }
53
+
54
+ const entityName = options.entity.name;
55
+ if (entityName === "") {
56
+ throw new Error(
57
+ "[station] defineResource: 'entity' class has no name (anonymous class); pass 'name:' explicitly",
58
+ );
59
+ }
60
+ const slugBase = kebabCase(entityName);
61
+ const name = options.name ?? pluralise(slugBase);
62
+ if (options.name === undefined && !NAME_PATTERN.test(name)) {
63
+ throw new Error(
64
+ `[station] defineResource: entity name '${entityName}' produces invalid slug '${name}'; pass 'name:' explicitly to override`,
65
+ );
66
+ }
67
+ const label = options.label ?? titleCase(pluralise(slugBase));
68
+
69
+ const enabled: ReadonlySet<ResourceAction> =
70
+ options.actions === undefined
71
+ ? new Set<ResourceAction>(RESOURCE_ACTIONS)
72
+ : new Set<ResourceAction>(options.actions);
73
+
74
+ const orderedActions: ReadonlyArray<ResourceAction> = Object.freeze(
75
+ RESOURCE_ACTIONS.filter((action) => enabled.has(action)),
76
+ );
77
+
78
+ // 54.5: validate per-field overrides — keyed by camelCase property
79
+ // name on the entity. Validation is shape-only here; the inferred
80
+ // form vs override merge happens at render time in views/form.ts.
81
+ const formFieldsRaw = options.formFields ?? {};
82
+ const formFields: Record<string, FormFieldOverride> = {};
83
+ for (const [field, override] of Object.entries(formFieldsRaw)) {
84
+ if (override === null || typeof override !== "object") {
85
+ throw new TypeError(
86
+ `[station] defineResource: 'formFields.${field}' must be an object`,
87
+ );
88
+ }
89
+ formFields[field] = override;
90
+ }
91
+
92
+ // 54.6: audit sink shape-check — if provided, must be callable. No
93
+ // extra validation; the sink is exercised lazily on first write.
94
+ let audit: AuditSink | undefined;
95
+ if (options.audit !== undefined) {
96
+ if (typeof options.audit !== "function") {
97
+ throw new TypeError(
98
+ `[station] defineResource: 'audit' must be a function (got ${typeof options.audit})`,
99
+ );
100
+ }
101
+ audit = options.audit;
102
+ }
103
+
104
+ // 54.6 hardening: optional audit-failure observability hook.
105
+ let onAuditError: ((event: AuditEvent, error: unknown) => void) | undefined;
106
+ if (options.onAuditError !== undefined) {
107
+ if (typeof options.onAuditError !== "function") {
108
+ throw new TypeError(
109
+ `[station] defineResource: 'onAuditError' must be a function (got ${typeof options.onAuditError})`,
110
+ );
111
+ }
112
+ onAuditError = options.onAuditError;
113
+ }
114
+
115
+ const resource: Resource<T> = {
116
+ entity: options.entity,
117
+ name,
118
+ label,
119
+ actions: orderedActions,
120
+ audit,
121
+ onAuditError,
122
+ formFields: Object.freeze(formFields),
123
+ };
124
+
125
+ return Object.freeze(resource);
126
+ }
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ export { kebabCase, pluralise, titleCase } from "./casing.js";
2
+ export { defineResource } from "./defineResource.js";
3
+ export { ResourceRegistry } from "./ResourceRegistry.js";
4
+ export type { StationAppContext } from "./StationProvider.js";
5
+ export { default as StationProvider } from "./StationProvider.js";
6
+ export {
7
+ type AuditEvent,
8
+ type AuditSink,
9
+ type FormFieldOverride,
10
+ RESOURCE_ACTIONS,
11
+ type Resource,
12
+ type ResourceAction,
13
+ type ResourceOptions,
14
+ } from "./types.js";
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Default `ResourceRegistry` singleton — Adonis-style:
3
+ *
4
+ * import station from '@c9up/station/services/main'
5
+ *
6
+ * station.register(defineResource({ entity: User }))
7
+ *
8
+ * Populated either by `StationProvider.boot()` (lands in story 54.7) or by
9
+ * the app itself via `setStation(myRegistry)`.
10
+ */
11
+
12
+ import type { ResourceRegistry } from "../ResourceRegistry.js";
13
+
14
+ let instance: ResourceRegistry | undefined;
15
+
16
+ /** @internal Bind the singleton (called by StationProvider or by the app). */
17
+ export function setStation(value: ResourceRegistry): void {
18
+ instance = value;
19
+ }
20
+
21
+ /** @internal Read the singleton (or `undefined` pre-boot). */
22
+ export function getStation(): ResourceRegistry | undefined {
23
+ return instance;
24
+ }
25
+
26
+ const station: ResourceRegistry = new Proxy({} as ResourceRegistry, {
27
+ get(_target, prop) {
28
+ if (!instance) {
29
+ throw new Error(
30
+ "[station] ResourceRegistry singleton accessed before StationProvider.boot() ran " +
31
+ "or `setStation(myRegistry)` was called. Wire one of them first.",
32
+ );
33
+ }
34
+ const value = Reflect.get(instance, prop, instance);
35
+ return typeof value === "function" ? value.bind(instance) : value;
36
+ },
37
+ });
38
+
39
+ export default station;
package/src/types.ts ADDED
@@ -0,0 +1,108 @@
1
+ export type ResourceAction = "list" | "show" | "create" | "edit" | "destroy";
2
+
3
+ export const RESOURCE_ACTIONS: ReadonlyArray<ResourceAction> = Object.freeze([
4
+ "list",
5
+ "show",
6
+ "create",
7
+ "edit",
8
+ "destroy",
9
+ ]);
10
+
11
+ /**
12
+ * Audit-trail event (Story 54.6). Emitted AFTER a write action lands
13
+ * successfully — on a 4xx/5xx the event does not fire (the action's
14
+ * effect is the boundary). `before` is present for `edit` and
15
+ * `destroy`; `after` is present for `create` and `edit`.
16
+ */
17
+ export interface AuditEvent {
18
+ readonly action: ResourceAction;
19
+ readonly resource: string;
20
+ readonly recordId?: unknown;
21
+ readonly userId?: unknown;
22
+ readonly before?: Readonly<Record<string, unknown>>;
23
+ readonly after?: Readonly<Record<string, unknown>>;
24
+ readonly at: Date;
25
+ }
26
+
27
+ export type AuditSink = (event: AuditEvent) => void | Promise<void>;
28
+
29
+ /**
30
+ * Per-field override for the auto-generated form (Story 54.5). Station
31
+ * infers the form from the entity's `@Column` metadata; consumers can
32
+ * override one column at a time without losing the inference for the
33
+ * others.
34
+ */
35
+ export interface FormFieldOverride {
36
+ /** Hide the field from the form entirely (still rendered on show). */
37
+ hidden?: boolean;
38
+ /** Override the rendered `<input type>`. */
39
+ inputType?:
40
+ | "text"
41
+ | "number"
42
+ | "email"
43
+ | "password"
44
+ | "date"
45
+ | "datetime-local"
46
+ | "checkbox"
47
+ | "textarea";
48
+ /** Override the visible label (defaults to the column name title-cased). */
49
+ label?: string;
50
+ /** Add an `<input placeholder>`. */
51
+ placeholder?: string;
52
+ /** Mark the field as required at the HTML level. */
53
+ required?: boolean;
54
+ }
55
+
56
+ export interface ResourceOptions<TEntity> {
57
+ /** Entity class (Atlas `@Entity()`-decorated constructor). */
58
+ entity: new (
59
+ ...args: never[]
60
+ ) => TEntity;
61
+ /** Human-readable label shown in the admin sidebar (e.g. "Users"). */
62
+ label?: string;
63
+ /** Subset of actions to mount. Default: all five. */
64
+ actions?: ReadonlyArray<ResourceAction>;
65
+ /** URL slug override. Default: derived from the entity class name. */
66
+ name?: string;
67
+ /**
68
+ * Audit sink invoked AFTER each successful write (Story 54.6). When
69
+ * omitted, Station falls back to a once-per-process stderr warning
70
+ * so apps don't silently lose audit visibility.
71
+ */
72
+ audit?: AuditSink;
73
+ /**
74
+ * Invoked when the audit sink throws (Story 54.6 hardening, retro
75
+ * 2026-06-01). The mutation has already committed, so this is a
76
+ * compliance-observability hook — emit an alert / enqueue a retry /
77
+ * record the gap. Station does NOT block the user request on an audit
78
+ * failure; without this handler the failure is logged at `error`
79
+ * level. The handler is itself wrapped so a throwing handler can't
80
+ * crash the request.
81
+ */
82
+ onAuditError?: (event: AuditEvent, error: unknown) => void;
83
+ /**
84
+ * Per-field overrides on top of the inferred form (Story 54.5).
85
+ * Keyed by the entity's camelCase property name; absent keys keep
86
+ * the inferred defaults.
87
+ */
88
+ formFields?: Readonly<Record<string, FormFieldOverride>>;
89
+ }
90
+
91
+ export interface Resource<TEntity = unknown> {
92
+ /** The entity class passed in. */
93
+ readonly entity: new (
94
+ ...args: never[]
95
+ ) => TEntity;
96
+ /** URL slug — e.g. "users", "blog-posts". Always lowercase, kebab-case. */
97
+ readonly name: string;
98
+ /** Display label — e.g. "Users". Defaults to a title-cased pluralised entity name. */
99
+ readonly label: string;
100
+ /** Frozen list of enabled actions, in canonical order (list, show, create, edit, destroy). */
101
+ readonly actions: ReadonlyArray<ResourceAction>;
102
+ /** Optional audit sink (Story 54.6). */
103
+ readonly audit?: AuditSink;
104
+ /** Optional audit-failure observability hook (Story 54.6 hardening). */
105
+ readonly onAuditError?: (event: AuditEvent, error: unknown) => void;
106
+ /** Frozen per-field overrides for the inferred form (Story 54.5). */
107
+ readonly formFields: Readonly<Record<string, FormFieldOverride>>;
108
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Station-branded 404 for `GET /admin/<slug>/:id` where the row doesn't
3
+ * exist. NOT used for unknown slugs (those fall through to Ream's
4
+ * default 404 — see story spec D6).
5
+ */
6
+
7
+ import type { Resource } from "../../types.js";
8
+ import { escapeHtml, safeHtml } from "../escape.js";
9
+ import { renderLayout } from "../layout.js";
10
+
11
+ export interface NotFoundPageInput {
12
+ resource: Resource;
13
+ id: string;
14
+ }
15
+
16
+ export function renderNotFoundPage(input: NotFoundPageInput): string {
17
+ const { resource, id } = input;
18
+ const slug = encodeURIComponent(resource.name);
19
+ const body =
20
+ `<h1>404 Not Found</h1>` +
21
+ `<p>No ${escapeHtml(resource.label.toLowerCase())} with ID <code>${escapeHtml(id)}</code>.</p>` +
22
+ `<a class="st-backlink" href="/admin/${slug}">← Back to ${escapeHtml(resource.label)}</a>`;
23
+ return renderLayout({
24
+ title: "Not Found",
25
+ bodyHtml: safeHtml(body),
26
+ });
27
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * HTML-escape helpers for Station's hand-rolled view layer.
3
+ *
4
+ * Every dynamic value interpolated into a `${…}` slot in a view template
5
+ * MUST flow through `escapeHtml()`. The lint sweep at
6
+ * `tests/unit/no-unescaped-interpolation.test.ts` enforces this on a
7
+ * grep-pass over `src/views/**` so a missing wrap surfaces as a failing
8
+ * test rather than a stored-XSS bug.
9
+ *
10
+ * `safeHtml()` is the escape hatch for pre-escaped fragments that views
11
+ * compose (e.g., the rendered body the layout helper splices into its
12
+ * shell). The branded shape (`{ readonly __safe: true; readonly html }`)
13
+ * makes the boundary explicit and lets the lint sweep skip lines that
14
+ * mention `__safe` as the only legitimate raw-HTML carrier.
15
+ */
16
+
17
+ const ESCAPE_MAP: Readonly<Record<string, string>> = Object.freeze({
18
+ "&": "&amp;",
19
+ "<": "&lt;",
20
+ ">": "&gt;",
21
+ '"': "&quot;",
22
+ "'": "&#39;",
23
+ });
24
+
25
+ /** Escape a value for safe interpolation into HTML text or attribute content. */
26
+ export function escapeHtml(value: unknown): string {
27
+ if (value === null || value === undefined) return "";
28
+ const str = typeof value === "string" ? value : String(value);
29
+ return str.replace(/[&<>"']/g, (ch) => ESCAPE_MAP[ch] ?? ch);
30
+ }
31
+
32
+ /**
33
+ * Branded wrapper marking a string as already HTML-escaped (or
34
+ * deliberately raw, e.g., the inline CSS block in the layout). The
35
+ * lint sweep that bans bare `${…}` interpolations excludes lines
36
+ * containing `__safe`.
37
+ */
38
+ export interface SafeHtml {
39
+ readonly __safe: true;
40
+ readonly html: string;
41
+ }
42
+
43
+ /** Mark a string as pre-escaped / deliberately raw HTML. */
44
+ export function safeHtml(html: string): SafeHtml {
45
+ return Object.freeze({ __safe: true as const, html });
46
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * `GET /admin/<slug>/new` + `GET /admin/<slug>/:id/edit` — form view
3
+ * shared between the create and edit actions (Story 54.3).
4
+ *
5
+ * Field rendering is INFERRED from the entity's `@Column` metadata
6
+ * (Story 54.5): column type maps to an `<input type>`, boolean →
7
+ * checkbox, text-long → textarea, etc. The primary key and any
8
+ * timestamp columns (`created_at` / `updated_at` / `deleted_at`) are
9
+ * skipped from the form body. Per-field overrides declared on
10
+ * `Resource.formFields` win over the inferred defaults.
11
+ *
12
+ * Every dynamic value flows through `escapeHtml()` so a malicious
13
+ * column value rendered into an edit form cannot smuggle markup. The
14
+ * lint sweep at `tests/unit/no-unescaped-interpolation.test.ts`
15
+ * enforces the convention.
16
+ */
17
+
18
+ import type { ColumnMetadata } from "@c9up/atlas";
19
+ import type { FormFieldOverride, Resource } from "../types.js";
20
+ import { escapeHtml, safeHtml } from "./escape.js";
21
+ import { renderLayout } from "./layout.js";
22
+
23
+ const SKIPPED_COLUMN_NAMES: ReadonlySet<string> = new Set([
24
+ "created_at",
25
+ "updated_at",
26
+ "deleted_at",
27
+ ]);
28
+
29
+ export interface FormPageInput {
30
+ resource: Resource;
31
+ columns: ReadonlyArray<ColumnMetadata>;
32
+ pkColumn: string;
33
+ /** Existing row for edit; undefined for create. */
34
+ row?: Readonly<Record<string, unknown>>;
35
+ /** Validation errors keyed by column propertyKey. */
36
+ errors?: Readonly<Record<string, string>>;
37
+ /** Caller-controlled hidden inputs (e.g. CSRF token). */
38
+ hiddenInputs?: ReadonlyArray<{ name: string; value: string }>;
39
+ }
40
+
41
+ export function renderFormPage(input: FormPageInput): string {
42
+ const { resource, columns, pkColumn, row, errors, hiddenInputs } = input;
43
+ const isEdit = row !== undefined;
44
+ const slug = encodeURIComponent(resource.name);
45
+ const id = isEdit ? String(row[pkColumn] ?? "") : "";
46
+ const action = isEdit
47
+ ? `/admin/${slug}/${encodeURIComponent(id)}`
48
+ : `/admin/${slug}`;
49
+ const methodOverride = isEdit
50
+ ? `<input type="hidden" name="_method" value="PUT">`
51
+ : "";
52
+ const heading = isEdit
53
+ ? `Edit ${escapeHtml(resource.label)} #${escapeHtml(id)}`
54
+ : `New ${escapeHtml(resource.label)}`;
55
+
56
+ const hiddens = (hiddenInputs ?? [])
57
+ .map(
58
+ (h) =>
59
+ `<input type="hidden" name="${escapeHtml(h.name)}" value="${escapeHtml(h.value)}">`,
60
+ )
61
+ .join("");
62
+
63
+ const fieldHtml = columns
64
+ .filter(
65
+ (c) => !shouldSkipColumn(c, pkColumn, resource.formFields[c.propertyKey]),
66
+ )
67
+ .map((c) => renderField(c, row, errors, resource.formFields[c.propertyKey]))
68
+ .join("");
69
+
70
+ const submitLabel = isEdit ? "Update" : "Create";
71
+ const cancelUrl = isEdit
72
+ ? `/admin/${slug}/${encodeURIComponent(id)}`
73
+ : `/admin/${slug}`;
74
+
75
+ const body =
76
+ `<h1>${heading}</h1>` +
77
+ `<form class="st-form" method="POST" action="${escapeHtml(action)}">` +
78
+ methodOverride +
79
+ hiddens +
80
+ fieldHtml +
81
+ `<div class="st-form-actions">` +
82
+ `<button type="submit">${escapeHtml(submitLabel)}</button>` +
83
+ `<a class="st-cancel" href="${escapeHtml(cancelUrl)}">Cancel</a>` +
84
+ `</div>` +
85
+ `</form>`;
86
+
87
+ return renderLayout({
88
+ title: isEdit ? `Edit ${resource.label} #${id}` : `New ${resource.label}`,
89
+ bodyHtml: safeHtml(body),
90
+ });
91
+ }
92
+
93
+ function shouldSkipColumn(
94
+ c: ColumnMetadata,
95
+ pkColumn: string,
96
+ override: FormFieldOverride | undefined,
97
+ ): boolean {
98
+ if (override?.hidden === true) return true;
99
+ // Atlas's ColumnMetadata exposes the property key (camelCase). The
100
+ // pkColumn passed by the provider is also camelCase (it comes from
101
+ // `atlas.getPrimaryKey(entity)`), so the comparison is direct.
102
+ // snake_case fallbacks live in SKIPPED_COLUMN_NAMES below for the
103
+ // timestamps convention.
104
+ if (c.propertyKey === pkColumn) return true;
105
+ const snake = c.propertyKey.replace(/([A-Z])/g, "_$1").toLowerCase();
106
+ if (SKIPPED_COLUMN_NAMES.has(snake)) return true;
107
+ if (SKIPPED_COLUMN_NAMES.has(c.propertyKey)) return true;
108
+ return false;
109
+ }
110
+
111
+ function renderField(
112
+ c: ColumnMetadata,
113
+ row: Readonly<Record<string, unknown>> | undefined,
114
+ errors: Readonly<Record<string, string>> | undefined,
115
+ override: FormFieldOverride | undefined,
116
+ ): string {
117
+ const inputType = override?.inputType ?? inferInputType(c);
118
+ const labelText = override?.label ?? titleise(c.propertyKey);
119
+ const required = override?.required === true ? " required" : "";
120
+ const placeholder =
121
+ override?.placeholder !== undefined
122
+ ? ` placeholder="${escapeHtml(override.placeholder)}"`
123
+ : "";
124
+ const name = c.propertyKey;
125
+ const fieldId = `f-${escapeHtml(name)}`;
126
+ const rawValue = row?.[name];
127
+ const errorMsg = errors?.[name];
128
+ const errorHtml =
129
+ errorMsg !== undefined
130
+ ? `<p class="st-field-error">${escapeHtml(errorMsg)}</p>`
131
+ : "";
132
+
133
+ const input = renderInput(
134
+ inputType,
135
+ fieldId,
136
+ name,
137
+ rawValue,
138
+ required,
139
+ placeholder,
140
+ );
141
+
142
+ return (
143
+ `<div class="st-field">` +
144
+ `<label for="${escapeHtml(fieldId)}">${escapeHtml(labelText)}</label>` +
145
+ input +
146
+ errorHtml +
147
+ `</div>`
148
+ );
149
+ }
150
+
151
+ /** Render the `<input>` / `<textarea>` element for a field by input type. */
152
+ function renderInput(
153
+ inputType: FormFieldOverride["inputType"],
154
+ fieldId: string,
155
+ name: string,
156
+ rawValue: unknown,
157
+ required: string,
158
+ placeholder: string,
159
+ ): string {
160
+ if (inputType === "checkbox") {
161
+ const checked = rawValue ? " checked" : "";
162
+ return `<input id="${escapeHtml(fieldId)}" type="checkbox" name="${escapeHtml(name)}" value="1"${checked}>`;
163
+ }
164
+ const value =
165
+ rawValue === undefined || rawValue === null ? "" : String(rawValue);
166
+ if (inputType === "textarea") {
167
+ return `<textarea id="${escapeHtml(fieldId)}" name="${escapeHtml(name)}"${required}${placeholder}>${escapeHtml(value)}</textarea>`;
168
+ }
169
+ return `<input id="${escapeHtml(fieldId)}" type="${escapeHtml(inputType)}" name="${escapeHtml(name)}" value="${escapeHtml(value)}"${required}${placeholder}>`;
170
+ }
171
+
172
+ function inferInputType(c: ColumnMetadata): FormFieldOverride["inputType"] {
173
+ const type = (c.type ?? "").toString().toLowerCase();
174
+ if (type === "boolean") return "checkbox";
175
+ if (type === "integer" || type === "bigint" || type === "number") {
176
+ return "number";
177
+ }
178
+ if (type === "date") return "date";
179
+ if (type === "datetime" || type === "timestamp") return "datetime-local";
180
+ if (type === "text" || type === "longtext") return "textarea";
181
+ const name = c.propertyKey.toLowerCase();
182
+ if (name.includes("email")) return "email";
183
+ if (name.includes("password")) return "password";
184
+ return "text";
185
+ }
186
+
187
+ function titleise(propertyKey: string): string {
188
+ // camelCase → "Camel Case"
189
+ const spaced = propertyKey.replace(/([a-z0-9])([A-Z])/g, "$1 $2");
190
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
191
+ }