// Build-time markup for the Tool shed page: its Made here list hung on a pegboard, its Borrowed list as loan cases on shelves. import fs from 'node:fs'; import path from 'node:path'; import { ITEM, text, trimStop, hash, findList, address } from './list.mjs'; import { sprigMarkup } from '../../notes-src/stack/folio.mjs'; import { noteUrl, PAGES } from '../../notes-src/stack/routes.mjs'; /** * @typedef {{ name: string, source: string, line: string, atWork: string }} Tool a parsed Made here item * @typedef {{ name: string, href: string, line: string }} Borrowed a parsed Borrowed item * @typedef {Tool & { url: string, text: string, needs: string[] }} ShedTool a Made here tool with its checked file */ // A source path is a file in the repo: relative, no scheme. const REPO_PATH = /^(?![a-z][a-z0-9+.-]*:|\/)[\w./-]+$/i; /** Text made safe for a double-quoted attribute. */ const attr = (s) => s.replace(/&/g, '&').replace(/"/g, '"'); // PLACEHOLDER ART. The tool silhouettes hung on the pegboard were drawn in code as stand-ins, to be // replaced by hand-drawn or commissioned riso art (KO-67). Each is a Sunflower plate under a Hunter // Green plate, as the sprigs are, and each tag wears the garden's sprout sprig; swap the paths here, // the CSS hooks are `.peg-tool` and `.hang-tag .sprig` in notes.css. const SILHOUETTES = [ // trowel '', // hand fork '', // dibber '', ]; /** The draft stage's sprout sprig tucked on a tag; decorative, so hidden from screen readers. */ const SPRIG = sprigMarkup('draft'); /** A tool's silhouette, hung from its peg; decorative, so hidden from screen readers. */ function silhouette(name) { const art = SILHOUETTES[hash(text(name)) % SILHOUETTES.length]; return ``; } /** A tag's note on what its file needs: "Stands alone", or "Needs: …" naming each import. */ const needsNote = (needs) => `

${needs.length ? `Needs: ${needs.map((n) => `${n}`).join(', ')}` : 'Stands alone'}

