~bigbes/sr-ht-spec

ref: 865a21fa60c6230f79a17ef880767c87729c0435 sr-ht-spec/web/diff.go -rw-r--r-- 8.2 KiB
865a21fa — Eugene Blikh feat(web): digest tracks 'since you last looked' via digest_mark (spec-mfm) 25 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
package web

import (
	"fmt"
	"html/template"
	"strings"

	"sourcecraft.dev/bigbes/sr-ht-spec/prosediff"
)

// inlineSimilarityThreshold is the Phase 0 verdict's presentation switch: a
// modified prose block whose token similarity is at or above it renders as an
// inline word diff, and one below it renders as a two-column old/new view.
//
// 13% of real prose modifications shred into interleaved fragments — those
// paragraphs really were rewritten sentence by sentence — and every one of them
// scores at or below 0.73. Rendering them inline makes one review in eight
// unreadable, which is the one where the agent changed the most. prosediff
// exports BlockChange.Similarity for exactly this decision and computes no HTML
// itself; this is where the decision is made.
const inlineSimilarityThreshold = 0.75

// diffView is the whole rendered diff of one document, ready for the proposal
// template. Unchanged reports the degenerate case — a proposal that touches a
// document without changing it — so the page can say so rather than show an
// empty diff.
type diffView struct {
	HTML      template.HTML
	Stats     prosediff.Stats
	Unchanged bool
}

// renderDocDiff diffs the approved (old) and proposed (new) source of one
// document and renders it to HTML.
//
// It walks prosediff's block-change model rather than its text renderer: the
// text renderer is for a terminal, and the review page needs headings grouped,
// word edits marked with <ins>/<del>, and — the load-bearing part — the
// two-column fallback for shredded blocks. Every piece of document content is
// HTML-escaped before it reaches the output; the only markup this produces is
// its own structure.
func renderDocDiff(oldSrc, newSrc []byte) diffView {
	d := prosediff.Compare(oldSrc, newSrc)
	view := diffView{Stats: d.Stats, Unchanged: !d.Stats.Changed()}
	if view.Unchanged {
		return view
	}

	var b strings.Builder
	b.WriteString(`<div class="prosediff">`)
	lastPath := "\x00" // impossible path, so the first real one always prints
	inHunk := false
	closeHunk := func() {
		if inHunk {
			b.WriteString(`</div></section>`)
			inHunk = false
		}
	}

	for _, c := range d.Changes {
		if c.Kind == prosediff.ChangeEqual {
			continue // the review shows only what changed
		}
		blk := c.New
		if blk == nil {
			blk = c.Old
		}
		if path := strings.Join(blk.HeadingPath, " › "); path != lastPath {
			closeHunk()
			lastPath = path
			shown := path
			if shown == "" {
				shown = "(document preamble)"
			}
			b.WriteString(`<section class="ph-hunk"><div class="ph-path">`)
			b.WriteString(template.HTMLEscapeString(shown))
			b.WriteString(`</div><div class="ph-blocks">`)
			inHunk = true
		}
		writeBlock(&b, c)
	}
	closeHunk()
	b.WriteString(`</div>`)
	view.HTML = template.HTML(b.String())
	return view
}

// writeBlock renders one changed block. Insert/delete/move show the whole
// block; a modify chooses among a code line diff, an inline word diff and the
// two-column view, on the rules the design pins.
func writeBlock(b *strings.Builder, c prosediff.BlockChange) {
	switch c.Kind {
	case prosediff.ChangeInsert:
		writeWholeBlock(b, "ph-insert", "added", c.New)
	case prosediff.ChangeDelete:
		writeWholeBlock(b, "ph-delete", "removed", c.Old)
	case prosediff.ChangeMoveIn:
		fmt.Fprintf(b, `<div class="ph-block ph-move"><span class="ph-label">%s moved here (was line %d)</span></div>`,
			template.HTMLEscapeString(c.New.Label()), c.Old.StartLine)
	case prosediff.ChangeMoveOut:
		fmt.Fprintf(b, `<div class="ph-block ph-move"><span class="ph-label">%s moved away (now line %d)</span></div>`,
			template.HTMLEscapeString(c.Old.Label()), c.New.StartLine)
	case prosediff.ChangeModify:
		writeModify(b, c)
	}
}

// writeWholeBlock renders an inserted or deleted block: its whole text, in a
// <pre> for a non-prose kind so code keeps its wrapping, and reflowed prose
// otherwise.
func writeWholeBlock(b *strings.Builder, class, verb string, blk *prosediff.Block) {
	label := verb + " " + blk.Label()
	fmt.Fprintf(b, `<div class="ph-block %s"><span class="ph-label">%s</span>`,
		class, template.HTMLEscapeString(label))
	writeBody(b, blk.Text, blk.Kind.Prose())
	b.WriteString(`</div>`)
}

