sku 12.6.2 → 12.8.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,86 @@
1
1
  # sku
2
2
 
3
+ ## 12.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Export a `Server` type for `sku`'s server entrypoint ([#981](https://github.com/seek-oss/sku/pull/981))
8
+
9
+ **EXAMPLE USAGE**:
10
+
11
+ ```tsx
12
+ // server.tsx
13
+ import { renderToString } from 'react-dom/server';
14
+ import type { Server } from 'sku';
15
+ import { App } from './App';
16
+
17
+ export default (): Server => ({
18
+ renderCallback: ({ SkuProvider, getHeadTags, getBodyTags }, _req, res) => {
19
+ const app = renderToString(
20
+ <SkuProvider>
21
+ <App />
22
+ </SkuProvider>,
23
+ );
24
+
25
+ res.send(/* html */ `
26
+ <!DOCTYPE html>
27
+ <html>
28
+ <head>
29
+ <meta charset="UTF-8">
30
+ <title>My Awesome Project</title>
31
+ <meta name="viewport" content="width=device-width, initial-scale=1">
32
+ ${getHeadTags()}
33
+ </head>
34
+ <body>
35
+ <div id="app">${app}</div>
36
+ ${getBodyTags()}
37
+ </body>
38
+ </html>`);
39
+ },
40
+ });
41
+ ```
42
+
43
+ > [!NOTE]
44
+ > The `Server` type may conflict with existing attempts in projects to define a `Server` type.
45
+
46
+ ## 12.7.0
47
+
48
+ ### Minor Changes
49
+
50
+ - Update TypeScript from 5.3 to 5.5 ([#1003](https://github.com/seek-oss/sku/pull/1003))
51
+
52
+ Both the 5.4 and 5.5 releases include breaking changes. See the [TypeScript 5.4 announcement] and [TypeScript 5.5 announcement] for more information.
53
+
54
+ [typescript 5.4 announcement]: https://devblogs.microsoft.com/typescript/announcing-typescript-5-4/
55
+ [typeScript 5.5 announcement]: https://devblogs.microsoft.com/typescript/announcing-typescript-5-5/
56
+
57
+ - Add support for `--watch` flag to `sku translations compile` ([#989](https://github.com/seek-oss/sku/pull/989))
58
+
59
+ The `sku translations compile` command now accepts a `--watch` flag. When this flag is provided, `translations.json` files will be re-compiled whenever changes are detected.
60
+
61
+ **EXAMPLE USAGE**:
62
+
63
+ ```sh
64
+ sku translations compile --watch
65
+ ```
66
+
67
+ ### Patch Changes
68
+
69
+ - Update all `@vocab/*` dependencies ([#989](https://github.com/seek-oss/sku/pull/989))
70
+
71
+ - Fixes a bug where the project name was not being reported correctly when sending telemetry ([#1001](https://github.com/seek-oss/sku/pull/1001))
72
+
73
+ - Enable `babel-loader` cache ([#990](https://github.com/seek-oss/sku/pull/990))
74
+
75
+ Sku's webpack configuration now configures `babel-loader` to emit a cache to `node_modules/.cache/babel-loader`. The primary use case for this cache is speeding up production builds. It can also speed up local development in situations where the webpack cache is invalidated.
76
+
77
+ - Minify build output with [SWC] ([#992](https://github.com/seek-oss/sku/pull/992))
78
+
79
+ Minification of production build output is now performed by [SWC]. Previously this was performed by [Terser]. This should result in a noticeable reduction in build times for larger projects, as well as a slight decrease in bundle size.
80
+
81
+ [swc]: https://swc.rs/docs/configuration/minification
82
+ [terser]: https://terser.org/
83
+
3
84
  ## 12.6.2
4
85
 
5
86
  ### Patch Changes
@@ -94,6 +94,13 @@ module.exports = ({
94
94
  return {
95
95
  babelrc: false,
96
96
  sourceType: isBrowser ? 'unambiguous' : 'module',
97
+ // `babel-jest` does not support the `cacheDirectory` option.
98
+ // It is only used by `babel-loader`.
99
+ ...(!isJest
100
+ ? {
101
+ cacheDirectory: true,
102
+ }
103
+ : {}),
97
104
  presets,
98
105
  plugins,
99
106
  };
@@ -134,7 +134,17 @@ const makeWebpackConfig = ({
134
134
  // The 'TerserPlugin' is actually the default minimizer for webpack
135
135
  // We add a custom one to ensure licence comments stay inside the final JS assets
136
136
  // Without this a '*.js.LICENSE.txt' file would be created alongside
137
- minimizer: [new TerserPlugin({ extractComments: false })],
137
+ minimizer: [
138
+ new TerserPlugin({
139
+ extractComments: false,
140
+ minify: TerserPlugin.swcMinify,
141
+ parallel: true,
142
+ terserOptions: {
143
+ compress: true,
144
+ mangle: true,
145
+ },
146
+ }),
147
+ ],
138
148
  concatenateModules: isProductionBuild,
139
149
  ...(!isLibrary
140
150
  ? {
@@ -176,6 +186,7 @@ const makeWebpackConfig = ({
176
186
  loader: require.resolve('babel-loader'),
177
187
  options: {
178
188
  babelrc: false,
189
+ cacheDirectory: true,
179
190
  presets: [
180
191
  [
181
192
  require.resolve('@babel/preset-env'),
@@ -97,7 +97,17 @@ const makeWebpackConfig = ({
97
97
  // The 'TerserPlugin' is actually the default minimizer for webpack
98
98
  // We add a custom one to ensure licence comments stay inside the final JS assets
99
99
  // Without this a '*.js.LICENSE.txt' file would be created alongside
100
- minimizer: [new TerserPlugin({ extractComments: false })],
100
+ minimizer: [
101
+ new TerserPlugin({
102
+ extractComments: false,
103
+ minify: TerserPlugin.swcMinify,
104
+ parallel: true,
105
+ terserOptions: {
106
+ compress: true,
107
+ mangle: true,
108
+ },
109
+ }),
110
+ ],
101
111
  concatenateModules: isProductionBuild,
102
112
  splitChunks: {
103
113
  chunks: 'all',
@@ -138,6 +148,7 @@ const makeWebpackConfig = ({
138
148
  loader: require.resolve('babel-loader'),
139
149
  options: {
140
150
  babelrc: false,
151
+ cacheDirectory: true,
141
152
  presets: [
142
153
  [
143
154
  require.resolve('@babel/preset-env'),
package/entry/csp.js CHANGED
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  import { createHash } from 'node:crypto';
2
3
  import { parse, valid } from 'node-html-parser';
3
4
  import { URL } from 'node:url';
@@ -6,9 +7,18 @@ const scriptTypeIgnoreList = ['application/json', 'application/ld+json'];
6
7
 
7
8
  const defaultBaseName = 'http://relative-url';
8
9
 
10
+ /** @typedef {import("node:crypto").BinaryLike} BinaryLike */
11
+
12
+ /** @param {BinaryLike} scriptContents */
9
13
  const hashScriptContents = (scriptContents) =>
10
14
  createHash('sha256').update(scriptContents).digest('base64');
11
15
 
16
+ /**
17
+ * @typedef {object} CreateCSPHandlerOptions
18
+ * @property {string[]} [extraHosts=[]]
19
+ * @property {boolean} [isDevelopment=false]
20
+ * @param {CreateCSPHandlerOptions} [options={}]
21
+ */
12
22
  export default function createCSPHandler({
13
23
  extraHosts = [],
14
24
  isDevelopment = false,
@@ -17,10 +27,14 @@ export default function createCSPHandler({
17
27
  const hosts = new Set();
18
28
  const shas = new Set();
19
29
 
30
+ /** @param {BinaryLike | undefined?} contents */
20
31
  const addScriptContents = (contents) => {
21
- shas.add(hashScriptContents(contents));
32
+ if (contents) {
33
+ shas.add(hashScriptContents(contents));
34
+ }
22
35
  };
23
36
 
37
+ /** @param {string} src */
24
38
  const addScriptUrl = (src) => {
25
39
  const { origin } = new URL(src, defaultBaseName);
26
40
 
@@ -31,18 +45,22 @@ export default function createCSPHandler({
31
45
 
32
46
  extraHosts.forEach((host) => addScriptUrl(host));
33
47
 
48
+ /** @param {import("node-html-parser").HTMLElement} scriptNode */
34
49
  const processScriptNode = (scriptNode) => {
35
50
  const src = scriptNode.getAttribute('src');
36
51
 
37
52
  if (src) {
38
53
  addScriptUrl(src);
39
- } else if (
40
- !scriptTypeIgnoreList.includes(scriptNode.getAttribute('type'))
41
- ) {
42
- addScriptContents(scriptNode.firstChild.rawText);
54
+ return;
55
+ }
56
+
57
+ const scriptType = scriptNode.getAttribute('type');
58
+ if (scriptType == null || !scriptTypeIgnoreList.includes(scriptType)) {
59
+ addScriptContents(scriptNode.firstChild?.rawText);
43
60
  }
44
61
  };
45
62
 
63
+ /** @type {import("../sku-types.d.ts").RenderCallbackParams['registerScript']} */
46
64
  const registerScript = (script) => {
47
65
  if (tagReturned) {
48
66
  throw new Error(
@@ -53,18 +71,16 @@ export default function createCSPHandler({
53
71
  );
54
72
  }
55
73
 
56
- if (
57
- process.env.NODE_ENV !== 'production' &&
58
- !valid(script, { script: true })
59
- ) {
74
+ if (process.env.NODE_ENV !== 'production' && !valid(script)) {
60
75
  console.error(`Invalid script passed to 'registerScript'\n${script}`);
61
76
  }
62
77
 
63
- parse(script, { script: true })
64
- .querySelectorAll('script')
65
- .forEach(processScriptNode);
78
+ parse(script).querySelectorAll('script').forEach(processScriptNode);
66
79
  };
67
80
 
81
+ /**
82
+ * @returns {string}
83
+ */
68
84
  const createCSPTag = () => {
69
85
  tagReturned = true;
70
86
 
@@ -90,11 +106,12 @@ export default function createCSPHandler({
90
106
  )};">`;
91
107
  };
92
108
 
109
+ /**
110
+ * @param {string} html
111
+ * @returns {string}
112
+ */
93
113
  const handleHtml = (html) => {
94
114
  const root = parse(html, {
95
- script: true,
96
- style: true,
97
- pre: true,
98
115
  comment: true,
99
116
  });
100
117
 
@@ -108,7 +125,16 @@ export default function createCSPHandler({
108
125
 
109
126
  root.querySelectorAll('script').forEach(processScriptNode);
110
127
 
111
- root.querySelector('head').insertAdjacentHTML('afterbegin', createCSPTag());
128
+ const headElement = root.querySelector('head');
129
+ if (!headElement) {
130
+ throw new Error(
131
+ `Unable to find 'head' element in HTML in order to create CSP tag. Check the following output of renderDocument for invalid HTML.\n${
132
+ html.length > 250 ? `${html.substring(0, 200)}...` : html
133
+ }`,
134
+ );
135
+ }
136
+
137
+ headElement.insertAdjacentHTML('afterbegin', createCSPTag());
112
138
 
113
139
  return root.toString();
114
140
  };
@@ -0,0 +1,93 @@
1
+ import createCSPHandler from './csp.js';
2
+
3
+ describe('createCSPHandler', () => {
4
+ it('should create a CSP tag', () => {
5
+ const cspHandler = createCSPHandler();
6
+
7
+ cspHandler.registerScript('<script>console.log("Hello, World!")</script>');
8
+ cspHandler.registerScript(
9
+ '<script><script src="https://code.jquery.com/jquery-3.5.0.slim.min.js"></script></script>',
10
+ );
11
+ cspHandler.registerScript('<script>console.log("Hello, World!")</script>');
12
+
13
+ expect(cspHandler.createCSPTag()).toMatchInlineSnapshot(
14
+ `"<meta http-equiv="Content-Security-Policy" content="script-src 'self' 'sha256-uCWFWr3Z6MtMWnxQ5Qyr6+zW+EGpZvdwyqLxlDbe/rE=' 'sha256-lLONtxmGZgnK0e52sTdx8mwK2vMdmln9LPZAbPGHAR0=';">"`,
15
+ );
16
+ });
17
+
18
+ it('should inject a CSP tag into HTML', () => {
19
+ const cspHandler = createCSPHandler();
20
+
21
+ cspHandler.registerScript('<script>console.log("Hello, World!")</script>');
22
+ cspHandler.registerScript(
23
+ '<script><script src="https://code.jquery.com/jquery-3.5.0.slim.min.js"></script></script>',
24
+ );
25
+ cspHandler.registerScript('<script>console.log("Hello, World!")</script>');
26
+
27
+ const html =
28
+ /* html */
29
+ `<html>
30
+ <!--
31
+ Hello
32
+ World!
33
+ -->
34
+ <head>
35
+ <script>
36
+ console.log("Hello, World!");
37
+ console.log("Hello, World!");
38
+ </script>
39
+ <noscript>
40
+ You have
41
+ Javascript disabled
42
+ </noscript>
43
+ <style>
44
+ div {
45
+ color: papayawhip;
46
+ }
47
+ </style>
48
+ </head>
49
+ <body>
50
+ <div>
51
+ Welcome to my App!
52
+ </div>
53
+ <pre>
54
+ Multi
55
+ Line
56
+ Text
57
+ </pre>
58
+ </html>`;
59
+
60
+ expect(cspHandler.handleHtml(html)).toMatchInlineSnapshot(`
61
+ "<html>
62
+ <!--
63
+ Hello
64
+ World!
65
+ -->
66
+ <head><meta http-equiv="Content-Security-Policy" content="script-src 'self' 'sha256-uCWFWr3Z6MtMWnxQ5Qyr6+zW+EGpZvdwyqLxlDbe/rE=' 'sha256-lLONtxmGZgnK0e52sTdx8mwK2vMdmln9LPZAbPGHAR0=' 'sha256-IYf6lwpasx2aXpcaX33x4ihyqfUb7LQFFjpVwJyfoYk=';">
67
+ <script>
68
+ console.log("Hello, World!");
69
+ console.log("Hello, World!");
70
+ </script>
71
+ <noscript>
72
+ You have
73
+ Javascript disabled
74
+ </noscript>
75
+ <style>
76
+ div {
77
+ color: papayawhip;
78
+ }
79
+ </style>
80
+ </head>
81
+
82
+ <div>
83
+ Welcome to my App!
84
+ </div>
85
+ <pre>
86
+ Multi
87
+ Line
88
+ Text
89
+ </pre>
90
+ </html>"
91
+ `);
92
+ });
93
+ });
@@ -10,12 +10,15 @@ const getNewTags = ({ before, after }) => {
10
10
  return afterArr.filter((tag) => !beforeArr.includes(tag)).join('\n');
11
11
  };
12
12
 
13
+ /** @typedef {import("../sku-types.d.ts").RenderCallbackParams} RenderCallbackParams */
14
+
13
15
  export default (stats, publicPath, csp) => {
14
16
  const extractor = new ChunkExtractor({
15
17
  stats,
16
18
  entrypoints: [defaultEntryPoint],
17
19
  });
18
20
 
21
+ /** @type {RenderCallbackParams['SkuProvider']} */
19
22
  const SkuProvider = ({ children }) => (
20
23
  <ChunkExtractorManager extractor={extractor}>
21
24
  {children}
@@ -40,6 +43,7 @@ export default (stats, publicPath, csp) => {
40
43
  const getCssHeadTags = () => extractor.getStyleTags();
41
44
 
42
45
  return {
46
+ /** @type RenderCallbackParams['getHeadTags'] */
43
47
  getHeadTags: ({ excludeJs, excludeCss } = {}) => {
44
48
  const tags = [];
45
49
 
@@ -56,6 +60,7 @@ export default (stats, publicPath, csp) => {
56
60
  }
57
61
  return tags.join('\n');
58
62
  },
63
+ /** @type RenderCallbackParams['flushHeadTags'] */
59
64
  flushHeadTags: ({ excludeJs, excludeCss } = {}) => {
60
65
  const tags = [];
61
66
 
@@ -92,6 +97,7 @@ export default (stats, publicPath, csp) => {
92
97
  }
93
98
  return tags.join('\n');
94
99
  },
100
+ /** @type RenderCallbackParams['getBodyTags'] */
95
101
  getBodyTags: () => extractor.getScriptTags(extraScriptTagAttributes),
96
102
  SkuProvider,
97
103
  extractor,
@@ -54,9 +54,11 @@ app.get('*', (...args) => {
54
54
 
55
55
  const { SkuProvider, extractor, flushHeadTags, getHeadTags, getBodyTags } =
56
56
  makeExtractor(webpackStats, publicPath, cspHandler);
57
+ /** @type {import("../../sku-types.d.ts").RenderCallbackParams['addLanguageChunk']} */
57
58
  const addLanguageChunk = (language) =>
58
59
  extractor.addChunk(getChunkName(language));
59
60
 
61
+ /** @type {import('express').Express} */
60
62
  const result = renderCallback(
61
63
  {
62
64
  SkuProvider,
package/lib/parseArgs.js CHANGED
@@ -44,6 +44,7 @@ module.exports = (processArgv) => {
44
44
  'debug',
45
45
  // Passed to Vocab in the `translations` script
46
46
  'delete-unused-keys',
47
+ 'watch',
47
48
  ],
48
49
  // `minimist` does not push unknown flags to `_` even if this function returns `true`, so we
49
50
  // need to track them ourselves
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sku",
3
- "version": "12.6.2",
3
+ "version": "12.8.0",
4
4
  "description": "Front-end development toolkit, powered by Webpack, Babel, CSS Modules, Less and Jest",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -44,14 +44,18 @@
44
44
  "@storybook/cli": "^7.0.17",
45
45
  "@storybook/react": "^7.0.17",
46
46
  "@storybook/react-webpack5": "^7.0.17",
47
+ "@types/express": "^4.17.11",
48
+ "@swc/core": "^1.5.25",
47
49
  "@types/jest": "^29.0.0",
48
50
  "@types/loadable__component": "^5.13.1",
51
+ "@types/loadable__server": "^5.12.10",
52
+ "@types/react": "^18.2.3",
49
53
  "@vanilla-extract/jest-transform": "^1.1.0",
50
54
  "@vanilla-extract/webpack-plugin": "^2.2.0",
51
- "@vocab/core": "^1.3.0",
52
- "@vocab/phrase": "^1.2.4",
55
+ "@vocab/core": "^1.6.2",
56
+ "@vocab/phrase": "^2.0.0",
53
57
  "@vocab/pseudo-localize": "^1.0.1",
54
- "@vocab/webpack": "^1.2.1",
58
+ "@vocab/webpack": "^1.2.9",
55
59
  "@zendesk/babel-plugin-react-displayname": "zendesk/babel-plugin-react-displayname#7a837f2",
56
60
  "autoprefixer": "^10.3.1",
57
61
  "babel-jest": "^29.0.0",
@@ -114,7 +118,7 @@
114
118
  "svgo-loader": "^4.0.0",
115
119
  "terser-webpack-plugin": "^5.1.4",
116
120
  "tree-kill": "^1.2.1",
117
- "typescript": "~5.3.0",
121
+ "typescript": "~5.5.0",
118
122
  "webpack": "^5.52.0",
119
123
  "webpack-bundle-analyzer": "^4.6.1",
120
124
  "webpack-dev-server": "^5.0.2",
@@ -127,14 +131,12 @@
127
131
  "devDependencies": {
128
132
  "@types/cross-spawn": "^6.0.3",
129
133
  "@types/debug": "^4.1.12",
130
- "@types/express": "^4.17.11",
131
134
  "@types/minimist": "^1.2.5",
132
135
  "@types/picomatch": "^2.3.3",
133
- "@types/react": "^18.2.3",
134
136
  "@types/react-dom": "^18.2.3",
135
137
  "@types/which": "^3.0.0",
136
138
  "@vanilla-extract/css": "^1.0.0",
137
- "@vocab/react": "^1.0.1",
139
+ "@vocab/react": "^1.1.11",
138
140
  "braid-design-system": "^32.0.0",
139
141
  "react": "^18.2.0",
140
142
  "react-dom": "^18.2.0",
@@ -2,7 +2,11 @@ const envCi = require('env-ci');
2
2
 
3
3
  const { branch } = envCi();
4
4
  const chalk = require('chalk');
5
- const args = require('../config/args').argv;
5
+ const {
6
+ argv: args,
7
+ watch,
8
+ 'delete-unused-keys': deleteUnusedKeys,
9
+ } = require('../config/args');
6
10
  const { compile, validate } = require('@vocab/core');
7
11
  const { push, pull } = require('@vocab/phrase');
8
12
  const { getVocabConfig } = require('../config/vocab/vocab');
@@ -51,16 +55,13 @@ const ensureBranch = () => {
51
55
 
52
56
  try {
53
57
  if (translationSubCommand === 'compile') {
54
- compile({ watch: false }, vocabConfig);
58
+ console.log('Watching for changes to translations');
59
+ compile({ watch }, vocabConfig);
55
60
  }
56
61
  if (translationSubCommand === 'validate') {
57
62
  validate(vocabConfig);
58
63
  }
59
64
  if (translationSubCommand === 'push') {
60
- const deleteUnusedKeys = commandArguments.includes(
61
- '--delete-unused-keys',
62
- );
63
-
64
65
  ensureBranch();
65
66
  push({ branch, deleteUnusedKeys }, vocabConfig);
66
67
  }
@@ -75,5 +76,8 @@ const ensureBranch = () => {
75
76
 
76
77
  process.exit(1);
77
78
  }
78
- console.log(chalk.cyan('Translations complete'));
79
+
80
+ if (!watch) {
81
+ console.log(chalk.cyan('Translations complete'));
82
+ }
79
83
  })();
package/sku-types.d.ts CHANGED
@@ -1,4 +1,31 @@
1
1
  import type { ComponentType, ReactNode } from 'react';
2
+ import type { Express, RequestHandler } from 'express';
3
+ import type { ChunkExtractor } from '@loadable/server';
4
+
5
+ export interface RenderCallbackParams {
6
+ SkuProvider: ({ children }: { children: ReactNode }) => JSX.Element;
7
+ addLanguageChunk: (language: string) => void;
8
+ getBodyTags: () => string;
9
+ getHeadTags: (options?: {
10
+ excludeJs?: boolean;
11
+ excludeCss?: boolean;
12
+ }) => string;
13
+ flushHeadTags: (options?: {
14
+ excludeJs?: boolean;
15
+ excludeCss?: boolean;
16
+ }) => string;
17
+ extractor: ChunkExtractor;
18
+ registerScript?: (script: string) => void;
19
+ }
20
+
21
+ export interface Server {
22
+ renderCallback: (
23
+ params: RenderCallbackParams,
24
+ ...requestHandlerParams: Parameters<RequestHandler>
25
+ ) => void;
26
+ onStart?: (app: Express) => void;
27
+ middleware?: RequestHandler | RequestHandler[];
28
+ }
2
29
 
3
30
  interface SharedRenderProps {
4
31
  routeName: string;
@@ -10,7 +10,7 @@ const { languages } = require('../context');
10
10
  let projectName = 'unknown';
11
11
  let braidVersion = 'unknown';
12
12
  try {
13
- const packageJson = requireFromCwd('package.json');
13
+ const packageJson = requireFromCwd('./package.json');
14
14
 
15
15
  if (packageJson.name) {
16
16
  projectName = packageJson.name;