Public: anyone can read this; the crew can change it.
A `Makefile` opens as its targets: each target with its prerequisites and recipe, phony ones marked #11218
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11209-a-makefile-opens-as-its-targets-each-tar → main 6 commits fork 2 threads · 2 unresolved
7 files
changed +315
−1
docs/features/file-makefile.md added +14
| @@ -0,0 +1,14 @@ | ||
| 1 | # A `Makefile` opens as its targets | |
| 2 | ||
| 3 | A file named `Makefile`, `GNUmakefile` or `*.mk` opens as what it declares instead of raw text. Source is one tap away (`?view=source`). | |
| 4 | ||
| 5 | - **Variables** come first, in one table: name, assignment operator (`=`, `:=`, `?=`, `+=`, `!=`), value. | |
| 6 | - **Targets** follow, one row each: the target name, its prerequisites, its recipe (mono, one line per command). Order-only prerequisites (after `|`) sit under the prerequisites. A `::` rule and a `target: ; cmd` inline recipe read the same way. | |
| 7 | - **Phony:** a target named in `.PHONY` carries a `phony` badge, wherever the declaration sits in the file. The `.PHONY` line itself is **not** repeated as a row (the badges already say it); a name listed there with no rule of its own shows nowhere. | |
| 8 | - **Comments** stay in place as muted notes. A trailing backslash joins the next line. | |
| 9 | - **Not understood** (`include`, `ifeq`/`endif`, a `define` block, a target-specific variable, a recipe with no target) stays as source text where it was; nothing else is dropped except blank lines. | |
| 10 | - **Cap:** at most 500 items, then `N more not shown`. Files over 1 MB keep Source. | |
| 11 | - **At 360 px** each table sits in a `table-scroll` region (the existing wrapper, focusable): the table scrolls sideways inside it and the page never does. Recipe cells wrap (`.table td pre`), so most recipes need no scrolling. I chose scroll over stacking because it keeps the three columns aligned for comparing targets, and adds no CSS. | |
| 12 | - Every value is escaped and the page is drawn on the server. No script, no new CSS. | |
| 13 | ||
| 14 | Code: `src/web/render/makefile.ts`, `src/web/render/kinds.ts`. Tests: `test/render-makefile.test.ts`. |
src/web/render/kinds.ts +6
| @@ -42,6 +42,7 @@ export type FileKind = | ||
| 42 | 42 | | "junit" |
| 43 | 43 | | "env" |
| 44 | 44 | | "openapi" |
| 45 | | "makefile" | |
| 45 | 46 | | "dockerfile" |
| 46 | 47 | | "sql" |
| 47 | 48 | | "text" |
| @@ -84,6 +85,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = { | ||
| 84 | 85 | junit: 5 * MB, |
| 85 | 86 | env: 1 * MB, |
| 86 | 87 | openapi: 1 * MB, |
| 88 | makefile: 1 * MB, | |
| 87 | 89 | dockerfile: 1 * MB, |
| 88 | 90 | sql: 1 * MB, |
| 89 | 91 | text: 1 * MB, |
| @@ -219,6 +221,8 @@ export const isEnvExample = (path: string): boolean => | ||
| 219 | 221 | /** `openapi.json|yaml|yml` and `*.openapi.*`: the name of an OpenAPI document; its content decides whether it is drawn as one. */ |
| 220 | 222 | const isOpenApiName = (path: string): boolean => /(^|\/|\.)openapi\.(json|ya?ml)$/i.test(path); |
| 221 | 223 | |
| 224 | /** `Makefile`, `GNUmakefile` and `*.mk`, by name. */ | |
| 225 | const isMakefileName = (path: string): boolean => /(^|\/)(makefile|gnumakefile|[^/]+\.mk)$/i.test(path); | |
| 222 | 226 | /** `Dockerfile`, `Containerfile` and `*.Dockerfile`, in any case. */ |
| 223 | 227 | const isDockerfileName = (path: string): boolean => /(^|\/)(Dockerfile|Containerfile|[^/]+\.Dockerfile)$/i.test(path); |
| 224 | 228 | |
| @@ -294,6 +298,7 @@ export function fileKind(path: string, head: Uint8Array): FileKind { | ||
| 294 | 298 | if (isEnvExample(path)) return "env"; |
| 295 | 299 | if (isJunit(path, head)) return "junit"; |
| 296 | 300 | if (isOpenApiName(path)) return "openapi"; |
| 301 | if (isMakefileName(path)) return "makefile"; | |
| 297 | 302 | if (isDockerfileName(path)) return "dockerfile"; |
| 298 | 303 | return TEXTUAL[extension(path)] ?? "text"; |
| 299 | 304 | } |
| @@ -311,6 +316,7 @@ export function kindByName(path: string): FileKind { | ||
| 311 | 316 | if (isLcovName(path)) return "lcov"; |
| 312 | 317 | if (isEnvExample(path)) return "env"; |
| 313 | 318 | if (isOpenApiName(path)) return "openapi"; |
| 319 | if (isMakefileName(path)) return "makefile"; | |
| 314 | 320 | if (isDockerfileName(path)) return "dockerfile"; |
| 315 | 321 | return MEDIA[ext]?.kind ?? TEXTUAL[ext] ?? "text"; |
| 316 | 322 | } |
src/web/render/makefile.ts added +188
| @@ -0,0 +1,188 @@ | ||
| 1 | /** | |
| 2 | * A `Makefile`, `GNUmakefile` or `*.mk` as what it declares: variables in one | |
| 3 | * block on top, then one row per target with its prerequisites and recipe | |
| 4 | * (mono), `.PHONY` targets marked. Comments stay in place as notes. A line | |
| 5 | * the parser does not understand (`include`, `ifeq`, a `define` block, | |
| 6 | * a recipe with no target) stays as source text where it was; nothing is | |
| 7 | * dropped except blank lines and `.PHONY` lines (the badges say it). A backslash at the end of a line joins the next. | |
| 8 | * Every cell is escaped by the template; at most `ROW_CAP` items are drawn. | |
| 9 | */ | |
| 10 | ||
| 11 | import { html, type Html } from "../html.ts"; | |
| 12 | import { fileSize } from "../ui.ts"; | |
| 13 | import type { Rendered, RenderInput } from "./renderers.ts"; | |
| 14 | ||
| 15 | export const ROW_CAP = 500; | |
| 16 | ||
| 17 | export interface MakeVariable { | |
| 18 | readonly name: string; | |
| 19 | readonly op: string; | |
| 20 | readonly value: string; | |
| 21 | } | |
| 22 | export interface MakeTarget { | |
| 23 | readonly names: string; | |
| 24 | /** `::` rules and `:` rules alike; the prerequisites before any `|`. */ | |
| 25 | readonly prereqs: readonly string[]; | |
| 26 | readonly orderOnly: readonly string[]; | |
| 27 | /** One entry per recipe line, continuation lines kept inside it. */ | |
| 28 | readonly recipe: readonly string[]; | |
| 29 | readonly phony: boolean; | |
| 30 | } | |
| 31 | export type MakeItem = | |
| 32 | | { readonly kind: "variable"; readonly variable: MakeVariable } | |
| 33 | | { readonly kind: "target"; readonly target: MakeTarget } | |
| 34 | | { readonly kind: "comment"; readonly text: string } | |
| 35 | | { readonly kind: "source"; readonly text: string }; | |
| 36 | ||
| 37 | export interface MakeDoc { | |
| 38 | readonly items: readonly MakeItem[]; | |
| 39 | readonly targets: number; | |
| 40 | readonly variables: number; | |
| 41 | } | |
| 42 | ||
| 43 | const VARIABLE = /^(?:(?:export|override|private)\s+)*([A-Za-z0-9_.@%<^+?*$(){}-]+?)\s*(:::=|::=|:=|\?=|\+=|!=|=)\s*(.*)$/; | |
| 44 | const DIRECTIVE = /^(?:-?include|sinclude|ifn?eq|ifn?def|else|endif|define|endef|undefine|vpath|export|unexport|override)\b/; | |
| 45 | ||
| 46 | /** Physical lines joined where a line ends in an odd number of backslashes. */ | |
| 47 | function logicalLines(source: string): string[] { | |
| 48 | const out: string[] = []; | |
| 49 | let cur: string | undefined; | |
| 50 | for (const raw of source.replace(/^/, "").split(/\r?\n/)) { | |
| 51 | cur = cur === undefined ? raw : `${cur}\n${raw}`; | |
| 52 | const slashes = /\\*$/.exec(raw)![0].length; | |
| 53 | if (slashes % 2 === 0 || raw.startsWith("#") && cur === raw) { | |
| 54 | out.push(cur); | |
| 55 | cur = undefined; | |
| 56 | } | |
| 57 | } | |
| 58 | if (cur !== undefined) out.push(cur); | |
| 59 | return out; | |
| 60 | } | |
| 61 | ||
| 62 | const words = (s: string): string[] => s.split(/\s+/).filter((w) => w !== ""); | |
| 63 | const flat = (s: string): string => s.replace(/\\\n\s*/g, " "); | |
| 64 | ||
| 65 | /** Read a makefile. Pure; never throws. */ | |
| 66 | export function parseMakefile(source: string): MakeDoc { | |
| 67 | const items: MakeItem[] = []; | |
| 68 | const phony = new Set<string>(); | |
| 69 | const recipes: string[][] = []; | |
| 70 | let current: string[] | undefined; | |
| 71 | let inDefine = false; | |
| 72 | let targets = 0; | |
| 73 | let variables = 0; | |
| 74 | const pending: { index: number; names: string; prereqs: string[]; orderOnly: string[]; recipe: string[] }[] = []; | |
| 75 | ||
| 76 | for (const line of logicalLines(source)) { | |
| 77 | if (line.trim() === "") continue; | |
| 78 | if (inDefine) { | |
| 79 | items.push({ kind: "source", text: line }); | |
| 80 | if (/^\s*endef\b/.test(line)) inDefine = false; | |
| 81 | continue; | |
| 82 | } | |
| 83 | if (line.startsWith("\t")) { | |
| 84 | if (current !== undefined) current.push(line.slice(1)); | |
| 85 | else items.push({ kind: "source", text: line }); | |
| 86 | continue; | |
| 87 | } | |
| 88 | current = undefined; | |
| 89 | const text = flat(line).trim(); | |
| 90 | if (text.startsWith("#")) { | |
| 91 | items.push({ kind: "comment", text: text.replace(/^#+\s?/, "") }); | |
| 92 | continue; | |
| 93 | } | |
| 94 | if (/^define\b/.test(text)) { | |
| 95 | inDefine = true; | |
| 96 | items.push({ kind: "source", text: line }); | |
| 97 | continue; | |
| 98 | } | |
| 99 | if (DIRECTIVE.test(text) && !/^(?:export|override)\s+[\w.-]+\s*(?:[:?+!]?=)/.test(text)) { | |
| 100 | items.push({ kind: "source", text: line }); | |
| 101 | continue; | |
| 102 | } | |
| 103 | const v = VARIABLE.exec(text); | |
| 104 | if (v !== null) { | |
| 105 | variables++; | |
| 106 | items.push({ kind: "variable", variable: { name: v[1]!, op: v[2]!, value: v[3]! } }); | |
| 107 | continue; | |
| 108 | } | |
| 109 | const colon = text.search(/::?(?!=)/); | |
| 110 | if (colon > 0) { | |
| 111 | const names = text.slice(0, colon).trim(); | |
| 112 | let rest = text.slice(colon).replace(/^::?/, ""); | |
| 113 | let inline: string | undefined; | |
| 114 | const semi = rest.indexOf(";"); | |
| 115 | if (semi !== -1) { | |
| 116 | inline = rest.slice(semi + 1).trim(); | |
| 117 | rest = rest.slice(0, semi); | |
| 118 | } | |
| 119 | rest = rest.replace(/(^|\s)#.*$/, ""); | |
| 120 | // A target-specific variable (`all: CC=gcc`) is not a rule: keep it as written. | |
| 121 | if (names !== "" && !rest.includes("=")) { | |
| 122 | const [pre = "", order = ""] = rest.split("|", 2).concat(rest.includes("|") ? [] : [""]); | |
| 123 | const prereqs = words(pre); | |
| 124 | const orderOnly = words(order); | |
| 125 | if (names === ".PHONY") { | |
| 126 | for (const w of prereqs) phony.add(w); | |
| 127 | continue; // read into the badges; the line is not repeated as a row | |
| 128 | } | |
| 129 | const recipe: string[] = inline !== undefined && inline !== "" ? [inline] : []; | |
| 130 | targets++; | |
| 131 | pending.push({ index: items.length, names, prereqs, orderOnly, recipe }); | |
| 132 | items.push({ kind: "source", text: "" }); // replaced below, once `.PHONY` is known | |
| 133 | recipes.push(recipe); | |
| 134 | current = recipe; | |
| 135 | continue; | |
| 136 | } | |
| 137 | } | |
| 138 | items.push({ kind: "source", text: line }); | |
| 139 | } | |
| 140 | for (const p of pending) | |
| 141 | items[p.index] = { | |
| 142 | kind: "target", | |
| 143 | target: { | |
| 144 | names: p.names, | |
| 145 | prereqs: p.prereqs, | |
| 146 | orderOnly: p.orderOnly, | |
| 147 | recipe: p.recipe, | |
| 148 | phony: words(p.names).some((n) => phony.has(n)), | |
| 149 | }, | |
| 150 | }; | |
| 151 | return { items, targets, variables }; | |
| 152 | } | |
| 153 | ||
| 154 | const list = (words: readonly string[]): Html => | |
| 155 | words.length === 0 ? html`—` : html`${words.map((w) => html`<code>${w}</code> `)}`; | |
| 156 | ||
| 157 | function targetRow(t: MakeTarget): Html { | |
| 158 | const mark = t.phony ? html` <span class="badge">phony</span>` : html``; | |
| 159 | const order = t.orderOnly.length > 0 ? html`<div class="muted small">order-only: ${list(t.orderOnly)}</div>` : html``; | |
| 160 | const recipe = t.recipe.length === 0 ? html`—` : html`<pre><code>${t.recipe.join("\n")}</code></pre>`; | |
| 161 | return html`<tr><th scope="row"><code>${t.names}</code>${mark}</th><td>${list(t.prereqs)}${order}</td><td>${recipe}</td></tr>`; | |
| 162 | } | |
| 163 | ||
| 164 | export function makefile(input: RenderInput): Rendered { | |
| 165 | const doc = parseMakefile(new TextDecoder().decode(input.body)); | |
| 166 | if (doc.items.length === 0) return { content: html``, lines: 0, fallback: "This file holds nothing to draw." }; | |
| 167 | const shown = doc.items.slice(0, ROW_CAP); | |
| 168 | const vars = shown.flatMap((i) => (i.kind === "variable" ? [i.variable] : [])); | |
| 169 | const rows: Html[] = []; | |
| 170 | for (const i of shown) { | |
| 171 | if (i.kind === "target") rows.push(targetRow(i.target)); | |
| 172 | else if (i.kind === "comment") rows.push(html`<tr><td colspan="3" class="muted small">${i.text}</td></tr>`); | |
| 173 | else if (i.kind === "source") rows.push(html`<tr><td colspan="3"><pre><code>${i.text}</code></pre></td></tr>`); | |
| 174 | } | |
| 175 | const varBlock = | |
| 176 | vars.length === 0 | |
| 177 | ? html`` | |
| 178 | : html`<div class="table-scroll" role="region" tabindex="0" aria-label="Variables"><table class="table" data-dense aria-label="Variables"><thead><tr><th scope="col">Variable</th><th scope="col">Value</th></tr></thead><tbody>${vars.map((v) => html`<tr><th scope="row"><code>${v.name}</code> <span class="muted small">${v.op}</span></th><td><code>${v.value}</code></td></tr>`)}</tbody></table></div>`; | |
| 179 | const ruleBlock = | |
| 180 | rows.length === 0 | |
| 181 | ? html`` | |
| 182 | : html`<div class="table-scroll" role="region" tabindex="0" aria-label="Targets"><table class="table" data-dense aria-label="Targets"><thead><tr><th scope="col">Target</th><th scope="col">Prerequisites</th><th scope="col">Recipe</th></tr></thead><tbody>${rows}</tbody></table></div>`; | |
| 183 | const more = doc.items.length - shown.length; | |
| 184 | const foot = more > 0 ? html`<p class="muted small">${more.toLocaleString()} more not shown</p>` : html``; | |
| 185 | const content = html`<div>${varBlock}${ruleBlock}${foot}</div>`; | |
| 186 | const t = doc.targets; | |
outdated
Principle 1/3: a |
||
| 187 | return { content, lines: 0, summary: `${t.toLocaleString()} ${t === 1 ? "target" : "targets"} · ${doc.variables.toLocaleString()} ${doc.variables === 1 ? "variable" : "variables"} · ${fileSize(input.size)}` }; | |
| 188 | } |
src/web/render/renderers.ts +2
| @@ -25,6 +25,7 @@ import { htmlPreview } from "./html_preview.ts"; | ||
| 25 | 25 | import { image } from "./image.ts"; |
| 26 | 26 | import { fileKind, HEAD_BYTES, kindByName, NO_BYTES, RENDER_CAP, type FileKind } from "./kinds.ts"; |
| 27 | 27 | import { log, hasEscape } from "./log.ts"; |
| 28 | import { makefile } from "./makefile.ts"; | |
| 28 | 29 | import { markdown } from "./markdown.ts"; |
| 29 | 30 | import { model3d } from "./model3d.ts"; |
| 30 | 31 | import { notebook } from "./notebook.ts"; |
| @@ -104,6 +105,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = { | ||
| 104 | 105 | env: envView, |
| 105 | 106 | sql: sqlView, |
| 106 | 107 | openapi, |
| 108 | makefile, | |
| 107 | 109 | dockerfile, |
| 108 | 110 | mail: eml, |
| 109 | 111 | archive, |
test/fuzz-renderers.test.ts +6
| @@ -19,6 +19,7 @@ import { geojson } from "../src/web/render/geojson.ts"; | ||
| 19 | 19 | import { subtitles } from "../src/web/render/subtitles.ts"; |
| 20 | 20 | import { patch } from "../src/web/render/patch.ts"; |
| 21 | 21 | import { notebook } from "../src/web/render/notebook.ts"; |
| 22 | import { makefile } from "../src/web/render/makefile.ts"; | |
| 22 | 23 | import { lcov } from "../src/web/render/lcov.ts"; |
| 23 | 24 | import { sarif } from "../src/web/render/sarif.ts"; |
| 24 | 25 | import { gcode } from "../src/web/render/gcode.ts"; |
| @@ -121,6 +122,11 @@ const SAMPLES: Record<string, { path: string; render: Renderer; files: readonly | ||
| 121 | 122 | render: lcov, |
| 122 | 123 | files: ["TN:\nSF:src/a.ts\nFNF:2\nFNH:1\nDA:1,1\nDA:2,0\nLF:2\nLH:1\nBRF:4\nBRH:2\nend_of_record\n"], |
| 123 | 124 | }, |
| 125 | makefile: { | |
| 126 | path: "Makefile", | |
| 127 | render: makefile, | |
| 128 | files: [".PHONY: all clean\nCC := <b>gcc</b>\nall: a.o | dir\n\t$(CC) -o x \\\n\t a.o\nclean:\n\trm -f *.o\ninclude <script>.mk\n"], | |
| 129 | }, | |
| 124 | 130 | srt: { |
| 125 | 131 | path: "talk.srt", |
| 126 | 132 | render: subtitles, |
test/render-kinds.test.ts +1 −1
| @@ -125,7 +125,7 @@ describe("the registry of file kinds", () => { | ||
| 125 | 125 | }); |
| 126 | 126 | |
| 127 | 127 | test("a file with no known extension is text or binary by its bytes", () => { |
| 128 | expect(fileKind("Makefile", enc("all:\n"))).toBe("text"); | |
| 128 | expect(fileKind("LICENSE", enc("MIT\n"))).toBe("text"); | |
| 129 | 129 | expect(fileKind("src/a.ts", enc("export {}\n"))).toBe("text"); |
| 130 | 130 | expect(fileKind("blob.dat", of(1, 0, 2))).toBe("binary"); |
| 131 | 131 | expect(fileKind(".png", SAMPLES.png![0])).toBe("binary"); |
test/render-makefile.test.ts added +98
| @@ -0,0 +1,98 @@ | ||
| 1 | import { describe, expect, test } from "bun:test"; | |
| 2 | import { makefile, parseMakefile, ROW_CAP } from "../src/web/render/makefile.ts"; | |
| 3 | import { fileKind, kindByName } from "../src/web/render/kinds.ts"; | |
| 4 | ||
| 5 | const enc = (s: string) => new TextEncoder().encode(s); | |
| 6 | const draw = (text: string) => { | |
| 7 | const body = enc(text); | |
| 8 | const r = makefile({ path: "Makefile", size: body.length, body, media: "", download: "" }); | |
| 9 | return { html: String(r.content), summary: r.summary, fallback: r.fallback }; | |
| 10 | }; | |
| 11 | const targets = (text: string) => parseMakefile(text).items.flatMap((i) => (i.kind === "target" ? [i.target] : [])); | |
| 12 | ||
| 13 | describe("makefile kind", () => { | |
| 14 | test("by name", () => { | |
| 15 | for (const p of ["Makefile", "makefile", "GNUmakefile", "a/b/rules.mk", "x/Makefile"]) { | |
| 16 | expect(fileKind(p, enc("all:\n"))).toBe("makefile"); | |
| 17 | expect(kindByName(p)).toBe("makefile"); | |
| 18 | } | |
| 19 | expect(fileKind("Makefile.txt", enc("x"))).toBe("text"); | |
| 20 | expect(fileKind("notmakefile.c", enc("x"))).toBe("text"); | |
| 21 | }); | |
| 22 | }); | |
| 23 | ||
| 24 | describe("parseMakefile", () => { | |
| 25 | test("targets, prerequisites, order-only and recipe", () => { | |
| 26 | const [t] = targets("app: a.o b.o | dir\n\tcc -o app a.o b.o\n\t@echo done\n"); | |
| 27 | expect(t).toMatchObject({ names: "app", prereqs: ["a.o", "b.o"], orderOnly: ["dir"], recipe: ["cc -o app a.o b.o", "@echo done"], phony: false }); | |
| 28 | }); | |
| 29 | test("phony, even when declared after the target", () => { | |
| 30 | const ts = targets("test:\n\tbun test\nbuild:\n\tx\n.PHONY: test\n"); | |
| 31 | expect(ts.map((t) => [t.names, t.phony])).toEqual([["test", true], ["build", false]]); | |
| 32 | }); | |
| 33 | test("variables", () => { | |
| 34 | const doc = parseMakefile("CC := gcc\nFLAGS ?= -O2 -Wall\nexport PATH += :/x\n"); | |
| 35 | expect(doc.variables).toBe(3); | |
| 36 | expect(doc.items.map((i) => (i.kind === "variable" ? [i.variable.name, i.variable.op, i.variable.value] : null))).toEqual([ | |
| 37 | ["CC", ":=", "gcc"], | |
| 38 | ["FLAGS", "?=", "-O2 -Wall"], | |
| 39 | ["PATH", "+=", ":/x"], | |
| 40 | ]); | |
| 41 | }); | |
| 42 | test("continuation lines join in prerequisites and stay in a recipe", () => { | |
| 43 | const [t] = targets("all: a \\\n b \\\n c\n\techo one \\\n\t two\n\techo three\n"); | |
| 44 | expect(t!.prereqs).toEqual(["a", "b", "c"]); | |
| 45 | expect(t!.recipe).toEqual(["echo one \\\n\t two", "echo three"]); | |
| 46 | }); | |
| 47 | test("an inline recipe after ;", () => { | |
| 48 | expect(targets("a: b ; echo hi\n")[0]!.recipe).toEqual(["echo hi"]); | |
| 49 | }); | |
| 50 | test("what it does not understand stays as source, in place", () => { | |
| 51 | const doc = parseMakefile("include foo.mk\nifeq ($(A),1)\nX=1\nendif\ndefine T\n\tbody: not a rule\nendef\n\torphan\nall: CC=gcc\n"); | |
| 52 | expect(doc.targets).toBe(0); | |
| 53 | expect(doc.items.filter((i) => i.kind === "source").map((i) => (i as { text: string }).text)).toEqual([ | |
| 54 | "include foo.mk", | |
| 55 | "ifeq ($(A),1)", | |
| 56 | "endif", | |
| 57 | "define T", | |
| 58 | "\tbody: not a rule", | |
| 59 | "endef", | |
| 60 | "\torphan", | |
| 61 | "all: CC=gcc", | |
| 62 | ]); | |
| 63 | }); | |
| 64 | test("comments stay as notes", () => { | |
| 65 | expect(parseMakefile("# build it\nall:\n").items[0]).toEqual({ kind: "comment", text: "build it" }); | |
| 66 | }); | |
| 67 | }); | |
| 68 | ||
| 69 | describe("makefile view", () => { | |
| 70 | test("draws variables on top, targets after, phony marked", () => { | |
| 71 | const { html, summary } = draw(".PHONY: clean\nCC = gcc\nall: a.o\n\t$(CC) a.o\nclean:\n\trm a.o\n"); | |
| 72 | expect(html.indexOf("Variables")).toBeLessThan(html.indexOf("Targets")); | |
| 73 | expect(html).toContain("<code>clean</code> <span class=\"badge\">phony</span>"); | |
| 74 | expect(html).not.toContain("<code>all</code> <span"); | |
| 75 | expect(summary).toContain("2 targets · 1 variable"); | |
| 76 | }); | |
| 77 | test("the .PHONY line is not repeated as a row", () => { | |
| 78 | const { html } = draw(".PHONY: clean\nclean:\n\trm a.o\n"); | |
| 79 | expect(html).not.toContain(".PHONY"); | |
| 80 | expect(parseMakefile(".PHONY: a\n").items).toEqual([]); | |
| 81 | }); | |
| 82 | test("each table scrolls on its own, so the page never does", () => { | |
| 83 | const { html } = draw("CC = gcc\nall:\n\tx\n"); | |
| 84 | expect(html.match(/<div class="table-scroll" role="region" tabindex="0"/g)?.length).toBe(2); | |
| 85 | }); | |
| 86 | test("escapes every value", () => { | |
| 87 | const { html } = draw("X = <img src=x onerror=1>\n<b>t</b>: <i>p</i>\n\t<script>1</script>\n# <u>c</u>\ninclude <s>.mk\n"); | |
| 88 | expect(html).not.toMatch(/<(img|b|i|script|u|s)[ >]/); | |
| 89 | expect(html).toContain("<script>"); | |
| 90 | }); | |
| 91 | test("empty file falls back", () => { | |
| 92 | expect(draw("\n\n").fallback).toBeDefined(); | |
| 93 | }); | |
| 94 | test("caps the rows and says so", () => { | |
| 95 | const { html } = draw(Array.from({ length: ROW_CAP + 3 }, (_, i) => `t${i}:\n`).join("")); | |
| 96 | expect(html).toContain("3 more not shown"); | |
| 97 | }); | |
| 98 | }); |
The
.PHONYdeclaration is pushed as a source row, so the list reads the same fact twice (the badge and the line). Decide and document it; the issue only asks for the marker.