@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/views/show.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `GET /admin/<slug>/:id` — detail view (columns only in 54.2).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* `encodeURIComponent
|
|
4
|
+
* Pure `(input) => ShowViewModel` builder. Columns × row are flattened into
|
|
5
|
+
* a `fields` array with pre-stringified values (inker's grammar has no
|
|
6
|
+
* dynamic `row[key]` indexing), and the slug is `encodeURIComponent`-ed for
|
|
7
|
+
* the back-link. `templates/show.inker` iterates + interpolates; inker owns
|
|
8
|
+
* the HTML-escaping (the retired `escape.ts` no longer runs here).
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
11
|
import type { ColumnMetadata } from "@c9up/atlas";
|
|
10
12
|
import type { Resource } from "../types.js";
|
|
11
|
-
import { escapeHtml, safeHtml } from "./escape.js";
|
|
12
|
-
import { renderLayout } from "./layout.js";
|
|
13
13
|
|
|
14
14
|
export interface ShowPageInput {
|
|
15
15
|
resource: Resource;
|
|
@@ -18,20 +18,49 @@ export interface ShowPageInput {
|
|
|
18
18
|
pkColumn: string;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
/** A single `<dt>/<dd>` pair: the column key + its pre-stringified value. */
|
|
22
|
+
export interface ShowField {
|
|
23
|
+
label: string;
|
|
24
|
+
value: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A `type` alias (not `interface`) so the view-model carries an implicit
|
|
28
|
+
// index signature and stays assignable to the renderer's
|
|
29
|
+
// `Readonly<Record<string, unknown>>` data param without a cast.
|
|
30
|
+
export type ShowViewModel = {
|
|
31
|
+
title: string;
|
|
32
|
+
heading: string;
|
|
33
|
+
fields: ShowField[];
|
|
34
|
+
backHref: string;
|
|
35
|
+
backLabel: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function buildShowViewModel(input: ShowPageInput): ShowViewModel {
|
|
22
39
|
const { resource, row, columns, pkColumn } = input;
|
|
23
40
|
const id = String(row[pkColumn] ?? "");
|
|
24
41
|
const slug = encodeURIComponent(resource.name);
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
42
|
+
// HTML-escaping is a per-character replace, so it distributes over
|
|
43
|
+
// concatenation: the retired view escaped label and id separately, so
|
|
44
|
+
// composing the heading here then letting inker's `{{ }}` escape it whole
|
|
45
|
+
// stays equivalent — inker escapes a superset of the retired escape.ts
|
|
46
|
+
// (backtick, U+2028/U+2029), so the output is byte-identical except for
|
|
47
|
+
// those extra chars, and at minimum equally safe. Built via join (no `+`
|
|
48
|
+
// concat, no interpolation slot) so neither biome's useTemplate nor the
|
|
49
|
+
// no-unescaped-interpolation grep-ban fires on this now-data-only file.
|
|
50
|
+
const heading = [resource.label, id].join(" #");
|
|
51
|
+
return {
|
|
52
|
+
title: heading,
|
|
53
|
+
heading,
|
|
54
|
+
fields: columns.map((c) => ({
|
|
55
|
+
label: c.propertyKey,
|
|
56
|
+
value: stringifyValue(row[c.propertyKey]),
|
|
57
|
+
})),
|
|
58
|
+
backHref: `/admin/${slug}`,
|
|
59
|
+
backLabel: resource.label,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Match the retired escaper's `null | undefined` → empty-output behaviour. */
|
|
64
|
+
function stringifyValue(value: unknown): string {
|
|
65
|
+
return value === null || value === undefined ? "" : String(value);
|
|
37
66
|
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
@layout('station::layout')
|
|
2
|
+
<h1>{{ heading }}</h1>
|
|
3
|
+
<form class="st-form" method="POST" action="{{ action }}">@if(isEdit)<input type="hidden" name="_method" value="PUT">@endif@if(csrfEnabled){{ csrfField() }}@endif@each(f in fields)<div class="st-field"><label for="{{ f.fieldId }}">{{ f.label }}</label>@if(f.isCheckbox)<input id="{{ f.fieldId }}" type="checkbox" name="{{ f.name }}" value="1"@if(f.checked) checked@endif>@elseif(f.isTextarea)<textarea id="{{ f.fieldId }}" name="{{ f.name }}"@if(f.required) required@endif@if(f.hasPlaceholder) placeholder="{{ f.placeholder }}"@endif>{{ f.value }}</textarea>@else<input id="{{ f.fieldId }}" type="{{ f.inputType }}" name="{{ f.name }}" value="{{ f.value }}"@if(f.required) required@endif@if(f.hasPlaceholder) placeholder="{{ f.placeholder }}"@endif>@endif@if(f.error)<p class="st-field-error">{{ f.error }}</p>@endif</div>@endeach<div class="st-form-actions"><button type="submit">{{ submitLabel }}</button><a class="st-cancel" href="{{ cancelUrl }}">Cancel</a></div></form>
|
|
@@ -1,18 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
import { escapeHtml } from "./escape.js";
|
|
13
|
-
/** ~80 LOC inline stylesheet. Dependency-free; no PostCSS, no Tailwind. */
|
|
14
|
-
export const STATION_INLINE_CSS = `
|
|
15
|
-
:root {
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<title>{{ title }} · Station</title>
|
|
7
|
+
<style>:root {
|
|
16
8
|
color-scheme: light dark;
|
|
17
9
|
--st-fg: #1f2937;
|
|
18
10
|
--st-fg-muted: #6b7280;
|
|
@@ -60,26 +52,9 @@ tbody tr:hover { background: var(--st-bg-alt); }
|
|
|
60
52
|
dl { display: grid; grid-template-columns: max-content 1fr; gap: 0.5rem 1.5rem; }
|
|
61
53
|
dt { font-weight: 600; color: var(--st-fg-muted); }
|
|
62
54
|
dd { margin: 0; }
|
|
63
|
-
.st-backlink { display: inline-block; margin-top: 1.5rem; }
|
|
64
|
-
`.trim();
|
|
65
|
-
/**
|
|
66
|
-
* Render the outer HTML shell. `bodyHtml` is a `SafeHtml` brand to make
|
|
67
|
-
* the trust boundary explicit: callers must escape every dynamic value
|
|
68
|
-
* inside the body, then wrap the final string via `safeHtml()`.
|
|
69
|
-
*/
|
|
70
|
-
export function renderLayout(input) {
|
|
71
|
-
const titleEscaped = escapeHtml(input.title);
|
|
72
|
-
return `<!doctype html>
|
|
73
|
-
<html lang="en">
|
|
74
|
-
<head>
|
|
75
|
-
<meta charset="utf-8">
|
|
76
|
-
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
77
|
-
<title>${titleEscaped} · Station</title>
|
|
78
|
-
<style>${STATION_INLINE_CSS}</style>
|
|
55
|
+
.st-backlink { display: inline-block; margin-top: 1.5rem; }</style>
|
|
79
56
|
</head>
|
|
80
57
|
<body>
|
|
81
|
-
<main
|
|
58
|
+
<main>{{> body }}</main>
|
|
82
59
|
</body>
|
|
83
|
-
</html
|
|
84
|
-
}
|
|
85
|
-
//# sourceMappingURL=layout.js.map
|
|
60
|
+
</html>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
@layout('station::layout')
|
|
2
|
+
<h1>{{ heading }}</h1>
|
|
3
|
+
@if(empty)<p class="st-empty">No {{ labelLower }} yet.</p>@else<table>
|
|
4
|
+
<thead><tr>@each(col in columns)<th>{{ col }}</th>@endeach<th></th></tr></thead>
|
|
5
|
+
<tbody>@each(row in rows)<tr>@each(cell in row.cells)<td>{{ cell }}</td>@endeach<td><a href="{{ row.showHref }}">Show</a></td></tr>@endeach</tbody>
|
|
6
|
+
</table>@endif
|
|
7
|
+
<div class="st-pager">@if(pager.prev.disabled)<span class="st-disabled">« Prev</span>@else<a href="{{ pager.prev.href }}">« Prev</a>@endif@each(p in pager.pages)@if(p.isEllipsis)<span class="st-ellipsis">…</span>@elseif(p.isCurrent)<strong>{{ p.n }}</strong>@else<a href="{{ p.href }}">{{ p.n }}</a>@endif@endeach@if(pager.next.disabled)<span class="st-disabled">Next »</span>@else<a href="{{ pager.next.href }}">Next »</a>@endif</div>
|
|
8
|
+
@if(caption.show)<p class="st-caption">Showing {{ caption.start }}–{{ caption.end }} of {{ caption.total }}</p>@endif
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
@layout('station::layout')
|
|
2
|
+
<h1>Sign in</h1>
|
|
3
|
+
@if(error)<p class="st-form-error" role="alert">{{ error }}</p>@endif
|
|
4
|
+
<form class="st-form" method="POST" action="{{ action }}">@if(csrfEnabled){{ csrfField() }}@endif<div class="st-field"><label for="f-email">Email</label><input id="f-email" type="email" name="email" value="{{ email }}" required autocomplete="email" autofocus></div><div class="st-field"><label for="f-password">Password</label><input id="f-password" type="password" name="password" required autocomplete="current-password"></div><div class="st-form-actions"><button type="submit">Sign in</button></div></form>
|
|
@@ -1,12 +0,0 @@
|
|
|
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
|
-
import type { Resource } from "../../types.js";
|
|
7
|
-
export interface NotFoundPageInput {
|
|
8
|
-
resource: Resource;
|
|
9
|
-
id: string;
|
|
10
|
-
}
|
|
11
|
-
export declare function renderNotFoundPage(input: NotFoundPageInput): string;
|
|
12
|
-
//# sourceMappingURL=404.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"404.d.ts","sourceRoot":"","sources":["../../../src/views/errors/404.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAI/C,MAAM,WAAW,iBAAiB;IACjC,QAAQ,EAAE,QAAQ,CAAC;IACnB,EAAE,EAAE,MAAM,CAAC;CACX;AAED,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,iBAAiB,GAAG,MAAM,CAWnE"}
|
package/dist/views/errors/404.js
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
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
|
-
import { escapeHtml, safeHtml } from "../escape.js";
|
|
7
|
-
import { renderLayout } from "../layout.js";
|
|
8
|
-
export function renderNotFoundPage(input) {
|
|
9
|
-
const { resource, id } = input;
|
|
10
|
-
const slug = encodeURIComponent(resource.name);
|
|
11
|
-
const body = `<h1>404 Not Found</h1>` +
|
|
12
|
-
`<p>No ${escapeHtml(resource.label.toLowerCase())} with ID <code>${escapeHtml(id)}</code>.</p>` +
|
|
13
|
-
`<a class="st-backlink" href="/admin/${slug}">← Back to ${escapeHtml(resource.label)}</a>`;
|
|
14
|
-
return renderLayout({
|
|
15
|
-
title: "Not Found",
|
|
16
|
-
bodyHtml: safeHtml(body),
|
|
17
|
-
});
|
|
18
|
-
}
|
|
19
|
-
//# sourceMappingURL=404.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"404.js","sourceRoot":"","sources":["../../../src/views/errors/404.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AACpD,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAO5C,MAAM,UAAU,kBAAkB,CAAC,KAAwB;IAC1D,MAAM,EAAE,QAAQ,EAAE,EAAE,EAAE,GAAG,KAAK,CAAC;IAC/B,MAAM,IAAI,GAAG,kBAAkB,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;IAC/C,MAAM,IAAI,GACT,wBAAwB;QACxB,SAAS,UAAU,CAAC,QAAQ,CAAC,KAAK,CAAC,WAAW,EAAE,CAAC,kBAAkB,UAAU,CAAC,EAAE,CAAC,cAAc;QAC/F,uCAAuC,IAAI,eAAe,UAAU,CAAC,QAAQ,CAAC,KAAK,CAAC,MAAM,CAAC;IAC5F,OAAO,YAAY,CAAC;QACnB,KAAK,EAAE,WAAW;QAClB,QAAQ,EAAE,QAAQ,CAAC,IAAI,CAAC;KACxB,CAAC,CAAC;AACJ,CAAC"}
|
package/dist/views/escape.d.ts
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
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
|
-
/** Escape a value for safe interpolation into HTML text or attribute content. */
|
|
17
|
-
export declare function escapeHtml(value: unknown): string;
|
|
18
|
-
/**
|
|
19
|
-
* Branded wrapper marking a string as already HTML-escaped (or
|
|
20
|
-
* deliberately raw, e.g., the inline CSS block in the layout). The
|
|
21
|
-
* lint sweep that bans bare `${…}` interpolations excludes lines
|
|
22
|
-
* containing `__safe`.
|
|
23
|
-
*/
|
|
24
|
-
export interface SafeHtml {
|
|
25
|
-
readonly __safe: true;
|
|
26
|
-
readonly html: string;
|
|
27
|
-
}
|
|
28
|
-
/** Mark a string as pre-escaped / deliberately raw HTML. */
|
|
29
|
-
export declare function safeHtml(html: string): SafeHtml;
|
|
30
|
-
//# sourceMappingURL=escape.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"escape.d.ts","sourceRoot":"","sources":["../../src/views/escape.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAUH,iFAAiF;AACjF,wBAAgB,UAAU,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAIjD;AAED;;;;;GAKG;AACH,MAAM,WAAW,QAAQ;IACxB,QAAQ,CAAC,MAAM,EAAE,IAAI,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACtB;AAED,4DAA4D;AAC5D,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,QAAQ,CAE/C"}
|
package/dist/views/escape.js
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
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
|
-
const ESCAPE_MAP = Object.freeze({
|
|
17
|
-
"&": "&",
|
|
18
|
-
"<": "<",
|
|
19
|
-
">": ">",
|
|
20
|
-
'"': """,
|
|
21
|
-
"'": "'",
|
|
22
|
-
});
|
|
23
|
-
/** Escape a value for safe interpolation into HTML text or attribute content. */
|
|
24
|
-
export function escapeHtml(value) {
|
|
25
|
-
if (value === null || value === undefined)
|
|
26
|
-
return "";
|
|
27
|
-
const str = typeof value === "string" ? value : String(value);
|
|
28
|
-
return str.replace(/[&<>"']/g, (ch) => ESCAPE_MAP[ch] ?? ch);
|
|
29
|
-
}
|
|
30
|
-
/** Mark a string as pre-escaped / deliberately raw HTML. */
|
|
31
|
-
export function safeHtml(html) {
|
|
32
|
-
return Object.freeze({ __safe: true, html });
|
|
33
|
-
}
|
|
34
|
-
//# sourceMappingURL=escape.js.map
|
package/dist/views/escape.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"escape.js","sourceRoot":"","sources":["../../src/views/escape.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,MAAM,UAAU,GAAqC,MAAM,CAAC,MAAM,CAAC;IAClE,GAAG,EAAE,OAAO;IACZ,GAAG,EAAE,MAAM;IACX,GAAG,EAAE,MAAM;IACX,GAAG,EAAE,QAAQ;IACb,GAAG,EAAE,OAAO;CACZ,CAAC,CAAC;AAEH,iFAAiF;AACjF,MAAM,UAAU,UAAU,CAAC,KAAc;IACxC,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO,EAAE,CAAC;IACrD,MAAM,GAAG,GAAG,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IAC9D,OAAO,GAAG,CAAC,OAAO,CAAC,UAAU,EAAE,CAAC,EAAE,EAAE,EAAE,CAAC,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,CAAC,CAAC;AAC9D,CAAC;AAaD,4DAA4D;AAC5D,MAAM,UAAU,QAAQ,CAAC,IAAY;IACpC,OAAO,MAAM,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,IAAa,EAAE,IAAI,EAAE,CAAC,CAAC;AACvD,CAAC"}
|
package/dist/views/layout.d.ts
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
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
|
-
import { type SafeHtml } from "./escape.js";
|
|
13
|
-
/** ~80 LOC inline stylesheet. Dependency-free; no PostCSS, no Tailwind. */
|
|
14
|
-
export declare const STATION_INLINE_CSS: string;
|
|
15
|
-
/**
|
|
16
|
-
* Render the outer HTML shell. `bodyHtml` is a `SafeHtml` brand to make
|
|
17
|
-
* the trust boundary explicit: callers must escape every dynamic value
|
|
18
|
-
* inside the body, then wrap the final string via `safeHtml()`.
|
|
19
|
-
*/
|
|
20
|
-
export declare function renderLayout(input: {
|
|
21
|
-
title: string;
|
|
22
|
-
bodyHtml: SafeHtml;
|
|
23
|
-
}): string;
|
|
24
|
-
//# sourceMappingURL=layout.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"layout.d.ts","sourceRoot":"","sources":["../../src/views/layout.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAc,KAAK,QAAQ,EAAE,MAAM,aAAa,CAAC;AAExD,2EAA2E;AAC3E,eAAO,MAAM,kBAAkB,QAkDvB,CAAC;AAET;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE;IACnC,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,QAAQ,CAAC;CACnB,GAAG,MAAM,CAcT"}
|
package/dist/views/layout.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"layout.js","sourceRoot":"","sources":["../../src/views/layout.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAE,UAAU,EAAiB,MAAM,aAAa,CAAC;AAExD,2EAA2E;AAC3E,MAAM,CAAC,MAAM,kBAAkB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkDjC,CAAC,IAAI,EAAE,CAAC;AAET;;;;GAIG;AACH,MAAM,UAAU,YAAY,CAAC,KAG5B;IACA,MAAM,YAAY,GAAG,UAAU,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;IAC7C,OAAO;;;;;WAKG,YAAY;WACZ,kBAAkB;;;UAGnB,KAAK,CAAC,QAAQ,CAAC,IAAI;;QAErB,CAAC;AACT,CAAC"}
|
package/src/views/errors/404.ts
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
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
|
-
}
|
package/src/views/escape.ts
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
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
|
-
"&": "&",
|
|
19
|
-
"<": "<",
|
|
20
|
-
">": ">",
|
|
21
|
-
'"': """,
|
|
22
|
-
"'": "'",
|
|
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
|
-
}
|
package/src/views/layout.ts
DELETED
|
@@ -1,90 +0,0 @@
|
|
|
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
|
-
}
|