proteum 2.5.19 → 2.5.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.
@@ -33,6 +33,7 @@ When tradeoffs exist inside optimization work, optimize in this order:
33
33
  - Improve SEO output and crawlable, semantic HTML.
34
34
  - The SSR document emits every script (entry and page chunks) as `defer` and never preloads a script or a stylesheet: a `defer` script is already fetched when the parser meets it, and the preloads used to race the render-critical CSS at High priority. A `page.scripts` entry with `attrs: { async: true }` keeps `async` instead of `defer`. Third-party tags that should not compete with the first paint belong in an inline loader that runs after `load`, not in a `url` entry.
35
35
  - `page.metas` keys are emitted as `name=` unless they belong to an Open Graph vocabulary (`og:`, `article:`, `fb:`, `profile:`, `book:`, `music:`, `video:`), which use `property=`. A key the page already pushed into `page.head` as a `meta` is not emitted a second time, so a page-level `robots` or `og:image` wins over the defaults.
36
+ - The default JSON-LD graph (`Organization`, `WebSite`, generic `WebPage`) adds its `WebPage` only when the page pushed none itself, and never emits empty `sameAs` or `potentialAction` arrays: a second `WebPage` for the same URL with another name reads as two contradictory pages, and an empty array says "none" where the app said nothing.
36
37
  - The SSR router renders the same tree as the client router, idle page loader included: a page-level element that exists on one side only logs a hydration mismatch on every load and costs the Lighthouse Best Practices score.
37
38
  - Production CSS is minified with the `lightningcss` package (`cli/compiler/common/cssMinimizer.ts`), not rspack's bundled minimizer: the bundled copy drops the semicolon between a declaration and a nested at-rule two levels deep, which is the shape Tailwind v4 emits for every variant with an opacity modifier, and the rule is silently lost in production only.
38
39
  - For explicit crawl surfaces such as redirects, sitemap or RSS output, and public resources with custom semantics, prefer `server/routes/**` over generated controller actions when the endpoint is not a normal app API.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "proteum",
3
3
  "description": "LLM-first Opinionated Typescript Framework for web applications.",
4
- "version": "2.5.19",
4
+ "version": "2.5.20",
5
5
  "author": "Gaetan Le Gac (https://github.com/gaetanlegac)",
6
6
  "repository": "git://github.com/gaetanlegac/proteum.git",
7
7
  "license": "MIT",
@@ -12,6 +12,7 @@ import type { Layout, TRoute, TErrorRoute, TClientOrServerContext } from '@commo
12
12
  import PageResponse, { TFrontRenderer, TPageRenderContext } from '@common/router/response/page';
13
13
  import { getClientBuildManifest } from './clientManifest';
14
14
  import { buildMetaTags } from './metas';
15
+ import { buildDefaultJsonLd } from './jsonld';
15
16
 
16
17
  // Composants UI
17
18
  import App from '@client/app/component';
@@ -181,44 +182,14 @@ export default class ServerPage<TRouter extends TServerRouter = TServerRouter> e
181
182
 
182
183
  private buildJsonLd() {
183
184
  this.jsonld.push(
184
- {
185
- '@type': 'Organization',
186
- '@id': this.router.url('/#organization'),
187
- name: this.app.identity.author.name,
188
- url: this.app.identity.author.url,
189
- logo: {
190
- '@type': 'ImageObject',
191
- '@id': this.router.url('/#logo'),
192
- url: this.router.url('/public/brand/1024.png'),
193
- width: '1024px',
194
- height: '1024px',
195
- caption: this.app.identity.name,
196
- },
197
- sameAs: [],
198
- },
199
- {
200
- '@type': 'WebSite',
201
- '@id': this.router.url('/#website'),
202
- url: this.router.url('/'),
203
- name: this.app.identity.name,
204
- description: this.app.identity.description,
205
- publisher: { '@id': this.router.url('/#organization') },
206
- inLanguage: this.app.identity.locale,
207
- potentialAction: [],
208
-
209
- ...(this.app.identity.web.jsonld || {}),
210
- },
211
- {
212
- '@type': 'WebPage',
213
- '@id': this.url,
185
+ ...buildDefaultJsonLd({
186
+ pageJsonLd: this.jsonld,
214
187
  url: this.url,
215
-
216
- isPartOf: { '@id': this.router.url('/#website') },
217
-
218
- name: this.title,
188
+ title: this.title,
219
189
  description: this.description,
220
- inLanguage: this.app.identity.locale,
221
- },
190
+ identity: this.app.identity,
191
+ resolveUrl: (path) => this.router.url(path),
192
+ }),
222
193
  );
223
194
  }
224
195
  }
