@open-yama/astro 0.1.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/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@open-yama/astro",
3
+ "version": "0.1.0",
4
+ "description": "Yama Astro help build personalized themes for Yama CMS",
5
+ "license": "MIT",
6
+ "author": "Yama CMS",
7
+ "type": "module",
8
+ "exports": {
9
+ ".": "./src/index.ts",
10
+ "./components/Favicons.astro": "./src/components/Favicons.astro",
11
+ "./components/GoogleFonts.astro": "./src/components/GoogleFonts.astro",
12
+ "./integrations/colors": "./src/integrations/colors.ts",
13
+ "./integrations/favicons": "./src/integrations/favicons.ts",
14
+ "./modules/colors": "./src/modules/colors.ts",
15
+ "./modules/favicons": "./src/modules/favicons.ts",
16
+ "./modules/fonts": "./src/modules/fonts.ts",
17
+ "./modules/ogImage": "./src/modules/ogImage.ts"
18
+ },
19
+ "dependencies": {
20
+ "astro": "^6.4.8",
21
+ "chalk": "^5.6.2",
22
+ "favicons": "^7.3.0",
23
+ "file-type": "^22.0.1",
24
+ "fs-extra": "^11.3.5",
25
+ "pretty-hrtime": "^1.0.3"
26
+ },
27
+ "devDependencies": {
28
+ "@types/fs-extra": "^11.0.4",
29
+ "@types/node": "^25.9.3",
30
+ "@types/pretty-hrtime": "^1.0.3"
31
+ }
32
+ }
@@ -0,0 +1,20 @@
1
+ ---
2
+ import * as path from "node:path";
3
+ import * as fs from "node:fs/promises";
4
+ import { publicDir } from "astro:config/server";
5
+
6
+
7
+
8
+ const faviconsHTMLPath = path.join(publicDir instanceof URL ? publicDir.pathname : publicDir, "favicons", "favicons.html");
9
+ let faviconsHTML = null;
10
+
11
+ try {
12
+ faviconsHTML = await fs.readFile(faviconsHTMLPath, "utf-8");
13
+ } catch (error) {
14
+ console.error(`[yama-favicon] Warning: Failed to read favicons HTML file at ${faviconsHTMLPath}`);
15
+ return;
16
+ }
17
+ ---
18
+
19
+ <!-- Favicons links to inject in the head -->
20
+ <Fragment set:html={faviconsHTML} />
@@ -0,0 +1,66 @@
1
+ ---
2
+ /**
3
+ * Code source slightly modified from : https://github.com/sebholstein/astro-google-fonts-optimizer/blob/main/packages/astro-google-fonts-optimizer/
4
+ * License : MIT
5
+ */
6
+ import { getContent } from "../modules/fonts";
7
+ import type { SiteFontsConfig } from "../modules/fonts";
8
+
9
+
10
+
11
+ export interface Props {
12
+ fontsData: SiteFontsConfig;
13
+ preload?: boolean;
14
+ }
15
+
16
+
17
+
18
+ const { fontsData, preload = true } = Astro.props as Props;
19
+
20
+ if (!fontsData?.embeddedCode) {
21
+ console.error("No Google Fonts embeddedCode provided");
22
+ return;
23
+ }
24
+
25
+ const content = await getContent(fontsData);
26
+
27
+ const isRaw = "rawEmbed" in content;
28
+ const preconnects = !isRaw ? content.preconnects : [];
29
+ const inlineCss = "css" in content ? content.css : "";
30
+ const hasCss = Boolean(inlineCss);
31
+ const sheetUrl = "url" in content ? content.url : "";
32
+ ---
33
+
34
+ {isRaw ? (
35
+ <Fragment set:html={content.rawEmbed} is:raw />
36
+ ) : (
37
+ <>
38
+ {preconnects.map(({ href, crossOrigin }) => (
39
+ <link
40
+ rel="preconnect"
41
+ href={href}
42
+ {...(crossOrigin ? { crossorigin: "anonymous" } : {})}
43
+ />
44
+ ))}
45
+ {hasCss ? (
46
+ <style set:html={inlineCss} is:inline />
47
+ ) : sheetUrl ? (
48
+ preload ? (
49
+ <>
50
+ <link
51
+ rel="preload"
52
+ as="style"
53
+ href={sheetUrl}
54
+ onload="this.onload=null;this.rel='stylesheet'"
55
+ />
56
+ <Fragment
57
+ set:html={`<noscript><link rel="stylesheet" href="${sheetUrl}"></noscript>`}
58
+ is:raw
59
+ />
60
+ </>
61
+ ) : (
62
+ <link rel="stylesheet" href={sheetUrl} />
63
+ )
64
+ ) : null}
65
+ </>
66
+ )}
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { default as GoogleFonts } from "./components/GoogleFonts.astro"
2
+ export { default as Favicons } from "./components/Favicons.astro"
3
+
@@ -0,0 +1,27 @@
1
+ import type { AstroIntegration, AstroIntegrationLogger } from "astro";
2
+ import { buildColorsCSS, type ColorsOptions } from "../modules/colors";
3
+ import path from "path";
4
+
5
+
6
+
7
+ export default function yamaColors(options: Partial<ColorsOptions> = {}): AstroIntegration {
8
+ const opts: ColorsOptions = {
9
+ input: options.input ?? "yama.config.json",
10
+ output: options.output ?? "src/styles/generated-colors.css",
11
+ fallbackColors: options.fallbackColors,
12
+ };
13
+
14
+ const buildColors = async (logger: AstroIntegrationLogger) => {
15
+ logger.info("Building color themes...");
16
+ buildColorsCSS(opts, logger);
17
+ };
18
+
19
+ return {
20
+ name: "yama-colors",
21
+ hooks: {
22
+ "astro:config:setup": async ({ addWatchFile, config }) => addWatchFile(path.resolve(config.root.pathname, opts.input)),
23
+ "astro:server:start": async ({ logger }) => buildColors(logger),
24
+ "astro:build:start": async ({ logger }) => buildColors(logger),
25
+ },
26
+ };
27
+ }
@@ -0,0 +1,42 @@
1
+ import type { FaviconOptions } from "favicons";
2
+ import type { AstroIntegration, AstroIntegrationLogger, ViteUserConfig } from "astro";
3
+
4
+ import chalk from "chalk";
5
+ import { generateFavicons } from "../modules/favicons";
6
+ import { executionTime } from "../modules/favicons";
7
+
8
+
9
+
10
+ export default function (logoPath: string, options: FaviconOptions = {}): AstroIntegration {
11
+ return {
12
+ name: "yama-favicon",
13
+ hooks: {
14
+ "astro:build:setup": async ({ logger, vite }: { logger: AstroIntegrationLogger; vite: ViteUserConfig }) => {
15
+ const execTime = executionTime({
16
+ logger,
17
+ callback: ({ words }) => logger.info(chalk.green(`✓ Completed in ${words}.`)),
18
+ });
19
+ execTime.start("yama-favicon");
20
+
21
+ if (!logoPath) {
22
+ throw new Error("No logo path provided");
23
+ }
24
+
25
+ logger.info("Generating favicons…");
26
+ await generateFavicons(logoPath, {
27
+ ...options,
28
+ path: "/favicons",
29
+ icons: {
30
+ ...options.icons,
31
+ appleStartup: false,
32
+ yandex: false,
33
+ },
34
+ publicDir: vite.publicDir as string,
35
+ logger,
36
+ });
37
+
38
+ execTime.stop("yama-favicon");
39
+ },
40
+ },
41
+ };
42
+ }
@@ -0,0 +1,75 @@
1
+ import type { AstroIntegrationLogger } from "astro";
2
+
3
+ import fs from "fs";
4
+ import path from "path";
5
+ import chalk from "chalk";
6
+
7
+
8
+
9
+ export type ColorsOptions = {
10
+ input: string;
11
+ output: string;
12
+ fallbackColors?: ColorsData;
13
+ };
14
+
15
+ export type ColorsData = {
16
+ code: string;
17
+ };
18
+
19
+ type ThemesRecord = Record<string, Record<string, string>>;
20
+
21
+
22
+
23
+ /**
24
+ * Fetch colors data from yama.config.json and parse the stringified themes
25
+ * record (e.g. `{"default":{...},"dark":{...}}`) used to build CSS variables.
26
+ */
27
+ export function getColorsData(input: string, fallbackColors?: ColorsData): ThemesRecord {
28
+ const raw = fs.readFileSync(input, "utf-8");
29
+ const config = JSON.parse(raw);
30
+ const colorsData: ColorsData | undefined = config?.site?.data?.colors ?? fallbackColors;
31
+
32
+ if (!colorsData?.code) {
33
+ throw new Error("Missing 'site.data.colors.code' in yama.config.json and no fallback colors provided");
34
+ }
35
+
36
+ return JSON.parse(colorsData.code) as ThemesRecord;
37
+ }
38
+
39
+ export function buildColorsCSS(options: ColorsOptions, logger: AstroIntegrationLogger) {
40
+ const { input, output } = options;
41
+
42
+ try {
43
+ const themes = getColorsData(input, options.fallbackColors);
44
+
45
+ /* DEFAULT THEME */
46
+ let css = `
47
+ /**
48
+ * This file is automatically generated by a script. Do not edit manually.
49
+ */
50
+
51
+ :root {
52
+ `;
53
+ for (const [key, value] of Object.entries(themes.default)) {
54
+ css += ` --color-${key}: ${value};\n`;
55
+ }
56
+ css += `}\n\n`;
57
+
58
+ /* OTHER THEMES */
59
+ for (const [themeName, vars] of Object.entries(themes)) {
60
+ if (themeName === "default") continue;
61
+ css += `[data-theme="${themeName}"] {\n`;
62
+ for (const [key, value] of Object.entries(vars as Record<string, string>)) {
63
+ css += ` --color-${key}: ${value};\n`;
64
+ }
65
+ css += `}\n\n`;
66
+ }
67
+
68
+ fs.mkdirSync(path.dirname(output), { recursive: true });
69
+ fs.writeFileSync(output, css, "utf-8");
70
+
71
+ logger.info(chalk.green(`✓ Colors CSS generated → ${output}`));
72
+ } catch (err: any) {
73
+ logger.error(chalk.red(`✗ Colors build failed: ${err.message}`));
74
+ }
75
+ }
@@ -0,0 +1,261 @@
1
+ import { favicons, type FaviconOptions } from "favicons";
2
+ import type { AstroIntegrationLogger } from "astro";
3
+
4
+ import { fileTypeFromBuffer } from "file-type";
5
+ import * as fs from "node:fs/promises";
6
+ import * as fsExtra from "fs-extra";
7
+ import * as path from "node:path";
8
+ import chalk from "chalk";
9
+ import sharp from "sharp";
10
+ import prettyHrtime from "pretty-hrtime";
11
+
12
+
13
+
14
+ type DownloadLogoOptions = {
15
+ cacheDir?: string;
16
+ logger?: AstroIntegrationLogger;
17
+ };
18
+
19
+
20
+
21
+ const DEFAULT_CACHE_DIR = "node_modules/.cache/yama/favicons";
22
+ const DEFAULT_CACHE_DIR_CONTENT = `${DEFAULT_CACHE_DIR}/content/`;
23
+ const LOGO_BASENAME = "logo";
24
+
25
+ export async function getCachedLogoPath({
26
+ cacheDir = DEFAULT_CACHE_DIR,
27
+ fileName = LOGO_BASENAME,
28
+ excludeSvg = false,
29
+ }: {
30
+ cacheDir?: string;
31
+ fileName?: string;
32
+ excludeSvg?: boolean;
33
+ } = {}) {
34
+ try {
35
+ const files = await fs.readdir(cacheDir);
36
+ const logoFile = files
37
+ .filter((file) => file.startsWith(`${fileName}.`))
38
+ .find((file) => (excludeSvg ? !file.endsWith(`.svg`) : true));
39
+
40
+ if (!logoFile) return null;
41
+
42
+ return path.join(cacheDir, logoFile);
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ // If logo is a SVG, we also generate a PNG to cover all cases (OG Image, ect.)
49
+ async function ensurePngLogoGenerated(logoPath: string): Promise<string> {
50
+ if (!logoPath.endsWith(".svg")) {
51
+ return logoPath;
52
+ }
53
+
54
+ const pngPath = logoPath.replace(/\.svg$/i, ".png");
55
+
56
+ const exists = await fs
57
+ .stat(pngPath)
58
+ .then(() => true)
59
+ .catch(() => false);
60
+ if (exists) return pngPath;
61
+
62
+ await sharp(logoPath).png().toFile(pngPath);
63
+
64
+ return pngPath;
65
+ }
66
+
67
+ async function removeCachedLogos(cacheDir: string) {
68
+ try {
69
+ const files = await fs.readdir(cacheDir);
70
+
71
+ await Promise.all(files.filter((file) => file.startsWith(LOGO_BASENAME)).map((file) => fs.unlink(path.join(cacheDir, file))));
72
+ } catch {}
73
+ }
74
+
75
+ export async function downloadLogo(url: string, { cacheDir = DEFAULT_CACHE_DIR, logger }: DownloadLogoOptions) {
76
+ const res = await fetch(url);
77
+ if (!res.ok) throw new Error(`Failed to download logo: ${url}`);
78
+
79
+ const buffer = Buffer.from(await res.arrayBuffer());
80
+ let ext: string | undefined;
81
+
82
+ const fileType = await fileTypeFromBuffer(buffer);
83
+
84
+ if (fileType) {
85
+ ext = fileType.ext;
86
+ } else {
87
+ const contentType = res.headers.get("content-type");
88
+
89
+ if (contentType?.includes("image/svg+xml")) {
90
+ ext = "svg";
91
+ }
92
+ }
93
+
94
+ if (!ext) {
95
+ throw new Error("Unable to detect logo file type");
96
+ }
97
+
98
+ const logoPath = path.join(cacheDir, `logo.${ext}`);
99
+ const cacheExists = await fs
100
+ .stat(logoPath)
101
+ .then(() => true)
102
+ .catch(() => false);
103
+ const cached = cacheExists ? await fs.readFile(logoPath) : null;
104
+ const unchanged = cached && Buffer.compare(cached, buffer) === 0;
105
+
106
+ if (unchanged) {
107
+ logger?.info(chalk.gray("Logo found in cache (unchanged)"));
108
+ return {
109
+ relativeLogoPath: logoPath,
110
+ unchanged,
111
+ };
112
+ }
113
+
114
+ await removeCachedLogos(cacheDir);
115
+ await fs.mkdir(cacheDir, { recursive: true });
116
+ await fs.writeFile(logoPath, buffer);
117
+ await ensurePngLogoGenerated(logoPath);
118
+
119
+ logger?.info(chalk.gray(cached ? "Logo updated in cache" : "Logo generated and cached"));
120
+ return {
121
+ relativeLogoPath: logoPath,
122
+ unchanged: false,
123
+ };
124
+ }
125
+
126
+ export async function generateFavicons(
127
+ logoPath: string,
128
+ options: FaviconOptions & {
129
+ publicDir: string | false;
130
+ logger?: AstroIntegrationLogger;
131
+ cacheDir?: string;
132
+ },
133
+ ) {
134
+ if (!options.publicDir) {
135
+ throw new Error("Public directory is required to generate favicons");
136
+ }
137
+ // Prevent removing the project root files by accident
138
+ if (options.path === "/") {
139
+ throw new Error(
140
+ "Favicons directory path cannot be root (`/`), you should use a subpath instead to avoid overriding the project root files and unwanted behavior, use `/favicons` for example",
141
+ );
142
+ }
143
+
144
+ const { relativeLogoPath, unchanged } = await downloadLogo(logoPath, {
145
+ logger: options.logger,
146
+ });
147
+ const cacheDir = options.cacheDir ?? DEFAULT_CACHE_DIR_CONTENT;
148
+ const cacheDirExists = await fs
149
+ .stat(cacheDir)
150
+ .then(() => true)
151
+ .catch(() => false);
152
+
153
+ const outputDir = path.join(options.publicDir, options.path!);
154
+
155
+ // Skip generation if the logo is unchanged in the cache
156
+ if (unchanged && cacheDirExists) {
157
+ await fsExtra.copy(cacheDir, outputDir);
158
+ options.logger?.info(chalk.gray("Favicons found in cache (unchanged)"));
159
+ return;
160
+ }
161
+
162
+ const response = await favicons(relativeLogoPath, options);
163
+
164
+ // Remove the existing favicons directory if it exists
165
+ await fs.rm(outputDir, { recursive: true, force: true });
166
+
167
+ // Create the favicons directory
168
+ await fs.mkdir(outputDir, { recursive: true });
169
+
170
+ // Generate the favicons images
171
+ Promise.all(
172
+ response.images.map(async (image) => {
173
+ await fs.writeFile(path.join(outputDir, image.name), image.contents);
174
+ }),
175
+ );
176
+
177
+ // Generate the favicons files (manifest, browserconfig, etc.)
178
+ Promise.all(
179
+ response.files.map(async (file) => {
180
+ await fs.writeFile(path.join(outputDir, file.name), file.contents);
181
+ }),
182
+ );
183
+
184
+ // Generate the favicons links HTML to be used in the head of the project HTML files
185
+ await fs.writeFile(path.join(outputDir, "favicons.html"), response.html.join("\n"));
186
+
187
+ // Cache the favicons content
188
+ try {
189
+ await fsExtra.copy(outputDir, cacheDir);
190
+ options.logger?.info(chalk.gray("Favicons generated and cached"));
191
+ } catch (error) {
192
+ options.logger?.error(chalk.red("Failed to cache favicons", error));
193
+ }
194
+ }
195
+
196
+ /*
197
+ *
198
+ *
199
+ * FAVICONS UTILITIES
200
+ * Code source who relies on execution time is based on [execution-time](https://github.com/svenkatreddy/execution-time) package.
201
+ */
202
+ type HrTime = [number, number];
203
+
204
+ type PerformanceEntry = {
205
+ startAt: HrTime;
206
+ };
207
+
208
+ type PerformanceResult = {
209
+ name: string;
210
+ time: number; // milliseconds
211
+ words: string;
212
+ preciseWords: string;
213
+ verboseWords: string;
214
+ };
215
+
216
+ type LoggerOptions = {
217
+ logger: AstroIntegrationLogger;
218
+ callback: ({ time, words }: { time: number; words: string }) => void;
219
+ silent?: boolean;
220
+ };
221
+
222
+
223
+
224
+ const namedPerformances: Record<string, PerformanceEntry> = {};
225
+ const defaultName = "default";
226
+
227
+ export function executionTime({ logger, callback, silent = false }: LoggerOptions) {
228
+ return {
229
+ start(name: string = defaultName): void {
230
+ namedPerformances[name] = {
231
+ startAt: process.hrtime(),
232
+ };
233
+ },
234
+
235
+ stop(name: string = defaultName): PerformanceResult {
236
+ const entry = namedPerformances[name];
237
+ if (!entry?.startAt) {
238
+ throw new Error(`Namespace: ${name} doesnt exist`);
239
+ }
240
+
241
+ const diff = process.hrtime(entry.startAt);
242
+ const time = diff[0] * 1e3 + diff[1] * 1e-6;
243
+
244
+ const words = prettyHrtime(diff).replace(" ", "");
245
+ const preciseWords = prettyHrtime(diff, { precise: true }).replace(" ", "");
246
+ const verboseWords = prettyHrtime(diff, { verbose: true }).replace(" ", "");
247
+
248
+ if (logger && callback && !silent) {
249
+ callback({ time, words });
250
+ }
251
+
252
+ return {
253
+ name,
254
+ time,
255
+ words,
256
+ preciseWords,
257
+ verboseWords,
258
+ };
259
+ },
260
+ };
261
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Inspired by https://github.com/sebholstein/astro-google-fonts-optimizer (MIT).
3
+ * Parses Google Fonts pasted HTML and inlines fetched @font-face CSS at build time.
4
+ */
5
+
6
+ export type SiteFontsConfig = {
7
+ embeddedCode: string;
8
+ body: string;
9
+ headings: string;
10
+ };
11
+
12
+ type Preconnect = { href: string; crossOrigin: boolean };
13
+
14
+
15
+
16
+ const DEFAULT_PRECONNECTS: Preconnect[] = [
17
+ { href: "https://fonts.googleapis.com", crossOrigin: false },
18
+ { href: "https://fonts.gstatic.com", crossOrigin: true },
19
+ ];
20
+
21
+ function getAttr(attrs: string, name: string): string | undefined {
22
+ const re = new RegExp(`\\b${name}\\s*=\\s*["']([^"']*)["']`, "i");
23
+ return re.exec(attrs)?.[1]?.trim();
24
+ };
25
+
26
+ function relOf(attrs: string): string | undefined {
27
+ return getAttr(attrs, "rel")?.toLowerCase();
28
+ }
29
+
30
+ /** Parse Google Fonts snippet: preconnects + primary css2 stylesheet URL. */
31
+ export function parseGoogleFontsEmbedded(html: string): { stylesheetUrl?: string; preconnects: Preconnect[] } {
32
+ const preconnects: Preconnect[] = [];
33
+ let stylesheetUrl: string | undefined;
34
+ const linkTagRegex = /<link\s+([^>]+)>/gi;
35
+
36
+ for (const m of html.matchAll(linkTagRegex)) {
37
+ const attrs = m[1];
38
+ const href = getAttr(attrs, "href");
39
+ if (!href) continue;
40
+
41
+ const rel = relOf(attrs);
42
+ if (rel === "preconnect") {
43
+ preconnects.push({ href, crossOrigin: /\bcrossorigin\b/i.test(attrs) });
44
+ } else if (rel === "stylesheet" && href.includes("fonts.googleapis.com")) {
45
+ if (!stylesheetUrl) stylesheetUrl = href;
46
+ }
47
+ }
48
+
49
+ return { stylesheetUrl, preconnects };
50
+ };
51
+
52
+ const userAgents: { name: string; ua: string }[] = [
53
+ // this must always be the first element here!
54
+ {
55
+ name: "woff",
56
+ ua: "Mozilla/5.0 (Windows NT 10.0; Trident/7.0; rv:11.0) like Gecko",
57
+ },
58
+ // from: https://github.com/fontsource/google-font-metadata/blob/main/data/user-agents.json
59
+ {
60
+ name: "woff2",
61
+ ua: 'Mozilla/5.0 (Windows NT 6.3; rv:39.0) Gecko/20100101 Firefox/44.0"',
62
+ },
63
+ ];
64
+
65
+ export async function downloadFontCSS(url: string): Promise<string | undefined> {
66
+ if (!url) {
67
+ throw new Error("No Google Fonts URL provided");
68
+ }
69
+
70
+ const chunks = await Promise.all(
71
+ userAgents.map(async (entry) => {
72
+ const res = await fetch(url, {
73
+ headers: { "User-Agent": entry.ua },
74
+ });
75
+ if (!res.ok) throw new Error(res.statusText);
76
+ const text = await res.text();
77
+ return text.replace(/ {2,}/g, "").replace(/\t+/g, "").replace(/\n+/g, "");
78
+ }),
79
+ );
80
+
81
+ return chunks.join(" ");
82
+ };
83
+
84
+
85
+ /*
86
+ *
87
+ *
88
+ * FONTS UTILITIES
89
+ *
90
+ */
91
+
92
+ export type FontsHeadContent =
93
+ | { rawEmbed: string }
94
+ | { css: string; preconnects: Preconnect[] }
95
+ | { url: string; preconnects: Preconnect[] };
96
+
97
+
98
+
99
+ export async function getContent(fontsData: SiteFontsConfig): Promise<FontsHeadContent> {
100
+ const { stylesheetUrl, preconnects: parsed } = parseGoogleFontsEmbedded(fontsData.embeddedCode);
101
+ const preconnects = parsed.length > 0 ? parsed : DEFAULT_PRECONNECTS;
102
+
103
+ if (!stylesheetUrl) {
104
+ console.warn("[fonts] No fonts.googleapis.com stylesheet in embeddedCode; injecting raw snippet.");
105
+ return { rawEmbed: fontsData.embeddedCode };
106
+ }
107
+
108
+ try {
109
+ const css = await downloadFontCSS(stylesheetUrl);
110
+ if (css) {
111
+ return { css, preconnects };
112
+ }
113
+ return { url: stylesheetUrl, preconnects };
114
+ } catch (err) {
115
+ console.error("[fonts] Downloading CSS from Google Fonts:", err);
116
+ return { url: stylesheetUrl, preconnects };
117
+ }
118
+ };
@@ -0,0 +1,125 @@
1
+ import * as path from "node:path";
2
+ import { downloadLogo } from "./favicons.ts";
3
+
4
+
5
+
6
+ const REMOTE_URL = /^https?:\/\//i;
7
+ const cache = new Map<string, Promise<string>>();
8
+
9
+ /**
10
+ * Resolve a logo source (remote URL or local path) to a local file path
11
+ * suitable for `astro-og-canvas`. Remote logos are downloaded once and
12
+ * cached; SVG sources fall back to the auto-generated PNG companion since
13
+ * `canvaskit-wasm` does not render SVG natively.
14
+ */
15
+ export function getLocalLogoPath(source: string): Promise<string> {
16
+ const cached = cache.get(source);
17
+ if (cached) return cached;
18
+
19
+ const promise = resolveLogoPath(source);
20
+ cache.set(source, promise);
21
+ return promise;
22
+ }
23
+
24
+ async function resolveLogoPath(source: string): Promise<string> {
25
+ if (!REMOTE_URL.test(source)) {
26
+ return path.resolve(source);
27
+ }
28
+
29
+ const { relativeLogoPath } = await downloadLogo(source, {});
30
+
31
+ // `downloadLogo` already emits a PNG companion next to SVG sources.
32
+ return relativeLogoPath.toLowerCase().endsWith(".svg") ? relativeLogoPath.replace(/\.svg$/i, ".png") : relativeLogoPath;
33
+ }
34
+
35
+ /*
36
+ *
37
+ *
38
+ * COLORS UTILITIES
39
+ * All these functions and formats are compatible and made for the package "astro-og-canvas"
40
+ */
41
+
42
+ export type RGB = [number, number, number];
43
+
44
+
45
+
46
+ /**
47
+ * Convert a CSS color string (`#hex`, `hsl(...)`, `rgb(...)`) or an already
48
+ * parsed `[R, G, B]` tuple into a `[R, G, B]` tuple consumable by
49
+ * `astro-og-canvas`.
50
+ */
51
+ export function toRGB(color: string | RGB): RGB {
52
+ if (Array.isArray(color)) return color;
53
+
54
+ const value = color.trim().toLowerCase();
55
+ if (value.startsWith("#")) return hexToRGB(value);
56
+ if (value.startsWith("hsl")) return hslToRGB(value);
57
+ if (value.startsWith("rgb")) return rgbStringToRGB(value);
58
+
59
+ throw new Error(`Unsupported color format: ${color}`);
60
+ }
61
+
62
+ function hexToRGB(hex: string): RGB {
63
+ let h = hex.replace("#", "");
64
+ // Expand shorthand (#RGB / #RGBA) to full form.
65
+ if (h.length === 3 || h.length === 4) {
66
+ h = h
67
+ .split("")
68
+ .map((c) => c + c)
69
+ .join("");
70
+ }
71
+ if (h.length !== 6 && h.length !== 8) {
72
+ throw new Error(`Invalid hex color: ${hex}`);
73
+ }
74
+ return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
75
+ }
76
+
77
+ function hslToRGB(hsl: string): RGB {
78
+ const match = hsl.match(/hsla?\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)%\s*,\s*(\d+(?:\.\d+)?)%/);
79
+ if (!match) throw new Error(`Invalid HSL color: ${hsl}`);
80
+
81
+ const h = (((Number(match[1]) % 360) + 360) % 360) / 360;
82
+ const s = Number(match[2]) / 100;
83
+ const l = Number(match[3]) / 100;
84
+
85
+ if (s === 0) {
86
+ const v = Math.round(l * 255);
87
+ return [v, v, v];
88
+ }
89
+
90
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
91
+ const p = 2 * l - q;
92
+
93
+ return [
94
+ Math.round(hueToChannel(p, q, h + 1 / 3) * 255),
95
+ Math.round(hueToChannel(p, q, h) * 255),
96
+ Math.round(hueToChannel(p, q, h - 1 / 3) * 255),
97
+ ];
98
+ }
99
+
100
+ function hueToChannel(p: number, q: number, t: number): number {
101
+ let v = t;
102
+ if (v < 0) v += 1;
103
+ if (v > 1) v -= 1;
104
+ if (v < 1 / 6) return p + (q - p) * 6 * v;
105
+ if (v < 1 / 2) return q;
106
+ if (v < 2 / 3) return p + (q - p) * (2 / 3 - v) * 6;
107
+ return p;
108
+ }
109
+
110
+ function rgbStringToRGB(rgb: string): RGB {
111
+ const match = rgb.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
112
+ if (!match) throw new Error(`Invalid RGB color: ${rgb}`);
113
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
114
+ }
115
+
116
+ /*
117
+ *
118
+ *
119
+ * OG IMAGE UTILITIES
120
+ *
121
+ */
122
+
123
+ export function getOgImageURL(origin: string, permalink: string) {
124
+ return new URL(`/open-graph${permalink}.png`, origin).toString();
125
+ };