aztomiq 1.0.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.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -0
  3. package/bin/aztomiq.js +336 -0
  4. package/bin/create-aztomiq.js +77 -0
  5. package/package.json +58 -0
  6. package/scripts/analyze-screenshots.js +217 -0
  7. package/scripts/build.js +39 -0
  8. package/scripts/builds/admin.js +17 -0
  9. package/scripts/builds/assets.js +167 -0
  10. package/scripts/builds/cache.js +48 -0
  11. package/scripts/builds/config.js +31 -0
  12. package/scripts/builds/data.js +210 -0
  13. package/scripts/builds/pages.js +288 -0
  14. package/scripts/builds/playground-examples.js +50 -0
  15. package/scripts/builds/templates.js +118 -0
  16. package/scripts/builds/utils.js +37 -0
  17. package/scripts/create-bug-tracker.js +277 -0
  18. package/scripts/deploy.js +135 -0
  19. package/scripts/feedback-generator.js +102 -0
  20. package/scripts/ui-test.js +624 -0
  21. package/scripts/utils/extract-examples.js +44 -0
  22. package/scripts/utils/migrate-icons.js +67 -0
  23. package/src/includes/breadcrumbs.ejs +100 -0
  24. package/src/includes/cloud-tags.ejs +120 -0
  25. package/src/includes/footer.ejs +37 -0
  26. package/src/includes/generator.ejs +226 -0
  27. package/src/includes/head.ejs +73 -0
  28. package/src/includes/header-data-only.ejs +43 -0
  29. package/src/includes/header.ejs +71 -0
  30. package/src/includes/layout.ejs +68 -0
  31. package/src/includes/legacy-banner.ejs +19 -0
  32. package/src/includes/mega-menu.ejs +80 -0
  33. package/src/includes/schema.ejs +20 -0
  34. package/src/templates/manifest.json.ejs +30 -0
  35. package/src/templates/readme-dist.md.ejs +58 -0
  36. package/src/templates/robots.txt.ejs +4 -0
  37. package/src/templates/sitemap.xml.ejs +69 -0
  38. package/src/templates/sw.js.ejs +78 -0
