@vintasoftware/pr-review-canvas 0.1.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/LICENSE +202 -0
- package/README.md +192 -0
- package/bin/pr-review.mjs +5 -0
- package/docs/reference.md +340 -0
- package/package.json +74 -0
- package/pr-review.config.example.yml +68 -0
- package/prompts/chat-seed.md +64 -0
- package/prompts/generation-format.md +255 -0
- package/prompts/generation-strict.md +34 -0
- package/prompts/generation-surfacing.md +67 -0
- package/prompts/layers-default.md +13 -0
- package/prompts/quality-standards.md +32 -0
- package/skills/pr-review-canvas/SKILL.md +177 -0
- package/src/acpx/acpx.ts +530 -0
- package/src/acpx/agents.ts +85 -0
- package/src/acpx/events.ts +216 -0
- package/src/acpx/ndjson.ts +69 -0
- package/src/acpx/preflight.ts +44 -0
- package/src/canvas/export.ts +95 -0
- package/src/canvas/import.ts +138 -0
- package/src/canvas/name.ts +55 -0
- package/src/canvas/zip.ts +123 -0
- package/src/chat/chat-manager.ts +389 -0
- package/src/chat/context.ts +160 -0
- package/src/chat/seed.ts +71 -0
- package/src/chat/threads.ts +114 -0
- package/src/cli.ts +199 -0
- package/src/commands.ts +424 -0
- package/src/config.ts +142 -0
- package/src/contract/api.ts +190 -0
- package/src/contract/canvas-manifest.ts +29 -0
- package/src/contract/chat.ts +76 -0
- package/src/contract/comments.ts +96 -0
- package/src/contract/discovery.ts +20 -0
- package/src/contract/generation-context.ts +77 -0
- package/src/contract/keys.ts +14 -0
- package/src/contract/links.ts +5 -0
- package/src/contract/mermaid-fences.ts +4 -0
- package/src/contract/review-artifact.ts +324 -0
- package/src/contract/settings.ts +144 -0
- package/src/contract/state.ts +46 -0
- package/src/contract/validation.ts +43 -0
- package/src/git/diff-collector.ts +151 -0
- package/src/git/git.ts +115 -0
- package/src/git/lang.ts +1 -0
- package/src/git/materialize.ts +79 -0
- package/src/git/patch-lines.ts +60 -0
- package/src/github/attachments.ts +288 -0
- package/src/github/capabilities.ts +112 -0
- package/src/github/comments.ts +132 -0
- package/src/github/gh.ts +196 -0
- package/src/github/post-comment.ts +104 -0
- package/src/github/post-review.ts +44 -0
- package/src/github/pr.ts +133 -0
- package/src/github/review-body.ts +72 -0
- package/src/github/threads.ts +63 -0
- package/src/paths.ts +10 -0
- package/src/project-config.ts +219 -0
- package/src/prompt-files.ts +26 -0
- package/src/review/diagram-nodes.ts +227 -0
- package/src/review/doctor.ts +139 -0
- package/src/review/glob.ts +33 -0
- package/src/review/install-skill.ts +107 -0
- package/src/review/normalize.ts +209 -0
- package/src/review/prepare.ts +165 -0
- package/src/review/prompt.ts +233 -0
- package/src/review/publish.ts +209 -0
- package/src/review/skill-command.ts +4 -0
- package/src/review/test-paths.ts +32 -0
- package/src/review/text-length.ts +15 -0
- package/src/review/trim-caps.ts +114 -0
- package/src/review/validate-folds.ts +110 -0
- package/src/review/validate.ts +520 -0
- package/src/server/app.ts +46 -0
- package/src/server/bundle.ts +266 -0
- package/src/server/capped-body.ts +62 -0
- package/src/server/context.ts +174 -0
- package/src/server/env.ts +7 -0
- package/src/server/errors.ts +65 -0
- package/src/server/html.ts +140 -0
- package/src/server/node-server.ts +42 -0
- package/src/server/routes/api.ts +256 -0
- package/src/server/routes/chat-routes.ts +221 -0
- package/src/server/routes/pages.ts +64 -0
- package/src/server/routes/review-routes.ts +245 -0
- package/src/server/routes/static.ts +114 -0
- package/src/server/security.ts +104 -0
- package/src/server/sse.ts +67 -0
- package/src/store/atomic-json.ts +68 -0
- package/src/store/canvas-store.ts +120 -0
- package/src/store/data-dir.ts +29 -0
- package/src/store/derived-store.ts +93 -0
- package/src/store/pr-store.ts +69 -0
- package/src/store/settings-store.ts +152 -0
- package/src/store/state-store.ts +121 -0
- package/static/js/anchors.js +141 -0
- package/static/js/api.js +542 -0
- package/static/js/app.js +418 -0
- package/static/js/ask.js +35 -0
- package/static/js/chat-context.js +137 -0
- package/static/js/chat-scroll.js +114 -0
- package/static/js/chat.js +843 -0
- package/static/js/code-folds.js +200 -0
- package/static/js/commands.js +110 -0
- package/static/js/comment-link.js +37 -0
- package/static/js/composer.js +241 -0
- package/static/js/contract-types.d.ts +59 -0
- package/static/js/deep-link.js +160 -0
- package/static/js/diagram.js +582 -0
- package/static/js/diff-decorations.js +204 -0
- package/static/js/diff-renderer.js +860 -0
- package/static/js/dom.js +145 -0
- package/static/js/download.js +52 -0
- package/static/js/empty-state.js +161 -0
- package/static/js/errors.js +135 -0
- package/static/js/fences.js +90 -0
- package/static/js/header.js +134 -0
- package/static/js/hunks.js +62 -0
- package/static/js/import-zone.js +95 -0
- package/static/js/interactions.js +952 -0
- package/static/js/keyboard.js +131 -0
- package/static/js/keys.js +97 -0
- package/static/js/lang.js +54 -0
- package/static/js/layers.js +596 -0
- package/static/js/links.js +150 -0
- package/static/js/markdown.js +232 -0
- package/static/js/mermaid-fences.js +55 -0
- package/static/js/nav.js +91 -0
- package/static/js/overview.js +85 -0
- package/static/js/points.js +247 -0
- package/static/js/progress.js +49 -0
- package/static/js/proposed-comment.js +133 -0
- package/static/js/quick-questions.js +216 -0
- package/static/js/regenerate.js +69 -0
- package/static/js/review-session.js +257 -0
- package/static/js/scroll-spy.js +66 -0
- package/static/js/selection.js +193 -0
- package/static/js/settings.js +206 -0
- package/static/js/signoff.js +171 -0
- package/static/js/skin.js +56 -0
- package/static/js/store.js +35 -0
- package/static/js/theme.js +56 -0
- package/static/js/threads.js +78 -0
- package/static/js/vendor.d.ts +15 -0
- package/static/styles/base.css +223 -0
- package/static/styles/chat-tools.css +130 -0
- package/static/styles/chat.css +140 -0
- package/static/styles/commands.css +156 -0
- package/static/styles/diff.css +258 -0
- package/static/styles/header.css +114 -0
- package/static/styles/layout.css +123 -0
- package/static/styles/panels.css +152 -0
- package/static/styles/responsive.css +80 -0
- package/static/styles/review-actions.css +124 -0
- package/static/styles/review.css +473 -0
- package/static/styles/skin-github.css +356 -0
- package/static/styles.css +14 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// The canvas link scheme. Four forms, used in markdown, diagrams, and chat:
|
|
3
|
+
// #layer:<layerKey>
|
|
4
|
+
// #file:<path>
|
|
5
|
+
// #hunk:<path>#<n>
|
|
6
|
+
// #line:<path>:<start>[-<end>][:old]
|
|
7
|
+
// The server validates links with this parser (src/contract/links.ts re-exports it) and the
|
|
8
|
+
// browser turns them into anchors, so both sides agree on what a link means.
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {{ kind: 'layer', layerKey: string }
|
|
12
|
+
* | { kind: 'file', path: string }
|
|
13
|
+
* | { kind: 'hunk', path: string, n: number }
|
|
14
|
+
* | { kind: 'line', path: string, start: number, end: number, side: 'new' | 'old' }} ParsedLink
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {{
|
|
19
|
+
* layers: ReadonlyArray<{ key: string }>,
|
|
20
|
+
* files: ReadonlyArray<{ path: string, hunks: ReadonlyArray<import('./hunks.js').HunkRange> }>,
|
|
21
|
+
* }} LinkTargets
|
|
22
|
+
*/
|
|
23
|
+
import { hunkForLine, hunkLineRanges } from './hunks.js'
|
|
24
|
+
|
|
25
|
+
const LINE_RE = /^([^\n]+?):(\d+)(?:-(\d+))?(:old)?$/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Parses one link. Returns null for anything that is not one of the four forms.
|
|
29
|
+
* @param {string} href
|
|
30
|
+
* @returns {ParsedLink | null}
|
|
31
|
+
*/
|
|
32
|
+
export function parseLink(href) {
|
|
33
|
+
if (href.startsWith('#layer:')) {
|
|
34
|
+
const layerKey = href.slice('#layer:'.length)
|
|
35
|
+
return layerKey ? { kind: 'layer', layerKey } : null
|
|
36
|
+
}
|
|
37
|
+
if (href.startsWith('#file:')) {
|
|
38
|
+
const path = href.slice('#file:'.length)
|
|
39
|
+
return path ? { kind: 'file', path } : null
|
|
40
|
+
}
|
|
41
|
+
if (href.startsWith('#hunk:')) {
|
|
42
|
+
const m = /^(.+)#(\d+)$/.exec(href.slice('#hunk:'.length))
|
|
43
|
+
if (!m || m[1] === undefined || m[2] === undefined) {
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
return { kind: 'hunk', path: m[1], n: Number(m[2]) }
|
|
47
|
+
}
|
|
48
|
+
if (href.startsWith('#line:')) {
|
|
49
|
+
const m = LINE_RE.exec(href.slice('#line:'.length))
|
|
50
|
+
if (!m || m[1] === undefined || m[2] === undefined) {
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
const start = Number(m[2])
|
|
54
|
+
const end = m[3] === undefined ? start : Number(m[3])
|
|
55
|
+
if (end < start) {
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
return { kind: 'line', path: m[1], start, end, side: m[4] ? 'old' : 'new' }
|
|
59
|
+
}
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Every link found in a markdown text, in order. Matches the `#…` inside `](…)` and bare links
|
|
65
|
+
* that stand on their own (surrounded by whitespace or at the edges).
|
|
66
|
+
* @param {string} markdown
|
|
67
|
+
* @returns {string[]}
|
|
68
|
+
*/
|
|
69
|
+
export function extractLinks(markdown) {
|
|
70
|
+
/** @type {string[]} */
|
|
71
|
+
const out = []
|
|
72
|
+
const re = /\]\((#(?:layer|file|hunk|line):[^)\s]+)\)|(?:^|\s)(#(?:layer|file|hunk|line):[^\s)]+)/g
|
|
73
|
+
for (const m of markdown.matchAll(re)) {
|
|
74
|
+
if (m[1] !== undefined) {
|
|
75
|
+
out.push(m[1])
|
|
76
|
+
} else if (m[2] !== undefined) {
|
|
77
|
+
// A bare link at the end of a sentence carries the punctuation; it is not part of the target.
|
|
78
|
+
out.push(m[2].replace(/[.,;:!?]+$/, ''))
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return out
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Checks a parsed link against the artifact. The message explains what is missing so the
|
|
86
|
+
* validator can print it as-is.
|
|
87
|
+
* @param {ParsedLink} link
|
|
88
|
+
* @param {LinkTargets} targets
|
|
89
|
+
* @returns {{ ok: true } | { ok: false, message: string }}
|
|
90
|
+
*/
|
|
91
|
+
export function resolveLink(link, targets) {
|
|
92
|
+
if (link.kind === 'layer') {
|
|
93
|
+
return targets.layers.some(l => l.key === link.layerKey)
|
|
94
|
+
? { ok: true }
|
|
95
|
+
: { ok: false, message: `layer ${link.layerKey} does not exist` }
|
|
96
|
+
}
|
|
97
|
+
const file = targets.files.find(f => f.path === link.path)
|
|
98
|
+
if (!file) {
|
|
99
|
+
return { ok: false, message: `${link.path} is not in the diff` }
|
|
100
|
+
}
|
|
101
|
+
if (link.kind === 'hunk' && (link.n < 1 || link.n > file.hunks.length)) {
|
|
102
|
+
return { ok: false, message: `${link.path}#${link.n} does not exist (file has ${file.hunks.length} hunks)` }
|
|
103
|
+
}
|
|
104
|
+
if (link.kind === 'line') {
|
|
105
|
+
const start = hunkForLine(file.hunks, link.side, link.start)
|
|
106
|
+
const end = hunkForLine(file.hunks, link.side, link.end)
|
|
107
|
+
if (start === null || start !== end) {
|
|
108
|
+
const range = link.end === link.start ? `${link.start}` : `${link.start}-${link.end}`
|
|
109
|
+
return { ok: false, message: `${link.path}:${range} (${link.side}) is not inside one hunk of the diff (${hunkLineRanges(file.hunks, link.side)})` }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { ok: true }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The DOM id a link points at. Line links target their first line.
|
|
117
|
+
* @param {ParsedLink} link
|
|
118
|
+
* @param {(path: string) => string} keyFor
|
|
119
|
+
* @returns {string}
|
|
120
|
+
*/
|
|
121
|
+
export function linkTargetId(link, keyFor) {
|
|
122
|
+
switch (link.kind) {
|
|
123
|
+
case 'layer':
|
|
124
|
+
return `layer-${link.layerKey}`
|
|
125
|
+
case 'file':
|
|
126
|
+
return `file-${keyFor(link.path)}`
|
|
127
|
+
case 'hunk':
|
|
128
|
+
return `hunk-${keyFor(link.path)}-${link.n}`
|
|
129
|
+
case 'line':
|
|
130
|
+
return `L-${keyFor(link.path)}-${link.side}-${link.start}`
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Short text for a link when the markdown had none, for example `packages/x.ts:40-52`.
|
|
136
|
+
* @param {ParsedLink} link
|
|
137
|
+
* @returns {string}
|
|
138
|
+
*/
|
|
139
|
+
export function linkLabel(link) {
|
|
140
|
+
switch (link.kind) {
|
|
141
|
+
case 'layer':
|
|
142
|
+
return link.layerKey
|
|
143
|
+
case 'file':
|
|
144
|
+
return link.path
|
|
145
|
+
case 'hunk':
|
|
146
|
+
return `${link.path} hunk ${link.n}`
|
|
147
|
+
case 'line':
|
|
148
|
+
return `${link.path}:${link.start}${link.end !== link.start ? `-${link.end}` : ''}${link.side === 'old' ? ' (old)' : ''}`
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Markdown for every AI-written or GitHub-written text: marked (GFM, raw HTML off) →
|
|
3
|
+
// DOMPurify with an allowlist → headings demoted → path:line linkify. Returns an HTML string that
|
|
4
|
+
// is safe to assign to innerHTML.
|
|
5
|
+
import DOMPurify from 'dompurify'
|
|
6
|
+
import { Marked } from 'marked'
|
|
7
|
+
import { diagramPlaceholderHtml } from './diagram.js'
|
|
8
|
+
import { esc } from './dom.js'
|
|
9
|
+
import { linkLabel, parseLink } from './links.js'
|
|
10
|
+
import { splitMermaid } from './mermaid-fences.js'
|
|
11
|
+
|
|
12
|
+
const ALLOWED_TAGS = [
|
|
13
|
+
'p',
|
|
14
|
+
'br',
|
|
15
|
+
'em',
|
|
16
|
+
'strong',
|
|
17
|
+
'code',
|
|
18
|
+
'pre',
|
|
19
|
+
'ul',
|
|
20
|
+
'ol',
|
|
21
|
+
'li',
|
|
22
|
+
'a',
|
|
23
|
+
'blockquote',
|
|
24
|
+
'table',
|
|
25
|
+
'thead',
|
|
26
|
+
'tbody',
|
|
27
|
+
'tr',
|
|
28
|
+
'th',
|
|
29
|
+
'td',
|
|
30
|
+
// h1 and h2 pass the sanitizer only so demoteHeadings can turn them into h3 and h4.
|
|
31
|
+
'h1',
|
|
32
|
+
'h2',
|
|
33
|
+
'h3',
|
|
34
|
+
'h4',
|
|
35
|
+
'h5',
|
|
36
|
+
'h6',
|
|
37
|
+
'hr',
|
|
38
|
+
'del',
|
|
39
|
+
'input',
|
|
40
|
+
'img',
|
|
41
|
+
'details',
|
|
42
|
+
'summary',
|
|
43
|
+
'kbd',
|
|
44
|
+
'sup',
|
|
45
|
+
'sub',
|
|
46
|
+
]
|
|
47
|
+
// `type`, `checked`, and `disabled` are for GFM task-list checkboxes. No `class`: model text must
|
|
48
|
+
// not be able to style itself as a command.
|
|
49
|
+
const ALLOWED_ATTR = ['href', 'src', 'alt', 'title', 'type', 'checked', 'disabled', 'open']
|
|
50
|
+
const ALLOWED_URI = /^(?:https?:|#)/i
|
|
51
|
+
|
|
52
|
+
const marked = new Marked({
|
|
53
|
+
gfm: true,
|
|
54
|
+
breaks: false,
|
|
55
|
+
renderer: {
|
|
56
|
+
// Raw HTML in the source renders as text, never as markup.
|
|
57
|
+
html(token) {
|
|
58
|
+
return esc(token.text)
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const githubMarked = new Marked({ gfm: true, breaks: false })
|
|
64
|
+
|
|
65
|
+
/** Model text never outranks the page's own headings: h1→h3, h2→h4, h3→h5, h4-h6→h6. */
|
|
66
|
+
const DEMOTE = { H1: 'h3', H2: 'h4', H3: 'h5', H4: 'h6', H5: 'h6', H6: 'h6' }
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {HTMLElement} root
|
|
70
|
+
*/
|
|
71
|
+
function demoteHeadings(root) {
|
|
72
|
+
for (const el of Array.from(root.querySelectorAll('h1,h2,h3,h4,h5,h6'))) {
|
|
73
|
+
const tag = DEMOTE[/** @type {keyof typeof DEMOTE} */ (el.tagName)]
|
|
74
|
+
const next = document.createElement(tag)
|
|
75
|
+
while (el.firstChild) {
|
|
76
|
+
next.append(el.firstChild)
|
|
77
|
+
}
|
|
78
|
+
el.replaceWith(next)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Turns `path/to/file.ts:12` and `path/to/file.ts:12-20` in text nodes into canvas links, but
|
|
84
|
+
* only for paths that are in the diff.
|
|
85
|
+
* @param {HTMLElement} root
|
|
86
|
+
* @param {ReadonlySet<string>} paths
|
|
87
|
+
*/
|
|
88
|
+
function linkifyPaths(root, paths) {
|
|
89
|
+
if (paths.size === 0) {
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
const re = /((?:[\w@.-]+\/)+[\w@.-]+\.[\w-]+):(\d+)(?:-(\d+))?/g
|
|
93
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
|
|
94
|
+
/** @type {Text[]} */
|
|
95
|
+
const texts = []
|
|
96
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
97
|
+
const parent = n.parentElement
|
|
98
|
+
if (parent && !parent.closest('a,code,pre')) {
|
|
99
|
+
texts.push(/** @type {Text} */ (n))
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
for (const node of texts) {
|
|
103
|
+
const text = node.data
|
|
104
|
+
let last = 0
|
|
105
|
+
/** @type {Node[]} */
|
|
106
|
+
const out = []
|
|
107
|
+
for (const m of text.matchAll(re)) {
|
|
108
|
+
const path = m[1] ?? ''
|
|
109
|
+
if (!paths.has(path) || m.index === undefined) {
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
out.push(document.createTextNode(text.slice(last, m.index)))
|
|
113
|
+
const a = document.createElement('a')
|
|
114
|
+
const range = m[3] ? `${m[2]}-${m[3]}` : m[2]
|
|
115
|
+
a.href = `#line:${path}:${range}`
|
|
116
|
+
a.className = 'loc'
|
|
117
|
+
a.textContent = m[0]
|
|
118
|
+
out.push(a)
|
|
119
|
+
last = m.index + m[0].length
|
|
120
|
+
}
|
|
121
|
+
if (out.length === 0) {
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
out.push(document.createTextNode(text.slice(last)))
|
|
125
|
+
node.replaceWith(...out)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Canvas links get `.loc` and a label when empty; http(s) links open in a new tab.
|
|
131
|
+
* @param {HTMLElement} root
|
|
132
|
+
*/
|
|
133
|
+
function decorateLinks(root) {
|
|
134
|
+
for (const a of Array.from(root.querySelectorAll('a'))) {
|
|
135
|
+
const href = a.getAttribute('href') ?? ''
|
|
136
|
+
const link = parseLink(href)
|
|
137
|
+
if (link) {
|
|
138
|
+
a.classList.add('loc')
|
|
139
|
+
a.setAttribute('data-link', href)
|
|
140
|
+
if (a.textContent === '' || a.textContent === href) {
|
|
141
|
+
a.textContent = linkLabel(link)
|
|
142
|
+
}
|
|
143
|
+
} else if (/^https?:/i.test(href)) {
|
|
144
|
+
a.setAttribute('target', '_blank')
|
|
145
|
+
a.setAttribute('rel', 'noopener noreferrer')
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* One markdown text, without mermaid blocks.
|
|
152
|
+
* @param {string} src markdown
|
|
153
|
+
* @param {{ paths?: ReadonlySet<string>, github?: boolean }} opts
|
|
154
|
+
* @returns {string} sanitized HTML
|
|
155
|
+
*/
|
|
156
|
+
function renderProse(src, opts) {
|
|
157
|
+
const raw = (opts.github ? githubMarked : marked).parse(src, { async: false })
|
|
158
|
+
// marked's renderer escapes raw HTML, and the sanitizer is still the boundary: nothing from
|
|
159
|
+
// `raw` reaches a DOM tree before DOMPurify has parsed and filtered it.
|
|
160
|
+
const root = document.createElement('div')
|
|
161
|
+
root.append(
|
|
162
|
+
DOMPurify.sanitize(raw, {
|
|
163
|
+
ALLOWED_TAGS,
|
|
164
|
+
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'style', 'form'],
|
|
165
|
+
ALLOWED_ATTR,
|
|
166
|
+
ALLOWED_URI_REGEXP: ALLOWED_URI,
|
|
167
|
+
KEEP_CONTENT: true,
|
|
168
|
+
RETURN_DOM_FRAGMENT: true,
|
|
169
|
+
})
|
|
170
|
+
)
|
|
171
|
+
for (const element of root.querySelectorAll('*')) {
|
|
172
|
+
if (!ALLOWED_TAGS.includes(element.tagName.toLowerCase())) element.remove()
|
|
173
|
+
}
|
|
174
|
+
for (const img of root.querySelectorAll('img')) {
|
|
175
|
+
if (!/^https:\/\//i.test(img.getAttribute('src') ?? '')) {
|
|
176
|
+
img.remove()
|
|
177
|
+
} else {
|
|
178
|
+
img.loading = 'lazy'
|
|
179
|
+
img.referrerPolicy = 'no-referrer'
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
for (const input of root.querySelectorAll('input')) {
|
|
183
|
+
input.type = 'checkbox'
|
|
184
|
+
input.disabled = true
|
|
185
|
+
}
|
|
186
|
+
demoteHeadings(root)
|
|
187
|
+
linkifyPaths(root, opts.paths ?? new Set())
|
|
188
|
+
decorateLinks(root)
|
|
189
|
+
return root.innerHTML
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The link reference definitions of a text, written out again as markdown. Marked reads them, so
|
|
194
|
+
* a definition shown inside a code block is an example and a definition written over two lines is
|
|
195
|
+
* still one definition. The title is dropped, since the sanitizer allows no `title` attribute.
|
|
196
|
+
* @param {string} src markdown
|
|
197
|
+
* @returns {string}
|
|
198
|
+
*/
|
|
199
|
+
function linkDefinitions(src) {
|
|
200
|
+
// The destination is already parsed, so the characters marked reads in one are written back
|
|
201
|
+
// escaped: it parses this text a second time and must arrive at the same destination.
|
|
202
|
+
return Object.entries(marked.lexer(src).links)
|
|
203
|
+
.map(([label, def]) => `[${label}]: <${def.href.replace(/[\\<>]/g, ch => `\\${ch}`)}>`)
|
|
204
|
+
.join('\n')
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Markdown as sanitized HTML. Two fields draw diagrams, the PR summary and a layer rationale, and
|
|
209
|
+
* they ask for it with `diagrams`. Everywhere else a ```mermaid fence stays a code block: a PR
|
|
210
|
+
* description and a GitHub comment are text from anyone, and the validator counts and measures
|
|
211
|
+
* diagrams only in the two fields the model writes them in.
|
|
212
|
+
* @param {string} src markdown
|
|
213
|
+
* @param {{ paths?: ReadonlySet<string>, diagrams?: boolean, github?: boolean }} [opts] `paths` linkifies `path:line`
|
|
214
|
+
* @returns {string} sanitized HTML
|
|
215
|
+
*/
|
|
216
|
+
export function renderMarkdown(src, opts = {}) {
|
|
217
|
+
if (opts.diagrams !== true) {
|
|
218
|
+
return renderProse(src, opts)
|
|
219
|
+
}
|
|
220
|
+
const segments = splitMermaid(src)
|
|
221
|
+
// Each piece of prose is parsed on its own, so the link definitions of the whole text are
|
|
222
|
+
// repeated in front of every piece: `[label]: target` written after a drawing still resolves a
|
|
223
|
+
// `[label]` written before it. A definition renders nothing, so repeating one shows nothing.
|
|
224
|
+
const defs = linkDefinitions(src)
|
|
225
|
+
return segments
|
|
226
|
+
.map(segment =>
|
|
227
|
+
segment.type === 'mermaid'
|
|
228
|
+
? diagramPlaceholderHtml(segment.text)
|
|
229
|
+
: renderProse(defs === '' ? segment.text : `${defs}\n\n${segment.text}`, opts)
|
|
230
|
+
)
|
|
231
|
+
.join('')
|
|
232
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Finds the ```mermaid fences in a markdown text. One implementation for both sides: the client
|
|
3
|
+
// turns each block into a diagram placeholder, and the server counts and measures them
|
|
4
|
+
// (src/contract/mermaid-fences.ts re-exports this file).
|
|
5
|
+
import { splitFences } from './fences.js'
|
|
6
|
+
|
|
7
|
+
/** @typedef {{ type: 'markdown' | 'mermaid', text: string }} MermaidSegment */
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The pieces of a markdown text in order: prose and mermaid blocks. A mermaid block is a fenced
|
|
11
|
+
* block whose info string is exactly `mermaid`.
|
|
12
|
+
* @param {string} markdown
|
|
13
|
+
* @returns {MermaidSegment[]}
|
|
14
|
+
*/
|
|
15
|
+
export function splitMermaid(markdown) {
|
|
16
|
+
return splitFences(markdown, 'mermaid').map(s => ({
|
|
17
|
+
type: s.type === 'block' ? 'mermaid' : 'markdown',
|
|
18
|
+
text: s.text,
|
|
19
|
+
}))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The kind of drawing a source asks for, taken from its first word: `stateDiagram-v2` reads as
|
|
24
|
+
* `stateDiagram`. An empty or unreadable source has no kind.
|
|
25
|
+
* @param {string} source mermaid text
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
export function diagramKind(source) {
|
|
29
|
+
const first = source.split('\n').find(line => line.trim() !== '' && !line.trim().startsWith('%%')) ?? ''
|
|
30
|
+
const word = first.trim().split(/[\s{(]/)[0] ?? ''
|
|
31
|
+
return /^[A-Za-z]/.test(word) ? word.replace(/-v\d+$/, '') : ''
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The source of every mermaid block, in order.
|
|
36
|
+
* @param {string} markdown
|
|
37
|
+
* @returns {string[]}
|
|
38
|
+
*/
|
|
39
|
+
export function mermaidBlocks(markdown) {
|
|
40
|
+
return splitMermaid(markdown)
|
|
41
|
+
.filter(s => s.type === 'mermaid')
|
|
42
|
+
.map(s => s.text)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The text without its mermaid blocks, for a length measured on the prose a reader reads.
|
|
47
|
+
* @param {string} markdown
|
|
48
|
+
* @returns {string}
|
|
49
|
+
*/
|
|
50
|
+
export function withoutMermaid(markdown) {
|
|
51
|
+
return splitMermaid(markdown)
|
|
52
|
+
.filter(s => s.type === 'markdown')
|
|
53
|
+
.map(s => s.text)
|
|
54
|
+
.join('\n')
|
|
55
|
+
}
|
package/static/js/nav.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// The page order of layers and file cards, for the rail, scrollspy, and the j/k n/p keys. Other
|
|
3
|
+
// comes last with its files, mirroring the ids layers.js renders.
|
|
4
|
+
/** @typedef {import('./contract-types.js').ReviewArtifact} ReviewArtifact */
|
|
5
|
+
import { fileAnchorId, layerAnchorId, sanitizeKey } from './keys.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {{ kind: 'overview', id: 'overview' }
|
|
9
|
+
* | { kind: 'layer', id: string, layerId: string, key: string, other: boolean }
|
|
10
|
+
* | { kind: 'file', id: string, layerId: string, key: string, path: string, isTest: boolean, other: boolean }} NavItem
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Overview, then every layer followed by its file cards, with the Other layer last.
|
|
15
|
+
* @param {ReviewArtifact} artifact
|
|
16
|
+
* @returns {NavItem[]}
|
|
17
|
+
*/
|
|
18
|
+
export function buildNavOrder(artifact) {
|
|
19
|
+
/** @type {NavItem[]} */
|
|
20
|
+
const out = [{ kind: 'overview', id: 'overview' }]
|
|
21
|
+
const layers = [
|
|
22
|
+
...artifact.layers.filter(l => l.kind !== 'other'),
|
|
23
|
+
...artifact.layers.filter(l => l.kind === 'other'),
|
|
24
|
+
]
|
|
25
|
+
/** @type {Set<string>} */
|
|
26
|
+
const seen = new Set()
|
|
27
|
+
for (const layer of layers) {
|
|
28
|
+
const other = layer.kind === 'other'
|
|
29
|
+
out.push({ kind: 'layer', id: layerAnchorId(layer.key), layerId: layer.id, key: layer.key, other })
|
|
30
|
+
for (const f of layer.files) {
|
|
31
|
+
const key = sanitizeKey(f.path)
|
|
32
|
+
// The first card of a file carries the plain id; later cards of the same file add the layer key.
|
|
33
|
+
const id = seen.has(key) ? `${fileAnchorId(key)}-${layer.key}` : fileAnchorId(key)
|
|
34
|
+
seen.add(key)
|
|
35
|
+
out.push({ kind: 'file', id, layerId: layer.id, key, path: f.path, isTest: f.isTest, other })
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return out
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {ReadonlyArray<NavItem>} order
|
|
43
|
+
* @param {string | null} currentId
|
|
44
|
+
* @param {'layer' | 'file'} kind
|
|
45
|
+
* @param {1 | -1} direction
|
|
46
|
+
* @returns {NavItem | null}
|
|
47
|
+
*/
|
|
48
|
+
function step(order, currentId, kind, direction) {
|
|
49
|
+
const from = currentId === null ? (direction === 1 ? -1 : order.length) : order.findIndex(i => i.id === currentId)
|
|
50
|
+
const start = from === -1 && currentId !== null ? (direction === 1 ? -1 : order.length) : from
|
|
51
|
+
for (let i = start + direction; i >= 0 && i < order.length; i += direction) {
|
|
52
|
+
const item = order[i]
|
|
53
|
+
if (item !== undefined && item.kind === kind) {
|
|
54
|
+
return item
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** @param {ReadonlyArray<NavItem>} order @param {string | null} currentId */
|
|
61
|
+
export function nextLayer(order, currentId) {
|
|
62
|
+
return step(order, currentId, 'layer', 1)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** @param {ReadonlyArray<NavItem>} order @param {string | null} currentId */
|
|
66
|
+
export function prevLayer(order, currentId) {
|
|
67
|
+
return step(order, currentId, 'layer', -1)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** @param {ReadonlyArray<NavItem>} order @param {string | null} currentId */
|
|
71
|
+
export function nextFile(order, currentId) {
|
|
72
|
+
return step(order, currentId, 'file', 1)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** @param {ReadonlyArray<NavItem>} order @param {string | null} currentId */
|
|
76
|
+
export function prevFile(order, currentId) {
|
|
77
|
+
return step(order, currentId, 'file', -1)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The layer an item belongs to: itself for a layer, its owner for a file, null for the overview.
|
|
82
|
+
* @param {ReadonlyArray<NavItem>} order
|
|
83
|
+
* @param {string} id
|
|
84
|
+
*/
|
|
85
|
+
export function layerOf(order, id) {
|
|
86
|
+
const item = order.find(i => i.id === id)
|
|
87
|
+
if (!item || item.kind === 'overview') {
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
return item.kind === 'layer' ? item : (order.find(i => i.kind === 'layer' && i.layerId === item.layerId) ?? null)
|
|
91
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** @typedef {import('./contract-types.js').PrBundle} PrBundle */
|
|
3
|
+
/** @typedef {import('./contract-types.js').IssueComment} IssueComment */
|
|
4
|
+
|
|
5
|
+
import { commentHtml } from './diff-decorations.js'
|
|
6
|
+
import { viewCommentHtml } from './comment-link.js'
|
|
7
|
+
import { detailsSummaryHtml, esc, avatarHtml, timeAgo } from './dom.js'
|
|
8
|
+
import { renderMarkdown } from './markdown.js'
|
|
9
|
+
import { dismissedListHtml, postedUrls, sevsumHtml } from './points.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* "What changes": the PR-wide summary as one prose block.
|
|
13
|
+
* @param {string} summary
|
|
14
|
+
* @param {ReadonlySet<string>} paths
|
|
15
|
+
*/
|
|
16
|
+
export function summaryHtml(summary, paths) {
|
|
17
|
+
return `<div class="summary prose">${renderMarkdown(summary, { paths, diagrams: true })}</div>`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {IssueComment} c
|
|
22
|
+
* @param {Date} now
|
|
23
|
+
*/
|
|
24
|
+
export function issueCommentHtml(c, now) {
|
|
25
|
+
return (
|
|
26
|
+
`<div class="cmt">${avatarHtml(c)}` +
|
|
27
|
+
`<span class="who"><b>${esc(c.author)}</b> <span class="muted">${esc(timeAgo(c.createdAt, now))}</span> ${viewCommentHtml(c.url)}</span>` +
|
|
28
|
+
`<div class="prose">${renderMarkdown(c.body, { github: true })}</div></div>`
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* PR-level comments; bot comments (`[bot]` authors) collapse into one details block.
|
|
34
|
+
* @param {ReadonlyArray<IssueComment>} comments
|
|
35
|
+
* @param {Date} now
|
|
36
|
+
*/
|
|
37
|
+
export function conversationHtml(comments, now) {
|
|
38
|
+
const humans = comments.filter(c => !c.author.endsWith('[bot]'))
|
|
39
|
+
const bots = comments.filter(c => c.author.endsWith('[bot]'))
|
|
40
|
+
const humanHtml =
|
|
41
|
+
humans.length === 0
|
|
42
|
+
? '<p class="muted">No PR-level comments yet.</p>'
|
|
43
|
+
: humans.map(c => issueCommentHtml(c, now)).join('')
|
|
44
|
+
const botHtml =
|
|
45
|
+
bots.length === 0
|
|
46
|
+
? ''
|
|
47
|
+
: `<details class="bots">${detailsSummaryHtml(`<span class="muted small">${bots.length} bot ${bots.length === 1 ? 'comment' : 'comments'}</span>`, 'Toggle bot comments')}${bots
|
|
48
|
+
.map(c => issueCommentHtml(c, now))
|
|
49
|
+
.join('')}</details>`
|
|
50
|
+
return (
|
|
51
|
+
`<h3 class="lbl sub">Conversation · ${comments.length}</h3><div class="body conversation">${humanHtml}${botHtml}` +
|
|
52
|
+
'<div class="pr-composer-host"></div>' +
|
|
53
|
+
'<p><button class="cmd" type="button" data-act="pr-comment" data-needs-post>comment</button></p></div>'
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {PrBundle} bundle
|
|
59
|
+
* @param {{ paths: ReadonlySet<string>, now: Date }} ctx
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
export function renderOverview(bundle, ctx) {
|
|
63
|
+
const artifact = bundle.artifact
|
|
64
|
+
const active = artifact ? artifact.points.filter(p => bundle.state.dismissed[p.fingerprint] === undefined) : []
|
|
65
|
+
const summary = artifact ? summaryHtml(artifact.summary, ctx.paths) : ''
|
|
66
|
+
const description = bundle.pr.body.trim()
|
|
67
|
+
? `<details class="pr-desc">${detailsSummaryHtml('<span>PR description (from GitHub)</span>', 'Toggle PR description')}<div class="body prose">${renderMarkdown(bundle.pr.body, { paths: ctx.paths, github: true })}</div></details>`
|
|
68
|
+
: '<div class="pr-desc body muted">No PR description.</div>'
|
|
69
|
+
return (
|
|
70
|
+
'<section class="panel" id="overview" aria-labelledby="ov-h">' +
|
|
71
|
+
`<div class="panel-h"><h2 id="ov-h">Overview</h2>${artifact ? sevsumHtml(active, artifact.layers) : ''}</div>` +
|
|
72
|
+
`<div class="body">${summary}</div>` +
|
|
73
|
+
description +
|
|
74
|
+
conversationHtml(bundle.comments.issueComments, ctx.now) +
|
|
75
|
+
(bundle.comments.reviews ?? []).map(review => `<div class="body"><span class="pill">${esc(review.state.toLowerCase().replaceAll('_', ' '))}</span>${issueCommentHtml(review, ctx.now)}</div>`).join('') +
|
|
76
|
+
(bundle.comments.reviewComments.length ? `<details class="body all-review-comments">${detailsSummaryHtml(`<span>All review comments · ${bundle.comments.reviewComments.length}</span>`, 'Toggle all review comments')}${bundle.comments.reviewComments.map(c => `<div><p class="muted small">${esc(c.path)}${c.line ? `:${c.line}` : ''}${c.outdated ? ' · outdated' : ''}${c.resolved ? ' · resolved' : ''}</p>${commentHtml(c, ctx.now)}</div>`).join('')}</details>` : '') +
|
|
77
|
+
(artifact
|
|
78
|
+
? dismissedListHtml(artifact.points, bundle.state, {
|
|
79
|
+
paths: ctx.paths,
|
|
80
|
+
posted: postedUrls(bundle.state, bundle.comments.reviewComments),
|
|
81
|
+
})
|
|
82
|
+
: '') +
|
|
83
|
+
'</section>'
|
|
84
|
+
)
|
|
85
|
+
}
|