gofront 1.3.12 → 1.4.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 CHANGED
@@ -283,6 +283,12 @@ example/templ/
283
283
  `.templ` syntax at a glance:
284
284
 
285
285
  ```go
286
+ css cardStyle() {
287
+ background: #ffffff;
288
+ border-radius: 8px;
289
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
290
+ }
291
+
286
292
  templ TodoItem(t Todo) {
287
293
  <li class={ todoItemClass(t) } draggable="true" data-id={ t.id }>
288
294
  <input type="checkbox" checked?={ t.done }/>
@@ -294,7 +300,7 @@ templ TodoItem(t Todo) {
294
300
  }
295
301
 
296
302
  templ AppView() {
297
- <div class="card">
303
+ <div class={ cardStyle() }>
298
304
  @Header(highCount(), syncMsg, syncCls)
299
305
  @TodoList(visibleTodos())
300
306
  </div>
@@ -302,6 +308,7 @@ templ AppView() {
302
308
  ```
303
309
 
304
310
  Key syntax features:
311
+ - `css Name() { ... }` — scoped CSS declarations (templ.guide spec); compiles to a deterministic scoped class name (`gfc_<name>_<hash>`) and injects styles automatically into `<head>`
305
312
  - `{ expr }` — interpolate any Go expression (strings auto-cast, others use `String()`)
306
313
  - `attr={ expr }` — dynamic attribute value (expression)
307
314
  - `attr?={ expr }` — boolean attribute (present/absent based on truthiness)
@@ -369,8 +376,8 @@ parity with the other examples (priority mode, validation, localStorage persiste
369
376
  sync status, drag-and-drop reordering).
370
377
 
371
378
  The templ example additionally demonstrates: **`.templ` file compilation**, template
372
- components with parameters, `{ expr }` interpolation, `attr?={}` conditional boolean
373
- attributes, `@Component()` calls inside templates, `if / else if / else` chains,
379
+ components with parameters, scoped `css` declarations (`css Name() { ... }`), `{ expr }` interpolation,
380
+ `attr?={}` conditional boolean attributes, `@Component()` calls inside templates, `if / else if / else` chains,
374
381
  `switch` blocks, `for range` loops, and `@templ.Raw()` for trusted HTML injection —
375
382
  all inside template bodies.
376
383
 
@@ -620,7 +627,7 @@ signatures into GoFront's internal type representation.
620
627
  | Unused import detection | ✓ |
621
628
  | External `.d.ts` types | ✓ |
622
629
  | npm package type resolution | ✓ |
623
- | `.templ` files in packages | ✓ — mix `.go` and `.templ` files freely; templ components visible across the whole package |
630
+ | `.templ` files in packages | ✓ — mix `.go` and `.templ` files freely; templ components and `css` declarations visible across the whole package |
624
631
 
625
632
  ---
626
633
 
@@ -694,18 +701,18 @@ npm run test:e2e # E2E tests (Playwright, headless Chromium)
694
701
  npm run test:all # all of the above
