@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.
- package/LICENSE +21 -0
- package/README.md +3 -0
- package/dist/ResourceRegistry.d.ts +18 -0
- package/dist/ResourceRegistry.d.ts.map +1 -0
- package/dist/ResourceRegistry.js +38 -0
- package/dist/ResourceRegistry.js.map +1 -0
- package/dist/StationProvider.d.ts +109 -0
- package/dist/StationProvider.d.ts.map +1 -0
- package/dist/StationProvider.js +1144 -0
- package/dist/StationProvider.js.map +1 -0
- package/dist/casing.d.ts +27 -0
- package/dist/casing.d.ts.map +1 -0
- package/dist/casing.js +75 -0
- package/dist/casing.js.map +1 -0
- package/dist/defineResource.d.ts +9 -0
- package/dist/defineResource.d.ts.map +1 -0
- package/dist/defineResource.js +84 -0
- package/dist/defineResource.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/services/main.d.ts +18 -0
- package/dist/services/main.d.ts.map +1 -0
- package/dist/services/main.js +31 -0
- package/dist/services/main.js.map +1 -0
- package/dist/types.d.ts +85 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/dist/views/errors/404.d.ts +12 -0
- package/dist/views/errors/404.d.ts.map +1 -0
- package/dist/views/errors/404.js +19 -0
- package/dist/views/errors/404.js.map +1 -0
- package/dist/views/escape.d.ts +30 -0
- package/dist/views/escape.d.ts.map +1 -0
- package/dist/views/escape.js +34 -0
- package/dist/views/escape.js.map +1 -0
- package/dist/views/form.d.ts +34 -0
- package/dist/views/form.d.ts.map +1 -0
- package/dist/views/form.js +139 -0
- package/dist/views/form.js.map +1 -0
- package/dist/views/layout.d.ts +24 -0
- package/dist/views/layout.d.ts.map +1 -0
- package/dist/views/layout.js +85 -0
- package/dist/views/layout.js.map +1 -0
- package/dist/views/list.d.ts +22 -0
- package/dist/views/list.d.ts.map +1 -0
- package/dist/views/list.js +85 -0
- package/dist/views/list.js.map +1 -0
- package/dist/views/login.d.ts +25 -0
- package/dist/views/login.d.ts.map +1 -0
- package/dist/views/login.js +44 -0
- package/dist/views/login.js.map +1 -0
- package/dist/views/show.d.ts +17 -0
- package/dist/views/show.d.ts.map +1 -0
- package/dist/views/show.js +24 -0
- package/dist/views/show.js.map +1 -0
- package/package.json +63 -0
- package/src/ResourceRegistry.ts +49 -0
- package/src/StationProvider.ts +1579 -0
- package/src/casing.ts +86 -0
- package/src/defineResource.ts +126 -0
- package/src/index.ts +14 -0
- package/src/services/main.ts +39 -0
- package/src/types.ts +108 -0
- package/src/views/errors/404.ts +27 -0
- package/src/views/escape.ts +46 -0
- package/src/views/form.ts +191 -0
- package/src/views/layout.ts +90 -0
- package/src/views/list.ts +121 -0
- package/src/views/login.ts +65 -0
- package/src/views/show.ts +37 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layout shell shared by every Station page (list / show / 404 in this
|
|
3
|
+
* story; create / edit will join later). Pure string output so consumers
|
|
4
|
+
* can `response.send(html)` directly without bundler glue.
|
|
5
|
+
*
|
|
6
|
+
* Inline CSS lives here rather than `/_assets/station/*` because Story
|
|
7
|
+
* 54.2 ships zero assets — the route mount that serves a separate
|
|
8
|
+
* stylesheet lands in 54.7 alongside the Warden + login plumbing. The
|
|
9
|
+
* size budget (~80 lines, ~3 KB) keeps the inline approach cheaper than
|
|
10
|
+
* an extra round-trip per page until then.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { escapeHtml, type SafeHtml } from "./escape.js";
|
|
14
|
+
|
|
15
|
+
/** ~80 LOC inline stylesheet. Dependency-free; no PostCSS, no Tailwind. */
|
|
16
|
+
export const STATION_INLINE_CSS = `
|
|
17
|
+
:root {
|
|
18
|
+
color-scheme: light dark;
|
|
19
|
+
--st-fg: #1f2937;
|
|
20
|
+
--st-fg-muted: #6b7280;
|
|
21
|
+
--st-bg: #ffffff;
|
|
22
|
+
--st-bg-alt: #f9fafb;
|
|
23
|
+
--st-border: #e5e7eb;
|
|
24
|
+
--st-accent: #2563eb;
|
|
25
|
+
--st-accent-hover: #1d4ed8;
|
|
26
|
+
--st-danger: #b91c1c;
|
|
27
|
+
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
28
|
+
}
|
|
29
|
+
@media (prefers-color-scheme: dark) {
|
|
30
|
+
:root {
|
|
31
|
+
--st-fg: #e5e7eb;
|
|
32
|
+
--st-fg-muted: #9ca3af;
|
|
33
|
+
--st-bg: #0f172a;
|
|
34
|
+
--st-bg-alt: #1e293b;
|
|
35
|
+
--st-border: #334155;
|
|
36
|
+
--st-accent: #60a5fa;
|
|
37
|
+
--st-accent-hover: #93c5fd;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
body { margin: 0; color: var(--st-fg); background: var(--st-bg); }
|
|
41
|
+
main { max-width: 64rem; margin: 0 auto; padding: 2rem 1.5rem; }
|
|
42
|
+
h1 { font-size: 1.75rem; margin: 0 0 1.5rem; }
|
|
43
|
+
a { color: var(--st-accent); text-decoration: none; }
|
|
44
|
+
a:hover { color: var(--st-accent-hover); text-decoration: underline; }
|
|
45
|
+
table { width: 100%; border-collapse: collapse; margin-bottom: 1.5rem; }
|
|
46
|
+
th, td {
|
|
47
|
+
text-align: left;
|
|
48
|
+
padding: 0.5rem 0.75rem;
|
|
49
|
+
border-bottom: 1px solid var(--st-border);
|
|
50
|
+
vertical-align: top;
|
|
51
|
+
}
|
|
52
|
+
th { background: var(--st-bg-alt); font-weight: 600; }
|
|
53
|
+
tbody tr:hover { background: var(--st-bg-alt); }
|
|
54
|
+
.st-empty { color: var(--st-fg-muted); font-style: italic; }
|
|
55
|
+
.st-pager { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; margin-bottom: 0.75rem; }
|
|
56
|
+
.st-pager a, .st-pager span { padding: 0.25rem 0.5rem; border-radius: 0.25rem; }
|
|
57
|
+
.st-pager a { border: 1px solid var(--st-border); }
|
|
58
|
+
.st-pager strong { padding: 0.25rem 0.5rem; background: var(--st-accent); color: var(--st-bg); border-radius: 0.25rem; }
|
|
59
|
+
.st-pager .st-disabled { color: var(--st-fg-muted); border: 1px solid var(--st-border); }
|
|
60
|
+
.st-pager .st-ellipsis { color: var(--st-fg-muted); }
|
|
61
|
+
.st-caption { color: var(--st-fg-muted); font-size: 0.875rem; }
|
|
62
|
+
dl { display: grid; grid-template-columns: max-content 1fr; gap: 0.5rem 1.5rem; }
|
|
63
|
+
dt { font-weight: 600; color: var(--st-fg-muted); }
|
|
64
|
+
dd { margin: 0; }
|
|
65
|
+
.st-backlink { display: inline-block; margin-top: 1.5rem; }
|
|
66
|
+
`.trim();
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Render the outer HTML shell. `bodyHtml` is a `SafeHtml` brand to make
|
|
70
|
+
* the trust boundary explicit: callers must escape every dynamic value
|
|
71
|
+
* inside the body, then wrap the final string via `safeHtml()`.
|
|
72
|
+
*/
|
|
73
|
+
export function renderLayout(input: {
|
|
74
|
+
title: string;
|
|
75
|
+
bodyHtml: SafeHtml;
|
|
76
|
+
}): string {
|
|
77
|
+
const titleEscaped = escapeHtml(input.title);
|
|
78
|
+
return `<!doctype html>
|
|
79
|
+
<html lang="en">
|
|
80
|
+
<head>
|
|
81
|
+
<meta charset="utf-8">
|
|
82
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
83
|
+
<title>${titleEscaped} · Station</title>
|
|
84
|
+
<style>${STATION_INLINE_CSS}</style>
|
|
85
|
+
</head>
|
|
86
|
+
<body>
|
|
87
|
+
<main>${input.bodyHtml.html}</main>
|
|
88
|
+
</body>
|
|
89
|
+
</html>`;
|
|
90
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET /admin/<slug>` — paginated list view.
|
|
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`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ColumnMetadata } from "@c9up/atlas";
|
|
11
|
+
import type { Resource } from "../types.js";
|
|
12
|
+
import { escapeHtml, safeHtml } from "./escape.js";
|
|
13
|
+
import { renderLayout } from "./layout.js";
|
|
14
|
+
|
|
15
|
+
export interface ListPageInput {
|
|
16
|
+
resource: Resource;
|
|
17
|
+
rows: ReadonlyArray<Record<string, unknown>>;
|
|
18
|
+
columns: ReadonlyArray<ColumnMetadata>;
|
|
19
|
+
pkColumn: string;
|
|
20
|
+
page: number;
|
|
21
|
+
perPage: number;
|
|
22
|
+
total: number;
|
|
23
|
+
lastPage: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function renderListPage(input: ListPageInput): string {
|
|
27
|
+
const { resource, rows, columns, pkColumn, page, perPage, total, lastPage } =
|
|
28
|
+
input;
|
|
29
|
+
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({
|
|
37
|
+
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>`;
|
|
46
|
+
}
|
|
47
|
+
|
|
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>`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function renderPager(
|
|
72
|
+
slug: string,
|
|
73
|
+
page: number,
|
|
74
|
+
perPage: number,
|
|
75
|
+
lastPage: number,
|
|
76
|
+
): string {
|
|
77
|
+
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>`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function renderPageNumbers(
|
|
91
|
+
slug: string,
|
|
92
|
+
page: number,
|
|
93
|
+
lastPage: number,
|
|
94
|
+
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>`;
|
|
100
|
+
if (lastPage <= 7) {
|
|
101
|
+
const parts: string[] = [];
|
|
102
|
+
for (let i = 1; i <= lastPage; i++) parts.push(link(i));
|
|
103
|
+
return parts.join("");
|
|
104
|
+
}
|
|
105
|
+
// Collapsed shape: 1 … current-1, current, current+1 … lastPage
|
|
106
|
+
const parts: string[] = [link(1)];
|
|
107
|
+
const start = Math.max(2, page - 1);
|
|
108
|
+
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("");
|
|
114
|
+
}
|
|
115
|
+
|
|
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>`;
|
|
121
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET /admin/login` — sign-in form rendered by StationProvider when
|
|
3
|
+
* the host has `@c9up/warden` wired (Story 54.7). The form POSTs
|
|
4
|
+
* `email` + `password` to `/admin/login`; the provider's
|
|
5
|
+
* `buildLoginHandler` runs them through `auth.authenticate(...)` and
|
|
6
|
+
* sets a session cookie on success.
|
|
7
|
+
*
|
|
8
|
+
* Every dynamic value flows through `escapeHtml()` so a tampered
|
|
9
|
+
* `?error=…` query parameter cannot smuggle markup into the page.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { escapeHtml, safeHtml } from "./escape.js";
|
|
13
|
+
import { renderLayout } from "./layout.js";
|
|
14
|
+
|
|
15
|
+
export interface LoginPageInput {
|
|
16
|
+
/** Pre-fill the email field (e.g. after a failed attempt). */
|
|
17
|
+
email?: string;
|
|
18
|
+
/** Optional error message to display above the form. */
|
|
19
|
+
error?: string;
|
|
20
|
+
/** Form action path. Default `/admin/login`. */
|
|
21
|
+
action?: string;
|
|
22
|
+
/** Caller-controlled hidden inputs (typically `_csrf`). */
|
|
23
|
+
hiddenInputs?: ReadonlyArray<{ name: string; value: string }>;
|
|
24
|
+
}
|
|
25
|
+
|
|
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>`;
|
|
60
|
+
|
|
61
|
+
return renderLayout({
|
|
62
|
+
title: "Sign in",
|
|
63
|
+
bodyHtml: safeHtml(body),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET /admin/<slug>/:id` — detail view (columns only in 54.2).
|
|
3
|
+
*
|
|
4
|
+
* Relations are deferred — see story spec AC17 Spec Deviations. Every
|
|
5
|
+
* dynamic value flows through `escapeHtml()`; URL components through
|
|
6
|
+
* `encodeURIComponent()`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ColumnMetadata } from "@c9up/atlas";
|
|
10
|
+
import type { Resource } from "../types.js";
|
|
11
|
+
import { escapeHtml, safeHtml } from "./escape.js";
|
|
12
|
+
import { renderLayout } from "./layout.js";
|
|
13
|
+
|
|
14
|
+
export interface ShowPageInput {
|
|
15
|
+
resource: Resource;
|
|
16
|
+
row: Record<string, unknown>;
|
|
17
|
+
columns: ReadonlyArray<ColumnMetadata>;
|
|
18
|
+
pkColumn: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function renderShowPage(input: ShowPageInput): string {
|
|
22
|
+
const { resource, row, columns, pkColumn } = input;
|
|
23
|
+
const id = String(row[pkColumn] ?? "");
|
|
24
|
+
const slug = encodeURIComponent(resource.name);
|
|
25
|
+
const heading = `${escapeHtml(resource.label)} #${escapeHtml(id)}`;
|
|
26
|
+
const dl = columns
|
|
27
|
+
.map(
|
|
28
|
+
(c) =>
|
|
29
|
+
`<dt>${escapeHtml(c.propertyKey)}</dt><dd>${escapeHtml(row[c.propertyKey])}</dd>`,
|
|
30
|
+
)
|
|
31
|
+
.join("");
|
|
32
|
+
const backLink = `<a class="st-backlink" href="/admin/${slug}">← Back to ${escapeHtml(resource.label)}</a>`;
|
|
33
|
+
return renderLayout({
|
|
34
|
+
title: `${resource.label} #${id}`,
|
|
35
|
+
bodyHtml: safeHtml(`<h1>${heading}</h1><dl>${dl}</dl>${backLink}`),
|
|
36
|
+
});
|
|
37
|
+
}
|