jackherd 1.8.1 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/INSTALL.md +2 -0
- package/README.md +37 -44
- package/dist/{chunk-OU2KALXP.js → chunk-MCP4XJQH.js} +15 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +19 -3
- package/dist/plan-format.d.ts +19 -2
- package/dist/plan-format.js +21 -7
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/index.ts +22 -4
- package/kit/hooks/hooks/approval-gate/run.ts +29 -6
- package/kit/hooks/hooks/approval-gate/subagent.ts +18 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +67 -20
- package/kit/hooks/hooks/sent-file/index.ts +19 -0
- package/kit/hooks/hooks/sent-file/record.ts +128 -0
- package/kit/hooks/hooks/sent-file/run.ts +48 -0
- package/kit/hooks/hooks/sent-file/store.ts +18 -0
- package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
- package/kit/hooks/hooks.json +9 -0
- package/kit/hooks/statusline.sh +1 -1
- package/kit/workflow/skills/implement/SKILL.md +2 -0
- package/kit/workflow/skills/orchestrate/SKILL.md +30 -6
- package/package.json +2 -1
- package/schemas/jackherd.schema.json +26 -2
- package/src/cli.ts +3 -0
- package/src/config/schema.ts +22 -2
- package/src/context/fill.ts +12 -0
- package/src/context/session-log.ts +43 -1
- package/src/plan-format/clock.ts +4 -0
- package/src/plan-format/dependencies.ts +2 -5
- package/src/plan-format/lanes.ts +30 -5
- package/src/plan-format/terminal-text.ts +4 -0
- package/src/plan-format/waiting.ts +48 -0
- package/src/plans/active-tasks.ts +60 -0
- package/src/plans/current-phase.ts +17 -7
- package/src/plans/current-task.ts +63 -5
- package/src/plans/lane-standing.ts +9 -4
- package/src/plans/lane-summary.ts +141 -74
- package/src/plans/lane-tickets.ts +15 -0
- package/src/plans/layout-phase.ts +57 -18
- package/src/plans/layout-summary.ts +85 -67
- package/src/plans/layout.ts +253 -89
- package/src/plans/live.ts +22 -0
- package/src/plans/phase-models.ts +1 -1
- package/src/plans/phase-ranges.ts +45 -0
- package/src/plans/pull-requests.ts +6 -0
- package/src/plans/selection.ts +19 -16
- package/src/plans/sent-files-read.ts +22 -0
- package/src/plans/sent-files.ts +121 -0
- package/src/plans/signals-read.ts +54 -0
- package/src/plans/snapshot.ts +251 -55
- package/src/plans/subagents-read.ts +82 -0
- package/src/plans/subagents.ts +132 -0
- package/src/plans/texts.ts +16 -11
- package/src/plans/unlisted-lanes.ts +10 -5
- package/src/plans/view.ts +3 -3
- package/src/plans/waiting-click.ts +76 -0
- package/src/preview/command.ts +83 -0
- package/src/preview/embed.ts +209 -0
- package/src/preview/images.ts +89 -0
- package/src/preview/open.ts +12 -0
- package/src/preview/options.ts +32 -0
- package/src/preview/page.ts +89 -0
- package/src/preview/render.ts +41 -0
- package/src/settled/board-row.ts +1 -2
- package/src/shared/is-record.ts +2 -0
- package/src/shared/private-records.ts +48 -0
- package/src/task-signals/agent.ts +15 -0
- package/src/task-signals/claude-read.ts +72 -0
- package/src/task-signals/claude-signals.ts +112 -0
- package/src/task-signals/command.ts +71 -0
- package/src/task-signals/fixtures/session.jsonl +7 -0
- package/src/task-signals/matcher.ts +96 -0
- package/src/task-signals/record-store.ts +40 -0
- package/src/task-signals/record.ts +68 -0
- package/src/task-signals/text.ts +13 -0
- package/src/task-signals/types.ts +14 -0
- package/src/terminal/open.ts +13 -6
- package/src/terminal/text.ts +8 -1
- package/src/terminal/width.ts +2 -1
- package/src/vocabulary/index.ts +28 -0
- package/src/waiting/command.ts +35 -0
- package/src/watch/appointments.ts +2 -3
- package/src/watch/approvals.ts +2 -2
- package/src/watch/command.ts +91 -5
- package/src/watch/failed-tests.ts +56 -10
- package/src/watch/failure-read.ts +18 -6
- package/src/watch/failures.ts +29 -5
- package/src/watch/feedback.ts +1 -1
- package/src/watch/gh-process.ts +9 -3
- package/src/watch/json-guards.ts +0 -3
- package/src/watch/lane-fill.ts +1 -1
- package/src/watch/lane-model.ts +1 -1
- package/src/watch/lane-parallel.ts +1 -1
- package/src/watch/lane-progress.ts +19 -7
- package/src/watch/lane-report.ts +5 -1
- package/src/watch/lane-rest.ts +28 -0
- package/src/watch/plan-badges.ts +9 -2
- package/src/watch/previews.ts +1 -1
- package/src/watch/pull-requests.ts +2 -1
- package/src/watch/source-path.ts +1 -1
- package/src/watch/sources.ts +1 -1
- package/src/watch/stream-output.ts +12 -7
- package/src/watch/task-parallel-input.ts +118 -0
- package/src/watch/task-parallel-read.ts +149 -0
- package/src/watch/task-parallel-touched.ts +39 -0
- package/src/watch/task-parallel.ts +206 -0
- package/src/watch/waiting-line.ts +19 -0
- package/src/watch/watch-events.ts +13 -1
- package/src/watch/workflow-runs.ts +2 -1
package/src/plans/texts.ts
CHANGED
|
@@ -3,13 +3,13 @@ import { CHECK_NAME_LENGTH, CHECKS, type ChecksState, type PullRequest } from '.
|
|
|
3
3
|
import { shownWithMore } from './shown-with-more'
|
|
4
4
|
import { MODEL_MARK, NO_AGENT_WORD, PHASE_COUNT } from './symbols'
|
|
5
5
|
|
|
6
|
-
/** The words of an open lane:
|
|
6
|
+
/** The words of an open lane: the age of a running phase, the word before the open phases that wait, the finished phases not merged yet, and the pointer of a phase with only its review gate open. */
|
|
7
7
|
export type HerdTexts = {
|
|
8
|
-
sections: { session: string; chips: string; now: string; next: string; later: string; done: string }
|
|
9
8
|
minutesAgo: (minutes: number) => string
|
|
10
|
-
|
|
9
|
+
next: string
|
|
11
10
|
notMerged: string
|
|
12
11
|
reviewGate: string
|
|
12
|
+
gate: string
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
/**
|
|
@@ -24,6 +24,8 @@ export type PlanTexts = {
|
|
|
24
24
|
noWorktree: string
|
|
25
25
|
noRow: string
|
|
26
26
|
notInPlan: string
|
|
27
|
+
/** Before a task an agent reports that no task of the plan names. */
|
|
28
|
+
now: string
|
|
27
29
|
/** In place of the count of a phase whose only open box is the documentation box of its review gate. */
|
|
28
30
|
allButDocs: string
|
|
29
31
|
tickedSummary: (count: number) => string
|
|
@@ -64,6 +66,7 @@ export const boardTexts = (term: string) => ({
|
|
|
64
66
|
pageNotWritten: (reason: string) => `${term} page not written: ${reason}`,
|
|
65
67
|
pageNotOpenedReason: (reason: string) => `${term} page: ${reason}`,
|
|
66
68
|
notReadable: (reason: string) => `${term} not readable: ${reason}`,
|
|
69
|
+
notOpened: (reason: string) => `could not open: ${reason}`,
|
|
67
70
|
})
|
|
68
71
|
|
|
69
72
|
const ALL_BUT_DOCS = 'docs open'
|
|
@@ -76,17 +79,18 @@ export const TEXTS: PlanTexts = {
|
|
|
76
79
|
noWorktree: 'no worktree',
|
|
77
80
|
noRow: 'no row in lanes',
|
|
78
81
|
notInPlan: 'not in the plan',
|
|
82
|
+
now: 'now:',
|
|
79
83
|
allButDocs: ALL_BUT_DOCS,
|
|
80
84
|
tickedSummary: (count) => `${count} ticked · click or a lists them`,
|
|
81
85
|
draft: 'draft · ',
|
|
82
86
|
privateLink: 'private',
|
|
83
87
|
phaseTally: (done, total) => (total > 0 ? `${done}/${total} ph` : ''),
|
|
84
88
|
herd: {
|
|
85
|
-
sections: { session: 'SESSION', chips: '📎', now: 'NOW', next: 'NEXT', later: 'LATER', done: 'DONE' },
|
|
86
89
|
minutesAgo: (minutes) => (minutes === 0 ? '· just now' : `· ${minutes} min ago`),
|
|
87
|
-
|
|
90
|
+
next: 'next',
|
|
88
91
|
notMerged: 'not merged',
|
|
89
92
|
reviewGate: 'Review gate',
|
|
93
|
+
gate: '· gate',
|
|
90
94
|
},
|
|
91
95
|
planned: (phases, plans) =>
|
|
92
96
|
`planned (${phases} ${phases === 1 ? 'phase' : 'phases'} in ${plans} ${plans === 1 ? 'plan' : 'plans'})`,
|
|
@@ -98,12 +102,13 @@ export const TEXTS: PlanTexts = {
|
|
|
98
102
|
},
|
|
99
103
|
hint: '↑↓ select →← open a show ticked s html q quit ? help',
|
|
100
104
|
legend: [
|
|
101
|
-
'enter/space or click toggles a lane or a phase, click or enter on a task opens its whole text, and opens a ↗ pull request · wheel PgUp PgDn scroll · j k h l like the arrows · click or ctrl+click on a
|
|
102
|
-
'● working · ◆ blocked, waits for you · ◉ done = the agent finished its answer, not the lane · ○ idle · (·) no agent = a worktree nobody works in · waiting = not started: no worktree yet, something left to do, and what it starts after; finished lanes (cleaned up, or every named phase ticked) are not shown · working
|
|
103
|
-
`▸ ▾ open and close ·
|
|
104
|
-
|
|
105
|
-
'↗ pull request · 💬 new feedback · ✋ changes requested · 🌐 preview · 📡 ticket: chip 📡 T-1 · status
|
|
106
|
-
|
|
105
|
+
'enter/space or click toggles a lane or a phase, click or enter on a task opens its whole text, and opens a ↗ pull request · wheel PgUp PgDn scroll · j k h l like the arrows · click or ctrl+click on a link chip opens its link · ✦ artifact · ⑂ pull request or issue · ◈ Figma · private = the plan says so',
|
|
106
|
+
'● working · ◆ blocked, waits for you · ◉ done = the agent finished its answer, not the lane · ○ idle · (·) no agent = a worktree nobody works in · • state unknown · only blocked wears its word in the lane line, the symbol says the rest · waiting = not started: no worktree yet, something left to do, and what it starts after; finished lanes (cleaned up, or every named phase ticked) are not shown · a working or blocked lane opens by itself only when it first appears, after that → ← and clicks are yours alone',
|
|
107
|
+
`▸ ▾ open and close · an open lane's branches, top down: the agent's title and ⎇ its branch, ↗ its pull requests, its link chips, the phases it works on, next, ✓ · a lane shows only the phases its board rows name · a phase row = some of its tasks are ticked, one of its boxes was ticked in the last ${STALE_ACTIVITY_MINUTES} min, or an agent reports working on it, by number · n min ago since the last tick or report, · gate = only the review gate is open · ▶ teal = a task an agent reported (its task list or jh task; its name dim at the right edge when several agents work in the lane), ▷ dim = only guessed from the words of its log · now: <text> = a reported task no plan task names · next = the other open phases, as ranges · ✓ the finished ones, not merged = ticked in the worktree, not on the base branch yet · ${ALL_BUT_DOCS} = only the documentation box is open · a folded lane line names the phase moved last, +n more active ones, and its count · ${PHASE_COUNT.empty} no checkboxes · ${PHASE_COUNT.missing} not in the plan · a lists the ticked checkboxes one by one · s opens every lane and plan as one HTML page`,
|
|
108
|
+
`the bar = progress over the lane's plans · n/m = m checkboxes, n ticked · n/m ph = n of m phases finished · n/n in green = all ticked · 🥣 share of the context window used, on a folded lane only once it warns · Sonnet ${MODEL_MARK.differs} Opus in amber = the phase's subagent ran on another model than the plan's`,
|
|
109
|
+
'↗ pull request · 💬 new feedback · ✋ changes requested · 🌐 preview · 📡 ticket: chip 📡 T-1 · status among the link chips, 📡 <n> in amber on a folded lane when tickets are new',
|
|
110
|
+
'▌ on top = waits for your answer: 📎 a file or an Artifact that was sent (click: a cut-off row expands, then it opens), ? a question of the board · it goes when the board row is closed or `jh waiting close <id>` · woof closes the block',
|
|
111
|
+
'⎇ branch · ↑ commits ahead · ± uncommitted · no worktree: plan read from main',
|
|
107
112
|
],
|
|
108
113
|
}
|
|
109
114
|
|
|
@@ -2,8 +2,8 @@ import { isAbsolute, relative, sep } from 'node:path'
|
|
|
2
2
|
import type { SprintLane } from '../plan-format'
|
|
3
3
|
import type { LaneAgent } from '../terminal'
|
|
4
4
|
|
|
5
|
-
/** A worktree with an agent that has no row in the sprint table; `name` is its directory below the worktree directory. */
|
|
6
|
-
export type UnlistedLane = { name: string; agent: LaneAgent }
|
|
5
|
+
/** A worktree with an agent, or only a subagent of the coordinator, that has no row in the sprint table; `name` is its directory below the worktree directory. */
|
|
6
|
+
export type UnlistedLane = { name: string; agent: LaneAgent | null }
|
|
7
7
|
|
|
8
8
|
type PlacedAgent = { lane: string; agent: LaneAgent }
|
|
9
9
|
|
|
@@ -25,21 +25,26 @@ const placed = (agents: readonly LaneAgent[], worktreeDirectory: string): Placed
|
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* The lanes that run but are missing from the sprint table: one per worktree below `worktreeDirectory` with an
|
|
28
|
-
* agent in it
|
|
29
|
-
* agent of a worktree stands for it; the
|
|
28
|
+
* agent in it, or a subagent of the coordinator at work in it (`subagentLanes`, no agent then). A worktree counts as
|
|
29
|
+
* listed when the table names it, or names any agent running in it. The first agent of a worktree stands for it; the
|
|
30
|
+
* lanes come in name order.
|
|
30
31
|
*/
|
|
31
32
|
export const unlistedLanes = (
|
|
32
33
|
agents: readonly LaneAgent[],
|
|
33
34
|
listed: ReadonlySet<string>,
|
|
34
35
|
worktreeDirectory: string,
|
|
36
|
+
subagentLanes: ReadonlySet<string> = new Set(),
|
|
35
37
|
): UnlistedLane[] => {
|
|
36
38
|
const inWorktrees = placed(agents, worktreeDirectory)
|
|
37
39
|
const listedWorktrees = new Set(
|
|
38
40
|
inWorktrees.filter(({ lane, agent }) => listed.has(lane) || listed.has(agent.name)).map(({ lane }) => lane),
|
|
39
41
|
)
|
|
40
|
-
const byLane = new Map<string, LaneAgent>()
|
|
42
|
+
const byLane = new Map<string, LaneAgent | null>()
|
|
41
43
|
for (const { lane, agent } of inWorktrees) {
|
|
42
44
|
if (!listedWorktrees.has(lane) && !byLane.has(lane)) byLane.set(lane, agent)
|
|
43
45
|
}
|
|
46
|
+
for (const lane of subagentLanes) {
|
|
47
|
+
if (!listed.has(lane) && !byLane.has(lane)) byLane.set(lane, null)
|
|
48
|
+
}
|
|
44
49
|
return [...byLane].sort(([left], [right]) => left.localeCompare(right)).map(([name, agent]) => ({ name, agent }))
|
|
45
50
|
}
|
package/src/plans/view.ts
CHANGED
|
@@ -37,6 +37,8 @@ export const renderSnapshot = (
|
|
|
37
37
|
planned: snapshot.planned,
|
|
38
38
|
watch: snapshot.watch,
|
|
39
39
|
watchesTickets: snapshot.watchesTickets,
|
|
40
|
+
awaiting: snapshot.waiting,
|
|
41
|
+
unplaced: snapshot.unplaced,
|
|
40
42
|
},
|
|
41
43
|
selection,
|
|
42
44
|
width,
|
|
@@ -45,9 +47,7 @@ export const renderSnapshot = (
|
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
export const startSelection = (snapshot: Snapshot, options: Options): Selection =>
|
|
48
|
-
options.lane
|
|
49
|
-
? laneSelection(snapshot.views[0]?.progress, snapshot.views[0]?.status.agent ?? null)
|
|
50
|
-
: overviewSelection()
|
|
50
|
+
options.lane ? laneSelection(snapshot.views[0]?.progress) : overviewSelection()
|
|
51
51
|
|
|
52
52
|
/** Header and footer stay put; the body shows `bodyRows` lines from `top`, padded so the footer sits at the bottom. */
|
|
53
53
|
export const draw = (screen: Screen, top: number): void => {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { WaitingItem } from '../plan-format/waiting'
|
|
2
|
+
import { fit, wrap } from '../terminal/text'
|
|
3
|
+
import { textWidth } from '../terminal/width'
|
|
4
|
+
import { plain } from './layout-phase'
|
|
5
|
+
import { MOUSE_BUTTON, type MouseEvent } from './selection'
|
|
6
|
+
|
|
7
|
+
/** How many for-you rows the herd draws; the rest is one `+<n>` line. */
|
|
8
|
+
export const SHOWN_WAITING = 5
|
|
9
|
+
/** The bar and the blank that start every line of a for-you row. */
|
|
10
|
+
export const WAITING_MARK = '▌'
|
|
11
|
+
|
|
12
|
+
const FILE_MARK = '📎'
|
|
13
|
+
const QUESTION_MARK = '?'
|
|
14
|
+
const OPENABLE = /^(https?|file):\/\//
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* One for-you row as drawn: its plain text lines (more than one while it is expanded), whether its text is wider than
|
|
18
|
+
* the pane (`cut`), whether it is expanded, and the address a click opens.
|
|
19
|
+
*/
|
|
20
|
+
export type WaitingRow = { key: string; lines: string[]; cut: boolean; expanded: boolean; url: string | null }
|
|
21
|
+
|
|
22
|
+
/** What a click does: expand or fold the row (its key), open an address, or nothing (null). */
|
|
23
|
+
export type WaitingClick = { toggle: string } | { open: string }
|
|
24
|
+
|
|
25
|
+
/** The key of a row in the herd's expanded set: the record's id, else its text. */
|
|
26
|
+
export const waitingKey = ({ id, item }: WaitingItem): string => `waiting>${id ?? item}`
|
|
27
|
+
|
|
28
|
+
const roomOf = (width: number): number => Math.max(width - textWidth(`${WAITING_MARK} `), 1)
|
|
29
|
+
|
|
30
|
+
const rowOf = (item: WaitingItem, expandedKeys: ReadonlySet<string>, width: number): WaitingRow => {
|
|
31
|
+
const text = `${item.kind === 'file' ? FILE_MARK : QUESTION_MARK} ${plain(item.item)}`
|
|
32
|
+
const room = roomOf(width)
|
|
33
|
+
const cut = textWidth(text) > room
|
|
34
|
+
const key = waitingKey(item)
|
|
35
|
+
const expanded = cut && expandedKeys.has(key)
|
|
36
|
+
const url = item.kind === 'file' && item.url && OPENABLE.test(item.url) ? item.url : null
|
|
37
|
+
return { key, lines: expanded ? wrap(text, room) : [fit(text, room)], cut, expanded, url }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The rows the herd draws for what waits on the user: the first five, each folded to one line unless expanded. */
|
|
41
|
+
export const waitingRows = (
|
|
42
|
+
items: readonly WaitingItem[],
|
|
43
|
+
expandedKeys: ReadonlySet<string>,
|
|
44
|
+
width: number,
|
|
45
|
+
): WaitingRow[] => items.slice(0, SHOWN_WAITING).map((item) => rowOf(item, expandedKeys, width))
|
|
46
|
+
|
|
47
|
+
const rowAt = (rows: readonly WaitingRow[], firstLine: number, line: number): WaitingRow | null => {
|
|
48
|
+
let first = firstLine
|
|
49
|
+
for (const row of rows) {
|
|
50
|
+
if (line < first + row.lines.length) return line >= first ? row : null
|
|
51
|
+
first += row.lines.length
|
|
52
|
+
}
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const clickOn = ({ key, cut, expanded, url }: WaitingRow): WaitingClick | null => {
|
|
57
|
+
if (url) return cut && !expanded ? { toggle: key } : { open: url }
|
|
58
|
+
return cut ? { toggle: key } : null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* What a plain left click on the for-you block does. A row that is cut off expands on the first click; then a
|
|
63
|
+
* question folds on the next click and a file or Artifact opens. A row that fits opens its file on the first click, a
|
|
64
|
+
* question does nothing. The `+<n>` line, other lines, a click with a modifier (ctrl is left to the terminal's own link
|
|
65
|
+
* opening), a release and the wheel do nothing. `firstLine` is the screen line (0-based) of the first row, as
|
|
66
|
+
* `renderScreen` returns it.
|
|
67
|
+
*/
|
|
68
|
+
export const waitingClick = (
|
|
69
|
+
rows: readonly WaitingRow[],
|
|
70
|
+
firstLine: number,
|
|
71
|
+
{ button, row, pressed }: Pick<MouseEvent, 'button' | 'row' | 'pressed'>,
|
|
72
|
+
): WaitingClick | null => {
|
|
73
|
+
if (!pressed || button !== MOUSE_BUTTON.left) return null
|
|
74
|
+
const target = rowAt(rows, firstLine, row - 1)
|
|
75
|
+
return target ? clickOn(target) : null
|
|
76
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { existsSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { basename, dirname, join, resolve } from 'node:path'
|
|
4
|
+
import { openInBrowser } from '../terminal/open'
|
|
5
|
+
import { runGh } from '../watch/gh'
|
|
6
|
+
import { embedExternalImages, fetchImage } from './embed'
|
|
7
|
+
import { rewriteImages } from './images'
|
|
8
|
+
import { mediaTypeOf } from './open'
|
|
9
|
+
import { asksForHelp, PREVIEW_USAGE, parsePreviewOptions } from './options'
|
|
10
|
+
import { previewPage } from './page'
|
|
11
|
+
import { renderMarkdown, repositorySlugOf } from './render'
|
|
12
|
+
|
|
13
|
+
type PackageFacts = { root: string; name: string }
|
|
14
|
+
|
|
15
|
+
const packageOf = (file: string): PackageFacts | null => {
|
|
16
|
+
for (let directory = dirname(file); ; directory = dirname(directory)) {
|
|
17
|
+
const manifest = join(directory, 'package.json')
|
|
18
|
+
if (existsSync(manifest)) {
|
|
19
|
+
const name = (JSON.parse(readFileSync(manifest, 'utf8')) as { name?: unknown }).name
|
|
20
|
+
if (typeof name === 'string') return { root: directory, name }
|
|
21
|
+
}
|
|
22
|
+
if (dirname(directory) === directory) return null
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const remoteSlugOf = (directory: string): string | null => {
|
|
27
|
+
const result = Bun.spawnSync(['git', '-C', directory, 'remote', 'get-url', 'origin'], {
|
|
28
|
+
stdout: 'pipe',
|
|
29
|
+
stderr: 'pipe',
|
|
30
|
+
})
|
|
31
|
+
return result.exitCode === 0 ? repositorySlugOf(result.stdout.toString()) : null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Whether `file` is a regular file whose real path, links followed, lies under the real path of `root`. */
|
|
35
|
+
const isRegularFileUnder = (root: string, file: string): boolean => {
|
|
36
|
+
if (!existsSync(file)) return false
|
|
37
|
+
return realpathSync(file).startsWith(`${realpathSync(root)}/`) && statSync(file).isFile()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The image at `path` under `root` as a data address; null when it is missing, no image, no regular file, or outside
|
|
42
|
+
* `root` once links are followed, so a link in the working tree cannot put a file from elsewhere into the page.
|
|
43
|
+
*/
|
|
44
|
+
export const inlineImage = (root: string, path: string): string | null => {
|
|
45
|
+
const file = resolve(root, path)
|
|
46
|
+
const type = mediaTypeOf(file)
|
|
47
|
+
if (type === null || !file.startsWith(`${root}/`) || !isRegularFileUnder(root, file)) return null
|
|
48
|
+
return `data:${type};base64,${readFileSync(file).toString('base64')}`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `jh preview <markdown-file> [--out <path>] [--open]`: renders the file through GitHub's markdown API and writes a
|
|
53
|
+
* page that shows it in GitHub and in npm style, light and dark, and at phone width. The package's own images come
|
|
54
|
+
* from the working tree, so the preview shows what is not published yet. External images (badges) are embedded as data addresses, so the page is complete where it may load nothing from outside; one that cannot be fetched stays linked, with one warning. Prints the path of the page; `--open`
|
|
55
|
+
* opens it. `--help` prints the usage. Exit 0 written, 1 failed (`gh` missing or not signed in, file unreadable).
|
|
56
|
+
*/
|
|
57
|
+
export const run = async (args: string[]): Promise<number> => {
|
|
58
|
+
if (asksForHelp(args)) {
|
|
59
|
+
console.log(PREVIEW_USAGE)
|
|
60
|
+
return 0
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const options = parsePreviewOptions(args)
|
|
64
|
+
const file = resolve(options.file)
|
|
65
|
+
if (!existsSync(file)) throw new Error(`${options.file} does not exist`)
|
|
66
|
+
const facts = packageOf(file)
|
|
67
|
+
const html = await renderMarkdown(readFileSync(file, 'utf8'), remoteSlugOf(dirname(file)), dirname(file), runGh)
|
|
68
|
+
const own = facts ?? { root: dirname(file), name: '' }
|
|
69
|
+
const local = rewriteImages(html, own.name, (path) => inlineImage(own.root, path))
|
|
70
|
+
const { html: rewritten, failed } = await embedExternalImages(local, fetchImage)
|
|
71
|
+
if (failed > 0)
|
|
72
|
+
console.error(`${failed} external ${failed === 1 ? 'image' : 'images'} could not be embedded and stay linked`)
|
|
73
|
+
const out = resolve(options.out ?? join(tmpdir(), `jh-preview-${basename(file, '.md')}.html`))
|
|
74
|
+
writeFileSync(out, previewPage({ title: basename(file), html: rewritten }))
|
|
75
|
+
console.log(out)
|
|
76
|
+
const reason = options.open ? openInBrowser(out) : null
|
|
77
|
+
if (reason) console.error(`could not open the page: ${reason}`)
|
|
78
|
+
return 0
|
|
79
|
+
} catch (error) {
|
|
80
|
+
console.error(error instanceof Error ? error.message : String(error))
|
|
81
|
+
return 1
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { lookup } from 'node:dns/promises'
|
|
2
|
+
import { isIPv4, isIPv6 } from 'node:net'
|
|
3
|
+
import { decodedAttribute } from './images'
|
|
4
|
+
|
|
5
|
+
/** An image as fetched: its media type and bytes; null when it cannot be had. */
|
|
6
|
+
type FetchedImage = { type: string; bytes: Uint8Array }
|
|
7
|
+
|
|
8
|
+
/** The seam the page's external images are fetched through; a test replaces it. */
|
|
9
|
+
export type ImageFetch = (url: string) => Promise<FetchedImage | null>
|
|
10
|
+
|
|
11
|
+
/** What fetching an image needs from the network: the addresses of a host, and one request. Tests pass their own. */
|
|
12
|
+
export type ImageSeams = {
|
|
13
|
+
resolve: (host: string) => Promise<string[]>
|
|
14
|
+
request: (url: string, init: RequestInit) => Promise<Response>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const MAX_BYTES = 5 * 1024 * 1024
|
|
18
|
+
const TIMEOUT_MS = 10_000
|
|
19
|
+
const MAX_REDIRECTS = 3
|
|
20
|
+
const EXTERNAL_IMAGE = /<img\b[^>]*?\ssrc="(https:\/\/[^"]+)"/g
|
|
21
|
+
const REDIRECT = new Set([301, 302, 303, 307, 308])
|
|
22
|
+
/** A plain image media type; anything else would be written into the page's `src` attribute unchecked. */
|
|
23
|
+
const IMAGE_TYPE = /^image\/[a-z0-9.+-]+$/i
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* IPv4 ranges no public image lives in: this network, private, shared (carrier NAT), loopback, link-local (cloud
|
|
27
|
+
* metadata), IETF protocol assignments, benchmarking, multicast and reserved. As [first octets, prefix length].
|
|
28
|
+
*/
|
|
29
|
+
const NON_PUBLIC_V4: [number, number, number][] = [
|
|
30
|
+
[0, 0, 8],
|
|
31
|
+
[10, 0, 8],
|
|
32
|
+
[100, 64, 10],
|
|
33
|
+
[127, 0, 8],
|
|
34
|
+
[169, 254, 16],
|
|
35
|
+
[172, 16, 12],
|
|
36
|
+
[192, 0, 24],
|
|
37
|
+
[192, 168, 16],
|
|
38
|
+
[198, 18, 15],
|
|
39
|
+
[224, 0, 3],
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
const v4Number = (address: string): number => address.split('.').reduce((value, part) => value * 256 + Number(part), 0)
|
|
43
|
+
|
|
44
|
+
const isPublicV4 = (address: string): boolean => {
|
|
45
|
+
const value = v4Number(address)
|
|
46
|
+
return NON_PUBLIC_V4.every(([first, second, prefix]) => {
|
|
47
|
+
const network = (first * 256 + second) * 65_536
|
|
48
|
+
const size = 2 ** (32 - prefix)
|
|
49
|
+
return value < network || value >= network + size
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** An IPv4 address as the last two groups of an IPv6 address: `127.0.0.1` → `7f00:1`. */
|
|
54
|
+
const v4AsGroups = (address: string): string => {
|
|
55
|
+
const value = v4Number(address)
|
|
56
|
+
return `${(value >>> 16).toString(16)}:${(value & 0xffff).toString(16)}`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const groupsOf = (part: string | undefined): number[] =>
|
|
60
|
+
part ? part.split(':').map((group) => Number.parseInt(group, 16)) : []
|
|
61
|
+
|
|
62
|
+
/** The eight 16-bit groups of an IPv6 address, an embedded IPv4 tail included. */
|
|
63
|
+
const v6Groups = (address: string): number[] => {
|
|
64
|
+
const tail = /\d+\.\d+\.\d+\.\d+$/.exec(address)?.[0]
|
|
65
|
+
const text = tail ? `${address.slice(0, -tail.length)}${v4AsGroups(tail)}` : address
|
|
66
|
+
const [head, rest] = text.split('::')
|
|
67
|
+
const left = groupsOf(head)
|
|
68
|
+
const right = groupsOf(rest)
|
|
69
|
+
return rest === undefined ? left : [...left, ...Array(8 - left.length - right.length).fill(0), ...right]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const v4Of = (high: number, low: number): string => [high >>> 8, high & 0xff, low >>> 8, low & 0xff].join('.')
|
|
73
|
+
|
|
74
|
+
const isPublicV6 = (address: string): boolean => {
|
|
75
|
+
const groups = v6Groups(address)
|
|
76
|
+
const [first] = groups
|
|
77
|
+
const zeroPrefix = groups.slice(0, 5).every((group) => group === 0)
|
|
78
|
+
if (zeroPrefix && (groups[5] === 0xffff || groups[5] === 0)) {
|
|
79
|
+
return groups[5] === 0xffff || groups[6] !== 0 ? isPublicV4(v4Of(groups[6], groups[7])) : false
|
|
80
|
+
}
|
|
81
|
+
// Unique local fc00::/7, link-local fe80::/10, site-local fec0::/10, multicast ff00::/8.
|
|
82
|
+
return (first & 0xfe00) !== 0xfc00 && (first & 0xffc0) !== 0xfe80 && (first & 0xffc0) !== 0xfec0 && first < 0xff00
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Whether an IP address is one a public host answers on; loopback, private, link-local and the like are not. */
|
|
86
|
+
export const isPublicAddress = (address: string): boolean => {
|
|
87
|
+
if (isIPv4(address)) return isPublicV4(address)
|
|
88
|
+
if (isIPv6(address)) return isPublicV6(address)
|
|
89
|
+
return false
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Whether the address is https and every address its host resolves to is public; none resolved is no. */
|
|
93
|
+
const isFetchable = async (url: URL, resolve: ImageSeams['resolve']): Promise<boolean> => {
|
|
94
|
+
if (url.protocol !== 'https:') return false
|
|
95
|
+
const host = url.hostname.replace(/^\[(.*)\]$/, '$1')
|
|
96
|
+
const addresses = isIPv4(host) || isIPv6(host) ? [host] : await resolve(host)
|
|
97
|
+
return addresses.length > 0 && addresses.every(isPublicAddress)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The body up to `limit` bytes, read as a stream; null once it grows past it. */
|
|
101
|
+
const readCapped = async (body: ReadableStream<Uint8Array> | null, limit: number): Promise<Uint8Array | null> => {
|
|
102
|
+
if (!body) return new Uint8Array()
|
|
103
|
+
const reader = body.getReader()
|
|
104
|
+
const chunks: Uint8Array[] = []
|
|
105
|
+
let size = 0
|
|
106
|
+
for (let read = await reader.read(); !read.done; read = await reader.read()) {
|
|
107
|
+
size += read.value.length
|
|
108
|
+
if (size > limit) {
|
|
109
|
+
await reader.cancel()
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
chunks.push(read.value)
|
|
113
|
+
}
|
|
114
|
+
const bytes = new Uint8Array(size)
|
|
115
|
+
let offset = 0
|
|
116
|
+
for (const chunk of chunks) {
|
|
117
|
+
bytes.set(chunk, offset)
|
|
118
|
+
offset += chunk.length
|
|
119
|
+
}
|
|
120
|
+
return bytes
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The image an answer carries: a successful answer with a plain `image/<subtype>` type within the size limit, refused by its
|
|
125
|
+
* announced length before anything is read, else while it streams; null otherwise.
|
|
126
|
+
*/
|
|
127
|
+
export const imageFromResponse = async (response: Response): Promise<FetchedImage | null> => {
|
|
128
|
+
const type = response.headers.get('content-type')?.split(';')[0].trim().toLowerCase()
|
|
129
|
+
if (!response.ok || type === undefined || !IMAGE_TYPE.test(type)) return null
|
|
130
|
+
if (Number(response.headers.get('content-length') ?? 0) > MAX_BYTES) return null
|
|
131
|
+
const bytes = await readCapped(response.body, MAX_BYTES)
|
|
132
|
+
return bytes ? { type, bytes } : null
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Fetches an image from a public https host: the host is resolved first and refused when any of its addresses is
|
|
137
|
+
* loopback, private or link-local, redirects are followed by hand (at most three), each target checked the same way.
|
|
138
|
+
* Null for any refusal or failure. Resolving and requesting are two lookups, so a host that changes its answer between
|
|
139
|
+
* them (DNS rebinding) is not caught; the page is a local preview, which bounds what such a host could read.
|
|
140
|
+
*/
|
|
141
|
+
export const fetchImageWith = async (
|
|
142
|
+
address: string,
|
|
143
|
+
{ resolve, request }: ImageSeams,
|
|
144
|
+
): Promise<FetchedImage | null> => {
|
|
145
|
+
let url = new URL(address)
|
|
146
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
|
|
147
|
+
if (!(await isFetchable(url, resolve))) return null
|
|
148
|
+
const response = await request(url.href, { redirect: 'manual', signal: AbortSignal.timeout(TIMEOUT_MS) })
|
|
149
|
+
const location = response.headers.get('location')
|
|
150
|
+
if (!REDIRECT.has(response.status)) return imageFromResponse(response)
|
|
151
|
+
if (!location) return null
|
|
152
|
+
url = new URL(location, url)
|
|
153
|
+
}
|
|
154
|
+
return null
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const systemSeams: ImageSeams = {
|
|
158
|
+
resolve: async (host) => (await lookup(host, { all: true, verbatim: true })).map(({ address }) => address),
|
|
159
|
+
request: (url, init) => fetch(url, init),
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Fetches an image over the network with a time limit; null for any failure. */
|
|
163
|
+
export const fetchImage: ImageFetch = async (url) => {
|
|
164
|
+
try {
|
|
165
|
+
return await fetchImageWith(url, systemSeams)
|
|
166
|
+
} catch {
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const dataAddressOf = ({ type, bytes }: FetchedImage): string =>
|
|
172
|
+
`data:${type};base64,${Buffer.from(bytes).toString('base64')}`
|
|
173
|
+
|
|
174
|
+
const safely = async (fetchOne: ImageFetch, url: string): Promise<FetchedImage | null> => {
|
|
175
|
+
try {
|
|
176
|
+
return await fetchOne(url)
|
|
177
|
+
} catch {
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The page with every external image (`https://…` in an `<img src>`) shown from a data address, so it is complete where
|
|
184
|
+
* a page may load nothing from outside (an Artifact's content security policy). Each address is fetched once, as the
|
|
185
|
+
* address its attribute text stands for (`&` read as `&`), and replaced by its text as written. One that
|
|
186
|
+
* cannot be fetched, or answers with no plain image type, keeps its address; `failed` counts those addresses. Plain
|
|
187
|
+
* http images are left as they are.
|
|
188
|
+
*/
|
|
189
|
+
export const embedExternalImages = async (
|
|
190
|
+
html: string,
|
|
191
|
+
fetchOne: ImageFetch,
|
|
192
|
+
): Promise<{ html: string; failed: number }> => {
|
|
193
|
+
const addresses = [...new Set([...html.matchAll(EXTERNAL_IMAGE)].map(([, address]) => address))]
|
|
194
|
+
const fetched = await Promise.all(
|
|
195
|
+
addresses.map(async (address) => [address, await safely(fetchOne, decodedAttribute(address))] as const),
|
|
196
|
+
)
|
|
197
|
+
const embedded = new Map(
|
|
198
|
+
fetched.flatMap(([address, image]) =>
|
|
199
|
+
image && IMAGE_TYPE.test(image.type) ? [[address, dataAddressOf(image)] as const] : [],
|
|
200
|
+
),
|
|
201
|
+
)
|
|
202
|
+
return {
|
|
203
|
+
html: html.replace(EXTERNAL_IMAGE, (tag, address: string) => {
|
|
204
|
+
const data = embedded.get(address)
|
|
205
|
+
return data ? tag.replace(`src="${address}"`, `src="${data}"`) : tag
|
|
206
|
+
}),
|
|
207
|
+
failed: addresses.length - embedded.size,
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { posix } from 'node:path'
|
|
2
|
+
|
|
3
|
+
/** Reads the file at a path relative to the package root as an address the page can show; null when it cannot. */
|
|
4
|
+
export type LocalImage = (relativePath: string) => string | null
|
|
5
|
+
|
|
6
|
+
const CAMO = /^https:\/\/camo\.githubusercontent\.com\/[0-9a-f]+\/([0-9a-f]+)$/
|
|
7
|
+
const SCHEME = /^[a-z][a-z0-9+.-]*:|^\/\//i
|
|
8
|
+
|
|
9
|
+
/** The address GitHub's image proxy stands for; any other address as it is. */
|
|
10
|
+
const canonicalOf = (src: string): string => {
|
|
11
|
+
const hex = CAMO.exec(src)?.[1]
|
|
12
|
+
return hex ? Buffer.from(hex, 'hex').toString('utf8') : src
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The path below `<package>[@version]/` of an unpkg address of the package; null for any other address. */
|
|
16
|
+
const unpkgPathOf = (address: string, packageName: string): string | null => {
|
|
17
|
+
if (!URL.canParse(address)) return null
|
|
18
|
+
const { host, pathname } = new URL(address)
|
|
19
|
+
const [, name, ...rest] = pathname.split('/')
|
|
20
|
+
if (host !== 'unpkg.com' || name?.split('@')[0] !== packageName || rest.length === 0) return null
|
|
21
|
+
return posix.normalize(rest.join('/'))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The path inside the package an image address means: a relative path, or an `unpkg.com/<package>/…` address of the
|
|
26
|
+
* package itself (any version, `?` part cut off). Null for every other address.
|
|
27
|
+
*/
|
|
28
|
+
export const packagePathOf = (address: string, packageName: string): string | null => {
|
|
29
|
+
const clean = address.split(/[?#]/)[0]
|
|
30
|
+
const unpkg = unpkgPathOf(clean, packageName)
|
|
31
|
+
if (unpkg !== null) return unpkg
|
|
32
|
+
if (SCHEME.test(clean) || clean.startsWith('/') || clean === '') return null
|
|
33
|
+
const normalized = posix.normalize(clean)
|
|
34
|
+
return normalized.startsWith('..') ? null : normalized
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const ENTITIES: Record<string, string> = { '&': '&', '"': '"', '<': '<', '>': '>', ''': "'" }
|
|
38
|
+
|
|
39
|
+
/** An attribute's text as the address it stands for, the entities GitHub's renderer writes undone. */
|
|
40
|
+
export const decodedAttribute = (text: string): string =>
|
|
41
|
+
text.replace(/&(amp|quot|lt|gt|#39);/g, (entity) => ENTITIES[entity])
|
|
42
|
+
|
|
43
|
+
/** An address as attribute text: nothing in it can close the attribute or open a tag. */
|
|
44
|
+
const escapedAttribute = (text: string): string =>
|
|
45
|
+
text.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
|
|
46
|
+
|
|
47
|
+
const attributeOf = (tag: string, name: string): string | null => {
|
|
48
|
+
const text = new RegExp(`\\s${name}="([^"]*)"`).exec(tag)?.[1]
|
|
49
|
+
return text === undefined ? null : decodedAttribute(text)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const withoutAttributes = (tag: string, names: readonly string[]): string =>
|
|
53
|
+
names.reduce((rest, name) => rest.replace(new RegExp(`\\s${name}="[^"]*"`, 'g'), ''), tag)
|
|
54
|
+
|
|
55
|
+
/** The tag with `source` as its only address; `source` is the address itself, not attribute text, and is escaped here. */
|
|
56
|
+
const withSource = (tag: string, source: string): string =>
|
|
57
|
+
withoutAttributes(tag, ['src', 'data-canonical-src']).replace(/^<img/, () => `<img src="${escapedAttribute(source)}"`)
|
|
58
|
+
|
|
59
|
+
const localOrKept = (address: string, packageName: string, read: LocalImage): string => {
|
|
60
|
+
const path = packagePathOf(address, packageName)
|
|
61
|
+
return (path === null ? null : read(path)) ?? address
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One `<img>` tag with its address resolved: the working tree's file for the package's own images, the original address otherwise. */
|
|
65
|
+
const resolveImage = (tag: string, packageName: string, read: LocalImage): string => {
|
|
66
|
+
const address = canonicalOf(attributeOf(tag, 'data-canonical-src') ?? attributeOf(tag, 'src') ?? '')
|
|
67
|
+
return address === '' ? tag : withSource(tag, localOrKept(address, packageName, read))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const withClass = (tag: string, className: string): string => tag.replace(/^<img/, `<img class="${className}"`)
|
|
71
|
+
|
|
72
|
+
/** `<picture>` with a dark source as two images, one shown per theme; the page switches themes itself, the browser's media query would not follow it. */
|
|
73
|
+
const themedPicture = (picture: string): string => {
|
|
74
|
+
const dark = /<source[^>]*\bsrcset="([^"]+)"/.exec(picture)?.[1]
|
|
75
|
+
const image = /<img[^>]*>/.exec(picture)?.[0]
|
|
76
|
+
if (dark === undefined || image === undefined) return picture
|
|
77
|
+
const darkImage = withSource(image, decodedAttribute(dark))
|
|
78
|
+
return `${withClass(image, 'only-light')}${withClass(darkImage, 'only-dark')}`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The rendered HTML with every image shown from the working tree where it is the package's own (relative paths and
|
|
83
|
+
* `unpkg.com/<package>/…`, so an unpublished README shows its unpublished pictures) and every `<picture>` split
|
|
84
|
+
* into a light and a dark image. Other addresses stay as they are.
|
|
85
|
+
*/
|
|
86
|
+
export const rewriteImages = (html: string, packageName: string, read: LocalImage): string =>
|
|
87
|
+
html
|
|
88
|
+
.replace(/<picture>[\s\S]*?<\/picture>/g, themedPicture)
|
|
89
|
+
.replace(/<img\b[^>]*>/g, (tag) => resolveImage(tag, packageName, read))
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const MEDIA_TYPES: Record<string, string> = {
|
|
2
|
+
svg: 'image/svg+xml',
|
|
3
|
+
png: 'image/png',
|
|
4
|
+
jpg: 'image/jpeg',
|
|
5
|
+
jpeg: 'image/jpeg',
|
|
6
|
+
gif: 'image/gif',
|
|
7
|
+
webp: 'image/webp',
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** The media type of an image file by its extension; null when the preview does not inline that kind of file. */
|
|
11
|
+
export const mediaTypeOf = (path: string): string | null =>
|
|
12
|
+
MEDIA_TYPES[path.split('.').pop()?.toLowerCase() ?? ''] ?? null
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { CLI_NAME } from '../version/release'
|
|
2
|
+
|
|
3
|
+
/** What `jh preview` was asked for: the Markdown file, where to write the page (null: the temporary directory) and whether to open it. */
|
|
4
|
+
export type PreviewOptions = { file: string; out: string | null; open: boolean }
|
|
5
|
+
|
|
6
|
+
export const PREVIEW_USAGE = `usage: ${CLI_NAME} preview <markdown-file> [--out <path>] [--open]`
|
|
7
|
+
|
|
8
|
+
/** Whether the command line asks for the usage: `--help` or `-h` anywhere, as the top level of `jh` reads it. */
|
|
9
|
+
export const asksForHelp = (args: readonly string[]): boolean => args.includes('--help') || args.includes('-h')
|
|
10
|
+
|
|
11
|
+
const usageError = (message: string): Error => new Error(`${message}\n${PREVIEW_USAGE}`)
|
|
12
|
+
|
|
13
|
+
/** Reads `<file> [--out <path>] [--open]` in any order; throws on an unknown option, a missing value or a missing or second file. */
|
|
14
|
+
export const parsePreviewOptions = (args: readonly string[]): PreviewOptions => {
|
|
15
|
+
const words: string[] = []
|
|
16
|
+
let out: string | null = null
|
|
17
|
+
let open = false
|
|
18
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
19
|
+
const arg = args[i]
|
|
20
|
+
if (arg === '--open') open = true
|
|
21
|
+
else if (arg === '--out') {
|
|
22
|
+
const value = args[i + 1]
|
|
23
|
+
if (value === undefined || value.startsWith('--')) throw usageError('--out needs a path')
|
|
24
|
+
out = value
|
|
25
|
+
i += 1
|
|
26
|
+
} else if (arg.startsWith('--')) throw usageError(`unknown option ${arg}`)
|
|
27
|
+
else words.push(arg)
|
|
28
|
+
}
|
|
29
|
+
if (words.length === 0) throw usageError('the Markdown file is required')
|
|
30
|
+
if (words.length > 1) throw usageError(`one file at most, got ${words.join(' ')}`)
|
|
31
|
+
return { file: words[0], out, open }
|
|
32
|
+
}
|