Skip to content

Instantly share code, notes, and snippets.

@jasonbyrne
Created May 17, 2022 11:39
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jasonbyrne/9df2058d2470935ded4467c370285b1d to your computer and use it in GitHub Desktop.
Save jasonbyrne/9df2058d2470935ded4467c370285b1d to your computer and use it in GitHub Desktop.
Generate Placeholder SVG with Cloudflare Workers
// Credit:: https://github.com/cloudfour/simple-svg-placeholder
type SvgOptions = {
width: number
height: number
text: string
fontFamily: string
fontWeight: string
bgColor: string
textColor: string
fontSize?: number
}
const defaultOptions: SvgOptions = {
width: 300,
height: 300,
text: ':-)',
fontFamily: 'sans-serif',
fontWeight: 'bold',
bgColor: 'red',
textColor: 'white',
}
const generateSVG = (qsOpts: SvgOptions): string => {
const opts = {
...defaultOptions,
...qsOpts,
}
const fontSize =
opts.fontSize || Math.floor(Math.min(opts.width, opts.height) * 0.3)
const dy = fontSize * 0.35
const charset = 'UTF-8'
const dataUri = false
const str = `<svg xmlns="http://www.w3.org/2000/svg" width="${opts.width}" height="${opts.height}" viewBox="0 0 ${opts.width} ${opts.height}">
<rect fill="${opts.bgColor}" width="${opts.width}" height="${opts.height}"/>
<text fill="${opts.textColor}" font-family="${opts.fontFamily}" font-size="${fontSize}" dy="${dy}" font-weight="${opts.fontWeight}" x="50%" y="50%" text-anchor="middle">${opts.text}</text>
</svg>`
// Thanks to: filamentgroup/directory-encoder
const cleaned = str
.replace(/[\t\n\r]/gim, '') // Strip newlines and tabs
.replace(/\s\s+/g, ' ') // Condense multiple spaces
.replace(/'/gim, '\\i') // Normalize quotes
if (dataUri) {
const encoded = encodeURIComponent(cleaned)
.replace(/\(/g, '%28') // Encode brackets
.replace(/\)/g, '%29')
return `data:image/svg+xml;charset=${charset},${encoded}`
}
return cleaned
}
export default generateSVG
import generateSVG from './generate-svg'
import { IncomingRequest } from './incoming-request'
import {
sanitizeColor,
sanitizeNumber,
sanitizeString,
} from './sanitizers'
const cacheTtl = 60 * 60 * 24 * 90 // 90 days
// QS Options: width, height, bgColor, textColor, text
export async function generatePlaceholder(
req: IncomingRequest,
): Promise<Response> {
const url = new URL(req.request.url)
url.searchParams.sort() // improve cache-hits by sorting search params
const cache = caches.default // Cloudflare edge caching
let response = await cache.match(req.request, { ignoreMethod: true }) // try to find match for this request in the edge cache
if (response) {
// use cache found on Cloudflare edge. Set X-Worker-Cache header for helpful debug
const headers = new Headers(response.headers)
headers.set('X-Worker-Cache', 'true')
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
})
}
const imageOptions = {
width: sanitizeNumber(url.searchParams.get('width'), [10, 1200], 300),
height: sanitizeNumber(url.searchParams.get('height'), [10, 1200], 300),
fontFamily: 'sans-serif',
fontWeight: 'bold',
bgColor: sanitizeColor(url.searchParams.get('bgColor'), 'red'),
textColor: sanitizeColor(url.searchParams.get('textColor'), 'white'),
text: sanitizeString(url.searchParams.get('text'), ':)'),
fontSize:
sanitizeNumber(url.searchParams.get('fontSize'), [3, 400], 0) ||
undefined,
}
response = new Response(generateSVG(imageOptions), {
headers: {
'content-type': 'image/svg+xml; charset=utf-8',
},
})
// set cache header on 200 response
if (response.status === 200) {
response.headers.set('Cache-Control', 'public, max-age=' + cacheTtl)
} else {
// only cache other things for 5 minutes (errors, 404s, etc.)
response.headers.set('Cache-Control', 'public, max-age=300')
}
req.event.waitUntil(cache.put(req.request, response.clone())) // store in cache
return response
}
const COLOR_NAMES = [
'aliceblue',
'antiquewhite',
'aqua',
'aquamarine',
'azure',
'beige',
'bisque',
'black',
'blanchedalmond',
'blue',
'blueviolet',
'brown',
'burlywood',
'cadetblue',
'chartreuse',
'chocolate',
'coral',
'cornflowerblue',
'cornsilk',
'crimson',
'cyan',
'darkblue',
'darkcyan',
'darkgoldenrod',
'darkgray',
'darkgrey',
'darkgreen',
'darkkhaki',
'darkmagenta',
'darkolivegreen',
'darkorange',
'darkorchid',
'darkred',
'darksalmon',
'darkseagreen',
'darkslateblue',
'darkslategray',
'darkslategrey',
'darkturquoise',
'darkviolet',
'deeppink',
'deepskyblue',
'dimgray',
'dimgrey',
'dodgerblue',
'firebrick',
'floralwhite',
'forestgreen',
'fuchsia',
'gainsboro',
'ghostwhite',
'gold',
'goldenrod',
'gray',
'grey',
'green',
'greenyellow',
'honeydew',
'hotpink',
'indianred',
'indigo',
'ivory',
'khaki',
'lavender',
'lavenderblush',
'lawngreen',
'lemonchiffon',
'lightblue',
'lightcoral',
'lightcyan',
'lightgoldenrodyellow',
'lightgray',
'lightgrey',
'lightgreen',
'lightpink',
'lightsalmon',
'lightseagreen',
'lightskyblue',
'lightslategray',
'lightslategrey',
'lightsteelblue',
'lightyellow',
'lime',
'limegreen',
'linen',
'magenta',
'maroon',
'mediumaquamarine',
'mediumblue',
'mediumorchid',
'mediumpurple',
'mediumseagreen',
'mediumslateblue',
'mediumspringgreen',
'mediumturquoise',
'mediumvioletred',
'midnightblue',
'mintcream',
'mistyrose',
'moccasin',
'navajowhite',
'navy',
'oldlace',
'olive',
'olivedrab',
'orange',
'orangered',
'orchid',
'palegoldenrod',
'palegreen',
'paleturquoise',
'palevioletred',
'papayawhip',
'peachpuff',
'peru',
'pink',
'plum',
'powderblue',
'purple',
'rebeccapurple',
'red',
'rosybrown',
'royalblue',
'saddlebrown',
'salmon',
'sandybrown',
'seagreen',
'seashell',
'sienna',
'silver',
'skyblue',
'slateblue',
'slategray',
'slategrey',
'snow',
'springgreen',
'steelblue',
'tan',
'teal',
'thistle',
'tomato',
'turquoise',
'violet',
'wheat',
'white',
'whitesmoke',
'yellow',
'yellowgreen',
]
export const sanitizeNumber = (
input: string | null,
range: [number, number],
defaultValue: number,
): number => {
if (input === null) return defaultValue
const num = parseInt(input)
if (!isNaN(num) && num >= range[0] && num <= range[1]) return num
return defaultValue
}
export const sanitizeString = (
input: string | null,
defaultValue: string,
): string => {
if (input === null) return defaultValue
return input.replace(/[^a-z]+/gi, '')
}
export const sanitizeColor = (
input: string | null,
defaultValue: string,
): string => {
if (input === null) return defaultValue
if (COLOR_NAMES.includes(input.toLowerCase())) return input.toLowerCase()
if (input.match(/^[A-F0-9]{6}$/i)) return `#${input}`
return defaultValue
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment