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.
- package/AGENTS.md +323 -0
- package/API.md +582 -0
- package/CODE_OF_CONDUCT.md +91 -0
- package/CONTRIBUTING.md +312 -0
- package/README.md +171 -7
- package/SECURITY.md +56 -0
- package/THEME_DEVELOPMENT.md +388 -0
- package/package.json +19 -3
- package/src/constants.js +14 -2
- package/src/index.js +14 -2
- package/src/live-ui/editor.js +173 -0
- package/src/live-ui/preview.js +77 -0
- package/src/live-ui/sidebar.js +523 -0
- package/src/utils/escapeHTML.js +10 -0
- package/src/utils/generateOGImage.js +51 -10
- package/src/utils/parseArgs.js +33 -2
- package/src/utils/readImageAsBase64.js +16 -4
- package/src/utils/setupWatcher.js +69 -0
- package/src/utils/showHelp.js +15 -2
- package/src/utils/startLiveServer.js +218 -0
- package/src/utils/websocketServer.js +53 -0
- package/test-og.js +40 -0
- package/theme/dark/style.css +1 -0
- package/theme/default/index.js +10 -8
- package/validateConfig.js +59 -0
- package/vitest.config.js +11 -0
|
@@ -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
|
|
6
|
-
|
|
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
|
-
${
|
|
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, '&')
|
|
82
|
+
.replace(/</g, '<')
|
|
83
|
+
.replace(/>/g, '>')
|
|
84
|
+
.replace(/"/g, '"')
|
|
85
|
+
.replace(/'/g, ''');
|
|
45
86
|
}
|
package/src/utils/parseArgs.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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;
|
package/src/utils/showHelp.js
CHANGED
|
@@ -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
|
|
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>', '<Title>');
|
|
34
|
+
// URL should have ampersand escaped and angle brackets escaped
|
|
35
|
+
assertContainsEscaped(out, '&other=<bad>', '&other=<bad>');
|
|
36
|
+
assertContainsEscaped(out, "O'Connor", "O'Connor");
|
|
37
|
+
assertContainsEscaped(out, '"Quote"', '"Quote"');
|
|
38
|
+
assertContainsEscaped(out, "<tags>", '<tags>');
|
|
39
|
+
|
|
40
|
+
console.log('All assertions passed.');
|
package/theme/dark/style.css
CHANGED
package/theme/default/index.js
CHANGED
|
@@ -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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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">
|