diff options
| author | Dawid Rycerz <dawid@rycerz.xyz> | 2025-07-22 15:08:37 +0300 |
|---|---|---|
| committer | Dawid Rycerz <dawid@rycerz.xyz> | 2025-07-22 15:08:37 +0300 |
| commit | fcc2f4704e39b0e69b377cc138f75027721dac22 (patch) | |
| tree | 732fc94b354a26c08fba9cc9059f9c6c900182be /src/utils | |
Initial template
Diffstat (limited to 'src/utils')
| -rw-r--r-- | src/utils/blog.ts | 281 | ||||
| -rw-r--r-- | src/utils/directories.ts | 18 | ||||
| -rw-r--r-- | src/utils/frontmatter.ts | 50 | ||||
| -rw-r--r-- | src/utils/images-optimization.ts | 351 | ||||
| -rw-r--r-- | src/utils/images.ts | 111 | ||||
| -rw-r--r-- | src/utils/permalinks.ts | 134 | ||||
| -rw-r--r-- | src/utils/utils.ts | 52 |
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; +}; |
