htmlboard 0.1.0 → 0.3.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/README.md +120 -92
- package/bin/htmlboard.js +93 -59
- package/package.json +2 -2
- package/skills/htmlboard/SKILL.md +27 -33
- package/src/client/explorer.css +302 -598
- package/src/client/explorer.js +983 -601
- package/src/client/favicon.svg +1 -0
- package/src/client/fonts/OFL-bricolage-grotesque.txt +93 -0
- package/src/client/fonts/OFL-inter.txt +93 -0
- package/src/client/fonts/bricolage-grotesque.woff2 +0 -0
- package/src/client/fonts/inter.woff2 +0 -0
- package/src/client/index.html +87 -175
- package/src/client/logo.svg +1 -0
- package/src/client/mark.svg +1 -0
- package/src/designs.js +398 -0
- package/src/index.js +8 -2
- package/src/migrate.js +193 -0
- package/src/server.js +93 -212
- package/src/templates/{artboards/desktop.html → 01-screens/01-desktop.html} +2 -0
- package/src/templates/{artboards/mobile.html → 01-screens/02-mobile.html} +2 -0
- package/src/templates/{artboards/components.html → 02-components/01-ui-kit.html} +2 -0
- package/src/templates/designs.json +0 -50
package/src/designs.js
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
// Which designs a folder holds, and how the canvas shows them.
|
|
5
|
+
//
|
|
6
|
+
// Every HTML file is a design; its folder is its section (named after the folder, without its
|
|
7
|
+
// number prefix), and number prefixes on files and folders set the order (01-…, 02-…). A file
|
|
8
|
+
// describes itself in its <head>:
|
|
9
|
+
//
|
|
10
|
+
// <title>Agenda</title> the design's name
|
|
11
|
+
// <meta name="description" content="…"> its description
|
|
12
|
+
// <meta name="htmlboard" content="width=390, height=844, category=mobile"> ("skip" leaves it out)
|
|
13
|
+
// <meta name="htmlboard:label" content="claro"> added to the name of the file's own frame
|
|
14
|
+
// <meta name="htmlboard:variant" content="?tema=escuro · escuro"> one more frame of the same file
|
|
15
|
+
//
|
|
16
|
+
// A designs.json beside them is optional and only curates: sections with their titles, the order,
|
|
17
|
+
// overrides for a design's values. Designs it doesn't list come after the ones it does (unless it
|
|
18
|
+
// says "unlisted": "hide").
|
|
19
|
+
|
|
20
|
+
/** Folders discovery leaves alone (hidden ones too) */
|
|
21
|
+
const IGNORED_DIRS = new Set(['node_modules', 'dist', 'build']);
|
|
22
|
+
|
|
23
|
+
/** Default frame size for each device category; anything else is 1200 wide and grows with its page */
|
|
24
|
+
const CATEGORY_SIZES = {
|
|
25
|
+
mobile: [390, 844],
|
|
26
|
+
tablet: [768, 1024],
|
|
27
|
+
components: [720, 600],
|
|
28
|
+
desktop: [1280, 800]
|
|
29
|
+
};
|
|
30
|
+
const DEFAULT_WIDTH = 1200;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Guess what a design is for: words in its path first, then its size when it is a screen's (a
|
|
34
|
+
* phone's width, a tablet's portrait 3:4, a desktop's landscape). Boards, flyers and other formats
|
|
35
|
+
* get none (null). Without a height (a page that grows) only the width counts.
|
|
36
|
+
*/
|
|
37
|
+
export function inferCategory(file, width, height) {
|
|
38
|
+
const lower = file.toLowerCase();
|
|
39
|
+
if (/mobile|phone|iphone/.test(lower)) return 'mobile';
|
|
40
|
+
if (/tablet|ipad/.test(lower)) return 'tablet';
|
|
41
|
+
if (/component|\bui\b|atomic/.test(lower)) return 'components';
|
|
42
|
+
if (/desktop|laptop/.test(lower)) return 'desktop';
|
|
43
|
+
if (!width) return null;
|
|
44
|
+
if (width <= 480) return 'mobile';
|
|
45
|
+
if (!height) return width >= 1024 ? 'desktop' : width >= 600 ? 'tablet' : null;
|
|
46
|
+
const ratio = height / width;
|
|
47
|
+
if (width >= 600 && width <= 1100 && ratio >= 1.3 && ratio <= 1.45) return 'tablet';
|
|
48
|
+
if (width >= 1024 && ratio <= 0.8) return 'desktop';
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** "01-home" → "home": a number prefix (up to three digits) only orders */
|
|
53
|
+
export function stripPrefix(name) {
|
|
54
|
+
return name.replace(/^\d{1,3}[-_. ]+(?=\S)/, '');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function slugify(text) {
|
|
58
|
+
return String(text).toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
|
|
59
|
+
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A file or folder name as a title: prefix and extension off, - and _ as spaces */
|
|
63
|
+
function humanize(name) {
|
|
64
|
+
return stripPrefix(name.replace(/\.html?$/i, '')).replace(/[-_]+/g, ' ').trim() || name;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function folderTitle(folder) {
|
|
68
|
+
return folder.split('/').map(humanize).join(' / ');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A design's id: its path without prefixes and extension (and with its query), as a slug */
|
|
72
|
+
function designId(file) {
|
|
73
|
+
const [filePath, query = ''] = file.split(/\?(.*)/s);
|
|
74
|
+
const bare = filePath.replace(/\.html?$/i, '').split('/').map(stripPrefix).join('/');
|
|
75
|
+
return slugify(query ? `${bare}-${query}` : bare);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const byName = (a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
|
|
79
|
+
|
|
80
|
+
const compact = (object) => Object.fromEntries(Object.entries(object).filter(([, v]) => v !== undefined && v !== null && v !== ''));
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------- what a file says
|
|
83
|
+
|
|
84
|
+
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
|
|
85
|
+
|
|
86
|
+
function decode(text) {
|
|
87
|
+
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, code) => {
|
|
88
|
+
if (code[0] === '#') {
|
|
89
|
+
const n = code[1].toLowerCase() === 'x' ? parseInt(code.slice(2), 16) : parseInt(code.slice(1), 10);
|
|
90
|
+
return Number.isFinite(n) ? String.fromCodePoint(n) : whole;
|
|
91
|
+
}
|
|
92
|
+
return ENTITIES[code.toLowerCase()] ?? whole;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function attributes(tag) {
|
|
97
|
+
const attrs = {};
|
|
98
|
+
for (const m of tag.matchAll(/([^\s"'<>/=]+)\s*(?:=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g)) {
|
|
99
|
+
attrs[m[1].toLowerCase()] = decode(m[2] ?? m[3] ?? m[4] ?? '');
|
|
100
|
+
}
|
|
101
|
+
return attrs;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** <meta name="htmlboard" content="width=390, height=844, category=mobile"> (or "skip") */
|
|
105
|
+
function parseSettings(content) {
|
|
106
|
+
const settings = {};
|
|
107
|
+
for (const part of content.split(/[,;]/)) {
|
|
108
|
+
const [rawKey, ...rest] = part.split('=');
|
|
109
|
+
const key = rawKey.trim().toLowerCase();
|
|
110
|
+
const value = rest.join('=').trim();
|
|
111
|
+
if (key === 'skip' && (!value || /^(true|yes)$/i.test(value))) settings.skip = true;
|
|
112
|
+
else if ((key === 'width' || key === 'height') && /^\d+$/.test(value) && Number(value) > 0) settings[key] = Number(value);
|
|
113
|
+
else if (key === 'category' && value) settings.category = value.toLowerCase();
|
|
114
|
+
}
|
|
115
|
+
return settings;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** <meta name="htmlboard:variant" content="?tema=escuro · escuro">: the query, then a label */
|
|
119
|
+
function parseVariant(content) {
|
|
120
|
+
const [, first, rest] = content.match(/^(\S+)\s*(.*)$/s);
|
|
121
|
+
const query = first.startsWith('?') ? first : `?${first}`;
|
|
122
|
+
if (query === '?') return null;
|
|
123
|
+
return { query, label: rest.replace(/^[·|:,\-–—]+\s*/, '').trim() };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** What an HTML file says about itself, from its <head> (see the top of this file) */
|
|
127
|
+
export function parseDesignMeta(html) {
|
|
128
|
+
const end = html.search(/<\/head\s*>|<body[\s>]/i);
|
|
129
|
+
const head = end >= 0 ? html.slice(0, end) : html.slice(0, 65536);
|
|
130
|
+
const meta = { variants: [] };
|
|
131
|
+
const title = head.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
132
|
+
if (title) {
|
|
133
|
+
const text = decode(title[1]).replace(/\s+/g, ' ').trim();
|
|
134
|
+
if (text) meta.title = text;
|
|
135
|
+
}
|
|
136
|
+
for (const [tag] of head.matchAll(/<meta\b[^>]*>/gi)) {
|
|
137
|
+
const attrs = attributes(tag);
|
|
138
|
+
const name = (attrs.name || '').toLowerCase();
|
|
139
|
+
const content = (attrs.content || '').trim();
|
|
140
|
+
if (!content) continue;
|
|
141
|
+
if (name === 'description') meta.description = content;
|
|
142
|
+
else if (name === 'htmlboard') Object.assign(meta, parseSettings(content));
|
|
143
|
+
else if (name === 'htmlboard:label') meta.label = content;
|
|
144
|
+
else if (name === 'htmlboard:variant') {
|
|
145
|
+
const variant = parseVariant(content);
|
|
146
|
+
if (variant) meta.variants.push(variant);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return meta;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const metaCache = new Map(); // full path → { mtimeMs, size, meta }
|
|
153
|
+
|
|
154
|
+
/** parseDesignMeta for a file, read again only when it changed */
|
|
155
|
+
export function readDesignMeta(fullPath) {
|
|
156
|
+
let stat;
|
|
157
|
+
try {
|
|
158
|
+
stat = fs.statSync(fullPath);
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
const cached = metaCache.get(fullPath);
|
|
163
|
+
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) return cached.meta;
|
|
164
|
+
let meta;
|
|
165
|
+
try {
|
|
166
|
+
meta = parseDesignMeta(fs.readFileSync(fullPath, 'utf8'));
|
|
167
|
+
} catch {
|
|
168
|
+
meta = { variants: [] };
|
|
169
|
+
}
|
|
170
|
+
metaCache.set(fullPath, { mtimeMs: stat.mtimeMs, size: stat.size, meta });
|
|
171
|
+
return meta;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------- the designs in a folder
|
|
175
|
+
|
|
176
|
+
function fileDesign(file, folder, meta) {
|
|
177
|
+
const title = meta.title || humanize(path.posix.basename(file));
|
|
178
|
+
const device = CATEGORY_SIZES[meta.category || inferCategory(file)];
|
|
179
|
+
const width = meta.width || device?.[0] || DEFAULT_WIDTH;
|
|
180
|
+
const height = meta.height ?? (meta.width ? undefined : device?.[1]);
|
|
181
|
+
return compact({
|
|
182
|
+
key: file,
|
|
183
|
+
file,
|
|
184
|
+
folder,
|
|
185
|
+
title: meta.label ? `${title} · ${meta.label}` : title,
|
|
186
|
+
baseTitle: title,
|
|
187
|
+
description: meta.description,
|
|
188
|
+
width,
|
|
189
|
+
height,
|
|
190
|
+
category: meta.category
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function variantDesign(base, { query, label }) {
|
|
195
|
+
return compact({
|
|
196
|
+
...base,
|
|
197
|
+
key: base.file + query,
|
|
198
|
+
file: base.file + query,
|
|
199
|
+
title: `${base.baseTitle} · ${label || query.slice(1)}`,
|
|
200
|
+
variantOf: base.file
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Every design in the folder, in order: the files of a folder by name (number prefixes order
|
|
206
|
+
* them), each followed by its variants, then its subfolders by name.
|
|
207
|
+
*/
|
|
208
|
+
export function scanDesigns(baseDir) {
|
|
209
|
+
const designs = [];
|
|
210
|
+
const walk = (dir, folder) => {
|
|
211
|
+
let entries;
|
|
212
|
+
try {
|
|
213
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
214
|
+
} catch {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
entries = entries.filter((e) => !e.name.startsWith('.') && !IGNORED_DIRS.has(e.name)).sort((a, b) => byName(a.name, b.name));
|
|
218
|
+
for (const entry of entries) {
|
|
219
|
+
if (!entry.isFile() || !/\.html?$/i.test(entry.name)) continue;
|
|
220
|
+
const file = folder ? `${folder}/${entry.name}` : entry.name;
|
|
221
|
+
const meta = readDesignMeta(path.join(dir, entry.name));
|
|
222
|
+
if (!meta || meta.skip) continue;
|
|
223
|
+
const base = fileDesign(file, folder, meta);
|
|
224
|
+
designs.push(base);
|
|
225
|
+
for (const variant of meta.variants) designs.push(variantDesign(base, variant));
|
|
226
|
+
}
|
|
227
|
+
for (const entry of entries) {
|
|
228
|
+
if (entry.isDirectory()) walk(path.join(dir, entry.name), folder ? `${folder}/${entry.name}` : entry.name);
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
walk(baseDir, '');
|
|
232
|
+
return designs;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Designs grouped into one section per folder, in order (`rootName` for the files at the top) */
|
|
236
|
+
function groupByFolder(designs, rootName = '') {
|
|
237
|
+
const groups = new Map();
|
|
238
|
+
for (const design of designs) {
|
|
239
|
+
if (!groups.has(design.folder ?? '')) {
|
|
240
|
+
const folder = design.folder ?? '';
|
|
241
|
+
groups.set(folder, { name: folder ? folderTitle(folder) : rootName, folder, artboards: [] });
|
|
242
|
+
}
|
|
243
|
+
groups.get(design.folder ?? '').artboards.push(design);
|
|
244
|
+
}
|
|
245
|
+
return [...groups.values()];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---------------------------------------------------------------- designs.json
|
|
249
|
+
|
|
250
|
+
const OVERRIDES = ['title', 'description', 'width', 'height', 'category', 'tags', 'id'];
|
|
251
|
+
|
|
252
|
+
function toEntry(entry) {
|
|
253
|
+
if (typeof entry === 'string') return entry ? { file: entry } : null;
|
|
254
|
+
return entry && entry.file ? { ...entry, file: String(entry.file) } : null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const normFolder = (folder) => String(folder).replace(/\\/g, '/').replace(/^\.?\/+|\/+$/g, '');
|
|
258
|
+
|
|
259
|
+
/** A designs.json entry: the file's own design (or a variant of it) with the entry's values on top */
|
|
260
|
+
function resolveEntry(entry, byKey) {
|
|
261
|
+
const file = entry.file;
|
|
262
|
+
let design = byKey.get(file);
|
|
263
|
+
if (!design && file.includes('?')) {
|
|
264
|
+
const base = byKey.get(file.split('?')[0]);
|
|
265
|
+
if (base) design = variantDesign(base, { query: file.slice(file.indexOf('?')), label: '' });
|
|
266
|
+
}
|
|
267
|
+
if (!design) {
|
|
268
|
+
const filePath = file.split('?')[0];
|
|
269
|
+
const folder = path.posix.dirname(filePath);
|
|
270
|
+
design = { key: file, file, folder: folder === '.' ? '' : folder, title: humanize(path.posix.basename(filePath)) };
|
|
271
|
+
}
|
|
272
|
+
design = { ...design };
|
|
273
|
+
for (const key of OVERRIDES) if (entry[key] !== undefined) design[key] = entry[key];
|
|
274
|
+
return design;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** designs.json's sections filled from the files, then the designs it doesn't list */
|
|
278
|
+
function curate(config, found) {
|
|
279
|
+
const byKey = new Map(found.map((d) => [d.key, d]));
|
|
280
|
+
const sources = (Array.isArray(config.sections) ? config.sections : []).map((section) => ({
|
|
281
|
+
section,
|
|
282
|
+
entries: (section.designs ?? section.artboards ?? []).map(toEntry).filter(Boolean)
|
|
283
|
+
}));
|
|
284
|
+
const listed = new Set(sources.flatMap(({ entries }) => entries.map((e) => e.file)));
|
|
285
|
+
const used = new Set();
|
|
286
|
+
const free = (d) => !used.has(d.key) && !listed.has(d.key);
|
|
287
|
+
|
|
288
|
+
const sections = sources.map(({ section, entries }) => {
|
|
289
|
+
const artboards = [];
|
|
290
|
+
const add = (design) => {
|
|
291
|
+
used.add(design.key);
|
|
292
|
+
artboards.push(design);
|
|
293
|
+
};
|
|
294
|
+
for (const entry of entries) {
|
|
295
|
+
add(resolveEntry(entry, byKey));
|
|
296
|
+
// A file's variants follow it, unless designs.json places them itself
|
|
297
|
+
if (!entry.file.includes('?')) for (const d of found) if (d.variantOf === entry.file && free(d)) add(d);
|
|
298
|
+
}
|
|
299
|
+
// { "folder": "…" } brings the folder's designs it doesn't list
|
|
300
|
+
if (section.folder) for (const d of found) if (d.folder === normFolder(section.folder) && free(d)) add(d);
|
|
301
|
+
const { designs, artboards: _artboards, title, ...rest } = section;
|
|
302
|
+
return { ...rest, name: section.name ?? title ?? '', artboards };
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// A section owns a folder it names, or the one folder (not the top) all its designs come from;
|
|
306
|
+
// the folder's new designs join it.
|
|
307
|
+
const owners = new Map();
|
|
308
|
+
sources.forEach(({ section }, i) => {
|
|
309
|
+
const folders = section.folder
|
|
310
|
+
? [normFolder(section.folder)]
|
|
311
|
+
: [...new Set(sections[i].artboards.map((d) => d.folder ?? ''))];
|
|
312
|
+
if (folders.length !== 1 || (!section.folder && !folders[0])) return;
|
|
313
|
+
owners.set(folders[0], owners.has(folders[0]) && owners.get(folders[0]) !== i ? -1 : i);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const unlisted = found.filter((d) => !used.has(d.key));
|
|
317
|
+
const rest = [];
|
|
318
|
+
for (const design of unlisted) {
|
|
319
|
+
const owner = owners.get(design.folder ?? '');
|
|
320
|
+
if (owner !== undefined && owner >= 0) sections[owner].artboards.push(design);
|
|
321
|
+
else rest.push(design);
|
|
322
|
+
}
|
|
323
|
+
if (config.unlisted !== 'hide') sections.push(...groupByFolder(rest, 'Unlisted'));
|
|
324
|
+
|
|
325
|
+
const { sections: _sections, ...top } = config;
|
|
326
|
+
return { ...top, sections };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Ids, paths from the served root, categories; and nothing the explorer doesn't need */
|
|
330
|
+
function finalize(manifest, prefix = '') {
|
|
331
|
+
const usedIds = new Set();
|
|
332
|
+
const uniqueId = (wanted) => {
|
|
333
|
+
let id = wanted || 'design';
|
|
334
|
+
for (let n = 2; usedIds.has(id); n++) id = `${wanted}-${n}`;
|
|
335
|
+
usedIds.add(id);
|
|
336
|
+
return id;
|
|
337
|
+
};
|
|
338
|
+
const sections = manifest.sections.map((section, i) => {
|
|
339
|
+
const { folder, artboards, ...rest } = section;
|
|
340
|
+
const name = section.name ?? '';
|
|
341
|
+
return {
|
|
342
|
+
...rest,
|
|
343
|
+
id: section.id ?? (slugify(name) || (folder ? slugify(folder.split('/').map(stripPrefix).join('-')) : `section-${i + 1}`)),
|
|
344
|
+
name,
|
|
345
|
+
artboards: artboards.map((design) => {
|
|
346
|
+
const { key, folder: _folder, baseTitle, variantOf, ...board } = design;
|
|
347
|
+
const category = board.category ?? inferCategory(design.file.split('?')[0], board.width, board.height);
|
|
348
|
+
return compact({
|
|
349
|
+
...board,
|
|
350
|
+
id: uniqueId(board.id ?? designId(design.file)),
|
|
351
|
+
file: prefix ? path.posix.join(prefix, design.file) : design.file,
|
|
352
|
+
category
|
|
353
|
+
});
|
|
354
|
+
})
|
|
355
|
+
};
|
|
356
|
+
});
|
|
357
|
+
return { ...manifest, sections };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** The designs of a folder without a designs.json: one section per folder */
|
|
361
|
+
export function discoverDesigns(baseDir) {
|
|
362
|
+
return finalize({
|
|
363
|
+
title: path.basename(path.resolve(baseDir)) || 'Designs',
|
|
364
|
+
autoDiscovered: true,
|
|
365
|
+
sections: groupByFolder(scanDesigns(baseDir))
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* A manifest's sections with ids and categories filled in, without looking at any file (the short
|
|
371
|
+
* form works: sections[].title and designs[] of file names or { file, title, width, height }).
|
|
372
|
+
*/
|
|
373
|
+
export function normalizeManifest(manifest, prefix = '') {
|
|
374
|
+
const { unlisted, ...result } = finalize(curate({ ...manifest, unlisted: 'hide' }, []), prefix);
|
|
375
|
+
return manifest.unlisted === undefined ? result : { ...result, unlisted: manifest.unlisted };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* What the explorer shows for a designs folder: its files, curated by its designs.json when it has
|
|
380
|
+
* one. With `rootDir` (a folder containing baseDir), file paths are given relative to it.
|
|
381
|
+
*/
|
|
382
|
+
export function loadDesignsManifest(baseDir, rootDir = baseDir) {
|
|
383
|
+
const prefix = path.relative(rootDir, baseDir).split(path.sep).join('/');
|
|
384
|
+
const found = scanDesigns(baseDir);
|
|
385
|
+
const configPath = path.join(baseDir, 'designs.json');
|
|
386
|
+
if (fs.existsSync(configPath)) {
|
|
387
|
+
try {
|
|
388
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
389
|
+
return { ...finalize(curate(config, found), prefix), autoDiscovered: false };
|
|
390
|
+
} catch (err) {
|
|
391
|
+
console.warn(`[htmlboard] Failed parsing designs.json: ${err.message}. Showing the files without it.`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
...finalize({ title: path.basename(path.resolve(baseDir)) || 'Designs', sections: groupByFolder(found) }, prefix),
|
|
396
|
+
autoDiscovered: true
|
|
397
|
+
};
|
|
398
|
+
}
|
package/src/index.js
CHANGED
|
@@ -6,11 +6,17 @@
|
|
|
6
6
|
export {
|
|
7
7
|
createServer,
|
|
8
8
|
startServer,
|
|
9
|
+
watchTree
|
|
10
|
+
} from './server.js';
|
|
11
|
+
|
|
12
|
+
export {
|
|
9
13
|
discoverDesigns,
|
|
10
14
|
loadDesignsManifest,
|
|
11
15
|
normalizeManifest,
|
|
12
|
-
inferCategory
|
|
13
|
-
|
|
16
|
+
inferCategory,
|
|
17
|
+
parseDesignMeta,
|
|
18
|
+
scanDesigns
|
|
19
|
+
} from './designs.js';
|
|
14
20
|
|
|
15
21
|
export {
|
|
16
22
|
installSkills,
|
package/src/migrate.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { loadDesignsManifest, parseDesignMeta, stripPrefix } from './designs.js';
|
|
5
|
+
|
|
6
|
+
// `htmlboard migrate`: moves what a designs.json says about each design into the design's own file
|
|
7
|
+
// (its size, category, label, variants and description) and leaves designs.json with what only it
|
|
8
|
+
// can say: the sections, their titles and order, and values that differ from the files'.
|
|
9
|
+
|
|
10
|
+
const OWN_TAGS = /[ \t]*<meta\b[^>]*\bname\s*=\s*["']?htmlboard(?::label|:variant)?["'\s>][^>]*>[ \t]*(?:\r?\n)?/gi;
|
|
11
|
+
const SEP = ' · ';
|
|
12
|
+
|
|
13
|
+
const splitQuery = (file) => {
|
|
14
|
+
const at = file.indexOf('?');
|
|
15
|
+
return at < 0 ? [file, ''] : [file.slice(0, at), file.slice(at)];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const escapeAttr = (text) => String(text).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
19
|
+
|
|
20
|
+
function fallbackTitle(file) {
|
|
21
|
+
return stripPrefix(path.posix.basename(file).replace(/\.html?$/i, '')).replace(/[-_]+/g, ' ').trim();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** What follows "<title> · " in a title, or null when the title doesn't start with the file's */
|
|
25
|
+
function labelOf(title, fileTitle) {
|
|
26
|
+
if (title === undefined) return '';
|
|
27
|
+
if (title === fileTitle) return '';
|
|
28
|
+
return title.startsWith(fileTitle + SEP) ? title.slice(fileTitle.length + SEP.length) : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The file's htmlboard tags, written after its <title> (replacing any it had) */
|
|
32
|
+
function rewriteHead(html, lines) {
|
|
33
|
+
const cleaned = html.replace(OWN_TAGS, '');
|
|
34
|
+
if (!lines.length) return cleaned;
|
|
35
|
+
const title = cleaned.match(/^([ \t]*)<title[^>]*>[\s\S]*?<\/title>[^\n]*\n?/im);
|
|
36
|
+
if (title) {
|
|
37
|
+
const at = title.index + title[0].length;
|
|
38
|
+
const eol = title[0].endsWith('\n') ? '' : '\n';
|
|
39
|
+
return cleaned.slice(0, at) + eol + lines.map((line) => title[1] + line + '\n').join('') + cleaned.slice(at);
|
|
40
|
+
}
|
|
41
|
+
const head = cleaned.match(/<head[^>]*>[^\n]*\n?/i);
|
|
42
|
+
const at = head ? head.index + head[0].length : 0;
|
|
43
|
+
return cleaned.slice(0, at) + lines.map((line) => line + '\n').join('') + cleaned.slice(at);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The migration of a designs folder: { files: [{ file, lines, html }], config, entries: [before,
|
|
48
|
+
* after], unlisted: [files designs.json didn't list] }. Nothing is written.
|
|
49
|
+
*/
|
|
50
|
+
export function planMigration(designsDir) {
|
|
51
|
+
const configPath = path.join(designsDir, 'designs.json');
|
|
52
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
53
|
+
const sections = Array.isArray(config.sections) ? config.sections : [];
|
|
54
|
+
|
|
55
|
+
// Every entry, by the file it shows
|
|
56
|
+
const all = [];
|
|
57
|
+
sections.forEach((section, s) => {
|
|
58
|
+
(section.designs ?? section.artboards ?? []).forEach((raw, e) => {
|
|
59
|
+
const entry = typeof raw === 'string' ? { file: raw } : raw && raw.file ? { ...raw, file: String(raw.file) } : null;
|
|
60
|
+
if (!entry) return;
|
|
61
|
+
const [base, query] = splitQuery(entry.file);
|
|
62
|
+
all.push({ entry, base, query, s, e, keep: {} });
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
const byBase = new Map();
|
|
66
|
+
for (const item of all) {
|
|
67
|
+
if (!byBase.has(item.base)) byBase.set(item.base, []);
|
|
68
|
+
byBase.get(item.base).push(item);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const files = [];
|
|
72
|
+
for (const [base, items] of byBase) {
|
|
73
|
+
const full = path.join(designsDir, base);
|
|
74
|
+
const main = items.find((item) => !item.query);
|
|
75
|
+
const html = fs.existsSync(full) ? fs.readFileSync(full, 'utf8') : null;
|
|
76
|
+
const meta = html === null ? null : parseDesignMeta(html);
|
|
77
|
+
// Only files on disk, not skipped, whose own frame designs.json shows; anything else stays as it is
|
|
78
|
+
if (!main || !meta || meta.skip) {
|
|
79
|
+
for (const item of items) item.keep = { ...item.entry };
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const fileTitle = meta.title || fallbackTitle(base);
|
|
83
|
+
const width = main.entry.width ?? meta.width;
|
|
84
|
+
const height = main.entry.height ?? meta.height;
|
|
85
|
+
const category = main.entry.category ?? meta.category;
|
|
86
|
+
const settings = [width && `width=${width}`, height && `height=${height}`, category && `category=${category}`].filter(Boolean);
|
|
87
|
+
const lines = [];
|
|
88
|
+
if (settings.length) lines.push(`<meta name="htmlboard" content="${escapeAttr(settings.join(', '))}">`);
|
|
89
|
+
|
|
90
|
+
const label = labelOf(main.entry.title, fileTitle);
|
|
91
|
+
if (label === null) main.keep.title = main.entry.title;
|
|
92
|
+
else if (label) lines.push(`<meta name="htmlboard:label" content="${escapeAttr(label)}">`);
|
|
93
|
+
else if (meta.label && main.entry.title === undefined) lines.push(`<meta name="htmlboard:label" content="${escapeAttr(meta.label)}">`);
|
|
94
|
+
if (main.entry.description !== undefined && !meta.description) {
|
|
95
|
+
lines.push(`<meta name="description" content="${escapeAttr(main.entry.description)}">`);
|
|
96
|
+
} else if (main.entry.description !== undefined && main.entry.description !== meta.description) {
|
|
97
|
+
main.keep.description = main.entry.description;
|
|
98
|
+
}
|
|
99
|
+
for (const key of ['tags', 'id']) if (main.entry[key] !== undefined) main.keep[key] = main.entry[key];
|
|
100
|
+
|
|
101
|
+
// The variants designs.json shows, in its order (then any the file declared that it didn't)
|
|
102
|
+
const variants = items.filter((item) => item.query);
|
|
103
|
+
const declared = meta.variants.filter((v) => !variants.some((item) => item.query === v.query));
|
|
104
|
+
for (const item of variants) {
|
|
105
|
+
const variantLabel = labelOf(item.entry.title, fileTitle);
|
|
106
|
+
if (variantLabel === null) item.keep.title = item.entry.title;
|
|
107
|
+
lines.push(`<meta name="htmlboard:variant" content="${escapeAttr(item.query + (variantLabel ? SEP + variantLabel : ''))}">`);
|
|
108
|
+
for (const key of ['width', 'height', 'category']) {
|
|
109
|
+
if (item.entry[key] !== undefined && item.entry[key] !== { width, height, category }[key]) item.keep[key] = item.entry[key];
|
|
110
|
+
}
|
|
111
|
+
for (const key of ['description', 'tags', 'id']) if (item.entry[key] !== undefined) item.keep[key] = item.entry[key];
|
|
112
|
+
}
|
|
113
|
+
for (const v of declared) lines.push(`<meta name="htmlboard:variant" content="${escapeAttr(v.query + (v.label ? SEP + v.label : ''))}">`);
|
|
114
|
+
|
|
115
|
+
const next = rewriteHead(html, lines);
|
|
116
|
+
if (next !== html) files.push({ file: base, lines, html: next });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// A variant that follows its file (or the file's other variants) in the same section, with
|
|
120
|
+
// nothing left to say, comes from the file now
|
|
121
|
+
const dropped = new Set();
|
|
122
|
+
for (const item of all) {
|
|
123
|
+
if (!item.query || Object.keys(item.keep).length || !byBase.get(item.base).some((i) => !i.query)) continue;
|
|
124
|
+
const run = all.filter((i) => i.s === item.s && i.e < item.e).reverse();
|
|
125
|
+
let follows = false;
|
|
126
|
+
for (const before of run) {
|
|
127
|
+
if (before.base !== item.base) break;
|
|
128
|
+
if (!before.query) {
|
|
129
|
+
follows = true;
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
if (!dropped.has(before)) break;
|
|
133
|
+
}
|
|
134
|
+
if (follows) dropped.add(item);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const next = { ...config };
|
|
138
|
+
next.sections = sections.map((section, s) => {
|
|
139
|
+
const { designs, artboards, ...rest } = section;
|
|
140
|
+
const entries = all.filter((item) => item.s === s && !dropped.has(item)).map((item) => {
|
|
141
|
+
const keep = Object.fromEntries(Object.entries(item.keep).filter(([key]) => key !== 'file'));
|
|
142
|
+
return Object.keys(keep).length ? { file: item.entry.file, ...keep } : item.entry.file;
|
|
143
|
+
});
|
|
144
|
+
return { ...rest, designs: entries };
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
return { files, config: next, entries: [all.length, all.length - dropped.size] };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The canvas as a flat list, to compare two of them */
|
|
151
|
+
function canvasLines(manifest) {
|
|
152
|
+
return manifest.sections.flatMap((section) => section.artboards.map((b) =>
|
|
153
|
+
[section.name, b.title, b.file, b.width, b.height ?? 'grows', b.category ?? '-'].join(' | ')));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The canvas the migration would leave, against today's: a list of differences (empty when
|
|
158
|
+
* nothing changes), from a copy of the folder's HTML files with the plan applied.
|
|
159
|
+
*/
|
|
160
|
+
export function checkMigration(designsDir, plan) {
|
|
161
|
+
const before = canvasLines(loadDesignsManifest(designsDir));
|
|
162
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'htmlboard-migrate-'));
|
|
163
|
+
try {
|
|
164
|
+
const copy = (dir, rel) => {
|
|
165
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
166
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
167
|
+
const from = path.join(dir, entry.name);
|
|
168
|
+
const to = path.join(temp, rel, entry.name);
|
|
169
|
+
if (entry.isDirectory()) {
|
|
170
|
+
fs.mkdirSync(to, { recursive: true });
|
|
171
|
+
copy(from, path.join(rel, entry.name));
|
|
172
|
+
} else if (/\.html?$/i.test(entry.name)) fs.copyFileSync(from, to);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
copy(designsDir, '');
|
|
176
|
+
for (const { file, html } of plan.files) fs.writeFileSync(path.join(temp, file), html);
|
|
177
|
+
fs.writeFileSync(path.join(temp, 'designs.json'), JSON.stringify(plan.config, null, 2) + '\n');
|
|
178
|
+
const after = canvasLines(loadDesignsManifest(temp));
|
|
179
|
+
const differences = [];
|
|
180
|
+
for (let i = 0; i < Math.max(before.length, after.length); i++) {
|
|
181
|
+
if (before[i] !== after[i]) differences.push({ before: before[i], after: after[i] });
|
|
182
|
+
}
|
|
183
|
+
return differences;
|
|
184
|
+
} finally {
|
|
185
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Writes the plan: the files' tags and the shorter designs.json */
|
|
190
|
+
export function applyMigration(designsDir, plan) {
|
|
191
|
+
for (const { file, html } of plan.files) fs.writeFileSync(path.join(designsDir, file), html);
|
|
192
|
+
fs.writeFileSync(path.join(designsDir, 'designs.json'), JSON.stringify(plan.config, null, 2) + '\n');
|
|
193
|
+
}
|