@jojomatik/nuxt-bundle 2.0.0-beta.30 → 2.0.0-beta.31
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/README.md +21 -0
- package/modules/favicon-module.ts +223 -0
- package/package.json +3 -1
- package/public/favicon.svg +1 -0
package/README.md
CHANGED
|
@@ -39,5 +39,26 @@ licenseModule: {
|
|
|
39
39
|
}
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
+
## Favicons
|
|
43
|
+
The favicon module is opt-in. When enabled, it generates all favicon files from a single SVG during the build:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
export default defineNuxtConfig({
|
|
47
|
+
extends: "@jojomatik/nuxt-bundle",
|
|
48
|
+
faviconModule: {
|
|
49
|
+
enabled: true,
|
|
50
|
+
// source: "public/favicon.svg",
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
- `source` is the SVG to generate from (default `public/favicon.svg`). If it does not exist, nothing is generated.
|
|
56
|
+
|
|
57
|
+
Generated files: `favicon.ico`, `favicon-16x16.png`, `favicon-32x32.png`, `apple-touch-icon.png`, `android-chrome-192x192.png`, `android-chrome-512x512.png`, `safari-pinned-tab.svg` and `site.webmanifest`.
|
|
58
|
+
|
|
59
|
+
The files are written to a cache directory under `node_modules/.cache` (not your source tree) and served from `/assets/favicon/` with `max-age=0`, so clients revalidate them (the file names are not versioned). A file that already exists in `public/assets/favicon/` is never generated or overridden, so you can supply your own version of any of them.
|
|
60
|
+
|
|
61
|
+
The `site.webmanifest` uses the `name` from your `package.json` (without scope, default `App`) as `name` and `short_name`, and the `primary` and `background` colors of the default Vuetify theme (set via `vuetify.vuetifyOptions.theme`) as `theme_color` and `background_color` (default `#ffffff`).
|
|
62
|
+
|
|
42
63
|
## Licensing
|
|
43
64
|
This project is licensed under the MIT License (MIT). See also [`LICENSE`](LICENSE).
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { defineNuxtModule, resolvePath, useLogger } from "nuxt/kit";
|
|
4
|
+
import sharp from "sharp";
|
|
5
|
+
|
|
6
|
+
const moduleKey = "@jojomatik/nuxt-bundle/favicon-module";
|
|
7
|
+
|
|
8
|
+
const logger = useLogger(moduleKey);
|
|
9
|
+
|
|
10
|
+
/** URL path (below the app root) the files are served from. */
|
|
11
|
+
export const publicPath = "assets/favicon";
|
|
12
|
+
|
|
13
|
+
export interface FaviconModuleOptions {
|
|
14
|
+
/** Opt-in: favicons are only generated when set to `true`. */
|
|
15
|
+
enabled: boolean;
|
|
16
|
+
/** Path of the source SVG, relative to the project root. */
|
|
17
|
+
source: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** PNG files to generate: file name and edge length in pixels. */
|
|
21
|
+
export const pngTargets = [
|
|
22
|
+
{ name: "favicon-16x16.png", size: 16 },
|
|
23
|
+
{ name: "favicon-32x32.png", size: 32 },
|
|
24
|
+
{ name: "apple-touch-icon.png", size: 180 },
|
|
25
|
+
{ name: "android-chrome-192x192.png", size: 192 },
|
|
26
|
+
{ name: "android-chrome-512x512.png", size: 512 },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/** Removes leading and trailing slashes without regular expressions. */
|
|
30
|
+
export function trimSlashes(value: string): string {
|
|
31
|
+
let start = 0;
|
|
32
|
+
let end = value.length;
|
|
33
|
+
while (start < end && value[start] === "/") start++;
|
|
34
|
+
while (end > start && value[end - 1] === "/") end--;
|
|
35
|
+
return value.slice(start, end);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Wraps a PNG image in an ICO container. */
|
|
39
|
+
export function pngToIco(png: Buffer, size: number): Buffer {
|
|
40
|
+
const header = Buffer.alloc(22);
|
|
41
|
+
header.writeUInt16LE(0, 0); // reserved
|
|
42
|
+
header.writeUInt16LE(1, 2); // type: icon
|
|
43
|
+
header.writeUInt16LE(1, 4); // image count
|
|
44
|
+
header.writeUInt8(size >= 256 ? 0 : size, 6);
|
|
45
|
+
header.writeUInt8(size >= 256 ? 0 : size, 7);
|
|
46
|
+
header.writeUInt16LE(1, 10); // color planes
|
|
47
|
+
header.writeUInt16LE(32, 12); // bits per pixel
|
|
48
|
+
header.writeUInt32LE(png.length, 14);
|
|
49
|
+
header.writeUInt32LE(header.length, 18);
|
|
50
|
+
return Buffer.concat([header, png]);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ManifestInput {
|
|
54
|
+
name?: string;
|
|
55
|
+
themeColor?: string;
|
|
56
|
+
backgroundColor?: string;
|
|
57
|
+
publicPath: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Builds the web manifest content (icon URLs are relative to the manifest), falling back to sensible defaults. */
|
|
61
|
+
export function createManifest({
|
|
62
|
+
name,
|
|
63
|
+
themeColor,
|
|
64
|
+
backgroundColor,
|
|
65
|
+
}: ManifestInput) {
|
|
66
|
+
const appName = name?.trim() || "App";
|
|
67
|
+
// Icons are served next to the manifest, so resolve them relative to it.
|
|
68
|
+
const base = ".";
|
|
69
|
+
return {
|
|
70
|
+
name: appName,
|
|
71
|
+
short_name: appName,
|
|
72
|
+
icons: [
|
|
73
|
+
{
|
|
74
|
+
src: `${base}/android-chrome-192x192.png`,
|
|
75
|
+
sizes: "192x192",
|
|
76
|
+
type: "image/png",
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
src: `${base}/android-chrome-512x512.png`,
|
|
80
|
+
sizes: "512x512",
|
|
81
|
+
type: "image/png",
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
theme_color: themeColor || "#ffffff",
|
|
85
|
+
background_color: backgroundColor || "#ffffff",
|
|
86
|
+
display: "standalone",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface ThemeLike {
|
|
91
|
+
defaultTheme?: string;
|
|
92
|
+
themes?: Record<string, { colors?: Record<string, unknown> } | undefined>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Reads a theme color of the default (or first) vuetify theme. */
|
|
96
|
+
export function getThemeColor(
|
|
97
|
+
theme: ThemeLike | undefined,
|
|
98
|
+
color: string,
|
|
99
|
+
): string | undefined {
|
|
100
|
+
const themes = theme?.themes ?? {};
|
|
101
|
+
const selected =
|
|
102
|
+
themes[theme?.defaultTheme ?? "light"] ?? Object.values(themes)[0];
|
|
103
|
+
const value = selected?.colors?.[color];
|
|
104
|
+
return typeof value === "string" ? value : undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Generates all favicon files from the single SVG source.
|
|
109
|
+
* Returns the names of the generated files.
|
|
110
|
+
*/
|
|
111
|
+
export async function generateFavicons(
|
|
112
|
+
source: string,
|
|
113
|
+
outputDir: string,
|
|
114
|
+
skip: (name: string) => boolean = () => false,
|
|
115
|
+
): Promise<string[]> {
|
|
116
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
117
|
+
const svg = fs.readFileSync(source);
|
|
118
|
+
const generated: string[] = [];
|
|
119
|
+
|
|
120
|
+
const renderPng = (size: number) =>
|
|
121
|
+
sharp(svg, { density: 384 })
|
|
122
|
+
.resize(size, size, { fit: "contain", background: "#0000" })
|
|
123
|
+
.png()
|
|
124
|
+
.toBuffer();
|
|
125
|
+
|
|
126
|
+
const targets = pngTargets.filter(({ name }) => !skip(name));
|
|
127
|
+
const pngs = await Promise.all(targets.map(({ size }) => renderPng(size)));
|
|
128
|
+
targets.forEach(({ name }, index) => {
|
|
129
|
+
fs.writeFileSync(path.join(outputDir, name), pngs[index]);
|
|
130
|
+
generated.push(name);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
if (!skip("favicon.ico")) {
|
|
134
|
+
const icoPng = await renderPng(48);
|
|
135
|
+
fs.writeFileSync(path.join(outputDir, "favicon.ico"), pngToIco(icoPng, 48));
|
|
136
|
+
generated.push("favicon.ico");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!skip("safari-pinned-tab.svg")) {
|
|
140
|
+
fs.copyFileSync(source, path.join(outputDir, "safari-pinned-tab.svg"));
|
|
141
|
+
generated.push("safari-pinned-tab.svg");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return generated;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export default defineNuxtModule<FaviconModuleOptions>({
|
|
148
|
+
meta: {
|
|
149
|
+
name: moduleKey,
|
|
150
|
+
configKey: "faviconModule",
|
|
151
|
+
},
|
|
152
|
+
defaults: {
|
|
153
|
+
enabled: false,
|
|
154
|
+
source: "public/favicon.svg",
|
|
155
|
+
},
|
|
156
|
+
async setup(options, nuxt) {
|
|
157
|
+
if (!options.enabled) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const source = path.resolve(nuxt.options.rootDir, options.source);
|
|
162
|
+
if (!fs.existsSync(source)) {
|
|
163
|
+
logger.info(`No favicon source found at ${source}, skipping.`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Generated files live in a cache directory, never in the source tree.
|
|
168
|
+
// Nuxt's build directory can't be used, as it is cleaned during the build.
|
|
169
|
+
const outputDir = path.join(
|
|
170
|
+
nuxt.options.rootDir,
|
|
171
|
+
"node_modules/.cache/@jojomatik/nuxt-bundle/favicon",
|
|
172
|
+
);
|
|
173
|
+
const publicDir = await resolvePath(nuxt.options.dir.public);
|
|
174
|
+
const existsInSource = (name: string) =>
|
|
175
|
+
fs.existsSync(path.join(publicDir, publicPath, name));
|
|
176
|
+
|
|
177
|
+
nuxt.hook("nitro:config", async (nitroConfig) => {
|
|
178
|
+
const files = await generateFavicons(source, outputDir, existsInSource);
|
|
179
|
+
|
|
180
|
+
if (!existsInSource("site.webmanifest")) {
|
|
181
|
+
let name: string | undefined;
|
|
182
|
+
try {
|
|
183
|
+
name = JSON.parse(
|
|
184
|
+
fs.readFileSync(
|
|
185
|
+
path.join(nuxt.options.rootDir, "package.json"),
|
|
186
|
+
"utf-8",
|
|
187
|
+
),
|
|
188
|
+
).name;
|
|
189
|
+
} catch {
|
|
190
|
+
// no readable package.json, use the default name
|
|
191
|
+
}
|
|
192
|
+
if (name?.startsWith("@")) {
|
|
193
|
+
name = name.slice(name.indexOf("/") + 1);
|
|
194
|
+
}
|
|
195
|
+
const theme = (
|
|
196
|
+
nuxt.options as {
|
|
197
|
+
vuetify?: { vuetifyOptions?: { theme?: ThemeLike } };
|
|
198
|
+
}
|
|
199
|
+
).vuetify?.vuetifyOptions?.theme;
|
|
200
|
+
const manifest = createManifest({
|
|
201
|
+
name,
|
|
202
|
+
themeColor: getThemeColor(theme, "primary"),
|
|
203
|
+
backgroundColor: getThemeColor(theme, "background"),
|
|
204
|
+
publicPath,
|
|
205
|
+
});
|
|
206
|
+
fs.writeFileSync(
|
|
207
|
+
path.join(outputDir, "site.webmanifest"),
|
|
208
|
+
JSON.stringify(manifest, null, 2),
|
|
209
|
+
);
|
|
210
|
+
files.push("site.webmanifest");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
nitroConfig.publicAssets ||= [];
|
|
214
|
+
nitroConfig.publicAssets.push({
|
|
215
|
+
dir: outputDir,
|
|
216
|
+
baseURL: "/" + publicPath,
|
|
217
|
+
// Fixed file names (not versioned): always revalidate instead of caching long-term.
|
|
218
|
+
maxAge: 0,
|
|
219
|
+
});
|
|
220
|
+
logger.success(`Generated ${files.length} favicon files in ${outputDir}`);
|
|
221
|
+
});
|
|
222
|
+
},
|
|
223
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jojomatik/nuxt-bundle",
|
|
3
|
-
"version": "2.0.0-beta.
|
|
3
|
+
"version": "2.0.0-beta.31",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"packageManager": "yarn@1.22.22",
|
|
6
6
|
"description": "A bundle of commonly used nuxt dependencies and configuration.",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"modules",
|
|
31
31
|
"nuxt.config.ts",
|
|
32
32
|
"package.json",
|
|
33
|
+
"public/favicon.svg",
|
|
33
34
|
"README.md",
|
|
34
35
|
"vuetify-options.ts"
|
|
35
36
|
],
|
|
@@ -59,6 +60,7 @@
|
|
|
59
60
|
"rollup-plugin-license": "3.7.1",
|
|
60
61
|
"sass": "1.106.1",
|
|
61
62
|
"semantic-release": "25.0.9",
|
|
63
|
+
"sharp": "0.34.5",
|
|
62
64
|
"storybook": "10.6.5",
|
|
63
65
|
"typescript": "6.0.3",
|
|
64
66
|
"vue-router": "5.4.0",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg id="Ebene_1" data-name="Ebene 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512"><defs><style>.cls-1{fill:none;}.cls-2{fill:url(#Unbenannter_Verlauf_2);}.cls-3{fill:url(#Unbenannter_Verlauf_3);}</style><linearGradient id="Unbenannter_Verlauf_2" x1="191.53" y1="124.12" x2="445.95" y2="378.54" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#004c6d"/><stop offset="1" stop-color="#037a7a"/></linearGradient><linearGradient id="Unbenannter_Verlauf_3" x1="133.22" y1="194.26" x2="382.3" y2="443.34" gradientUnits="userSpaceOnUse"><stop offset="0.01" stop-color="#004c6d"/><stop offset="1" stop-color="#037a7a"/></linearGradient></defs><rect class="cls-1" width="512" height="512"/><path class="cls-2" d="M204.21,371.81l33.08-33.09c19.46,15.57,32.76,33,52.69,33,30.49,0,46.23-16.54,46.23-63V90.11H394V313.69c0,59.58-29.29,108.2-97.24,108.2C251.12,421.89,231.45,402.94,204.21,371.81Z"/><polygon class="cls-3" points="209.4 311.75 176.03 345.11 68 237.07 176.03 129.04 209.4 162.4 134.73 237.07 209.4 311.75"/></svg>
|