Skip to content

feat: add @geajs/ssg package#13

Open
cemalturkcan wants to merge 26 commits intodashersw:mainfrom
cemalturkcan:main
Open

feat: add @geajs/ssg package#13
cemalturkcan wants to merge 26 commits intodashersw:mainfrom
cemalturkcan:main

Conversation

@cemalturkcan
Copy link
Copy Markdown

@cemalturkcan cemalturkcan commented Mar 26, 2026

Routes are rendered to HTML at build time via a new @geajs/ssg package. Pages without interactive components ship zero JS. Pages with interactive components get selective hydration through data-gea markers.

@geajs/ssg

  • Route crawling, HTML generation with configurable concurrency
  • Markdown content pipeline (gray-matter + marked)
  • <Head> component — meta tags, title, JSON-LD, canonical
  • Selective MPA hydration via data-gea attributes
  • sitemap.xml / robots.txt generation
  • HTML minification
  • Vite plugin with dev + preview server support

@geajs/core changes

  • Head component, resetUidCounter, Link._ssgCurrentPath
  • SSG-aware RouterView

examples/ssg-basic

Static pages, blog with markdown content, contact page with Counter and LiveClock hydration.

Tests

  • 97 unit tests (content, crawl, generate, head, render, shell)
  • Playwright E2E covering static pages, hydration, blog content, navigation

Summary by CodeRabbit

  • New Features

    • Built-in static site generation (SSG) with markdown content, parameterized routes, nested layouts, selective hydration for interactive components, active-link highlighting, sitemap and robots generation, and HTML minification options.
  • Documentation

    • Comprehensive SSG docs and navigation entry added.
  • Examples

    • New complete SSG example project demonstrating routes, markdown posts, layouts, and hydrated widgets.
  • Tests

    • New test suites validating content loading, route crawling, rendering, head tags, shell injection, and full generation.
  • Chores

    • Updated ignore list to exclude IDE and data directories.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 26, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a new @geajs/ssg package implementing static site generation (content loading, route crawling, rendering, shell injection, sitemap/robots), a Vite plugin and client-side hydration, an example SSG app, docs, tests, and framework adjustments to support SSG rendering modes and head/link/outlet behaviors.

Changes

