@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.
- package/dist/StationProvider.d.ts +94 -18
- package/dist/StationProvider.d.ts.map +1 -1
- package/dist/StationProvider.js +693 -148
- package/dist/StationProvider.js.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/views/form.d.ts +56 -16
- package/dist/views/form.d.ts.map +1 -1
- package/dist/views/form.js +53 -75
- package/dist/views/form.js.map +1 -1
- package/dist/views/list.d.ts +43 -5
- package/dist/views/list.d.ts.map +1 -1
- package/dist/views/list.js +63 -60
- package/dist/views/list.js.map +1 -1
- package/dist/views/login.d.ts +20 -8
- package/dist/views/login.d.ts.map +1 -1
- package/dist/views/login.js +15 -33
- package/dist/views/login.js.map +1 -1
- package/dist/views/show.d.ts +18 -4
- package/dist/views/show.d.ts.map +1 -1
- package/dist/views/show.js +29 -15
- package/dist/views/show.js.map +1 -1
- package/package.json +21 -4
- package/src/StationProvider.ts +941 -155
- package/src/index.ts +11 -1
- package/src/views/form.ts +111 -113
- package/src/views/list.ts +108 -75
- package/src/views/login.ts +31 -44
- package/src/views/show.ts +47 -18
- package/templates/errors/404.inker +4 -0
- package/templates/form.inker +3 -0
- package/{dist/views/layout.js → templates/layout.inker} +10 -35
- package/templates/list.inker +8 -0
- package/templates/login.inker +4 -0
- package/templates/show.inker +4 -0
- package/dist/views/errors/404.d.ts +0 -12
- package/dist/views/errors/404.d.ts.map +0 -1
- package/dist/views/errors/404.js +0 -19
- package/dist/views/errors/404.js.map +0 -1
- package/dist/views/escape.d.ts +0 -30
- package/dist/views/escape.d.ts.map +0 -1
- package/dist/views/escape.js +0 -34
- package/dist/views/escape.js.map +0 -1
- package/dist/views/layout.d.ts +0 -24
- package/dist/views/layout.d.ts.map +0 -1
- package/dist/views/layout.js.map +0 -1
- package/src/views/errors/404.ts +0 -27
- package/src/views/escape.ts +0 -46
- 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
|
-
*
|
|
6
|
-
* (Story 54.5): column type maps
|
|
7
|
-
* checkbox, text-long → textarea, etc. The
|
|
8
|
-
* timestamp columns (`created_at` / `updated_at` /
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
/**
|
|
38
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
?
|
|
54
|
-
:
|
|
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
|
|
104
|
+
const fields = columns
|
|
64
105
|
.filter(
|
|
65
106
|
(c) => !shouldSkipColumn(c, pkColumn, resource.formFields[c.propertyKey]),
|
|
66
107
|
)
|
|
67
|
-
.map((c) =>
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
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) =>
|
|
5
|
-
* `
|
|
6
|
-
*
|
|
7
|
-
* `
|
|
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
|
-
|
|
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
|
|
31
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
89
|
+
function buildPager(
|
|
72
90
|
slug: string,
|
|
73
91
|
page: number,
|
|
74
92
|
perPage: number,
|
|
75
93
|
lastPage: number,
|
|
76
|
-
):
|
|
94
|
+
): ListViewModel["pager"] {
|
|
77
95
|
const pp = encodeURIComponent(String(perPage));
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
107
|
+
function buildPageNumbers(
|
|
91
108
|
slug: string,
|
|
92
109
|
page: number,
|
|
93
110
|
lastPage: number,
|
|
94
111
|
pp: string,
|
|
95
|
-
):
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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:
|
|
102
|
-
for (let i = 1; i <= lastPage; i++) parts.push(
|
|
103
|
-
return parts
|
|
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:
|
|
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(
|
|
110
|
-
for (let i = start; i <= end; i++) parts.push(
|
|
111
|
-
if (end < lastPage - 1) parts.push(
|
|
112
|
-
parts.push(
|
|
113
|
-
return parts
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
}
|
package/src/views/login.ts
CHANGED
|
@@ -5,13 +5,14 @@
|
|
|
5
5
|
* `buildLoginHandler` runs them through `auth.authenticate(...)` and
|
|
6
6
|
* sets a session cookie on success.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
/**
|
|
23
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
42
|
+
export function buildLoginViewModel(input: LoginPageInput): LoginViewModel {
|
|
43
|
+
return {
|
|
62
44
|
title: "Sign in",
|
|
63
|
-
|
|
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
|
}
|