lovable-ssr 0.1.18 → 0.1.20

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
@@ -1,6 +1,6 @@
1
1
  # lovable-ssr
2
2
 
3
- SSR and route data engine for [Lovable](https://lovable.dev) projects: route registry, `getServerData`, Express + Vite server.
3
+ SSR and route data engine for [Lovable](https://lovable.dev) projects: route registry, `getData`, Express + Vite server, and built-in SEO support.
4
4
 
5
5
  **Documentation:** [Documentação completa](https://calm-meadow-5cf6.github-8c8.workers.dev/)
6
6
 
@@ -8,6 +8,8 @@ SSR and route data engine for [Lovable](https://lovable.dev) projects: route reg
8
8
 
9
9
  ```bash
10
10
  npm i lovable-ssr
11
+ # or
12
+ yarn add lovable-ssr
11
13
  ```
12
14
 
13
15
  Peer dependencies: `react`, `react-dom`, `react-router-dom` (^18 / ^6).
@@ -17,6 +19,7 @@ Peer dependencies: `react`, `react-dom`, `react-router-dom` (^18 / ^6).
17
19
  1. Register routes with `registerRoutes(routes)`.
18
20
  2. Wrap your app with `BrowserRouteDataProvider` and render `AppRoutes` (inside `BrowserRouter`).
19
21
  3. (Optional) SSR: entry module + `createServer` from `lovable-ssr/server`.
22
+ 4. (Optional) SEO: add `<SEO />` on pages to set meta tags and JSON-LD; helmet content is injected during SSR.
20
23
 
21
24
  Details, examples, and API: see the [documentation](https://calm-meadow-5cf6.github-8c8.workers.dev/).
22
25
 
@@ -1,6 +1,6 @@
1
1
  import type { ReactNode } from 'react';
2
2
  /**
3
- * Wraps children with RouteDataProvider using initial data from the browser:
3
+ * Wraps children with HelmetProvider and RouteDataProvider using initial data from the browser:
4
4
  * - window.__PRELOADED_DATA__ (from SSR)
5
5
  * - window.location.pathname + RouterService for matchedRoute and routeParams
6
6
  *
@@ -1 +1 @@
1
- {"version":3,"file":"BrowserRouteDataProvider.d.ts","sourceRoot":"","sources":["../../src/components/BrowserRouteDataProvider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAIvC;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,2CA0B7E"}
1
+ {"version":3,"file":"BrowserRouteDataProvider.d.ts","sourceRoot":"","sources":["../../src/components/BrowserRouteDataProvider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAOvC;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,2CA4B7E"}
@@ -1,8 +1,10 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import ReactHelmetAsync from 'react-helmet-async';
2
3
  import RouterService from '../router/RouterService.js';
3
4
  import { RouteDataProvider } from '../router/RouteDataContext.js';
5
+ const { HelmetProvider } = ReactHelmetAsync;
4
6
  /**
5
- * Wraps children with RouteDataProvider using initial data from the browser:
7
+ * Wraps children with HelmetProvider and RouteDataProvider using initial data from the browser:
6
8
  * - window.__PRELOADED_DATA__ (from SSR)
7
9
  * - window.location.pathname + RouterService for matchedRoute and routeParams
8
10
  *
@@ -21,5 +23,5 @@ export function BrowserRouteDataProvider({ children }) {
21
23
  routeParams: routeParamsResult.routeParams,
22
24
  searchParams: Object.keys(searchParams).length > 0 ? searchParams : routeParamsResult.searchParams,
23
25
  };
24
- return (_jsx(RouteDataProvider, { initialData: preloadedData, initialRoute: matchedRoute, initialParams: initialParams, children: children }));
26
+ return (_jsx(HelmetProvider, { children: _jsx(RouteDataProvider, { initialData: preloadedData, initialRoute: matchedRoute, initialParams: initialParams, children: children }) }));
25
27
  }
@@ -0,0 +1,15 @@
1
+ export interface SEOProps {
2
+ title: string;
3
+ description: string;
4
+ image?: string;
5
+ url?: string;
6
+ type?: string;
7
+ noindex?: boolean;
8
+ structuredData?: object;
9
+ }
10
+ /**
11
+ * Centralized SEO component for meta tags and JSON-LD.
12
+ * Use within HelmetProvider (provided by framework at app level).
13
+ */
14
+ export declare function SEO({ title, description, image, url, type, noindex, structuredData, }: SEOProps): import("react/jsx-runtime").JSX.Element;
15
+ //# sourceMappingURL=SEO.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SEO.d.ts","sourceRoot":"","sources":["../../src/components/SEO.tsx"],"names":[],"mappings":"AAIA,MAAM,WAAW,QAAQ;IACvB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;GAGG;AACH,wBAAgB,GAAG,CAAC,EAClB,KAAK,EACL,WAAW,EACX,KAAK,EACL,GAAG,EACH,IAAgB,EAChB,OAAe,EACf,cAAc,GACf,EAAE,QAAQ,2CAuBV"}
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import ReactHelmetAsync from 'react-helmet-async';
3
+ const { Helmet } = ReactHelmetAsync;
4
+ /**
5
+ * Centralized SEO component for meta tags and JSON-LD.
6
+ * Use within HelmetProvider (provided by framework at app level).
7
+ */
8
+ export function SEO({ title, description, image, url, type = 'website', noindex = false, structuredData, }) {
9
+ return (_jsxs(Helmet, { children: [_jsx("title", { children: title }), _jsx("meta", { name: "description", content: description }), noindex && _jsx("meta", { name: "robots", content: "noindex, nofollow" }), url && _jsx("link", { rel: "canonical", href: url }), url && _jsx("meta", { property: "og:url", content: url }), _jsx("meta", { property: "og:title", content: title }), _jsx("meta", { property: "og:description", content: description }), _jsx("meta", { property: "og:type", content: type }), image && _jsx("meta", { property: "og:image", content: image }), _jsx("meta", { name: "twitter:card", content: "summary_large_image" }), _jsx("meta", { name: "twitter:title", content: title }), _jsx("meta", { name: "twitter:description", content: description }), image && _jsx("meta", { name: "twitter:image", content: image }), structuredData && (_jsx("script", { type: "application/ld+json", children: JSON.stringify(structuredData) }))] }));
10
+ }
package/dist/index.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import './globals.js';
2
- export type { RouteConfig, ComponentWithGetData, RouteDataParams } from './types.js';
2
+ export type { RouteConfig, ComponentWithGetData, RouteDataParams, SitemapEntry, SitemapRouteConfig, SitemapChangefreq, } from './types.js';
3
3
  export { registerRoutes, getRoutes } from './registry.js';
4
4
  export { BrowserRouteDataProvider } from './components/BrowserRouteDataProvider.js';
5
+ export { SEO, type SEOProps } from './components/SEO.js';
5
6
  export { default as RouterService } from './router/RouterService.js';
6
7
  export { RouteDataProvider, useRouteData, buildRouteKey, type RouteDataState, type InitialRouteShape, } from './router/RouteDataContext.js';
7
8
  export { AppRoutes } from './components/AppRoutes.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,cAAc,CAAC;AACtB,YAAY,EAAE,WAAW,EAAE,oBAAoB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AACrF,OAAO,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAC1D,OAAO,EAAE,wBAAwB,EAAE,MAAM,0CAA0C,CAAC;AACpF,OAAO,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,2BAA2B,CAAC;AACrE,OAAO,EACL,iBAAiB,EACjB,YAAY,EACZ,aAAa,EACb,KAAK,cAAc,EACnB,KAAK,iBAAiB,GACvB,MAAM,8BAA8B,CAAC;AACtC,OAAO,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAC;AACtD,OAAO,EACL,MAAM,EACN,KAAK,YAAY,EACjB,KAAK,aAAa,GACnB,MAAM,iBAAiB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,cAAc,CAAC;AACtB,YAAY,EACV,WAAW,EACX,oBAAoB,EACpB,eAAe,EACf,YAAY,EACZ,kBAAkB,EAClB,iBAAiB,GAClB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAC1D,OAAO,EAAE,wBAAwB,EAAE,MAAM,0CAA0C,CAAC;AACpF,OAAO,EAAE,GAAG,EAAE,KAAK,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,2BAA2B,CAAC;AACrE,OAAO,EACL,iBAAiB,EACjB,YAAY,EACZ,aAAa,EACb,KAAK,cAAc,EACnB,KAAK,iBAAiB,GACvB,MAAM,8BAA8B,CAAC;AACtC,OAAO,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAC;AACtD,OAAO,EACL,MAAM,EACN,KAAK,YAAY,EACjB,KAAK,aAAa,GACnB,MAAM,iBAAiB,CAAC"}
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import './globals.js';
2
2
  export { registerRoutes, getRoutes } from './registry.js';
3
3
  export { BrowserRouteDataProvider } from './components/BrowserRouteDataProvider.js';
4
+ export { SEO } from './components/SEO.js';
4
5
  export { default as RouterService } from './router/RouterService.js';
5
6
  export { RouteDataProvider, useRouteData, buildRouteKey, } from './router/RouteDataContext.js';
6
7
  export { AppRoutes } from './components/AppRoutes.js';
@@ -3,6 +3,12 @@ import { RequestContext } from '../types.js';
3
3
  export interface RenderResult {
4
4
  html: string;
5
5
  preloadedData: Record<string, unknown>;
6
+ helmet?: {
7
+ title: string;
8
+ meta: string;
9
+ link: string;
10
+ script: string;
11
+ };
6
12
  }
7
13
  export interface RenderOptions {
8
14
  wrap?: (children: ReactNode) => ReactNode;
@@ -1 +1 @@
1
- {"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../../src/ssr/render.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAMvC,OAAO,EAAE,cAAc,EAAmB,MAAM,aAAa,CAAC;AAE9D,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACxC;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,CAAC,EAAE,CAAC,QAAQ,EAAE,SAAS,KAAK,SAAS,CAAC;IAC1C;;;OAGG;IACH,cAAc,CAAC,EAAE,cAAc,CAAC;CACjC;AAED;;;GAGG;AACH,wBAAsB,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC,YAAY,CAAC,CAwCxF"}
1
+ {"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../../src/ssr/render.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAUvC,OAAO,EAAE,cAAc,EAAmB,MAAM,aAAa,CAAC;AAE9D,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACvC,MAAM,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;CACxE;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,CAAC,EAAE,CAAC,QAAQ,EAAE,SAAS,KAAK,SAAS,CAAC;IAC1C;;;OAGG;IACH,cAAc,CAAC,EAAE,cAAc,CAAC;CACjC;AAED;;;GAGG;AACH,wBAAsB,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC,YAAY,CAAC,CAqDxF"}
@@ -1,6 +1,8 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { renderToString } from 'react-dom/server';
3
+ import ReactHelmetAsync from 'react-helmet-async';
3
4
  import { StaticRouter } from 'react-router-dom/server';
5
+ const { HelmetProvider } = ReactHelmetAsync;
4
6
  import { AppRoutes } from '../components/AppRoutes.js';
5
7
  import { RouteDataProvider } from '../router/RouteDataContext.js';
6
8
  import RouterService from '../router/RouterService.js';
@@ -32,8 +34,17 @@ export async function render(url, options) {
32
34
  preloadedData = { ...preloadedData, is_success: false };
33
35
  }
34
36
  }
35
- const inner = (_jsx(StaticRouter, { location: url, children: _jsx(RouteDataProvider, { initialData: preloadedData, initialRoute: matchedRoute, initialParams: params, children: _jsx(AppRoutes, {}) }) }));
37
+ const helmetContext = {};
38
+ const inner = (_jsx(HelmetProvider, { context: helmetContext, children: _jsx(StaticRouter, { location: url, children: _jsx(RouteDataProvider, { initialData: preloadedData, initialRoute: matchedRoute, initialParams: params, children: _jsx(AppRoutes, {}) }) }) }));
36
39
  const app = options?.wrap ? options.wrap(inner) : inner;
37
40
  const html = renderToString(app);
38
- return { html, preloadedData };
41
+ const helmet = helmetContext.helmet
42
+ ? {
43
+ title: helmetContext.helmet.title.toString(),
44
+ meta: helmetContext.helmet.meta.toString(),
45
+ link: helmetContext.helmet.link.toString(),
46
+ script: helmetContext.helmet.script.toString(),
47
+ }
48
+ : undefined;
49
+ return { html, preloadedData, helmet };
39
50
  }
@@ -2,6 +2,12 @@ import { type Express } from 'express';
2
2
  export interface RenderResult {
3
3
  html: string;
4
4
  preloadedData: Record<string, unknown>;
5
+ helmet?: {
6
+ title: string;
7
+ meta: string;
8
+ link: string;
9
+ script: string;
10
+ };
5
11
  }
6
12
  export interface CreateServerConfig {
7
13
  root: string;
@@ -10,6 +16,12 @@ export interface CreateServerConfig {
10
16
  port?: number;
11
17
  /** Optional link tag to inject in dev for CSS (e.g. '<link rel="stylesheet" href="/src/index.css">') */
12
18
  cssLinkInDev?: string;
19
+ /** Register routes before the SSR catch-all (e.g. sitemap.xml, robots.txt) */
20
+ extraRoutes?: (app: Express) => void;
21
+ /** Enable sitemap.xml and robots.txt from route registry. Routes with sitemap.include are included. */
22
+ sitemap?: {
23
+ siteUrl: string;
24
+ };
13
25
  }
14
26
  export declare function createServer(config: CreateServerConfig): Promise<{
15
27
  getApp: () => Express;
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/ssr/server.ts"],"names":[],"mappings":"AAGA,OAAgB,EAAE,KAAK,OAAO,EAAkD,MAAM,SAAS,CAAC;AAKhG,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACxC;AAED,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,yFAAyF;IACzF,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,wGAAwG;IACxG,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAkPD,wBAAsB,YAAY,CAAC,MAAM,EAAE,kBAAkB,GAAG,OAAO,CAAC;IACtE,MAAM,EAAE,MAAM,OAAO,CAAC;IACtB,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,IAAI,KAAK,IAAI,CAAC;CACxD,CAAC,CAOD;AAED;qDACqD;AACrD,wBAAgB,SAAS,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,kBAAkB,CAAC,QAO7D"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/ssr/server.ts"],"names":[],"mappings":"AAGA,OAAgB,EAAE,KAAK,OAAO,EAAkD,MAAM,SAAS,CAAC;AAOhG,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACvC,MAAM,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;CACxE;AAED,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,yFAAyF;IACzF,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,wGAAwG;IACxG,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,8EAA8E;IAC9E,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,IAAI,CAAC;IACrC,uGAAuG;IACvG,OAAO,CAAC,EAAE;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;CAC/B;AAmVD,wBAAsB,YAAY,CAAC,MAAM,EAAE,kBAAkB,GAAG,OAAO,CAAC;IACtE,MAAM,EAAE,MAAM,OAAO,CAAC;IACtB,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,IAAI,KAAK,IAAI,CAAC;CACxD,CAAC,CAOD;AAED;qDACqD;AACrD,wBAAgB,SAAS,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,kBAAkB,CAAC,QAO7D"}
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
4
  import express from 'express';
5
5
  import { createServer as createViteServer } from 'vite';
6
+ import { getRoutes } from '../registry.js';
6
7
  import RouterService from '../router/RouterService.js';
7
8
  function defaultDistEntryPath(entryPath) {
8
9
  return entryPath
@@ -30,6 +31,8 @@ class SsrServer {
30
31
  entryPath: config.entryPath,
31
32
  port: config.port ?? 5173,
32
33
  cssLinkInDev: config.cssLinkInDev ?? '<link rel="stylesheet" href="/src/index.css"></head>',
34
+ extraRoutes: config.extraRoutes ?? (() => { }),
35
+ sitemap: config.sitemap ?? undefined,
33
36
  };
34
37
  this.isProd = process.env.NODE_ENV === 'production';
35
38
  this.app = express();
@@ -65,8 +68,74 @@ class SsrServer {
65
68
  this.app.use(express.static(path.join(this.config.root, 'dist'), { index: false }));
66
69
  }
67
70
  configureRequestHandler() {
71
+ if (this.config.sitemap?.siteUrl) {
72
+ this.registerSitemapRoutes(this.config.sitemap.siteUrl);
73
+ }
74
+ this.config.extraRoutes?.(this.app);
68
75
  this.app.use('*', (req, res, next) => this.handleRequest(req, res, next));
69
76
  }
77
+ registerSitemapRoutes(siteUrl) {
78
+ const baseUrl = siteUrl.replace(/\/$/, '');
79
+ this.app.get('/robots.txt', (_req, res) => {
80
+ res.type('text/plain');
81
+ res.send(`User-agent: *
82
+ Allow: /
83
+
84
+ Sitemap: ${baseUrl}/sitemap.xml`);
85
+ });
86
+ this.app.get('/sitemap.xml', async (_req, res) => {
87
+ const today = new Date().toISOString().split('T')[0];
88
+ const entries = [];
89
+ for (const route of getRoutes()) {
90
+ const cfg = route.sitemap;
91
+ if (!cfg?.include)
92
+ continue;
93
+ const changefreq = cfg.changefreq ?? 'weekly';
94
+ const priority = cfg.priority ?? 0.5;
95
+ if (cfg.getEntries) {
96
+ try {
97
+ const dynamicEntries = await cfg.getEntries({ siteUrl: baseUrl });
98
+ for (const e of dynamicEntries) {
99
+ entries.push({
100
+ loc: e.loc,
101
+ lastmod: e.lastmod ?? today,
102
+ changefreq: e.changefreq ?? changefreq,
103
+ priority: e.priority ?? priority,
104
+ });
105
+ }
106
+ }
107
+ catch (err) {
108
+ console.error(`[lovable-ssr] sitemap getEntries failed for ${route.path}:`, err);
109
+ }
110
+ }
111
+ else if (!route.path.includes(':')) {
112
+ entries.push({
113
+ loc: `${baseUrl}${route.path === '/' ? '' : route.path}`,
114
+ lastmod: today,
115
+ changefreq,
116
+ priority,
117
+ });
118
+ }
119
+ }
120
+ const xml = this.buildSitemapXml(entries);
121
+ res.type('application/xml').send(xml);
122
+ });
123
+ }
124
+ buildSitemapXml(entries) {
125
+ const lines = entries.map((e) => ` <url><loc>${this.escapeXml(e.loc)}</loc><lastmod>${e.lastmod ?? ''}</lastmod><changefreq>${e.changefreq ?? 'weekly'}</changefreq><priority>${e.priority ?? 0.5}</priority></url>`);
126
+ return `<?xml version="1.0" encoding="UTF-8"?>
127
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
128
+ ${lines.join('\n')}
129
+ </urlset>`;
130
+ }
131
+ escapeXml(str) {
132
+ return str
133
+ .replace(/&/g, '&amp;')
134
+ .replace(/</g, '&lt;')
135
+ .replace(/>/g, '&gt;')
136
+ .replace(/"/g, '&quot;')
137
+ .replace(/'/g, '&apos;');
138
+ }
70
139
  async handleRequest(req, res, next) {
71
140
  const url = req.originalUrl;
72
141
  const pathname = url.replace(/\?.*$/, '').replace(/#.*$/, '') || '/';
@@ -100,6 +169,7 @@ class SsrServer {
100
169
  }
101
170
  let appHtml;
102
171
  let preloadedData;
172
+ let helmet;
103
173
  // Constrói um contexto simples de request (cookies raw + headers) para o getData.
104
174
  const cookiesRaw = req.headers.cookie ?? '';
105
175
  const cookies = {};
@@ -131,26 +201,31 @@ class SsrServer {
131
201
  if (cached) {
132
202
  appHtml = cached.html;
133
203
  preloadedData = cached.preloadedData;
204
+ helmet = cached.helmet;
134
205
  }
135
206
  else {
136
207
  const result = await render(url, { requestContext });
137
208
  appHtml = typeof result.html === 'string' ? result.html : '';
138
209
  preloadedData = result.preloadedData ?? {};
139
- this._ssrCache.set(cacheKey, { html: appHtml, preloadedData });
210
+ helmet = result.helmet;
211
+ this._ssrCache.set(cacheKey, { html: appHtml, preloadedData, helmet });
140
212
  }
141
213
  }
142
214
  else {
143
215
  const result = await render(url, { requestContext });
144
216
  appHtml = typeof result.html === 'string' ? result.html : '';
145
217
  preloadedData = result.preloadedData ?? {};
218
+ helmet = result.helmet;
146
219
  }
147
220
  }
148
221
  else {
149
222
  const result = await render(url, { requestContext });
150
223
  appHtml = typeof result.html === 'string' ? result.html : '';
151
224
  preloadedData = result.preloadedData ?? {};
225
+ helmet = result.helmet;
152
226
  }
153
227
  let html = template.replace('<div id="root"></div>', `<div id="root">${appHtml}</div>`);
228
+ html = this.injectHelmet(html, helmet);
154
229
  html = this.injectPreloadedData(html, preloadedData);
155
230
  if (this.vite) {
156
231
  const transformed = await this.vite.transformIndexHtml(url, html);
@@ -186,6 +261,14 @@ class SsrServer {
186
261
  return html;
187
262
  return html.replace('</head>', this.config.cssLinkInDev);
188
263
  }
264
+ injectHelmet(html, helmet) {
265
+ if (!helmet)
266
+ return html;
267
+ const tags = [helmet.title, helmet.meta, helmet.link, helmet.script].filter(Boolean).join('\n');
268
+ if (!tags)
269
+ return html;
270
+ return html.replace('</head>', `${tags}\n</head>`);
271
+ }
189
272
  injectPreloadedData(html, preloadedData) {
190
273
  if (Object.keys(preloadedData).length === 0)
191
274
  return html;
package/dist/types.d.ts CHANGED
@@ -3,10 +3,27 @@ import type React from 'react';
3
3
  export type ComponentWithGetData = React.ComponentType<any> & {
4
4
  getData?: (params?: RouteDataParams) => Promise<Record<string, unknown>>;
5
5
  };
6
+ export type SitemapChangefreq = 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
7
+ export type SitemapEntry = {
8
+ loc: string;
9
+ lastmod?: string;
10
+ changefreq?: SitemapChangefreq;
11
+ priority?: number;
12
+ };
13
+ export type SitemapRouteConfig = {
14
+ include: boolean;
15
+ changefreq?: SitemapChangefreq;
16
+ priority?: number;
17
+ /** For dynamic routes (e.g. /video/:id): return all URLs for this pattern. */
18
+ getEntries?: (ctx: {
19
+ siteUrl: string;
20
+ }) => Promise<SitemapEntry[]>;
21
+ };
6
22
  export type RouteConfig = {
7
23
  path: string;
8
24
  Component: ComponentWithGetData;
9
25
  isSSR: boolean;
26
+ sitemap?: SitemapRouteConfig;
10
27
  };
11
28
  export type RequestContext = {
12
29
  cookiesRaw: string;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,WAAW,CAAC;AAChD,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAE/B,MAAM,MAAM,oBAAoB,GAAG,KAAK,CAAC,aAAa,CAAC,GAAG,CAAC,GAAG;IAC5D,OAAO,CAAC,EAAE,CAAC,MAAM,CAAC,EAAE,eAAe,KAAK,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CAC1E,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,oBAAoB,CAAC;IAChC,KAAK,EAAE,OAAO,CAAC;CAChB,CAAC;AACF,MAAM,MAAM,cAAc,GAAG;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,OAAO,EAAE,mBAAmB,CAAC;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,GAAG,EAAE,MAAM,CAAC;CACb,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC5B,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACrC,OAAO,CAAC,EAAE,cAAc,CAAC;CAC1B,CAAC"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,WAAW,CAAC;AAChD,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAE/B,MAAM,MAAM,oBAAoB,GAAG,KAAK,CAAC,aAAa,CAAC,GAAG,CAAC,GAAG;IAC5D,OAAO,CAAC,EAAE,CAAC,MAAM,CAAC,EAAE,eAAe,KAAK,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CAC1E,CAAC;AAEF,MAAM,MAAM,iBAAiB,GACzB,QAAQ,GACR,QAAQ,GACR,OAAO,GACP,QAAQ,GACR,SAAS,GACT,QAAQ,GACR,OAAO,CAAC;AAEZ,MAAM,MAAM,YAAY,GAAG;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,iBAAiB,CAAC;IAC/B,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,OAAO,EAAE,OAAO,CAAC;IACjB,UAAU,CAAC,EAAE,iBAAiB,CAAC;IAC/B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,8EAA8E;IAC9E,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,YAAY,EAAE,CAAC,CAAC;CACpE,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,oBAAoB,CAAC;IAChC,KAAK,EAAE,OAAO,CAAC;IACf,OAAO,CAAC,EAAE,kBAAkB,CAAC;CAC9B,CAAC;AACF,MAAM,MAAM,cAAc,GAAG;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,OAAO,EAAE,mBAAmB,CAAC;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,GAAG,EAAE,MAAM,CAAC;CACb,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC5B,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACrC,OAAO,CAAC,EAAE,cAAc,CAAC;CAC1B,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lovable-ssr",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "description": "SSR and route data engine for Lovable projects",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -43,10 +43,12 @@
43
43
  "peerDependencies": {
44
44
  "react": "^18.0.0",
45
45
  "react-dom": "^18.0.0",
46
+ "react-helmet-async": "^2.0.5",
46
47
  "react-router-dom": "^6.0.0"
47
48
  },
48
49
  "dependencies": {
49
- "express": "^4.21.0"
50
+ "express": "^4.21.0",
51
+ "react-helmet-async": "^2.0.5"
50
52
  },
51
53
  "devDependencies": {
52
54
  "@types/express": "^4.17.21",