Cohort / File(s) Summary
Repository metadata & docs
\.gitignore, README.md, docs/SUMMARY.md, docs/tooling/ssg.md, packages/gea-ssg/README.md
Added ignore entries; documented the SSG package, added README entries, TOC update, and a full SSG docs page.
New package manifest & config
packages/gea-ssg/package.json, packages/gea-ssg/tsconfig.json
New package manifest and TS config for @geajs/ssg with exports, build/test scripts, and deps.
SSG public API & entry
packages/gea-ssg/src/index.ts, packages/gea-ssg/src/types.ts
Package entry re-exports and comprehensive type definitions for SSG options, routes, content, and generate results.
Content utilities
packages/gea-ssg/src/content.ts, packages/gea-ssg/src/client.ts
Server-side content preload/caching and serialization; client ssg accessors and hydrate() implementation reading injected content and instantiating components.
Route crawling & generation
packages/gea-ssg/src/crawl.ts, packages/gea-ssg/src/generate.ts
Route expansion (including content-driven and paths), end-to-end generate pipeline with hooks, concurrency, output writing, sitemap/robots generation, and content-client serialization.
Rendering, shell & head utilities
packages/gea-ssg/src/render.ts, packages/gea-ssg/src/shell.ts, packages/gea-ssg/src/head.ts
SSG-aware renderToString (hydratable markers), shell parsing/injection, head tag generation and HTML minification utilities.
Vite plugin & dev/preview behavior
packages/gea-ssg/src/vite-plugin.ts
Vite plugin factory geaSSG implementing build/dev/preview plugins, internal SSR server use for generation, content injection for dev, and preview middleware for static output.
Tests
packages/gea-ssg/tests/*
Comprehensive tests for content, crawl, render, head, shell, and generate behaviors (multiple new test files).
Framework runtime changes
packages/gea/src/index.ts, packages/gea/src/lib/base/*, packages/gea/src/lib/head.ts, packages/gea/src/lib/router/*
Exports added (resetUidCounter, resolveRoute, Head); component-level _ssgMode flag and constructor guard; UID reset function; DOM guards in ComponentManager; new Head component managing accumulated head state; router additions: SSGRouteConfig type, resolve change, RouterView/Outlet/Link SSG-related static fields and SSG rendering path.
Example project
examples/ssg-basic/*
New example app demonstrating SSG: project config, Vite config using geaSSG, index.html, App and view components (Home, About, Contact, Blog, BlogPost, NotFound), interactive components (Counter, LiveClock), styles, markdown content, and README.

Sequence Diagram(s)

sequenceDiagram
    participant Vite as rgba(52,152,219,0.5)
    participant Plugin as rgba(46,204,113,0.5)
    participant Generate as rgba(155,89,182,0.5)
    participant Crawl as rgba(241,196,15,0.5)
    participant Render as rgba(231,76,60,0.5)
    participant FS as rgba(127,140,141,0.5)

    Vite->>Plugin: closeBundle / build hook
    Plugin->>Plugin: resolve config & entry
    Plugin->>Generate: invoke generate(options)
    Generate->>Crawl: crawlRoutes(routes)
    Crawl-->>Generate: StaticRoute[]
    Generate->>Generate: preloadContent (if set)
    loop per static route
        Generate->>Render: renderToString(component, props, {hydrate})
        Render-->>Generate: { html, hasHydrationMarkers }
        Generate->>Generate: inject head, minify, add _ssg content script as needed
        Generate->>FS: write outputPath (HTML)
    end
    Generate->>FS: write sitemap.xml / robots.txt (optional)
    Generate-->>Plugin: GenerateResult
    Plugin-->>Vite: generation complete
Loading
sequenceDiagram
    participant Browser as rgba(52,152,219,0.5)
    participant StaticHTML as rgba(155,89,182,0.5)
    participant HydrateScript as rgba(46,204,113,0.5)
    participant Hydrator as rgba(241,196,15,0.5)
    participant Component as rgba(231,76,60,0.5)

    Browser->>StaticHTML: load pre-rendered HTML
    StaticHTML->>Browser: display static content
    Browser->>HydrateScript: load client script (hydrate)
    HydrateScript->>Hydrator: scan DOM [data-gea]
    loop for each marker
        Hydrator->>Component: instantiate Component(props)
        Component->>Browser: render interactive DOM & bind events
    end
    HydrateScript-->>Browser: hydration finished
Loading

Estimated Code Review Effort

🎯 5 (Critical) | ⏱️ ~110 minutes

Possibly Related PRs

  • Runtime fixes #2 — Modifies router resolution logic related to tryResolveEntry; strongly related to this PR's resolve/route handling changes.

Poem

🐰 I nibbled docs and stitched each route,
I baked the pages, saved each route,
Slugs hop in rows, and titles gleam,
Buttons wake up when JS redeems,
Hop! The site is static, swift, and neat. 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 35.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: add @geajs/ssg package' directly and clearly describes the main change: introducing a new static site generation package to the Gea framework.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (9)
packages/gea/src/lib/base/uid.ts-5-7 (1)

5-7: ⚠️ Potential issue | 🟡 Minor

Validate seed to keep UID format stable.

resetUidCounter accepts any number; negative, fractional, or non-finite values can lead to unexpected ID strings from toString(36).

Defensive guard
 export function resetUidCounter(seed: number = 0): void {
+  if (!Number.isSafeInteger(seed) || seed < 0) {
+    throw new RangeError('seed must be a non-negative safe integer')
+  }
   counter = seed
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/gea/src/lib/base/uid.ts` around lines 5 - 7, The resetUidCounter
function should validate and normalize the incoming seed so counter remains a
non-negative integer; update resetUidCounter to check Number.isFinite(seed) and
seed >= 0, then coerce to an integer (e.g. Math.floor or equivalent) before
assigning to counter, and either throw or default to 0 for invalid inputs to
prevent negative, fractional, or non-finite values from producing malformed
base-36 UIDs; reference the resetUidCounter function and the module-level
counter variable when making this change.
examples/ssg-basic/package.json-3-3 (1)

3-3: ⚠️ Potential issue | 🟡 Minor

Remove manual version from this package.json.

Line 3 conflicts with repo versioning policy; let changesets own version changes.

✅ Suggested change
-  "version": "1.0.0",

As per coding guidelines, "Never manually edit version numbers in package.json — let changesets own them".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/ssg-basic/package.json` at line 3, Remove the manual "version"
property from the package.json so changesets can manage versions; locate the
"version" key (currently "version": "1.0.0") in examples/ssg-basic/package.json
and delete that line entirely, committing the file without a version field so
the repository's changesets workflow can control version bumps.
packages/gea/src/lib/router/link.ts-33-39 (1)

33-39: ⚠️ Potential issue | 🟡 Minor

Normalize trailing slashes before SSG active-route comparison.

Line 38 can miss active state when to already ends with / (it checks to + '/', yielding //). This breaks static data-active for nested routes in trailing-slash setups.

💡 Suggested fix
-    const ssgPath = Link._ssgCurrentPath
+    const ssgPath = Link._ssgCurrentPath
     if (ssgPath !== null) {
-      const to = props.to
+      const normalize = (p: string) => (p !== '/' && p.endsWith('/') ? p.slice(0, -1) : p)
+      const to = normalize(props.to)
+      const current = normalize(ssgPath)
       const active = props.exact
-        ? ssgPath === to
+        ? current === to
         : to === '/'
-          ? ssgPath === '/'
-          : ssgPath === to || ssgPath.startsWith(to + '/')
+          ? current === '/'
+          : current === to || current.startsWith(to + '/')
       if (active) activeAttr = ' data-active'
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/gea/src/lib/router/link.ts` around lines 33 - 39, The active-route
check in link.ts can fail when props.to ends with a trailing slash because the
expression (to + '/') produces '//' — normalize both ssgPath and to before
comparing: trim any trailing slash from to (except keep single '/' for root) and
normalize ssgPath similarly, then evaluate the existing exact/non-exact logic
using the normalizedTo and normalizedPath variables (used where to, ssgPath,
active, activeAttr are referenced) so data-active is set correctly for nested
routes.
packages/gea/src/index.ts-6-16 (1)

6-16: ⚠️ Potential issue | 🟡 Minor

Add a changeset file for these public API exports.

This PR changes @geajs/core's public API but lacks a corresponding changeset entry. Run npx changeset to declare the bump type (patch/minor/major) and summary.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/gea/src/index.ts` around lines 6 - 16, This PR adds/changes public
exports (e.g., resetUidCounter, applyListChanges, ListConfig, createRouter,
Router, matchRoute, Head, RouteMap, RouteEntry, etc.) but lacks a changeset; run
npx changeset at the repo root and create a changeset that names the affected
package (the package exposing these exports, e.g. `@geajs/core` or packages/gea),
choose the appropriate bump type (patch/minor/major), and write a short summary
describing the export changes so the release tooling will include this API
change in the version bump.
packages/gea-ssg/tests/render.test.ts-82-85 (1)

82-85: ⚠️ Potential issue | 🟡 Minor

This doesn't validate seed yet.

The check around Line 82 uses MockComponent, which always returns a constant string, so it passes even if resetUidCounter(seed) is never applied. Render a real Gea Component that surfaces this.id (and ideally compare same-seed vs different-seed output) so the test actually exercises the seeded path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/gea-ssg/tests/render.test.ts` around lines 82 - 85, The test
currently uses MockComponent (constant output) so it never exercises seeding;
replace MockComponent with a real Gea Component (e.g., a class or function
component that references this.id in its render) and call renderToString with
seed 42 twice to assert equality, and once with a different seed (e.g., 43) to
assert the output differs; ensure you still use renderToString(...) and validate
outputs so the seeded path (resetUidCounter/seed handling that affects this.id)
is actually exercised.
packages/gea-ssg/README.md-101-105 (1)

101-105: ⚠️ Potential issue | 🟡 Minor

Add a language to this fenced block.

The docs lint will keep flagging this unlabeled snippet; text is enough for this output-mapping example.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/gea-ssg/README.md` around lines 101 - 105, The fenced code block
showing route-to-output mappings in README.md is unlabeled; change the opening
fence from ``` to ```text so the snippet is marked as text (e.g., update the
block that contains "/           -> dist/index.html" "/about      ->
dist/about/index.html" "/contact    -> dist/contact/index.html" to start with
```text).
docs/tooling/ssg.md-68-79 (1)

68-79: ⚠️ Potential issue | 🟡 Minor

Give this fenced example a language.

The docs lint will keep flagging the unlabeled block; text is enough for this file-tree snippet.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/tooling/ssg.md` around lines 68 - 79, The fenced code block showing the
"dist/" file-tree lacks a language tag; update the fenced block (the code block
that begins with "dist/") to include a language identifier (e.g., add "text"
after the opening ```), so the docs lint stops flagging it.
packages/gea-ssg/tests/generate.test.ts-124-127 (1)

124-127: ⚠️ Potential issue | 🟡 Minor

Keep the shell fixture outside outDir.

Here shellPath is tempDir/index.html, and the tests also render / to outDir: tempDir. That means the root-page write clobbers the template file, so the suite depends on generate() reading the shell eagerly and can turn flaky once multiple routes render concurrently. A sibling temp dir or nested dist/ output would avoid that coupling.

Also applies to: 135-140

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/gea-ssg/tests/generate.test.ts` around lines 124 - 127, The shell
fixture is being created at tempDir/index.html while tests set outDir to
tempDir, causing the generated root page to overwrite the shell; fix by placing
the shell outside the output dir (e.g., create a separate temp dir for fixtures
or use a nested output like join(tempDir, 'dist') for outDir) so shellPath is
not inside outDir; update the beforeEach setup that defines tempDir, shellPath
and outDir (and the similar block referenced at lines ~135-140) to create
distinct directories and ensure generate() reads the fixture without risk of
being clobbered.
packages/gea-ssg/package.json-3-3 (1)

3-3: ⚠️ Potential issue | 🟡 Minor

Let Changesets own this package version.

Hardcoding 1.0.0 here bypasses the repo's release workflow for published packages. Please keep the version managed by Changesets instead of editing package.json directly.

As per coding guidelines "Never manually edit version numbers in package.json — let changesets own them".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/gea-ssg/package.json` at line 3, The package.json change hardcodes
"version": "1.0.0" which bypasses Changesets; revert the manual edit to the
"version" field in packages/gea-ssg/package.json back to the repository's
Changesets-managed placeholder (remove the "1.0.0" bump), do not commit manual
version changes, and if you intended to release create a proper changeset so
Changesets will update the version during the release process.
🧹 Nitpick comments (2)
packages/gea/src/lib/head.ts (1)

7-16: Collect Head._current only during SSG to avoid browser-side accumulation.

Array props are appended on every render, so repeated browser re-renders can grow Head._current indefinitely even though it’s primarily SSG state.

Scope accumulation to SSG mode
-    if (!Head._current) Head._current = {}
-    const props = this.props || {}
-    for (const [key, value] of Object.entries(props)) {
-      if (key === 'id') continue
-      if (Array.isArray(value) && Array.isArray(Head._current[key])) {
-        Head._current[key] = [...Head._current[key], ...value]
-      } else {
-        Head._current[key] = value
-      }
-    }
+    if (Component._ssgMode) {
+      if (!Head._current) Head._current = {}
+      const props = this.props || {}
+      for (const [key, value] of Object.entries(props)) {
+        if (key === 'id') continue
+        if (Array.isArray(value) && Array.isArray(Head._current[key])) {
+          Head._current[key] = [...Head._current[key], ...value]
+        } else {
+          Head._current[key] = value
+        }
+      }
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/gea/src/lib/head.ts` around lines 7 - 16, The Head._current
accumulation should only happen during SSG to avoid browser-side growth; wrap
the existing mutation block that iterates this.props and updates Head._current
in a server-only guard (e.g., if (typeof window === "undefined") { ... }) so the
array-append logic for Head._current[key] runs only on the server/SSG passes;
keep the same behavior for skipping 'id' and merging arrays but prevent any
updates to Head._current in client-side renders.
packages/gea-ssg/tests/render.test.ts (1)

66-80: Add a template()-throwing regression case.

The current error-path assertions only cover constructor failure. If renderToString stops trapping errors thrown during template(), this suite still passes, so please add a component whose template() throws and verify both the default rethrow path and the onRenderError path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/gea-ssg/tests/render.test.ts` around lines 66 - 80, Add a new test
component whose template() (not constructor) throws and mirror the existing two
cases: one asserting renderToString rethrows by default and one asserting
renderToString calls onRenderError and returns an empty string; create e.g.
TemplateThrowingComponent with a template() that throws a specific Error
message, then add an assert.throws(() =>
renderToString(TemplateThrowingComponent), { message: 'Component template
failed' }) case and a second case that calls
renderToString(TemplateThrowingComponent, undefined, { onRenderError: (err) =>
capturedError = err }) asserting returned html is '' and capturedError.message
matches 'Component template failed'.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/gea-ssg/src/crawl.ts`:
- Around line 37-86: The SSG route branch can emit paths with unresolved
":param" segments; before pushing any route into result (in the content slug
loop that calls resolveParams(fullPath, params), in the paths loop that calls
resolveParams(fullPath, pathEntry.params), and in the final result.push for bare
component routes) compute the resolvedPath and skip adding the entry if it still
contains unresolved params (e.g. use a small helper like
hasUnresolvedParams(path) => /(^|\/):\w+\b/.test(path)); keep the existing /404
handling but ensure all other pushes use the resolvedPath and are guarded by
this check.

In `@packages/gea-ssg/src/generate.ts`:
- Around line 130-133: Replace the brittle HTML scan with an explicit hydration
signal: update renderToString() to return a hasHydrationMarkers boolean (true
when it embeds GEA hydration markers) and update the call site in generate.ts to
branch on that flag instead of fullHtml.includes('data-gea='); remove the regex
checks for module/script/link when options.hydrate is true and use
hasHydrationMarkers to decide whether to strip module script/modulepreload tags.
Ensure any callers of renderToString() (and its return type) are updated to
accept the new tuple/object return containing fullHtml and hasHydrationMarkers
so callers use the explicit flag.

In `@packages/gea-ssg/src/head.ts`:
- Around line 64-66: replaceTitle currently only replaces an existing <title>
and does nothing if none exists; update the replaceTitle(html: string, title:
string) function to detect absence of a <title> and insert one instead of being
a no-op: if the regex /<title>[^<]*<\/title>/i does not match, create
`<title>${escHtml(title)}</title>` and inject it into the document head
(preferably right after the opening <head> or before </head> if an opening tag
position is not easily found), falling back to prepending to the document if no
<head> is present; keep using escHtml(title) and ensure the function returns the
modified HTML string.

In `@packages/gea-ssg/src/render.ts`:
- Around line 35-47: The current JSON.stringify probe in (cm as
any).setComponent only detects throwing non-serializables and misses silent
conversions (Date→string, undefined dropped, functions removed), causing
mismatched client hydration; change the logic in setComponent to perform a
deterministic, recursive validation/normalization of comp.props (reject or
explicitly convert unsupported types: serialize Dates to ISO, convert undefined
to null or explicit marker, throw on functions/symbols) and then store the
normalized object into entry.props; apply the same validation/normalization
routine to any values used for data-gea attribute injection (the code around the
data-gea attribute assignment) so attributes contain the same, lossless
representation that will be used by the client for hydration.

In `@packages/gea-ssg/src/shell.ts`:
- Around line 13-15: The regex in parseShell interpolates appElementId directly
into openTagRegex causing special regex metacharacters in IDs to be interpreted;
fix by escaping appElementId before building the RegExp (add an escapeRegExp
utility or use a sanitizedEscapedId variable) and use that escaped value when
constructing openTagRegex in parseShell; also add a unit test calling
parseShell('<div id="app.main"></div>', 'app.main') (and similar cases with '+'
or other metacharacters) to verify correct matching.

In `@packages/gea-ssg/src/vite-plugin.ts`:
- Around line 89-101: The current branch ignores a partial user override because
it only uses options.routes and options.app when both are present; update the
logic in the gea-ssg initialization so that you always load the module via
viteServer.ssrLoadModule(entry) when either option is missing, then merge
values: set ssgOpts.routes = options.routes ?? ssgEntry.routes and ssgOpts.app =
options.app ?? (ssgEntry.App || ssgEntry.default); finally, keep the existing
validation and throw the Error if either ssgOpts.routes or ssgOpts.app is still
missing after the merge (use symbols options.routes, options.app,
ssgOpts.routes, ssgOpts.app, entry, viteServer.ssrLoadModule, ssgEntry).
- Around line 103-104: The call to generate(ssgOpts) ignores per-route failures
reported in the returned result, so CI can pass with partial output; update the
usage of generate in the block around generate(ssgOpts) to capture its result
(e.g., const result = await generate(ssgOpts)), check result.errors (or
result.failedRoutes) after it completes, and if any errors exist, log them and
throw an Error (or otherwise exit non‑zero) to fail the build so route rendering
failures do not silently pass.
- Around line 42-54: In closeBundle(), change the loadConfigFromFile call to use
{ command: 'build', mode: config.mode } so build-specific branches are loaded,
and normalize resolve.alias results instead of discarding array forms: when
loaded.config.resolve?.alias exists, detect if it's an array and convert
array-form entries into an object map (merging entries and preserving
precedence) or otherwise accept the object as-is, assigning the normalized map
to userAlias; keep the existing userPlugins handling intact so SSR server
created later uses the same plugins and alias mapping as the build.
- Around line 107-113: The closeBundle hook currently forces process.exit via
setTimeout(...).unref(), which can kill other plugins' parallel closeBundle
tasks; remove that process.exit call and instead run the SSG generation that
uses the viteServer in an isolated child process (spawn/fork) so the plugin's
viteServer lifecycle is contained; update the closeBundle implementation (the
function that constructs/uses viteServer and calls setTimeout(...).unref()) to
spawn a child process to perform the SSG work and wait for its exit code before
returning, and ensure the parent does not call process.exit so other plugins'
closeBundle hooks can finish naturally.

In `@packages/gea/src/lib/base/component.tsx`:
- Around line 59-67: The created(this.props) call runs during SSG and may cause
server-side side effects; move the invocation behind the same _ssgMode guard as
createdHooks so created() only executes when Component._ssgMode is false (and
keep the existing __setupLocalStateObservers call inside that guard), or
alternatively split created into a server-safe init and a browser-only
createdBrowser method and invoke only the browser one when Component._ssgMode is
false; update references to created, createdHooks, Component._ssgMode and
__setupLocalStateObservers accordingly.

In `@packages/gea/src/lib/head.ts`:
- Around line 54-73: Injected meta/link tags from props.meta/props.link are
never removed or fully reconciled across navigations and the current link lookup
uses only rel which can collide; update the reconciliation in the head handling
so that for meta entries (handled by this._setMeta/key) you detect existing tags
by both name and property, update content when present and remove any previously
injected tags no longer in props.meta, and for link entries build a precise
selector from all identifying attributes (not just rel), or attach a tracking
data attribute when creating elements so you can reliably find, update, or
remove those exact link elements (attrs, selector, el) on subsequent updates
rather than blindly creating duplicates or overwriting unrelated links.
- Around line 116-127: The _setMeta function currently bails out on falsy
content and leaves existing meta tags stale; change behavior so when content is
null/undefined or an empty string you locate the existing meta element (using
the same attr logic: property for og/twitter, name otherwise) and remove it from
the DOM if present, then return; only create and append a new meta element when
content is non-empty, and otherwise set el.content when you have a valid element
and non-empty content. Target the _setMeta method and the
document.querySelector(`meta[${attr}="${nameOrProperty}"]`) usage to implement
this removal-first logic.

In `@packages/gea/src/lib/router/router-view.ts`:
- Around line 25-47: The SSG render path (RouterView._ssgRoute handling) can
leave Outlet._ssgHtml populated and skip component/layout.dispose if an
exception occurs; wrap the per-component and per-layout template rendering and
dispose logic in try/finally blocks so that dispose() is always called when
defined and Outlet._ssgHtml is always restored to null after each layout
iteration and after the leaf render (including the no-layout branch), ensuring
no stale _ssgHtml persists between renders (referencing RouterView._ssgRoute,
component, layouts, params, leaf, layout, dispose and Outlet._ssgHtml).

---

Minor comments:
In `@docs/tooling/ssg.md`:
- Around line 68-79: The fenced code block showing the "dist/" file-tree lacks a
language tag; update the fenced block (the code block that begins with "dist/")
to include a language identifier (e.g., add "text" after the opening ```), so
the docs lint stops flagging it.

In `@examples/ssg-basic/package.json`:
- Line 3: Remove the manual "version" property from the package.json so
changesets can manage versions; locate the "version" key (currently "version":
"1.0.0") in examples/ssg-basic/package.json and delete that line entirely,
committing the file without a version field so the repository's changesets
workflow can control version bumps.

In `@packages/gea-ssg/package.json`:
- Line 3: The package.json change hardcodes "version": "1.0.0" which bypasses
Changesets; revert the manual edit to the "version" field in
packages/gea-ssg/package.json back to the repository's Changesets-managed
placeholder (remove the "1.0.0" bump), do not commit manual version changes, and
if you intended to release create a proper changeset so Changesets will update
the version during the release process.

In `@packages/gea-ssg/README.md`:
- Around line 101-105: The fenced code block showing route-to-output mappings in
README.md is unlabeled; change the opening fence from ``` to ```text so the
snippet is marked as text (e.g., update the block that contains "/           ->
dist/index.html" "/about      -> dist/about/index.html" "/contact    ->
dist/contact/index.html" to start with ```text).

In `@packages/gea-ssg/tests/generate.test.ts`:
- Around line 124-127: The shell fixture is being created at tempDir/index.html
while tests set outDir to tempDir, causing the generated root page to overwrite
the shell; fix by placing the shell outside the output dir (e.g., create a
separate temp dir for fixtures or use a nested output like join(tempDir, 'dist')
for outDir) so shellPath is not inside outDir; update the beforeEach setup that
defines tempDir, shellPath and outDir (and the similar block referenced at lines
~135-140) to create distinct directories and ensure generate() reads the fixture
without risk of being clobbered.

In `@packages/gea-ssg/tests/render.test.ts`:
- Around line 82-85: The test currently uses MockComponent (constant output) so
it never exercises seeding; replace MockComponent with a real Gea Component
(e.g., a class or function component that references this.id in its render) and
call renderToString with seed 42 twice to assert equality, and once with a
different seed (e.g., 43) to assert the output differs; ensure you still use
renderToString(...) and validate outputs so the seeded path
(resetUidCounter/seed handling that affects this.id) is actually exercised.

In `@packages/gea/src/index.ts`:
- Around line 6-16: This PR adds/changes public exports (e.g., resetUidCounter,
applyListChanges, ListConfig, createRouter, Router, matchRoute, Head, RouteMap,
RouteEntry, etc.) but lacks a changeset; run npx changeset at the repo root and
create a changeset that names the affected package (the package exposing these
exports, e.g. `@geajs/core` or packages/gea), choose the appropriate bump type
(patch/minor/major), and write a short summary describing the export changes so
the release tooling will include this API change in the version bump.

In `@packages/gea/src/lib/base/uid.ts`:
- Around line 5-7: The resetUidCounter function should validate and normalize
the incoming seed so counter remains a non-negative integer; update
resetUidCounter to check Number.isFinite(seed) and seed >= 0, then coerce to an
integer (e.g. Math.floor or equivalent) before assigning to counter, and either
throw or default to 0 for invalid inputs to prevent negative, fractional, or
non-finite values from producing malformed base-36 UIDs; reference the
resetUidCounter function and the module-level counter variable when making this
change.

In `@packages/gea/src/lib/router/link.ts`:
- Around line 33-39: The active-route check in link.ts can fail when props.to
ends with a trailing slash because the expression (to + '/') produces '//' —
normalize both ssgPath and to before comparing: trim any trailing slash from to
(except keep single '/' for root) and normalize ssgPath similarly, then evaluate
the existing exact/non-exact logic using the normalizedTo and normalizedPath
variables (used where to, ssgPath, active, activeAttr are referenced) so
data-active is set correctly for nested routes.

---

Nitpick comments:
In `@packages/gea-ssg/tests/render.test.ts`:
- Around line 66-80: Add a new test component whose template() (not constructor)
throws and mirror the existing two cases: one asserting renderToString rethrows
by default and one asserting renderToString calls onRenderError and returns an
empty string; create e.g. TemplateThrowingComponent with a template() that
throws a specific Error message, then add an assert.throws(() =>
renderToString(TemplateThrowingComponent), { message: 'Component template
failed' }) case and a second case that calls
renderToString(TemplateThrowingComponent, undefined, { onRenderError: (err) =>
capturedError = err }) asserting returned html is '' and capturedError.message
matches 'Component template failed'.

In `@packages/gea/src/lib/head.ts`:
- Around line 7-16: The Head._current accumulation should only happen during SSG
to avoid browser-side growth; wrap the existing mutation block that iterates
this.props and updates Head._current in a server-only guard (e.g., if (typeof
window === "undefined") { ... }) so the array-append logic for
Head._current[key] runs only on the server/SSG passes; keep the same behavior
for skipping 'id' and merging arrays but prevent any updates to Head._current in
client-side renders.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7e73c635-0ad2-469c-8003-75bad55a6ebd

📥 Commits

Reviewing files that changed from the base of the PR and between c04cfd9 and e01f4c4.

⛔ Files ignored due to path filters (2)
  • examples/ssg-basic/package-lock.json is excluded by !**/package-lock.json
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (55)
  • .gitignore
  • README.md
  • docs/SUMMARY.md
  • docs/tooling/ssg.md
  • examples/ssg-basic/README.md
  • examples/ssg-basic/index.html
  • examples/ssg-basic/package.json
  • examples/ssg-basic/src/App.tsx
  • examples/ssg-basic/src/content/blog/getting-started-with-gea.md
  • examples/ssg-basic/src/content/blog/reactive-stores-deep-dive.md
  • examples/ssg-basic/src/content/blog/understanding-ssg.md
  • examples/ssg-basic/src/content/changelog/v1.1.md
  • examples/ssg-basic/src/content/changelog/v1.md
  • examples/ssg-basic/src/main.ts
  • examples/ssg-basic/src/styles.css
  • examples/ssg-basic/src/views/About.tsx
  • examples/ssg-basic/src/views/Blog.tsx
  • examples/ssg-basic/src/views/BlogPost.tsx
  • examples/ssg-basic/src/views/Contact.tsx
  • examples/ssg-basic/src/views/Counter.tsx
  • examples/ssg-basic/src/views/Home.tsx
  • examples/ssg-basic/src/views/LiveClock.tsx
  • examples/ssg-basic/src/views/NotFound.tsx
  • examples/ssg-basic/tsconfig.json
  • examples/ssg-basic/vite.config.ts
  • packages/gea-ssg/README.md
  • packages/gea-ssg/package.json
  • packages/gea-ssg/src/client.ts
  • packages/gea-ssg/src/content.ts
  • packages/gea-ssg/src/crawl.ts
  • packages/gea-ssg/src/generate.ts
  • packages/gea-ssg/src/head.ts
  • packages/gea-ssg/src/index.ts
  • packages/gea-ssg/src/render.ts
  • packages/gea-ssg/src/shell.ts
  • packages/gea-ssg/src/types.ts
  • packages/gea-ssg/src/vite-plugin.ts
  • packages/gea-ssg/tests/content.test.ts
  • packages/gea-ssg/tests/crawl.test.ts
  • packages/gea-ssg/tests/generate.test.ts
  • packages/gea-ssg/tests/head.test.ts
  • packages/gea-ssg/tests/render.test.ts
  • packages/gea-ssg/tests/shell.test.ts
  • packages/gea-ssg/tsconfig.json
  • packages/gea/src/index.ts
  • packages/gea/src/lib/base/component-manager.ts
  • packages/gea/src/lib/base/component.tsx
  • packages/gea/src/lib/base/uid.ts
  • packages/gea/src/lib/head.ts
  • packages/gea/src/lib/router/index.ts
  • packages/gea/src/lib/router/link.ts
  • packages/gea/src/lib/router/outlet.ts
  • packages/gea/src/lib/router/resolve.ts
  • packages/gea/src/lib/router/router-view.ts
  • packages/gea/src/lib/router/types.ts

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/gea-ssg/src/generate.ts (1)