695
702
  ```
696
703
 
697
- **Unit tests** (~1,396) cover language features, type errors, edge cases, DOM (jsdom),
704
+ **Unit tests** (~1,400+) cover language features, type errors, edge cases, DOM (jsdom),
698
705
  external `.d.ts`, npm resolver, multi-file compilation, embedded structs, string
699
706
  formatting, map iteration order, integer overflow semantics, unused variable detection,
700
707
  unused import detection, semantic difference verification, stdlib shim packages, generics,
701
708
  the `testing` framework itself, and `.templ` file compilation (element rendering,
702
- interpolation, boolean attrs, component calls, `if/else/else-if` chains, `for range`,
703
- `switch/case/default`, `@templ.Raw()` raw HTML injection, SVG namespace handling, mixed
704
- `.go`+`.templ` packages).
709
+ interpolation, boolean attrs, component calls, scoped `css` declarations with class hashing,
710
+ `if/else/else-if` chains, `for range`, `switch/case/default`, `@templ.Raw()` raw HTML injection,
711
+ SVG namespace handling, mixed `.go`+`.templ` packages).
705
712
 
706
- **E2E tests** (~104, Playwright) run all five example apps in a real browser and verify
713
+ **E2E tests** (~105, Playwright) run all five example apps in a real browser and verify
707
714
  CRUD, filtering, priority mode, persistence (reload), drag-and-drop reordering, and sync
708
715
  status. Per-app suites check app-specific behaviour: scoped styles, stats bar, loading
709
- placeholder, `gom.If` conditional rendering, templ-specific features (`if/else`
710
- priority hint, `for` loop rendering, conditional bool attributes), and the WebGL2 cube
716
+ placeholder, `gom.If` conditional rendering, templ-specific features (scoped `css` injection,
717
+ `if/else` priority hint, `for` loop rendering, conditional bool attributes), and the WebGL2 cube
711
718
  rendering.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gofront",
3
- "version": "1.3.12",
3
+ "version": "1.4.0",
4
4
  "author": "Luuk van Venrooij",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -27,6 +27,7 @@ import {
27
27
  HELPER_ERROR,
28
28
  HELPER_ERROR_IS,
29
29
  HELPER_IFACE_BOX,
30
+ HELPER_INJECT_STYLES,
30
31
  HELPER_LEN,
31
32
  HELPER_PATH_CLEAN,
32
33
  HELPER_S,
@@ -97,6 +98,7 @@ export class CodeGen {
97
98
  this.structNames = new Set();
98
99
  this.structFields = new Map();
99
100
  this.namedWrapperNames = new Set();
101
+ this.collectedCss = [];
100
102
  this.jsImports = jsImports;
101
103
  this.bundledPackages = bundledPackages;
102
104
  this.namedReturnVars = null; // names of current function's named return vars
@@ -191,6 +193,10 @@ export class CodeGen {
191
193
  }
192
194
 
193
195
  _callInitAndMain(initNames, program, isTest = false) {
196
+ if (this.collectedCss.length > 0) {
197
+ const uniqueCss = [...new Set(this.collectedCss)];
198
+ this.line(`__injectStyles(${JSON.stringify(uniqueCss.join("\n\n"))});`);
199
+ }
194
200
  for (const name of initNames) this.line(`${name}();`);
195
201
  if (
196
202
  !isTest &&
@@ -201,6 +207,14 @@ export class CodeGen {
201
207
 
202
208
  generate(program, options = {}) {
203
209
  const isTest = options.isTest ?? false;
210
+ // Ensure _pkgName is set on all decls (generateAll sets it for
211
+ // multi-file; for single-file compiles we derive it from the AST).
212
+ const pkgName = program.pkg?.name;
213
+ if (pkgName) {
214
+ for (const d of program.decls) {
215
+ if (!d._pkgName) d._pkgName = pkgName;
216
+ }
217
+ }
204
218
  this._renameReservedIdents(program.decls);
205
219
  const methods = this._collectDecls(program);
206
220
  this._emitJsImports();
@@ -227,7 +241,7 @@ export class CodeGen {
227
241
  return this.out.join("\n");
228
242
  }
229
243
 
230
- // Emits FuncDecl and TemplDecl nodes; returns renamed init function names.
244
+ // Emits FuncDecl, TemplDecl, and CssDecl nodes; returns renamed init function names.
231
245
  _emitFuncDecls(program) {
232
246
  let initCount = 0;
233
247
  const initNames = [];
@@ -247,6 +261,10 @@ export class CodeGen {
247
261
  this._currentSrcFileIdx = d._srcFileIdx ?? 0;
248
262
  this.genTemplDecl(d);
249
263
  this.blank();
264
+ } else if (d.kind === "CssDecl") {
265
+ this._currentSrcFileIdx = d._srcFileIdx ?? 0;
266
+ this.genCssDecl(d);
267
+ this.blank();
250
268
  }
251
269
  }
252
270
  return initNames;
@@ -306,6 +324,7 @@ export class CodeGen {
306
324
  MethodDecl: "_renameMethodDeclNode",
307
325
  FuncLit: "_renameSignature",
308
326
  TemplDecl: "_renameSignature",
327
+ CssDecl: "_renameSignature",
309
328
  VarDecl: "_renameDeclSpecs",
310
329
  ConstDecl: "_renameDeclSpecs",
311
330
  TypeSwitchStmt: "_renameTypeSwitchNode",
@@ -381,6 +400,7 @@ export class CodeGen {
381
400
  _prependHelpers(isTest = false) {
382
401
  const needsTesting = isTest || this._usesTesting;
383
402
  const HELPER_MAP = [
403
+ [this.collectedCss.length > 0, HELPER_INJECT_STYLES],
384
404
  [this._usesLen, HELPER_LEN],
385
405
  [this._usesAppend, HELPER_APPEND],
386
406
  [this._usesSliceGuard, HELPER_S],
@@ -414,12 +434,17 @@ export class CodeGen {
414
434
  for (let i = 0; i < programs.length; i++) {
415
435
  for (const decl of programs[i].decls) {
416
436
  decl._srcFileIdx = i;
437
+ decl._pkgName = programs[i].pkg?.name;
417
438
  }
418
439
  }
419
440
  const merged = { decls: programs.flatMap((p) => p.decls) };
420
441
  return this.generate(merged, options);
421
442
  }
422
443
 
444
+ getCss() {
445
+ return [...new Set(this.collectedCss)].join("\n\n");
446
+ }
447
+
423
448
  getMappings() {
424
449
  return this._srcMappings;
425
450
  }
@@ -312,3 +312,14 @@ var __GoFront_T = __GoFront_T || class {
312
312
  }
313
313
  };
314
314
  var __testing_T = __GoFront_T;`;
