krz/orgo

Lightning fast org-mode static site generator. fast go org-mode static-site-generator

Commit 4db41537b5

4db41537b58c193ae6fc4ffe4f154a282b23fc24

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-11 02:56 UTC

org-mode SSG v0.3: native Rust org parser, multi-file site build with link resolution, incremental content-hash rebuilds

Layout: unified · split

.gitignore added +1
@@ -0,0 +1 @@
1/target
Cargo.lock added +991
@@ -0,0 +1,991 @@
1# This file is automatically @generated by Cargo.
2# It is not intended for manual editing.
3version = 4
4
5[[package]]
6name = "adler2"
7version = "2.0.1"
8source = "registry+https://github.com/rust-lang/crates.io-index"
9checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
10
11[[package]]
12name = "android_system_properties"
13version = "0.1.6"
14source = "registry+https://github.com/rust-lang/crates.io-index"
15checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
16dependencies = [
17 "libc",
18]
19
20[[package]]
21name = "anstream"
22version = "1.0.0"
23source = "registry+https://github.com/rust-lang/crates.io-index"
24checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
25dependencies = [
26 "anstyle",
27 "anstyle-parse",
28 "anstyle-query",
29 "anstyle-wincon",
30 "colorchoice",
31 "is_terminal_polyfill",
32 "utf8parse",
33]
34
35[[package]]
36name = "anstyle"
37version = "1.0.14"
38source = "registry+https://github.com/rust-lang/crates.io-index"
39checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
40
41[[package]]
42name = "anstyle-parse"
43version = "1.0.0"
44source = "registry+https://github.com/rust-lang/crates.io-index"
45checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
46dependencies = [
47 "utf8parse",
48]
49
50[[package]]
51name = "anstyle-query"
52version = "1.1.5"
53source = "registry+https://github.com/rust-lang/crates.io-index"
54checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
55dependencies = [
56 "windows-sys",
57]
58
59[[package]]
60name = "anstyle-wincon"
61version = "3.0.11"
62source = "registry+https://github.com/rust-lang/crates.io-index"
63checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
64dependencies = [
65 "anstyle",
66 "once_cell_polyfill",
67 "windows-sys",
68]
69
70[[package]]
71name = "anyhow"
72version = "1.0.104"
73source = "registry+https://github.com/rust-lang/crates.io-index"
74checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
75
76[[package]]
77name = "arrayref"
78version = "0.3.9"
79source = "registry+https://github.com/rust-lang/crates.io-index"
80checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
81
82[[package]]
83name = "arrayvec"
84version = "0.7.8"
85source = "registry+https://github.com/rust-lang/crates.io-index"
86checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
87
88[[package]]
89name = "autocfg"
90version = "1.5.1"
91source = "registry+https://github.com/rust-lang/crates.io-index"
92checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
93
94[[package]]
95name = "base64"
96version = "0.22.1"
97source = "registry+https://github.com/rust-lang/crates.io-index"
98checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
99
100[[package]]
101name = "bincode"
102version = "1.3.3"
103source = "registry+https://github.com/rust-lang/crates.io-index"
104checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
105dependencies = [
106 "serde",
107]
108
109[[package]]
110name = "bitflags"
111version = "2.13.1"
112source = "registry+https://github.com/rust-lang/crates.io-index"
113checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
114
115[[package]]
116name = "blake3"
117version = "1.8.6"
118source = "registry+https://github.com/rust-lang/crates.io-index"
119checksum = "76ae7bad254120e9e4c63bafc385310756f90c484eac0e36b8317cf09cb92a77"
120dependencies = [
121 "arrayref",
122 "arrayvec",
123 "cc",
124 "cfg-if",
125 "constant_time_eq",
126 "cpufeatures",
127]
128
129[[package]]
130name = "bumpalo"
131version = "3.20.3"
132source = "registry+https://github.com/rust-lang/crates.io-index"
133checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
134
135[[package]]
136name = "camino"
137version = "1.2.5"
138source = "registry+https://github.com/rust-lang/crates.io-index"
139checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d"
140dependencies = [
141 "serde_core",
142]
143
144[[package]]
145name = "cc"
146version = "1.4.2"
147source = "registry+https://github.com/rust-lang/crates.io-index"
148checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e"
149dependencies = [
150 "find-msvc-tools",
151 "shlex",
152]
153
154[[package]]
155name = "cfg-if"
156version = "1.0.4"
157source = "registry+https://github.com/rust-lang/crates.io-index"
158checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
159
160[[package]]
161name = "chrono"
162version = "0.4.45"
163source = "registry+https://github.com/rust-lang/crates.io-index"
164checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
165dependencies = [
166 "iana-time-zone",
167 "js-sys",
168 "num-traits",
169 "serde",
170 "wasm-bindgen",
171 "windows-link",
172]
173
174[[package]]
175name = "clap"
176version = "4.6.6"
177source = "registry+https://github.com/rust-lang/crates.io-index"
178checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
179dependencies = [
180 "clap_builder",
181 "clap_derive",
182]
183
184[[package]]
185name = "clap_builder"
186version = "4.6.6"
187source = "registry+https://github.com/rust-lang/crates.io-index"
188checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
189dependencies = [
190 "anstream",
191 "anstyle",
192 "clap_lex",
193 "strsim",
194]
195
196[[package]]
197name = "clap_derive"
198version = "4.6.4"
199source = "registry+https://github.com/rust-lang/crates.io-index"
200checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
201dependencies = [
202 "heck",
203 "proc-macro2",
204 "quote",
205 "syn 3.0.3",
206]
207
208[[package]]
209name = "clap_lex"
210version = "1.1.0"
211source = "registry+https://github.com/rust-lang/crates.io-index"
212checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
213
214[[package]]
215name = "colorchoice"
216version = "1.0.5"
217source = "registry+https://github.com/rust-lang/crates.io-index"
218checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
219
220[[package]]
221name = "console"
222version = "0.16.4"
223source = "registry+https://github.com/rust-lang/crates.io-index"
224checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"
225dependencies = [
226 "encode_unicode",
227 "libc",
228 "windows-sys",
229]
230
231[[package]]
232name = "constant_time_eq"
233version = "0.4.2"
234source = "registry+https://github.com/rust-lang/crates.io-index"
235checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
236
237[[package]]
238name = "core-foundation-sys"
239version = "0.8.7"
240source = "registry+https://github.com/rust-lang/crates.io-index"
241checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
242
243[[package]]
244name = "cpufeatures"
245version = "0.3.0"
246source = "registry+https://github.com/rust-lang/crates.io-index"
247checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
248dependencies = [
249 "libc",
250]
251
252[[package]]
253name = "crc32fast"
254version = "1.5.0"
255source = "registry+https://github.com/rust-lang/crates.io-index"
256checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
257dependencies = [
258 "cfg-if",
259]
260
261[[package]]
262name = "deranged"
263version = "0.5.8"
264source = "registry+https://github.com/rust-lang/crates.io-index"
265checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
266
267[[package]]
268name = "encode_unicode"
269version = "1.0.0"
270source = "registry+https://github.com/rust-lang/crates.io-index"
271checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
272
273[[package]]
274name = "equivalent"
275version = "1.0.2"
276source = "registry+https://github.com/rust-lang/crates.io-index"
277checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
278
279[[package]]
280name = "errno"
281version = "0.3.14"
282source = "registry+https://github.com/rust-lang/crates.io-index"
283checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
284dependencies = [
285 "libc",
286 "windows-sys",
287]
288
289[[package]]
290name = "fastrand"
291version = "2.5.0"
292source = "registry+https://github.com/rust-lang/crates.io-index"
293checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
294
295[[package]]
296name = "find-msvc-tools"
297version = "0.1.10"
298source = "registry+https://github.com/rust-lang/crates.io-index"
299checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de"
300
301[[package]]
302name = "flate2"
303version = "1.1.9"
304source = "registry+https://github.com/rust-lang/crates.io-index"
305checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
306dependencies = [
307 "crc32fast",
308 "miniz_oxide",
309]
310
311[[package]]
312name = "fnv"
313version = "1.0.7"
314source = "registry+https://github.com/rust-lang/crates.io-index"
315checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
316
317[[package]]
318name = "futures-core"
319version = "0.3.33"
320source = "registry+https://github.com/rust-lang/crates.io-index"
321checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
322
323[[package]]
324name = "futures-task"
325version = "0.3.33"
326source = "registry+https://github.com/rust-lang/crates.io-index"
327checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
328
329[[package]]
330name = "futures-util"
331version = "0.3.33"
332source = "registry+https://github.com/rust-lang/crates.io-index"
333checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
334dependencies = [
335 "futures-core",
336 "futures-task",
337 "pin-project-lite",
338 "slab",
339]
340
341[[package]]
342name = "getrandom"
343version = "0.4.3"
344source = "registry+https://github.com/rust-lang/crates.io-index"
345checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
346dependencies = [
347 "cfg-if",
348 "libc",
349 "r-efi",
350]
351
352[[package]]
353name = "hashbrown"
354version = "0.17.1"
355source = "registry+https://github.com/rust-lang/crates.io-index"
356checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
357
358[[package]]
359name = "heck"
360version = "0.5.0"
361source = "registry+https://github.com/rust-lang/crates.io-index"
362checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
363
364[[package]]
365name = "iana-time-zone"
366version = "0.1.65"
367source = "registry+https://github.com/rust-lang/crates.io-index"
368checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
369dependencies = [
370 "android_system_properties",
371 "core-foundation-sys",
372 "iana-time-zone-haiku",
373 "js-sys",
374 "log",
375 "wasm-bindgen",
376 "windows-core",
377]
378
379[[package]]
380name = "iana-time-zone-haiku"
381version = "0.1.2"
382source = "registry+https://github.com/rust-lang/crates.io-index"
383checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
384dependencies = [
385 "cc",
386]
387
388[[package]]
389name = "indexmap"
390version = "2.14.0"
391source = "registry+https://github.com/rust-lang/crates.io-index"
392checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
393dependencies = [
394 "equivalent",
395 "hashbrown",
396]
397
398[[package]]
399name = "insta"
400version = "1.48.0"
401source = "registry+https://github.com/rust-lang/crates.io-index"
402checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82"
403dependencies = [
404 "console",
405 "once_cell",
406 "serde",
407 "similar",
408 "tempfile",
409]
410
411[[package]]
412name = "is_terminal_polyfill"
413version = "1.70.2"
414source = "registry+https://github.com/rust-lang/crates.io-index"
415checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
416
417[[package]]
418name = "itoa"
419version = "1.0.18"
420source = "registry+https://github.com/rust-lang/crates.io-index"
421checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
422
423[[package]]
424name = "js-sys"
425version = "0.3.104"
426source = "registry+https://github.com/rust-lang/crates.io-index"
427checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
428dependencies = [
429 "cfg-if",
430 "futures-util",
431 "wasm-bindgen",
432]
433
434[[package]]
435name = "libc"
436version = "0.2.189"
437source = "registry+https://github.com/rust-lang/crates.io-index"
438checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
439
440[[package]]
441name = "linked-hash-map"
442version = "0.5.6"
443source = "registry+https://github.com/rust-lang/crates.io-index"
444checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
445
446[[package]]
447name = "linux-raw-sys"
448version = "0.12.1"
449source = "registry+https://github.com/rust-lang/crates.io-index"
450checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
451
452[[package]]
453name = "log"
454version = "0.4.33"
455source = "registry+https://github.com/rust-lang/crates.io-index"
456checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
457
458[[package]]
459name = "memchr"
460version = "2.8.3"
461source = "registry+https://github.com/rust-lang/crates.io-index"
462checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
463
464[[package]]
465name = "memo-map"
466version = "0.3.3"
467source = "registry+https://github.com/rust-lang/crates.io-index"
468checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"
469
470[[package]]
471name = "minijinja"
472version = "2.23.0"
473source = "registry+https://github.com/rust-lang/crates.io-index"
474checksum = "42d74234349a775546a83af0f0c0c0e3a73227dee4950a542cda81a47240b3e6"
475dependencies = [
476 "memo-map",
477 "serde",
478]
479
480[[package]]
481name = "miniz_oxide"
482version = "0.8.9"
483source = "registry+https://github.com/rust-lang/crates.io-index"
484checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
485dependencies = [
486 "adler2",
487 "simd-adler32",
488]
489
490[[package]]
491name = "num-conv"
492version = "0.2.2"
493source = "registry+https://github.com/rust-lang/crates.io-index"
494checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
495
496[[package]]
497name = "num-traits"
498version = "0.2.19"
499source = "registry+https://github.com/rust-lang/crates.io-index"
500checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
501dependencies = [
502 "autocfg",
503]
504
505[[package]]
506name = "once_cell"
507version = "1.21.4"
508source = "registry+https://github.com/rust-lang/crates.io-index"
509checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
510
511[[package]]
512name = "once_cell_polyfill"
513version = "1.70.2"
514source = "registry+https://github.com/rust-lang/crates.io-index"
515checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
516
517[[package]]
518name = "onig"
519version = "6.5.3"
520source = "registry+https://github.com/rust-lang/crates.io-index"
521checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2"
522dependencies = [
523 "bitflags",
524 "libc",
525 "once_cell",
526 "onig_sys",
527]
528
529[[package]]
530name = "onig_sys"
531version = "69.9.3"
532source = "registry+https://github.com/rust-lang/crates.io-index"
533checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7"
534dependencies = [
535 "cc",
536 "pkg-config",
537]
538
539[[package]]
540name = "org-ssg"
541version = "0.3.0"
542dependencies = [
543 "anyhow",
544 "blake3",
545 "camino",
546 "chrono",
547 "clap",
548 "insta",
549 "minijinja",
550 "serde",
551 "serde_json",
552 "syntect",
553 "thiserror",
554 "walkdir",
555]
556
557[[package]]
558name = "pin-project-lite"
559version = "0.2.17"
560source = "registry+https://github.com/rust-lang/crates.io-index"
561checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
562
563[[package]]
564name = "pkg-config"
565version = "0.3.33"
566source = "registry+https://github.com/rust-lang/crates.io-index"
567checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
568
569[[package]]
570name = "plist"
571version = "1.10.0"
572source = "registry+https://github.com/rust-lang/crates.io-index"
573checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
574dependencies = [
575 "base64",
576 "indexmap",
577 "quick-xml",
578 "serde",
579 "time",
580]
581
582[[package]]
583name = "powerfmt"
584version = "0.2.0"
585source = "registry+https://github.com/rust-lang/crates.io-index"
586checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
587
588[[package]]
589name = "proc-macro2"
590version = "1.0.107"
591source = "registry+https://github.com/rust-lang/crates.io-index"
592checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
593dependencies = [
594 "unicode-ident",
595]
596
597[[package]]
598name = "quick-xml"
599version = "0.41.0"
600source = "registry+https://github.com/rust-lang/crates.io-index"
601checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
602dependencies = [
603 "memchr",
604]
605
606[[package]]
607name = "quote"
608version = "1.0.47"
609source = "registry+https://github.com/rust-lang/crates.io-index"
610checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
611dependencies = [
612 "proc-macro2",
613]
614
615[[package]]
616name = "r-efi"
617version = "6.0.0"
618source = "registry+https://github.com/rust-lang/crates.io-index"
619checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
620
621[[package]]
622name = "regex-syntax"
623version = "0.8.11"
624source = "registry+https://github.com/rust-lang/crates.io-index"
625checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
626
627[[package]]
628name = "rustix"
629version = "1.1.4"
630source = "registry+https://github.com/rust-lang/crates.io-index"
631checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
632dependencies = [
633 "bitflags",
634 "errno",
635 "libc",
636 "linux-raw-sys",
637 "windows-sys",
638]
639
640[[package]]
641name = "rustversion"
642version = "1.0.23"
643source = "registry+https://github.com/rust-lang/crates.io-index"
644checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
645
646[[package]]
647name = "same-file"
648version = "1.0.6"
649source = "registry+https://github.com/rust-lang/crates.io-index"
650checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
651dependencies = [
652 "winapi-util",
653]
654
655[[package]]
656name = "serde"
657version = "1.0.229"
658source = "registry+https://github.com/rust-lang/crates.io-index"
659checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
660dependencies = [
661 "serde_core",
662 "serde_derive",
663]
664
665[[package]]
666name = "serde_core"
667version = "1.0.229"
668source = "registry+https://github.com/rust-lang/crates.io-index"
669checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
670dependencies = [
671 "serde_derive",
672]
673
674[[package]]
675name = "serde_derive"
676version = "1.0.229"
677source = "registry+https://github.com/rust-lang/crates.io-index"
678checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
679dependencies = [
680 "proc-macro2",
681 "quote",
682 "syn 3.0.3",
683]
684
685[[package]]
686name = "serde_json"
687version = "1.0.151"
688source = "registry+https://github.com/rust-lang/crates.io-index"
689checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
690dependencies = [
691 "itoa",
692 "memchr",
693 "serde",
694 "serde_core",
695 "zmij",
696]
697
698[[package]]
699name = "shlex"
700version = "2.0.1"
701source = "registry+https://github.com/rust-lang/crates.io-index"
702checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
703
704[[package]]
705name = "simd-adler32"
706version = "0.3.10"
707source = "registry+https://github.com/rust-lang/crates.io-index"
708checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
709
710[[package]]
711name = "similar"
712version = "2.7.0"
713source = "registry+https://github.com/rust-lang/crates.io-index"
714checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
715
716[[package]]
717name = "slab"
718version = "0.4.12"
719source = "registry+https://github.com/rust-lang/crates.io-index"
720checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
721
722[[package]]
723name = "strsim"
724version = "0.11.1"
725source = "registry+https://github.com/rust-lang/crates.io-index"
726checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
727
728[[package]]
729name = "syn"
730version = "2.0.119"
731source = "registry+https://github.com/rust-lang/crates.io-index"
732checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
733dependencies = [
734 "proc-macro2",
735 "quote",
736 "unicode-ident",
737]
738
739[[package]]
740name = "syn"
741version = "3.0.3"
742source = "registry+https://github.com/rust-lang/crates.io-index"
743checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
744dependencies = [
745 "proc-macro2",
746 "quote",
747 "unicode-ident",
748]
749
750[[package]]
751name = "syntect"
752version = "5.3.0"
753source = "registry+https://github.com/rust-lang/crates.io-index"
754checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925"
755dependencies = [
756 "bincode",
757 "flate2",
758 "fnv",
759 "once_cell",
760 "onig",
761 "plist",
762 "regex-syntax",
763 "serde",
764 "serde_derive",
765 "serde_json",
766 "thiserror",
767 "walkdir",
768 "yaml-rust",
769]
770
771[[package]]
772name = "tempfile"
773version = "3.27.0"
774source = "registry+https://github.com/rust-lang/crates.io-index"
775checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
776dependencies = [
777 "fastrand",
778 "getrandom",
779 "once_cell",
780 "rustix",
781 "windows-sys",
782]
783
784[[package]]
785name = "thiserror"
786version = "2.0.20"
787source = "registry+https://github.com/rust-lang/crates.io-index"
788checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
789dependencies = [
790 "thiserror-impl",
791]
792
793[[package]]
794name = "thiserror-impl"
795version = "2.0.20"
796source = "registry+https://github.com/rust-lang/crates.io-index"
797checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
798dependencies = [
799 "proc-macro2",
800 "quote",
801 "syn 3.0.3",
802]
803
804[[package]]
805name = "time"
806version = "0.3.55"
807source = "registry+https://github.com/rust-lang/crates.io-index"
808checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
809dependencies = [
810 "deranged",
811 "num-conv",
812 "powerfmt",
813 "serde_core",
814 "time-core",
815 "time-macros",
816]
817
818[[package]]
819name = "time-core"
820version = "0.1.9"
821source = "registry+https://github.com/rust-lang/crates.io-index"
822checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
823
824[[package]]
825name = "time-macros"
826version = "0.2.32"
827source = "registry+https://github.com/rust-lang/crates.io-index"
828checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
829dependencies = [
830 "num-conv",
831 "time-core",
832]
833
834[[package]]
835name = "unicode-ident"
836version = "1.0.24"
837source = "registry+https://github.com/rust-lang/crates.io-index"
838checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
839
840[[package]]
841name = "utf8parse"
842version = "0.2.2"
843source = "registry+https://github.com/rust-lang/crates.io-index"
844checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
845
846[[package]]
847name = "walkdir"
848version = "2.5.0"
849source = "registry+https://github.com/rust-lang/crates.io-index"
850checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
851dependencies = [
852 "same-file",
853 "winapi-util",
854]
855
856[[package]]
857name = "wasm-bindgen"
858version = "0.2.127"
859source = "registry+https://github.com/rust-lang/crates.io-index"
860checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
861dependencies = [
862 "cfg-if",
863 "once_cell",
864 "rustversion",
865 "wasm-bindgen-macro",
866 "wasm-bindgen-shared",
867]
868
869[[package]]
870name = "wasm-bindgen-macro"
871version = "0.2.127"
872source = "registry+https://github.com/rust-lang/crates.io-index"
873checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
874dependencies = [
875 "quote",
876 "wasm-bindgen-macro-support",
877]
878
879[[package]]
880name = "wasm-bindgen-macro-support"
881version = "0.2.127"
882source = "registry+https://github.com/rust-lang/crates.io-index"
883checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
884dependencies = [
885 "bumpalo",
886 "proc-macro2",
887 "quote",
888 "syn 2.0.119",
889 "wasm-bindgen-shared",
890]
891
892[[package]]
893name = "wasm-bindgen-shared"
894version = "0.2.127"
895source = "registry+https://github.com/rust-lang/crates.io-index"
896checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
897dependencies = [
898 "unicode-ident",
899]
900
901[[package]]
902name = "winapi-util"
903version = "0.1.11"
904source = "registry+https://github.com/rust-lang/crates.io-index"
905checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
906dependencies = [
907 "windows-sys",
908]
909
910[[package]]
911name = "windows-core"
912version = "0.62.2"
913source = "registry+https://github.com/rust-lang/crates.io-index"
914checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
915dependencies = [
916 "windows-implement",
917 "windows-interface",
918 "windows-link",
919 "windows-result",
920 "windows-strings",
921]
922
923[[package]]
924name = "windows-implement"
925version = "0.60.2"
926source = "registry+https://github.com/rust-lang/crates.io-index"
927checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
928dependencies = [
929 "proc-macro2",
930 "quote",
931 "syn 2.0.119",
932]
933
934[[package]]
935name = "windows-interface"
936version = "0.59.3"
937source = "registry+https://github.com/rust-lang/crates.io-index"
938checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
939dependencies = [
940 "proc-macro2",
941 "quote",
942 "syn 2.0.119",
943]
944
945[[package]]
946name = "windows-link"
947version = "0.2.1"
948source = "registry+https://github.com/rust-lang/crates.io-index"
949checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
950
951[[package]]
952name = "windows-result"
953version = "0.4.1"
954source = "registry+https://github.com/rust-lang/crates.io-index"
955checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
956dependencies = [
957 "windows-link",
958]
959
960[[package]]
961name = "windows-strings"
962version = "0.5.1"
963source = "registry+https://github.com/rust-lang/crates.io-index"
964checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
965dependencies = [
966 "windows-link",
967]
968
969[[package]]
970name = "windows-sys"
971version = "0.61.2"
972source = "registry+https://github.com/rust-lang/crates.io-index"
973checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
974dependencies = [
975 "windows-link",
976]
977
978[[package]]
979name = "yaml-rust"
980version = "0.4.5"
981source = "registry+https://github.com/rust-lang/crates.io-index"
982checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
983dependencies = [
984 "linked-hash-map",
985]
986
987[[package]]
988name = "zmij"
989version = "1.0.23"
990source = "registry+https://github.com/rust-lang/crates.io-index"
991checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
Cargo.toml added +35
@@ -0,0 +1,35 @@
1[package]
2name = "org-ssg"
3version = "0.3.0"
4edition = "2021"
5description = "Org-mode static site generator that renders the org element tree straight to HTML"
6license = "MIT"
7
8[lib]
9name = "org_ssg"
10path = "src/lib.rs"
11
12[[bin]]
13name = "org-ssg"
14path = "src/main.rs"
15
16[dependencies]
17# Parsing / model
18serde = { version = "1", features = ["derive"] }
19serde_json = "1"
20chrono = { version = "0.4", features = ["serde"] }
21camino = { version = "1", features = ["serde1"] }
22
23# Pipeline
24walkdir = "2"
25syntect = "5"
26minijinja = "2"
27blake3 = "1"
28
29# CLI / errors
30clap = { version = "4", features = ["derive"] }
31anyhow = "1"
32thiserror = "2"
33
34[dev-dependencies]
35insta = { version = "1", features = ["json"] }
README.md added +164
@@ -0,0 +1,164 @@
1# org-ssg
2
3An org-mode static site generator, in Rust. Org is treated as the *source language*,
4not an inconvenient input to be normalized into markdown. The org element tree —
5headings, drawers, blocks, links with their org-specific semantics — **is** the
6document model, and we render that tree straight to HTML. We never round-trip through
7a markdown-shaped intermediate representation, because the point is to preserve what
8markdown cannot express: property drawers, TODO/priority/tag metadata on headings,
9`#+` directives, ID links, named/captioned blocks, footnote semantics.
10
11The one non-obvious early commitment is **incremental builds keyed on content
12hashing**, treated as a first-class architectural concern from day one. The discipline
13it imposes on the data model — pure, hashable, dependency-tracked units — is the real
14deliverable, even while the corpus is small enough that a full rebuild is instant.
15
16## Pipeline
17
18```
19DISCOVER → PARSE → INDEX → RESOLVE → RENDER → TEMPLATE → EMIT
20```
21
22PARSE and RENDER are pure functions of their inputs (cacheable, hashable). INDEX/RESOLVE
23is the only inherently global stage — it is where the link dependency graph is born.
24
25| Stage | Module | Notes |
26|---|---|---|
27| PARSE | `src/parser.rs` | Hand-written recursive descent: line lexer → element builder → inline tokenizer. |
28| model | `src/model.rs` | The org element tree — Elements (block) vs Objects (inline). |
29| INDEX | `src/index.rs` | Collect link targets into a symbol table. |
30| RESOLVE | `src/resolve.rs` | Rewrite links to URLs; return the used-target list (dependency edges). |
31| RENDER | `src/render.rs` | Tree → HTML fragment; syntect highlighting; footnote two-pass. |
32| TEMPLATE | `src/template.rs` | minijinja: fragment + metadata → full page. |
33| incremental | `src/incremental.rs` | Content/config/template hashing, dep graph, cache manifest, invalidation. |
34
35## v1 scope (recommended; must be reconciled against a corpus audit first)
36
37**IN — v1 must handle:** headings with nesting; TODO keywords; priorities `[#A]`; tags;
38property drawers; plain lists (unordered/ordered/description, checkboxes, nesting);
39tables (with rule rows, no `#+TBLFM:`); source blocks with syntax highlighting;
40example/quote/center blocks; links (external, internal `[[*Heading]]`/`[[#custom-id]]`,
41`id:`); footnotes (inline and referenced); `#+` keywords/directives; inline markup
42(bold/italic/underline/verbatim/code/strike); timestamps (active/inactive, ranges);
43paragraphs and horizontal rules; images with `#+CAPTION`/`#+ATTR_HTML`.
44
45**OUT — explicitly not v1 (parse-and-ignore or reject loudly):** Babel execution /
46`:results`; `#+TBLFM:` formulas; LaTeX / MathJax; `#+INCLUDE:`; radio targets and
47macros; drawers other than PROPERTIES/LOGBOOK; column view / clocking / agenda
48semantics; non-HTML export blocks; the full Unicode entity set.
49
50**Scope guardrail:** every IN item gets a golden-file fixture from a real document;
51every OUT item gets a test asserting it degrades predictably (ignored, no crash). The
52IN/OUT line is enforced by tests, defending against the project's #1 risk: scope creep
53back toward all-of-org.
54
55## Phase plan
56
57| Phase | Scope | Status |
58|---|---|---|
59| **M0** | **Buildable skeleton: crate layout, module stubs, deps, test harness, fixtures** | **done** |
60| **v0.1** | **End-to-end core parse → render: `build` a single `.org` file to HTML** | **done** |
61| **v0.2** | **Multi-file SITE build: INDEX + RESOLVE internal links, minijinja templates, `build <src-dir> <out-dir>`, tables + footnotes** | **done** |
62| **v0.3** | **Incremental build layer: content/config/template hashing, dependency graph, per-page render keys, persisted cache manifest, invalidation** | **done** |
63| 0 | Corpus audit + `emacs --batch` ground-truth oracle | todo |
64| 1 | Line lexer + heading/section skeleton | done |
65| 2 | Block elements — lists, source blocks, tables, footnote defs done; generic drawers | partial |
66| 3 | Inline objects — emphasis, links, bare URLs, footnote refs done; timestamps | partial |
67| 4 | Rendering to HTML — tree walk, tables, footnote two-pass, minijinja templating done; real syntect highlighting | partial |
68| 5 | Link resolution + symbol table (INDEX + RESOLVE, used-target list, broken-link reporting) | done |
69| 6 | Incremental build layer (hashing, dep graph, invalidation) done; `watch` is a simple poll loop | done |
70| 7 | Hardening: rayon parallelism, CLI polish, error locations | todo |
71
72### v0.2 in / out
73
74**Added in v0.2:** the INDEX stage (`SymbolTable` of `:ID:`/`:CUSTOM_ID:`/heading/`file:`
75targets across a directory); the RESOLVE stage — rewrites `[[#custom-id]]`, `[[id:...]]`,
76`[[*Heading]]` and `[[file:other.org]]` links to real relative output URLs, returns the
77`used_targets` list (the `uses` edges, spec §4.3/R2) and reports unresolved links as
78warnings rather than crashing; a minijinja base layout (title, nav, body) applied to every
79page; a `build <src-dir> <out-dir>` path that walks the tree, parses + resolves + renders +
80templates every `.org` into a linked static site and copies non-`.org` assets through;
81plus two new constructs — pipe **tables** (with header band from the rule row) and
82**footnotes** (block `[fn:1]` definitions, referenced `[fn:1]`, and inline `[fn:1:text]`,
83rendered as a numbered, back-linked notes section).
84
85**Still stubbed (`todo!`):** timestamps; TODO keywords and priorities; generic
86(non-PROPERTIES) drawers. Source-block syntax highlighting remains a `<pre><code>`
87passthrough behind the `Highlighter` trait; real syntect tokenizing is deferred.
88
89### v0.3 in / out
90
91**Added in v0.3 — the incremental build layer (spec §4, the flagship, non-retrofittable
92feature):**
93
94- **Three hash classes (spec §4.1)** in `src/incremental.rs`: a **content hash** (blake3
95 of a file's bytes), a **config hash** (blake3 of the resolved `BuildConfig`), and a
96 **template hash** (blake3 of the template sources). A change in any one invalidates the
97 pages it affects.
98- **Dependency graph (spec §4.3)** built from RESOLVE's `defines`/`uses` edges: a page
99 depends on the targets it links to, so editing (or renaming a heading in) a file
100 invalidates the pages that *link into* it, not just the file itself — the load-bearing
101 R2 invariant. On rebuild the graph is merged with the previous build's `defines` so a
102 *removed* target still pulls in its linkers.
103- **Per-page `render_key`** = `H(content ⊕ resolved-links ⊕ config ⊕ template)`. If a
104 page's render key is unchanged, its on-disk output is already correct and it is skipped.
105 The config component folds in a **site-structure hash** (every page's `(path, title)`),
106 because the shared nav bar is global chrome — a title change or a page add/remove alters
107 the nav on every page and so must re-render them all (otherwise byte-equivalence breaks).
108- **Persisted cache manifest** (`<out>/.org-ssg-cache.json`, JSON), carrying per-page
109 records, the config/template hashes, and the serialized dependency graph, tagged with
110 `CACHE_FORMAT_VERSION`. A version mismatch, a missing file, or a corrupt file all fall
111 back to a clean full rebuild — the cache is an optimization, never a correctness
112 dependency.
113- **Wired into `build_site`**: only pages whose render key changed (or that link into a
114 changed file's targets) are re-rendered; unchanged outputs are left in place. `--no-cache`
115 forces a full rebuild; `clean <out-dir>` removes the output directory (and its cache).
116 `SiteReport` now reports `rendered` vs `skipped` counts.
117
118The hard gates are enforced by `tests/incremental.rs`: full-vs-incremental **byte
119equivalence** (and a second unchanged build re-rendering **zero** pages); **edit-one-file**
120re-renders exactly the changed page plus its linkers; **renamed-heading** invalidates the
121linking page and updates its emitted anchor; and cache **version-bump / missing / corrupt**
122all fall back to a full rebuild.
123
124**Out of scope in v0.3 (unchanged from v0.2):** real syntect highlighting; timestamps and
125TODO keywords. `watch` is a minimal mtime poll loop (`watch <src-dir> -o <out-dir>`), not an
126OS file-watcher — the fs-notify integration is deferred. The parse-tree cache (spec §4.5,
127"optionally") is not persisted: PARSE/INDEX/RESOLVE run for every file each build (cheap and
128pure); the incremental win is on RENDER + EMIT.
129
130**From v0.1 (core subset):** headings with nesting and anchors (every heading is now
131anchored — `:CUSTOM_ID:`/`:ID:` else a slug of its text) and trailing tags; paragraphs;
132plain lists (unordered + ordered) with checkboxes; source blocks; inline markup (`*bold*`,
133`/italic/`, `_underline_`, `+strike+`, `=verbatim=`, `~code~`); links and bare URLs.
134
135## Dependencies
136
137Parser is hand-written recursive descent (not `nom`/`chumsky`/`pest` — org is
138line-oriented and context-sensitive, not clean CFG). Key crates: `syntect` (syntax
139highlighting, behind a `Highlighter` trait so tree-sitter can be swapped in later),
140`minijinja` (runtime templates), `blake3` (content/cache hashing), `chrono`,
141`camino`, `walkdir`, `clap`, `anyhow`/`thiserror`. `insta` for snapshot tests.
142
143## Build & test
144
145```
146cargo build
147cargo test
148cargo run -- build fixtures/minimal.org -o minimal.html # single file
149cargo run -- build fixtures/site -o _site # whole site (incremental)
150cargo run -- build fixtures/site -o _site --no-cache # force a full rebuild
151cargo run -- watch fixtures/site -o _site # poll + rebuild on change
152cargo run -- clean _site # remove output + cache
153```
154
155A second `build` of an unchanged site re-renders nothing; editing a page re-renders only
156that page and the pages that link into it (watch the `rendered`/`cached` counts).
157
158`fixtures/` holds tiny `.org` samples: single-file ones (`minimal.org`, `core.org`,
159`elements.org`, `table.org`, `footnote.org`) and a linked multi-file site under
160`fixtures/site/` (`index.org`, `guide.org`, `about.org` + a `style.css` asset). The
161real corpus (golden files derived from actual documents) lands in Phase 0. `cargo test`
162includes `insta` snapshots of the element tree and rendered HTML for the single-file
163fixtures, the two templated site pages (proving cross-file link resolution), and the
164table and footnote constructs.
fixtures/core.org added +21
@@ -0,0 +1,21 @@
1#+TITLE: Core Constructs
2
3Intro paragraph with a bare URL https://example.com and some ~inline code~.
4
5* Ordered and checked
6
71. first item
82. second item with /emphasis/
9
10- [ ] todo item
11- [X] done item
12
13* Links and code
14
15An external [[https://example.org][site]] and a bare [[https://bare.example]].
16
17#+BEGIN_SRC rust
18fn main() {
19 println!("hello");
20}
21#+END_SRC
fixtures/elements.org added +31
@@ -0,0 +1,31 @@
1#+TITLE: Element Sampler
2#+FILETAGS: :sample:
3
4* Code and tables
5
6#+BEGIN_SRC rust
7fn main() {
8 println!("hello");
9}
10#+END_SRC
11
12| Name | Score |
13|-------+-------|
14| alpha | 10 |
15| beta | 20 |
16
17* Links and footnotes
18
19An external link: [[https://example.com][Example]] and an id link [[id:abc-123]].
20
21Text with a footnote reference.[fn:1]
22
23[fn:1] The footnote definition.
24
25* Blocks
26
27#+BEGIN_QUOTE
28A quoted paragraph.
29#+END_QUOTE
30
31-----
fixtures/footnote.org added +8
@@ -0,0 +1,8 @@
1#+TITLE: Footnote Sample
2
3Text with a reference.[fn:1] And a second one.[fn:2]
4
5An inline footnote.[fn:3:defined right here]
6
7[fn:1] The first definition.
8[fn:2] The second definition, with /emphasis/.
fixtures/minimal.org added +20
@@ -0,0 +1,20 @@
1#+TITLE: Minimal Fixture
2#+DATE: 2026-08-08
3#+AUTHOR: Owner
4
5A single paragraph of preamble text before any heading.
6
7* First Heading
8 :PROPERTIES:
9 :CUSTOM_ID: first
10 :END:
11
12Some body text with *bold*, /italic/, and =verbatim=.
13
14** A Subheading :tag1:tag2:
15
16- an unordered item
17- another with a checkbox [ ]
18
19* Second Heading
20 See [[#first][the first heading]].
fixtures/site/about.org added +3
@@ -0,0 +1,3 @@
1#+TITLE: About
2
3This site is built by org-ssg. Back to [[file:index.org][home]].
fixtures/site/guide.org added +17
@@ -0,0 +1,17 @@
1#+TITLE: Guide
2
3* Setup
4 :PROPERTIES:
5 :CUSTOM_ID: setup
6 :END:
7
8Install the steps in order.[fn:1] Then return [[file:index.org][home]].
9
10[fn:1] Read the manual before you begin.
11
12* Data
13
14| Name | Score |
15|-------+-------|
16| alpha | 10 |
17| beta | 20 |
fixtures/site/index.org added +9
@@ -0,0 +1,9 @@
1#+TITLE: Home
2
3Welcome. See the [[file:guide.org][guide]] and jump straight to its
4[[#setup][setup section]] across files.
5
6Also see [[*Overview]] further down this page.
7
8* Overview
9The overview lives on the home page.
fixtures/site/style.css added +2
@@ -0,0 +1,2 @@
1body { font-family: system-ui, sans-serif; max-width: 40rem; margin: 2rem auto; }
2nav a { margin-right: 1rem; }
fixtures/table.org added +6
@@ -0,0 +1,6 @@
1#+TITLE: Table Sample
2
3| Name | Score |
4|-------+-------|
5| alpha | 10 |
6| beta | 20 |
src/incremental.rs added +257
@@ -0,0 +1,257 @@
1//! Incremental build layer (spec §4): content/config/template hashing, the link
2//! dependency graph, the cache manifest, and the invalidation algorithm.
3//!
4//! This is the hard, non-retrofittable part (spec §4). The data model is already
5//! shaped for it — pure, hashable, dependency-tracked units — so this layer is mostly
6//! bookkeeping over the graph.
7//!
8//! The three hash classes (spec §4.1) all feed a page's composed `render_key`:
9//! 1. **content hash** — blake3 of a source file's raw bytes (drives re-parse).
10//! 2. **config hash** — blake3 of the resolved global config (a base-URL/options change
11//! can invalidate everything).
12//! 3. **template hash** — blake3 of the templates (a base-layout edit invalidates every
13//! page that uses it).
14//!
15//! Skip rule (spec §4.1): if a page's `render_key` is unchanged, its emitted file on
16//! disk is already correct — skip it. The dependency graph (spec §4.3) additionally
17//! invalidates pages that *link into* a changed file's targets, so a renamed/removed
18//! heading invalidates the pages that link to it, not just the file that owns it.
19
20use std::collections::{HashMap, HashSet};
21
22use camino::{Utf8Path, Utf8PathBuf};
23use serde::{Deserialize, Serialize};
24
25use crate::index::{SymbolTable, TargetId};
26use crate::model::ContentHash;
27use crate::util::output_url;
28
29/// Bump whenever the `Document` type, hashing scheme, or resolution rules change.
30/// On mismatch: discard cache, full rebuild (spec §4.5). The blake3 crate's major
31/// version is folded in as the "hash-algo version" so a hash upgrade also busts.
32pub const CACHE_FORMAT_VERSION: u32 = 3;
33
34/// blake3 hex identity for a content/config/template/render-key hash class (spec §4.1).
35pub type Hash = ContentHash;
36
37/// Resolved global build config. Its hash is a component of every page's render key
38/// (spec §4.1): a change here can invalidate the whole site. Kept minimal for v0.3 —
39/// there is no user-facing config yet — but structured so real knobs (base URL, TODO
40/// keyword set, highlighter theme id, inline features) flow into the hash when added.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct BuildConfig {
43 pub output_extension: String,
44 pub highlighter_theme: String,
45}
46
47impl Default for BuildConfig {
48 fn default() -> Self {
49 BuildConfig {
50 output_extension: "html".to_string(),
51 highlighter_theme: "passthrough".to_string(),
52 }
53 }
54}
55
56/// Compose bytes into a blake3 hash. The one place hashing happens for composite keys.
57fn hash_bytes(bytes: &[u8]) -> Hash {
58 ContentHash(*blake3::hash(bytes).as_bytes())
59}
60
61/// blake3 of the resolved global config (spec §4.1, hash class 2).
62pub fn config_hash(config: &BuildConfig) -> Hash {
63 let json = serde_json::to_vec(config).expect("BuildConfig serializes");
64 hash_bytes(&json)
65}
66
67/// Fold two hashes into one composite (order-sensitive).
68pub fn combine(a: Hash, b: Hash) -> Hash {
69 let mut hasher = blake3::Hasher::new();
70 hasher.update(&a.0);
71 hasher.update(&b.0);
72 ContentHash(*hasher.finalize().as_bytes())
73}
74
75/// Hash of the global site structure that appears in every page's chrome. The nav bar is
76/// built from every page's `(path, title)`, so any title change, path change, page
77/// addition, or removal alters the nav on ALL pages and must invalidate them. This is a
78/// genuine global dependency (like the config/template hashes, spec §4.1), so it is
79/// folded into every page's render key. Deterministic: entries are sorted.
80pub fn site_structure_hash(entries: &[(String, String)]) -> Hash {
81 let mut sorted = entries.to_vec();
82 sorted.sort();
83 let mut hasher = blake3::Hasher::new();
84 for (path, title) in &sorted {
85 hasher.update(path.as_bytes());
86 hasher.update(&[0]);
87 hasher.update(title.as_bytes());
88 hasher.update(&[0]);
89 }
90 ContentHash(*hasher.finalize().as_bytes())
91}
92
93/// blake3 over the template sources (spec §4.1, hash class 3). One combined hash over
94/// all templates; when partials land, split this per-template so a single-partial edit
95/// invalidates only its users.
96pub fn template_hash(sources: &[(&str, &str)]) -> Hash {
97 let mut hasher = blake3::Hasher::new();
98 for (name, src) in sources {
99 hasher.update(name.as_bytes());
100 hasher.update(&[0]);
101 hasher.update(src.as_bytes());
102 hasher.update(&[0]);
103 }
104 ContentHash(*hasher.finalize().as_bytes())
105}
106
107/// The link dependency graph (spec §4.3). `defines`: file → targets it owns.
108/// `uses`: page → targets it resolved a link to (the invalidation-critical edges).
109#[derive(Debug, Default, Clone, Serialize, Deserialize)]
110pub struct DepGraph {
111 pub defines: HashMap<Utf8PathBuf, HashSet<TargetId>>,
112 pub uses: HashMap<Utf8PathBuf, HashSet<TargetId>>,
113}
114
115impl DepGraph {
116 /// Merge `self` (typically the previous build's graph) with `other` (this build's),
117 /// unioning the `defines` targets per file and taking `other`'s `uses`. Used to build
118 /// the graph handed to [`invalidation_set`]: a target that a changed file *removed*
119 /// is still present in the old `defines`, so pages that linked to it are still found
120 /// (the renamed/removed-heading case, spec §4.3 step 2).
121 pub fn merged_defines_with(&self, other: &DepGraph) -> DepGraph {
122 let mut defines = self.defines.clone();
123 for (file, targets) in &other.defines {
124 defines.entry(file.clone()).or_default().extend(targets.iter().cloned());
125 }
126 DepGraph {
127 defines,
128 uses: other.uses.clone(),
129 }
130 }
131}
132
133/// blake3 over the resolved URLs of the targets a page consumed (spec §4.1: the
134/// `resolved_links_hash` component). Computed relative to the linking page, so a target
135/// whose resolved URL/anchor changed — a renamed `*Heading`, a moved file — flips this
136/// hash and therefore the page's `render_key`. Deterministic: targets are sorted.
137pub fn resolved_links_hash(
138 from: &Utf8Path,
139 used: &HashSet<TargetId>,
140 symbols: &SymbolTable,
141) -> Hash {
142 let mut pairs: Vec<(String, String)> = used
143 .iter()
144 .map(|tid| {
145 let url = symbols
146 .targets
147 .get(tid)
148 .map(|loc| output_url(from, &loc.source_path, loc.anchor.as_deref()))
149 .unwrap_or_default();
150 (tid.sort_key(), url)
151 })
152 .collect();
153 pairs.sort();
154 let mut hasher = blake3::Hasher::new();
155 for (tid, url) in &pairs {
156 hasher.update(tid.as_bytes());
157 hasher.update(&[0]);
158 hasher.update(url.as_bytes());
159 hasher.update(&[0]);
160 }
161 ContentHash(*hasher.finalize().as_bytes())
162}
163
164/// Compose a page's final-output cache key (spec §4.1):
165///
166/// ```text
167/// render_key = H( parse_result_hash ⊕ resolved_links_hash ⊕ config_hash ⊕ template_hash )
168/// ```
169///
170/// `parse_result_hash` is the source content hash (PARSE is a pure function of the file
171/// bytes, so the content hash fully identifies the parse result). If `render_key` is
172/// unchanged the on-disk output is already correct and the page is skipped.
173pub fn render_key(
174 parse_result_hash: Hash,
175 resolved_links_hash: Hash,
176 config_hash: Hash,
177 template_hash: Hash,
178) -> Hash {
179 let mut hasher = blake3::Hasher::new();
180 hasher.update(&parse_result_hash.0);
181 hasher.update(&resolved_links_hash.0);
182 hasher.update(&config_hash.0);
183 hasher.update(&template_hash.0);
184 ContentHash(*hasher.finalize().as_bytes())
185}
186
187/// Per-page cache record persisted in the manifest (spec §4.5).
188#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct PageRecord {
190 pub content_hash: ContentHash,
191 pub render_key: Hash,
192 pub output_path: Utf8PathBuf,
193}
194
195/// The on-disk cache manifest (spec §4.5). Serialized as JSON (human-diffable; the
196/// cache is an optimization, never a correctness dependency — a `--no-cache`/`clean`
197/// run always produces byte-identical output).
198#[derive(Debug, Default, Serialize, Deserialize)]
199pub struct Manifest {
200 pub format_version: u32,
201 pub config_hash: Option<Hash>,
202 pub template_hash: Option<Hash>,
203 pub pages: HashMap<Utf8PathBuf, PageRecord>,
204 pub graph: DepGraph,
205}
206
207/// The cache-manifest file lives inside the output directory (spec §4.5: an on-disk
208/// cache dir). `clean` removes the output directory, taking the cache with it.
209pub fn manifest_path(out: &Utf8Path) -> Utf8PathBuf {
210 out.join(".org-ssg-cache.json")
211}
212
213/// Load the manifest, returning `None` on ANY of: missing file, read/parse error, or a
214/// cache-format version mismatch (spec §4.5). `None` ⇒ the caller does a full rebuild.
215/// The cache is never a correctness dependency, so a corrupt cache is never a crash.
216pub fn load_manifest(out: &Utf8Path) -> Option<Manifest> {
217 let bytes = std::fs::read(manifest_path(out)).ok()?;
218 let manifest: Manifest = serde_json::from_slice(&bytes).ok()?;
219 if manifest.format_version != CACHE_FORMAT_VERSION {
220 return None;
221 }
222 Some(manifest)
223}
224
225/// Persist the manifest into the output directory. A write failure is surfaced to the
226/// caller (a failed cache write only costs the next build a full rebuild).
227pub fn save_manifest(out: &Utf8Path, manifest: &Manifest) -> std::io::Result<()> {
228 let json = serde_json::to_vec_pretty(manifest).expect("manifest serializes");
229 std::fs::write(manifest_path(out), json)
230}
231
232/// Given the set of changed files and the dependency graph, compute the set of pages to
233/// rebuild (spec §4.3 invalidation algorithm): the changed files themselves, plus every
234/// page with a `uses` edge into a target *defined by* a changed file. Pass a graph whose
235/// `defines` is the union of the previous and current builds (see
236/// [`DepGraph::merged_defines_with`]) so that a target a changed file *removed* still
237/// pulls in the pages that linked to it (the renamed/removed-heading case).
238pub fn invalidation_set(
239 changed: &HashSet<Utf8PathBuf>,
240 graph: &DepGraph,
241) -> HashSet<Utf8PathBuf> {
242 // Targets touched by any changed file (added, removed, or possibly-moved).
243 let mut delta_targets: HashSet<&TargetId> = HashSet::new();
244 for file in changed {
245 if let Some(targets) = graph.defines.get(file) {
246 delta_targets.extend(targets.iter());
247 }
248 }
249 // Changed files themselves, plus any page that links into a delta'd target.
250 let mut result: HashSet<Utf8PathBuf> = changed.clone();
251 for (page, used) in &graph.uses {
252 if used.iter().any(|t| delta_targets.contains(t)) {
253 result.insert(page.clone());
254 }
255 }
256 result
257}
src/index.rs added +133
@@ -0,0 +1,133 @@
1//! INDEX stage (spec §2.1, §4.3): collect link targets across all documents into a
2//! global symbol table. This is the only inherently global stage before RESOLVE.
3
4use std::collections::HashMap;
5
6use camino::{Utf8Path, Utf8PathBuf};
7use serde::{Deserialize, Serialize};
8
9use crate::model::{Document, Section};
10use crate::util::{plain_text, slugify};
11
12/// Identity of a link target. A target is owned by exactly one file (spec §4.3).
13///
14/// `Serialize`/`Deserialize` so the dependency graph (defines/uses edges) round-trips
15/// through the on-disk cache manifest (spec §4.5).
16#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
17pub enum TargetId {
18 Id(String),
19 CustomId(String),
20 Heading(String),
21 File(Utf8PathBuf),
22}
23
24impl TargetId {
25 /// A stable string form used to order targets deterministically when hashing
26 /// (so a page's `resolved_links_hash` does not depend on `HashSet` iteration order).
27 pub fn sort_key(&self) -> String {
28 match self {
29 TargetId::Id(s) => format!("id:{s}"),
30 TargetId::CustomId(s) => format!("custom:{s}"),
31 TargetId::Heading(s) => format!("heading:{s}"),
32 TargetId::File(p) => format!("file:{p}"),
33 }
34 }
35}
36
37/// Where a resolved target lives, once INDEX has seen its defining file.
38#[derive(Debug, Clone)]
39pub struct TargetLocation {
40 pub source_path: Utf8PathBuf,
41 /// Final URL fragment/anchor for the target, filled during resolution.
42 pub anchor: Option<String>,
43}
44
45/// Maps every collected target to its owning location (spec §4.3 "defines" edges).
46#[derive(Debug, Default)]
47pub struct SymbolTable {
48 pub targets: HashMap<TargetId, TargetLocation>,
49}
50
51impl SymbolTable {
52 pub fn new() -> Self {
53 Self::default()
54 }
55
56 /// Walk one document, registering every `:ID:`/`:CUSTOM_ID:`/heading/file target.
57 /// A target is owned by exactly one file (spec §4.3); the anchor is the fragment
58 /// the renderer emits for that target's heading.
59 pub fn index_document(&mut self, doc: &Document) {
60 let path = &doc.source_path;
61 self.targets.insert(
62 TargetId::File(path.clone()),
63 TargetLocation {
64 source_path: path.clone(),
65 anchor: None,
66 },
67 );
68 index_section(&doc.root, path, &mut self.targets);
69 }
70}
71
72/// The set of link targets a single document *defines* (owns) — the `defines` edges of
73/// the dependency graph (spec §4.3). Mirrors [`SymbolTable::index_document`] but returns
74/// the targets for one file in isolation, which is what the incremental layer records
75/// per-file in the cache manifest.
76pub fn document_targets(doc: &Document) -> Vec<TargetId> {
77 let mut out = vec![TargetId::File(doc.source_path.clone())];
78 collect_targets(&doc.root, &mut out);
79 out
80}
81
82fn collect_targets(section: &Section, out: &mut Vec<TargetId>) {
83 if let Some(h) = &section.heading {
84 if let Some(cid) = &h.custom_id {
85 out.push(TargetId::CustomId(cid.clone()));
86 }
87 if let Some(id) = &h.id {
88 out.push(TargetId::Id(id.clone()));
89 }
90 out.push(TargetId::Heading(plain_text(&h.title)));
91 }
92 for child in &section.children {
93 collect_targets(child, out);
94 }
95}
96
97fn index_section(section: &Section, path: &Utf8Path, targets: &mut HashMap<TargetId, TargetLocation>) {
98 if let Some(h) = &section.heading {
99 let anchor = h
100 .custom_id
101 .clone()
102 .or_else(|| h.id.clone())
103 .unwrap_or_else(|| slugify(&plain_text(&h.title)));
104 if let Some(cid) = &h.custom_id {
105 targets.insert(
106 TargetId::CustomId(cid.clone()),
107 TargetLocation {
108 source_path: path.to_owned(),
109 anchor: Some(cid.clone()),
110 },
111 );
112 }
113 if let Some(id) = &h.id {
114 targets.insert(
115 TargetId::Id(id.clone()),
116 TargetLocation {
117 source_path: path.to_owned(),
118 anchor: Some(id.clone()),
119 },
120 );
121 }
122 targets.insert(
123 TargetId::Heading(plain_text(&h.title)),
124 TargetLocation {
125 source_path: path.to_owned(),
126 anchor: Some(anchor),
127 },
128 );
129 }
130 for child in &section.children {
131 index_section(child, path, targets);
132 }
133}
src/lib.rs added +19
@@ -0,0 +1,19 @@
1//! org-ssg — an org-mode static site generator.
2//!
3//! Org is the source language, not an input to be normalized into markdown. The org
4//! element tree ([`model`]) *is* the document model; we render it straight to HTML.
5//!
6//! Pipeline (spec §2.1), one module per stage:
7//! DISCOVER → [`parser`] (PARSE) → [`index`] (INDEX) → [`resolve`] (RESOLVE) →
8//! [`render`] (RENDER) → [`template`] (TEMPLATE) → EMIT, with [`incremental`]
9//! deciding which pages actually need rewriting.
10
11pub mod incremental;
12pub mod index;
13pub mod model;
14pub mod parser;
15pub mod render;
16pub mod resolve;
17pub mod site;
18pub mod template;
19pub mod util;
src/main.rs added +180
@@ -0,0 +1,180 @@
1//! CLI entry point (spec §3.5): `build`, `watch`, `clean`.
2
3use std::fs;
4
5use anyhow::{Context, Result};
6use camino::{Utf8Path, Utf8PathBuf};
7use clap::{Parser, Subcommand};
8
9use org_ssg::parser::parse;
10use org_ssg::render::{render, Html, SyntectHighlighter};
11use org_ssg::resolve::ResolvedDoc;
12use org_ssg::site::{build_site, BuildOptions};
13use org_ssg::template::Templater;
14
15#[derive(Parser)]
16#[command(name = "org-ssg", about = "Org-mode static site generator")]
17struct Cli {
18 #[command(subcommand)]
19 command: Command,
20}
21
22#[derive(Subcommand)]
23enum Command {
24 /// Build a site. If INPUT is a directory, walk it and emit a linked static site
25 /// to OUTPUT (a directory); if INPUT is a single `.org` file, emit one HTML file.
26 Build {
27 /// Input `.org` file, or a source directory for a whole-site build.
28 input: Utf8PathBuf,
29 /// Output path: an `.html` file for a single input, or a directory for a site.
30 #[arg(short, long)]
31 output: Option<Utf8PathBuf>,
32 /// Bypass the incremental cache and re-render every page (spec §4.5).
33 #[arg(long)]
34 no_cache: bool,
35 /// Treat broken internal links as errors (spec §4.3.4).
36 #[arg(long)]
37 strict: bool,
38 },
39 /// Watch a source directory and rebuild incrementally on change (simple poll loop).
40 Watch {
41 /// Source directory to watch.
42 input: Utf8PathBuf,
43 /// Output directory.
44 #[arg(short, long)]
45 output: Utf8PathBuf,
46 },
47 /// Remove the build output directory (which holds the cache manifest).
48 Clean {
49 /// Output directory to remove.
50 output: Utf8PathBuf,
51 },
52}
53
54fn main() -> Result<()> {
55 let cli = Cli::parse();
56 match cli.command {
57 Command::Build {
58 input,
59 output,
60 no_cache,
61 strict,
62 } => {
63 if input.is_dir() {
64 let out = output
65 .context("site build requires an output directory: build <src-dir> -o <out-dir>")?;
66 let opts = BuildOptions { no_cache, strict };
67 let report = build_site(&input, &out, &opts)?;
68 println!(
69 "built {} page(s) ({} rendered, {} cached), copied {} asset(s) from {} -> {} ({} unresolved link(s))",
70 report.pages.len(),
71 report.rendered.len(),
72 report.skipped.len(),
73 report.assets.len(),
74 input,
75 out,
76 report.broken.len()
77 );
78 } else {
79 let output = output.unwrap_or_else(|| input.with_extension("html"));
80 build_file(&input, &output)?;
81 println!("built {} -> {}", input, output);
82 }
83 Ok(())
84 }
85 // Watch is intentionally a minimal poll loop, not an OS file-watch (spec §5 Phase
86 // 6 lists `watch`; the real fs-notify integration is deferred). It rebuilds
87 // incrementally whenever any source file's mtime advances.
88 Command::Watch { input, output } => watch(&input, &output),
89 Command::Clean { output } => {
90 if output.exists() {
91 fs::remove_dir_all(&output)
92 .with_context(|| format!("removing output directory {output}"))?;
93 println!("removed {output}");
94 } else {
95 println!("nothing to clean: {output} does not exist");
96 }
97 Ok(())
98 }
99 }
100}
101
102/// Minimal poll-based watch loop: rebuild incrementally whenever a source file changes.
103/// Not an OS file-watcher (deferred); it snapshots source mtimes every 500ms.
104fn watch(input: &Utf8Path, output: &Utf8Path) -> Result<()> {
105 use std::time::{Duration, SystemTime};
106
107 if !input.is_dir() {
108 anyhow::bail!("watch requires a source directory: watch <src-dir> -o <out-dir>");
109 }
110 let opts = BuildOptions::default();
111
112 let snapshot = |root: &Utf8Path| -> Vec<(Utf8PathBuf, SystemTime)> {
113 let mut v = Vec::new();
114 for entry in walkdir::WalkDir::new(root).sort_by_file_name() {
115 let Ok(entry) = entry else { continue };
116 if !entry.file_type().is_file() {
117 continue;
118 }
119 if let (Ok(path), Ok(meta)) = (
120 Utf8PathBuf::from_path_buf(entry.path().to_owned()),
121 entry.metadata(),
122 ) {
123 let mtime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
124 v.push((path, mtime));
125 }
126 }
127 v
128 };
129
130 let report = build_site(input, output, &opts)?;
131 println!(
132 "watching {input} -> {output}: built {} page(s) ({} rendered). Ctrl-C to stop.",
133 report.pages.len(),
134 report.rendered.len()
135 );
136 let mut last = snapshot(input);
137 loop {
138 std::thread::sleep(Duration::from_millis(500));
139 let now = snapshot(input);
140 if now != last {
141 match build_site(input, output, &opts) {
142 Ok(report) => println!(
143 "rebuilt: {} rendered, {} cached",
144 report.rendered.len(),
145 report.skipped.len()
146 ),
147 Err(e) => eprintln!("build error: {e:#}"),
148 }
149 last = now;
150 }
151 }
152}
153
154/// Single-file build: read → PARSE → RENDER → TEMPLATE → write. No cross-file link
155/// resolution (there is no corpus to resolve against); links keep their best-effort
156/// URLs. Whole-site link resolution lives in [`build_site`].
157fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> {
158 let source = fs::read_to_string(input)
159 .with_context(|| format!("reading source file {input}"))?;
160 let document = parse(input, &source).with_context(|| format!("parsing {input}"))?;
161
162 let title = document
163 .keywords
164 .entries
165 .iter()
166 .find(|(k, _)| k.eq_ignore_ascii_case("TITLE"))
167 .map(|(_, v)| v.clone())
168 .unwrap_or_else(|| input.file_stem().unwrap_or("untitled").to_string());
169
170 let resolved = ResolvedDoc { document };
171 let highlighter = SyntectHighlighter;
172 let Html(fragment) = render(&resolved, &highlighter);
173
174 let templater = Templater::new();
175 let page = templater
176 .render_page(&title, &fragment, &[])
177 .with_context(|| format!("templating {input}"))?;
178 fs::write(output, page).with_context(|| format!("writing output file {output}"))?;
179 Ok(())
180}
src/model.rs added +224
@@ -0,0 +1,224 @@
1//! The org element tree. This *is* the document model (spec §2.2) — the parser's
2//! output type is what the renderer consumes; there is no separate document AST.
3//!
4//! Org's two-tier structure is mirrored in the type system:
5//! - [`Element`] — block-level things (headings, paragraphs, lists, tables, blocks).
6//! - [`Object`] — inline things inside an element's content (bold, links, timestamps).
7//!
8//! This split lets the renderer never accidentally nest a heading inside emphasis.
9//! The whole tree is `serde`-serializable so the parse cache and golden-file
10//! snapshots share one representation (spec §4, §5).
11
12use camino::Utf8PathBuf;
13use chrono::NaiveDateTime;
14use serde::{Deserialize, Serialize};
15
16/// Content hash of raw source bytes. blake3 (spec §4.1). Serialized as hex.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
18pub struct ContentHash(pub [u8; 32]);
19
20/// Parsed `#+KEYWORD:` directives (`#+TITLE`, `#+DATE`, `#+OPTIONS`, ...).
21#[derive(Debug, Clone, Default, Serialize, Deserialize)]
22pub struct Keywords {
23 pub entries: Vec<(String, String)>,
24}
25
26/// Parsed `:PROPERTIES:` drawer as an ordered key/value map.
27#[derive(Debug, Clone, Default, Serialize, Deserialize)]
28pub struct Properties {
29 pub entries: Vec<(String, String)>,
30}
31
32/// A TODO keyword resolved against the configured keyword set.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct TodoKeyword {
35 pub name: String,
36 pub done: bool,
37}
38
39/// One source file → one Document. This is the unit of parsing and caching (spec §2.3).
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct Document {
42 pub source_path: Utf8PathBuf,
43 pub content_hash: ContentHash,
44 pub keywords: Keywords,
45 /// Pre-first-heading content plus child headings.
46 pub root: Section,
47}
48
49/// A section = content directly under a heading (or the file preamble), followed by
50/// nested subsections. Recursive, mirroring org's headline hierarchy — so that
51/// "renaming a heading invalidates its subtree's link targets" is a local operation.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct Section {
54 /// `None` for the file preamble.
55 pub heading: Option<Heading>,
56 /// Block-level content of THIS section.
57 pub content: Vec<Element>,
58 /// Nested sub-headings.
59 pub children: Vec<Section>,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct Heading {
64 pub level: u8,
65 pub todo: Option<TodoKeyword>,
66 /// `'A'..` from `[#A]`.
67 pub priority: Option<char>,
68 /// Inline objects — headings can contain markup/links.
69 pub title: Vec<Object>,
70 pub tags: Vec<String>,
71 pub properties: Properties,
72 /// `:ID:`.
73 pub id: Option<String>,
74 /// `:CUSTOM_ID:`.
75 pub custom_id: Option<String>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub enum Element {
80 Paragraph(Vec<Object>),
81 List(List),
82 Table(Table),
83 SrcBlock {
84 lang: Option<String>,
85 params: BlockParams,
86 code: String,
87 },
88 ExampleBlock(String),
89 QuoteBlock(Vec<Element>),
90 CenterBlock(Vec<Element>),
91 /// html passes through; others dropped at render (spec §1 OUT).
92 ExportBlock {
93 backend: String,
94 raw: String,
95 },
96 HorizontalRule,
97 FootnoteDefinition {
98 label: String,
99 content: Vec<Element>,
100 },
101 /// Stray `#+FOO:` kept as metadata.
102 Keyword {
103 key: String,
104 value: String,
105 },
106 /// Generic drawer; LOGBOOK special-cased (spec §8 Q7).
107 Drawer {
108 name: String,
109 content: Vec<Element>,
110 },
111 Comment(String),
112}
113
114/// `#+BEGIN_SRC` switches / header args. Parsed but mostly ignored in v1.
115#[derive(Debug, Clone, Default, Serialize, Deserialize)]
116pub struct BlockParams {
117 pub raw: String,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct List {
122 pub kind: ListKind,
123 pub items: Vec<ListItem>,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub enum ListKind {
128 Unordered,
129 Ordered,
130 Description,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct ListItem {
135 pub bullet: Bullet,
136 pub checkbox: Option<Checkbox>,
137 /// Description-list term before `::`.
138 pub term: Option<Vec<Object>>,
139 /// Items hold block content (may nest lists).
140 pub content: Vec<Element>,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub enum Bullet {
145 Dash,
146 Plus,
147 /// `1.` / `1)` — carries the ordinal.
148 Ordered(u32),
149}
150
151/// `[ ]` / `[X]` / `[-]`.
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153pub enum Checkbox {
154 Off,
155 On,
156 Trans,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct Table {
161 /// Rule rows preserved to locate the header band.
162 pub rows: Vec<TableRow>,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub enum TableRow {
167 Cells(Vec<Vec<Object>>),
168 Rule,
169}
170
171/// Inline objects (spec §2.3).
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub enum Object {
174 Text(String),
175 Bold(Vec<Object>),
176 Italic(Vec<Object>),
177 Underline(Vec<Object>),
178 StrikeThrough(Vec<Object>),
179 /// `=...=` : no nested markup (String, not Vec<Object>, by design).
180 Verbatim(String),
181 /// `~...~` : no nested markup.
182 Code(String),
183 Link(Link),
184 FootnoteRef {
185 label: String,
186 inline: Option<Vec<Object>>,
187 },
188 Timestamp(Timestamp),
189 LineBreak,
190 /// Resolved `\alpha`-style entity, only if enabled.
191 Entity(String),
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct Link {
196 /// Unresolved at parse time — resolution is a separate global stage (spec §2.3).
197 pub target: LinkTarget,
198 pub description: Option<Vec<Object>>,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
202pub enum LinkTarget {
203 /// `https:`, `mailto:`, ...
204 External(String),
205 File {
206 path: Utf8PathBuf,
207 search: Option<String>,
208 },
209 /// `[[id:...]]`.
210 Id(String),
211 /// `[[#...]]`.
212 CustomId(String),
213 /// `[[*Heading text]]`.
214 Heading(String),
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct Timestamp {
219 /// `<...>` vs `[...]`.
220 pub active: bool,
221 pub start: NaiveDateTime,
222 pub end: Option<NaiveDateTime>,
223 pub has_time: bool,
224}
src/parser.rs added +886
@@ -0,0 +1,886 @@
1//! PARSE stage (spec §2.1, §3.1): bytes → tokens → org element tree.
2//!
3//! Hand-written recursive descent, deliberately two-tier (spec §3.1):
4//! 1. [`line_lexer`] — cheap first pass classifying each line, context-free.
5//! 2. [`build_document`] — recursive descent over the line stream into `Section`s/`Element`s.
6//! 3. [`inline`] — scans an element's text runs into `Vec<Object>`, implementing
7//! org's emphasis pre/post-char rules explicitly.
8//!
9//! PARSE is a pure function of a single file's bytes (spec §2.1): it never depends on
10//! another file, which is what makes content-hash caching sound.
11//!
12//! v0.1 scope (the CORE subset): headings + nesting, property drawers on headings,
13//! paragraphs, plain lists (unordered + ordered) with checkboxes, source blocks, and
14//! inline markup (bold/italic/underline/strike/verbatim/code, links, bare URLs).
15//! Out of scope and left graceful (parsed-and-ignored, never crashing): tables,
16//! footnotes, timestamps, TODO keywords, non-SRC blocks (kept verbatim as example
17//! blocks), generic drawers other than PROPERTIES.
18
19use camino::Utf8Path;
20
21use crate::model::{
22 BlockParams, Bullet, Checkbox, ContentHash, Document, Element, Heading, Keywords, Link,
23 LinkTarget, List, ListItem, ListKind, Object, Properties, Section, Table, TableRow,
24};
25
26#[derive(Debug, thiserror::Error)]
27pub enum ParseError {
28 #[error("parse error at line {line}: {message}")]
29 At { line: usize, message: String },
30}
31
32/// Classified lines produced by the first pass (spec §3.1).
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum Line {
35 Heading,
36 BlockBegin { kind: String },
37 BlockEnd,
38 ListItem,
39 TableRow,
40 Keyword,
41 DrawerBegin,
42 DrawerEnd,
43 Rule,
44 Blank,
45 Text,
46}
47
48/// First pass: classify each raw line. Context-free per line.
49pub fn line_lexer(source: &str) -> Vec<Line> {
50 source.lines().map(classify_line).collect()
51}
52
53fn classify_line(line: &str) -> Line {
54 if line.trim().is_empty() {
55 return Line::Blank;
56 }
57 if heading_level(line).is_some() {
58 return Line::Heading;
59 }
60 let t = line.trim_start();
61 let upper = t.to_ascii_uppercase();
62 if let Some(rest) = upper.strip_prefix("#+BEGIN_") {
63 let kind = rest.split_whitespace().next().unwrap_or("").to_string();
64 return Line::BlockBegin { kind };
65 }
66 if upper.starts_with("#+END_") {
67 return Line::BlockEnd;
68 }
69 if keyword_kv(line).is_some() {
70 return Line::Keyword;
71 }
72 if is_rule(line) {
73 return Line::Rule;
74 }
75 if t.eq_ignore_ascii_case(":END:") {
76 return Line::DrawerEnd;
77 }
78 if is_drawer_begin(t) {
79 return Line::DrawerBegin;
80 }
81 if is_list_item(t).is_some() {
82 return Line::ListItem;
83 }
84 if t.starts_with('|') {
85 return Line::TableRow;
86 }
87 Line::Text
88}
89
90/// blake3 of raw source bytes — the content hash that drives re-parse decisions (spec §4.1).
91pub fn content_hash(bytes: &[u8]) -> ContentHash {
92 ContentHash(*blake3::hash(bytes).as_bytes())
93}
94
95/// Parse one source file into a [`Document`]. Pure over `(path, source)`.
96pub fn parse(path: &Utf8Path, source: &str) -> Result<Document, ParseError> {
97 let content_hash = content_hash(source.as_bytes());
98 let lines: Vec<&str> = source.lines().collect();
99 let classes = line_lexer(source);
100
101 let mut keywords = Keywords::default();
102 let mut root = Section {
103 heading: None,
104 content: Vec::new(),
105 children: Vec::new(),
106 };
107
108 let heading_idxs: Vec<usize> = classes
109 .iter()
110 .enumerate()
111 .filter(|(_, c)| **c == Line::Heading)
112 .map(|(i, _)| i)
113 .collect();
114 let first = heading_idxs.first().copied().unwrap_or(lines.len());
115
116 // Preamble: document-level keywords are lifted into `keywords`; the remaining
117 // lines become the root section's block content.
118 {
119 let mut body: Vec<&str> = Vec::new();
120 for (l, c) in lines[..first].iter().zip(&classes[..first]) {
121 if *c == Line::Keyword {
122 if let Some((k, v)) = keyword_kv(l) {
123 keywords.entries.push((k, v));
124 }
125 } else {
126 body.push(l);
127 }
128 }
129 root.content = parse_elements(&body);
130 }
131
132 // Each heading segment runs from its own line up to (but excluding) the next heading.
133 let mut flat: Vec<(u8, Section)> = Vec::new();
134 for (k, &h_idx) in heading_idxs.iter().enumerate() {
135 let end = heading_idxs.get(k + 1).copied().unwrap_or(lines.len());
136 let heading = parse_heading(lines[h_idx]);
137 let level = heading.level;
138 let (heading, content) = parse_section_body(heading, &lines[h_idx + 1..end]);
139 flat.push((
140 level,
141 Section {
142 heading: Some(heading),
143 content,
144 children: Vec::new(),
145 },
146 ));
147 }
148
149 let mut pos = 0;
150 root.children = build_children(&mut flat, &mut pos, 0);
151
152 Ok(Document {
153 source_path: path.to_owned(),
154 content_hash,
155 keywords,
156 root,
157 })
158}
159
160/// Fold the flat `(level, section)` list into org's nested hierarchy by level.
161fn build_children(flat: &mut [(u8, Section)], pos: &mut usize, parent_level: u8) -> Vec<Section> {
162 let mut children = Vec::new();
163 while *pos < flat.len() {
164 let level = flat[*pos].0;
165 if level <= parent_level {
166 break;
167 }
168 let mut section = std::mem::replace(&mut flat[*pos].1, empty_section());
169 *pos += 1;
170 section.children = build_children(flat, pos, level);
171 children.push(section);
172 }
173 children
174}
175
176fn empty_section() -> Section {
177 Section {
178 heading: None,
179 content: Vec::new(),
180 children: Vec::new(),
181 }
182}
183
184/// Second-tier: scan an element's text into inline objects, applying org's
185/// pre/post-char emphasis rules (spec §3.1, R3 — the highest-divergence area).
186pub fn inline(text: &str) -> Vec<Object> {
187 let chars: Vec<char> = text.chars().collect();
188 parse_inline_run(&chars)
189}
190
191// ---------------------------------------------------------------------------
192// Headings
193// ---------------------------------------------------------------------------
194
195/// `*`-prefixed heading depth, or `None` if the line is not a heading.
196fn heading_level(line: &str) -> Option<u8> {
197 if !line.starts_with('*') {
198 return None;
199 }
200 let stars = line.chars().take_while(|c| *c == '*').count();
201 let after = &line[stars..];
202 if after.starts_with(' ') || after.is_empty() {
203 Some(stars.min(u8::MAX as usize) as u8)
204 } else {
205 None
206 }
207}
208
209fn parse_heading(line: &str) -> Heading {
210 let level = heading_level(line).unwrap_or(1);
211 let rest = line[level as usize..].trim();
212 let (title_str, tags) = split_tags(rest);
213 Heading {
214 level,
215 todo: None, // TODO keywords: out of scope for v0.1.
216 priority: None, // priorities: out of scope for v0.1.
217 title: inline(title_str.trim()),
218 tags,
219 properties: Properties::default(),
220 id: None,
221 custom_id: None,
222 }
223}
224
225/// Split a trailing `:tag1:tag2:` cluster off the heading text.
226fn split_tags(rest: &str) -> (&str, Vec<String>) {
227 let trimmed = rest.trim_end();
228 if !trimmed.ends_with(':') {
229 return (rest, Vec::new());
230 }
231 let start = match trimmed.rfind(char::is_whitespace) {
232 Some(i) => i + 1,
233 None => 0,
234 };
235 let candidate = &trimmed[start..];
236 if is_tag_cluster(candidate) {
237 let tags = candidate
238 .split(':')
239 .filter(|s| !s.is_empty())
240 .map(|s| s.to_string())
241 .collect();
242 (&trimmed[..start], tags)
243 } else {
244 (rest, Vec::new())
245 }
246}
247
248/// A `:a:b:c:` cluster: colon-delimited, non-empty tag names, colon-bounded.
249fn is_tag_cluster(s: &str) -> bool {
250 if !s.starts_with(':') || !s.ends_with(':') || s.len() < 3 {
251 return false;
252 }
253 let inner = &s[1..s.len() - 1];
254 !inner.is_empty()
255 && inner.split(':').all(|part| {
256 !part.is_empty()
257 && part
258 .chars()
259 .all(|c| c.is_alphanumeric() || matches!(c, '_' | '@' | '#' | '%'))
260 })
261}
262
263// ---------------------------------------------------------------------------
264// Section body: property drawer + block content
265// ---------------------------------------------------------------------------
266
267fn parse_section_body(mut heading: Heading, body: &[&str]) -> (Heading, Vec<Element>) {
268 let mut idx = 0;
269 while idx < body.len() && body[idx].trim().is_empty() {
270 idx += 1;
271 }
272 if idx < body.len() && body[idx].trim().eq_ignore_ascii_case(":PROPERTIES:") {
273 idx += 1;
274 while idx < body.len() {
275 let t = body[idx].trim();
276 if t.eq_ignore_ascii_case(":END:") {
277 idx += 1;
278 break;
279 }
280 if let Some((k, v)) = parse_property(t) {
281 if k.eq_ignore_ascii_case("CUSTOM_ID") {
282 heading.custom_id = Some(v.clone());
283 } else if k.eq_ignore_ascii_case("ID") {
284 heading.id = Some(v.clone());
285 }
286 heading.properties.entries.push((k, v));
287 }
288 idx += 1;
289 }
290 }
291 let content = parse_elements(&body[idx..]);
292 (heading, content)
293}
294
295/// `:KEY: value` inside a drawer.
296fn parse_property(line: &str) -> Option<(String, String)> {
297 let line = line.trim();
298 let line = line.strip_prefix(':')?;
299 let end = line.find(':')?;
300 let key = line[..end].trim().to_string();
301 if key.is_empty() {
302 return None;
303 }
304 let value = line[end + 1..].trim().to_string();
305 Some((key, value))
306}
307
308// ---------------------------------------------------------------------------
309// Block-level element builder
310// ---------------------------------------------------------------------------
311
312fn parse_elements(lines: &[&str]) -> Vec<Element> {
313 let mut out = Vec::new();
314 let mut i = 0;
315 while i < lines.len() {
316 let line = lines[i];
317 if line.trim().is_empty() {
318 i += 1;
319 continue;
320 }
321 if let Some((kind, after)) = block_begin(line) {
322 let mut j = i + 1;
323 let mut inner = Vec::new();
324 while j < lines.len() && !is_block_end(lines[j]) {
325 inner.push(lines[j]);
326 j += 1;
327 }
328 let code = inner.join("\n");
329 if kind.eq_ignore_ascii_case("SRC") {
330 let (lang, params) = parse_src_header(&after);
331 out.push(Element::SrcBlock { lang, params, code });
332 } else {
333 // Non-SRC blocks (quote/example/center/export) are kept verbatim for
334 // v0.1 rather than richly modeled — see module scope note.
335 out.push(Element::ExampleBlock(code));
336 }
337 i = if j < lines.len() { j + 1 } else { j };
338 continue;
339 }
340 if is_rule(line) {
341 out.push(Element::HorizontalRule);
342 i += 1;
343 continue;
344 }
345 if let Some((key, value)) = keyword_kv(line) {
346 out.push(Element::Keyword { key, value });
347 i += 1;
348 continue;
349 }
350 if line.trim_start().starts_with('|') {
351 let (table, next) = parse_table(lines, i);
352 out.push(Element::Table(table));
353 i = next;
354 continue;
355 }
356 if let Some((label, first_rest)) = footnote_def_label(line) {
357 let (def, next) = parse_footnote_def(lines, i, label, first_rest);
358 out.push(def);
359 i = next;
360 continue;
361 }
362 if is_list_item(line.trim_start()).is_some() {
363 let (list, next) = parse_list(lines, i);
364 out.push(Element::List(list));
365 i = next;
366 continue;
367 }
368 // Paragraph: gather consecutive soft-wrapped text lines.
369 let mut para = Vec::new();
370 while i < lines.len() {
371 let l = lines[i];
372 if l.trim().is_empty() || is_structural(l) {
373 break;
374 }
375 para.push(l.trim());
376 i += 1;
377 }
378 if !para.is_empty() {
379 out.push(Element::Paragraph(inline(&para.join(" "))));
380 }
381 }
382 out
383}
384
385/// Is this line the start of a non-paragraph construct?
386fn is_structural(line: &str) -> bool {
387 let t = line.trim_start();
388 block_begin(line).is_some()
389 || is_block_end(line)
390 || is_rule(line)
391 || keyword_kv(line).is_some()
392 || is_list_item(t).is_some()
393 || t.starts_with('|')
394 || footnote_def_label(line).is_some()
395 || heading_level(line).is_some()
396}
397
398// ---------------------------------------------------------------------------
399// Tables (spec §1 IN; `#+TBLFM:` formulas are parse-and-ignored via keyword_kv)
400// ---------------------------------------------------------------------------
401
402/// Consume a run of consecutive `|`-prefixed lines into a [`Table`]. Rule rows
403/// (`|---+---|`) are preserved as [`TableRow::Rule`] so the renderer can locate the
404/// header band.
405fn parse_table(lines: &[&str], start: usize) -> (Table, usize) {
406 let mut rows = Vec::new();
407 let mut i = start;
408 while i < lines.len() {
409 let t = lines[i].trim_start();
410 if !t.starts_with('|') {
411 break;
412 }
413 if is_table_rule(t) {
414 rows.push(TableRow::Rule);
415 } else {
416 rows.push(TableRow::Cells(parse_table_cells(t)));
417 }
418 i += 1;
419 }
420 (Table { rows }, i)
421}
422
423/// A rule row: only `|`, `-`, `+`, whitespace, and at least one `-`.
424fn is_table_rule(t: &str) -> bool {
425 t.starts_with('|')
426 && t.contains('-')
427 && t.chars().all(|c| matches!(c, '|' | '-' | '+' | ' '))
428}
429
430fn parse_table_cells(t: &str) -> Vec<Vec<Object>> {
431 let inner = t.trim().trim_start_matches('|').trim_end_matches('|');
432 inner.split('|').map(|cell| inline(cell.trim())).collect()
433}
434
435// ---------------------------------------------------------------------------
436// Footnote definitions (spec §1 IN; inline refs handled in the inline tokenizer)
437// ---------------------------------------------------------------------------
438
439/// A footnote *definition* line: `[fn:LABEL] text...`. Returns the label and the
440/// remainder on the same line. `[fn:LABEL:inline]` (a colon inside the label span) is
441/// an inline reference, not a definition, so it is rejected here.
442fn footnote_def_label(line: &str) -> Option<(String, String)> {
443 let t = line.trim_start();
444 let r = t.strip_prefix("[fn:")?;
445 let end = r.find(']')?;
446 let label = &r[..end];
447 if label.is_empty() || label.contains(':') {
448 return None;
449 }
450 Some((label.to_string(), r[end + 1..].trim_start().to_string()))
451}
452
453/// Gather a footnote definition's content: the remainder of its opening line plus
454/// following continuation lines up to the next blank/structural/definition line.
455fn parse_footnote_def(
456 lines: &[&str],
457 start: usize,
458 label: String,
459 first_rest: String,
460) -> (Element, usize) {
461 let mut parts: Vec<String> = Vec::new();
462 if !first_rest.is_empty() {
463 parts.push(first_rest);
464 }
465 let mut i = start + 1;
466 while i < lines.len() {
467 let l = lines[i];
468 if l.trim().is_empty() || is_structural(l) {
469 break;
470 }
471 parts.push(l.trim().to_string());
472 i += 1;
473 }
474 let content = if parts.is_empty() {
475 Vec::new()
476 } else {
477 vec![Element::Paragraph(inline(&parts.join(" ")))]
478 };
479 (Element::FootnoteDefinition { label, content }, i)
480}
481
482fn parse_list(lines: &[&str], start: usize) -> (List, usize) {
483 let kind = match is_list_item(lines[start].trim_start()) {
484 Some(Bullet::Ordered(_)) => ListKind::Ordered,
485 _ => ListKind::Unordered,
486 };
487 let mut items = Vec::new();
488 let mut i = start;
489 while i < lines.len() {
490 let t = lines[i].trim_start();
491 let bullet = match is_list_item(t) {
492 Some(b) => b,
493 None => break,
494 };
495 let item_kind = match bullet {
496 Bullet::Ordered(_) => ListKind::Ordered,
497 _ => ListKind::Unordered,
498 };
499 if item_kind != kind {
500 break;
501 }
502 let rest = item_body(t, &bullet);
503 let (checkbox, text) = split_checkbox(rest);
504 items.push(ListItem {
505 bullet,
506 checkbox,
507 term: None, // description lists: out of scope for v0.1.
508 content: vec![Element::Paragraph(inline(text.trim()))],
509 });
510 i += 1;
511 }
512 (List { kind, items }, i)
513}
514
515/// Text of a list item after its bullet marker.
516fn item_body<'a>(item: &'a str, bullet: &Bullet) -> &'a str {
517 match bullet {
518 Bullet::Dash | Bullet::Plus => item[1..].trim_start(),
519 Bullet::Ordered(_) => {
520 // Skip digits then the `.`/`)` terminator.
521 let after_digits = item.trim_start_matches(|c: char| c.is_ascii_digit());
522 after_digits
523 .strip_prefix('.')
524 .or_else(|| after_digits.strip_prefix(')'))
525 .unwrap_or(after_digits)
526 .trim_start()
527 }
528 }
529}
530
531/// Detect a leading `[ ]`/`[X]`/`[-]` checkbox on a list item.
532fn split_checkbox(text: &str) -> (Option<Checkbox>, &str) {
533 let bytes = text.as_bytes();
534 if bytes.len() >= 3 && bytes[0] == b'[' && bytes[2] == b']' {
535 let cb = match bytes[1] {
536 b' ' => Some(Checkbox::Off),
537 b'X' | b'x' => Some(Checkbox::On),
538 b'-' => Some(Checkbox::Trans),
539 _ => None,
540 };
541 if let Some(cb) = cb {
542 return (Some(cb), text[3..].trim_start());
543 }
544 }
545 (None, text)
546}
547
548// ---------------------------------------------------------------------------
549// Line predicates / small parsers
550// ---------------------------------------------------------------------------
551
552fn block_begin(line: &str) -> Option<(String, String)> {
553 let t = line.trim_start();
554 let upper = t.to_ascii_uppercase();
555 let rest_upper = upper.strip_prefix("#+BEGIN_")?;
556 let kind_len = rest_upper
557 .find(char::is_whitespace)
558 .unwrap_or(rest_upper.len());
559 // Index back into the original-case string past "#+BEGIN_".
560 let base = t.len() - rest_upper.len();
561 let kind = t[base..base + kind_len].to_string();
562 let after = t[base + kind_len..].trim().to_string();
563 Some((kind, after))
564}
565
566fn is_block_end(line: &str) -> bool {
567 line.trim_start().to_ascii_uppercase().starts_with("#+END_")
568}
569
570fn parse_src_header(after: &str) -> (Option<String>, BlockParams) {
571 let mut parts = after.splitn(2, char::is_whitespace);
572 let lang = parts.next().filter(|s| !s.is_empty()).map(|s| s.to_string());
573 let params = BlockParams {
574 raw: parts.next().unwrap_or("").trim().to_string(),
575 };
576 (lang, params)
577}
578
579/// `#+KEY: value`, excluding `#+BEGIN_`/`#+END_` block delimiters.
580fn keyword_kv(line: &str) -> Option<(String, String)> {
581 let t = line.trim_start();
582 let rest = t.strip_prefix("#+")?;
583 if rest.to_ascii_uppercase().starts_with("BEGIN_")
584 || rest.to_ascii_uppercase().starts_with("END_")
585 {
586 return None;
587 }
588 let colon = rest.find(':')?;
589 let key = rest[..colon].trim().to_string();
590 if key.is_empty() {
591 return None;
592 }
593 let value = rest[colon + 1..].trim().to_string();
594 Some((key, value))
595}
596
597fn is_rule(line: &str) -> bool {
598 let t = line.trim();
599 t.len() >= 5 && t.chars().all(|c| c == '-')
600}
601
602fn is_drawer_begin(t: &str) -> bool {
603 if !t.starts_with(':') || !t.ends_with(':') || t.len() < 3 {
604 return false;
605 }
606 let inner = &t[1..t.len() - 1];
607 !inner.is_empty()
608 && inner
609 .chars()
610 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
611}
612
613/// If `t` (already left-trimmed) begins a list item, return its bullet.
614fn is_list_item(t: &str) -> Option<Bullet> {
615 let bytes = t.as_bytes();
616 if bytes.is_empty() {
617 return None;
618 }
619 if (bytes[0] == b'-' || bytes[0] == b'+')
620 && (bytes.len() == 1 || bytes[1] == b' ')
621 {
622 return Some(if bytes[0] == b'-' {
623 Bullet::Dash
624 } else {
625 Bullet::Plus
626 });
627 }
628 let digits: String = t.chars().take_while(|c| c.is_ascii_digit()).collect();
629 if !digits.is_empty() {
630 let after = &t[digits.len()..];
631 if (after.starts_with('.') || after.starts_with(')'))
632 && (after.len() == 1 || after.as_bytes()[1] == b' ')
633 {
634 if let Ok(n) = digits.parse::<u32>() {
635 return Some(Bullet::Ordered(n));
636 }
637 }
638 }
639 None
640}
641
642// ---------------------------------------------------------------------------
643// Inline tokenizer (spec §3.1, R3)
644// ---------------------------------------------------------------------------
645
646fn parse_inline_run(chars: &[char]) -> Vec<Object> {
647 let mut out = Vec::new();
648 let mut buf = String::new();
649 let mut i = 0;
650 let n = chars.len();
651 while i < n {
652 let c = chars[i];
653 if c == '[' && starts_with_at(chars, i, "[fn:") {
654 if let Some((obj, next)) = try_footnote_ref(chars, i) {
655 flush(&mut buf, &mut out);
656 out.push(obj);
657 i = next;
658 continue;
659 }
660 }
661 if c == '[' && i + 1 < n && chars[i + 1] == '[' {
662 if let Some((obj, next)) = try_link(chars, i) {
663 flush(&mut buf, &mut out);
664 out.push(obj);
665 i = next;
666 continue;
667 }
668 }
669 if is_scheme_start(chars, i) && boundary_before(chars, i) {
670 if let Some((obj, next)) = try_bare_url(chars, i) {
671 flush(&mut buf, &mut out);
672 out.push(obj);
673 i = next;
674 continue;
675 }
676 }
677 if is_marker(c) {
678 if let Some((obj, next)) = try_emphasis(chars, i) {
679 flush(&mut buf, &mut out);
680 out.push(obj);
681 i = next;
682 continue;
683 }
684 }
685 buf.push(c);
686 i += 1;
687 }
688 flush(&mut buf, &mut out);
689 out
690}
691
692fn flush(buf: &mut String, out: &mut Vec<Object>) {
693 if !buf.is_empty() {
694 out.push(Object::Text(std::mem::take(buf)));
695 }
696}
697
698fn starts_with_at(chars: &[char], i: usize, needle: &str) -> bool {
699 let n: Vec<char> = needle.chars().collect();
700 i + n.len() <= chars.len() && chars[i..i + n.len()] == n[..]
701}
702
703/// A footnote reference: `[fn:LABEL]` (referenced) or `[fn:LABEL:text]` (inline
704/// definition). Anonymous inline footnotes `[fn::text]` carry an empty label.
705fn try_footnote_ref(chars: &[char], i: usize) -> Option<(Object, usize)> {
706 let n = chars.len();
707 let close = (i + 1..n).find(|&k| chars[k] == ']')?;
708 let inner: String = chars[i + 1..close].iter().collect();
709 let rest = inner.strip_prefix("fn:")?;
710 let (label, inline_objs) = match rest.split_once(':') {
711 Some((l, txt)) => {
712 let txt_chars: Vec<char> = txt.chars().collect();
713 (l.to_string(), Some(parse_inline_run(&txt_chars)))
714 }
715 None => (rest.to_string(), None),
716 };
717 if label.is_empty() && inline_objs.is_none() {
718 return None;
719 }
720 Some((
721 Object::FootnoteRef {
722 label,
723 inline: inline_objs,
724 },
725 close + 1,
726 ))
727}
728
729/// `[[target]]` or `[[target][description]]`.
730fn try_link(chars: &[char], i: usize) -> Option<(Object, usize)> {
731 let n = chars.len();
732 let mut j = i + 2;
733 while j + 1 < n {
734 if chars[j] == ']' && chars[j + 1] == ']' {
735 let inner = &chars[i + 2..j];
736 let (target_str, desc) = split_link_inner(inner);
737 let target = parse_target(&target_str);
738 let description = desc.map(|d| parse_inline_run(&d));
739 return Some((Object::Link(Link { target, description }), j + 2));
740 }
741 j += 1;
742 }
743 None
744}
745
746/// Split `target][desc` into its two halves at the first `][`.
747fn split_link_inner(inner: &[char]) -> (String, Option<Vec<char>>) {
748 for k in 0..inner.len().saturating_sub(1) {
749 if inner[k] == ']' && inner[k + 1] == '[' {
750 let target: String = inner[..k].iter().collect();
751 let desc: Vec<char> = inner[k + 2..].to_vec();
752 return (target, Some(desc));
753 }
754 }
755 (inner.iter().collect(), None)
756}
757
758fn parse_target(s: &str) -> LinkTarget {
759 if let Some(r) = s.strip_prefix('#') {
760 LinkTarget::CustomId(r.to_string())
761 } else if let Some(r) = s.strip_prefix("id:") {
762 LinkTarget::Id(r.to_string())
763 } else if let Some(r) = s.strip_prefix('*') {
764 LinkTarget::Heading(r.to_string())
765 } else if let Some(r) = s.strip_prefix("file:") {
766 LinkTarget::File {
767 path: r.into(),
768 search: None,
769 }
770 } else if is_external_scheme(s) {
771 LinkTarget::External(s.to_string())
772 } else {
773 LinkTarget::File {
774 path: s.into(),
775 search: None,
776 }
777 }
778}
779
780fn is_external_scheme(s: &str) -> bool {
781 let s = s.to_ascii_lowercase();
782 ["http://", "https://", "mailto:", "ftp://", "news:", "tel:"]
783 .iter()
784 .any(|p| s.starts_with(p))
785}
786
787fn is_scheme_start(chars: &[char], i: usize) -> bool {
788 let tail: String = chars[i..].iter().take(8).collect();
789 let tail = tail.to_ascii_lowercase();
790 tail.starts_with("http://") || tail.starts_with("https://") || tail.starts_with("mailto:")
791}
792
793/// A bare URL in running text, e.g. `https://example.com`.
794fn try_bare_url(chars: &[char], i: usize) -> Option<(Object, usize)> {
795 let n = chars.len();
796 let mut j = i;
797 while j < n {
798 let c = chars[j];
799 if c.is_whitespace() || matches!(c, '<' | '>' | '[' | ']' | '"' | '{' | '}') {
800 break;
801 }
802 j += 1;
803 }
804 // Trim trailing sentence punctuation that is unlikely to be part of the URL.
805 while j > i && matches!(chars[j - 1], '.' | ',' | ';' | ':' | '!' | '?' | ')') {
806 j -= 1;
807 }
808 if j <= i {
809 return None;
810 }
811 let url: String = chars[i..j].iter().collect();
812 Some((
813 Object::Link(Link {
814 target: LinkTarget::External(url),
815 description: None,
816 }),
817 j,
818 ))
819}
820
821fn is_marker(c: char) -> bool {
822 matches!(c, '*' | '/' | '_' | '+' | '=' | '~')
823}
824
825fn pre_ok(prev: Option<char>) -> bool {
826 match prev {
827 None => true,
828 Some(c) => c.is_whitespace() || matches!(c, '-' | '(' | '{' | '\'' | '"'),
829 }
830}
831
832fn post_ok(next: Option<char>) -> bool {
833 match next {
834 None => true,
835 Some(c) => {
836 c.is_whitespace() || matches!(c, '-' | '.' | ',' | ';' | ':' | '!' | '?' | ')' | '}' | '[' | '"' | '\'')
837 }
838 }
839}
840
841/// Org emphasis with pre/post-char boundary rules. `=`/`~` carry literal content.
842fn try_emphasis(chars: &[char], i: usize) -> Option<(Object, usize)> {
843 let n = chars.len();
844 let m = chars[i];
845 let prev = if i == 0 { None } else { Some(chars[i - 1]) };
846 if !pre_ok(prev) {
847 return None;
848 }
849 if i + 1 >= n {
850 return None;
851 }
852 let after = chars[i + 1];
853 if after.is_whitespace() || after == m {
854 return None;
855 }
856 let mut j = i + 1;
857 while j < n {
858 if chars[j] == m && j > i + 1 {
859 let before = chars[j - 1];
860 let next = chars.get(j + 1).copied();
861 if !before.is_whitespace() && post_ok(next) {
862 let inner = &chars[i + 1..j];
863 let obj = match m {
864 '=' => Object::Verbatim(inner.iter().collect()),
865 '~' => Object::Code(inner.iter().collect()),
866 '*' => Object::Bold(parse_inline_run(inner)),
867 '/' => Object::Italic(parse_inline_run(inner)),
868 '_' => Object::Underline(parse_inline_run(inner)),
869 '+' => Object::StrikeThrough(parse_inline_run(inner)),
870 _ => unreachable!(),
871 };
872 return Some((obj, j + 1));
873 }
874 }
875 j += 1;
876 }
877 None
878}
879
880fn boundary_before(chars: &[char], i: usize) -> bool {
881 if i == 0 {
882 return true;
883 }
884 let c = chars[i - 1];
885 c.is_whitespace() || matches!(c, '(' | '[' | '{' | '<' | '"' | '\'')
886}
src/render.rs added +340
@@ -0,0 +1,340 @@
1//! RENDER stage (spec §2.1, §2.4): resolved element tree → HTML fragment.
2//!
3//! A tree walk emitting HTML into a buffer. Two sub-concerns get care (spec §2.4):
4//! 1. Footnotes use a two-pass layout — definitions are collected up front, references
5//! are numbered in order of first appearance during the walk, and a back-linked
6//! notes section is emitted at page end.
7//! 2. Syntax highlighting happens HERE, not at parse time — it is an output concern
8//! and its cost must be cache-skippable (spec §4.2). Emit CSS classes, not inline
9//! styles, so themes live in the stylesheet (spec §3.2).
10//!
11//! v0.2 renders: headings (always anchored, with tags), paragraphs, plain lists
12//! (unordered/ordered + checkboxes), source/example blocks, horizontal rules, tables
13//! (with header band from the rule row), footnotes, and inline markup. Real syntect
14//! tokenizing remains a `<pre><code>` passthrough for now (see [`SyntectHighlighter`]).
15
16use std::collections::HashMap;
17
18use crate::model::{Checkbox, Element, LinkTarget, ListKind, Object, Section, TableRow};
19use crate::resolve::ResolvedDoc;
20use crate::util::{plain_text, slugify};
21
22/// A rendered HTML fragment (content only — no page chrome; spec §2.4).
23#[derive(Debug, Clone)]
24pub struct Html(pub String);
25
26/// Pluggable highlighter so tree-sitter can replace syntect per-language later
27/// without touching the renderer (spec §3.2, R6).
28pub trait Highlighter {
29 fn highlight(&self, code: &str, lang: Option<&str>) -> Html;
30}
31
32/// Default v1 highlighter. For now this is a plain `<pre><code>` passthrough that
33/// escapes the code and tags it with a `language-*` class; real syntect tokenizing
34/// to CSS-class spans is deferred (spec §3.2, §4.2).
35pub struct SyntectHighlighter;
36
37impl Highlighter for SyntectHighlighter {
38 fn highlight(&self, code: &str, lang: Option<&str>) -> Html {
39 let class = match lang {
40 Some(l) => format!(" class=\"language-{}\"", escape_attr(l)),
41 None => String::new(),
42 };
43 Html(format!(
44 "<pre><code{}>{}</code></pre>\n",
45 class,
46 escape_html(code)
47 ))
48 }
49}
50
51/// Carries the highlighter plus the footnote collector across the tree walk (spec §2.4).
52struct Renderer<'a> {
53 hl: &'a dyn Highlighter,
54 /// Block footnote definitions, keyed by label (collected before the walk).
55 block_defs: HashMap<String, Vec<Element>>,
56 /// Inline footnote definitions discovered at reference sites.
57 inline_defs: HashMap<String, Vec<Object>>,
58 /// Reference keys in order of first appearance — drives numbering and note order.
59 order: Vec<String>,
60}
61
62/// Render a resolved document to an HTML fragment.
63pub fn render(doc: &ResolvedDoc, highlighter: &dyn Highlighter) -> Html {
64 let mut r = Renderer {
65 hl: highlighter,
66 block_defs: HashMap::new(),
67 inline_defs: HashMap::new(),
68 order: Vec::new(),
69 };
70 r.collect_defs(&doc.document.root);
71 let mut out = String::new();
72 r.render_section(&doc.document.root, &mut out);
73 r.emit_footnotes(&mut out);
74 Html(out)
75}
76
77impl Renderer<'_> {
78 /// First footnote pass: gather every block definition in the tree by label.
79 fn collect_defs(&mut self, section: &Section) {
80 collect_defs_in(&section.content, &mut self.block_defs);
81 for child in &section.children {
82 self.collect_defs(child);
83 }
84 }
85
86 fn render_section(&mut self, section: &Section, out: &mut String) {
87 if let Some(h) = &section.heading {
88 let level = h.level.clamp(1, 6);
89 let anchor = h
90 .custom_id
91 .clone()
92 .or_else(|| h.id.clone())
93 .unwrap_or_else(|| slugify(&plain_text(&h.title)));
94 out.push_str(&format!("<h{} id=\"{}\">", level, escape_attr(&anchor)));
95 self.render_objects(&h.title, out);
96 for tag in &h.tags {
97 out.push_str(&format!(" <span class=\"tag\">{}</span>", escape_html(tag)));
98 }
99 out.push_str(&format!("</h{}>\n", level));
100 }
101 for element in &section.content {
102 self.render_element(element, out);
103 }
104 for child in &section.children {
105 self.render_section(child, out);
106 }
107 }
108
109 fn render_element(&mut self, element: &Element, out: &mut String) {
110 match element {
111 Element::Paragraph(objs) => {
112 out.push_str("<p>");
113 self.render_objects(objs, out);
114 out.push_str("</p>\n");
115 }
116 Element::List(list) => {
117 let tag = match list.kind {
118 ListKind::Ordered => "ol",
119 _ => "ul",
120 };
121 out.push_str(&format!("<{}>\n", tag));
122 for item in &list.items {
123 out.push_str("<li>");
124 if let Some(cb) = &item.checkbox {
125 let checked = matches!(cb, Checkbox::On);
126 out.push_str(&format!(
127 "<input type=\"checkbox\" disabled{}> ",
128 if checked { " checked" } else { "" }
129 ));
130 }
131 match item.content.as_slice() {
132 [Element::Paragraph(objs)] => self.render_objects(objs, out),
133 els => {
134 for el in els {
135 self.render_element(el, out);
136 }
137 }
138 }
139 out.push_str("</li>\n");
140 }
141 out.push_str(&format!("</{}>\n", tag));
142 }
143 Element::Table(table) => self.render_table(table, out),
144 Element::SrcBlock { lang, code, .. } => {
145 let Html(h) = self.hl.highlight(code, lang.as_deref());
146 out.push_str(&h);
147 }
148 Element::ExampleBlock(code) => {
149 out.push_str(&format!("<pre>{}</pre>\n", escape_html(code)));
150 }
151 Element::HorizontalRule => out.push_str("<hr>\n"),
152 // Definitions are emitted in the footnotes section, not inline.
153 Element::FootnoteDefinition { .. } => {}
154 // Out of scope (non-HTML export, generic drawers, stray keywords, comments):
155 // emitted as nothing rather than crashing.
156 _ => {}
157 }
158 }
159
160 /// Rows before the first rule row become the `<thead>`; the rest are the `<tbody>`.
161 fn render_table(&mut self, table: &crate::model::Table, out: &mut String) {
162 let rule_at = table
163 .rows
164 .iter()
165 .position(|r| matches!(r, TableRow::Rule));
166 out.push_str("<table>\n");
167 let mut wrote_body = false;
168 let mut in_body = rule_at.is_none();
169 for (idx, row) in table.rows.iter().enumerate() {
170 match row {
171 TableRow::Rule => {
172 if Some(idx) == rule_at {
173 in_body = true;
174 }
175 continue;
176 }
177 TableRow::Cells(cells) => {
178 let (open, cell_tag) = if in_body {
179 if !wrote_body {
180 wrote_body = true;
181 ("<tbody>\n<tr>", "td")
182 } else {
183 ("<tr>", "td")
184 }
185 } else {
186 ("<thead>\n<tr>", "th")
187 };
188 out.push_str(open);
189 for cell in cells {
190 out.push_str(&format!("<{}>", cell_tag));
191 self.render_objects(cell, out);
192 out.push_str(&format!("</{}>", cell_tag));
193 }
194 out.push_str("</tr>\n");
195 // Close the header band right after its last row.
196 if !in_body
197 && rule_at.map(|r| idx + 1 == r).unwrap_or(false)
198 {
199 out.push_str("</thead>\n");
200 }
201 }
202 }
203 }
204 if wrote_body {
205 out.push_str("</tbody>\n");
206 }
207 out.push_str("</table>\n");
208 }
209
210 fn render_objects(&mut self, objs: &[Object], out: &mut String) {
211 for obj in objs {
212 self.render_object(obj, out);
213 }
214 }
215
216 fn render_object(&mut self, obj: &Object, out: &mut String) {
217 match obj {
218 Object::Text(t) => out.push_str(&escape_html(t)),
219 Object::Bold(inner) => self.wrap(out, "strong", inner),
220 Object::Italic(inner) => self.wrap(out, "em", inner),
221 Object::Underline(inner) => self.wrap(out, "u", inner),
222 Object::StrikeThrough(inner) => self.wrap(out, "del", inner),
223 Object::Verbatim(s) => {
224 out.push_str(&format!("<code class=\"verbatim\">{}</code>", escape_html(s)))
225 }
226 Object::Code(s) => out.push_str(&format!("<code>{}</code>", escape_html(s))),
227 Object::Link(link) => {
228 let href = link_href(&link.target);
229 out.push_str(&format!("<a href=\"{}\">", escape_attr(&href)));
230 match &link.description {
231 Some(desc) => self.render_objects(desc, out),
232 None => out.push_str(&escape_html(&link_text(&link.target))),
233 }
234 out.push_str("</a>");
235 }
236 Object::FootnoteRef { label, inline } => {
237 let key = if label.is_empty() {
238 format!("__anon{}", self.order.len() + 1)
239 } else {
240 label.clone()
241 };
242 if let Some(objs) = inline {
243 self.inline_defs.insert(key.clone(), objs.clone());
244 }
245 if !self.order.contains(&key) {
246 self.order.push(key.clone());
247 }
248 let num = self.order.iter().position(|l| l == &key).unwrap() + 1;
249 out.push_str(&format!(
250 "<sup class=\"footnote-ref\"><a id=\"fnr-{n}\" href=\"#fn-{n}\">{n}</a></sup>",
251 n = num
252 ));
253 }
254 Object::LineBreak => out.push_str("<br>\n"),
255 // Timestamps, entities: out of scope for now.
256 _ => {}
257 }
258 }
259
260 fn wrap(&mut self, out: &mut String, tag: &str, inner: &[Object]) {
261 out.push_str(&format!("<{}>", tag));
262 self.render_objects(inner, out);
263 out.push_str(&format!("</{}>", tag));
264 }
265
266 /// Second footnote pass: emit the numbered, back-linked notes section (spec §2.4).
267 fn emit_footnotes(&mut self, out: &mut String) {
268 if self.order.is_empty() {
269 return;
270 }
271 let order = self.order.clone();
272 let inline_defs = self.inline_defs.clone();
273 let block_defs = self.block_defs.clone();
274 out.push_str("<section class=\"footnotes\">\n<hr>\n<ol>\n");
275 for (idx, label) in order.iter().enumerate() {
276 let n = idx + 1;
277 out.push_str(&format!("<li id=\"fn-{n}\">"));
278 if let Some(objs) = inline_defs.get(label) {
279 self.render_objects(objs, out);
280 } else if let Some(els) = block_defs.get(label) {
281 for el in els {
282 self.render_element(el, out);
283 }
284 }
285 out.push_str(&format!(
286 " <a class=\"footnote-back\" href=\"#fnr-{n}\">&#8617;</a></li>\n"
287 ));
288 }
289 out.push_str("</ol>\n</section>\n");
290 }
291}
292
293fn collect_defs_in(elements: &[Element], defs: &mut HashMap<String, Vec<Element>>) {
294 for el in elements {
295 if let Element::FootnoteDefinition { label, content } = el {
296 defs.entry(label.clone()).or_insert_with(|| content.clone());
297 }
298 }
299}
300
301/// Best-effort URL for a link target. After RESOLVE, internal targets have been
302/// rewritten to `External` with their final URL; anything still internal here is an
303/// unresolved link, rendered to a plausible anchor so the page stays self-consistent.
304fn link_href(target: &LinkTarget) -> String {
305 match target {
306 LinkTarget::External(s) => s.clone(),
307 LinkTarget::CustomId(id) => format!("#{}", id),
308 LinkTarget::Id(id) => format!("#{}", id),
309 LinkTarget::Heading(text) => format!("#{}", slugify(text)),
310 LinkTarget::File { path, .. } => path.to_string(),
311 }
312}
313
314fn link_text(target: &LinkTarget) -> String {
315 match target {
316 LinkTarget::External(s) => s.clone(),
317 LinkTarget::CustomId(id) | LinkTarget::Id(id) => id.clone(),
318 LinkTarget::Heading(text) => text.clone(),
319 LinkTarget::File { path, .. } => path.to_string(),
320 }
321}
322
323fn escape_html(s: &str) -> String {
324 let mut out = String::with_capacity(s.len());
325 for c in s.chars() {
326 match c {
327 '&' => out.push_str("&amp;"),
328 '<' => out.push_str("&lt;"),
329 '>' => out.push_str("&gt;"),
330 _ => out.push(c),
331 }
332 }
333 out
334}
335
336fn escape_attr(s: &str) -> String {
337 let mut out = escape_html(s);
338 out = out.replace('"', "&quot;");
339 out
340}
src/resolve.rs added +173
@@ -0,0 +1,173 @@
1//! RESOLVE stage (spec §2.1, §4.3): rewrite `LinkTarget`s to final URLs using the
2//! symbol table, AND record which targets each page consumed.
3//!
4//! Critical invariant (spec §4.3, R2): RESOLVE returns the used-target list as a
5//! first-class side output from v1, even before incrementality consumes it. Throwing
6//! it away would make renamed-heading invalidation impossible to compute later without
7//! re-resolving everything.
8//!
9//! Resolution rewrites each internal link into an [`crate::model::LinkTarget::External`]
10//! carrying its final URL, so the renderer needs no symbol-table knowledge. Unresolved
11//! links are left untouched (the renderer falls back to a best-effort anchor) and
12//! reported as [`BrokenLink`] warnings (spec §4.3.4).
13
14use camino::Utf8Path;
15
16use crate::index::{SymbolTable, TargetId};
17use crate::model::{Document, Element, Link, LinkTarget, Object, Section, TableRow};
18use crate::util::{normalize_link_path, output_url};
19
20/// A document whose links have been rewritten to concrete URLs.
21#[derive(Debug, Clone)]
22pub struct ResolvedDoc {
23 pub document: Document,
24}
25
26/// A broken internal link — surfaced as a warning, or an error under `--strict` (spec §4.3.4).
27#[derive(Debug, Clone)]
28pub struct BrokenLink {
29 pub target: TargetId,
30}
31
32/// Result of resolving one page. `used_targets` are the "uses" edges (spec §4.3).
33#[derive(Debug)]
34pub struct ResolveOutput {
35 pub resolved: ResolvedDoc,
36 /// The "uses" edges — MUST be captured from day one (spec §4.3, R2).
37 pub used_targets: Vec<TargetId>,
38 pub broken: Vec<BrokenLink>,
39}
40
41/// `resolve(doc, &SymbolTable) -> (ResolvedDoc, Vec<TargetId used>)` (spec §4.3).
42pub fn resolve(doc: &Document, symbols: &SymbolTable) -> ResolveOutput {
43 let mut document = doc.clone();
44 let from = doc.source_path.clone();
45 let mut used = Vec::new();
46 let mut broken = Vec::new();
47 let mut cx = Cx {
48 from: &from,
49 symbols,
50 used: &mut used,
51 broken: &mut broken,
52 };
53 cx.section(&mut document.root);
54 ResolveOutput {
55 resolved: ResolvedDoc { document },
56 used_targets: used,
57 broken,
58 }
59}
60
61/// The text a description-less internal link should display once its target becomes a URL.
62fn human_text(target: &LinkTarget) -> Option<String> {
63 match target {
64 LinkTarget::Heading(t) => Some(t.clone()),
65 LinkTarget::CustomId(id) | LinkTarget::Id(id) => Some(id.clone()),
66 LinkTarget::File { path, .. } => Some(path.to_string()),
67 LinkTarget::External(_) => None,
68 }
69}
70
71struct Cx<'a> {
72 from: &'a Utf8Path,
73 symbols: &'a SymbolTable,
74 used: &'a mut Vec<TargetId>,
75 broken: &'a mut Vec<BrokenLink>,
76}
77
78impl Cx<'_> {
79 fn section(&mut self, section: &mut Section) {
80 if let Some(h) = &mut section.heading {
81 self.objects(&mut h.title);
82 }
83 for el in &mut section.content {
84 self.element(el);
85 }
86 for child in &mut section.children {
87 self.section(child);
88 }
89 }
90
91 fn element(&mut self, el: &mut Element) {
92 match el {
93 Element::Paragraph(objs) => self.objects(objs),
94 Element::List(list) => {
95 for item in &mut list.items {
96 if let Some(term) = &mut item.term {
97 self.objects(term);
98 }
99 for e in &mut item.content {
100 self.element(e);
101 }
102 }
103 }
104 Element::Table(table) => {
105 for row in &mut table.rows {
106 if let TableRow::Cells(cells) = row {
107 for cell in cells {
108 self.objects(cell);
109 }
110 }
111 }
112 }
113 Element::QuoteBlock(inner)
114 | Element::CenterBlock(inner)
115 | Element::Drawer { content: inner, .. }
116 | Element::FootnoteDefinition { content: inner, .. } => {
117 for e in inner {
118 self.element(e);
119 }
120 }
121 _ => {}
122 }
123 }
124
125 fn objects(&mut self, objs: &mut [Object]) {
126 for obj in objs {
127 match obj {
128 Object::Link(link) => self.link(link),
129 Object::Bold(i)
130 | Object::Italic(i)
131 | Object::Underline(i)
132 | Object::StrikeThrough(i) => self.objects(i),
133 Object::FootnoteRef {
134 inline: Some(i), ..
135 } => self.objects(i),
136 _ => {}
137 }
138 }
139 }
140
141 fn link(&mut self, link: &mut Link) {
142 if let Some(desc) = &mut link.description {
143 self.objects(desc);
144 }
145 let tid = match &link.target {
146 LinkTarget::External(_) => return,
147 LinkTarget::CustomId(id) => TargetId::CustomId(id.clone()),
148 LinkTarget::Id(id) => TargetId::Id(id.clone()),
149 LinkTarget::Heading(t) => TargetId::Heading(t.clone()),
150 LinkTarget::File { path, .. } => {
151 TargetId::File(normalize_link_path(self.from, path))
152 }
153 };
154 match self.symbols.targets.get(&tid) {
155 Some(loc) => {
156 self.used.push(tid.clone());
157 // Preserve the human-readable text before the target becomes a bare URL,
158 // so a description-less `[[*Heading]]` still renders as the heading text.
159 if link.description.is_none() {
160 if let Some(text) = human_text(&link.target) {
161 link.description = Some(vec![Object::Text(text)]);
162 }
163 }
164 let url = output_url(self.from, &loc.source_path, loc.anchor.as_deref());
165 link.target = LinkTarget::External(url);
166 }
167 None => {
168 self.broken.push(BrokenLink { target: tid });
169 // Leave the original target for the renderer's best-effort fallback.
170 }
171 }
172 }
173}
src/site.rs added +420
@@ -0,0 +1,420 @@
1//! Site build: walk a source directory, PARSE every `.org` file, INDEX their targets,
2//! then RESOLVE + RENDER + TEMPLATE each page into a linked static site, copying
3//! non-`.org` assets through unchanged (spec §2.1 DISCOVER…EMIT).
4//!
5//! v0.3 wires in the incremental layer (spec §4, [`crate::incremental`]): a persisted
6//! cache manifest lets a rebuild re-render only the pages whose composed `render_key`
7//! changed, plus the pages that *link into* a changed file's targets (the dependency
8//! graph, spec §4.3). Unchanged pages keep their existing on-disk output untouched.
9//! `--no-cache` forces a full rebuild; the cache is never a correctness dependency, so a
10//! full rebuild and an incremental rebuild produce byte-identical output.
11
12use std::collections::HashSet;
13use std::fs;
14
15use anyhow::{Context, Result};
16use camino::{Utf8Path, Utf8PathBuf};
17use walkdir::WalkDir;
18
19use crate::incremental::{
20 self, combine, config_hash, render_key, resolved_links_hash, site_structure_hash,
21 template_hash, BuildConfig, DepGraph, Hash, Manifest, PageRecord, CACHE_FORMAT_VERSION,
22};
23use crate::index::{document_targets, SymbolTable, TargetId};
24use crate::model::{ContentHash, Document};
25use crate::parser::parse;
26use crate::render::{render, Html, SyntectHighlighter};
27use crate::resolve::resolve;
28use crate::template::{template_sources, NavItem, Templater};
29use crate::util::output_url;
30
31/// A fully built page: source and output paths (relative to their roots) and its
32/// final templated HTML.
33#[derive(Debug, Clone)]
34pub struct BuiltPage {
35 pub source: Utf8PathBuf,
36 pub output: Utf8PathBuf,
37 pub title: String,
38 pub html: String,
39}
40
41/// Unresolved internal links found during a build: `(page, target)` (spec §4.3.4).
42pub type BrokenLinks = Vec<(Utf8PathBuf, TargetId)>;
43
44/// Options controlling a site build.
45#[derive(Debug, Clone, Default)]
46pub struct BuildOptions {
47 /// Bypass the incremental cache and re-render every page (spec §4.5).
48 pub no_cache: bool,
49 /// Treat broken internal links as a build error rather than a warning (spec §4.3.4).
50 pub strict: bool,
51}
52
53/// Summary of a site build.
54#[derive(Debug, Default)]
55pub struct SiteReport {
56 /// Every output page (rendered this build or reused from cache).
57 pub pages: Vec<Utf8PathBuf>,
58 /// Pages actually re-rendered and written this build (the invalidation set).
59 pub rendered: Vec<Utf8PathBuf>,
60 /// Pages whose existing on-disk output was reused unchanged (spec §4.1 skip rule).
61 pub skipped: Vec<Utf8PathBuf>,
62 pub assets: Vec<Utf8PathBuf>,
63 /// Unresolved internal links: `(page, target)`. Warnings, not failures (spec §4.3.4).
64 pub broken: Vec<(Utf8PathBuf, TargetId)>,
65}
66
67/// Everything a build needs about one page *before* the decision to render it: its
68/// hashes, its resolved element tree, and the dependency edges it participates in.
69struct PagePrep {
70 source: Utf8PathBuf,
71 output: Utf8PathBuf,
72 title: String,
73 content_hash: ContentHash,
74 resolved: crate::resolve::ResolvedDoc,
75 used: HashSet<TargetId>,
76 defines: HashSet<TargetId>,
77 broken: Vec<TargetId>,
78 nav: Vec<NavItem>,
79}
80
81/// DISCOVER + PARSE + INDEX + RESOLVE the whole site, returning per-page prep and the
82/// global symbol table. RENDER/TEMPLATE is deferred to the caller so the incremental
83/// build can render only the pages it must. PARSE/INDEX/RESOLVE are cheap and pure, so
84/// they run for every file each build; the incremental win is on RENDER + EMIT (spec §4.4).
85fn prepare_pages(src: &Utf8Path) -> Result<(Vec<PagePrep>, SymbolTable)> {
86 let (org_rel, _assets) = discover(src)?;
87
88 // PARSE every file (relative paths keep snapshots and links machine-independent).
89 let mut docs: Vec<Document> = Vec::new();
90 for rel in &org_rel {
91 let abs = src.join(rel);
92 let source = fs::read_to_string(&abs).with_context(|| format!("reading {abs}"))?;
93 let doc = parse(rel.as_path(), &source).with_context(|| format!("parsing {rel}"))?;
94 docs.push(doc);
95 }
96
97 // INDEX: collect every link target across the corpus.
98 let mut symbols = SymbolTable::new();
99 for doc in &docs {
100 symbols.index_document(doc);
101 }
102
103 // Nav is global; titles come from #+TITLE (falling back to the file stem).
104 let entries: Vec<(Utf8PathBuf, String)> = docs
105 .iter()
106 .map(|d| (d.source_path.clone(), page_title(d)))
107 .collect();
108
109 let mut pages = Vec::new();
110 for doc in &docs {
111 let out = resolve(doc, &symbols);
112 let used: HashSet<TargetId> = out.used_targets.iter().cloned().collect();
113 let broken: Vec<TargetId> = out.broken.iter().map(|b| b.target.clone()).collect();
114 let defines: HashSet<TargetId> = document_targets(doc).into_iter().collect();
115
116 // Nav links are relative to *this* page (spec URL scheme, §8 Q3).
117 let nav: Vec<NavItem> = entries
118 .iter()
119 .map(|(path, title)| NavItem {
120 title: title.clone(),
121 url: output_url(&doc.source_path, path, None),
122 })
123 .collect();
124
125 pages.push(PagePrep {
126 source: doc.source_path.clone(),
127 output: doc.source_path.with_extension("html"),
128 title: page_title(doc),
129 content_hash: doc.content_hash,
130 resolved: out.resolved,
131 used,
132 defines,
133 broken,
134 nav,
135 });
136 }
137
138 Ok((pages, symbols))
139}
140
141/// Parse + index + resolve + render + template a whole site *in memory*, without
142/// touching the output directory. Shared by the tests (full render, every page).
143pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> {
144 let (preps, _symbols) = prepare_pages(src)?;
145 let highlighter = SyntectHighlighter;
146 let templater = Templater::new();
147
148 let mut pages = Vec::new();
149 let mut broken = Vec::new();
150 for p in &preps {
151 for t in &p.broken {
152 broken.push((p.source.clone(), t.clone()));
153 }
154 let html = render_page(&templater, &highlighter, p)?;
155 pages.push(BuiltPage {
156 source: p.source.clone(),
157 output: p.output.clone(),
158 title: p.title.clone(),
159 html,
160 });
161 }
162 Ok((pages, broken))
163}
164
165/// RENDER + TEMPLATE one prepared page into its final HTML string.
166fn render_page(
167 templater: &Templater,
168 highlighter: &SyntectHighlighter,
169 p: &PagePrep,
170) -> Result<String> {
171 let Html(fragment) = render(&p.resolved, highlighter);
172 templater
173 .render_page(&p.title, &fragment, &p.nav)
174 .with_context(|| format!("templating {}", p.source))
175}
176
177/// Full site build with the incremental layer (spec §4). Renders only the pages whose
178/// `render_key` changed or that link into a changed file's targets; reuses the on-disk
179/// output of everything else; persists an updated cache manifest.
180pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result<SiteReport> {
181 let (_org_rel, assets) = discover(src)?;
182 let (preps, symbols) = prepare_pages(src)?;
183
184 // The global hash classes (spec §4.1): a change in any invalidates the site. The
185 // config hash is combined with a site-structure hash because the nav bar — global
186 // chrome on every page — is built from every page's (path, title), so a title/path
187 // change or a page add/remove must re-render every page (else stale nav on disk).
188 let cfg = BuildConfig::default();
189 let nav_entries: Vec<(String, String)> = preps
190 .iter()
191 .map(|p| (p.source.to_string(), p.title.clone()))
192 .collect();
193 let cfg_hash = combine(config_hash(&cfg), site_structure_hash(&nav_entries));
194 let tmpl_hash = template_hash(template_sources());
195
196 // Compose each page's render key and record its dependency edges.
197 let mut new_graph = DepGraph::default();
198 let mut new_records: Vec<(Utf8PathBuf, PageRecord, Hash)> = Vec::new();
199 for p in &preps {
200 let rlh = resolved_links_hash(&p.source, &p.used, &symbols);
201 let key = render_key(p.content_hash, rlh, cfg_hash, tmpl_hash);
202 new_graph.defines.insert(p.source.clone(), p.defines.clone());
203 new_graph.uses.insert(p.source.clone(), p.used.clone());
204 new_records.push((
205 p.source.clone(),
206 PageRecord {
207 content_hash: p.content_hash,
208 render_key: key,
209 output_path: p.output.clone(),
210 },
211 key,
212 ));
213 }
214
215 // Load the prior manifest (unless bypassed). Absent/corrupt/version-mismatch ⇒ None
216 // ⇒ full rebuild (spec §4.5).
217 let prior = if opts.no_cache {
218 None
219 } else {
220 incremental::load_manifest(out)
221 };
222
223 let rebuild: HashSet<Utf8PathBuf> = compute_rebuild_set(
224 &preps,
225 &new_records,
226 &new_graph,
227 cfg_hash,
228 tmpl_hash,
229 out,
230 prior.as_ref(),
231 );
232
233 // Delete outputs for pages that existed last build but are gone now (spec §4.3 step 1:
234 // removed files). Their targets are already in the merged graph, so their linkers were
235 // invalidated above.
236 if let Some(prior) = &prior {
237 let current: HashSet<&Utf8PathBuf> = preps.iter().map(|p| &p.source).collect();
238 for (src_path, rec) in &prior.pages {
239 if !current.contains(src_path) {
240 let dest = out.join(&rec.output_path);
241 let _ = fs::remove_file(&dest);
242 }
243 }
244 }
245
246 let highlighter = SyntectHighlighter;
247 let templater = Templater::new();
248 let mut report = SiteReport::default();
249
250 for p in &preps {
251 for t in &p.broken {
252 report.broken.push((p.source.clone(), t.clone()));
253 }
254 report.pages.push(p.output.clone());
255
256 let dest = out.join(&p.output);
257 if rebuild.contains(&p.source) {
258 if let Some(parent) = dest.parent() {
259 fs::create_dir_all(parent).with_context(|| format!("creating {parent}"))?;
260 }
261 let html = render_page(&templater, &highlighter, p)?;
262 fs::write(&dest, &html).with_context(|| format!("writing {dest}"))?;
263 report.rendered.push(p.output.clone());
264 } else {
265 // Skip: the on-disk output is already correct (spec §4.1). Leave it untouched.
266 report.skipped.push(p.output.clone());
267 }
268 }
269
270 // Assets are a dumb copy in v0.3 (spec §8 Q11): copy every run. Cheap, and keeps the
271 // full-vs-incremental byte equivalence trivially true for non-`.org` files.
272 for rel in &assets {
273 let from = src.join(rel);
274 let dest = out.join(rel);
275 if let Some(parent) = dest.parent() {
276 fs::create_dir_all(parent).with_context(|| format!("creating {parent}"))?;
277 }
278 fs::copy(&from, &dest).with_context(|| format!("copying {from} -> {dest}"))?;
279 report.assets.push(rel.clone());
280 }
281
282 // Persist the manifest for the next build.
283 let manifest = Manifest {
284 format_version: CACHE_FORMAT_VERSION,
285 config_hash: Some(cfg_hash),
286 template_hash: Some(tmpl_hash),
287 pages: new_records
288 .into_iter()
289 .map(|(src_path, rec, _)| (src_path, rec))
290 .collect(),
291 graph: new_graph,
292 };
293 incremental::save_manifest(out, &manifest)
294 .with_context(|| format!("writing cache manifest under {out}"))?;
295
296 if opts.strict && !report.broken.is_empty() {
297 for (page, target) in &report.broken {
298 eprintln!("error: unresolved link in {page}: {target:?}");
299 }
300 anyhow::bail!(
301 "{} unresolved internal link(s) under --strict",
302 report.broken.len()
303 );
304 }
305 for (page, target) in &report.broken {
306 eprintln!("warning: unresolved link in {page}: {target:?}");
307 }
308
309 Ok(report)
310}
311
312/// The set of source files to (re)render this build (spec §4.3 invalidation algorithm),
313/// as the union of:
314/// - **no prior cache** (absent/corrupt/version-mismatch/`--no-cache`) ⇒ every page;
315/// - a **global** config- or template-hash change ⇒ every page (spec §4.1);
316/// - **content-changed** files ∪ pages that link into a changed file's targets, via the
317/// dependency graph merged with the prior build's `defines` (spec §4.3, so a removed
318/// target still invalidates its linkers);
319/// - any page whose composed **render_key** differs from the cached one (catches URL
320/// changes on linked targets precisely);
321/// - any page whose **output file is missing** on disk.
322fn compute_rebuild_set(
323 preps: &[PagePrep],
324 new_records: &[(Utf8PathBuf, PageRecord, Hash)],
325 new_graph: &DepGraph,
326 cfg_hash: Hash,
327 tmpl_hash: Hash,
328 out: &Utf8Path,
329 prior: Option<&Manifest>,
330) -> HashSet<Utf8PathBuf> {
331 let all: HashSet<Utf8PathBuf> = preps.iter().map(|p| p.source.clone()).collect();
332
333 let Some(prior) = prior else {
334 return all; // No usable cache ⇒ full rebuild.
335 };
336
337 // A global config/template change invalidates every page (spec §4.1).
338 if prior.config_hash != Some(cfg_hash) || prior.template_hash != Some(tmpl_hash) {
339 return all;
340 }
341
342 // Content-changed = hash differs from the cached record, or the file is new.
343 let mut changed: HashSet<Utf8PathBuf> = HashSet::new();
344 for p in preps {
345 match prior.pages.get(&p.source) {
346 Some(rec) if rec.content_hash == p.content_hash => {}
347 _ => {
348 changed.insert(p.source.clone());
349 }
350 }
351 }
352
353 // Graph expansion: changed files ∪ pages that link into a changed file's targets.
354 // Merge prior `defines` so a target a changed file removed still pulls its linkers.
355 let merged = prior.graph.merged_defines_with(new_graph);
356 let mut rebuild = incremental::invalidation_set(&changed, &merged);
357
358 // Precise render_key delta (catches a linked target's URL change; also a belt for the
359 // graph). A page whose render_key matches the cache and whose output exists is correct.
360 for (src_path, _rec, key) in new_records {
361 let unchanged = prior
362 .pages
363 .get(src_path)
364 .map(|old| old.render_key == *key)
365 .unwrap_or(false);
366 if !unchanged {
367 rebuild.insert(src_path.clone());
368 }
369 }
370
371 // Any page whose output file is missing must be re-emitted regardless.
372 for p in preps {
373 if !out.join(&p.output).exists() {
374 rebuild.insert(p.source.clone());
375 }
376 }
377
378 rebuild
379}
380
381/// Walk `src`, returning `.org` source paths and non-`.org` asset paths, both relative
382/// to `src` and sorted for deterministic output. The cache manifest is not an asset.
383fn discover(src: &Utf8Path) -> Result<(Vec<Utf8PathBuf>, Vec<Utf8PathBuf>)> {
384 let mut org = Vec::new();
385 let mut assets = Vec::new();
386 for entry in WalkDir::new(src).sort_by_file_name() {
387 let entry = entry.with_context(|| format!("walking {src}"))?;
388 if !entry.file_type().is_file() {
389 continue;
390 }
391 let abs = Utf8PathBuf::from_path_buf(entry.into_path())
392 .map_err(|p| anyhow::anyhow!("non-UTF-8 path: {}", p.display()))?;
393 let rel = abs
394 .strip_prefix(src)
395 .map(|p| p.to_owned())
396 .unwrap_or_else(|_| abs.clone());
397 if rel.extension() == Some("org") {
398 org.push(rel);
399 } else {
400 assets.push(rel);
401 }
402 }
403 org.sort();
404 assets.sort();
405 Ok((org, assets))
406}
407
408fn page_title(doc: &Document) -> String {
409 doc.keywords
410 .entries
411 .iter()
412 .find(|(k, _)| k.eq_ignore_ascii_case("TITLE"))
413 .map(|(_, v)| v.clone())
414 .unwrap_or_else(|| {
415 doc.source_path
416 .file_stem()
417 .unwrap_or("untitled")
418 .to_string()
419 })
420}
src/template.rs added +85
@@ -0,0 +1,85 @@
1//! TEMPLATE stage (spec §2.1, §2.4, §3.3): rendered fragment + page metadata → full HTML.
2//!
3//! minijinja (Jinja2 semantics, runtime templates: edit-and-rebuild, no recompile).
4//! Templates are a hashing input for incrementality (spec §4.1): a base-layout edit
5//! invalidates every page that transitively uses it. Keep the fragment/template
6//! boundary sharp so content HTML can be snapshot-tested independently of chrome.
7
8use minijinja::{context, Environment};
9use serde::Serialize;
10
11/// A navigation entry: a page title and the URL to reach it from the current page.
12#[derive(Debug, Clone, Serialize)]
13pub struct NavItem {
14 pub title: String,
15 pub url: String,
16}
17
18/// The base layout applied to every page: `<title>`, a nav bar, and the body.
19/// Minimal but real — a single `base` template, no partials yet.
20const BASE_TEMPLATE: &str = r#"<!DOCTYPE html>
21<html lang="en">
22<head>
23<meta charset="utf-8">
24<title>{{ title }}</title>
25</head>
26<body>
27<nav>
28{%- for item in nav %}
29<a href="{{ item.url }}">{{ item.title }}</a>
30{%- endfor %}
31</nav>
32<main>
33{{ body | safe }}</main>
34</body>
35</html>
36"#;
37
38/// The source text of every template that participates in the page layout. Hashed by
39/// the incremental layer (spec §4.1): a base-layout edit invalidates every page that
40/// uses it. There is a single `base` template today; when partials arrive this returns
41/// the transitive closure so a single-partial edit invalidates only its users.
42pub fn template_sources() -> &'static [(&'static str, &'static str)] {
43 &[("base", BASE_TEMPLATE)]
44}
45
46#[derive(Debug, thiserror::Error)]
47pub enum TemplateError {
48 #[error("template error: {0}")]
49 Render(String),
50}
51
52/// Wraps a rendered fragment in its page template.
53pub struct Templater {
54 env: Environment<'static>,
55}
56
57impl Templater {
58 pub fn new() -> Self {
59 let mut env = Environment::new();
60 env.add_template("base", BASE_TEMPLATE)
61 .expect("base template compiles");
62 Templater { env }
63 }
64
65 /// fragment + page metadata → full HTML page.
66 pub fn render_page(
67 &self,
68 title: &str,
69 body: &str,
70 nav: &[NavItem],
71 ) -> Result<String, TemplateError> {
72 let tmpl = self
73 .env
74 .get_template("base")
75 .map_err(|e| TemplateError::Render(e.to_string()))?;
76 tmpl.render(context! { title => title, body => body, nav => nav })
77 .map_err(|e| TemplateError::Render(e.to_string()))
78 }
79}
80
81impl Default for Templater {
82 fn default() -> Self {
83 Self::new()
84 }
85}
src/util.rs added +110
@@ -0,0 +1,110 @@
1//! Small shared helpers used across INDEX, RESOLVE, RENDER and the site build:
2//! flattening inline objects to plain text, slugifying heading text into anchors,
3//! and computing relative output URLs between pages.
4
5use camino::{Utf8Path, Utf8PathBuf};
6
7use crate::model::Object;
8
9/// Flatten inline objects to their plain-text content (markup stripped). Used to
10/// derive heading anchors and `[[*Heading]]` link identities (spec §4.3).
11pub fn plain_text(objs: &[Object]) -> String {
12 let mut out = String::new();
13 plain_text_into(objs, &mut out);
14 out
15}
16
17fn plain_text_into(objs: &[Object], out: &mut String) {
18 for obj in objs {
19 match obj {
20 Object::Text(t) => out.push_str(t),
21 Object::Bold(i)
22 | Object::Italic(i)
23 | Object::Underline(i)
24 | Object::StrikeThrough(i) => plain_text_into(i, out),
25 Object::Verbatim(s) | Object::Code(s) | Object::Entity(s) => out.push_str(s),
26 Object::Link(l) => {
27 if let Some(desc) = &l.description {
28 plain_text_into(desc, out);
29 }
30 }
31 Object::FootnoteRef { .. } | Object::Timestamp(_) | Object::LineBreak => {}
32 }
33 }
34}
35
36/// Turn heading text into a URL-safe anchor slug.
37pub fn slugify(text: &str) -> String {
38 let mut out = String::new();
39 let mut prev_dash = false;
40 for c in text.chars() {
41 if c.is_alphanumeric() {
42 out.extend(c.to_lowercase());
43 prev_dash = false;
44 } else if !prev_dash {
45 out.push('-');
46 prev_dash = true;
47 }
48 }
49 out.trim_matches('-').to_string()
50}
51
52/// The output URL to reach `to_rel` (a source `.org` path relative to the site root)
53/// from the page at `from_rel`, honoring an optional `anchor`. Same-file links reduce
54/// to a bare `#anchor` fragment; cross-file links become a relative `.html` path.
55pub fn output_url(from_rel: &Utf8Path, to_rel: &Utf8Path, anchor: Option<&str>) -> String {
56 let path = if from_rel == to_rel {
57 String::new()
58 } else {
59 let to_html = to_rel.with_extension("html");
60 let from_dir = from_rel.parent().unwrap_or_else(|| Utf8Path::new(""));
61 relative_path(from_dir, &to_html)
62 };
63 match anchor {
64 Some(a) if !a.is_empty() => {
65 if path.is_empty() {
66 format!("#{a}")
67 } else {
68 format!("{path}#{a}")
69 }
70 }
71 _ => {
72 if path.is_empty() {
73 "#".to_string()
74 } else {
75 path
76 }
77 }
78 }
79}
80
81/// Relative path from `from_dir` to `to`, using `../` where needed. `/`-joined for URLs.
82fn relative_path(from_dir: &Utf8Path, to: &Utf8Path) -> String {
83 let from_c: Vec<&str> = from_dir.components().map(|c| c.as_str()).collect();
84 let to_c: Vec<&str> = to.components().map(|c| c.as_str()).collect();
85 let mut i = 0;
86 while i < from_c.len() && i < to_c.len() && from_c[i] == to_c[i] {
87 i += 1;
88 }
89 let mut parts: Vec<&str> = vec![".."; from_c.len() - i];
90 parts.extend(&to_c[i..]);
91 parts.join("/")
92}
93
94/// Resolve a `file:` link path (as written) against the linking page's directory,
95/// normalizing `.`/`..` so it can be matched against indexed file targets.
96pub fn normalize_link_path(from_rel: &Utf8Path, path: &Utf8Path) -> Utf8PathBuf {
97 let base = from_rel.parent().unwrap_or_else(|| Utf8Path::new(""));
98 let joined = base.join(path);
99 let mut stack: Vec<&str> = Vec::new();
100 for comp in joined.components() {
101 match comp.as_str() {
102 "." => {}
103 ".." => {
104 stack.pop();
105 }
106 other => stack.push(other),
107 }
108 }
109 Utf8PathBuf::from(stack.join("/"))
110}
tests/harness.rs added +34
@@ -0,0 +1,34 @@
1//! Proves the test harness works end to end while the pipeline is still stubbed.
2//!
3//! Two layers, matching the spec's testing philosophy (§5):
4//! - a plain unit test on the one real function (`content_hash`);
5//! - an `insta` JSON snapshot of a hand-built element-tree value, standing in for the
6//! element-tree snapshots the parser will produce from Phase 1 onward.
7
8use org_ssg::model::{Link, LinkTarget, Object};
9use org_ssg::parser::content_hash;
10
11#[test]
12fn content_hash_is_deterministic_blake3() {
13 let a = content_hash(b"hello\n");
14 let b = content_hash(b"hello\n");
15 let c = content_hash(b"goodbye\n");
16 assert_eq!(a, b, "same bytes must hash identically");
17 assert_ne!(a, c, "different bytes must hash differently");
18 // blake3 is 32 bytes.
19 assert_eq!(a.0.len(), 32);
20}
21
22#[test]
23fn element_tree_json_snapshot() {
24 // A small inline-object tree built by hand — the shape the parser will emit.
25 let tree = vec![
26 Object::Text("see ".into()),
27 Object::Bold(vec![Object::Text("bold".into())]),
28 Object::Link(Link {
29 target: LinkTarget::CustomId("first".into()),
30 description: Some(vec![Object::Text("the first heading".into())]),
31 }),
32 ];
33 insta::assert_json_snapshot!(tree);
34}
tests/incremental.rs added +296
@@ -0,0 +1,296 @@
1//! Incremental build layer gates (spec §4, Phase 6). These are the hard correctness
2//! tests the incremental design exists to satisfy:
3//!
4//! - **Byte-equivalence**: a full (`--no-cache`) build and an incremental rebuild of an
5//! unchanged site produce byte-identical output, and the second build re-renders ZERO
6//! pages (spec §4.5, R5).
7//! - **Edit-one-file**: editing a page re-renders exactly that page plus the pages that
8//! link into it — no more, no less (spec §4.3).
9//! - **Renamed-heading**: renaming a heading a cross-page link points at invalidates the
10//! linking page and updates its emitted anchor (spec §4.3, R2 — the load-bearing case).
11//! - **Cache fallback**: a version bump, a missing cache, or a corrupt cache all fall
12//! back to a full rebuild (spec §4.5).
13
14use std::collections::BTreeMap;
15use std::sync::atomic::{AtomicU32, Ordering};
16
17use camino::Utf8PathBuf;
18
19use org_ssg::incremental::{manifest_path, Manifest, CACHE_FORMAT_VERSION};
20use org_ssg::site::{build_site, BuildOptions};
21
22/// A fresh, empty temp directory unique to this process + call.
23fn tmpdir(tag: &str) -> Utf8PathBuf {
24 static N: AtomicU32 = AtomicU32::new(0);
25 let n = N.fetch_add(1, Ordering::Relaxed);
26 let base = Utf8PathBuf::from_path_buf(std::env::temp_dir())
27 .expect("utf-8 temp dir")
28 .join(format!("org-ssg-it-{}-{tag}-{n}", std::process::id()));
29 if base.exists() {
30 std::fs::remove_dir_all(&base).unwrap();
31 }
32 std::fs::create_dir_all(&base).unwrap();
33 base
34}
35
36fn write(dir: &Utf8PathBuf, name: &str, content: &str) {
37 std::fs::write(dir.join(name), content).unwrap();
38}
39
40/// Every output file (relative path → bytes) except the cache manifest, which is an
41/// internal artifact with non-deterministic map ordering.
42fn output_files(out: &Utf8PathBuf) -> BTreeMap<String, Vec<u8>> {
43 let mut map = BTreeMap::new();
44 for entry in walkdir::WalkDir::new(out).sort_by_file_name() {
45 let entry = entry.unwrap();
46 if !entry.file_type().is_file() {
47 continue;
48 }
49 let path = Utf8PathBuf::from_path_buf(entry.path().to_owned()).unwrap();
50 if path.file_name() == Some(".org-ssg-cache.json") {
51 continue;
52 }
53 let rel = path.strip_prefix(out).unwrap().to_string();
54 map.insert(rel, std::fs::read(&path).unwrap());
55 }
56 map
57}
58
59fn out(p: &str) -> Utf8PathBuf {
60 Utf8PathBuf::from(p)
61}
62
63/// Two linked pages: `b.org` links to a `:CUSTOM_ID:` heading in `a.org`, plus a css asset.
64fn write_linked_site(src: &Utf8PathBuf) {
65 write(
66 src,
67 "a.org",
68 "#+TITLE: A\n\n* Setup\n:PROPERTIES:\n:CUSTOM_ID: setup\n:END:\nOriginal body.\n",
69 );
70 write(src, "b.org", "#+TITLE: B\n\nSee [[#setup][the setup]].\n");
71 write(src, "style.css", "body { color: black; }\n");
72}
73
74#[test]
75fn full_and_incremental_are_byte_identical_and_second_build_renders_nothing() {
76 let root = tmpdir("byteeq");
77 let src = root.join("src");
78 std::fs::create_dir_all(&src).unwrap();
79 write_linked_site(&src);
80
81 // Full build (cache bypassed) to a reference directory.
82 let full = root.join("full");
83 let rfull = build_site(
84 &src,
85 &full,
86 &BuildOptions {
87 no_cache: true,
88 strict: false,
89 },
90 )
91 .unwrap();
92 assert_eq!(rfull.rendered.len(), 2, "full build renders every page");
93
94 // Incremental directory: first build populates the cache and renders everything.
95 let inc = root.join("inc");
96 let r1 = build_site(&src, &inc, &BuildOptions::default()).unwrap();
97 assert_eq!(r1.rendered.len(), 2, "first incremental build renders all");
98
99 // Second incremental build of the UNCHANGED site must re-render ZERO pages.
100 let r2 = build_site(&src, &inc, &BuildOptions::default()).unwrap();
101 assert!(
102 r2.rendered.is_empty(),
103 "unchanged rebuild must render nothing, rendered: {:?}",
104 r2.rendered
105 );
106 assert_eq!(r2.skipped.len(), 2, "both pages reused from cache");
107
108 // Full output == incremental output, byte for byte.
109 assert_eq!(
110 output_files(&full),
111 output_files(&inc),
112 "incremental output must be byte-identical to a full build"
113 );
114}
115
116#[test]
117fn editing_a_page_rebuilds_it_and_its_linkers_exactly() {
118 let root = tmpdir("editone");
119 let src = root.join("src");
120 std::fs::create_dir_all(&src).unwrap();
121 write_linked_site(&src);
122 let out_dir = root.join("out");
123
124 // Prime the cache.
125 build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
126
127 // Edit a.org's body (not its heading/custom-id): b.org links into a.org, so the
128 // invalidation set is exactly {a, b} — b is re-rendered because it links to a.
129 write(
130 &src,
131 "a.org",
132 "#+TITLE: A\n\n* Setup\n:PROPERTIES:\n:CUSTOM_ID: setup\n:END:\nEdited body.\n",
133 );
134 let r = build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
135
136 let mut rendered = r.rendered.clone();
137 rendered.sort();
138 assert_eq!(
139 rendered,
140 vec![out("a.html"), out("b.html")],
141 "editing a.org re-renders exactly a.html and its linker b.html"
142 );
143 assert_eq!(r.skipped, Vec::<Utf8PathBuf>::new(), "nothing else exists to skip");
144}
145
146#[test]
147fn editing_a_leaf_page_rebuilds_only_itself() {
148 let root = tmpdir("editleaf");
149 let src = root.join("src");
150 std::fs::create_dir_all(&src).unwrap();
151 write_linked_site(&src);
152 let out_dir = root.join("out");
153
154 build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
155
156 // b.org has NO inbound links, so editing it invalidates only itself.
157 write(&src, "b.org", "#+TITLE: B\n\nSee [[#setup][the setup]]. Edited.\n");
158 let r = build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
159
160 assert_eq!(r.rendered, vec![out("b.html")], "only the edited leaf re-renders");
161 assert!(
162 r.skipped.contains(&out("a.html")),
163 "the unlinked page a.html is reused"
164 );
165}
166
167#[test]
168fn renaming_a_linked_heading_invalidates_the_linking_page() {
169 let root = tmpdir("rename");
170 let src = root.join("src");
171 std::fs::create_dir_all(&src).unwrap();
172 // b.org links to a.org's heading BY TEXT (the fragile `[[*Heading]]` case, spec §4.3).
173 write(&src, "a.org", "#+TITLE: A\n\n* Target Heading\nBody.\n");
174 write(&src, "b.org", "#+TITLE: B\n\nJump to [[*Target Heading][there]].\n");
175 let out_dir = root.join("out");
176
177 build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
178 let b_before = std::fs::read_to_string(out_dir.join("b.html")).unwrap();
179 assert!(
180 b_before.contains("a.html#target-heading"),
181 "b.html should link to the target heading anchor initially:\n{b_before}"
182 );
183
184 // Rename the heading a.org owns. b.org's [[*Target Heading]] now dangles.
185 write(&src, "a.org", "#+TITLE: A\n\n* Renamed Heading\nBody.\n");
186 let r = build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
187
188 assert!(
189 r.rendered.contains(&out("b.html")),
190 "the linking page must be invalidated by the rename, rendered: {:?}",
191 r.rendered
192 );
193 assert!(
194 r.rendered.contains(&out("a.html")),
195 "the renamed page itself is re-rendered"
196 );
197
198 let b_after = std::fs::read_to_string(out_dir.join("b.html")).unwrap();
199 assert_ne!(b_before, b_after, "b.html's emitted link must change");
200 assert!(
201 !b_after.contains("a.html#target-heading"),
202 "the stale cross-file anchor must be gone:\n{b_after}"
203 );
204 assert!(
205 !r.broken.is_empty(),
206 "the now-dangling link should be reported as broken"
207 );
208}
209
210#[test]
211fn changing_a_title_rebuilds_every_page_for_the_shared_nav() {
212 let root = tmpdir("navtitle");
213 let src = root.join("src");
214 std::fs::create_dir_all(&src).unwrap();
215 write_linked_site(&src);
216 let out_dir = root.join("out");
217
218 build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
219 let b_before = std::fs::read_to_string(out_dir.join("b.html")).unwrap();
220
221 // a.org's #+TITLE feeds the nav bar on every page, so changing it must re-render all.
222 write(
223 &src,
224 "a.org",
225 "#+TITLE: A Renamed\n\n* Setup\n:PROPERTIES:\n:CUSTOM_ID: setup\n:END:\nOriginal body.\n",
226 );
227 let r = build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
228
229 assert_eq!(r.rendered.len(), 2, "a title change re-renders every page");
230 let b_after = std::fs::read_to_string(out_dir.join("b.html")).unwrap();
231 assert_ne!(b_before, b_after, "b.html's nav must reflect a.org's new title");
232 assert!(b_after.contains("A Renamed"), "b.html nav shows the updated title");
233}
234
235#[test]
236fn missing_cache_falls_back_to_full_rebuild() {
237 let root = tmpdir("nocache");
238 let src = root.join("src");
239 std::fs::create_dir_all(&src).unwrap();
240 write_linked_site(&src);
241 let out_dir = root.join("out");
242
243 build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
244 // Delete the cache manifest → next build has nothing to skip against.
245 std::fs::remove_file(manifest_path(&out_dir)).unwrap();
246
247 let r = build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
248 assert_eq!(r.rendered.len(), 2, "a missing cache forces a full rebuild");
249 assert!(r.skipped.is_empty());
250}
251
252#[test]
253fn cache_version_mismatch_falls_back_to_full_rebuild() {
254 let root = tmpdir("versionbump");
255 let src = root.join("src");
256 std::fs::create_dir_all(&src).unwrap();
257 write_linked_site(&src);
258 let out_dir = root.join("out");
259
260 build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
261
262 // Rewrite the manifest with a future cache-format version. On mismatch the loader
263 // discards it (spec §4.5), so the next build re-renders everything.
264 let stale = Manifest {
265 format_version: CACHE_FORMAT_VERSION + 1,
266 ..Default::default()
267 };
268 std::fs::write(
269 manifest_path(&out_dir),
270 serde_json::to_vec(&stale).unwrap(),
271 )
272 .unwrap();
273
274 let r = build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
275 assert_eq!(
276 r.rendered.len(),
277 2,
278 "a cache-format version bump forces a full rebuild"
279 );
280 assert!(r.skipped.is_empty());
281}
282
283#[test]
284fn corrupt_cache_falls_back_without_crashing() {
285 let root = tmpdir("corrupt");
286 let src = root.join("src");
287 std::fs::create_dir_all(&src).unwrap();
288 write_linked_site(&src);
289 let out_dir = root.join("out");
290
291 build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
292 std::fs::write(manifest_path(&out_dir), b"this is not json{{{").unwrap();
293
294 let r = build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
295 assert_eq!(r.rendered.len(), 2, "a corrupt cache is never a correctness dependency");
296}
tests/pipeline.rs added +48
@@ -0,0 +1,48 @@
1//! End-to-end PARSE → RENDER snapshots for the v0.1 core org subset (spec §5).
2//!
3//! Two layers, per the spec's testing philosophy: an element-tree JSON snapshot
4//! (parser correctness) and a rendered-HTML snapshot (renderer correctness), for
5//! each fixture.
6
7use camino::Utf8PathBuf;
8
9use org_ssg::model::Document;
10use org_ssg::parser::parse;
11use org_ssg::render::{render, Html, SyntectHighlighter};
12use org_ssg::resolve::ResolvedDoc;
13
14fn parse_fixture(name: &str) -> Document {
15 let path = Utf8PathBuf::from(env!("CARGO_MANIFEST_DIR"))
16 .join("fixtures")
17 .join(name);
18 let source = std::fs::read_to_string(&path).expect("read fixture");
19 // Use a stable relative path so snapshots don't embed an absolute machine path.
20 parse(Utf8PathBuf::from("fixtures").join(name).as_path(), &source).expect("parse fixture")
21}
22
23fn render_fixture(name: &str) -> String {
24 let document = parse_fixture(name);
25 let resolved = ResolvedDoc { document };
26 let Html(html) = render(&resolved, &SyntectHighlighter);
27 html
28}
29
30#[test]
31fn minimal_element_tree() {
32 insta::assert_json_snapshot!(parse_fixture("minimal.org").root);
33}
34
35#[test]
36fn minimal_html() {
37 insta::assert_snapshot!(render_fixture("minimal.org"));
38}
39
40#[test]
41fn core_element_tree() {
42 insta::assert_json_snapshot!(parse_fixture("core.org").root);
43}
44
45#[test]
46fn core_html() {
47 insta::assert_snapshot!(render_fixture("core.org"));
48}
tests/site.rs added +124
@@ -0,0 +1,124 @@
1//! Multi-file site build + new-construct snapshots for v0.2 (spec §5, Phase 4/5).
2//!
3//! Layers, per the spec's testing philosophy: rendered-HTML snapshots (resolved links,
4//! templated pages, tables, footnotes) plus explicit assertions that a cross-file link
5//! resolves to the right URL and that unresolved links are reported, not fatal.
6
7use camino::Utf8PathBuf;
8
9use org_ssg::index::{SymbolTable, TargetId};
10use org_ssg::parser::parse;
11use org_ssg::render::{render, Html, SyntectHighlighter};
12use org_ssg::resolve::{resolve, ResolvedDoc};
13use org_ssg::site::{render_site, BuiltPage};
14
15fn fixtures() -> Utf8PathBuf {
16 Utf8PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixtures")
17}
18
19fn build_fixture_site() -> Vec<BuiltPage> {
20 let (pages, broken) = render_site(&fixtures().join("site")).expect("build site");
21 assert!(broken.is_empty(), "fixture site has no broken links: {broken:?}");
22 pages
23}
24
25fn page<'a>(pages: &'a [BuiltPage], source: &str) -> &'a BuiltPage {
26 pages
27 .iter()
28 .find(|p| p.source == source)
29 .unwrap_or_else(|| panic!("no page for {source}"))
30}
31
32fn render_fragment(name: &str) -> String {
33 let path = fixtures().join(name);
34 let source = std::fs::read_to_string(&path).expect("read fixture");
35 let document = parse(Utf8PathBuf::from(name).as_path(), &source).expect("parse");
36 let Html(html) = render(&ResolvedDoc { document }, &SyntectHighlighter);
37 html
38}
39
40#[test]
41fn site_index_html() {
42 let pages = build_fixture_site();
43 insta::assert_snapshot!(page(&pages, "index.org").html);
44}
45
46#[test]
47fn site_guide_html() {
48 let pages = build_fixture_site();
49 insta::assert_snapshot!(page(&pages, "guide.org").html);
50}
51
52/// The invariant the whole RESOLVE stage exists for: a cross-file `[[#setup]]` link on
53/// the home page must resolve to the guide page's URL plus the target anchor.
54#[test]
55fn cross_file_link_resolves() {
56 let pages = build_fixture_site();
57 let index = &page(&pages, "index.org").html;
58 assert!(
59 index.contains("href=\"guide.html#setup\""),
60 "cross-file custom-id link should resolve to guide.html#setup, got:\n{index}"
61 );
62 // The `file:` link resolves to the bare output path.
63 assert!(
64 index.contains("href=\"guide.html\""),
65 "file: link should resolve to guide.html"
66 );
67 // A same-page `[[*Overview]]` link stays a local fragment.
68 assert!(
69 index.contains("href=\"#overview\""),
70 "same-page heading link should be a local fragment"
71 );
72}
73
74/// Unresolved internal links are reported as warnings, never a crash (spec §4.3.4).
75#[test]
76fn unresolved_link_is_reported_not_fatal() {
77 let source = "Broken [[#does-not-exist][link]] here.\n";
78 let doc = parse(Utf8PathBuf::from("orphan.org").as_path(), source).expect("parse");
79 let mut symbols = SymbolTable::new();
80 symbols.index_document(&doc);
81 let out = resolve(&doc, &symbols);
82 assert_eq!(out.broken.len(), 1, "one unresolved link expected");
83 assert_eq!(
84 out.broken[0].target,
85 TargetId::CustomId("does-not-exist".into())
86 );
87 assert!(out.used_targets.is_empty(), "nothing resolved, nothing used");
88}
89
90/// RESOLVE records the `uses` edges (spec §4.3, R2) even though incrementality does
91/// not consume them yet.
92#[test]
93fn resolve_records_used_targets() {
94 let pages = build_fixture_site();
95 // index.org uses: guide's #setup, guide.org (file), and its own *Overview → 3.
96 let _ = pages; // pages already assert no broken links; check the edge count directly.
97 let src = fixtures().join("site").join("index.org");
98 let source = std::fs::read_to_string(&src).unwrap();
99 let doc = parse(Utf8PathBuf::from("index.org").as_path(), &source).unwrap();
100
101 let guide_src = fixtures().join("site").join("guide.org");
102 let guide = parse(
103 Utf8PathBuf::from("guide.org").as_path(),
104 &std::fs::read_to_string(&guide_src).unwrap(),
105 )
106 .unwrap();
107
108 let mut symbols = SymbolTable::new();
109 symbols.index_document(&doc);
110 symbols.index_document(&guide);
111 let out = resolve(&doc, &symbols);
112 assert_eq!(out.used_targets.len(), 3, "three internal links resolved");
113 assert!(out.broken.is_empty());
114}
115
116#[test]
117fn table_render() {
118 insta::assert_snapshot!(render_fragment("table.org"));
119}
120
121#[test]
122fn footnote_render() {
123 insta::assert_snapshot!(render_fragment("footnote.org"));
124}
tests/snapshots/harness__element_tree_json_snapshot.snap added +28
@@ -0,0 +1,28 @@
1---
2source: tests/harness.rs
3expression: tree
4---
5[
6 {
7 "Text": "see "
8 },
9 {
10 "Bold": [
11 {
12 "Text": "bold"
13 }
14 ]
15 },
16 {
17 "Link": {
18 "target": {
19 "CustomId": "first"
20 },
21 "description": [
22 {
23 "Text": "the first heading"
24 }
25 ]
26 }
27 }
28]
tests/snapshots/pipeline__core_element_tree.snap added +200
@@ -0,0 +1,200 @@
1---
2source: tests/pipeline.rs
3expression: "parse_fixture(\"core.org\").root"
4---
5{
6 "heading": null,
7 "content": [
8 {
9 "Paragraph": [
10 {
11 "Text": "Intro paragraph with a bare URL "
12 },
13 {
14 "Link": {
15 "target": {
16 "External": "https://example.com"
17 },
18 "description": null
19 }
20 },
21 {
22 "Text": " and some "
23 },
24 {
25 "Code": "inline code"
26 },
27 {
28 "Text": "."
29 }
30 ]
31 }
32 ],
33 "children": [
34 {
35 "heading": {
36 "level": 1,
37 "todo": null,
38 "priority": null,
39 "title": [
40 {
41 "Text": "Ordered and checked"
42 }
43 ],
44 "tags": [],
45 "properties": {
46 "entries": []
47 },
48 "id": null,
49 "custom_id": null
50 },
51 "content": [
52 {
53 "List": {
54 "kind": "Ordered",
55 "items": [
56 {
57 "bullet": {
58 "Ordered": 1
59 },
60 "checkbox": null,
61 "term": null,
62 "content": [
63 {
64 "Paragraph": [
65 {
66 "Text": "first item"
67 }
68 ]
69 }
70 ]
71 },
72 {
73 "bullet": {
74 "Ordered": 2
75 },
76 "checkbox": null,
77 "term": null,
78 "content": [
79 {
80 "Paragraph": [
81 {
82 "Text": "second item with "
83 },
84 {
85 "Italic": [
86 {
87 "Text": "emphasis"
88 }
89 ]
90 }
91 ]
92 }
93 ]
94 }
95 ]
96 }
97 },
98 {
99 "List": {
100 "kind": "Unordered",
101 "items": [
102 {
103 "bullet": "Dash",
104 "checkbox": "Off",
105 "term": null,
106 "content": [
107 {
108 "Paragraph": [
109 {
110 "Text": "todo item"
111 }
112 ]
113 }
114 ]
115 },
116 {
117 "bullet": "Dash",
118 "checkbox": "On",
119 "term": null,
120 "content": [
121 {
122 "Paragraph": [
123 {
124 "Text": "done item"
125 }
126 ]
127 }
128 ]
129 }
130 ]
131 }
132 }
133 ],
134 "children": []
135 },
136 {
137 "heading": {
138 "level": 1,
139 "todo": null,
140 "priority": null,
141 "title": [
142 {
143 "Text": "Links and code"
144 }
145 ],
146 "tags": [],
147 "properties": {
148 "entries": []
149 },
150 "id": null,
151 "custom_id": null
152 },
153 "content": [
154 {
155 "Paragraph": [
156 {
157 "Text": "An external "
158 },
159 {
160 "Link": {
161 "target": {
162 "External": "https://example.org"
163 },
164 "description": [
165 {
166 "Text": "site"
167 }
168 ]
169 }
170 },
171 {
172 "Text": " and a bare "
173 },
174 {
175 "Link": {
176 "target": {
177 "External": "https://bare.example"
178 },
179 "description": null
180 }
181 },
182 {
183 "Text": "."
184 }
185 ]
186 },
187 {
188 "SrcBlock": {
189 "lang": "rust",
190 "params": {
191 "raw": ""
192 },
193 "code": "fn main() {\n println!(\"hello\");\n}"
194 }
195 }
196 ],
197 "children": []
198 }
199 ]
200}
tests/snapshots/pipeline__core_html.snap added +19
@@ -0,0 +1,19 @@
1---
2source: tests/pipeline.rs
3expression: "render_fixture(\"core.org\")"
4---
5<p>Intro paragraph with a bare URL <a href="https://example.com">https://example.com</a> and some <code>inline code</code>.</p>
6<h1 id="ordered-and-checked">Ordered and checked</h1>
7<ol>
8<li>first item</li>
9<li>second item with <em>emphasis</em></li>
10</ol>
11<ul>
12<li><input type="checkbox" disabled> todo item</li>
13<li><input type="checkbox" disabled checked> done item</li>
14</ul>
15<h1 id="links-and-code">Links and code</h1>
16<p>An external <a href="https://example.org">site</a> and a bare <a href="https://bare.example">https://bare.example</a>.</p>
17<pre><code class="language-rust">fn main() {
18 println!("hello");
19}</code></pre>
tests/snapshots/pipeline__minimal_element_tree.snap added +180
@@ -0,0 +1,180 @@
1---
2source: tests/pipeline.rs
3expression: "parse_fixture(\"minimal.org\").root"
4---
5{
6 "heading": null,
7 "content": [
8 {
9 "Paragraph": [
10 {
11 "Text": "A single paragraph of preamble text before any heading."
12 }
13 ]
14 }
15 ],
16 "children": [
17 {
18 "heading": {
19 "level": 1,
20 "todo": null,
21 "priority": null,
22 "title": [
23 {
24 "Text": "First Heading"
25 }
26 ],
27 "tags": [],
28 "properties": {
29 "entries": [
30 [
31 "CUSTOM_ID",
32 "first"
33 ]
34 ]
35 },
36 "id": null,
37 "custom_id": "first"
38 },
39 "content": [
40 {
41 "Paragraph": [
42 {
43 "Text": "Some body text with "
44 },
45 {
46 "Bold": [
47 {
48 "Text": "bold"
49 }
50 ]
51 },
52 {
53 "Text": ", "
54 },
55 {
56 "Italic": [
57 {
58 "Text": "italic"
59 }
60 ]
61 },
62 {
63 "Text": ", and "
64 },
65 {
66 "Verbatim": "verbatim"
67 },
68 {
69 "Text": "."
70 }
71 ]
72 }
73 ],
74 "children": [
75 {
76 "heading": {
77 "level": 2,
78 "todo": null,
79 "priority": null,
80 "title": [
81 {
82 "Text": "A Subheading"
83 }
84 ],
85 "tags": [
86 "tag1",
87 "tag2"
88 ],
89 "properties": {
90 "entries": []
91 },
92 "id": null,
93 "custom_id": null
94 },
95 "content": [
96 {
97 "List": {
98 "kind": "Unordered",
99 "items": [
100 {
101 "bullet": "Dash",
102 "checkbox": null,
103 "term": null,
104 "content": [
105 {
106 "Paragraph": [
107 {
108 "Text": "an unordered item"
109 }
110 ]
111 }
112 ]
113 },
114 {
115 "bullet": "Dash",
116 "checkbox": null,
117 "term": null,
118 "content": [
119 {
120 "Paragraph": [
121 {
122 "Text": "another with a checkbox [ ]"
123 }
124 ]
125 }
126 ]
127 }
128 ]
129 }
130 }
131 ],
132 "children": []
133 }
134 ]
135 },
136 {
137 "heading": {
138 "level": 1,
139 "todo": null,
140 "priority": null,
141 "title": [
142 {
143 "Text": "Second Heading"
144 }
145 ],
146 "tags": [],
147 "properties": {
148 "entries": []
149 },
150 "id": null,
151 "custom_id": null
152 },
153 "content": [
154 {
155 "Paragraph": [
156 {
157 "Text": "See "
158 },
159 {
160 "Link": {
161 "target": {
162 "CustomId": "first"
163 },
164 "description": [
165 {
166 "Text": "the first heading"
167 }
168 ]
169 }
170 },
171 {
172 "Text": "."
173 }
174 ]
175 }
176 ],
177 "children": []
178 }
179 ]
180}
tests/snapshots/pipeline__minimal_html.snap added +14
@@ -0,0 +1,14 @@
1---
2source: tests/pipeline.rs
3expression: "render_fixture(\"minimal.org\")"
4---
5<p>A single paragraph of preamble text before any heading.</p>
6<h1 id="first">First Heading</h1>
7<p>Some body text with <strong>bold</strong>, <em>italic</em>, and <code class="verbatim">verbatim</code>.</p>
8<h2 id="a-subheading">A Subheading <span class="tag">tag1</span> <span class="tag">tag2</span></h2>
9<ul>
10<li>an unordered item</li>
11<li>another with a checkbox [ ]</li>
12</ul>
13<h1 id="second-heading">Second Heading</h1>
14<p>See <a href="#first">the first heading</a>.</p>
tests/snapshots/site__footnote_render.snap added +16
@@ -0,0 +1,16 @@
1---
2source: tests/site.rs
3expression: "render_fragment(\"footnote.org\")"
4---
5<p>Text with a reference.<sup class="footnote-ref"><a id="fnr-1" href="#fn-1">1</a></sup> And a second one.<sup class="footnote-ref"><a id="fnr-2" href="#fn-2">2</a></sup></p>
6<p>An inline footnote.<sup class="footnote-ref"><a id="fnr-3" href="#fn-3">3</a></sup></p>
7<section class="footnotes">
8<hr>
9<ol>
10<li id="fn-1"><p>The first definition.</p>
11 <a class="footnote-back" href="#fnr-1">&#8617;</a></li>
12<li id="fn-2"><p>The second definition, with <em>emphasis</em>.</p>
13 <a class="footnote-back" href="#fnr-2">&#8617;</a></li>
14<li id="fn-3">defined right here <a class="footnote-back" href="#fnr-3">&#8617;</a></li>
15</ol>
16</section>
tests/snapshots/site__site_guide_html.snap added +39
@@ -0,0 +1,39 @@
1---
2source: tests/site.rs
3expression: "page(&pages, \"guide.org\").html"
4---
5<!DOCTYPE html>
6<html lang="en">
7<head>
8<meta charset="utf-8">
9<title>Guide</title>
10</head>
11<body>
12<nav>
13<a href="about.html">About</a>
14<a href="#">Guide</a>
15<a href="index.html">Home</a>
16</nav>
17<main>
18<h1 id="setup">Setup</h1>
19<p>Install the steps in order.<sup class="footnote-ref"><a id="fnr-1" href="#fn-1">1</a></sup> Then return <a href="index.html">home</a>.</p>
20<h1 id="data">Data</h1>
21<table>
22<thead>
23<tr><th>Name</th><th>Score</th></tr>
24</thead>
25<tbody>
26<tr><td>alpha</td><td>10</td></tr>
27<tr><td>beta</td><td>20</td></tr>
28</tbody>
29</table>
30<section class="footnotes">
31<hr>
32<ol>
33<li id="fn-1"><p>Read the manual before you begin.</p>
34 <a class="footnote-back" href="#fnr-1">&#8617;</a></li>
35</ol>
36</section>
37</main>
38</body>
39</html>
tests/snapshots/site__site_index_html.snap added +24
@@ -0,0 +1,24 @@
1---
2source: tests/site.rs
3expression: "page(&pages, \"index.org\").html"
4---
5<!DOCTYPE html>
6<html lang="en">
7<head>
8<meta charset="utf-8">
9<title>Home</title>
10</head>
11<body>
12<nav>
13<a href="about.html">About</a>
14<a href="guide.html">Guide</a>
15<a href="#">Home</a>
16</nav>
17<main>
18<p>Welcome. See the <a href="guide.html">guide</a> and jump straight to its <a href="guide.html#setup">setup section</a> across files.</p>
19<p>Also see <a href="#overview">Overview</a> further down this page.</p>
20<h1 id="overview">Overview</h1>
21<p>The overview lives on the home page.</p>
22</main>
23</body>
24</html>
tests/snapshots/site__table_render.snap added +13
@@ -0,0 +1,13 @@
1---
2source: tests/site.rs
3expression: "render_fragment(\"table.org\")"
4---
5<table>
6<thead>
7<tr><th>Name</th><th>Score</th></tr>
8</thead>
9<tbody>
10<tr><td>alpha</td><td>10</td></tr>
11<tr><td>beta</td><td>20</td></tr>
12</tbody>
13</table>