240-250: Consider XML-escaping URL paths.

Route paths and lastmod values are inserted directly into XML without escaping. While developer-controlled paths rarely contain &, <, or >, a path like /foo&bar would produce malformed XML.

♻️ Optional: Add XML escaping helper
+function escapeXml(str: string): string {
+  return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
+}
+
 async function generateSitemap(...) {
   // ...
   .map((p) => {
     const head = headConfigs.get(p.path)
     const lastmod = head?.lastmod ? `\n    <lastmod>${head.lastmod}</lastmod>` : ''
-    const loc = trailingSlash && p.path !== '/' ? `${hostname}${p.path}/` : `${hostname}${p.path}`
+    const loc = escapeXml(trailingSlash && p.path !== '/' ? `${hostname}${p.path}/` : `${hostname}${p.path}`)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/gea-ssg/src/generate.ts` around lines 240 - 250, The sitemap
generation inserts route paths and lastmod directly into XML (see map callback
using headConfigs, lastmod, loc, trailingSlash, hostname, changefreq, priority)
which can produce malformed XML for values with &, <, >, etc.; add a small
xmlEscape helper (e.g., escape &, <, >, ", ') and apply it to the path portion
when building loc and to head?.lastmod before embedding into the <lastmod>
element so all inserted values are XML-escaped.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/gea-ssg/src/generate.ts`:
- Around line 240-250: The sitemap generation inserts route paths and lastmod
directly into XML (see map callback using headConfigs, lastmod, loc,
trailingSlash, hostname, changefreq, priority) which can produce malformed XML
for values with &, <, >, etc.; add a small xmlEscape helper (e.g., escape &, <,
>, ", ') and apply it to the path portion when building loc and to head?.lastmod
before embedding into the <lastmod> element so all inserted values are
XML-escaped.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 777ff576-12d6-46b9-8882-a09f27884493

📥 Commits

Reviewing files that changed from the base of the PR and between e01f4c4 and 18fac4c.

📒 Files selected for processing (2)
  • examples/ssg-basic/vite.config.ts
  • packages/gea-ssg/src/generate.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • examples/ssg-basic/vite.config.ts

@cemalturkcan cemalturkcan changed the title feat: add @geajs/ssg — static site generation with MPA hydration feat: add @geajs/ssg package Mar 26, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (4)
packages/gea-ssg/src/vite-plugin.ts (4)

103-103: ⚠️ Potential issue | 🟠 Major

Build doesn't fail when route rendering has errors.

generate() returns errors in result.errors without throwing. Ignoring the result allows CI to pass with a partial static site.

🛠️ Suggested fix
-            await generate(ssgOpts)
+            const result = await generate(ssgOpts)
+            if (result.errors.length) {
+              const paths = result.errors.map(e => e.path).join(', ')
+              throw new Error(`[gea-ssg] SSG failed for ${result.errors.length} route(s): ${paths}`)
+            }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/gea-ssg/src/vite-plugin.ts` at line 103, The call to await
generate(ssgOpts) ignores the returned result which contains non-thrown errors
in result.errors; change the call to capture the result (e.g., const result =
await generate(ssgOpts)), then check result.errors (or result.errors.length) and
if any errors exist throw an Error or terminate with a non-zero exit so the
build fails; update the code around the generate(ssgOpts) call to perform this
check and surface the first/combined error messages.

42-46: ⚠️ Potential issue | 🟠 Major

Use command: 'build' when loading config in closeBundle.

This hook runs after build completes, but config is loaded with command: 'serve'. Build-specific config branches (conditionals on command === 'build') are ignored, causing the SSR server to use different configuration than the actual build.

🛠️ Suggested fix
           if (config.configFile) {
             const loaded = await loadConfigFromFile(
-              { command: 'serve', mode: config.mode },
+              { command: 'build', mode: config.mode },
               config.configFile,
               config.root,
             )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/gea-ssg/src/vite-plugin.ts` around lines 42 - 46, The hook currently
loads Vite config with loadConfigFromFile using { command: 'serve' } which
ignores build-specific branches; update the call in the closeBundle path to use
{ command: 'build', mode: config.mode } so build-only conditionals are
respected. Locate the loadConfigFromFile invocation in vite-plugin.ts (the call
to loadConfigFromFile with command: 'serve') and change the command value to
'build'; ensure any downstream usage of the returned loaded config (e.g., the
variable loaded) remains unchanged so the SSR server uses the actual build
configuration.

89-101: ⚠️ Potential issue | 🟠 Major

Partial overrides are silently ignored.

When only options.routes or options.app is provided (not both), the code falls through to load the entry module but doesn't merge the user-provided value. This silently drops half of the override.

🛠️ Suggested fix
-            if (options.routes && options.app) {
-              ssgOpts.routes = options.routes
-              ssgOpts.app = options.app
-            } else {
-              const entry = options.entry || 'src/App.tsx'
-              const ssgEntry = await viteServer.ssrLoadModule(entry)
-              ssgOpts.routes = ssgEntry.routes
-              ssgOpts.app = ssgEntry.App || ssgEntry.default
-
-              if (!ssgOpts.routes || !ssgOpts.app) {
-                throw new Error(`[gea-ssg] ${entry} must export "routes" and "App" (or default).`)
-              }
+            const entry = options.entry || 'src/App.tsx'
+            const ssgEntry = await viteServer.ssrLoadModule(entry)
+            ssgOpts.routes = options.routes ?? ssgEntry.routes
+            ssgOpts.app = options.app ?? ssgEntry.App ?? ssgEntry.default
+
+            if (!ssgOpts.routes || !ssgOpts.app) {
+              throw new Error(`[gea-ssg] ${entry} must export "routes" and "App" (or default), or provide both in plugin options.`)
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/gea-ssg/src/vite-plugin.ts` around lines 89 - 101, The current logic
ignores partial overrides: if only options.routes or options.app is provided the
code still loads the entry and overwrites the provided value. Update the block
around options.routes/options.app so you only call
viteServer.ssrLoadModule(entry) when either ssgOpts.routes or ssgOpts.app is
missing, then set ssgOpts.routes = options.routes ?? ssgEntry.routes and
ssgOpts.app = options.app ?? (ssgEntry.App || ssgEntry.default); finally keep
the existing validation that both ssgOpts.routes and ssgOpts.app exist and throw
the same error if not.

51-54: ⚠️ Potential issue | 🟠 Major

Array-form aliases are silently dropped.

The !Array.isArray(alias) check discards array-format resolve.alias configurations, causing SSR module resolution mismatches. Normalize array entries to object form.

🛠️ Suggested fix
               const alias = loaded.config.resolve?.alias
-              if (alias && typeof alias === 'object' && !Array.isArray(alias)) {
-                userAlias = alias as Record<string, string>
+              if (alias && typeof alias === 'object') {
+                if (Array.isArray(alias)) {
+                  for (const entry of alias) {
+                    if (typeof entry.find === 'string' && typeof entry.replacement === 'string') {
+                      userAlias[entry.find] = entry.replacement
+                    }
+                  }
+                } else {
+                  userAlias = alias as Record<string, string>
+                }
               }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/gea-ssg/src/vite-plugin.ts` around lines 51 - 54, The current check
drops array-form resolve.alias entries; update the logic around
loaded.config.resolve?.alias to normalize array aliases into userAlias instead
of ignoring them: if alias is an array, iterate its entries (handle objects with
find/replacement and pair tuples) and populate userAlias (the same
Record<string,string> used for object-form aliases) by converting each
entry.find to a string key and entry.replacement to the value; keep existing
branch for object-form aliases and ensure later consumers use the unified
userAlias mapping for SSR resolution.
🧹 Nitpick comments (1)
packages/gea-ssg/tests/head.test.ts (1)

81-84: Test name doesn't match assertion.

The test is named "returns empty string when no config" but asserts that og:type is present. Since buildHeadTags({}) always emits og:type (line 25 in head.ts), the function never returns an empty string. Consider renaming to reflect actual behavior.

💡 Suggested fix
-  it('returns empty string when no config', () => {
+  it('includes default og:type when no config', () => {
     const tags = buildHeadTags({})
     assert.ok(tags.includes('og:type'))
   })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/gea-ssg/tests/head.test.ts` around lines 81 - 84, The test name is
misleading: update the test in head.test.ts that calls buildHeadTags({}) to
reflect actual behavior (buildHeadTags always emits og:type). Rename the test
from "returns empty string when no config" to something like "includes default
og:type when no config" (or adjust the assertion to expect an empty string if
you change buildHeadTags instead), and ensure the assertion continues to check
for 'og:type' presence against buildHeadTags.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/gea-ssg/src/render.ts`:
- Line 69: The code stores the original comp.props into entry.props (entry.props
= comp.props) after detecting lossy conversions, so server HTML will differ from
the normalized props; change it to store the normalized/round-tripped value
instead (assign entry.props = roundTripped or the normalized cProps used for
JSON output). Locate the block around the lossy-detection logic (references:
comp.props, roundTripped, cProps, entry.props) and replace the assignment so
entry.props gets the round-tripped/normalized object that will be
JSON.stringify'd, ensuring server-rendered HTML and client-hydrated props match.

---

Duplicate comments:
In `@packages/gea-ssg/src/vite-plugin.ts`:
- Line 103: The call to await generate(ssgOpts) ignores the returned result
which contains non-thrown errors in result.errors; change the call to capture
the result (e.g., const result = await generate(ssgOpts)), then check
result.errors (or result.errors.length) and if any errors exist throw an Error
or terminate with a non-zero exit so the build fails; update the code around the
generate(ssgOpts) call to perform this check and surface the first/combined
error messages.
- Around line 42-46: The hook currently loads Vite config with
loadConfigFromFile using { command: 'serve' } which ignores build-specific
branches; update the call in the closeBundle path to use { command: 'build',
mode: config.mode } so build-only conditionals are respected. Locate the
loadConfigFromFile invocation in vite-plugin.ts (the call to loadConfigFromFile
with command: 'serve') and change the command value to 'build'; ensure any
downstream usage of the returned loaded config (e.g., the variable loaded)
remains unchanged so the SSR server uses the actual build configuration.
- Around line 89-101: The current logic ignores partial overrides: if only
options.routes or options.app is provided the code still loads the entry and
overwrites the provided value. Update the block around
options.routes/options.app so you only call viteServer.ssrLoadModule(entry) when
either ssgOpts.routes or ssgOpts.app is missing, then set ssgOpts.routes =
options.routes ?? ssgEntry.routes and ssgOpts.app = options.app ?? (ssgEntry.App
|| ssgEntry.default); finally keep the existing validation that both
ssgOpts.routes and ssgOpts.app exist and throw the same error if not.
- Around line 51-54: The current check drops array-form resolve.alias entries;
update the logic around loaded.config.resolve?.alias to normalize array aliases
into userAlias instead of ignoring them: if alias is an array, iterate its
entries (handle objects with find/replacement and pair tuples) and populate
userAlias (the same Record<string,string> used for object-form aliases) by
converting each entry.find to a string key and entry.replacement to the value;
keep existing branch for object-form aliases and ensure later consumers use the
unified userAlias mapping for SSR resolution.

---

Nitpick comments:
In `@packages/gea-ssg/tests/head.test.ts`:
- Around line 81-84: The test name is misleading: update the test in
head.test.ts that calls buildHeadTags({}) to reflect actual behavior
(buildHeadTags always emits og:type). Rename the test from "returns empty string
when no config" to something like "includes default og:type when no config" (or
adjust the assertion to expect an empty string if you change buildHeadTags
instead), and ensure the assertion continues to check for 'og:type' presence
against buildHeadTags.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5e41198d-6a71-448c-aca9-6a9825b84d23

📥 Commits

Reviewing files that changed from the base of the PR and between 18fac4c and ca92083.

📒 Files selected for processing (12)
  • packages/gea-ssg/src/content.ts
  • packages/gea-ssg/src/crawl.ts
  • packages/gea-ssg/src/generate.ts
  • packages/gea-ssg/src/head.ts
  • packages/gea-ssg/src/index.ts
  • packages/gea-ssg/src/render.ts
  • packages/gea-ssg/src/shell.ts
  • packages/gea-ssg/src/vite-plugin.ts
  • packages/gea-ssg/tests/generate.test.ts
  • packages/gea-ssg/tests/head.test.ts
  • packages/gea-ssg/tests/render.test.ts
  • packages/gea-ssg/tests/shell.test.ts
✅ Files skipped from review due to trivial changes (3)
  • packages/gea-ssg/tests/render.test.ts
  • packages/gea-ssg/tests/shell.test.ts
  • packages/gea-ssg/tests/generate.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/gea-ssg/src/crawl.ts

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/gea/src/lib/head.ts`:
- Around line 64-68: The selector-building code in head.ts constructs attribute
selectors directly from attrs.rel and attrs.href which can break if those values
contain special characters; update the logic that sets selector (the branches
that assign selector = `link[rel="${attrs.rel}"][href="${attrs.href}"]` and
selector = `link[rel="${attrs.rel}"]`) to wrap attribute values with
CSS.escape() (e.g., CSS.escape(attrs.rel) and CSS.escape(attrs.href)) so the
generated selector is safe for all input.
- Line 31: The current assignment only sets document.title when props.title is
truthy, leaving the previous title on SPA navigation; update the cleanup path in
the _removeStale function to explicitly clear or reset document.title when
props.title is falsy (e.g., set document.title = '' or a configured
defaultTitle) so the title does not persist across routes—ensure you reference
and update the code path that checks props.title (the same location that
currently runs "if (props.title) document.title = props.title") and use a single
shared defaultTitle constant or site config value if available.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f9209888-cbca-4140-b269-f179b0f0cbfd

📥 Commits

Reviewing files that changed from the base of the PR and between ca92083 and 10b376a.

📒 Files selected for processing (4)
  • examples/ssg-basic/src/views/About.tsx
  • packages/gea/src/lib/base/component.tsx
  • packages/gea/src/lib/head.ts
  • packages/gea/src/lib/router/router-view.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/gea/src/lib/base/component.tsx
  • examples/ssg-basic/src/views/About.tsx
  • packages/gea/src/lib/router/router-view.ts

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
packages/gea-ssg/src/vite-plugin.ts (1)

197-200: Add error handling for stream read.

createReadStream(notFound) can emit an error if the file is removed between the existsSync check and the read (race condition). While unlikely in a preview server, unhandled stream errors could crash the server.

Suggested defensive fix
           if (existsSync(notFound)) {
             res.statusCode = 404
-            createReadStream(notFound).pipe(res)
+            createReadStream(notFound)
+              .on('error', () => {
+                res.statusCode = 500
+                res.end('Internal Server Error')
+              })
+              .pipe(res)
             return
           }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/gea-ssg/src/vite-plugin.ts` around lines 197 - 200, The not-found
response uses createReadStream(notFound) without handling stream errors; add an
'error' listener on the stream returned by createReadStream inside the block
where existsSync(notFound) is checked (the code that sets res.statusCode = 404
and pipes the stream) so that if the stream emits 'error' you set an appropriate
error status (e.g., 500 if headers not sent), write a short error message or
call res.end(), and avoid crashing the server; ensure the error handler cleans
up the stream and stops further processing after piping.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/gea-ssg/src/vite-plugin.ts`:
- Around line 162-167: The invalidate function's path check can fail on Windows
due to differing separators/case, so normalize and compare canonical paths:
resolve/normalize contentDir and the incoming file path (use
path.resolve/path.normalize) and then use path.relative to determine if file is
inside contentDir (i.e., path.relative(resolvedContentDir, resolvedFile) does
not start with '..' and is not equal to '' for directories), and for Windows
also compare in lowercase or use a case-insensitive check; keep the existing
'.md' suffix check after normalization and still call server.ws.send({ type:
'full-reload' }) when the normalized file is in the directory. Ensure these
changes are made within the invalidate function and use the existing contentDir
and server variables.
- Around line 185-188: The extname check uses the raw req.url (including query)
so it can falsely detect file extensions; change the logic to strip the query
string first (compute const url = req.url.split('?')[0]) and then run
extname(url) and the root check, i.e. replace uses of extname(req.url) with
extname(url) and ensure testPath still uses the already-stripped url; update the
conditional that currently reads if (!req.url || extname(req.url) || req.url ===
'/') to use the cleaned url and preserve the call to next() when appropriate
(referencing req.url, url, extname, testPath, ts, and config.build.outDir).

---

Nitpick comments:
In `@packages/gea-ssg/src/vite-plugin.ts`:
- Around line 197-200: The not-found response uses createReadStream(notFound)
without handling stream errors; add an 'error' listener on the stream returned
by createReadStream inside the block where existsSync(notFound) is checked (the
code that sets res.statusCode = 404 and pipes the stream) so that if the stream
emits 'error' you set an appropriate error status (e.g., 500 if headers not
sent), write a short error message or call res.end(), and avoid crashing the
server; ensure the error handler cleans up the stream and stops further
processing after piping.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 956b76a6-798b-4951-ad5c-4a67e291eb13

📥 Commits

Reviewing files that changed from the base of the PR and between 10b376a and c1aa98f.

📒 Files selected for processing (1)
  • packages/gea-ssg/src/vite-plugin.ts

Add @geajs/ssg package for build-time static HTML generation with:
- Vite plugin integration (build, dev, preview modes)
- Markdown content system via ssg.content() and ssg.file()
- Dynamic routes from content files or explicit paths
- Layout and outlet support for nested page structures
- Active link detection for static HTML output
- Sitemap generation
- Path traversal protection and XSS-safe content injection

Core changes:
- Guard browser APIs in component-manager for Node compatibility
- Add resetUidCounter for deterministic SSG rendering
- Add SSG rendering hooks to RouterView, Outlet, and Link
- Support SSG route configs in resolve.ts
- Add Component._ssgMode to skip reactive hooks during SSG

Includes ssg-basic example with blog, dynamic routes, and markdown content.
61 SSG tests, 377 core tests passing.
Client component renders an empty placeholder during SSG — child
components only mount in the browser. This keeps route config clean
and gives per-section control instead of per-route.
…ailing slash

- Add <Head> component for per-page title, meta, og, twitter, canonical, JSON-LD
- Generate 404.html from wildcard (*) routes
- Generate robots.txt with configurable allow/disallow rules
- Add sitemap lastmod from Head config dates
- Add HTML minification (preserves pre/code/script/style)
- Add trailing slash configuration for output paths
- Add 29 head tests, 10 generate tests for new features
- Update README and docs with new feature documentation
…meta/link/jsonld support, trailingSlash showcase
… component hydration

Replace full SPA re-render with per-component hydration via data-gea
attributes and UID matching. Static pages get zero JavaScript. Interactive
pages load only the shared runtime + needed components.

- render.ts: inject data-gea attributes on hydrate-listed components during SSG
- client.ts: add hydrate() that attaches components to existing SSG DOM
- generate.ts: strip <script>/<link modulepreload> from static pages, remove content.js
- types.ts: add hydrate option to SSGOptions
- example: replace innerHTML='' + Router with hydrate({ Counter, LiveClock })
…, nested div parsing, JSDoc, docs

- render.ts: capture component props as data-gea-props, save/restore setComponent
- client.ts: parse props, one-shot getUid for nested component safety, fix return value
- shell.ts: depth-tracking for nested divs inside app element
- index.ts: export server-side hydrate() no-op for type checking
- generate.ts: document MPA content.js skip, add sitemap placeholder warning
- vite-plugin.ts: expand process.exit workaround comment
- content.ts, head.ts: add JSDoc on public API functions
- shell.test.ts: add nested div test case
- README.md: document content API behavior in MPA mode
- crawl: skip routes with unresolved :param segments
- render: return RenderResult with hasHydrationMarkers flag
- render: warn on lossy prop serialization (Date, non-serializable)
- generate: use hasHydrationMarkers instead of HTML text scan
- head: insert <title> when shell has none
- shell: escape regex metacharacters in appElementId
- vite-plugin: add 500ms grace period before force-exit
- vite-plugin: fix 404 preview fallback for trailingSlash mode
- content: fix misleading JSDoc about recursive scanning
Copy link
Copy Markdown
Collaborator

@puskuruk puskuruk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for your contribution! I'm going to review your PR tomorrow at night. Could you please make sure all the coderabbit comments are resolved and your pr is rebased with the main branch by tomorrow night? Thanks in advance!

- vite-plugin: use command 'build' for loadConfigFromFile, support array aliases
- vite-plugin: merge routes/app independently so partial overrides work
- vite-plugin: check generate() errors and fail build on rendering failures
- vite-plugin: strip query string before extname check in preview server
- vite-plugin: normalize paths for Windows compatibility in file watcher
- head: track custom meta/link tags with data-gea-head, clean on route change
- head: clear stale document.title when new page has no title
- head: use CSS.escape() in meta selector queries for safety
- render: use roundTripped (JSON-parsed) props instead of raw component props
- generate: XML-escape sitemap <loc> URLs
- core: remove duplicate resetUidCounter export that broke build
@cemalturkcan
Copy link
Copy Markdown
Author

Thanks! All CodeRabbit comments are resolved and rebased on latest main. Looking forward to your review! I'm also planning to convert the gea project site to use @geajs/ssg as a follow-up PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants