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.
- package/agents/project/optimizations.md +5 -0
- package/cli/compiler/client/index.ts +3 -3
- package/cli/compiler/common/cssMinimizer.ts +74 -0
- package/client/services/router/components/PageLoading.tsx +41 -0
- package/client/services/router/components/router.ssr.tsx +13 -7
- package/client/services/router/components/router.tsx +1 -20
- package/package.json +3 -1
- package/server/services/router/http/index.ts +10 -0
- package/server/services/router/response/page/document.tsx +19 -22
- package/server/services/router/response/page/index.tsx +9 -41
- package/server/services/router/response/page/jsonld.ts +94 -0
- package/server/services/router/response/page/metas.ts +45 -0
- package/tests/css-minimizer.test.cjs +31 -0
- package/tests/page-jsonld.test.cjs +45 -0
- package/tests/page-metas.test.cjs +30 -0
|
@@ -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
|
-
|
|
352
|
-
|
|
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
|
|
25
|
+
export default function RouterComponent({ service: _clientRouter, loaderComponent }: TProps) {
|
|
25
26
|
const context = useContext();
|
|
26
27
|
const currentPage = context.page;
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
// Same tree as the client router: the page, then the idle loader.
|
|
30
30
|
return (
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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.
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|