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
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
|
package main
import (
"bytes"
"encoding/xml"
"fmt"
"html/template"
"io"
"os"
"path/filepath"
"strconv"
"strings"
texttemplate "text/template"
"time"
)
// baseURL is where the site is served. It is a constant because it appears in
// feed GUIDs and enclosure URLs, which are permanent: a podcast client that
// has seen an episode remembers its URL forever. Changing this is a migration,
// not a configuration change.
const baseURL = "https://observations.profpatsch.de"
const (
feedTitle = "observations"
feedDescription = "Reading stuff & thinking about it."
feedAuthor = "Profpatsch"
feedEmail = "mail@profpatsch.de"
feedLanguage = "en"
// submitURL is the endpoint observations-inbox(1) serves. It is a
// same-origin path rather than an absolute URL so that the form works
// unchanged on a local copy of the site.
submitURL = "/submit"
// maxSubmitBytes must agree with maxUploadBytes in observations-inbox.
// The page states it and the browser checks it before uploading, purely
// so that an oversized file fails immediately rather than after a long
// upload over a phone connection; the server enforces it regardless.
maxSubmitBytes = 5 << 20
maxSubmitSize = "5 MB"
)
// render writes the whole site: an index, a page per episode, and the feed.
// It is a pure function of the episodes directory, so it can be re-run at any
// time and produces the same bytes.
func render(d *dirs) error {
eps, err := loadEpisodes(d.episodes)
if err != nil {
return err
}
if err := os.MkdirAll(d.out, 0o755); err != nil {
return err
}
if err := renderTo(filepath.Join(d.out, "index.html"), indexTmpl, map[string]any{
"Episodes": eps,
"Title": feedTitle,
"Tagline": feedDescription,
"Generated": generatedComment,
}); err != nil {
return err
}
for _, e := range eps {
dir := filepath.Join(d.out, e.Slug())
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
if err := renderTo(filepath.Join(dir, "index.html"), episodeTmpl, map[string]any{
"E": e,
"BaseURL": baseURL,
"Generated": generatedComment,
"SubmitURL": submitURL,
"MaxSize": maxSubmitSize,
// Typed as JS so html/template emits them as the literals they
// are. Passed as plain values they still work, but the escaper
// writes ` 5242880 ` and '\/submit' — correct, and misleading to
// read.
"SubmitURLJS": template.JS(`"` + submitURL + `"`),
"MaxBytesJS": template.JS(strconv.Itoa(maxSubmitBytes)),
}); err != nil {
return err
}
}
if err := renderFeed(filepath.Join(d.out, "feed.xml"), eps); err != nil {
return err
}
fmt.Fprintf(os.Stderr, "rendered %d episode(s) into %s\n", len(eps), d.out)
return nil
}
// executor is satisfied by both html/template and text/template. The pages are
// HTML (and want contextual autoescaping); the feed is XML (and must not have
// it) — see the comment on feedTmpl.
type executor interface {
Execute(w io.Writer, data any) error
}
func renderTo(path string, t executor, data any) error {
var buf bytes.Buffer
if err := t.Execute(&buf, data); err != nil {
return fmt.Errorf("%s: %w", path, err)
}
return os.WriteFile(path, buf.Bytes(), 0o644)
}
func renderFeed(path string, eps []*Episode) error {
type item struct {
Title string
Link string
GUID string
PubDate string
Duration string
Bytes int64
MIME string
AudioURL string
Summary string
}
var items []item
for _, e := range eps {
t, err := e.RecordedTime()
if err != nil {
return fmt.Errorf("episode %s: %w", e.Slug(), err)
}
items = append(items, item{
Title: e.Title(),
Link: fmt.Sprintf("%s/%s/", baseURL, e.Slug()),
GUID: fmt.Sprintf("%s/%s/", baseURL, e.Slug()),
PubDate: t.Format(time.RFC1123Z),
// itunes:duration accepts H:MM:SS or seconds; the former is
// what clients display verbatim.
Duration: e.DurationHMS(),
Bytes: e.Audio.Bytes,
MIME: e.Audio.MIME,
AudioURL: fmt.Sprintf("%s/audio/%s", baseURL, e.Audio.File),
Summary: episodeSummary(e),
})
}
var built string
if len(eps) > 0 {
t, err := eps[0].RecordedTime()
if err == nil {
built = t.Format(time.RFC1123Z)
}
}
return renderTo(path, feedTmpl, map[string]any{
"Title": feedTitle,
"Link": baseURL + "/",
"FeedURL": baseURL + "/feed.xml",
"Description": feedDescription,
"Author": feedAuthor,
"Email": feedEmail,
"Language": feedLanguage,
"Built": built,
"Items": items,
})
}
// episodeSummary is the prose a podcast client shows under the title. It names
// every text read, because that is what the episode is about, and a listener
// deciding whether to play 70 minutes wants to know which texts those are.
func episodeSummary(e *Episode) string {
var b strings.Builder
switch len(e.Sources) {
case 0:
// validate() rules this out, but rendering must not panic on an
// episode someone hand-edited into that state.
case 1:
b.WriteString("Reading " + describeSource(e.Sources[0]) + ", with commentary.")
default:
b.WriteString("Reading, with commentary:")
for _, s := range e.Sources {
b.WriteString("\n\n• " + describeSource(s))
if s.Note != "" {
b.WriteString(" — " + s.Note)
}
}
}
if e.Notes != "" {
b.WriteString("\n\n" + e.Notes)
}
// One source keeps the bare "Source:" line it has always had, so existing
// episodes render unchanged. Several need labelling to be readable.
if len(e.Sources) == 1 {
b.WriteString("\n\nSource: " + e.Sources[0].URL)
} else {
for _, s := range e.Sources {
b.WriteString("\n\n" + s.Title + ": " + s.URL)
}
}
return b.String()
}
func describeSource(s Source) string {
d := fmt.Sprintf("%q by %s", s.Title, s.Author)
if s.Site != "" {
d += ", " + s.Site
}
return d + fmt.Sprintf(" (%s)", s.Published)
}
// Styling follows the rest of the site: one inline stylesheet, no external
// assets, no JavaScript, and a dark mode that follows the system preference.
// The player is the browser's own <audio> element — it handles seeking, speed
// and background playback better than anything worth hand-writing.
// Colours are written as light-dark(light, dark) rather than as a light rule
// plus a @media (prefers-color-scheme: dark) block of overrides.
//
// The media query approach does not declare a theme; it declares a second set
// of rules at the *same* specificity, so which colour applies is decided by
// source order. That makes every later declaration able to silently clobber
// the dark half — which is precisely how the source box shipped grey-on-grey:
// `.source { background: #f2f2f2 }` sat below the dark block and won. Keeping
// the block last fixes the instance and leaves the trap, so the ordering
// constraint is removed instead: one declaration per colour, and no second
// place for it to be overridden from.
//
// This needs `color-scheme: light dark` on :root to resolve, which is also
// what makes the browser paint its own <audio> widget to match — CSS on
// <audio> cannot reach the UA-drawn control.
//
// Cost: light-dark() is Baseline since May 2024, so a browser older than that
// falls back to the light value in dark mode. That is readable, just not dark,
// and the alternative (a @supports block carrying a duplicate palette) would
// reintroduce the duplication this removes.
const styleCSS = `
*, *::before, *::after { box-sizing: border-box; }
:root { color-scheme: light dark; }
body {
font-family: 'Open Sans', system-ui, sans-serif;
font-size: 20px;
line-height: 1.5;
max-width: 34em;
margin: 0 auto;
padding: 1.5em 1em 4em;
background: light-dark(#fff, #1a1a1a);
color: light-dark(#111, #ddd);
}
a { color: light-dark(#06c, #adf); }
a:visited { color: light-dark(#639, #c9a0ff); }
h1 { font-weight: 300; font-size: 2em; margin-bottom: 0; }
.tagline { font-style: italic; color: light-dark(#666, #999); margin-top: 0.2em; }
.invite { font-size: 0.85em; color: light-dark(#666, #999); }
hr { border: none; border-top: 1px solid light-dark(#ccc, #444); margin: 2em 0; }
audio { width: 100%; margin: 1.5em 0 0.5em; }
.meta, .date { font-size: 0.8em; color: light-dark(#666, #999); }
.source {
background: light-dark(#f2f2f2, #252525);
border-radius: 4px;
padding: 0.8em 1em;
margin: 1.5em 0;
font-size: 0.9em;
}
.source dt { font-weight: 600; }
.source dd { margin: 0 0 0.6em; }
.source dd:last-child { margin-bottom: 0; }
.source .note { color: light-dark(#666, #999); }
ul.episodes { list-style: none; padding: 0; }
ul.episodes li { margin: 0 0 1.2em; }
ul.episodes .num { font-variant-numeric: tabular-nums; color: light-dark(#888, #777); }
nav { margin-top: 3em; font-size: 0.85em; }
footer { margin-top: 3em; font-size: 0.8em; color: light-dark(#666, #999); }
`
// replyCSS styles the submission form. It is kept apart from styleCSS because
// only episode pages carry the form: shipping these rules on the index would
// be bytes for markup that is not there.
const replyCSS = `
/* The drop zone. It is a <label> wrapping a hidden file input, so that
clicking it opens a file picker with no JavaScript at all; the drag and
drop handlers are an enhancement on top of something that already
works. */
.drop {
display: block;
border: 2px dashed light-dark(#bbb, #555);
border-radius: 6px;
padding: 1.5em 1em;
text-align: center;
cursor: pointer;
color: light-dark(#666, #999);
font-size: 0.9em;
transition: border-color 0.15s, background 0.15s;
}
.drop:hover, .drop.over {
border-color: light-dark(#06c, #adf);
background: light-dark(#f4f8ff, #202830);
}
.drop input[type=file] { display: none; }
.drop.busy { opacity: 0.6; pointer-events: none; }
.reply { margin: 2em 0; }
.reply label.line { display: block; margin: 0.8em 0; font-size: 0.85em; }
.reply input[type=text] {
font: inherit;
font-size: 0.9rem;
padding: 0.35em 0.5em;
width: 100%;
max-width: 22em;
border: 1px solid light-dark(#bbb, #555);
border-radius: 4px;
background: light-dark(#fff, #222);
color: inherit;
}
.reply .consent { font-size: 0.8em; color: light-dark(#666, #999); }
.reply .consent input { margin-right: 0.4em; }
.reply .status { font-size: 0.85em; margin-top: 0.8em; min-height: 1.4em; }
.reply .status.error { color: light-dark(#c33, #f66); }
.reply .status.done { color: light-dark(#282, #6c6); }
.reply details { font-size: 0.8em; color: light-dark(#666, #999); }
.reply summary { cursor: pointer; }
/* Recorder. Hidden in the markup and revealed by the script only where
MediaRecorder exists, so a browser that cannot record never shows a
button that would not work. */
.recorder { margin: 0.8em 0; display: flex; align-items: center; gap: 0.8em; flex-wrap: wrap; }
.recorder button {
font: inherit;
font-size: 0.9rem;
padding: 0.4em 1em;
border-radius: 999px;
border: 1px solid light-dark(#c33, #f66);
background: transparent;
color: light-dark(#c33, #f66);
cursor: pointer;
}
.recorder button:hover { background: light-dark(#fdf0f0, #2a1e1e); }
.rectime {
font-size: 0.85em;
color: light-dark(#666, #999);
font-variant-numeric: tabular-nums;
}
.preview { width: 100%; margin-top: 0.4em; }
`
// transcriptCSS styles the rendered transcript. Kept apart from styleCSS for
// the same reason as replyCSS: only episode pages have a transcript.
//
// The stored line breaks are not reproduced. A line is 37–42 characters
// because that is the width a subtitle is read at (observations-transcripts(5)),
// and the body here is 34em — laying the file out literally would give a
// column of ragged half-width lines in a space twice as wide. The lines are
// therefore spans that flow as prose, and their boundaries survive only as
// what the script seeks and highlights.
const transcriptCSS = `
/* The episode nav leads the page rather than trailing it: with the
transcript inline, a nav after the content sits below ten thousand
words, which is nowhere. Only episode pages have one, so the rule
travels with them. */
nav.top { margin: 0 0 2em; }
.transcriptnote { margin-bottom: 1.5em; }
.transcript { margin: 1.5em 0; }
.transcript p { margin: 0 0 1em; }
/* The paragraph timestamp hangs in the margin where there is room for it,
and falls back to sitting inline on a narrow screen. float rather than
absolute positioning, so that it never overlaps the text when the
margin is too small to hold it. */
.transcript .ts {
float: left;
margin-left: -5.2em;
width: 4.4em;
text-align: right;
font-size: 0.75em;
/* Nudged down to sit on the first baseline of the paragraph rather than
above it, since it is smaller than the text it labels. */
line-height: 2;
color: light-dark(#999, #777);
font-variant-numeric: tabular-nums;
text-decoration: none;
}
.transcript .ts:hover { color: light-dark(#06c, #adf); }
/* A quoted paragraph starts further right than a plain one, by the
blockquote's border and padding, so its timestamp has to be pulled back
out through both or it would sit in a column of its own — and its right
edge would land inside the quotation's text.
-5.2em is in the timestamp's own em and matches the rule above; the two
subtracted lengths are absolute, so they mean the same here as they do
on the blockquote. Naming them once is what keeps the offset and the
indent from drifting apart. */
.transcript blockquote .ts {
margin-left: calc(-5.2em - var(--quote-indent) - var(--quote-rule));
}
/* Below this the margin is too narrow to hang anything in, so every
timestamp sits inline instead — including the quoted ones, which is why
this comes after the rule above and resets the margin outright. */
@media (max-width: 46em) {
.transcript .ts { float: none; margin-left: 0; width: auto; margin-right: 0.6em; }
.transcript blockquote .ts { margin-left: 0; }
}
/* Quotations are the text being read, as opposed to the reaction to it —
the distinction the whole format is built on, so it is worth a rule and
an indent rather than a tint alone. */
.transcript blockquote {
/* Named because the timestamp rule below has to undo exactly these two
to keep every timestamp on the page in one column.
In px, not em, and that is the whole point: a custom property is
substituted as tokens, so an em in one would be re-resolved against
whatever element uses it. The timestamp is 0.75em, so one em here
would come out 15px there against the 20px it is worth on this
element, and the quoted timestamps would sit 5px right of every
other one. An absolute length means both sides get the same number. */
--quote-indent: 20px;
--quote-rule: 3px;
margin: 1.2em 0;
padding: 0.2em 0 0.2em var(--quote-indent);
border-left: var(--quote-rule) solid light-dark(#ccc, #444);
color: light-dark(#333, #bbb);
}
.transcript blockquote .attrib {
font-size: 0.8em;
color: light-dark(#666, #999);
margin-top: 0.4em;
}
/* The mark that links a passage back to where it was read from. It is on
every quotation, unlike the credit line, because the question it
answers is asked of whatever passage is on screen.
An arrow rather than words: the credit already names the text, so
repeating "Read this in ..." under each of an episode's thirty-odd
passages would be more furniture than quotation. It carries hidden text
for screen readers, which would otherwise be read an arrow and told
nothing. */
.transcript .src {
font-size: 0.8em;
text-decoration: none;
color: light-dark(#999, #777);
white-space: nowrap;
padding: 0 0.15em;
}
.transcript .src:hover { color: light-dark(#06c, #adf); }
/* Visually hidden, still announced. Not display:none, which would take it
out of the accessibility tree along with everything else. */
.vh {
position: absolute;
width: 1px; height: 1px;
margin: -1px; padding: 0; border: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/* The pointer and the hover tint appear only while the recording is
running, which is the only time a click does anything. Without the
script the class is never set at all, so the text stays plain prose
that can be read and selected like any other.
The transition is on the span rather than the hover state so that the
tint fades out as well as in, which stops a moving cursor from
flickering a trail of boxes down the paragraph. */
.transcript span[data-t] { transition: background 0.12s; }
.transcript.seekable span[data-t] { cursor: pointer; }
.transcript.seekable span[data-t]:hover {
background: light-dark(#e4ecfa, #26313d);
}
/* A background tint rather than a colour change, so contrast against the
page is unaffected wherever the highlight lands. */
.transcript span.playing {
background: light-dark(#fdf3d0, #3a3320);
border-radius: 2px;
}
`
// Every rendered file says where it came from. The output lives in the website
// tree next to hand-written pages, so without this the obvious place to fix a
// typo is the generated HTML — where the fix survives exactly until the next
// render, silently.
//
// html/template strips comments from the template source, so for the HTML
// pages this has to arrive as data — hence template.HTML and the "Generated"
// key passed to both page templates.
const generatedBy = `<!-- Generated by observations(1). Do not edit.
Content lives in users/Profpatsch/observations/episodes/*.json,
layout and CSS in that directory's render.go. Re-run:
observations render -out users/Profpatsch/web/observations -->
`
// generatedComment is the same notice, marked as trusted markup so
// html/template emits it rather than escaping it into visible text.
var generatedComment = template.HTML(generatedBy)
var indexTmpl = template.Must(template.New("index").Parse(`<!DOCTYPE html>
{{ .Generated }}<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ .Title }}</title>
<meta name="description" content="{{ .Tagline }}">
<meta property="og:title" content="{{ .Title }}">
<meta property="og:description" content="{{ .Tagline }}">
<meta property="og:type" content="website">
<meta property="og:url" content="` + baseURL + `/">
<link rel="canonical" href="` + baseURL + `/">
<link rel="alternate" type="application/rss+xml" title="{{ .Title }}" href="/feed.xml">
<style>` + styleCSS + `</style>
</head>
<body>
<h1>{{ .Title }}</h1>
<p class="tagline">{{ .Tagline }}</p>
<hr>
{{ if .Episodes }}
<ul class="episodes">
{{ range .Episodes }}
<li>
<span class="num">{{ .Slug }}</span>
<a href="/{{ .Slug }}/">{{ .Subject }}</a>
<div class="meta">
{{ .Byline }} ·
{{ .DurationShort }} · <span class="date">{{ .Recorded }}</span>
</div>
</li>
{{ end }}
</ul>
{{ else }}
<p>No episodes yet.</p>
{{ end }}
<nav>
<a href="/feed.xml">RSS feed</a> — paste this into a podcast app to subscribe.
</nav>
<footer>
<a href="https://profpatsch.de">profpatsch.de</a>
</footer>
</body>
</html>
`))
var episodeTmpl = template.Must(template.New("episode").Parse(`<!DOCTYPE html>
{{ .Generated }}<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ .E.Title }} — observations</title>
<meta name="description" content="{{ .E.MetaDescription }}">
<meta property="og:title" content="{{ .E.Title }}">
<meta property="og:description" content="{{ .E.MetaDescription }}">
<meta property="og:type" content="article">
<meta property="og:url" content="{{ .BaseURL }}/{{ .E.Slug }}/">
<meta property="og:audio" content="{{ .BaseURL }}/audio/{{ .E.Audio.File }}">
<meta property="og:audio:type" content="{{ .E.Audio.MIME }}">
<link rel="canonical" href="{{ .BaseURL }}/{{ .E.Slug }}/">
<link rel="alternate" type="application/rss+xml" title="observations" href="/feed.xml">
<style>` + styleCSS + replyCSS + transcriptCSS + `</style>
</head>
<body>
<nav class="top">
<a href="/">All episodes</a> · <a href="/feed.xml">RSS</a>
</nav>
<h1>{{ .E.Title }}</h1>
<p class="meta">{{ .E.DurationShort }} · recorded {{ .E.Recorded }}</p>
<audio controls preload="metadata" id="player" src="/audio/{{ .E.Audio.File }}">
<a href="/audio/{{ .E.Audio.File }}">Download the audio</a>
</audio>
{{ with .E.Notes }}<p>{{ . }}</p>{{ end }}
<!-- Every link off this page opens in a new tab, which is otherwise a thing
to avoid: it takes a decision that belongs to the reader, and the back
button is the one control everybody knows.
An episode page is the exception, because leaving it stops the audio. A
reader who follows a source mid-episode and comes back has lost their
place in a half-hour recording, and browsers do not restore it. The tab
is what keeps the recording playing while the article is read.
rel="noopener" because target="_blank" otherwise hands the opened page a
handle to this one. -->
{{ range .E.Sources }}<dl class="source">
<dt>Reading</dt>
<dd><a href="{{ .URL }}" target="_blank" rel="noopener">{{ .Title }}</a>{{ if .Note }} <span class="note">({{ .Note }})</span>{{ end }}</dd>
<dt>By</dt>
<dd>{{ .Author }}{{ if .Site }}, {{ .Site }}{{ end }}</dd>
<dt>Published</dt>
<dd>{{ .Published }}</dd>
</dl>
{{ end }}
<h2 class="replyhead">Send one back</h2>
<p class="invite">Have an observation? Hit record, or drop a file here
— it might end up on the next episode. The passphrase is said at the start
of every episode.</p>
<form class="reply" id="reply" method="POST" action="{{ .SubmitURL }}"
enctype="multipart/form-data">
<label class="drop" id="drop">
<input type="file" name="file" accept="audio/*" id="file">
<span id="droptext">Drop a recording here, or pick one — {{ .MaxSize }} at most</span>
</label>
<!-- The recorder is revealed by the script, and only once the browser is
known to support it. Hidden here so that a browser without
MediaRecorder, or without JavaScript at all, sees the file input and
nothing that does not work. -->
<div class="recorder" id="recorder" hidden>
<button type="button" id="rec">Record</button>
<span class="rectime" id="rectime" aria-live="polite"></span>
<audio class="preview" id="preview" controls hidden></audio>
</div>
<label class="line">Passphrase<br>
<input type="text" name="passphrase" id="passphrase" autocomplete="off"
spellcheck="false" required></label>
<label class="line consent">
<input type="checkbox" name="consent" value="on" id="consent">
I am allowed to run this through Google Gemini to transcribe it, so I can
cite from it in the next episode. Leave this unchecked and it won’t leave
my server, but then I won’t promise that I will splice the audio.
</label>
<button type="submit" id="send">Send it</button>
<p class="status" id="status" role="status" aria-live="polite"></p>
<details>
<summary>What happens to this</summary>
<p>It goes to me (Profpatsch) and nowhere else. If I am allowed to include
a snippet of your audio, please state so at the beginning or the end of
your message, otherwise I will only cite or paraphrase.</p>
</details>
</form>
{{ if .E.HasTranscript }}
<h2 id="transcript">Transcript</h2>
<!-- The note says what the transcript is for, not how good it is. Whether a
transcript has been through a correction pass is not something render
knows — the file does not record it — and claiming "corrected by hand"
over raw recogniser output would be a lie on whichever episode is next
to be ingested. See observations-transcripts(7).
It describes the behaviour as it actually is: seeking works while the
recording plays, and the sentence says so rather than inviting a click
that does nothing on a page nobody has started. -->
<p class="meta transcriptnote">While it plays, click a line to jump
there.</p>
<div class="transcript" id="transcript-body">
{{- range .E.TranscriptBlocks }}
{{- if .Quote }}
<blockquote>
{{- $url := .AttribURL }}{{ $attrib := .AttribText }}{{ $link := .Link }}{{ $lt := .LinkTitle }}
{{- $last := .LastPara }}
{{- range $i, $p := .Paras }}
<p id="{{ $p.Anchor }}"><a class="ts" href="#{{ $p.Anchor }}">{{ $p.Timecode }}</a>{{ range $p.Lines }}<span data-t="{{ .Times }}">{{ .Text }}</span> {{ end }}
{{- if and $link (eq $i $last) }}<a class="src" href="{{ $link }}" target="_blank" rel="noopener" title="Read this in “{{ $lt }}”"><span aria-hidden="true">↗</span><span class="vh"> Read this passage in the original (opens in a new tab)</span></a>{{ end }}</p>
{{- end }}
{{- if $attrib }}
<p class="attrib">— {{ if $url }}<a href="{{ $url }}" target="_blank" rel="noopener">{{ $attrib }}</a>{{ else }}{{ $attrib }}{{ end }}</p>
{{- end }}
</blockquote>
{{- else }}
{{- range .Paras }}
<p id="{{ .Anchor }}"><a class="ts" href="#{{ .Anchor }}">{{ .Timecode }}</a>{{ range .Lines }}<span data-t="{{ .Times }}">{{ .Text }}</span> {{ end }}</p>
{{- end }}
{{- end }}
{{- end }}
</div>
{{ end }}
<script>
// Progressive enhancement only: without this script the form still submits
// normally, because it is an ordinary multipart form with a real action.
(function () {
var form = document.getElementById('reply');
var drop = document.getElementById('drop');
var file = document.getElementById('file');
var text = document.getElementById('droptext');
var status = document.getElementById('status');
var send = document.getElementById('send');
var maxBytes = {{ .MaxBytesJS }};
// recordedTake is set when the file came from the microphone rather than
// from disk, because the two want different words: a recording has no
// filename worth showing, and telling someone who just recorded to pick a
// lower quality setting is advice they cannot act on.
var recordedTake = false;
function say(msg, kind) {
status.textContent = msg;
status.className = 'status' + (kind ? ' ' + kind : '');
}
function isFileDrag(e) {
return Array.prototype.indexOf.call(
(e.dataTransfer && e.dataTransfer.types) || [], 'Files') >= 0;
}
['dragenter', 'dragover'].forEach(function (ev) {
drop.addEventListener(ev, function (e) {
if (!isFileDrag(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
drop.classList.add('over');
});
});
['dragleave', 'dragend'].forEach(function (ev) {
drop.addEventListener(ev, function () { drop.classList.remove('over'); });
});
drop.addEventListener('drop', function (e) {
if (!isFileDrag(e)) return;
e.preventDefault();
drop.classList.remove('over');
if (e.dataTransfer.files.length) {
// Assigning to .files is what makes the dropped file part of the form,
// so a no-JS submit and a dropped file take the same path.
recordedTake = false;
file.files = e.dataTransfer.files;
file.dispatchEvent(new Event('change'));
}
});
file.addEventListener('change', function () {
if (!file.files.length) return;
var f = file.files[0];
text.textContent = recordedTake
? 'Your recording is ready to send'
: f.name;
if (f.size > maxBytes) {
say(recordedTake
? 'That take is ' + (f.size / 1048576).toFixed(1) +
' MB, over the ' + (maxBytes / 1048576) + ' MB limit. A shorter one will fit.'
: 'That file is ' + (f.size / 1048576).toFixed(1) +
' MB, which is over the limit. A shorter recording, or a lower ' +
'quality setting, should fit.', 'error');
} else {
say('');
}
});
form.addEventListener('submit', function (e) {
if (!file.files.length) {
e.preventDefault();
say('Pick a recording first.', 'error');
return;
}
// Checked here as well as on the server, only so the answer is instant.
if (file.files[0].size > maxBytes) {
e.preventDefault();
say('That file is too large to send.', 'error');
return;
}
e.preventDefault();
drop.classList.add('busy');
send.disabled = true;
say('Sending…');
fetch(form.action, { method: 'POST', body: new FormData(form) })
.then(function (r) {
return r.json().catch(function () {
return { ok: r.ok, error: 'The server said ' + r.status + '.' };
});
})
.then(function (r) {
if (r.ok) {
form.reset();
recordedTake = false;
preview.hidden = true;
preview.removeAttribute('src');
recTime.textContent = '';
recBtn.textContent = 'Record';
text.textContent = 'Drop a recording here, or pick one';
say('Sent. Thank you — it has arrived.', 'done');
} else {
say(r.error || 'That did not work.', 'error');
}
})
.catch(function () {
say('That did not get through. Check your connection and try again.',
'error');
})
.finally(function () {
drop.classList.remove('busy');
send.disabled = false;
});
});
// ── recording in the browser ─────────────────────────────────────────────
//
// A recorded take is put into the same file input a dropped file lands in,
// so from here on there is one path: one file, one submit handler, one set
// of checks. DataTransfer is the only way to construct a FileList, which is
// what .files demands.
//
// Everything here is behind a feature test and the controls stay hidden
// until it passes: a browser without MediaRecorder (or without permission)
// is left with the file input, which works everywhere.
var recorderEl = document.getElementById('recorder');
var recBtn = document.getElementById('rec');
var recTime = document.getElementById('rectime');
var preview = document.getElementById('preview');
var canRecord = !!(window.MediaRecorder && navigator.mediaDevices &&
navigator.mediaDevices.getUserMedia &&
window.DataTransfer);
if (canRecord) recorderEl.hidden = false;
var mediaRec = null, chunks = [], startedAt = 0, ticker = null, stream = null;
function mmss(sec) {
var m = Math.floor(sec / 60), s = sec % 60;
return m + ':' + (s < 10 ? '0' : '') + s;
}
// The size limit is a duration limit in disguise, and the sender cannot see
// bytes accumulating. Showing elapsed time next to the estimate is what
// keeps a long take from being rejected only at the end, after the effort.
function tick() {
var sec = Math.round((Date.now() - startedAt) / 1000);
recTime.textContent = mmss(sec) + ' — recording';
}
function stopTracks() {
if (stream) { stream.getTracks().forEach(function (t) { t.stop(); }); stream = null; }
}
function pickMime() {
// Safari produces mp4, Chrome and Firefox webm. The server sniffs the
// bytes rather than trusting any of this, so the only job here is to ask
// for something the browser can actually produce.
var wanted = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus'];
for (var i = 0; i < wanted.length; i++) {
if (MediaRecorder.isTypeSupported && MediaRecorder.isTypeSupported(wanted[i])) {
return wanted[i];
}
}
return '';
}
function startRecording() {
navigator.mediaDevices.getUserMedia({ audio: true }).then(function (s) {
stream = s;
chunks = [];
var mime = pickMime();
mediaRec = mime ? new MediaRecorder(s, { mimeType: mime }) : new MediaRecorder(s);
mediaRec.addEventListener('dataavailable', function (e) {
if (e.data && e.data.size) chunks.push(e.data);
});
mediaRec.addEventListener('stop', function () {
stopTracks();
clearInterval(ticker);
var type = (mediaRec.mimeType || 'audio/webm').split(';')[0];
var blob = new Blob(chunks, { type: type });
var ext = type.indexOf('mp4') >= 0 ? '.m4a'
: type.indexOf('ogg') >= 0 ? '.ogg' : '.webm';
var dt = new DataTransfer();
dt.items.add(new File([blob], 'recording' + ext, { type: type }));
file.files = dt.files;
recordedTake = true;
preview.src = URL.createObjectURL(blob);
preview.hidden = false;
recBtn.textContent = 'Record again';
recTime.textContent = mmss(Math.round((Date.now() - startedAt) / 1000));
// Reuse the same reporting the file input goes through, so an
// oversized take is reported the one way.
file.dispatchEvent(new Event('change'));
});
mediaRec.start();
startedAt = Date.now();
tick();
ticker = setInterval(tick, 1000);
recBtn.textContent = 'Stop';
say('');
}).catch(function () {
say('The microphone is not available. You can still drop a file.', 'error');
});
}
// Choosing a file through the picker replaces any recorded take.
file.addEventListener('click', function () { recordedTake = false; });
if (canRecord) {
recBtn.addEventListener('click', function () {
if (mediaRec && mediaRec.state === 'recording') {
mediaRec.stop();
} else {
startRecording();
}
});
}
// Say up front if the inbox is closed, rather than after someone has
// recorded a reply and tried to send it.
fetch({{ .SubmitURLJS }}, { method: 'GET' })
.then(function (r) { return r.json(); })
.then(function (st) {
if (st && st.open === false) {
drop.classList.add('busy');
send.disabled = true;
say('Submissions are closed for the moment — the inbox is full. ' +
'Try again in a few days.', 'error');
}
})
.catch(function () { /* the form still works; let them try */ });
})();
// ── the transcript ────────────────────────────────────────────────────────
//
// Its own IIFE: it shares nothing with the form above, and neither should be
// able to break the other. Progressive enhancement again — without any of
// this the transcript is still prose, and its paragraph timestamps are still
// in-page anchors.
(function () {
// The container, not the heading above it: they are different elements and
// only one holds the text. They had the same id once, which meant this
// lookup returned the heading, found no lines in it, and returned before
// binding anything — the whole transcript was inert and the hover styling
// never appeared, because the class below was never added.
var box = document.getElementById('transcript-body');
var player = document.getElementById('player');
if (!box || !player) return;
var spans = Array.prototype.slice.call(box.querySelectorAll('span[data-t]'));
if (!spans.length) return;
// Parsed once. The values are milliseconds; the player works in seconds.
var lines = spans.map(function (el) {
var t = (el.getAttribute('data-t') || '').split(',');
return { el: el, start: +t[0] / 1000, end: +t[1] / 1000 };
});
// ── clicking ────────────────────────────────────────────────────────────
//
// A click seeks to the start of the line it landed in. That is an exact
// stored timestamp rather than an estimate: a line's start is the first
// word's start (observations-transcripts(5)), so the audio lands on the
// beginning of the phrase that was clicked.
//
// Only while the recording is running. Seeking is an operation on playback,
// and on a page that is not playing there is nothing to move; making a
// click start the audio would mean a page being read quietly can be made to
// speak by a stray click. The affordance is gated the same way, so nothing
// invites a click that would do nothing — see the 'playing' class.
lines.forEach(function (line) {
line.el.addEventListener('click', function () {
if (player.paused) return;
// A click also fires at the end of a drag-select, so copying a sentence
// out of a playing episode would otherwise move the playhead every
// time. A collapsed selection means nothing was selected.
var sel = window.getSelection && window.getSelection();
if (sel && !sel.isCollapsed) return;
player.currentTime = line.start;
});
});
// Whether a click would do anything depends on the player, so the styling
// that says so follows it. Set from the actual state rather than assumed,
// since a page restored from the back/forward cache can arrive playing.
function affordance() {
box.classList.toggle('seekable', !player.paused);
}
['play', 'playing', 'pause', 'ended', 'emptied'].forEach(function (ev) {
player.addEventListener(ev, affordance);
});
affordance();
// ── following along ─────────────────────────────────────────────────────
//
// Nothing scrolls. The reader decides where to look; a page that moves
// itself is unusable when reading ahead of the audio, which is most of the
// time.
var current = null;
// Binary search for the line covering t. Lines ascend and do not overlap
// (observations-transcripts(5)), so this is well defined; a t that falls in
// a gap between lines is silence, and highlights nothing.
function lineAt(t) {
var lo = 0, hi = lines.length - 1;
while (lo <= hi) {
var mid = (lo + hi) >> 1;
if (t < lines[mid].start) hi = mid - 1;
else if (t > lines[mid].end) lo = mid + 1;
else return lines[mid];
}
return null;
}
function mark(line) {
if (line === current) return;
if (current) current.el.classList.remove('playing');
if (line) line.el.classList.add('playing');
current = line;
}
player.addEventListener('timeupdate', function () {
mark(lineAt(player.currentTime));
});
player.addEventListener('seeked', function () {
mark(lineAt(player.currentTime));
});
// ── arriving on a timestamp link ────────────────────────────────────────
//
// A paragraph anchor is #t<ms>. Landing on one seeks there, so a link
// shared into a chat starts where it says it does rather than merely
// scrolling to it. It does not autoplay: arriving at a page that starts
// making noise is worse than one click.
function seekToHash() {
var m = /^#t(\d+)$/.exec(location.hash || '');
if (!m) return;
var t = +m[1] / 1000;
// On a fresh load the metadata may not be there yet, and assigning
// currentTime before it is silently does nothing.
if (player.readyState > 0) {
player.currentTime = t;
mark(lineAt(t));
} else {
player.addEventListener('loadedmetadata', function once() {
player.removeEventListener('loadedmetadata', once);
player.currentTime = t;
mark(lineAt(t));
});
}
}
window.addEventListener('hashchange', seekToHash);
seekToHash();
})();
</script>
</body>
</html>
`))
// RSS 2.0 with the iTunes extensions, which is what podcast clients read.
//
// This uses text/template with an explicit XML escaper, not html/template:
// html/template's autoescaping is contextual and assumes it is producing HTML,
// so pointing it at an XML document is a category error that produces subtly
// wrong output (URLs in particular). Every interpolation below therefore goes
// through `x`, which is xml.EscapeText.
//
// There is deliberately no itunes:image: artwork does not exist yet, and an
// element pointing at a missing file is worse than an absent one. Adding it
// later changes nothing else about the feed.
var feedTmpl = texttemplate.Must(texttemplate.New("feed").Funcs(texttemplate.FuncMap{"x": xmlEscape}).Parse(
`<?xml version="1.0" encoding="UTF-8"?>
` + generatedBy + `<rss version="2.0"
xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
xmlns:atom="http://www.w3.org/2005/Atom"
xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>{{ x .Title }}</title>
<link>{{ x .Link }}</link>
<atom:link href="{{ x .FeedURL }}" rel="self" type="application/rss+xml"/>
<description>{{ x .Description }}</description>
<language>{{ x .Language }}</language>
<generator>observations</generator>
{{ with .Built }}<lastBuildDate>{{ x . }}</lastBuildDate>{{ end }}
<itunes:author>{{ x .Author }}</itunes:author>
<itunes:summary>{{ x .Description }}</itunes:summary>
<itunes:type>episodic</itunes:type>
<itunes:explicit>false</itunes:explicit>
<itunes:category text="Society & Culture"/>
<itunes:owner>
<itunes:name>{{ x .Author }}</itunes:name>
<itunes:email>{{ x .Email }}</itunes:email>
</itunes:owner>
{{ range .Items }}
<item>
<title>{{ x .Title }}</title>
<link>{{ x .Link }}</link>
<guid isPermaLink="true">{{ x .GUID }}</guid>
<pubDate>{{ x .PubDate }}</pubDate>
<description>{{ x .Summary }}</description>
<itunes:summary>{{ x .Summary }}</itunes:summary>
<itunes:duration>{{ x .Duration }}</itunes:duration>
<itunes:explicit>false</itunes:explicit>
<enclosure url="{{ x .AudioURL }}" length="{{ .Bytes }}" type="{{ x .MIME }}"/>
</item>
{{ end }}
</channel>
</rss>
`))
func xmlEscape(s string) string {
var b bytes.Buffer
if err := xml.EscapeText(&b, []byte(s)); err != nil {
// EscapeText only fails if the writer fails, and a bytes.Buffer
// does not.
panic(err)
}
return b.String()
}
|