diff --git a/.clippy.toml b/.clippy.toml new file mode 100644 index 0000000..0358cdb --- /dev/null +++ b/.clippy.toml @@ -0,0 +1,2 @@ +allow-unwrap-in-tests = true +allow-expect-in-tests = true diff --git a/.forgejo/workflows/check.yaml b/.forgejo/workflows/check.yaml index 66f5b5d..ee0fa0a 100644 --- a/.forgejo/workflows/check.yaml +++ b/.forgejo/workflows/check.yaml @@ -12,8 +12,11 @@ jobs: - name: checkout code uses: actions/checkout@v6 - - name: build + - name: cargo build run: cargo build + + - name: format + run: cargo fmt -- --check - name: lint run: cargo clippy -- -Dwarnings - name: format diff --git a/.justfile b/.justfile index f34bf85..1ad717b 100644 --- a/.justfile +++ b/.justfile @@ -1,116 +1,176 @@ _default: @just --list +watch_cmd := "watchexec -qc -r -e rs,toml,html --color always -- " +cover_cmd := 'cargo llvm-cov --ignore-filename-regex "main\.rs|dev\.rs"' +just_cmd := 'just --unstable --timestamp --explain --command-color green' + # DEV -# Build on changes -[group('dev')] -serve-watch: - bacon --job run-long -- localhost:3003 +# Start server +[group: 'develop'] +run: + cargo run -- --hostname localhost --port 3003 -alias sw := serve-watch -alias dev := serve-watch -alias d := serve-watch +alias r := run + +# Build on changes +[group: 'develop'] +run-watch: + {{ watch_cmd }} {{ just_cmd }} run + +alias rw := run-watch +alias dev := run-watch +alias d := run-watch + +# Run all assessments on changes +[group: 'develop'] +verify-watch: + {{ watch_cmd }} {{ just_cmd }} verify + +alias vw := verify-watch # Run tests on changes -[group('dev')] +[group: 'develop'] test-watch: - bacon --job test + {{ watch_cmd }} {{ just_cmd }} test alias tw := test-watch -# Format check on changes -[group('dev')] +# Run tests with coverage reports on changes +[group: 'develop'] +cover-watch: + {{ watch_cmd }} {{ just_cmd }} cover-report + +alias ow := cover-watch + +# Run cargo check on changes +[group: 'develop'] +check-watch: + {{ watch_cmd }} {{ just_cmd }} check + +alias cw := check-watch + +# Lint on changes +[group: 'develop'] +lint-watch: + {{ watch_cmd }} {{ just_cmd }} lint + +alias lw := lint-watch + +# Assess formatting on changes +[group: 'develop'] format-watch: - bacon --job fmt-check + {{ watch_cmd }} {{ just_cmd }} format-assess alias fw := format-watch -# Check before push -[group('dev')] -push: check +# Format all files +[group: 'develop'] +format: + cargo fmt + +alias f := format + +# Verify before push +[group: 'develop'] +push: verify git push +alias p := push -# RUN +# ANALYSIS -# Start server -[group('run')] -serve: - cargo run localhost:3003 +# Run all analysis +[group: 'assess'] +verify: format-assess lint check test cover-assess -alias s := serve +alias v := verify + +# Assess coverage +[group: 'assess'] +cover-assess: + {{ cover_cmd }} --fail-under-regions 90 report + +# Assess formatting +[group: 'assess'] +format-assess: + cargo fmt -- --check + +alias fc := format-assess + +# Lint with Clippy +[group: 'assess'] +lint: + cargo clippy + +alias l := lint + +# Run cargo check +[group: 'assess'] +check: + cargo check --workspace + +alias c := check + +# Run tests +[group: 'assess'] +test: + cargo test -- --skip 'serial_tests::' + cargo test -- --test 'serial_tests::' --test-threads 1 + +alias t := test + +# Run tests with coverage +[group: 'assess'] +cover: + {{ cover_cmd }} --no-report -- --skip 'serial_tests::' + {{ cover_cmd }} --no-report -- --test 'serial_tests::' --test-threads 1 + +alias o := cover + +## COVER + +# Make coverage report +[group: 'cover'] +cover-report: cover + {{ cover_cmd }} report --html + {{ cover_cmd }} report + +alias or := cover-report + +# Open coverage report +[group: 'cover'] +cover-open: cover + {{ cover_cmd }} report --open + +alias oo := cover-open # BUILD # Build project with Cargo -[group('build')] +[group: 'build'] build: cargo build alias b := build # Cleanup build artifacts -[group('build')] +[group: 'build'] clean: cargo clean alias cl := clean -# Clean, build, run checks -[group('build')] -full-build: clean build check +# Clean, run assessments, release build +[group: 'build'] +full-build: clean verify release-build alias fb := full-build # Release build -[group('build')] -release-build: +[group: 'build'] +release-build: verify cargo build --release alias rb := release-build - -# CHECKS - -# Lint, check formatting and run tests -[group('checks')] -check: format-check lint cargo-check test - -alias c := check - -# Run cargo check -[group('checks')] -cargo-check: - cargo check --workspace - -alias cc := cargo-check - -# Lint with Clippy -[group('checks')] -lint: - cargo clippy - -alias l := lint - -# Check formatting without changing files -[group('checks')] -format-check: - cargo fmt -- --check - -alias fc := format-check - -# Run tests -[group('checks')] -test: - cargo test - -alias t := test - -# FORMATTING - -# Format all files -[group('checks')] -format: - cargo fmt - -alias f := format diff --git a/Cargo.lock b/Cargo.lock index c967ee9..7e88c5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -84,6 +90,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.10.0" @@ -185,6 +197,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -246,6 +267,8 @@ dependencies = [ "tera", "tokio", "toml", + "tower", + "ureq", ] [[package]] @@ -260,6 +283,16 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +[[package]] +name = "flate2" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -552,6 +585,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -772,6 +815,55 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.23.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -885,6 +977,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + [[package]] name = "siphasher" version = "1.0.1" @@ -917,6 +1015,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.111" @@ -1092,6 +1196,47 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d39cb1dbab692d82a977c0392ffac19e188bd9186a9f32806f0aaa859d75585a" +dependencies = [ + "base64", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf-8", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "version_check" version = "0.9.5" @@ -1159,6 +1304,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "webpki-roots" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi-util" version = "0.1.11" @@ -1227,13 +1381,22 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets", + "windows-targets 0.53.5", ] [[package]] @@ -1245,6 +1408,22 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + [[package]] name = "windows-targets" version = "0.53.5" @@ -1252,58 +1431,106 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ "windows-link", - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_aarch64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + [[package]] name = "windows_i686_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_i686_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "windows_x86_64_msvc" version = "0.53.1" @@ -1335,3 +1562,9 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" diff --git a/Cargo.toml b/Cargo.toml index a76d7b1..c29ea33 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,26 +5,14 @@ description = "A non-linear writing instrument." license = "AGPL-3.0-only" repository = "https://codeberg.org/jutty/en" +homepage = "https://en.jutty.dev" +documentation = "https://en.jutty.dev/node/Documentation" edition = "2024" rust-version= "1.91.1" -[package.metadata.bacon.jobs.fmt-check] -command = [ "cargo", "fmt", "--check", "--", "--color=always" ] -need_stdout = true - -[package.metadata.bacon.jobs.fmt] -command = [ "cargo", "fmt" ] -need_stdout = true - -[lints.rust] -# levels: allow, expect, warn, force-warn, deny, forbid -unsafe_code = { level = "forbid", priority = 99 } -unused = { level = "warn", priority = 10 } -let_underscore= { level = "warn", priority = 10 } -nonstandard-style = "warn" -future-incompatible = "warn" -keyword-idents = "warn" +[features] +serial-tests = [] [dependencies] axum = "0.8.7" @@ -34,6 +22,19 @@ serde_json = "1.0.145" serde = { version = "1.0.228", features = ["derive"] } toml = "0.9.8" +[dev-dependencies] +ureq = "3" +tower = { version = "0.5.2", features = ["util"] } + +[lints.rust] +# levels: allow, expect, warn, force-warn, deny, forbid +unsafe_code = { level = "deny", priority = 99 } +unused = { level = "warn", priority = 10 } +let_underscore= { level = "warn", priority = 10 } +nonstandard-style = "warn" +future-incompatible = "warn" +keyword-idents = "warn" + [lints.clippy] # levels: allow, warn, deny, forbid @@ -100,7 +101,6 @@ mismatching_type_param_order = "warn" missing_errors_doc = "warn" missing_fields_in_debug = "warn" missing_panics_doc = "warn" -must_use_candidate = "warn" mut_mut = "warn" naive_bytecount = "warn" needless_continue = "warn" @@ -148,14 +148,11 @@ wildcard_imports = "warn" zero_sized_map_values = "warn" # restrictive -allow_attributes = "forbid" arithmetic_side_effects = "warn" as_conversions = "warn" as_pointer_underscore = "warn" as_underscore = "warn" -default_numeric_fallback = "warn" deref_by_slicing = "warn" -else_if_without_else = "warn" empty_drop = "warn" empty_enum_variants_with_brackets = "warn" error_impl_error = "warn" @@ -164,7 +161,6 @@ expect_used = "warn" filetype_is_file = "warn" float_cmp_const = "warn" fn_to_numeric_cast_any = "warn" -get_unwrap = "warn" if_then_some_else_none = "warn" indexing_slicing = "warn" infinite_loop = "warn" @@ -188,7 +184,6 @@ pattern_type_mismatch = "warn" pub_without_shorthand = "warn" redundant_test_prefix = "warn" redundant_type_annotations = "warn" -ref_patterns = "warn" renamed_function_params = "warn" rest_pat_in_fully_bound_structs = "warn" return_and_then = "warn" @@ -197,10 +192,8 @@ semicolon_outside_block = "warn" shadow_reuse = "warn" shadow_same = "warn" shadow_unrelated = "warn" -single_char_lifetime_names = "warn" string_add = "warn" string_lit_chars_any = "warn" -tests_outside_test_module = "warn" unnecessary_self_imports = "warn" unneeded_field_pattern = "warn" unseparated_literal_suffix = "warn" @@ -213,3 +206,11 @@ wildcard_enum_match_arm = "warn" # cargo negative_feature_names = "warn" redundant_feature_names = "warn" + +[package.metadata.bacon.jobs.fmt-check] +command = [ "cargo", "fmt", "--check", "--", "--color=always" ] +need_stdout = true + +[package.metadata.bacon.jobs.fmt] +command = [ "cargo", "fmt" ] +need_stdout = true diff --git a/README.md b/README.md index 5795ff2..e37332e 100644 --- a/README.md +++ b/README.md @@ -4,20 +4,42 @@ en is a tool to write non-linear, connected pieces of text and have their refere It works by ingesting a TOML file containing your node specification and serving it as a website that allows nodes to be browsed, searched and listed in relation to each other or as a shallow tree of nodes. +## Learn more + +You can learn more and see what en looks like by visiting the [homepage](https://en.jutty.dev), which is rendered using en itself. + ## Roadmap -- [ ] Anchor rendering - - [ ] Automatic anchors -- [ ] Connection kinds -- [ ] Reduce O(n) calls in the formats module - [ ] Add tests +- [ ] Richer text formatting + - [x] Headers + - [x] Preformatted blocks + - [x] Inline code + - [x] Anchor rendering + - [ ] Automatic anchors + - [ ] `#` syntax for header ID anchors + - [x] External anchors + - [ ] Bold, italics, underline, strikethrough + - [ ] Lists + - [ ] Checkboxes + - [ ] Move this roadmap to en +- [ ] Connection kinds + - [ ] Mutual + - [ ] Category <-> Membership + - [ ] Opposite <-> Equivalent + - [ ] Contrast <-> Similar + - [ ] Cognate <-> Unrelated + - [ ] Specialization <-> Generalization + - [ ] Custom connection kinds +- [ ] Strip/render some syntax in Tree text preview +- [ ] Begin centralizing state +- [ ] Full-text search +- [ ] Render to filesystem +- [ ] Reduce O(n) calls in the formats module +- [ ] Multi-file graphs +- [ ] Multi-graph +- [ ] Themes - [x] Array syntax for lightweight connections - [x] Automatic IDs - [x] Automatic titles - [x] Mismatch between TOML ID and provided ID - -## Motivation - -I created en because I wanted to write a complex, long-form register of my personal worldview. I have always written a lot, but I find non-fiction essays hard to carry to fruition in the usual, linear structure which you'd commonly find in, for instance, a typical philosophy book. - -I call en a "writing instrument" because that's how I relate to it. I use it to write my thoughts and connect them. I like how you can write new pages easily from a single big file so that creating new pages and connecting them is effortless compared to creating a new file for each one and them handling that spread of files. With en, I can just write a few lines and I already have a new page laid out. It fits how my thoughts also spread and fork very quickly. diff --git a/src/dev.rs b/src/dev.rs index 899bcb3..743ade7 100644 --- a/src/dev.rs +++ b/src/dev.rs @@ -1,7 +1,93 @@ -pub fn log(function: &F, message: &str) { - eprintln!( - "{:?} [{}] {message}", - crate::ONSET.elapsed(), - std::any::type_name_of_val(function).replace("en::", ""), - ); +pub fn elog(function: &str, message: &str) { + eprintln!("{:?} [{function}] {message}", crate::ONSET.elapsed()); +} + +#[macro_export] +macro_rules! log { + ($fmt:expr $(, $($arg:tt)+ )? ) => {{ + let mut display_path = String::new(); + let mut path = std::any::type_name_of_val(&|| {}) + .to_string().replace("::{{closure}}", ""); + + let level: u8 = std::env::var("DEBUG") + .unwrap_or("0".to_string()).trim().parse().unwrap_or(0); + + if path.matches("::").count() > 3 { + + if let Some(s) = path.split(" as ").next() + .map(|parent| parent.replace(['<', '>'], "")) + .and_then(|parent| { path.split(" as ").nth(1) + .and_then(|s| s.split("::").last()) + .map(|caller| format!("{parent}::{caller}")) + }) { path = s; } + + let path_vec: Vec<&str> = path.split("::").collect(); + + if let ( + Some(last), + Some(second_to_last), + Some(third_to_last), + ) = ( + path_vec.get(path_vec.len().saturating_sub(1)), + path_vec.get(path_vec.len().saturating_sub(2)), + path_vec.get(path_vec.len().saturating_sub(3)), + ) { + display_path = if level > 3 { + path.clone() + } else if level > 0 { + format!("{third_to_last}::{second_to_last}::{last}") + } else { + format!("{second_to_last}::{last}") + }; + } + } else { + display_path = path.clone() + }; + + let filter = std::env::var("DEBUG_FILTER").unwrap_or("any".to_string()); + + if filter == "any" || filter.is_empty() || path.contains(&filter) { + $crate::dev::elog(&display_path, &format!($fmt $(, $($arg)+ )?)); + }; + + }}; +} + +#[cfg(test)] +mod tests { + + fn run_in_debug_level(level: &str) { + #[allow(unsafe_code)] + unsafe { + std::env::set_var("DEBUG", level); + log!("Debug is set to {level}"); + } + } + + #[test] + fn debug_var_set() { + for level in 0..9 { + run_in_debug_level(&level.to_string()); + } + run_in_debug_level(""); + run_in_debug_level("駄目!"); + } + + #[test] + fn trait_stripping() { + pub trait Loggable { + fn test(&self); + } + + struct Logger {} + + impl Loggable for Logger { + fn test(&self) { + log!("This is inside a trait implementation"); + } + } + + let logger = Logger {}; + logger.test(); + } } diff --git a/src/formats.rs b/src/formats.rs deleted file mode 100644 index 2054962..0000000 --- a/src/formats.rs +++ /dev/null @@ -1,122 +0,0 @@ -use std::collections::HashMap; - -use crate::types::{Graph, Node, Edge}; - -pub fn populate_graph() -> Graph { - let toml_source = match std::fs::read_to_string("./static/graph.toml") { - Ok(s) => s, - Err(e) => format!("Error: {e}"), - }; - let graph = deserialize_graph(&Format::Toml, &toml_source); - - let nodes = modulate_nodes(&graph.nodes); - - Graph { - nodes: nodes.clone(), - incoming: make_incoming(&nodes), - ..graph - } -} - -fn modulate_nodes(old_nodes: &HashMap) -> HashMap { - let mut nodes: HashMap = HashMap::new(); - - for (key, node) in old_nodes { - let connections = node.connections.clone().unwrap_or_default(); - let mut new_edges = connections.clone(); - - for (i, edge) in connections.iter().enumerate() { - let mut new_edge = edge.clone(); - - // Populate empty "from" IDs in edges with node's ID - if edge.from.is_empty() { - new_edge.from.clone_from(key); - } - - // Flag detached edges - if !old_nodes.contains_key(&edge.to) { - new_edge.detached = true; - } - - if let Some(e) = new_edges.get_mut(i) { - *e = new_edge; - } - } - - // Create connections for each link - for link in &node.links { - new_edges.push(Edge { - from: key.clone(), - to: link.clone(), - anchor: String::new(), - detached: !old_nodes.contains_key(link), - }); - } - - // Populate empty titles with IDs - let new_title = if node.title.is_empty() { - key.clone() - } else { - node.title.clone() - }; - - let new_node = Node { - id: key.clone(), - title: new_title, - connections: Some(new_edges), - ..node.clone() - }; - - nodes.insert(key.clone(), new_node); - } - - nodes -} - -// Construct a HashMap with incoming connections (reversed edges) -fn make_incoming(nodes: &HashMap) -> HashMap> { - let mut incoming: HashMap> = HashMap::new(); - - for node in nodes.clone().into_values() { - let empty_vec: Vec = vec![]; - for edge in &node.connections.clone().unwrap_or_default() { - let mut edges = - incoming.get(&edge.to.clone()).unwrap_or(&empty_vec).clone(); - edges.extend_from_slice(std::slice::from_ref(edge)); - incoming.insert(edge.to.clone(), edges.clone()); - } - } - - incoming -} - -pub enum Format { - Toml, - Json, -} - -pub fn serialize_graph(out_format: &Format, graph: &Graph) -> String { - match *out_format { - Format::Toml => match toml::to_string(graph) { - Ok(s) => s, - Err(e) => e.to_string(), - }, - Format::Json => match serde_json::to_string(graph) { - Ok(s) => s, - Err(e) => e.to_string(), - }, - } -} - -pub fn deserialize_graph(in_format: &Format, serial: &str) -> Graph { - match *in_format { - Format::Toml => match toml::from_str(serial) { - Ok(g) => g, - Err(error) => Graph::new(Some(error.to_string())), - }, - Format::Json => match serde_json::from_str(serial) { - Ok(g) => g, - Err(error) => Graph::new(Some(error.to_string())), - }, - } -} diff --git a/src/handlers/fixed.rs b/src/handlers/fixed.rs deleted file mode 100644 index c9b0fb0..0000000 --- a/src/handlers/fixed.rs +++ /dev/null @@ -1,61 +0,0 @@ -use axum::{ - body::Body, - http::{Response, StatusCode, header, HeaderValue}, -}; - -use crate::formats::{Format, populate_graph, serialize_graph}; -use crate::handlers; - -pub async fn file(file_path: &str, content_type: &str) -> Response { - let content = match std::fs::read(file_path) { - Ok(s) => s, - Err(e) => { - panic!("[file_handler] Failed to read file contents: {e}") - }, - }; - - let mut response = Response::new(Body::from(content)); - *response.status_mut() = StatusCode::OK; - let header = header::CONTENT_TYPE; - - if let Ok(header_value) = HeaderValue::from_str(content_type) { - if let Some(h) = response.headers_mut().insert(header, header_value) { - crate::dev::log( - &file, - &format!( - "Overwrote existing header {h:?} because a header for \ - the same key existed" - ), - ); - } - } else { - crate::dev::log( - &file, - &format!( - "Failed to create content type \ - header value from {content_type}" - ), - ); - } - - response -} - -#[expect(clippy::unused_async)] -pub async fn serial(format: &Format) -> Response { - let graph = populate_graph(); - let body = serialize_graph(format, &graph); - - match *format { - Format::Toml => handlers::raw::make_response( - &body, - 200, - &[(header::CONTENT_TYPE, "text/plain")], - ), - Format::Json => handlers::raw::make_response( - &body, - 200, - &[(header::CONTENT_TYPE, "application/json")], - ), - } -} diff --git a/src/handlers/graph.rs b/src/handlers/graph.rs deleted file mode 100644 index dfabf82..0000000 --- a/src/handlers/graph.rs +++ /dev/null @@ -1,38 +0,0 @@ -use axum::{body::Body, extract::Path, http::Response}; - -use crate::{formats::populate_graph, types::Node, handlers}; - -pub async fn node(Path(id): Path) -> Response { - let mut context = tera::Context::new(); - - let graph = populate_graph(); - let nodes = graph.nodes; - let empty_node = - Node::new(Some(format!("Could not find node with ID {id}."))); - - let node: &Node = nodes.get(&id).unwrap_or(&empty_node); - - context.insert("id", &id); - context.insert("title", &node.title); - context.insert("text", &node.text); - context.insert("connections", &node.connections.clone()); - context.insert("incoming", &graph.incoming.get(&id)); - - let not_found = node.clone() == empty_node; - let template_name = "node.html".to_string(); - - handlers::template::by_filename( - &template_name, - &context, - if not_found { 404 } else { 500 }, - Some( - format!( - "Failed to generate page for node {} (ID {}).\n\ - Node struct:
{:#?}
", - node.title, id, node - ) - .to_owned(), - ), - not_found, - ) -} diff --git a/src/handlers/navigation.rs b/src/handlers/navigation.rs deleted file mode 100644 index cdfdec7..0000000 --- a/src/handlers/navigation.rs +++ /dev/null @@ -1,30 +0,0 @@ -use axum::{ - body::Body, - http::{Response}, - response::Redirect, - Form, -}; - -use crate::{formats::populate_graph, types::Node, handlers}; - -#[expect(clippy::unused_async)] -pub async fn nexus(template: &str) -> Response { - let mut context = tera::Context::new(); - let graph = populate_graph(); - let root_node = graph.get_root().unwrap_or_default(); - let nodes: Vec = graph.nodes.into_values().collect(); - - context.insert("nodes", &nodes); - context.insert("root_node", &root_node); - - handlers::template::by_filename(template, &context, 500, None, false) -} - -pub async fn search(Form(query): Form) -> Redirect { - Redirect::permanent(format!("/node/{}", query.node).as_str()) -} - -#[derive(serde::Deserialize)] -pub struct Query { - node: String, -} diff --git a/src/handlers/raw.rs b/src/handlers/raw.rs deleted file mode 100644 index 13a8ccb..0000000 --- a/src/handlers/raw.rs +++ /dev/null @@ -1,39 +0,0 @@ -use axum::{ - body::Body, - http::{header, HeaderValue, Response, StatusCode}, -}; - -pub fn make_response( - body: &str, - status_code: u16, - headers: &[(header::HeaderName, &str)], -) -> Response { - let mut response = Response::new(Body::from(body.to_owned())); - - *response.status_mut() = StatusCode::from_u16(status_code) - .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); - - for header in headers { - if let Ok(wrapped) = HeaderValue::from_str(header.1) { - if let Some(overwritten) = - response.headers_mut().insert(header.0.clone(), wrapped) - { - crate::dev::log( - &make_response, - &format!( - "Overwrote header {overwritten:?} \ - because another for key {} already existed", - header.0 - ), - ); - } - } else { - crate::dev::log( - &make_response, - &format!("Failed to wrap header value {}", header.1), - ); - } - } - - response -} diff --git a/src/handlers/template.rs b/src/handlers/template.rs deleted file mode 100644 index b511e27..0000000 --- a/src/handlers/template.rs +++ /dev/null @@ -1,78 +0,0 @@ -use axum::{ - body::Body, - http::{header, Response, StatusCode}, -}; - -use crate::handlers::raw::make_response; - -pub(super) fn by_filename( - name: &str, - context: &tera::Context, - error_code: u16, - error_message: Option, - is_error: bool, -) -> Response { - let (body, render_status) = render(name, context, error_message); - - let status_code = if is_error { error_code } else { render_status }; - - make_response(&body, status_code, &[(header::CONTENT_TYPE, "text/html")]) -} - -#[expect(clippy::unused_async)] -pub async fn fixed(name: &str) -> Response { - by_filename(name, &tera::Context::new(), 500, None, false) -} - -pub(super) fn render( - name: &str, - // TODO take Option, skip context if None, - // then template_handler can replace static_template_handler - context: &tera::Context, - error_message: Option, -) -> (String, u16) { - // TODO just return an Option here - let tera = match tera::Tera::new(concat!( - env!("CARGO_MANIFEST_DIR"), - "/templates/**/*" - )) { - Ok(t) => t, - Err(e) => { - println!("Tera parsing error: {e:#?}"); - panic!("{e}") - }, - }; - - match tera.render(name, context) { - Ok(t) => (t, 200), - Err(e) => { - let mut error_context = tera::Context::new(); - - let out_error_message = match error_message { - Some(s) => &format!( - "Template render failed.\n\ - User message: {s}, - Engine message:\n
{e:#?}
\n\ - Context:\n
{context:#?}
" - ), - None => &format!( - "Template render failed.\n\ - Engine message:\n
{e:#?}
\n\ - Context:\n
{context:#?}
" - ), - }; - - error_context.insert("message", out_error_message); - error_context.insert( - "title", - &StatusCode::INTERNAL_SERVER_ERROR.to_string(), - ); - - ( - tera.render("error.html", &error_context) - .unwrap_or(out_error_message.clone()), - 500, - ) - }, - } -} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..630bf0c --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,13 @@ +use std::{sync, time}; + +pub mod prelude { + pub use crate::log; +} + +pub mod types; +pub mod router; +pub mod syntax; +pub mod dev; + +pub static ONSET: sync::LazyLock = + sync::LazyLock::new(time::Instant::now); diff --git a/src/main.rs b/src/main.rs index edeb0c7..6a7bfba 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,22 +1,11 @@ -use std::{backtrace, env, io, panic, sync, time}; +use std::{backtrace, io, panic}; -use axum::{routing::get, Router}; - -use formats::Format; - -mod formats; -mod types; -mod handlers; -mod dev; - -static ONSET: sync::LazyLock = - sync::LazyLock::new(time::Instant::now); +use en::{prelude::*, ONSET, syntax::serial::populate_graph, syntax}; #[tokio::main] async fn main() -> io::Result<()> { - let args: Vec = env::args().collect(); - let default_address = "0.0.0.0:0".to_string(); - let address: &String = args.get(1).unwrap_or(&default_address); + let args = syntax::command::Arguments::new().parse(); + let address = args.make_address(); panic::set_hook(Box::new(|info| { let payload = info @@ -36,64 +25,25 @@ async fn main() -> io::Result<()> { } })); - let app = Router::new() - .route( - "/", - get(|| handlers::navigation::nexus("index.html")) - .post(handlers::navigation::search), - ) - .route( - "/graph/toml", - get(|| handlers::fixed::serial(&Format::Toml)), - ) - .route( - "/graph/json", - get(|| handlers::fixed::serial(&Format::Json)), - ) - .route( - "/static/style.css", - get(|| handlers::fixed::file("./static/style.css", "text/css")), - ) - .route( - "/static/favicon.svg", - get(|| { - handlers::fixed::file("./static/favicon.svg", "image/svg+xml") - }), - ) - .route( - "/node/{node_id}", - get(handlers::graph::node).post(handlers::graph::node), - ) - .route("/tree", get(|| handlers::navigation::nexus("tree.html"))) - .route("/about", get(|| handlers::template::fixed("about.html"))) - .route( - "/acknowledgments", - get(|| handlers::template::fixed("acknowledgments.html")), - ) - .fallback(handlers::error::not_found); + let graph = populate_graph(); + let router = en::router::new(&graph); let listener = - tokio::net::TcpListener::bind(address).await.map_err(|e| { - dev::log( - &main, - &format!("Failed to create listener at {address}: {e:#?}"), - ); + tokio::net::TcpListener::bind(&address).await.map_err(|e| { + log!("Failed to create listener at {address}: {e:#?}"); e })?; - dev::log( - &main, - &format!( - "Listening on {}", - listener - .local_addr() - .map(|s| s.to_string()) - .unwrap_or("".to_string()) - ), + log!( + "Listening on {}", + listener + .local_addr() + .map(|s| s.to_string()) + .unwrap_or("".to_string()) ); - axum::serve(listener, app).await.map_err(|e| { - dev::log(&main, &format!("Failed to serve application: {e:#?}")); + axum::serve(listener, router).await.map_err(|e| { + log!("Failed to serve application: {e:#?}"); io::Error::other(e) })?; diff --git a/src/router.rs b/src/router.rs new file mode 100644 index 0000000..e7aa60c --- /dev/null +++ b/src/router.rs @@ -0,0 +1,178 @@ +use axum::{routing::get, Router}; + +use crate::{syntax::serial::Format, types::Graph}; + +mod handlers; + +pub fn new(graph: &Graph) -> Router { + let mut router = Router::new() + .route( + "/", + get(|| handlers::navigation::page("index.html")) + .post(handlers::navigation::search), + ) + .route( + "/static/style.css", + get(|| handlers::fixed::file("./static/style.css", "text/css")), + ) + .route( + "/static/favicon.svg", + get(|| { + handlers::fixed::file("./static/favicon.svg", "image/svg+xml") + }), + ) + .route( + "/node/{node_id}", + get(handlers::graph::node).post(handlers::graph::node), + ) + .fallback(handlers::error::not_found); + + if graph.meta.config.about { + router = router + .route("/about", get(|| handlers::navigation::page("about.html"))); + } + + if graph.meta.config.tree { + router = router + .route("/tree", get(|| handlers::navigation::page("tree.html"))); + } + + if graph.meta.config.raw { + if graph.meta.config.raw_json { + router = router.route( + "/graph/json", + get(|| handlers::fixed::serial(&Format::JSON)), + ); + } + if graph.meta.config.raw_toml { + router = router.route( + "/graph/toml", + get(|| handlers::fixed::serial(&Format::TOML)), + ); + } + } + + router +} + +#[cfg(test)] +mod tests { + use crate::{ + syntax::serial::populate_graph, + types::{Config, Meta}, + }; + + use super::*; + use axum::{ + body::Body, + http::{Request, StatusCode}, + response::Response, + }; + use tower::ServiceExt as _; + + async fn request(uri: &str, config: Option<&Config>) -> Response { + let default_graph = populate_graph(); + let graph = Graph { + meta: Meta { + config: config + .map(|c| c.to_owned()) + .unwrap_or(default_graph.meta.config), + ..default_graph.meta + }, + ..default_graph + }; + let router = new(&graph); + + router + .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) + .await + .unwrap() + } + + #[tokio::test] + async fn smoke() { + let router = axum::Router::new(); + let response = router + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn routes() { + let routes = [ + "/", + "/about", + "/tree", + "/node/Syntax", + "/static/style.css", + "/static/favicon.svg", + "/graph/json", + "/graph/toml", + ]; + + for route in routes { + let response = request(route, None).await; + assert_eq!(response.status(), StatusCode::OK); + } + } + + #[tokio::test] + async fn no_about_page() { + let config = Config { + about: false, + ..populate_graph().meta.config + }; + + let response = request("/about", Some(&config)).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn no_tree_page() { + let config = Config { + tree: false, + ..populate_graph().meta.config + }; + + let response = request("/tree", Some(&config)).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn no_toml_raw_graph() { + let config = Config { + raw_toml: false, + ..populate_graph().meta.config + }; + + let response = request("/graph/toml", Some(&config)).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn no_json_raw_graph() { + let config = Config { + raw_json: false, + ..populate_graph().meta.config + }; + + let response = request("/graph/json", Some(&config)).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn no_raw_graph() { + let config = Config { + raw: false, + ..populate_graph().meta.config + }; + + let toml_response = request("/graph/toml", Some(&config)).await; + assert_eq!(toml_response.status(), StatusCode::NOT_FOUND); + let json_response = request("/graph/json", Some(&config)).await; + assert_eq!(json_response.status(), StatusCode::NOT_FOUND); + } +} diff --git a/src/handlers.rs b/src/router/handlers.rs similarity index 100% rename from src/handlers.rs rename to src/router/handlers.rs diff --git a/src/handlers/error.rs b/src/router/handlers/error.rs similarity index 52% rename from src/handlers/error.rs rename to src/router/handlers/error.rs index 0aaa543..f6c61e6 100644 --- a/src/handlers/error.rs +++ b/src/router/handlers/error.rs @@ -3,9 +3,12 @@ use axum::{ http::{Response, StatusCode, header}, }; -use crate::handlers; +use crate::{syntax::serial::populate_graph, router::handlers}; -pub fn by_code(code: Option, message: Option<&str>) -> Response { +pub(in crate::router::handlers) fn by_code( + code: Option, + message: Option<&str>, +) -> Response { let out_code = code.unwrap_or(500); let out_message = &message.unwrap_or("Unknown error"); @@ -23,6 +26,7 @@ fn make_body(code: Option, message: Option<&str>) -> String { let out_code = code.unwrap_or(500); let out_message = &message.unwrap_or("Unknown error"); + let config = populate_graph().meta.config; context.insert( "title", @@ -33,6 +37,7 @@ fn make_body(code: Option, message: Option<&str>) -> String { context.insert("message", out_message); context.insert("status_code", &out_code.to_string()); + context.insert("config", &config); handlers::template::render( "error.html", @@ -51,3 +56,33 @@ pub async fn not_found() -> Response { Some("The page you tried to access could not be found."), ) } + +#[cfg(test)] +mod tests { + use axum::{ + http::{StatusCode}, + }; + use super::*; + + #[tokio::test] + async fn not_found() { + let response = super::not_found().await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn internal_error() { + assert!(by_code(Some(201), None).status() == 201); + assert!(by_code(Some(304), None).status() == 304); + assert!(by_code(Some(418), None).status() == 418); + assert!(by_code(Some(505), None).status() == 505); + } + + #[test] + fn custom_message() { + let pattern = "sibPtt0mvHPWS9HQ0YBQfGu8cUs954LZ"; + let body = make_body(Some(501), Some(pattern)); + assert!(body.contains(pattern)); + assert!(!body.contains(&pattern.chars().rev().collect::())); + } +} diff --git a/src/router/handlers/fixed.rs b/src/router/handlers/fixed.rs new file mode 100644 index 0000000..90995bd --- /dev/null +++ b/src/router/handlers/fixed.rs @@ -0,0 +1,108 @@ +use axum::{ + body::Body, + http::{Response, StatusCode, header, HeaderValue}, +}; + +use crate::prelude::*; +use crate::{ + router::handlers, + syntax::serial::{Format, populate_graph, serialize_graph}, +}; + +/// # Panics +/// Will panic if file read fails. +#[expect(clippy::unused_async)] +pub async fn file(file_path: &str, content_type: &str) -> Response { + let content = match std::fs::read(file_path) { + Ok(s) => s, + Err(e) => { + panic!("Failed to read {file_path} contents: {e}") + }, + }; + + let mut response = Response::new(Body::from(content)); + *response.status_mut() = StatusCode::OK; + let header = header::CONTENT_TYPE; + + if let Ok(header_value) = HeaderValue::from_str(content_type) { + response.headers_mut().append(header, header_value); + } else { + log!("Failed to create content type header value from {content_type}"); + } + + response +} + +#[expect(clippy::unused_async)] +pub async fn serial(format: &Format) -> Response { + let graph = populate_graph(); + let body = serialize_graph(format, &graph); + + match *format { + Format::TOML => handlers::raw::make_response( + &body, + 200, + &[(header::CONTENT_TYPE, "text/plain")], + ), + Format::JSON => handlers::raw::make_response( + &body, + 200, + &[(header::CONTENT_TYPE, "application/json")], + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn serial_toml() { + let response = serial(&Format::TOML).await; + assert!(response.status() == 200); + } + + #[tokio::test] + async fn serial_toml_content_type() { + let response = serial(&Format::TOML).await; + assert!( + response.headers().get(header::CONTENT_TYPE).unwrap() + == "text/plain" + ); + } + + #[tokio::test] + async fn serial_json_content_type() { + let response = serial(&Format::JSON).await; + assert!( + response.headers().get(header::CONTENT_TYPE).unwrap() + == "application/json" + ); + } + + #[tokio::test] + async fn file_valid_header() { + let payload = "y1mgMhjeIMFsRNZ1tskP52DfWuvhvbRP"; + let response = file("./static/graph.toml", payload).await; + assert_eq!( + response.headers().get(header::CONTENT_TYPE).unwrap(), + payload + ); + } + + #[tokio::test] + async fn file_invalid_header() { + let response = file("./static/graph.toml", "\n").await; + println!("{response:#?}"); + assert!(response.headers().get(header::CONTENT_TYPE).is_none()); + } + + #[tokio::test] + #[should_panic( + expected = "Failed to read IvnhZhdHb1xDnUw4hYDDNIERoaOojkiu \ + contents: No such file or directory (os error 2)" + )] + async fn file_invalid_path() { + drop(file("IvnhZhdHb1xDnUw4hYDDNIERoaOojkiu", "text/plain").await); + } +} diff --git a/src/router/handlers/graph.rs b/src/router/handlers/graph.rs new file mode 100644 index 0000000..7ce2858 --- /dev/null +++ b/src/router/handlers/graph.rs @@ -0,0 +1,83 @@ +use axum::response::IntoResponse as _; +use axum::{body::Body, extract::Path, http::Response, response::Redirect}; + +use crate::syntax::content; + +use crate::{syntax::serial::populate_graph, router::handlers, types::Node}; + +pub async fn node(Path(id): Path) -> Response { + let graph = populate_graph(); + let empty_node = Node::new(Some(format!("Could not find node ID {id}."))); + let node = graph.find_node(&id).unwrap_or(empty_node.clone()); + + if !graph.nodes.contains_key(&id) + && graph.lowercase_keymap.contains_key(&id) + { + return Redirect::permanent(format!("/node/{}", node.id).as_str()) + .into_response(); + } + + let mut context = tera::Context::new(); + context.insert("node", &node); + context.insert("text", &content::parse(&node.text)); + context.insert("incoming", &graph.incoming.get(&id)); + context.insert("config", &graph.meta.config.parse_text()); + + let not_found = node == empty_node; + + handlers::template::by_filename( + "node.html", + &context, + if not_found { 404 } else { 500 }, + Some( + format!( + "Failed to generate page for node {} (ID {}).\n\ + Node struct:
{:#?}
", + node.title, id, node + ) + .to_owned(), + ), + not_found, + ) +} + +#[cfg(test)] +mod tests { + use axum::{ + http::{HeaderName, StatusCode}, + }; + + use super::*; + + #[tokio::test] + async fn syntax() { + let response = node(Path("Syntax".to_string())).await; + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn syntax_content_type() { + let response = node(Path("Syntax".to_string())).await; + assert!( + response + .headers() + .get(HeaderName::from_static("content-type"),) + .unwrap() + .to_str() + .unwrap() + == "text/html" + ); + } + + #[tokio::test] + async fn not_found() { + let response = node(Path("InexistentNode".to_string())).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn redirect() { + let response = node(Path("syntax".to_string())).await; + assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT); + } +} diff --git a/src/router/handlers/navigation.rs b/src/router/handlers/navigation.rs new file mode 100644 index 0000000..f3a2602 --- /dev/null +++ b/src/router/handlers/navigation.rs @@ -0,0 +1,66 @@ +use axum::{ + body::Body, + http::{Response}, + response::Redirect, + Form, +}; + +use crate::{syntax::serial::populate_graph, router::handlers, types::Node}; + +#[expect(clippy::unused_async)] +pub async fn page(template: &str) -> Response { + let mut context = tera::Context::new(); + let graph = populate_graph(); + let root_node = graph.get_root().unwrap_or_default(); + let nodes: Vec = graph.nodes.into_values().collect(); + + context.insert("nodes", &nodes); + context.insert("root_node", &root_node); + context.insert("config", &graph.meta.config.parse_text()); + + handlers::template::by_filename(template, &context, 500, None, false) +} + +pub async fn search(Form(query): Form) -> Redirect { + Redirect::permanent(format!("/node/{}", query.node).as_str()) +} + +#[derive(serde::Deserialize)] +pub struct Query { + node: String, +} + +#[cfg(test)] +mod tests { + use axum::{ + http::{StatusCode}, + }; + use super::*; + + #[tokio::test] + async fn search_redirect() { + let query = Form(Query { + node: String::from("duZzBrgCzMhVY15wehxasezsGNatOKIq"), + }); + let response = search(query).await; + assert!(response.status_code() == StatusCode::PERMANENT_REDIRECT); + } + + #[tokio::test] + async fn about_page_ok() { + let response = page("about.html").await; + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn tree_page_ok() { + let response = page("tree.html").await; + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn inexistent_page_error() { + let response = page("HBvcwqT8wLk6hxk1GdvNcEzJ6IiZ2Fod").await; + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + } +} diff --git a/src/router/handlers/raw.rs b/src/router/handlers/raw.rs new file mode 100644 index 0000000..9ba49ad --- /dev/null +++ b/src/router/handlers/raw.rs @@ -0,0 +1,65 @@ +use axum::{ + body::Body, + http::{header, HeaderValue, Response, StatusCode}, +}; + +use crate::prelude::*; + +pub(in crate::router::handlers) fn make_response( + body: &str, + status_code: u16, + headers: &[(header::HeaderName, &str)], +) -> Response { + let mut response = Response::new(Body::from(body.to_owned())); + + *response.status_mut() = StatusCode::from_u16(status_code) + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + + for header in headers { + if let Ok(wrapped) = HeaderValue::from_str(header.1) { + if let Some(overwritten) = + response.headers_mut().insert(header.0.clone(), wrapped) + { + log!( + "Overwrote header {overwritten:?} \ + because another for key {} already existed", + header.0 + ); + } + } else { + log!("Failed to create header value from {}", header.1); + } + } + + response +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn repeated_header() { + let headers = [ + (header::ACCEPT, "Not really"), + (header::ACCEPT, "This again?"), + ]; + let response = make_response("", 418, &headers); + assert!(response.headers().get_all(header::ACCEPT).iter().count() == 1); + assert_eq!( + response + .headers() + .get(header::ACCEPT) + .unwrap() + .to_str() + .unwrap(), + "This again?", + ); + } + + #[test] + fn invalid_header() { + let response = make_response("", 418, &[(header::MAX_FORWARDS, "\n")]); + assert!(response.headers().get(header::MAX_FORWARDS).is_none()); + } +} diff --git a/src/router/handlers/template.rs b/src/router/handlers/template.rs new file mode 100644 index 0000000..3b221ef --- /dev/null +++ b/src/router/handlers/template.rs @@ -0,0 +1,203 @@ +use axum::{ + body::Body, + http::{header, Response, StatusCode}, +}; + +use crate::{prelude::*, router::handlers::raw::make_response}; + +pub(in crate::router::handlers) fn by_filename( + name: &str, + context: &tera::Context, + error_code: u16, + error_message: Option, + is_error: bool, +) -> Response { + let (body, render_status) = render(name, context, error_message); + + let status_code = if is_error { error_code } else { render_status }; + + make_response(&body, status_code, &[(header::CONTENT_TYPE, "text/html")]) +} + +pub(in crate::router::handlers) fn render( + name: &str, + // TODO take Option, skip context if None, + // then template_handler can replace static_template_handler + context: &tera::Context, + error_message: Option, +) -> (String, u16) { + // TODO just return an Option/String> here + let tera = match tera::Tera::new("./templates/**/*") { + Ok(t) => t, + Err(e) => { + return (emergency_wrap(&e), 500); + }, + }; + + match tera.render(name, context) { + Ok(t) => (t, 200), + Err(e) => { + let mut error_context = tera::Context::new(); + + let out_error_message = match error_message { + Some(s) => &format!( + "Template render failed.\n\ + User message: {s}, + Engine message:\n
{e:#?}
\n\ + Context:\n
{context:#?}
" + ), + None => &format!( + "Template render failed.\n\ + Engine message:\n
{e:#?}
\n\ + Context:\n
{context:#?}
" + ), + }; + + error_context.insert("message", out_error_message); + error_context.insert( + "title", + &StatusCode::INTERNAL_SERVER_ERROR.to_string(), + ); + + ( + tera.render("error.html", &error_context) + .unwrap_or(out_error_message.clone()), + 500, + ) + }, + } +} + +fn emergency_wrap(error: &tera::Error) -> String { + log!("{error:#?}"); + format!( + r#" + + + Pre-Templating Error + + + + + +

Early Pre-Templating Error

+

This normally indicates a malformed template.

+
+            {error:#?}
+            
+

+ If you haven't modified templates, plese consider + reporting it. +

+ + + "# + ) +} + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn by_filename_forced_error() { + let response = + by_filename("index.html", &tera::Context::new(), 418, None, true); + assert_eq!(response.status(), 418); + } + + #[test] + fn by_filename_index() { + let response = + by_filename("index.html", &tera::Context::new(), 418, None, false); + assert_eq!(response.status(), 200); + } + + #[test] + fn by_filename_file_not_found() { + let response = by_filename( + "bwbl3BnWsluIgbO2NV9t3vtihwcjuF6t", + &tera::Context::new(), + 418, + None, + false, + ); + assert_eq!(response.status(), 500); + } + + #[test] + fn by_filename_empty() { + let response = by_filename("", &tera::Context::new(), 418, None, false); + assert_eq!(response.status(), 500); + } + + #[test] + fn render_with_context() { + let payload = "dBgIw8DnNHxJojiXzu445qUC4UpxwZCy"; + let mut context = tera::Context::new(); + let node = crate::types::Node::new(Some(payload.to_string())); + let graph = crate::syntax::serial::populate_graph(); + context.insert("node", &node); + context.insert("text", &crate::syntax::content::parse(&node.text)); + context.insert("incoming", &graph.incoming.get(&node.id)); + context.insert("config", &graph.meta.config.parse_text()); + let (body, status) = render("node.html", &context, None); + assert_eq!(status, 200); + assert!(body.matches(payload).count() == 1); + } + + #[test] + fn render_custom_error_message() { + let payload = "dBgIw8DnNHxJojiXzu445qUC4UpxwZCy"; + let (body, status) = render( + "ObH9jYUl4wMhUNcXnuqwVVzHoqx4ufyN", + &tera::Context::new(), + Some(payload.to_string()), + ); + assert_eq!(status, 500); + assert!(body.matches(payload).count() == 1); + } + + #[test] + fn render_empty() { + let (body, status) = render( + "R8D1pxwHZDxcH5SMjR7rZEnIzmpkiHkH", + &tera::Context::new(), + None, + ); + assert_eq!(status, 500); + assert!(body.matches("Template render failed").count() == 1); + } + + #[test] + fn render_not_found() { + let payload = "OL6kb9qHe7Iwr7wFIRKUTeFhF34BRsQo"; + let (body, status) = render(payload, &tera::Context::new(), None); + + assert!(body.matches("TemplateNotFound").count() > 0); + assert!(body.matches(payload).count() > 0); + assert_eq!(status, 500); + } + + #[test] + fn render_bad_context() { + let (body, status) = render("node.html", &tera::Context::new(), None); + assert!(body.matches("Template render failed.").count() > 0); + assert_eq!(status, 500); + } + + #[test] + fn emergency_wrap_custom_message() { + let payload = "JLaTtsnd2IFukIOvqFNymeuiaS6nMaUc"; + let error = tera::Error::msg(payload); + let html = emergency_wrap(&error); + assert!(html.matches(payload).count() == 1); + } +} diff --git a/src/syntax.rs b/src/syntax.rs new file mode 100644 index 0000000..c88a706 --- /dev/null +++ b/src/syntax.rs @@ -0,0 +1,3 @@ +pub mod command; +pub mod content; +pub mod serial; diff --git a/src/syntax/command.rs b/src/syntax/command.rs new file mode 100644 index 0000000..2ca7b01 --- /dev/null +++ b/src/syntax/command.rs @@ -0,0 +1,114 @@ +use std::path::PathBuf; + +use crate::prelude::*; + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Arguments { + pub hostname: String, + pub port: u16, + pub graph_path: PathBuf, +} + +impl Arguments { + pub fn make_address(&self) -> String { + format!("{}:{}", self.hostname, self.port) + } + + pub fn new() -> Arguments { + Arguments { + hostname: String::from("0.0.0.0"), + port: 0, + graph_path: PathBuf::from("./static/graph.toml"), + } + } + + #[must_use] + pub fn parse(&self) -> Arguments { + let args: Vec = std::env::args().collect(); + parse(self, &args) + } +} + +fn parse(defaults: &Arguments, args: &[String]) -> Arguments { + let mut out_args = defaults.clone(); + + let filtered_args = if let Some((head, tail)) = args.split_first() { + if head.starts_with('-') { args } else { tail } + } else { + args + }; + + for arg in filtered_args.chunks(2) { + if let Some(argument) = arg.first() + && let Some(parameter) = arg.get(1) + { + if argument.eq("-h") || argument.eq("--hostname") { + out_args.hostname = String::from(parameter); + } else if argument.eq("-p") || argument.eq("--port") { + out_args.port = parameter.parse().unwrap_or(out_args.port); + } else if argument.eq("-g") || argument.eq("--graph") { + out_args.graph_path = PathBuf::from(parameter); + } else { + log!("Dropped unrecognized argument {argument}"); + } + } else { + panic!("Argument {arg:?} has no corresponding value") + } + } + out_args +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn address() { + let args = Arguments { + hostname: String::from("localhost"), + port: 3007, + graph_path: PathBuf::new(), + }; + + assert_eq!(args.make_address(), "localhost:3007"); + } + + #[test] + fn hostname() { + let defaults = Arguments::new(); + + let payload = String::from("olUCu7vWcUAsumv2xpj2Z55EDheWLTEu"); + let args = + parse(&defaults, &[String::from("-h"), String::from(&payload)]); + assert_eq!(args.hostname, payload); + } + + #[test] + fn port() { + let defaults = Arguments::new(); + + let payload = 3901; + let args = parse(&defaults, &[String::from("-p"), payload.to_string()]); + assert_eq!(args.port, payload); + } + + #[test] + fn graph_path() { + let defaults = Arguments::new(); + + let payload = PathBuf::from("/tmp/"); + let args = parse( + &defaults, + &[String::from("-g"), payload.to_str().unwrap().to_string()], + ); + assert_eq!(args.graph_path, payload); + } + + #[test] + fn empty() { + let defaults = Arguments::new(); + + let args = parse(&defaults, &[]); + assert_eq!(defaults, args); + } +} diff --git a/src/syntax/content.rs b/src/syntax/content.rs new file mode 100644 index 0000000..d885392 --- /dev/null +++ b/src/syntax/content.rs @@ -0,0 +1,17 @@ +use parser::{token::Token, lexeme::Lexeme}; + +pub mod parser; + +pub trait Parseable: Into { + fn probe(lexeme: &Lexeme) -> bool; + fn lex(lexeme: &Lexeme) -> Self; + fn render(&self) -> String; +} + +type Probe = fn(&Lexeme) -> bool; +type Lexer = fn(&Lexeme) -> Token; +type LexMap<'lm> = &'lm [(Probe, Lexer)]; + +pub fn parse(text: &str) -> String { + parser::read(text) +} diff --git a/src/syntax/content/parser.rs b/src/syntax/content/parser.rs new file mode 100644 index 0000000..9e67f39 --- /dev/null +++ b/src/syntax/content/parser.rs @@ -0,0 +1,242 @@ +use std::collections::{HashMap}; + +use crate::{syntax::serial::populate_graph, types::Config}; +use super::{Parseable as _, Token, LexMap}; +use token::{ + anchor::Anchor, linebreak::LineBreak, paragraph::Paragraph, header::Header, + preformat::PreFormat, literal::Literal, code::Code, +}; +use lexeme::Lexeme; + +pub mod token; +pub mod lexeme; +pub mod segment; + +const LEXMAP: LexMap = &[ + (LineBreak::probe, |word| { + Token::LineBreak(LineBreak::lex(word)) + }), + (Literal::probe, |word| Token::Literal(Literal::lex(word))), +]; + +fn lex(text: &str, map: LexMap) -> Vec { + let mut tokens: Vec = Vec::new(); + let mut state = State::new(); + let config: Config = populate_graph().meta.config; + + let segments = segment::segment(text); + let lexemes = Lexeme::collect(&segments); + + let mut iterator = lexemes.iter().peekable(); + while let Some(lexeme) = iterator.next() { + match state.context.block { + BlockContext::None => { + if PreFormat::probe(lexeme) { + state.context.block = BlockContext::PreFormat; + tokens.push(Token::PreFormat(PreFormat::new(true))); + continue; + } else if Header::probe(lexeme) { + let mut header = Header::lex(lexeme); + header.dom_id = Some(Header::make_id( + &config, + &mut iterator, + &mut state.dom_ids, + )); + state.context.block = BlockContext::Header(header.level()); + tokens.push(Token::Header(header)); + continue; + } else if Paragraph::probe(lexeme) { + state.context.block = BlockContext::Paragraph; + tokens.push(Token::Paragraph(Paragraph::new(true))); + } + }, + BlockContext::PreFormat => { + if PreFormat::probe(lexeme) { + tokens.push(Token::PreFormat(PreFormat::new(false))); + state.context.block = BlockContext::None; + } else { + tokens.push(Token::Literal(Literal::lex(lexeme))); + } + continue; + }, + BlockContext::Paragraph => { + if lexeme.text() == "\n" { + tokens.push(Token::Paragraph(Paragraph::new(false))); + state.context.block = BlockContext::None; + } + }, + BlockContext::Header(n) => { + if lexeme.text() == "\n" { + tokens.push(Token::Header(Header::from_u8(n, false, None))); + state.context.block = BlockContext::None; + } + }, + } + + match state.context.inline { + InlineContext::None => { + if Code::probe(lexeme) { + state.context.inline = InlineContext::Code; + tokens.push(Token::Code(Code::new(true))); + continue; + } else if Anchor::probe(lexeme) { + state.context.inline = InlineContext::Anchor; + state.buffers.anchor.clear(); + + if lexeme.match_first_char('|') { + state.buffers.anchor.candidate.leading = true; + } else { + state.buffers.anchor.candidate.text = lexeme.text(); + } + continue; + } + }, + InlineContext::Code => { + if Code::probe(lexeme) { + state.context.inline = InlineContext::None; + tokens.push(Token::Code(Code::new(false))); + continue; + } + }, + InlineContext::Anchor => { + let buffer = &mut state.buffers.anchor; + let candidate = &mut buffer.candidate; + if candidate.text.is_empty() { + if lexeme.next == "|" { + buffer.text.push_str(&lexeme.text()); + candidate.text.clone_from(&buffer.text); + } else { + buffer.text.push_str(&lexeme.text()); + } + continue; + } else if candidate.destination.is_none() { + // candidate is leading and we found the second pipe + if candidate.leading && lexeme.text() == "|" { + // third pipe immediately after second: forcing flanking + if lexeme.match_next_first_char('|') { + continue; + // whitespace or punctuation after pipe: flanking anchor + } else if lexeme.is_next_whitespace() + || lexeme.is_next_punctuation() + { + candidate.destination = + Some(candidate.text.clone()); + let token = Token::Anchor(candidate.clone()); + tokens.push(token); + state.context.inline = InlineContext::None; + // non-whitespace after pipe is the destination + } else { + candidate.destination = Some(lexeme.next.clone()); + let token = Token::Anchor(candidate.clone()); + tokens.push(token); + state.context.inline = InlineContext::None; + // if there is a trailing pipe, consume it + if let Some(next) = iterator.next() + && next.next == "|" + { + iterator.next(); + } + } + // candidate is nonleading and we found a second pipe + } else if !candidate.leading && lexeme.next == "|" { + candidate.destination = Some(lexeme.text()); + tokens.push(Token::Anchor(candidate.clone())); + state.context.inline = InlineContext::None; + iterator.next(); + // candidate is nonleading and we found whitespace + } else if lexeme.is_next_whitespace() { + candidate.destination = Some(lexeme.text()); + let token = Token::Anchor(candidate.clone()); + tokens.push(token); + state.context.inline = InlineContext::None; + // candidate is nonleading and we haven't found whitespace + } else { + buffer.destination.push_str(&lexeme.text()); + } + continue; + } else { + unreachable!("Anchor is already fully parsed"); + } + }, + } + + for &(ref probe, lex) in map { + if probe(lexeme) { + tokens.push(lex(lexeme)); + break; + } + } + } + + tokens +} + +enum BlockContext { + Paragraph, + Header(u8), + PreFormat, + None, +} + +enum InlineContext { + Anchor, + Code, + None, +} + +struct State { + context: Context, + dom_ids: HashMap>, + buffers: Buffers, +} + +struct Context { + block: BlockContext, + inline: InlineContext, +} + +struct Buffers { + anchor: AnchorBuffer, +} + +#[derive(Debug)] +struct AnchorBuffer { + candidate: Anchor, + text: String, + destination: String, +} + +impl AnchorBuffer { + fn clear(&mut self) { + self.candidate = Anchor::empty(); + self.text = String::new(); + self.destination = String::new(); + } +} + +impl State { + fn new() -> State { + State { + context: Context { + inline: InlineContext::None, + block: BlockContext::None, + }, + dom_ids: HashMap::new(), + buffers: Buffers { + anchor: AnchorBuffer { + candidate: Anchor::empty(), + text: String::new(), + destination: String::new(), + }, + }, + } + } +} + +fn parse(tokens: &[Token]) -> String { + tokens.iter().map(Token::render).collect::() +} + +pub(super) fn read(text: &str) -> String { + parse(&lex(text, LEXMAP)) +} diff --git a/src/syntax/content/parser/lexeme.rs b/src/syntax/content/parser/lexeme.rs new file mode 100644 index 0000000..14e94e5 --- /dev/null +++ b/src/syntax/content/parser/lexeme.rs @@ -0,0 +1,92 @@ +#[derive(Clone, Debug)] +pub struct Lexeme { + text: String, + pub next: String, +} + +impl Lexeme { + pub fn new(raw: &str, next: &str) -> Lexeme { + Lexeme { + text: raw.to_owned(), + next: next.to_owned(), + } + } + + pub fn text(&self) -> String { + self.text.clone() + } + + pub fn is_whitespace(&self) -> bool { + self.text == " " || self.text == "\n" + } + + pub fn is_next_whitespace(&self) -> bool { + self.next == " " || self.next == "\n" + } + + pub fn is_next_punctuation(&self) -> bool { + let punctuation = [",", ".", ":", ";", "?", "!", "(", ")", "\"", "'"]; + punctuation.contains(&self.next.as_str()) + } + + pub fn next_first_char(&self) -> Option { + self.next.chars().nth(0) + } + + pub fn match_first_char(&self, query: char) -> bool { + if let Some(first) = self.text.chars().nth(0) { + first == query + } else { + false + } + } + + pub fn match_next_first_char(&self, query: char) -> bool { + if let Some(first) = self.next.chars().nth(0) { + first == query + } else { + false + } + } + + /// # Panics + /// Panics if number of chars for a single lexeme exceeds `i2::MAX` + pub fn count_char(&self, c: char) -> i32 { + let count = self.text().chars().filter(|&n| n == c).count(); + match i32::try_from(count) { + Ok(i) => i, + Err(e) => { + panic!("Wild char number {count} is a bit much: {e:#?}"); + }, + } + } + + pub fn split_chars(&self) -> Vec { + let vector: Vec = self.text().chars().collect(); + vector + } + + pub fn split_words(self) -> Vec { + self.text().split(' ').map(str::to_string).collect() + } + + pub fn first(self) -> Option { + self.split_words().first().map(String::to_owned) + } + + pub fn collect(raw_strings: &[String]) -> Vec { + let mut out_vector = Vec::with_capacity(raw_strings.len()); + let mut iterator = raw_strings.iter().peekable(); + + while let Some(raw) = iterator.next() { + let next = + iterator.peek().map(|s| (*s).clone()).unwrap_or_default(); + out_vector.push(Lexeme { + text: raw.to_owned(), + next, + }); + } + + out_vector + } +} diff --git a/src/syntax/content/parser/segment.rs b/src/syntax/content/parser/segment.rs new file mode 100644 index 0000000..33b2f04 --- /dev/null +++ b/src/syntax/content/parser/segment.rs @@ -0,0 +1,199 @@ +pub fn segment(text: &str) -> Vec { + delimiter::atomize(text) +} + +mod delimiter { + + fn make_delimiters() -> Vec { + vec!['\n', ' ', '`', '|'] + } + + pub fn atomize(text: &str) -> Vec { + let delimiters = make_delimiters(); + text.chars().fold( + Vec::new(), + |mut accumulator: Vec, character| { + if delimiters.contains(&character) { + accumulator.push(character.to_string()); + } else if let Some(last) = accumulator.last_mut() { + if delimiters + .iter() + .map(char::to_string) + .filter(|d| d == last) + .count() + > 0 + { + accumulator.push(character.to_string()); + } else { + last.push(character); + } + } else { + accumulator.push(character.to_string()); + } + accumulator + }, + ) + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn atomize_words() { + let words = " justification for the actions of those who hold authority inevitably dwindles "; // 2 + let actual = atomize(words); + let expected = vec![ + " ", + " ", + " ", + " ", + "justification", + " ", + "for", + " ", + " ", + "the", + " ", + "actions", + " ", + " ", + " ", + "of", + " ", + "those", + " ", + " ", + "who", + " ", + "hold", + " ", + "authority", + " ", + " ", + " ", + "inevitably", + " ", + "dwindles", + " ", + " ", + ]; + assert_eq!(actual, expected); + } + + #[test] + fn atomize_ticks_no_spaces() { + let s = "a`c`adc`dadcdbd`cdb`dcdb`dc`dad`bdc"; + let actual = atomize(s); + let expected = vec![ + "a", "`", "c", "`", "adc", "`", "dadcdbd", "`", "cdb", "`", + "dcdb", "`", "dc", "`", "dad", "`", "bdc", + ] + .iter() + .map(std::string::ToString::to_string) + .collect::>(); + + assert_eq!(actual, expected); + } + + #[test] + fn atomize_ticks_with_spaces() { + let s = "a`c`adc`da dcdb d` cdb` dcdb `dc ` d ad ` bdc"; + + let actual = atomize(s); + let expected = vec![ + "a", "`", "c", "`", "adc", "`", "da", " ", "dcdb", " ", "d", + "`", " ", "cdb", "`", " ", "dcdb", " ", "`", "dc", " ", "`", + " ", "d", " ", "ad", " ", "`", " ", "bdc", + ] + .iter() + .map(std::string::ToString::to_string) + .collect::>(); + assert_eq!(actual, expected); + } + + #[test] + fn atomize_pipes() { + let s = "every other |time| as it was perceived"; + let actual = atomize(s); + let expected = vec![ + "every", + " ", + "other", + " ", + "|", + "time", + "|", + " ", + "as", + " ", + "it", + " ", + "was", + " ", + "perceived", + ]; + assert_eq!(actual, expected); + } + + #[test] + fn atomize_pipes_and_ticks() { + let s = "every other |time| as `it could or |perhaps somehow|then or now| it was` perceived"; + let actual = atomize(s); + let expected = vec![ + "every", + " ", + "other", + " ", + "|", + "time", + "|", + " ", + "as", + " ", + "`", + "it", + " ", + "could", + " ", + "or", + " ", + "|", + "perhaps", + " ", + "somehow", + "|", + "then", + " ", + "or", + " ", + "now", + "|", + " ", + "it", + " ", + "was", + "`", + " ", + "perceived", + ]; + assert_eq!(actual, expected); + } + + #[test] + fn atomize_newlines() { + let s = "a`c`adc`da \ndcdb d` cdb` dc\ndb `dc ` d ad ` bdc"; + + let actual = atomize(s); + let expected = vec![ + "a", "`", "c", "`", "adc", "`", "da", " ", "\n", "dcdb", " ", + "d", "`", " ", "cdb", "`", " ", "dc", "\n", "db", " ", "`", + "dc", " ", "`", " ", "d", " ", "ad", " ", "`", " ", "bdc", + ] + .iter() + .map(std::string::ToString::to_string) + .collect::>(); + assert_eq!(actual, expected); + } + } +} diff --git a/src/syntax/content/parser/token.rs b/src/syntax/content/parser/token.rs new file mode 100644 index 0000000..85ed1bf --- /dev/null +++ b/src/syntax/content/parser/token.rs @@ -0,0 +1,85 @@ +use crate::syntax::content::Parseable as _; + +pub mod literal; +pub mod anchor; +pub mod linebreak; +pub mod paragraph; +pub mod span; +pub mod header; +pub mod preformat; +pub mod code; + +#[derive(Debug)] +pub enum Token { + Anchor(anchor::Anchor), + Code(code::Code), + Header(header::Header), + LineBreak(linebreak::LineBreak), + Literal(literal::Literal), + Paragraph(paragraph::Paragraph), + PreFormat(preformat::PreFormat), + Span(span::Span), +} + +impl Token { + pub fn render(&self) -> String { + match *self { + Token::Anchor(ref d) => d.render(), + Token::Code(ref d) => d.render(), + Token::Header(ref d) => d.render(), + Token::LineBreak(ref d) => d.render(), + Token::Literal(ref d) => d.render(), + Token::Paragraph(ref d) => d.render(), + Token::PreFormat(ref d) => d.render(), + Token::Span(ref d) => d.render(), + } + } +} + +impl From for Token { + fn from(d: paragraph::Paragraph) -> Token { + Token::Paragraph(d) + } +} + +impl From for Token { + fn from(d: header::Header) -> Token { + Token::Header(d) + } +} + +impl From for Token { + fn from(d: span::Span) -> Token { + Token::Span(d) + } +} + +impl From for Token { + fn from(d: literal::Literal) -> Token { + Token::Literal(d) + } +} + +impl From for Token { + fn from(d: anchor::Anchor) -> Token { + Token::Anchor(d) + } +} + +impl From for Token { + fn from(d: linebreak::LineBreak) -> Token { + Token::LineBreak(d) + } +} + +impl From for Token { + fn from(d: preformat::PreFormat) -> Token { + Token::PreFormat(d) + } +} + +impl From for Token { + fn from(d: code::Code) -> Token { + Token::Code(d) + } +} diff --git a/src/syntax/content/parser/token/anchor.rs b/src/syntax/content/parser/token/anchor.rs new file mode 100644 index 0000000..ac9a488 --- /dev/null +++ b/src/syntax/content/parser/token/anchor.rs @@ -0,0 +1,66 @@ +use std::fmt::Display; + +use crate::syntax::content::{Parseable, parser::lexeme::Lexeme}; + +#[derive(Debug, Clone)] +pub struct Anchor { + pub text: String, + pub destination: Option, + pub leading: bool, +} + +impl Parseable for Anchor { + fn probe(lexeme: &Lexeme) -> bool { + lexeme.text() == "|" || (!lexeme.is_whitespace() && lexeme.next == "|") + } + + fn lex(_lexeme: &Lexeme) -> Anchor { + panic!("Attempt to lex an anchor directly from a lexeme"); + } + + fn render(&self) -> String { + let Some(ref destination) = self.destination else { + panic!( + "Attempt to render anchor {self:?} without knowing its destination." + ) + }; + + format!( + r#"{}"#, + Anchor::resolve_destination(destination), + &self.text + ) + } +} + +impl Anchor { + pub fn new(text: &str, destination: &str, spaced: bool) -> Anchor { + Anchor { + text: text.to_owned(), + destination: Some(Anchor::resolve_destination(destination)), + leading: spaced, + } + } + + fn resolve_destination(raw: &str) -> String { + if raw.contains(":") || raw.contains("/") { + raw.to_owned() + } else { + format!("/node/{raw}") + } + } + + pub fn empty() -> Anchor { + Anchor { + text: String::new(), + destination: None, + leading: false, + } + } +} + +impl Display for Anchor { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "Anchor: <{}> to <{:?}>", &self.text, &self.destination) + } +} diff --git a/src/syntax/content/parser/token/code.rs b/src/syntax/content/parser/token/code.rs new file mode 100644 index 0000000..549f60e --- /dev/null +++ b/src/syntax/content/parser/token/code.rs @@ -0,0 +1,32 @@ +use crate::{ + syntax::content::{Parseable, Lexeme}, +}; + +#[derive(Debug)] +pub struct Code { + open: bool, +} + +impl Code { + pub fn new(open: bool) -> Code { + Code { open } + } +} + +impl Parseable for Code { + fn probe(lexeme: &Lexeme) -> bool { + lexeme.text() == "`" + } + + fn lex(_lexeme: &Lexeme) -> Code { + panic!("Attempt to lex a code tag directly from a lexeme") + } + + fn render(&self) -> String { + if self.open { + String::from("") + } else { + String::from("") + } + } +} diff --git a/src/syntax/content/parser/token/header.rs b/src/syntax/content/parser/token/header.rs new file mode 100644 index 0000000..cb3620f --- /dev/null +++ b/src/syntax/content/parser/token/header.rs @@ -0,0 +1,185 @@ +use std::{ + collections::{HashMap, hash_map::Entry}, + iter::Peekable, + slice, +}; + +use crate::{ + prelude::*, + types::Config, + syntax::content::{Parseable, Lexeme}, +}; + +use std::fmt::Display; + +#[derive(Debug)] +pub struct Header { + open: Option, + level: Level, + pub dom_id: Option, +} + +impl Header { + pub fn new(level: Level, open: bool, dom_id: Option<&str>) -> Header { + Header { + open: Some(open), + level, + dom_id: dom_id.map(std::borrow::ToOwned::to_owned), + } + } + + pub fn make_id( + config: &Config, + iterator: &mut Peekable>, + ids: &mut HashMap>, + ) -> String { + let base_id = match iterator.peek() { + Some(next_lexeme) + if !config.ascii_dom_ids || next_lexeme.next.is_ascii() => + { + next_lexeme.next.clone() + }, + _ => String::from("h"), + }; + + match ids.entry(base_id.clone()) { + Entry::Occupied(mut occupied) => { + let ids_vec = occupied.get_mut(); + let suffix = ids_vec.len(); + let id_with_suffix = format!("{base_id}-{suffix}"); + ids_vec.push(id_with_suffix.clone()); + id_with_suffix + }, + Entry::Vacant(vacant) => { + vacant.insert(vec![base_id.clone()]); + base_id + }, + } + } + + pub fn from_u8(level: u8, open: bool, dom_id: Option<&str>) -> Header { + Header { + level: Level::from_u8(level), + open: Some(open), + dom_id: dom_id.map(std::borrow::ToOwned::to_owned), + } + } + + pub fn level(&self) -> u8 { + match self.level { + Level::One => 1, + Level::Two => 2, + Level::Three => 3, + Level::Four => 4, + Level::Five => 5, + Level::Six => 6, + } + } +} + +impl Parseable for Header { + fn probe(lexeme: &Lexeme) -> bool { + if lexeme + .split_chars() + .into_iter() + .filter(|e| *e != '#') + .count() + == 0 + { + let level = lexeme.text().len(); + lexeme.clone().split_words().len() == 1 && level > 0 && level <= 6 + } else { + false + } + } + + fn lex(lexeme: &Lexeme) -> Header { + Header::new( + lexeme.text().len().into(), + true, + Some(&lexeme.next.to_ascii_lowercase()), + ) + } + + fn render(&self) -> String { + if let Some(open) = self.open { + if open && let Some(ref id) = self.dom_id { + format!(r#""#, self.level, id) + } else if open { + format!("", self.level) + } else { + format!("", self.level) + } + } else { + panic!("Attempt to render a header tag while open state is unknown") + } + } +} + +impl Display for Header { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + if let Some(open) = self.open { + if open { + write!(f, "Level {} Open Header", self.level) + } else { + write!(f, "Level {} Closed Header", self.level) + } + } else { + write!(f, "Level {} Header (Unknown open state)", self.level) + } + } +} + +#[derive(Debug)] +pub enum Level { + One, + Two, + Three, + Four, + Five, + Six, +} + +impl Level { + fn from_u8(u: u8) -> Level { + if u <= 1 { + Level::One + } else if u == 2 { + Level::Two + } else if u == 3 { + Level::Three + } else if u == 4 { + Level::Four + } else if u == 5 { + Level::Five + } else { + Level::Six + } + } +} + +impl From for Level { + fn from(z: usize) -> Level { + let u8 = match u8::try_from(z) { + Ok(u) => u, + Err(e) => { + log!("Truncating header level {z} to 6: {e:?}"); + 6 + }, + }; + Level::from_u8(u8) + } +} + +impl Display for Level { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match *self { + Level::One => write!(f, "1"), + Level::Two => write!(f, "2"), + Level::Three => write!(f, "3"), + Level::Four => write!(f, "4"), + Level::Five => write!(f, "5"), + Level::Six => write!(f, "6"), + } + } +} diff --git a/src/syntax/content/parser/token/linebreak.rs b/src/syntax/content/parser/token/linebreak.rs new file mode 100644 index 0000000..d56b49c --- /dev/null +++ b/src/syntax/content/parser/token/linebreak.rs @@ -0,0 +1,27 @@ +use std::fmt::Display; +use crate::{ + syntax::content::{Parseable, parser::lexeme::Lexeme}, +}; + +#[derive(Debug)] +pub struct LineBreak {} + +impl Parseable for LineBreak { + fn probe(lexeme: &Lexeme) -> bool { + lexeme.text() == "\n" + } + + fn lex(_lexeme: &Lexeme) -> LineBreak { + LineBreak {} + } + + fn render(&self) -> String { + "\n".to_owned() + } +} + +impl Display for LineBreak { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "Line Break") + } +} diff --git a/src/syntax/content/parser/token/literal.rs b/src/syntax/content/parser/token/literal.rs new file mode 100644 index 0000000..723b152 --- /dev/null +++ b/src/syntax/content/parser/token/literal.rs @@ -0,0 +1,29 @@ +use std::fmt::Display; +use crate::syntax::content::{Parseable, parser::lexeme::Lexeme}; + +#[derive(Debug)] +pub struct Literal { + text: String, +} + +impl Parseable for Literal { + fn probe(_lexeme: &Lexeme) -> bool { + true + } + + fn lex(lexeme: &Lexeme) -> Literal { + Literal { + text: lexeme.text(), + } + } + + fn render(&self) -> String { + self.text.clone() + } +} + +impl Display for Literal { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "Literal: <{}>", &self.text) + } +} diff --git a/src/syntax/content/parser/token/paragraph.rs b/src/syntax/content/parser/token/paragraph.rs new file mode 100644 index 0000000..2348286 --- /dev/null +++ b/src/syntax/content/parser/token/paragraph.rs @@ -0,0 +1,52 @@ +use std::fmt::Display; +use crate::syntax::content::{Parseable, parser::lexeme::Lexeme}; + +#[derive(Debug)] +pub struct Paragraph { + open: Option, +} + +impl Paragraph { + pub fn new(open: bool) -> Paragraph { + Paragraph { open: Some(open) } + } +} + +impl Parseable for Paragraph { + fn probe(lexeme: &Lexeme) -> bool { + // lexeme for paragraph is any non-whitespace, parser knows the context + !lexeme.is_whitespace() + } + + fn lex(_lexeme: &Lexeme) -> Paragraph { + Paragraph { open: None } + } + + fn render(&self) -> String { + if let Some(open) = self.open { + if open { + "

".to_owned() + } else { + "

".to_owned() + } + } else { + panic!( + "Attempt to render a paragraph tag while open state is unknown" + ) + } + } +} + +impl Display for Paragraph { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + if let Some(open) = self.open { + if open { + write!(f, "Open Paragraph") + } else { + write!(f, "Closed Paragraph") + } + } else { + write!(f, "Unitialized Paragraph (Unknown open state)") + } + } +} diff --git a/src/syntax/content/parser/token/preformat.rs b/src/syntax/content/parser/token/preformat.rs new file mode 100644 index 0000000..195636d --- /dev/null +++ b/src/syntax/content/parser/token/preformat.rs @@ -0,0 +1,38 @@ +use crate::{ + syntax::content::{Parseable, Lexeme}, +}; + +#[derive(Debug)] +pub struct PreFormat { + open: Option, +} + +impl PreFormat { + pub fn new(open: bool) -> PreFormat { + PreFormat { open: Some(open) } + } +} + +impl Parseable for PreFormat { + fn probe(lexeme: &Lexeme) -> bool { + lexeme.match_first_char('`') && lexeme.next == "\n" + } + + fn lex(_lexeme: &Lexeme) -> PreFormat { + PreFormat { open: None } + } + + fn render(&self) -> String { + if let Some(o) = self.open { + if o { + "
".to_owned()
+            } else {
+                "
".to_owned() + } + } else { + panic!( + "Attempt to render a preformat tag while open state is unknown" + ) + } + } +} diff --git a/src/syntax/content/parser/token/span.rs b/src/syntax/content/parser/token/span.rs new file mode 100644 index 0000000..b312a28 --- /dev/null +++ b/src/syntax/content/parser/token/span.rs @@ -0,0 +1,50 @@ +use std::fmt::Display; +use crate::syntax::content::{Parseable, parser::lexeme::Lexeme}; + +#[derive(Debug)] +pub struct Span { + open: Option, +} + +impl Span { + pub fn new(open: bool) -> Span { + Span { open: Some(open) } + } +} + +impl Parseable for Span { + fn probe(_lexeme: &Lexeme) -> bool { + // there is no lexeme for span + false + } + + fn lex(_lexeme: &Lexeme) -> Span { + Span { open: None } + } + + fn render(&self) -> String { + if let Some(open) = self.open { + if open { + "".to_owned() + } else { + "".to_owned() + } + } else { + panic!("Attempt to render a span tag while open state is unknown") + } + } +} + +impl Display for Span { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + if let Some(open) = self.open { + if open { + write!(f, "Open Span") + } else { + write!(f, "Closed Span") + } + } else { + write!(f, "Span (Unknown open state)") + } + } +} diff --git a/src/syntax/serial.rs b/src/syntax/serial.rs new file mode 100644 index 0000000..7ed2a87 --- /dev/null +++ b/src/syntax/serial.rs @@ -0,0 +1,224 @@ +use std::collections::HashMap; + +use crate::{ + syntax::command::Arguments, + types::{Edge, Graph, Node}, +}; + +pub fn populate_graph() -> Graph { + let args = Arguments::new().parse(); + let toml_source = match std::fs::read_to_string(args.graph_path) { + Ok(s) => s, + Err(e) => format!("Error: {e}"), + }; + let graph = deserialize_graph(&Format::TOML, &toml_source); + + let nodes = modulate_nodes(&graph.nodes); + + Graph { + nodes: nodes.clone(), + incoming: make_incoming(&nodes), + lowercase_keymap: map_lowercase_keys(&nodes), + ..graph + } +} + +fn map_lowercase_keys( + source_map: &HashMap, +) -> HashMap { + let mut out_map: HashMap = HashMap::new(); + let keys = source_map.keys(); + for key in keys { + out_map.insert(key.clone().to_lowercase(), key.clone()); + } + out_map +} + +fn modulate_nodes(old_nodes: &HashMap) -> HashMap { + let mut nodes: HashMap = HashMap::new(); + + for (key, node) in old_nodes { + let connections = node.connections.clone().unwrap_or_default(); + let mut new_edges = connections.clone(); + + for (i, edge) in connections.iter().enumerate() { + let mut new_edge = edge.clone(); + + // Populate empty "from" IDs in edges with node's ID + if edge.from.is_empty() { + new_edge.from.clone_from(key); + } + + // Flag detached edges + if !old_nodes.contains_key(&edge.to) { + new_edge.detached = true; + } + + if let Some(e) = new_edges.get_mut(i) { + *e = new_edge; + } + } + + // Create connections for each link + for link in &node.links { + new_edges.push(Edge { + from: key.clone(), + to: link.clone(), + anchor: String::new(), + detached: !old_nodes.contains_key(link), + }); + } + + // Populate empty titles with IDs + let new_title = if node.title.is_empty() { + key.clone() + } else { + node.title.clone() + }; + + let new_node = Node { + id: key.clone(), + title: new_title, + connections: Some(new_edges), + ..node.clone() + }; + + nodes.insert(key.clone(), new_node); + } + + nodes +} + +// Construct a HashMap with incoming connections (reversed edges) +fn make_incoming(nodes: &HashMap) -> HashMap> { + let mut incoming: HashMap> = HashMap::new(); + + for node in nodes.clone().into_values() { + let empty_vec: Vec = vec![]; + for edge in &node.connections.clone().unwrap_or_default() { + let mut edges = + incoming.get(&edge.to.clone()).unwrap_or(&empty_vec).clone(); + edges.extend_from_slice(std::slice::from_ref(edge)); + incoming.insert(edge.to.clone(), edges.clone()); + } + } + + incoming +} + +pub enum Format { + TOML, + JSON, +} + +pub fn serialize_graph(out_format: &Format, graph: &Graph) -> String { + match *out_format { + Format::TOML => match toml::to_string(graph) { + Ok(s) => s, + Err(e) => e.to_string(), + }, + Format::JSON => match serde_json::to_string(graph) { + Ok(s) => s, + Err(e) => e.to_string(), + }, + } +} + +pub fn deserialize_graph(in_format: &Format, serial: &str) -> Graph { + match *in_format { + Format::TOML => match toml::from_str(serial) { + Ok(g) => g, + Err(error) => Graph::new(Some(&error.to_string())), + }, + Format::JSON => match serde_json::from_str(serial) { + Ok(g) => g, + Err(error) => Graph::new(Some(&error.to_string())), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn good_json() { + let json = r#" + { + "nodes": { + "JSON": { + "text": "", + "title": "JSON", + "links": [], + "id": "JSON", + "hidden": false, + "connections": [] + } + }, + "root_node": "JSON" + } + "#; + + let graph = deserialize_graph(&Format::JSON, json); + assert!(graph.meta.messages.is_empty()); + } + + #[test] + fn bad_json() { + let graph = deserialize_graph(&Format::JSON, ":::"); + let message = graph.meta.messages.first().unwrap(); + assert!(message.contains("expected value at line 1 column 1")); + } + + #[test] + fn detached_node() { + let node = Node { + id: String::from("SomeNode"), + text: String::new(), + title: String::new(), + links: vec![String::new()], + hidden: false, + connections: Some(vec![Edge { + anchor: String::from("SomeAnchor"), + from: String::new(), + to: String::new(), + detached: false, + }]), + }; + + let mut map: HashMap = HashMap::new(); + map.insert(String::from("SomeNode"), node); + + let modulated_map = modulate_nodes(&map); + let modulated_node = modulated_map.get("SomeNode").unwrap().clone(); + let modulated_connections = modulated_node.connections.unwrap(); + let modulated_connection = modulated_connections.first().unwrap(); + assert!(modulated_connection.anchor == "SomeAnchor"); + assert!(modulated_connection.detached); + } +} + +#[cfg(test)] +mod serial_tests { + use super::*; + + #[test] + fn bad_graph_path() { + println!("T"); + let original_working_directory = std::env::current_dir().unwrap(); + + assert!( + std::env::set_current_dir(std::path::Path::new( + "tests/mocks/no_graph" + )) + .is_ok() + ); + + let graph = populate_graph(); + let message = graph.meta.messages.first().unwrap(); + assert!(message.contains("TOML parse error")); + assert!(message.contains("No such file or directory")); + + assert!(std::env::set_current_dir(original_working_directory).is_ok()); + } +} diff --git a/src/types.rs b/src/types.rs index 4bbcfee..00d78e6 100644 --- a/src/types.rs +++ b/src/types.rs @@ -2,14 +2,18 @@ use std::collections::HashMap; use serde::{Serialize, Deserialize}; +use crate::syntax::content; + #[derive(Serialize, Deserialize, Clone, Default, PartialEq, Eq, Debug)] pub struct Graph { pub nodes: HashMap, pub root_node: String, - #[serde(default)] - pub messages: Vec, - #[serde(skip)] + #[serde(skip_deserializing)] pub incoming: HashMap>, + #[serde(skip_deserializing)] + pub lowercase_keymap: HashMap, + #[serde(default)] + pub meta: Meta, } #[derive(Serialize, Deserialize, Clone, Default, PartialEq, Eq, Debug)] @@ -21,6 +25,8 @@ pub struct Node { pub links: Vec, #[serde(default)] pub id: String, + #[serde(default)] + pub hidden: bool, #[serde(skip_serializing_if = "Option::is_none")] pub connections: Option>, @@ -37,19 +43,118 @@ pub struct Edge { pub detached: bool, } +#[derive(Serialize, Deserialize, Clone, Default, PartialEq, Eq, Debug)] +pub struct Meta { + pub config: Config, + #[serde(default = "mkversion")] + pub version: (u8, u8, u8), + #[serde(default)] + pub messages: Vec, +} + +// See: https://github.com/serde-rs/serde/issues/368 +fn mkversion() -> (u8, u8, u8) { + (0, 0, 0) +} + +#[expect(clippy::struct_excessive_bools)] +#[derive(Serialize, Deserialize, Clone, Default, PartialEq, Eq, Debug)] +pub struct Config { + #[serde(default)] + pub site_title: String, + #[serde(default)] + pub site_description: String, + #[serde(default = "mktrue")] + pub footer: bool, + #[serde(default = "mktrue")] + pub footer_credits: bool, + #[serde(default = "mktrue")] + pub footer_date: bool, + #[serde(default)] + pub footer_text: String, + #[serde(default = "mktrue")] + pub about: bool, + #[serde(default)] + pub about_text: String, + #[serde(default = "mktrue")] + pub tree: bool, + #[serde(default = "mktrue")] + pub raw: bool, + #[serde(default = "mktrue")] + pub raw_toml: bool, + #[serde(default = "mktrue")] + pub raw_json: bool, + #[serde(default = "mktrue")] + pub index_search: bool, + #[serde(default = "mktrue")] + pub index_node_list: bool, + #[serde(default = "mk8")] + pub index_node_count: u16, + #[serde(default = "mktrue")] + pub index_root_node: bool, + #[serde(default = "mkfalse")] + pub tree_node_text: bool, + #[serde(default = "mkfalse")] + pub ascii_dom_ids: bool, + #[serde(default)] + pub content_language: String, +} + +// See: https://github.com/serde-rs/serde/issues/368 +fn mktrue() -> bool { + true +} +fn mkfalse() -> bool { + false +} +fn mk8() -> u16 { + 8 +} + impl Graph { - pub fn new(message: Option) -> Graph { - Self { + pub fn new(message: Option<&str>) -> Graph { + Graph { nodes: HashMap::new(), root_node: "VoidNode".to_string(), incoming: HashMap::new(), - messages: vec![ - message - .unwrap_or("This graph is empty or in error".to_string()), - ], + lowercase_keymap: HashMap::new(), + meta: Meta { + config: Config { + site_title: String::new(), + site_description: String::new(), + footer: true, + footer_credits: true, + footer_date: true, + footer_text: String::new(), + about: true, + about_text: String::new(), + tree: true, + raw: true, + raw_toml: true, + raw_json: true, + index_search: true, + index_node_list: true, + index_node_count: 8, + index_root_node: true, + tree_node_text: false, + ascii_dom_ids: false, + content_language: String::new(), + }, + version: (0, 1, 0), + messages: message.map_or(vec![], |m| vec![m.to_string()]), + }, } } + pub fn find_node(&self, query: &str) -> Option { + self.nodes.get(query).cloned().or_else(|| { + self.lowercase_keymap + .get(query) + .and_then(|lower_key| self.nodes.get(lower_key)) + .cloned() + }) + } + pub fn get_root(&self) -> Option { self.nodes.get(&self.root_node).cloned() } @@ -57,7 +162,7 @@ impl Graph { impl Node { pub fn new(message: Option) -> Node { - Self { + Node { id: "VoidNode".to_string(), title: "Pure Void".to_string(), text: match message { @@ -66,6 +171,111 @@ impl Node { }, connections: None, links: vec![], + hidden: false, } } } + +impl Config { + #[must_use] + pub fn parse_text(self) -> Config { + let footer_text = if self.footer_text.is_empty() { + self.footer_text + } else { + content::parse(&self.footer_text) + }; + + let about_text = if self.about_text.is_empty() { + self.about_text + } else { + content::parse(&self.about_text) + }; + + Config { + footer_text, + about_text, + ..self + } + } +} + +#[cfg(test)] +mod tests { + use crate::syntax::serial::populate_graph; + + use super::*; + + #[test] + fn empty_graph() { + let graph = Graph::new(Some("ISryQFd9peG6eYz9CFRQFWeD1GnPo0oj")); + assert!(graph.nodes.is_empty()); + assert!(graph.incoming.is_empty()); + assert_eq!( + graph.meta.messages.first().unwrap(), + "ISryQFd9peG6eYz9CFRQFWeD1GnPo0oj" + ); + } + + #[test] + fn empty_node_message() { + let node = Node::new(None); + assert_eq!(node.text, "Node is empty, missing or wasn't found."); + } + + #[test] + fn empty_footer_text() { + let default_graph = populate_graph(); + + let config = Config { + footer_text: String::new(), + ..default_graph.meta.config + }; + + let parsed_config = config.parse_text(); + + println!("{:?}", parsed_config.footer_text); + assert!(parsed_config.footer_text.is_empty()); + } + + #[test] + fn config_footer_text() { + let payload = "0kqBrdS8NPrU4xVxh2xW0hUzAw926JCQ"; + let default_graph = populate_graph(); + + let config = Config { + footer_text: format!("`{payload}`"), + ..default_graph.meta.config + }; + + let parsed_config = config.parse_text(); + + assert!( + parsed_config + .footer_text + .matches(format!("{payload}").as_str()) + .count() + == 1 + ); + } + + #[test] + fn config_about_text() { + let payload = "ZqPFl84JlzSS0QUo61RwTUPONIE78Lmw"; + let default_graph = populate_graph(); + + let config = Config { + about_text: format!("`{payload}`"), + ..default_graph.meta.config + }; + + let parsed_config = config.parse_text(); + + assert!( + parsed_config + .about_text + .matches(format!("{payload}").as_str()) + .count() + == 1 + ); + } +} diff --git a/static/graph.toml b/static/graph.toml index ac70b0b..f66957c 100644 --- a/static/graph.toml +++ b/static/graph.toml @@ -1,97 +1,401 @@ -root_node = "Interface" +root_node = "Documentation" -[nodes.Interface] +[nodes.Documentation] text = """ -An interface is a point of contact between the inside and the outside of something. Contrast with intraface. +## Installation + +For now, if you want to try en, you must build it yourself. + +In an environment with a |Rust toolchain|https://rustup.rs/ and Git installed, run: + +` +git clone https://codeberg.org/jutty/en +cd en +cargo build --release +` + +The en binary will be in `target/release/en`. + +You can start it and point it to an address, port and graph: + +` +en --host localhost --port 3003 --graph ./graph.toml +` + +See |CLI| for defaults and details on the CLI options. + + +## Graph Syntax + +The graph is a TOML file. You can create nodes by adding text such as: + +` +[nodes.Computer] +text = "A computer is a machine capable of executing arbitrary instructions." +` + +If you need longer text, it's more convenient to use triple quotes: + +` +[nodes.Computer] +text = \""" +A computer is a machine capable of executing arbitrary instructions. +\""" +` + +Some special syntax is allowed inside the node text. See |Syntax| for supported features. + +## Connections + +Nodes can have connections between each other. + +To add a simple connection without any associated properties, you can simply add links: + +` +[nodes.Quark] +text = "A subatomic particle that forms hadrons." + +links = [ "Particle", "Hadron" ] +` + +This will create two outgoing connections from Quark: to Particle and to Hadron. It will also list Quark as an incoming connection in these nodes' pages. + +If you want to add properties to the connection, you can use the connection syntax: + +` +[[nodes.Quark.connections]] +to = "Particle physics" +anchor = "particle" +` + +This will create a connection from Quark to "Particle physics", and the first occurrence of the word "particle" in the text of Quark gets anchored to this connection. """ -links = ["Intraface"] - -[nodes.Intraface] +[nodes.CLI] +title = "CLI Options" text = """ -The intraface is the reflexive process of communicating, creating, thinking, that does not or cannot get shared with others. Contrast with interface. +You can set the hostname, port and graph file path using CLI options: + +For the hostname, use `-h` or `--hostname`: + +` +en -h localhost +en --hostname 10.120.0.5 +` + +If unspecified, the default is `0.0.0.0`. + +For the port, use `-p` or `--port`: + +` +en -p 3003 +en --port 3000 +` + +If unspecified, the default is to use a random available port assigned by the operating system. + +For the graph path, use `-g` or `--graph`: + +` +en -g graph.toml +en --g ./static/my-graph.toml +` + +If unspecified, the default is `./static/graph.toml`. + +You can combine these options as you wish: + +` +en -h localhost -p 3000 +en -p 3003 --host localhost --graph ./graph.toml +en --g ./graph.toml -p 1312 +` + +If an option is specified more than once, the last use will override any previous ones. + """ -links = ["Thinking", "Interface"] +[nodes.Syntax] +text= """ + +## Anchors + +Anchors follow the following syntax: + +` +anchor|destination +` + +For example: + +` +docs|/node/Documentation +` + +If the left side contains spaces, you need a leading `|` character: + +` +|en docs|https://en.jutty.dev/node/Documentation +` + +If you have a trailing character that you don't want to be considered as part of the destination, you can separate it with a third `|`: + +` +This gem|PreciousStone|, though green, was not an emerald. +` + +To make a plain address clickable, wrap it in two `|` characters: + +` +|https://en.jutty.dev| +` + +### Node anchors + +We saw above an example like `docs|/node/Documentation`, but there is a shorter syntax for node anchors. + +If the address doesn't contain any `/` or `:` characters, it will be interpreted as a node ID: + +` +particles|ParticlePhysics +` + +This allows you to specify what to display as the anchor text, but just the ID wrapped inside two `|` characters also works: + +` +|Documentation| +` + +Because en can resolve IDs case insensitively (with priority to case-sensitive matches), you can also write the above anchor as `|documentation|`. + +In summary, all of the anchors below are valid and lead to the same page: + +` +|en Syntax|https://en.jutty.dev/node/Syntax| +|en Syntax|https://en.jutty.dev/node/Syntax +Syntax|https://en.jutty.dev/node/Syntax + +Syntax|/node/syntax + +|syntax|Syntax +Syntax|syntax +Syntax|syntax| + +|Syntax| +|syntax| +` + +While flexible, this can sometimes be ambiguous. See |AnchorSyntax| for some caveats regarding anchors. -[nodes.Thinking] -text = """ -Thinking is a process by which some beings create and manipulate mental constructs. """ -[nodes.Paradigm] +[nodes.AnchorSyntax] +title = "Anchor Syntax" text = """ -A paradigm is a cohesive set of beliefs, methods and principles that serve both as justification for a given position and as guidance for how to pursue its praxis. +Anchor syntax can be very concise, but some situations lead to ambiguity. + +In short, following these two rules should keep you out of trouble: + +- Avoid special characters in your node IDs +- When needed, use full three-pipe `|text|destination|` syntax to fix ambiguity + +## Punctuation in destinations + +Consider this example: + +` +|gem|PreciousStone +|PreciousStone|, +` + +Both seem to point to the node with ID `PreciousStone`, as they _seem_ to. But if we didn't treat punctuation differently, we'd have: + +` +|a|b +|a|b +` + +For this reason, punctuation is treated differently. It won't be considered as a possible destination, so you can write the previous example and have it behave as expected. + +This also means you can't have punctuation symbols as node IDs or as their first character. + +These are the punctuation symbols that are treated specially: + +` +, . : ; ? ! ( ) ' " + +` + +You can also force this using a third pipe: + +` +|PreciousStone||, +` + +This unambiguously tells en that your destination is a node ID. + +## URL detection + +en must differentiate node anchors from outgoing URLs: + +` +|sample|Example| +|sample|https://example.com| + +|Example| +|https://example.com| +` + +It does this by looking at the destination and checking if it contains a `:` or `/`, so also avoid these in your node IDs. + """ -links = [ "Principle", "Belief", "Method", "Position", "Praxis" ] - -[nodes.Principle] +[nodes.en] text = """ -A principle is a belief that implies commitment and necessity. +en is a tool to write non-linear, connected pieces of text and have their references mapped out as a graph of connected information. -Principles change, but to change one's principles too constantly defeats its purpose. +It works by ingesting a TOML file containing your node specification and serving it as a website that allows nodes to be browsed, searched and listed in relation to each other or as a shallow tree of nodes. -A principle is usually informed by experience or formed by cultural context, namely religion. +## Motivation + +en was created out of the desire to write complex, long-form descriptions of a personal worldview without being constrained or getting stuck trying to mimic the linearity of a typical philosophy book. + +It's described as a "writing instrument" because it's not so much about the presentation or even the web format. While that's the medium for this particular implementation, you can notice en serves its raw data in both TOML and JSON. It's first and foremost about mapping out and structuring written thoughts. + +Because en is defined in simple configuration files, you can add new pages easily from a few lines and start connecting them. Instead of having to create a dedicated file or resource for each new entry you find deserving of observation, with its own beginning and end, its own "I'm empty, fill me to completion" demeanor, you can stay in the flow of your sprawling thoughts. This is meant to fit the specific wiring of minds whose thoughts spread and fork quickly and often, whether to great depth or across wide expanses. -As other beliefs, simply identifying with a principle does not mean one follows it, which can introduce a sense of dissonance and/or guilt. """ -links = [ "Dissonance", "Guilt", "Belief", "Religion", ] +links = [ "Graph" ] -[[nodes.Principle.connections]] -anchor = "identifying" -to = "Identity" +[[nodes.en.connections]] +to = "TOML" +anchor = "TOML" -[nodes.Religion] +[nodes.Graph] text = """ -A religion is a paradigm that involves unfalsifiable beliefs, particularly those in the domain of morality. +A graph is a data structure composed of connected (and disconnected) nodes. -A reductive critique of religion dismisses it based on its dogmatic adherence to certain beliefs usually rooted in tradition. +A familiar example is that of a social network. Each account can be thought of as a node and the "follow" and "follower" relationships can be thought of as edges (connections). A node may have many or few connections, and the nodes it is connected to are meaningful to understand how it fits into the whole. -As a counterpoint, consider that the fact religion carries false beliefs does not imply all of the beliefs that religion carries are false, which is an assertion that holds for any other entity. The beliefs that compose the episteme of a religion may include both falsifiable and unfalsifiable beliefs. In this sense, it does not differ from any other paradigm. - -Religion does not subsist solely because of its hard truths, but because it caters to various other basic human necessities: community, identity, knowledge regarding how to conduct one's life. This gives religion enormous presence in society and allows it to act as a strong political force against societal changes that contradict its positions. - -One poignant fact about religion is that its dogmas tend to create exclusion, segregating those who can or want to adhere to them from those who can't or don't want to. This not only means religion will create divisions, it also means that the people excluded from it will be left with less means to fulfill the previously mentioned basic human necessities that religion addresses. +en uses this concept to create a writing tool, allowing you to map out complex thoughts as a web of connected texts. """ -links = [ - "Paradigm", - "Principle", - "Truth", - "Tradition", - "Morality", - "Episteme", - "Necessity", - "Knowledge", - "Community", - "Identity", - "Dogma", - "Reductionism", -] - -[nodes.Identity] +[nodes.TOML] text = """ -Identity is how individuals construe their sameness and otherness from each other and from nothingness. +TOML is a configuration format that can be easily read and understood by humans and machines alike. + +To learn more about TOML, you can visit its website at |https://toml.io|. + +To see the TOML declaration that translates into the rendered graph you are reading right now, visit the "TOML Graph" link on the top navigation bar. """ -links = ["Principle"] - -[[nodes.Identity.connections]] -anchor = "nothingness" -to = "Emptiness" - -[nodes.Emptiness] +[nodes.Acknowledgments] text = """ -Emptiness is the vacuous base in which entities exist. +en is only possible thanks to a number of projects and people: + +- |The Rust Programing Language|https://rust-lang.org/ +- Tokio|https://tokio.rs/ +- Axum|https://github.com/tokio-rs/axum +- Tera|https://keats.github.io/tera/ +- Serde|https://serde.rs/ and the |toml crate|https://github.com/toml-rs/toml +- Bacon|https://dystroy.org/bacon/config/ """ -links = [ "Entity" ] - -[nodes.Entity] +[nodes.Test] +hidden = true text = """ -An entity is anything except for actual emptiness. It does not have to be sentient, or physical. It can be an idea, a concept, a memory. The concept of emptiness is an entity, but emptiness itself is not. + +This node is just for testing syntax rendering, but I appreciate your curiosity. + +` +|en purple|https://purple.en/n/purple +cyan|https://cyan.en/n/cyan + +|en Giraffe|/node/Giraffe +|Gorilla|/node/Gorilla +Crow|/node/Crow + +|Circle|Circle +Circle|Circle +|Circle| +` + +|en purple|https://purple.en/n/purple +cyan|https://cyan.en/n/cyan + +|en Giraffe|/node/Giraffe +Crow|/node/Crow + +Circle|Circle +|Circle| + +These `|anchors|` are inside `|backticks|Backtick` and should `|not render|https://test.com` as backticks but as `|raw text|` instead. This `|syntax is|` now `being demonstrated|https://test.com` here. + +Well |have I ever found such a long anchor in my entire life|Nowhere|, have I? + +This failed to parse due to a misunderstanding about what `parts.push(peaker.next().unwrap_or_else(|| unreachable!() ));` really meant. + +This greedy anchor is |at the end of a line|Somewhere +This greedy anchor is |at the end of a line|Somewhere| +This greedy anchor is |at the end of a line with a period|Somewhere|. +This inline code is `at the end of a line` +This inline code is `at the end of a line with a period`. + +--- + +For trailing characters you don't want as part of destination, add a third `|`: + +` +This gem|PreciousStone|, though green, was not an emerald. +` + +Which renders as: + +This gem|PreciousStone|, though green, was not an emerald. + +Supported for punctuation only. + +### Node anchors + +We saw example `docs|/node/Documentation`, but shorter syntax exists. + +## Green +## Green +## Green +## Purple +## Purple +## Purple +## Cyan +### Cyan +#### Cyan +### Cyan +## Cyan +## Épistème +## Épistème +## Epistème +## Epistēmē +### Epistēmē +#### Epistēmē +#### Epistēmē + +|en Syntax|https://en.jutty.dev/node/Syntax| +|en Syntax|https://en.jutty.dev/node/Syntax +Syntax|https://en.jutty.dev/node/Syntax + +Syntax|/node/syntax + +|syntax|Syntax +Syntax|syntax +Syntax|syntax| + +|Syntax| +|syntax| """ -links = [ "Emptiness" ] +[meta.config] +content_language = "en" +footer_credits = false +footer_text = """ +made by jutty|https://jutty.dev • acknowledgments|Acknowledgments • |source code|https://codeberg.org/jutty/en +""" diff --git a/static/style.css b/static/style.css index 55babcf..97a5840 100644 --- a/static/style.css +++ b/static/style.css @@ -1,21 +1,91 @@ -* { - line-height: 1.6em; -} - html { - height: 100%; + height: 100%; + font-family: sans-serif; + line-height: 1.5; } body { - height: 100%; - display: grid; - grid-template-rows: auto 1fr auto; + height: 100%; + display: grid; + grid-template-rows: auto 1fr auto; +} + +pre { + max-width: 90vw; + overflow: auto; + box-sizing: border-box; + padding: 10px; + margin: 10px; + +} + +code { + padding: 3px 6px; + border-radius: 6px; + margin-right: 2px; +} + +pre, code { + background-color: #e0e0e0; + border: solid 1px #d0d0d0; +} + +a { + color: #0d6161; + text-decoration: underline dotted #138e8e; + text-decoration-thickness: 1.5px; +} + +a:visited { + text-decoration-color: #aaa; +} + +div.header-row { + display: flex; + justify-content: space-between; + align-items: center; +} + +div.labels { + margin-right: 10px; + display: flex; + align-items: center; +} + +span.label { + padding: 3px 6px; + border-radius: 6px; + margin: 5px; +} + +span.id-label { + background-color: #e0e0e0; + border: solid 1px #d0d0d0; +} + +span.hidden-label { + background-color: #888; + color: #eee; + border: solid 1px #d0d0d0; +} + +h1.node-title { + display: inline; + margin: 10px 0; } footer div { - margin: 20px 0; - text-align: center; - font-size: 0.8em; + margin: 20px 0; + text-align: center; + font-size: 0.8em; +} + +footer p { + margin: 0; +} + +nav li { + margin-right: 10px; } @media (prefers-color-scheme: dark) { @@ -23,4 +93,35 @@ footer div { background-color: #222222; color: #f1e9e5; } + + pre, code { + background-color: #333333; + border: solid 1px #434343; + } + + a { + color: #1bc8c8; + text-decoration-color: #159b9b; + } + + span.id-label { + background-color: #444; + border-color: #666; + } + + span.hidden-label { + background-color: #000; + border-color: #555; + color: #969696; + } +} + +@media (max-width: 600px) { + nav li { + margin-right: 3px; + } + + div.header-row { + display: block; + } } diff --git a/templates/about.html b/templates/about.html index 9c7d15a..25a1397 100644 --- a/templates/about.html +++ b/templates/about.html @@ -5,16 +5,21 @@ {%- block body %}

About

+{% if config.about_text %} +{{ config.about_text | safe }} +{% else %}

en is a program to create a connected collection of texts.

-

You define your graph using a plain-text configuration file and then en reads this file and generates a webpage like the one you are seeing right now.

+

You define your graph using a plain-text configuration file, en reads this file and generates a website like the one you are browsing right now.

If you'd like to learn more:

+{% endif %} {%- endblock body %} diff --git a/templates/acknowledgments.html b/templates/acknowledgments.html deleted file mode 100644 index 1a1e218..0000000 --- a/templates/acknowledgments.html +++ /dev/null @@ -1,20 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Acknowledgments{% endblock title %} - -{%- block body %} -

Acknowledgments

- -

en is only possible thanks to a number of projects and people:

- - - -{%- endblock body %} diff --git a/templates/base.html b/templates/base.html index b7b42d8..07023de 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,7 +1,15 @@ +{% if config.content_language %} + +{% else %} +{% endif %} + {% if config.site_title %} + {% block title %}{% endblock title %} • {{config.site_title}} + {% else %} {% block title %}{% endblock title %} • en + {% endif %} @@ -13,10 +21,20 @@ @@ -24,22 +42,29 @@ {% block body %} {% endblock body %} + {% if config.footer %}

- made by jutty - • - acknowledgments - • - source code + {% if config.footer_text %}{{ config.footer_text | safe }}{% endif %} + {% if config.footer_text and (config.footer_credits or config.footer_date) %} + {% endif %} + {% if config.footer_credits %} + made with en, a non-linear writing instrument + {% endif %} + {% if config.footer_credits and config.footer_date %}
+ {% endif %} + {% if config.footer_date %} built + {% endif %}
+ {% endif %} diff --git a/templates/empty.html b/templates/empty.html index 3bcea75..9dd335d 100644 --- a/templates/empty.html +++ b/templates/empty.html @@ -1,2 +1,14 @@

There are no nodes. The graph is either empty or failed to parse.

-

Check the raw endpoints for possible parsing errors.

+{% if config.raw %} +

Check the + {% if config.raw_toml %} + + {% elif config.raw_json %} + + {% endif %} + raw endpoints + {% if config.raw_toml or config.raw_json %} + + {% endif %} + for possible parsing errors.

+{% endif %} diff --git a/templates/error.html b/templates/error.html index 971cbfe..4bf2617 100644 --- a/templates/error.html +++ b/templates/error.html @@ -11,7 +11,7 @@ fallen
out of the circle
you are welcome to climb
- back onto the tree + back onto the {% if config.tree %}tree{% else %}tree{% endif %}

diff --git a/templates/index.html b/templates/index.html index 76b32c3..20cfd34 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3,34 +3,56 @@ {% block title %}Index{% endblock title %} {%- block body %} -

en

+

{%if config.site_title %}{{ config.site_title }}{% else %}en{%endif%}

- A non-linear writing instrument. + + {% if config.site_description %} + {{config.site_description}} + {% else %} + A non-linear writing instrument. + {% endif %} + {% if nodes %} + {% if config.index_search %}

+ {% endif %} + {% if config.index_node_list or config.index_root_node %}
+ {% if config.index_node_list %}

Nodes

+ {% endif %} + {% endif %} {% else %}
{% include "empty.html" %} diff --git a/templates/node.html b/templates/node.html index 21d461a..5249483 100644 --- a/templates/node.html +++ b/templates/node.html @@ -1,32 +1,35 @@ {% extends "base.html" %} -{% block title %}{{ title }}{% endblock title %} +{% block title %}{{ node.title }}{% endblock title %} {%- block body %}
-

{{ title }}

- ID: {{ id }} - {% for line in text | split(pat="\n") %} - {% if line %}

{{ line }}

{% endif %} - {% endfor %} +
+

{{ node.title }}

+
+ {% if node.title != node.id %}ID: {{ node.id }}{% endif %} + {% if node.hidden %}Hidden{% endif %} +
+
+ {{ text | safe }}
- {% if connections or incoming %} + {% if node.connections or incoming %} - {% else %} - Node has no connections. {% endif %} {%- endblock body %} diff --git a/templates/tree.html b/templates/tree.html index 6c5e135..563d071 100644 --- a/templates/tree.html +++ b/templates/tree.html @@ -2,6 +2,9 @@ {% block title %}Tree{% endblock title %} + + + {%- block body %} {% if nodes or root_node %}

Tree

@@ -12,7 +15,9 @@
  • {{root_node.title}} + {% if root_node.connections or config.tree_node_text %}
      + {% if config.tree_node_text %}
    • Text:
      • @@ -24,16 +29,18 @@
      - {% if root_node.connections %} -
    • Connections -
        - {% for connection in root_node.connections %} -
      • {{connection.to}}
      • - {% endfor %} -
      -
    • - {% endif %} + {% endif %} + {% if root_node.connections %} + {% if config.tree_node_text %}
    • Connections +
        {% endif %} + {% for connection in root_node.connections %} +
      • {{connection.to}}
      • + {% endfor %} + {% if config.tree_node_text %}
      +
    • {% endif %} + {% endif %}
    + {% endif %}
{% endif %} @@ -41,10 +48,12 @@ {% if nodes %}

All nodes

    - {% for node in nodes %} + {% for node in nodes | filter(attribute="hidden", value=false)%}
  • {{node.title}} + {% if node.connections or config.tree_node_text %}
      + {% if config.tree_node_text %}
    • Text:
      • @@ -56,18 +65,20 @@
      - {% if node.connections %} -
    • Connections -
        - {% for connection in node.connections %} - {% if not connection.detached %} -
      • {{connection.to}}
      • {% endif %} - {% endfor %} -
      -
    • - {% endif %} + {% if node.connections %} + {% if config.tree_node_text %}
    • Connections +
        {% endif %} + {% for connection in node.connections %} + {% if not connection.detached %} +
      • {{connection.to}}
      • + {% endif %} + {% endfor %} + {% if config.tree_node_text %}
      +
    • {% endif %} + {% endif %}
    + {% endif %}
  • {% endfor %} {% endif %} diff --git a/tests/mocks/bad_graph/static/graph.toml b/tests/mocks/bad_graph/static/graph.toml new file mode 100644 index 0000000..2d79863 --- /dev/null +++ b/tests/mocks/bad_graph/static/graph.toml @@ -0,0 +1,3 @@ +[meta] +config = { +' diff --git a/tests/mocks/good_json/graph.json b/tests/mocks/good_json/graph.json new file mode 100644 index 0000000..31363fb --- /dev/null +++ b/tests/mocks/good_json/graph.json @@ -0,0 +1,13 @@ +{ + "nodes": { + "JSON": { + "text": "", + "title": "JSON", + "links": [], + "id": "JSON", + "hidden": false, + "connections": [] + } + }, + "root_node": "JSON" +}