opentwig 1.0.5 → 1.1.0

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.
@@ -2,8 +2,37 @@ const sharp = require('sharp');
2
2
  const readImageAsBase64 = require('./readImageAsBase64');
3
3
 
4
4
  module.exports = async function({name, content, avatar}) {
5
- const avatarBase64 = avatar && avatar.path ? readImageAsBase64(avatar.path) : null;
6
- // Create SVG with the same dimensions and styling as the original HTML
5
+ const avatarInfo = avatar && avatar.path ? readImageAsBase64(avatar.path) : { isSvg: false, content: '' };
6
+
7
+ // If avatar is SVG, we'll inline the markup and use a clipPath to make it circular.
8
+ // For raster images, keep using a data URI in an <image> element.
9
+ let avatarElement = '';
10
+ let avatarDefs = '';
11
+
12
+ if (avatarInfo && avatarInfo.isSvg && avatarInfo.content) {
13
+ // Ensure the inlined SVG has a root <svg> — if not, wrap it.
14
+ let inlined = avatarInfo.content.trim();
15
+ if (!/^<svg[\s>]/i.test(inlined)) {
16
+ inlined = `<svg xmlns=\"http://www.w3.org/2000/svg\">${inlined}</svg>`;
17
+ }
18
+
19
+ // Give the inlined SVG an id so we can reference it with <use>
20
+ // Remove any existing id attributes to avoid collisions
21
+ inlined = inlined.replace(/\s(id=\"[^\"]*\")/ig, '');
22
+ const symbolId = 'avatar-svg';
23
+
24
+ // Put the avatar SVG inside a <g> with an id inside <defs>
25
+ avatarDefs = `<defs><clipPath id="avatar-clip"><circle cx="48" cy="48" r="48"/></clipPath><g id="${symbolId}">${inlined}</g></defs>`;
26
+
27
+ // Use a foreignObject to render the SVG content into a 96x96 box and clip it
28
+ // Some renderers don't allow referencing inline <svg> via <use> with complex markup, so use <g> directly
29
+ avatarElement = `<g transform="translate(504,267)"><svg x="0" y="0" width="96" height="96" viewBox="0 0 96 96" preserveAspectRatio="xMidYMid slice" clip-path="url(#avatar-clip)">${inlined}</svg></g>`;
30
+
31
+ } else if (avatarInfo && avatarInfo.content) {
32
+ // Raster image data URI
33
+ avatarElement = `<image href="${avatarInfo.content}" x="504" y="267" width="96" height="96" clip-path="circle(48px at 48px 48px)"/>`;
34
+ }
35
+
7
36
  const svg = `
8
37
  <svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg">
9
38
  <defs>
@@ -21,25 +50,37 @@ module.exports = async function({name, content, avatar}) {
21
50
  }
22
51
  </style>
23
52
  </defs>
24
-
53
+ ${avatarDefs}
54
+
25
55
  <!-- Background -->
26
56
  <rect width="1200" height="630" fill="#2d2d2d"/>
27
-
57
+
28
58
  <!-- Avatar circle background -->
29
59
  <circle cx="552" cy="315" r="48" fill="#dedede"/>
30
-
60
+
31
61
  <!-- Avatar image (if provided) -->
32
- ${avatarBase64 ? `<image href="${avatarBase64}" x="504" y="267" width="96" height="96" clip-path="circle(48px at 48px 48px)"/>` : ''}
33
-
62
+ ${avatarElement}
63
+
34
64
  <!-- Text content -->
35
- <text x="660" y="300" class="name-text">${name}</text>
36
- <text x="660" y="325" class="content-text">${content}</text>
65
+ <text x="660" y="300" class="name-text">${escapeXml(name)}</text>
66
+ <text x="660" y="325" class="content-text">${escapeXml(content)}</text>
37
67
  </svg>
38
68
  `;
39
-
69
+
40
70
  // Convert SVG to JPG
41
71
  const jpgBuffer = await sharp(Buffer.from(svg))
42
72
  .jpeg({ quality: 90 })
43
73
  .toBuffer();
44
74
  return jpgBuffer;
75
+ }
76
+
77
+ // Minimal XML escaping for text nodes
78
+ function escapeXml(unsafe) {
79
+ if (!unsafe) return '';
80
+ return String(unsafe)
81
+ .replace(/&/g, '&amp;')
82
+ .replace(/</g, '&lt;')
83
+ .replace(/>/g, '&gt;')
84
+ .replace(/"/g, '&quot;')
85
+ .replace(/'/g, '&apos;');
45
86
  }
@@ -4,18 +4,49 @@ const CONSTANTS = require('../constants');
4
4
 
5
5
  module.exports = function parseArgs() {
6
6
  const args = process.argv.slice(2);
7
-
8
- for (const arg of args) {
7
+ const parsedArgs = {
8
+ mode: 'build',
9
+ port: null
10
+ };
11
+
12
+ let i = 0;
13
+ while (i < args.length) {
14
+ const arg = args[i];
15
+
9
16
  if (CONSTANTS.CLI_OPTIONS.HELP.includes(arg)) {
10
17
  showHelp();
11
18
  process.exit(0);
12
19
  } else if (CONSTANTS.CLI_OPTIONS.INIT.includes(arg)) {
13
20
  createSampleConfig();
14
21
  process.exit(0);
22
+ } else if (CONSTANTS.CLI_OPTIONS.VALIDATE.includes(arg)) {
23
+ require('../../validateConfig.js')();
24
+ process.exit(0);
25
+ } else if (CONSTANTS.CLI_OPTIONS.LIVE.includes(arg)) {
26
+ parsedArgs.mode = 'live';
27
+ } else if (CONSTANTS.CLI_OPTIONS.PORT.includes(arg)) {
28
+ if (i + 1 < args.length) {
29
+ const portValue = args[i + 1];
30
+ const portNum = parseInt(portValue);
31
+ if (!isNaN(portNum) && portNum > 0 && portNum < 65536) {
32
+ parsedArgs.port = portNum;
33
+ } else {
34
+ console.error(`${CONSTANTS.MESSAGES.ERROR_PREFIX} Invalid port number: ${portValue}. Please use a port between 1 and 65535.`);
35
+ process.exit(1);
36
+ }
37
+ i += 2;
38
+ continue;
39
+ } else {
40
+ console.error(`${CONSTANTS.MESSAGES.ERROR_PREFIX} Port option requires a value. Usage: --port <PORT> or -p <PORT>`);
41
+ process.exit(1);
42
+ }
15
43
  } else {
16
44
  console.error(`${CONSTANTS.MESSAGES.UNKNOWN_OPTION} ${arg}`);
17
45
  console.error(CONSTANTS.MESSAGES.USE_HELP);
18
46
  process.exit(1);
19
47
  }
48
+ i++;
20
49
  }
50
+
51
+ return parsedArgs;
21
52
  };
@@ -2,15 +2,19 @@ const path = require('path');
2
2
  const fs = require('fs');
3
3
  const cwd = process.cwd();
4
4
 
5
+ /**
6
+ * Read an image and return either a data URI (for raster images) or the raw SVG markup (for SVG files).
7
+ * Returns an object: { isSvg: boolean, content: string }
8
+ */
5
9
  module.exports = function(filePath) {
6
10
  if (!filePath || typeof filePath !== 'string') {
7
- return '';
11
+ return { isSvg: false, content: '' };
8
12
  }
9
13
 
10
14
  const absolutePath = path.join(cwd, filePath);
11
15
  if (!fs.existsSync(absolutePath)) {
12
16
  console.warn(`Avatar file not found: ${absolutePath}. Continuing without avatar.`);
13
- return '';
17
+ return { isSvg: false, content: '' };
14
18
  }
15
19
 
16
20
  const extension = path.extname(absolutePath).toLowerCase();
@@ -22,7 +26,7 @@ module.exports = function(filePath) {
22
26
  process.exit(1);
23
27
  }
24
28
 
25
- // Map extensions to MIME types and return a data URI
29
+ // Map extensions to MIME types
26
30
  const mimeByExt = {
27
31
  '.png': 'image/png',
28
32
  '.jpg': 'image/jpeg',
@@ -31,7 +35,15 @@ module.exports = function(filePath) {
31
35
  '.webp': 'image/webp',
32
36
  '.svg': 'image/svg+xml'
33
37
  };
38
+
39
+ if (extension === '.svg') {
40
+ // Return raw SVG markup (strip XML prolog if present)
41
+ let raw = fs.readFileSync(absolutePath, 'utf8');
42
+ raw = raw.replace(/^\s*<\?xml[^>]*>\s*/i, '');
43
+ return { isSvg: true, content: raw };
44
+ }
45
+
34
46
  const mime = mimeByExt[extension];
35
47
  const base64 = fs.readFileSync(absolutePath, 'base64');
36
- return `data:${mime};base64,${base64}`;
48
+ return { isSvg: false, content: `data:${mime};base64,${base64}` };
37
49
  }
@@ -0,0 +1,69 @@
1
+ const chokidar = require('chokidar');
2
+
3
+ const debounce = (func, delay) => {
4
+ let timeoutId;
5
+ return function(...args) {
6
+ clearTimeout(timeoutId);
7
+ timeoutId = setTimeout(() => func.apply(this, args), delay);
8
+ };
9
+ };
10
+
11
+ const setupWatcher = (configPath, wss, onConfigChange) => {
12
+ let watcher = null;
13
+ let isPaused = false;
14
+
15
+ const handleConfigChange = debounce(async (event, path) => {
16
+ if (isPaused) return;
17
+
18
+ console.log(`Config file changed: ${event} ${path}`);
19
+
20
+ try {
21
+ if (onConfigChange) {
22
+ await onConfigChange(path);
23
+ }
24
+
25
+ wss.broadcastReload();
26
+ } catch (error) {
27
+ console.error('Error handling config change:', error);
28
+ }
29
+ }, 500);
30
+
31
+ watcher = chokidar.watch(configPath, {
32
+ persistent: true,
33
+ ignoreInitial: true
34
+ });
35
+
36
+ watcher
37
+ .on('change', handleConfigChange)
38
+ .on('error', (error) => {
39
+ console.error('Watcher error:', error);
40
+ });
41
+
42
+ console.log(`Watching config file: ${configPath}`);
43
+
44
+ const pause = () => {
45
+ isPaused = true;
46
+ console.log('Config watcher paused');
47
+ };
48
+
49
+ const resume = () => {
50
+ isPaused = false;
51
+ console.log('Config watcher resumed');
52
+ };
53
+
54
+ const close = () => {
55
+ if (watcher) {
56
+ watcher.close();
57
+ watcher = null;
58
+ console.log('Config watcher closed');
59
+ }
60
+ };
61
+
62
+ return {
63
+ pause,
64
+ resume,
65
+ close
66
+ };
67
+ };
68
+
69
+ module.exports = setupWatcher;
@@ -8,19 +8,32 @@ USAGE:
8
8
  OPTIONS:
9
9
  --help, -h Show this help message
10
10
  --init, -i Create a sample config.json file
11
+ --live, -l Start live preview with config editor
12
+ --port, -p PORT Specify port for live preview (default: 3000)
11
13
 
12
14
  DESCRIPTION:
13
15
  OpenTwig generates a beautiful link in bio page from a config.json file.
14
16
 
15
17
  To get started:
16
18
  1. Run 'npx opentwig --init' to create a sample config.json
17
- 2. Edit the config.json file with your information
19
+ 2. Edit config.json file with your information
18
20
  3. Run 'npx opentwig' to generate your page
21
+
22
+ For live editing:
23
+ 1. Run 'npx opentwig --live' to start live preview
24
+ 2. Edit your config in browser sidebar
25
+ 3. Changes are saved automatically to config.json
26
+
27
+ For custom port:
28
+ npx opentwig --live --port 3001
19
29
 
20
- EXAMPLES:
30
+ EXAMPLES:
21
31
  npx opentwig --init # Create sample config
22
32
  npx opentwig --help # Show this help
23
33
  npx opentwig # Generate page from config.json
34
+ npx opentwig --live # Start live preview
35
+ npx opentwig --live -p 9000 # Live preview on port 9000
36
+ npx opentwig --live --port 3001 # Live preview on port 3001
24
37
 
25
38
  For more information, visit: https://github.com/tufantunc/opentwig
26
39
  `);
@@ -0,0 +1,218 @@
1
+ const express = require('express');
2
+ const path = require('path');
3
+ const fs = require('fs');
4
+ const http = require('http');
5
+ const { default: open } = require('open');
6
+ const multer = require('multer');
7
+ const createWSServer = require('./websocketServer');
8
+ const loadConfig = require('./loadConfig');
9
+ const buildPage = require('./buildPage');
10
+ const saveFiles = require('./saveFiles');
11
+ const setupWatcher = require('./setupWatcher');
12
+ const CONSTANTS = require('../constants');
13
+ const { applyDefaults, SAMPLE_CONFIG } = require('./configDefaults');
14
+
15
+ const startLiveServer = async (customPort) => {
16
+ const app = express();
17
+ const server = http.createServer(app);
18
+ const wss = createWSServer(server);
19
+
20
+ const PORT = customPort || process.env.PORT || CONSTANTS.LIVE_MODE.PORT;
21
+ const HOST = process.env.HOST || CONSTANTS.LIVE_MODE.DEFAULT_HOST;
22
+
23
+ const cwd = process.cwd();
24
+ const packageDir = path.join(path.dirname(require.main.filename || process.argv[1]), '..');
25
+ const configPath = path.join(cwd, CONSTANTS.CONFIG_FILE);
26
+
27
+ app.use(express.json());
28
+
29
+ app.use('/live-ui', express.static(path.join(packageDir, 'src', 'live-ui')));
30
+
31
+ app.get('/', (req, res) => {
32
+ res.sendFile(path.join(packageDir, 'src', 'live-ui', 'index.html'));
33
+ });
34
+
35
+ app.use(express.static(path.join(cwd, 'dist')));
36
+
37
+ app.get('/api/config', (req, res) => {
38
+ try {
39
+ if (!currentConfig) {
40
+ if (fs.existsSync(configPath)) {
41
+ currentConfig = loadConfig();
42
+ } else {
43
+ currentConfig = applyDefaults(SAMPLE_CONFIG);
44
+ fs.writeFileSync(configPath, JSON.stringify(currentConfig, null, 4));
45
+ }
46
+ }
47
+ res.json(currentConfig);
48
+ } catch (error) {
49
+ res.status(500).json({ error: error.message });
50
+ }
51
+ });
52
+
53
+ app.post('/api/config', (req, res) => {
54
+ try {
55
+ const newConfig = req.body;
56
+ currentConfig = applyDefaults(newConfig);
57
+
58
+ fs.writeFileSync(configPath, JSON.stringify(currentConfig, null, 4));
59
+
60
+ buildPage(currentConfig).then(({ html, css, ogImage, qrImage }) => {
61
+ saveFiles(html, css, currentConfig.avatar, ogImage, qrImage);
62
+ wss.broadcastConfigUpdate(currentConfig);
63
+ }).catch(error => {
64
+ console.error('Error building page:', error);
65
+ });
66
+
67
+ res.json({ success: true, config: currentConfig });
68
+ } catch (error) {
69
+ res.status(500).json({ error: error.message });
70
+ }
71
+ });
72
+
73
+ const upload = multer({
74
+ dest: cwd,
75
+ limits: { fileSize: 5 * 1024 * 1024 }
76
+ }).single('avatar');
77
+
78
+ app.post('/api/avatar', (req, res) => {
79
+ upload(req, res, (err) => {
80
+ if (err) {
81
+ console.error('Avatar upload error:', err);
82
+ return res.status(500).json({ success: false, error: err.message });
83
+ }
84
+
85
+ if (!req.file) {
86
+ return res.status(400).json({ success: false, error: 'No file uploaded' });
87
+ }
88
+
89
+ const avatarPath = path.basename(req.file.path);
90
+
91
+ res.json({ success: true, path: avatarPath });
92
+ });
93
+ });
94
+
95
+ app.get('/api/themes', (req, res) => {
96
+ try {
97
+ const themeDir = path.join(packageDir, 'theme');
98
+ const themes = fs.readdirSync(themeDir).filter(file => {
99
+ const themePath = path.join(themeDir, file);
100
+ return fs.statSync(themePath).isDirectory();
101
+ });
102
+ res.json(themes);
103
+ } catch (error) {
104
+ res.status(500).json({ error: error.message });
105
+ }
106
+ });
107
+
108
+ app.get('/api/validate', (req, res) => {
109
+ try {
110
+ const config = req.query.config ? JSON.parse(req.query.config) : null;
111
+
112
+ if (!config) {
113
+ return res.json({ valid: false, errors: ['Config is required'] });
114
+ }
115
+
116
+ const errors = [];
117
+ const warnings = [];
118
+
119
+ if (!config.url) {
120
+ errors.push('URL is required');
121
+ }
122
+
123
+ if (!config.name) {
124
+ errors.push('Name is required');
125
+ }
126
+
127
+ if (config.url && !config.url.startsWith('http')) {
128
+ warnings.push('URL should start with http:// or https://');
129
+ }
130
+
131
+ if (config.links && config.links.length === 0) {
132
+ warnings.push('No links configured');
133
+ }
134
+
135
+ res.json({
136
+ valid: errors.length === 0,
137
+ errors,
138
+ warnings
139
+ });
140
+ } catch (error) {
141
+ res.status(500).json({ error: error.message });
142
+ }
143
+ });
144
+
145
+ app.get('/api/status', (req, res) => {
146
+ res.json({
147
+ connected: true,
148
+ clientCount: wss.getClientCount(),
149
+ configPath,
150
+ configExists: fs.existsSync(configPath)
151
+ });
152
+ });
153
+
154
+ app.get('/', (req, res) => {
155
+ res.sendFile(path.join(packageDir, 'src', 'live-ui', 'index.html'));
156
+ });
157
+
158
+ server.listen(PORT, HOST, async () => {
159
+ console.log(`${CONSTANTS.MESSAGES.SUCCESS_PREFIX} Live server running at http://${HOST}:${PORT}`);
160
+ console.log(` Config: ${configPath}`);
161
+
162
+ if (fs.existsSync(configPath)) {
163
+ currentConfig = loadConfig();
164
+ await buildPage(currentConfig);
165
+ console.log(` Page built from existing config`);
166
+ } else {
167
+ currentConfig = applyDefaults(SAMPLE_CONFIG);
168
+ fs.writeFileSync(configPath, JSON.stringify(currentConfig, null, 4));
169
+ await buildPage(currentConfig);
170
+ console.log(` Created sample config`);
171
+ }
172
+
173
+ console.log(`\n📖 Press Ctrl+C to stop the server`);
174
+
175
+ let browserOpened = false;
176
+ try {
177
+ if (typeof open !== 'undefined' && open) {
178
+ await open(`http://${HOST}:${PORT}`);
179
+ browserOpened = true;
180
+ } else {
181
+ console.log(` ⚠️ Browser auto-open not available`);
182
+ console.log(` Please visit: http://${HOST}:${PORT}`);
183
+ }
184
+ } catch (error) {
185
+ console.log(` ⚠️ Could not open browser automatically`);
186
+ console.log(` Please visit: http://${HOST}:${PORT}`);
187
+
188
+ if (error.code === 'ENOENT') {
189
+ console.log(` No browser command found. Please open the URL manually.`);
190
+ } else if (error.code === 'EACCES') {
191
+ console.log(` Permission denied to open browser. Please open the URL manually.`);
192
+ } else {
193
+ console.log(` Error: ${error.message}`);
194
+ }
195
+ }
196
+
197
+ if (browserOpened) {
198
+ console.log(` ✅ Browser opened successfully`);
199
+ }
200
+ });
201
+
202
+ const watcher = setupWatcher(configPath, wss, async (changedPath) => {
203
+ try {
204
+ currentConfig = loadConfig();
205
+ await buildPage(currentConfig);
206
+ console.log('Page rebuilt from config change');
207
+ } catch (error) {
208
+ console.error('Error rebuilding page:', error);
209
+ }
210
+ });
211
+
212
+ server.on('close', () => {
213
+ console.log('\nServer stopped');
214
+ watcher.close();
215
+ });
216
+ };
217
+
218
+ module.exports = startLiveServer;
@@ -0,0 +1,53 @@
1
+ const WebSocket = require('ws');
2
+
3
+ const createWSServer = (server) => {
4
+ const wss = new WebSocket.Server({ server, path: '/ws' });
5
+
6
+ wss.on('connection', (ws) => {
7
+ console.log('Client connected to WebSocket');
8
+
9
+ ws.on('close', () => {
10
+ console.log('Client disconnected from WebSocket');
11
+ });
12
+
13
+ ws.on('error', (error) => {
14
+ console.error('WebSocket error:', error);
15
+ });
16
+ });
17
+
18
+ const broadcast = (message) => {
19
+ const data = JSON.stringify(message);
20
+
21
+ wss.clients.forEach((client) => {
22
+ if (client.readyState === WebSocket.OPEN) {
23
+ client.send(data);
24
+ }
25
+ });
26
+ };
27
+
28
+ const broadcastReload = () => {
29
+ broadcast({ type: 'reload' });
30
+ };
31
+
32
+ const broadcastConfigUpdate = (config) => {
33
+ broadcast({ type: 'config-update', config });
34
+ };
35
+
36
+ const broadcastThemeChange = (theme) => {
37
+ broadcast({ type: 'theme-change', theme });
38
+ };
39
+
40
+ const getClientCount = () => {
41
+ return wss.clients.size;
42
+ };
43
+
44
+ return {
45
+ broadcast,
46
+ broadcastReload,
47
+ broadcastConfigUpdate,
48
+ broadcastThemeChange,
49
+ getClientCount
50
+ };
51
+ };
52
+
53
+ module.exports = createWSServer;
package/test-og.js ADDED
@@ -0,0 +1,40 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const render = require('./theme/default');
5
+
6
+ const sample = {
7
+ title: 'A <Title> & Test "Quote"',
8
+ url: 'https://example.com/page?arg=1&other=<bad>',
9
+ name: "O'Connor & Co <dev>",
10
+ content: 'This is a description with <tags> & special "characters" and \'quotes\'.',
11
+ avatar: null,
12
+ links: [],
13
+ footerLinks: [],
14
+ share: {}
15
+ };
16
+
17
+ const out = render(sample);
18
+ const outPath = path.join(__dirname, 'test-output.html');
19
+ fs.writeFileSync(outPath, out);
20
+ console.log('Wrote', outPath);
21
+
22
+ // Basic assertions
23
+ function assertContainsEscaped(haystack, raw, escaped) {
24
+ if (haystack.includes(raw)) {
25
+ throw new Error(`Found unescaped string: ${raw}`);
26
+ }
27
+ if (!haystack.includes(escaped)) {
28
+ throw new Error(`Did not find escaped string: ${escaped}`);
29
+ }
30
+ }
31
+
32
+ // Check several values
33
+ assertContainsEscaped(out, '<Title>', '&lt;Title&gt;');
34
+ // URL should have ampersand escaped and angle brackets escaped
35
+ assertContainsEscaped(out, '&other=<bad>', '&amp;other=&lt;bad&gt;');
36
+ assertContainsEscaped(out, "O'Connor", "O&#39;Connor");
37
+ assertContainsEscaped(out, '"Quote"', '&quot;Quote&quot;');
38
+ assertContainsEscaped(out, "<tags>", '&lt;tags&gt;');
39
+
40
+ console.log('All assertions passed.');
@@ -189,6 +189,7 @@ body {
189
189
  width: 140px;
190
190
  height: 140px;
191
191
  border-radius: 8px;
192
+ filter: invert(1);
192
193
  }
193
194
  }
194
195
 
@@ -4,20 +4,22 @@ const footerLinkComponent = require('./components/footer-link');
4
4
  const shareButtonComponent = require('./components/share-button');
5
5
  const qrComponent = require('./components/qr');
6
6
  const dialogComponent = require('./components/dialog');
7
+ const escapeHTML = require('../../src/utils/escapeHTML');
7
8
 
8
9
  module.exports = function({title, url, name, content, avatar, links, footerLinks, share}) {
10
+
9
11
  return `<!DOCTYPE html>
10
12
  <html lang="en">
11
13
  <head>
12
14
  <meta charset="UTF-8" />
13
15
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
14
- <title>${title}</title>
15
- <meta name="description" content="${content}">
16
+ <title>${escapeHTML(title)}</title>
17
+ <meta name="description" content="${escapeHTML(content)}">
16
18
  <link rel="stylesheet" href="./style.css">
17
- <meta property="og:title" content="tufantunc | Twitter | Linktree"/>
18
- <meta property="og:description" content="Merhaba."/>
19
- <meta property="og:url" content="${url}"/>
20
- <meta property="og:image" content="${url}/og-image.png"/>
19
+ <meta property="og:title" content="${escapeHTML(title)}"/>
20
+ <meta property="og:description" content="${escapeHTML(content)}"/>
21
+ <meta property="og:url" content="${escapeHTML(url)}"/>
22
+ <meta property="og:image" content="${escapeHTML(url)}/og-image.jpg"/>
21
23
  </head>
22
24
  <body>
23
25
  <div class="app-bg">
@@ -28,8 +30,8 @@ module.exports = function({title, url, name, content, avatar, links, footerLinks
28
30
 
29
31
  <div class="profile">
30
32
  ${avatarComponent({avatar})}
31
- <div class="name">${name}</div>
32
- <div class="tagline">${content}</div>
33
+ <div class="name">${escapeHTML(name)}</div>
34
+ <div class="tagline">${escapeHTML(content)}</div>
33
35
  </div>
34
36
 
35
37
  <div class="links">