@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,101 @@
1
+ /**
2
+ * Minimal PNG encoder for noise backgrounds.
3
+ * Uses node:zlib for deflate compression.
4
+ */
5
+
6
+ import { deflateSync } from 'node:zlib'
7
+
8
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
9
+
10
+ function crc32(data: Buffer): number {
11
+ let crc = 0xffffffff
12
+ const table: number[] = []
13
+
14
+ // Build CRC table
15
+ for (let n = 0; n < 256; n++) {
16
+ let c = n
17
+ for (let k = 0; k < 8; k++) {
18
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
19
+ }
20
+ table[n] = c
21
+ }
22
+
23
+ // Calculate CRC
24
+ for (let i = 0; i < data.length; i++) {
25
+ crc = table[(crc ^ data[i]) & 0xff] ^ (crc >>> 8)
26
+ }
27
+
28
+ return (crc ^ 0xffffffff) >>> 0
29
+ }
30
+
31
+ function createChunk(type: string, data: Buffer): Buffer {
32
+ const length = Buffer.alloc(4)
33
+ length.writeUInt32BE(data.length, 0)
34
+
35
+ const typeBuffer = Buffer.from(type, 'ascii')
36
+ const crcData = Buffer.concat([typeBuffer, data])
37
+ const crc = Buffer.alloc(4)
38
+ crc.writeUInt32BE(crc32(crcData), 0)
39
+
40
+ return Buffer.concat([length, typeBuffer, data, crc])
41
+ }
42
+
43
+ function createIHDR(width: number, height: number): Buffer {
44
+ const data = Buffer.alloc(13)
45
+ data.writeUInt32BE(width, 0) // Width
46
+ data.writeUInt32BE(height, 4) // Height
47
+ data.writeUInt8(8, 8) // Bit depth: 8 bits
48
+ data.writeUInt8(2, 9) // Colour type: 2 (RGB)
49
+ data.writeUInt8(0, 10) // Compression method
50
+ data.writeUInt8(0, 11) // Filter method
51
+ data.writeUInt8(0, 12) // Interlace method
52
+
53
+ return createChunk('IHDR', data)
54
+ }
55
+
56
+ function createIDAT(pixels: Uint8ClampedArray, width: number, height: number): Buffer {
57
+ // Apply filter (none filter = 0) per row
58
+ const rawData = Buffer.alloc(height * (width * 3 + 1))
59
+
60
+ let srcOffset = 0
61
+ let dstOffset = 0
62
+
63
+ for (let y = 0; y < height; y++) {
64
+ rawData[dstOffset++] = 0 // Filter type: none
65
+ for (let x = 0; x < width; x++) {
66
+ const r = pixels[srcOffset++]
67
+ const g = pixels[srcOffset++]
68
+ const b = pixels[srcOffset++]
69
+ srcOffset++ // Skip alpha
70
+ rawData[dstOffset++] = r
71
+ rawData[dstOffset++] = g
72
+ rawData[dstOffset++] = b
73
+ }
74
+ }
75
+
76
+ const compressed = deflateSync(rawData)
77
+ return createChunk('IDAT', compressed)
78
+ }
79
+
80
+ function createIEND(): Buffer {
81
+ return createChunk('IEND', Buffer.alloc(0))
82
+ }
83
+
84
+ /**
85
+ * Encode raw RGBA pixel data as a PNG Buffer.
86
+ */
87
+ export function encodePNG(pixels: Uint8ClampedArray, width: number, height: number): Buffer {
88
+ return Buffer.concat([
89
+ PNG_SIGNATURE,
90
+ createIHDR(width, height),
91
+ createIDAT(pixels, width, height),
92
+ createIEND(),
93
+ ])
94
+ }
95
+
96
+ /**
97
+ * PNGEncoder namespace for cleaner imports.
98
+ */
99
+ export const PNGEncoder = {
100
+ encode: encodePNG,
101
+ }
package/src/svg.ts ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * SVG to PNG conversion using @resvg/resvg-js.
3
+ */
4
+
5
+ import { Resvg } from '@resvg/resvg-js'
6
+
7
+ export interface SvgToPngOptions {
8
+ /** Scale to fit width in pixels */
9
+ fitWidth?: number
10
+ /** Background colour for transparent areas */
11
+ backgroundColor?: string
12
+ }
13
+
14
+ /**
15
+ * Convert an SVG string to PNG Buffer.
16
+ */
17
+ export function svgToPng(svg: string, options: SvgToPngOptions = {}): Buffer {
18
+ const opts = {
19
+ fitTo: options.fitWidth
20
+ ? { mode: 'width' as const, value: options.fitWidth }
21
+ : undefined,
22
+ background: options.backgroundColor,
23
+ }
24
+
25
+ const resvg = new Resvg(svg, opts)
26
+ const rendered = resvg.render()
27
+
28
+ return Buffer.from(rendered.asPng())
29
+ }
30
+
31
+ /**
32
+ * Convert an SVG string to PNG data URL.
33
+ */
34
+ export function svgToPngDataUrl(svg: string, options: SvgToPngOptions = {}): string {
35
+ const png = svgToPng(svg, options)
36
+ return `data:image/png;base64,${png.toString('base64')}`
37
+ }
38
+
39
+ /**
40
+ * Convert an SVG string to PNG Response (for SvelteKit endpoints).
41
+ */
42
+ export function svgToPngResponse(svg: string, options: SvgToPngOptions = {}, cacheMaxAge = 3600): Response {
43
+ const png = svgToPng(svg, options)
44
+
45
+ return new Response(png, {
46
+ headers: {
47
+ 'Content-Type': 'image/png',
48
+ 'Cache-Control': `public, max-age=${cacheMaxAge}`,
49
+ },
50
+ })
51
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Blog OG template.
3
+ * Clean centered layout.
4
+ */
5
+
6
+ import type { OgTemplateProps } from '../types.js'
7
+
8
+ export function blogTemplate({
9
+ title,
10
+ description,
11
+ siteName,
12
+ colors,
13
+ width,
14
+ height,
15
+ }: OgTemplateProps) {
16
+ return {
17
+ type: 'div',
18
+ props: {
19
+ style: {
20
+ display: 'flex',
21
+ flexDirection: 'column',
22
+ alignItems: 'center',
23
+ justifyContent: 'center',
24
+ width,
25
+ height,
26
+ backgroundColor: colors.background,
27
+ },
28
+ children: [
29
+ {
30
+ type: 'h1',
31
+ props: {
32
+ style: {
33
+ fontSize: 64,
34
+ fontWeight: 700,
35
+ color: colors.text,
36
+ letterSpacing: '-0.02em',
37
+ margin: 0,
38
+ textAlign: 'center',
39
+ lineHeight: 1.1,
40
+ maxWidth: 1000,
41
+ },
42
+ children: title,
43
+ },
44
+ },
45
+ description ? {
46
+ type: 'p',
47
+ props: {
48
+ style: {
49
+ fontSize: 28,
50
+ fontWeight: 400,
51
+ color: colors.accent,
52
+ marginTop: 28,
53
+ marginBottom: 0,
54
+ textAlign: 'center',
55
+ lineHeight: 1.4,
56
+ maxWidth: 900,
57
+ },
58
+ children: description,
59
+ },
60
+ } : null,
61
+ {
62
+ type: 'p',
63
+ props: {
64
+ style: {
65
+ fontSize: 24,
66
+ fontWeight: 400,
67
+ color: colors.accent,
68
+ marginTop: 56,
69
+ marginBottom: 0,
70
+ textAlign: 'center',
71
+ opacity: 0.7,
72
+ },
73
+ children: siteName,
74
+ },
75
+ },
76
+ ].filter(Boolean),
77
+ },
78
+ }
79
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Default OG template.
3
+ * Clean, centered layout.
4
+ */
5
+
6
+ import type { OgTemplateProps } from '../types.js'
7
+
8
+ export function defaultTemplate({
9
+ title,
10
+ description,
11
+ siteName,
12
+ colors,
13
+ width,
14
+ height,
15
+ }: OgTemplateProps) {
16
+ return {
17
+ type: 'div',
18
+ props: {
19
+ style: {
20
+ display: 'flex',
21
+ flexDirection: 'column',
22
+ alignItems: 'center',
23
+ justifyContent: 'center',
24
+ width,
25
+ height,
26
+ backgroundColor: colors.background,
27
+ },
28
+ children: [
29
+ {
30
+ type: 'h1',
31
+ props: {
32
+ style: {
33
+ fontSize: 72,
34
+ fontWeight: 700,
35
+ color: colors.text,
36
+ letterSpacing: '-0.02em',
37
+ margin: 0,
38
+ textAlign: 'center',
39
+ },
40
+ children: title,
41
+ },
42
+ },
43
+ description ? {
44
+ type: 'p',
45
+ props: {
46
+ style: {
47
+ fontSize: 32,
48
+ fontWeight: 400,
49
+ color: colors.accent,
50
+ marginTop: 24,
51
+ marginBottom: 0,
52
+ textAlign: 'center',
53
+ maxWidth: 900,
54
+ },
55
+ children: description,
56
+ },
57
+ } : null,
58
+ {
59
+ type: 'p',
60
+ props: {
61
+ style: {
62
+ fontSize: 28,
63
+ fontWeight: 400,
64
+ color: colors.accent,
65
+ marginTop: 64,
66
+ marginBottom: 0,
67
+ textAlign: 'center',
68
+ opacity: 0.7,
69
+ },
70
+ children: siteName,
71
+ },
72
+ },
73
+ ].filter(Boolean),
74
+ },
75
+ }
76
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Built-in OG templates.
3
+ */
4
+
5
+ export { blogTemplate } from './blog.js'
6
+ export { profileTemplate } from './profile.js'
7
+ export { defaultTemplate } from './default.js'
8
+
9
+ import { blogTemplate } from './blog.js'
10
+ import { profileTemplate } from './profile.js'
11
+ import { defaultTemplate } from './default.js'
12
+ import type { OgTemplate } from '../types.js'
13
+
14
+ export const templates = {
15
+ blog: blogTemplate,
16
+ profile: profileTemplate,
17
+ default: defaultTemplate,
18
+ } as const
19
+
20
+ export type TemplateName = keyof typeof templates
21
+
22
+ export function getTemplate(name: TemplateName | OgTemplate): OgTemplate {
23
+ if (typeof name === 'function') {
24
+ return name
25
+ }
26
+ return templates[name]
27
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Profile OG template.
3
+ * Centered layout.
4
+ */
5
+
6
+ import type { OgTemplateProps } from '../types.js'
7
+
8
+ export function profileTemplate({
9
+ title,
10
+ description,
11
+ siteName,
12
+ image,
13
+ colors,
14
+ width,
15
+ height,
16
+ }: OgTemplateProps) {
17
+ const children: unknown[] = []
18
+
19
+ if (image) {
20
+ children.push({
21
+ type: 'img',
22
+ props: {
23
+ src: image,
24
+ width: 120,
25
+ height: 120,
26
+ style: {
27
+ borderRadius: '50%',
28
+ marginBottom: 32,
29
+ objectFit: 'cover',
30
+ },
31
+ },
32
+ })
33
+ }
34
+
35
+ children.push({
36
+ type: 'h1',
37
+ props: {
38
+ style: {
39
+ fontSize: 56,
40
+ fontWeight: 700,
41
+ color: colors.text,
42
+ letterSpacing: '-0.02em',
43
+ margin: 0,
44
+ textAlign: 'center',
45
+ lineHeight: 1.1,
46
+ maxWidth: 900,
47
+ },
48
+ children: title,
49
+ },
50
+ })
51
+
52
+ if (description) {
53
+ children.push({
54
+ type: 'p',
55
+ props: {
56
+ style: {
57
+ fontSize: 26,
58
+ fontWeight: 400,
59
+ color: colors.accent,
60
+ marginTop: 20,
61
+ marginBottom: 0,
62
+ textAlign: 'center',
63
+ lineHeight: 1.4,
64
+ maxWidth: 700,
65
+ },
66
+ children: description,
67
+ },
68
+ })
69
+ }
70
+
71
+ children.push({
72
+ type: 'p',
73
+ props: {
74
+ style: {
75
+ fontSize: 24,
76
+ fontWeight: 400,
77
+ color: colors.accent,
78
+ marginTop: 48,
79
+ marginBottom: 0,
80
+ textAlign: 'center',
81
+ opacity: 0.7,
82
+ },
83
+ children: siteName,
84
+ },
85
+ })
86
+
87
+ return {
88
+ type: 'div',
89
+ props: {
90
+ style: {
91
+ display: 'flex',
92
+ flexDirection: 'column',
93
+ alignItems: 'center',
94
+ justifyContent: 'center',
95
+ width,
96
+ height,
97
+ backgroundColor: colors.background,
98
+ },
99
+ children,
100
+ },
101
+ }
102
+ }
package/src/types.ts ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * @ewanc26/og types
3
+ */
4
+
5
+ // ─── Colour Configuration ─────────────────────────────────────────────────────
6
+
7
+ export interface OgColorConfig {
8
+ /** Background color (very dark). @default '#0f1a15' */
9
+ background: string
10
+ /** Primary text color. @default '#e8f5e9' */
11
+ text: string
12
+ /** Secondary/accent text (mint). @default '#86efac' */
13
+ accent: string
14
+ }
15
+
16
+ export const defaultColors: OgColorConfig = {
17
+ background: '#0f1a15',
18
+ text: '#e8f5e9',
19
+ accent: '#86efac',
20
+ }
21
+
22
+ // ─── Font Configuration ───────────────────────────────────────────────────────
23
+
24
+ export interface OgFontConfig {
25
+ heading?: string
26
+ body?: string
27
+ }
28
+
29
+ // ─── Noise Configuration ──────────────────────────────────────────────────────
30
+
31
+ export interface OgNoiseConfig {
32
+ enabled?: boolean
33
+ seed?: string
34
+ opacity?: number
35
+ colorMode?: 'grayscale' | 'hsl'
36
+ }
37
+
38
+ // ─── Template Props ────────────────────────────────────────────────────────────
39
+
40
+ export interface OgTemplateProps {
41
+ title: string
42
+ description?: string
43
+ siteName: string
44
+ image?: string
45
+ colors: OgColorConfig
46
+ noiseDataUrl?: string
47
+ circleNoiseDataUrl?: string
48
+ width: number
49
+ height: number
50
+ }
51
+
52
+ export type OgTemplate = (props: OgTemplateProps) => unknown
53
+
54
+ // ─── Generation Options ───────────────────────────────────────────────────────
55
+
56
+ export interface OgGenerateOptions {
57
+ title: string
58
+ description?: string
59
+ siteName: string
60
+ image?: string
61
+ template?: 'blog' | 'profile' | 'default' | OgTemplate
62
+ colors?: Partial<OgColorConfig>
63
+ fonts?: OgFontConfig
64
+ noise?: OgNoiseConfig
65
+ noiseSeed?: string
66
+ width?: number
67
+ height?: number
68
+ debugSvg?: boolean
69
+ }
70
+
71
+ // ─── SvelteKit Endpoint Options ───────────────────────────────────────────────
72
+
73
+ export interface OgEndpointOptions {
74
+ siteName: string
75
+ defaultTemplate?: 'blog' | 'profile' | 'default' | OgTemplate
76
+ colors?: Partial<OgColorConfig>
77
+ fonts?: OgFontConfig
78
+ noise?: OgNoiseConfig
79
+ cacheMaxAge?: number
80
+ width?: number
81
+ height?: number
82
+ }
83
+
84
+ // ─── Internal Types ────────────────────────────────────────────────────────────
85
+
86
+ export interface InternalGenerateContext {
87
+ width: number
88
+ height: number
89
+ fonts: { heading: ArrayBuffer; body: ArrayBuffer }
90
+ colors: OgColorConfig
91
+ noiseDataUrl?: string
92
+ }