repos / neovimcraft

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

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