@stacksjs/image 0.74.32 → 0.74.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/fonts.d.ts CHANGED
@@ -1,6 +1,23 @@
1
1
  import type { Font } from 'ts-images';
2
2
  import type { ImageFontConfig } from '@stacksjs/types';
3
3
  export declare function resolveFontPath(value: string, root?: string): string;
4
+ /**
5
+ * Whether a loaded face actually produces outlines.
6
+ *
7
+ * A face can load without error, report a glyph for every character, and draw
8
+ * nothing - which writes a card of the right dimensions with a background and
9
+ * no text, reports success, and exits 0. The failure is invisible in exactly
10
+ * the situation the feature exists for, because nobody looks at their own
11
+ * og:image (stacksjs/stacks#2575).
12
+ *
13
+ * The check has to reach the outlines, and it is worth saying why rather than
14
+ * leaving it to look like belt and braces. The face that prompted this -
15
+ * `Monaco.ttf`, which this repository used to ship in the obvious place to
16
+ * point `images.fonts.title` at, and no longer does - mapped all 20 sample
17
+ * characters to a glyph id and returned an **empty contour list for every one
18
+ * of them**: 6 glyphs, no drawable Latin. A cmap check passes that.
19
+ */
20
+ export declare function drawsGlyphs(font: Font): boolean;
4
21
  export declare function loadFonts(fonts: ImageFontConfig | undefined, root?: string): Promise<ResolvedFonts>;
5
22
  /**
6
23
  * Resolve the faces the generators draw with.
package/dist/fonts.js CHANGED
@@ -1,3 +1,5 @@
1
1
  import{existsSync}from"node:fs";import{readFile}from"node:fs/promises";import{isAbsolute,resolve}from"node:path";import process from"node:process";import{loadFont}from"ts-images";export function resolveFontPath(value,root=process.cwd()){if(isAbsolute(value))return value;const local=resolve(root,value);if(existsSync(local))return local;try{return Bun.resolveSync(value,root)}catch{throw Error(`[image] Font not found: ${value}
2
2
  Looked for ${local} and for a module resolvable from ${root}.
3
- Set \`images.fonts.title\` in config/images.ts to a TrueType (.ttf) file. OpenType/CFF (.otf) and WOFF2 are not TrueType outlines and cannot be read.`)}}export async function loadFonts(fonts,root=process.cwd()){if(!fonts?.title)throw Error("[image] No font configured. Set `images.fonts.title` in config/images.ts to a TrueType (.ttf) file - generated cards and screenshots draw real glyphs and cannot fall back to a system face.");const title=loadFont(new Uint8Array(await readFile(resolveFontPath(fonts.title,root)))),body=fonts.body?loadFont(new Uint8Array(await readFile(resolveFontPath(fonts.body,root)))):title;return{title,body}}
3
+ Set \`images.fonts.title\` in config/images.ts to a TrueType (.ttf) file. OpenType/CFF (.otf) and WOFF2 are not TrueType outlines and cannot be read.`)}}const SAMPLE_CHARACTERS="AaEeHhNnOoRrSsTt0123";export function drawsGlyphs(font){for(const character of SAMPLE_CHARACTERS){const glyphId=font.glyphIdFor(character.codePointAt(0));if(glyphId>0&&font.outline(glyphId).some((contour)=>contour.length>0))return!0}return!1}function loadDrawableFont(bytes,path,option){const font=loadFont(bytes);if(!drawsGlyphs(font))throw Error(`[image] Font draws no glyphs: ${path}
4
+ It loaded, and it reports ${font.glyphCount} glyph(s), but every outline for common Latin characters is empty - a card drawn with it would be a background and no text, written successfully and silently wrong.
5
+ Set \`images.fonts.${option}\` in config/images.ts to a TrueType face with drawable outlines. Bitmap-only faces and .ttf files wrapping OpenType/CFF outlines both look like this.`);return font}export async function loadFonts(fonts,root=process.cwd()){if(!fonts?.title)throw Error("[image] No font configured. Set `images.fonts.title` in config/images.ts to a TrueType (.ttf) file - generated cards and screenshots draw real glyphs and cannot fall back to a system face.");const titlePath=resolveFontPath(fonts.title,root),title=loadDrawableFont(new Uint8Array(await readFile(titlePath)),titlePath,"title");let body=title;if(fonts.body){const bodyPath=resolveFontPath(fonts.body,root);body=loadDrawableFont(new Uint8Array(await readFile(bodyPath)),bodyPath,"body")}return{title,body}}
package/dist/social.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ImagesConfig } from '@stacksjs/types';
2
+ import type { SocialCardPreset } from 'ts-images';
2
3
  /**
3
4
  * Name a card after its route.
4
5
  *
@@ -9,6 +10,31 @@ import type { ImagesConfig } from '@stacksjs/types';
9
10
  */
