repos / neovimcraft

website that makes it easy to find neovim plugins
git clone https://github.com/neurosnap/neovimcraft.git

neovimcraft / src / scripts
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 ? `&nbsp;x&nbsp;${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&apos;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&apos;re interested. I also write
308        <a href="https://bower.sh">blog articles about software</a>.
309      </p>
310      <p>
311        I&apos;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);