`; /** * One tool hung on the pegboard: its silhouette on a peg, and the tag tied below it. * @param {ShedTool} t */ function hungTool(t) { return [ '
  • ', silhouette(t.name), '
    ', SPRIG, `

    ${t.name}

    `, t.line ? `

    ${t.line}

    ` : '', t.atWork ? `

    At work ${t.atWork}

    ` : '', needsNote(t.needs), `

    Take it

    `, '
    ', '
  • ', ].filter(Boolean).join('\n'); } // PLACEHOLDER ART, as above: the power tools the borrowed cases carry, drawn on a 120 × 80 grid, one per // case in turn. The CSS hooks are `.cases`, `.power-tool`, `.plank` and `.bracket` in notes.css. const POWER_TOOLS = [ // drill '', // circular saw '', // router '', // sander '', // jigsaw '', // glue gun '', ]; const HEART = ''; const STICKER_SPRIGS = ['sprout-g', 'bloom-g', 'bud-g']; // A shelf bracket in profile: a cut-wood gusset with a curved brace and a round cut-out, screwed to the wall. const GUSSET = 'M2 0H46V5C37 6 29 11 23.5 18.5C18 26 14.5 37 12.5 49.5L11.5 55H2Z M15 12.5a4.6 4.6 0 1 0 .01 0Z'; const bracket = (side) => [ `', ].join(''); /** The power tool carried in the i-th case, on the two riso plates; decorative, so hidden from screen readers. */ function powerTool(i) { const art = POWER_TOOLS[i % POWER_TOOLS.length]; return `${art}${art}`; } /** * One borrowed tool in the carry case it was lent in, standing on a plank shelf: the tool on a badge, * a sticker or two, and a luggage tag tied to the handle saying who lent it, its name linked to its maker. * @param {Borrowed} b * @param {number} i its place on the shelves, which picks its tool and stickers */ function loanCase(b, i) { const lender = address(b.href); return [ '
  • ', '
    ', '
    ', ``, '', ``, '
    ', lender ? `On loan from ${lender}` : '', `

    ${b.name}

    `, `

    ${b.line}

    `, '
    ', '
    ', '
    ', ``, '
  • ', ].filter(Boolean).join('\n'); } /** * Redraw the shed's two lists: Made here as `tools` hung on a pegboard, Borrowed as loan cases on shelves. * Leaves the body alone, to read as prose, if either list is missing or a Borrowed item doesn't parse. * @param {string} html the rendered shed body * @param {ShedTool[]} tools its Made here list, from `shedSources` */ export function shedWall(html, tools) { const made = findList(html ?? '', 'Made here'); const borrowed = findList(html ?? '', 'Borrowed'); const onShelf = borrowed?.items.map(parseBorrowed) ?? []; if (!made || !borrowed || !tools.length || !onShelf.length || onShelf.some((b) => !b)) return html; const board = ['
      ', ...tools.map(hungTool), '
    '].join('\n'); const cases = ['
      ', ...onShelf.map((b, i) => loanCase(/** @type {Borrowed} */ (b), i)), '
    '].join('\n'); // Splice the later list first, so the earlier one's offsets still hold. const parts = [{ ...made, markup: board }, { ...borrowed, markup: cases }].sort((a, b) => b.start - a.start); return parts.reduce((out, p) => out.slice(0, p.start) + p.markup + out.slice(p.end), html); } /** Where the Tool shed publishes its Made here source files. */ export const SOURCE_DIR_URL = `${noteUrl(PAGES.toolShed)}src/`; /** The URL a Made here source file is published at: "scripts/notes/plat.mjs" → "/notes/tool-shed/src/plat.mjs". */ export const sourceUrl = (path) => `${SOURCE_DIR_URL}${path.split('/').pop()}`; /** * Parse one rendered Borrowed item, `Name: what it's used for.` * @returns {Borrowed | null} */ export function parseBorrowed(li) { const m = li.trim().match(ITEM); if (!m || !m[3].trim()) return null; const [, href, name, line] = m; return { name, href, line: line.trim() }; } /** * Parse one rendered Made here item, `Name: what it does. At work: page.` * @returns {Tool | null} */ export function parseTool(li) { const m = li.trim().match(ITEM); if (!m || !REPO_PATH.test(m[1])) return null; const [, source, name, rest] = m; const [line, atWork = ''] = rest.split(/At work:/); return { name, source, line: line.trim(), atWork: trimStop(atWork) }; } // The only repo directories the shed publishes from: the notes build scripts and the browser stack modules. const SOURCE_DIRS = ['scripts/notes/', 'notes-src/stack/']; const IMPORT = /^\s*import\s+(?:[^'"]*?\s+from\s+)?['"]([^'"]+)['"]/gm; /** What a file imports, one name per `import` line: a relative module by file name, a package or built-in as written. */ const needsOf = (src) => [...src.matchAll(IMPORT)].map(([, spec]) => (spec.startsWith('.') ? spec.split('/').pop() : spec)); /** * The shed's Made here tools, each with its file checked: a repo path under SOURCE_DIRS that exists, * published at a URL no other tool's file takes. Throws, naming the item, when one isn't, or when an * item doesn't parse, since the pegboard would otherwise lose a tool or link to nothing. [] with no list. * @param {string} html the rendered shed body * @param {(path: string) => string | null} read a repo file's text, or null when there's no such file * @returns {ShedTool[]} */ export function shedSources(html, read) { const byUrl = new Map(); return (findList(html ?? '', 'Made here')?.items ?? []).map((li) => { const t = parseTool(li); if (!t) throw new Error(`Tool shed: can't read the Made here item "${text(li)}"; write it as [Name](repo path): what it does.`); const label = `Tool shed: "${text(t.name)}" (${t.source})`; if (t.source.split('/').includes('..') || !SOURCE_DIRS.some((dir) => t.source.startsWith(dir))) { throw new Error(`${label} is outside the directories the shed publishes from: ${SOURCE_DIRS.join(', ')}.`); } const src = read(t.source); if (src == null) throw new Error(`${label} is not a file in the repo.`); const url = sourceUrl(t.source); if (byUrl.has(url)) throw new Error(`${label} would be published at ${url}, as "${text(byUrl.get(url))}" already is; rename one file.`); byUrl.set(url, t.name); return { ...t, url, text: src, needs: needsOf(src) }; }); } /** * The shed as one build reads it: its Made here tools, from the synced shed rendered as its page is, and * their files, published so every Take it link has one. Reads the shed the first time `tools` or `publish` * asks, and again only after `reset`, which the build calls as each build starts, full or incremental, so * no build reuses an earlier one's reading. A bad Made here item or source path throws, naming the item. * @param {object} opts * @param {string} opts.source the synced shed's file; no file, no tools * @param {(file: string) => string} opts.render the file's body as its page renders it * @param {(path: string) => string | null} opts.read a repo file's text, or null when there's no such file */ export function shedReader({ source, render, read }) { /** @type {ShedTool[] | undefined} */ let shed; const tools = () => (shed ??= fs.existsSync(source) ? shedSources(render(fs.readFileSync(source, 'utf8')), read) : []); return { reset() { shed = undefined; }, tools, /** Publish exactly the tools' files under `outDir`, at their sourceUrl, clearing any an earlier build left. */ publish(outDir) { const files = tools(); fs.rmSync(path.join(outDir, SOURCE_DIR_URL), { recursive: true, force: true }); for (const f of files) { const dest = path.join(outDir, f.url); fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.writeFileSync(dest, f.text); } }, }; }