@djangocfg/nextjs 2.1.627 → 2.1.628
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/dist/app-icon/index.d.mts +11 -0
- package/dist/app-icon/index.mjs +66 -0
- package/dist/app-icon/index.mjs.map +1 -0
- package/dist/app-icon/next.d.mts +51 -0
- package/dist/app-icon/next.mjs +87 -0
- package/dist/app-icon/next.mjs.map +1 -0
- package/dist/element-DhDVWCha.d.mts +39 -0
- package/package.json +21 -11
- package/src/app-icon/element.test.ts +35 -0
- package/src/app-icon/element.ts +91 -0
- package/src/app-icon/index.ts +3 -0
- package/src/app-icon/next.ts +47 -0
- package/src/app-icon/paths.ts +6 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { A as AppIconGlyph, a as AppIconOptions, b as AppIconShape, I as IconNode, c as appIconElement, d as appIconGradient, o as oklchToHex } from '../element-DhDVWCha.mjs';
|
|
2
|
+
import 'react';
|
|
3
|
+
|
|
4
|
+
/** The manifest's `icons` for an app that serves `createAppIcon` at `app/icon.tsx`. Pass it to `createManifest({ icons })`. */
|
|
5
|
+
declare const appIconPaths: {
|
|
6
|
+
readonly logo192: "/icon/192";
|
|
7
|
+
readonly logo384: "/icon/384";
|
|
8
|
+
readonly logo512: "/icon/512";
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export { appIconPaths };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// src/app-icon/element.ts
|
|
2
|
+
import { createElement } from "react";
|
|
3
|
+
function oklchToHex(l, c, h) {
|
|
4
|
+
const a = c * Math.cos(h * Math.PI / 180);
|
|
5
|
+
const b = c * Math.sin(h * Math.PI / 180);
|
|
6
|
+
const l_ = (l + 0.3963377774 * a + 0.2158037573 * b) ** 3;
|
|
7
|
+
const m_ = (l - 0.1055613458 * a - 0.0638541728 * b) ** 3;
|
|
8
|
+
const s_ = (l - 0.0894841775 * a - 1.291485548 * b) ** 3;
|
|
9
|
+
const lin = [
|
|
10
|
+
4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_,
|
|
11
|
+
-1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_,
|
|
12
|
+
-0.0041960863 * l_ - 0.7034186147 * m_ + 1.707614701 * s_
|
|
13
|
+
];
|
|
14
|
+
const hex = lin.map((v) => {
|
|
15
|
+
const g = v <= 31308e-7 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055;
|
|
16
|
+
return Math.round(Math.min(1, Math.max(0, g)) * 255).toString(16).padStart(2, "0");
|
|
17
|
+
});
|
|
18
|
+
return `#${hex.join("")}`;
|
|
19
|
+
}
|
|
20
|
+
function appIconGradient(hue) {
|
|
21
|
+
return `linear-gradient(150deg, ${oklchToHex(0.66, 0.17, hue)}, ${oklchToHex(0.47, 0.18, hue + 22)})`;
|
|
22
|
+
}
|
|
23
|
+
function nodesOf(glyph) {
|
|
24
|
+
if (Array.isArray(glyph)) return glyph;
|
|
25
|
+
const node = glyph.__iconData?.node;
|
|
26
|
+
if (!node) throw new Error("app-icon: glyph is not a lucide icon module (expected `import * as x from 'lucide-react/icons/<name>'`)");
|
|
27
|
+
return node;
|
|
28
|
+
}
|
|
29
|
+
function appIconElement({ glyph, hue, size, shape = "rounded" }) {
|
|
30
|
+
const square = shape === "square";
|
|
31
|
+
const side = Math.round(size * (square ? 0.46 : 0.58));
|
|
32
|
+
const glyphSvg = createElement(
|
|
33
|
+
"svg",
|
|
34
|
+
{ width: side, height: side, viewBox: "0 0 24 24", fill: "none", stroke: "#ffffff", strokeWidth: 2.1, strokeLinecap: "round", strokeLinejoin: "round" },
|
|
35
|
+
...nodesOf(glyph).map(([tag, { key, ...attrs }]) => createElement(tag, { key, ...attrs }))
|
|
36
|
+
);
|
|
37
|
+
return createElement(
|
|
38
|
+
"div",
|
|
39
|
+
{
|
|
40
|
+
style: {
|
|
41
|
+
width: size,
|
|
42
|
+
height: size,
|
|
43
|
+
display: "flex",
|
|
44
|
+
alignItems: "center",
|
|
45
|
+
justifyContent: "center",
|
|
46
|
+
backgroundImage: appIconGradient(hue),
|
|
47
|
+
borderRadius: square ? 0 : Math.round(size * 0.225)
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
glyphSvg
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// src/app-icon/paths.ts
|
|
55
|
+
var appIconPaths = {
|
|
56
|
+
logo192: "/icon/192",
|
|
57
|
+
logo384: "/icon/384",
|
|
58
|
+
logo512: "/icon/512"
|
|
59
|
+
};
|
|
60
|
+
export {
|
|
61
|
+
appIconElement,
|
|
62
|
+
appIconGradient,
|
|
63
|
+
appIconPaths,
|
|
64
|
+
oklchToHex
|
|
65
|
+
};
|
|
66
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/app-icon/element.ts","../../src/app-icon/paths.ts"],"sourcesContent":["/**\n * App icon: ONE definition of the picture an app shows in a tab, on a home screen and as a PWA icon.\n *\n * House style: a full-bleed tile in the product's hue (a two-stop gradient), a white lucide glyph in the inner ~46-58%,\n * and NO border near the edge. The OS cuts its own corner (iOS ~22%, Android a mask); anything drawn at the edge never\n * matches that curve.\n *\n * Pure: plain host elements only (`next/og` renders nothing else) and no `next/og` import, so a client bundle can use\n * the same gradient for an in-app tile. The route factory lives in `./next`.\n */\n\nimport { createElement, type ReactElement } from 'react';\n\n/** What lucide stores for an icon: `[tag, attrs][]`; `attrs.key` is React's key. */\nexport type IconNode = [tag: string, attrs: Record<string, string>][];\n\n/**\n * A glyph is a lucide icon MODULE, `import * as workflow from 'lucide-react/icons/workflow'` (a lucide component is a\n * client component and `next/og` cannot render it, so its drawing data is read from the module), or the node list itself.\n */\nexport type AppIconGlyph = object | IconNode;\n\nexport type AppIconShape = 'rounded' | 'square';\n\nexport interface AppIconOptions {\n glyph: AppIconGlyph;\n /** OKLCH hue, 0-360. The switcher tile should use `appIconGradient(hue)` so both agree. */\n hue: number;\n /** Side in px. */\n size: number;\n /** `rounded`: transparent corners (browser tab). `square`: full-bleed for iOS and maskable PWA icons. @default 'rounded' */\n shape?: AppIconShape;\n}\n\n/** OKLCH to sRGB hex. `next/og` does not parse `oklch()`; CSS does, and the two must show the same colour. */\nexport function oklchToHex(l: number, c: number, h: number): string {\n const a = c * Math.cos((h * Math.PI) / 180);\n const b = c * Math.sin((h * Math.PI) / 180);\n const l_ = (l + 0.3963377774 * a + 0.2158037573 * b) ** 3;\n const m_ = (l - 0.1055613458 * a - 0.0638541728 * b) ** 3;\n const s_ = (l - 0.0894841775 * a - 1.291485548 * b) ** 3;\n const lin = [\n 4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_,\n -1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_,\n -0.0041960863 * l_ - 0.7034186147 * m_ + 1.707614701 * s_,\n ];\n const hex = lin.map((v) => {\n const g = v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055;\n return Math.round(Math.min(1, Math.max(0, g)) * 255).toString(16).padStart(2, '0');\n });\n return `#${hex.join('')}`;\n}\n\n/** The gradient behind a glyph, two hex stops from one hue. */\nexport function appIconGradient(hue: number): string {\n return `linear-gradient(150deg, ${oklchToHex(0.66, 0.17, hue)}, ${oklchToHex(0.47, 0.18, hue + 22)})`;\n}\n\nfunction nodesOf(glyph: AppIconGlyph): IconNode {\n if (Array.isArray(glyph)) return glyph as IconNode;\n const node = (glyph as { __iconData?: { node?: IconNode } }).__iconData?.node;\n if (!node) throw new Error(\"app-icon: glyph is not a lucide icon module (expected `import * as x from 'lucide-react/icons/<name>'`)\");\n return node;\n}\n\n/** The picture as plain JSX, for `new ImageResponse(...)`. */\nexport function appIconElement({ glyph, hue, size, shape = 'rounded' }: AppIconOptions): ReactElement {\n const square = shape === 'square';\n // A square icon is cropped by the OS: keep the glyph inside the maskable safe zone (the inner 60%).\n const side = Math.round(size * (square ? 0.46 : 0.58));\n const glyphSvg = createElement(\n 'svg',\n { width: side, height: side, viewBox: '0 0 24 24', fill: 'none', stroke: '#ffffff', strokeWidth: 2.1, strokeLinecap: 'round', strokeLinejoin: 'round' },\n ...nodesOf(glyph).map(([tag, { key, ...attrs }]) => createElement(tag, { key, ...attrs })),\n );\n return createElement(\n 'div',\n {\n style: {\n width: size,\n height: size,\n display: 'flex',\n alignItems: 'center',\n justifyContent: 'center',\n backgroundImage: appIconGradient(hue),\n borderRadius: square ? 0 : Math.round(size * 0.225),\n },\n },\n glyphSvg,\n );\n}\n","/** The manifest's `icons` for an app that serves `createAppIcon` at `app/icon.tsx`. Pass it to `createManifest({ icons })`. */\nexport const appIconPaths = {\n logo192: '/icon/192',\n logo384: '/icon/384',\n logo512: '/icon/512',\n} as const;\n"],"mappings":";AAWA,SAAS,qBAAwC;AAwB1C,SAAS,WAAW,GAAW,GAAW,GAAmB;AAClE,QAAM,IAAI,IAAI,KAAK,IAAK,IAAI,KAAK,KAAM,GAAG;AAC1C,QAAM,IAAI,IAAI,KAAK,IAAK,IAAI,KAAK,KAAM,GAAG;AAC1C,QAAM,MAAM,IAAI,eAAe,IAAI,eAAe,MAAM;AACxD,QAAM,MAAM,IAAI,eAAe,IAAI,eAAe,MAAM;AACxD,QAAM,MAAM,IAAI,eAAe,IAAI,cAAc,MAAM;AACvD,QAAM,MAAM;AAAA,IACV,eAAe,KAAK,eAAe,KAAK,eAAe;AAAA,IACvD,gBAAgB,KAAK,eAAe,KAAK,eAAe;AAAA,IACxD,gBAAgB,KAAK,eAAe,KAAK,cAAc;AAAA,EACzD;AACA,QAAM,MAAM,IAAI,IAAI,CAAC,MAAM;AACzB,UAAM,IAAI,KAAK,WAAY,QAAQ,IAAI,QAAQ,MAAM,IAAI,OAAO;AAChE,WAAO,KAAK,MAAM,KAAK,IAAI,GAAG,KAAK,IAAI,GAAG,CAAC,CAAC,IAAI,GAAG,EAAE,SAAS,EAAE,EAAE,SAAS,GAAG,GAAG;AAAA,EACnF,CAAC;AACD,SAAO,IAAI,IAAI,KAAK,EAAE,CAAC;AACzB;AAGO,SAAS,gBAAgB,KAAqB;AACnD,SAAO,2BAA2B,WAAW,MAAM,MAAM,GAAG,CAAC,KAAK,WAAW,MAAM,MAAM,MAAM,EAAE,CAAC;AACpG;AAEA,SAAS,QAAQ,OAA+B;AAC9C,MAAI,MAAM,QAAQ,KAAK,EAAG,QAAO;AACjC,QAAM,OAAQ,MAA+C,YAAY;AACzE,MAAI,CAAC,KAAM,OAAM,IAAI,MAAM,yGAAyG;AACpI,SAAO;AACT;AAGO,SAAS,eAAe,EAAE,OAAO,KAAK,MAAM,QAAQ,UAAU,GAAiC;AACpG,QAAM,SAAS,UAAU;AAEzB,QAAM,OAAO,KAAK,MAAM,QAAQ,SAAS,OAAO,KAAK;AACrD,QAAM,WAAW;AAAA,IACf;AAAA,IACA,EAAE,OAAO,MAAM,QAAQ,MAAM,SAAS,aAAa,MAAM,QAAQ,QAAQ,WAAW,aAAa,KAAK,eAAe,SAAS,gBAAgB,QAAQ;AAAA,IACtJ,GAAG,QAAQ,KAAK,EAAE,IAAI,CAAC,CAAC,KAAK,EAAE,KAAK,GAAG,MAAM,CAAC,MAAM,cAAc,KAAK,EAAE,KAAK,GAAG,MAAM,CAAC,CAAC;AAAA,EAC3F;AACA,SAAO;AAAA,IACL;AAAA,IACA;AAAA,MACE,OAAO;AAAA,QACL,OAAO;AAAA,QACP,QAAQ;AAAA,QACR,SAAS;AAAA,QACT,YAAY;AAAA,QACZ,gBAAgB;AAAA,QAChB,iBAAiB,gBAAgB,GAAG;AAAA,QACpC,cAAc,SAAS,IAAI,KAAK,MAAM,OAAO,KAAK;AAAA,MACpD;AAAA,IACF;AAAA,IACA;AAAA,EACF;AACF;;;ACzFO,IAAM,eAAe;AAAA,EAC1B,SAAS;AAAA,EACT,SAAS;AAAA,EACT,SAAS;AACX;","names":[]}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { ImageResponse } from 'next/og';
|
|
2
|
+
import { AppIconGlyph } from './index.mjs';
|
|
3
|
+
import 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Route handlers for Next's icon file conventions, from one glyph and one hue.
|
|
7
|
+
*
|
|
8
|
+
* // app/icon.tsx
|
|
9
|
+
* import * as workflow from 'lucide-react/icons/workflow';
|
|
10
|
+
* import { createAppIcon } from '@djangocfg/nextjs/app-icon/next';
|
|
11
|
+
* const icon = createAppIcon({ glyph: workflow, hue: 55 });
|
|
12
|
+
* export const generateImageMetadata = icon.generateImageMetadata;
|
|
13
|
+
* export default icon.icon;
|
|
14
|
+
*
|
|
15
|
+
* // app/apple-icon.tsx
|
|
16
|
+
* export const size = { width: 180, height: 180 };
|
|
17
|
+
* export const contentType = 'image/png';
|
|
18
|
+
* export default icon.appleIcon; // same createAppIcon call, or call it again
|
|
19
|
+
*
|
|
20
|
+
* Served at `/icon/tab` (64, rounded), `/icon/192`, `/icon/384`, `/icon/512` (square) and `/apple-icon` (180, square).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
declare const appIconSizes: {
|
|
24
|
+
readonly tab: 64;
|
|
25
|
+
readonly '192': 192;
|
|
26
|
+
readonly '384': 384;
|
|
27
|
+
readonly '512': 512;
|
|
28
|
+
};
|
|
29
|
+
declare const appleIconSize: {
|
|
30
|
+
readonly width: 180;
|
|
31
|
+
readonly height: 180;
|
|
32
|
+
};
|
|
33
|
+
declare function createAppIcon({ glyph, hue }: {
|
|
34
|
+
glyph: AppIconGlyph;
|
|
35
|
+
hue: number;
|
|
36
|
+
}): {
|
|
37
|
+
generateImageMetadata(): {
|
|
38
|
+
id: string;
|
|
39
|
+
size: {
|
|
40
|
+
width: 64 | 192 | 384 | 512;
|
|
41
|
+
height: 64 | 192 | 384 | 512;
|
|
42
|
+
};
|
|
43
|
+
contentType: string;
|
|
44
|
+
}[];
|
|
45
|
+
icon({ id }: {
|
|
46
|
+
id: Promise<string | number>;
|
|
47
|
+
}): Promise<ImageResponse>;
|
|
48
|
+
appleIcon(): ImageResponse;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export { appIconSizes, appleIconSize, createAppIcon };
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// src/app-icon/next.ts
|
|
2
|
+
import { ImageResponse } from "next/og";
|
|
3
|
+
|
|
4
|
+
// src/app-icon/element.ts
|
|
5
|
+
import { createElement } from "react";
|
|
6
|
+
function oklchToHex(l, c, h) {
|
|
7
|
+
const a = c * Math.cos(h * Math.PI / 180);
|
|
8
|
+
const b = c * Math.sin(h * Math.PI / 180);
|
|
9
|
+
const l_ = (l + 0.3963377774 * a + 0.2158037573 * b) ** 3;
|
|
10
|
+
const m_ = (l - 0.1055613458 * a - 0.0638541728 * b) ** 3;
|
|
11
|
+
const s_ = (l - 0.0894841775 * a - 1.291485548 * b) ** 3;
|
|
12
|
+
const lin = [
|
|
13
|
+
4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_,
|
|
14
|
+
-1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_,
|
|
15
|
+
-0.0041960863 * l_ - 0.7034186147 * m_ + 1.707614701 * s_
|
|
16
|
+
];
|
|
17
|
+
const hex = lin.map((v) => {
|
|
18
|
+
const g = v <= 31308e-7 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055;
|
|
19
|
+
return Math.round(Math.min(1, Math.max(0, g)) * 255).toString(16).padStart(2, "0");
|
|
20
|
+
});
|
|
21
|
+
return `#${hex.join("")}`;
|
|
22
|
+
}
|
|
23
|
+
function appIconGradient(hue) {
|
|
24
|
+
return `linear-gradient(150deg, ${oklchToHex(0.66, 0.17, hue)}, ${oklchToHex(0.47, 0.18, hue + 22)})`;
|
|
25
|
+
}
|
|
26
|
+
function nodesOf(glyph) {
|
|
27
|
+
if (Array.isArray(glyph)) return glyph;
|
|
28
|
+
const node = glyph.__iconData?.node;
|
|
29
|
+
if (!node) throw new Error("app-icon: glyph is not a lucide icon module (expected `import * as x from 'lucide-react/icons/<name>'`)");
|
|
30
|
+
return node;
|
|
31
|
+
}
|
|
32
|
+
function appIconElement({ glyph, hue, size, shape = "rounded" }) {
|
|
33
|
+
const square = shape === "square";
|
|
34
|
+
const side = Math.round(size * (square ? 0.46 : 0.58));
|
|
35
|
+
const glyphSvg = createElement(
|
|
36
|
+
"svg",
|
|
37
|
+
{ width: side, height: side, viewBox: "0 0 24 24", fill: "none", stroke: "#ffffff", strokeWidth: 2.1, strokeLinecap: "round", strokeLinejoin: "round" },
|
|
38
|
+
...nodesOf(glyph).map(([tag, { key, ...attrs }]) => createElement(tag, { key, ...attrs }))
|
|
39
|
+
);
|
|
40
|
+
return createElement(
|
|
41
|
+
"div",
|
|
42
|
+
{
|
|
43
|
+
style: {
|
|
44
|
+
width: size,
|
|
45
|
+
height: size,
|
|
46
|
+
display: "flex",
|
|
47
|
+
alignItems: "center",
|
|
48
|
+
justifyContent: "center",
|
|
49
|
+
backgroundImage: appIconGradient(hue),
|
|
50
|
+
borderRadius: square ? 0 : Math.round(size * 0.225)
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
glyphSvg
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// src/app-icon/next.ts
|
|
58
|
+
var appIconSizes = { tab: 64, "192": 192, "384": 384, "512": 512 };
|
|
59
|
+
var appleIconSize = { width: 180, height: 180 };
|
|
60
|
+
function createAppIcon({ glyph, hue }) {
|
|
61
|
+
return {
|
|
62
|
+
generateImageMetadata() {
|
|
63
|
+
return Object.entries(appIconSizes).map(([id, side]) => ({
|
|
64
|
+
id,
|
|
65
|
+
size: { width: side, height: side },
|
|
66
|
+
contentType: "image/png"
|
|
67
|
+
}));
|
|
68
|
+
},
|
|
69
|
+
async icon({ id }) {
|
|
70
|
+
const key = String(await id);
|
|
71
|
+
const side = appIconSizes[key] ?? appIconSizes.tab;
|
|
72
|
+
return new ImageResponse(appIconElement({ glyph, hue, size: side, shape: key === "tab" ? "rounded" : "square" }), {
|
|
73
|
+
width: side,
|
|
74
|
+
height: side
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
appleIcon() {
|
|
78
|
+
return new ImageResponse(appIconElement({ glyph, hue, size: appleIconSize.width, shape: "square" }), appleIconSize);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export {
|
|
83
|
+
appIconSizes,
|
|
84
|
+
appleIconSize,
|
|
85
|
+
createAppIcon
|
|
86
|
+
};
|
|
87
|
+
//# sourceMappingURL=next.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/app-icon/next.ts","../../src/app-icon/element.ts"],"sourcesContent":["/**\n * Route handlers for Next's icon file conventions, from one glyph and one hue.\n *\n * // app/icon.tsx\n * import * as workflow from 'lucide-react/icons/workflow';\n * import { createAppIcon } from '@djangocfg/nextjs/app-icon/next';\n * const icon = createAppIcon({ glyph: workflow, hue: 55 });\n * export const generateImageMetadata = icon.generateImageMetadata;\n * export default icon.icon;\n *\n * // app/apple-icon.tsx\n * export const size = { width: 180, height: 180 };\n * export const contentType = 'image/png';\n * export default icon.appleIcon; // same createAppIcon call, or call it again\n *\n * Served at `/icon/tab` (64, rounded), `/icon/192`, `/icon/384`, `/icon/512` (square) and `/apple-icon` (180, square).\n */\n\nimport { ImageResponse } from 'next/og';\n\nimport { appIconElement, type AppIconGlyph } from './element';\n\nexport const appIconSizes = { tab: 64, '192': 192, '384': 384, '512': 512 } as const;\nexport const appleIconSize = { width: 180, height: 180 } as const;\n\nexport function createAppIcon({ glyph, hue }: { glyph: AppIconGlyph; hue: number }) {\n return {\n generateImageMetadata() {\n return Object.entries(appIconSizes).map(([id, side]) => ({\n id,\n size: { width: side, height: side },\n contentType: 'image/png',\n }));\n },\n async icon({ id }: { id: Promise<string | number> }) {\n const key = String(await id) as keyof typeof appIconSizes;\n const side = appIconSizes[key] ?? appIconSizes.tab;\n return new ImageResponse(appIconElement({ glyph, hue, size: side, shape: key === 'tab' ? 'rounded' : 'square' }), {\n width: side,\n height: side,\n });\n },\n appleIcon() {\n return new ImageResponse(appIconElement({ glyph, hue, size: appleIconSize.width, shape: 'square' }), appleIconSize);\n },\n };\n}\n","/**\n * App icon: ONE definition of the picture an app shows in a tab, on a home screen and as a PWA icon.\n *\n * House style: a full-bleed tile in the product's hue (a two-stop gradient), a white lucide glyph in the inner ~46-58%,\n * and NO border near the edge. The OS cuts its own corner (iOS ~22%, Android a mask); anything drawn at the edge never\n * matches that curve.\n *\n * Pure: plain host elements only (`next/og` renders nothing else) and no `next/og` import, so a client bundle can use\n * the same gradient for an in-app tile. The route factory lives in `./next`.\n */\n\nimport { createElement, type ReactElement } from 'react';\n\n/** What lucide stores for an icon: `[tag, attrs][]`; `attrs.key` is React's key. */\nexport type IconNode = [tag: string, attrs: Record<string, string>][];\n\n/**\n * A glyph is a lucide icon MODULE, `import * as workflow from 'lucide-react/icons/workflow'` (a lucide component is a\n * client component and `next/og` cannot render it, so its drawing data is read from the module), or the node list itself.\n */\nexport type AppIconGlyph = object | IconNode;\n\nexport type AppIconShape = 'rounded' | 'square';\n\nexport interface AppIconOptions {\n glyph: AppIconGlyph;\n /** OKLCH hue, 0-360. The switcher tile should use `appIconGradient(hue)` so both agree. */\n hue: number;\n /** Side in px. */\n size: number;\n /** `rounded`: transparent corners (browser tab). `square`: full-bleed for iOS and maskable PWA icons. @default 'rounded' */\n shape?: AppIconShape;\n}\n\n/** OKLCH to sRGB hex. `next/og` does not parse `oklch()`; CSS does, and the two must show the same colour. */\nexport function oklchToHex(l: number, c: number, h: number): string {\n const a = c * Math.cos((h * Math.PI) / 180);\n const b = c * Math.sin((h * Math.PI) / 180);\n const l_ = (l + 0.3963377774 * a + 0.2158037573 * b) ** 3;\n const m_ = (l - 0.1055613458 * a - 0.0638541728 * b) ** 3;\n const s_ = (l - 0.0894841775 * a - 1.291485548 * b) ** 3;\n const lin = [\n 4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_,\n -1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_,\n -0.0041960863 * l_ - 0.7034186147 * m_ + 1.707614701 * s_,\n ];\n const hex = lin.map((v) => {\n const g = v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055;\n return Math.round(Math.min(1, Math.max(0, g)) * 255).toString(16).padStart(2, '0');\n });\n return `#${hex.join('')}`;\n}\n\n/** The gradient behind a glyph, two hex stops from one hue. */\nexport function appIconGradient(hue: number): string {\n return `linear-gradient(150deg, ${oklchToHex(0.66, 0.17, hue)}, ${oklchToHex(0.47, 0.18, hue + 22)})`;\n}\n\nfunction nodesOf(glyph: AppIconGlyph): IconNode {\n if (Array.isArray(glyph)) return glyph as IconNode;\n const node = (glyph as { __iconData?: { node?: IconNode } }).__iconData?.node;\n if (!node) throw new Error(\"app-icon: glyph is not a lucide icon module (expected `import * as x from 'lucide-react/icons/<name>'`)\");\n return node;\n}\n\n/** The picture as plain JSX, for `new ImageResponse(...)`. */\nexport function appIconElement({ glyph, hue, size, shape = 'rounded' }: AppIconOptions): ReactElement {\n const square = shape === 'square';\n // A square icon is cropped by the OS: keep the glyph inside the maskable safe zone (the inner 60%).\n const side = Math.round(size * (square ? 0.46 : 0.58));\n const glyphSvg = createElement(\n 'svg',\n { width: side, height: side, viewBox: '0 0 24 24', fill: 'none', stroke: '#ffffff', strokeWidth: 2.1, strokeLinecap: 'round', strokeLinejoin: 'round' },\n ...nodesOf(glyph).map(([tag, { key, ...attrs }]) => createElement(tag, { key, ...attrs })),\n );\n return createElement(\n 'div',\n {\n style: {\n width: size,\n height: size,\n display: 'flex',\n alignItems: 'center',\n justifyContent: 'center',\n backgroundImage: appIconGradient(hue),\n borderRadius: square ? 0 : Math.round(size * 0.225),\n },\n },\n glyphSvg,\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { ReactElement } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* App icon: ONE definition of the picture an app shows in a tab, on a home screen and as a PWA icon.
|
|
5
|
+
*
|
|
6
|
+
* House style: a full-bleed tile in the product's hue (a two-stop gradient), a white lucide glyph in the inner ~46-58%,
|
|
7
|
+
* and NO border near the edge. The OS cuts its own corner (iOS ~22%, Android a mask); anything drawn at the edge never
|
|
8
|
+
* matches that curve.
|
|
9
|
+
*
|
|
10
|
+
* Pure: plain host elements only (`next/og` renders nothing else) and no `next/og` import, so a client bundle can use
|
|
11
|
+
* the same gradient for an in-app tile. The route factory lives in `./next`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** What lucide stores for an icon: `[tag, attrs][]`; `attrs.key` is React's key. */
|
|
15
|
+
type IconNode = [tag: string, attrs: Record<string, string>][];
|
|
16
|
+
/**
|
|
17
|
+
* A glyph is a lucide icon MODULE, `import * as workflow from 'lucide-react/icons/workflow'` (a lucide component is a
|
|
18
|
+
* client component and `next/og` cannot render it, so its drawing data is read from the module), or the node list itself.
|
|
19
|
+
*/
|
|
20
|
+
type AppIconGlyph = object | IconNode;
|
|
21
|
+
type AppIconShape = 'rounded' | 'square';
|
|
22
|
+
interface AppIconOptions {
|
|
23
|
+
glyph: AppIconGlyph;
|
|
24
|
+
/** OKLCH hue, 0-360. The switcher tile should use `appIconGradient(hue)` so both agree. */
|
|
25
|
+
hue: number;
|
|
26
|
+
/** Side in px. */
|
|
27
|
+
size: number;
|
|
28
|
+
/** `rounded`: transparent corners (browser tab). `square`: full-bleed for iOS and maskable PWA icons. @default 'rounded' */
|
|
29
|
+
shape?: AppIconShape;
|
|
30
|
+
}
|
|
31
|
+
/** OKLCH to sRGB hex. `next/og` does not parse `oklch()`; CSS does, and the two must show the same colour. */
|
|
32
|
+
declare function oklchToHex(l: number, c: number, h: number): string;
|
|
33
|
+
/** The gradient behind a glyph, two hex stops from one hue. */
|
|
34
|
+
declare function appIconGradient(hue: number): string;
|
|
35
|
+
/** The picture as plain JSX, for `new ImageResponse(...)`. */
|
|
36
|
+
declare function appIconElement({ glyph, hue, size, shape }: AppIconOptions): ReactElement;
|
|
37
|
+
|
|
38
|
+
export { appIconElement as c, appIconGradient as d, oklchToHex as o };
|
|
39
|
+
export type { AppIconGlyph as A, IconNode as I, AppIconOptions as a, AppIconShape as b };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/nextjs",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.628",
|
|
4
4
|
"description": "Next.js server utilities: sitemap, health, OG images, contact forms, navigation, config",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"nextjs",
|
|
@@ -92,6 +92,16 @@
|
|
|
92
92
|
"import": "./dist/pwa/worker/index.mjs",
|
|
93
93
|
"default": "./dist/pwa/worker/index.mjs"
|
|
94
94
|
},
|
|
95
|
+
"./app-icon": {
|
|
96
|
+
"types": "./dist/app-icon/index.d.mts",
|
|
97
|
+
"import": "./dist/app-icon/index.mjs",
|
|
98
|
+
"default": "./dist/app-icon/index.mjs"
|
|
99
|
+
},
|
|
100
|
+
"./app-icon/next": {
|
|
101
|
+
"types": "./dist/app-icon/next.d.mts",
|
|
102
|
+
"import": "./dist/app-icon/next.mjs",
|
|
103
|
+
"default": "./dist/app-icon/next.mjs"
|
|
104
|
+
},
|
|
95
105
|
"./og-image": {
|
|
96
106
|
"types": "./dist/og-image/index.d.mts",
|
|
97
107
|
"import": "./dist/og-image/index.mjs",
|
|
@@ -162,10 +172,10 @@
|
|
|
162
172
|
"ai-docs": "tsx src/ai/cli.ts"
|
|
163
173
|
},
|
|
164
174
|
"peerDependencies": {
|
|
165
|
-
"@djangocfg/api": "^2.1.
|
|
166
|
-
"@djangocfg/devtools": "^2.1.
|
|
167
|
-
"@djangocfg/i18n": "^2.1.
|
|
168
|
-
"@djangocfg/ui-core": "^2.1.
|
|
175
|
+
"@djangocfg/api": "^2.1.628",
|
|
176
|
+
"@djangocfg/devtools": "^2.1.628",
|
|
177
|
+
"@djangocfg/i18n": "^2.1.628",
|
|
178
|
+
"@djangocfg/ui-core": "^2.1.628",
|
|
169
179
|
"next": "^16.4.5",
|
|
170
180
|
"next-intl": "^4.9.1"
|
|
171
181
|
},
|
|
@@ -187,12 +197,12 @@
|
|
|
187
197
|
"semver": "^7.8.6"
|
|
188
198
|
},
|
|
189
199
|
"devDependencies": {
|
|
190
|
-
"@djangocfg/api": "^2.1.
|
|
191
|
-
"@djangocfg/devtools": "^2.1.
|
|
192
|
-
"@djangocfg/i18n": "^2.1.
|
|
193
|
-
"@djangocfg/layouts": "^2.1.
|
|
194
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
195
|
-
"@djangocfg/ui-core": "^2.1.
|
|
200
|
+
"@djangocfg/api": "^2.1.628",
|
|
201
|
+
"@djangocfg/devtools": "^2.1.628",
|
|
202
|
+
"@djangocfg/i18n": "^2.1.628",
|
|
203
|
+
"@djangocfg/layouts": "^2.1.628",
|
|
204
|
+
"@djangocfg/typescript-config": "^2.1.628",
|
|
205
|
+
"@djangocfg/ui-core": "^2.1.628",
|
|
196
206
|
"@types/node": "^25.9.5",
|
|
197
207
|
"@types/react": "19.3.0",
|
|
198
208
|
"@types/react-dom": "19.3.0",
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { describe, it } from 'node:test';
|
|
3
|
+
|
|
4
|
+
import type { ReactElement } from 'react';
|
|
5
|
+
|
|
6
|
+
import { appIconElement, appIconGradient, oklchToHex } from './element';
|
|
7
|
+
|
|
8
|
+
const GOLDEN = '#3f93f7';
|
|
9
|
+
|
|
10
|
+
type Tile = ReactElement<{ style: Record<string, unknown>; children: ReactElement<{ width: number }> }>;
|
|
11
|
+
|
|
12
|
+
describe('app-icon', () => {
|
|
13
|
+
it('converts oklch to a stable hex (golden)', () => {
|
|
14
|
+
assert.equal(oklchToHex(0.66, 0.17, 255), GOLDEN);
|
|
15
|
+
assert.equal(oklchToHex(1, 0, 0), '#ffffff');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('builds the gradient from two hex stops', () => {
|
|
19
|
+
assert.match(appIconGradient(55), /^linear-gradient\(150deg, #[0-9a-f]{6}, #[0-9a-f]{6}\)$/);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('draws a full-bleed tile with no border and a smaller glyph on a square', () => {
|
|
23
|
+
const rounded = appIconElement({ glyph: [['path', { key: 'a', d: 'M0 0' }]], hue: 55, size: 100 }) as Tile;
|
|
24
|
+
const square = appIconElement({ glyph: [['path', { key: 'a', d: 'M0 0' }]], hue: 55, size: 100, shape: 'square' }) as Tile;
|
|
25
|
+
assert.equal(rounded.props.style.border, undefined);
|
|
26
|
+
assert.equal(rounded.props.style.borderRadius, 23);
|
|
27
|
+
assert.equal(square.props.style.borderRadius, 0);
|
|
28
|
+
assert.equal(rounded.props.children.props.width, 58);
|
|
29
|
+
assert.equal(square.props.children.props.width, 46);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('refuses a glyph that is not a lucide module', () => {
|
|
33
|
+
assert.throws(() => appIconElement({ glyph: {}, hue: 0, size: 10 }), /lucide/);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App icon: ONE definition of the picture an app shows in a tab, on a home screen and as a PWA icon.
|
|
3
|
+
*
|
|
4
|
+
* House style: a full-bleed tile in the product's hue (a two-stop gradient), a white lucide glyph in the inner ~46-58%,
|
|
5
|
+
* and NO border near the edge. The OS cuts its own corner (iOS ~22%, Android a mask); anything drawn at the edge never
|
|
6
|
+
* matches that curve.
|
|
7
|
+
*
|
|
8
|
+
* Pure: plain host elements only (`next/og` renders nothing else) and no `next/og` import, so a client bundle can use
|
|
9
|
+
* the same gradient for an in-app tile. The route factory lives in `./next`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createElement, type ReactElement } from 'react';
|
|
13
|
+
|
|
14
|
+
/** What lucide stores for an icon: `[tag, attrs][]`; `attrs.key` is React's key. */
|
|
15
|
+
export type IconNode = [tag: string, attrs: Record<string, string>][];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A glyph is a lucide icon MODULE, `import * as workflow from 'lucide-react/icons/workflow'` (a lucide component is a
|
|
19
|
+
* client component and `next/og` cannot render it, so its drawing data is read from the module), or the node list itself.
|
|
20
|
+
*/
|
|
21
|
+
export type AppIconGlyph = object | IconNode;
|
|
22
|
+
|
|
23
|
+
export type AppIconShape = 'rounded' | 'square';
|
|
24
|
+
|
|
25
|
+
export interface AppIconOptions {
|
|
26
|
+
glyph: AppIconGlyph;
|
|
27
|
+
/** OKLCH hue, 0-360. The switcher tile should use `appIconGradient(hue)` so both agree. */
|
|
28
|
+
hue: number;
|
|
29
|
+
/** Side in px. */
|
|
30
|
+
size: number;
|
|
31
|
+
/** `rounded`: transparent corners (browser tab). `square`: full-bleed for iOS and maskable PWA icons. @default 'rounded' */
|
|
32
|
+
shape?: AppIconShape;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** OKLCH to sRGB hex. `next/og` does not parse `oklch()`; CSS does, and the two must show the same colour. */
|
|
36
|
+
export function oklchToHex(l: number, c: number, h: number): string {
|
|
37
|
+
const a = c * Math.cos((h * Math.PI) / 180);
|
|
38
|
+
const b = c * Math.sin((h * Math.PI) / 180);
|
|
39
|
+
const l_ = (l + 0.3963377774 * a + 0.2158037573 * b) ** 3;
|
|
40
|
+
const m_ = (l - 0.1055613458 * a - 0.0638541728 * b) ** 3;
|
|
41
|
+
const s_ = (l - 0.0894841775 * a - 1.291485548 * b) ** 3;
|
|
42
|
+
const lin = [
|
|
43
|
+
4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_,
|
|
44
|
+
-1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_,
|
|
45
|
+
-0.0041960863 * l_ - 0.7034186147 * m_ + 1.707614701 * s_,
|
|
46
|
+
];
|
|
47
|
+
const hex = lin.map((v) => {
|
|
48
|
+
const g = v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055;
|
|
49
|
+
return Math.round(Math.min(1, Math.max(0, g)) * 255).toString(16).padStart(2, '0');
|
|
50
|
+
});
|
|
51
|
+
return `#${hex.join('')}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The gradient behind a glyph, two hex stops from one hue. */
|
|
55
|
+
export function appIconGradient(hue: number): string {
|
|
56
|
+
return `linear-gradient(150deg, ${oklchToHex(0.66, 0.17, hue)}, ${oklchToHex(0.47, 0.18, hue + 22)})`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function nodesOf(glyph: AppIconGlyph): IconNode {
|
|
60
|
+
if (Array.isArray(glyph)) return glyph as IconNode;
|
|
61
|
+
const node = (glyph as { __iconData?: { node?: IconNode } }).__iconData?.node;
|
|
62
|
+
if (!node) throw new Error("app-icon: glyph is not a lucide icon module (expected `import * as x from 'lucide-react/icons/<name>'`)");
|
|
63
|
+
return node;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The picture as plain JSX, for `new ImageResponse(...)`. */
|
|
67
|
+
export function appIconElement({ glyph, hue, size, shape = 'rounded' }: AppIconOptions): ReactElement {
|
|
68
|
+
const square = shape === 'square';
|
|
69
|
+
// A square icon is cropped by the OS: keep the glyph inside the maskable safe zone (the inner 60%).
|
|
70
|
+
const side = Math.round(size * (square ? 0.46 : 0.58));
|
|
71
|
+
const glyphSvg = createElement(
|
|
72
|
+
'svg',
|
|
73
|
+
{ width: side, height: side, viewBox: '0 0 24 24', fill: 'none', stroke: '#ffffff', strokeWidth: 2.1, strokeLinecap: 'round', strokeLinejoin: 'round' },
|
|
74
|
+
...nodesOf(glyph).map(([tag, { key, ...attrs }]) => createElement(tag, { key, ...attrs })),
|
|
75
|
+
);
|
|
76
|
+
return createElement(
|
|
77
|
+
'div',
|
|
78
|
+
{
|
|
79
|
+
style: {
|
|
80
|
+
width: size,
|
|
81
|
+
height: size,
|
|
82
|
+
display: 'flex',
|
|
83
|
+
alignItems: 'center',
|
|
84
|
+
justifyContent: 'center',
|
|
85
|
+
backgroundImage: appIconGradient(hue),
|
|
86
|
+
borderRadius: square ? 0 : Math.round(size * 0.225),
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
glyphSvg,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route handlers for Next's icon file conventions, from one glyph and one hue.
|
|
3
|
+
*
|
|
4
|
+
* // app/icon.tsx
|
|
5
|
+
* import * as workflow from 'lucide-react/icons/workflow';
|
|
6
|
+
* import { createAppIcon } from '@djangocfg/nextjs/app-icon/next';
|
|
7
|
+
* const icon = createAppIcon({ glyph: workflow, hue: 55 });
|
|
8
|
+
* export const generateImageMetadata = icon.generateImageMetadata;
|
|
9
|
+
* export default icon.icon;
|
|
10
|
+
*
|
|
11
|
+
* // app/apple-icon.tsx
|
|
12
|
+
* export const size = { width: 180, height: 180 };
|
|
13
|
+
* export const contentType = 'image/png';
|
|
14
|
+
* export default icon.appleIcon; // same createAppIcon call, or call it again
|
|
15
|
+
*
|
|
16
|
+
* Served at `/icon/tab` (64, rounded), `/icon/192`, `/icon/384`, `/icon/512` (square) and `/apple-icon` (180, square).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { ImageResponse } from 'next/og';
|
|
20
|
+
|
|
21
|
+
import { appIconElement, type AppIconGlyph } from './element';
|
|
22
|
+
|
|
23
|
+
export const appIconSizes = { tab: 64, '192': 192, '384': 384, '512': 512 } as const;
|
|
24
|
+
export const appleIconSize = { width: 180, height: 180 } as const;
|
|
25
|
+
|
|
26
|
+
export function createAppIcon({ glyph, hue }: { glyph: AppIconGlyph; hue: number }) {
|
|
27
|
+
return {
|
|
28
|
+
generateImageMetadata() {
|
|
29
|
+
return Object.entries(appIconSizes).map(([id, side]) => ({
|
|
30
|
+
id,
|
|
31
|
+
size: { width: side, height: side },
|
|
32
|
+
contentType: 'image/png',
|
|
33
|
+
}));
|
|
34
|
+
},
|
|
35
|
+
async icon({ id }: { id: Promise<string | number> }) {
|
|
36
|
+
const key = String(await id) as keyof typeof appIconSizes;
|
|
37
|
+
const side = appIconSizes[key] ?? appIconSizes.tab;
|
|
38
|
+
return new ImageResponse(appIconElement({ glyph, hue, size: side, shape: key === 'tab' ? 'rounded' : 'square' }), {
|
|
39
|
+
width: side,
|
|
40
|
+
height: side,
|
|
41
|
+
});
|
|
42
|
+
},
|
|
43
|
+
appleIcon() {
|
|
44
|
+
return new ImageResponse(appIconElement({ glyph, hue, size: appleIconSize.width, shape: 'square' }), appleIconSize);
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|