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.
@@ -0,0 +1,388 @@
1
+ # Theme Development Guide
2
+
3
+ This guide will help you create custom themes for OpenTwig. Themes control the visual appearance of generated "link in bio" pages.
4
+
5
+ ## Theme Structure
6
+
7
+ A theme consists of two main files in a directory under `theme/`:
8
+
9
+ ```
10
+ theme/my-theme/
11
+ ├── index.js # Theme template (HTML generation)
12
+ ├── style.css # Theme styles (CSS)
13
+ └── components/ # Optional: Custom components
14
+ ├── avatar.js
15
+ ├── link.js
16
+ ├── footer-link.js
17
+ ├── share-button.js
18
+ └── qr.js
19
+ ```
20
+
21
+ ## Creating a Simple Theme
22
+
23
+ ### Step 1: Create Theme Directory
24
+
25
+ Create a new directory for your theme:
26
+ ```bash
27
+ mkdir theme/my-theme
28
+ cd theme/my-theme
29
+ ```
30
+
31
+ ### Step 2: Create index.js (Template)
32
+
33
+ The `index.js` file exports a function that generates the HTML structure. You can either:
34
+
35
+ **Option A: Create a custom template**
36
+ ```javascript
37
+ const escapeHTML = require('../../src/utils/escapeHTML');
38
+ const { avatar } = require('./components/avatar');
39
+ const { link } = require('./components/link');
40
+
41
+ module.exports = function(config) {
42
+ return `<!DOCTYPE html>
43
+ <html lang="en">
44
+ <head>
45
+ <meta charset="UTF-8">
46
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
47
+ <title>${escapeHTML(config.title)}</title>
48
+ <link rel="stylesheet" href="style.css">
49
+ </head>
50
+ <body>
51
+ <div class="container">
52
+ ${avatar(config)}
53
+ <h1>${escapeHTML(config.name)}</h1>
54
+ ${config.content ? `<p class="bio">${escapeHTML(config.content)}</p>` : ''}
55
+ <div class="links">
56
+ ${config.links.map(link).join('')}
57
+ </div>
58
+ </div>
59
+ </body>
60
+ </html>`;
61
+ };
62
+ ```
63
+
64
+ **Option B: Reuse the default template** (recommended for CSS-only themes)
65
+ ```javascript
66
+ module.exports = require('../default/index.js');
67
+ ```
68
+
69
+ ### Step 3: Create style.css
70
+
71
+ Define your theme's visual styling:
72
+ ```css
73
+ /* Basic CSS Reset */
74
+ * {
75
+ margin: 0;
76
+ padding: 0;
77
+ box-sizing: border-box;
78
+ }
79
+
80
+ /* Container */
81
+ .container {
82
+ max-width: 600px;
83
+ margin: 0 auto;
84
+ padding: 40px 20px;
85
+ text-align: center;
86
+ font-family: Arial, sans-serif;
87
+ }
88
+
89
+ /* Avatar */
90
+ .avatar {
91
+ width: 120px;
92
+ height: 120px;
93
+ border-radius: 50%;
94
+ object-fit: cover;
95
+ margin-bottom: 20px;
96
+ }
97
+
98
+ /* Links */
99
+ .link {
100
+ display: block;
101
+ background: #fff;
102
+ color: #000;
103
+ padding: 15px 20px;
104
+ margin: 10px 0;
105
+ text-decoration: none;
106
+ border-radius: 8px;
107
+ border: 2px solid #000;
108
+ transition: all 0.3s ease;
109
+ }
110
+
111
+ .link:hover {
112
+ background: #000;
113
+ color: #fff;
114
+ }
115
+ ```
116
+
117
+ ### Step 4: Register Your Theme
118
+
119
+ Add your theme name to the `SUPPORTED_THEMES` array in `src/constants.js`:
120
+
121
+ ```javascript
122
+ const SUPPORTED_THEMES = ['default', 'dark', 'minimal', 'colorful', 'my-theme'];
123
+ ```
124
+
125
+ ### Step 5: Test Your Theme
126
+
127
+ 1. Create or edit a `config.json` file:
128
+ ```json
129
+ {
130
+ "theme": "my-theme",
131
+ "url": "https://example.com",
132
+ "name": "Your Name",
133
+ "content": "Your bio",
134
+ "links": [
135
+ {"url": "https://twitter.com", "title": "Twitter"},
136
+ {"url": "https://github.com", "title": "GitHub"}
137
+ ]
138
+ }
139
+ ```
140
+
141
+ 2. Generate your page:
142
+ ```bash
143
+ npm start
144
+ ```
145
+
146
+ 3. View the result in `dist/index.html`
147
+
148
+ ## Theme Components
149
+
150
+ Themes can include custom components in the `components/` directory. Components are small, reusable functions that generate HTML fragments.
151
+
152
+ ### Available Components (from default theme)
153
+
154
+ #### avatar.js
155
+ ```javascript
156
+ module.exports = function(config) {
157
+ if (!config.avatar) return '';
158
+ return `<img src="avatar.png" alt="${escapeHTML(config.name)}" class="avatar">`;
159
+ };
160
+ ```
161
+
162
+ #### link.js
163
+ ```javascript
164
+ module.exports = function(linkConfig) {
165
+ return `<a href="${linkConfig.url}" target="_blank" rel="noopener" class="link">
166
+ <span>${escapeHTML(linkConfig.title)}</span>
167
+ </a>`;
168
+ };
169
+ ```
170
+
171
+ #### footer-link.js
172
+ ```javascript
173
+ module.exports = function(link) {
174
+ if (link.content) {
175
+ return `<button class="footer-link" data-title="${escapeHTML(link.title)}">
176
+ ${escapeHTML(link.title)}
177
+ </button>`;
178
+ }
179
+ return `<a href="${link.url}" class="footer-link" target="_blank" rel="noopener">
180
+ ${escapeHTML(link.title)}
181
+ </a>`;
182
+ };
183
+ ```
184
+
185
+ #### share-button.js
186
+ ```javascript
187
+ module.exports = function(config) {
188
+ if (!config.share) return '';
189
+ return `<button class="share-button" onclick="sharePage()">
190
+ ${escapeHTML(config.share.text || 'Share')}
191
+ </button>
192
+ <script>
193
+ function sharePage() {
194
+ if (navigator.share) {
195
+ navigator.share({
196
+ title: '${escapeHTML(config.share.title)}',
197
+ url: '${escapeHTML(config.share.url)}'
198
+ });
199
+ }
200
+ }
201
+ </script>`;
202
+ };
203
+ ```
204
+
205
+ #### qr.js
206
+ ```javascript
207
+ module.exports = function(config) {
208
+ return `<img src="qr.svg" alt="QR Code" class="qr-code">`;
209
+ };
210
+ ```
211
+
212
+ #### dialog.js
213
+ ```javascript
214
+ module.exports = function(link) {
215
+ return `<dialog id="dialog-${escapeHTML(link.title)}">
216
+ <div class="dialog-content">
217
+ <h2>${escapeHTML(link.title)}</h2>
218
+ <p>${escapeHTML(link.content)}</p>
219
+ <button onclick="this.closest('dialog').close()">Close</button>
220
+ </div>
221
+ </dialog>`;
222
+ };
223
+ ```
224
+
225
+ ## Theme Best Practices
226
+
227
+ ### 1. Use escapeHTML()
228
+ Always escape user-generated content to prevent XSS attacks:
229
+ ```javascript
230
+ const escapeHTML = require('../../src/utils/escapeHTML');
231
+
232
+ ${escapeHTML(config.name)}
233
+ ```
234
+
235
+ ### 2. Responsive Design
236
+ Make your theme work well on mobile devices:
237
+ ```css
238
+ @media (max-width: 600px) {
239
+ .container {
240
+ padding: 20px 15px;
241
+ }
242
+ .link {
243
+ padding: 12px 15px;
244
+ }
245
+ }
246
+ ```
247
+
248
+ ### 3. Accessibility
249
+ Include proper HTML attributes:
250
+ ```html
251
+ <a href="..." target="_blank" rel="noopener" aria-label="${escapeHTML(link.title)}">
252
+ ```
253
+
254
+ ### 4. CSS Optimization
255
+ PostCSS will automatically:
256
+ - Add vendor prefixes
257
+ - Minify CSS (if `config.minify` is true)
258
+
259
+ ### 5. Optional Features
260
+ Always check if optional features are configured before including them:
261
+ ```javascript
262
+ ${config.avatar ? avatar(config) : ''}
263
+ ${config.content ? `<p>${escapeHTML(config.content)}</p>` : ''}
264
+ ```
265
+
266
+ ## Theme CSS Guidelines
267
+
268
+ ### Required CSS Classes
269
+
270
+ While themes can vary in design, they should support these core classes for consistency:
271
+
272
+ ```css
273
+ /* Main container */
274
+ .container { }
275
+
276
+ /* Avatar */
277
+ .avatar { }
278
+
279
+ /* Profile name */
280
+ .name { }
281
+
282
+ /* Bio/description */
283
+ .bio { }
284
+
285
+ /* Link buttons */
286
+ .link { }
287
+ .link:hover { }
288
+
289
+ /* Footer links */
290
+ .footer-link { }
291
+
292
+ /* Share button */
293
+ .share-button { }
294
+
295
+ /* QR code (desktop only) */
296
+ .qr-code { }
297
+
298
+ /* Modal dialog */
299
+ dialog { }
300
+ .dialog-content { }
301
+ ```
302
+
303
+ ### CSS Variables (Optional)
304
+
305
+ You can use CSS variables for easier theming:
306
+ ```css
307
+ :root {
308
+ --primary-color: #000;
309
+ --secondary-color: #fff;
310
+ --background-color: #f5f5f5;
311
+ --text-color: #333;
312
+ }
313
+ ```
314
+
315
+ ## Examples
316
+
317
+ ### Minimal Theme
318
+ Reuses default template with flat, simple CSS:
319
+ ```javascript
320
+ module.exports = require('../default/index.js');
321
+ ```
322
+
323
+ ```css
324
+ .link {
325
+ background: transparent;
326
+ border: 1px solid #ccc;
327
+ color: #333;
328
+ }
329
+ ```
330
+
331
+ ### Dark Theme
332
+ Reuses default template with dark colors:
333
+ ```javascript
334
+ module.exports = require('../default/index.js');
335
+ ```
336
+
337
+ ```css
338
+ :root {
339
+ --background-color: #1a1a1a;
340
+ --text-color: #fff;
341
+ }
342
+ ```
343
+
344
+ ### Custom Layout Theme
345
+ Create a completely different layout in index.js:
346
+ ```javascript
347
+ module.exports = function(config) {
348
+ return `<!DOCTYPE html>
349
+ <html>
350
+ <head>
351
+ <!-- custom layout -->
352
+ </head>
353
+ <body class="sidebar-layout">
354
+ <!-- sidebar navigation -->
355
+ <!-- main content area -->
356
+ </body>
357
+ </html>`;
358
+ };
359
+ ```
360
+
361
+ ## Testing Your Theme
362
+
363
+ 1. Test with all configuration options (avatar, links, footer links, etc.)
364
+ 2. Test on different screen sizes (mobile, tablet, desktop)
365
+ 3. Test with long names/titles
366
+ 4. Test with many links
367
+ 5. Verify HTML validity (use https://validator.w3.org/)
368
+ 6. Check accessibility (use https://wave.webaim.org/)
369
+
370
+ ## Submitting Your Theme
371
+
372
+ If you'd like to contribute your theme to OpenTwig:
373
+
374
+ 1. Follow the contributing guide in `CONTRIBUTING.md`
375
+ 2. Ensure your theme includes:
376
+ - Complete CSS styling
377
+ - Responsive design
378
+ - Accessibility considerations
379
+ - JSDoc comments in index.js
380
+ 3. Submit a pull request with your theme
381
+
382
+ ## Resources
383
+
384
+ - **Default Theme**: `theme/default/` - Reference implementation
385
+ - **Dark Theme**: `theme/dark/` - Example of CSS-only customization
386
+ - **Minimal Theme**: `theme/minimal/` - Example of simplified styling
387
+ - **Colorful Theme**: `theme/colorful/` - Example of advanced CSS effects
388
+ - **Utility Functions**: `src/utils/` - Helper functions available for themes
package/package.json CHANGED
@@ -1,13 +1,23 @@
1
1
  {
2
2
  "name": "opentwig",
3
- "version": "1.0.5",
3
+ "version": "1.1.0",
4
4
  "description": "opentwig 🌿 is an open source link in bio page generator.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
7
7
  "opentwig": "./src/index.js"
8
8
  },
9
9
  "scripts": {
10
- "start": "node src/index.js"
10
+ "start": "node src/index.js",
11
+ "test": "vitest",
12
+ "test:run": "vitest run",
13
+ "test:coverage": "vitest run --coverage",
14
+ "test-og": "node test-og.js",
15
+ "live": "node src/index.js --live"
16
+ },
17
+ "devDependencies": {
18
+ "@vitest/coverage-v8": "^2.0.0",
19
+ "baseline-browser-mapping": "^2.9.19",
20
+ "vitest": "^2.0.0"
11
21
  },
12
22
  "repository": {
13
23
  "type": "git",
@@ -37,11 +47,17 @@
37
47
  "homepage": "https://github.com/tufantunc/opentwig#readme",
38
48
  "dependencies": {
39
49
  "autoprefixer": "^10.4.21",
50
+ "chokidar": "^5.0.0",
51
+ "cors": "^2.8.6",
52
+ "express": "^5.2.1",
40
53
  "html-minifier-terser": "^7.2.0",
54
+ "multer": "^2.0.2",
55
+ "open": "^11.0.0",
41
56
  "postcss": "^8.5.6",
42
57
  "postcss-minify": "^1.2.0",
43
58
  "qrcode": "^1.5.4",
44
- "sharp": "^0.34.4"
59
+ "sharp": "^0.34.4",
60
+ "ws": "^8.19.0"
45
61
  },
46
62
  "browserslist": [
47
63
  "ie 9"
package/src/constants.js CHANGED
@@ -24,7 +24,10 @@ const CONSTANTS = {
24
24
  // CLI options
25
25
  CLI_OPTIONS: {
26
26
  HELP: ['--help', '-h'],
27
- INIT: ['--init', '-i']
27
+ INIT: ['--init', '-i'],
28
+ VALIDATE: ['--validate-config'],
29
+ LIVE: ['--live', '-l'],
30
+ PORT: ['--port', '-p']
28
31
  },
29
32
 
30
33
  // Messages
@@ -34,6 +37,7 @@ const CONSTANTS = {
34
37
  CONFIG_CREATED: 'Sample config.json created successfully!',
35
38
  CONFIG_EDIT_INSTRUCTIONS: 'Edit config.json with your information and run "npx opentwig" to generate your page.',
36
39
  PAGE_GENERATED: '🎉 Page generated successfully!',
40
+ SHOWCASE_REMINDER: '🌟 Don\'t forget to add your site in showcase: https://github.com/tufantunc/opentwig',
37
41
  UNKNOWN_OPTION: 'Unknown option:',
38
42
  USE_HELP: 'Use --help for usage information.',
39
43
  ERROR_PREFIX: '❌ Error:',
@@ -51,8 +55,16 @@ const CONSTANTS = {
51
55
  DEFAULT_CONTENT: 'Hello World! Here is my bio.',
52
56
  DEFAULT_URL: 'https://links.yourwebsite.com',
53
57
 
58
+ // Live Mode settings
59
+ LIVE_MODE: {
60
+ PORT: 3000,
61
+ DEFAULT_HOST: 'localhost',
62
+ AUTO_SAVE_DELAY: 500,
63
+ WS_PATH: '/ws'
64
+ },
65
+
54
66
  // Required fields for validation
55
67
  REQUIRED_FIELDS: ['url', 'name']
56
68
  };
57
69
 
58
- module.exports = CONSTANTS;
70
+ module.exports = CONSTANTS;
package/src/index.js CHANGED
@@ -6,6 +6,7 @@ const saveFiles = require('./utils/saveFiles');
6
6
  const parseArgs = require('./utils/parseArgs');
7
7
  const buildPage = require('./utils/buildPage');
8
8
  const CONSTANTS = require('./constants');
9
+ const startLiveServer = require('./utils/startLiveServer');
9
10
 
10
11
  /**
11
12
  * Main application function with proper error handling
@@ -13,7 +14,13 @@ const CONSTANTS = require('./constants');
13
14
  const main = async () => {
14
15
  try {
15
16
  // Parse CLI arguments first
16
- parseArgs();
17
+ const args = parseArgs();
18
+
19
+ // Check if live mode is requested
20
+ if (args.mode === 'live') {
21
+ await startLiveServer(args.port);
22
+ return;
23
+ }
17
24
 
18
25
  // Load and validate configuration
19
26
  const config = loadConfig();
@@ -26,6 +33,7 @@ const main = async () => {
26
33
 
27
34
  // Success message
28
35
  console.log(CONSTANTS.MESSAGES.PAGE_GENERATED);
36
+ console.log(CONSTANTS.MESSAGES.SHOWCASE_REMINDER);
29
37
 
30
38
  } catch (error) {
31
39
  console.error(`${CONSTANTS.MESSAGES.ERROR_PREFIX} ${error.message}`);
@@ -33,4 +41,8 @@ const main = async () => {
33
41
  }
34
42
  };
35
43
 
36
- main();
44
+ if (require.main === module) {
45
+ main();
46
+ }
47
+
48
+ module.exports = { main };
@@ -0,0 +1,173 @@
1
+ const API_BASE = '/api';
2
+
3
+ let currentConfig = null;
4
+ let autoSaveTimeout = null;
5
+ let isAutoSaveEnabled = true;
6
+
7
+ const loadConfig = async () => {
8
+ try {
9
+ const response = await fetch(`${API_BASE}/config`);
10
+ const config = await response.json();
11
+ currentConfig = config;
12
+ return config;
13
+ } catch (error) {
14
+ console.error('Error loading config:', error);
15
+ showNotification('Failed to load configuration', 'error');
16
+ return null;
17
+ }
18
+ };
19
+
20
+ const saveConfig = async (config) => {
21
+ try {
22
+ const response = await fetch(`${API_BASE}/config`, {
23
+ method: 'POST',
24
+ headers: {
25
+ 'Content-Type': 'application/json'
26
+ },
27
+ body: JSON.stringify(config)
28
+ });
29
+
30
+ const result = await response.json();
31
+
32
+ if (result.success) {
33
+ currentConfig = result.config;
34
+ updateLastSavedTime();
35
+ showNotification('Configuration saved', 'success');
36
+ return true;
37
+ } else {
38
+ throw new Error(result.error || 'Save failed');
39
+ }
40
+ } catch (error) {
41
+ console.error('Error saving config:', error);
42
+ showNotification(`Failed to save: ${error.message}`, 'error');
43
+ return false;
44
+ }
45
+ };
46
+
47
+ const autoSave = (config) => {
48
+ if (!isAutoSaveEnabled) return;
49
+
50
+ clearTimeout(autoSaveTimeout);
51
+ autoSaveTimeout = setTimeout(() => {
52
+ saveConfig(config);
53
+ }, 500);
54
+ };
55
+
56
+ const validateField = (field, value) => {
57
+ const errors = [];
58
+
59
+ switch (field) {
60
+ case 'url':
61
+ if (!value || value.trim() === '') {
62
+ errors.push('URL is required');
63
+ } else if (!value.startsWith('http://') && !value.startsWith('https://')) {
64
+ errors.push('URL must start with http:// or https://');
65
+ }
66
+ break;
67
+ case 'name':
68
+ if (!value || value.trim() === '') {
69
+ errors.push('Name is required');
70
+ }
71
+ break;
72
+ case 'links':
73
+ if (value && value.length > 0) {
74
+ value.forEach((link, index) => {
75
+ if (!link.url || link.url.trim() === '') {
76
+ errors.push(`Link ${index + 1}: URL is required`);
77
+ }
78
+ if (!link.title || link.title.trim() === '') {
79
+ errors.push(`Link ${index + 1}: Title is required`);
80
+ }
81
+ });
82
+ }
83
+ break;
84
+ }
85
+
86
+ return errors;
87
+ };
88
+
89
+ const updateLastSavedTime = () => {
90
+ const lastSavedEl = document.getElementById('lastSaved');
91
+ const now = new Date();
92
+ lastSavedEl.textContent = now.toLocaleTimeString();
93
+ };
94
+
95
+ const showNotification = (message, type = 'info') => {
96
+ const container = document.getElementById('notificationContainer');
97
+ const notification = document.createElement('div');
98
+ notification.className = `notification ${type}`;
99
+ notification.textContent = message;
100
+
101
+ container.appendChild(notification);
102
+
103
+ setTimeout(() => {
104
+ notification.style.opacity = '0';
105
+ setTimeout(() => {
106
+ container.removeChild(notification);
107
+ }, 300);
108
+ }, 3000);
109
+ };
110
+
111
+ const exportConfig = () => {
112
+ if (!currentConfig) {
113
+ showNotification('No configuration to export', 'error');
114
+ return;
115
+ }
116
+
117
+ const dataStr = JSON.stringify(currentConfig, null, 4);
118
+ const dataBlob = new Blob([dataStr], { type: 'application/json' });
119
+ const url = URL.createObjectURL(dataBlob);
120
+
121
+ const link = document.createElement('a');
122
+ link.href = url;
123
+ link.download = 'config.json';
124
+ document.body.appendChild(link);
125
+ link.click();
126
+ document.body.removeChild(link);
127
+
128
+ URL.revokeObjectURL(url);
129
+ showNotification('Configuration exported', 'success');
130
+ };
131
+
132
+ const handleSave = () => {
133
+ if (currentConfig) {
134
+ saveConfig(currentConfig);
135
+ }
136
+ };
137
+
138
+ const toggleAutoSave = () => {
139
+ const checkbox = document.getElementById('autoSave');
140
+ isAutoSaveEnabled = checkbox.checked;
141
+
142
+ if (isAutoSaveEnabled) {
143
+ showNotification('Auto-save enabled', 'info');
144
+ } else {
145
+ showNotification('Auto-save disabled', 'info');
146
+ }
147
+ };
148
+
149
+ document.addEventListener('DOMContentLoaded', () => {
150
+ const saveBtn = document.getElementById('saveBtn');
151
+ const exportBtn = document.getElementById('exportBtn');
152
+ const autoSaveCheckbox = document.getElementById('autoSave');
153
+
154
+ saveBtn.addEventListener('click', handleSave);
155
+ exportBtn.addEventListener('click', exportConfig);
156
+ autoSaveCheckbox.addEventListener('change', toggleAutoSave);
157
+
158
+ loadConfig().then(config => {
159
+ if (config) {
160
+ renderConfigForm(config);
161
+ }
162
+ });
163
+ });
164
+
165
+ window.configEditor = {
166
+ loadConfig,
167
+ saveConfig,
168
+ autoSave,
169
+ currentConfig: () => currentConfig,
170
+ updateConfig: (newConfig) => {
171
+ currentConfig = newConfig;
172
+ }
173
+ };