summaryrefslogtreecommitdiff
path: root/src/utils
diff options
context:
space:
mode:
Diffstat (limited to 'src/utils')
-rw-r--r--src/utils/blog.ts281
-rw-r--r--src/utils/directories.ts18
-rw-r--r--src/utils/frontmatter.ts50
-rw-r--r--src/utils/images-optimization.ts351
-rw-r--r--src/utils/images.ts111
-rw-r--r--src/utils/permalinks.ts134
-rw-r--r--src/utils/utils.ts52
7 files changed, 997 insertions, 0 deletions
diff --git a/src/utils/blog.ts b/src/utils/blog.ts
new file mode 100644
index 0000000..d0fa4e2
--- /dev/null
+++ b/src/utils/blog.ts
@@ -0,0 +1,281 @@
+import type { PaginateFunction } from 'astro';
+import { getCollection, render } from 'astro:content';
+import type { CollectionEntry } from 'astro:content';
+import type { Post } from '~/types';
+import { APP_BLOG } from 'astrowind:config';
+import { cleanSlug, trimSlash, BLOG_BASE, POST_PERMALINK_PATTERN, CATEGORY_BASE, TAG_BASE } from './permalinks';
+
+const generatePermalink = async ({
+ id,
+ slug,
+ publishDate,
+ category,
+}: {
+ id: string;
+ slug: string;
+ publishDate: Date;
+ category: string | undefined;
+}) => {
+ const year = String(publishDate.getFullYear()).padStart(4, '0');
+ const month = String(publishDate.getMonth() + 1).padStart(2, '0');
+ const day = String(publishDate.getDate()).padStart(2, '0');
+ const hour = String(publishDate.getHours()).padStart(2, '0');
+ const minute = String(publishDate.getMinutes()).padStart(2, '0');
+ const second = String(publishDate.getSeconds()).padStart(2, '0');
+
+ const permalink = POST_PERMALINK_PATTERN.replace('%slug%', slug)
+ .replace('%id%', id)
+ .replace('%category%', category || '')
+ .replace('%year%', year)
+ .replace('%month%', month)
+ .replace('%day%', day)
+ .replace('%hour%', hour)
+ .replace('%minute%', minute)
+ .replace('%second%', second);
+
+ return permalink
+ .split('/')
+ .map((el) => trimSlash(el))
+ .filter((el) => !!el)
+ .join('/');
+};
+
+const getNormalizedPost = async (post: CollectionEntry<'post'>): Promise<Post> => {
+ const { id, data } = post;
+ const { Content, remarkPluginFrontmatter } = await render(post);
+
+ const {
+ publishDate: rawPublishDate = new Date(),
+ updateDate: rawUpdateDate,
+ title,
+ excerpt,
+ image,
+ tags: rawTags = [],
+ category: rawCategory,
+ author,
+ draft = false,
+ metadata = {},
+ } = data;
+
+ const slug = cleanSlug(id); // cleanSlug(rawSlug.split('/').pop());
+ const publishDate = new Date(rawPublishDate);
+ const updateDate = rawUpdateDate ? new Date(rawUpdateDate) : undefined;
+
+ const category = rawCategory
+ ? {
+ slug: cleanSlug(rawCategory),
+ title: rawCategory,
+ }
+ : undefined;
+
+ const tags = rawTags.map((tag: string) => ({
+ slug: cleanSlug(tag),
+ title: tag,
+ }));
+
+ return {
+ id: id,
+ slug: slug,
+ permalink: await generatePermalink({ id, slug, publishDate, category: category?.slug }),
+
+ publishDate: publishDate,
+ updateDate: updateDate,
+
+ title: title,
+ excerpt: excerpt,
+ image: image,
+
+ category: category,
+ tags: tags,
+ author: author,
+
+ draft: draft,
+
+ metadata,
+
+ Content: Content,
+ // or 'content' in case you consume from API
+
+ readingTime: remarkPluginFrontmatter?.readingTime,
+ };
+};
+
+const load = async function (): Promise<Array<Post>> {
+ const posts = await getCollection('post');
+ const normalizedPosts = posts.map(async (post) => await getNormalizedPost(post));
+
+ const results = (await Promise.all(normalizedPosts))
+ .sort((a, b) => b.publishDate.valueOf() - a.publishDate.valueOf())
+ .filter((post) => !post.draft);
+
+ return results;
+};
+
+let _posts: Array<Post>;
+
+/** */
+export const isBlogEnabled = APP_BLOG.isEnabled;
+export const isRelatedPostsEnabled = APP_BLOG.isRelatedPostsEnabled;
+export const isBlogListRouteEnabled = APP_BLOG.list.isEnabled;
+export const isBlogPostRouteEnabled = APP_BLOG.post.isEnabled;
+export const isBlogCategoryRouteEnabled = APP_BLOG.category.isEnabled;
+export const isBlogTagRouteEnabled = APP_BLOG.tag.isEnabled;
+
+export const blogListRobots = APP_BLOG.list.robots;
+export const blogPostRobots = APP_BLOG.post.robots;
+export const blogCategoryRobots = APP_BLOG.category.robots;
+export const blogTagRobots = APP_BLOG.tag.robots;
+
+export const blogPostsPerPage = APP_BLOG?.postsPerPage;
+
+/** */
+export const fetchPosts = async (): Promise<Array<Post>> => {
+ if (!_posts) {
+ _posts = await load();
+ }
+
+ return _posts;
+};
+
+/** */
+export const findPostsBySlugs = async (slugs: Array<string>): Promise<Array<Post>> => {
+ if (!Array.isArray(slugs)) return [];
+
+ const posts = await fetchPosts();
+
+ return slugs.reduce(function (r: Array<Post>, slug: string) {
+ posts.some(function (post: Post) {
+ return slug === post.slug && r.push(post);
+ });
+ return r;
+ }, []);
+};
+
+/** */
+export const findPostsByIds = async (ids: Array<string>): Promise<Array<Post>> => {
+ if (!Array.isArray(ids)) return [];
+
+ const posts = await fetchPosts();
+
+ return ids.reduce(function (r: Array<Post>, id: string) {
+ posts.some(function (post: Post) {
+ return id === post.id && r.push(post);
+ });
+ return r;
+ }, []);
+};
+
+/** */
+export const findLatestPosts = async ({ count }: { count?: number }): Promise<Array<Post>> => {
+ const _count = count || 4;
+ const posts = await fetchPosts();
+
+ return posts ? posts.slice(0, _count) : [];
+};
+
+/** */
+export const getStaticPathsBlogList = async ({ paginate }: { paginate: PaginateFunction }) => {
+ if (!isBlogEnabled || !isBlogListRouteEnabled) return [];
+ return paginate(await fetchPosts(), {
+ params: { blog: BLOG_BASE || undefined },
+ pageSize: blogPostsPerPage,
+ });
+};
+
+/** */
+export const getStaticPathsBlogPost = async () => {
+ if (!isBlogEnabled || !isBlogPostRouteEnabled) return [];
+ return (await fetchPosts()).flatMap((post) => ({
+ params: {
+ blog: post.permalink,
+ },
+ props: { post },
+ }));
+};
+
+/** */
+export const getStaticPathsBlogCategory = async ({ paginate }: { paginate: PaginateFunction }) => {
+ if (!isBlogEnabled || !isBlogCategoryRouteEnabled) return [];
+
+ const posts = await fetchPosts();
+ const categories = {};
+ posts.map((post) => {
+ if (post.category?.slug) {
+ categories[post.category?.slug] = post.category;
+ }
+ });
+
+ return Array.from(Object.keys(categories)).flatMap((categorySlug) =>
+ paginate(
+ posts.filter((post) => post.category?.slug && categorySlug === post.category?.slug),
+ {
+ params: { category: categorySlug, blog: CATEGORY_BASE || undefined },
+ pageSize: blogPostsPerPage,
+ props: { category: categories[categorySlug] },
+ }
+ )
+ );
+};
+
+/** */
+export const getStaticPathsBlogTag = async ({ paginate }: { paginate: PaginateFunction }) => {
+ if (!isBlogEnabled || !isBlogTagRouteEnabled) return [];
+
+ const posts = await fetchPosts();
+ const tags = {};
+ posts.map((post) => {
+ if (Array.isArray(post.tags)) {
+ post.tags.map((tag) => {
+ tags[tag?.slug] = tag;
+ });
+ }
+ });
+
+ return Array.from(Object.keys(tags)).flatMap((tagSlug) =>
+ paginate(
+ posts.filter((post) => Array.isArray(post.tags) && post.tags.find((elem) => elem.slug === tagSlug)),
+ {
+ params: { tag: tagSlug, blog: TAG_BASE || undefined },
+ pageSize: blogPostsPerPage,
+ props: { tag: tags[tagSlug] },
+ }
+ )
+ );
+};
+
+/** */
+export async function getRelatedPosts(originalPost: Post, maxResults: number = 4): Promise<Post[]> {
+ const allPosts = await fetchPosts();
+ const originalTagsSet = new Set(originalPost.tags ? originalPost.tags.map((tag) => tag.slug) : []);
+
+ const postsWithScores = allPosts.reduce((acc: { post: Post; score: number }[], iteratedPost: Post) => {
+ if (iteratedPost.slug === originalPost.slug) return acc;
+
+ let score = 0;
+ if (iteratedPost.category && originalPost.category && iteratedPost.category.slug === originalPost.category.slug) {
+ score += 5;
+ }
+
+ if (iteratedPost.tags) {
+ iteratedPost.tags.forEach((tag) => {
+ if (originalTagsSet.has(tag.slug)) {
+ score += 1;
+ }
+ });
+ }
+
+ acc.push({ post: iteratedPost, score });
+ return acc;
+ }, []);
+
+ postsWithScores.sort((a, b) => b.score - a.score);
+
+ const selectedPosts: Post[] = [];
+ let i = 0;
+ while (selectedPosts.length < maxResults && i < postsWithScores.length) {
+ selectedPosts.push(postsWithScores[i].post);
+ i++;
+ }
+
+ return selectedPosts;
+}
diff --git a/src/utils/directories.ts b/src/utils/directories.ts
new file mode 100644
index 0000000..b754797
--- /dev/null
+++ b/src/utils/directories.ts
@@ -0,0 +1,18 @@
+import path from 'path';
+import { fileURLToPath } from 'url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+/** */
+export const getProjectRootDir = (): string => {
+ const mode = import.meta.env.MODE;
+
+ return mode === 'production' ? path.join(__dirname, '../') : path.join(__dirname, '../../');
+};
+
+const __srcFolder = path.join(getProjectRootDir(), '/src');
+
+/** */
+export const getRelativeUrlByFilePath = (filepath: string): string => {
+ return filepath.replace(__srcFolder, '');
+};
diff --git a/src/utils/frontmatter.ts b/src/utils/frontmatter.ts
new file mode 100644
index 0000000..bf33632
--- /dev/null
+++ b/src/utils/frontmatter.ts
@@ -0,0 +1,50 @@
+import getReadingTime from 'reading-time';
+import { toString } from 'mdast-util-to-string';
+import { visit } from 'unist-util-visit';
+import type { RehypePlugin, RemarkPlugin } from '@astrojs/markdown-remark';
+
+export const readingTimeRemarkPlugin: RemarkPlugin = () => {
+ return function (tree, file) {
+ const textOnPage = toString(tree);
+ const readingTime = Math.ceil(getReadingTime(textOnPage).minutes);
+
+ if (typeof file?.data?.astro?.frontmatter !== 'undefined') {
+ file.data.astro.frontmatter.readingTime = readingTime;
+ }
+ };
+};
+
+export const responsiveTablesRehypePlugin: RehypePlugin = () => {
+ return function (tree) {
+ if (!tree.children) return;
+
+ for (let i = 0; i < tree.children.length; i++) {
+ const child = tree.children[i];
+
+ if (child.type === 'element' && child.tagName === 'table') {
+ tree.children[i] = {
+ type: 'element',
+ tagName: 'div',
+ properties: {
+ style: 'overflow:auto',
+ },
+ children: [child],
+ };
+
+ i++;
+ }
+ }
+ };
+};
+
+export const lazyImagesRehypePlugin: RehypePlugin = () => {
+ return function (tree) {
+ if (!tree.children) return;
+
+ visit(tree, 'element', function (node) {
+ if (node.tagName === 'img') {
+ node.properties.loading = 'lazy';
+ }
+ });
+ };
+};
diff --git a/src/utils/images-optimization.ts b/src/utils/images-optimization.ts
new file mode 100644
index 0000000..67eea8b
--- /dev/null
+++ b/src/utils/images-optimization.ts
@@ -0,0 +1,351 @@
+import { getImage } from 'astro:assets';
+import { transformUrl, parseUrl } from 'unpic';
+
+import type { ImageMetadata } from 'astro';
+import type { HTMLAttributes } from 'astro/types';
+
+type Layout = 'fixed' | 'constrained' | 'fullWidth' | 'cover' | 'responsive' | 'contained';
+
+export interface ImageProps extends Omit<HTMLAttributes<'img'>, 'src'> {
+ src?: string | ImageMetadata | null;
+ width?: string | number | null;
+ height?: string | number | null;
+ alt?: string | null;
+ loading?: 'eager' | 'lazy' | null;
+ decoding?: 'sync' | 'async' | 'auto' | null;
+ style?: string;
+ srcset?: string | null;
+ sizes?: string | null;
+ fetchpriority?: 'high' | 'low' | 'auto' | null;
+
+ layout?: Layout;
+ widths?: number[] | null;
+ aspectRatio?: string | number | null;
+ objectPosition?: string;
+
+ format?: string;
+}
+
+export type ImagesOptimizer = (
+ image: ImageMetadata | string,
+ breakpoints: number[],
+ width?: number,
+ height?: number,
+ format?: string
+) => Promise<Array<{ src: string; width: number }>>;
+
+/* ******* */
+const config = {
+ // FIXME: Use this when image.width is minor than deviceSizes
+ imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
+
+ deviceSizes: [
+ 640, // older and lower-end phones
+ 750, // iPhone 6-8
+ 828, // iPhone XR/11
+ 960, // older horizontal phones
+ 1080, // iPhone 6-8 Plus
+ 1280, // 720p
+ 1668, // Various iPads
+ 1920, // 1080p
+ 2048, // QXGA
+ 2560, // WQXGA
+ 3200, // QHD+
+ 3840, // 4K
+ 4480, // 4.5K
+ 5120, // 5K
+ 6016, // 6K
+ ],
+
+ formats: ['image/webp'],
+};
+
+const computeHeight = (width: number, aspectRatio: number) => {
+ return Math.floor(width / aspectRatio);
+};
+
+const parseAspectRatio = (aspectRatio: number | string | null | undefined): number | undefined => {
+ if (typeof aspectRatio === 'number') return aspectRatio;
+
+ if (typeof aspectRatio === 'string') {
+ const match = aspectRatio.match(/(\d+)\s*[/:]\s*(\d+)/);
+
+ if (match) {
+ const [, num, den] = match.map(Number);
+ if (den && !isNaN(num)) return num / den;
+ } else {
+ const numericValue = parseFloat(aspectRatio);
+ if (!isNaN(numericValue)) return numericValue;
+ }
+ }
+
+ return undefined;
+};
+
+/**
+ * Gets the `sizes` attribute for an image, based on the layout and width
+ */
+export const getSizes = (width?: number, layout?: Layout): string | undefined => {
+ if (!width || !layout) {
+ return undefined;
+ }
+ switch (layout) {
+ // If screen is wider than the max size, image width is the max size,
+ // otherwise it's the width of the screen
+ case `constrained`:
+ return `(min-width: ${width}px) ${width}px, 100vw`;
+
+ // Image is always the same width, whatever the size of the screen
+ case `fixed`:
+ return `${width}px`;
+
+ // Image is always the width of the screen
+ case `fullWidth`:
+ return `100vw`;
+
+ default:
+ return undefined;
+ }
+};
+
+const pixelate = (value?: number) => (value || value === 0 ? `${value}px` : undefined);
+
+const getStyle = ({
+ width,
+ height,
+ aspectRatio,
+ layout,
+ objectFit = 'cover',
+ objectPosition = 'center',
+ background,
+}: {
+ width?: number;
+ height?: number;
+ aspectRatio?: number;
+ objectFit?: string;
+ objectPosition?: string;
+ layout?: string;
+ background?: string;
+}) => {
+ const styleEntries: Array<[prop: string, value: string | undefined]> = [
+ ['object-fit', objectFit],
+ ['object-position', objectPosition],
+ ];
+
+ // If background is a URL, set it to cover the image and not repeat
+ if (background?.startsWith('https:') || background?.startsWith('http:') || background?.startsWith('data:')) {
+ styleEntries.push(['background-image', `url(${background})`]);
+ styleEntries.push(['background-size', 'cover']);
+ styleEntries.push(['background-repeat', 'no-repeat']);
+ } else {
+ styleEntries.push(['background', background]);
+ }
+ if (layout === 'fixed') {
+ styleEntries.push(['width', pixelate(width)]);
+ styleEntries.push(['height', pixelate(height)]);
+ styleEntries.push(['object-position', 'top left']);
+ }
+ if (layout === 'constrained') {
+ styleEntries.push(['max-width', pixelate(width)]);
+ styleEntries.push(['max-height', pixelate(height)]);
+ styleEntries.push(['aspect-ratio', aspectRatio ? `${aspectRatio}` : undefined]);
+ styleEntries.push(['width', '100%']);
+ }
+ if (layout === 'fullWidth') {
+ styleEntries.push(['width', '100%']);
+ styleEntries.push(['aspect-ratio', aspectRatio ? `${aspectRatio}` : undefined]);
+ styleEntries.push(['height', pixelate(height)]);
+ }
+ if (layout === 'responsive') {
+ styleEntries.push(['width', '100%']);
+ styleEntries.push(['height', 'auto']);
+ styleEntries.push(['aspect-ratio', aspectRatio ? `${aspectRatio}` : undefined]);
+ }
+ if (layout === 'contained') {
+ styleEntries.push(['max-width', '100%']);
+ styleEntries.push(['max-height', '100%']);
+ styleEntries.push(['object-fit', 'contain']);
+ styleEntries.push(['aspect-ratio', aspectRatio ? `${aspectRatio}` : undefined]);
+ }
+ if (layout === 'cover') {
+ styleEntries.push(['max-width', '100%']);
+ styleEntries.push(['max-height', '100%']);
+ }
+
+ const styles = Object.fromEntries(styleEntries.filter(([, value]) => value));
+
+ return Object.entries(styles)
+ .map(([key, value]) => `${key}: ${value};`)
+ .join(' ');
+};
+
+const getBreakpoints = ({
+ width,
+ breakpoints,
+ layout,
+}: {
+ width?: number;
+ breakpoints?: number[];
+ layout: Layout;
+}): number[] => {
+ if (layout === 'fullWidth' || layout === 'cover' || layout === 'responsive' || layout === 'contained') {
+ return breakpoints || config.deviceSizes;
+ }
+ if (!width) {
+ return [];
+ }
+ const doubleWidth = width * 2;
+ if (layout === 'fixed') {
+ return [width, doubleWidth];
+ }
+ if (layout === 'constrained') {
+ return [
+ // Always include the image at 1x and 2x the specified width
+ width,
+ doubleWidth,
+ // Filter out any resolutions that are larger than the double-res image
+ ...(breakpoints || config.deviceSizes).filter((w) => w < doubleWidth),
+ ];
+ }
+
+ return [];
+};
+
+/* ** */
+export const astroAssetsOptimizer: ImagesOptimizer = async (
+ image,
+ breakpoints,
+ _width,
+ _height,
+ format = undefined
+) => {
+ if (!image) {
+ return [];
+ }
+
+ return Promise.all(
+ breakpoints.map(async (w: number) => {
+ const result = await getImage({ src: image, width: w, inferSize: true, ...(format ? { format: format } : {}) });
+
+ return {
+ src: result?.src,
+ width: result?.attributes?.width ?? w,
+ height: result?.attributes?.height,
+ };
+ })
+ );
+};
+
+export const isUnpicCompatible = (image: string) => {
+ return typeof parseUrl(image) !== 'undefined';
+};
+
+/* ** */
+export const unpicOptimizer: ImagesOptimizer = async (image, breakpoints, width, height, format = undefined) => {
+ if (!image || typeof image !== 'string') {
+ return [];
+ }
+
+ const urlParsed = parseUrl(image);
+ if (!urlParsed) {
+ return [];
+ }
+
+ return Promise.all(
+ breakpoints.map(async (w: number) => {
+ const _height = width && height ? computeHeight(w, width / height) : height;
+ const url =
+ transformUrl({
+ url: image,
+ width: w,
+ height: _height,
+ cdn: urlParsed.cdn,
+ ...(format ? { format: format } : {}),
+ }) || image;
+ return {
+ src: String(url),
+ width: w,
+ height: _height,
+ };
+ })
+ );
+};
+
+/* ** */
+export async function getImagesOptimized(
+ image: ImageMetadata | string,
+ {
+ src: _,
+ width,
+ height,
+ sizes,
+ aspectRatio,
+ objectPosition,
+ widths,
+ layout = 'constrained',
+ style = '',
+ format,
+ ...rest
+ }: ImageProps,
+ transform: ImagesOptimizer = () => Promise.resolve([])
+): Promise<{ src: string; attributes: HTMLAttributes<'img'> }> {
+ if (typeof image !== 'string') {
+ width ||= Number(image.width) || undefined;
+ height ||= typeof width === 'number' ? computeHeight(width, image.width / image.height) : undefined;
+ }
+
+ width = (width && Number(width)) || undefined;
+ height = (height && Number(height)) || undefined;
+
+ widths ||= config.deviceSizes;
+ sizes ||= getSizes(Number(width) || undefined, layout);
+ aspectRatio = parseAspectRatio(aspectRatio);
+
+ // Calculate dimensions from aspect ratio
+ if (aspectRatio) {
+ if (width) {
+ if (height) {
+ /* empty */
+ } else {
+ height = width / aspectRatio;
+ }
+ } else if (height) {
+ width = Number(height * aspectRatio);
+ } else if (layout !== 'fullWidth') {
+ // Fullwidth images have 100% width, so aspectRatio is applicable
+ console.error('When aspectRatio is set, either width or height must also be set');
+ console.error('Image', image);
+ }
+ } else if (width && height) {
+ aspectRatio = width / height;
+ } else if (layout !== 'fullWidth') {
+ // Fullwidth images don't need dimensions
+ console.error('Either aspectRatio or both width and height must be set');
+ console.error('Image', image);
+ }
+
+ let breakpoints = getBreakpoints({ width: width, breakpoints: widths, layout: layout });
+ breakpoints = [...new Set(breakpoints)].sort((a, b) => a - b);
+
+ const srcset = (await transform(image, breakpoints, Number(width) || undefined, Number(height) || undefined, format))
+ .map(({ src, width }) => `${src} ${width}w`)
+ .join(', ');
+
+ return {
+ src: typeof image === 'string' ? image : image.src,
+ attributes: {
+ width: width,
+ height: height,
+ srcset: srcset || undefined,
+ sizes: sizes,
+ style: `${getStyle({
+ width: width,
+ height: height,
+ aspectRatio: aspectRatio,
+ objectPosition: objectPosition,
+ layout: layout,
+ })}${style ?? ''}`,
+ ...rest,
+ },
+ };
+}
diff --git a/src/utils/images.ts b/src/utils/images.ts
new file mode 100644
index 0000000..d6f14f0
--- /dev/null
+++ b/src/utils/images.ts
@@ -0,0 +1,111 @@
+import { isUnpicCompatible, unpicOptimizer, astroAssetsOptimizer } from './images-optimization';
+import type { ImageMetadata } from 'astro';
+import type { OpenGraph } from '@astrolib/seo';
+
+const load = async function () {
+ let images: Record<string, () => Promise<unknown>> | undefined = undefined;
+ try {
+ images = import.meta.glob('~/assets/images/**/*.{jpeg,jpg,png,tiff,webp,gif,svg,JPEG,JPG,PNG,TIFF,WEBP,GIF,SVG}');
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ } catch (error) {
+ // continue regardless of error
+ }
+ return images;
+};
+
+let _images: Record<string, () => Promise<unknown>> | undefined = undefined;
+
+/** */
+export const fetchLocalImages = async () => {
+ _images = _images || (await load());
+ return _images;
+};
+
+/** */
+export const findImage = async (
+ imagePath?: string | ImageMetadata | null
+): Promise<string | ImageMetadata | undefined | null> => {
+ // Not string
+ if (typeof imagePath !== 'string') {
+ return imagePath;
+ }
+
+ // Absolute paths
+ if (imagePath.startsWith('http://') || imagePath.startsWith('https://') || imagePath.startsWith('/')) {
+ return imagePath;
+ }
+
+ // Relative paths or not "~/assets/"
+ if (!imagePath.startsWith('~/assets/images')) {
+ return imagePath;
+ }
+
+ const images = await fetchLocalImages();
+ const key = imagePath.replace('~/', '/src/');
+
+ return images && typeof images[key] === 'function'
+ ? ((await images[key]()) as { default: ImageMetadata })['default']
+ : null;
+};
+
+/** */
+export const adaptOpenGraphImages = async (
+ openGraph: OpenGraph = {},
+ astroSite: URL | undefined = new URL('')
+): Promise<OpenGraph> => {
+ if (!openGraph?.images?.length) {
+ return openGraph;
+ }
+
+ const images = openGraph.images;
+ const defaultWidth = 1200;
+ const defaultHeight = 626;
+
+ const adaptedImages = await Promise.all(
+ images.map(async (image) => {
+ if (image?.url) {
+ const resolvedImage = (await findImage(image.url)) as ImageMetadata | string | undefined;
+ if (!resolvedImage) {
+ return {
+ url: '',
+ };
+ }
+
+ let _image;
+
+ if (
+ typeof resolvedImage === 'string' &&
+ (resolvedImage.startsWith('http://') || resolvedImage.startsWith('https://')) &&
+ isUnpicCompatible(resolvedImage)
+ ) {
+ _image = (await unpicOptimizer(resolvedImage, [defaultWidth], defaultWidth, defaultHeight, 'jpg'))[0];
+ } else if (resolvedImage) {
+ const dimensions =
+ typeof resolvedImage !== 'string' && resolvedImage?.width <= defaultWidth
+ ? [resolvedImage?.width, resolvedImage?.height]
+ : [defaultWidth, defaultHeight];
+ _image = (
+ await astroAssetsOptimizer(resolvedImage, [dimensions[0]], dimensions[0], dimensions[1], 'jpg')
+ )[0];
+ }
+
+ if (typeof _image === 'object') {
+ return {
+ url: 'src' in _image && typeof _image.src === 'string' ? String(new URL(_image.src, astroSite)) : '',
+ width: 'width' in _image && typeof _image.width === 'number' ? _image.width : undefined,
+ height: 'height' in _image && typeof _image.height === 'number' ? _image.height : undefined,
+ };
+ }
+ return {
+ url: '',
+ };
+ }
+
+ return {
+ url: '',
+ };
+ })
+ );
+
+ return { ...openGraph, ...(adaptedImages ? { images: adaptedImages } : {}) };
+};
diff --git a/src/utils/permalinks.ts b/src/utils/permalinks.ts
new file mode 100644
index 0000000..4e3078d
--- /dev/null
+++ b/src/utils/permalinks.ts
@@ -0,0 +1,134 @@
+import slugify from 'limax';
+
+import { SITE, APP_BLOG } from 'astrowind:config';
+
+import { trim } from '~/utils/utils';
+
+export const trimSlash = (s: string) => trim(trim(s, '/'));
+const createPath = (...params: string[]) => {
+ const paths = params
+ .map((el) => trimSlash(el))
+ .filter((el) => !!el)
+ .join('/');
+ return '/' + paths + (SITE.trailingSlash && paths ? '/' : '');
+};
+
+const BASE_PATHNAME = SITE.base || '/';
+
+export const cleanSlug = (text = '') =>
+ trimSlash(text)
+ .split('/')
+ .map((slug) => slugify(slug))
+ .join('/');
+
+export const BLOG_BASE = cleanSlug(APP_BLOG?.list?.pathname);
+export const CATEGORY_BASE = cleanSlug(APP_BLOG?.category?.pathname);
+export const TAG_BASE = cleanSlug(APP_BLOG?.tag?.pathname) || 'tag';
+
+export const POST_PERMALINK_PATTERN = trimSlash(APP_BLOG?.post?.permalink || `${BLOG_BASE}/%slug%`);
+
+/** */
+export const getCanonical = (path = ''): string | URL => {
+ const url = String(new URL(path, SITE.site));
+ if (SITE.trailingSlash == false && path && url.endsWith('/')) {
+ return url.slice(0, -1);
+ } else if (SITE.trailingSlash == true && path && !url.endsWith('/')) {
+ return url + '/';
+ }
+ return url;
+};
+
+/** */
+export const getPermalink = (slug = '', type = 'page'): string => {
+ let permalink: string;
+
+ if (
+ slug.startsWith('https://') ||
+ slug.startsWith('http://') ||
+ slug.startsWith('://') ||
+ slug.startsWith('#') ||
+ slug.startsWith('javascript:')
+ ) {
+ return slug;
+ }
+
+ switch (type) {
+ case 'home':
+ permalink = getHomePermalink();
+ break;
+
+ case 'blog':
+ permalink = getBlogPermalink();
+ break;
+
+ case 'asset':
+ permalink = getAsset(slug);
+ break;
+
+ case 'category':
+ permalink = createPath(CATEGORY_BASE, trimSlash(slug));
+ break;
+
+ case 'tag':
+ permalink = createPath(TAG_BASE, trimSlash(slug));
+ break;
+
+ case 'post':
+ permalink = createPath(trimSlash(slug));
+ break;
+
+ case 'page':
+ default:
+ permalink = createPath(slug);
+ break;
+ }
+
+ return definitivePermalink(permalink);
+};
+
+/** */
+export const getHomePermalink = (): string => getPermalink('/');
+
+/** */
+export const getBlogPermalink = (): string => getPermalink(BLOG_BASE);
+
+/** */
+export const getAsset = (path: string): string =>
+ '/' +
+ [BASE_PATHNAME, path]
+ .map((el) => trimSlash(el))
+ .filter((el) => !!el)
+ .join('/');
+
+/** */
+const definitivePermalink = (permalink: string): string => createPath(BASE_PATHNAME, permalink);
+
+/** */
+export const applyGetPermalinks = (menu: object = {}) => {
+ if (Array.isArray(menu)) {
+ return menu.map((item) => applyGetPermalinks(item));
+ } else if (typeof menu === 'object' && menu !== null) {
+ const obj = {};
+ for (const key in menu) {
+ if (key === 'href') {
+ if (typeof menu[key] === 'string') {
+ obj[key] = getPermalink(menu[key]);
+ } else if (typeof menu[key] === 'object') {
+ if (menu[key].type === 'home') {
+ obj[key] = getHomePermalink();
+ } else if (menu[key].type === 'blog') {
+ obj[key] = getBlogPermalink();
+ } else if (menu[key].type === 'asset') {
+ obj[key] = getAsset(menu[key].url);
+ } else if (menu[key].url) {
+ obj[key] = getPermalink(menu[key].url, menu[key].type);
+ }
+ }
+ } else {
+ obj[key] = applyGetPermalinks(menu[key]);
+ }
+ }
+ return obj;
+ }
+ return menu;
+};
diff --git a/src/utils/utils.ts b/src/utils/utils.ts
new file mode 100644
index 0000000..e2ed559
--- /dev/null
+++ b/src/utils/utils.ts
@@ -0,0 +1,52 @@
+import { I18N } from 'astrowind:config';
+
+export const formatter: Intl.DateTimeFormat = new Intl.DateTimeFormat(I18N?.language, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ timeZone: 'UTC',
+});
+
+export const getFormattedDate = (date: Date): string => (date ? formatter.format(date) : '');
+
+export const trim = (str = '', ch?: string) => {
+ let start = 0,
+ end = str.length || 0;
+ while (start < end && str[start] === ch) ++start;
+ while (end > start && str[end - 1] === ch) --end;
+ return start > 0 || end < str.length ? str.substring(start, end) : str;
+};
+
+// Function to format a number in thousands (K) or millions (M) format depending on its value
+export const toUiAmount = (amount: number) => {
+ if (!amount) return 0;
+
+ let value: string;
+
+ if (amount >= 1000000000) {
+ const formattedNumber = (amount / 1000000000).toFixed(1);
+ if (Number(formattedNumber) === parseInt(formattedNumber)) {
+ value = parseInt(formattedNumber) + 'B';
+ } else {
+ value = formattedNumber + 'B';
+ }
+ } else if (amount >= 1000000) {
+ const formattedNumber = (amount / 1000000).toFixed(1);
+ if (Number(formattedNumber) === parseInt(formattedNumber)) {
+ value = parseInt(formattedNumber) + 'M';
+ } else {
+ value = formattedNumber + 'M';
+ }
+ } else if (amount >= 1000) {
+ const formattedNumber = (amount / 1000).toFixed(1);
+ if (Number(formattedNumber) === parseInt(formattedNumber)) {
+ value = parseInt(formattedNumber) + 'K';
+ } else {
+ value = formattedNumber + 'K';
+ }
+ } else {
+ value = Number(amount).toFixed(0);
+ }
+
+ return value;
+};