| 1 | package movement |
| 2 | |
| 3 | import ( |
| 4 | "html/template" |
| 5 | "strings" |
| 6 | ) |
| 7 | |
| 8 | // Links inside copy, without putting markup inside copy. |
| 9 | // |
| 10 | // The alternative was to store the lede with anchors in it and render it |
| 11 | // through template.HTML, which turns the copy table into a place where |
| 12 | // escaping is off and one careless edit is an injection. Instead the copy |
| 13 | // stays plain text — reviewable as words, diffable as words — and the links |
| 14 | // are structured data beside it: a phrase, and where it points. |
| 15 | // |
| 16 | // The phrase has to appear in the copy verbatim. When copy is rewritten and a |
| 17 | // phrase stops matching, the link silently stops appearing, so a test asserts |
| 18 | // every phrase still resolves rather than trusting that nobody reworded |
| 19 | // around it. |
| 20 | |
| 21 | type copyLink struct { |
| 22 | Phrase string |
| 23 | URL string |
| 24 | } |
| 25 | |
| 26 | var links = map[string][]copyLink{ |
| 27 | "index.lede": { |
| 28 | {"philosophy", "https://11factor.org"}, |
| 29 | {"software ideals", "https://carlosframework.com/"}, |
| 30 | |
| 31 | // Three consecutive words, three destinations. Paul's call, and it |
| 32 | // only works because the words are adjacent: "projects we’re working |
| 33 | // on" reads as one phrase and turns out to be three doors. The space |
| 34 | // between them is not underlined, so they stay visibly separate |
| 35 | // links rather than one long one. |
| 36 | {"projects", "https://elevenmessenger.com/"}, |
| 37 | {"we’re", "https://rastrillo.org/"}, |
| 38 | {"working on", "https://carloku.com/"}, |
| 39 | }, |
| 40 | } |
| 41 | |
| 42 | // Linked renders one copy string as HTML: escaped first, then anchors spliced |
| 43 | // in for each phrase. Escaping happens before any markup is added, so nothing |
| 44 | // in the copy itself can become markup. |
| 45 | func Linked(id string) []template.HTML { |
| 46 | ls := links[id] |
| 47 | |
| 48 | out := make([]template.HTML, 0, 4) |
| 49 | for _, para := range Paragraphs(id) { |
| 50 | safe := escape(para) |
| 51 | for _, l := range ls { |
| 52 | needle := escape(l.Phrase) |
| 53 | if !strings.Contains(safe, needle) { |
| 54 | continue |
| 55 | } |
| 56 | anchor := `<a class="lede-link" href="` + escape(l.URL) + |
| 57 | `" target="_blank" rel="noopener noreferrer">` + needle + `</a>` |
| 58 | // Once only: a phrase that recurs should not become a row of |
| 59 | // identical links down the paragraph. |
| 60 | safe = strings.Replace(safe, needle, anchor, 1) |
| 61 | } |
| 62 | out = append(out, template.HTML(safe)) |
| 63 | } |
| 64 | return out |
| 65 | } |
| 66 | |
| 67 | // LinkPhrases lists the phrases a copy id expects to find, so a test can |
| 68 | // check they are all still present after a rewrite. |
| 69 | func LinkPhrases(id string) []string { |
| 70 | var out []string |
| 71 | for _, l := range links[id] { |
| 72 | out = append(out, l.Phrase) |
| 73 | } |
| 74 | return out |
| 75 | } |
| 76 | |
| 77 | func escape(s string) string { |
| 78 | var b strings.Builder |
| 79 | template.HTMLEscape(&b, []byte(s)) |
| 80 | return b.String() |
| 81 | } |
| 82 | |