add template in hard
This commit is contained in:
278
front/src/utils/blog.ts
Normal file
278
front/src/utils/blog.ts
Normal file
@ -0,0 +1,278 @@
|
||||
import type { PaginateFunction } from 'astro';
|
||||
import { getCollection } 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, slug: rawSlug = '', data } = post;
|
||||
const { Content, remarkPluginFrontmatter } = await post.render();
|
||||
|
||||
const {
|
||||
publishDate: rawPublishDate = new Date(),
|
||||
updateDate: rawUpdateDate,
|
||||
title,
|
||||
excerpt,
|
||||
image,
|
||||
tags: rawTags = [],
|
||||
category: rawCategory,
|
||||
author,
|
||||
draft = false,
|
||||
metadata = {},
|
||||
} = data;
|
||||
|
||||
const slug = cleanSlug(rawSlug); // 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) => {
|
||||
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) => {
|
||||
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;
|
||||
}
|
18
front/src/utils/directories.ts
Normal file
18
front/src/utils/directories.ts
Normal file
@ -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, '');
|
||||
};
|
39
front/src/utils/frontmatter.mjs
Normal file
39
front/src/utils/frontmatter.mjs
Normal file
@ -0,0 +1,39 @@
|
||||
import getReadingTime from 'reading-time';
|
||||
import { toString } from 'mdast-util-to-string';
|
||||
import lazyLoadPlugin from 'rehype-plugin-image-native-lazy-loading';
|
||||
|
||||
export function readingTimeRemarkPlugin() {
|
||||
return function (tree, file) {
|
||||
const textOnPage = toString(tree);
|
||||
const readingTime = Math.ceil(getReadingTime(textOnPage).minutes);
|
||||
|
||||
file.data.astro.frontmatter.readingTime = readingTime;
|
||||
};
|
||||
}
|
||||
|
||||
export function responsiveTablesRehypePlugin() {
|
||||
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') {
|
||||
const wrapper = {
|
||||
type: 'element',
|
||||
tagName: 'div',
|
||||
properties: {
|
||||
style: 'overflow:auto',
|
||||
},
|
||||
children: [child],
|
||||
};
|
||||
|
||||
tree.children[i] = wrapper;
|
||||
|
||||
i++;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const lazyImagesRehypePlugin = lazyLoadPlugin;
|
325
front/src/utils/images-optimization.ts
Normal file
325
front/src/utils/images-optimization.ts
Normal file
@ -0,0 +1,325 @@
|
||||
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 AttributesProps extends HTMLAttributes<'img'> {}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export type ImagesOptimizer = (
|
||||
image: ImageMetadata | string,
|
||||
breakpoints: number[],
|
||||
width?: number,
|
||||
height?: number
|
||||
) => 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 astroAsseetsOptimizer: ImagesOptimizer = async (image, breakpoints, _width, _height) => {
|
||||
if (!image) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
breakpoints.map(async (w: number) => {
|
||||
const url = (await getImage({ src: image, width: w, inferSize: true })).src;
|
||||
return {
|
||||
src: url,
|
||||
width: w,
|
||||
};
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
export const isUnpicCompatible = (image: string) => {
|
||||
return typeof parseUrl(image) !== 'undefined';
|
||||
};
|
||||
|
||||
/* ** */
|
||||
export const unpicOptimizer: ImagesOptimizer = async (image, breakpoints, width, height) => {
|
||||
if (!image || typeof image !== 'string') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const urlParsed = parseUrl(image);
|
||||
if (!urlParsed) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
breakpoints.map(async (w: number) => {
|
||||
const url =
|
||||
transformUrl({
|
||||
url: image,
|
||||
width: w,
|
||||
height: width && height ? computeHeight(w, width / height) : height,
|
||||
cdn: urlParsed.cdn,
|
||||
}) || image;
|
||||
return {
|
||||
src: String(url),
|
||||
width: w,
|
||||
};
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
/* ** */
|
||||
export async function getImagesOptimized(
|
||||
image: ImageMetadata | string,
|
||||
{ src: _, width, height, sizes, aspectRatio, widths, layout = 'constrained', style = '', ...rest }: ImageProps,
|
||||
transform: ImagesOptimizer = () => Promise.resolve([])
|
||||
): Promise<{ src: string; attributes: AttributesProps }> {
|
||||
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))
|
||||
.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,
|
||||
layout: layout,
|
||||
})}${style ?? ''}`,
|
||||
...rest,
|
||||
},
|
||||
};
|
||||
}
|
99
front/src/utils/images.ts
Normal file
99
front/src/utils/images.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { getImage } from 'astro:assets';
|
||||
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}');
|
||||
} catch (e) {
|
||||
// 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 | undefined;
|
||||
if (!resolvedImage) {
|
||||
return {
|
||||
url: '',
|
||||
};
|
||||
}
|
||||
|
||||
const _image = await getImage({
|
||||
src: resolvedImage,
|
||||
alt: 'Placeholder alt',
|
||||
width: image?.width || defaultWidth,
|
||||
height: image?.height || defaultHeight,
|
||||
});
|
||||
|
||||
if (typeof _image === 'object') {
|
||||
return {
|
||||
url: 'src' in _image && typeof _image.src === 'string' ? String(new URL(_image.src, astroSite)) : 'pepe',
|
||||
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 } : {}) };
|
||||
};
|
134
front/src/utils/permalinks.ts
Normal file
134
front/src/utils/permalinks.ts
Normal file
@ -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;
|
||||
};
|
52
front/src/utils/utils.ts
Normal file
52
front/src/utils/utils.ts
Normal file
@ -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;
|
||||
};
|
Reference in New Issue
Block a user