10
11
  export declare function socialCardName(path: string): string;
11
12
  export declare function generateSocialCardSet(images: ImagesConfig, root?: string): Promise<SocialCardResult[]>;
13
+ /**
14
+ * Draw one card and hand back the bytes, without touching the disk.
15
+ *
16
+ * `generateSocialCardSet` covers the pages a site can enumerate at build time.
17
+ * A forge, a shop, or any app with a page per entity cannot: the card for
18
+ * `/owner/repository` has to be drawn when somebody asks for it. Routing that
19
+ * through a file means inventing a writable directory inside a request
20
+ * handler and reading back what was just written, which is two syscalls and a
21
+ * cleanup problem in exchange for nothing.
22
+ *
23
+ * The theme, fonts, mark and palette come from the same `config/images.ts` the
24
+ * build-time set uses, so a card drawn at request time matches the ones on
25
+ * disk. That consistency is the whole reason this shares `socialCardTheme`
26
+ * rather than taking its own options: a card that does not match only reveals
27
+ * itself in someone else's timeline.
28
+ *
29
+ * **This does not decide who may call it, and a caller must.** Rendering
30
+ * attacker-supplied text into an image served from your own domain is both a
31
+ * CPU amplification vector and a way to put arbitrary words on your brand.
32
+ * Pair it with `signedUrl()` / `verifySignedUrl()` from `@stacksjs/router`, or
33
+ * derive the copy from a record you looked up rather than from the query
34
+ * string. Returns `null` when social cards are not enabled, so an unconfigured
35
+ * app answers 404 rather than 500.
36
+ */
37
+ export declare function renderOnDemandSocialCard(images: ImagesConfig, card: OnDemandSocialCard, root?: string): Promise<RenderedSocialCard | null>;
12
38
  /**
13
39
  * The meta tags a page needs so a scraper renders the card at full size.
14
40
  *
@@ -36,3 +62,18 @@ export declare interface SocialCardResult {
36
62
  height: number
37
63
  title: string
38
64
  }
65
+ /** The copy that varies per card, which is all an on-demand caller supplies. */
66
+ export declare interface OnDemandSocialCard {
67
+ title: string
68
+ eyebrow?: string
69
+ subtitle?: string
70
+ foreground?: string
71
+ preset?: SocialCardPreset
72
+ }
73
+ /** What a rendered card is, when it is a response body rather than a file. */
74
+ export declare interface RenderedSocialCard {
75
+ bytes: Uint8Array
76
+ contentType: string
77
+ width: number
78
+ height: number
79
+ }
package/dist/social.js CHANGED
@@ -1 +1 @@
1
- import{mkdir}from"node:fs/promises";import process from"node:process";import{generateSocialCards}from"ts-images";import{loadFonts}from"./fonts";import{background,color,device,markPainter,projectFile,requireProjectFile,themed}from"./theme";const PRESET_SIZES={og:{width:1200,height:630},twitter:{width:1200,height:600},square:{width:1200,height:1200},portrait:{width:1200,height:1500}};export function socialCardName(path){const trimmed=path.replace(/^\/+|\/+$/g,"");return trimmed===""?"og":trimmed.replace(/[^a-z0-9]+/gi,"-").toLowerCase()}export async function generateSocialCardSet(images,root=process.cwd()){if(images.social?.enabled!==!0)return[];const social=themed(images,images.social),fonts=await loadFonts(images.fonts,root),outputDir=projectFile(social.outputDir??"public/social",root),publicPath=`/${(social.publicPath??"/social").replace(/^\/+|\/+$/g,"")}`,presets=social.presets?.length?social.presets:["og"],format=social.format??"jpeg";await mkdir(outputDir,{recursive:!0});const mark=await markPainter(social.mark,root),shared={titleFont:fonts.title,bodyFont:fonts.body,brand:social.brand,drawMark:mark?.draw,markAspect:mark?.aspect,markPlate:social.markPlate===!1?null:color(social.markPlate),surface:background(social.background,root),color:color(social.color),mutedColor:color(social.mutedColor),accent:color(social.accent),format,quality:social.quality,presets},pages=social.pages?.length?social.pages:[{path:"/",title:social.brand??"Home"}],deviceOptions=device(social.device),results=[];for(const page of pages){const name=socialCardName(page.path),shot=page.foreground??social.foreground,files=await generateSocialCards(outputDir,{...shared,name,title:page.title,eyebrow:page.eyebrow,subtitle:page.subtitle,foreground:shot?{image:requireProjectFile(shot,root,`Product shot for ${page.path}`),radius:deviceOptions?.radius,borderColor:deviceOptions?.borderColor,shadow:deviceOptions?.shadow,scale:deviceOptions?.scale}:void 0}),urls=Object.fromEntries(Object.entries(files).map(([preset,file])=>[preset,`${publicPath}/${file.slice(file.lastIndexOf("/")+1)}`]));results.push({path:page.path,name,files,urls,width:PRESET_SIZES[presets[0]].width,height:PRESET_SIZES[presets[0]].height,title:page.title})}return results}export function socialMetaTags(card,siteUrl,format="jpeg"){const primary=`${siteUrl.replace(/\/+$/,"")}${card.urls.og??Object.values(card.urls)[0]}`,mimeType=format==="jpeg"?"image/jpeg":`image/${format}`;return[`<meta property="og:image" content="${primary}">`,`<meta property="og:image:type" content="${mimeType}">`,`<meta property="og:image:width" content="${card.width}">`,`<meta property="og:image:height" content="${card.height}">`,`<meta property="og:image:alt" content="${escapeAttribute(card.title)}">`,'<meta name="twitter:card" content="summary_large_image">',`<meta name="twitter:image" content="${primary}">`,`<meta name="twitter:image:alt" content="${escapeAttribute(card.title)}">`]}function escapeAttribute(value){return value.replace(/&/g,"&amp;").replace(/"/g,"&quot;").replace(/</g,"&lt;").replace(/>/g,"&gt;")}
1
+ import{mkdir}from"node:fs/promises";import process from"node:process";import{generateSocialCards,renderSocialCard}from"ts-images";import{loadFonts}from"./fonts";import{background,color,device,markPainter,projectFile,requireProjectFile,themed}from"./theme";const PRESET_SIZES={og:{width:1200,height:630},twitter:{width:1200,height:600},square:{width:1200,height:1200},portrait:{width:1200,height:1500}};export function socialCardName(path){const trimmed=path.replace(/^\/+|\/+$/g,"");return trimmed===""?"og":trimmed.replace(/[^a-z0-9]+/gi,"-").toLowerCase()}async function socialCardTheme(images,root){const social=themed(images,images.social),fonts=await loadFonts(images.fonts,root),mark=await markPainter(social.mark,root),presets=social.presets?.length?social.presets:["og"];return{social,presets,deviceOptions:device(social.device),shared:{titleFont:fonts.title,bodyFont:fonts.body,brand:social.brand,drawMark:mark?.draw,markAspect:mark?.aspect,markPlate:social.markPlate===!1?null:color(social.markPlate),surface:background(social.background,root),color:color(social.color),mutedColor:color(social.mutedColor),accent:color(social.accent),format:social.format??"jpeg",quality:social.quality,presets}}}function cardForeground(shot,deviceOptions,root,label){if(!shot)return;return{image:requireProjectFile(shot,root,label),radius:deviceOptions?.radius,borderColor:deviceOptions?.borderColor,shadow:deviceOptions?.shadow,scale:deviceOptions?.scale}}export async function generateSocialCardSet(images,root=process.cwd()){if(images.social?.enabled!==!0)return[];const{social,presets,deviceOptions,shared}=await socialCardTheme(images,root),outputDir=projectFile(social.outputDir??"public/social",root),publicPath=`/${(social.publicPath??"/social").replace(/^\/+|\/+$/g,"")}`;await mkdir(outputDir,{recursive:!0});const pages=social.pages?.length?social.pages:[{path:"/",title:social.brand??"Home"}],results=[];for(const page of pages){const name=socialCardName(page.path),shot=page.foreground??social.foreground,files=await generateSocialCards(outputDir,{...shared,name,title:page.title,eyebrow:page.eyebrow,subtitle:page.subtitle,foreground:cardForeground(shot,deviceOptions,root,`Product shot for ${page.path}`)}),urls=Object.fromEntries(Object.entries(files).map(([preset,file])=>[preset,`${publicPath}/${file.slice(file.lastIndexOf("/")+1)}`]));results.push({path:page.path,name,files,urls,width:PRESET_SIZES[presets[0]].width,height:PRESET_SIZES[presets[0]].height,title:page.title})}return results}export async function renderOnDemandSocialCard(images,card,root=process.cwd()){if(images.social?.enabled!==!0)return null;const{social,presets,deviceOptions,shared}=await socialCardTheme(images,root),preset=card.preset??presets[0],{width,height}=PRESET_SIZES[preset],format=shared.format,{presets:_presets,...cardOptions}=shared;return{bytes:await renderSocialCard({...cardOptions,width,height,title:card.title,eyebrow:card.eyebrow,subtitle:card.subtitle,foreground:cardForeground(card.foreground??social.foreground,deviceOptions,root,"On-demand product shot")}),contentType:format==="jpeg"?"image/jpeg":`image/${format}`,width,height}}export function socialMetaTags(card,siteUrl,format="jpeg"){const primary=`${siteUrl.replace(/\/+$/,"")}${card.urls.og??Object.values(card.urls)[0]}`,mimeType=format==="jpeg"?"image/jpeg":`image/${format}`;return[`<meta property="og:image" content="${primary}">`,`<meta property="og:image:type" content="${mimeType}">`,`<meta property="og:image:width" content="${card.width}">`,`<meta property="og:image:height" content="${card.height}">`,`<meta property="og:image:alt" content="${escapeAttribute(card.title)}">`,'<meta name="twitter:card" content="summary_large_image">',`<meta name="twitter:image" content="${primary}">`,`<meta name="twitter:image:alt" content="${escapeAttribute(card.title)}">`]}function escapeAttribute(value){return value.replace(/&/g,"&amp;").replace(/"/g,"&quot;").replace(/</g,"&lt;").replace(/>/g,"&gt;")}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/image",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.74.32",
5
+ "version": "0.74.34",
6
6
  "description": "Native responsive image delivery for Stacks.",
7
7
  "author": "Chris Breuer",
8
8
  "license": "MIT",
@@ -31,7 +31,7 @@
31
31
  "prepublishOnly": "bun run build"
32
32
  },
33
33
  "dependencies": {
34
- "@stacksjs/types": "0.74.32",
34
+ "@stacksjs/types": "0.74.34",
35
35
  "ts-images": "^0.2.11"
36
36
  },
37
37
  "devDependencies": {