pvgttm-web/lib/build.js
2023-07-03 16:16:31 -04:00

411 lines
11 KiB
JavaScript

const { exists } = require("fs-extra/lib/fs");
module.exports = async (config) => {
const
{ promises: fs } = require("fs"),
fse = require("fs-extra"),
{ version } = require("../package.json"),
packageInfo = fse.statSync("./package.json", "utf-8"),
path = require("path"),
ejs = require("ejs"),
frontMatter = require("front-matter"),
glob = require("glob"),
dictionary = {
"/astral/vessels.html": ["vessel", "vessels"],
"/campaign/timeline.html": ["last session"],
"https://oldschoolessentials.necroticgnome.com/srd/index.php/Structures":
["stronghold"],
"/astral/timeline.html": ["CAC", "BAC", "Common Astral Calendar"],
"/astral/adventuring.html#portals": ["portal", "portals"],
"/astral/adventuring.html#outposts": ["outpost", "outposts"],
"/astral/adventuring.html#fragments": ["island", "islands"],
},
{ build, isRebuild, logFunction: log = () => {}, site } = config || {},
{ outputPath, journalsPerPage = 5, srcPath } = build,
md = require("markdown-it")({
html: true,
linkify: true,
typographer: true,
xhtmlOut: true,
})
.use(require("markdown-it-anchor").default)
.use(require("markdown-it-table-of-contents"), {
containerHeaderHtml:
'<div class="toc-container-header">Contents</div>',
includeLevel: [2, 3, 4],
})
.use(require("markdown-it-footnote"))
.use(require("markdown-it-multimd-table"), {
multiline: true,
rowspan: true,
headerless: true,
multibody: true,
autolabel: true,
})
.use(require("markdown-it-emoji"))
.use(require("markdown-it-mark"))
.use(
require("markdown-it-include"),
path.join(srcPath, "assets", "fragments")
)
/*
.use(require("markdown-it-auto-crosslinker"), {
dictionary,
})
*/
.use(require("markdown-it-implicit-figures"), {
dataType: true,
figcaption: true,
tabindex: true,
lazyLoading: true,
link: true,
}),
copyAssets = async (directory) => {
const assets = await fs.readdir(directory);
assets.forEach(async (asset) => {
// we no longer merge scripts and styles, thanks to http/2's parallel file handling
if (asset === "_root") {
fse.copy(path.join(srcPath, "assets", asset), outputPath);
} else {
fse.copy(
path.join(srcPath, "assets", asset),
path.join(outputPath, asset)
);
}
});
},
getReadTime = (text) => {
const WPM = 275,
fixedString = text.replace(/[^\w\s]+/g, ""),
count = fixedString.split(/\s+/).length;
if (count < WPM) return "less than 1 minute";
else return `${Math.ceil(count / WPM)} minutes`;
},
tagSorter = (a, b) => a.toLowerCase().localeCompare(b.toLowerCase()),
parseFile = (file, pagePath, siteData, isSupport) => {
const { dir, ext, name } = path.parse(file) || {},
hasExt = name.indexOf(".") > -1,
destPath = path.join(outputPath, dir),
filePath = path.join(pagePath, file),
// read page file
data = fse.readFileSync(filePath, "utf-8"),
info = fse.statSync(filePath, "utf-8"),
// render page
{ attributes, body } = frontMatter(data),
{ content_type: contentType, tags: originalTags = [] } =
attributes,
// TODO: Look for tags in posts as well, link to them, and add them to tag pages
tags =
typeof originalTags === "string"
? originalTags.split(/\W+/)
: [].concat(originalTags),
innerTags = (
contentType === "journal"
? body.match(/\b#(\w+)/g) || []
: []
).map((val) => val.replace("#", "")),
allTags = [...tags, ...innerTags].sort(tagSorter),
updatedBody =
contentType === "journal"
? allTags.reduce(
(acc, tag) =>
acc.replace(
`#${tag}`,
`
<a href="/journal/tags/${tag}/index.html">
#<span class="p-category category">${tag}</span>
</a>`
),
body
)
: body;
return {
...config,
page: {
name,
...attributes,
date_upd: attributes?.date_pub !== info.mtime ? info.mtime : '',
body: updatedBody,
destPath,
filePath,
path: path.join(dir, hasExt ? name : `${name}.html`),
tags: [...tags, ...innerTags].sort(tagSorter),
ext,
},
site: {
...site,
pages: isSupport ? siteData : [],
version,
lastUpdated: packageInfo.mtime,
},
};
},
parseContent = (page, siteData) => {site
const {
body,
content_type: contentType,
filePath,
// tags,
} = page || {},
{ ext } = path.parse(filePath) || {},
{ pages, tags } = siteData || {};
let content = body,
readTime;
if (ext === ".md") {
if (contentType === "journal" && typeof body === "string") {
readTime = getReadTime(body);
}
content = md.render(body);
} else if (ext === ".ejs") {
content = ejs.render(
body,
{ page, site: { ...site, pages, tags, version, lastUpdated: packageInfo.mtime, } },
{ filename: filePath }
);
}
return { ...page, content, readTime };
},
renderFile = async (page, isSupport) => {
const {
content,
destPath,
layout,
path: pagePath,
pages,
siteTags,
tags,
} = page || {};
try {
const layoutFileName = `${srcPath}/layouts/${
layout || "default"
}.ejs`,
layoutData = await fs.readFile(layoutFileName, "utf-8"),
completePage = isSupport
? content
: ejs.render(layoutData, {
content,
page,
site: {
...site,
pages,
tags:
page.content_type === "journal"
? siteTags
: tags,
},
filename: layoutFileName,
});
if (!completePage) {
console.log("failed!", pagePath, content);
return;
}
// create destination directory
fse.mkdirsSync(destPath);
// save the html file
fse.writeFileSync(
path.join(outputPath, pagePath),
completePage
);
} catch (e) {
console.log("failed!", pagePath);
console.log("paths", destPath, outputPath);
console.error(e);
return;
}
};
// md.use(emoji);
log(`${isRebuild ? "Reb" : "B"}uilding...`);
// clear destination folder
fse.emptyDirSync(outputPath);
// copy assets folder
await copyAssets(path.join(srcPath, "assets"));
const files = ["pages", "sitePosts"].reduce((acc, pageDir) => {
return [
...acc,
...glob
.sync("**/*.@(md|ejs|html)", {
cwd: path.join(srcPath, pageDir),
})
.map((file) =>
parseFile(file, path.join(srcPath, pageDir))
),
];
}, []),
sortByPubDate = (a, b) => {
if (a.date_pub && b.date_pub) {
let a_dt = new Date(a.date_pub).getTime(),
b_dt = new Date(b.date_pub).getTime();
if (a_dt < b_dt) {
return 1;
}
if (b_dt < a_dt) {
return -1;
}
return 0;
}
if (a.date_pub) return -1;
if (b.date_pub) return 1;
return 0;
},
pages = files
.map(({ page }) => ({ ...page }))
.filter(({ is_draft = false }) => !is_draft)
.filter(({ status }) => status !== "draft")
.sort(sortByPubDate),
tagCloud = pages.reduce((acc, curr) => {
const { tags } = curr;
tags.forEach((tag) => {
if (acc[tag]) acc[tag]++;
else acc[tag] = 1;
});
return acc;
}, {}),
tags = Object.keys(tagCloud).sort(tagSorter),
yearCloud = pages
.filter(({ content_type = "" }) => content_type === "journal")
.reduce((acc, curr) => {
const { date_pub } = curr;
if (date_pub) {
const year = new Date(date_pub).getFullYear();
if (acc[year]) acc[year]++;
else acc[year] = 1;
}
return acc;
}, {}),
years = Object.keys(yearCloud).sort().reverse(),
pagesWithContent = pages.map((page) =>
parseContent(page, { pages, tags })
);
// add data for the whole site to each page as it's rendered
pagesWithContent.forEach((page) => {
renderFile({ ...page, pages: pagesWithContent, siteTags: tags });
});
/* Journal Stuff - Tags & Years */
// make page(s) for each tag
tags.forEach((tag) => {
// check counts
let postCount = tagCloud[tag],
pageCount = Math.ceil(postCount / journalsPerPage);
for (let i = 1; i <= pageCount; i++) {
const firstEntryIndex = journalsPerPage * (i - 1),
lastEntryIndex = journalsPerPage * i;
renderFile({
content: tag,
destPath: path.join(outputPath, "journal", "tags", tag),
entriesToList: pagesWithContent
.filter(
(p) =>
p && Array.isArray(p.tags) && p.tags.includes(tag)
)
.slice(firstEntryIndex, lastEntryIndex),
layout: "tag",
path: `journal/tags/${tag}/${
i === 1 ? "index.html" : `page${i}.html`
}`,
site: { ...site, pages: pagesWithContent, tags },
pageCount,
pageNum: i,
pages: pagesWithContent,
tag,
tags,
title: `Journal Entries Tagged with #${tag}`,
});
}
});
// make page(s) for each year
years.forEach((year) => {
// check counts
let postCount = yearCloud[year],
pageCount = Math.ceil(postCount / journalsPerPage);
for (let i = 1; i <= pageCount; i++) {
const firstEntryIndex = journalsPerPage * (i - 1),
lastEntryIndex = journalsPerPage * i;
console.log("config", config);
// TODO: rethink the data passed in here - you're paging solution works (kinda), take it over the finish line!
renderFile({
content: year,
destPath: path.join(outputPath, "journal", year),
entriesToList: pagesWithContent
.filter(({ content_type = "", date_pub = "" }) => {
if (!date_pub || content_type !== "journal")
return false;
const p_dt = new Date(date_pub).getTime(),
y1_dt = new Date(
`${year}-01-01T00:00:00-0500`
).getTime(),
y2_dt = new Date(
`${year}-12-31T23:59:59-0500`
).getTime();
return p_dt >= y1_dt && p_dt <= y2_dt;
})
.slice(firstEntryIndex, lastEntryIndex),
layout: "journal-year",
path: `journal/${year}/${
i === 1 ? "index.html" : `page${i}.html`
}`,
site: { ...site, pages: pagesWithContent, tags },
pageCount,
pageNum: i,
pages: pagesWithContent,
tags,
title: `Journal Entries from ${year}`,
year,
});
}
});
/* Support pages - anything too weird / specific for markdown rendering */
// collect support pages
const support = ["support"].reduce((acc, pageDir) => {
return [
...acc,
...glob
.sync("**/*.@(md|ejs|html)", {
cwd: path.join(srcPath, pageDir),
})
.map((file) =>
parseFile(
file,
path.join(srcPath, pageDir),
pagesWithContent,
true
)
),
];
}, []);
// write each one out
support.forEach((fileData) => {
const { page } = fileData;
if (page?.ext === ".ejs") {
const pageAndContent = parseContent(page, {
pages: pagesWithContent,
tags,
});
return renderFile({ ...fileData, ...pageAndContent, tags }, true);
}
return renderFile(fileData, true);
});
};