jackherd 0.0.1 → 1.0.1
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 +65 -0
- package/LICENSE +21 -0
- package/NOTICE +38 -0
- package/README.md +1122 -3
- package/assets/branding/README.md +62 -0
- package/assets/branding/jackherd-icon-256.png +0 -0
- package/assets/branding/jackherd-icon-512.png +0 -0
- package/assets/branding/jackherd-icon-64.png +0 -0
- package/assets/branding/jackherd-icon-small-32.png +0 -0
- package/assets/branding/jackherd-icon-small-64.png +0 -0
- package/assets/branding/jackherd-icon-small.svg +1 -0
- package/assets/branding/jackherd-icon.svg +1 -0
- package/assets/branding/jackherd-logo-1024.png +0 -0
- package/assets/branding/jackherd-logo-512.png +0 -0
- package/assets/branding/jackherd-logo-dark-1024.png +0 -0
- package/assets/branding/jackherd-logo-dark-512.png +0 -0
- package/assets/branding/jackherd-logo-dark.svg +1 -0
- package/assets/branding/jackherd-logo-mark-1024.png +0 -0
- package/assets/branding/jackherd-logo-mark-512.png +0 -0
- package/assets/branding/jackherd-logo-mark.svg +1 -0
- package/assets/branding/jackherd-logo.svg +1 -0
- package/dist/chunk-R6MGHCD7.js +93 -0
- package/dist/config.d.ts +167 -0
- package/dist/config.js +401 -0
- package/dist/plan-format.d.ts +325 -0
- package/dist/plan-format.js +636 -0
- package/docs/lanes.mmd +9 -0
- package/docs/lanes.svg +1 -0
- package/docs/skills.mmd +10 -0
- package/docs/skills.svg +1 -0
- package/docs/workflow.svg +172 -0
- package/herdr-plugin/herdr-plugin.toml +22 -0
- package/herdr-plugin/open-plans.ts +43 -0
- package/herdr-plugin/restore-plans.ts +107 -0
- package/install.sh +157 -0
- package/kit/hooks/hooks/git-guard/decision.ts +438 -0
- package/kit/hooks/hooks/git-guard/index.ts +38 -0
- package/kit/hooks/hooks/git-guard/shell.ts +407 -0
- package/kit/hooks/hooks/watch-guard/background.ts +36 -0
- package/kit/hooks/hooks/watch-guard/decision.ts +53 -0
- package/kit/hooks/hooks/watch-guard/index.ts +11 -0
- package/kit/hooks/hooks/watch-guard/shell-input.ts +32 -0
- package/kit/hooks/hooks/watch-guard/starts.ts +154 -0
- package/kit/hooks/hooks/watch-guard/text-fallback.ts +40 -0
- package/kit/hooks/hooks.json +30 -0
- package/kit/hooks/statusline.sh +72 -0
- package/kit/review/agents/devops.md +11 -0
- package/kit/review/agents/public-content.md +39 -0
- package/kit/review/agents/security-audit.md +11 -0
- package/kit/review/agents/uncle-bob.md +11 -0
- package/kit/review/skills/devops/SKILL.md +50 -0
- package/kit/review/skills/diagnose/SKILL.md +140 -0
- package/kit/review/skills/diagnose/references/feedback-loops.md +59 -0
- package/kit/review/skills/diagnose/scripts/hitl-loop.template.sh +71 -0
- package/kit/review/skills/pre-commit/SKILL.md +100 -0
- package/kit/review/skills/review/SKILL.md +43 -0
- package/kit/review/skills/security-audit/SKILL.md +65 -0
- package/kit/review/skills/uncle-bob/SKILL.md +43 -0
- package/kit/rules/rules/kit/arc42.md +89 -0
- package/kit/rules/rules/kit/architecture-files.md +87 -0
- package/kit/rules/rules/kit/code-style.md +159 -0
- package/kit/rules/rules/kit/pull-requests.md +59 -0
- package/kit/rules/rules/kit/testing.md +91 -0
- package/kit/rules/rules/kit/writing-for-agents.md +110 -0
- package/kit/workflow/agents/explore.md +25 -0
- package/kit/workflow/agents/implement.md +16 -0
- package/kit/workflow/agents/plan.md +13 -0
- package/kit/workflow/agents/spec-review.md +96 -0
- package/kit/workflow/skills/follow/SKILL.md +48 -0
- package/kit/workflow/skills/grill/SKILL.md +60 -0
- package/kit/workflow/skills/implement/SKILL.md +107 -0
- package/kit/workflow/skills/orchestrate/SKILL.md +565 -0
- package/kit/workflow/skills/orchestrate/handover/decision.ts +247 -0
- package/kit/workflow/skills/orchestrate/handover/index.ts +171 -0
- package/kit/workflow/skills/plan/SKILL.md +118 -0
- package/kit/workflow/skills/plan/references/template-English.md +92 -0
- package/kit/workflow/skills/plan/references/template-German.md +92 -0
- package/kit/workflow/skills/start/SKILL.md +47 -0
- package/package.json +63 -5
- package/schemas/jackherd.schema.json +601 -0
- package/src/check/changes.ts +45 -0
- package/src/check/command-line.ts +12 -0
- package/src/check/command.ts +136 -0
- package/src/check/deferral-arrows.ts +41 -0
- package/src/check/execute.ts +29 -0
- package/src/check/glob.ts +15 -0
- package/src/check/plan-structure-check.ts +135 -0
- package/src/check/plan.ts +106 -0
- package/src/check/public-content-check.ts +32 -0
- package/src/check/public-content-facts.ts +177 -0
- package/src/check/public-content.ts +216 -0
- package/src/check/report.ts +55 -0
- package/src/check/result.ts +42 -0
- package/src/check/roots.ts +36 -0
- package/src/check/routing.ts +22 -0
- package/src/check/schema.ts +83 -0
- package/src/check/settings.ts +44 -0
- package/src/claude/paths.ts +14 -0
- package/src/cli.ts +46 -0
- package/src/config/command.ts +45 -0
- package/src/config/detect.ts +196 -0
- package/src/config/index.ts +2 -0
- package/src/config/json-schema.ts +5 -0
- package/src/config/legacy-names.ts +56 -0
- package/src/config/link-kinds.ts +25 -0
- package/src/config/location.ts +46 -0
- package/src/config/read-config.ts +42 -0
- package/src/config/repository-facts.ts +19 -0
- package/src/config/repository-values.ts +46 -0
- package/src/config/schema.ts +302 -0
- package/src/context/command.ts +39 -0
- package/src/context/coordinator-name.ts +16 -0
- package/src/context/fill.ts +69 -0
- package/src/context/index.ts +10 -0
- package/src/context/level.ts +22 -0
- package/src/context/options.ts +29 -0
- package/src/context/report.ts +42 -0
- package/src/context/session-log.ts +91 -0
- package/src/plan-format/drift.ts +69 -0
- package/src/plan-format/header.ts +27 -0
- package/src/plan-format/index.ts +9 -0
- package/src/plan-format/lanes.ts +128 -0
- package/src/plan-format/markdown.ts +98 -0
- package/src/plan-format/phase-tally.ts +55 -0
- package/src/plan-format/phases.ts +224 -0
- package/src/plan-format/plan-structure.ts +150 -0
- package/src/plan-format/progress.ts +202 -0
- package/src/plan-format/sprint-document.ts +69 -0
- package/src/plan-format/terminal-text.ts +28 -0
- package/src/plans/board-choice.ts +60 -0
- package/src/plans/board-pick.ts +72 -0
- package/src/plans/board-rows.ts +62 -0
- package/src/plans/command.ts +60 -0
- package/src/plans/ensure-panes.ts +71 -0
- package/src/plans/error-text.ts +5 -0
- package/src/plans/fetch.ts +96 -0
- package/src/plans/git.ts +145 -0
- package/src/plans/github.ts +35 -0
- package/src/plans/lane-discovery.ts +34 -0
- package/src/plans/lane-plans.ts +80 -0
- package/src/plans/layout.ts +881 -0
- package/src/plans/link-chips.ts +74 -0
- package/src/plans/live.ts +336 -0
- package/src/plans/markdown-html.ts +140 -0
- package/src/plans/options.ts +15 -0
- package/src/plans/phase-source.ts +71 -0
- package/src/plans/plan-chips.ts +75 -0
- package/src/plans/plan-panes.ts +206 -0
- package/src/plans/planned.ts +117 -0
- package/src/plans/pull-requests.ts +107 -0
- package/src/plans/restart-decision.ts +42 -0
- package/src/plans/selection.ts +352 -0
- package/src/plans/settings.ts +68 -0
- package/src/plans/single-flight.ts +25 -0
- package/src/plans/snapshot.ts +300 -0
- package/src/plans/sprint-export.ts +103 -0
- package/src/plans/sprint-html.ts +301 -0
- package/src/plans/sprint-page.ts +287 -0
- package/src/plans/supervisor.ts +124 -0
- package/src/plans/symbols.ts +54 -0
- package/src/plans/texts.ts +103 -0
- package/src/plans/unlisted-lanes.ts +45 -0
- package/src/plans/view.ts +68 -0
- package/src/plans/worked-phases.ts +53 -0
- package/src/sync/checksum.ts +13 -0
- package/src/sync/choice.ts +14 -0
- package/src/sync/command-context.ts +113 -0
- package/src/sync/command.ts +94 -0
- package/src/sync/error-message.ts +1 -0
- package/src/sync/gitignore.ts +45 -0
- package/src/sync/global-kit.ts +176 -0
- package/src/sync/herdr-plugin.ts +175 -0
- package/src/sync/if-changed.ts +109 -0
- package/src/sync/init-command.ts +239 -0
- package/src/sync/init.ts +136 -0
- package/src/sync/kit-hooks.ts +18 -0
- package/src/sync/kit-path.ts +77 -0
- package/src/sync/kit-source.ts +169 -0
- package/src/sync/kit.ts +9 -0
- package/src/sync/legacy-files.ts +116 -0
- package/src/sync/manifest.ts +44 -0
- package/src/sync/next-steps.ts +42 -0
- package/src/sync/plan-file.ts +154 -0
- package/src/sync/plan-hooks.ts +165 -0
- package/src/sync/project-fs.ts +132 -0
- package/src/sync/render.ts +60 -0
- package/src/sync/report.ts +96 -0
- package/src/sync/settings-hooks.ts +90 -0
- package/src/sync/settings-migration.ts +94 -0
- package/src/sync/settings-offer.ts +168 -0
- package/src/sync/setup-questions.ts +70 -0
- package/src/sync/setup.ts +52 -0
- package/src/sync/sync-plan.ts +120 -0
- package/src/sync/sync-result.ts +9 -0
- package/src/sync/sync.ts +307 -0
- package/src/sync/tools.ts +52 -0
- package/src/terminal/herdr.ts +91 -0
- package/src/terminal/index.ts +13 -0
- package/src/terminal/none.ts +7 -0
- package/src/terminal/open.ts +39 -0
- package/src/terminal/text.ts +159 -0
- package/src/terminal/types.ts +30 -0
- package/src/terminal/width.ts +90 -0
- package/src/update/command.ts +65 -0
- package/src/update/package-manager.ts +56 -0
- package/src/update/update.ts +105 -0
- package/src/version/installed-version.ts +32 -0
- package/src/version/release.ts +22 -0
- package/src/version/skill-diff.ts +63 -0
- package/src/version/started-kit.ts +14 -0
- package/src/vocabulary/index.ts +132 -0
- package/src/watch/base-tip.ts +18 -0
- package/src/watch/command.ts +919 -0
- package/src/watch/coordinator-fill.ts +76 -0
- package/src/watch/feedback-read.ts +73 -0
- package/src/watch/feedback.ts +308 -0
- package/src/watch/gh.ts +22 -0
- package/src/watch/github-read.ts +175 -0
- package/src/watch/herdr.ts +11 -0
- package/src/watch/json-guards.ts +9 -0
- package/src/watch/kit-update.ts +18 -0
- package/src/watch/lane-fill.ts +36 -0
- package/src/watch/lane-message.ts +99 -0
- package/src/watch/lane-progress.ts +44 -0
- package/src/watch/lane-report-file.ts +53 -0
- package/src/watch/lane-report.ts +270 -0
- package/src/watch/lane-search.ts +41 -0
- package/src/watch/lane-sources.ts +46 -0
- package/src/watch/lane-watch.ts +121 -0
- package/src/watch/monitor-tool.ts +19 -0
- package/src/watch/options.ts +79 -0
- package/src/watch/plan-badges-read.ts +30 -0
- package/src/watch/plan-badges.ts +66 -0
- package/src/watch/plan-views.ts +59 -0
- package/src/watch/preview-read.ts +60 -0
- package/src/watch/previews.ts +234 -0
- package/src/watch/pull-requests.ts +117 -0
- package/src/watch/single-report.ts +15 -0
- package/src/watch/skill-change.ts +61 -0
- package/src/watch/source-dry-run-command.ts +35 -0
- package/src/watch/source-dry-run.ts +124 -0
- package/src/watch/source-fields.ts +9 -0
- package/src/watch/source-path.ts +40 -0
- package/src/watch/source-run.ts +90 -0
- package/src/watch/sources.ts +344 -0
- package/src/watch/stream-output.ts +14 -0
- package/src/watch/texts.ts +76 -0
- package/src/watch/watch-events.ts +399 -0
- package/src/watch/watcher-lock.ts +145 -0
- package/src/watch/workflow-runs.ts +96 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { contextLabel } from '../context'
|
|
2
|
+
import { CLI_NAME } from '../version/release'
|
|
3
|
+
import type { LinkChip } from './link-chips'
|
|
4
|
+
import { renderPlanMarkdown, scrollable } from './markdown-html'
|
|
5
|
+
import { phaseList, plannedPhaseCount } from './planned'
|
|
6
|
+
import type {
|
|
7
|
+
PageLane,
|
|
8
|
+
PagePhase,
|
|
9
|
+
PagePlanDocument,
|
|
10
|
+
PagePlanned,
|
|
11
|
+
PagePlanRow,
|
|
12
|
+
PagePlanTable,
|
|
13
|
+
PageStep,
|
|
14
|
+
PlanRef,
|
|
15
|
+
SprintPage,
|
|
16
|
+
} from './sprint-page'
|
|
17
|
+
import { phaseCountOf, phaseStateOf, statusSymbol, statusWord } from './symbols'
|
|
18
|
+
import { laneMarkText, TEXTS } from './texts'
|
|
19
|
+
|
|
20
|
+
const BAR_PERCENT = 100
|
|
21
|
+
|
|
22
|
+
const STYLE = `
|
|
23
|
+
:root { color-scheme: light dark; --bg: #fafaf9; --panel: #ffffff; --fg: #1c1917; --muted: #78716c; --line: #e7e5e4;
|
|
24
|
+
--accent: #2563eb; --ok: #15803d; --warn: #b45309; --bad: #b91c1c; --bar: #e7e5e4; }
|
|
25
|
+
@media (prefers-color-scheme: dark) { :root { --bg: #131315; --panel: #1c1c1f; --fg: #e7e5e4; --muted: #a8a29e;
|
|
26
|
+
--line: #2e2e33; --accent: #60a5fa; --ok: #4ade80; --warn: #fbbf24; --bad: #f87171; --bar: #2e2e33; } }
|
|
27
|
+
* { box-sizing: border-box; }
|
|
28
|
+
body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.5 system-ui, sans-serif; }
|
|
29
|
+
main { max-width: 1040px; margin: 0 auto; padding: 24px 16px 64px; }
|
|
30
|
+
a { color: var(--accent); }
|
|
31
|
+
code { font: 0.9em ui-monospace, monospace; background: var(--line); padding: 1px 4px; border-radius: 4px; }
|
|
32
|
+
h1 { margin: 0 0 4px; font-size: 1.6rem; }
|
|
33
|
+
h2 { margin: 32px 0 12px; font-size: 1.15rem; }
|
|
34
|
+
h3 { margin: 16px 0 4px; font-size: 1rem; }
|
|
35
|
+
.meta, .muted { color: var(--muted); font-size: 0.9rem; }
|
|
36
|
+
.goal p { margin: 8px 0; white-space: pre-line; }
|
|
37
|
+
.lane { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; margin: 12px 0; }
|
|
38
|
+
.lane > summary { display: flex; flex-wrap: wrap; gap: 4px 12px; align-items: center; padding: 10px 14px; cursor: pointer; }
|
|
39
|
+
.lane.closed { opacity: 0.6; }
|
|
40
|
+
.name { font-size: 1.02rem; }
|
|
41
|
+
.word { color: var(--muted); }
|
|
42
|
+
.context { color: var(--muted); font-size: 0.9rem; font-variant-numeric: tabular-nums; }
|
|
43
|
+
.context.warn { color: var(--warn); } .context.alert { color: var(--bad); }
|
|
44
|
+
.bar { display: inline-block; width: 120px; height: 8px; background: var(--bar); border-radius: 4px; overflow: hidden; }
|
|
45
|
+
.bar i { display: block; height: 100%; background: var(--ok); }
|
|
46
|
+
.count { font-variant-numeric: tabular-nums; }
|
|
47
|
+
.grow { flex: 1; }
|
|
48
|
+
.lane-body { padding: 0 14px 14px; border-top: 1px solid var(--line); }
|
|
49
|
+
.step { margin-top: 12px; }
|
|
50
|
+
.step-meta { margin: 0; color: var(--muted); font-size: 0.88rem; }
|
|
51
|
+
.note { margin: 4px 0 0; }
|
|
52
|
+
ul.phases { list-style: none; margin: 6px 0 0; padding: 0; }
|
|
53
|
+
.phase { padding: 4px 0; border-top: 1px dashed var(--line); }
|
|
54
|
+
.phase:first-child { border-top: 0; }
|
|
55
|
+
.phase .count { display: inline-block; min-width: 4.5em; margin-right: .4em; font-variant-numeric: tabular-nums; }
|
|
56
|
+
.state-all .count { color: var(--ok); }
|
|
57
|
+
.state-missing, .state-empty { color: var(--muted); }
|
|
58
|
+
.deps { color: var(--muted); font-size: 0.85rem; margin-left: 1.4em; }
|
|
59
|
+
details.items { margin-left: 1.4em; }
|
|
60
|
+
details.items summary { cursor: pointer; color: var(--muted); font-size: 0.88rem; }
|
|
61
|
+
ul.items { margin: 4px 0 0; padding-left: 1.2em; }
|
|
62
|
+
.pr { margin: 2px 0; }
|
|
63
|
+
.checks-passed { color: var(--ok); } .checks-failed { color: var(--bad); } .checks-pending { color: var(--warn); }
|
|
64
|
+
/* tables sit in .table-scroll so a wide one scrolls sideways */
|
|
65
|
+
.planned { opacity: 0.7; margin: 10px 0; }
|
|
66
|
+
.planned > summary { cursor: pointer; }
|
|
67
|
+
.table-scroll { overflow-x: auto; margin: 8px 0; }
|
|
68
|
+
.chips { flex-basis: 100%; display: flex; flex-wrap: wrap; gap: 6px; }
|
|
69
|
+
.chip { display: inline-flex; gap: 4px; align-items: baseline; border: 1px solid var(--line); border-radius: 999px; padding: 1px 10px; font-size: 0.85rem; }
|
|
70
|
+
.chip .icon { margin-right: 4px; }
|
|
71
|
+
table { border-collapse: collapse; width: 100%; background: var(--panel); }
|
|
72
|
+
table.plans { table-layout: fixed; }
|
|
73
|
+
table.plans .bar { display: block; width: 100%; margin-bottom: 2px; }
|
|
74
|
+
.table-caption { margin: 16px 0 4px; }
|
|
75
|
+
.gh { font-size: 0.8rem; color: var(--muted); }
|
|
76
|
+
.plan { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; margin: 10px 0; }
|
|
77
|
+
.plan > summary { display: flex; flex-wrap: wrap; gap: 4px 12px; align-items: center; padding: 10px 14px; cursor: pointer; }
|
|
78
|
+
.plan-body { padding: 0 14px 14px; border-top: 1px solid var(--line); overflow-x: auto; }
|
|
79
|
+
.plan-body h3, .plan-body h4, .plan-body h5 { margin: 18px 0 6px; }
|
|
80
|
+
th, td { border: 1px solid var(--line); padding: 6px 10px; text-align: left; vertical-align: top; }
|
|
81
|
+
th { color: var(--muted); font-weight: 600; font-size: 0.85rem; }
|
|
82
|
+
footer { margin-top: 40px; color: var(--muted); font-size: 0.85rem; }
|
|
83
|
+
`
|
|
84
|
+
|
|
85
|
+
export const escapeHtml = (text: string): string =>
|
|
86
|
+
text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
|
|
87
|
+
|
|
88
|
+
/** A private-use character that cannot occur in escaped text; it keeps a finished link out of the bold and code passes. */
|
|
89
|
+
const PLACEHOLDER = '\uE000'
|
|
90
|
+
const SAFE_LINK = /^https?:\/\//
|
|
91
|
+
const SAFE_TARGET = /^(?:https?|file):\/\/|^#/
|
|
92
|
+
|
|
93
|
+
/** A link target the page may use: http(s) for the web, `file:` for the local checkout, `#` for a place on the page; never a script. */
|
|
94
|
+
export const isSafeTarget = (target: string): boolean => SAFE_TARGET.test(target)
|
|
95
|
+
|
|
96
|
+
const linked = (target: string | null, label: string): string =>
|
|
97
|
+
target !== null && isSafeTarget(target) ? `<a href="${escapeHtml(target)}">${label}</a>` : label
|
|
98
|
+
|
|
99
|
+
/** A plan's title linked to its section on the page, with a small link to its file beside it. */
|
|
100
|
+
const planLink = (plan: PlanRef | null, label: string): string => {
|
|
101
|
+
if (plan === null) return label
|
|
102
|
+
const file = linked(plan.file, 'on GitHub')
|
|
103
|
+
return plan.anchor === null
|
|
104
|
+
? linked(plan.file, label)
|
|
105
|
+
: `${linked(`#${plan.anchor}`, label)} <span class="gh">(${file})</span>`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Escaped text with the markdown a sprint document uses inline: `**bold**`, `` `code` `` and
|
|
110
|
+
* `[text](https://…)`; a link that is not http(s) is shown as its text, never as a link.
|
|
111
|
+
*/
|
|
112
|
+
export const inlineMarkdown = (text: string): string => {
|
|
113
|
+
const anchors: string[] = []
|
|
114
|
+
const withoutLinks = escapeHtml(text).replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label: string, href: string) => {
|
|
115
|
+
anchors.push(SAFE_LINK.test(href) ? `<a href="${href}">${label}</a>` : label)
|
|
116
|
+
return `${PLACEHOLDER}${anchors.length - 1}${PLACEHOLDER}`
|
|
117
|
+
})
|
|
118
|
+
return withoutLinks
|
|
119
|
+
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
120
|
+
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
121
|
+
.replace(new RegExp(`${PLACEHOLDER}(\\d+)${PLACEHOLDER}`, 'g'), (_, index: string) => anchors[Number(index)])
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const plainMarkdown = (text: string): string => text.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/\*\*|`/g, '')
|
|
125
|
+
|
|
126
|
+
const timestamp = (date: Date): string => `${date.toISOString().slice(0, 16).replace('T', ' ')} UTC`
|
|
127
|
+
|
|
128
|
+
const baseRefLine = ({ baseRef }: SprintPage): string => {
|
|
129
|
+
const commit = baseRef.commit ? ` @ <code>${escapeHtml(baseRef.commit)}</code>` : ' (not found)'
|
|
130
|
+
const committed = baseRef.committedAt ? `, committed ${escapeHtml(baseRef.committedAt)}` : ''
|
|
131
|
+
const fetched = baseRef.fetchedAt ? `last fetch ${timestamp(baseRef.fetchedAt)}` : 'last fetch unknown'
|
|
132
|
+
return `${escapeHtml(baseRef.name)}${commit}${committed} · ${fetched}`
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const percent = (done: number, total: number): number => (total === 0 ? 0 : Math.floor((done / total) * BAR_PERCENT))
|
|
136
|
+
|
|
137
|
+
const bar = (done: number, total: number): string =>
|
|
138
|
+
total === 0
|
|
139
|
+
? ''
|
|
140
|
+
: `<span class="bar" role="img" aria-label="${done} of ${total}"><i style="width:${percent(done, total)}%"></i></span> <span class="count">${done}/${total}</span>`
|
|
141
|
+
|
|
142
|
+
const branchLine = ({ branch, ahead, dirty }: PageLane): string => {
|
|
143
|
+
if (!branch) return `<span class="muted">${escapeHtml(TEXTS.noWorktree)}</span>`
|
|
144
|
+
const marks = [ahead ? `↑${ahead}` : '', dirty ? `±${dirty}` : ''].filter(Boolean).join(' ')
|
|
145
|
+
return `⎇ <code>${escapeHtml(branch)}</code>${marks ? ` ${marks}` : ''}`
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const pullRequestLine = (lane: PageLane): string =>
|
|
149
|
+
lane.pullRequests
|
|
150
|
+
.map(
|
|
151
|
+
(pull) =>
|
|
152
|
+
`<div class="pr">↗ ${linked(pull.url, `#${pull.number}`)} <span class="checks-${pull.checks}">${escapeHtml(TEXTS.checks[pull.checks])}</span> ${escapeHtml(pull.draft ? TEXTS.draft : '')}${escapeHtml(pull.title)}</div>`,
|
|
153
|
+
)
|
|
154
|
+
.join('')
|
|
155
|
+
|
|
156
|
+
const phaseTitle = (phase: PagePhase): string =>
|
|
157
|
+
phase.title === null ? `<span class="muted">${escapeHtml(TEXTS.notInPlan)}</span>` : inlineMarkdown(phase.title)
|
|
158
|
+
|
|
159
|
+
const openItems = (phase: PagePhase): string => {
|
|
160
|
+
const open = phase.items.filter((item) => !item.done)
|
|
161
|
+
if (open.length === 0) return ''
|
|
162
|
+
const rows = open.map((item) => `<li>${inlineMarkdown(item.text)}</li>`).join('')
|
|
163
|
+
return `<details class="items"><summary>${open.length} open</summary><ul class="items">${rows}</ul></details>`
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const phaseRow = (phase: PagePhase): string => {
|
|
167
|
+
const state = phaseStateOf(phase)
|
|
168
|
+
const deps = phase.dependsOn
|
|
169
|
+
? `<div class="deps">depends on ${inlineMarkdown(plainMarkdown(phase.dependsOn))}</div>`
|
|
170
|
+
: ''
|
|
171
|
+
return `<li class="phase state-${state}"><span class="count">${phaseCountOf(phase)}</span><strong>P${escapeHtml(phase.id)}</strong> ${phaseTitle(phase)}${deps}${openItems(phase)}</li>`
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const stepBlock = (step: PageStep): string => {
|
|
175
|
+
const title = planLink(step.plan, escapeHtml(step.planTitle))
|
|
176
|
+
const facts = [
|
|
177
|
+
step.model ? `Model: ${escapeHtml(step.model)}` : '',
|
|
178
|
+
step.startsAfter ? `Starts after: ${inlineMarkdown(plainMarkdown(step.startsAfter))}` : '',
|
|
179
|
+
]
|
|
180
|
+
.filter(Boolean)
|
|
181
|
+
.join(' · ')
|
|
182
|
+
const phases = step.phases.length > 0 ? `<ul class="phases">${step.phases.map(phaseRow).join('')}</ul>` : ''
|
|
183
|
+
const note = step.note ? `<p class="note">${inlineMarkdown(step.note)}</p>` : ''
|
|
184
|
+
return `<section class="step"><h3>${title}</h3>${facts ? `<p class="step-meta">${facts}</p>` : ''}${note}${phases}</section>`
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const laneBlock = (lane: PageLane): string => {
|
|
188
|
+
const port = lane.port === null ? '' : `<span class="meta">port ${lane.port}</span>`
|
|
189
|
+
const closed = lane.closed ? '<span class="meta">closed</span>' : ''
|
|
190
|
+
const markText = laneMarkText(lane)
|
|
191
|
+
const mark = markText ? `<span class="meta">${escapeHtml(markText)}</span> ` : ''
|
|
192
|
+
const context = lane.context
|
|
193
|
+
? `<span class="context ${lane.context.level}" title="context window">${contextLabel(lane.context.share)}</span> `
|
|
194
|
+
: ''
|
|
195
|
+
const summary = `<summary><span title="${escapeHtml(statusWord(lane.agent))}">${statusSymbol(lane.agent)}</span> <strong class="name">${escapeHtml(lane.name)}</strong> <span class="word">${escapeHtml(statusWord(lane.agent))}</span> ${port} ${closed}${mark}<span class="grow"></span>${context}${bar(lane.done, lane.total)}</summary>`
|
|
196
|
+
const facts = `<p class="meta">${branchLine(lane)}</p>${pullRequestLine(lane)}`
|
|
197
|
+
return `<details class="lane${lane.closed ? ' closed' : ''}"${lane.closed ? '' : ' open'}>${summary}<div class="lane-body">${facts}${lane.steps.map(stepBlock).join('')}</div></details>`
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const PLAN_LINK_IN_CELL = /\[[^\]]+\]\([^)\s]+\)/
|
|
201
|
+
|
|
202
|
+
const planCell = (cell: string, row: PagePlanRow): string =>
|
|
203
|
+
row.title !== null && PLAN_LINK_IN_CELL.test(cell)
|
|
204
|
+
? cell.replace(PLAN_LINK_IN_CELL, () => planLink(row.plan, escapeHtml(row.title ?? '')))
|
|
205
|
+
: inlineMarkdown(cell)
|
|
206
|
+
|
|
207
|
+
const planProgress = ({ progress }: PagePlanRow): string =>
|
|
208
|
+
progress === null || progress.total === 0
|
|
209
|
+
? '<td class="muted">–</td>'
|
|
210
|
+
: `<td>${bar(progress.done, progress.total)}</td>`
|
|
211
|
+
|
|
212
|
+
/** Column widths shared by every plans table, so tables that belong together line up. */
|
|
213
|
+
const PLAN_COLUMNS = ['4%', '24%', '34%', '30%', '8%']
|
|
214
|
+
|
|
215
|
+
const planColumns = (count: number): string =>
|
|
216
|
+
`<colgroup>${PLAN_COLUMNS.slice(0, count)
|
|
217
|
+
.map((width) => `<col style="width:${width}">`)
|
|
218
|
+
.join('')}</colgroup>`
|
|
219
|
+
|
|
220
|
+
const planTable = ({ caption, header, rows }: PagePlanTable): string => {
|
|
221
|
+
const head = `${header.map((cell) => `<th>${escapeHtml(cell)}</th>`).join('')}<th>Checkboxes</th>`
|
|
222
|
+
const body = rows
|
|
223
|
+
.map((row) => `<tr>${row.cells.map((cell) => `<td>${planCell(cell, row)}</td>`).join('')}${planProgress(row)}</tr>`)
|
|
224
|
+
.join('')
|
|
225
|
+
const title = caption ? `<p class="table-caption">${inlineMarkdown(caption)}</p>` : ''
|
|
226
|
+
return `${title}${scrollable(`<table class="plans">${planColumns(header.length + 1)}<thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`)}`
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const plansSection = ({ plans }: SprintPage): string =>
|
|
230
|
+
plans.length === 0 ? '' : `<h2>Plans</h2>${plans.map(planTable).join('')}`
|
|
231
|
+
|
|
232
|
+
const plannedRow = ({ title, plan, phaseIds, done, total }: PagePlanned): string =>
|
|
233
|
+
`<li>${planLink(plan, escapeHtml(title))} <span class="muted">${escapeHtml(phaseList({ phaseIds }))}</span> ${bar(done, total)}</li>`
|
|
234
|
+
|
|
235
|
+
/** The open phases no lane works on, dimmed and closed, so the page keeps its focus on the active lanes. */
|
|
236
|
+
const plannedSection = ({ planned }: SprintPage): string => {
|
|
237
|
+
if (planned.length === 0) return ''
|
|
238
|
+
const phases = plannedPhaseCount(planned)
|
|
239
|
+
const summary = `<summary>○ ${escapeHtml(TEXTS.planned(phases, planned.length))}</summary>`
|
|
240
|
+
return `<details class="planned">${summary}<ul class="phases">${planned.map(plannedRow).join('')}</ul></details>`
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const chip = (link: LinkChip): string => {
|
|
244
|
+
const hint = link.private ? ' <span class="muted">privat</span>' : ''
|
|
245
|
+
return `<span class="chip">${linked(link.url, `<span class="icon">${escapeHtml(link.icon)}</span>${escapeHtml(link.label)}`)}${hint}</span>`
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The markdown of one plan section as HTML, kept by `renderPlanMarkdown`; calling it ahead of `renderSprintHtml` moves the work out of the render. */
|
|
249
|
+
export const renderPlanBody = (doc: PagePlanDocument, page: SprintPage): string =>
|
|
250
|
+
renderPlanMarkdown(doc.markdown, { fromPath: doc.repoPath, ...page.links }, doc.anchor, doc.title)
|
|
251
|
+
|
|
252
|
+
const planDocument = (doc: PagePlanDocument, page: SprintPage): string => {
|
|
253
|
+
const status = doc.status ? `<span class="muted">${inlineMarkdown(doc.status)}</span>` : ''
|
|
254
|
+
const progress = doc.progress.total > 0 ? bar(doc.progress.done, doc.progress.total) : ''
|
|
255
|
+
const chips = doc.chips.length > 0 ? `<span class="chips">${doc.chips.map(chip).join('')}</span>` : ''
|
|
256
|
+
const summary = `<summary><strong>${escapeHtml(doc.title)}</strong> ${status}<span class="grow"></span>${progress}${chips}</summary>`
|
|
257
|
+
const body = renderPlanBody(doc, page)
|
|
258
|
+
return `<details class="plan">${summary}<div class="plan-body"><a id="${escapeHtml(doc.anchor)}"></a><p class="meta">${linked(doc.file, 'on GitHub')}</p>${body}</div></details>`
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const planDocuments = (page: SprintPage): string =>
|
|
262
|
+
page.planDocuments.length === 0
|
|
263
|
+
? ''
|
|
264
|
+
: `<h2>Plan documents</h2>${page.planDocuments.map((doc) => planDocument(doc, page)).join('\n')}`
|
|
265
|
+
|
|
266
|
+
const historyTable = ({ document }: SprintPage): string => {
|
|
267
|
+
const { header, rows } = document.history
|
|
268
|
+
if (rows.length === 0) return ''
|
|
269
|
+
const head = header.map((cell) => `<th>${escapeHtml(cell)}</th>`).join('')
|
|
270
|
+
const body = rows.map((row) => `<tr>${row.map((cell) => `<td>${inlineMarkdown(cell)}</td>`).join('')}</tr>`).join('')
|
|
271
|
+
return `<h2>History</h2>${scrollable(`<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`)}`
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** One page for the whole board: one file, inline CSS, light and dark by the reader's setting, no script. */
|
|
275
|
+
export const renderSprintHtml = (page: SprintPage): string => {
|
|
276
|
+
const goal = page.document.goal.map((paragraph) => `<p>${inlineMarkdown(paragraph)}</p>`).join('')
|
|
277
|
+
return `<!doctype html>
|
|
278
|
+
<html lang="en">
|
|
279
|
+
<head>
|
|
280
|
+
<meta charset="utf-8">
|
|
281
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
282
|
+
<title>${escapeHtml(page.title)}</title>
|
|
283
|
+
<style>${STYLE}</style>
|
|
284
|
+
</head>
|
|
285
|
+
<body>
|
|
286
|
+
<main>
|
|
287
|
+
<h1>${escapeHtml(page.title)}</h1>
|
|
288
|
+
<p class="meta">Snapshot of ${timestamp(page.generatedAt)} · ${baseRefLine(page)}</p>
|
|
289
|
+
${goal ? `<section class="goal">${goal}</section>` : ''}
|
|
290
|
+
${plansSection(page)}
|
|
291
|
+
<h2>Lanes</h2>
|
|
292
|
+
${page.lanes.map(laneBlock).join('\n')}
|
|
293
|
+
${plannedSection(page)}
|
|
294
|
+
${historyTable(page)}
|
|
295
|
+
${planDocuments(page)}
|
|
296
|
+
<footer>Generated by <code>${CLI_NAME} plans</code>. A snapshot, not a source: the markdown files stay the truth.</footer>
|
|
297
|
+
</main>
|
|
298
|
+
</body>
|
|
299
|
+
</html>
|
|
300
|
+
`
|
|
301
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { join, posix } from 'node:path'
|
|
2
|
+
import { pathToFileURL } from 'node:url'
|
|
3
|
+
import type { LinkKind } from '../config/link-kinds'
|
|
4
|
+
import type { ContextReading } from '../context'
|
|
5
|
+
import {
|
|
6
|
+
type ChecklistItem,
|
|
7
|
+
type PhaseProgress,
|
|
8
|
+
parsePlanPhases,
|
|
9
|
+
readPhaseDependencies,
|
|
10
|
+
readPlanHeader,
|
|
11
|
+
readSprintDocument,
|
|
12
|
+
resolvePlanPath,
|
|
13
|
+
type SprintDocument,
|
|
14
|
+
} from '../plan-format'
|
|
15
|
+
import type { PlanLanguage } from '../vocabulary'
|
|
16
|
+
import type { LaneView } from './layout'
|
|
17
|
+
import { extractLinkChips, type LinkChip } from './link-chips'
|
|
18
|
+
import { planAnchorOf } from './markdown-html'
|
|
19
|
+
import type { PullRequest } from './pull-requests'
|
|
20
|
+
import type { Snapshot } from './snapshot'
|
|
21
|
+
|
|
22
|
+
/** The base ref the page was read from: its name, its commit and when this clone last fetched it. */
|
|
23
|
+
export type BaseRefInfo = { name: string; commit: string | null; committedAt: string | null; fetchedAt: Date | null }
|
|
24
|
+
|
|
25
|
+
/** What the page needs besides the snapshot; read by the caller, so the page itself is a pure function. */
|
|
26
|
+
export type PageContext = {
|
|
27
|
+
/** The main checkout, so a plan link can be a `file:` URL when the remote is unknown. */
|
|
28
|
+
root: string
|
|
29
|
+
generatedAt: Date
|
|
30
|
+
baseRef: BaseRefInfo
|
|
31
|
+
/** `https://github.com/<owner>/<repo>/blob/<branch>/`, null when the remote is unknown. */
|
|
32
|
+
repoBlobBase: string | null
|
|
33
|
+
/** The directory of the sprint document, relative to the repository. */
|
|
34
|
+
sprintDirectory: string
|
|
35
|
+
language: PlanLanguage
|
|
36
|
+
/** The kinds of link a plan's text is searched for, shown as chips on the plan's section. */
|
|
37
|
+
linkKinds: readonly LinkKind[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type PagePhase = {
|
|
41
|
+
id: string
|
|
42
|
+
/** Null when the plan does not have the phase. */
|
|
43
|
+
title: string | null
|
|
44
|
+
done: number
|
|
45
|
+
total: number
|
|
46
|
+
items: ChecklistItem[]
|
|
47
|
+
dependsOn: string | null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* How a plan is reached from the page: the id of its section when the page carries the plan, and its file on
|
|
52
|
+
* GitHub (a `file:` URL of the checkout when the remote is unknown).
|
|
53
|
+
*/
|
|
54
|
+
export type PlanRef = { anchor: string | null; file: string }
|
|
55
|
+
|
|
56
|
+
export type PageStep = {
|
|
57
|
+
planTitle: string
|
|
58
|
+
plan: PlanRef | null
|
|
59
|
+
model: string
|
|
60
|
+
startsAfter: string
|
|
61
|
+
note: string
|
|
62
|
+
phases: PagePhase[]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type PageLane = {
|
|
66
|
+
name: string
|
|
67
|
+
port: number | null
|
|
68
|
+
closed: boolean
|
|
69
|
+
agent: string | null
|
|
70
|
+
/** How full the agent's context is; absent when the lane has no reading. */
|
|
71
|
+
context?: ContextReading | null
|
|
72
|
+
branch: string | null
|
|
73
|
+
ahead: number | null
|
|
74
|
+
dirty: number
|
|
75
|
+
pullRequests: PullRequest[]
|
|
76
|
+
steps: PageStep[]
|
|
77
|
+
done: number
|
|
78
|
+
total: number
|
|
79
|
+
/** A running lane the sprint table has no row for; absent for a lane the table lists. */
|
|
80
|
+
unlisted?: boolean
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type PagePlanRow = {
|
|
84
|
+
cells: string[]
|
|
85
|
+
title: string | null
|
|
86
|
+
plan: PlanRef | null
|
|
87
|
+
/** Ticked and all checkboxes over the plan's phases; null when the plan could not be read. */
|
|
88
|
+
progress: { done: number; total: number } | null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type PagePlanTable = { caption: string | null; header: string[]; rows: PagePlanRow[] }
|
|
92
|
+
|
|
93
|
+
/** A plan the page carries in full: its section, what its header says, and the markdown to render. */
|
|
94
|
+
export type PagePlanDocument = {
|
|
95
|
+
anchor: string
|
|
96
|
+
/** Repository path of the plan file. */
|
|
97
|
+
repoPath: string
|
|
98
|
+
title: string
|
|
99
|
+
status: string
|
|
100
|
+
progress: { done: number; total: number }
|
|
101
|
+
file: string
|
|
102
|
+
markdown: string
|
|
103
|
+
chips: LinkChip[]
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A plan with open phases no lane with an agent leads, as the page lists it; `plan` links its section or its file. */
|
|
107
|
+
export type PagePlanned = {
|
|
108
|
+
title: string
|
|
109
|
+
plan: PlanRef
|
|
110
|
+
phaseIds: string[]
|
|
111
|
+
done: number
|
|
112
|
+
total: number
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type SprintPage = {
|
|
116
|
+
title: string
|
|
117
|
+
generatedAt: Date
|
|
118
|
+
baseRef: BaseRefInfo
|
|
119
|
+
document: SprintDocument
|
|
120
|
+
plans: PagePlanTable[]
|
|
121
|
+
lanes: PageLane[]
|
|
122
|
+
planDocuments: PagePlanDocument[]
|
|
123
|
+
planned: PagePlanned[]
|
|
124
|
+
/** What the renderer needs to point a link of a plan at the right place. */
|
|
125
|
+
links: { anchors: ReadonlyMap<string, string>; blobBase: string | null; root: string }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const fileLink = (path: string, context: PageContext): string =>
|
|
129
|
+
context.repoBlobBase ? `${context.repoBlobBase}${path}` : pathToFileURL(join(context.root, path)).href
|
|
130
|
+
|
|
131
|
+
/** The plans of the page by link as the sprint writes it: those whose text was read have a section. */
|
|
132
|
+
const planRefs = (snapshot: Snapshot, hrefs: readonly string[], context: PageContext): Map<string, PlanRef> => {
|
|
133
|
+
const taken = new Set<string>()
|
|
134
|
+
const refs = new Map<string, PlanRef>()
|
|
135
|
+
for (const href of hrefs) {
|
|
136
|
+
if (refs.has(href)) continue
|
|
137
|
+
const path = resolvePlanPath(context.sprintDirectory, href)
|
|
138
|
+
const base = planAnchorOf(path)
|
|
139
|
+
const anchor = href in snapshot.documents.plans ? uniqueId(base, taken) : null
|
|
140
|
+
refs.set(href, { anchor, file: fileLink(path, context) })
|
|
141
|
+
}
|
|
142
|
+
return refs
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const uniqueId = (base: string, taken: Set<string>): string => {
|
|
146
|
+
let id = base
|
|
147
|
+
for (let count = 2; taken.has(id); count += 1) id = `${base}-${count}`
|
|
148
|
+
taken.add(id)
|
|
149
|
+
return id
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const phaseOf = (phase: PhaseProgress, dependencies: Record<string, string>): PagePhase => {
|
|
153
|
+
const numbered = phase.id.replace(/[a-z]$/, '')
|
|
154
|
+
return {
|
|
155
|
+
id: phase.id,
|
|
156
|
+
title: phase.title,
|
|
157
|
+
done: phase.done,
|
|
158
|
+
total: phase.total,
|
|
159
|
+
items: phase.items,
|
|
160
|
+
dependsOn: dependencies[phase.id] ?? dependencies[numbered] ?? null,
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
type DependenciesByPlan = ReadonlyMap<string, Record<string, string>>
|
|
165
|
+
|
|
166
|
+
const laneOf = (view: LaneView, dependencies: DependenciesByPlan, refs: ReadonlyMap<string, PlanRef>): PageLane => {
|
|
167
|
+
const { progress, status, pullRequests } = view
|
|
168
|
+
return {
|
|
169
|
+
name: progress.lane.name,
|
|
170
|
+
port: progress.lane.port,
|
|
171
|
+
closed: progress.lane.closed,
|
|
172
|
+
agent: status.agent,
|
|
173
|
+
context: view.context ?? null,
|
|
174
|
+
branch: status.branch,
|
|
175
|
+
ahead: status.ahead,
|
|
176
|
+
dirty: status.dirty,
|
|
177
|
+
pullRequests,
|
|
178
|
+
done: progress.done,
|
|
179
|
+
total: progress.total,
|
|
180
|
+
...(view.unlisted ? { unlisted: true } : {}),
|
|
181
|
+
steps: progress.steps.map(({ step, phases }) => ({
|
|
182
|
+
planTitle: step.planTitle,
|
|
183
|
+
plan: step.planHref ? (refs.get(step.planHref) ?? null) : null,
|
|
184
|
+
model: step.model,
|
|
185
|
+
startsAfter: step.startsAfter,
|
|
186
|
+
note: step.label,
|
|
187
|
+
phases: phases.map((phase) => phaseOf(phase, (step.planHref && dependencies.get(step.planHref)) || {})),
|
|
188
|
+
})),
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const progressOf = (text: string | undefined, language: PlanLanguage): PagePlanRow['progress'] => {
|
|
193
|
+
if (text === undefined) return null
|
|
194
|
+
const items = parsePlanPhases(text, language).flatMap((phase) => phase.items)
|
|
195
|
+
return { done: items.filter((item) => item.done).length, total: items.length }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The plans the sprint lists, as the sprint lists them, with how far each plan's checkboxes are ticked. */
|
|
199
|
+
const planTablesOf = (
|
|
200
|
+
snapshot: Snapshot,
|
|
201
|
+
document: SprintDocument,
|
|
202
|
+
refs: ReadonlyMap<string, PlanRef>,
|
|
203
|
+
context: PageContext,
|
|
204
|
+
): PagePlanTable[] =>
|
|
205
|
+
document.planTables.map(({ caption, header, rows }) => ({
|
|
206
|
+
caption,
|
|
207
|
+
header,
|
|
208
|
+
rows: rows.map((row) => ({
|
|
209
|
+
cells: row.cells,
|
|
210
|
+
title: row.title,
|
|
211
|
+
plan: row.href ? (refs.get(row.href) ?? null) : null,
|
|
212
|
+
progress: progressOf(row.href ? snapshot.documents.plans[row.href] : undefined, context.language),
|
|
213
|
+
})),
|
|
214
|
+
}))
|
|
215
|
+
|
|
216
|
+
const documentsOf = (
|
|
217
|
+
snapshot: Snapshot,
|
|
218
|
+
refs: ReadonlyMap<string, PlanRef>,
|
|
219
|
+
context: PageContext,
|
|
220
|
+
): PagePlanDocument[] =>
|
|
221
|
+
[...refs.entries()].flatMap(([href, ref]) => {
|
|
222
|
+
const markdown = snapshot.documents.plans[href]
|
|
223
|
+
if (markdown === undefined || ref.anchor === null) return []
|
|
224
|
+
const repoPath = resolvePlanPath(context.sprintDirectory, href)
|
|
225
|
+
const header = readPlanHeader(markdown, context.language, posix.basename(repoPath, '.md'))
|
|
226
|
+
return [
|
|
227
|
+
{
|
|
228
|
+
anchor: ref.anchor,
|
|
229
|
+
repoPath,
|
|
230
|
+
title: header.title,
|
|
231
|
+
status: header.status,
|
|
232
|
+
progress: progressOf(markdown, context.language) ?? { done: 0, total: 0 },
|
|
233
|
+
file: ref.file,
|
|
234
|
+
markdown,
|
|
235
|
+
chips: extractLinkChips(markdown, context.linkKinds),
|
|
236
|
+
},
|
|
237
|
+
]
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
/** The links the page names plans by, in the order the sprint names them: its plans tables first, then the lanes. */
|
|
241
|
+
const hrefsOf = (document: SprintDocument, views: readonly LaneView[]): string[] => [
|
|
242
|
+
...document.planTables.flatMap(({ rows }) => rows.flatMap((row) => (row.href ? [row.href] : []))),
|
|
243
|
+
...views.flatMap(({ progress }) => progress.steps.flatMap(({ step }) => (step.planHref ? [step.planHref] : []))),
|
|
244
|
+
]
|
|
245
|
+
|
|
246
|
+
/** The whole sprint as a page: every lane of the table in table order, closed and waiting ones too. */
|
|
247
|
+
export const buildSprintPage = (snapshot: Snapshot, context: PageContext): SprintPage => {
|
|
248
|
+
const order = snapshot.documents.laneOrder
|
|
249
|
+
const views = [...snapshot.views].sort(
|
|
250
|
+
(left, right) => order.indexOf(left.progress.lane.name) - order.indexOf(right.progress.lane.name),
|
|
251
|
+
)
|
|
252
|
+
const dependencies = new Map(
|
|
253
|
+
Object.entries(snapshot.documents.plans).map(([href, text]) => [
|
|
254
|
+
href,
|
|
255
|
+
readPhaseDependencies(text, context.language),
|
|
256
|
+
]),
|
|
257
|
+
)
|
|
258
|
+
const document = readSprintDocument(snapshot.documents.sprint, context.language)
|
|
259
|
+
const refs = planRefs(snapshot, hrefsOf(document, views), context)
|
|
260
|
+
const planDocuments = documentsOf(snapshot, refs, context)
|
|
261
|
+
const anchors = new Map(planDocuments.map((doc) => [doc.repoPath, doc.anchor]))
|
|
262
|
+
return {
|
|
263
|
+
title: snapshot.title,
|
|
264
|
+
generatedAt: context.generatedAt,
|
|
265
|
+
baseRef: context.baseRef,
|
|
266
|
+
document,
|
|
267
|
+
plans: planTablesOf(snapshot, document, refs, context),
|
|
268
|
+
lanes: views.map((view) => laneOf(view, dependencies, refs)),
|
|
269
|
+
planDocuments,
|
|
270
|
+
planned: snapshot.planned.map(({ path, title, phaseIds, done, total }) => ({
|
|
271
|
+
title,
|
|
272
|
+
plan: { anchor: anchors.get(path) ?? null, file: fileLink(path, context) },
|
|
273
|
+
phaseIds,
|
|
274
|
+
done,
|
|
275
|
+
total,
|
|
276
|
+
})),
|
|
277
|
+
links: {
|
|
278
|
+
anchors,
|
|
279
|
+
blobBase: context.repoBlobBase,
|
|
280
|
+
root: context.root,
|
|
281
|
+
},
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** The file the page is written to: `<temporary directory>/jackherd/<board directory>.html`. */
|
|
286
|
+
export const sprintPagePath = (temporaryDirectory: string, sprintDirectory: string): string =>
|
|
287
|
+
join(temporaryDirectory, 'jackherd', `${posix.basename(sprintDirectory) || 'board'}.html`)
|