@@ -0,0 +1,67 @@
1
+ const fs = require('fs-extra');
2
+ const path = require('path');
3
+ const yaml = require('js-yaml');
4
+
5
+ const mapping = {
6
+ '✍️': 'pen-tool',
7
+ 'βš–οΈ': 'scale',
8
+ 'πŸ”–': 'bookmark',
9
+ 'πŸ’Ό': 'briefcase',
10
+ 'πŸ“ˆ': 'line-chart',
11
+ '⏰': 'clock',
12
+ 'πŸ—“οΈ': 'calendar-clock',
13
+ 'πŸ“…': 'calendar-days',
14
+ 'πŸ‘Ύ': 'code',
15
+ '🧾': 'receipt',
16
+ 'πŸ”’': 'lock',
17
+ 'πŸ’Έ': 'banknote',
18
+ 'πŸ’»': 'braces',
19
+ 'πŸ”‘': 'key',
20
+ '🏠': 'landmark',
21
+ 'πŸ“': 'pilcrow',
22
+ 'πŸŒ™': 'moon',
23
+ 'πŸ”': 'key-round',
24
+ 'πŸ“‹': 'clipboard',
25
+ 'οΌ…': 'percent',
26
+ 'πŸ”³': 'qr-code',
27
+ '🎲': 'dices',
28
+ '🏦': 'landmark',
29
+ 'πŸ₯': 'shield-plus',
30
+ '↔️': 'file-diff',
31
+ '🧹': 'wand-2',
32
+ 'πŸ”„': 'refresh-cw',
33
+ '⏳': 'stopwatch',
34
+ 'πŸ“': 'ruler',
35
+ 'πŸ”—': 'link',
36
+ 'πŸ†”': 'fingerprint',
37
+ 'πŸ‘¨β€πŸ’»': 'code-2',
38
+ 'πŸ“Š': 'bar-chart-3'
39
+ };
40
+
41
+ const featuresDir = path.join(__dirname, '../../src/features');
42
+
43
+ async function transform() {
44
+ const features = await fs.readdir(featuresDir);
45
+ for (const feature of features) {
46
+ const configPath = path.join(featuresDir, feature, 'tool.yaml');
47
+ if (await fs.pathExists(configPath)) {
48
+ let content = await fs.readFile(configPath, 'utf8');
49
+ let changed = false;
50
+
51
+ for (const [emoji, lucide] of Object.entries(mapping)) {
52
+ if (content.includes(emoji)) {
53
+ // Use regex to replace exactly the icon value or items icon
54
+ content = content.split(emoji).join(lucide);
55
+ changed = true;
56
+ }
57
+ }
58
+
59
+ if (changed) {
60
+ await fs.writeFile(configPath, content);
61
+ console.log(`βœ… Updated ${feature}/tool.yaml`);
62
+ }
63
+ }
64
+ }
65
+ }
66
+
67
+ transform();
@@ -0,0 +1,100 @@
1
+ <% const isHome=(typeof pageUrl==='undefined' || pageUrl==='' || pageUrl==='/' ); const hasValidConfig=typeof toolConfig
2
+ !=='undefined' && (toolConfig.translationKey || toolConfig.defaultTitle); if (!isHome && hasValidConfig &&
3
+ !toolConfig.hideBreadcrumbs) { const catId=toolConfig.category; const cat=global.categories && catId ?
4
+ global.categories[catId] : null; const toolTitle=toolConfig.translationKey ? t(toolConfig.translationKey + '.title' )
5
+ : toolConfig.defaultTitle; %>
6
+ <nav class="breadcrumbs-container" aria-label="Breadcrumb">
7
+ <ol class="breadcrumbs-list">
8
+ <li class="breadcrumb-item">
9
+ <a href="<%= rootPath %><%= locale %>/" class="breadcrumb-link" title="<%= t('nav.home') || 'Home' %>">
10
+ <i data-lucide="home"></i>
11
+ </a>
12
+ </li>
13
+
14
+ <% if (cat) { %>
15
+ <li class="breadcrumb-separator">
16
+ <i data-lucide="chevron-right"></i>
17
+ </li>
18
+ <li class="breadcrumb-item">
19
+ <a href="<%= rootPath %><%= locale %>/categories/#cat-<%= catId %>" class="breadcrumb-link">
20
+ <%= t(cat.translation_key) %>
21
+ </a>
22
+ </li>
23
+ <% } %>
24
+
25
+ <li class="breadcrumb-separator">
26
+ <i data-lucide="chevron-right"></i>
27
+ </li>
28
+ <li class="breadcrumb-item active" aria-current="page">
29
+ <span class="breadcrumb-current">
30
+ <%= toolTitle %>
31
+ </span>
32
+ </li>
33
+ </ol>
34
+ </nav>
35
+
36
+ <style>
37
+ .breadcrumbs-container {
38
+ padding: 1rem 0;
39
+ margin-bottom: 1rem;
40
+ font-size: 0.85rem;
41
+ color: var(--text-muted);
42
+ }
43
+
44
+ .breadcrumbs-list {
45
+ list-style: none;
46
+ padding: 0;
47
+ margin: 0;
48
+ display: flex;
49
+ align-items: center;
50
+ flex-wrap: wrap;
51
+ gap: 8px;
52
+ }
53
+
54
+ .breadcrumb-item {
55
+ display: flex;
56
+ align-items: center;
57
+ }
58
+
59
+ .breadcrumb-link {
60
+ color: var(--text-muted);
61
+ text-decoration: none;
62
+ transition: color 0.2s;
63
+ display: flex;
64
+ align-items: center;
65
+ gap: 4px;
66
+ }
67
+
68
+ .breadcrumb-link i {
69
+ width: 14px;
70
+ height: 14px;
71
+ }
72
+
73
+ .breadcrumb-link:hover {
74
+ color: var(--primary-color);
75
+ }
76
+
77
+ .breadcrumb-separator {
78
+ color: var(--border-color);
79
+ display: flex;
80
+ align-items: center;
81
+ }
82
+
83
+ .breadcrumb-separator i {
84
+ width: 14px;
85
+ height: 14px;
86
+ }
87
+
88
+ .breadcrumb-current {
89
+ font-weight: 500;
90
+ color: var(--text-color);
91
+ }
92
+
93
+ @media (max-width: 600px) {
94
+ .breadcrumbs-container {
95
+ padding: 0.75rem 0;
96
+ font-size: 0.75rem;
97
+ }
98
+ }
99
+ </style>
100
+ <% } %>
@@ -0,0 +1,120 @@
1
+ <% const tagMap={}; tools.forEach(tool=> {
2
+ if (tool.status === 'active' && tool.tags && Array.isArray(tool.tags)) {
3
+ tool.tags.forEach(tag => {
4
+ const normalized = tag.toLowerCase().trim();
5
+ if (normalized) {
6
+ tagMap[normalized] = (tagMap[normalized] || 0) + 1;
7
+ }
8
+ });
9
+ }
10
+ });
11
+
12
+ const sortedTags = Object.keys(tagMap).sort((a, b) => tagMap[b] - tagMap[a]);
13
+ const displayTags = sortedTags.slice(0, 25);
14
+ %>
15
+
16
+ <% if (displayTags.length> 0) { %>
17
+ <section class="tag-cloud-section container">
18
+ <div class="section-header">
19
+ <h2 class="section-title">
20
+ <i data-lucide="tags"></i>
21
+ <%= t('home.tag_cloud_title') || 'Tα»« khΓ³a phα»• biαΊΏn' %>
22
+ </h2>
23
+ <p class="section-desc">TΓ¬m kiαΊΏm bα»™ cΓ΄ng cα»₯ theo nhu cαΊ§u cα»§a bαΊ‘n</p>
24
+ </div>
25
+
26
+ <div class="tag-cloud">
27
+ <% displayTags.forEach(tag=> {
28
+ const count = tagMap[tag];
29
+ const sizeClass = count > 3 ? 'tag-lg' : (count > 1 ? 'tag-md' : 'tag-sm');
30
+ %>
31
+ <a href="<%= rootPath %><%= locale %>/categories/" class="cloud-tag <%= sizeClass %>">
32
+ #<%= tag %>
33
+ </a>
34
+ <% }); %>
35
+ </div>
36
+ </section>
37
+
38
+ <style>
39
+ .tag-cloud-section {
40
+ padding: 4rem 0;
41
+ margin-top: 3rem;
42
+ border-top: 1px solid var(--border-color);
43
+ }
44
+
45
+ .section-header {
46
+ margin-bottom: 2rem;
47
+ text-align: center;
48
+ }
49
+
50
+ .section-title {
51
+ display: flex;
52
+ align-items: center;
53
+ justify-content: center;
54
+ gap: 12px;
55
+ font-size: 1.75rem;
56
+ font-weight: 800;
57
+ margin-bottom: 0.5rem;
58
+ }
59
+
60
+ .section-desc {
61
+ color: var(--text-muted);
62
+ font-size: 1rem;
63
+ }
64
+
65
+ .tag-cloud {
66
+ display: flex;
67
+ flex-wrap: wrap;
68
+ justify-content: center;
69
+ gap: 12px;
70
+ max-width: 900px;
71
+ margin: 0 auto;
72
+ }
73
+
74
+ .cloud-tag {
75
+ text-decoration: none;
76
+ padding: 6px 16px;
77
+ border-radius: 50px;
78
+ background: var(--card-bg);
79
+ border: 1px solid var(--border-color);
80
+ color: var(--text-color);
81
+ font-weight: 500;
82
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
83
+ display: inline-block;
84
+ }
85
+
86
+ .cloud-tag:hover {
87
+ border-color: var(--primary-color);
88
+ color: var(--primary-color);
89
+ transform: translateY(-2px) scale(1.05);
90
+ box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
91
+ }
92
+
93
+ .tag-sm {
94
+ font-size: 0.85rem;
95
+ opacity: 0.7;
96
+ }
97
+
98
+ .tag-md {
99
+ font-size: 1rem;
100
+ font-weight: 600;
101
+ opacity: 0.85;
102
+ }
103
+
104
+ .tag-lg {
105
+ font-size: 1.15rem;
106
+ font-weight: 700;
107
+ background: linear-gradient(135deg, var(--card-bg), var(--bg-hover));
108
+ border-color: var(--primary-color);
109
+ color: var(--primary-color);
110
+ }
111
+
112
+ [data-theme="dark"] .cloud-tag {
113
+ background: rgba(255, 255, 255, 0.05);
114
+ }
115
+
116
+ [data-theme="dark"] .tag-lg {
117
+ background: rgba(59, 130, 246, 0.1);
118
+ }
119
+ </style>
120
+ <% } %>
@@ -0,0 +1,37 @@
1
+ <footer class="site-footer">
2
+ <div class="container footer-inner">
3
+ <div class="footer-col">
4
+ <h3>
5
+ <%= t('footer.about_title') || 'AZtomiq' %>
6
+ </h3>
7
+ <p>
8
+ <%= t('footer.about_desc') || 'A modern framework for utility sites.' %>
9
+ </p>
10
+ </div>
11
+ <div class="footer-col">
12
+ <h4>
13
+ <%= t('footer.links_title') || 'Links' %>
14
+ </h4>
15
+ <ul>
16
+ <li><a href="<%= rootPath %><%= locale %>/privacy/">
17
+ <%= t('footer.policy') || 'Privacy Policy' %>
18
+ </a></li>
19
+ <li><a href="<%= rootPath %><%= locale %>/terms/">
20
+ <%= t('footer.terms') || 'Terms of Service' %>
21
+ </a></li>
22
+ <li><a href="<%= rootPath %><%= locale %>/changelog/">
23
+ <%= t('footer.changelog') || 'Version History' %>
24
+ </a></li>
25
+ <li><a href="<%= global.site.github %>" target="_blank" rel="noopener">
26
+ GitHub
27
+ </a></li>
28
+ </ul>
29
+ </div>
30
+ <div class="footer-copyright">
31
+ <span>
32
+ <%= t('footer.copyright') || 'Β© 2025 AZtomiq' %>
33
+ </span>
34
+ <span>v<%= packageVersion || '1.0.0' %></span>
35
+ </div>
36
+ </div>
37
+ </footer>
@@ -0,0 +1,226 @@
1
+ <!-- Auto-generated UI -->
2
+ <% if (typeof toolConfig !=='undefined' && toolConfig.inputs) { %>
3
+
4
+ <section class="tool-container">
5
+ <div class="page-header">
6
+ <div style="display: flex; align-items: center; justify-content: center; gap: 0.75rem; margin-bottom: 0.5rem;">
7
+ <h1 style="margin-bottom: 0;">
8
+ <i data-lucide="<%= toolConfig.icon %>"></i>
9
+ <%= t(toolConfig.translationKey + '.title' ) %>
10
+ </h1>
11
+ <% if (typeof toolConfig !=='undefined' && toolConfig.meta && toolConfig.meta.version) { %>
12
+ <span class="version-badge" id="open-changelog">v<%= toolConfig.meta.version %></span>
13
+ <% } %>
14
+ </div>
15
+ <p>
16
+ <%= t(toolConfig.translationKey + '.desc' ) %>
17
+ </p>
18
+ </div>
19
+
20
+ <div class="calculator-card">
21
+
22
+ <!-- Output Section -->
23
+ <% if (toolConfig.output) { %>
24
+ <div class="password-display">
25
+ <% if (toolConfig.output.type==='textarea' ) { %>
26
+ <textarea id="<%= toolConfig.output.id %>" <%=toolConfig.output.readonly ? 'readonly' : '' %>
27
+ placeholder="<%= t(toolConfig.output.placeholder) %>"
28
+ rows="<%= toolConfig.output.rows || 1 %>"></textarea>
29
+ <% } %>
30
+
31
+ <div class="display-actions">
32
+ <% if (toolConfig.actions.secondary) { %>
33
+ <% toolConfig.actions.secondary.forEach(function(action) { %>
34
+ <button class="btn-icon" onclick="<%= action['function'] %>()" title="<%= t(action.title) %>">
35
+ <i data-lucide="<%= action.icon %>"></i>
36
+ </button>
37
+ <% }); %>
38
+ <% } %>
39
+ <!-- Regenerate Button -->
40
+ <button class="btn-icon" onclick="<%= toolConfig.actions.primary['function'] %>()"
41
+ title="Regenerate"><i data-lucide="refresh-cw"></i></button>
42
+ </div>
43
+ </div>
44
+ <% } %>
45
+
46
+ <!-- Strength Meter -->
47
+ <% if (toolConfig.id==='password-generator' ) { %>
48
+ <div class="strength-meter">
49
+ <div class="strength-bar" id="strength-bar"></div>
50
+ <span id="strength-text">Strength</span>
51
+ </div>
52
+ <% } %>
53
+
54
+ <!-- Inputs Section -->
55
+ <div class="controls-grid">
56
+ <% for (let i=0; i < toolConfig.inputs.length; i++) { const input=toolConfig.inputs[i]; let
57
+ isPair=false; let input2=null; if (input.layout==='half' && toolConfig.inputs[i+1] &&
58
+ toolConfig.inputs[i+1].layout==='half' ) { isPair=true; input2=toolConfig.inputs[i+1]; i++; } %>
59
+
60
+ <% if (input.type==='group' ) { %>
61
+ <!-- Group -->
62
+ <div class="control-box">
63
+ <h3>
64
+ <%= t(input.label) %>
65
+ </h3>
66
+ <% input.items.forEach(function(item) { %>
67
+ <label class="checkbox-container">
68
+ <input type="<%= item.type %>" id="<%= item.id %>" <%=item.default ? 'checked' : '' %>>
69
+ <span>
70
+ <%= t(item.label) %>
71
+ </span>
72
+ </label>
73
+ <% }); %>
74
+ </div>
75
+
76
+ <% } else if (isPair) { %>
77
+ <!-- Paired Inputs -->
78
+ <div class="control-box full-width"
79
+ style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
80
+ <!-- First Input -->
81
+ <div>
82
+ <label class="control-label">
83
+ <%= t(input.label) %>
84
+ <% if (input.displayValue) { %> <span id="<%= input.id %>-val">
85
+ <%= input.default %>
86
+ </span>
87
+ <% } %>
88
+ </label>
89
+ <% if (input.type==='range' ) { %>
90
+ <input type="range" id="<%= input.id %>" min="<%= input.min %>" max="<%= input.max %>"
91
+ value="<%= input.default %>"
92
+ oninput="if(document.getElementById('<%= input.id %>-val')) document.getElementById('<%= input.id %>-val').innerText = this.value">
93
+ <% } else if (input.type==='number' ) { %>
94
+ <input type="number" id="<%= input.id %>" min="<%= input.min %>" max="<%= input.max %>"
95
+ value="<%= input.default %>" class="form-input">
96
+ <% } %>
97
+ </div>
98
+
99
+ <!-- Second Input -->
100
+ <div>
101
+ <label class="control-label">
102
+ <%= t(input2.label) %>
103
+ <% if (input2.displayValue) { %> <span id="<%= input2.id %>-val">
104
+ <%= input2.default %>
105
+ </span>
106
+ <% } %>
107
+ </label>
108
+ <% if (input2.type==='range' ) { %>
109
+ <input type="range" id="<%= input2.id %>" min="<%= input2.min %>" max="<%= input2.max %>"
110
+ value="<%= input2.default %>"
111
+ oninput="if(document.getElementById('<%= input2.id %>-val')) document.getElementById('<%= input2.id %>-val').innerText = this.value">
112
+ <% } else if (input2.type==='number' ) { %>
113
+ <input type="number" id="<%= input2.id %>" min="<%= input2.min %>" max="<%= input2.max %>"
114
+ value="<%= input2.default %>" class="form-input">
115
+ <% } %>
116
+ </div>
117
+ </div>
118
+
119
+ <% } else if (input.type==='select' ) { %>
120
+ <!-- Select Input -->
121
+ <div class="control-box">
122
+ <label for="<%= input.id %>">
123
+ <%= t(input.label) %>
124
+ </label>
125
+ <select id="<%= input.id %>">
126
+ <% input.options.forEach(function(opt) { %>
127
+ <option value="<%= opt.value %>" <%=input.default===opt.value ? 'selected' : '' %>><%=
128
+ t(opt.label) %>
129
+ </option>
130
+ <% }); %>
131
+ </select>
132
+ </div>
133
+
134
+ <% } else if (input.type==='icon_select' ) { %>
135
+ <!-- Icon Select (for type: icon_select) -->
136
+ <div class="control-box">
137
+ <label for="<%= input.id %>">
138
+ <%= t(input.label) %>
139
+ </label>
140
+ <div class="icon-select-grid">
141
+ <% input.options.forEach(function(opt) { %>
142
+ <label class="icon-option">
143
+ <input type="radio" name="<%= input.id %>" value="<%= opt.value %>"
144
+ <%=input.default===opt.value ? 'checked' : '' %>>
145
+ <span class="icon-label" title="<%= t(opt.label) %>">
146
+ <i data-lucide="<%= opt.icon %>"></i>
147
+ </span>
148
+ </label>
149
+ <% }); %>
150
+ </div>
151
+ </div>
152
+
153
+ <% } else if (input.type==='textarea' ) { %>
154
+ <!-- Textarea Input -->
155
+ <div class="control-box full-width">
156
+ <label for="<%= input.id %>" class="control-label">
157
+ <%= t(input.label) %>
158
+ </label>
159
+ <textarea id="<%= input.id %>" rows="<%= input.rows || 5 %>"
160
+ placeholder="<%= input.placeholder ? t(input.placeholder) : '' %>" class="form-input"
161
+ style="width: 100%; padding: 0.75rem; border: 1px solid var(--border-color); border-radius: var(--radius); background: var(--bg-card); color: var(--text-primary); font-family: inherit; resize: vertical;"><%= input.default || '' %></textarea>
162
+ </div>
163
+
164
+ <% } else { %>
165
+ <!-- Single/Orphan Input (number/range) -->
166
+ <div class="control-box">
167
+ <label class="control-label">
168
+ <%= t(input.label) %>
169
+ <% if (input.displayValue) { %> <span id="<%= input.id %>-val">
170
+ <%= input.default %>
171
+ </span>
172
+ <% } %>
173
+ </label>
174
+ <% if (input.type==='range' ) { %>
175
+ <input type="range" id="<%= input.id %>" min="<%= input.min %>" max="<%= input.max %>"
176
+ value="<%= input.default %>"
177
+ oninput="if(document.getElementById('<%= input.id %>-val')) document.getElementById('<%= input.id %>-val').innerText = this.value">
178
+ <% } else if (input.type==='number' ) { %>
179
+ <input type="number" id="<%= input.id %>" min="<%= input.min %>"
180
+ max="<%= input.max %>" value="<%= input.default %>" class="form-input">
181
+ <% } %>
182
+ </div>
183
+ <% } %>
184
+
185
+ <% } %>
186
+ </div>
187
+
188
+ <!-- Primary Action -->
189
+ <button class="btn btn-primary btn-block" onclick="<%= toolConfig.actions.primary['function'] %>()"
190
+ style="margin-top: 1.5rem;">
191
+ <%= t(toolConfig.actions.primary.label) %>
192
+ </button>
193
+
194
+ </div>
195
+
196
+ <!-- Content Guide Section -->
197
+ <% if (toolConfig.guide) { %>
198
+ <section class="info-section">
199
+ <h2>
200
+ <%= t(toolConfig.guide.title) %>
201
+ </h2>
202
+ <% if (toolConfig.guide.items) { %>
203
+ <ul>
204
+ <% toolConfig.guide.items.forEach(function(item) { %>
205
+ <li>
206
+ <% if (item.title) { %><strong>
207
+ <%= t(item.title) %>:
208
+ </strong>
209
+ <% } %>
210
+ <%= t(item.content) %>
211
+ </li>
212
+ <% }); %>
213
+ </ul>
214
+ <% } %>
215
+ </section>
216
+ <% } else if (toolConfig.id==='password-generator' ) { %>
217
+ <div class="info-section">
218
+ <h2>Why use a strong password?</h2>
219
+ <p>Using a unique, random password for every account is the single best thing you can do to protect your
220
+ digital life.</p>
221
+ <p>We generate passwords locally on your device. Nothing is sent to our servers.</p>
222
+ </div>
223
+ <% } %>
224
+
225
+ </section>
226
+ <% } %>
@@ -0,0 +1,73 @@
1
+ <meta charset="UTF-8">
2
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
3
+ <title>
4
+ <%= title %>
5
+ </title>
6
+ <% const metaDesc=(toolConfig && toolConfig.translationKey) ? t(toolConfig.translationKey + '.desc' ) :
7
+ t('meta.description'); const ogImgPath=(toolConfig && toolConfig.ogImage) ? toolConfig.ogImage : global.site.og_image;
8
+ %>
9
+ <meta name="description" content="<%= metaDesc %>">
10
+ <% const kws=(toolConfig && toolConfig.keywords) ? toolConfig.keywords : global.site.keywords; if (kws && kws.length>
11
+ 0) {
12
+ %>
13
+ <meta name="keywords" content="<%= kws.join(', ') %>">
14
+ <% } %>
15
+
16
+ <!-- Canonical URL -->
17
+ <link rel="canonical" href="<%= global.site.url %>/<%= locale %>/<%= pageUrl %>" />
18
+
19
+ <!-- Hreflang Tags for Multi-language -->
20
+ <% const otherLocale=locale==='vi' ? 'en' : 'vi' ; %>
21
+ <link rel="alternate" hreflang="vi" href="<%= global.site.url %>/vi/<%= pageUrl %>" />
22
+ <link rel="alternate" hreflang="en" href="<%= global.site.url %>/en/<%= pageUrl %>" />
23
+ <link rel="alternate" hreflang="x-default" href="<%= global.site.url %>/vi/<%= pageUrl %>" />
24
+
25
+ <!-- Open Graph / Facebook -->
26
+ <meta property="og:type" content="<%= toolConfig && toolConfig.id ? 'article' : 'website' %>" />
27
+ <meta property="og:url" content="<%= global.site.url %>/<%= locale %>/<%= pageUrl %>" />
28
+ <meta property="og:title" content="<%= title %>" />
29
+ <meta property="og:description" content="<%= metaDesc %>" />
30
+ <meta property="og:image" content="<%= global.site.url %><%= ogImgPath %>" />
31
+ <meta property="og:site_name" content="AZtomiq" />
32
+ <meta property="og:locale" content="<%= locale === 'vi' ? 'vi_VN' : 'en_US' %>" />
33
+ <meta property="og:locale:alternate" content="<%= otherLocale === 'vi' ? 'vi_VN' : 'en_US' %>" />
34
+
35
+ <!-- Twitter Card -->
36
+ <meta name="twitter:card" content="summary_large_image" />
37
+ <meta name="twitter:url" content="<%= global.site.url %>/<%= locale %>/<%= pageUrl %>" />
38
+ <meta name="twitter:title" content="<%= title %>" />
39
+ <meta name="twitter:description" content="<%= metaDesc %>" />
40
+ <meta name="twitter:image" content="<%= global.site.url %><%= ogImgPath %>" />
41
+
42
+ <!-- Author & Copyright -->
43
+ <meta name="author" content="<%= global.site.author %>" />
44
+ <meta name="robots" content="index, follow" />
45
+
46
+ <!-- Stylesheets -->
47
+ <link rel="stylesheet" href="<%= asset('assets/css/global.css') %>">
48
+ <% if (toolConfig && toolConfig._assets && toolConfig._assets.css) { %>
49
+ <link rel="stylesheet" href="<%= toolConfig._assets.css %>">
50
+ <% } %>
51
+ <link rel="icon" type="image/svg+xml" href="<%= rootPath %>assets/images/logo.svg">
52
+ <link rel="manifest" href="<%= rootPath %>manifest.json">
53
+
54
+ <!-- Lucide Icons Library -->
55
+ <script src="https://unpkg.com/lucide@latest"></script>
56
+
57
+ <script src="<%= asset('assets/js/user-mode.js') %>"></script>
58
+
59
+ <!-- Dark Mode Script (Blocking) -->
60
+ <script>
61
+ const savedTheme = localStorage.getItem('theme') || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
62
+ document.documentElement.setAttribute('data-theme', savedTheme);
63
+ </script>
64
+
65
+ <!-- Vercel Analytics (Commented to fix 404 in non-Vercel environments)
66
+ <script>
67
+ window.va = window.va || function () { (window.va.q = window.va.q || []).push(arguments); };
68
+ </script>
69
+ <script defer src="/_vercel/analytics/script.js"></script>
70
+ -->
71
+
72
+ <!-- Schema.org Structured Data -->
73
+ <%- include('schema.ejs') %>
@@ -0,0 +1,43 @@
1
+ <!-- Search Overlay / Modal (Reusable by hidden header mode) -->
2
+ <div id="search-modal" class="search-modal">
3
+ <div class="search-overlay"></div>
4
+ <div class="search-container">
5
+ <div class="search-header">
6
+ <span class="search-icon" aria-hidden="true"><i data-lucide="search"></i></span>
7
+ <input type="text" id="search-input" placeholder="<%= t('nav.search_placeholder') %>" autocomplete="off"
8
+ aria-label="<%= t('nav.search_placeholder') %>">
9
+ <button id="close-search" class="close-search"
10
+ aria-label="<%= locale === 'vi' ? 'Đóng tìm kiếm' : 'Close search' %>">&times;</button>
11
+ </div>
12
+ <div id="search-results" class="search-results">
13
+ <!-- Results will be injected here -->
14
+ </div>
15
+ <div class="search-footer">
16
+ <div class="search-hints">
17
+ <span><kbd>↡</kbd> Select</span>
18
+ <span><kbd>↑↓</kbd> Navigate</span>
19
+ <span><kbd>esc</kbd> Close</span>
20
+ </div>
21
+ </div>
22
+ </div>
23
+ </div>
24
+
25
+ <!-- Embed i18n data for client-side JS -->
26
+ <script id="i18n-data" type="application/json">
27
+ {
28
+ "search_no_results": "<%= t('nav.search_no_results') %>"
29
+ }
30
+ </script>
31
+
32
+ <!-- Embed tools data for client-side search -->
33
+ <script id="tools-data" type="application/json">
34
+ <% const translate = t; %>
35
+ <%- JSON.stringify(tools.filter(t => t.status === 'active' || t.status === 'not-ready' || t.status === 'legacy').map(tool => ({
36
+ id: tool.id,
37
+ link: rootPath + locale + tool.link,
38
+ icon: tool.icon,
39
+ title: translate(tool.translationKey + '.title'),
40
+ desc: translate(tool.translationKey + '.desc'),
41
+ category: translate('home.cats.' + tool.category + '.title') || tool.category
42
+ }))) %>
43
+ </script>