@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 +32 -0
- package/src/components/Favicons.astro +20 -0
- package/src/components/GoogleFonts.astro +66 -0
- package/src/index.ts +3 -0
- package/src/integrations/colors.ts +27 -0
- package/src/integrations/favicons.ts +42 -0
- package/src/modules/colors.ts +75 -0
- package/src/modules/favicons.ts +261 -0
- package/src/modules/fonts.ts +118 -0
- package/src/modules/ogImage.ts +125 -0
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,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
|
+
};
|