// writeModify renders a modified block. A code, frontmatter or HTML block has a
// line-oriented edit script and renders line by line; a prose block has a word
// edit script and renders inline when it stayed similar enough to follow, and
// two-column when it did not.
func writeModify(b *strings.Builder, c prosediff.BlockChange) {
	label := "changed " + c.New.Label()
	if c.StructureOnly {
		label = fmt.Sprintf("changed %s → %s (structure)", c.Old.Label(), c.New.Label())
	}
	if c.Moved {
		label += fmt.Sprintf(" (moved from line %d)", c.Old.StartLine)
	}

	if len(c.Lines) > 0 {
		fmt.Fprintf(b, `<div class="ph-block ph-modify"><span class="ph-label">%s</span><pre class="ph-code">`,
			template.HTMLEscapeString(label))
		writeLineSpans(b, c.Lines)
		b.WriteString(`</pre></div>`)
		return
	}

	if c.Similarity >= inlineSimilarityThreshold {
		fmt.Fprintf(b, `<div class="ph-block ph-modify"><span class="ph-label">%s</span><div class="ph-body ph-inline">`,
			template.HTMLEscapeString(label))
		writeInlineSpans(b, c.Words)
		b.WriteString(`</div></div>`)
		return
	}

	// The two-column fallback: the block was rewritten enough that inline marks
	// would shred it. The old column keeps deletions, the new keeps insertions,
	// each still marked, so a reviewer reads two coherent paragraphs side by side.
	fmt.Fprintf(b, `<div class="ph-block ph-modify ph-columns"><span class="ph-label">%s (rewritten)</span><div class="ph-cols"><div class="ph-col ph-old">`,
		template.HTMLEscapeString(label))
	writeColumnSpans(b, c.Words, true)
	b.WriteString(`</div><div class="ph-col ph-new">`)
	writeColumnSpans(b, c.Words, false)
	b.WriteString(`</div></div></div>`)
}

// writeBody renders whole-block text: escaped, in a <pre> when it is not prose.
func writeBody(b *strings.Builder, text string, prose bool) {
	if prose {
		b.WriteString(`<div class="ph-body">`)
		b.WriteString(template.HTMLEscapeString(text))
		b.WriteString(`</div>`)
		return
	}
	b.WriteString(`<pre class="ph-code">`)
	b.WriteString(template.HTMLEscapeString(text))
	b.WriteString(`</pre>`)
}

// writeInlineSpans renders a word edit script inline, marking deletions and
// insertions where they sit. The Space flag reproduces prosediff's own spacing:
// a span that replaces the one before it carries Space=false, so a one-word
// substitution does not render with a gap in the middle.
func writeInlineSpans(b *strings.Builder, spans []prosediff.Span) {
	emitted := false
	for _, s := range spans {
		if s.Space && emitted {
			b.WriteByte(' ')
		}
		emitted = true
		writeSpan(b, s)
	}
}

// writeColumnSpans renders one side of the two-column view: the old side keeps
// equal and deleted words, the new side keeps equal and inserted ones. Both
// still mark their changes, so each column is a readable paragraph that also
// shows what moved.
func writeColumnSpans(b *strings.Builder, spans []prosediff.Span, old bool) {
	emitted := false
	for _, s := range spans {
		keep := s.Op == prosediff.OpEqual ||
			(old && s.Op == prosediff.OpDelete) ||
			(!old && s.Op == prosediff.OpInsert)
		if !keep {
			continue
		}
		if s.Space && emitted {
			b.WriteByte(' ')
		}
		emitted = true
		writeSpan(b, s)
	}
}

// writeSpan writes one span's escaped text, wrapped in <del> or <ins> for a
// change and bare for an equal run.
func writeSpan(b *strings.Builder, s prosediff.Span) {
	esc := template.HTMLEscapeString(s.Text)
	switch s.Op {
	case prosediff.OpDelete:
		b.WriteString("<del>")
		b.WriteString(esc)
		b.WriteString("</del>")
	case prosediff.OpInsert:
		b.WriteString("<ins>")
		b.WriteString(esc)
		b.WriteString("</ins>")
	default:
		b.WriteString(esc)
	}
}

// writeLineSpans renders a line-oriented edit script (a code fence, frontmatter
// or HTML block) one line per row inside a <pre>, each line class-marked.
func writeLineSpans(b *strings.Builder, spans []prosediff.Span) {
	for _, s := range spans {
		class := "ph-line-eq"
		switch s.Op {
		case prosediff.OpDelete:
			class = "ph-line-del"
		case prosediff.OpInsert:
			class = "ph-line-ins"
		}
		fmt.Fprintf(b, `<span class="%s">%s</span>`+"\n", class, template.HTMLEscapeString(s.Text))
	}
}