proteum 2.5.18 → 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.
@@ -31,6 +31,11 @@ When tradeoffs exist inside optimization work, optimize in this order:
31
31
  ## SEO And Crawlable Output
32
32
 
33
33
  - Improve SEO output and crawlable, semantic HTML.
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
+ - `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.
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.
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.
34
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.
35
40
 
36
41
  ## Validation
@@ -11,6 +11,7 @@ import { rspack, type Configuration, type Module } from '@rspack/core';
11
11
  // Core
12
12
  import createCommonConfig, { TCompileMode, TCompileOutputTarget, regex } from '../common';
13
13
  import { createClientBundleAnalysisPlugins } from '../common/bundleAnalysis';
14
+ import CssMinimizerPlugin from '../common/cssMinimizer';
14
15
  import { toRspackAliases } from '../common/rspackAliases';
15
16
  import { resolveUiSingletonAliases } from '../common/uiSingletons';
16
17
  import identityAssets from './identite';
@@ -348,9 +349,8 @@ export default function createCompiler(
348
349
  removeAvailableModules: true,
349
350
  minimizer: [
350
351
  new rspack.SwcJsMinimizerRspackPlugin({}),
351
- new rspack.LightningCssMinimizerRspackPlugin({
352
- ...(lightningCssTargets ? { minimizerOptions: { targets: lightningCssTargets } } : {}),
353
- }),
352
+ // Not `LightningCssMinimizerRspackPlugin`: see cssMinimizer.ts.
353
+ new CssMinimizerPlugin(lightningCssTargets ? { targets: lightningCssTargets } : {}),
354
354
  ],
355
355
  nodeEnv: 'production',
356
356
  sideEffects: true,
@@ -0,0 +1,74 @@
1
+ /*----------------------------------
2
+ - CSS MINIMIZER
3
+ ----------------------------------*/
4
+
5
+ /**
6
+ * Minifies the emitted stylesheets with the `lightningcss` npm package instead of
7
+ * rspack's `LightningCssMinimizerRspackPlugin`.
8
+ *
9
+ * The rspack plugin runs the LightningCSS copy bundled in its Rust binding, and that
10
+ * copy prints a declaration followed by a nested at-rule without the semicolon between
11
+ * them when the nesting is two levels deep (`.a{@media (...){color:#fff;@supports (...){...}}}`
12
+ * comes out as `color:#fff@supports (...)`), which invalidates the whole rule. Tailwind v4
13
+ * emits exactly that shape for every variant with an opacity modifier, so a production
14
+ * build silently lost hover, responsive and dark-mode colours while dev looked right.
15
+ * Reproduced on @rspack/core 1.7.9 through 2.2.6 whenever the browser targets keep CSS
16
+ * nesting; `lightningcss` 1.33 prints it correctly, so the minifier reads that package.
17
+ */
18
+
19
+ /*----------------------------------
20
+ - DEPENDENCIES
21
+ ----------------------------------*/
22
+
23
+ import browserslist from 'browserslist';
24
+ import { browserslistToTargets, transform } from 'lightningcss';
25
+ import type { Compiler } from '@rspack/core';
26
+
27
+ /*----------------------------------
28
+ - TYPES
29
+ ----------------------------------*/
30
+
31
+ export type TCssMinimizerOptions = {
32
+ /** Browserslist queries; omitted means LightningCSS defaults (no lowering). */
33
+ targets?: string | readonly string[];
34
+ };
35
+
36
+ /*----------------------------------
37
+ - PLUGIN
38
+ ----------------------------------*/
39
+
40
+ const PLUGIN_NAME = 'ProteumCssMinimizer';
41
+
42
+ export const minifyCss = (filename: string, code: string, options: TCssMinimizerOptions = {}): string => {
43
+ const targets = options.targets ? browserslistToTargets(browserslist(options.targets)) : undefined;
44
+ const result = transform({
45
+ filename,
46
+ code: Buffer.from(code),
47
+ minify: true,
48
+ ...(targets ? { targets } : {}),
49
+ });
50
+
51
+ return result.code.toString();
52
+ };
53
+
54
+ export default class CssMinimizerPlugin {
55
+ public constructor(private readonly options: TCssMinimizerOptions = {}) {}
56
+
57
+ public apply(compiler: Compiler) {
58
+ const { Compilation, sources } = compiler.webpack;
59
+
60
+ compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => {
61
+ compilation.hooks.processAssets.tap(
62
+ { name: PLUGIN_NAME, stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE },
63
+ (assets) => {
64
+ for (const [name, source] of Object.entries(assets)) {
65
+ if (!name.endsWith('.css')) continue;
66
+
67
+ const minified = minifyCss(name, source.source().toString(), this.options);
68
+ compilation.updateAsset(name, new sources.RawSource(minified), { minimized: true });
69
+ }
70
+ },
71
+ );
72
+ });
73
+ }
74
+ }
@@ -0,0 +1,41 @@
1
+ /*----------------------------------
2
+ - PAGE LOADING INDICATOR
3
+ ----------------------------------*/
4
+
5
+ // Npm
6
+ import React from 'react';
7
+
8
+ // Specific
9
+ import type ClientRouter from '..';
10
+
11
+ /*----------------------------------
12
+ - TYPES
13
+ ----------------------------------*/
14
+
15
+ export type TPageLoadingProps = {
16
+ clientRouter?: ClientRouter;
17
+ loaderComponent?: React.ComponentType<{ isLoading: boolean }>;
18
+ };
19
+
20
+ /*----------------------------------
21
+ - COMPONENT
22
+ ----------------------------------*/
23
+
24
+ /**
25
+ * Rendered by both the client router and its SSR variant. The server used to omit it,
26
+ * so every hydration started with "Expected a DOM node of type div" in the console:
27
+ * the loader must exist in the server HTML, idle, for the two trees to match.
28
+ */
29
+ export default function PageLoading({ clientRouter, loaderComponent: LoaderComponent }: TPageLoadingProps) {
30
+ const [isLoading, setLoading] = React.useState(false);
31
+
32
+ if (clientRouter) clientRouter.setLoading = setLoading;
33
+
34
+ return LoaderComponent ? (
35
+ <LoaderComponent isLoading={isLoading} />
36
+ ) : (
37
+ <div id="loading" class={isLoading ? 'display' : ''}>
38
+ <i class="spin" />
39
+ </div>
40
+ );
41
+ }
@@ -11,6 +11,7 @@ import useContext from '@/client/context';
11
11
  // Specific
