Render integrated investment dossier page
This commit is contained in:
parent
bee1d30431
commit
f7a515c527
7 changed files with 1050 additions and 1381 deletions
219
scripts/render-integrated-dossier.mjs
Normal file
219
scripts/render-integrated-dossier.mjs
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = process.cwd();
|
||||
const sourcePath = path.join(root, "docs", "gamecult_integrated_dossier.tex");
|
||||
const outputPath = path.join(root, "GameCult", "Integrated-Dossier.md");
|
||||
|
||||
function readBraceArg(text, start) {
|
||||
while (text[start] && /\s/.test(text[start])) start += 1;
|
||||
if (text[start] !== "{") return null;
|
||||
let depth = 0;
|
||||
for (let index = start; index < text.length; index += 1) {
|
||||
if (text[index] === "\\" && text[index + 1]) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (text[index] === "{") depth += 1;
|
||||
if (text[index] === "}") depth -= 1;
|
||||
if (depth === 0) {
|
||||
return { value: text.slice(start + 1, index), end: index + 1 };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function replaceCommand(text, command, argCount, replacer) {
|
||||
let output = "";
|
||||
let cursor = 0;
|
||||
const token = `\\${command}`;
|
||||
while (cursor < text.length) {
|
||||
const index = text.indexOf(token, cursor);
|
||||
if (index === -1) {
|
||||
output += text.slice(cursor);
|
||||
break;
|
||||
}
|
||||
output += text.slice(cursor, index);
|
||||
let next = index + token.length;
|
||||
const args = [];
|
||||
for (let i = 0; i < argCount; i += 1) {
|
||||
const arg = readBraceArg(text, next);
|
||||
if (!arg) break;
|
||||
args.push(arg.value);
|
||||
next = arg.end;
|
||||
}
|
||||
output += args.length === argCount ? replacer(args) : text.slice(index, next);
|
||||
cursor = next;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function splitCells(row) {
|
||||
const cells = [];
|
||||
let current = "";
|
||||
let depth = 0;
|
||||
for (let i = 0; i < row.length; i += 1) {
|
||||
const char = row[i];
|
||||
if (char === "\\" && row[i + 1]) {
|
||||
current += char + row[i + 1];
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "{") depth += 1;
|
||||
if (char === "}") depth -= 1;
|
||||
if (char === "&" && depth === 0) {
|
||||
cells.push(current.trim());
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
cells.push(current.trim());
|
||||
return cells.filter((cell) => cell.length > 0);
|
||||
}
|
||||
|
||||
function stripInline(text) {
|
||||
let output = text;
|
||||
output = replaceCommand(output, "src", 2, ([label, url]) => {
|
||||
return `<sup><a href="${url}">${cleanInline(label)}</a></sup>`;
|
||||
});
|
||||
output = replaceCommand(output, "href", 2, ([url, label]) => `[${cleanInline(label)}](${url})`);
|
||||
output = replaceCommand(output, "term", 1, ([body]) => `<strong>${cleanInline(body)}</strong>`);
|
||||
output = replaceCommand(output, "textbf", 1, ([body]) => `<strong>${cleanInline(body)}</strong>`);
|
||||
output = replaceCommand(output, "textcolor", 2, ([, body]) => cleanInline(body));
|
||||
return cleanInline(output);
|
||||
}
|
||||
|
||||
function cleanInline(text) {
|
||||
return text
|
||||
.replace(/\\LaTeX\{\}/g, "LaTeX")
|
||||
.replace(/\\textbackslash\{\}/g, "\\")
|
||||
.replace(/\\url\{([^}]+)\}/g, "$1")
|
||||
.replace(/\\_/g, "_")
|
||||
.replace(/\\%/g, "%")
|
||||
.replace(/\\\$/g, "$")
|
||||
.replace(/\\&/g, "&")
|
||||
.replace(/\\rightarrow/g, "\\to")
|
||||
.replace(/\\pageref\*\{[^}]+\}/g, "")
|
||||
.replace(/\\[a-zA-Z]+\*?(?:\[[^\]]+\])?/g, "")
|
||||
.replace(/[{}]/g, "")
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function convertLongTables(text) {
|
||||
return text.replace(/\\begin\{longtable\}\{[^\n]*\}([\s\S]*?)\\end\{longtable\}/g, (_, body) => {
|
||||
const rows = body
|
||||
.replace(/\\(?:toprule|midrule|bottomrule)/g, "")
|
||||
.split(/\\\\/)
|
||||
.map((row) => row.trim())
|
||||
.filter((row) => row.length > 0);
|
||||
|
||||
const renderedRows = rows
|
||||
.map((row, rowIndex) => {
|
||||
const cells = splitCells(row).map(stripInline);
|
||||
if (cells.length < 2) return "";
|
||||
const cellTag = rowIndex === 0 || row.includes("\\textbf") ? "th" : "td";
|
||||
return `<tr>${cells.map((cell) => `<${cellTag}>${cell}</${cellTag}>`).join("")}</tr>`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
return `\n<table class="integrated-dossier-table">\n<tbody>\n${renderedRows}\n</tbody>\n</table>\n`;
|
||||
});
|
||||
}
|
||||
|
||||
function convertDescription(text) {
|
||||
return text.replace(/\\begin\{description\}(?:\[[^\]]+\])?([\s\S]*?)\\end\{description\}/g, (_, body) => {
|
||||
const items = [...body.matchAll(/\\item\[([^\]]+)\]\s*([\s\S]*?)(?=\\item\[|$)/g)]
|
||||
.map((match) => {
|
||||
return `<dt>${stripInline(match[1])}</dt>\n<dd>${stripInline(match[2])}</dd>`;
|
||||
})
|
||||
.join("\n");
|
||||
return `\n<dl class="integrated-dossier-glossary">\n${items}\n</dl>\n`;
|
||||
});
|
||||
}
|
||||
|
||||
function convertVerbatim(text) {
|
||||
return text.replace(/\\begin\{verbatim\}([\s\S]*?)\\end\{verbatim\}/g, (_, body) => {
|
||||
return `\n\`\`\`text\n${body.trim()}\n\`\`\`\n`;
|
||||
});
|
||||
}
|
||||
|
||||
function convertTex(tex) {
|
||||
let body = tex.slice(tex.indexOf("\\chapter*{Executive Summary}"));
|
||||
body = body.replace(/\\end\{document\}[\s\S]*$/, "");
|
||||
body = convertVerbatim(body);
|
||||
body = convertLongTables(body);
|
||||
body = convertDescription(body);
|
||||
body = replaceCommand(body, "noteBox", 1, ([content]) => {
|
||||
return `\n<aside class="integrated-dossier-note">\n${stripInline(content)}\n</aside>\n`;
|
||||
});
|
||||
body = replaceCommand(body, "src", 2, ([label, url]) => {
|
||||
return `<sup><a href="${url}">${cleanInline(label)}</a></sup>`;
|
||||
});
|
||||
body = replaceCommand(body, "href", 2, ([url, label]) => `[${cleanInline(label)}](${url})`);
|
||||
body = replaceCommand(body, "term", 1, ([content]) => `<strong>${cleanInline(content)}</strong>`);
|
||||
body = replaceCommand(body, "textbf", 1, ([content]) => `<strong>${cleanInline(content)}</strong>`);
|
||||
body = replaceCommand(body, "textcolor", 2, ([, content]) => cleanInline(content));
|
||||
|
||||
return body
|
||||
.replace(/\\chapter\*\{([^}]+)\}/g, "\n## $1\n")
|
||||
.replace(/\\chapter\{([^}]+)\}/g, "\n## $1\n")
|
||||
.replace(/\\section\*\{([^}]+)\}/g, "\n### $1\n")
|
||||
.replace(/\\section\{([^}]+)\}/g, "\n### $1\n")
|
||||
.replace(/\\subsection\{([^}]+)\}/g, "\n#### $1\n")
|
||||
.replace(/\\addcontentsline\{[^}]+\}\{[^}]+\}\{[^}]+\}/g, "")
|
||||
.replace(/\\appendix/g, "\n## Appendix\n")
|
||||
.replace(/\\begin\{itemize\}|\\end\{itemize\}/g, "")
|
||||
.replace(/\\begin\{enumerate\}(?:\[[^\]]+\])?|\\end\{enumerate\}/g, "")
|
||||
.replace(/^\s*\\item\s+/gm, "- ")
|
||||
.replace(/\\hypersetup\{[^}]+\}/g, "")
|
||||
.replace(/\\pagenumbering\{[^}]+\}/g, "")
|
||||
.replace(/\\clearpage|\\tableofcontents|\\listoftables/g, "")
|
||||
.replace(/\\noindent|\\centering|\\vfill|\\vspace\*?\{[^}]+\}/g, "")
|
||||
.replace(/\\begin\{[^}]+\}(?:\[[^\]]+\])?|\\end\{[^}]+\}/g, "")
|
||||
.replace(/\\LaTeX\{\}/g, "LaTeX")
|
||||
.replace(/\\_/g, "_")
|
||||
.replace(/\\%/g, "%")
|
||||
.replace(/\\\$/g, "$")
|
||||
.replace(/\\rightarrow/g, "\\to")
|
||||
.replace(/\\\\/g, "<br />")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
const source = fs.readFileSync(sourcePath, "utf8");
|
||||
const body = convertTex(source);
|
||||
const generatedAt = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const page = `---
|
||||
title: Integrated Investment Dossier
|
||||
description: "GameCult / Epiphany / Bifrost integrated diligence memo, product thesis, risk map, and staged proof plan."
|
||||
enableToc: true
|
||||
cssclasses:
|
||||
- integrated-dossier-page
|
||||
---
|
||||
|
||||
<section class="integrated-dossier-hero">
|
||||
<p class="integrated-dossier-kicker">GameCult / Epiphany / Bifrost</p>
|
||||
<h1>Integrated Investment Dossier</h1>
|
||||
<p>Diligence memo, product thesis, risk map, and staged proof plan for serious readers who need the machine without the fireworks.</p>
|
||||
<dl>
|
||||
<div><dt>Prepared for</dt><dd>Internal venture partner discussion</dd></div>
|
||||
<div><dt>Prepared on</dt><dd>May 30, 2026</dd></div>
|
||||
<div><dt>Status</dt><dd>Discussion draft, not legal or tax advice</dd></div>
|
||||
<div><dt>Source</dt><dd><code>docs/gamecult_integrated_dossier.tex</code></dd></div>
|
||||
</dl>
|
||||
<p class="integrated-dossier-source">Generated on ${generatedAt}.</p>
|
||||
</section>
|
||||
|
||||
<aside class="integrated-dossier-note integrated-dossier-lead">
|
||||
<strong>Core thesis.</strong> GameCult is an early, founder-led attempt to turn agent-assisted work into an inspectable production system. Epiphany addresses persistent agent context and execution; CultMesh addresses typed distributed state; Bifrost addresses work intake, governance, receipts, credit, and eventual reward flows. The investable question is not whether the vision is large. It is whether this stack can repeatedly produce accepted external work with lower coordination cost than a conventional team, agency, or enterprise workflow platform.
|
||||
</aside>
|
||||
|
||||
${body}
|
||||
`;
|
||||
|
||||
fs.writeFileSync(outputPath, page, "utf8");
|
||||
console.log(`Wrote ${path.relative(root, outputPath)}`);
|
||||
|
|
@ -1,239 +0,0 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = process.cwd();
|
||||
const dossierRoot = path.join(root, "docs", "portfolio-dossier");
|
||||
const outputPath = path.join(root, "GameCult", "Portfolio-Dossier.md");
|
||||
|
||||
const figureMap = {
|
||||
"figures/moonshot-trajectory.pdf": "/static/interactive/portfolio-pitch/figures/moonshot-trajectory.svg",
|
||||
"figures/value-orbit-map.pdf": "/static/interactive/portfolio-pitch/figures/value-orbit-map.svg",
|
||||
"figures/patronage-furnace.pdf": "/static/interactive/portfolio-pitch/figures/patronage-furnace.svg",
|
||||
"figures/surface-web-stack.pdf": "/static/interactive/portfolio-pitch/figures/surface-web-stack.svg",
|
||||
};
|
||||
|
||||
function readTex(relativePath) {
|
||||
return fs.readFileSync(path.join(dossierRoot, `${relativePath}.tex`), "utf8");
|
||||
}
|
||||
|
||||
function expandInputs(tex, currentDir = "") {
|
||||
return tex.replace(/\\input\{([^}]+)\}/g, (_, inputPath) => {
|
||||
if (inputPath === "preamble") return "";
|
||||
if (inputPath === "figures/surface-web-stack") return "\n\\SurfaceWebStack\n";
|
||||
if (inputPath === "figures/value-furnace") return "\n\\ValueFurnace\n";
|
||||
const relative = path.normalize(path.join(currentDir, inputPath)).replace(/\\/g, "/");
|
||||
const file = path.join(dossierRoot, `${relative}.tex`);
|
||||
if (!fs.existsSync(file)) return "";
|
||||
return `\n${expandInputs(fs.readFileSync(file, "utf8"), path.dirname(relative))}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
function readBraceArg(text, start) {
|
||||
while (text[start] && /\s/.test(text[start])) start += 1;
|
||||
if (text[start] !== "{") return null;
|
||||
let depth = 0;
|
||||
for (let index = start; index < text.length; index += 1) {
|
||||
const char = text[index];
|
||||
if (char === "\\" && text[index + 1]) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "{") depth += 1;
|
||||
if (char === "}") depth -= 1;
|
||||
if (depth === 0) {
|
||||
return {
|
||||
value: text.slice(start + 1, index),
|
||||
end: index + 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function replaceCommand(text, command, replacer) {
|
||||
let output = "";
|
||||
let cursor = 0;
|
||||
const token = `\\${command}`;
|
||||
while (cursor < text.length) {
|
||||
const index = text.indexOf(token, cursor);
|
||||
if (index === -1) {
|
||||
output += text.slice(cursor);
|
||||
break;
|
||||
}
|
||||
|
||||
output += text.slice(cursor, index);
|
||||
let next = index + token.length;
|
||||
const args = [];
|
||||
while (true) {
|
||||
const arg = readBraceArg(text, next);
|
||||
if (!arg) break;
|
||||
args.push(arg.value);
|
||||
next = arg.end;
|
||||
}
|
||||
output += replacer(args);
|
||||
cursor = next;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function stripInline(tex) {
|
||||
return tex
|
||||
.replace(/\\textbf\{([^{}]+)\}/g, "**$1**")
|
||||
.replace(/\\texttt\{([^{}]+)\}/g, "`$1`")
|
||||
.replace(/\\color\{[^}]+\}/g, "")
|
||||
.replace(/\\Large/g, "")
|
||||
.replace(/\\\\\[[^\]]*\]/g, "\n")
|
||||
.replace(/\\\\/g, "\n")
|
||||
.replace(/\\_/g, "_")
|
||||
.replace(/\\%/g, "%")
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function callout(label, body, kind = "orange") {
|
||||
return `<aside class="portfolio-dossier-callout ${kind}">\n<strong>${label}</strong>\n\n${stripInline(body)}\n</aside>`;
|
||||
}
|
||||
|
||||
function projectionTable(rows) {
|
||||
const body = rows
|
||||
.map(([label, value]) => `<tr><th>${label}</th><td>${stripInline(value)}</td></tr>`)
|
||||
.join("\n");
|
||||
return `<table class="portfolio-dossier-table">\n<tbody>\n${body}\n</tbody>\n</table>`;
|
||||
}
|
||||
|
||||
function figure(src, caption) {
|
||||
const image = figureMap[src] || src;
|
||||
return `<figure class="portfolio-dossier-figure">\n<img src="${image}" alt="${stripInline(caption)}" />\n<figcaption>${stripInline(caption)}</figcaption>\n</figure>`;
|
||||
}
|
||||
|
||||
function convertSpecialBlocks(tex) {
|
||||
return tex
|
||||
.replace(
|
||||
/\\begin\{center\}\s*\\fcolorbox\{voidorange\}\{voidpanel\}\{\\parbox\{[^}]+\}\{\\color\{voidtext\}\\Large\s*([\s\S]*?)\}\}\s*\\end\{center\}/g,
|
||||
(_, body) => callout("Public spell", body, "orange"),
|
||||
)
|
||||
.replace(
|
||||
/\\begin\{center\}\s*\\fcolorbox\{voidorange\}\{voidpanel\}\{\\parbox\{[^}]+\}\{\\color\{voidtext\}\s*([\s\S]*?)\}\}\s*\\end\{center\}/g,
|
||||
(_, body) => callout("Pipeline", body, "orange"),
|
||||
)
|
||||
.replace(
|
||||
/\\begin\{center\}\s*\\fcolorbox\{voidblue\}\{voidpanel\}\{\\parbox\{[^}]+\}\{\\color\{voidtext\}\s*([\s\S]*?)\}\}\s*\\end\{center\}/g,
|
||||
(_, body) => callout("Surface contract", body, "blue"),
|
||||
);
|
||||
}
|
||||
|
||||
function convertFigureTables(tex) {
|
||||
return tex
|
||||
.replace(
|
||||
/\\SurfaceWebStack/g,
|
||||
`<section class="portfolio-dossier-card">\n<h4>Eve Surface-Web Stack</h4>\n${projectionTable([
|
||||
["Provider", "Owns state, permissions, commands, and acceptance."],
|
||||
["CultUI Tree", "Declares semantic controls, bindings, layout intent, and provenance."],
|
||||
["Eve", "Projects one surface contract into web, desktop, mobile, game UI, and overlays."],
|
||||
["Renderer", "Chooses native affordances without stealing authority from the provider."],
|
||||
])}\n</section>`,
|
||||
)
|
||||
.replace(
|
||||
/\\ValueFurnace/g,
|
||||
`<section class="portfolio-dossier-card">\n<h4>Value Furnace</h4>\n${projectionTable([
|
||||
["Input", "Patronage, game spend, verified work, support, and risk."],
|
||||
["Normalization", "Project-local rules turn events into effective points."],
|
||||
["Governance", "Log-power voting keeps power legible without pretending all support is equal."],
|
||||
["Output", "Priority, reward pressure, credit, receipts, and scoped voting weight."],
|
||||
])}\n</section>`,
|
||||
);
|
||||
}
|
||||
|
||||
function convertTexToMarkdown(tex) {
|
||||
let text = tex
|
||||
.replace(/\\documentclass[\s\S]*?\\begin\{document\}/, "")
|
||||
.replace(/\\PortfolioTitlePage/g, "")
|
||||
.replace(/\\tableofcontents/g, "")
|
||||
.replace(/\\clearpage/g, "")
|
||||
.replace(/\\appendix/g, "\n## Appendix\n")
|
||||
.replace(/\\end\{document\}/g, "");
|
||||
|
||||
text = convertFigureTables(convertSpecialBlocks(text));
|
||||
|
||||
text = replaceCommand(text, "ProjectHeader", ([name, domain, thesis, room]) => {
|
||||
return `\n## ${stripInline(name)}\n\n<section class="portfolio-dossier-project-meta">\n${projectionTable([
|
||||
["Domain", domain],
|
||||
["Thesis", thesis],
|
||||
["Homepage Room", room],
|
||||
])}\n</section>\n`;
|
||||
});
|
||||
text = replaceCommand(text, "ProjectionBand", ([base, sharp, mythic, failure]) => {
|
||||
return `\n<section class="portfolio-dossier-card projection-band">\n<h4>Projection Band</h4>\n${projectionTable([
|
||||
["Base Case", base],
|
||||
["Sharp Case", sharp],
|
||||
["Mythic Case", mythic],
|
||||
["Failure Mode", failure],
|
||||
])}\n</section>\n`;
|
||||
});
|
||||
text = replaceCommand(text, "ValueLadder", ([survival, product, platform, worldWeb]) => {
|
||||
return `\n<section class="portfolio-dossier-card value-ladder">\n<h4>Value Projection Ladder</h4>\n${projectionTable([
|
||||
["Survival", survival],
|
||||
["Product", product],
|
||||
["Platform", platform],
|
||||
["World-Web", worldWeb],
|
||||
])}\n</section>\n`;
|
||||
});
|
||||
text = replaceCommand(text, "MoonGraphic", ([, src, caption]) => `\n${figure(src, caption)}\n`);
|
||||
text = replaceCommand(text, "Verdict", ([body]) => `\n${callout("Void says", body, "orange")}\n`);
|
||||
text = replaceCommand(text, "Term", ([body]) => `<span class="portfolio-term">${stripInline(body)}</span>`);
|
||||
text = replaceCommand(text, "Signal", ([body]) => `<span class="portfolio-signal">${stripInline(body)}</span>`);
|
||||
|
||||
text = text
|
||||
.replace(/\\chapter\{([^}]+)\}/g, "\n## $1\n")
|
||||
.replace(/\\section\{([^}]+)\}/g, "\n### $1\n")
|
||||
.replace(/\\subsection\{([^}]+)\}/g, "\n#### $1\n")
|
||||
.replace(/\\begin\{itemize\}/g, "")
|
||||
.replace(/\\end\{itemize\}/g, "")
|
||||
.replace(/^\s*\\item\s+/gm, "- ")
|
||||
.replace(/\\begin\{center\}|\\end\{center\}/g, "")
|
||||
.replace(/\\begin\{tabularx\}[\s\S]*?\\end\{tabularx\}/g, "")
|
||||
.replace(/\\begin\{figure\}[\s\S]*?\\end\{figure\}/g, "")
|
||||
.replace(/\\noindent/g, "")
|
||||
.replace(/\\vspace\{[^}]+\}/g, "")
|
||||
.replace(/\\href\{([^}]+)\}\{([^}]+)\}/g, "[$2]($1)")
|
||||
.replace(/\\\[/g, () => "$$")
|
||||
.replace(/\\\]/g, () => "$$")
|
||||
.replace(/\\rightarrow/g, "\\to")
|
||||
.replace(/\\max/g, "\\max")
|
||||
.replace(/\\_/g, "_")
|
||||
.replace(/\\%/g, "%")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
const expanded = expandInputs(readTex("main"));
|
||||
const body = convertTexToMarkdown(expanded);
|
||||
const generatedAt = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const page = `---
|
||||
title: Portfolio Dossier
|
||||
description: "GameCult's over-the-top TeX portfolio dossier rendered as a dark-mode web page with Montserrat and Ubuntu branding."
|
||||
enableToc: true
|
||||
cssclasses:
|
||||
- portfolio-dossier-page
|
||||
---
|
||||
|
||||
<section class="portfolio-dossier-hero">
|
||||
<p class="portfolio-dossier-kicker">GameCult / Void Dossier</p>
|
||||
<h1>Portfolio Dossier</h1>
|
||||
<p>Eve, Bifrost, CultMesh, Sai, and the surface-web portfolio. Same TeX source, rendered for humans who would rather not receive a white PDF brick to the face.</p>
|
||||
<div class="portfolio-dossier-actions">
|
||||
<a href="/static/interactive/portfolio-pitch/figures/moonshot-trajectory.svg">Moonshot graphic</a>
|
||||
<a href="/Pitch">Play the VN pitch</a>
|
||||
<a href="https://github.com/GameCult">GameCult GitHub</a>
|
||||
</div>
|
||||
<p class="portfolio-dossier-source">Generated from <code>docs/portfolio-dossier/main.tex</code> on ${generatedAt}.</p>
|
||||
</section>
|
||||
|
||||
${body}
|
||||
`;
|
||||
|
||||
fs.writeFileSync(outputPath, page, "utf8");
|
||||
console.log(`Wrote ${path.relative(root, outputPath)}`);
|
||||
Loading…
Reference in a new issue