Mustache-based templating engine
This commit is contained in:
parent
8fcd25543d
commit
b9463ad58f
257
lib.js
257
lib.js
@ -1,6 +1,7 @@
|
|||||||
import Path from "path"
|
import Path from "path"
|
||||||
import FS from "fs/promises"
|
import FS from "fs/promises"
|
||||||
import { JSDOM } from "jsdom"
|
import { JSDOM } from "jsdom"
|
||||||
|
import Mustache from "mustache"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -286,11 +287,8 @@ export const createCache = async (cache = {}) => {
|
|||||||
return cache
|
return cache
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getCacheFilename = (source) =>
|
|
||||||
source.name + '.xml'
|
|
||||||
|
|
||||||
export const getCachePath = (source, cache) =>
|
export const getCachePath = (source, cache) =>
|
||||||
Path.join(cache.path, getCacheFilename(source))
|
Path.join(cache.path, source.cacheFilename)
|
||||||
|
|
||||||
export const cacheSource = (source, cache) =>
|
export const cacheSource = (source, cache) =>
|
||||||
write(getCachePath(source, cache), renderCache(source, cache))
|
write(getCachePath(source, cache), renderCache(source, cache))
|
||||||
@ -346,7 +344,7 @@ export const renderCache = (source, cache) => `\
|
|||||||
<title>${source.displayName}</title>
|
<title>${source.displayName}</title>
|
||||||
<description>${source.description}</description>
|
<description>${source.description}</description>
|
||||||
<link>${buildCacheLink(source)}</link>
|
<link>${buildCacheLink(source)}</link>
|
||||||
<atom:link href="${new URL(getCacheFilename(source), cache.directoryUrl)}" rel="self" type="application/rss+xml" />
|
<atom:link href="${new URL(source.cacheFilename, cache.directoryUrl)}" rel="self" type="application/rss+xml" />
|
||||||
<pubDate>${new Date(source.latestPostDate).toUTCString()}</pubDate>
|
<pubDate>${new Date(source.latestPostDate).toUTCString()}</pubDate>
|
||||||
<generator>rssssing</generator>
|
<generator>rssssing</generator>
|
||||||
${source.items.map(item => item.outerHTML.replaceAll(/\n\s*/g, '')).join('\n')}
|
${source.items.map(item => item.outerHTML.replaceAll(/\n\s*/g, '')).join('\n')}
|
||||||
@ -467,6 +465,16 @@ export const createView = async (view = {}) => {
|
|||||||
if(view.imageStoreDirectory)
|
if(view.imageStoreDirectory)
|
||||||
await openImageStore(view)
|
await openImageStore(view)
|
||||||
|
|
||||||
|
if(isUnset(view.templatesPath)) {
|
||||||
|
view.templatesPath = Path.join(import.meta.dirname, 'templates')
|
||||||
|
}
|
||||||
|
|
||||||
|
if(isUnset(view.stylesheetPath)) {
|
||||||
|
view.stylesheetPath = Path.join(import.meta.dirname, 'assets/style.css')
|
||||||
|
}
|
||||||
|
|
||||||
|
view.batch.add(openTemplates(view))
|
||||||
|
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -481,32 +489,76 @@ export const openImageStore = async view => {
|
|||||||
let dirents = await FS.readdir(imageStorePath, { withFileTypes: true })
|
let dirents = await FS.readdir(imageStorePath, { withFileTypes: true })
|
||||||
|
|
||||||
for(let dirent of dirents) {
|
for(let dirent of dirents) {
|
||||||
if(dirent.isFile()) {
|
if(!dirent.isFile())
|
||||||
let basename = dirent.name.slice(0, dirent.name.lastIndexOf('.'))
|
continue
|
||||||
view.imageStore.set(basename, Path.join(view.imageStoreDirectory, dirent.name))
|
|
||||||
}
|
let basename = dirent.name.slice(0, dirent.name.lastIndexOf('.'))
|
||||||
|
view.imageStore.set(basename, Path.join(view.imageStoreDirectory, dirent.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
export const openTemplates = async (view) => {
|
||||||
|
view.templates = {}
|
||||||
|
let exists = await doesExist(view.templatesPath)
|
||||||
|
let dirents
|
||||||
|
|
||||||
|
if(exists)
|
||||||
|
dirents = await FS.readdir(view.templatesPath, { withFileTypes: true })
|
||||||
|
|
||||||
|
if(!exists || dirents.length === 0)
|
||||||
|
throw new Error('Assets directory must contain a "nav" and "main" file.')
|
||||||
|
|
||||||
|
for(let dirent of dirents) {
|
||||||
|
if(!dirent.isFile())
|
||||||
|
continue
|
||||||
|
|
||||||
|
let extensionStart = dirent.name.lastIndexOf('.')
|
||||||
|
let basename = dirent.name.slice(0, extensionStart)
|
||||||
|
let extension = dirent.name.slice(extensionStart + 1)
|
||||||
|
|
||||||
|
if(basename == 'main' && isUnset(view.formatExtension))
|
||||||
|
view.formatExtension = extension
|
||||||
|
|
||||||
|
view.batch.add(
|
||||||
|
FS.readFile(Path.join(view.templatesPath, dirent.name), { encoding: 'utf-8' })
|
||||||
|
// Must remove trailing newlines so partials will work cleanly
|
||||||
|
.then(template => view.templates[basename] = template )
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
export const writeView = (sources, feeds, view) => {
|
export const writeView = (sources, feeds, view) => {
|
||||||
view.header = renderNav(feeds, sources)
|
for(let source of sources)
|
||||||
|
labelList(source, view)
|
||||||
|
|
||||||
|
for(let feed of feeds)
|
||||||
|
labelList(feed, view)
|
||||||
|
|
||||||
|
view.header = renderNav(sources, feeds, view)
|
||||||
let pages = []
|
let pages = []
|
||||||
|
|
||||||
|
for(let source of sources) {
|
||||||
|
pages = pages.concat(createPages(source, view))
|
||||||
|
}
|
||||||
|
|
||||||
for(let feed of feeds) {
|
for(let feed of feeds) {
|
||||||
pages = pages.concat(createPages(feed, view))
|
pages = pages.concat(createPages(feed, view))
|
||||||
}
|
}
|
||||||
|
|
||||||
for(let source of sources) {
|
|
||||||
pages = pages.concat(createPages(source, view))
|
|
||||||
}
|
|
||||||
|
|
||||||
for(let page of pages) {
|
for(let page of pages) {
|
||||||
writePage(page, view)
|
writePage(page, view)
|
||||||
}
|
}
|
||||||
|
|
||||||
writeStylesheet(Path.join(import.meta.dirname, 'assets/style.css'), view)
|
writeStylesheet(view.stylesheetPath, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const labelList = (list, view) => {
|
||||||
|
list.indexFilename = nameFinalPage(list, view)
|
||||||
|
list.empty = list.posts.length == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createPages = (list, view) => {
|
export const createPages = (list, view) => {
|
||||||
@ -520,11 +572,12 @@ export const createPages = (list, view) => {
|
|||||||
posts.push(list.posts[i])
|
posts.push(list.posts[i])
|
||||||
|
|
||||||
if(i % view.pageSize == 0) {
|
if(i % view.pageSize == 0) {
|
||||||
let title = getPageTitle(list, pages.length)
|
let filename = i < view.pageSize ? list.indexFilename : namePage(list, pages.length, view)
|
||||||
let filename = i < view.pageSize ? getFinalPageFilename(list) : getPageFilename(list, pages.length)
|
|
||||||
let page = {
|
let page = {
|
||||||
filename,
|
filename,
|
||||||
title,
|
title: list.displayName,
|
||||||
|
index: pages.length,
|
||||||
posts: posts.reverse(),
|
posts: posts.reverse(),
|
||||||
lastPageLink
|
lastPageLink
|
||||||
}
|
}
|
||||||
@ -546,152 +599,74 @@ export const createPages = (list, view) => {
|
|||||||
return pages
|
return pages
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const namePage = (list, number, view) =>
|
||||||
|
list.name + '-' + number + '.' + view.formatExtension
|
||||||
|
|
||||||
|
export const nameFinalPage = (list, view) =>
|
||||||
|
(list.main ? 'index' : list.name) + '.' + view.formatExtension
|
||||||
|
|
||||||
export const writePage = (page, view) => {
|
export const writePage = (page, view) => {
|
||||||
let html = renderPage(page.title, page.posts, view.header, renderNextPageLink(page.lastPageLink))
|
let content = renderPage(page, view)
|
||||||
let promise = write(Path.join(view.path, page.filename), html)
|
let promise = write(Path.join(view.path, page.filename), content)
|
||||||
|
|
||||||
view.batch.add(promise.then(annotate(`Created "${page.title}" (${page.filename})`)))
|
view.batch.add(promise.then(annotate(`Created ${page.filename}`)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getFinalPageFilename = list =>
|
export const renderPage = (page, view) =>
|
||||||
(list.main ? 'index' : list.name) + '.html'
|
Mustache.render(view.templates.main, {
|
||||||
|
...page,
|
||||||
|
sections: page.posts.map(createSection),
|
||||||
|
header: view.header,
|
||||||
|
footer: view.footer
|
||||||
|
}, view.templates)
|
||||||
|
|
||||||
export const getPageFilename = (list, i) =>
|
export const createMustacheDelimitedArray = (array) => {
|
||||||
list.name + '-' + i + '.html'
|
if(isUnset(array) || array.length === 0)
|
||||||
|
return false
|
||||||
|
|
||||||
export const getPageTitle = (list, i) =>
|
return {
|
||||||
list.displayName + ' - ' + (i + 1)
|
final: array[array.length -1],
|
||||||
|
entries: array.slice(0, -1)
|
||||||
export const renderPage = (title, posts, header, footer) => `\
|
}
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
|
|
||||||
<title>${title}</title>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<link rel="stylesheet" href="./style.css">
|
|
||||||
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<header>
|
|
||||||
${header}
|
|
||||||
</header>
|
|
||||||
<main>
|
|
||||||
|
|
||||||
${posts.map(renderPost).join('\n')}
|
|
||||||
|
|
||||||
</main>
|
|
||||||
<footer>
|
|
||||||
${footer}
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>`
|
|
||||||
|
|
||||||
export const renderPost = (post, index) => {
|
|
||||||
let details = []
|
|
||||||
|
|
||||||
if(post.title)
|
|
||||||
details.push([ 'title', `"${post.title}"` ])
|
|
||||||
|
|
||||||
if(post.categories && post.categories.length > 0)
|
|
||||||
details.push([ 'categories', post.categories.map(name => `<mark>${name}</mark>`).join(', ') ])
|
|
||||||
|
|
||||||
details.push([ 'source', `<a href="${post.source.hostname}">${post.source.hostname}</a>` ])
|
|
||||||
details.push([ 'lists', post.occurances.map(occ => `<a href="${occ.page.filename}#${occ.index}">${occ.list.displayName}</a>`).join(', ') ])
|
|
||||||
|
|
||||||
return `\
|
|
||||||
<section id="${index}">
|
|
||||||
${post.images.map(renderImage).join('\n')}
|
|
||||||
<details>
|
|
||||||
|
|
||||||
<summary><b>${post.source.displayName} (${post.source.type})</b> ${renderDate(new Date(post.date))} <a href="${post.link}">open</a></summary>
|
|
||||||
<ul>
|
|
||||||
${details.map(args => renderPostDetail(...args)).join('\n')}
|
|
||||||
<ul>
|
|
||||||
|
|
||||||
</details>
|
|
||||||
<hr>
|
|
||||||
</section>`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const renderPostDetail = (name, value) =>
|
export const createSection = (post, index) => {
|
||||||
`<li><b>${name}</b> ${value}</li>`
|
let date = new Date(post.date)
|
||||||
|
|
||||||
export const renderImage = href => {
|
return {
|
||||||
return `\
|
post,
|
||||||
<a href="${href}"> <figure> <img src="${href}" loading="lazy"></img> </figure> </a>`
|
index,
|
||||||
|
date: {
|
||||||
|
month: date.getMonth() + 1,
|
||||||
|
day: date.getDate(),
|
||||||
|
year: date.getFullYear()
|
||||||
|
},
|
||||||
|
categories: createMustacheDelimitedArray(post.categories),
|
||||||
|
occurances: createMustacheDelimitedArray(post.occurances)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const renderDate = date =>
|
export const renderNav = (sources, feeds, view) => {
|
||||||
(date.getMonth() + 1) + '.' + date.getDate() + '.' + date.getFullYear()
|
let sourceTypes = []
|
||||||
|
|
||||||
export const renderNextPageLink = link => `\
|
|
||||||
<a href="${link}">next</a>`
|
|
||||||
|
|
||||||
export const renderNav = (feeds, sources) => {
|
|
||||||
let sections = {}
|
|
||||||
|
|
||||||
for(let source of sources) {
|
for(let source of sources) {
|
||||||
let section = sections[source.type]
|
let section = sourceTypes[source.type]
|
||||||
|
|
||||||
if(section) {
|
if(section) {
|
||||||
section.push(source)
|
section.push(source)
|
||||||
} else {
|
} else {
|
||||||
sections[source.type] = [
|
sourceTypes[source.type] = [
|
||||||
source
|
source
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let out = ''
|
return Mustache.render(view.templates.nav, {
|
||||||
|
sourceTypes: Object.values(sourceTypes).map(createMustacheDelimitedArray),
|
||||||
for(let name in sections) {
|
feeds: createMustacheDelimitedArray(feeds),
|
||||||
out += `
|
}, view.templates)
|
||||||
<li>
|
|
||||||
|
|
||||||
<b>${name}</b><br>
|
|
||||||
${sections[name].map(renderNavEntry).join('\n')}
|
|
||||||
|
|
||||||
</li>`
|
|
||||||
}
|
|
||||||
|
|
||||||
return `\
|
|
||||||
<details>
|
|
||||||
|
|
||||||
<summary>Feeds</summary>
|
|
||||||
<section>
|
|
||||||
<ul>
|
|
||||||
|
|
||||||
${feeds.map(renderNavEntry).join('\n')}
|
|
||||||
|
|
||||||
</ul>
|
|
||||||
<hr>
|
|
||||||
<ul>
|
|
||||||
|
|
||||||
${out}
|
|
||||||
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</details>
|
|
||||||
<hr>`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const renderNavEntry = (list) => {
|
|
||||||
let extra = ''
|
|
||||||
|
|
||||||
if(list.errored) {
|
|
||||||
extra += ' (errored)'
|
|
||||||
} else if (list.posts.length == 0) {
|
|
||||||
extra += ' (empty)'
|
|
||||||
}
|
|
||||||
|
|
||||||
return `<a href="${getFinalPageFilename(list)}">${list.displayName}</a>${extra}`
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
//
|
//
|
||||||
// ,-. ,-. . . ;-. ,-. ,-.
|
// ,-. ,-. . . ;-. ,-. ,-.
|
||||||
// `-. | | | | | | |-'
|
// `-. | | | | | | |-'
|
||||||
@ -723,6 +698,8 @@ export const populateSource = async (channel, source, postReducerCallback, cache
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const createSource = async (source, getChannel, postReducerCallback, cache) => {
|
export const createSource = async (source, getChannel, postReducerCallback, cache) => {
|
||||||
|
source.cacheFilename = source.name + '.xml'
|
||||||
|
|
||||||
if(cache.enabled)
|
if(cache.enabled)
|
||||||
source = await openCache(source, cache)
|
source = await openCache(source, cache)
|
||||||
source = await populateSource(await getChannel(source), source, postReducerCallback, cache)
|
source = await populateSource(await getChannel(source), source, postReducerCallback, cache)
|
||||||
|
@ -7,10 +7,11 @@
|
|||||||
"start": "node .",
|
"start": "node .",
|
||||||
"setup": "mkdir out && cp -r default/* ."
|
"setup": "mkdir out && cp -r default/* ."
|
||||||
},
|
},
|
||||||
"author": "",
|
"author": "Dakedres",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jsdom": "^22.1.0"
|
"jsdom": "^22.1.0",
|
||||||
|
"mustache": "^4.2.0"
|
||||||
},
|
},
|
||||||
"type": "module"
|
"type": "module"
|
||||||
}
|
}
|
||||||
|
55
templates/main.html
Normal file
55
templates/main.html
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
|
||||||
|
<title>{{title}} - {{index}}</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="stylesheet" href="./style.css">
|
||||||
|
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
|
||||||
|
{{& header}}
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
|
||||||
|
{{#sections}}
|
||||||
|
<section id="{{index}}">
|
||||||
|
|
||||||
|
{{#post.images}}
|
||||||
|
<a href="{{& .}}"> <figure> <img src="{{& .}}" loading="lazy"></img> </figure> </a>
|
||||||
|
{{/post.images}}
|
||||||
|
<details>
|
||||||
|
|
||||||
|
<summary><b>{{post.source.displayName}} ({{post.source.type}})</b> {{date.month}}.{{date.day}}.{{date.year}} <a href="{{& post.link}}">open</a></summary>
|
||||||
|
<ul>
|
||||||
|
|
||||||
|
{{#post.title}}
|
||||||
|
<li> <b>title</b> {{.}} </li>
|
||||||
|
{{/post.title}}
|
||||||
|
{{#categories}}
|
||||||
|
<li> <b>categories</b> {{#entries}}<mark>{{.}}</mark>, {{/entries}}<mark>{{final}}</mark> </li>
|
||||||
|
{{/categories}}
|
||||||
|
<li> <b>source</b> <a href="{{& post.source.hostname}}">{{post.source.hostname}}</a> </li>
|
||||||
|
{{#occurances}}
|
||||||
|
<li> <b>lists</b> {{#entries}}{{> occurance-link}} {{/entries}}{{#final}}{{> occurance-link}}{{/final}} </li>
|
||||||
|
{{/occurances}}
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
<hr>
|
||||||
|
{{/sections}}
|
||||||
|
|
||||||
|
</main>
|
||||||
|
<footer>
|
||||||
|
<a href="{{& lastPageLink}}">next</a>
|
||||||
|
|
||||||
|
{{& footer}}
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
1
templates/nav-link.html
Normal file
1
templates/nav-link.html
Normal file
@ -0,0 +1 @@
|
|||||||
|
<a href="{{indexFilename}}">{{displayName}}</a>{{#errored}} (errored){{/errored}}{{^errored}}{{#empty}} (empty){{/empty}}{{/errored}}
|
30
templates/nav.html
Normal file
30
templates/nav.html
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<details>
|
||||||
|
|
||||||
|
<summary>Feeds</summary>
|
||||||
|
<section>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
|
||||||
|
{{#feeds}}
|
||||||
|
{{#entries}}{{> nav-link}} {{/entries}}{{#final}}{{> nav-link}}{{/final}}
|
||||||
|
{{/feeds}}
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
<hr>
|
||||||
|
<ul>
|
||||||
|
|
||||||
|
{{#sourceTypes}}
|
||||||
|
<li>
|
||||||
|
|
||||||
|
<b>{{final.type}}</b><br>
|
||||||
|
{{#entries}}{{> nav-link}} {{/entries}}{{#final}}{{> nav-link}}{{/final}}
|
||||||
|
|
||||||
|
</li>
|
||||||
|
{{/sourceTypes}}
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</details>
|
||||||
|
<hr>
|
1
templates/occurance-link.html
Normal file
1
templates/occurance-link.html
Normal file
@ -0,0 +1 @@
|
|||||||
|
<a href="{{page.filename}}#{{index}}">{{list.displayName}}</a>
|
12
yarn.lock
Normal file → Executable file
12
yarn.lock
Normal file → Executable file
@ -162,18 +162,16 @@ mime-types@^2.1.12:
|
|||||||
dependencies:
|
dependencies:
|
||||||
mime-db "1.52.0"
|
mime-db "1.52.0"
|
||||||
|
|
||||||
mime-types@^2.1.35:
|
|
||||||
version "2.1.35"
|
|
||||||
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a"
|
|
||||||
integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
|
|
||||||
dependencies:
|
|
||||||
mime-db "1.52.0"
|
|
||||||
|
|
||||||
ms@2.1.2:
|
ms@2.1.2:
|
||||||
version "2.1.2"
|
version "2.1.2"
|
||||||
resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz"
|
resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz"
|
||||||
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
|
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
|
||||||
|
|
||||||
|
mustache@^4.2.0:
|
||||||
|
version "4.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/mustache/-/mustache-4.2.0.tgz#e5892324d60a12ec9c2a73359edca52972bf6f64"
|
||||||
|
integrity sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==
|
||||||
|
|
||||||
nwsapi@^2.2.4:
|
nwsapi@^2.2.4:
|
||||||
version "2.2.7"
|
version "2.2.7"
|
||||||
resolved "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.7.tgz"
|
resolved "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.7.tgz"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user