12
12
  import type ClientRouter from '..';
13
13
  import PageComponent from './Page';
14
+ import PageLoading from './PageLoading';
14
15
 
15
16
  /*----------------------------------
16
17
  - TYPES
@@ -21,16 +22,21 @@ export type TProps = { service?: ClientRouter; loaderComponent?: React.Component
21
22
  /*----------------------------------
22
23
  - COMPONENT
23
24
  ----------------------------------*/
24
- export default function RouterComponent({ service: _clientRouter, loaderComponent: _loaderComponent }: TProps) {
25
+ export default function RouterComponent({ service: _clientRouter, loaderComponent }: TProps) {
25
26
  const context = useContext();
26
27
  const currentPage = context.page;
27
28
 
28
- if (!currentPage) return null;
29
-
29
+ // Same tree as the client router: the page, then the idle loader.
30
30
  return (
31
- <PageComponent
32
- page={currentPage as Parameters<typeof PageComponent>[0]['page']}
33
- key={currentPage.chunkId === undefined ? undefined : 'page_' + currentPage.chunkId}
34
- />
31
+ <>
32
+ {currentPage && (
33
+ <PageComponent
34
+ page={currentPage as Parameters<typeof PageComponent>[0]['page']}
35
+ key={currentPage.chunkId === undefined ? undefined : 'page_' + currentPage.chunkId}
36
+ />
37
+ )}
38
+
39
+ <PageLoading loaderComponent={loaderComponent} />
40
+ </>
35
41
  );
36
42
  }
@@ -10,6 +10,7 @@ import useContext from '@/client/context';
10
10
  // Specific
11
11
  import type ClientRouter from '..';
12
12
  import PageComponent from './Page';
13
+ import PageLoading from './PageLoading';
13
14
  import ClientRequest from '../request';
14
15
  import { history, location, Update } from '../request/history';
15
16
  //import initTooltips from '@client/components/Donnees/Tooltip';
@@ -34,26 +35,6 @@ const withProfiler = <T,>(callback: (runtime: (typeof import('@client/dev/profil
34
35
  return callback(profilerModule.profilerRuntime);
35
36
  };
36
37
 
37
- const PageLoading = ({
38
- clientRouter,
39
- loaderComponent: LoaderComponent,
40
- }: {
41
- clientRouter?: ClientRouter;
42
- loaderComponent?: React.ComponentType<{ isLoading: boolean }>;
43
- }) => {
44
- const [isLoading, setLoading] = React.useState(false);
45
-
46
- if (clientRouter) clientRouter.setLoading = setLoading;
47
-
48
- return LoaderComponent ? (
49
- <LoaderComponent isLoading={isLoading} />
50
- ) : (
51
- <div id="loading" class={isLoading ? 'display' : ''}>
52
- <i class="spin" />
53
- </div>
54
- );
55
- };
56
-
57
38
  const scrollToElement = (selector: string) =>
58
39
  document.querySelector(selector)?.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' });
59
40
 
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.18",
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",
@@ -38,6 +38,7 @@
38
38
  "autoprefixer": "^10.4.21",
39
39
  "aws-sdk": "^2.1415.0",
40
40
  "bowser": "^2.11.0",
41
+ "browserslist": "^4.29.0",
41
42
  "cli-highlight": "^2.1.11",
42
43
  "clipanion": "^4.0.0-rc.4",
43
44
  "compression": "^1.7.4",
@@ -67,6 +68,7 @@
67
68
  "iso-639-1": "^2.1.9",
68
69
  "jsonwebtoken": "^8.5.1",
69
70
  "less-loader": "^10.0.1",
71
+ "lightningcss": "^1.33.0",
70
72
  "locale": "^0.1.0",
71
73
  "markdown-it": "^13.0.1",
72
74
  "md5": "^2.3.0",
@@ -513,6 +513,16 @@ export default class HttpServer<TRouter extends TServerRouter = TServerRouter> {
513
513
 
514
514
  routes.use('/robots.txt', express.static(path.resolve(__dirname, 'public/robots.txt')));
515
515
 
516
+ // Browsers probe the site root for `/favicon.ico` regardless of the icon links
517
+ // the document declares, so without this mount every probe walked the whole
518
+ // router to reach a 404 (12 ms of routing versus 2 ms of static file). Served
519
+ // from the app's own build output; when an app ships no icon there, static
520
+ // falls through and the 404 behaves exactly as before.
521
+ routes.use(
522
+ '/favicon.ico',
523
+ express.static(path.join(Container.path.root, APP_OUTPUT_DIR, 'public/app/favicon.ico')),
524
+ );
525
+
516
526
  routes.get('/ping', (req, res) => res.send('pong'));
517
527
 
518
528
  /*----------------------------------
@@ -66,13 +66,11 @@ export default class DocumentRenderer<TRouter extends TServerRouter> {
66
66
  <head>
67
67
  {/* Format */}
68
68
  <meta charSet="utf-8" />
69
- <meta content="IE=edge" httpEquiv="X-UA-Compatible" />
70
69
  <meta name="viewport" content="width=device-width, initial-scale=1" />
71
70
 
72
71
  {/* Mobile */}
73
72
  <meta name="application-name" content={this.app.identity.web.title} />
74
73
  <meta name="apple-mobile-web-app-title" content={this.app.identity.web.title} />
75
- <meta name="apple-mobile-web-app-title" content={this.app.identity.web.title} />
76
74
  <meta content={this.app.identity.author.name} name="author" />
77
75
  <meta name="theme-color" content={this.app.identity.maincolor} />
78
76
  <meta name="msapplication-TileColor" content={this.app.identity.maincolor} />
@@ -134,10 +132,7 @@ export default class DocumentRenderer<TRouter extends TServerRouter> {
134
132
 
135
133
  {page.style.map((style) =>
136
134
  'url' in style ? (
137
- <>
138
- <link rel="preload" href={style.url} as="style" />
139
- <link rel="stylesheet" type="text/css" href={style.url} />
140
- </>
135
+ <link key={style.id} rel="stylesheet" type="text/css" href={style.url} />
141
136
  ) : (
142
137
  <>
143
138
  <style id={style.id} dangerouslySetInnerHTML={{ __html: style.inline }} />
@@ -156,12 +151,9 @@ export default class DocumentRenderer<TRouter extends TServerRouter> {
156
151
  {styles.map((style) => {
157
152
  const href = this.clientAssetUrl(style);
158
153
 
159
- return (
160
- <React.Fragment key={style}>
161
- <link rel="preload" href={href} as="style" />
162
- <link rel="stylesheet" type="text/css" href={href} />
163
- </React.Fragment>
164
- );
154
+ // A stylesheet link in `<head>` is fetched at Highest priority on its own;
155
+ // the preload that used to precede it was a duplicate request line.
156
+ return <link key={style} rel="stylesheet" type="text/css" href={href} />;
165
157
  })}
166
158
  </>
167
159
  );
@@ -175,12 +167,10 @@ export default class DocumentRenderer<TRouter extends TServerRouter> {
175
167
  {scripts.map((script) => {
176
168
  const src = this.clientAssetUrl(script);
177
169
 
178
- return (
179
- <React.Fragment key={script}>
180
- <link rel="preload" href={src} as="script" />
181
- <script defer type="text/javascript" src={src} />
182
- </React.Fragment>
183
- );
170
+ // No `<link rel="preload">` beside the tag: a `defer` script is already fetched
171
+ // as soon as the parser meets it, at Low priority. The preload re-requested it
172
+ // at High priority, so every script raced the render-critical stylesheets.
173
+ return <script key={script} defer type="text/javascript" src={src} />;
184
174
  })}
185
175
  </>
186
176
  );
@@ -226,12 +216,19 @@ export default class DocumentRenderer<TRouter extends TServerRouter> {
226
216
 
227
217
  {this.clientScripts()}
228
218
 
219
+ {/* Page chunks are `defer` like the entry, unless the page asked for `async`
220
+ (an analytics tag, for example). Without it they were parser-blocking in
221
+ `<head>`, and `addChunks` runs after `renderToString`, so no page could set
222
+ the attribute itself. Same rule as above: no preload beside the tag. */}
229
223
  {page.scripts.map((script) =>
230
224
  'url' in script ? (
231
- <>
232
- <link rel="preload" href={script.url} as="script" />
233
- <script type="text/javascript" src={script.url} {...(script.attrs || {})} />
234
- </>
225
+ <script
226
+ key={script.id}
227
+ type="text/javascript"
228
+ src={script.url}
229
+ defer={!(script.attrs && script.attrs.async)}
230
+ {...(script.attrs || {})}
231
+ />
235
232
  ) : (
236
233
  <>
237
234
  <script
@@ -11,6 +11,8 @@ import { type TServerRouter, TRouterContext } from '@server/services/router';
11
11
  import type { Layout, TRoute, TErrorRoute, TClientOrServerContext } from '@common/router';
12
12
  import PageResponse, { TFrontRenderer, TPageRenderContext } from '@common/router/response/page';
13
13
  import { getClientBuildManifest } from './clientManifest';
14
+ import { buildMetaTags } from './metas';
15
+ import { buildDefaultJsonLd } from './jsonld';
14
16
 
15
17
  // Composants UI
16
18
  import App from '@client/app/component';
@@ -175,53 +177,19 @@ export default class ServerPage<TRouter extends TServerRouter = TServerRouter> e
175
177
  ...this.metas,
176
178
  };
177
179
 
178
- for (const key in metas) {
179
- const value = metas[key];
180
- if (value === '') continue;
181
- this.head.push({ $: 'meta', property: key, content: value });
182
- }
180
+ this.head.push(...buildMetaTags(metas, this.head));
183
181
  }
184
182
 
185
183
  private buildJsonLd() {
186
184
  this.jsonld.push(
187
- {
188
- '@type': 'Organization',
189
- '@id': this.router.url('/#organization'),
190
- name: this.app.identity.author.name,
191
- url: this.app.identity.author.url,
192
- logo: {
193
- '@type': 'ImageObject',
194
- '@id': this.router.url('/#logo'),
195
- url: this.router.url('/public/brand/1024.png'),
196
- width: '1024px',
197
- height: '1024px',
198
- caption: this.app.identity.name,
199
- },
200
- sameAs: [],
201
- },
202
- {
203
- '@type': 'WebSite',
204
- '@id': this.router.url('/#website'),
205
- url: this.router.url('/'),
206
- name: this.app.identity.name,
207
- description: this.app.identity.description,
208
- publisher: { '@id': this.router.url('/#organization') },
209
- inLanguage: this.app.identity.locale,
210
- potentialAction: [],
211
-
212
- ...(this.app.identity.web.jsonld || {}),
213
- },
214
- {
215
- '@type': 'WebPage',
216
- '@id': this.url,
185
+ ...buildDefaultJsonLd({
186
+ pageJsonLd: this.jsonld,
217
187
  url: this.url,
218
-
219
- isPartOf: { '@id': this.router.url('/#website') },
220
-
221
- name: this.title,
188
+ title: this.title,
222
189
  description: this.description,
223
- inLanguage: this.app.identity.locale,
224
- },
190
+ identity: this.app.identity,
191
+ resolveUrl: (path) => this.router.url(path),
192
+ }),
225
193
  );
226
194
  }
227
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
+ /*----------------------------------
2
+ - PAGE METAS
3
+ ----------------------------------*/
4
+
5
+ /*----------------------------------
6
+ - TYPES
7
+ ----------------------------------*/
8
+
9
+ type TMetaValue = string | Date | undefined | null;
10
+ export type TMetaTag = { $: string } & { [key: string]: TMetaValue };
11
+
12
+ /*----------------------------------
13
+ - HELPERS
14
+ ----------------------------------*/
15
+
16
+ /**
17
+ * Open Graph and its extensions are RDFa vocabularies read from `property`. Everything
18
+ * else (`robots`, `description`, `twitter:*`, `theme-color`...) is only honoured as
19
+ * `name`: emitted as `property="robots"`, the default robots meta was inert on every page.
20
+ */
21
+ export const isOpenGraphMetaKey = (key: string): boolean => /^(og|article|fb|profile|book|music|video):/.test(key);
22
+
23
+ /**
24
+ * Turns the page's meta dictionary into head tags, skipping the keys the page already
25
+ * pushed into `head` itself (a `noindex` on a faceted view, a per-page social image):
26
+ * a second tag with the same key would leave crawlers to pick between the two.
27
+ */
28
+ export const buildMetaTags = (metas: { [key: string]: TMetaValue }, head: readonly TMetaTag[]): TMetaTag[] => {
29
+ const pushedMetaKeys = new Set(
30
+ head
31
+ .filter((tag) => tag.$ === 'meta')
32
+ .map((tag) => tag.name || tag.property)
33
+ .filter((key): key is string => typeof key === 'string'),
34
+ );
35
+ const tags: TMetaTag[] = [];
36
+
37
+ for (const key in metas) {
38
+ const value = metas[key];
39
+ if (value === '' || value === undefined || value === null || pushedMetaKeys.has(key)) continue;
40
+
41
+ tags.push({ $: 'meta', [isOpenGraphMetaKey(key) ? 'property' : 'name']: key, content: value });
42
+ }
43
+
44
+ return tags;
45
+ };
@@ -0,0 +1,31 @@
1
+ const assert = require('node:assert/strict');
2
+
3
+ require('ts-node/register/transpile-only');
4
+
5
+ const { minifyCss } = require('../cli/compiler/common/cssMinimizer');
6
+
7
+ // The shape Tailwind v4 emits for a variant with an opacity modifier (`lg:text-white/70`):
8
+ // a declaration followed by a nested `@supports` fallback, two levels deep.
9
+ const NESTED_FALLBACK =
10
+ '.a { @media (width >= 64rem) { color: #ffffffb3; @supports (color: color-mix(in lab, red, red)) { color: color-mix(in oklab, var(--c) 70%, transparent); } } }';
11
+
12
+ test('css minimizer keeps the semicolon between a declaration and a nested at-rule', () => {
13
+ // Browsers that support nesting, so nothing is flattened: the case the rspack-bundled
14
+ // LightningCSS printed as `color:#ffffffb3@supports`, which drops the whole rule.
15
+ const minified = minifyCss('x.css', NESTED_FALLBACK, { targets: ['last 2 chrome versions'] });
16
+
17
+ assert.doesNotMatch(minified, /[^;{}\s]@supports/);
18
+ assert.match(minified, /color:#ffffffb3;@supports/);
19
+ });
20
+
21
+ test('css minimizer lowers nesting for browsers that need it and honours a string query', () => {
22
+ const minified = minifyCss('x.css', NESTED_FALLBACK, { targets: 'chrome 100' });
23
+
24
+ assert.doesNotMatch(minified, /[^;{}\s]@supports/);
25
+ // Chrome 100 has no CSS nesting: the media query is hoisted around the rule.
26
+ assert.match(minified, /^@media \(min-width:64rem\)\{\.a\{/);
27
+ });
28
+
29
+ test('css minimizer minifies without targets', () => {
30
+ assert.equal(minifyCss('x.css', '.a {\n color: #ffffff;\n}\n'), '.a{color:#fff}');
31
+ });
@@ -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
+ });
@@ -0,0 +1,30 @@
1
+ const assert = require('node:assert/strict');
2
+
3
+ require('ts-node/register/transpile-only');
4
+
5
+ const { buildMetaTags, isOpenGraphMetaKey } = require('../server/services/router/response/page/metas');
6
+
7
+ test('page metas use property only for Open Graph vocabularies', () => {
8
+ assert.equal(isOpenGraphMetaKey('og:title'), true);
9
+ assert.equal(isOpenGraphMetaKey('article:published_time'), true);
10
+ assert.equal(isOpenGraphMetaKey('robots'), false);
11
+ assert.equal(isOpenGraphMetaKey('twitter:card'), false);
12
+
13
+ const tags = buildMetaTags({ robots: 'index', 'og:type': 'website', 'twitter:card': 'summary' }, []);
14
+
15
+ assert.deepEqual(tags, [
16
+ { $: 'meta', name: 'robots', content: 'index' },
17
+ { $: 'meta', property: 'og:type', content: 'website' },
18
+ { $: 'meta', name: 'twitter:card', content: 'summary' },
19
+ ]);
20
+ });
21
+
22
+ test('page metas skip keys the page already pushed and empty values', () => {
23
+ const head = [
24
+ { $: 'meta', name: 'robots', content: 'noindex,follow' },
25
+ { $: 'link', rel: 'canonical', href: '/x' },
26
+ ];
27
+ const tags = buildMetaTags({ robots: 'index', 'og:description': '', 'og:title': 'T' }, head);
28
+
29
+ assert.deepEqual(tags, [{ $: 'meta', property: 'og:title', content: 'T' }]);
30
+ });