@c9up/station 0.1.5 → 0.1.7

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 (51) hide show
  1. package/dist/StationProvider.d.ts +94 -18
  2. package/dist/StationProvider.d.ts.map +1 -1
  3. package/dist/StationProvider.js +693 -148
  4. package/dist/StationProvider.js.map +1 -1
  5. package/dist/index.d.ts +7 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +7 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/views/form.d.ts +56 -16
  10. package/dist/views/form.d.ts.map +1 -1
  11. package/dist/views/form.js +53 -75
  12. package/dist/views/form.js.map +1 -1
  13. package/dist/views/list.d.ts +43 -5
  14. package/dist/views/list.d.ts.map +1 -1
  15. package/dist/views/list.js +63 -60
  16. package/dist/views/list.js.map +1 -1
  17. package/dist/views/login.d.ts +20 -8
  18. package/dist/views/login.d.ts.map +1 -1
  19. package/dist/views/login.js +15 -33
  20. package/dist/views/login.js.map +1 -1
  21. package/dist/views/show.d.ts +18 -4
  22. package/dist/views/show.d.ts.map +1 -1
  23. package/dist/views/show.js +29 -15
  24. package/dist/views/show.js.map +1 -1
  25. package/package.json +21 -4
  26. package/src/StationProvider.ts +941 -155
  27. package/src/index.ts +11 -1
  28. package/src/views/form.ts +111 -113
  29. package/src/views/list.ts +108 -75
  30. package/src/views/login.ts +31 -44
  31. package/src/views/show.ts +47 -18
  32. package/templates/errors/404.inker +4 -0
  33. package/templates/form.inker +3 -0
  34. package/{dist/views/layout.js → templates/layout.inker} +10 -35
  35. package/templates/list.inker +8 -0
  36. package/templates/login.inker +4 -0
  37. package/templates/show.inker +4 -0
  38. package/dist/views/errors/404.d.ts +0 -12
  39. package/dist/views/errors/404.d.ts.map +0 -1
  40. package/dist/views/errors/404.js +0 -19
  41. package/dist/views/errors/404.js.map +0 -1
  42. package/dist/views/escape.d.ts +0 -30
  43. package/dist/views/escape.d.ts.map +0 -1
  44. package/dist/views/escape.js +0 -34
  45. package/dist/views/escape.js.map +0 -1
  46. package/dist/views/layout.d.ts +0 -24
  47. package/dist/views/layout.d.ts.map +0 -1
  48. package/dist/views/layout.js.map +0 -1
  49. package/src/views/errors/404.ts +0 -27
  50. package/src/views/escape.ts +0 -46
  51. package/src/views/layout.ts +0 -90
package/src/index.ts CHANGED
@@ -1,8 +1,18 @@
1
1
  export { kebabCase, pluralise, titleCase } from "./casing.js";
2
2
  export { defineResource } from "./defineResource.js";
3
3
  export { ResourceRegistry } from "./ResourceRegistry.js";
4
- export type { StationAppContext } from "./StationProvider.js";
4
+ export type { StationAppContext, StationConfig } from "./StationProvider.js";
5
5
  export { default as StationProvider } from "./StationProvider.js";
