fse-ssr 0.1.0

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/README.md ADDED
@@ -0,0 +1,40 @@
1
+ # fse-ssr
2
+
3
+ Compile-to-Tera SSR bindings for [full_stack_engine](https://github.com/StevenUster/full_stack_engine)'s
4
+ Astro starter. Author `.astro` templates in native TypeScript against
5
+ `ssr<T>()` placeholders; a build-time Astro/Vite integration compiles the
6
+ expressions that touch them into the equivalent Tera syntax, so the built
7
+ HTML is exactly what the Rust backend's Tera renderer expects — no template
8
+ syntax ever appears in frontend source.
9
+
10
+ This package is not generically reusable outside apps built on
11
+ full_stack_engine: it assumes the framework's `render_tpl` context shape and
12
+ the `<script type="application/json" id="__fse-props__">` placeholder emitted
13
+ by its `Layout.astro`. See the starter's [README](../starter/README.md#server-rendered-data-in-templates-fse-ssr)
14
+ for usage and the supported expression grammar.
15
+
16
+ ## Entry points
17
+
18
+ - `fse-ssr` — the Astro integration (`astro.config.mjs`).
19
+ - `fse-ssr/ssr` — `ssr<T>()` and its types, for use inside `.astro` files.
20
+ - `fse-ssr/client` — `pageProps<T>()`, for browser/island code.
21
+
22
+ ## Development
23
+
24
+ ```bash
25
+ bun install
26
+ bun run build # tsc, one-shot
27
+ bun run dev # tsc --watch
28
+ ```
29
+
30
+ This package is standalone — it isn't linked into the starter via a
31
+ workspace. To try local changes in the starter before publishing a new
32
+ version, `bun publish` a prerelease (or use `bun pm pack` + install the
33
+ tarball) and bump the starter's `fse-ssr` dependency to it.
34
+
35
+ ## Publishing
36
+
37
+ ```bash
38
+ bun run build
39
+ bun publish
40
+ ```
@@ -0,0 +1,11 @@
1
+ /**
2
+ * fse-ssr browser runtime.
3
+ *
4
+ * The Rust server serializes each page's full render context into
5
+ * `<script type="application/json" id="__fse-props__">` (see the framework's
6
+ * `render_template`). Client code — islands, inline scripts — reads it here,
7
+ * so no extra request is needed for the data the page was rendered with.
8
+ */
9
+ import type { GlobalSsrContext } from "./types";
10
+ /** Typed page props, parsed once from the embedded JSON blob. */
11
+ export declare function pageProps<T = Record<string, never>>(): T & GlobalSsrContext;
package/dist/client.js ADDED
@@ -0,0 +1,9 @@
1
+ let cached;
2
+ /** Typed page props, parsed once from the embedded JSON blob. */
3
+ export function pageProps() {
4
+ if (cached === undefined) {
5
+ const el = document.getElementById("__fse-props__");
6
+ cached = el?.textContent ? JSON.parse(el.textContent) : {};
7
+ }
8
+ return cached;
9
+ }
@@ -0,0 +1,24 @@
1
+ import type { GlobalSsrContext, Ssr } from "./types";
2
+ export type { GlobalSsrContext, Ssr, SsrList, SsrValue, Translations } from "./types";
3
+ /**
4
+ * Typed SSR placeholders for the page's render context. Interpolate them in
5
+ * the template like ordinary values; the built HTML contains the matching
6
+ * Tera expressions and the server substitutes the real data per request.
7
+ */
8
+ export declare function ssr<T = Record<string, never>>(): Ssr<T & GlobalSsrContext>;
9
+ /** Renders one interpolated template chunk. */
10
+ export declare function __fseChunk(v: unknown): unknown;
11
+ /** Drop-in replacement for Astro's `addAttribute` that understands SSR markers. */
12
+ export declare function __fseAddAttribute(value: unknown, key: string, shouldEscape?: boolean, tagName?: string): unknown;
13
+ /** `a === b`, `a < b`, … — compiles to a Tera comparison when SSR values are involved. */
14
+ export declare function __fseBin(op: string, l: unknown, r: unknown): unknown;
15
+ /** `a && b` / `a || b` with a lazily evaluated right side. */
16
+ export declare function __fseLogic(op: "&&" | "||", l: unknown, rThunk: () => unknown): unknown;
17
+ /** `t ? a : b` — compiles to `{% if %}…{% else %}…{% endif %}` for SSR tests. */
18
+ export declare function __fseCond(test: unknown, consThunk: () => unknown, altThunk: () => unknown): unknown;
19
+ /** `!x` — compiles to `not (…)` for SSR values. */
20
+ export declare function __fseNot(v: unknown): unknown;
21
+ /** `a ?? b` — compiles to Tera's `default` filter for SSR values. */
22
+ export declare function __fseNullish(l: unknown, rThunk: () => unknown): unknown;
23
+ /** Guards build-time `if` statements against accidental SSR-value tests. */
24
+ export declare function __fseGuardIf(test: unknown): unknown;
@@ -0,0 +1,377 @@
1
+ /**
2
+ * fse-ssr build-time runtime.
3
+ *
4
+ * `ssr<T>()` returns proxies that stand in for server data while Astro
5
+ * renders the static HTML. Interpolating a proxy emits the matching Tera
6
+ * expression (`{{ path }}`); `.map()` emits a `{% for %}` loop. Comparisons
7
+ * and conditionals can't be intercepted by proxies (JS operators aren't
8
+ * overloadable), so the fse-ssr Astro integration rewrites the compiled
9
+ * template's expressions to the `__fse*` helpers below, which fall back to
10
+ * plain JavaScript semantics whenever no SSR placeholder is involved.
11
+ *
12
+ * This module runs only while Astro renders (build / dev server) — it never
13
+ * ships to the browser. Island code reads values from the package's `/client`
14
+ * entry.
15
+ */
16
+ import { addAttribute as astroAddAttribute, markHTMLString, } from "astro/runtime/server/index.js";
17
+ const KIND = Symbol("fse-ssr.kind");
18
+ const EXPR = Symbol("fse-ssr.expr");
19
+ class FseSsrError extends Error {
20
+ constructor(message) {
21
+ super(`[fse-ssr] ${message}`);
22
+ this.name = "FseSsrError";
23
+ }
24
+ }
25
+ function kindOf(v) {
26
+ if (v === null || (typeof v !== "object" && typeof v !== "function")) {
27
+ return undefined;
28
+ }
29
+ return v[KIND];
30
+ }
31
+ const isProxy = (v) => kindOf(v) === "value";
32
+ const isMarker = (v) => kindOf(v) !== undefined;
33
+ function exprOfProxy(v) {
34
+ return v[EXPR];
35
+ }
36
+ const raw = (s) => markHTMLString(s);
37
+ // ─── Tera expression helpers ────────────────────────────────────────────────
38
+ /** Serializes a build-time literal for use inside a Tera expression. */
39
+ function teraLiteral(v) {
40
+ if (isProxy(v))
41
+ return exprOfProxy(v);
42
+ if (typeof v === "string") {
43
+ if (!/^[^'\\\n\r]*$/.test(v)) {
44
+ throw new FseSsrError(`String literal ${JSON.stringify(v)} can't be used in an SSR expression (quotes/backslashes are not supported).`);
45
+ }
46
+ return `'${v}'`;
47
+ }
48
+ if (typeof v === "number" && Number.isFinite(v))
49
+ return String(v);
50
+ if (typeof v === "boolean")
51
+ return v ? "true" : "false";
52
+ throw new FseSsrError(`Value of type ${typeof v} can't be used in an SSR expression.`);
53
+ }
54
+ /**
55
+ * Truthiness test for a bare value: guarded with `default(value=false)` so a
56
+ * key that is absent from the request context reads as false instead of
57
+ * failing the whole render.
58
+ */
59
+ function bareCond(expr) {
60
+ return expr.includes("|") ? expr : `${expr} | default(value=false)`;
61
+ }
62
+ /** Condition tree for anything usable as a boolean. */
63
+ function condNodeOf(v) {
64
+ switch (kindOf(v)) {
65
+ case "value":
66
+ return { k: "truthy", expr: bareCond(exprOfProxy(v)) };
67
+ case "cond":
68
+ return v.node;
69
+ case "logic": {
70
+ const m = v;
71
+ return { k: m.op, parts: [condNodeOf(m.left), condNodeOf(m.value)] };
72
+ }
73
+ default:
74
+ throw new FseSsrError("This value can't be used as a server-side condition. Use an SSR value or a comparison of SSR values.");
75
+ }
76
+ }
77
+ const INVERTED_OP = {
78
+ "==": "!=",
79
+ "!=": "==",
80
+ "<": ">=",
81
+ ">=": "<",
82
+ ">": "<=",
83
+ "<=": ">",
84
+ };
85
+ function negate(n) {
86
+ switch (n.k) {
87
+ case "truthy":
88
+ return { k: "not", part: n };
89
+ case "not":
90
+ return n.part;
91
+ case "cmp":
92
+ return { ...n, op: INVERTED_OP[n.op] };
93
+ case "and":
94
+ return { k: "or", parts: n.parts.map(negate) };
95
+ case "or":
96
+ return { k: "and", parts: n.parts.map(negate) };
97
+ }
98
+ }
99
+ function flatten(k, parts) {
100
+ return parts.flatMap((p) => (p.k === k ? flatten(k, p.parts) : [p]));
101
+ }
102
+ /**
103
+ * Emits a Tera condition. Tera's precedence matches JS (`and` binds tighter
104
+ * than `or`), but there are no parentheses to override it, so an `or` nested
105
+ * inside an `and` cannot be expressed and fails the build.
106
+ */
107
+ function renderCond(n) {
108
+ switch (n.k) {
109
+ case "truthy":
110
+ return n.expr;
111
+ case "cmp":
112
+ return `${n.l} ${n.op} ${n.r}`;
113
+ case "not":
114
+ return n.part.k === "truthy" ? `not ${n.part.expr}` : renderCond(negate(n.part));
115
+ case "or":
116
+ return flatten("or", n.parts).map(renderCond).join(" or ");
117
+ case "and": {
118
+ const parts = flatten("and", n.parts);
119
+ if (parts.some((p) => p.k === "or")) {
120
+ throw new FseSsrError("`(a || b) && c` can't be expressed in Tera (it has no grouping parentheses). " +
121
+ "Distribute the condition, e.g. `(a && c) || (b && c)`, or compute a flag in the service.");
122
+ }
123
+ return parts.map(renderCond).join(" and ");
124
+ }
125
+ }
126
+ }
127
+ /** Tera condition string for anything usable as a boolean. */
128
+ function condExprOf(v) {
129
+ return renderCond(condNodeOf(v));
130
+ }
131
+ // ─── Value proxies ──────────────────────────────────────────────────────────
132
+ const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
133
+ // A proxy coerced to a string and used as a computed key, e.g.
134
+ // `t.roles[row.role]` where `row.role` is itself an SSR value.
135
+ const COERCED_KEY = /^\{\{ (.+) \}\}$/;
136
+ let loopDepth = 0;
137
+ function valueProxy(expr) {
138
+ return new Proxy({}, {
139
+ get(_target, key) {
140
+ if (key === KIND)
141
+ return "value";
142
+ if (key === EXPR)
143
+ return expr;
144
+ if (key === Symbol.toPrimitive || key === "toString" || key === "valueOf") {
145
+ return () => `{{ ${expr} }}`;
146
+ }
147
+ // Safety net: if a proxy reaches Astro's renderer unwrapped, Astro
148
+ // treats anything tagged HTMLString as raw markup and stringifies it,
149
+ // which yields the correct `{{ path }}` output.
150
+ if (key === Symbol.toStringTag)
151
+ return "HTMLString";
152
+ if (typeof key === "symbol")
153
+ return undefined;
154
+ // Astro probes children for these; answering with a child proxy
155
+ // would make it await / stream us.
156
+ if (key === "then" || key === "getReader" || key === "toJSON") {
157
+ return undefined;
158
+ }
159
+ if (key === "map") {
160
+ return (cb) => loopBlock(expr, cb);
161
+ }
162
+ if (key === "length")
163
+ return valueProxy(`${expr} | length`);
164
+ if (expr.includes("|")) {
165
+ throw new FseSsrError(`Can't access ".${String(key)}" on the filtered expression "${expr}".`);
166
+ }
167
+ const coerced = COERCED_KEY.exec(key);
168
+ if (coerced)
169
+ return valueProxy(`${expr}[${coerced[1]}]`);
170
+ if (IDENT.test(key) || /^\d+$/.test(key)) {
171
+ return valueProxy(expr === "" ? key : `${expr}.${key}`);
172
+ }
173
+ throw new FseSsrError(`"${key}" is not a valid SSR property name (on "${expr}").`);
174
+ },
175
+ has(_target, key) {
176
+ return typeof key !== "symbol" && key !== "then" && key !== "getReader";
177
+ },
178
+ });
179
+ }
180
+ function block(parts) {
181
+ return { [KIND]: "block", parts };
182
+ }
183
+ function loopBlock(listExpr, cb) {
184
+ const name = `it${loopDepth}`;
185
+ loopDepth += 1;
186
+ try {
187
+ const body = cb(valueProxy(name), valueProxy("loop.index0"), valueProxy(listExpr));
188
+ return block([raw(`{% for ${name} in ${listExpr} %}`), body, raw("{% endfor %}")]);
189
+ }
190
+ finally {
191
+ loopDepth -= 1;
192
+ }
193
+ }
194
+ /**
195
+ * Typed SSR placeholders for the page's render context. Interpolate them in
196
+ * the template like ordinary values; the built HTML contains the matching
197
+ * Tera expressions and the server substitutes the real data per request.
198
+ */
199
+ export function ssr() {
200
+ return valueProxy("");
201
+ }
202
+ // ─── Helpers injected by the build-time transform ───────────────────────────
203
+ // Each helper preserves plain JavaScript semantics when no SSR placeholder is
204
+ // involved, so rewriting every expression in a compiled .astro module is safe.
205
+ /** Renders one interpolated template chunk. */
206
+ export function __fseChunk(v) {
207
+ switch (kindOf(v)) {
208
+ case undefined:
209
+ return v;
210
+ case "value":
211
+ return raw(`{{ ${exprOfProxy(v)} }}`);
212
+ case "block":
213
+ return v.parts.map(__fseChunk);
214
+ case "logic": {
215
+ const m = v;
216
+ if (m.op === "and") {
217
+ return __fseChunk(block([raw(`{% if ${condExprOf(m.left)} %}`), m.value, raw("{% endif %}")]));
218
+ }
219
+ // `{a || fallback}`: render `a` when truthy, the fallback otherwise.
220
+ if (!isProxy(m.left)) {
221
+ throw new FseSsrError('"cond || fallback" needs an SSR value on the left to know what to render when it is truthy.');
222
+ }
223
+ return __fseChunk(block([
224
+ raw(`{% if ${condExprOf(m.left)} %}{{ ${exprOfProxy(m.left)} }}{% else %}`),
225
+ m.value,
226
+ raw("{% endif %}"),
227
+ ]));
228
+ }
229
+ case "ternary": {
230
+ const m = v;
231
+ return __fseChunk(block([
232
+ raw(`{% if ${renderCond(m.cond)} %}`),
233
+ m.cons,
234
+ raw("{% else %}"),
235
+ m.alt,
236
+ raw("{% endif %}"),
237
+ ]));
238
+ }
239
+ default:
240
+ throw new FseSsrError("A bare comparison can't be rendered as content. Use `{cond && <...>}` or `{cond ? a : b}`.");
241
+ }
242
+ }
243
+ // Keep in sync with Astro's boolean-attribute list (runtime/server/render/util.js).
244
+ const BOOLEAN_ATTRS = /^(?:allowfullscreen|async|autofocus|autoplay|checked|controls|default|defer|disabled|disablepictureinpicture|disableremoteplayback|formnovalidate|hidden|inert|loop|muted|nomodule|novalidate|open|playsinline|readonly|required|reversed|scoped|seamless|selected|itemscope)$/i;
245
+ function attrText(v) {
246
+ if (isProxy(v))
247
+ return `{{ ${exprOfProxy(v)} }}`;
248
+ if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
249
+ return String(v).replace(/&/g, "&#38;").replace(/"/g, "&#34;");
250
+ }
251
+ throw new FseSsrError(`Value of type ${typeof v} can't be rendered inside an SSR attribute.`);
252
+ }
253
+ /** Drop-in replacement for Astro's `addAttribute` that understands SSR markers. */
254
+ export function __fseAddAttribute(value, key, shouldEscape, tagName) {
255
+ const kind = kindOf(value);
256
+ if (kind === undefined) {
257
+ if (key === "class:list" && Array.isArray(value)) {
258
+ value = value.map((entry) => isProxy(entry) ? `{{ ${exprOfProxy(entry)} }}` : entry);
259
+ }
260
+ return astroAddAttribute(value, key, shouldEscape, tagName);
261
+ }
262
+ if (BOOLEAN_ATTRS.test(key)) {
263
+ return raw(`{% if ${condExprOf(value)} %} ${key}{% endif %}`);
264
+ }
265
+ if (kind === "value") {
266
+ return raw(` ${key}="{{ ${exprOfProxy(value)} }}"`);
267
+ }
268
+ if (kind === "ternary") {
269
+ const m = value;
270
+ return raw(` ${key}="{% if ${renderCond(m.cond)} %}${attrText(m.cons)}{% else %}${attrText(m.alt)}{% endif %}"`);
271
+ }
272
+ throw new FseSsrError(`This SSR expression can't be used for the "${key}" attribute.`);
273
+ }
274
+ /** `a === b`, `a < b`, … — compiles to a Tera comparison when SSR values are involved. */
275
+ export function __fseBin(op, l, r) {
276
+ if ((isMarker(l) || isMarker(r)) &&
277
+ l !== undefined &&
278
+ l !== null &&
279
+ r !== undefined &&
280
+ r !== null) {
281
+ const tera = {
282
+ "===": "==",
283
+ "==": "==",
284
+ "!==": "!=",
285
+ "!=": "!=",
286
+ "<": "<",
287
+ "<=": "<=",
288
+ ">": ">",
289
+ ">=": ">=",
290
+ };
291
+ const teraOp = tera[op];
292
+ if (!teraOp) {
293
+ throw new FseSsrError(`Operator "${op}" is not supported on SSR values.`);
294
+ }
295
+ const marker = {
296
+ [KIND]: "cond",
297
+ node: { k: "cmp", op: teraOp, l: teraLiteral(l), r: teraLiteral(r) },
298
+ };
299
+ return marker;
300
+ }
301
+ /* eslint-disable eqeqeq */
302
+ switch (op) {
303
+ case "===":
304
+ return l === r;
305
+ case "!==":
306
+ return l !== r;
307
+ case "==":
308
+ return l == r;
309
+ case "!=":
310
+ return l != r;
311
+ case "<":
312
+ return l < r;
313
+ case "<=":
314
+ return l <= r;
315
+ case ">":
316
+ return l > r;
317
+ case ">=":
318
+ return l >= r;
319
+ default:
320
+ throw new FseSsrError(`Unknown operator "${op}".`);
321
+ }
322
+ /* eslint-enable eqeqeq */
323
+ }
324
+ /** `a && b` / `a || b` with a lazily evaluated right side. */
325
+ export function __fseLogic(op, l, rThunk) {
326
+ if (isMarker(l)) {
327
+ const marker = {
328
+ [KIND]: "logic",
329
+ op: op === "&&" ? "and" : "or",
330
+ left: l,
331
+ value: rThunk(),
332
+ };
333
+ return marker;
334
+ }
335
+ if (op === "&&")
336
+ return l ? rThunk() : l;
337
+ return l ? l : rThunk();
338
+ }
339
+ /** `t ? a : b` — compiles to `{% if %}…{% else %}…{% endif %}` for SSR tests. */
340
+ export function __fseCond(test, consThunk, altThunk) {
341
+ if (isMarker(test)) {
342
+ const marker = {
343
+ [KIND]: "ternary",
344
+ cond: condNodeOf(test),
345
+ cons: consThunk(),
346
+ alt: altThunk(),
347
+ };
348
+ return marker;
349
+ }
350
+ return test ? consThunk() : altThunk();
351
+ }
352
+ /** `!x` — compiles to `not (…)` for SSR values. */
353
+ export function __fseNot(v) {
354
+ if (isMarker(v)) {
355
+ const marker = { [KIND]: "cond", node: negate(condNodeOf(v)) };
356
+ return marker;
357
+ }
358
+ return !v;
359
+ }
360
+ /** `a ?? b` — compiles to Tera's `default` filter for SSR values. */
361
+ export function __fseNullish(l, rThunk) {
362
+ if (isProxy(l)) {
363
+ return valueProxy(`${exprOfProxy(l)} | default(value=${teraLiteral(rThunk())})`);
364
+ }
365
+ if (isMarker(l)) {
366
+ throw new FseSsrError('"??" is only supported directly on SSR values.');
367
+ }
368
+ return l ?? rThunk();
369
+ }
370
+ /** Guards build-time `if` statements against accidental SSR-value tests. */
371
+ export function __fseGuardIf(test) {
372
+ if (isMarker(test)) {
373
+ throw new FseSsrError("SSR values can't drive build-time `if` statements in frontmatter. " +
374
+ "Move the condition into the template: {cond && <...>} or {cond ? a : b}.");
375
+ }
376
+ return test;
377
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Public types for fse-ssr.
3
+ *
4
+ * `ssr<T>()` hands the template a tree of typed placeholders. To the type
5
+ * checker they behave like the real values (an `SsrValue<string>` is
6
+ * assignable wherever a `string` is expected, can be compared with `===`,
7
+ * used in template literals, …). At build time each placeholder compiles to
8
+ * the matching Tera expression, so the server fills in the real value on
9
+ * every request.
10
+ */
11
+ declare const SSR_BRAND: unique symbol;
12
+ /**
13
+ * The app's `t.*` shape. Intentionally empty here — the fse-ssr Astro
14
+ * integration generates a `declare module "…/ssr" { interface Translations
15
+ * {...} }` augmentation from the app's own locale JSON (see
16
+ * `.astro/fse-ssr-translations.d.ts` in a consuming project), so `t.*`
17
+ * accesses are typed per app without this package knowing any app's shape.
18
+ */
19
+ export interface Translations {
20
+ }
21
+ /** A server-rendered scalar: types like `T`, renders as `{{ path }}`. */
22
+ export type SsrValue<T> = T & {
23
+ readonly [SSR_BRAND]?: true;
24
+ };
25
+ /**
26
+ * A server-rendered list: `.map()` compiles to a Tera `{% for %}` loop whose
27
+ * callback runs exactly once at build time with a placeholder item.
28
+ */
29
+ export interface SsrList<E> {
30
+ map<R>(cb: (item: Ssr<E>, index: SsrValue<number>) => R): R;
31
+ /** Compiles to `path | length`. */
32
+ readonly length: SsrValue<number>;
33
+ }
34
+ /** Maps a plain props interface to its SSR-placeholder counterpart. */
35
+ export type Ssr<T> = 0 extends 1 & T ? T : [T] extends [(infer E)[]] ? SsrList<E> : [T] extends [string | number | boolean | null | undefined] ? SsrValue<T> : T extends object ? {
36
+ [K in keyof T]: Ssr<T[K]>;
37
+ } : SsrValue<T>;
38
+ /**
39
+ * Context every page receives automatically (see the app's
40
+ * `global_context_injector` and the framework's `inject_locale_context`).
41
+ */
42
+ export interface GlobalSsrContext {
43
+ t: Translations;
44
+ lang: string;
45
+ /** Every locale keyed by language code, for client-side use. */
46
+ i18n: Record<string, unknown>;
47
+ /** JWT claims when a valid token cookie is present. */
48
+ user?: {
49
+ sub: string;
50
+ role: string;
51
+ [k: string]: unknown;
52
+ };
53
+ can_read_users?: boolean;
54
+ }
55
+ export {};
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,257 @@
1
+ /**
2
+ * fse-ssr Astro integration.
3
+ *
4
+ * Astro compiles each .astro file to a JS module whose template is a tagged
5
+ * template literal — conditionals, comparisons and attribute values appear in
6
+ * it as plain JS expressions. This integration rewrites those expressions to
7
+ * the `__fse*` helpers exported from this package's `/ssr` entry, which keep
8
+ * normal JavaScript semantics unless an SSR placeholder is involved, in which
9
+ * case they emit Tera syntax into the built HTML.
10
+ *
11
+ * It also generates a `Translations` declaration-merge file from the app's
12
+ * locale JSON so `t.*` accesses are type-checked per app.
13
+ */
14
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { dirname } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+
18
+ import _generate from "@babel/generator";
19
+ import { parse } from "@babel/parser";
20
+ import _traverse from "@babel/traverse";
21
+ import * as t from "@babel/types";
22
+
23
+ const traverse = _traverse.default ?? _traverse;
24
+ const generate = _generate.default ?? _generate;
25
+
26
+ // Read once so the injected helper import always matches this package's
27
+ // actual published name, even if it's renamed later.
28
+ const PKG_NAME = JSON.parse(
29
+ readFileSync(fileURLToPath(new URL("./package.json", import.meta.url)), "utf8"),
30
+ ).name;
31
+ const SSR_SPECIFIER = `${PKG_NAME}/ssr`;
32
+
33
+ const HELPERS = [
34
+ "__fseChunk",
35
+ "__fseAddAttribute",
36
+ "__fseBin",
37
+ "__fseLogic",
38
+ "__fseCond",
39
+ "__fseNot",
40
+ "__fseNullish",
41
+ "__fseGuardIf",
42
+ ];
43
+
44
+ const BIN_OPS = new Set(["===", "!==", "==", "!=", "<", "<=", ">", ">="]);
45
+
46
+ /** True if the expression awaits/yields at its own function level (thunking it would be invalid). */
47
+ function containsAwaitOrYield(path) {
48
+ let found = false;
49
+ path.traverse({
50
+ AwaitExpression() {
51
+ found = true;
52
+ },
53
+ YieldExpression() {
54
+ found = true;
55
+ },
56
+ Function(p) {
57
+ p.skip();
58
+ },
59
+ });
60
+ return found;
61
+ }
62
+
63
+ export function transformAstroModule(code) {
64
+ if (!code.includes("astro/compiler-runtime")) return null;
65
+
66
+ const ast = parse(code, {
67
+ sourceType: "module",
68
+ plugins: ["typescript", "jsx"],
69
+ });
70
+
71
+ let renderLocal = null;
72
+ let addAttributeLocal = null;
73
+ for (const node of ast.program.body) {
74
+ if (node.type === "ImportDeclaration" && node.source.value === "astro/compiler-runtime") {
75
+ for (const spec of node.specifiers) {
76
+ if (spec.type === "ImportSpecifier" && spec.imported.type === "Identifier") {
77
+ if (spec.imported.name === "render") renderLocal = spec.local.name;
78
+ if (spec.imported.name === "addAttribute") addAttributeLocal = spec.local.name;
79
+ }
80
+ }
81
+ }
82
+ }
83
+ if (!renderLocal) return null;
84
+
85
+ let used = false;
86
+ const injected = new WeakSet();
87
+ const helperCall = (name, args) => {
88
+ used = true;
89
+ const node = t.callExpression(t.identifier(name), args);
90
+ injected.add(node);
91
+ return node;
92
+ };
93
+ const thunk = (expr) => t.arrowFunctionExpression([], expr);
94
+
95
+ traverse(ast, {
96
+ // Wrap every `${…}` slot of the render template so proxies and markers
97
+ // become raw Tera output instead of reaching Astro's renderer directly.
98
+ TaggedTemplateExpression(path) {
99
+ const tag = path.node.tag;
100
+ if (tag.type !== "Identifier" || tag.name !== renderLocal) return;
101
+ path.node.quasi.expressions = path.node.quasi.expressions.map((expr) =>
102
+ injected.has(expr) ? expr : helperCall("__fseChunk", [expr]),
103
+ );
104
+ },
105
+ CallExpression(path) {
106
+ const callee = path.node.callee;
107
+ if (
108
+ addAttributeLocal &&
109
+ callee.type === "Identifier" &&
110
+ callee.name === addAttributeLocal &&
111
+ !injected.has(path.node)
112
+ ) {
113
+ path.node.callee = t.identifier("__fseAddAttribute");
114
+ injected.add(path.node);
115
+ used = true;
116
+ }
117
+ },
118
+ BinaryExpression(path) {
119
+ if (!BIN_OPS.has(path.node.operator)) return;
120
+ if (path.node.left.type === "PrivateName") return;
121
+ path.replaceWith(
122
+ helperCall("__fseBin", [
123
+ t.stringLiteral(path.node.operator),
124
+ path.node.left,
125
+ path.node.right,
126
+ ]),
127
+ );
128
+ },
129
+ LogicalExpression(path) {
130
+ if (containsAwaitOrYield(path.get("right"))) return;
131
+ const { operator, left, right } = path.node;
132
+ if (operator === "&&" || operator === "||") {
133
+ path.replaceWith(
134
+ helperCall("__fseLogic", [t.stringLiteral(operator), left, thunk(right)]),
135
+ );
136
+ } else if (operator === "??") {
137
+ path.replaceWith(helperCall("__fseNullish", [left, thunk(right)]));
138
+ }
139
+ },
140
+ ConditionalExpression(path) {
141
+ if (
142
+ containsAwaitOrYield(path.get("consequent")) ||
143
+ containsAwaitOrYield(path.get("alternate"))
144
+ ) {
145
+ return;
146
+ }
147
+ path.replaceWith(
148
+ helperCall("__fseCond", [
149
+ path.node.test,
150
+ thunk(path.node.consequent),
151
+ thunk(path.node.alternate),
152
+ ]),
153
+ );
154
+ },
155
+ UnaryExpression(path) {
156
+ if (path.node.operator !== "!") return;
157
+ if (injected.has(path.node)) return;
158
+ path.replaceWith(helperCall("__fseNot", [path.node.argument]));
159
+ },
160
+ // SSR values are only meaningful inside the template; a build-time `if`
161
+ // testing one would silently take the truthy branch, so fail loudly.
162
+ IfStatement(path) {
163
+ if (injected.has(path.node.test)) return;
164
+ path.node.test = helperCall("__fseGuardIf", [path.node.test]);
165
+ },
166
+ });
167
+
168
+ if (!used) return null;
169
+
170
+ ast.program.body.unshift(
171
+ t.importDeclaration(
172
+ HELPERS.map((h) => t.importSpecifier(t.identifier(h), t.identifier(h))),
173
+ t.stringLiteral(SSR_SPECIFIER),
174
+ ),
175
+ );
176
+
177
+ return generate(ast, { retainLines: false }, code);
178
+ }
179
+
180
+ function vitePlugin() {
181
+ return {
182
+ name: PKG_NAME,
183
+ enforce: "post",
184
+ transform(code, id) {
185
+ // The bare .astro id is the compiled component; variants with a query
186
+ // (?astro&type=script/style) are extracted assets and must stay untouched.
187
+ if (!id.endsWith(".astro")) return null;
188
+ const result = transformAstroModule(code);
189
+ return result ? { code: result.code, map: result.map ?? null } : null;
190
+ },
191
+ };
192
+ }
193
+
194
+ function tsType(value) {
195
+ if (typeof value === "string") return "string";
196
+ if (typeof value === "number") return "number";
197
+ if (typeof value === "boolean") return "boolean";
198
+ if (value && typeof value === "object" && !Array.isArray(value)) {
199
+ const fields = Object.entries(value)
200
+ .map(([k, v]) => `${JSON.stringify(k)}: ${tsType(v)};`)
201
+ .join(" ");
202
+ return `{ ${fields} }`;
203
+ }
204
+ return "unknown";
205
+ }
206
+
207
+ // Generated into the *consuming* project's `.astro/` (Astro's own convention
208
+ // for build-generated type files — gitignored, regenerated on every run),
209
+ // never into this package, since the shape is app-specific.
210
+ function generateTranslationTypes(rootUrl, localesPath, defaultLocale, logger) {
211
+ const localeFile = fileURLToPath(new URL(`${localesPath}/${defaultLocale}.json`, rootUrl));
212
+ const outFile = fileURLToPath(new URL("./.astro/fse-ssr-translations.d.ts", rootUrl));
213
+ let locale;
214
+ try {
215
+ locale = JSON.parse(readFileSync(localeFile, "utf8"));
216
+ } catch (err) {
217
+ logger.warn(`Could not read ${localeFile}: ${err.message} — t.* will be untyped.`);
218
+ locale = null;
219
+ }
220
+ const body = locale ? tsType(locale) : "Record<string, unknown>";
221
+ // Declaration merging: augments the `Translations` interface this package
222
+ // exports from its `/ssr` entry, instead of exporting an app-specific type
223
+ // from a generically-published package.
224
+ const content =
225
+ `// Generated by ${PKG_NAME} from ${localesPath}/${defaultLocale}.json — do not edit.\n` +
226
+ `import "${SSR_SPECIFIER}";\n\n` +
227
+ `declare module "${SSR_SPECIFIER}" {\n` +
228
+ ` interface Translations ${body}\n` +
229
+ `}\n`;
230
+ try {
231
+ if (readFileSync(outFile, "utf8") === content) return;
232
+ } catch {
233
+ // First run: the file (and possibly `.astro/`) doesn't exist yet.
234
+ }
235
+ mkdirSync(dirname(outFile), { recursive: true });
236
+ writeFileSync(outFile, content);
237
+ }
238
+
239
+ /**
240
+ * @param {{ locales?: string, defaultLocale?: string }} [options]
241
+ * `locales`: path to the locale directory, relative to the Astro project
242
+ * root (default "../../locales" — the starter layout).
243
+ */
244
+ export default function fseSsr(options = {}) {
245
+ const { locales = "../../locales", defaultLocale = "en" } = options;
246
+ return {
247
+ name: PKG_NAME,
248
+ hooks: {
249
+ "astro:config:setup": ({ config, updateConfig, logger }) => {
250
+ generateTranslationTypes(config.root, locales, defaultLocale, logger);
251
+ updateConfig({
252
+ vite: { plugins: [vitePlugin()] },
253
+ });
254
+ },
255
+ },
256
+ };
257
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "fse-ssr",
3
+ "version": "0.1.0",
4
+ "description": "Compile-to-Tera SSR bindings for full_stack_engine's Astro starter: author native TypeScript in .astro files, get Tera output at build time.",
5
+ "type": "module",
6
+ "license": "MIT OR Apache-2.0",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/StevenUster/full_stack_engine.git",
10
+ "directory": "fse-ssr"
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "integration.mjs"
15
+ ],
16
+ "exports": {
17
+ ".": "./integration.mjs",
18
+ "./ssr": {
19
+ "types": "./dist/runtime.d.ts",
20
+ "import": "./dist/runtime.js"
21
+ },
22
+ "./client": {
23
+ "types": "./dist/client.d.ts",
24
+ "import": "./dist/client.js"
25
+ }
26
+ },
27
+ "scripts": {
28
+ "build": "tsc",
29
+ "dev": "tsc --watch --preserveWatchOutput"
30
+ },
31
+ "peerDependencies": {
32
+ "astro": ">=5.0.0"
33
+ },
34
+ "dependencies": {
35
+ "@babel/generator": "^8.0.0",
36
+ "@babel/parser": "^8.0.0",
37
+ "@babel/traverse": "^8.0.0",
38
+ "@babel/types": "^8.0.0"
39
+ },
40
+ "devDependencies": {
41
+ "astro": "5.17.1",
42
+ "typescript": "^5.9.3"
43
+ }
44
+ }