UNPKG

teeny-site

Version:

A very simple static site generator

232 lines (201 loc) 8.01 kB
#!/usr/bin/env node import { JSDOM } from 'jsdom' import fs from 'fs-extra' import { marked } from 'marked' import http from 'http' import chokidar from 'chokidar' import fm from 'front-matter' import path from 'path' let { version } = JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url))) const templateUsageMap = new Map() // key = templatePath, value = Set of pagePaths const pageUsageMap = new Map() // key = pagePath, value = templatePath const scriptArgs = process.argv.slice(2) const command = scriptArgs[0] console.log('Teeny-Site ' + version) switch (command) { case 'build': build() break case 'develop': develop(scriptArgs[1] ? Number(scriptArgs[1]) : 8000) break case 'init': init() break default: console.log(`Command 'teeny ${command}' does not exist.`) process.exit(1) } async function build() { await fs.emptyDir('public/') await safeExecute( async () => await fs.copy('templates/', 'public/', { filter: (src, dest) => isNotHiddenFile(src) && !src.endsWith('.html'), }) ) await safeExecute( async () => await fs.copy('pages/', 'public/', { filter: (src, dest) => isNotHiddenFile(src) && !src.endsWith('.md'), }) ) await safeExecute( async () => await fs.copy('static/', 'public/', { filter: (src, dest) => isNotHiddenFile(src), }) ) await processDirectory('pages') } async function processDirectory(directoryPath) { let contents = await fs.readdir(`${directoryPath}/`) const processPagePromises = [] for (const element of contents) { const isDirectory = (await fs.lstat(`${directoryPath}/${element}`)).isDirectory() if (isDirectory) { await processDirectory(`${directoryPath}/${element}`, processPagePromises) continue } else if (!element.endsWith('.md')) { continue } processPagePromises.push(processPage(`${directoryPath}/${element}`)) } await Promise.all(processPagePromises) } async function develop(port) { await build() const server = startServer(port) const watcher = chokidar.watch(['pages/', 'static/', 'templates/']).on('change', async (changed_file_path, _) => { changed_file_path = changed_file_path.split(path.sep).join(path.posix.sep) console.log(`Detected change in file ${changed_file_path}.`) if ( changed_file_path.startsWith('static') || (changed_file_path.startsWith('templates') && !changed_file_path.endsWith('.html')) || (changed_file_path.startsWith('pages') && !changed_file_path.endsWith('.md')) ) { await safeExecute( async () => await fs.copy( changed_file_path, `public/${changed_file_path.substring(changed_file_path.split('/')[0].length + 1)}`, { filter: (src, dest) => isNotHiddenFile(src), } ) ) } else if (changed_file_path.startsWith('pages')) { processPage(changed_file_path) } else if (templateUsageMap.has(changed_file_path)) { templateUsageMap.get(changed_file_path).forEach((element) => { processPage(element) }) } }) } async function init() { const examplePage = `---\ntemplate: homepage\ntitle: Teeny page\nauthor: teeny\n---\n\n# Hello World\n` const exampleTemplate = `<html>\n <head>\n <title>{{ title }}</title>\n <meta name="author" content="{{ author }}" />\n </head>\n\n <body>\n <p>My first Teeny page</p>\n <div id="page-content"></div>\n <script type="text/javascript" src="main.js"></script>\n </body>\n</html>\n` const defaultTemplate = `<html>\n <body>\n <div id="page-content"></div>\n </body>\n</html>\n` const exampleStaticAssetJs = `console.log('hello world')\n` writeToFileIfNotExists('pages/index.md', examplePage) writeToFileIfNotExists('templates/homepage.html', exampleTemplate) writeToFileIfNotExists('templates/default.html', defaultTemplate) writeToFileIfNotExists('static/main.js', exampleStaticAssetJs) } async function processPage(pagePath) { let templatePath = 'templates/default.html' const fileData = await fs.readFile(pagePath, 'utf-8') const { attributes: frontmatter, body: markdown } = await fm(fileData) if (frontmatter.template) { templatePath = `templates/${frontmatter.template}.html` } if (pageUsageMap.has(pagePath)) { templateUsageMap.get(pageUsageMap.get(pagePath)).delete(pagePath) } if (templateUsageMap.has(templatePath)) { templateUsageMap.get(templatePath).add(pagePath) } else { templateUsageMap.set(templatePath, new Set([pagePath])) } pageUsageMap.set(pagePath, templatePath) let templateString = await fs.readFile(templatePath, 'utf-8') for (const key in frontmatter) { templateString = templateString.replaceAll(`{{ ${key} }}`, frontmatter[key]) } const dom = new JSDOM(templateString) const parsedHtml = marked.parse(markdown) const document = dom.window.document const pageContentElement = document.getElementById('page-content') if (pageContentElement) { pageContentElement.innerHTML = parsedHtml } else { console.log( `Could not find element with id 'page-content' in template ${templatePath}. Generating page without markdown content.` ) } const wrapperHtmlElement = document.getElementsByTagName('html') if (!wrapperHtmlElement.length) { console.log(`Templates should contain the 'html' tag.`) process.exit(1) } if (!document.title || document.title == `{{ title }}`) { if (!frontmatter.title) { const h1s = document.getElementsByTagName('h1') if (h1s.length) { document.title = h1s[0].innerHTML } } else { document.title = frontmatter.title } } const finalHtml = document.getElementsByTagName('html')[0].outerHTML const pagePathParts = pagePath.replace('pages/', '').split('/') const pageName = pagePathParts.pop().split('.md')[0] const targetPath = pagePathParts.join('/') await fs.outputFile(`public/${targetPath}/${pageName}.html`, finalHtml) console.log(`Build public/${targetPath}/${pageName}.html`) } function startServer(port) { console.log(`Development server starting on http://localhost:${port}`) return http .createServer(function (req, res) { const url = req.url let filePath = url if (url === '/') { filePath = '/index.html' } else if (!url.includes('.')) { filePath += '.html' } fs.readFile('public' + filePath, function (err, data) { if (err) { try { res.writeHead(404) let data = fs.readFileSync('public/404.html') res.end(data) } catch (err) { res.end('<h1>404: Page not found</h1>') } } else { res.writeHead(200) res.end(data) } }) }) .listen(port) } async function safeExecute(func) { try { await func() } catch {} } async function writeToFileIfNotExists(path, data) { try { await fs.outputFile(path, data, { flag: 'wx' }) } catch { console.log(path + ' was not created because it already exists') } } function isNotHiddenFile(src) { return !src.match(/.+[\/\\]\..*/) }