// 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) =>
`
',
].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 ``;
}
/**
* 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 [
'
',
].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 = ['
'].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);
}
},
};
}