@ewanc26/og 0.1.1

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.
@@ -0,0 +1,60 @@
1
+ /**
2
+ * @ewanc26/og types
3
+ */
4
+ interface OgColorConfig {
5
+ /** Background color (very dark). @default '#0f1a15' */
6
+ background: string;
7
+ /** Primary text color. @default '#e8f5e9' */
8
+ text: string;
9
+ /** Secondary/accent text (mint). @default '#86efac' */
10
+ accent: string;
11
+ }
12
+ declare const defaultColors: OgColorConfig;
13
+ interface OgFontConfig {
14
+ heading?: string;
15
+ body?: string;
16
+ }
17
+ interface OgNoiseConfig {
18
+ enabled?: boolean;
19
+ seed?: string;
20
+ opacity?: number;
21
+ colorMode?: 'grayscale' | 'hsl';
22
+ }
23
+ interface OgTemplateProps {
24
+ title: string;
25
+ description?: string;
26
+ siteName: string;
27
+ image?: string;
28
+ colors: OgColorConfig;
29
+ noiseDataUrl?: string;
30
+ circleNoiseDataUrl?: string;
31
+ width: number;
32
+ height: number;
33
+ }
34
+ type OgTemplate = (props: OgTemplateProps) => unknown;
35
+ interface OgGenerateOptions {
36
+ title: string;
37
+ description?: string;
38
+ siteName: string;
39
+ image?: string;
40
+ template?: 'blog' | 'profile' | 'default' | OgTemplate;
41
+ colors?: Partial<OgColorConfig>;
42
+ fonts?: OgFontConfig;
43
+ noise?: OgNoiseConfig;
44
+ noiseSeed?: string;
45
+ width?: number;
46
+ height?: number;
47
+ debugSvg?: boolean;
48
+ }
49
+ interface OgEndpointOptions {
50
+ siteName: string;
51
+ defaultTemplate?: 'blog' | 'profile' | 'default' | OgTemplate;
52
+ colors?: Partial<OgColorConfig>;
53
+ fonts?: OgFontConfig;
54
+ noise?: OgNoiseConfig;
55
+ cacheMaxAge?: number;
56
+ width?: number;
57
+ height?: number;
58
+ }
59
+
60
+ export { type OgGenerateOptions as O, type OgNoiseConfig as a, type OgFontConfig as b, type OgEndpointOptions as c, type OgColorConfig as d, type OgTemplate as e, type OgTemplateProps as f, defaultColors as g };
@@ -0,0 +1,60 @@
1
+ /**
2
+ * @ewanc26/og types
3
+ */
4
+ interface OgColorConfig {
5
+ /** Background color (very dark). @default '#0f1a15' */
6
+ background: string;
7
+ /** Primary text color. @default '#e8f5e9' */
8
+ text: string;
9
+ /** Secondary/accent text (mint). @default '#86efac' */
10
+ accent: string;
11
+ }
12
+ declare const defaultColors: OgColorConfig;
13
+ interface OgFontConfig {
14
+ heading?: string;
15
+ body?: string;
16
+ }
17
+ interface OgNoiseConfig {
18
+ enabled?: boolean;
19
+ seed?: string;
20
+ opacity?: number;
21
+ colorMode?: 'grayscale' | 'hsl';
22
+ }
23
+ interface OgTemplateProps {
24
+ title: string;
25
+ description?: string;
26
+ siteName: string;
27
+ image?: string;
28
+ colors: OgColorConfig;
29
+ noiseDataUrl?: string;
30
+ circleNoiseDataUrl?: string;
31
+ width: number;
32
+ height: number;
33
+ }
34
+ type OgTemplate = (props: OgTemplateProps) => unknown;
35
+ interface OgGenerateOptions {
36
+ title: string;
37
+ description?: string;
38
+ siteName: string;
39
+ image?: string;
40
+ template?: 'blog' | 'profile' | 'default' | OgTemplate;
41
+ colors?: Partial<OgColorConfig>;
42
+ fonts?: OgFontConfig;
43
+ noise?: OgNoiseConfig;
44
+ noiseSeed?: string;
45
+ width?: number;
46
+ height?: number;
47
+ debugSvg?: boolean;
48
+ }
49
+ interface OgEndpointOptions {
50
+ siteName: string;
51
+ defaultTemplate?: 'blog' | 'profile' | 'default' | OgTemplate;
52
+ colors?: Partial<OgColorConfig>;
53
+ fonts?: OgFontConfig;
54
+ noise?: OgNoiseConfig;
55
+ cacheMaxAge?: number;
56
+ width?: number;
57
+ height?: number;
58
+ }
59
+
60
+ export { type OgGenerateOptions as O, type OgNoiseConfig as a, type OgFontConfig as b, type OgEndpointOptions as c, type OgColorConfig as d, type OgTemplate as e, type OgTemplateProps as f, defaultColors as g };
Binary file
Binary file
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@ewanc26/og",
3
+ "version": "0.1.1",
4
+ "description": "Dynamic OpenGraph image generator with noise backgrounds, bold typography, and Satori-based rendering. Works in SvelteKit endpoints, edge runtimes, and build scripts.",
5
+ "author": "Ewan Croft",
6
+ "license": "AGPL-3.0-only",
7
+ "type": "module",
8
+ "keywords": [
9
+ "og",
10
+ "opengraph",
11
+ "image",
12
+ "satori",
13
+ "sveltekit",
14
+ "seo",
15
+ "social"
16
+ ],
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/ewanc26/pkgs.git",
20
+ "directory": "packages/og"
21
+ },
22
+ "homepage": "https://github.com/ewanc26/pkgs/tree/main/packages/og",
23
+ "bugs": {
24
+ "url": "https://github.com/ewanc26/pkgs/issues"
25
+ },
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "src",
32
+ "fonts"
33
+ ],
34
+ "exports": {
35
+ ".": {
36
+ "types": "./dist/index.d.ts",
37
+ "import": "./dist/index.js",
38
+ "require": "./dist/index.cjs"
39
+ },
40
+ "./templates": {
41
+ "types": "./dist/templates/index.d.ts",
42
+ "import": "./dist/templates/index.js"
43
+ }
44
+ },
45
+ "main": "./dist/index.cjs",
46
+ "module": "./dist/index.js",
47
+ "types": "./dist/index.d.ts",
48
+ "scripts": {
49
+ "build": "tsup",
50
+ "dev": "tsup --watch",
51
+ "check": "tsc --noEmit"
52
+ },
53
+ "dependencies": {
54
+ "@ewanc26/noise": "workspace:*",
55
+ "@resvg/resvg-js": "^2.6.0",
56
+ "satori": "^0.15.2"
57
+ },
58
+ "devDependencies": {
59
+ "@types/node": "^25.5.0",
60
+ "tsup": "^8.5.1",
61
+ "typescript": "^5.9.3"
62
+ }
63
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * SvelteKit endpoint helpers.
3
+ */
4
+
5
+ import { generateOgResponse } from './generate.js'
6
+ import type { OgEndpointOptions, OgTemplate } from './types.js'
7
+
8
+ /**
9
+ * Create a SvelteKit GET handler for OG image generation.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * // src/routes/og/[title]/+server.ts
14
+ * import { createOgEndpoint } from '@ewanc26/og';
15
+ *
16
+ * export const GET = createOgEndpoint({
17
+ * siteName: 'ewancroft.uk',
18
+ * defaultTemplate: 'blog',
19
+ * });
20
+ * ```
21
+ *
22
+ * The endpoint expects query parameters:
23
+ * - `title` (required): Page title
24
+ * - `description`: Optional description
25
+ * - `image`: Optional avatar/logo URL
26
+ * - `seed`: Optional noise seed
27
+ */
28
+ export function createOgEndpoint(options: OgEndpointOptions) {
29
+ const {
30
+ siteName,
31
+ defaultTemplate: template = 'default',
32
+ colors,
33
+ fonts,
34
+ noise,
35
+ cacheMaxAge = 3600,
36
+ width,
37
+ height,
38
+ } = options
39
+
40
+ return async ({ url }: { url: URL }) => {
41
+ const title = url.searchParams.get('title')
42
+ const description = url.searchParams.get('description') ?? undefined
43
+ const image = url.searchParams.get('image') ?? undefined
44
+ const noiseSeed = url.searchParams.get('seed') ?? undefined
45
+
46
+ if (!title) {
47
+ return new Response('Missing title parameter', { status: 400 })
48
+ }
49
+
50
+ try {
51
+ return await generateOgResponse(
52
+ {
53
+ title,
54
+ description,
55
+ siteName,
56
+ image,
57
+ template: template as OgTemplate,
58
+ colors,
59
+ fonts,
60
+ noise,
61
+ noiseSeed,
62
+ width,
63
+ height,
64
+ },
65
+ cacheMaxAge
66
+ )
67
+ } catch (error) {
68
+ console.error('Failed to generate OG image:', error)
69
+ return new Response('Failed to generate image', { status: 500 })
70
+ }
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Create a typed OG image URL for use in meta tags.
76
+ */
77
+ export function createOgImageUrl(
78
+ baseUrl: string,
79
+ params: {
80
+ title: string
81
+ description?: string
82
+ image?: string
83
+ seed?: string
84
+ }
85
+ ): string {
86
+ const url = new URL(baseUrl)
87
+ url.searchParams.set('title', params.title)
88
+ if (params.description) url.searchParams.set('description', params.description)
89
+ if (params.image) url.searchParams.set('image', params.image)
90
+ if (params.seed) url.searchParams.set('seed', params.seed)
91
+ return url.toString()
92
+ }
package/src/fonts.ts ADDED
@@ -0,0 +1,121 @@
1
+ /**
2
+ * @ewanc26/og fonts
3
+ *
4
+ * Font loading utilities. Bundles Inter font by default.
5
+ */
6
+
7
+ import { readFile } from 'node:fs/promises'
8
+ import { dirname, resolve } from 'node:path'
9
+ import { fileURLToPath } from 'node:url'
10
+ import type { OgFontConfig } from './types.js'
11
+
12
+ // Declare __dirname for CJS contexts (injected by bundlers)
13
+ declare const __dirname: string | undefined
14
+
15
+ // ─── Paths ────────────────────────────────────────────────────────────────────
16
+
17
+ /**
18
+ * Get the directory of the current module.
19
+ * Works in both ESM and bundled contexts.
20
+ */
21
+ function getModuleDir(): string {
22
+ // ESM context
23
+ if (typeof import.meta !== 'undefined' && import.meta.url) {
24
+ return dirname(fileURLToPath(import.meta.url))
25
+ }
26
+ // Bundled CJS context - __dirname is injected by bundlers
27
+ if (typeof __dirname !== 'undefined') {
28
+ return __dirname
29
+ }
30
+ // Fallback
31
+ return resolve(process.cwd(), 'node_modules/@ewanc26/og/dist')
32
+ }
33
+
34
+ /**
35
+ * Resolve the fonts directory relative to the installed package.
36
+ */
37
+ function getFontsDir(): string {
38
+ return resolve(getModuleDir(), '../fonts')
39
+ }
40
+
41
+ /**
42
+ * Resolve bundled font paths. Uses getters to defer resolution until runtime.
43
+ */
44
+ export const BUNDLED_FONTS = {
45
+ get heading() {
46
+ return resolve(getFontsDir(), 'Inter-Bold.ttf')
47
+ },
48
+ get body() {
49
+ return resolve(getFontsDir(), 'Inter-Regular.ttf')
50
+ },
51
+ } as const
52
+
53
+ // ─── Font Loading ──────────────────────────────────────────────────────────────
54
+
55
+ export interface LoadedFonts {
56
+ heading: ArrayBuffer
57
+ body: ArrayBuffer
58
+ }
59
+
60
+ /**
61
+ * Load fonts from config, falling back to bundled DM Sans.
62
+ */
63
+ export async function loadFonts(config?: OgFontConfig): Promise<LoadedFonts> {
64
+ const headingPath = config?.heading ?? BUNDLED_FONTS.heading
65
+ const bodyPath = config?.body ?? BUNDLED_FONTS.body
66
+
67
+ const [heading, body] = await Promise.all([
68
+ loadFontFile(headingPath),
69
+ loadFontFile(bodyPath),
70
+ ])
71
+
72
+ return { heading, body }
73
+ }
74
+
75
+ /**
76
+ * Load a font from file path or URL.
77
+ */
78
+ async function loadFontFile(source: string): Promise<ArrayBuffer> {
79
+ // Handle URLs
80
+ if (source.startsWith('http://') || source.startsWith('https://')) {
81
+ const response = await fetch(source)
82
+ if (!response.ok) {
83
+ throw new Error(`Failed to load font from URL: ${source}`)
84
+ }
85
+ return response.arrayBuffer()
86
+ }
87
+
88
+ // Handle file paths
89
+ const buffer = await readFile(source)
90
+ return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)
91
+ }
92
+
93
+ // ─── Font Registration for Satori ─────────────────────────────────────────────
94
+
95
+ export type SatoriFontConfig = {
96
+ name: string
97
+ data: ArrayBuffer
98
+ weight: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900
99
+ style: 'normal' | 'italic'
100
+ }
101
+
102
+ /**
103
+ * Create Satori-compatible font config from loaded fonts.
104
+ * Uses Inter font family with weight 700 for headings and 400 for body.
105
+ */
106
+ export function createSatoriFonts(fonts: LoadedFonts): SatoriFontConfig[] {
107
+ return [
108
+ {
109
+ name: 'Inter',
110
+ data: fonts.heading,
111
+ weight: 700,
112
+ style: 'normal',
113
+ },
114
+ {
115
+ name: 'Inter',
116
+ data: fonts.body,
117
+ weight: 400,
118
+ style: 'normal',
119
+ },
120
+ ]
121
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Core OG image generation.
3
+ * Uses satori for JSX-to-SVG and resvg-js for SVG-to-PNG.
4
+ */
5
+
6
+ import satori from 'satori'
7
+ import { Resvg } from '@resvg/resvg-js'
8
+ import { loadFonts, createSatoriFonts } from './fonts.js'
9
+ import { generateNoiseDataUrl, generateCircleNoiseDataUrl } from './noise.js'
10
+ import { getTemplate } from './templates/index.js'
11
+ import { defaultColors } from './types.js'
12
+ import type {
13
+ OgGenerateOptions,
14
+ OgColorConfig,
15
+ OgTemplateProps,
16
+ } from './types.js'
17
+
18
+ // Standard OG image dimensions
19
+ export const OG_WIDTH = 1200
20
+ export const OG_HEIGHT = 630
21
+
22
+ /**
23
+ * Generate an OG image as PNG Buffer.
24
+ */
25
+ export async function generateOgImage(options: OgGenerateOptions): Promise<Buffer> {
26
+ const {
27
+ title,
28
+ description,
29
+ siteName,
30
+ image,
31
+ template = 'blog',
32
+ colors: colorOverrides,
33
+ fonts: fontConfig,
34
+ noise: noiseConfig,
35
+ noiseSeed,
36
+ width = OG_WIDTH,
37
+ height = OG_HEIGHT,
38
+ debugSvg = false,
39
+ } = options
40
+
41
+ // Merge colours
42
+ const colors: OgColorConfig = {
43
+ ...defaultColors,
44
+ ...colorOverrides,
45
+ }
46
+
47
+ // Load fonts
48
+ const fonts = await loadFonts(fontConfig)
49
+ const satoriFonts = createSatoriFonts(fonts)
50
+
51
+ // Generate noise background
52
+ const noiseEnabled = noiseConfig?.enabled !== false
53
+ const noiseSeedValue = noiseSeed || noiseConfig?.seed || title
54
+ const noiseDataUrl = noiseEnabled
55
+ ? generateNoiseDataUrl({
56
+ seed: noiseSeedValue,
57
+ width,
58
+ height,
59
+ opacity: noiseConfig?.opacity ?? 0.4,
60
+ colorMode: noiseConfig?.colorMode ?? 'grayscale',
61
+ })
62
+ : undefined
63
+
64
+ // Generate circular noise decoration
65
+ const circleNoiseDataUrl = noiseEnabled
66
+ ? generateCircleNoiseDataUrl({
67
+ seed: `${noiseSeedValue}-circle`,
68
+ size: 200,
69
+ opacity: noiseConfig?.opacity ?? 0.15,
70
+ colorMode: noiseConfig?.colorMode ?? 'grayscale',
71
+ })
72
+ : undefined
73
+
74
+ // Get template function
75
+ const templateFn = getTemplate(template as Parameters<typeof getTemplate>[0])
76
+
77
+ // Build template props
78
+ const props: OgTemplateProps = {
79
+ title,
80
+ description,
81
+ siteName,
82
+ image,
83
+ colors,
84
+ noiseDataUrl,
85
+ circleNoiseDataUrl,
86
+ width,
87
+ height,
88
+ }
89
+
90
+ // Render template to Satori-compatible structure
91
+ const element = templateFn(props)
92
+
93
+ // Generate SVG with satori
94
+ const svg = await satori(element as Parameters<typeof satori>[0], {
95
+ width,
96
+ height,
97
+ fonts: satoriFonts,
98
+ })
99
+
100
+ // Debug: return SVG string
101
+ if (debugSvg) {
102
+ return Buffer.from(svg)
103
+ }
104
+
105
+ // Convert SVG to PNG with resvg-js
106
+ const resvg = new Resvg(svg, {
107
+ fitTo: {
108
+ mode: 'width',
109
+ value: width,
110
+ },
111
+ })
112
+ const pngData = resvg.render()
113
+
114
+ return Buffer.from(pngData.asPng())
115
+ }
116
+
117
+ /**
118
+ * Generate OG image and return as base64 data URL.
119
+ */
120
+ export async function generateOgImageDataUrl(options: OgGenerateOptions): Promise<string> {
121
+ const png = await generateOgImage(options)
122
+ return `data:image/png;base64,${png.toString('base64')}`
123
+ }
124
+
125
+ /**
126
+ * Generate OG image and return as Response (for SvelteKit endpoints).
127
+ */
128
+ export async function generateOgResponse(options: OgGenerateOptions, cacheMaxAge = 3600): Promise<Response> {
129
+ const png = await generateOgImage(options)
130
+
131
+ return new Response(png, {
132
+ headers: {
133
+ 'Content-Type': 'image/png',
134
+ 'Cache-Control': `public, max-age=${cacheMaxAge}`,
135
+ },
136
+ })
137
+ }
package/src/index.ts ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @ewanc26/og
3
+ *
4
+ * Dynamic OpenGraph image generator with noise backgrounds, bold typography,
5
+ * and Satori-based rendering. Works in SvelteKit endpoints, edge runtimes,
6
+ * and build scripts.
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * import { generateOgImage, createOgEndpoint } from '@ewanc26/og';
11
+ * import { blogTemplate } from '@ewanc26/og/templates';
12
+ *
13
+ * // Generate PNG
14
+ * const png = await generateOgImage({
15
+ * title: 'My Blog Post',
16
+ * description: 'A description',
17
+ * siteName: 'ewancroft.uk',
18
+ * });
19
+ *
20
+ * // SvelteKit endpoint
21
+ * export const GET = createOgEndpoint({ siteName: 'ewancroft.uk' });
22
+ * ```
23
+ */
24
+
25
+ // Core generation
26
+ export { generateOgImage, generateOgImageDataUrl, generateOgResponse, OG_WIDTH, OG_HEIGHT } from './generate.js'
27
+
28
+ // Types
29
+ export type {
30
+ OgColorConfig,
31
+ OgFontConfig,
32
+ OgNoiseConfig,
33
+ OgTemplateProps,
34
+ OgTemplate,
35
+ OgGenerateOptions,
36
+ OgEndpointOptions,
37
+ } from './types.js'
38
+ export { defaultColors } from './types.js'
39
+
40
+ // Noise (for advanced customization)
41
+ export { generateNoiseDataUrl, generateCircleNoiseDataUrl } from './noise.js'
42
+
43
+ // Fonts (for advanced customization)
44
+ export { loadFonts, createSatoriFonts, BUNDLED_FONTS } from './fonts.js'
45
+
46
+ // Endpoint helpers
47
+ export { createOgEndpoint } from './endpoint.js'
48
+
49
+ // SVG to PNG conversion
50
+ export { svgToPng, svgToPngDataUrl, svgToPngResponse } from './svg.js'
51
+ export type { SvgToPngOptions } from './svg.js'
package/src/noise.ts ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * @ewanc26/og noise
3
+ */
4
+
5
+ import { generateNoisePixels } from '@ewanc26/noise'
6
+ import { PNGEncoder } from './png-encoder.js'
7
+ import type { OgNoiseConfig } from './types.js'
8
+
9
+ export interface NoiseOptions {
10
+ seed: string
11
+ width: number
12
+ height: number
13
+ opacity?: number
14
+ colorMode?: OgNoiseConfig['colorMode']
15
+ }
16
+
17
+ export interface CircleNoiseOptions {
18
+ seed: string
19
+ size: number
20
+ opacity?: number
21
+ colorMode?: OgNoiseConfig['colorMode']
22
+ }
23
+
24
+ /**
25
+ * Generate a noise PNG as a data URL.
26
+ */
27
+ export function generateNoiseDataUrl(options: NoiseOptions): string {
28
+ const { seed, width, height, opacity = 0.4, colorMode = 'grayscale' } = options
29
+
30
+ const pixels = generateNoisePixels(width, height, seed, {
31
+ gridSize: 4,
32
+ octaves: 3,
33
+ colorMode: colorMode === 'grayscale'
34
+ ? { type: 'grayscale', range: [20, 60] }
35
+ : { type: 'hsl', hueRange: 40, saturationRange: [30, 50], lightnessRange: [30, 50] }
36
+ })
37
+
38
+ if (opacity < 1) {
39
+ for (let i = 3; i < pixels.length; i += 4) {
40
+ pixels[i] = Math.round(pixels[i] * opacity)
41
+ }
42
+ }
43
+
44
+ const pngBuffer = PNGEncoder.encode(pixels, width, height)
45
+ return `data:image/png;base64,${pngBuffer.toString('base64')}`
46
+ }
47
+
48
+ /**
49
+ * Generate a circular noise PNG as a data URL.
50
+ * Creates a square image with circular transparency mask.
51
+ */
52
+ export function generateCircleNoiseDataUrl(options: CircleNoiseOptions): string {
53
+ const { seed, size, opacity = 0.15, colorMode = 'grayscale' } = options
54
+
55
+ const pixels = generateNoisePixels(size, size, seed, {
56
+ gridSize: 4,
57
+ octaves: 3,
58
+ colorMode: colorMode === 'grayscale'
59
+ ? { type: 'grayscale', range: [30, 70] }
60
+ : { type: 'hsl', hueRange: 40, saturationRange: [30, 50], lightnessRange: [30, 50] }
61
+ })
62
+
63
+ const center = size / 2
64
+ const radius = size / 2
65
+
66
+ // Apply circular mask
67
+ for (let y = 0; y < size; y++) {
68
+ for (let x = 0; x < size; x++) {
69
+ const idx = (y * size + x) * 4
70
+ const dx = x - center + 0.5
71
+ const dy = y - center + 0.5
72
+ const dist = Math.sqrt(dx * dx + dy * dy)
73
+
74
+ if (dist > radius) {
75
+ // Outside circle - fully transparent
76
+ pixels[idx + 3] = 0
77
+ } else if (dist > radius - 2) {
78
+ // Anti-alias edge
79
+ const edgeOpacity = (radius - dist) / 2
80
+ pixels[idx + 3] = Math.round(255 * edgeOpacity * opacity)
81
+ } else {
82
+ // Inside circle - apply opacity
83
+ pixels[idx + 3] = Math.round(255 * opacity)
84
+ }
85
+ }
86
+ }
87
+
88
+ const pngBuffer = PNGEncoder.encode(pixels, size, size)
89
+ return `data:image/png;base64,${pngBuffer.toString('base64')}`
90
+ }