Merge pull request #5 from LoredDev/dev@package-banners

Feature(package-banners): Move the banner creation logic to it's own package.
This commit is contained in:
Guz
2023-06-26 16:54:34 -03:00
committed by GitHub
52 changed files with 2945 additions and 172 deletions

View File

@@ -188,7 +188,7 @@ module.exports = {
'@typescript-eslint/semi': ['error', 'always'],
'max-depth': ['error', 3],
'max-nested-callbacks': ['error', 3],
'complexity': ['error', 4],
'complexity': ['error', 8],
'no-tabs': ['error', { allowIndentationTabs: true }],
'spellcheck/spell-checker': ['error', {
skipWords: [
@@ -226,6 +226,13 @@ module.exports = {
'duotone',
'tsconfig',
'workspace',
'woff',
'marknow',
'lored',
'guz013',
'xml',
'jsconfig',
'vitest',
],
minLength: 4,
}],

View File

@@ -4,6 +4,10 @@
"path": "../apps/www",
"name": "apps/www"
},
{
"path": "../packages/banners",
"name": "@marknow/banners"
},
{
"path": "../",
"name": "ROOT"

View File

@@ -4,5 +4,6 @@
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"unocss.root": "apps/www"
"unocss.root": "apps/www",
"vitest.enable": true
}

View File

@@ -1,5 +1,4 @@
/src/lib/components.d.ts
/src/lib/imports.d.ts
/.eslint-auto-import.json
*.woff
*.woff2
/static

View File

@@ -22,6 +22,7 @@
"@iconify-json/svg-spinners": "^1.1.1",
"@iconify/types": "^2.0.0",
"@iconify/utils": "^2.1.6",
"@marknow/banners": "workspace:*",
"@poppanator/sveltekit-svg": "^3.0.1",
"@sveltejs/adapter-vercel": "^3.0.1",
"@sveltejs/kit": "^1.20.2",
@@ -30,9 +31,8 @@
"@unocss/extractor-svelte": "^0.52.7",
"mdsvex": "^0.10.6",
"rehype-external-links": "^2.1.0",
"sass": "^1.63.4",
"sass": "^1.63.6",
"satori": "^0.10.1",
"satori-html": "^0.3.2",
"svelte": "^3.59.1",
"svelte-check": "^3.4.3",
"svelte-preprocess": "^5.0.4",

View File

@@ -1,28 +1,22 @@
<script lang="ts">
import type { PageData } from './$types';
export let data: PageData;
</script>
<main un-grid="~ rows-4">
<div un-h="sm" un-flex="~ justify-center items-center" un-bg="#33578a">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html data.banner}
<img src="/api.svg" alt="">
</div>
<div un-h="sm" un-flex="~ justify-center items-center" un-bg="#0d1117">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html data.banner}
<img src="/api.svg" alt="">
</div>
<div un-h="sm" un-flex="~ justify-center items-center" un-bg="#ffffff">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html data.banner}
<img src="/api.svg" alt="">
</div>
<div un-h="sm" un-flex="~ justify-center items-center" un-bg="#0a0c10">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html data.banner}
<img src="/api.svg" alt="">
</div>
<div un-h="sm" un-flex="~ justify-center items-center" un-bg="#22272e">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html data.banner}
<img src="/api.svg" alt="">
</div>
</main>

View File

@@ -1,6 +0,0 @@
import type { PageLoad } from './$types';
export const load = (async ({ fetch }): Promise<{ banner: string }> => {
const banner = await (await fetch('/api.svg')).text();
return { banner };
}) satisfies PageLoad;

View File

