From 05c575bb266766119b6ebb4b029734af38f4f08e Mon Sep 17 00:00:00 2001 From: Joshua Bemenderfer Date: Tue, 22 Sep 2020 20:42:46 -0400 Subject: [PATCH] Initial commit. --- cache.js | 19 ++++++++++ components/Footer.js | 47 ++++++++++++++++++++++++ components/Header.js | 24 ++++++++++++ components/NavBar.js | 58 +++++++++++++++++++++++++++++ index.js | 51 ++++++++++++++++++++++++++ package.json | 6 +++ pages/About.js | 42 +++++++++++++++++++++ pages/Home.js | 36 ++++++++++++++++++ pages/Page.js | 29 +++++++++++++++ styles/base.js | 58 +++++++++++++++++++++++++++++ templater.js | 87 ++++++++++++++++++++++++++++++++++++++++++++ 11 files changed, 457 insertions(+) create mode 100644 cache.js create mode 100644 components/Footer.js create mode 100644 components/Header.js create mode 100644 components/NavBar.js create mode 100644 index.js create mode 100644 package.json create mode 100644 pages/About.js create mode 100644 pages/Home.js create mode 100644 pages/Page.js create mode 100644 styles/base.js create mode 100644 templater.js diff --git a/cache.js b/cache.js new file mode 100644 index 0000000..bcffa49 --- /dev/null +++ b/cache.js @@ -0,0 +1,19 @@ +import crypto from 'crypto' + +export const cache = {} + +export function hash(key) { + const hash = crypto.createHash('sha256') + .update(JSON.stringify(key)) + .digest('hex') + + return hash +} + +export function toCache(hash, data) { + cache[hash] = data +} + +export function fromCache(hash) { + return cache[hash] || null +} diff --git a/components/Footer.js b/components/Footer.js new file mode 100644 index 0000000..b3fa15c --- /dev/null +++ b/components/Footer.js @@ -0,0 +1,47 @@ +import { html, css, sanitize } from '../templater.js' +import base from '../styles/base.js' + +const body = async (ctx, { path, title }) => html` + +` + +const styles = css` + footer { + display: flex; + flex-wrap: wrap; + } + + footer > * { + flex: 1; + flex-basis: 30rem; + margin: 2rem; + margin-bottom: 0; + padding-bottom: 2rem; + border-bottom: 1px solid rgba(0, 0, 0, 0.1); + } + + footer dt { + font-weight: bolder; + } + + footer dd { + margin-left: 1rem; + } +` + +export async function Footer (ctx, { path, title }) { + ctx.css.add(styles) + ctx.body = await body(ctx, { path, title }) + + return ctx +} diff --git a/components/Header.js b/components/Header.js new file mode 100644 index 0000000..7c3bfd6 --- /dev/null +++ b/components/Header.js @@ -0,0 +1,24 @@ +import { html, css, sanitize } from '../templater.js' +import { NavBar } from '../components/NavBar.js' +import base from '../styles/base.js' + +const body = async (ctx, { path, title }) => html` +
+

Title: ${sanitize(title)}

+ ${await ctx.b(NavBar, { path })} +
+` + +const styles = css` + header { + display: flex; + align-items: center; + } +` + +export async function Header (ctx, { path, title }) { + ctx.css.add(styles) + ctx.body = await body(ctx, { path, title }) + + return ctx +} diff --git a/components/NavBar.js b/components/NavBar.js new file mode 100644 index 0000000..2875c21 --- /dev/null +++ b/components/NavBar.js @@ -0,0 +1,58 @@ +import { html, css } from '../templater.js' + +const body = (path) => html` + +` + +const styles = css` + nav ul { + list-style: none; + display: flex; + align-items: center; + } + + nav li { + padding: 1rem 2rem; + } + + nav a { + display: block; + position: relative; + padding: .5rem 1rem; + color: var(--color-body); + text-decoration: none; + } + + nav a:before { + content: ' '; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.3); + border-radius: 1rem; + transition: transform 200ms; + transform: scale(0); + z-index: -1; + } + + nav a:hover:before, nav .active a:before { + transform: scale(1); + } + + nav .active a { + color: var(--color-headings); + } +` + +export function NavBar (ctx, { path }) { + ctx.css.add(styles) + ctx.body = body(path) + return ctx +} diff --git a/index.js b/index.js new file mode 100644 index 0000000..ade916c --- /dev/null +++ b/index.js @@ -0,0 +1,51 @@ +import http from 'http' +import path from 'path' +import { page } from './pages/Page.js' +import { HomePage } from './pages/Home.js' +import { AboutPage } from './pages/About.js' + +const SERVER_PORT = 8080 + +function main() { + const server = http.createServer(async (req, res) => { + const url = new URL(req.url, `https://noop.local`) + const params = url.searchParams + + // Ignore requests to files with extensions. + if (path.extname(url.pathname)) { + res.writeHead(404) + res.end() + return + } + + const title = params.get('title') || `${url.pathname}?title=YOUR_TITLE` + + + const pageMap = { + '/': HomePage, + '/about': AboutPage + } + + const selectedPage = pageMap[url.pathname] || pageMap['/'] + + try { + const output = await page(selectedPage, { + path: url.pathname, + title + }) + + res.write(output) + } catch (e) { + console.error(e) + res.writeHead(500) + } + + res.end() + }) + + server.listen(SERVER_PORT, () => { + console.info(`Now listening for requests on ${SERVER_PORT}.`) + }) +} + +main() diff --git a/package.json b/package.json new file mode 100644 index 0000000..12ecd50 --- /dev/null +++ b/package.json @@ -0,0 +1,6 @@ +{ + "type": "module", + "scripts": { + "start": "node ./index.js" + } +} diff --git a/pages/About.js b/pages/About.js new file mode 100644 index 0000000..176d7e9 --- /dev/null +++ b/pages/About.js @@ -0,0 +1,42 @@ +import { html, css, sanitize } from '../templater.js' +import { Header } from '../components/Header.js' +import { Footer } from '../components/Footer.js' +import base from '../styles/base.js' + +const head = ({ title }) => html` + About Page - ${sanitize(title)} +` + +const body = async (ctx, { path, title }) => html` + ${await ctx.b(Header, { path, title })} +
+
+

About Page - ${sanitize(title)}

+

+ Veniam quis commodo ad Lorem do proident et elit labore amet incididunt et culpa. Non ex in deserunt veniam minim pariatur incididunt consectetur. Aliqua occaecat irure eu est. +

+
    +
  • Anim reprehenderit enim amet eu id esse duis exercitation nulla fugiat sunt esse exercitation.
  • +
  • In non ex esse et fugiat excepteur aliquip ea.
  • +
  • Reprehenderit aliqua aute cupidatat enim ipsum eu.
  • +
  • Consectetur exercitation exercitation excepteur quis in qui qui magna cillum sint.
  • +
+
+
+ ${await ctx.b(Footer, { path, title })} +` + +const styles = css` + main { + background-color: #fff; + } +` + +export async function AboutPage (ctx, { path, title }) { + ctx.css.add(base) + ctx.css.add(styles) + ctx.head.add(head({ title })) + ctx.body = await body(ctx, { path, title }) + + return ctx +} diff --git a/pages/Home.js b/pages/Home.js new file mode 100644 index 0000000..fdce832 --- /dev/null +++ b/pages/Home.js @@ -0,0 +1,36 @@ +import { html, css, sanitize } from '../templater.js' +import { Header } from '../components/Header.js' +import { Footer } from '../components/Footer.js' +import base from '../styles/base.js' + +const head = ({ title }) => html` + Home Page - ${sanitize(title)} +` + +const body = async (ctx, { path, title }) => html` + ${await ctx.b(Header, { path, title })} +
+
+

Home Page - ${sanitize(title)}

+

+ Lorem ex excepteur occaecat et labore veniam veniam duis pariatur laboris. Aute incididunt anim laborum cupidatat mollit amet pariatur. Consectetur nostrud eu amet eu cillum duis ipsum qui ad magna nisi. Ad aliquip est fugiat dolor ad deserunt. Dolore excepteur enim ullamco duis irure elit occaecat magna mollit culpa elit amet elit minim. Fugiat nostrud sunt consequat labore dolore sunt minim nisi deserunt minim sint consequat. Cupidatat adipisicing sit ullamco proident proident id esse mollit. +

+
+
+ ${await ctx.b(Footer, { path, title })} +` + +const styles = css` + main { + background-color: #eee; + } +` + +export async function HomePage (ctx, { path, title }) { + ctx.css.add(base) + ctx.css.add(styles) + ctx.head.add(head({ title })) + ctx.body = await body(ctx, { path, title }) + + return ctx +} diff --git a/pages/Page.js b/pages/Page.js new file mode 100644 index 0000000..bd40e5f --- /dev/null +++ b/pages/Page.js @@ -0,0 +1,29 @@ +import { html, render } from '../templater.js' + +async function Root (ctx, component, ...params) { + const result = await ctx.render(component, ...params) + const scripts = Array.from(result.js).join(';\n') + const css = Array.from(result.css).join('\n') + + return html` + + + + + + ${Array.from(result.head).join('\n')} + + + + ${result.body} + ${scripts ? `` : ''} + + + ` +} + +export function page(component, ...params) { + return render(Root, component, ...params) +} diff --git a/styles/base.js b/styles/base.js new file mode 100644 index 0000000..4e24e5b --- /dev/null +++ b/styles/base.js @@ -0,0 +1,58 @@ +import { css } from '../templater.js' + +export default css` +:root { + --color-bg: #d3b57e; + --color-headings: #522c05; + --color-body: #664405; + --base-font-size: 2rem; + --font-family: system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji" +} + +* { + margin: 0; + padding: 0; + font-size: 1em; + font-family: inherit; + box-sizing: border-box; +} + +html { + font-size: 62.5%; +} + +body { + color: var(--color-body); + background: var(--color-bg); + font-size: var(--base-font-size); + font-family: var(--font-family); +} + +p { + font-size: var(--base-font-size); +} + +h1 { + font-size: calc(var(--base-font-size) * 3); +} + +h2 { + font-size: calc(var(--base-font-size) * 2); +} + +h1, h2, h3, h4, h5, h6 { + font-weight: 300; + color: var(--color-headings); +} + +section, header, footer { + padding: 1rem; + max-width: 900px; + margin: auto; +} + +section ul { + margin-top: 1rem; + margin-left: 3rem; +} +` diff --git a/templater.js b/templater.js new file mode 100644 index 0000000..470d0e5 --- /dev/null +++ b/templater.js @@ -0,0 +1,87 @@ +import { hash, fromCache, toCache } from './cache.js' + +// Tagged template literal helpers. Operates in the same fashion as un-tagged template literals. +function tmpl(strings, ...values) { + return strings + .map((str, index) => values[index] ? str + values[index] : str) + .join('').trim() +} + +// Used for syntax highlighting in supported editors. +export const html = tmpl +export const css = tmpl +export const javascript = tmpl + +// Stupid-simple "sanitization". Just enough for this demo, not for production. +export function sanitize(str) { + if (typeof str !== 'string') return str + return str.split('<').join('<') +} + +// Shorthand to render a component and retrieve the body. +async function b(component, ...params) { + return (await this.render(component, ...params)).body +} + +// Shorthand to render a component and retrieve the head. +async function h(component, ...params) { + return (await this.render(component, ...params)).head +} + +// Shorthand to render a component and retrieve the css. +async function c(component, ...params) { + return (await this.render(component, ...params)).css +} + +// Shorthand to render a component and retrieve the js. +async function j(component, ...params) { + return (await this.render(component, ...params)).js +} + +/** + * Render a component and return the head elements, css, js, and body contents of it and its children. + * @param {Function: context} component A function + * @param {...any} params Arguments to pass to the component being rendered + * @returns {Object} + * An object containing head elements, css, js, and body text returned by the rendered component. + */ +export async function render(component, ...params) { + // Create a new context to pass to a component for each rendering. + const ctx = { + head: new Set(), + css: new Set(), + js: new Set(), + body: null, + } + // Add render functions to the context object, bound to the current context. + // This ensures any calls to render child components will have the parent context bound as "this", allowing copying + // child render results into parent context without having to pass the parent context explicitly to every child component. + ctx.render = render.bind(ctx) + ctx.b = b.bind(ctx) + ctx.h = h.bind(ctx) + ctx.c = c.bind(ctx) + ctx.j = j.bind(ctx) + + // Attempt to load result from cache by component name and params. + const key = hash([component.name, params]) + const stored = fromCache(key) + if (stored) { + console.info(`READING COMPONENT "${component.name}" FROM CACHE`) + } + + // Render component or load from cache. + const result = stored || await component(ctx, ...params) + // Store result to cache if it wasn't already present. + if (!stored) toCache(key, result) + + // Results with a parent context will copy child context fields into parent context. + // This enables straightforward component-level caching of body, head, css, and js at the cost of higher memory usage. + if (this) { + result.head.forEach(r => this.head.add(r)) + result.css.forEach(r => this.css.add(r)) + result.js.forEach(r => this.js.add(r)) + } + + // Return result context object to caller. + return result +}