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