@@ -1,33 +1,35 @@
import type { RequestHandler } from '@sveltejs/kit';
import satori from 'satori';
import { html as satoriHtml } from 'satori-html';
import Banner from './Banner.html?raw';
import font400 from '$lib/assets/Mona-Sans-Regular.woff?url';
import font600 from '$lib/assets/Mona-Sans-SemiBold.woff?url';
import newBanner from '@marknow/banners';
export const GET = (async ({ fetch }): Promise<Response> => {
const html = satoriHtml(Banner);
const banner = await newBanner({
title: 'Hello world',
subtitle: 'This is a test!',
icon: 'solar:hand-shake-bold-duotone',
colors: {
background: '#000000',
foreground: '#ffffff',
},
fonts: {
title: {
data: await (await fetch('/Mona-Sans-SemiBold.woff')).arrayBuffer(),
name: 'Mona Sans',
weight: 600,
style: 'normal',
},
subtitle: {
data: await (await fetch('/Mona-Sans-Regular.woff')).arrayBuffer(),
name: 'Mona Sans',
weight: 400,
style: 'normal',
},
},
libConfig: {
fetcher: fetch,
},
});
const banner = await satori(html,
{
width: 1000,
height: 180,
fonts: [
{
name: 'Mona Sans',
weight: 400,
style: 'normal',
data: await (await fetch(font400)).arrayBuffer(),
},
{
name: 'Mona Sans',
weight: 600,
style: 'normal',
data: await (await fetch(font600)).arrayBuffer(),
}],
});
return new Response(banner, {
return new Response(`${banner.toString()}`, {
status: 200,
headers: {
'Content-type': 'image/svg+xml',

View File

@@ -1,48 +0,0 @@
<div style="
display: flex;
justify-items: center;
align-items: center;
width: 1000px;
height: 180px;
">
<div style="
box-shadow: 0 5px 12px #00000040;
position: relative;
font-family: 'Mona Sans';
background-color: white;
margin: auto;
border-radius: 1em;
padding: 1.2em 2.5em;
display: flex;
min-width: 98%;
min-height: 20%;
gap: 1em;
">
<div style="
align-items: center;
display: flex;
margin: 1.5em 0;
">
<svg xmlns="http://www.w3.org/2000/svg" width="3.5em" height="3.5em" viewBox="0 0 24 24">
<g fill="currentColor">
<path
d="M15.75 2a.75.75 0 0 0-1.5 0v20a.75.75 0 0 0 1.5 0v-2.006c2.636-.027 4.104-.191 5.078-1.166C22 17.657 22 15.771 22 12c0-3.771 0-5.657-1.172-6.828c-.974-.975-2.442-1.139-5.078-1.166V2Z" />
<path fill-rule="evenodd"
d="M10 20c-3.771 0-5.657 0-6.828-1.172C2 17.657 2 15.771 2 12c0-3.771 0-5.657 1.172-6.828C4.343 4 6.229 4 10 4h3v16h-3ZM6.818 7.787c.3-.037.666-.037 1.066-.037h2.232c.4 0 .766 0 1.066.037c.329.041.68.137.98.405c.052.046.1.094.146.146c.268.3.364.651.405.98c.037.3.037.666.037 1.066v.041a.75.75 0 0 1-1.5 0c0-.455-.001-.726-.026-.922c-.024-.195-.228-.227-.228-.227c-.195-.025-.466-.026-.921-.026H9.75v5.5H11a.75.75 0 0 1 0 1.5H7a.75.75 0 0 1 0-1.5h1.25v-5.5h-.325c-.455 0-.726.001-.922.026c0 0-.203.032-.227.227c-.025.196-.026.467-.026.922a.75.75 0 0 1-1.5 0v-.041c0-.4 0-.766.037-1.066c.041-.329.137-.68.405-.98c.046-.052.094-.1.146-.146c.3-.268.651-.364.98-.405Z"
clip-rule="evenodd" />
</g>
</svg>
</div>
<div style="
align-items: center;
display: flex;
">
<div style="display: flex; flex-direction: column;">
<h1 style="margin: 0; font-weight: 600;">Marknow</h1>
<sub style="font-size: medium; font-weight: 400;">
Create beautiful markdown for your projects with ease
</sub>
</div>
</div>
</div>
</div>

View File

@@ -8,6 +8,8 @@
"keywords": [],
"main": "index.js",
"scripts": {
"test": "turbo run test",
"test:watch": "vitest watch",
"prepare": "husky install",
"lint": "eslint . && turbo run lint",
"lint:fix": "eslint . --fix && turbo run lint -- --fix",
@@ -24,6 +26,7 @@
"eslint-plugin-svelte": "^2.30.0",
"husky": "^8.0.3",
"turbo": "^1.10.3",
"vercel": "^30.2.2"
"vercel": "^30.2.2",
"vitest": "^0.32.2"
}
}

View File

@@ -0,0 +1,3 @@
/dist
*.woff
*.woff2

View File

@@ -0,0 +1,5 @@
process.env.ESLINT_TSCONFIG = 'jsconfig.json';
module.exports = {
root: false,
};

1
packages/banners/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/dist

View File

@@ -0,0 +1,4 @@
{
"gitDir": "../../.git",
"*": "eslint --fix"
}

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"checkJs": true,
"allowJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"module": "ES2022",
"target": "ES2022",
"alwaysStrict": true
},
"include": ["./src/**/*"],
"exclude": ["./node_modules/**", "./dist/**"]
}

View File

@@ -0,0 +1,35 @@
{
"name": "@marknow/banners",
"type": "module",
"version": "1.0.0",
"description": "",
"source": "./src/index.js",
"author": "",
"license": "ISC",
"keywords": [],
"exports": {
"types": "./src/index.d.ts",
"require": "./dist/index.cjs",
"import": "./src/index.js",
"default": "./src/index.js"
},
"main": "./dist/index.cjs",
"module": "./src/index.js",
"types": "./src/index.d.ts",
"scripts": {
"test": "vitest run",
"test:watch": "vitest watch",
"build:cjs": "microbundle --compress false --pkg-main false --strict --tsconfig ./jsconfig.json --generateTypes false -f cjs --target node",
"build": "pnpm run build:cjs",
"dev": "pnpm run build -w",
"lint": "eslint ."
},
"dependencies": {
"satori": "^0.10.1",
"satori-html": "^0.3.2"
},
"devDependencies": {
"@types/node": "^20.3.1",
"microbundle": "^0.15.1"
}
}

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,47 @@
/**
* @file
* Utility functions used to retrieve and manipulate fonts' objects.
*
* @author
* Guz013 (under the Lored organization) <https://github.com/LoredDev>
*
* @copyright
* Gustavo "Guz013" L. de Mello
*
* @module \@marknow/banners
*/
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
/**
* @param {import('./types').Reader | undefined} [reader=import('node:fs').readFile]
* The function to be used as reader of the local files.
*
* @typedef {import('satori').SatoriOptions['fonts'][0]} Font
* @returns {Promise<{subtitle: Font, title: Font}>}
*
* @module \@marknow/banners
* @access protected
*/
export async function getMonaSansFonts(reader) {
reader ||= (await import('node:fs/promises')).readFile;
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
return {
subtitle: {
name: 'Mona Sans',
weight: 400,
style: 'normal',
data: await reader(join(__dirname, './assets/Mona-Sans-Regular.woff')),
},
title: {
name: 'Mona Sans',
weight: 600,
style: 'normal',
data: await reader(join(__dirname, './assets/Mona-Sans-SemiBold.woff')),
},
};
}

View File

@@ -0,0 +1,117 @@
/**
* @author
* Guz013 (under the Lored organization) <https://github.com/LoredDev>
*
* @copyright
* Gustavo "Guz013" L. de Mello
*/
/* eslint-disable complexity */
/* eslint-disable @typescript-eslint/indent */
/**
* Constructor of the HTML banner converted to VNodes to be used in satori.
* Use the properties to customize and complete it.
*
* @typedef {import('satori').SatoriOptions['fonts'][0]} Font
* @typedef {{
* dimensions: { width: number, height: number },
* fonts: { title: Font, subtitle: Font },
* layout: 'vertical' | 'horizontal',
* colors: import('./types').Colors,
* rtl: boolean,
* }} Props
* @param {Props} properties
* Properties to be applied on the html.
*
* @returns {string}
*
* @module \@marknow/banners
* @access protected
*/
export default function html({
dimensions,
layout,
colors,
fonts,
rtl,
}) {
const horizontal = layout === 'horizontal';
return `
<div style="
display: flex;
justify-items: center;
align-items: center;
width: ${dimensions.width}px;
height: ${dimensions.height}px;
">
<div style="
box-shadow: 0 5px 12px #00000040;
position: relative;
color: ${colors.foreground};
background-color: ${colors.background};
margin: auto;
border-radius: 1em;
padding: ${horizontal ? '1.2' : '2.5'}em 2.5em;
display: flex;
${horizontal
? rtl
? 'flex-direction: row-reverse;'
: 'flex-direction: row;'
: 'flex-direction: column;'
}
align-items: center;
${horizontal
? 'justify-content: flex-start;'
: 'justify-content: center;'
}
min-width: 98%;
min-height: 20%;
gap: 1em;
">
<div style="
align-items: center;
display: flex;
margin: ${horizontal ? '1.5' : '0'}em 0;
width: 3.5em;
height: 3.5em;
">
%%MARKNOW-PLACEHOLDER-ICON%%
</div>
<div style="
align-items: center;
display: flex;
">
<div style="display: flex; flex-direction: column;">
<h1 style="
margin: ${horizontal ? '0' : '0 auto 1em auto'};
font-weight: ${fonts.title.weight};
font-family: ${fonts.title.name};
text-overflow: ellipsis;
max-width: 50em;
display: flex;
flex-direction: ${rtl ? 'row-reverse' : 'row'};
">
%%MARKNOW-PLACEHOLDER-TITLE%%
</h1>
<sub style="
font-size: medium;
font-weight: ${fonts.subtitle.weight};
font-family: ${fonts.subtitle.name};
text-overflow: ellipsis;
max-width: 50em;
display: flex;
flex-direction: ${rtl ? 'row-reverse' : 'row'};
${horizontal
? 'margin: 0;'
: 'margin: 0 auto;'
}
">
%%MARKNOW-PLACEHOLDER-SUBTILE%%
</sub>
</div>
</div>
</div>
</div>
`;
}

View File

@@ -0,0 +1,109 @@
/**
* @file
* Utility functions used to retrieve and manipulate icons SVG strings.
*
* @author
* Guz013 (under the Lored organization) <https://github.com/LoredDev>
*
* @copyright
* Gustavo "Guz013" L. de Mello
*
* @module \@marknow/banners
*/
/**
* Utility function used to get a SVG icon from Iconify OR from an url passed as the icon.
*
* If the `icon` parameter is not a valid icon name or url,
* it returns the `icon` itself.
*
* @param {string} icon
* The icon's name or url endpoint.
*
* @param {import('./types').Fetcher | undefined} [fetcher=globalThis.fetch]
* Fetch function to be used.
*
* @returns {Promise<string>}
*
* @module \@marknow/banners
* @access protected
*/
export async function getIcon(icon, fetcher = fetch) {
if (!isIconName(icon) && !isValidUrl(icon)) {
return icon;
}
else if (isValidUrl(icon)) {
const svg = (await fetcher(icon)).text();
return svg;
}
const [collection, iconName] = icon.split(':');
const svg = (await fetcher(`https://api.iconify.design/${collection}/${iconName}.svg`)).text();
return svg;
}
/**
* Utility function used to set the icons SVG width and height to the specified dimensions.
*
* @param {string} svg
* The svg string.
*
* @param {{width?: string | number, height?: string | number}} dimensions
* The dimensions values, if type number it is converted to pixels.
*
* @returns {string}
*
* @module \@marknow/banners
* @access protected
*/
export function setIconDimensions(svg, { width, height }) {
width = typeof width === 'number'
? `width="${width}px"`
: !width
? ''
: `width="${width}"`;
height = typeof height === 'number'
? `height="${height}px"`
: !height
? ''
: `height="${height}"`;
return svg
.replace(/width="([^"]*)"/, width)
.replace(/height="([^"]*)"/, height);
}
/**
* Checks if a given string is a valid
* [Iconify](https://iconify.design/)/[Icônes](https://icones.js.org/)-like icon name.
*
* @param {string} string The string to be checked.
* @returns {boolean}
*
* @module \@marknow/banners
* @access package
*/
function isIconName(string) {
return /^[a-z0-9-]+:[a-z0-9-]+(\[\])?$/.test(string);
}
/**
* Checks if string is a valid URL.
*
* @param {string} string The string to be checked.
* @returns {boolean}
*
* @module \@marknow/banners
* @access package
*/
function isValidUrl(string) {
try {
const url = new URL(string);
return url.protocol === 'https:' || url.protocol === 'http:';
}
catch (_) {
return false;
}
}

66
packages/banners/src/index.d.ts vendored Normal file
View File

@@ -0,0 +1,66 @@
/**
* @file
* This file contains all public functions and types declaration of the
* `@marknow/banners` package. Anything declared here can be accessed directly
* by the library consumers and has an @access public access level.
*
* The JSDocs of the functions are duplicated from the source files, so that
* it is more compatible with Typescript syntax and code documentation.
*
* @author
* Guz013 (under the Lored organization) <https://github.com/LoredDev>
*
* @copyright
* Gustavo "Guz013" L. de Mello
*
* @module \@marknow/banners
*/
/**
* The banner constructor function. Use the options to customize the
* appearance of the resulting banner.
*
* @param {import('./types').BannerOptions} options
* Options object for customizing the banner appearance.
*
* @returns {Promise<import('./types').Banner>}
*
* @example
* import newBanner from '@marknow/banners';
*
* export async function GET({ fetch }) {
* const banner = await newBanner({
* title: 'Hello world',
* subtitle: 'This is a example api endpoint.'
* icon: 'material-symbols:api'
* fonts: {
* title: {
* data: await (await fetch('/Mona-Sans-SemiBold.woff')).arrayBuffer(),
* name: 'Mona Sans',
* weight: 600,
* },
* subtitle: {
* data: await (await fetch('/Mona-Sans-Regular.woff')).arrayBuffer(),
* name: 'Mona Sans',
* weight: 400,
* },
* },
* libConfig: {
* fetcher: fetch,
* },
* });
*
* return new Response(banner.toString(), {
* status: 200,
* headers: {
* 'Content-type': 'image/svg+xml',
* },
* });
* }
*
* @module \@marknow/banners
* @access public
*/
export default function banner({ title, subtitle, icon, layout, config, }: import('./types').BannerOptions): Promise<import('./types').Banner>;
export type { BannerOptions, Banner } from './types'

View File

@@ -0,0 +1,132 @@
/**
* @file
* Main entry point of the `@marknow/banners` package. This file contains
* all ESModules exports of the public APIs and functions of the library.
* Anything exported here should have an @access public access level and
* declared on {@link ./index.d.ts types declaration} file.
*
* @author
* Guz013 (under the Lored organization) <https://github.com/LoredDev>
*
* @copyright
* Gustavo "Guz013" L. de Mello
*
* @module \@marknow/banners
*/
import { html as htmlToVNodes } from 'satori-html';
import satori from 'satori';
import bannerHtml from './html';
import { getMonaSansFonts } from './fonts';
import { getIcon, setIconDimensions } from './icons';
/**
* The banner constructor function. Use the options to customize the
* appearance of the resulting banner.
*
* @param {import('./types').BannerOptions} options
* Options object for customizing the banner appearance.
*
* @returns {Promise<import('./types').Banner>}
*
* @example
* import newBanner from '@marknow/banners';
*
* export async function GET({ fetch }) {
* const banner = await newBanner({
* title: 'Hello world',
* subtitle: 'This is a example api endpoint.'
* icon: 'material-symbols:api'
* fonts: {
* title: {
* data: await (await fetch('/Mona-Sans-SemiBold.woff')).arrayBuffer(),
* name: 'Mona Sans',
* weight: 600,
* },
* subtitle: {
* data: await (await fetch('/Mona-Sans-Regular.woff')).arrayBuffer(),
* name: 'Mona Sans',
* weight: 400,
* },
* },
* libConfig: {
* fetcher: fetch,
* },
* });
*
* return new Response(banner.toString(), {
* status: 200,
* headers: {
* 'Content-type': 'image/svg+xml',
* },
* });
* }
*
* @module \@marknow/banners
* @access public
*/
export default async function banner({
title,
fonts,
icon = '',
rtl = false,
subtitle = '',
layout = 'horizontal',
libConfig: config = {},
colors = {
background: '#ffffff',
foreground: '#000000',
},
}) {
fonts ||= await getMonaSansFonts(config?.reader);
config.iconHandler ||= icon => getIcon(icon, config?.fetcher);
title = truncateText(title, layout === 'horizontal' ? 45 : 90);
subtitle = truncateText(subtitle, layout === 'horizontal' ? 100 : 200);
const dimensions = {
width: 1000,
height: layout === 'horizontal' ? 180 : 280,
};
const htmlTemplate = bannerHtml({ layout, dimensions, fonts, colors, rtl });
const iconSvg = setIconDimensions(await config.iconHandler(icon), { width: '3.5em' });
const html = htmlTemplate
.replace('%%MARKNOW-PLACEHOLDER-TITLE%%', title)
.replace('%%MARKNOW-PLACEHOLDER-SUBTILE%%', subtitle)
.replace('%%MARKNOW-PLACEHOLDER-ICON%%', iconSvg);
const vNodes = htmlToVNodes(html);
const svg = await satori(vNodes, {
...dimensions,
fonts: [
fonts.title,
fonts.subtitle,
],
});
return {
toString() { return svg; },
icon: iconSvg,
vNodes,
html,
svg,
};
}
/**
* Small utility function used to truncate long texts on the banner
*
* @param {string} string - Text string to be truncated.
* @param {number} maxChar - Maximum number of characters.
*
* @returns {string}
*
* @module \@marknow/banners
* @access package
*/
function truncateText(string, maxChar) {
return string.length > maxChar ? `${string.slice(0, maxChar)}...` : string;
}

234
packages/banners/src/types.d.ts vendored Normal file
View File

@@ -0,0 +1,234 @@
/**
* @file
* Definitions of more complex or shared Typescript types used in the package.
*
* @author
* Guz013 (under the Lored organization) <https://github.com/LoredDev>
*
* @copyright
* Gustavo "Guz013" L. de Mello
*
* @module \@marknow/banners
*/
import type { OpenMode, PathLike } from "node:fs";
import type { FileHandle } from "node:fs/promises";
import type { SatoriOptions } from "satori/wasm";
/**
* Options object for customizing the banner appearance.
*
* @module \@marknow/banners
* @access public
*/
export interface BannerOptions {
/**
* **(REQUIRED)** Title to be displayed in the banner.
*/
title: string,
/**
* (Optional) Set the text direction right-to-left.
*/
rtl?: boolean = false,
/**
* (Optional) {@link https://iconify.design/ Iconify}/{@link https://icones.js.org/ Icônes}-like
* icon name OR url for svg file of the icon to be used in the banner.
*/
icon?: string = '',
/**
* (Optional) Subtitle/legend to be displayed in the banner
* bellow the title.
*/
subtitle?: string = '',
/**
* (Optional) Customize the colors of the banner.
*
* Customize the background color and foreground (title, subtitle
* and icon) colors.
*/
colors?: Colors = { background: '#ffffff', foreground: '#000000' },
/**
* (Optional) Customize the layout of the banner and position of elements.
*/
layout?: 'horizontal' | 'vertical' = 'horizontal',
/**
* (Optional) Customize the fonts used on the banner.
*
* Changes the default font file used for the title and subtitle.
* Defaults is {@link https://github.com/github/mona-sans Github's Mona Sans}
* semi-bold and regular respectively.
*
* @see {@link https://github.com/vercel/satori#fonts}
*/
fonts?: {
title: Font,
subtitle: Font,
}
/**
* (Optional) Customize the behavior of the package.
*
* Provide functions or polyfills to be used by the package
* for better compatibility or customization of the banner
* creation.
*/
libConfig?: {
/**
* (Optional) Fetch function used by the package to retrieve
* icons from {@link https://iconify.design/ Iconify} and custom
* icons provided as url.
*
* Default function used is the
* {@link https://developer.mozilla.org/en-US/docs/Web/API/fetch globalThis.fetch}
* function. Compatible with modern browsers, Node.js (version 18 and greater),
* Deno and Bun.
*
* @see {@link BannerOptions.icon}
*
* @param {RequestInfo} input - The request url/info.
* @param {RequestInit} - Request options.
* @returns {Promise<Response>}
*/
fetcher?: Fetcher = globalThis.fetch,
/**
* (Optional) The function used to read the font files and return a Buffer or
* ArrayBuffer from them.
*
* Default function used is the {@link https://nodejs.org/api/fs.html#fsreadfilepath-options-callback fs.readFile}
* from the Node file system promises api ({@link https://nodejs.org/api/fs.html#file-system node:fs/promises}).
* Compatible with Node.js (version 10 and greater), Deno and Bun.
*
* @param {PathLike | FileHandle} path - The path to the font files.
* @returns {Promise<Buffer | ArrayBuffer>}
*/
reader?: Reader = import ('node:fs/promises').readFile,
/**
* (Optional) The function used to get the icon svg file from {@link https://iconify.design/ Iconify}
* or URL endpoint passed.
*
* @see {@link BannerOptions.icon}
*
* @param {string} icon - Icon name or URL.
* @returns {string | Promise<string>}
*/
iconHandler?: (icon: string) => string | Promise<string>
}
}
/**
* The resulting banner object.
*
* Has a `toString()` function to be used in string literals
* that returns the svg string of the banner.
*
* @module \@marknow/banners
* @access public
*/
export interface Banner {
/**
* The resulting svg of the banner.
* @readonly
*/
svg: string,
/**
* The raw html used to create the banner.
* @readonly
*/
html: string,
/**
* The used icon's svg.
* @readonly
*/
icon: string,
/**
* React-element-like objects / VDOM used to create the banner.
* @readonly
*/
vNodes: VNode,
/**
* Returns the {@link Banner.svg svg string} of the banner.
* Useful when using the banner object directly on a string.
*
* @example
* import newBanner from '@marknow/banners';
*
* const banner = await newBanner({ ... });
*
* // Prints the resulting svg instead of the banner object itself.
* console.log(`Banner svg:\n${banner}`)
*
* @readonly
*/
toString(): string,
};
/**
* Font object for the banner passed to the `satori` package.
*
* @see {@link BannerOptions.fonts}
* @see {@link https://github.com/vercel/satori#fonts}
*
* @module \@marknow/banners > satori
* @access protected
*/
export type Font = SatoriOptions['fonts'][0];
export type Colors = {
foreground: string;
background: string;
}
/**
* "Global Fetch"-like function used by the package to retrieve
* icons from [Iconify](https://iconify.design/) and custom
* icons provided as url.
*
* @param {RequestInfo} input - The request url/info.
* @param {RequestInit} - Request options.
* @returns {Promise<Response>}
*
* @module \@marknow/banners
* @access protected
*/
export type Fetcher = (
input: RequestInfo | URL,
init?: RequestInit
) => Promise<Response>;
/**
* "Node.js' `fs.readFile`"-like function used to read the font files
* and return a Buffer or ArrayBuffer from them.
*
* @param {RequestInfo} input - The request url/info.
* @param {RequestInit} - Request options.
* @returns {Promise<Response>}
*
* @module \@marknow/banners
* @access protected
*/
export type Reader = (
path: PathLike | FileHandle,
) => Promise<Buffer | ArrayBuffer>
/**
* React-element-like objects / VDOM object used in satori.
*
* @module \@marknow/banners > satori-html
* @access protected
*/
export interface VNode {
type: string;
props: {
style?: Record<string, any>;
children?: string | VNode | VNode[];
[prop: string]: any;
};
}

View File

@@ -0,0 +1,42 @@
### [`banner.test.ts`](./banner.test.ts)
![horizontal](__snapshots__/banner-horizontal.svg)
![vertical](__snapshots__/banner-vertical.svg)
![rtl](__snapshots__/banner-rtl.svg)
### [`results.test.js`](./results.test.js);
![withTitle](__snapshots__/withTitle.svg)
![withSubtitle](__snapshots__/withSubtitle.svg)
![withIcon](__snapshots__/withIcon.svg)
![withComplete](__snapshots__/withComplete.svg)
### [`vertical-results.test.js`](./vertical-results.test.js);
![withTitle](__snapshots__/vertical-withTitle.svg)
![withSubtitle](__snapshots__/vertical-withSubtitle.svg)
![withIcon](__snapshots__/vertical-withIcon.svg)
![withComplete](__snapshots__/vertical-withComplete.svg)
### [`rtl-results.test.js`](./rtl-results.test.js);
![withTitle](__snapshots__/rtl-withTitle.svg)
![withSubtitle](__snapshots__/rtl-withSubtitle.svg)
![withIcon](__snapshots__/rtl-withIcon.svg)
![withComplete](__snapshots__/rtl-withComplete.svg)
### [`icons.test.js`](./icons.test.js)
![icons](__snapshots__/icons-withIconifyIcon.svg)

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 69 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 69 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 71 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import type { BannerOptions } from '../src/index';
import banner from '../src/index';
describe('Complex banner', async () => {
const options: BannerOptions = {
title: '@marknow/banners',
subtitle: 'This is a more complex banner that uses most of the package settings',
icon: 'solar:inbox-unread-bold-duotone',
colors: {
foreground: '#feefec',
background: '#1d1412',
},
fonts: {
title: {
// eslint-disable-next-line spellcheck/spell-checker
name: 'Quattrocento',
data: await (await fetch('https://fonts.bunny.net/quattrocento/files/quattrocento-latin-700-normal.woff')).arrayBuffer(),
weight: 700,
style: 'normal',
},
subtitle: {
// eslint-disable-next-line spellcheck/spell-checker
name: 'Quattrocento Sans',
data: await (await fetch('https://fonts.bunny.net/quattrocento-sans/files/quattrocento-sans-latin-400-normal.woff')).arrayBuffer(),
weight: 400,
style: 'normal',
},
},
libConfig: {
fetcher: globalThis.fetch,
},
};
it('Horizontal', async () => {
const result = await banner({
...options,
});
expect(result.toString()).toMatchFileSnapshot('./__snapshots__/banner-horizontal.svg');
});
it('Horizontal RTL', async () => {
const result = await banner({
...options,
rtl: true,
});
expect(result.toString()).toMatchFileSnapshot('./__snapshots__/banner-rtl.svg');
});
it('Vertical', async () => {
const result = await banner({
...options,
layout: 'vertical',
});
expect(result.toString()).toMatchFileSnapshot('./__snapshots__/banner-vertical.svg');
});
});

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import banner from '../src/index';
describe('Iconify icons', async () => {
it('API\'s Iconify icon', async () => {
const result = await banner({
title: 'Hello World',
subtitle: 'This is a test',
// eslint-disable-next-line spellcheck/spell-checker
icon: 'solar:test-tube-minimalistic-bold-duotone',
});
expect(result.toString()).toMatchFileSnapshot('./__snapshots__/icons-withIconifyIcon.svg');
});
it('Local Iconify icon', async () => {
const result = await banner({
title: 'Hello World',
subtitle: 'This is a test',
icon: `
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
<g fill="currentColor">
<path
d="M3.187 15.049a4.085 4.085 0 0 0 0 5.758a4.042 4.042 0 0 0 5.734 0l3.746-3.762l-1.772-.736a2.356 2.356 0 0 1-1.408-1.906a2.352 2.352 0 0 0-2.074-2.082h-1.51l-2.716 2.728Z" />
<path fill-rule="evenodd"
d="M13.363 2.233a.8.8 0 0 1 1.13.003l7.274 7.305a.8.8 0 0 1-1.134 1.129L13.36 3.364a.8.8 0 0 1 .003-1.13Z"
clip-rule="evenodd" />
<path d="M14.09 4.098L3.187 15.048a4.085 4.085 0 0 0 0 5.76a4.042 4.042 0 0 0 5.734 0L19.824 9.856l-5.734-5.76Z"
opacity=".5" />
</g>
</svg>
`.toString(),
});
expect(result.toString()).toMatchFileSnapshot('./__snapshots__/icons-withIconifyIcon.svg');
});
it('URL Iconify icon', async () => {
const result = await banner({
title: 'Hello World',
subtitle: 'This is a test',
icon: 'https://api.iconify.design/solar:test-tube-minimalistic-bold-duotone.svg',
});
expect(result.toString()).toMatchFileSnapshot('./__snapshots__/icons-withIconifyIcon.svg');
});
});

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import banner from '../src/index';
describe('Horizontal Layout', async () => {
it('With Title', async () => {
const result = await banner({
title: 'Hello World',
});
expect(result.toString()).toMatchFileSnapshot('./__snapshots__/withTitle.svg');
});
it('With Subtitle', async () => {
const result = await banner({
title: 'Hello World',
subtitle: 'This is a test',
});
expect(result.toString()).toMatchFileSnapshot('./__snapshots__/withSubtitle.svg');
});
it('With Icon', async () => {
const result = await banner({
title: 'Hello World',
icon: 'solar:test-tube-bold-duotone',
});
expect(result.toString()).toMatchFileSnapshot('./__snapshots__/withIcon.svg');
});
it('With Subtitle and Icon', async () => {
const result = await banner({
title: 'Hello World',
subtitle: 'This is a test',
icon: 'solar:test-tube-bold-duotone',
});
expect(result.toString()).toMatchFileSnapshot('./__snapshots__/withComplete.svg');
});
});

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import banner from '../src/index';
describe('Horizontal Layout', async () => {
it('With Title', async () => {
const result = await banner({
title: 'Hello World',
rtl: true,
});
expect(result.toString()).toMatchFileSnapshot('./__snapshots__/rtl-withTitle.svg');
});
it('With Subtitle', async () => {
const result = await banner({
title: 'Hello World',
subtitle: 'This is a test',
rtl: true,
});
expect(result.toString()).toMatchFileSnapshot('./__snapshots__/rtl-withSubtitle.svg');
});
it('With Icon', async () => {
const result = await banner({
title: 'Hello World',
icon: 'solar:test-tube-bold-duotone',
rtl: true,
});
expect(result.toString()).toMatchFileSnapshot('./__snapshots__/rtl-withIcon.svg');
});
it('With Subtitle and Icon', async () => {
const result = await banner({
title: 'Hello World',
subtitle: 'This is a test',
icon: 'solar:test-tube-bold-duotone',
rtl: true,
});
expect(result.toString()).toMatchFileSnapshot('./__snapshots__/rtl-withComplete.svg');
});
});

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import banner from '../src/index';
describe('Vertical Layout', async () => {
it('With Title', async () => {
const result = await banner({
title: 'Hello World',
layout: 'vertical',
});
expect(result.toString()).toMatchFileSnapshot('./__snapshots__/vertical-withTitle.svg');
});
it('With Subtitle', async () => {
const result = await banner({
title: 'Hello World',
subtitle: 'This is a test',
layout: 'vertical',
});
expect(result.toString()).toMatchFileSnapshot('./__snapshots__/vertical-withSubtitle.svg');
});
it('With Icon', async () => {
const result = await banner({
title: 'Hello World',
icon: 'solar:test-tube-bold-duotone',
layout: 'vertical',
});
expect(result.toString()).toMatchFileSnapshot('./__snapshots__/vertical-withIcon.svg');
});
it('With Subtitle and Icon', async () => {
const result = await banner({
title: 'Hello World',
subtitle: 'This is a test',
icon: 'solar:test-tube-bold-duotone',
layout: 'vertical',
});
expect(result.toString()).toMatchFileSnapshot('./__snapshots__/vertical-withComplete.svg');
});
});

View File

@@ -0,0 +1,10 @@
import { defineProject } from 'vitest/config';
export default defineProject({
test: {
environmentMatchGlobs: [
['**\/*{,.node}.test.{js,ts}', 'node'],
],
include: ['./tests/**/*.test.{js,ts}'],
},
});

1877
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,16 +2,39 @@
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
"outputs": ["dist/**"]
"dependsOn": [
"^test",
"test"
],
"outputs": [
"dist/**"
]
},
"lint": {},
"test": {
"dependsOn": [
"^lint",
"lint"
]
},
"test:watch": {
"dependsOn": [
"^lint",
"lint"
],
"persistent": true
},
"dev": {
"dependsOn": ["^build"],
"dependsOn": [
"^build"
],
"cache": false,
"persistent": true
},
"preview": {
"dependsOn": ["^build"],
"dependsOn": [
"^build"
],
"persistent": true
}
}

4
vitest.workspace.json Normal file
View File

@@ -0,0 +1,4 @@
[
"packages/*/vitest.config.{js,ts}",
"apps/*/vitest.config.{js,ts}"
]