@server/next 0.39.0 → 0.40.1

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/index.d.ts CHANGED
@@ -143,7 +143,7 @@ type KVStore = {
143
143
  keys: () => Promise<string[]>;
144
144
  };
145
145
  type Provider = "email" | "github" | "google" | "microsoft" | "discord" | "facebook" | "apple";
146
- type Strategy = "cookie" | "jwt" | "token" | "key";
146
+ type Strategy = "cookie" | "jwt" | "token";
147
147
  type AuthSession = {
148
148
  id: string;
149
149
  provider: Provider;
@@ -156,22 +156,30 @@ type AuthUser<T = Record<string, any>> = T & {
156
156
  strategy: Strategy;
157
157
  email: string;
158
158
  };
159
- type AuthOption = `${Strategy}:${Provider}` | "key" | {
159
+ type ProfileUser = {
160
+ id: string | number;
161
+ email: string;
162
+ } & Record<string, any>;
163
+ type AuthOption = `${Strategy}:${Provider}` | {
160
164
  strategy: Strategy;
161
165
  providers?: Provider | Provider[];
162
- key?: string;
163
166
  session?: StoreSource;
164
167
  store?: StoreSource;
165
168
  redirect?: string;
166
- cleanUser?: <T = AuthUser>(user: T) => T | Promise<T>;
169
+ onProfile?: (raw: any, provider: Provider) => ProfileUser | Promise<ProfileUser>;
170
+ onLogin?: (loginUser: AuthUser, existingUser: AuthUser | null, ctx: Context) => ProfileUser | Promise<ProfileUser>;
171
+ onUser?: <T = AuthUser>(user: T, ctx: Context) => T | Promise<T>;
172
+ onLogout?: (ctx: Context) => unknown;
167
173
  };
168
174
  type AuthSettings = {
169
175
  providers: Provider[];
170
176
  strategy: Strategy;
171
177
  store: KVStore;
172
178
  session: KVStore;
173
- key?: string;
174
- cleanUser: <T = AuthUser>(user: T) => T | Promise<T>;
179
+ onProfile?: (raw: any, provider: Provider) => ProfileUser | Promise<ProfileUser>;
180
+ onLogin?: (loginUser: AuthUser, existingUser: AuthUser | null, ctx: Context) => ProfileUser | Promise<ProfileUser>;
181
+ onUser: <T = AuthUser>(user: T, ctx: Context) => T | Promise<T>;
182
+ onLogout?: (ctx: Context) => unknown;
175
183
  redirect: string;
176
184
  };
177
185
  type LogLevel = "info";
@@ -501,4 +509,4 @@ declare class Server<O extends ServerConfig = {}> extends Router<O> {
501
509
  }
502
510
  declare function server<Session extends Record<string, any> = {}, User extends Record<string, any> = {}>(options?: Options): Server<ServerConfig<Session, User>>;
503
511
 
504
- export { type AuthOption, type AuthSession, type AuthSettings, type AuthUser, type BasicValue, type Body, type BodyMode, type BodyOption, type Bucket, type BucketFile, type BunEnv, type CacheOption, type Context, type Cookie, type CorsSettings, type ExtractPathParams, type FileInfo, type InferParamType, type InlineReply, type KVStore, type LogLevel, type Logger, type Method, type Middleware, type Options, type ParamTypeMap, type ParamsToObject, type PathToParams, type Platform, type Provider, type Route, type RouteOptions, type RouterMethod, type SecurityOptions, type SecuritySettings, type SerializableValue, Server, type ServerConfig, TypedServerError as ServerError, type Settings, type StoreSource, type Strategy, type Time, type UploadOptions, type UploadedFile, cache, cookies, server as default, download, file, headers, json, redirect, router, send, status, type };
512
+ export { type AuthOption, type AuthSession, type AuthSettings, type AuthUser, type BasicValue, type Body, type BodyMode, type BodyOption, type Bucket, type BucketFile, type BunEnv, type CacheOption, type Context, type Cookie, type CorsSettings, type ExtractPathParams, type FileInfo, type InferParamType, type InlineReply, type KVStore, type LogLevel, type Logger, type Method, type Middleware, type Options, type ParamTypeMap, type ParamsToObject, type PathToParams, type Platform, type ProfileUser, type Provider, type Route, type RouteOptions, type RouterMethod, type SecurityOptions, type SecuritySettings, type SerializableValue, Server, type ServerConfig, TypedServerError as ServerError, type Settings, type StoreSource, type Strategy, type Time, type UploadOptions, type UploadedFile, cache, cookies, server as default, download, file, headers, json, redirect, router, send, status, type };
package/index.js CHANGED
@@ -68,6 +68,10 @@ ServerError_default.extend({
68
68
  status: 401,
69
69
  message: "Credentials do not correspond to a user"
70
70
  },
71
+ AUTH_INVALID_USER: {
72
+ status: 500,
73
+ message: "{callback} must return a user with an 'id' and an 'email'"
74
+ },
71
75
  LOGIN_NO_EMAIL: "The email is required to log in",
72
76
  LOGIN_INVALID_EMAIL: "The email you wrote is not correct",
73
77
  LOGIN_NO_PASSWORD: "The email is required to log in",
@@ -912,6 +916,13 @@ var json = (...args) => r().json(...args);
912
916
  var file = (...args) => r().file(...args);
913
917
  var redirect = (...args) => r().redirect(...args);
914
918
 
919
+ // src/auth/assertUser.ts
920
+ function assertUser(user, callback3) {
921
+ if (!user || typeof user !== "object" || user.id == null || !user.email) {
922
+ throw ServerError_default.AUTH_INVALID_USER({ callback: callback3 });
923
+ }
924
+ }
925
+
915
926
  // src/helpers/jwt.ts
916
927
  var enc = new TextEncoder();
917
928
  var dec = new TextDecoder();
@@ -982,7 +993,7 @@ async function verifyJwt(token, secret) {
982
993
  // src/auth/finishLogin.ts
983
994
  async function finishLogin(ctx, input) {
984
995
  const settings = ctx.options.auth;
985
- const { strategy, cleanUser } = settings;
996
+ const { strategy, onLogin, onUser } = settings;
986
997
  const key = String(input.key);
987
998
  const auth2 = {
988
999
  id: createId(),
@@ -992,22 +1003,28 @@ async function finishLogin(ctx, input) {
992
1003
  email: input.email,
993
1004
  time: (/* @__PURE__ */ new Date()).toISOString().replace(/\.[0-9]*/, "")
994
1005
  };
995
- let user = input.user;
996
- if (input.store !== false) {
997
- const existing = await settings.store.get(key);
998
- user = { ...existing ?? {}, ...input.user };
999
- }
1000
- user = await cleanUser(user);
1001
- if (input.store !== false) await settings.store.set(key, user);
1006
+ const loginUser = {
1007
+ ...input.user,
1008
+ provider: input.provider,
1009
+ strategy
1010
+ };
1011
+ const existingUser = await settings.store.get(key) ?? null;
1012
+ const user = onLogin ? await onLogin(loginUser, existingUser, ctx) : { ...existingUser ?? {}, ...loginUser };
1013
+ assertUser(user, "onLogin");
1014
+ await settings.store.set(key, user);
1002
1015
  if (!strategy.includes("jwt")) {
1003
1016
  await settings.session.set(auth2.id, auth2, { expires: "1w" });
1004
1017
  }
1005
1018
  if (strategy.includes("jwt")) {
1006
1019
  const token = await signJwt(auth2, ctx.options.secret, 7 * 24 * 60 * 60);
1007
- return status(201).json({ ...user, token });
1020
+ const exposed = await onUser(user, ctx);
1021
+ assertUser(exposed, "onUser");
1022
+ return status(201).json({ ...exposed, token });
1008
1023
  }
1009
1024
  if (strategy.includes("token")) {
1010
- return status(201).json({ ...user, token: auth2.id });
1025
+ const exposed = await onUser(user, ctx);
1026
+ assertUser(exposed, "onUser");
1027
+ return status(201).json({ ...exposed, token: auth2.id });
1011
1028
  }
1012
1029
  if (strategy.includes("cookie")) {
1013
1030
  return cookies("authentication", {
@@ -1018,7 +1035,6 @@ async function finishLogin(ctx, input) {
1018
1035
  sameSite: "Lax"
1019
1036
  }).redirect(settings.redirect);
1020
1037
  }
1021
- if (strategy.includes("key")) throw new Error("Key auth not supported yet");
1022
1038
  throw new Error("Unknown auth type");
1023
1039
  }
1024
1040
 
@@ -1128,11 +1144,15 @@ var callback = async (ctx) => {
1128
1144
  const parsed = JSON.parse(body.user).name;
1129
1145
  if (parsed) name = `${parsed.firstName} ${parsed.lastName}`.trim();
1130
1146
  }
1147
+ const raw = { ...claims, name };
1148
+ const { onProfile } = ctx.options.auth;
1149
+ const profile = onProfile ? await onProfile(raw, "apple") : { id: raw.sub, name: raw.name, email: raw.email };
1150
+ assertUser(profile, "onProfile");
1131
1151
  const res = await finishLogin(ctx, {
1132
1152
  provider: "apple",
1133
- key: claims.sub,
1134
- email: claims.email,
1135
- user: { id: claims.sub, name, email: claims.email }
1153
+ key: profile.id,
1154
+ email: profile.email,
1155
+ user: profile
1136
1156
  });
1137
1157
  res.headers.append("set-cookie", clearState());
1138
1158
  return res;
@@ -1181,17 +1201,15 @@ function oauthProvider(config2) {
1181
1201
  }
1182
1202
  });
1183
1203
  if (!profileRes.ok) throw new Error(`${config2.name}: profile fetch failed`);
1184
- const profile = config2.profile(await profileRes.json());
1204
+ const raw = await profileRes.json();
1205
+ const { onProfile } = ctx.options.auth;
1206
+ const profile = onProfile ? await onProfile(raw, config2.name) : config2.profile(raw);
1207
+ assertUser(profile, "onProfile");
1185
1208
  const res = await finishLogin(ctx, {
1186
1209
  provider: config2.name,
1187
1210
  key: profile.id,
1188
1211
  email: profile.email,
1189
- user: {
1190
- id: profile.id,
1191
- name: profile.name,
1192
- email: profile.email,
1193
- picture: profile.picture
1194
- }
1212
+ user: profile
1195
1213
  });
1196
1214
  res.headers.append("set-cookie", clearState());
1197
1215
  return res;
@@ -1237,8 +1255,7 @@ async function emailLogin(ctx) {
1237
1255
  provider: "email",
1238
1256
  key: user.email,
1239
1257
  email: user.email,
1240
- user,
1241
- store: false
1258
+ user
1242
1259
  });
1243
1260
  }
1244
1261
  async function emailRegister(ctx) {
@@ -1259,13 +1276,11 @@ async function emailRegister(ctx) {
1259
1276
  time,
1260
1277
  ...data
1261
1278
  };
1262
- await store.set(email, user);
1263
1279
  return finishLogin(ctx, {
1264
1280
  provider: "email",
1265
1281
  key: email,
1266
1282
  email,
1267
- user,
1268
- store: false
1283
+ user
1269
1284
  });
1270
1285
  }
1271
1286
  async function emailResetPassword() {
@@ -1345,21 +1360,25 @@ var getUserProfile = async (code) => {
1345
1360
  const email = emails.sort((a) => a.primary ? -1 : 1)[0]?.email;
1346
1361
  return { ...profile, email };
1347
1362
  };
1363
+ var defaultProfile = (raw) => ({
1364
+ id: raw.id,
1365
+ name: raw.name,
1366
+ email: raw.email,
1367
+ picture: raw.avatar_url,
1368
+ location: raw.location,
1369
+ created: raw.created_at
1370
+ });
1348
1371
  var callback2 = async (ctx) => {
1349
1372
  checkState(ctx, ctx.url.query.state);
1350
- const profile = await getUserProfile(ctx.url.query.code);
1373
+ const raw = await getUserProfile(ctx.url.query.code);
1374
+ const { onProfile } = ctx.options.auth;
1375
+ const profile = onProfile ? await onProfile(raw, "github") : defaultProfile(raw);
1376
+ assertUser(profile, "onProfile");
1351
1377
  const res = await finishLogin(ctx, {
1352
1378
  provider: "github",
1353
1379
  key: profile.id,
1354
1380
  email: profile.email,
1355
- user: {
1356
- id: profile.id,
1357
- name: profile.name,
1358
- email: profile.email,
1359
- picture: profile.avatar_url,
1360
- location: profile.location,
1361
- created: profile.created_at
1362
- }
1381
+ user: profile
1363
1382
  });
1364
1383
  res.headers.append("set-cookie", clearState());
1365
1384
  return res;
@@ -1409,7 +1428,7 @@ var providers_default = {
1409
1428
 
1410
1429
  // src/auth/parseAuthOptions.ts
1411
1430
  var defaultRedirect = "/user";
1412
- function defaultCleanUser(fullUser) {
1431
+ function defaultOnUser(fullUser) {
1413
1432
  const { password: _password, ...user } = fullUser;
1414
1433
  return user;
1415
1434
  }
@@ -1424,19 +1443,6 @@ function parseAuthOptions(auth2, all) {
1424
1443
  throw new Error("Auth options needs a strategy");
1425
1444
  }
1426
1445
  const strategy = auth2.strategy;
1427
- if (strategy === "key") {
1428
- const key = auth2.key || env.AUTH_KEY;
1429
- if (!key) {
1430
- throw new Error("`key` auth needs the AUTH_KEY env var (or auth.key)");
1431
- }
1432
- return {
1433
- strategy,
1434
- providers: [],
1435
- key,
1436
- redirect: auth2.redirect || defaultRedirect,
1437
- cleanUser: auth2.cleanUser || defaultCleanUser
1438
- };
1439
- }
1440
1446
  const list = Array.isArray(auth2.providers) ? auth2.providers : auth2.providers ? [auth2.providers] : [];
1441
1447
  if (!list.length) {
1442
1448
  throw new Error("Auth options needs a provider");
@@ -1448,7 +1454,8 @@ function parseAuthOptions(auth2, all) {
1448
1454
  );
1449
1455
  }
1450
1456
  const redirect2 = auth2.redirect || defaultRedirect;
1451
- const cleanUser = auth2.cleanUser || defaultCleanUser;
1457
+ const { onProfile, onLogin, onLogout } = auth2;
1458
+ const onUser = auth2.onUser || defaultOnUser;
1452
1459
  if (!auth2.store && !all.store) {
1453
1460
  throw new Error("Need a userStore store for Auth");
1454
1461
  }
@@ -1462,7 +1469,10 @@ function parseAuthOptions(auth2, all) {
1462
1469
  strategy,
1463
1470
  providers: list,
1464
1471
  redirect: redirect2,
1465
- cleanUser,
1472
+ onProfile,
1473
+ onLogin,
1474
+ onUser,
1475
+ onLogout,
1466
1476
  store: authStore,
1467
1477
  session: sessionStore
1468
1478
  };
@@ -2236,14 +2246,6 @@ async function verify(password, hash3) {
2236
2246
  });
2237
2247
  }
2238
2248
 
2239
- // src/helpers/safeEqual.ts
2240
- function safeEqual(a, b) {
2241
- if (a.length !== b.length) return false;
2242
- let diff = 0;
2243
- for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
2244
- return diff === 0;
2245
- }
2246
-
2247
2249
  // src/auth/findSessionId.ts
2248
2250
  var validateToken = (authorization) => {
2249
2251
  const [type2, id] = authorization.trim().split(" ");
@@ -2276,19 +2278,6 @@ function findSessionId(ctx) {
2276
2278
  }
2277
2279
 
2278
2280
  // src/auth/getUser.ts
2279
- function getKeyUser(ctx) {
2280
- const expected = ctx.options.auth.key;
2281
- const header = ctx.headers.authorization;
2282
- if (!header) return;
2283
- const [type2, provided] = header.trim().split(" ");
2284
- if (type2?.toLowerCase() !== "bearer" || !provided) {
2285
- throw ServerError_default.AUTH_INVALID_HEADER({ type: type2 });
2286
- }
2287
- if (!expected || !safeEqual(provided, expected)) {
2288
- throw ServerError_default.AUTH_INVALID_TOKEN();
2289
- }
2290
- return { id: "key", strategy: "key", provider: "key" };
2291
- }
2292
2281
  async function getAuthSession(ctx) {
2293
2282
  const strategy = ctx.options.auth.strategy;
2294
2283
  if (strategy.includes("jwt")) {
@@ -2309,7 +2298,6 @@ async function getAuthSession(ctx) {
2309
2298
  async function getUser(ctx) {
2310
2299
  if (!ctx.options.auth) return;
2311
2300
  const options = ctx.options.auth;
2312
- if (options.strategy === "key") return getKeyUser(ctx);
2313
2301
  const auth2 = await getAuthSession(ctx);
2314
2302
  if (!auth2) return;
2315
2303
  if (options.strategy !== auth2.strategy) {
@@ -2328,7 +2316,9 @@ async function getUser(ctx) {
2328
2316
  if (!user) throw ServerError_default.AUTH_NO_USER();
2329
2317
  user.strategy = auth2.strategy;
2330
2318
  user.provider = auth2.provider;
2331
- return ctx.options.auth.cleanUser(user);
2319
+ const exposed = await ctx.options.auth.onUser(user, ctx);
2320
+ assertUser(exposed, "onUser");
2321
+ return exposed;
2332
2322
  }
2333
2323
 
2334
2324
  // src/auth/logout.ts
@@ -2338,15 +2328,13 @@ async function logout(ctx) {
2338
2328
  if (!strategy.includes("jwt")) {
2339
2329
  await ctx.options.auth.session.del(findSessionId(ctx));
2340
2330
  }
2331
+ if (ctx.options.auth.onLogout) await ctx.options.auth.onLogout(ctx);
2341
2332
  if (strategy.includes("token") || strategy.includes("jwt")) {
2342
2333
  return { token: null };
2343
2334
  }
2344
2335
  if (strategy.includes("cookie")) {
2345
2336
  return cookies({ authentication: null }).redirect("/");
2346
2337
  }
2347
- if (strategy.includes("key")) {
2348
- throw new Error("Key auth not supported yet");
2349
- }
2350
2338
  throw new Error("Unknown auth type");
2351
2339
  }
2352
2340
 
@@ -2362,7 +2350,6 @@ function auth(app) {
2362
2350
  app.use(async function middle(ctx) {
2363
2351
  ctx.user = await getUser(ctx);
2364
2352
  });
2365
- if (app.settings.auth.strategy === "key") return;
2366
2353
  app.post("/auth/logout", logout);
2367
2354
  const enabled = app.settings.auth.providers;
2368
2355
  for (const name of oauth2) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@server/next",
3
- "version": "0.39.0",
3
+ "version": "0.40.1",
4
4
  "description": "A fully-fledged web server with routing, file uploads, sessions, static files, schema validation, websockets, testing, etc.",
5
5
  "homepage": "https://server-js.com/",
6
6
  "repository": "github:franciscop/server-next",
@@ -17,8 +17,7 @@
17
17
  "start": "bun test --watch",
18
18
  "lint": "npx tsc --noEmit && npx @biomejs/biome lint ./src --skip=lint/suspicious/noExplicitAny --skip=lint/style/noParameterAssign --skip=lint/suspicious/noConfusingVoidType --skip=lint/complexity/noBannedTypes",
19
19
  "test": "npm run test:bun && tsc --noEmit",
20
- "test:bun": "bun test",
21
- "test:jest": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
20
+ "test:bun": "bun test"
22
21
  },
23
22
  "main": "index.js",
24
23
  "type": "module",
@@ -65,21 +64,15 @@
65
64
  "tutorials": "docs/tutorials"
66
65
  },
67
66
  "dependencies": {
68
- "bucket": "^0.6.0",
69
- "polystore": "^0.23.2"
67
+ "bucket": "^0.7.1",
68
+ "polystore": "^0.24.0"
70
69
  },
71
70
  "devDependencies": {
72
71
  "@types/bun": "^1.3.0",
73
- "@types/jest": "^30.0.0",
74
72
  "@types/node": "^24.10.0",
75
73
  "bun": "^1.3.13",
76
74
  "check-dts": "^0.8.2",
77
- "jest": "^29.7.0",
78
75
  "tsup": "^8.5.1",
79
76
  "typescript": "^6.0.2"
80
- },
81
- "jest": {
82
- "testEnvironment": "jest-environment-node",
83
- "transform": {}
84
77
  }
85
78
  }
@@ -38,14 +38,30 @@ const encode = (str = "") => {
38
38
  const isValidChild = (child) =>
39
39
  child != null && child !== false && child !== true;
40
40
 
41
- const minifyCss = (str) =>
42
- str
41
+ // A `</style>` inside the CSS would close the element early and let whatever
42
+ // follows run as HTML. The content is never encoded, so this runs on every
43
+ // <style>, minified or not.
44
+ const escapeCss = (str) => str.replace(/<\/style>/gi, "<\\/style>");
45
+
46
+ const minifyCss = (str) => {
47
+ // Quoted values are content, not formatting: the spaces and delimiters in
48
+ // `content: ", "` have to survive. They're set aside while the rest is
49
+ // squeezed, then put back. Comments go first and unconditionally, so one
50
+ // written inside a string is stripped along with the rest.
51
+ const quoted = [];
52
+ return str
43
53
  .replace(/\/\*[\s\S]*?\*\//g, "")
54
+ .replace(
55
+ /"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g,
56
+ (match) => `\0${quoted.push(match) - 1}\0`,
57
+ )
44
58
  .replace(/\s+/g, " ")
45
- .replace(/\s*([{}:;,>~+])\s*/g, "$1")
59
+ // `+` is left out on purpose for `calc(100% + 10px)`
60
+ .replace(/\s*([{}:;,>~])\s*/g, "$1")
46
61
  .replace(/;}/g, "}")
47
- .replace(/<\/style>/gi, "<\\/style>")
62
+ .replace(/\0(\d+)\0/g, (_, i) => quoted[i])
48
63
  .trim();
64
+ };
49
65
 
50
66
  // React element detection (safe for custom objects too)
51
67
  const isReactElement = (val) =>
@@ -115,9 +131,9 @@ const jsx = (tag, { children, ...props } = {}) => {
115
131
  children = raw(content);
116
132
  }
117
133
 
118
- // style: minified raw content
134
+ // style: raw content, minified only when asked for with <style minify>
119
135
  if (tag === "style" && typeof children === "string") {
120
- children = raw(minifyCss(children));
136
+ children = raw(escapeCss(props?.minify ? minifyCss(children) : children));
121
137
  }
122
138
 
123
139
  // dangerouslySetInnerHTML
@@ -139,6 +155,8 @@ const jsx = (tag, { children, ...props } = {}) => {
139
155
  // attributes
140
156
  let attrStr = Object.entries(props || {})
141
157
  .filter(([k]) => k !== "dangerouslySetInnerHTML")
158
+ // `minify` drives <style>, it isn't an HTML attribute
159
+ .filter(([k]) => !(tag === "style" && k === "minify"))
142
160
  .filter(([k, v]) => !/on[A-Z]/.test(k) && typeof v !== "function")
143
161
  .filter(([, v]) => v !== false)
144
162
  .map(([k, v]) => {
@@ -299,52 +299,115 @@ describe("script tag", () => {
299
299
  });
300
300
 
301
301
  describe("style tag", () => {
302
- it("renders style content", () => {
302
+ it("renders the CSS as written", () => {
303
303
  expect(<style>{"body { color: red; }"}</style>).toRender(
304
- "<style>body{color:red}</style>",
304
+ "<style>body { color: red; }</style>",
305
305
  );
306
306
  });
307
307
 
308
- it("minifies whitespace in style content", () => {
309
- expect(
310
- <style>{"body {\n color: red;\n margin: 0;\n}"}</style>,
311
- ).toRender("<style>body{color:red;margin:0}</style>");
312
- });
313
-
314
- it("strips CSS comments", () => {
315
- expect(
316
- <style>{"/* reset */ body { margin: 0; } /* end */"}</style>,
317
- ).toRender("<style>body{margin:0}</style>");
318
- });
319
-
320
- it("preserves child combinator in selectors", () => {
321
- expect(<style>{":not(pre) > code { background: none; }"}</style>).toRender(
322
- "<style>:not(pre)>code{background:none}</style>",
308
+ it("keeps comments and whitespace without `minify`", () => {
309
+ expect(<style>{"/* reset */ body {\n margin: 0;\n}"}</style>).toRender(
310
+ "<style>/* reset */ body {\n margin: 0;\n}</style>",
323
311
  );
324
312
  });
325
313
 
326
- it("preserves sibling combinators in selectors", () => {
327
- expect(
328
- <style>{"h2 + p { margin: 0; } h2 ~ p { color: red; }"}</style>,
329
- ).toRender("<style>h2+p{margin:0}h2~p{color:red}</style>");
314
+ it("sends the content raw, like <script>", () => {
315
+ // Encoding it would break the CSS, so user input must never reach here
316
+ expect(<style>{"a::after { content: '>'; }"}</style>).toRender(
317
+ "<style>a::after { content: '>'; }</style>",
318
+ );
330
319
  });
331
320
 
332
- it("removes spaces around braces, colons, and semicolons", () => {
333
- expect(<style>{"a { color : red ; font-size : 1em ; }"}</style>).toRender(
334
- "<style>a{color:red;font-size:1em}</style>",
321
+ it("escapes </style> either way, so it can't close the tag early", () => {
322
+ expect(<style>{"a { content: '</style>'; }"}</style>).toRender(
323
+ "<style>a { content: '<\\/style>'; }</style>",
324
+ );
325
+ expect(<style minify>{"a { content: '</style>'; }"}</style>).toRender(
326
+ "<style>a{content:'<\\/style>'}</style>",
335
327
  );
336
328
  });
337
329
 
338
- it("removes trailing semicolon before closing brace", () => {
339
- expect(<style>{"p { margin: 0; padding: 0; }"}</style>).toRender(
340
- "<style>p{margin:0;padding:0}</style>",
330
+ it("never renders `minify` as an attribute", () => {
331
+ expect(<style minify>{"a { color: red; }"}</style>).toRender(
332
+ "<style>a{color:red}</style>",
341
333
  );
342
334
  });
343
335
 
344
- it("does not break </style> injection", () => {
345
- expect(<style>{"a { content: '</style>'; }"}</style>).toRender(
346
- "<style>a{content:'<\\/style>'}</style>",
347
- );
336
+ describe("with `minify`", () => {
337
+ it("minifies whitespace in style content", () => {
338
+ expect(
339
+ <style minify>{"body {\n color: red;\n margin: 0;\n}"}</style>,
340
+ ).toRender("<style>body{color:red;margin:0}</style>");
341
+ });
342
+
343
+ it("strips CSS comments", () => {
344
+ expect(
345
+ <style minify>{"/* reset */ body { margin: 0; } /* end */"}</style>,
346
+ ).toRender("<style>body{margin:0}</style>");
347
+ });
348
+
349
+ it("preserves child combinator in selectors", () => {
350
+ expect(
351
+ <style minify>{":not(pre) > code { background: none; }"}</style>,
352
+ ).toRender("<style>:not(pre)>code{background:none}</style>");
353
+ });
354
+
355
+ it("preserves sibling combinators in selectors", () => {
356
+ // `+` keeps a single space, since removing it breaks calc() below
357
+ expect(
358
+ <style minify>
359
+ {"h2 + p { margin: 0; } h2 ~ p { color: red; }"}
360
+ </style>,
361
+ ).toRender("<style>h2 + p{margin:0}h2~p{color:red}</style>");
362
+ });
363
+
364
+ it("keeps the spaces calc() needs around + and -", () => {
365
+ // `calc(100%+10px)` is invalid CSS: the operators need their whitespace
366
+ expect(<style minify>{"a { top: calc(100% + 10px); }"}</style>).toRender(
367
+ "<style>a{top:calc(100% + 10px)}</style>",
368
+ );
369
+ expect(<style minify>{"a { top: calc(100% - 10px); }"}</style>).toRender(
370
+ "<style>a{top:calc(100% - 10px)}</style>",
371
+ );
372
+ });
373
+
374
+ it("removes spaces around braces, colons, and semicolons", () => {
375
+ expect(
376
+ <style minify>{"a { color : red ; font-size : 1em ; }"}</style>,
377
+ ).toRender("<style>a{color:red;font-size:1em}</style>");
378
+ });
379
+
380
+ it("removes trailing semicolon before closing brace", () => {
381
+ expect(<style minify>{"p { margin: 0; padding: 0; }"}</style>).toRender(
382
+ "<style>p{margin:0;padding:0}</style>",
383
+ );
384
+ });
385
+
386
+ it("leaves quoted values alone", () => {
387
+ // The spaces and delimiters in here are content, not formatting
388
+ expect(<style minify>{'a::after { content: ", "; }'}</style>).toRender(
389
+ '<style>a::after{content:", "}</style>',
390
+ );
391
+ expect(
392
+ <style minify>{'a::after { content: "a; b: c"; }'}</style>,
393
+ ).toRender('<style>a::after{content:"a; b: c"}</style>');
394
+ expect(
395
+ <style minify>{"a::after { content: 'x y'; }"}</style>,
396
+ ).toRender("<style>a::after{content:'x y'}</style>");
397
+ });
398
+
399
+ it("handles an escaped quote inside a value", () => {
400
+ expect(
401
+ <style minify>{'a::after { content: "say \\" hi"; }'}</style>,
402
+ ).toRender('<style>a::after{content:"say \\" hi"}</style>');
403
+ });
404
+
405
+ it("still strips a comment written inside a value", () => {
406
+ // Comments are stripped first, unconditionally
407
+ expect(
408
+ <style minify>{'a::after { content: "/* hi */"; }'}</style>,
409
+ ).toRender('<style>a::after{content:""}</style>');
410
+ });
348
411
  });
349
412
  });
350
413