Eric Bower
·
31 Jul 24
static.ts
1import htmlFile from "../../data/html.json" with { type: "json" };
2import dbFile from "../../data/db.json" with { type: "json" };
3import configDbFile from "../../data/db-config.json" with { type: "json" };
4
5import { dirname } from "../deps.ts";
6import { format, relativeTimeFromDates } from "../date.ts";
7import { derivePluginData } from "../plugin-data.ts";
8import type { Plugin, PluginData, PluginMap, Tag, TagMap } from "../types.ts";
9import { getResourceId } from "../entities.ts";
10
11const OUTDIR = "./public";
12
13async function createFile(fname: string, data: string) {
14 console.log(`Creating file ${fname}`);
15 await Deno.mkdir(dirname(fname), { recursive: true });
16 await Deno.writeTextFile(fname, data);
17}
18
19const sortNum = (a: number, b: number) => b - a;
20const sortDateStr = (a: string, b: string) => {
21 const dateA = new Date(a).getTime();
22 const dateB = new Date(b).getTime();
23 return dateB - dateA;
24};
25
26function onSort(by: keyof Plugin) {
27 if (by === "createdAt") {
28 return (a: Plugin, b: Plugin) => sortDateStr(a.createdAt, b.createdAt);
29 }
30 if (by === "updatedAt") {
31 return (a: Plugin, b: Plugin) => sortDateStr(a.updatedAt, b.updatedAt);
32 }
33 return (a: Plugin, b: Plugin) => sortNum(a.stars, b.stars);
34}
35
36const createAds = () => {
37 return `
38<div class="ad">
39 <div><a href="https://nvim.sh">nvim.sh</a></div>
40 <div>Search for plugins in the terminal</div>
41</div>
42
43<div class="ad">
44 <div><a href="https://tuns.sh">tuns.sh</a></div>
45 <div>Host publicly available web services on localhost using SSH</div>
46</div>
47
48<div class="ad">
49 <div><a href="https://pgs.sh">pgs.sh</a></div>
50 <div>A zero-install static site hosting service for hackers</div>
51</div>
52
53<div class="ad">
54 <div><a href="https://prose.sh">prose.sh</a></div>
55 <div>A blog platform for hackers</div>
56</div>
57
58<div class="ad">
59 <div><a href="https://bower.sh">bower.sh</a></div>
60 <div>My personal blog</div>
61</div>
62`;
63};
64
65const createHtmlFile = ({ head, body }: { head: string; body: string }) => {
66 return `
67<!DOCTYPE html>
68<html lang="en" data-theme="theme-dark">
69 <head>
70 <meta charset="utf-8" />
71 <link rel="icon" href="/favicon.ico" />
72 <meta name="viewport" content="width=device-width, initial-scale=1" />
73 <link rel="stylesheet" type="text/css" href="/reset.css" />
74 <link rel="stylesheet" type="text/css" href="/main.css" />
75 <meta property="og:image:width" content="1200" />
76 <meta property="og:image:height" content="600" />
77 <meta name="twitter:card" content="summary_large_image" />
78 <meta property="og:url" content="https://neovimcraft.com" />
79 <meta property="og:image" content="https://neovimcraft.com/neovimcraft.png" />
80 <meta
81 name="description"
82 content="Search through our curated neovim plugin directory."
83 />
84 <meta property="og:title" content="neovimcraft" />
85 <meta
86 property="og:description"
87 content="Search through our curated neovim plugin directory."
88 />
89 <meta name="twitter:image:src" content="https://neovimcraft.com/neovimcraft.png" />
90
91 ${head}
92 </head>
93 <body>
94 <div id="app">
95 <div id="app_content">${body}</div>
96 </div>
97 </body>
98</html>`;
99};
100
101const createNav = () => {
102 const links = [
103 ["/", "plugins"],
104 ["/c/", "configs"],
105 ["/about/", "about"],
106 ];
107
108 const linksStr = links.reduce((acc, link) => {
109 acc += `<a href="${link[0]}" class="link">${link[1]}</a>\n`;
110 return acc;
111 }, "");
112
113 return `
114<div class="nav">
115 <h1 class="logo">
116 <a href="/" class="logo-header">neovimcraft</a>
117 <a href="https://github.com/neurosnap/neovimcraft" class="flex">
118 ${createIcon("github", "github")}
119 </a>
120 </h1>
121 <div class="links">
122 ${linksStr}
123 </div>
124 <div class="menu-btn" id="menu-btn"><img src="/menu.svg" alt="menu" /></div>
125
126 <div class="menu-container hidden" id="menu">
127 <div class="menu-overlay menu-close"></div>
128 <div class="menu">
129 <div class="menu-header">
130 <h1 class="logo">
131 <a href="/" class="logo-header">neovimcraft</a>
132 <a href="https://github.com/neurosnap/neovimcraft" class="flex">
133 ${createIcon("github", "github")}
134 </a>
135 </h1>
136 <div class="menu-btn menu-close"><img src="/menu.svg" alt="menu" /></div>
137 </div>
138 <div class="menu-body">
139 ${linksStr}
140 ${createAds()}
141 </div>
142 </div>
143 </div>
144</div>`;
145};
146
147const createIcon = (icon: string, text: string) => {
148 return `<img class="icon" src="/${icon}.svg" alt=${text} title=${text} />`;
149};
150
151function findColor(tag: Tag) {
152 if (tag.count === 1) return "pink";
153 if (tag.count > 1 && tag.count <= 3) return "yellow";
154 if (tag.count > 3 && tag.count <= 10) return "orange";
155 if (tag.count > 10 && tag.count <= 15) return "green";
156 return "purple";
157}
158
159const createTag = (tag: Tag, showCount = true) => {
160 const countStr = showCount ? ` x ${tag.count}` : "";
161 const color = findColor(tag);
162 return `<span class="tag ${color}" data-id="${tag.id}">${tag.id}${countStr}</span>`;
163};
164
165const createPluginItem = (plugin: Plugin, tags: Tag[]) => {
166 const tagsStr = tags.reduce((acc, tag) => {
167 acc += createTag(tag, false);
168 return acc;
169 }, "");
170 const dataUsername = plugin.username.toLocaleLowerCase();
171 const dataRepo = plugin.repo.toLocaleLowerCase();
172 const dataDesc = (plugin.description || "").toLocaleLowerCase();
173 const dataTags = tags
174 .map((t) => t.id)
175 .join(",")
176 .toLocaleLowerCase();
177 const nf = new Intl.NumberFormat("en-US");
178
179 let repoLink = `
180 <a href=${plugin.link} class="flex">${createIcon("github", "github")}</a>
181 <div class="metric-item">${createIcon("star", "stars")} <span>${
182 nf.format(
183 plugin.stars,
184 )
185 }</span></div>
186 <div class="metric-item">
187 ${createIcon("alert-circle", "issues")} <span>${
188 nf.format(plugin.openIssues)
189 }</span>
190 </div>`;
191 if (plugin.type === "srht") {
192 repoLink = `<a href=${plugin.link} class="flex">${
193 createIcon("srht", "srht")
194 }</a>`;
195 }
196
197 return `
198<div class="container plugin" data-username="${dataUsername}" data-repo="${dataRepo}" data-desc="${dataDesc}" data-tags="${dataTags}">
199 <div class="header">
200 <h2 class="item_header">
201 <a href="/plugin/${plugin.username}/${plugin.repo}/">${plugin.repo}</a>
202 </h2>
203 <div class="metrics">
204 ${repoLink}
205 </div>
206 </div>
207 <div class="date">
208 <span>created ${relativeTimeFromDates(new Date(plugin.createdAt))} / </span>
209 <span>updated ${relativeTimeFromDates(new Date(plugin.updatedAt))}</span>
210 </div>
211 <div class="desc">
212 ${plugin.description}
213 </div>
214 <div class="tags">
215 ${tagsStr}
216 </div>
217</div>`;
218};
219
220const createConfigItem = (plugin: Plugin, tags: Tag[]) => {
221 const tagsStr = tags.reduce((acc, tag) => {
222 acc += createTag(tag, false);
223 return acc;
224 }, "");
225
226 const dataUsername = plugin.username.toLocaleLowerCase();
227 const dataRepo = plugin.repo.toLocaleLowerCase();
228 const dataDesc = (plugin.description || "").toLocaleLowerCase();
229 const dataTags = tags
230 .map((t) => t.id)
231 .join(",")
232 .toLocaleLowerCase();
233 const nf = new Intl.NumberFormat("en-US");
234
235 let repoLink = `
236 <a href=${plugin.link} class="flex">${createIcon("github", "github")}</a>
237 <div class="metric-item">${createIcon("star", "stars")} <span>${
238 nf.format(
239 plugin.stars,
240 )
241 }</span></div>
242 <div class="metric-item">
243 ${createIcon("alert-circle", "issues")} <span>${
244 nf.format(plugin.openIssues)
245 }</span>
246 </div>`;
247 if (plugin.type === "srht") {
248 repoLink = `<a href=${plugin.link} class="flex">${
249 createIcon("srht", "srht")
250 }</a>`;
251 }
252
253 return `
254<div class="container plugin" data-username="${dataUsername}" data-repo="${dataRepo}" data-desc="${dataDesc}" data-tags="${dataTags}">
255 <div class="header">
256 <h2 class="item_header">
257 <a href="/plugin/${plugin.username}/${plugin.repo}">${plugin.username}/${plugin.repo}</a>
258 </h2>
259 <div class="metrics">
260 ${repoLink}
261 </div>
262 </div>
263 <div class="date">
264 <span>created ${relativeTimeFromDates(new Date(plugin.createdAt))} / </span>
265 <span>updated ${relativeTimeFromDates(new Date(plugin.updatedAt))}</span>
266 </div>
267 <div class="desc">
268 ${plugin.description}
269 </div>
270 <div class="tags">
271 ${tagsStr}
272 </div>
273</div>`;
274};
275
276function getTags(tagDb: TagMap, tags: string[]): Tag[] {
277 return tags.map((t) => tagDb[t]).filter(Boolean);
278}
279
280const createAboutPage = () => {
281 const head = `
282<title>neovimcraft - about</title>
283<meta name="description" content="About neovimcraft" />
284<meta property="og:description" content="About neovimcraft" />
285<meta property="og:title" content="neovimcraft - about" />
286
287<script src="/nav.js" type="text/javascript"></script>
288`;
289 const nav = createNav();
290 const body = `${nav}
291<div class="about_container">
292 <div class="about_view">
293 <div class="intro">
294 <div class="blurb">
295 <h1>Hey all!</h1>
296 <p>
297 My name is <strong>Eric Bower</strong> and I built this site because neovim is awesome and
298 I want to provide resources for searching an building neovim plugins.
299 </p>
300 </div>
301 <img class="profile" src="/me.jpg" alt="Eric Bower" />
302 </div>
303 <div>
304 <p>
305 I'm a professional software engineer who has been programming since I was 13 years old.
306 I love building software as much as I love building something that people find useful. Most
307 of my time is devoted to growing my ability to build products.
308 </p>
309 <p>
310 I also care deeply about open-source code and have an active
311 <a href="https://github.com/neurosnap">Github</a>
312 , check it out if you're interested. I also write
313 <a href="https://bower.sh">blog articles about software</a>.
314 </p>
315 <p>
316 I'm happy to read feedback about neovimcraft so please feel free to
317 <a href="mailto:neovimcraft@erock.io">email me</a>.
318 </p>
319 </div>
320 <div>
321 <h2>FAQ</h2>
322 <p>Do you have questions not answered here? Email me!</p>
323 <h3>Where do we get our content from?</h3>
324 <p>
325 As of right now, most of our data is scraped from github. You can find our scrape script
326 <a
327 href="https://github.com/neurosnap/neovimcraft/blob/main/src/scripts/scrape.ts"
328 >here</a
329 >.
330 </p>
331 <h3>How can I submit a plugin or resource to this project?</h3>
332 <p>
333 Please read the <a href="https://github.com/neurosnap/neovimcraft#want-to-submit-a-plugin">neovimcraft README</a>.
334 </p>
335 </div>
336 </div>
337</div>`;
338
339 return createHtmlFile({ head, body });
340};
341
342const createSearchPage = (data: PluginData, by: keyof Plugin) => {
343 const pluginsStr = data.plugins.sort(onSort(by)).reduce((acc, plugin) => {
344 const plug = createPluginItem(plugin, getTags(data.tagDb, plugin.tags));
345 return `${acc}\n${plug}`;
346 }, "");
347 const tagListStr = data.tags.reduce(
348 (acc, tag) => `${acc}\n${createTag(tag)}`,
349 "",
350 );
351 const sortStr = () => {
352 let str = "";
353 if (by === "stars") {
354 str += "stars\n";
355 } else {
356 str += '<a href="/">stars</a>\n';
357 }
358
359 if (by === "createdAt") {
360 str += "created\n";
361 } else {
362 str += '<a href="/created/">created</a>\n';
363 }
364
365 if (by === "updatedAt") {
366 str += "updated\n";
367 } else {
368 str += '<a href="/updated/">updated</a>\n';
369 }
370
371 return str;
372 };
373
374 const head = `
375<title>neovimcraft</title>
376 <meta property="og:title" content="neovimcraft" />
377 <meta
378 name="description"
379 content="Search through our curated neovim plugin directory."
380 />
381 <meta
382 property="og:description"
383 content="Search through our curated neovim plugin directory."
384 />
385 <script src="/nav.js" type="text/javascript"></script>
386 <script src="/client.js" type="text/javascript"></script>
387`;
388 const nav = createNav();
389 const body = `${nav}
390<div class="search_container">
391 <div class="search_view">
392 <span class="search_icon">${createIcon("search", "search")}</span>
393 <input
394 id="search"
395 value=""
396 placeholder="search to find a plugin"
397 autocapitalize="off"
398 />
399 <span class="search_clear_icon" id="search_clear">
400 ${createIcon("x-circle", "clear search")}
401 </span>
402 </div>
403 <div class="tagline">
404 <span>Search through our curated list of neovim plugins. </span>
405 <span><a href="https://github.com/neurosnap/neovimcraft#want-to-submit-a-plugin">Submit a plugin</a></span>
406 </div>
407
408 <div class="sidebar">
409 ${tagListStr}
410 </div>
411 <div class="rightbar">
412 ${createAds()}
413 </div>
414 <div class="plugins">
415 <div class="plugins_container">
416 <div class="search_results">${data.plugins.length} results</div>
417 <div id="sort_links">
418 ${sortStr()}
419 </div>
420 <div id="plugins_list">
421 ${pluginsStr}
422 </div>
423 </div>
424 </div>
425</div>`;
426
427 return createHtmlFile({ head, body });
428};
429
430const createSearchConfigPage = (data: PluginData, by: keyof Plugin) => {
431 const pluginsStr = data.plugins.sort(onSort(by)).reduce((acc, plugin) => {
432 const plug = createConfigItem(plugin, []);
433 return `${acc}\n${plug}`;
434 }, "");
435 const sortStr = () => {
436 let str = "";
437 if (by === "stars") {
438 str += "stars\n";
439 } else {
440 str += '<a href="/c">stars</a>\n';
441 }
442
443 if (by === "createdAt") {
444 str += "created\n";
445 } else {
446 str += '<a href="/c/created/">created</a>\n';
447 }
448
449 if (by === "updatedAt") {
450 str += "updated\n";
451 } else {
452 str += '<a href="/c/updated/">updated</a>\n';
453 }
454
455 return str;
456 };
457
458 const head = `
459<title>neovimcraft</title>
460 <meta property="og:title" content="neovimcraft" />
461 <meta
462 name="description"
463 content="Search through our curated neovim config directory."
464 />
465 <meta
466 property="og:description"
467 content="Search through our curated neovim config directory."
468 />
469 <script src="/nav.js" type="text/javascript"></script>
470 <script src="/client.js" type="text/javascript"></script>
471`;
472 const nav = createNav();
473 const body = `${nav}
474<div class="search_container">
475 <div class="search_view">
476 <span class="search_icon">${createIcon("search", "search")}</span>
477 <input
478 id="search"
479 value=""
480 placeholder="search to find a config"
481 autocapitalize="off"
482 />
483 <span class="search_clear_icon" id="search_clear">
484 ${createIcon("x-circle", "clear search")}
485 </span>
486 </div>
487 <div class="tagline">
488 <span>Search through our curated list of neovim configs. </span>
489 <span><a href="https://github.com/neurosnap/neovimcraft#want-to-submit-a-config">Submit a config</a></span>
490 </div>
491
492 <div class="sidebar"></div>
493 <div class="rightbar">
494 ${createAds()}
495 </div>
496 <div class="plugins">
497 <div class="plugins_container">
498 <div class="search_results">${data.plugins.length} results</div>
499 <div id="sort_links">
500 ${sortStr()}
501 </div>
502 <div id="plugins_list">
503 ${pluginsStr}
504 </div>
505 </div>
506 </div>
507</div>`;
508
509 return createHtmlFile({ head, body });
510};
511
512const createPluginView = (plugin: Plugin, tags: Tag[]) => {
513 const tagsStr = tags.reduce((acc, tag) => {
514 acc += createTag(tag, false);
515 return acc;
516 }, "");
517 const nf = new Intl.NumberFormat("en-US");
518
519 let metricsStr = "";
520 if (plugin.type === "github") {
521 metricsStr = `
522 <div class="metrics_view">
523 <div class="metric">
524 ${createIcon("star", "stars")}
525 <span>${nf.format(plugin.stars)}</span>
526 </div>
527 <div class="metric">
528 ${createIcon("alert-circle", "issues")}
529 <span>${nf.format(plugin.openIssues)}</span>
530 </div>
531 <div class="metric">
532 ${createIcon("users", "subscribers")} <span>${
533 nf.format(plugin.subscribers)
534 }</span>
535 </div>
536 <div class="metric">
537 ${createIcon("git-branch", "forks")} <span>${
538 nf.format(plugin.forks)
539 }</span>
540 </div>
541 </div>`;
542 }
543
544 return `
545<div class="meta">
546 <div class="tags_view">
547 ${tagsStr}
548 </div>
549 ${metricsStr}
550 <div class="timestamps">
551 <div>
552 <h5 class="ts_header">CREATED</h5>
553 <h2>${format(new Date(plugin.createdAt))}</h2>
554 </div>
555 <div>
556 <h5 class="ts_header">UPDATED</h5>
557 <h2>${relativeTimeFromDates(new Date(plugin.updatedAt))}</h2>
558 </div>
559 </div>
560 <hr />
561</div>
562`;
563};
564
565const createPluginPage = (plugin: Plugin, tags: Tag[], html: string) => {
566 const head = `
567<title>
568 ${plugin.id}: ${plugin.description}
569</title>
570<meta property="og:title" content=${plugin.id} />
571<meta name="twitter:title" content=${plugin.id} />
572<meta itemprop="name" content=${plugin.id} />
573
574<meta name="description" content="${plugin.id}: ${plugin.description}" />
575<meta itemprop="description" content="${plugin.id}: ${plugin.description}" />
576<meta property="og:description" content="${plugin.id}: ${plugin.description}" />
577<meta name="twitter:description" content="${plugin.id}: ${plugin.description}" />
578
579<script src="/nav.js" type="text/javascript"></script>
580`;
581 const nav = createNav();
582 const body = `${nav}
583<div class="plugin_container">
584 <div class="view">
585 <div class="header">
586 <h2>${plugin.id}</h2>
587 ${plugin.homepage ? `<a href=${plugin.homepage}>website</a>` : ""}
588 <a href=${plugin.link} class="flex">${
589 createIcon(
590 "github",
591 "github",
592 )
593 } <span>github</span></a>
594 </div>
595 ${createPluginView(plugin, tags)}
596 ${html}
597 </div>
598</div>
599`;
600 return createHtmlFile({ head, body });
601};
602
603async function render(data: PluginData, htmlData: { [key: string]: string }) {
604 const files = [
605 createFile(`${OUTDIR}/index.html`, createSearchPage(data, "stars")),
606 createFile(
607 `${OUTDIR}/created/index.html`,
608 createSearchPage(data, "createdAt"),
609 ),
610 createFile(
611 `${OUTDIR}/updated/index.html`,
612 createSearchPage(data, "updatedAt"),
613 ),
614
615 createFile(`${OUTDIR}/about/index.html`, createAboutPage()),
616 ];
617
618 data.plugins.forEach((plugin) => {
619 const tags = getTags(data.tagDb, plugin.tags);
620 const id = getResourceId(plugin);
621 const html = htmlData[id] || "";
622 const fname =
623 `${OUTDIR}/plugin/${plugin.username}/${plugin.repo}/index.html`;
624 const page = createPluginPage(plugin, tags, html);
625 files.push(createFile(fname, page));
626 });
627
628 await Promise.all(files);
629}
630
631async function renderConfig(
632 data: PluginData,
633 htmlData: { [key: string]: string },
634) {
635 const files = [
636 createFile(
637 `${OUTDIR}/c/index.html`,
638 createSearchConfigPage(data, "stars"),
639 ),
640 createFile(
641 `${OUTDIR}/c/created/index.html`,
642 createSearchConfigPage(data, "createdAt"),
643 ),
644 createFile(
645 `${OUTDIR}/c/updated/index.html`,
646 createSearchConfigPage(data, "updatedAt"),
647 ),
648 ];
649
650 data.plugins.forEach((plugin) => {
651 const tags = getTags(data.tagDb, plugin.tags);
652 const id = getResourceId(plugin);
653 const html = htmlData[id] || "";
654 const fname =
655 `${OUTDIR}/plugin/${plugin.username}/${plugin.repo}/index.html`;
656 const page = createPluginPage(plugin, tags, html);
657 files.push(createFile(fname, page));
658 });
659
660 await Promise.all(files);
661}
662
663interface HTMLFile {
664 html: { [key: string]: string };
665}
666
667const htmlData = (htmlFile as HTMLFile).html;
668
669const pluginMap = (dbFile as any).plugins as PluginMap;
670const pluginData = derivePluginData(pluginMap);
671render(pluginData, htmlData).then(console.log).catch(console.error);
672
673const configMap = (configDbFile as any).plugins as PluginMap;
674const configData = derivePluginData(configMap);
675renderConfig(configData, htmlData).then(console.log).catch(console.error);