315
+
316
+ export const HELPER_INJECT_STYLES = `var __injectStyles = __injectStyles || function(css) {
317
+ if (typeof document === "undefined" || !document || !document.head || !css) return;
318
+ let s = document.getElementById("gofront-styles");
319
+ if (!s) {
320
+ s = document.createElement("style");
321
+ s.id = "gofront-styles";
322
+ document.head.appendChild(s);
323
+ }
324
+ s.textContent += "\\n" + css;
325
+ };`;
@@ -26,6 +26,35 @@ export const templGenMethods = {
26
26
  this.line("}");
27
27
  },
28
28
 
29
+ genCssDecl(decl) {
30
+ if (decl.params && decl.params.length > 0) {
31
+ throw new Error(
32
+ `css ${decl.name}: parameterized css declarations are not yet supported`,
33
+ );
34
+ }
35
+ const className = this.getCssClassName(decl);
36
+ const scopedCss = `.${className} {\n${decl.cssText.trim()}\n}`;
37
+ this.collectedCss.push(scopedCss);
38
+
39
+ this.line(`function ${decl.name}() {`);
40
+ this.indented(() => {
41
+ this.line(`return ${JSON.stringify(className)};`);
42
+ });
43
+ this.line("}");
44
+ },
45
+
46
+ getCssClassName(decl) {
47
+ if (decl._className) return decl._className;
48
+ let h = 2166136261 >>> 0;
49
+ const str = `${decl._pkgName || ""}_${decl.name}_${decl.cssText}`;
50
+ for (let i = 0; i < str.length; i++) {
51
+ h = Math.imul(h ^ str.charCodeAt(i), 16777619) >>> 0;
52
+ }
53
+ const hash = (h >>> 0).toString(36);
54
+ decl._className = `gfc_${decl.name}_${hash}`;
55
+ return decl._className;
56
+ },
57
+
29
58
  // Emit mount statements for an array of TemplNodes into parent variable `p`.
30
59
  _genTemplNodes(nodes, p) {
31
60
  if (!nodes || nodes.length === 0) return;
package/src/compiler.js CHANGED
@@ -310,7 +310,7 @@ export function compileSingleFile(inputPath, options = {}) {
310
310
  attachSourceMap(merged, outputDir);
311
311
  }
312
312
 
313
- return { js: merged.js };
313
+ return { js: merged.js, css: cg.getCss() };
314
314
  }
315
315
 
