proteum 2.5.17 → 2.5.19

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.
@@ -23,6 +23,7 @@ Managed compact root routers must use trigger -> canonical instruction file refe
23
23
  - For every non-trivial coding task, load and follow root-level `DOCUMENTATION.md` before coding.
24
24
  - For bug fixes, regressions, incidents, broken public routes, auth/OAuth failures, integration failures, or production behavior fixes, load and follow root-level `DOCUMENTATION.md` before coding so the relevant fix note, regression-test docs, ADR, or explicit skip reason is handled in the same change.
25
25
  - If the user reports an issue, or you encounter one during exploration, implementation, verification, or runtime reproduction, load and follow root-level `diagnostics.md`.
26
+ - If the task adds or changes client data fetching, polling, or retry behavior, read the `Client Request Discipline` section in `client/AGENTS.md` before editing.
26
27
  - If the task touches client-side files, especially `client/**` and page files, load and apply root-level `optimizations.md` only after implementation for post-implementation checking and optimization. Skip it at task start and skip it for server-only, test-only, doc-only, and non-client refactor tasks unless the user explicitly asks for optimization work.
27
28
  - If the task changes UX, copy, onboarding, pricing, product semantics, or commercial positioning, use root-level `DOCUMENTATION.md` to choose the smallest relevant `./docs/` pack before editing. If a dev server is already running, print the live dev server URL as a clickable Markdown link.
28
29
  - If the task needs new app or artifact boilerplate, prefer `npx proteum init ...` and `npx proteum create ...` before creating files by hand. Use `--dry-run --json` when an agent needs a machine-readable plan before writing files.
@@ -31,6 +31,17 @@ Coding style source of truth: root-level `CODING_STYLE.md`.
31
31
  - Toasts and form errors are local feedback only; use `setError(context.app.handleError(error, fallbackMessage))` or rethrow the caught error.
32
32
  - `console.*(error)` is not error handling and must not be the last stop for a caught error.
33
33
 
34
+ ## Client Request Discipline
35
+
36
+ - A page or layout must not accumulate one mount-time API call per component or store. Before adding a request that fires on page load, extend an existing load-time payload (SSR `data`, the surface's bootstrap endpoint, or an already-fetched store) and hydrate from it; a new load-time call requires an explicit reason why no existing read can carry the data.
37
+ - When an endpoint accepts a set (for example an array of ids), request the whole needed set in one call and coalesce triggers that land in the same tick. Never loop single-item requests over a known set.
38
+ - Any effect or reactive block that can issue a request must key on explicit values (ids, tokens, statuses), must guard with sequence tokens so state identity churn cannot re-fire it, and must treat every terminal status, including `'error'`, as ineligible for automatic re-request.
39
+ - Automatic retries require exponential backoff with jitter, a hard attempt cap, and respect for server pacing (`retryAfterMs`, `Retry-After`). A transport failure or a server failed status ends in a terminal error state with a manual retry affordance, never in an immediate re-request from a render or effect cycle.
40
+ - Treat HTTP 429 as a stop signal for the whole surface, not as a retriable error.
41
+ - Long polls honor the server-provided delay, run as a single chain per logical resource, and are cancelled when superseded or unmounted.
42
+ - Identical concurrent requests must share one in-flight promise, usually a module-level cache with in-flight dedupe.
43
+ - After changing load-time fetching, polling, or retry behavior, measure the surface's executed request count (browser network log or a request-count contract test) and report the before and after numbers with the change.
44
+
34
45
  ## Design
35
46
 
36
47
  - Follow the existing design language of the touched area.
@@ -31,6 +31,10 @@ 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 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
+ - 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
38
  - 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
39
 
36
40
  ## 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.17",
4
+ "version": "2.5.19",
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,7 @@ 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';
14
15
 
15
16
  // Composants UI
16
17
  import App from '@client/app/component';
@@ -175,11 +176,7 @@ export default class ServerPage<TRouter extends TServerRouter = TServerRouter> e
175
176
  ...this.metas,
176
177
  };
177
178
 
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
- }
179
+ this.head.push(...buildMetaTags(metas, this.head));
183
180
  }
184
181
 
185
182
  private buildJsonLd() {
@@ -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,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
+ });