6
+
7
+ import type { StationConfig } from "./StationProvider.js";
8
+
9
+ /**
10
+ * Author-time config helper for `config/station.ts` — AdonisJS `defineConfig`
11
+ * parity. Identity at runtime; the generic preserves literal types for inference.
12
+ */
13
+ export function defineConfig<T extends StationConfig>(config: T): T {
14
+ return config;
15
+ }
6
16
  export {
7
17
  type AuditEvent,
8
18
  type AuditSink,
package/src/views/form.ts CHANGED
@@ -2,23 +2,24 @@
2
2
  * `GET /admin/<slug>/new` + `GET /admin/<slug>/:id/edit` — form view
3
3
  * shared between the create and edit actions (Story 54.3).
4
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
5
+ * Pure `(input) => FormViewModel` builder (Story 57.3). Field rendering is
6
+ * INFERRED from the entity's `@Column` metadata (Story 54.5): column type maps
7
+ * to an `<input type>`, boolean → checkbox, text-long → textarea, etc. The
8
+ * primary key plus the timestamp columns (`created_at` / `updated_at` /
9
+ * `deleted_at`) are skipped from the form body. Per-field overrides declared on
10
10
  * `Resource.formFields` win over the inferred defaults.
11
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.
12
+ * The builder precomputes everything inker's `{{ }}` grammar cannot express —
13
+ * `encodeURIComponent` on the URL components (inker only HTML-escapes), the
14
+ * per-field variant discriminants, and pre-stringified values — and hands it to
15
+ * `templates/form.inker`, which iterates + interpolates. inker owns the
16
+ * HTML-escaping (the retired `escape.ts` no longer runs here). The CSRF hidden
17
+ * input is emitted by inker's canonical `{{ csrfField() }}` helper, guarded by
18
+ * the precomputed `csrfEnabled` boolean; enforcement remains story 57.6.
16
19
  */
17
20
 
18
21
  import type { ColumnMetadata } from "@c9up/atlas";
19
22
  import type { FormFieldOverride, Resource } from "../types.js";
20
- import { escapeHtml, safeHtml } from "./escape.js";
21
- import { renderLayout } from "./layout.js";
22
23
 
23
24
  const SKIPPED_COLUMN_NAMES: ReadonlySet<string> = new Set([
24
25
  "created_at",
@@ -26,68 +27,126 @@ const SKIPPED_COLUMN_NAMES: ReadonlySet<string> = new Set([
26
27
  "deleted_at",
27
28
  ]);
28
29
 
30
+ type InputType = NonNullable<FormFieldOverride["inputType"]>;
31
+
29
32
  export interface FormPageInput {
30
33
  resource: Resource;
31
34
  columns: ReadonlyArray<ColumnMetadata>;
32
35
  pkColumn: string;
33
36
  /** Existing row for edit; undefined for create. */
34
37
  row?: Readonly<Record<string, unknown>>;
38
+ /**
39
+ * Submitted values to repopulate the fields with, INDEPENDENT of `row`
40
+ * (57.7). On a 422 re-render the form must echo what the user just typed —
41
+ * for a *create* that means field values with NO `row` (so `isEdit` stays
42
+ * false and the action URL stays `/admin/<slug>`), and for an *edit* it means
43
+ * the submitted values while `row` still supplies the id/action. When absent,
44
+ * fields fall back to `row` (the normal create/edit GET render).
45
+ */
46
+ values?: Readonly<Record<string, unknown>>;
35
47
  /** Validation errors keyed by column propertyKey. */
36
48
  errors?: Readonly<Record<string, string>>;
37
- /** Caller-controlled hidden inputs (e.g. CSRF token). */
38
- hiddenInputs?: ReadonlyArray<{ name: string; value: string }>;
49
+ /**
50
+ * Whether the host has a CSRF token in the per-request store. When true the
51
+ * template emits `{{ csrfField() }}`; the handler derives it from
52
+ * `ctx.store` (57.3). Replaces the old `hiddenInputs?` array.
53
+ */
54
+ csrfEnabled?: boolean;
55
+ }
56
+
57
+ /** A single form field descriptor consumed by `form.inker`. */
58
+ export interface FormField {
59
+ name: string;
60
+ fieldId: string;
61
+ label: string;
62
+ inputType: InputType;
63
+ isCheckbox: boolean;
64
+ isTextarea: boolean;
65
+ value: string;
66
+ required: boolean;
67
+ placeholder: string;
68
+ hasPlaceholder: boolean;
69
+ checked: boolean;
70
+ error: string;
39
71
  }
40
72
 
41
- export function renderFormPage(input: FormPageInput): string {
42
- const { resource, columns, pkColumn, row, errors, hiddenInputs } = input;
73
+ // A `type` alias (not `interface`) so the view-model carries an implicit index
74
+ // signature and stays assignable to the renderer's
75
+ // `Readonly<Record<string, unknown>>` data param without a cast.
76
+ export type FormViewModel = {
77
+ title: string;
78
+ heading: string;
79
+ isEdit: boolean;
80
+ action: string;
81
+ cancelUrl: string;
82
+ submitLabel: string;
83
+ csrfEnabled: boolean;
84
+ fields: FormField[];
85
+ };
86
+
87
+ export function buildFormViewModel(input: FormPageInput): FormViewModel {
88
+ const { resource, columns, pkColumn, row, values, errors, csrfEnabled } =
89
+ input;
43
90
  const isEdit = row !== undefined;
44
91
  const slug = encodeURIComponent(resource.name);
45
92
  const id = isEdit ? String(row[pkColumn] ?? "") : "";
93
+ // Contains `encodeURIComponent(` + the structural `slug`, so the
94
+ // no-unescaped-interpolation grep-ban allows these interpolation slots.
46
95
  const action = isEdit
47
96
  ? `/admin/${slug}/${encodeURIComponent(id)}`
48
97
  : `/admin/${slug}`;
49
- const methodOverride = isEdit
50
- ? `<input type="hidden" name="_method" value="PUT">`
51
- : "";
98
+ // Composed via array `join` (no `${}`, no `+`) so neither biome's
99
+ // useTemplate nor the grep-ban fires on this now-data-only file (show.ts:50).
52
100
  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("");
101
+ ? ["Edit ", resource.label, " #", id].join("")
102
+ : ["New ", resource.label].join("");
62
103
 
63
- const fieldHtml = columns
104
+ const fields = columns
64
105
  .filter(
65
106
  (c) => !shouldSkipColumn(c, pkColumn, resource.formFields[c.propertyKey]),
66
107
  )
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>`;
108
+ .map((c) =>
109
+ buildField(c, values ?? row, errors, resource.formFields[c.propertyKey]),
110
+ );
111
+
112
+ return {
113
+ title: heading,
114
+ heading,
115
+ isEdit,
116
+ action,
117
+ cancelUrl: action,
118
+ submitLabel: isEdit ? "Update" : "Create",
119
+ csrfEnabled: csrfEnabled === true,
120
+ fields,
121
+ };
122
+ }
86
123
 
87
- return renderLayout({
88
- title: isEdit ? `Edit ${resource.label} #${id}` : `New ${resource.label}`,
89
- bodyHtml: safeHtml(body),
90
- });
124
+ function buildField(
125
+ c: ColumnMetadata,
126
+ source: Readonly<Record<string, unknown>> | undefined,
127
+ errors: Readonly<Record<string, string>> | undefined,
128
+ override: FormFieldOverride | undefined,
129
+ ): FormField {
130
+ const inputType = override?.inputType ?? inferInputType(c);
131
+ const name = c.propertyKey;
132
+ const rawValue = source?.[name];
133
+ const isCheckbox = inputType === "checkbox";
134
+ const value =
135
+ rawValue === undefined || rawValue === null ? "" : String(rawValue);
136
+ return {
137
+ name,
138
+ fieldId: ["f-", name].join(""),
139
+ label: override?.label ?? titleise(name),
140
+ inputType,
141
+ isCheckbox,
142
+ isTextarea: inputType === "textarea",
143
+ value,
144
+ required: override?.required === true,
145
+ placeholder: override?.placeholder ?? "",
146
+ hasPlaceholder: override?.placeholder !== undefined,
147
+ checked: isCheckbox ? Boolean(rawValue) : false,
148
+ error: errors?.[name] ?? "",
149
+ };
91
150
  }
92
151
 
93
152
  function shouldSkipColumn(
@@ -108,68 +167,7 @@ function shouldSkipColumn(
108
167
  return false;
109
168
  }
110
169
 
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"] {
170
+ function inferInputType(c: ColumnMetadata): InputType {
173
171
  const type = (c.type ?? "").toString().toLowerCase();
174
172
  if (type === "boolean") return "checkbox";
175
173
  if (type === "integer" || type === "bigint" || type === "number") {
package/src/views/list.ts CHANGED
@@ -1,16 +1,16 @@
1
1
  /**
2
2
  * `GET /admin/<slug>` — paginated list view.
3
3
  *
4
- * Pure `(input) => string` renderer. Every dynamic value flows through
5
- * `escapeHtml()` or `encodeURIComponent()` (URL components). The
6
- * "interpolation must wrap" rule is grep-enforced by
7
- * `tests/unit/no-unescaped-interpolation.test.ts`.
4
+ * Pure `(input) => ListViewModel` builder. It precomputes everything the
5
+ * inker `{{ }}` grammar cannot express — `encodeURIComponent` on URL
6
+ * components (inker only HTML-escapes), the pager-collapse shape, the
7
+ * caption bounds, and each row flattened to pre-stringified `cells` — and
8
+ * hands it to `templates/list.inker`, which iterates and interpolates.
9
+ * inker owns the HTML-escaping (the retired `escape.ts` no longer runs here).
8
10
  */
9
11
 
10
12
  import type { ColumnMetadata } from "@c9up/atlas";
11
13
  import type { Resource } from "../types.js";
12
- import { escapeHtml, safeHtml } from "./escape.js";
13
- import { renderLayout } from "./layout.js";
14
14
 
15
15
  export interface ListPageInput {
16
16
  resource: Resource;
@@ -23,99 +23,132 @@ export interface ListPageInput {
23
23
  lastPage: number;
24
24
  }
25
25
 
26
- export function renderListPage(input: ListPageInput): string {
26
+ /** A pager step (prev / next) — an anchor unless it is the edge (disabled). */
27
+ export interface ListPagerStep {
28
+ href: string;
29
+ disabled: boolean;
30
+ }
31
+
32
+ /** A numbered pager slot, or an ellipsis gap when `isEllipsis`. */
33
+ export interface ListPagerPage {
34
+ n: number;
35
+ href: string;
36
+ isCurrent: boolean;
37
+ isEllipsis: boolean;
38
+ }
39
+
40
+ /** A single data row: pre-stringified cells + the precomputed Show href. */
41
+ export interface ListRow {
42
+ cells: string[];
43
+ showHref: string;
44
+ }
45
+
46
+ // A `type` alias (not `interface`) so the view-model carries an implicit
47
+ // index signature and stays assignable to the renderer's
48
+ // `Readonly<Record<string, unknown>>` data param without a cast.
49
+ export type ListViewModel = {
50
+ title: string;
51
+ heading: string;
52
+ labelLower: string;
53
+ empty: boolean;
54
+ columns: string[];
55
+ rows: ListRow[];
56
+ pager: {
57
+ prev: ListPagerStep;
58
+ next: ListPagerStep;
59
+ pages: ListPagerPage[];
60
+ };
61
+ caption: { show: boolean; start: number; end: number; total: number };
62
+ };
63
+
64
+ export function buildListViewModel(input: ListPageInput): ListViewModel {
27
65
  const { resource, rows, columns, pkColumn, page, perPage, total, lastPage } =
28
66
  input;
29
67
  const slug = encodeURIComponent(resource.name);
30
- const body =
31
- rows.length === 0
32
- ? renderEmptyState(resource)
33
- : renderTable(rows, columns, pkColumn, slug);
34
- const pager = renderPager(slug, page, perPage, lastPage);
35
- const caption = renderCaption(page, perPage, total);
36
- return renderLayout({
68
+ const columnKeys = columns.map((c) => c.propertyKey);
69
+ return {
37
70
  title: resource.label,
38
- bodyHtml: safeHtml(
39
- `<h1>${escapeHtml(resource.label)}</h1>${body}${pager}${caption}`,
40
- ),
41
- });
42
- }
43
-
44
- function renderEmptyState(resource: Resource): string {
45
- return `<p class="st-empty">No ${escapeHtml(resource.label.toLowerCase())} yet.</p>`;
71
+ heading: resource.label,
72
+ labelLower: resource.label.toLowerCase(),
73
+ empty: rows.length === 0,
74
+ columns: columnKeys,
75
+ rows: rows.map((row) => ({
76
+ cells: columns.map((c) => stringifyCell(row[c.propertyKey])),
77
+ showHref: `/admin/${slug}/${encodeURIComponent(String(row[pkColumn] ?? ""))}`,
78
+ })),
79
+ pager: buildPager(slug, page, perPage, lastPage),
80
+ caption: buildCaption(page, perPage, total),
81
+ };
46
82
  }
47
83
 
48
- function renderTable(
49
- rows: ReadonlyArray<Record<string, unknown>>,
50
- columns: ReadonlyArray<ColumnMetadata>,
51
- pkColumn: string,
52
- slug: string,
53
- ): string {
54
- const headers = columns
55
- .map((c) => `<th>${escapeHtml(c.propertyKey)}</th>`)
56
- .join("");
57
- const headerRow = `<thead><tr>${headers}<th></th></tr></thead>`;
58
- const bodyRows = rows
59
- .map((row) => {
60
- const cells = columns
61
- .map((c) => `<td>${escapeHtml(row[c.propertyKey])}</td>`)
62
- .join("");
63
- const id = String(row[pkColumn] ?? "");
64
- const showLink = `<td><a href="/admin/${slug}/${encodeURIComponent(id)}">Show</a></td>`;
65
- return `<tr>${cells}${showLink}</tr>`;
66
- })
67
- .join("");
68
- return `<table>${headerRow}<tbody>${bodyRows}</tbody></table>`;
84
+ /** Match the retired escaper's `null | undefined` → `""` behaviour. */
85
+ function stringifyCell(value: unknown): string {
86
+ return value === null || value === undefined ? "" : String(value);
69
87
  }
70
88
 
71
- function renderPager(
89
+ function buildPager(
72
90
  slug: string,
73
91
  page: number,
74
92
  perPage: number,
75
93
  lastPage: number,
76
- ): string {
94
+ ): ListViewModel["pager"] {
77
95
  const pp = encodeURIComponent(String(perPage));
78
- const prev =
79
- page > 1
80
- ? `<a href="/admin/${slug}?page=${page - 1}&perPage=${pp}">« Prev</a>`
81
- : `<span class="st-disabled">« Prev</span>`;
82
- const next =
83
- page < lastPage
84
- ? `<a href="/admin/${slug}?page=${page + 1}&perPage=${pp}">Next »</a>`
85
- : `<span class="st-disabled">Next »</span>`;
86
- const numbers = renderPageNumbers(slug, page, lastPage, pp);
87
- return `<div class="st-pager">${prev}${numbers}${next}</div>`;
96
+ const href = (n: number): string => `/admin/${slug}?page=${n}&perPage=${pp}`;
97
+ return {
98
+ prev: { href: page > 1 ? href(page - 1) : "", disabled: page <= 1 },
99
+ next: {
100
+ href: page < lastPage ? href(page + 1) : "",
101
+ disabled: page >= lastPage,
102
+ },
103
+ pages: buildPageNumbers(slug, page, lastPage, pp),
104
+ };
88
105
  }
89
106
 
90
- function renderPageNumbers(
107
+ function buildPageNumbers(
91
108
  slug: string,
92
109
  page: number,
93
110
  lastPage: number,
94
111
  pp: string,
95
- ): string {
96
- const link = (n: number): string =>
97
- n === page
98
- ? `<strong>${n}</strong>`
99
- : `<a href="/admin/${slug}?page=${n}&perPage=${pp}">${n}</a>`;
112
+ ): ListPagerPage[] {
113
+ const href = (n: number): string => `/admin/${slug}?page=${n}&perPage=${pp}`;
114
+ const numbered = (n: number): ListPagerPage => ({
115
+ n,
116
+ href: href(n),
117
+ isCurrent: n === page,
118
+ isEllipsis: false,
119
+ });
120
+ const ellipsis: ListPagerPage = {
121
+ n: 0,
122
+ href: "",
123
+ isCurrent: false,
124
+ isEllipsis: true,
125
+ };
100
126
  if (lastPage <= 7) {
101
- const parts: string[] = [];
102
- for (let i = 1; i <= lastPage; i++) parts.push(link(i));
103
- return parts.join("");
127
+ const parts: ListPagerPage[] = [];
128
+ for (let i = 1; i <= lastPage; i++) parts.push(numbered(i));
129
+ return parts;
104
130
  }
105
131
  // Collapsed shape: 1 … current-1, current, current+1 … lastPage
106
- const parts: string[] = [link(1)];
132
+ const parts: ListPagerPage[] = [numbered(1)];
107
133
  const start = Math.max(2, page - 1);
108
134
  const end = Math.min(lastPage - 1, page + 1);
109
- if (start > 2) parts.push(`<span class="st-ellipsis">…</span>`);
110
- for (let i = start; i <= end; i++) parts.push(link(i));
111
- if (end < lastPage - 1) parts.push(`<span class="st-ellipsis">…</span>`);
112
- parts.push(link(lastPage));
113
- return parts.join("");
135
+ if (start > 2) parts.push(ellipsis);
136
+ for (let i = start; i <= end; i++) parts.push(numbered(i));
137
+ if (end < lastPage - 1) parts.push(ellipsis);
138
+ parts.push(numbered(lastPage));
139
+ return parts;
114
140
  }
115
141
 
116
- function renderCaption(page: number, perPage: number, total: number): string {
117
- if (total === 0) return "";
118
- const start = (page - 1) * perPage + 1;
119
- const end = Math.min(page * perPage, total);
120
- return `<p class="st-caption">Showing ${start}–${end} of ${total}</p>`;
142
+ function buildCaption(
143
+ page: number,
144
+ perPage: number,
145
+ total: number,
146
+ ): ListViewModel["caption"] {
147
+ if (total === 0) return { show: false, start: 0, end: 0, total };
148
+ return {
149
+ show: true,
150
+ start: (page - 1) * perPage + 1,
151
+ end: Math.min(page * perPage, total),
152
+ total,
153
+ };
121
154
  }
@@ -5,13 +5,14 @@
5
5
  * `buildLoginHandler` runs them through `auth.authenticate(...)` and
6
6
  * sets a session cookie on success.
7
7
  *
8
- * Every dynamic value flows through `escapeHtml()` so a tampered
9
- * `?error=…` query parameter cannot smuggle markup into the page.
8
+ * Pure `(input) => LoginViewModel` builder (Story 57.3): every value reaches
9
+ * HTML through `templates/login.inker`'s `{{ }}` auto-escape (the retired
10
+ * `escape.ts` no longer runs here), so a tampered `?error=…` query parameter
11
+ * cannot smuggle markup into the page. The CSRF hidden input is emitted by
12
+ * inker's canonical `{{ csrfField() }}` helper, guarded by the precomputed
13
+ * `csrfEnabled` boolean.
10
14
  */
11
15
 
12
- import { escapeHtml, safeHtml } from "./escape.js";
13
- import { renderLayout } from "./layout.js";
14
-
15
16
  export interface LoginPageInput {
16
17
  /** Pre-fill the email field (e.g. after a failed attempt). */
17
18
  email?: string;
@@ -19,47 +20,33 @@ export interface LoginPageInput {
19
20
  error?: string;
20
21
  /** Form action path. Default `/admin/login`. */
21
22
  action?: string;
22
- /** Caller-controlled hidden inputs (typically `_csrf`). */
23
- hiddenInputs?: ReadonlyArray<{ name: string; value: string }>;
23
+ /**
24
+ * Whether the host has a CSRF token in the per-request store. When true the
25
+ * template emits `{{ csrfField() }}`; the handler derives it from
26
+ * `ctx.store` (57.3). Replaces the old `hiddenInputs?` array.
27
+ */
28
+ csrfEnabled?: boolean;
24
29
  }
25
30
 
26
- export function renderLoginPage(input: LoginPageInput): string {
27
- const email = input.email ?? "";
28
- const error = input.error;
29
- const action = input.action ?? "/admin/login";
30
-
31
- const hiddens = (input.hiddenInputs ?? [])
32
- .map(
33
- (h) =>
34
- `<input type="hidden" name="${escapeHtml(h.name)}" value="${escapeHtml(h.value)}">`,
35
- )
36
- .join("");
37
-
38
- const errorBlock =
39
- error !== undefined
40
- ? `<p class="st-form-error" role="alert">${escapeHtml(error)}</p>`
41
- : "";
42
-
43
- const body =
44
- `<h1>Sign in</h1>` +
45
- errorBlock +
46
- `<form class="st-form" method="POST" action="${escapeHtml(action)}">` +
47
- hiddens +
48
- `<div class="st-field">` +
49
- `<label for="f-email">Email</label>` +
50
- `<input id="f-email" type="email" name="email" value="${escapeHtml(email)}" required autocomplete="email" autofocus>` +
51
- `</div>` +
52
- `<div class="st-field">` +
53
- `<label for="f-password">Password</label>` +
54
- `<input id="f-password" type="password" name="password" required autocomplete="current-password">` +
55
- `</div>` +
56
- `<div class="st-form-actions">` +
57
- `<button type="submit">Sign in</button>` +
58
- `</div>` +
59
- `</form>`;
31
+ // A `type` alias (not `interface`) so the view-model carries an implicit index
32
+ // signature and stays assignable to the renderer's
33
+ // `Readonly<Record<string, unknown>>` data param without a cast.
34
+ export type LoginViewModel = {
35
+ title: string;
36
+ email: string;
37
+ action: string;
38
+ error: string;
39
+ csrfEnabled: boolean;
40
+ };
60
41
 
61
- return renderLayout({
42
+ export function buildLoginViewModel(input: LoginPageInput): LoginViewModel {
43
+ return {
62
44
  title: "Sign in",
63
- bodyHtml: safeHtml(body),
64
- });
45
+ email: input.email ?? "",
46
+ // No URL-encoding: the login action is a fixed host-configured path.
47
+ action: input.action ?? "/admin/login",
48
+ // Empty string is falsy in inker, so `{% if error %}` gates the block.
49
+ error: input.error ?? "",
50
+ csrfEnabled: input.csrfEnabled === true,
51
+ };
65
52
  }