316
316
  export function compileDir(dir, options = {}) {
@@ -398,9 +398,15 @@ export function compileFiles(files, options = {}) {
398
398
  attachSourceMap(merged, outputDir);
399
399
  }
400
400
 
401
+ const mainCss = codegen.getCss();
402
+ const allCss = [...preambles.map((p) => p.css).filter(Boolean), mainCss]
403
+ .filter(Boolean)
404
+ .join("\n\n");
405
+
401
406
  return {
402
407
  pkgName,
403
408
  js: merged.js,
409
+ css: allCss,
404
410
  programs,
405
411
  exportedSymbols: checker.getExportedSymbols(),
406
412
  exportedTypes: checker.getExportedTypes(),
package/src/resolver.js CHANGED
@@ -65,7 +65,9 @@ export function resolveGwDir(importPath, fromFile) {
65
65
  const fromDir = dirname(resolve(fromFile));
66
66
  const dir = resolve(fromDir, importPath);
67
67
  if (!existsSync(dir)) return null;
68
- const gwFiles = readdirSync(dir).filter((f) => f.endsWith(".go"));
68
+ const gwFiles = readdirSync(dir).filter(
69
+ (f) => f.endsWith(".go") || f.endsWith(".templ"),
70
+ );
69
71
  return gwFiles.length > 0 ? dir : null;
70
72
  }
71
73
 
@@ -35,6 +35,8 @@ export const TT = {
35
35
  TEMPL_CASE: "TEMPL_CASE", // case expr:
36
36
  TEMPL_DEFAULT: "TEMPL_DEFAULT", // default:
37
37
  TEMPL_END: "TEMPL_END", // closing } of if/for/switch/templ body
38
+ CSS_KW: "css",
39
+ CSS_BODY: "CSS_BODY",
38
40
  };
39
41
 
40
42
  // Describes a parsed HTML attribute
@@ -123,6 +125,13 @@ export class TemplLexer {
123
125
  continue;
124
126
  }
125
127
 
128
+ // Detect `css` keyword at identifier boundaries
129
+ if (this._matchKeyword("css")) {
130
+ this.tokens.push(new Token(TT.CSS_KW, "css", this.line, this.col));
131
+ this._lexCssDecl();
132
+ continue;
133
+ }
134
+
126
135
  // Everything else: scan to end of top-level declaration using the
127
136
  // raw Go lexer, accumulating tokens until the next `templ` keyword
128
137
  // or EOF.
@@ -135,7 +144,7 @@ export class TemplLexer {
135
144
 
136
145
  // ── Go chunk lexing ──────────────────────────────────────────
137
146
 
138
- // Scan ahead to find the next `templ ` keyword at top level (not inside
147
+ // Scan ahead to find the next `templ ` or `css ` keyword at top level (not inside
139
148
  // braces/parens/strings/comments). Collect all source text up to that
140
149
  // point, lex it as normal Go, and append the tokens.
141
150
  _lexGoChunk() {
@@ -143,8 +152,12 @@ export class TemplLexer {
143
152
  let depth = 0; // brace depth
144
153
 
145
154
  while (!this.eof()) {
146
- // Check for `templ` keyword at top level (depth === 0)
147
- if (depth === 0 && this._peekKeyword("templ")) break;
155
+ // Check for `templ` or `css` keyword at top level (depth === 0)
156
+ if (
157
+ depth === 0 &&
158
+ (this._peekKeyword("templ") || this._peekKeyword("css"))
159
+ )
160
+ break;
148
161
 
149
162
  const ch = this.peek();
150
163
 
@@ -222,6 +235,97 @@ export class TemplLexer {
222
235
  this._lexHtmlBody(1);
223
236
  }
224
237
 
238
+ // ── CSS declaration lexing ───────────────────────────────────
239
+
240
+ _lexCssDecl() {
241
+ this.skipWhitespace();
242
+
243
+ // Name (identifier)
244
+ const nameStart = this.pos;
245
+ while (!this.eof() && /\w/.test(this.peek())) this.advance();
246
+ const name = this.src.slice(nameStart, this.pos);
247
+ if (!name) this.err("expected name after 'css'");
248
+ this.tokens.push(new Token(T.IDENT, name, this.line, this.col));
249
+
250
+ this.skipWhitespace();
251
+
252
+ // Parameter list (balanced parens)
253
+ if (this.peek() !== "(") this.err("expected '(' after css name");
254
+ const paramStart = this.pos;
255
+ this._skipBalanced("(", ")");
256
+ const paramSrc = this.src.slice(paramStart, this.pos);
257
+ for (const t of this._goTokens(paramSrc)) this.tokens.push(t);
258
+
259
+ this.skipWhitespace();
260
+
261
+ // Opening { of body
262
+ if (this.peek() !== "{") this.err("expected '{' after css params");
263
+ this.advance(); // consume {
264
+ this.tokens.push(new Token(T.LBRACE, "{", this.line, this.col));
265
+
266
+ this._lexCssBody();
267
+ }
268
+
269
+ _skipCssString(quote) {
270
+ this.advance(); // consume opening quote
271
+ while (!this.eof()) {
272
+ const ch = this.peek();
273
+ if (ch === "\\") {
274
+ this.advance();
275
+ if (!this.eof()) this.advance();
276
+ continue;
277
+ }
278
+ if (ch === quote) {
279
+ this.advance();
280
+ return;
281
+ }
282
+ if (ch === "\n") {
283
+ this.err("unterminated string in css block");
284
+ }
285
+ this.advance();
286
+ }
287
+ this.err("unterminated string in css block");
288
+ }
289
+
290
+ _lexCssBody() {
291
+ const start = this.pos;
292
+ let depth = 1;
293
+ while (!this.eof()) {
294
+ const ch = this.peek();
295
+ // Skip CSS comments /* ... */
296
+ if (ch === "/" && this.peek(1) === "*") {
297
+ this.advance();
298
+ this.advance();
299
+ this.skipBlockComment();
300
+ continue;
301
+ }
302
+ // Skip strings
303
+ if (ch === '"' || ch === "'") {
304
+ this._skipCssString(ch);
305
+ continue;
306
+ }
307
+ if (ch === "{") {
308
+ depth++;
309
+ this.advance();
310
+ } else if (ch === "}") {
311
+ depth--;
312
+ if (depth === 0) {
313
+ const cssText = this.src.slice(start, this.pos);
314
+ this.tokens.push(
315
+ new Token(TT.CSS_BODY, cssText, this.line, this.col),
316
+ );
317
+ this.advance(); // consume closing }
318
+ this.tokens.push(new Token(T.RBRACE, "}", this.line, this.col));
319
+ return;
320
+ }
321
+ this.advance();
322
+ } else {
323
+ this.advance();
324
+ }
325
+ }
326
+ this.err("unclosed css block");
327
+ }
328
+
225
329
  // Lex the HTML body of a templ declaration.
226
330
  // depth: current brace depth (starts at 1 for the opening { of templ)
227
331
  _lexHtmlBody(depth) {
@@ -42,9 +42,38 @@ export class TemplParser extends Parser {
42
42
 
43
43
  parseTopDecl() {
44
44
  if (this.check(TT.TEMPL_KW)) return this.parseTemplDecl();
45
+ if (this.check(TT.CSS_KW)) return this.parseCssDecl();
45
46
  return super.parseTopDecl();
46
47
  }
47
48
 
49
+ // ── CssDecl ───────────────────────────────────────────────────
50
+
51
+ parseCssDecl() {
52
+ const _line = this.peek().line;
53
+ this.advance(); // consume CSS_KW
54
+
55
+ const name = this.expect(T.IDENT).value;
56
+
57
+ // Parse parameter list (standard Go params: LPAREN...RPAREN)
58
+ const params = this.parseParamList();
59
+
60
+ // Optional semicolon between ) and {
61
+ this.semi();
62
+
63
+ // Consume opening {
64
+ this.expect(T.LBRACE);
65
+
66
+ // Consume CSS_BODY
67
+ const bodyToken = this.expect(TT.CSS_BODY);
68
+ const cssText = bodyToken.value;
69
+
70
+ // Consume closing }
71
+ this.expect(T.RBRACE);
72
+ this.semi();
73
+
74
+ return { kind: "CssDecl", name, params, cssText, _line };
75
+ }
76
+
48
77
  // ── TemplDecl ─────────────────────────────────────────────────
49
78
 
50
79
  parseTemplDecl() {
@@ -241,6 +241,7 @@ export class TypeChecker {
241
241
  if (d.kind === "FuncDecl" || d.kind === "MethodDecl")
242
242
  this.collectFunc(d);
243
243
  else if (d.kind === "TemplDecl") this._collectTemplDecl(d);
244
+ else if (d.kind === "CssDecl") this._collectCssDecl(d);
244
245
  }
245
246
  }
246
247
  }
@@ -267,7 +268,11 @@ export class TypeChecker {
267
268
  for (const p of programs) {
268
269
  this._setCurrentFile(p);
269
270
  for (const d of p.decls) {
270
- if (d.kind === "FuncDecl" || d.kind === "MethodDecl") {
271
+ if (
272
+ d.kind === "FuncDecl" ||
273
+ d.kind === "MethodDecl" ||
274
+ d.kind === "TemplDecl"
275
+ ) {
271
276
  this.checkTopDecl(d, this.globals);
272
277
  }
273
278
  }
@@ -457,11 +462,71 @@ export class TypeChecker {
457
462
  this.checkConstDecl(decl, scope);
458
463
  break;
459
464
  case "TypeDecl":
465
+ case "CssDecl":
466
+ break;
460
467
  case "TemplDecl":
461
- break; // already collected / no body to check
468
+ this._markTemplNodesUsed(decl.body);
469
+ break;
470
+ }
471
+ }
472
+
473
+ _markTokensUsed(tokens) {
474
+ if (!tokens) return;
475
+ for (const t of tokens) {
476
+ if (t.type === "IDENT" || t.type?.name === "IDENT") {
477
+ this.globals.lookup(t.value);
478
+ }
479
+ }
480
+ }
481
+
482
+ _markTemplNodesUsed(nodes) {
483
+ if (!nodes) return;
484
+ for (const n of nodes) {
485
+ if (n.kind === "TemplElement") {
486
+ for (const a of n.attrs || []) {
487
+ if (a.tokens) this._markTokensUsed(a.tokens);
488
+ }
489
+ this._markTemplNodesUsed(n.children);
490
+ } else if (n.kind === "TemplExpr" || n.kind === "TemplComponent") {
491
+ this._markTokensUsed(n.tokens);
492
+ } else if (n.kind === "TemplIf") {
493
+ this._markTokensUsed(n.condTokens);
494
+ this._markTemplNodesUsed(n.then);
495
+ this._markTemplNodesUsed(n.else_);
496
+ } else if (n.kind === "TemplFor") {
497
+ this._markTokensUsed(n.stmtTokens);
498
+ this._markTemplNodesUsed(n.body);
499
+ } else if (n.kind === "TemplSwitch") {
500
+ this._markTokensUsed(n.exprTokens);
501
+ for (const c of n.cases || []) {
502
+ this._markTokensUsed(c.caseTokens);
503
+ this._markTemplNodesUsed(c.body);
504
+ }
505
+ }
462
506
  }
463
507
  }
464
508
 
509
+ _collectCssDecl(decl) {
510
+ const paramTypes = (decl.params || []).map((p) =>
511
+ this.resolveTypeNode(p.type, this.globals),
512
+ );
513
+ const stringType = this.types.get("string") ?? {
514
+ kind: "basic",
515
+ name: "string",
516
+ };
517
+ const funcType = {
518
+ kind: "func",
519
+ params: paramTypes,
520
+ returns: [stringType],
521
+ variadic: false,
522
+ async: false,
523
+ };
524
+ if (this.globals.symbols.has(decl.name) && decl.name !== "init") {
525
+ this.err(`${decl.name} redeclared in this block`, decl);
526
+ }
527
+ this.globals.define(decl.name, funcType);
528
+ }
529
+
465
530
  _collectTemplDecl(decl) {
466
531
  const paramTypes = decl.params.map((p) =>
467
532
  this.resolveTypeNode(p.type, this.globals),