diff --git a/.changeset/favicon-generation.md b/.changeset/favicon-generation.md new file mode 100644 index 00000000..aad70e93 --- /dev/null +++ b/.changeset/favicon-generation.md @@ -0,0 +1,15 @@ +--- +"hyperbook": minor +"@hyperbook/markdown": patch +--- + +Add automatic favicon and PWA icon generation from logo + +When building a Hyperbook project, if no favicon.ico exists and a logo is defined in hyperbook.json, a complete set of favicons and PWA assets are automatically generated: + +- Generates 60+ files including favicon.ico, Android icons, Apple touch icons, and Apple startup images +- Creates web manifest with full PWA metadata (theme color, scope, language, developer info) +- Smart logo path resolution: checks root folder, book folder, and public folder +- Adds favicon, Apple touch icon, and manifest links to all HTML pages +- Uses hyperbook.json metadata: name, description, colors.brand, basePath, language, author +- Backward compatible: copies favicon.ico to root for browsers expecting it there diff --git a/packages/hyperbook/build.ts b/packages/hyperbook/build.ts index 934b2c95..bfd24b99 100644 --- a/packages/hyperbook/build.ts +++ b/packages/hyperbook/build.ts @@ -378,6 +378,84 @@ async function runBuild( } process.stdout.write("\n"); + // Generate favicons if logo exists and no favicon.ico is present + const faviconPath = path.join(rootOut, "favicon.ico"); + let faviconExists = false; + try { + await fs.access(faviconPath); + faviconExists = true; + } catch (e) { + // Favicon doesn't exist + } + + if (!faviconExists && hyperbookJson.logo) { + console.log(`${chalk.blue(`[${prefix}]`)} Generating favicons from logo.`); + + // Only generate if logo is a local file (not a URL) + if (!hyperbookJson.logo.includes("://")) { + let logoPath: string | null = null; + + // Resolve logo path by checking multiple locations + if (hyperbookJson.logo.startsWith("/")) { + // Absolute path starting with / - check book folder, then public folder + const bookPath = path.join(root, "book", hyperbookJson.logo); + const publicPath = path.join(root, "public", hyperbookJson.logo); + + try { + await fs.access(bookPath); + logoPath = bookPath; + } catch (e) { + try { + await fs.access(publicPath); + logoPath = publicPath; + } catch (e2) { + // Not found in either location + } + } + } else { + // Relative path - check root folder, then book folder, then public folder + const rootPath = path.join(root, hyperbookJson.logo); + const bookPath = path.join(root, "book", hyperbookJson.logo); + const publicPath = path.join(root, "public", hyperbookJson.logo); + + try { + await fs.access(rootPath); + logoPath = rootPath; + } catch (e) { + try { + await fs.access(bookPath); + logoPath = bookPath; + } catch (e2) { + try { + await fs.access(publicPath); + logoPath = publicPath; + } catch (e3) { + // Not found in any location + } + } + } + } + + if (logoPath) { + try { + const { generateFavicons } = await import("./helpers/generate-favicons"); + await generateFavicons(logoPath, rootOut, hyperbookJson, ASSETS_FOLDER); + console.log( + `${chalk.green(`[${prefix}]`)} Favicons generated successfully.`, + ); + } catch (e) { + console.log( + `${chalk.yellow(`[${prefix}]`)} Warning: Could not generate favicons. Error: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } else { + console.log( + `${chalk.yellow(`[${prefix}]`)} Warning: Could not generate favicons. Logo file not found: ${hyperbookJson.logo}`, + ); + } + } + } + i = 1; for (let directive of directives) { const assetsDirectivePath = path.join(assetsPath, `directive-${directive}`); diff --git a/packages/hyperbook/helpers/generate-favicons.ts b/packages/hyperbook/helpers/generate-favicons.ts new file mode 100644 index 00000000..3e945fa1 --- /dev/null +++ b/packages/hyperbook/helpers/generate-favicons.ts @@ -0,0 +1,68 @@ +import path from "path"; +import fs from "fs/promises"; +import { makeDir } from "./make-dir"; +import { HyperbookJson } from "@hyperbook/types"; + +export async function generateFavicons( + logoPath: string, + outputDir: string, + hyperbookConfig: HyperbookJson, + assetsFolder: string, +): Promise { + // Dynamic import to avoid bundling issues with sharp + const { favicons } = await import("favicons"); + + // Construct the favicon path relative to the base path + const faviconPath = path.posix.join( + hyperbookConfig.basePath || "/", + assetsFolder, + "favicons" + ); + + const config = { + path: faviconPath, + appName: hyperbookConfig.name, + appShortName: hyperbookConfig.name, + appDescription: hyperbookConfig.description || hyperbookConfig.name, + developerName: hyperbookConfig.author?.name, + developerURL: hyperbookConfig.author?.url, + lang: hyperbookConfig.language || "en", + background: "#fff", + theme_color: hyperbookConfig.colors?.brand || "#007864", + display: "standalone", + orientation: "any", + scope: hyperbookConfig.basePath || "/", + start_url: hyperbookConfig.basePath || "/", + version: "1.0", + icons: { + android: true, + appleIcon: true, + appleStartup: true, + favicons: true, + windows: false, + yandex: false, + }, + }; + + const response = await favicons(logoPath, config); + + // Create output directory for favicons + const faviconsDir = path.join(outputDir, assetsFolder, "favicons"); + await makeDir(faviconsDir, { recursive: true }); + + // Write favicon image files + for (const file of response.images) { + await fs.writeFile(path.join(faviconsDir, file.name), file.contents); + } + + // Write manifest and other files + for (const file of response.files) { + await fs.writeFile(path.join(faviconsDir, file.name), file.contents); + } + + // Also copy favicon.ico to the root for backward compatibility + const faviconIco = response.images.find(img => img.name === "favicon.ico"); + if (faviconIco) { + await fs.writeFile(path.join(outputDir, "favicon.ico"), faviconIco.contents); + } +} diff --git a/packages/hyperbook/package.json b/packages/hyperbook/package.json index 5e3641c9..c054cf74 100644 --- a/packages/hyperbook/package.json +++ b/packages/hyperbook/package.json @@ -28,7 +28,10 @@ "version": "pnpm build", "lint": "tsc --noEmit", "dev": "ncc build ./index.ts -w -o dist/", - "build": "rimraf dist && ncc build ./index.ts -o ./dist/ --no-cache --no-source-map-register && node postbuild.mjs" + "build": "rimraf dist && ncc build ./index.ts -o ./dist/ --no-cache --no-source-map-register --external favicons --external sharp && node postbuild.mjs" + }, + "dependencies": { + "favicons": "^7.2.0" }, "devDependencies": { "@hyperbook/fs": "workspace:*", diff --git a/packages/hyperbook/tsconfig.json b/packages/hyperbook/tsconfig.json index e4edad9e..7a5b5774 100644 --- a/packages/hyperbook/tsconfig.json +++ b/packages/hyperbook/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "target": "es2019", + "module": "commonjs", "moduleResolution": "node", "strict": true, "resolveJsonModule": true, diff --git a/packages/markdown/src/rehypeHtmlStructure.ts b/packages/markdown/src/rehypeHtmlStructure.ts index 168345fa..60b0ea8c 100644 --- a/packages/markdown/src/rehypeHtmlStructure.ts +++ b/packages/markdown/src/rehypeHtmlStructure.ts @@ -270,6 +270,35 @@ export default (ctx: HyperbookContext) => () => { }, children: [], }, + { + type: "element", + tagName: "link", + properties: { + rel: "icon", + type: "image/x-icon", + href: makeUrl(["favicon.ico"], "public"), + }, + children: [], + }, + { + type: "element", + tagName: "link", + properties: { + rel: "apple-touch-icon", + sizes: "180x180", + href: makeUrl(["favicons", "apple-touch-icon.png"], "assets"), + }, + children: [], + }, + { + type: "element", + tagName: "link", + properties: { + rel: "manifest", + href: makeUrl(["favicons", "manifest.webmanifest"], "assets"), + }, + children: [], + }, { type: "element", tagName: "link", diff --git a/packages/markdown/tests/__snapshots__/process.test.ts.snap b/packages/markdown/tests/__snapshots__/process.test.ts.snap index 789f932a..daae9ec1 100644 --- a/packages/markdown/tests/__snapshots__/process.test.ts.snap +++ b/packages/markdown/tests/__snapshots__/process.test.ts.snap @@ -1,7 +1,7 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`process > should add showLineNumbers 1`] = ` -"Markdown Referenz - Hyperbook Dokumenation