@@ -13,9 +13,10 @@ pub(crate) fn write(site: &Site, public: &Path) -> Result<()> {
1313 let mut counts: HashMap < String , u64 > = HashMap :: new ( ) ;
1414
1515 for path in md_paths {
16- if let Ok ( text) = fs:: read_to_string ( & path) {
17- let stripped = strip_links ( & text) ;
18- let stripped = strip_html ( & stripped) ;
16+ if let Ok ( text) = fs:: read_to_string ( & path)
17+ && !is_list_page ( & text)
18+ {
19+ let stripped = strip_entities ( & strip_html ( & strip_links ( & text) ) ) ;
1920 for word in tokenize_words ( & stripped) {
2021 * counts. entry ( word) . or_insert ( 0 ) += 1 ;
2122 }
@@ -29,121 +30,162 @@ pub(crate) fn write(site: &Site, public: &Path) -> Result<()> {
2930 let data_js = format ! ( "const ZIPF_DATA = {};" , serde_json:: to_string( & items) ?) ;
3031 let top_list_html = build_top_list_html ( & items) ;
3132
33+ let word_count: u64 = items. iter ( ) . map ( |( _, count) | count) . sum ( ) ;
34+ let unique = items. len ( ) ;
35+
3236 let html = format ! (
3337 r#"<!DOCTYPE html>
3438<html lang="en">
3539<head>
3640 <meta charset="utf-8" />
41+ <meta name="viewport" content="width=device-width, initial-scale=1" />
3742 <title>Zipf Distribution</title>
3843 <style>
39- body {{ font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial; padding: 20px; }}
40- .container {{ display: flex; gap: 20px; flex-wrap: wrap; }}
44+ body {{ font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial; margin: 0; padding: 20px; color: #222; background: #fff ; }}
45+ .container {{ display: flex; gap: 20px; flex-wrap: wrap; align-items: flex-start; }}
4146 .plot {{ flex: 1 1 480px; min-width: 300px; }}
42- .list {{ width: 360px ; max-height: 80vh; overflow: auto; }}
43- svg {{ width: 100%; height: 600px ; border: 1px solid #ddd; background: #fff; }}
47+ .list {{ flex: 0 1 320px ; max-height: 80vh; overflow: auto; }}
48+ svg {{ width: 100%; height: auto ; border: 1px solid #ddd; background: #fff; }}
4449 .axis {{ stroke: #666; stroke-width: 1; }}
45- .point {{ fill: steelblue; opacity: 0.9; }}
46- .label {{ font-size: 12px; fill: #333; }}
50+ .fit {{ stroke: #c33; stroke-width: 1; stroke-dasharray: 4 4; }}
51+ .point {{ fill: steelblue; opacity: 0.8; }}
52+ .point:hover {{ fill: #c33; opacity: 1; }}
53+ .label {{ font-size: 11px; fill: #333; }}
54+ .word {{ font-size: 11px; fill: #333; pointer-events: none; }}
4755 </style>
4856</head>
4957<body>
50- <h1>Zipf Distribution of Words in /content</h1>
51- <p>Scatter plot of rank vs frequency (log-log). Right column lists the top words.</p>
58+ <h1>Zipf distribution of the words in /content</h1>
59+ <p>{word_count} words, {unique} distinct. Rank against frequency, both logarithmic. The dashed line is what Zipf's law predicts from the most common word alone. The most common words are labelled; hover any point for its word, or find it in the list .</p>
5260 <div class="container">
5361 <div class="plot">
54- <svg id="plot" viewBox="0 0 800 600" preserveAspectRatio="none "></svg>
62+ <svg id="plot" viewBox="0 0 800 600" role="img" aria-label="rank against frequency "></svg>
5563 </div>
5664 <div class="list">
57- <h2>Top words</h2>
65+ <h2>All words</h2>
5866 {top_list_html}
5967 </div>
6068 </div>
6169
6270 <script>
6371{data_js}
6472
65- function log10(x) {{ return Math.log(x) / Math.LN10; }}
73+ const LABELLED = 25;
74+ const POINTS = 5000;
6675
6776function draw() {{
6877 const svg = document.getElementById('plot');
6978 const width = 800, height = 600, pad = 60;
70- svg.setAttribute('viewBox', `0 0 ${{width}} ${{height}}`);
71- const data = ZIPF_DATA.map((d, i) => ({{ rank: i + 1, count: d[1], word: d[0] }}));
79+ const data = ZIPF_DATA.slice(0, POINTS).map((d, i) => ({{ rank: i + 1, count: d[1], word: d[0] }}));
7280 if (data.length === 0) return;
7381
74- const ranks = data.map(d => d.rank);
75- const counts = data.map(d => d.count);
76- const xmin = Math.max(1, Math.min(...ranks));
77- const xmax = Math.max(...ranks);
78- const ymin = Math.max(1, Math.min(...counts));
79- const ymax = Math.max(...counts);
80-
81- const lxmin = log10(xmin), lxmax = log10(xmax);
82- const lymin = log10(ymin), lymax = log10(ymax);
83-
84- function sx(x) {{ return pad + ((log10(x) - lxmin) / (lxmax - lxmin)) * (width - pad * 2); }}
85- function sy(y) {{ return height - pad - ((log10(y) - lymin) / (lymax - lymin)) * (height - pad * 2); }}
82+ const log10 = Math.log10;
83+ const lxmax = Math.max(log10(ZIPF_DATA.length), 1);
84+ const lymax = Math.max(log10(data[0].count), 1);
85+ const sx = x => pad + (log10(x) / lxmax) * (width - pad * 2);
86+ const sy = y => height - pad - (log10(y) / lymax) * (height - pad * 2);
8687
87- svg.innerHTML = '';
8888 const ns = 'http://www.w3.org/2000/svg';
89- function el(name, attrs) {{
89+ function el(name, attrs, parent ) {{
9090 const e = document.createElementNS(ns, name);
9191 for (const k in attrs) e.setAttribute(k, attrs[k]);
92+ (parent || svg).appendChild(e);
9293 return e;
9394 }}
9495
95- svg.appendChild(el('rect', {{ x:0, y:0, width:width, height:height, fill:'white' }}));
96- svg.appendChild(el('line', {{ x1: pad, y1: height-pad, x2: width-pad, y2: height-pad, class:'axis' }}));
97- svg.appendChild(el('line', {{ x1: pad, y1: pad, x2: pad, y2: height-pad, class:'axis' }}));
96+ el('line', {{ x1: pad, y1: height - pad, x2: width - pad, y2: height - pad, class: 'axis' }});
97+ el('line', {{ x1: pad, y1: pad, x2: pad, y2: height - pad, class: 'axis' }});
9898
99- for (let e = Math.ceil(lxmin); e <= Math.floor(lxmax); e++) {{
100- const x = Math.pow(10, e);
101- const xPos = sx(x);
102- svg.appendChild(el('line', {{ x1:xPos, y1:height-pad, x2:xPos, y2:height-pad+6, stroke:'#666' }}));
103- svg.appendChild(el('text', {{ x:xPos, y:height-pad+20, 'text-anchor':'middle', class:'label' }})).textContent = '10^' + e;
99+ for (let e = 0; e <= Math.floor(lxmax); e++) {{
100+ const x = sx(Math.pow(10, e));
101+ el('line', {{ x1: x, y1: height - pad, x2: x, y2: height - pad + 6, class: 'axis' }});
102+ el('text', {{ x, y: height - pad + 20, 'text-anchor': 'middle', class: 'label' }}).textContent = Math.pow(10, e).toLocaleString();
104103 }}
104+ el('text', {{ x: width / 2, y: height - pad + 40, 'text-anchor': 'middle', class: 'label' }}).textContent = 'rank';
105105
106- for (let e = Math.ceil(lymin); e <= Math.floor(lymax); e++) {{
107- const y = Math.pow(10, e);
108- const yPos = sy(y);
109- svg.appendChild(el('line', {{ x1:pad-6, y1:yPos, x2:pad, y2:yPos, stroke:'#666' }}));
110- svg.appendChild(el('text', {{ x:pad-10, y:yPos+4, 'text-anchor':'end', class:'label' }})).textContent = '10^' + e;
106+ for (let e = 0; e <= Math.floor(lymax); e++) {{
107+ const y = sy(Math.pow(10, e));
108+ el('line', {{ x1: pad - 6, y1: y, x2: pad, y2: y, class: 'axis' }});
109+ el('text', {{ x: pad - 10, y: y + 4, 'text-anchor': 'end', class: 'label' }}).textContent = Math.pow(10, e).toLocaleString();
111110 }}
111+ el('text', {{ x: 16, y: height / 2, 'text-anchor': 'middle', class: 'label', transform: `rotate(-90 16 ${{height / 2}})` }}).textContent = 'occurrences';
112112
113- const limit = Math.min(data.length, 5000);
114- for (let i = 0; i < limit; i++) {{
115- const d = data[i];
116- svg.appendChild(el('circle', {{ cx: sx(d.rank), cy: sy(d.count), r: 2, class: 'point' }}));
113+ const top = data[0].count;
114+ const end = Math.min(ZIPF_DATA.length, top);
115+ el('line', {{ x1: sx(1), y1: sy(top), x2: sx(end), y2: sy(top / end), class: 'fit' }});
116+
117+ for (const d of data) {{
118+ const point = el('circle', {{ cx: sx(d.rank), cy: sy(d.count), r: 2, class: 'point' }});
119+ el('title', {{}}, point).textContent = `${{d.rank}}. ${{d.word}} (${{d.count}})`;
117120 }}
118121
119- for (let i = 0; i < Math.min(2000000, data.length); i++) {{
120- const d = data[i];
121- const t = el('text', {{ x: sx(d.rank) + 6, y: sy(d.count) + 4, class:'label' }});
122- t.textContent = `${{i+1}}: ${{d.word}} (${{d.count}})`;
123- svg.appendChild(t);
122+ let lastY = -Infinity;
123+ for (const d of data.slice(0, LABELLED)) {{
124+ const y = sy(d.count);
125+ if (Math.abs(y - lastY) < 12) continue;
126+ lastY = y;
127+ el('text', {{ x: sx(d.rank) + 6, y: y + 4, class: 'word' }}).textContent = d.word;
124128 }}
125129}}
126130
127- window.addEventListener('load', draw);
128- window.addEventListener('resize', draw);
131+ draw();
129132 </script>
130133</body>
131134</html>
132- "# ,
133- data_js = data_js,
134- top_list_html = top_list_html
135+ "#
135136 ) ;
136137
137138 fs:: write ( public. join ( "zipf.html" ) , html) ?;
138139 Ok ( ( ) )
139140}
140141
141- const FILE_BLACKLIST : & [ & str ] = & [ "content/misc/posts/index.md" ] ;
142+ /// Whether a page is an index rather than prose: anything tagged `list`, which
143+ /// covers the post listing, the gallery indices, and the 3 MB Udon exposure
144+ /// dump that would otherwise be half of every count on the page.
145+ fn is_list_page ( text : & str ) -> bool {
146+ let Some ( body) = text. strip_prefix ( "---" ) else {
147+ return false ;
148+ } ;
149+ let Some ( end) = body. find ( "\n ---" ) else {
150+ return false ;
151+ } ;
152+
153+ body[ ..end] . lines ( ) . any ( |line| {
154+ let line = line. trim ( ) ;
155+ line == "- list"
156+ || line
157+ . strip_prefix ( "tags:" )
158+ . is_some_and ( |tags| tags. split ( [ '[' , ']' , ',' ] ) . any ( |tag| tag. trim ( ) == "list" ) )
159+ } )
160+ }
142161
143- fn is_blacklisted ( path : & Path ) -> bool {
144- FILE_BLACKLIST
145- . iter ( )
146- . any ( |blocked| path. ends_with ( Path :: new ( blocked) ) )
162+ /// Drops `<`, `'` and the rest: an entity renders as punctuation, and
163+ /// left in it counts as a word called `lt`.
164+ fn strip_entities ( text : & str ) -> String {
165+ let mut out = String :: with_capacity ( text. len ( ) ) ;
166+ let mut rest = text;
167+
168+ while let Some ( start) = rest. find ( '&' ) {
169+ out. push_str ( & rest[ ..start] ) ;
170+ let after = & rest[ start + 1 ..] ;
171+ let entity = after. find ( ';' ) . filter ( |& end| {
172+ end <= 10
173+ && after[ ..end]
174+ . chars ( )
175+ . all ( |c| c. is_ascii_alphanumeric ( ) || c == '#' )
176+ } ) ;
177+
178+ match entity {
179+ Some ( end) => rest = & after[ end + 1 ..] ,
180+ None => {
181+ out. push ( '&' ) ;
182+ rest = after;
183+ }
184+ }
185+ }
186+
187+ out. push_str ( rest) ;
188+ out
147189}
148190
149191fn collect_md_files ( dir : & Path , out : & mut Vec < PathBuf > ) -> std:: io:: Result < ( ) > {
@@ -166,7 +208,7 @@ fn collect_md_files(dir: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
166208 . and_then ( OsStr :: to_str)
167209 . is_some_and ( |ext| ext. eq_ignore_ascii_case ( "md" ) ) ;
168210
169- if is_md && ! is_blacklisted ( & path ) {
211+ if is_md {
170212 out. push ( path) ;
171213 }
172214 }
@@ -287,9 +329,16 @@ fn tokenize_words(text: &str) -> Vec<String> {
287329 let mut words = Vec :: new ( ) ;
288330 let mut buf = String :: new ( ) ;
289331
290- for ch in text. chars ( ) {
291- if ch. is_alphabetic ( ) {
292- buf. push ( ch. to_ascii_lowercase ( ) ) ;
332+ let mut chars = text. chars ( ) . peekable ( ) ;
333+ while let Some ( ch) = chars. next ( ) {
334+ let apostrophe = matches ! ( ch, '\'' | '’' ) ;
335+ let joins =
336+ apostrophe && !buf. is_empty ( ) && chars. peek ( ) . is_some_and ( |next| next. is_alphabetic ( ) ) ;
337+
338+ if apostrophe && joins {
339+ buf. push ( '\'' ) ;
340+ } else if ch. is_alphabetic ( ) {
341+ buf. extend ( ch. to_lowercase ( ) ) ;
293342 } else if !buf. is_empty ( ) {
294343 words. push ( std:: mem:: take ( & mut buf) ) ;
295344 }
@@ -315,3 +364,45 @@ fn build_top_list_html(items: &[(String, u64)]) -> String {
315364 out. push_str ( "</ol>" ) ;
316365 out
317366}
367+
368+ #[ cfg( test) ]
369+ mod tests {
370+ use super :: * ;
371+
372+ #[ test]
373+ fn leaves_indices_out_of_the_count ( ) {
374+ assert ! ( is_list_page(
375+ "---\n title: posts\n tags:\n - list\n ---\n \n body"
376+ ) ) ;
377+ assert ! ( is_list_page( "---\n tags: [unity, list]\n ---\n " ) ) ;
378+ assert ! ( !is_list_page(
379+ "---\n title: a list of things\n ---\n \n - list\n "
380+ ) ) ;
381+ assert ! ( !is_list_page( "no frontmatter\n ---\n - list\n " ) ) ;
382+ }
383+
384+ #[ test]
385+ fn an_entity_is_not_a_word ( ) {
386+ assert_eq ! ( strip_entities( "a <b> & c's" ) , "a b cs" ) ;
387+ assert_eq ! ( strip_entities( "tom & jerry" ) , "tom & jerry" ) ;
388+ assert_eq ! (
389+ strip_entities( "¬anentity because it is too long;" ) ,
390+ "¬anentity because it is too long;"
391+ ) ;
392+ }
393+
394+ #[ test]
395+ fn counts_the_words_of_the_prose_and_nothing_else ( ) {
396+ let text = "See [the docs](https://example.com/x) and <em>this</em> — https://a.b/c." ;
397+ let words = tokenize_words ( & strip_entities ( & strip_html ( & strip_links ( text) ) ) ) ;
398+ assert_eq ! ( words, [ "see" , "and" , "this" ] ) ;
399+ }
400+
401+ #[ test]
402+ fn keeps_a_contraction_together_and_drops_the_quotes_around_a_word ( ) {
403+ assert_eq ! (
404+ tokenize_words( "Don't ‘stop’, it’s 'fine' École" ) ,
405+ [ "don't" , "stop" , "it's" , "fine" , "école" ]
406+ ) ;
407+ }
408+ }
0 commit comments