@@ -0,0 +1,94 @@
1
+ /*----------------------------------
2
+ - PAGE JSON-LD DEFAULTS
3
+ ----------------------------------*/
4
+
5
+ /*----------------------------------
6
+ - TYPES
7
+ ----------------------------------*/
8
+
9
+ type TJsonLdNode = { '@type'?: string | string[]; '@id'?: string; [key: string]: unknown };
10
+
11
+ type TIdentity = {
12
+ name: string;
13
+ description: string;
14
+ locale: string;
15
+ author: { name: string; url: string };
16
+ web: { jsonld?: Record<string, unknown> };
17
+ };
18
+
19
+ export type TDefaultJsonLdInput = {
20
+ /** The nodes the page pushed itself, before the defaults are added. */
21
+ pageJsonLd: readonly TJsonLdNode[];
22
+ url: string;
23
+ title: string;
24
+ description: string;
25
+ identity: TIdentity;
26
+ resolveUrl: (path: string) => string;
27
+ };
28
+
29
+ /*----------------------------------
30
+ - HELPERS
31
+ ----------------------------------*/
32
+
33
+ const hasType = (node: TJsonLdNode, type: string): boolean =>
34
+ Array.isArray(node['@type']) ? node['@type'].includes(type) : node['@type'] === type;
35
+
36
+ /**
37
+ * The publisher identity every page carries (`#organization`, `#website`) plus a generic
38
+ * `WebPage` for pages that describe none themselves.
39
+ *
40
+ * A page that already pushed a `WebPage` keeps its own: a second node for the same URL
41
+ * with another name and description reads as two contradictory pages to a consumer.
42
+ * Empty `sameAs` and `potentialAction` arrays are left out for the same reason: they say
43
+ * "no profiles" and "no actions" where the app said nothing. `identity.web.jsonld` can
44
+ * still add either to the `WebSite` node.
45
+ */
46
+ export const buildDefaultJsonLd = ({
47
+ pageJsonLd,
48
+ url,
49
+ title,
50
+ description,
51
+ identity,
52
+ resolveUrl,
53
+ }: TDefaultJsonLdInput): TJsonLdNode[] => {
54
+ const nodes: TJsonLdNode[] = [
55
+ {
56
+ '@type': 'Organization',
57
+ '@id': resolveUrl('/#organization'),
58
+ name: identity.author.name,
59
+ url: identity.author.url,
60
+ logo: {
61
+ '@type': 'ImageObject',
62
+ '@id': resolveUrl('/#logo'),
63
+ url: resolveUrl('/public/brand/1024.png'),
64
+ width: '1024px',
65
+ height: '1024px',
66
+ caption: identity.name,
67
+ },
68
+ },
69
+ {
70
+ '@type': 'WebSite',
71
+ '@id': resolveUrl('/#website'),
72
+ url: resolveUrl('/'),
73
+ name: identity.name,
74
+ description: identity.description,
75
+ publisher: { '@id': resolveUrl('/#organization') },
76
+ inLanguage: identity.locale,
77
+ ...(identity.web.jsonld || {}),
78
+ },
79
+ ];
80
+
81
+ if (!pageJsonLd.some((node) => hasType(node, 'WebPage'))) {
82
+ nodes.push({
83
+ '@type': 'WebPage',
84
+ '@id': url,
85
+ url,
86
+ isPartOf: { '@id': resolveUrl('/#website') },
87
+ name: title,
88
+ description,
89
+ inLanguage: identity.locale,
90
+ });
91
+ }
92
+
93
+ return nodes;
94
+ };
@@ -0,0 +1,45 @@
1
+ const assert = require('node:assert/strict');
2
+
3
+ require('ts-node/register/transpile-only');
4
+
5
+ const { buildDefaultJsonLd } = require('../server/services/router/response/page/jsonld');
6
+
7
+ const input = {
8
+ url: 'https://example.test/pricing',
9
+ title: 'Pricing',
10
+ description: 'What it costs.',
11
+ identity: {
12
+ name: 'Example',
13
+ description: 'An example app.',
14
+ locale: 'en-US',
15
+ author: { name: 'Example Ltd', url: 'https://example.test' },
16
+ web: { jsonld: { potentialAction: [{ '@type': 'SearchAction' }] } },
17
+ },
18
+ resolveUrl: (path) => 'https://example.test' + path,
19
+ };
20
+
21
+ test('default JSON-LD adds a generic WebPage only when the page has none', () => {
22
+ const withoutPage = buildDefaultJsonLd({ ...input, pageJsonLd: [{ '@type': 'FAQPage' }] });
23
+ assert.deepEqual(withoutPage.map((node) => node['@type']), ['Organization', 'WebSite', 'WebPage']);
24
+ assert.equal(withoutPage[2]['@id'], 'https://example.test/pricing');
25
+ assert.equal(withoutPage[2].name, 'Pricing');
26
+
27
+ const withPage = buildDefaultJsonLd({
28
+ ...input,
29
+ pageJsonLd: [{ '@type': 'WebPage', '@id': 'https://example.test/pricing#webpage', name: 'Plans' }],
30
+ });
31
+ assert.deepEqual(withPage.map((node) => node['@type']), ['Organization', 'WebSite']);
32
+
33
+ const withTypedArray = buildDefaultJsonLd({ ...input, pageJsonLd: [{ '@type': ['WebPage', 'FAQPage'] }] });
34
+ assert.deepEqual(withTypedArray.map((node) => node['@type']), ['Organization', 'WebSite']);
35
+ });
36
+
37
+ test('default JSON-LD emits no empty sameAs or potentialAction, and keeps identity additions', () => {
38
+ const nodes = buildDefaultJsonLd({ ...input, pageJsonLd: [] });
39
+ assert.equal('sameAs' in nodes[0], false);
40
+ assert.deepEqual(nodes[1].potentialAction, [{ '@type': 'SearchAction' }]);
41
+
42
+ const bare = buildDefaultJsonLd({ ...input, identity: { ...input.identity, web: {} }, pageJsonLd: [] });
43
+ assert.equal('potentialAction' in bare[1], false);
44
+ assert.equal(bare[1].publisher['@id'], 'https://example.test/#organization');
45
+ });