mirror of
https://github.com/samsonjs/samhuri.net.git
synced 2026-03-25 09:05:47 +00:00
Compare commits
554 commits
before-har
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fc3a242d7 | |||
|
|
4918611781 | ||
| 33ffcc4d35 | |||
| 451d72278b | |||
| 9a0b182879 | |||
| 48ca00ed21 | |||
| fb25cffa7c | |||
| 007b1058b6 | |||
| 23e62f4a49 | |||
| 99985430c2 | |||
| 5c646d8d50 | |||
| f1a6b7da24 | |||
| 98f37a09f4 | |||
| b709d0bcfb | |||
| 17aff11e43 | |||
| 4e215d6c3b | |||
| fb47d33699 | |||
| 2311e17cf1 | |||
| 67aa48f0e5 | |||
| f37eb4c7e7 | |||
| 46a355cfba | |||
| eba6e2c12f | |||
| 9db609e8d8 | |||
| ecd9ad3b3e | |||
| 3b2d4c6440 | |||
| c18a946dae | |||
| a656a8859d | |||
| a762a143be | |||
| e07f0c5c4d | |||
| 9506d2dd8c | |||
| d0579b0831 | |||
| f3419ed983 | |||
| 5eba24dfc8 | |||
| 9e7bbcab5a | |||
| 93a9f5b403 | |||
| 1df7fe4652 | |||
| 5399ce0c96 | |||
| f275946b00 | |||
| 7d7a7b5caf | |||
| 450603d05e | |||
| 84f5aac73c | |||
| e99d4645b5 | |||
| 43ab0abc2d | |||
| 928f02907e | |||
| b2653112ad | |||
| 2094bc1487 | |||
| fd6a452a77 | |||
| e83be79042 | |||
| ae50b85d0f | |||
| 69b997f8ab | |||
| eb093d1343 | |||
| b747889431 | |||
| 6f8db8854e | |||
| 034d079d3c | |||
| a86d08e538 | |||
| d108d099e9 | |||
| bbb4026804 | |||
| 4ead526184 | |||
| 78f2865c49 | |||
| 905ccfa674 | |||
| be47fc2974 | |||
| 60af819160 | |||
| 9bebea8225 | |||
| 0f6500cf2d | |||
| 96b249a21c | |||
| 9dfd5080ef | |||
| f71c9aabbb | |||
| b277106ed5 | |||
| 3bd2ff0a12 | |||
| ba8951e6a7 | |||
| eb90c07585 | |||
| da56d10818 | |||
| a27a3e482c | |||
| d97c9eb79c | |||
| 2fe6bfc73f | |||
| 56833f88e7 | |||
| 53c5ca3032 | |||
| 2d8fc2cfa3 | |||
| 1e8330dda4 | |||
| 8ea53b91d8 | |||
| be7cbb4264 | |||
| 4318c0b903 | |||
| f5aa5d71b3 | |||
| 9d37bc2861 | |||
| 07af1878be | |||
| 0018174306 | |||
| bc6fc8bb68 | |||
| 4626731919 | |||
| ffeea907b5 | |||
| d68014345a | |||
| 2cdedfa348 | |||
| 0c17d5c543 | |||
| 640c76d967 | |||
| 49b03025c6 | |||
| b28b52d75d | |||
| 6a0db8febe | |||
| 8b676c443a | |||
| e22c17e810 | |||
| 527e9e6617 | |||
| 44fef3fb78 | |||
| 487875098a | |||
| 1f3be38c5c | |||
| cac13d3e55 | |||
| d4299f66fa | |||
| 5f2ca2e44d | |||
| fd785fcf2f | |||
| f18778774c | |||
| eedba392d1 | |||
| 9c8591d2e1 | |||
| f7379854e5 | |||
| 0748053a82 | |||
| 23b3d2d625 | |||
| fa3ec10345 | |||
| 1426b4e75b | |||
| 34136951c4 | |||
| dee7869a21 | |||
| 170c44f4fb | |||
| f9055f82c2 | |||
| dd96d95fc4 | |||
| 1d0ffd52a2 | |||
| 885e5153ff | |||
| d69275ce29 | |||
| ed9ad222b2 | |||
| 652d192560 | |||
| d184ed06fa | |||
| 4b3dee6706 | |||
| 5fac69542c | |||
| 0a876c0c01 | |||
| 3a771ca83a | |||
| 51e7ea5e78 | |||
| ca145962cc | |||
| c8dc29a511 | |||
| 5ed68c45f8 | |||
| 4f384e3e4c | |||
| 666b926d53 | |||
| 38918fe5f9 | |||
| 085949bd87 | |||
| 09e45c9617 | |||
| 9173a09d88 | |||
| 4a03060c8c | |||
| 57de420eee | |||
| b00a48b096 | |||
| 947fb4ec3a | |||
| a552d28c0a | |||
| e53fda0851 | |||
| 447da5fdc1 | |||
| 98d8a2750f | |||
| 27df7f899d | |||
| 4e9c53a2f4 | |||
| 72bbc433eb | |||
| 9f8c1480ef | |||
| 810e7ed74d | |||
| 7f8abac24b | |||
| 545f3b89ae | |||
| 95a240cbf5 | |||
| c4f7af1684 | |||
| c2c28953ec | |||
| b2ca0ab0fd | |||
| a46ec511ee | |||
| 4b70fdf9e5 | |||
| acad65d1a5 | |||
| 46dba7dd54 | |||
| e2cf0f89dd | |||
| 64d24fa8eb | |||
| cc1c97b4a1 | |||
| 0091566f00 | |||
| 4785f241c8 | |||
| 6280bd6a20 | |||
| 55fc0ff693 | |||
| 89c2d37f16 | |||
| 5c47b83da6 | |||
| bd41c00f3a | |||
| 2ce8d2f376 | |||
| b11e0686ad | |||
| ea2b53d625 | |||
| 57bdf5d14a | |||
| 03bdab61f2 | |||
| 03d147b071 | |||
| 4c15bef55b | |||
| e500efccdf | |||
| 1e6348dbde | |||
| 309c7dddc0 | |||
| aa365bebe0 | |||
|
|
4da6cd7794 | ||
| f4073eda4c | |||
| a3831d326e | |||
|
|
696df4d442 | ||
|
|
655a0f90a2 | ||
| 27d0a751a1 | |||
| 12738a3f29 | |||
| 7f3947009a | |||
| df8bae7ced | |||
| 5c7dfa6a6c | |||
| 7bbfb98845 | |||
| 3acd4f78db | |||
|
|
cb0401522c | ||
|
|
1a7d409415 | ||
|
|
b6d1b294d3 | ||
| 09e1ef20a1 | |||
|
|
30a4401c81 | ||
| 005819d7d7 | |||
| 27c07523e1 | |||
| 4684cf3daf | |||
| 314781d93c | |||
| 2fc21be0c5 | |||
| cba8998b02 | |||
| fa45927947 | |||
| 90b2b9dc0a | |||
| 6ba46a1861 | |||
| c6e7367961 | |||
| 6bcc5e92c5 | |||
| a105b1b7e8 | |||
|
|
98895f9d67 | ||
|
|
f2ecd6f57c | ||
| 3e8d71bac9 | |||
| 0d381ad0dc | |||
| 5e97f43c10 | |||
| 4bf5ce4190 | |||
| 72867fe3fb | |||
| 0eb0bea3ed | |||
| b04202d4ad | |||
| bfae83019b | |||
| 263ea16e00 | |||
| 32a8a6d3b2 | |||
| 84c741e3a5 | |||
| e37c5be10d | |||
| b652b249b7 | |||
| 75b737b651 | |||
| b213a04010 | |||
|
|
2718ac19a0 | ||
| 34cbede753 | |||
| a99b9585d3 | |||
| f39f9b9a0d | |||
| 9c7e1f9568 | |||
| 742f32521c | |||
| d3100e94e0 | |||
| 210b5f1473 | |||
| 1ad8a6759f | |||
| 5955792247 | |||
| a0ca5988cd | |||
| c20ddb3437 | |||
| 0ffec2f04d | |||
|
|
7c4da166ac | ||
| 0608f984cb | |||
| 5c45ca9ee8 | |||
| 5421494e18 | |||
| d345a1532b | |||
| 443765ffc7 | |||
| b465463454 | |||
| 5feb0e62d2 | |||
| f1cba66d2c | |||
| 85bfaaa97c | |||
| 816b35fa1c | |||
| 50ed93f967 | |||
| d849f5d78c | |||
| 4dc0bde5fc | |||
| cdaca54b9b | |||
| 7d80cee384 | |||
| 9c34ba46d8 | |||
| 801e6c8175 | |||
| c5b42f4243 | |||
| c88590abd9 | |||
| fc3c00687c | |||
| 7231c7465b | |||
| dc07a0c0b2 | |||
| c23993c0a4 | |||
| 3ff373af77 | |||
| 2a8ca8bfab | |||
| f13078c074 | |||
| 387fc9efdb | |||
| 0487e9fbee | |||
| 7a13783b1b | |||
| ff092a0b01 | |||
| 12cc7d2648 | |||
| b52d0f13ec | |||
| 5920e6fbb3 | |||
| fe4c427273 | |||
| b6c3ba480f | |||
| 73ea651f9f | |||
| 07305daee4 | |||
| 674854cd37 | |||
| 6858634c4e | |||
| 331b758987 | |||
| d817578394 | |||
| f8b46917de | |||
| fb1597ccc2 | |||
| ed853a2fb5 | |||
| c140a832da | |||
| f00ec57e1d | |||
| 82d3f3ed73 | |||
| 21968b2421 | |||
| 61abbe4603 | |||
| 86ef23b8e4 | |||
| e10fcdec55 | |||
| 5eda114d5a | |||
| 583524e049 | |||
| 886b0c69c6 | |||
| 59534668b5 | |||
| d1fa9e72b4 | |||
| 6f0ad41beb | |||
| 26a025b27d | |||
| ff817c7a58 | |||
| a90bffd65b | |||
| 468b366145 | |||
| 160952965d | |||
| 422f0c7566 | |||
| 1de17f2df3 | |||
| e933ce399f | |||
| a6e3317690 | |||
| 82416e61e4 | |||
| 9697cab77b | |||
| be9fbfd32f | |||
| 14627ef707 | |||
| be8177b82b | |||
| e64f0c42d1 | |||
| 407acbcf2c | |||
| 7375cb06e0 | |||
| 289e54dc35 | |||
| 7279f5a655 | |||
| 29ece7b496 | |||
| e130050c39 | |||
| 4dc5c81046 | |||
| 43e59f1c56 | |||
| f2a855dab1 | |||
| 290c460053 | |||
| 7389c31b62 | |||
| cc8e8e6b24 | |||
| 0b16a94980 | |||
| 570d333b61 | |||
| 3c2bc2d6e3 | |||
| cdfb2c96d6 | |||
| 0e3fe1d6c1 | |||
| 4dcfe36e79 | |||
| fe75cb6196 | |||
| b8012c926a | |||
| 7cf0a2925b | |||
| a23216cc45 | |||
| e020e7fe32 | |||
| 1cee56ed0c | |||
| 67381d196e | |||
| c6f34a7f31 | |||
| dbd86b6e03 | |||
| 23ab7a52ad | |||
| fd879e9b1a | |||
| 743e099bde | |||
| 440d664e23 | |||
| c5dc19317e | |||
| c99f0c1833 | |||
| 7eac1f65a2 | |||
| 40ccb8331e | |||
| ebffb10379 | |||
| b41086855a | |||
| 91970aa918 | |||
| af04885c0b | |||
| 97f7afd9fe | |||
| 26a3f1a0e0 | |||
| 8b356c50d4 | |||
| d870dc9e7d | |||
| 3520059ff7 | |||
| 4787bd8206 | |||
| 1458377d42 | |||
| d6a5388e3e | |||
| 2ca3032bbd | |||
| c590d3f23c | |||
| afc2db292e | |||
| f2b1747022 | |||
| 69e46091ba | |||
| f2962d7be8 | |||
| 6ef965cb64 | |||
| d2cf0546b6 | |||
| 091a829413 | |||
| 2447d573fe | |||
| d84e1c70ea | |||
| 4da4058884 | |||
| a8d2e8b0d2 | |||
| 419aef4d03 | |||
| 5c43d1d6c1 | |||
| 0a76c5891d | |||
| dcc5e472a1 | |||
| 2541fd2785 | |||
| 7870fddf9d | |||
| 01ce127e07 | |||
| 445711086f | |||
| c90eac1f0a | |||
| 8afbd7e5b0 | |||
| ae9a10d7a3 | |||
| ae9832afe5 | |||
| 1a5af50f29 | |||
| 1cfe880c38 | |||
| d02adf6b31 | |||
| 7a2338de6b | |||
| 7572b2917f | |||
| 8d0036ddb0 | |||
| 732da17859 | |||
| b504f87e3c | |||
| 800ebe73f8 | |||
| 09d7f9043f | |||
| 52670c367f | |||
| f1ca433f62 | |||
| f200158d16 | |||
| 5dbbb4037d | |||
| b7e259402c | |||
| cbed64f462 | |||
| 6d0eb3f245 | |||
| c00608761d | |||
| 411700b6ef | |||
| 27de556ab5 | |||
| 7aa42fdbf8 | |||
| 0a68892774 | |||
| 7416e8c80f | |||
| 4513df26f5 | |||
| cec182882f | |||
| cc2c491509 | |||
| 776528024c | |||
| f54e5f12e5 | |||
| a136608ede | |||
| e544fe90b2 | |||
| be88d907a0 | |||
| eb471b0d24 | |||
| 2fb181dbcb | |||
| 446f1c85c3 | |||
| ec56d5f684 | |||
| abc9c802ef | |||
| 51ceac55ed | |||
| a6259b83ea | |||
| d66715fd8d | |||
| eea4a74d1c | |||
| 5830cb08c1 | |||
| 8d42efc0e1 | |||
| 60563c6ecf | |||
| 01a7ad64d7 | |||
| 5949cf20bd | |||
| e44afbe80d | |||
| 1dbd243008 | |||
| 9239d4110d | |||
| 5e6bebd230 | |||
| e627cf7155 | |||
| 31e13523b6 | |||
| c6a388967e | |||
| 75bd85d66f | |||
| aade9641e4 | |||
| ff5f87ff80 | |||
| 09b1224646 | |||
| 652a10b16d | |||
| ff0b565cc5 | |||
| c905f5c414 | |||
| d9731944c2 | |||
| 9ac9e9e4be | |||
| 65c90fcf48 | |||
| 4a9b1b8db6 | |||
| 586206ac1a | |||
| c472358979 | |||
| 93e4714d7e | |||
| 03a9143b5a | |||
| 56a2de1ebc | |||
| c8f543122b | |||
| 8f6b3be1eb | |||
| 7e47c2d670 | |||
| 034d975225 | |||
| 3546ab2952 | |||
| 9028d805ae | |||
| 890b1508c1 | |||
| f0b3174e61 | |||
| 291e40f859 | |||
| 3437b8f08a | |||
| 70e8ff6b18 | |||
| 5c6399b558 | |||
| cf4b6e7a0a | |||
| dd0e9e1b57 | |||
| c5c430367f | |||
| 87bbd8e0e1 | |||
| 22ed491d00 | |||
| 688184c80a | |||
| 59d2986054 | |||
| d09a0b7ef3 | |||
| 482b317814 | |||
| e9490d49ee | |||
| 03fd061411 | |||
| 76cfe3dd01 | |||
| 95ebc26d2b | |||
| 6d405b201b | |||
| 62159d17cb | |||
| 25482dd76c | |||
| a4b5975715 | |||
| 5df90721b3 | |||
| 8db90d67d2 | |||
| 94fe105091 | |||
| 96e4cf4e4b | |||
| 2e5c2a6c0b | |||
| 8d1323e4ef | |||
| 043e363300 | |||
| 24f60083a2 | |||
| 4d23c27584 | |||
| 720f88cc60 | |||
| 35d16d8ed1 | |||
| 4bf01948ec | |||
| 1d0e7fd36c | |||
| 6a9b6da4d5 | |||
| 48708ff957 | |||
| b3926f5ce1 | |||
| 8fb8b099af | |||
| 6f4303f65b | |||
| c1c16be54a | |||
| 127d15c8d7 | |||
| b8474ead44 | |||
| 0da549ed9f | |||
| 169135b711 | |||
| 2003385c7b | |||
| 202c8d3948 | |||
| ae80dc39a6 | |||
| 41f85e2876 | |||
| 6808a2cba2 | |||
| 34e1ecee53 | |||
| b98d9aae0a | |||
| 52db90b3d0 | |||
| 1718cfb547 | |||
| def13d8a93 | |||
| b34ddbb600 | |||
| 43233d8497 | |||
| 0c8d615840 | |||
| 3f597220cd | |||
| 9d3f886ce0 | |||
| b365a1dae9 | |||
| 5960252f24 | |||
| 5f159ef1ee | |||
| b11e6bf5b7 | |||
| c83c639b98 | |||
| be2b7af74a | |||
| 3a09348e3b | |||
| 4676585c78 | |||
| 4eec1bcba6 | |||
| 3e8d01d97f | |||
| 5736b78654 | |||
| 8507482d4d | |||
| b0740405b1 | |||
| 589b7bb5aa | |||
| 107d212f50 | |||
| 7479e8f13d | |||
| 9558ee8f91 | |||
| 4e991d461e | |||
| 4c611affa4 | |||
| d15214a914 | |||
| f11752d894 | |||
| 1fdeacb411 | |||
| 5b9125ad5e | |||
| 6616f77390 | |||
| 0cb36d7773 | |||
| 3513fc7d2a | |||
| eb5f13df19 | |||
| 7904fddaae | |||
| b73a290903 | |||
| 47cdd8509a | |||
| 4ddf988ff9 | |||
| 787c0da40f |
636 changed files with 305892 additions and 50583 deletions
65
.github/workflows/ci.yml
vendored
Normal file
65
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: .ruby-version
|
||||
bundler-cache: true
|
||||
|
||||
- name: Bootstrap
|
||||
run: bin/bootstrap
|
||||
|
||||
- name: Coverage
|
||||
run: bundle exec bake coverage
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: .ruby-version
|
||||
bundler-cache: true
|
||||
|
||||
- name: Bootstrap
|
||||
run: bin/bootstrap
|
||||
|
||||
- name: Lint
|
||||
run: bundle exec bake lint
|
||||
|
||||
debug:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: .ruby-version
|
||||
bundler-cache: true
|
||||
|
||||
- name: Bootstrap
|
||||
run: bin/bootstrap
|
||||
|
||||
- name: Debug Build
|
||||
run: bundle exec bake debug
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
|
|
@ -1,11 +1,3 @@
|
|||
.bundle
|
||||
_blog
|
||||
public/js/*.js
|
||||
public/css/*.css
|
||||
discussd/discuss.dirty
|
||||
public/blog
|
||||
public/proj
|
||||
node_modules
|
||||
public/s42/.htaccess
|
||||
public/images/blog
|
||||
public/f
|
||||
www
|
||||
gemini
|
||||
Tests/*/actual
|
||||
|
|
|
|||
1
.ruby-version
Normal file
1
.ruby-version
Normal file
|
|
@ -0,0 +1 @@
|
|||
4.0.1
|
||||
|
|
@ -1 +0,0 @@
|
|||
exclude = "{$exclude,_blog,public/proj,public/blog,*.min.js}"
|
||||
3
.zed/settings.json
Normal file
3
.zed/settings.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"file_scan_exclusions": ["public/tweets/", "www", ".DS_Store", ".git"]
|
||||
}
|
||||
84
AGENTS.md
Normal file
84
AGENTS.md
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
This repository is a Ruby static-site generator (Pressa) that outputs both HTML and Gemini formats.
|
||||
|
||||
- Generator code: `lib/pressa/` (entrypoint: `lib/pressa.rb`)
|
||||
- Build/deploy/draft tasks: `bake.rb`
|
||||
- Tests: `test/`
|
||||
- Site config: `site.toml`, `projects.toml`
|
||||
- Published posts: `posts/YYYY/MM/*.md`
|
||||
- Static and renderable public content: `public/`
|
||||
- Draft posts: `public/drafts/`
|
||||
- Generated HTML output: `www/` (safe to delete/regenerate)
|
||||
- Generated Gemini output: `gemini/` (safe to delete/regenerate)
|
||||
- Gemini protocol reference docs: `gemini-docs/`
|
||||
- CI: `.github/workflows/ci.yml` (runs coverage, lint, and debug build)
|
||||
|
||||
Keep new code under the existing `Pressa` module structure (for example `lib/pressa/posts`, `lib/pressa/projects`, `lib/pressa/views`, `lib/pressa/config`, `lib/pressa/utils`) and add matching tests under `test/`.
|
||||
|
||||
## Setup, Build, Test, and Development Commands
|
||||
- Use `rbenv exec` for Ruby commands in this repository (for example `rbenv exec bundle exec ...`) to ensure the project Ruby version is used.
|
||||
- `bin/bootstrap`: install prerequisites and gems (uses `rbenv` when available).
|
||||
- `rbenv exec bundle exec bake debug`: build HTML for `http://localhost:8000` into `www/`.
|
||||
- `rbenv exec bundle exec bake serve`: serve `www/` via WEBrick on port 8000.
|
||||
- `rbenv exec bundle exec bake watch target=debug`: Linux-only autorebuild loop (`inotifywait` required).
|
||||
- `rbenv exec bundle exec bake mudge|beta|release`: build HTML with environment-specific base URLs.
|
||||
- `rbenv exec bundle exec bake gemini`: build Gemini capsule into `gemini/`.
|
||||
- `rbenv exec bundle exec bake publish_beta`: build and rsync `www/` to beta host.
|
||||
- `rbenv exec bundle exec bake publish_gemini`: build and rsync `gemini/` to production host.
|
||||
- `rbenv exec bundle exec bake publish`: build and rsync both HTML and Gemini to production.
|
||||
- `rbenv exec bundle exec bake clean`: remove `www/` and `gemini/`.
|
||||
- `rbenv exec bundle exec bake test`: run test suite.
|
||||
- `rbenv exec bundle exec bake guard`: run Guard for continuous testing.
|
||||
- `rbenv exec bundle exec bake lint`: lint code with StandardRB.
|
||||
- `rbenv exec bundle exec bake lint_fix`: auto-fix lint issues.
|
||||
- `rbenv exec bundle exec bake coverage`: run tests and report `lib/` line coverage.
|
||||
- `rbenv exec bundle exec bake coverage_regression baseline=merge-base`: compare coverage to a baseline and fail on regression (override `baseline` as needed).
|
||||
|
||||
## Draft Workflow
|
||||
- `rbenv exec bundle exec bake new_draft "Post Title"` creates `public/drafts/<slug>.md`.
|
||||
- `rbenv exec bundle exec bake drafts` lists available drafts.
|
||||
- `rbenv exec bundle exec bake publish_draft public/drafts/<slug>.md` moves draft to `posts/YYYY/MM/` and updates `Date` and `Timestamp`.
|
||||
|
||||
## Content and Metadata Requirements
|
||||
Posts must include YAML front matter. Required keys (enforced by `Pressa::Posts::PostMetadata`) are:
|
||||
|
||||
- `Title`
|
||||
- `Author`
|
||||
- `Date`
|
||||
- `Timestamp`
|
||||
|
||||
Optional keys include `Tags`, `Link`, `Scripts`, and `Styles`.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
- Ruby (see `.ruby-version`).
|
||||
- Follow idiomatic Ruby style and keep code `bake lint`-clean.
|
||||
- Use 2-space indentation and descriptive `snake_case` names for methods/variables, `UpperCamelCase` for classes/modules.
|
||||
- Prefer small, focused classes for plugins, views, renderers, and config loaders.
|
||||
- Do not hand-edit generated files in `www/` or `gemini/`.
|
||||
|
||||
## Testing Guidelines
|
||||
- Use Minitest under `test/` (for example `test/posts`, `test/config`, `test/views`).
|
||||
- Add regression tests for parser, rendering, feed, and generator behavior changes.
|
||||
- Before submitting, run:
|
||||
- `rbenv exec bundle exec bake test`
|
||||
- `rbenv exec bundle exec bake coverage`
|
||||
- `rbenv exec bundle exec bake lint`
|
||||
- `rbenv exec bundle exec bake debug`
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
- Use concise, imperative commit subjects (history examples: `Fix internal permalink regression in archives`).
|
||||
- Keep commits scoped to one concern (generator logic, content, or deployment changes).
|
||||
- In PRs, include motivation, verification commands run, and deployment impact.
|
||||
- Include screenshots when changing rendered layout/CSS output.
|
||||
|
||||
## Deployment & Security Notes
|
||||
- Deployment is defined in `bake.rb` via rsync over SSH.
|
||||
- Current publish host is `mudge` with:
|
||||
- production HTML: `/var/www/samhuri.net/public`
|
||||
- beta HTML: `/var/www/beta.samhuri.net/public`
|
||||
- production Gemini: `/var/gemini/samhuri.net`
|
||||
- `bake publish` deploys both HTML and Gemini to production.
|
||||
- Validate `www/` and `gemini/` before publishing to avoid shipping stale assets.
|
||||
- Never commit credentials, SSH keys, or other secrets.
|
||||
19
Gemfile
19
Gemfile
|
|
@ -1,6 +1,15 @@
|
|||
source 'https://rubygems.org'
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem 'builder'
|
||||
gem 'json'
|
||||
gem 'mustache'
|
||||
gem 'rdiscount'
|
||||
gem "phlex", "~> 2.3"
|
||||
gem "kramdown", "~> 2.5"
|
||||
gem "kramdown-parser-gfm", "~> 1.1"
|
||||
gem "rouge", "~> 4.6"
|
||||
gem "dry-struct", "~> 1.8"
|
||||
gem "builder", "~> 3.3"
|
||||
gem "bake", "~> 0.20"
|
||||
|
||||
group :development, :test do
|
||||
gem "guard", "~> 2.18"
|
||||
gem "minitest", "~> 6.0"
|
||||
gem "standard", "~> 1.43"
|
||||
end
|
||||
|
|
|
|||
180
Gemfile.lock
180
Gemfile.lock
|
|
@ -1,16 +1,178 @@
|
|||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
builder (3.0.0)
|
||||
json (1.6.1)
|
||||
mustache (0.99.4)
|
||||
rdiscount (1.6.8)
|
||||
ast (2.4.3)
|
||||
bake (0.24.1)
|
||||
bigdecimal
|
||||
samovar (~> 2.1)
|
||||
bigdecimal (4.0.1)
|
||||
builder (3.3.0)
|
||||
coderay (1.1.3)
|
||||
concurrent-ruby (1.3.6)
|
||||
console (1.34.2)
|
||||
fiber-annotation
|
||||
fiber-local (~> 1.1)
|
||||
json
|
||||
dry-core (1.2.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
logger
|
||||
zeitwerk (~> 2.6)
|
||||
dry-inflector (1.3.1)
|
||||
dry-logic (1.6.0)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0)
|
||||
dry-core (~> 1.1)
|
||||
zeitwerk (~> 2.6)
|
||||
dry-struct (1.8.0)
|
||||
dry-core (~> 1.1)
|
||||
dry-types (~> 1.8, >= 1.8.2)
|
||||
ice_nine (~> 0.11)
|
||||
zeitwerk (~> 2.6)
|
||||
dry-types (1.9.1)
|
||||
bigdecimal (>= 3.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
dry-core (~> 1.0)
|
||||
dry-inflector (~> 1.0)
|
||||
dry-logic (~> 1.4)
|
||||
zeitwerk (~> 2.6)
|
||||
ffi (1.17.3-aarch64-linux-gnu)
|
||||
ffi (1.17.3-aarch64-linux-musl)
|
||||
ffi (1.17.3-arm-linux-gnu)
|
||||
ffi (1.17.3-arm-linux-musl)
|
||||
ffi (1.17.3-arm64-darwin)
|
||||
ffi (1.17.3-x86-linux-gnu)
|
||||
ffi (1.17.3-x86-linux-musl)
|
||||
ffi (1.17.3-x86_64-darwin)
|
||||
ffi (1.17.3-x86_64-linux-gnu)
|
||||
ffi (1.17.3-x86_64-linux-musl)
|
||||
fiber-annotation (0.2.0)
|
||||
fiber-local (1.1.0)
|
||||
fiber-storage
|
||||
fiber-storage (1.0.1)
|
||||
formatador (1.2.3)
|
||||
reline
|
||||
guard (2.20.1)
|
||||
formatador (>= 0.2.4)
|
||||
listen (>= 2.7, < 4.0)
|
||||
logger (~> 1.6)
|
||||
lumberjack (>= 1.0.12, < 2.0)
|
||||
nenv (~> 0.1)
|
||||
notiffany (~> 0.0)
|
||||
pry (>= 0.13.0)
|
||||
shellany (~> 0.0)
|
||||
thor (>= 0.18.1)
|
||||
ice_nine (0.11.2)
|
||||
io-console (0.8.2)
|
||||
json (2.19.2)
|
||||
kramdown (2.5.2)
|
||||
rexml (>= 3.4.4)
|
||||
kramdown-parser-gfm (1.1.0)
|
||||
kramdown (~> 2.0)
|
||||
language_server-protocol (3.17.0.5)
|
||||
lint_roller (1.1.0)
|
||||
listen (3.10.0)
|
||||
logger
|
||||
rb-fsevent (~> 0.10, >= 0.10.3)
|
||||
rb-inotify (~> 0.9, >= 0.9.10)
|
||||
logger (1.7.0)
|
||||
lumberjack (1.4.2)
|
||||
mapping (1.1.3)
|
||||
method_source (1.1.0)
|
||||
minitest (6.0.1)
|
||||
prism (~> 1.5)
|
||||
nenv (0.3.0)
|
||||
notiffany (0.1.3)
|
||||
nenv (~> 0.1)
|
||||
shellany (~> 0.0)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.10.1)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
phlex (2.4.1)
|
||||
refract (~> 1.0)
|
||||
zeitwerk (~> 2.7)
|
||||
prism (1.9.0)
|
||||
pry (0.16.0)
|
||||
coderay (~> 1.1)
|
||||
method_source (~> 1.0)
|
||||
reline (>= 0.6.0)
|
||||
racc (1.8.1)
|
||||
rainbow (3.1.1)
|
||||
rb-fsevent (0.11.2)
|
||||
rb-inotify (0.11.1)
|
||||
ffi (~> 1.0)
|
||||
refract (1.1.0)
|
||||
prism
|
||||
zeitwerk
|
||||
regexp_parser (2.11.3)
|
||||
reline (0.6.3)
|
||||
io-console (~> 0.5)
|
||||
rexml (3.4.4)
|
||||
rouge (4.7.0)
|
||||
rubocop (1.82.1)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.48.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.49.0)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.7)
|
||||
rubocop-performance (1.26.1)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.47.1, < 2.0)
|
||||
ruby-progressbar (1.13.0)
|
||||
samovar (2.4.1)
|
||||
console (~> 1.0)
|
||||
mapping (~> 1.0)
|
||||
shellany (0.0.1)
|
||||
standard (1.53.0)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.0)
|
||||
rubocop (~> 1.82.0)
|
||||
standard-custom (~> 1.0.0)
|
||||
standard-performance (~> 1.8)
|
||||
standard-custom (1.0.2)
|
||||
lint_roller (~> 1.0)
|
||||
rubocop (~> 1.50)
|
||||
standard-performance (1.9.0)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop-performance (~> 1.26.0)
|
||||
thor (1.5.0)
|
||||
unicode-display_width (3.2.0)
|
||||
unicode-emoji (~> 4.1)
|
||||
unicode-emoji (4.2.0)
|
||||
zeitwerk (2.7.4)
|
||||
|
||||
PLATFORMS
|
||||
ruby
|
||||
aarch64-linux-gnu
|
||||
aarch64-linux-musl
|
||||
arm-linux-gnu
|
||||
arm-linux-musl
|
||||
arm64-darwin
|
||||
x86-linux-gnu
|
||||
x86-linux-musl
|
||||
x86_64-darwin
|
||||
x86_64-linux-gnu
|
||||
x86_64-linux-musl
|
||||
|
||||
DEPENDENCIES
|
||||
builder
|
||||
json
|
||||
mustache
|
||||
rdiscount
|
||||
bake (~> 0.20)
|
||||
builder (~> 3.3)
|
||||
dry-struct (~> 1.8)
|
||||
guard (~> 2.18)
|
||||
kramdown (~> 2.5)
|
||||
kramdown-parser-gfm (~> 1.1)
|
||||
minitest (~> 6.0)
|
||||
phlex (~> 2.3)
|
||||
rouge (~> 4.6)
|
||||
standard (~> 1.43)
|
||||
|
||||
BUNDLED WITH
|
||||
4.0.6
|
||||
|
|
|
|||
56
Makefile
56
Makefile
|
|
@ -1,56 +0,0 @@
|
|||
JAVASCRIPTS=$(shell echo assets/js/*.js)
|
||||
STYLESHEETS=$(shell echo assets/css/*.css)
|
||||
POSTS=$(shell echo _blog/published/*.html) $(shell echo _blog/published/*.md)
|
||||
|
||||
all: proj blog combine
|
||||
|
||||
proj: projects.json templates/proj/index.html templates/proj/project.html
|
||||
@echo
|
||||
./bin/projects.js projects.json public/proj
|
||||
|
||||
blog: _blog/blog.json templates/blog/index.html templates/blog/post.html $(POSTS)
|
||||
@echo
|
||||
cd _blog && git pull
|
||||
./bin/blog.rb _blog public
|
||||
|
||||
minify: $(JAVASCRIPTS) $(STYLESHEETS)
|
||||
@echo
|
||||
./bin/minify.sh
|
||||
|
||||
combine: minify $(JAVASCRIPTS) $(STYLESHEETS)
|
||||
@echo
|
||||
./bin/combine.sh
|
||||
|
||||
publish_assets: combine
|
||||
@echo
|
||||
./bin/publish.sh --delete public/css public/images public/js
|
||||
./bin/publish.sh public/f
|
||||
|
||||
publish_blog: blog publish_assets
|
||||
@echo
|
||||
./bin/publish.sh --delete public/blog
|
||||
scp public/blog/posts.json bohodev.net:discussd/posts.json
|
||||
scp discussd/discussd.js bohodev.net:discussd/discussd.js
|
||||
scp public/s42/.htaccess samhuri.net:s42.ca/.htaccess
|
||||
ssh bohodev.net bin/restart-discussd.sh
|
||||
|
||||
publish_proj: proj publish_assets
|
||||
@echo
|
||||
./bin/publish.sh --delete public/proj
|
||||
|
||||
publish_index: public/index.html
|
||||
@echo
|
||||
./bin/publish.sh public/index.html
|
||||
|
||||
publish: publish_index publish_blog publish_proj
|
||||
@echo
|
||||
./bin/publish.sh public/.htaccess
|
||||
./bin/publish.sh public/favicon.ico
|
||||
|
||||
clean:
|
||||
rm -rf public/proj/*
|
||||
rm -rf public/blog/*
|
||||
rm public/css/*.css
|
||||
rm public/js/*.js
|
||||
|
||||
.PHONY: proj blog
|
||||
117
Readme.md
Normal file
117
Readme.md
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
# samhuri.net
|
||||
|
||||
Source code for [samhuri.net](https://samhuri.net), powered by a Ruby static site generator.
|
||||
|
||||
## Overview
|
||||
|
||||
This repository contains the Ruby static-site generator and site content for samhuri.net.
|
||||
|
||||
If what you want is an artisanal, hand-crafted, static site generator for your personal blog then this might be a decent starting point. If you want a static site generator for other purposes then this has the bones you need to do that too, by ripping out the bundled plugins for posts and projects and writing your own.
|
||||
|
||||
- Generator core: `lib/pressa/` (entrypoint: `lib/pressa.rb`)
|
||||
- Build tasks and utility workflows: `bake.rb`
|
||||
- Tests: `test/`
|
||||
- Config: `site.toml` and `projects.toml`
|
||||
- Content: `posts/` and `public/`
|
||||
- Output: `www/` (HTML), `gemini/` (Gemini capsule)
|
||||
|
||||
## Requirements
|
||||
|
||||
- Ruby (see `.ruby-version`)
|
||||
- Bundler
|
||||
- `rbenv` recommended
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
bin/bootstrap
|
||||
```
|
||||
|
||||
Or manually:
|
||||
|
||||
```bash
|
||||
rbenv install -s "$(cat .ruby-version)"
|
||||
bundle install
|
||||
```
|
||||
|
||||
## Build And Serve
|
||||
|
||||
```bash
|
||||
bake debug # build for http://localhost:8000
|
||||
bake serve # serve www/ locally
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Site metadata and project data are configured with TOML files at the repository root:
|
||||
|
||||
- `site.toml`: site identity, default scripts/styles, a `plugins` list (for example `["posts", "projects"]`), and output-specific settings under `outputs.*` (for example `outputs.html.remote_links` and `outputs.gemini.{exclude_public,recent_posts_limit,home_links}`), plus `projects_plugin` assets when that plugin is enabled.
|
||||
- `projects.toml`: project listing entries using `[[projects]]`.
|
||||
|
||||
`Pressa.create_site` loads both files from the provided `source_path` and supports URL overrides for `debug`, `beta`, and `release` builds.
|
||||
|
||||
## Customizing for your site
|
||||
|
||||
If this workflow seems like a good fit, here is the minimum to make it your own:
|
||||
|
||||
- Update `site.toml` with your site identity (`author`, `email`, `title`, `description`, `url`) and any global `scripts` / `styles`.
|
||||
- Set `plugins` in `site.toml` to explicitly enable features (`"posts"`, `"projects"`). Safe default if omitted is no plugins.
|
||||
- Define your projects in `projects.toml` using `[[projects]]` entries with `name`, `title`, `description`, and `url`.
|
||||
- Configure project-page-only assets in `site.toml` under `[projects_plugin]` (`scripts` and `styles`) when using the `"projects"` plugin.
|
||||
- Configure output pipelines with `site.toml` `outputs.*` tables:
|
||||
- `[outputs.html]` supports `remote_links` (array of `{label, href, icon}`).
|
||||
- `[outputs.gemini]` supports `exclude_public`, `recent_posts_limit`, and `home_links` (array of `{label, href}`).
|
||||
- Add custom plugins by implementing `Pressa::Plugin` in `lib/pressa/` and registering them in `lib/pressa/config/loader.rb`.
|
||||
- Adjust rendering and layout in `lib/pressa/views/` and the static content in `public/` as needed.
|
||||
|
||||
Other targets:
|
||||
|
||||
```bash
|
||||
bake mudge
|
||||
bake beta
|
||||
bake release
|
||||
bake gemini
|
||||
bake watch target=debug
|
||||
bake clean
|
||||
bake publish_beta
|
||||
bake publish_gemini
|
||||
bake publish
|
||||
```
|
||||
|
||||
## Draft Workflow
|
||||
|
||||
```bash
|
||||
bake new_draft "Post title"
|
||||
bake drafts
|
||||
bake publish_draft public/drafts/post-title.md
|
||||
```
|
||||
|
||||
Published posts in `posts/YYYY/MM/*.md` require YAML front matter keys:
|
||||
|
||||
- `Title`
|
||||
- `Author`
|
||||
- `Date`
|
||||
- `Timestamp`
|
||||
|
||||
## Tests And Lint
|
||||
|
||||
```bash
|
||||
bake test
|
||||
standardrb
|
||||
```
|
||||
|
||||
Or via bake:
|
||||
|
||||
```bash
|
||||
bake test
|
||||
bake lint
|
||||
bake lint_fix
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- `bake watch` is Linux-only and requires `inotifywait`.
|
||||
- Deployment uses `rsync` to host `mudge` (configured in `bake.rb`):
|
||||
- production: `/var/www/samhuri.net/public`
|
||||
- beta: `/var/www/beta.samhuri.net/public`
|
||||
- gemini: `/var/gemini/samhuri.net`
|
||||
22
TODO
22
TODO
|
|
@ -1,22 +0,0 @@
|
|||
TODO
|
||||
====
|
||||
|
||||
* bookmarklet for posting to blog
|
||||
|
||||
* comment admin
|
||||
|
||||
* comment notification
|
||||
|
||||
* promote JS links on project pages (the only ones that mention javascript so far!)
|
||||
|
||||
* last commit date
|
||||
|
||||
* svg commit graph
|
||||
|
||||
* semantic markup (section, nav, header, footer, etc)
|
||||
|
||||
* rss -> atom
|
||||
|
||||
* announce posts on twitter
|
||||
|
||||
* finish recovering old posts and comments
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
body { margin: 0
|
||||
; padding: 0
|
||||
}
|
||||
|
||||
h1 { margin: 0
|
||||
; padding: 0.2em
|
||||
; color: #9ab
|
||||
}
|
||||
|
||||
.center { text-align: center
|
||||
; font-size: 1.2em
|
||||
}
|
||||
|
||||
.hidden { display: none }
|
||||
|
||||
#index { width: 80%
|
||||
; min-width: 300px
|
||||
; max-width: 800px
|
||||
; border: solid 1px #999
|
||||
; -moz-border-radius: 10px
|
||||
; -webkit-border-radius: 10px
|
||||
; border-radius: 10px
|
||||
; background-color: #eee
|
||||
; margin: 1em auto
|
||||
; padding: 1em
|
||||
; font-size: 1.2em
|
||||
; line-height: 1.5em
|
||||
; list-style-type: none
|
||||
}
|
||||
|
||||
.date { float: right }
|
||||
|
||||
#article,
|
||||
article { width: 80%
|
||||
; min-width: 400px
|
||||
; max-width: 800px
|
||||
; margin: 0.6em auto
|
||||
; font-size: 1.2em
|
||||
; line-height: 1.4em
|
||||
; color: #222
|
||||
}
|
||||
|
||||
#article h1,
|
||||
article h1 { text-align: left
|
||||
; font-size: 2em
|
||||
; line-height: 1.2em
|
||||
; font-weight: normal
|
||||
; color: #222
|
||||
; margin: 0
|
||||
; padding-left: 0
|
||||
}
|
||||
|
||||
#article h1 a,
|
||||
article h1 a { color: #222
|
||||
; text-decoration: underline
|
||||
; border-bottom: none
|
||||
; text-shadow: #ccc 1px 1px 5px
|
||||
; -webkit-transition: text-shadow 0.4s ease-in
|
||||
}
|
||||
|
||||
#article h1 a:hover,
|
||||
article h1 a:hover { text-shadow: 1px 1px 6px #ffc
|
||||
; color: #000
|
||||
}
|
||||
|
||||
#article h2,
|
||||
article h2 { font-size: 1.8em
|
||||
; font-weight: normal
|
||||
; text-align: left
|
||||
; margin: 1em 0
|
||||
; padding: 0
|
||||
; color: #222
|
||||
}
|
||||
|
||||
#article h3,
|
||||
article h3 { font-size: 1.6em
|
||||
; font-weight: normal
|
||||
}
|
||||
|
||||
.time,
|
||||
time { color: #444
|
||||
; font-size: 1.2em
|
||||
}
|
||||
|
||||
.permalink { font-size: 1em }
|
||||
|
||||
.gist { font-size: 0.8em }
|
||||
|
||||
/* show discussion */
|
||||
#sd-container { margin: 3em 0 }
|
||||
|
||||
input[type=submit],
|
||||
#sd { border: solid 1px #999
|
||||
; border-right-color: #333
|
||||
; border-bottom-color: #333
|
||||
; padding: 0.4em 1em
|
||||
; color: #444
|
||||
; background-color: #ececec
|
||||
; -moz-border-radius: 5px
|
||||
; -webkit-border-radius: 5px
|
||||
; border-radius: 5px
|
||||
; text-decoration: none
|
||||
; margin: 0 2px 2px 0
|
||||
}
|
||||
|
||||
input[type=submit]:active,
|
||||
#sd:active { margin: 2px 0 0 2px
|
||||
; color: #000
|
||||
; background-color: #ffc
|
||||
}
|
||||
|
||||
#comment-stuff { display: none
|
||||
; color: #efefef
|
||||
; margin: 0
|
||||
; padding: 2em 0
|
||||
}
|
||||
|
||||
#comments-spinner { text-align: center }
|
||||
|
||||
#comments { width: 70%
|
||||
; max-width: 600px
|
||||
; margin: 0 auto
|
||||
}
|
||||
|
||||
.comment { color: #555
|
||||
; border-top: solid 2px #ccc
|
||||
; padding-bottom: 2em
|
||||
; margin-bottom: 2em
|
||||
}
|
||||
|
||||
.comment big { font-size: 2em
|
||||
; font-family: Verdana, sans-serif
|
||||
}
|
||||
|
||||
#comment-form { width: 400px
|
||||
; margin: 2em auto 0
|
||||
}
|
||||
|
||||
input[type=text],
|
||||
textarea { font-size: 1.4em
|
||||
; color: #333
|
||||
; width: 100%
|
||||
; padding: 0.2em
|
||||
; border: solid 1px #999
|
||||
; -moz-border-radius: 5px
|
||||
; -webkit-border-radius: 5px
|
||||
; border-radius: 5px
|
||||
; font-family: verdana, sans-serif
|
||||
}
|
||||
|
||||
input:focus[type=text],
|
||||
textarea:focus { border: solid 1px #333 }
|
||||
|
||||
textarea { height: 100px }
|
||||
|
||||
input[type=submit] { font-size: 1.1em
|
||||
; cursor: pointer
|
||||
}
|
||||
|
||||
pre { background-color: #eeeef3
|
||||
; margin: 0.5em 1em 1em
|
||||
; padding: 0.5em
|
||||
; border: dashed 1px #ccc
|
||||
}
|
||||
|
||||
footer { margin: 0 auto
|
||||
; padding: 0.2em 0
|
||||
; border-top: solid 1px #ddd
|
||||
; clear: both
|
||||
; width: 80%
|
||||
}
|
||||
|
||||
footer p { margin: 0.5em }
|
||||
|
||||
footer a { border-bottom: none
|
||||
; color: #25c
|
||||
; font-size: 1.2em
|
||||
; text-decoration: none
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
ul { behavior: none
|
||||
; padding-bottom: 25px
|
||||
}
|
||||
|
||||
img { behavior: url(../js/iepngfix.htc)
|
||||
; behavior: url(../../js/iepngfix.htc)
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
ul#projects li { list-style-type: none }
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
/* phones and iPad */
|
||||
|
||||
@media only screen and (orientation: portrait) and (min-device-width: 768px) and (max-device-width: 1024px),
|
||||
only screen and (min-device-width: 320px) and (max-device-width: 480px),
|
||||
only screen and (max-device-width: 800px)
|
||||
{
|
||||
ul.nav { padding: 0.5em
|
||||
; width: 60%
|
||||
; max-width: 600px
|
||||
}
|
||||
|
||||
ul.nav li { display: block
|
||||
; font-size: 1.5em
|
||||
; line-height: 1.8em
|
||||
}
|
||||
|
||||
ul.nav li:after { content: '' }
|
||||
}
|
||||
|
||||
/* phones */
|
||||
@media only screen and (min-device-width: 320px) and (max-device-width: 480px),
|
||||
handheld and (max-device-width: 800px)
|
||||
{
|
||||
/* common */
|
||||
|
||||
h1 { font-size: 2em
|
||||
; margin-top: 0.5em
|
||||
}
|
||||
h2 { font-size: 1.3em; line-height: 1.2em }
|
||||
|
||||
.navbar { font-size: 0.9em }
|
||||
.navbar { width: 32% }
|
||||
#breadcrumbs { margin-left: 5px }
|
||||
|
||||
#show-posts { margin-top: 1em
|
||||
; font-size: 0.8em
|
||||
}
|
||||
|
||||
#forkme { display: none }
|
||||
|
||||
ul.nav { width: 80% }
|
||||
|
||||
ul.nav li { font-size: 1.4em
|
||||
; line-height: 1.6em
|
||||
}
|
||||
|
||||
td { font-size: 1em
|
||||
; line-height: 1.1em
|
||||
}
|
||||
|
||||
#blog img { max-width: 100% }
|
||||
|
||||
#index { width: 90%
|
||||
; min-width: 200px
|
||||
; margin: 0.3em auto 1em
|
||||
; padding: 0.5em
|
||||
; font-size: 1em
|
||||
}
|
||||
|
||||
#index li > span.date { display: block
|
||||
; float: none
|
||||
; color: #666
|
||||
; font-size: 0.8em
|
||||
}
|
||||
|
||||
#blog #article h1,
|
||||
#blog article h1 { font-size: 1.6em
|
||||
; line-height: 1.2em
|
||||
; margin-top: 0
|
||||
}
|
||||
|
||||
#blog article h2 { font-size: 1.4em }
|
||||
|
||||
#article,
|
||||
article { min-width: 310px
|
||||
; margin: 0
|
||||
; padding: 0.6em 0.4em
|
||||
; font-size: 0.8em
|
||||
}
|
||||
|
||||
.time,
|
||||
time { font-size: 1.0em }
|
||||
|
||||
pre, .gist { font-size: 0.8em }
|
||||
|
||||
#comment-stuff { padding: 0
|
||||
; margin-top: 2em
|
||||
}
|
||||
|
||||
#comments { width: 100% }
|
||||
|
||||
#comment-form { width: 90%
|
||||
; margin: 0 auto
|
||||
}
|
||||
|
||||
input[type=text],
|
||||
textarea { font-size: 1.2em
|
||||
; width: 95%
|
||||
}
|
||||
|
||||
input[type=submit] { font-size: 1em }
|
||||
|
||||
/* proj */
|
||||
#info { width: 70%
|
||||
; padding: 0 1em
|
||||
}
|
||||
|
||||
#info > div { clear: left
|
||||
; width: 100%
|
||||
; max-width: 100%
|
||||
; padding: 0.5em 0.2em 1em
|
||||
; border-left: none
|
||||
; font-size: 1em
|
||||
}
|
||||
|
||||
#stats { font-size: 1em; margin-bottom: 0.5em }
|
||||
|
||||
footer { margin: 0
|
||||
; padding: 0.5em 0
|
||||
; font-size: 1em
|
||||
; width: 100%
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* landscape */
|
||||
|
||||
@media only screen and (orientation: landscape) and (min-device-width: 768px) and (max-device-width: 1024px),
|
||||
only screen and (orientation: landscape) and (min-device-width: 320px) and (max-device-width: 480px),
|
||||
handheld and (orientation: landscape) and (max-device-width: 800px)
|
||||
{
|
||||
body { font-size: 0.8em }
|
||||
}
|
||||
|
||||
|
||||
/* iPad portrait */
|
||||
@media only screen and (orientation: portrait) and (min-device-width: 768px) and (max-device-width: 1024px)
|
||||
{
|
||||
article > header > h1 { font-size: 1.8em }
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
td { font-size: 1.5em
|
||||
; line-height: 1.6em
|
||||
}
|
||||
|
||||
td:nth-child(2) { padding: 0 10px }
|
||||
|
||||
.highlight { font-size: 1.2em }
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
#stats a { text-decoration: none }
|
||||
|
||||
#info { text-align: center
|
||||
; margin: 1em auto
|
||||
; padding: 1em
|
||||
; border: solid 1px #ccc
|
||||
; width: 90%
|
||||
; max-width: 950px
|
||||
; background-color: #fff
|
||||
; -moz-border-radius: 20px
|
||||
; -webkit-border-radius: 20px
|
||||
; border-radius: 20px
|
||||
; behavior: url(../js/border-radius.htc)
|
||||
; behavior: url(../../js/border-radius.htc)
|
||||
}
|
||||
|
||||
h4 { margin: 0.5em 0 0.7em }
|
||||
|
||||
#info > div { text-align: center
|
||||
; font-size: 1.3em
|
||||
; width: 31%
|
||||
; max-width: 400px
|
||||
; float: left
|
||||
; display: inline
|
||||
; padding: 0.5em 0.2em
|
||||
; border-left: dashed 1px #aaa
|
||||
}
|
||||
|
||||
#info > div:first-child { border-left: none }
|
||||
|
||||
#info ul { list-style-type: none
|
||||
; text-align: center
|
||||
; padding: 0
|
||||
; margin: 0
|
||||
}
|
||||
|
||||
#info li { padding: 0.2em 0
|
||||
; margin: 0
|
||||
}
|
||||
|
||||
#info > br.clear { clear: both }
|
||||
|
||||
#contributors-box a { line-height: 1.8em }
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
body { background-color: #f7f7f7
|
||||
; color: #222
|
||||
; font-family: 'Helvetica Neue', Verdana, sans-serif
|
||||
}
|
||||
|
||||
h1 { text-align: center
|
||||
; font-size: 2em
|
||||
; font-weight: normal
|
||||
; margin: 0.8em 0 0.4em
|
||||
; padding: 0
|
||||
}
|
||||
|
||||
h2 { text-align: center
|
||||
; font-size: 1.7em
|
||||
; line-height: 1.1em
|
||||
; font-weight: normal
|
||||
; margin: 0.2em 0 1em
|
||||
; padding: 0
|
||||
}
|
||||
|
||||
a { color: #0E539C }
|
||||
|
||||
a.img { border: none }
|
||||
|
||||
.navbar { display: inline-block
|
||||
; width: 33%
|
||||
; font-size: 1.5em
|
||||
; line-height: 1.8em
|
||||
; margin: 0
|
||||
; padding: 0
|
||||
}
|
||||
|
||||
.navbar a { text-shadow: none }
|
||||
|
||||
#breadcrumbs a { color: #222 }
|
||||
#title { text-align: center }
|
||||
#archive { text-align: right }
|
||||
|
||||
#forkme { position: absolute
|
||||
; top: 0
|
||||
; right: 0
|
||||
; border: none
|
||||
}
|
||||
|
||||
ul.nav { text-align: center
|
||||
; max-width: 400px
|
||||
; margin: 0 auto
|
||||
; padding: 1em
|
||||
; border: solid 1px #ccc
|
||||
; background-color: #fff
|
||||
; -moz-border-radius: 20px
|
||||
; -webkit-border-radius: 20px
|
||||
; border-radius: 20px
|
||||
; behavior: url(js/border-radius.htc)
|
||||
; behavior: url(../js/border-radius.htc)
|
||||
}
|
||||
|
||||
ul.nav li { display: block
|
||||
; font-size: 1.6em
|
||||
; line-height: 1.8em
|
||||
; margin: 0
|
||||
; padding: 0
|
||||
}
|
||||
|
||||
ul.nav li a { padding: 5px
|
||||
; text-decoration: none
|
||||
; border-bottom: solid 1px #fff
|
||||
; text-shadow: #ccc 2px 2px 3px
|
||||
}
|
||||
ul.nav li a:visited { color: #227 }
|
||||
|
||||
ul.nav li a:hover,
|
||||
ul.nav li a:active { text-shadow: #cca 2px 2px 3px
|
||||
; border-bottom: solid 1px #aaa
|
||||
}
|
||||
|
||||
ul.nav li a:active { text-shadow: none }
|
||||
|
||||
footer { text-align: center
|
||||
; font-size: 1.2em
|
||||
; margin: 1em
|
||||
}
|
||||
|
||||
footer a { border-bottom: none }
|
||||
|
||||
#promote-js { margin-top: 3em
|
||||
; text-align: center
|
||||
}
|
||||
|
||||
#promote-js img { border: none }
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
;(function() {
|
||||
if (typeof console === 'undefined')
|
||||
window.console = {}
|
||||
if (typeof console.log !== 'function')
|
||||
window.console.log = function(){}
|
||||
if (typeof console.dir !== 'function')
|
||||
window.console.dir = function(){}
|
||||
|
||||
var server = 'http://bohodev.net:8000/'
|
||||
, getCommentsURL = function(post) { return server + 'comments/' + post }
|
||||
, postCommentURL = function() { return server + 'comment' }
|
||||
, countCommentsURL = function(post) { return server + 'count/' + post }
|
||||
|
||||
function getComments(cb) {
|
||||
SJS.request({uri: getCommentsURL(SJS.filename)}, function(err, request, body) {
|
||||
if (err) {
|
||||
if (typeof cb === 'function') cb(err)
|
||||
return
|
||||
}
|
||||
var data
|
||||
, comments
|
||||
, h = ''
|
||||
try {
|
||||
data = JSON.parse(body)
|
||||
}
|
||||
catch (e) {
|
||||
console.log('not json -> ' + body)
|
||||
return
|
||||
}
|
||||
comments = data.comments
|
||||
if (comments && comments.length) {
|
||||
h = data.comments.map(function(c) {
|
||||
return tmpl('comment_tmpl', c)
|
||||
}).join('')
|
||||
$('#comments').html(h)
|
||||
}
|
||||
if (typeof cb === 'function') cb()
|
||||
})
|
||||
}
|
||||
|
||||
function showComments(cb) {
|
||||
$('#sd-container').remove()
|
||||
getComments(function(err) {
|
||||
$('#comments-spinner').hide()
|
||||
if (err) {
|
||||
$('#comments').text('derp')
|
||||
if (typeof cb === 'function') cb(err)
|
||||
}
|
||||
else {
|
||||
$('#comment-stuff').slideDown(1.5, function() {
|
||||
if (typeof cb === 'function') cb()
|
||||
else this.scrollIntoView(true)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
jQuery(function($) {
|
||||
|
||||
$('#need-js').remove()
|
||||
|
||||
SJS.request({uri: countCommentsURL(SJS.filename)}, function(err, request, body) {
|
||||
if (err) return
|
||||
var data
|
||||
, n
|
||||
try {
|
||||
data = JSON.parse(body)
|
||||
}
|
||||
catch (e) {
|
||||
console.log('not json -> ' + body)
|
||||
return
|
||||
}
|
||||
n = data.count
|
||||
$('#sd').text(n > 0 ? 'show the discussion (' + n + ')' : 'start the discussion')
|
||||
})
|
||||
|
||||
// jump to comment if linked directly
|
||||
var hash = window.location.hash || ''
|
||||
if (/^#comment-\d+/.test(hash)) {
|
||||
showComments(function (err) {
|
||||
if (!err) {
|
||||
window.location.hash = ''
|
||||
window.location.hash = hash
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
$('#sd').click(showComments)
|
||||
|
||||
var showdown = new Showdown.converter()
|
||||
, tzOffset = -new Date().getTimezoneOffset() * 60 * 1000
|
||||
|
||||
$('#comment-form').submit(function() {
|
||||
var comment = $(this).serializeObject()
|
||||
comment.name = (comment.name || '').trim() || 'anonymous'
|
||||
comment.url = (comment.url || '').trim()
|
||||
if (comment.url && !comment.url.match(/^https?:\/\//)) {
|
||||
comment.url = 'http://' + comment.url
|
||||
}
|
||||
comment.body = comment.body || ''
|
||||
if (!comment.body) {
|
||||
alert("is that all you have to say?")
|
||||
document.getElementById('thoughts').focus()
|
||||
return false
|
||||
}
|
||||
comment.timestamp = +new Date() + tzOffset
|
||||
|
||||
var options = { method: 'POST'
|
||||
, uri: postCommentURL()
|
||||
, body: JSON.stringify(comment)
|
||||
}
|
||||
SJS.request(options, function(err, request, body) {
|
||||
if (err) {
|
||||
console.dir(err)
|
||||
alert('derp')
|
||||
return false
|
||||
}
|
||||
|
||||
$('#comment-form').get(0).reset()
|
||||
|
||||
comment.timestamp = +new Date()
|
||||
comment.html = showdown.makeHtml(comment.body)
|
||||
comment.name = (comment.name || '').trim() || 'anonymous'
|
||||
comment.url = (comment.url || '').trim()
|
||||
if (comment.url && !comment.url.match(/^https?:\/\//)) {
|
||||
comment.url = 'http://' + comment.url
|
||||
}
|
||||
$('#comments').append(tmpl('comment_tmpl', comment))
|
||||
})
|
||||
return false
|
||||
})
|
||||
})
|
||||
}());
|
||||
31
assets/js/jquery-serializeObject.js
vendored
31
assets/js/jquery-serializeObject.js
vendored
|
|
@ -1,31 +0,0 @@
|
|||
/*!
|
||||
* jQuery serializeObject - v0.2 - 1/20/2010
|
||||
* http://benalman.com/projects/jquery-misc-plugins/
|
||||
*
|
||||
* Copyright (c) 2010 "Cowboy" Ben Alman
|
||||
* Dual licensed under the MIT and GPL licenses.
|
||||
* http://benalman.com/about/license/
|
||||
*/
|
||||
|
||||
// Whereas .serializeArray() serializes a form into an array, .serializeObject()
|
||||
// serializes a form into an (arguably more useful) object.
|
||||
|
||||
;(function($,undefined){
|
||||
'$:nomunge'; // Used by YUI compressor.
|
||||
|
||||
$.fn.serializeObject = function(){
|
||||
var obj = {};
|
||||
|
||||
$.each( this.serializeArray(), function(i,o){
|
||||
var n = o.name,
|
||||
v = o.value;
|
||||
|
||||
obj[n] = obj[n] === undefined ? v
|
||||
: $.isArray( obj[n] ) ? obj[n].concat( v )
|
||||
: [ obj[n], v ];
|
||||
});
|
||||
|
||||
return obj;
|
||||
};
|
||||
|
||||
})(jQuery);
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
;(function() {
|
||||
if (typeof console === 'undefined') {
|
||||
console = {log:function(){}}
|
||||
}
|
||||
|
||||
var global = this
|
||||
global.SJS = {
|
||||
proj: function(name) {
|
||||
SJS.projName = name
|
||||
var data = createObjectStore(name)
|
||||
if (document.addEventListener) {
|
||||
document.addEventListener('DOMContentLoaded', ready, false)
|
||||
} else if (window.attachEvent) {
|
||||
window.attachEvent('onload', ready)
|
||||
}
|
||||
function ready() {
|
||||
function addClass(el, name) {
|
||||
var c = el.className || name
|
||||
if (!c.match(new RegExp('\b' + name + '\b', 'i'))) c += ' ' + name
|
||||
}
|
||||
function html(id, h) {
|
||||
document.getElementById(id).innerHTML = h
|
||||
}
|
||||
|
||||
var body = document.getElementsByTagName('body')[0]
|
||||
, text
|
||||
if ('innerText' in body) {
|
||||
text = function(id, text) {
|
||||
document.getElementById(id).innerText = text
|
||||
}
|
||||
} else {
|
||||
text = function(id, text) {
|
||||
document.getElementById(id).textContent = text
|
||||
}
|
||||
}
|
||||
|
||||
function highlight(id) {
|
||||
document.getElementById(id).style.className = ' highlight'
|
||||
}
|
||||
function textHighlight(id, t) {
|
||||
text(id, t)
|
||||
document.getElementById(id).className = ' highlight'
|
||||
}
|
||||
function hide(id) {
|
||||
document.getElementById(id).style.display = 'none'
|
||||
}
|
||||
|
||||
function langsByUsage(langs) {
|
||||
return Object.keys(langs).sort(function(a, b) {
|
||||
return langs[a] < langs[b] ? -1 : 1
|
||||
})
|
||||
}
|
||||
|
||||
function listify(things) {
|
||||
return '<ul><li>' + things.join('</li><li>') + '</li></ul>'
|
||||
}
|
||||
|
||||
function updateBranches(name, branches) {
|
||||
function branchLink(b) {
|
||||
return '<a href=https://github.com/samsonjs/' + name + '/tree/' + b.name + '>' + b.name + '</a>'
|
||||
}
|
||||
html('branches', listify(branches.map(branchLink)))
|
||||
}
|
||||
|
||||
function updateContributors(contributors) {
|
||||
function userLink(u) {
|
||||
return '<a href=https://github.com/' + u.login + '>' + (u.name || u.login) + '</a>'
|
||||
}
|
||||
html('contributors', listify(contributors.map(userLink)))
|
||||
}
|
||||
|
||||
function updateLangs(langs) {
|
||||
html('langs', listify(langsByUsage(langs)))
|
||||
}
|
||||
|
||||
function updateN(name, things) {
|
||||
textHighlight('n' + name, things.length)
|
||||
if (things.length === 1) hide(name.charAt(0) + 'plural')
|
||||
}
|
||||
|
||||
var t = data.get('t-' + name)
|
||||
if (!t || +new Date() - t > 3600 * 1000) {
|
||||
console.log('stale ' + String(t))
|
||||
data.set('t-' + name, +new Date())
|
||||
GITR.repo('samsonjs', name)
|
||||
.fetchBranches(function(err, branches) {
|
||||
if (err) {
|
||||
text('branches', '(oops)')
|
||||
} else {
|
||||
data.set('branches', branches)
|
||||
updateBranches(name, branches)
|
||||
}
|
||||
})
|
||||
.fetchLanguages(function(err, langs) {
|
||||
if (err) {
|
||||
text('langs', '(oops)')
|
||||
return
|
||||
}
|
||||
data.set('langs', langs)
|
||||
updateLangs(langs)
|
||||
})
|
||||
.fetchContributors(function(err, users) {
|
||||
if (err) {
|
||||
text('contributors', '(oops)')
|
||||
} else {
|
||||
data.set('contributors', users)
|
||||
updateContributors(users)
|
||||
}
|
||||
})
|
||||
.fetchWatchers(function(err, users) {
|
||||
if (err) {
|
||||
text('nwatchers', '?')
|
||||
} else {
|
||||
data.set('watchers', users)
|
||||
updateN('watchers', users)
|
||||
}
|
||||
})
|
||||
.fetchForks(function(err, repos) {
|
||||
if (err) {
|
||||
text('nforks', '?')
|
||||
} else {
|
||||
data.set('forks', repos)
|
||||
updateN('forks', repos)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
console.log('hit ' + t + ' (' + (+new Date() - t) + ')')
|
||||
updateBranches(name, data.get('branches'))
|
||||
updateLangs(data.get('langs'))
|
||||
updateContributors(data.get('contributors'))
|
||||
updateN('watchers', data.get('watchers'))
|
||||
updateN('forks', data.get('forks'))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}());
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
;(function() {
|
||||
if (typeof window.SJS === 'undefined') window.SJS = {}
|
||||
|
||||
// cors xhr request, quacks like mikeal's request module
|
||||
window.SJS.request = function(options, cb) {
|
||||
var url = options.uri
|
||||
, method = options.method || 'GET'
|
||||
, headers = options.headers || {}
|
||||
, body = typeof options.body === 'undefined' ? null : String(options.body)
|
||||
, xhr = new XMLHttpRequest()
|
||||
|
||||
// withCredentials => cors
|
||||
if ('withCredentials' in xhr) {
|
||||
xhr.open(method, url, true)
|
||||
} else if (typeof XDomainRequest === 'function') {
|
||||
xhr = new XDomainRequest()
|
||||
xhr.open(method, url)
|
||||
} else {
|
||||
cb(new Error('cross domain requests not supported'))
|
||||
return
|
||||
}
|
||||
for (var k in headers) if (headers.hasOwnProperty(k)) {
|
||||
xhr.setRequestHeader(k, headers[k])
|
||||
}
|
||||
xhr.onload = function() {
|
||||
if (xhr.status === 200 || xhr.status === 204) {
|
||||
cb(null, xhr, xhr.responseText)
|
||||
}
|
||||
else {
|
||||
console.log('xhr error ' + xhr.status + ': ' + xhr.responseText)
|
||||
cb(new Error('error: ' + xhr.status))
|
||||
}
|
||||
}
|
||||
xhr.send(body)
|
||||
}
|
||||
}());
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,92 +0,0 @@
|
|||
if (!window.localStorage || !window.sessionStorage) (function () {
|
||||
|
||||
var Storage = function (type) {
|
||||
function createCookie(name, value, days) {
|
||||
var date, expires;
|
||||
|
||||
if (days) {
|
||||
date = new Date();
|
||||
date.setTime(date.getTime()+(days*24*60*60*1000));
|
||||
expires = "; expires="+date.toGMTString();
|
||||
} else {
|
||||
expires = "";
|
||||
}
|
||||
document.cookie = name+"="+value+expires+"; path=/";
|
||||
}
|
||||
|
||||
function readCookie(name) {
|
||||
var nameEQ = name + "=",
|
||||
ca = document.cookie.split(';'),
|
||||
i, c;
|
||||
|
||||
for (i=0; i < ca.length; i++) {
|
||||
c = ca[i];
|
||||
while (c.charAt(0)==' ') {
|
||||
c = c.substring(1,c.length);
|
||||
}
|
||||
|
||||
if (c.indexOf(nameEQ) == 0) {
|
||||
return c.substring(nameEQ.length,c.length);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function setData(data) {
|
||||
data = JSON.stringify(data);
|
||||
if (type == 'session') {
|
||||
window.top.name = data;
|
||||
} else {
|
||||
createCookie('localStorage', data, 365);
|
||||
}
|
||||
}
|
||||
|
||||
function clearData() {
|
||||
if (type == 'session') {
|
||||
window.top.name = '';
|
||||
} else {
|
||||
createCookie('localStorage', '', 365);
|
||||
}
|
||||
}
|
||||
|
||||
function getData() {
|
||||
var data = type == 'session' ? window.top.name : readCookie('localStorage');
|
||||
return data ? JSON.parse(data) : {};
|
||||
}
|
||||
|
||||
|
||||
// initialise if there's already data
|
||||
var data = getData();
|
||||
|
||||
return {
|
||||
clear: function () {
|
||||
data = {};
|
||||
clearData();
|
||||
},
|
||||
getItem: function (key) {
|
||||
return data[key] || null;
|
||||
},
|
||||
key: function (i) {
|
||||
// not perfect, but works
|
||||
var ctr = 0;
|
||||
for (var k in data) {
|
||||
if (ctr == i) return k;
|
||||
else ctr++;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
removeItem: function (key) {
|
||||
delete data[key];
|
||||
setData(data);
|
||||
},
|
||||
setItem: function (key, value) {
|
||||
data[key] = value+''; // forces the value to a string
|
||||
setData(data);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
if (!window.localStorage) window.localStorage = new Storage('local');
|
||||
if (!window.sessionStorage) window.sessionStorage = new Storage('session');
|
||||
|
||||
}());
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
/// strftime
|
||||
/// http://github.com/samsonjs/strftime
|
||||
/// @_sjs
|
||||
///
|
||||
/// Copyright 2010 Sami Samhuri <sami@samhuri.net>
|
||||
/// MIT License
|
||||
|
||||
var strftime = (function() {
|
||||
var Weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday',
|
||||
'Friday', 'Saturday'];
|
||||
|
||||
var WeekdaysShort = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
var Months = ['January', 'February', 'March', 'April', 'May', 'June', 'July',
|
||||
'August', 'September', 'October', 'November', 'December'];
|
||||
|
||||
var MonthsShort = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug',
|
||||
'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
function pad(n, padding) {
|
||||
padding = padding || '0';
|
||||
return n < 10 ? (padding + n) : n;
|
||||
}
|
||||
|
||||
function hours12(d) {
|
||||
var hour = d.getHours();
|
||||
if (hour == 0) hour = 12;
|
||||
else if (hour > 12) hour -= 12;
|
||||
return hour;
|
||||
}
|
||||
|
||||
// Most of the specifiers supported by C's strftime
|
||||
function strftime(fmt, d) {
|
||||
d || (d = new Date());
|
||||
return fmt.replace(/%(.)/g, function(_, c) {
|
||||
switch (c) {
|
||||
case 'A': return Weekdays[d.getDay()];
|
||||
case 'a': return WeekdaysShort[d.getDay()];
|
||||
case 'B': return Months[d.getMonth()];
|
||||
case 'b': // fall through
|
||||
case 'h': return MonthsShort[d.getMonth()];
|
||||
case 'D': return strftime('%m/%d/%y', d);
|
||||
case 'd': return pad(d.getDate());
|
||||
case 'e': return d.getDate();
|
||||
case 'F': return strftime('%Y-%m-%d', d);
|
||||
case 'H': return pad(d.getHours());
|
||||
case 'I': return pad(hours12(d));
|
||||
case 'k': return pad(d.getHours(), ' ');
|
||||
case 'l': return pad(hours12(d), ' ');
|
||||
case 'M': return pad(d.getMinutes());
|
||||
case 'm': return pad(d.getMonth() + 1);
|
||||
case 'n': return '\n';
|
||||
case 'p': return d.getHours() < 12 ? 'AM' : 'PM';
|
||||
case 'R': return strftime('%H:%M', d);
|
||||
case 'r': return strftime('%I:%M:%S %p', d);
|
||||
case 'S': return pad(d.getSeconds());
|
||||
case 's': return d.getTime();
|
||||
case 'T': return strftime('%H:%M:%S', d);
|
||||
case 't': return '\t';
|
||||
case 'u':
|
||||
var day = d.getDay();
|
||||
return day == 0 ? 7 : day; // 1 - 7, Monday is first day of the week
|
||||
case 'v': return strftime('%e-%b-%Y', d);
|
||||
case 'w': return d.getDay(); // 0 - 6, Sunday is first day of the week
|
||||
case 'Y': return d.getFullYear();
|
||||
case 'y':
|
||||
var year = d.getYear();
|
||||
return year < 100 ? year : year - 100;
|
||||
case 'Z':
|
||||
var tz = d.toString().match(/\((\w+)\)/);
|
||||
return tz && tz[1] || '';
|
||||
case 'z':
|
||||
var off = d.getTimezoneOffset();
|
||||
return (off < 0 ? '-' : '+') + pad(off / 60) + pad(off % 60);
|
||||
default: return c;
|
||||
}
|
||||
});
|
||||
}
|
||||
return strftime;
|
||||
}());
|
||||
|
||||
if (typeof exports !== 'undefined') exports.strftime = strftime;
|
||||
else (function(global) { global.strftime = strftime }(this));
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
// Simple JavaScript Templating
|
||||
// John Resig - http://ejohn.org/ - MIT Licensed
|
||||
;(function(){
|
||||
var cache = {};
|
||||
|
||||
this.tmpl = function tmpl(str, data){
|
||||
// Figure out if we're getting a template, or if we need to
|
||||
// load the template - and be sure to cache the result.
|
||||
var fn = !/\W/.test(str) ?
|
||||
cache[str] = cache[str] ||
|
||||
tmpl(document.getElementById(str).innerHTML) :
|
||||
|
||||
// Generate a reusable function that will serve as a template
|
||||
// generator (and which will be cached).
|
||||
new Function("obj",
|
||||
"var p=[],print=function(){p.push.apply(p,arguments);};" +
|
||||
|
||||
// Introduce the data as local variables using with(){}
|
||||
"with(obj){p.push('" +
|
||||
|
||||
// Convert the template into pure JavaScript
|
||||
str
|
||||
.replace(/[\r\t\n]/g, " ")
|
||||
.split("<%").join("\t")
|
||||
.replace(/((^|%>)[^\t]*)'/g, "$1\r")
|
||||
.replace(/\t=(.*?)%>/g, "',$1,'")
|
||||
.split("\t").join("');")
|
||||
.split("%>").join("p.push('")
|
||||
.split("\r").join("\\'")
|
||||
+ "');}return p.join('');");
|
||||
|
||||
// Provide some basic currying to the user
|
||||
return data ? fn( data ) : fn;
|
||||
};
|
||||
}());
|
||||
500
bake.rb
Normal file
500
bake.rb
Normal file
|
|
@ -0,0 +1,500 @@
|
|||
# Build tasks for samhuri.net static site generator
|
||||
|
||||
require "etc"
|
||||
require "fileutils"
|
||||
require "open3"
|
||||
require "tmpdir"
|
||||
|
||||
LIB_PATH = File.expand_path("lib", __dir__).freeze
|
||||
$LOAD_PATH.unshift(LIB_PATH) unless $LOAD_PATH.include?(LIB_PATH)
|
||||
|
||||
DRAFTS_DIR = "public/drafts".freeze
|
||||
PUBLISH_HOST = "mudge".freeze
|
||||
PRODUCTION_PUBLISH_DIR = "/var/www/samhuri.net/public".freeze
|
||||
BETA_PUBLISH_DIR = "/var/www/beta.samhuri.net/public".freeze
|
||||
GEMINI_PUBLISH_DIR = "/var/gemini/samhuri.net".freeze
|
||||
WATCHABLE_DIRECTORIES = %w[public posts lib].freeze
|
||||
LINT_TARGETS = %w[bake.rb Gemfile lib test].freeze
|
||||
BUILD_TARGETS = %w[debug mudge beta release gemini].freeze
|
||||
|
||||
# Generate the site in debug mode (localhost:8000)
|
||||
def debug
|
||||
build("http://localhost:8000", output_format: "html", target_path: "www")
|
||||
end
|
||||
|
||||
# Generate the site for the mudge development server
|
||||
def mudge
|
||||
build("http://mudge:8000", output_format: "html", target_path: "www")
|
||||
end
|
||||
|
||||
# Generate the site for beta/staging
|
||||
def beta
|
||||
build("https://beta.samhuri.net", output_format: "html", target_path: "www")
|
||||
end
|
||||
|
||||
# Generate the site for production
|
||||
def release
|
||||
build("https://samhuri.net", output_format: "html", target_path: "www")
|
||||
end
|
||||
|
||||
# Generate the Gemini capsule for production
|
||||
def gemini
|
||||
build("https://samhuri.net", output_format: "gemini", target_path: "gemini")
|
||||
end
|
||||
|
||||
# Start local development server
|
||||
def serve
|
||||
require "webrick"
|
||||
server = WEBrick::HTTPServer.new(Port: 8000, DocumentRoot: "www")
|
||||
trap("INT") { server.shutdown }
|
||||
puts "Server running at http://localhost:8000"
|
||||
server.start
|
||||
end
|
||||
|
||||
# Create a new draft in public/drafts/.
|
||||
# @parameter title_parts [Array] Optional title words; defaults to Untitled.
|
||||
def new_draft(*title_parts)
|
||||
title, filename =
|
||||
if title_parts.empty?
|
||||
["Untitled", next_available_draft]
|
||||
else
|
||||
given_title = title_parts.join(" ")
|
||||
slug = slugify(given_title)
|
||||
abort "Error: title cannot be converted to a filename." if slug.empty?
|
||||
|
||||
filename = "#{slug}.md"
|
||||
path = draft_path(filename)
|
||||
abort "Error: draft already exists at #{path}" if File.exist?(path)
|
||||
|
||||
[given_title, filename]
|
||||
end
|
||||
|
||||
FileUtils.mkdir_p(DRAFTS_DIR)
|
||||
path = draft_path(filename)
|
||||
content = render_draft_template(title)
|
||||
File.write(path, content)
|
||||
|
||||
puts "Created new draft at #{path}"
|
||||
puts ">>> Contents below <<<"
|
||||
puts
|
||||
puts content
|
||||
end
|
||||
|
||||
# Publish a draft by moving it to posts/YYYY/MM and updating dates.
|
||||
# @parameter input_path [String] Draft path or filename in public/drafts.
|
||||
def publish_draft(input_path = nil)
|
||||
if input_path.nil? || input_path.strip.empty?
|
||||
puts "Usage: bake publish_draft <draft-path-or-filename>"
|
||||
puts
|
||||
puts "Available drafts:"
|
||||
drafts = Dir.glob("#{DRAFTS_DIR}/*.md").map { |path| File.basename(path) }
|
||||
if drafts.empty?
|
||||
puts " (no drafts found)"
|
||||
else
|
||||
drafts.each { |draft| puts " #{draft}" }
|
||||
end
|
||||
abort
|
||||
end
|
||||
|
||||
draft_path_value, draft_file = resolve_draft_input(input_path)
|
||||
abort "Error: File not found: #{draft_path_value}" unless File.exist?(draft_path_value)
|
||||
|
||||
now = Time.now
|
||||
content = File.read(draft_path_value)
|
||||
content.sub!(/^Date:.*$/, "Date: #{ordinal_date(now)}")
|
||||
content.sub!(/^Timestamp:.*$/, "Timestamp: #{now.strftime("%Y-%m-%dT%H:%M:%S%:z")}")
|
||||
|
||||
target_dir = "posts/#{now.strftime("%Y/%m")}"
|
||||
FileUtils.mkdir_p(target_dir)
|
||||
target_path = "#{target_dir}/#{draft_file}"
|
||||
|
||||
File.write(target_path, content)
|
||||
FileUtils.rm_f(draft_path_value)
|
||||
|
||||
puts "Published draft: #{draft_path_value} -> #{target_path}"
|
||||
end
|
||||
|
||||
# Watch content directories and rebuild on every change.
|
||||
# @parameter target [String] One of debug, mudge, beta, release, or gemini.
|
||||
def watch(target: "debug")
|
||||
unless command_available?("inotifywait")
|
||||
abort "inotifywait is required (install inotify-tools)."
|
||||
end
|
||||
|
||||
loop do
|
||||
abort "Error: watch failed." unless system("inotifywait", "-e", "modify,create,delete,move", *watch_paths)
|
||||
puts "changed at #{Time.now}"
|
||||
sleep 2
|
||||
run_build_target(target)
|
||||
end
|
||||
end
|
||||
|
||||
# Publish to beta/staging server
|
||||
def publish_beta
|
||||
beta
|
||||
run_rsync(local_paths: ["www/"], publish_dir: BETA_PUBLISH_DIR, dry_run: false, delete: true)
|
||||
end
|
||||
|
||||
# Publish Gemini capsule to production
|
||||
def publish_gemini
|
||||
gemini
|
||||
run_rsync(local_paths: ["gemini/"], publish_dir: GEMINI_PUBLISH_DIR, dry_run: false, delete: true)
|
||||
end
|
||||
|
||||
# Publish to production server
|
||||
def publish
|
||||
release
|
||||
run_rsync(local_paths: ["www/"], publish_dir: PRODUCTION_PUBLISH_DIR, dry_run: false, delete: true)
|
||||
publish_gemini
|
||||
end
|
||||
|
||||
# Clean generated files
|
||||
def clean
|
||||
FileUtils.rm_rf("www")
|
||||
FileUtils.rm_rf("gemini")
|
||||
puts "Cleaned www/ and gemini/ directories"
|
||||
end
|
||||
|
||||
# Default task: run coverage and lint.
|
||||
def default
|
||||
coverage
|
||||
lint
|
||||
end
|
||||
|
||||
# Run Minitest tests
|
||||
def test
|
||||
run_test_suite(test_file_list)
|
||||
end
|
||||
|
||||
# Run Guard for continuous testing
|
||||
def guard
|
||||
exec "bundle exec guard"
|
||||
end
|
||||
|
||||
# List all available drafts
|
||||
def drafts
|
||||
Dir.glob("#{DRAFTS_DIR}/*.md").sort.each do |draft|
|
||||
puts File.basename(draft)
|
||||
end
|
||||
end
|
||||
|
||||
# Run StandardRB linter
|
||||
def lint
|
||||
run_standardrb
|
||||
end
|
||||
|
||||
# Auto-fix StandardRB issues
|
||||
def lint_fix
|
||||
run_standardrb("--fix")
|
||||
end
|
||||
|
||||
# Measure line coverage for files under lib/.
|
||||
# @parameter lowest [Integer] Number of lowest-covered files to print (default: 10, use 0 to hide).
|
||||
def coverage(lowest: 10)
|
||||
lowest_count = Integer(lowest)
|
||||
abort "Error: lowest must be >= 0." if lowest_count.negative?
|
||||
|
||||
run_coverage(test_files: test_file_list, lowest_count:)
|
||||
end
|
||||
|
||||
# Compare line coverage for files under lib/ against a baseline and fail on regression.
|
||||
# @parameter baseline [String] Baseline ref, or "merge-base" (default) to compare against merge-base with remote default branch.
|
||||
# @parameter lowest [Integer] Number of lowest-covered files to print for the current checkout (default: 10, use 0 to hide).
|
||||
def coverage_regression(baseline: "merge-base", lowest: 10)
|
||||
lowest_count = Integer(lowest)
|
||||
abort "Error: lowest must be >= 0." if lowest_count.negative?
|
||||
|
||||
baseline_ref = resolve_coverage_baseline_ref(baseline)
|
||||
baseline_commit = capture_command("git", "rev-parse", "--short", baseline_ref).strip
|
||||
|
||||
puts "Running coverage for current checkout..."
|
||||
current_output = capture_coverage_output(test_files: test_file_list, lowest_count:, chdir: Dir.pwd)
|
||||
print current_output
|
||||
current_percent = parse_coverage_percent(current_output)
|
||||
|
||||
puts "Running coverage for baseline #{baseline_ref} (#{baseline_commit})..."
|
||||
baseline_percent = with_temporary_worktree(ref: baseline_ref) do |worktree_path|
|
||||
baseline_tests = test_file_list(chdir: worktree_path)
|
||||
baseline_output = capture_coverage_output(test_files: baseline_tests, lowest_count: 0, chdir: worktree_path)
|
||||
parse_coverage_percent(baseline_output)
|
||||
end
|
||||
|
||||
delta = current_percent - baseline_percent
|
||||
puts format("Baseline coverage (%s %s): %.2f%%", baseline_ref, baseline_commit, baseline_percent)
|
||||
puts format("Coverage delta: %+0.2f%%", delta)
|
||||
|
||||
return unless delta.negative?
|
||||
|
||||
abort format("Error: coverage regressed by %.2f%% against %s (%s).", -delta, baseline_ref, baseline_commit)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def run_test_suite(test_files)
|
||||
run_command("ruby", "-Ilib", "-Itest", "-e", "ARGV.each { |file| require File.expand_path(file) }", *test_files)
|
||||
end
|
||||
|
||||
def run_coverage(test_files:, lowest_count:)
|
||||
output = capture_coverage_output(test_files:, lowest_count:, chdir: Dir.pwd)
|
||||
print output
|
||||
end
|
||||
|
||||
def test_file_list(chdir: Dir.pwd)
|
||||
test_files = Dir.chdir(chdir) { Dir.glob("test/**/*_test.rb").sort }
|
||||
abort "Error: no tests found in test/**/*_test.rb under #{chdir}" if test_files.empty?
|
||||
|
||||
test_files
|
||||
end
|
||||
|
||||
def coverage_script(lowest_count:)
|
||||
<<~RUBY
|
||||
require "coverage"
|
||||
|
||||
root = Dir.pwd
|
||||
lib_root = File.join(root, "lib") + "/"
|
||||
Coverage.start(lines: true)
|
||||
|
||||
at_exit do
|
||||
result = Coverage.result
|
||||
rows = result.keys
|
||||
.select { |file| file.start_with?(lib_root) && file.end_with?(".rb") }
|
||||
.sort
|
||||
.map do |file|
|
||||
lines = result[file][:lines] || []
|
||||
total = 0
|
||||
covered = 0
|
||||
lines.each do |line_count|
|
||||
next if line_count.nil?
|
||||
total += 1
|
||||
covered += 1 if line_count.positive?
|
||||
end
|
||||
percent = total.zero? ? 100.0 : (covered.to_f / total * 100)
|
||||
[file, covered, total, percent]
|
||||
end
|
||||
|
||||
covered_lines = rows.sum { |row| row[1] }
|
||||
total_lines = rows.sum { |row| row[2] }
|
||||
overall_percent = total_lines.zero? ? 100.0 : (covered_lines.to_f / total_lines * 100)
|
||||
puts format("Coverage (lib): %.2f%% (%d / %d lines)", overall_percent, covered_lines, total_lines)
|
||||
|
||||
unless #{lowest_count}.zero? || rows.empty?
|
||||
puts "Lowest covered files:"
|
||||
rows.sort_by { |row| row[3] }.first(#{lowest_count}).each do |file, covered, total, percent|
|
||||
relative_path = file.delete_prefix(root + "/")
|
||||
puts format(" %6.2f%% %d/%d %s", percent, covered, total, relative_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
ARGV.each { |file| require File.expand_path(file) }
|
||||
RUBY
|
||||
end
|
||||
|
||||
def capture_coverage_output(test_files:, lowest_count:, chdir:)
|
||||
capture_command("ruby", "-Ilib", "-Itest", "-e", coverage_script(lowest_count:), *test_files, chdir:)
|
||||
end
|
||||
|
||||
def parse_coverage_percent(output)
|
||||
match = output.match(/Coverage \(lib\):\s+([0-9]+\.[0-9]+)%/)
|
||||
abort "Error: unable to parse coverage output." unless match
|
||||
|
||||
Float(match[1])
|
||||
end
|
||||
|
||||
def resolve_coverage_baseline_ref(baseline)
|
||||
baseline_name = baseline.to_s.strip
|
||||
abort "Error: baseline cannot be empty." if baseline_name.empty?
|
||||
|
||||
return coverage_merge_base_ref if baseline_name == "merge-base"
|
||||
|
||||
baseline_name
|
||||
end
|
||||
|
||||
def coverage_merge_base_ref
|
||||
remote = preferred_remote
|
||||
remote_head_ref = remote_default_branch_ref(remote)
|
||||
merge_base = capture_command("git", "merge-base", "HEAD", remote_head_ref).strip
|
||||
abort "Error: could not resolve merge-base with #{remote_head_ref}." if merge_base.empty?
|
||||
|
||||
merge_base
|
||||
end
|
||||
|
||||
def preferred_remote
|
||||
upstream = capture_command_optional("git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}").strip
|
||||
upstream_remote = upstream.split("/").first unless upstream.empty?
|
||||
return upstream_remote if upstream_remote && !upstream_remote.empty?
|
||||
|
||||
remotes = capture_command("git", "remote").lines.map(&:strip).reject(&:empty?)
|
||||
abort "Error: no git remotes configured; pass baseline=<ref>." if remotes.empty?
|
||||
|
||||
remotes.include?("origin") ? "origin" : remotes.first
|
||||
end
|
||||
|
||||
def remote_default_branch_ref(remote)
|
||||
symbolic = capture_command_optional("git", "symbolic-ref", "--quiet", "refs/remotes/#{remote}/HEAD").strip
|
||||
if symbolic.empty?
|
||||
fallback = "#{remote}/main"
|
||||
capture_command("git", "rev-parse", "--verify", fallback)
|
||||
return fallback
|
||||
end
|
||||
|
||||
symbolic.sub("refs/remotes/", "")
|
||||
end
|
||||
|
||||
def with_temporary_worktree(ref:)
|
||||
temp_root = Dir.mktmpdir("coverage-baseline-")
|
||||
worktree_path = File.join(temp_root, "worktree")
|
||||
|
||||
run_command("git", "worktree", "add", "--detach", worktree_path, ref)
|
||||
begin
|
||||
yield worktree_path
|
||||
ensure
|
||||
system("git", "worktree", "remove", "--force", worktree_path)
|
||||
FileUtils.rm_rf(temp_root)
|
||||
end
|
||||
end
|
||||
|
||||
def capture_command(*command, chdir: Dir.pwd)
|
||||
stdout, stderr, status = Dir.chdir(chdir) { Open3.capture3(*command) }
|
||||
output = +""
|
||||
output << stdout unless stdout.empty?
|
||||
output << stderr unless stderr.empty?
|
||||
abort "Error: command failed: #{command.join(" ")}\n#{output}" unless status.success?
|
||||
|
||||
output
|
||||
end
|
||||
|
||||
def capture_command_optional(*command, chdir: Dir.pwd)
|
||||
stdout, stderr, status = Dir.chdir(chdir) { Open3.capture3(*command) }
|
||||
return stdout if status.success?
|
||||
return "" if stderr.include?("no upstream configured") || stderr.include?("is not a symbolic ref")
|
||||
|
||||
""
|
||||
end
|
||||
|
||||
# Build the site with specified URL and output format.
|
||||
# @parameter url [String] The site URL to use.
|
||||
# @parameter output_format [String] One of html or gemini.
|
||||
# @parameter target_path [String] Target directory for generated output.
|
||||
def build(url, output_format:, target_path:)
|
||||
require "pressa"
|
||||
|
||||
puts "Building #{output_format} site for #{url}..."
|
||||
site = Pressa.create_site(source_path: ".", url_override: url, output_format:)
|
||||
generator = Pressa::SiteGenerator.new(site:)
|
||||
generator.generate(source_path: ".", target_path:)
|
||||
puts "Site built successfully in #{target_path}/"
|
||||
end
|
||||
|
||||
def run_build_target(target)
|
||||
target_name = target.to_s
|
||||
unless BUILD_TARGETS.include?(target_name)
|
||||
abort "Error: invalid target '#{target_name}'. Use one of: #{BUILD_TARGETS.join(", ")}"
|
||||
end
|
||||
|
||||
public_send(target_name)
|
||||
end
|
||||
|
||||
def watch_paths
|
||||
WATCHABLE_DIRECTORIES.flat_map { |path| ["-r", path] }
|
||||
end
|
||||
|
||||
def standardrb_command(*extra_args)
|
||||
["bundle", "exec", "standardrb", *extra_args, *LINT_TARGETS]
|
||||
end
|
||||
|
||||
def run_standardrb(*extra_args)
|
||||
run_command(*standardrb_command(*extra_args))
|
||||
end
|
||||
|
||||
def run_command(*command)
|
||||
abort "Error: command failed: #{command.join(" ")}" unless system(*command)
|
||||
end
|
||||
|
||||
def run_rsync(local_paths:, publish_dir:, dry_run:, delete:)
|
||||
command = ["rsync", "-aKv", "-e", "ssh -4"]
|
||||
command << "--dry-run" if dry_run
|
||||
command << "--delete" if delete
|
||||
command.concat(local_paths)
|
||||
command << "#{PUBLISH_HOST}:#{publish_dir}"
|
||||
abort "Error: rsync failed." unless system(*command)
|
||||
end
|
||||
|
||||
def resolve_draft_input(input_path)
|
||||
if input_path.include?("/")
|
||||
if input_path.start_with?("posts/")
|
||||
abort "Error: '#{input_path}' is already published in posts/ directory"
|
||||
end
|
||||
|
||||
[input_path, File.basename(input_path)]
|
||||
else
|
||||
[draft_path(input_path), input_path]
|
||||
end
|
||||
end
|
||||
|
||||
def draft_path(filename)
|
||||
File.join(DRAFTS_DIR, filename)
|
||||
end
|
||||
|
||||
def slugify(title)
|
||||
title.downcase
|
||||
.gsub(/[^a-z0-9\s-]/, "")
|
||||
.gsub(/\s+/, "-").squeeze("-")
|
||||
.gsub(/^-|-$/, "")
|
||||
end
|
||||
|
||||
def next_available_draft(base_filename = "untitled.md")
|
||||
return base_filename unless File.exist?(draft_path(base_filename))
|
||||
|
||||
name_without_ext = File.basename(base_filename, ".md")
|
||||
counter = 1
|
||||
loop do
|
||||
numbered_filename = "#{name_without_ext}-#{counter}.md"
|
||||
return numbered_filename unless File.exist?(draft_path(numbered_filename))
|
||||
|
||||
counter += 1
|
||||
end
|
||||
end
|
||||
|
||||
def render_draft_template(title)
|
||||
now = Time.now
|
||||
<<~FRONTMATTER
|
||||
---
|
||||
Author: #{current_author}
|
||||
Title: #{title}
|
||||
Date: unpublished
|
||||
Timestamp: #{now.strftime("%Y-%m-%dT%H:%M:%S%:z")}
|
||||
Tags:
|
||||
---
|
||||
|
||||
# #{title}
|
||||
|
||||
TKTK
|
||||
FRONTMATTER
|
||||
end
|
||||
|
||||
def current_author
|
||||
Etc.getlogin || ENV["USER"] || `whoami`.strip
|
||||
rescue
|
||||
ENV["USER"] || `whoami`.strip
|
||||
end
|
||||
|
||||
def ordinal_date(time)
|
||||
day = time.day
|
||||
suffix = case day
|
||||
when 1, 21, 31
|
||||
"st"
|
||||
when 2, 22
|
||||
"nd"
|
||||
when 3, 23
|
||||
"rd"
|
||||
else
|
||||
"th"
|
||||
end
|
||||
|
||||
time.strftime("#{day}#{suffix} %B, %Y")
|
||||
end
|
||||
|
||||
def command_available?(command)
|
||||
system("which", command, out: File::NULL, err: File::NULL)
|
||||
end
|
||||
262
bin/blog.rb
262
bin/blog.rb
|
|
@ -1,262 +0,0 @@
|
|||
#!/usr/bin/env ruby
|
||||
# encoding: utf-8
|
||||
|
||||
require 'time'
|
||||
require 'rubygems'
|
||||
require 'bundler/setup'
|
||||
require 'builder'
|
||||
require 'json'
|
||||
require 'mustache'
|
||||
require 'rdiscount'
|
||||
|
||||
DefaultKeywords = ['sjs', 'sami samhuri', 'sami', 'samhuri', 'samhuri.net', 'blog']
|
||||
|
||||
ShortURLCodeSet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||
ShortURLBase = ShortURLCodeSet.length.to_f
|
||||
|
||||
def main
|
||||
srcdir = ARGV.shift.to_s
|
||||
destdir = ARGV.shift.to_s
|
||||
Dir.mkdir(destdir) unless File.exists?(destdir)
|
||||
unless File.directory?(srcdir)
|
||||
puts 'usage: blog.rb <source dir> <dest dir>'
|
||||
exit 1
|
||||
end
|
||||
b = Blag.new srcdir, destdir
|
||||
puts 'title: ' + b.title
|
||||
puts 'subtitle: ' + b.subtitle
|
||||
puts 'url: ' + b.url
|
||||
puts "#{b.posts.size} posts"
|
||||
b.generate!
|
||||
puts 'done blog'
|
||||
end
|
||||
|
||||
class Blag
|
||||
attr_accessor :title, :subtitle, :url
|
||||
|
||||
def self.go! src, dest
|
||||
self.new(src, dest).generate!
|
||||
end
|
||||
|
||||
def initialize src, dest
|
||||
@src = src
|
||||
@dest = dest
|
||||
@blog_dest = File.join(dest, 'blog')
|
||||
@css_dest = File.join(dest, 'css')
|
||||
read_blog
|
||||
end
|
||||
|
||||
def generate!
|
||||
generate_posts
|
||||
generate_index
|
||||
generate_rss
|
||||
generate_posts_json
|
||||
generate_archive
|
||||
generate_short_urls
|
||||
copy_assets
|
||||
end
|
||||
|
||||
def generate_index
|
||||
# generate landing page
|
||||
index_template = File.read(File.join('templates', 'blog', 'index.html'))
|
||||
post = posts.first
|
||||
values = { :post => post,
|
||||
:styles => post[:styles],
|
||||
:article => html(post),
|
||||
:previous => posts[1],
|
||||
:filename => post[:filename],
|
||||
:url => post[:relative_url],
|
||||
:comments => post[:comments]
|
||||
}
|
||||
index_html = Mustache.render(index_template, values)
|
||||
File.open(File.join(@blog_dest, 'index.html'), 'w') {|f| f.puts(index_html) }
|
||||
end
|
||||
|
||||
def generate_posts
|
||||
page_template = File.read(File.join('templates', 'blog', 'post.html'))
|
||||
posts.each_with_index do |post, i|
|
||||
values = { :title => post[:title],
|
||||
:link => post[:link],
|
||||
:styles => post[:styles],
|
||||
:article => html(post),
|
||||
:previous => i < posts.length - 1 && posts[i + 1],
|
||||
:next => i > 0 && posts[i - 1],
|
||||
:filename => post[:filename],
|
||||
:url => post[:relative_url],
|
||||
:comments => post[:comments],
|
||||
:keywords => (DefaultKeywords + post[:tags]).join(',')
|
||||
}
|
||||
post[:html] = Mustache.render(page_template, values)
|
||||
File.open(File.join(@blog_dest, post[:filename]), 'w') {|f| f.puts(post[:html]) }
|
||||
end
|
||||
end
|
||||
|
||||
def generate_posts_json
|
||||
json = JSON.generate({ :published => posts.map {|p| p[:filename]} })
|
||||
File.open(File.join(@blog_dest, 'posts.json'), 'w') { |f| f.puts(json) }
|
||||
end
|
||||
|
||||
def generate_archive
|
||||
archive_template = File.read(File.join('templates', 'blog', 'archive.html'))
|
||||
html = Mustache.render(archive_template, :posts => posts)
|
||||
File.open(File.join(@blog_dest, 'archive'), 'w') { |f| f.puts(html) }
|
||||
end
|
||||
|
||||
def generate_rss
|
||||
# posts rss
|
||||
File.open(rss_file, 'w') { |f| f.puts(rss_for_posts.target!) }
|
||||
end
|
||||
|
||||
def generate_short_urls
|
||||
htaccess = ['RewriteEngine on', 'RewriteRule ^$ http://samhuri.net [R=301,L]']
|
||||
posts.reverse.each_with_index do |post, i|
|
||||
code = shorten(i + 1)
|
||||
htaccess << "RewriteRule ^#{code}$ #{post[:url]} [R=301,L]"
|
||||
end
|
||||
File.open(File.join(@dest, 's42', '.htaccess'), 'w') do |f|
|
||||
f.puts(htaccess)
|
||||
end
|
||||
end
|
||||
|
||||
def shorten(n)
|
||||
short = ''
|
||||
while n > 0
|
||||
short = ShortURLCodeSet[n % ShortURLBase, 1] + short
|
||||
n = (n / ShortURLBase).floor
|
||||
end
|
||||
short
|
||||
end
|
||||
|
||||
def copy_assets
|
||||
Dir[File.join(@src, 'css', '*.css')].each do |stylesheet|
|
||||
minified = File.join(@css_dest, File.basename(stylesheet).sub('.css', '.min.css'))
|
||||
`yui-compressor #{stylesheet} #{minified}`
|
||||
end
|
||||
Dir[File.join(@src, 'files', '*')].each do |file|
|
||||
FileUtils.copy(file, File.join(@dest, 'f', File.basename(file)))
|
||||
end
|
||||
Dir[File.join(@src, 'images', '*')].each do |file|
|
||||
FileUtils.copy(file, File.join(@dest, 'images', 'blog', File.basename(file)))
|
||||
end
|
||||
end
|
||||
|
||||
def posts
|
||||
prefix = File.join(@src, 'published') + '/'
|
||||
@posts ||= Dir[File.join(prefix, '*')].sort.reverse.map do |filename|
|
||||
lines = File.readlines(filename)
|
||||
post = { :filename => filename.sub(prefix, '').sub(/\.(html|m(ark)?d(own)?)$/i, '') }
|
||||
loop do
|
||||
line = lines.shift.strip
|
||||
m = line.match(/^(\w+):/)
|
||||
if m && param = m[1].downcase
|
||||
post[param.to_sym] = line.sub(Regexp.new('^' + param + ':\s*', 'i'), '').strip
|
||||
elsif line.match(/^----\s*$/)
|
||||
lines.shift while lines.first.strip.empty?
|
||||
break
|
||||
else
|
||||
puts "ignoring unknown header: #{line}"
|
||||
end
|
||||
end
|
||||
post[:type] = post[:link] ? :link : :post
|
||||
post[:title] += " →" if post[:type] == :link
|
||||
post[:styles] = (post[:styles] || '').split(/\s*,\s*/)
|
||||
post[:tags] = (post[:tags] || '').split(/\s*,\s*/)
|
||||
post[:relative_url] = post[:filename].sub(/\.html$/, '')
|
||||
post[:url] = @url + '/' + post[:relative_url]
|
||||
post[:timestamp] = post[:timestamp].to_i
|
||||
post[:content] = lines.join
|
||||
post[:body] = RDiscount.new(post[:content], :smart).to_html
|
||||
post[:rfc822] = Time.at(post[:timestamp]).rfc822
|
||||
# comments on by default
|
||||
post[:comments] = (post[:comments] == 'on' || post[:comments].nil?)
|
||||
post
|
||||
end.sort { |a, b| b[:timestamp] <=> a[:timestamp] }
|
||||
end
|
||||
|
||||
|
||||
private
|
||||
|
||||
def blog_file
|
||||
File.join(@src, 'blog.json')
|
||||
end
|
||||
|
||||
def read_blog
|
||||
blog = JSON.parse(File.read(blog_file))
|
||||
@title = blog['title']
|
||||
@subtitle = blog['subtitle']
|
||||
@url = blog['url']
|
||||
end
|
||||
|
||||
def html(post)
|
||||
Mustache.render(template(post[:type]), post)
|
||||
end
|
||||
|
||||
def template(type)
|
||||
if type == :post
|
||||
@post_template ||= File.read(File.join('templates', 'blog', 'post.mustache'))
|
||||
elsif type == :link
|
||||
@link_template ||= File.read(File.join('templates', 'blog', 'link.mustache'))
|
||||
else
|
||||
raise 'unknown post type: ' + type
|
||||
end
|
||||
end
|
||||
|
||||
def rss_template(type)
|
||||
if type == :post
|
||||
@post_rss_template ||= File.read(File.join('templates', 'blog', 'post.rss.html'))
|
||||
elsif type == :link
|
||||
@link_rss_template ||= File.read(File.join('templates', 'blog', 'link.rss.html'))
|
||||
else
|
||||
raise 'unknown post type: ' + type
|
||||
end
|
||||
end
|
||||
|
||||
def rss_file
|
||||
File.join(@blog_dest, 'sjs.rss')
|
||||
end
|
||||
|
||||
def rss_html(post)
|
||||
Mustache.render(rss_template(post[:type]), { :post => post })
|
||||
end
|
||||
|
||||
def rss_for_posts(options = {})
|
||||
title = options[:title] || @title
|
||||
subtitle = options[:subtitle] || @subtitle
|
||||
url = options[:url] || @url
|
||||
rss_posts ||= options[:posts] || posts[0, 10]
|
||||
|
||||
xml = Builder::XmlMarkup.new
|
||||
xml.instruct! :xml, :version => '1.0'
|
||||
xml.instruct! 'xml-stylesheet', :href => 'http://samhuri.net/css/blog-all.min.css', :type => 'text/css'
|
||||
|
||||
rss_posts.each do |post|
|
||||
post[:styles].each do |style|
|
||||
xml.instruct! 'xml-stylesheet', :href => "http://samhuri.net/css/#{style}.min.css", :type => 'text/css'
|
||||
end
|
||||
end
|
||||
|
||||
xml.rss :version => '2.0' do
|
||||
xml.channel do
|
||||
xml.title title
|
||||
xml.description subtitle
|
||||
xml.link url
|
||||
xml.pubDate posts.first[:rfc822]
|
||||
|
||||
rss_posts.each do |post|
|
||||
xml.item do
|
||||
xml.title post[:title]
|
||||
xml.description rss_html(post)
|
||||
xml.pubDate post[:rfc822]
|
||||
xml.author post[:author]
|
||||
xml.link post[:link] || post[:url]
|
||||
xml.guid post[:url]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
xml
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
main if $0 == __FILE__
|
||||
37
bin/bootstrap
Executable file
37
bin/bootstrap
Executable file
|
|
@ -0,0 +1,37 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
RUBY_VERSION="$(cat "$ROOT_DIR/.ruby-version")"
|
||||
|
||||
if [[ "$(uname)" = "Linux" ]]; then
|
||||
echo "*** installing Linux prerequisites"
|
||||
sudo apt install -y \
|
||||
build-essential \
|
||||
git \
|
||||
inotify-tools \
|
||||
libffi-dev \
|
||||
libyaml-dev \
|
||||
pkg-config \
|
||||
zlib1g-dev
|
||||
fi
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
if command -v rbenv >/dev/null 2>/dev/null; then
|
||||
echo "*** using rbenv (ruby $RUBY_VERSION)"
|
||||
rbenv install -s "$RUBY_VERSION"
|
||||
if ! rbenv exec gem list -i bundler >/dev/null 2>/dev/null; then
|
||||
rbenv exec gem install bundler
|
||||
fi
|
||||
rbenv exec bundle install
|
||||
else
|
||||
echo "*** rbenv not found, using system Ruby"
|
||||
if ! gem list -i bundler >/dev/null 2>/dev/null; then
|
||||
gem install bundler
|
||||
fi
|
||||
bundle install
|
||||
fi
|
||||
|
||||
echo "*** done"
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
#!/usr/bin/env zsh
|
||||
|
||||
### javascript ###
|
||||
|
||||
# blog
|
||||
echo "request,showdown,strftime,tmpl,jquery-serializeObject,blog -> blog-all.min.js"
|
||||
cat public/js/{request,showdown,strftime,tmpl,jquery-serializeObject,blog}.min.js >|public/js/blog-all.min.js
|
||||
|
||||
# project index
|
||||
echo "gitter,store -> proj-index-all.min.js"
|
||||
cat public/js/{gitter,store}.min.js >|public/js/proj-index-all.min.js
|
||||
|
||||
# projects
|
||||
echo "gitter,store,proj -> proj-all.min.js"
|
||||
cat public/js/{gitter,store,proj}.min.js >|public/js/proj-all.min.js
|
||||
|
||||
|
||||
### css ###
|
||||
|
||||
# blog
|
||||
echo "style,blog -> blog-all.min.css"
|
||||
cat public/css/{style,blog}.min.css >|public/css/blog-all.min.css
|
||||
|
||||
# project index
|
||||
echo "style,proj-common -> proj-index-all.min.css"
|
||||
cat public/css/{style,proj-common}.min.css >|public/css/proj-index-all.min.css
|
||||
|
||||
# projects
|
||||
echo "style,proj-common,proj -> proj-all.min.css"
|
||||
cat public/css/{style,proj-common,proj}.min.css >|public/css/proj-all.min.css
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
#!/usr/bin/env zsh
|
||||
|
||||
setopt extendedglob
|
||||
|
||||
[[ ! -d public/js ]] && mkdir public/js
|
||||
for js (assets/js/*.js) {
|
||||
target=public/js/${${js:t}%.js}.min.js
|
||||
if [ ! -f $target ] || [ $js -nt $target ]; then
|
||||
echo "$js -> $target"
|
||||
closure < $js >| $target
|
||||
fi
|
||||
}
|
||||
|
||||
[[ ! -d public/css ]] && mkdir public/css
|
||||
for css (assets/css/*.css) {
|
||||
target=public/css/${${css:t}%.css}.min.css
|
||||
if [ ! -f $target ] || [ $css -nt $target ]; then
|
||||
echo "$css -> $target"
|
||||
yui-compressor $css $target
|
||||
fi
|
||||
}
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
var fs = require('fs')
|
||||
, path = require('path')
|
||||
, mustache = require('mustache')
|
||||
|
||||
, rootDir = path.join(__dirname, '..')
|
||||
, projectFile = path.join(rootDir, process.argv[2])
|
||||
, templateDir = path.join(rootDir, 'templates', 'proj')
|
||||
, targetDir = path.join(rootDir, process.argv[3])
|
||||
|
||||
try {
|
||||
fs.mkdirSync(targetDir, 0775)
|
||||
}
|
||||
catch (e) {
|
||||
if (e.code != 'EEXIST') throw e
|
||||
}
|
||||
|
||||
function main() {
|
||||
var ctx = {}
|
||||
fs.readFile(path.join(templateDir, 'project.html'), function(err, html) {
|
||||
if (err) throw err
|
||||
ctx.template = html.toString()
|
||||
fs.readFile(projectFile, function(err, json) {
|
||||
if (err) throw err
|
||||
var projects = JSON.parse(json).projects
|
||||
, index = path.join(targetDir, 'index.html')
|
||||
|
||||
// write project index
|
||||
fs.readFile(path.join(templateDir, 'index.html'), function(err, tpl) {
|
||||
if (err) throw err
|
||||
fs.mkdir(targetDir, 0775, function(err) {
|
||||
if (err && err.code !== 'EEXIST') throw err
|
||||
fs.unlink(index, function(err) {
|
||||
if (err && err.code !== 'ENOENT') throw err
|
||||
var vals = { projects: projects }
|
||||
, html = mustache.to_html(tpl.toString(), vals)
|
||||
fs.writeFile(index, html, function(err) {
|
||||
if (err) throw err
|
||||
console.log('* (project index)')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// write project pages
|
||||
ctx.n = 0
|
||||
projects.forEach(function(project) {
|
||||
ctx.n += 1
|
||||
buildProject(project.name, project, ctx)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function buildProject(name, project, ctx) {
|
||||
var dir = path.join(targetDir, name)
|
||||
, index = path.join(dir, 'index.html')
|
||||
|
||||
try {
|
||||
fs.mkdirSync(dir, 0775)
|
||||
}
|
||||
catch (e) {
|
||||
if (e.code != 'EEXIST') throw e
|
||||
}
|
||||
|
||||
fs.unlink(index, function(err) {
|
||||
if (err && err.code !== 'ENOENT') throw err
|
||||
project.name = name
|
||||
fs.writeFile(index, mustache.to_html(ctx.template, project), function(err) {
|
||||
if (err) console.error('error: ', err.message)
|
||||
ctx.n -= 1
|
||||
console.log('* ' + name + (err ? ' (failed)' : ''))
|
||||
if (ctx.n === 0) console.log('done projects')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (module == require.main) main()
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
bail() {
|
||||
echo fail: $*
|
||||
exit 1
|
||||
}
|
||||
|
||||
# exit on errors
|
||||
set -e
|
||||
|
||||
publish_host=samhuri.net
|
||||
publish_dir=samhuri.net/public/
|
||||
|
||||
# test
|
||||
if [[ "$1" = "-t" ]]; then
|
||||
prefix=echo
|
||||
shift
|
||||
fi
|
||||
|
||||
# --delete, passed to rsync
|
||||
if [[ "$1" = "--delete" ]]; then
|
||||
delete="--delete"
|
||||
shift
|
||||
fi
|
||||
|
||||
if [[ $# -eq 0 ]]; then
|
||||
if [[ "$delete" != "" ]]; then
|
||||
bail "no paths given, cowardly refusing to publish everything with --delete"
|
||||
fi
|
||||
$prefix rsync -aKv $delete public/* "$publish_host":"${publish_dir}"
|
||||
else
|
||||
$prefix rsync -aKv $delete "$@" "$publish_host":"${publish_dir}"
|
||||
fi
|
||||
|
|
@ -1,343 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
var fs = require('fs')
|
||||
, http = require('http')
|
||||
, path = require('path')
|
||||
, parseURL = require('url').parse
|
||||
, keys = require('keys')
|
||||
, markdown = require('markdown')
|
||||
, strftime = require('strftime').strftime
|
||||
, DefaultOptions = { host: 'localhost'
|
||||
, port: 2020
|
||||
, postsFile: path.join(__dirname, 'posts.json')
|
||||
}
|
||||
|
||||
function main() {
|
||||
var options = parseArgs(DefaultOptions)
|
||||
, db = new keys.Dirty('./discuss.dirty')
|
||||
, context = { db: db
|
||||
, posts: null
|
||||
}
|
||||
, server = http.createServer(requestHandler(context))
|
||||
, loadPosts = function(cb) {
|
||||
readJSON(options.postsFile, function(err, posts) {
|
||||
if (err) {
|
||||
console.error('failed to parse posts file, is it valid JSON?')
|
||||
console.dir(err)
|
||||
process.exit(1)
|
||||
}
|
||||
if (context.posts === null) {
|
||||
var n = posts.published.length
|
||||
, t = strftime('%Y-%m-%d %I:%M:%S %p')
|
||||
console.log('(' + t + ') ' + 'loaded discussions for ' + n + ' posts...')
|
||||
}
|
||||
context.posts = posts.published
|
||||
if (typeof cb == 'function') cb()
|
||||
})
|
||||
}
|
||||
, listen = function() {
|
||||
console.log(process.argv[0] + ' listening on ' + options.host + ':' + options.port)
|
||||
server.listen(options.port, options.host)
|
||||
}
|
||||
loadPosts(function() {
|
||||
fs.watchFile(options.postsFile, loadPosts)
|
||||
if (db._loaded) {
|
||||
listen()
|
||||
} else {
|
||||
db.db.on('load', listen)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function readJSON(f, cb) {
|
||||
fs.readFile(f, function(err, buf) {
|
||||
var data
|
||||
if (!err) {
|
||||
try {
|
||||
data = JSON.parse(buf.toString())
|
||||
} catch (e) {
|
||||
err = e
|
||||
}
|
||||
}
|
||||
cb(err, data)
|
||||
})
|
||||
}
|
||||
|
||||
// returns a request handler that returns a string
|
||||
function createTextHandler(options) {
|
||||
if (typeof options === 'string') {
|
||||
options = { body: options }
|
||||
} else {
|
||||
options = options || {}
|
||||
}
|
||||
var body = options.body || ''
|
||||
, code = options.cody || 200
|
||||
, type = options.type || 'text/plain'
|
||||
, n = body.length
|
||||
return function(req, res) {
|
||||
var headers = res.headers || {}
|
||||
headers['content-type'] = type
|
||||
headers['content-length'] = n
|
||||
|
||||
// console.log('code: ', code)
|
||||
// console.log('headers: ', JSON.stringify(headers, null, 2))
|
||||
// console.log('body: ', body)
|
||||
|
||||
res.writeHead(code, headers)
|
||||
res.end(body)
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-Origin Resource Sharing
|
||||
var createCorsHandler = (function() {
|
||||
var AllowedOrigins = [ 'http://samhuri.net' ]
|
||||
|
||||
return function(handler) {
|
||||
handler = handler || createTextHandler('ok')
|
||||
return function(req, res) {
|
||||
var origin = req.headers.origin
|
||||
console.log('origin: ', origin)
|
||||
console.log('index: ', AllowedOrigins.indexOf(origin))
|
||||
if (AllowedOrigins.indexOf(origin) !== -1) {
|
||||
res.headers = { 'Access-Control-Allow-Origin': origin
|
||||
, 'Access-Control-Request-Method': 'POST, GET'
|
||||
, 'Access-Control-Allow-Headers': 'content-type'
|
||||
}
|
||||
handler(req, res)
|
||||
} else {
|
||||
BadRequest(req, res)
|
||||
}
|
||||
}
|
||||
}
|
||||
}())
|
||||
|
||||
var DefaultHandler = createTextHandler({ code: 404, body: 'not found' })
|
||||
, BadRequest = createTextHandler({ code: 400, body: 'bad request' })
|
||||
, ServerError = createTextHandler({ code: 500, body: 'server error' })
|
||||
, _routes = {}
|
||||
|
||||
function route(method, pattern, handler) {
|
||||
if (typeof pattern === 'function' && !handler) {
|
||||
handler = pattern
|
||||
pattern = ''
|
||||
}
|
||||
if (!pattern || typeof pattern.exec !== 'function') {
|
||||
pattern = new RegExp('^/' + pattern)
|
||||
}
|
||||
var route = { pattern: pattern, handler: handler }
|
||||
console.log('routing ' + method, pattern)
|
||||
if (!(method in _routes)) _routes[method] = []
|
||||
_routes[method].push(route)
|
||||
}
|
||||
|
||||
function resolve(method, path) {
|
||||
var rs = _routes[method]
|
||||
, i = rs.length
|
||||
, m
|
||||
, r
|
||||
while (i--) {
|
||||
r = rs[i]
|
||||
m = r.pattern.exec ? r.pattern.exec(path) : path.match(r.pattern)
|
||||
if (m) return r.handler
|
||||
}
|
||||
console.warn('*** using default handler, this is probably not what you want')
|
||||
return DefaultHandler
|
||||
}
|
||||
|
||||
function get(pattern, handler) {
|
||||
route('GET', pattern, handler)
|
||||
}
|
||||
|
||||
function post(pattern, handler) {
|
||||
route('POST', pattern, handler)
|
||||
}
|
||||
|
||||
function options(pattern, handler) {
|
||||
route('OPTIONS', pattern, handler)
|
||||
}
|
||||
|
||||
function handleRequest(req, res) {
|
||||
var handler = resolve(req.method, req.url)
|
||||
try {
|
||||
handler(req, res)
|
||||
} catch (e) {
|
||||
console.error('!!! error handling ' + req.method, req.url)
|
||||
console.dir(e)
|
||||
}
|
||||
}
|
||||
|
||||
function commentServer(context) {
|
||||
return { get: getComments
|
||||
, count: countComments
|
||||
, post: postComment
|
||||
}
|
||||
|
||||
function addComment(post, name, email, url, body, timestamp) {
|
||||
var comments = context.db.get(post) || []
|
||||
comments.push({ id: comments.length + 1
|
||||
, name: name
|
||||
, email: email
|
||||
, url: url
|
||||
, body: body
|
||||
, timestamp: timestamp || Date.now()
|
||||
})
|
||||
context.db.set(post, comments)
|
||||
console.log('[' + timestamp + '] comment on ' + post)
|
||||
console.log('name:', name)
|
||||
console.log('email:', email)
|
||||
console.log('url:', url)
|
||||
console.log('body:', body)
|
||||
}
|
||||
|
||||
function getComments(req, res) {
|
||||
var post = parseURL(req.url).pathname.replace(/^\/comments\//, '')
|
||||
, comments
|
||||
if (context.posts.indexOf(post) === -1) {
|
||||
console.warn('post not found: ' + post)
|
||||
BadRequest(req, res)
|
||||
return
|
||||
}
|
||||
comments = context.db.get(post) || []
|
||||
comments.forEach(function(c, i) {
|
||||
c.id = c.id || (i + 1)
|
||||
})
|
||||
res.respond({comments: comments.map(function(c) {
|
||||
delete c.email
|
||||
c.html = markdown.parse(c.body)
|
||||
// FIXME discount has a race condition, sometimes gives a string
|
||||
// with trailing garbage.
|
||||
while (c.html.charAt(c.html.length - 1) !== '>') {
|
||||
console.log("!!! removing trailing garbage from discount's html")
|
||||
c.html = c.html.slice(0, c.html.length - 1)
|
||||
}
|
||||
return c
|
||||
})})
|
||||
}
|
||||
|
||||
function postComment(req, res) {
|
||||
var body = ''
|
||||
req.on('data', function(chunk) { body += chunk })
|
||||
req.on('end', function() {
|
||||
var data, post, name, email, url, timestamp
|
||||
try {
|
||||
data = JSON.parse(body)
|
||||
} catch (e) {
|
||||
console.log('not json -> ' + body)
|
||||
BadRequest(req, res)
|
||||
return
|
||||
}
|
||||
post = (data.post || '').trim()
|
||||
name = (data.name || 'anonymous').trim()
|
||||
email = (data.email || '').trim()
|
||||
url = (data.url || '').trim()
|
||||
if (url && !url.match(/^https?:\/\//)) url = 'http://' + url
|
||||
body = data.body || ''
|
||||
if (!post || !body || context.posts.indexOf(post) === -1) {
|
||||
console.warn('mising post, body, or post not found: ' + post)
|
||||
console.warn('body: ', body)
|
||||
BadRequest(req, res)
|
||||
return
|
||||
}
|
||||
timestamp = +data.timestamp || Date.now()
|
||||
addComment(post, name, email, url, body, timestamp)
|
||||
res.respond()
|
||||
})
|
||||
}
|
||||
|
||||
function countComments(req, res) {
|
||||
var post = parseURL(req.url).pathname.replace(/^\/count\//, '')
|
||||
, comments
|
||||
if (context.posts.indexOf(post) === -1) {
|
||||
console.warn('post not found: ' + post)
|
||||
BadRequest(req, res)
|
||||
return
|
||||
}
|
||||
comments = context.db.get(post) || []
|
||||
res.respond({count: comments.length})
|
||||
}
|
||||
}
|
||||
|
||||
function requestHandler(context) {
|
||||
var comments = commentServer(context)
|
||||
get(/comments\//, createCorsHandler(comments.get))
|
||||
get(/count\//, createCorsHandler(comments.count))
|
||||
post(/comment\/?/, createCorsHandler(comments.post))
|
||||
options(createCorsHandler())
|
||||
|
||||
return function(req, res) {
|
||||
console.log(req.method + ' ' + req.url)
|
||||
res.respond = function(obj) {
|
||||
var s = ''
|
||||
var headers = res.headers || {}
|
||||
if (obj) {
|
||||
try {
|
||||
s = JSON.stringify(obj)
|
||||
} catch (e) {
|
||||
ServerError(req, res)
|
||||
return
|
||||
}
|
||||
headers['content-type'] = 'application/json'
|
||||
}
|
||||
headers['content-length'] = s.length
|
||||
|
||||
/*
|
||||
console.log('code: ', s ? 200 : 204)
|
||||
console.log('headers:', headers)
|
||||
console.log('body:', s)
|
||||
*/
|
||||
|
||||
res.writeHead(s ? 200 : 204, headers)
|
||||
res.end(s)
|
||||
}
|
||||
handleRequest(req, res)
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(defaults) {
|
||||
var expectingArg
|
||||
, options = Object.keys(defaults).reduce(function(os, k) {
|
||||
os[k] = defaults[k]
|
||||
return os
|
||||
}, {})
|
||||
process.argv.slice(2).forEach(function(arg) {
|
||||
if (expectingArg) {
|
||||
options[expectingArg] = arg
|
||||
expectingArg = null
|
||||
} else {
|
||||
// remove leading dashes
|
||||
while (arg.charAt(0) === '-') {
|
||||
arg = arg.slice(1)
|
||||
}
|
||||
switch (arg) {
|
||||
case 'h':
|
||||
case 'host':
|
||||
expectingArg = 'host'
|
||||
break
|
||||
|
||||
case 'p':
|
||||
case 'port':
|
||||
expectingArg = 'port'
|
||||
break
|
||||
|
||||
default:
|
||||
console.warn('unknown option: ' + arg + ' (setting anyway)')
|
||||
expectingArg = arg
|
||||
}
|
||||
}
|
||||
})
|
||||
return options
|
||||
}
|
||||
|
||||
var missingParams = (function() {
|
||||
var requiredParams = 'name email body'.split(' ')
|
||||
return function(d) {
|
||||
var anyMissing = false
|
||||
requiredParams.forEach(function(p) {
|
||||
var v = (d[p] || '').trim()
|
||||
if (!v) anyMissing = true
|
||||
})
|
||||
return anyMissing
|
||||
}
|
||||
}())
|
||||
|
||||
if (module == require.main) main()
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
{ "name" : "discussd"
|
||||
, "description" : "comment server"
|
||||
, "version" : "1.0.0"
|
||||
, "homepage" : "http://samhuri.net/proj/samhuri.net"
|
||||
, "author" : "Sami Samhuri <sami@samhuri.net>"
|
||||
, "repository" :
|
||||
{ "type" : "git"
|
||||
, "url" : "https://github.com/samsonjs/samhuri.net.git"
|
||||
}
|
||||
, "bugs" :
|
||||
{ "mail" : "sami@samhuri.net"
|
||||
, "url" : "https://github.com/samsonjs/samhuri.net/issues"
|
||||
}
|
||||
, "dependencies" :
|
||||
{ "dirty" : "0.9.x"
|
||||
, "keys" : "0.1.x"
|
||||
, "markdown" : "0.5.x"
|
||||
, "strftime" : "0.6.x"
|
||||
}
|
||||
, "bin" : { "discussd" : "./discussd/discussd.js" }
|
||||
, "engines" : { "node" : ">=0.6.0" }
|
||||
, "licenses" :
|
||||
[ { "type" : "MIT"
|
||||
, "url" : "http://github.com/samsonjs/samhuri.net/raw/master/LICENSE"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
|
||||
/* Fluid widths */
|
||||
|
||||
body
|
||||
{ width: 100%
|
||||
; min-width: 0
|
||||
; font-size: 80%
|
||||
}
|
||||
|
||||
#masthead
|
||||
{ width: 100% }
|
||||
|
||||
#masthead .grid_24
|
||||
{ text-align: center }
|
||||
|
||||
#header .container_24,
|
||||
#footer .container_24
|
||||
#masthead .container_24, /* doesn't seem to work */
|
||||
#content .container_24, /* doesn't seem to work */
|
||||
#content .container_24 .grid_15, /* doesn't seem to work */
|
||||
.sidebar, /* doesn't seem to work */
|
||||
{ width: 97% }
|
||||
|
||||
#masthead .grid_24 { width: 97% }
|
||||
#masthead .grid_24 .grid_7 { width: 100%; margin-bottom: 1em }
|
||||
#masthead .grid_24 .grid_11 { width: 95% }
|
||||
#masthead .hosts { width: 100%; padding-right: 10px }
|
||||
|
||||
#masthead .hosts .host
|
||||
{ width: 44%
|
||||
; display: inline-block
|
||||
; float: none
|
||||
; clear: left
|
||||
}
|
||||
|
||||
#episode { min-height: 0 }
|
||||
#episode h2 { font-size: 1.4em }
|
||||
h5, #episode h5 { font-size: 0.8em; line-height: 1.2em }
|
||||
|
||||
#episode p,
|
||||
#episode #sponsors
|
||||
{ font-size: 0.7em; line-height: 1.3em }
|
||||
|
||||
#episode #episode_links { font-size: 0.7em; line-height: 1.2em }
|
||||
|
||||
.player { width: 100% }
|
||||
.player .transport { width: 65% }
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
if (!window.__fiveShiftInjected__) {
|
||||
window.__fiveShiftInjected__ = true
|
||||
|
||||
$(function() {
|
||||
|
||||
// load custom css
|
||||
var head = document.getElementsByTagName('head')[0]
|
||||
, css = document.createElement('link')
|
||||
css.rel = 'stylesheet'
|
||||
css.type = 'text/css'
|
||||
css.href = 'http://samhuri.net/f/fiveshift.css?t=' + +new Date()
|
||||
head.appendChild(css)
|
||||
|
||||
// These don't center properly via CSS for some reason
|
||||
;[ '#masthead .container_24'
|
||||
, '#content .container_24'
|
||||
, '#content .container_24 .grid_15'
|
||||
, '.sidebar'
|
||||
].forEach(function(selector) {
|
||||
$(selector).css('width', '97%')
|
||||
})
|
||||
|
||||
// Fix up the viewport
|
||||
$('meta[name="viewport"]').attr('content','width=device-width,initial-scale=1.0')
|
||||
})
|
||||
}
|
||||
Binary file not shown.
1
f/hi.js
1
f/hi.js
|
|
@ -1 +0,0 @@
|
|||
alert('hi')
|
||||
BIN
images/favicon.pxm
Normal file
BIN
images/favicon.pxm
Normal file
Binary file not shown.
15
lib/pressa.rb
Normal file
15
lib/pressa.rb
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
require "pressa/site"
|
||||
require "pressa/site_generator"
|
||||
require "pressa/plugin"
|
||||
require "pressa/posts/plugin"
|
||||
require "pressa/projects/plugin"
|
||||
require "pressa/utils/markdown_renderer"
|
||||
require "pressa/utils/gemini_markdown_renderer"
|
||||
require "pressa/config/loader"
|
||||
|
||||
module Pressa
|
||||
def self.create_site(source_path: ".", url_override: nil, output_format: "html")
|
||||
loader = Config::Loader.new(source_path:)
|
||||
loader.build_site(url_override:, output_format:)
|
||||
end
|
||||
end
|
||||
440
lib/pressa/config/loader.rb
Normal file
440
lib/pressa/config/loader.rb
Normal file
|
|
@ -0,0 +1,440 @@
|
|||
require "pressa/site"
|
||||
require "pressa/posts/plugin"
|
||||
require "pressa/projects/plugin"
|
||||
require "pressa/utils/markdown_renderer"
|
||||
require "pressa/utils/gemini_markdown_renderer"
|
||||
require "pressa/config/simple_toml"
|
||||
|
||||
module Pressa
|
||||
module Config
|
||||
class ValidationError < StandardError; end
|
||||
|
||||
class Loader
|
||||
REQUIRED_SITE_KEYS = %w[author email title description url].freeze
|
||||
REQUIRED_PROJECT_KEYS = %w[name title description url].freeze
|
||||
|
||||
def initialize(source_path:)
|
||||
@source_path = source_path
|
||||
end
|
||||
|
||||
def build_site(url_override: nil, output_format: "html")
|
||||
site_config = load_toml("site.toml")
|
||||
|
||||
validate_required!(site_config, REQUIRED_SITE_KEYS, context: "site.toml")
|
||||
validate_no_legacy_output_keys!(site_config)
|
||||
|
||||
normalized_output_format = normalize_output_format(output_format)
|
||||
site_url = url_override || site_config["url"]
|
||||
output_options = build_output_options(site_config:, output_format: normalized_output_format)
|
||||
plugins = build_plugins(site_config, output_format: normalized_output_format)
|
||||
|
||||
Site.new(
|
||||
author: site_config["author"],
|
||||
email: site_config["email"],
|
||||
title: site_config["title"],
|
||||
description: site_config["description"],
|
||||
url: site_url,
|
||||
fediverse_creator: build_optional_string(
|
||||
site_config["fediverse_creator"],
|
||||
context: "site.toml fediverse_creator"
|
||||
),
|
||||
image_url: normalize_image_url(site_config["image_url"], site_url),
|
||||
scripts: build_scripts(site_config["scripts"], context: "site.toml scripts"),
|
||||
styles: build_styles(site_config["styles"], context: "site.toml styles"),
|
||||
plugins:,
|
||||
renderers: build_renderers(output_format: normalized_output_format),
|
||||
output_format: normalized_output_format,
|
||||
output_options:
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_toml(filename)
|
||||
path = File.join(@source_path, filename)
|
||||
SimpleToml.load_file(path)
|
||||
rescue ParseError => e
|
||||
raise ValidationError, e.message
|
||||
end
|
||||
|
||||
def build_projects(projects_config)
|
||||
projects = projects_config["projects"]
|
||||
raise ValidationError, "Missing required top-level array 'projects' in projects.toml" unless projects
|
||||
raise ValidationError, "Expected 'projects' to be an array in projects.toml" unless projects.is_a?(Array)
|
||||
|
||||
projects.map.with_index do |project, index|
|
||||
unless project.is_a?(Hash)
|
||||
raise ValidationError, "Project entry #{index + 1} must be a table in projects.toml"
|
||||
end
|
||||
|
||||
validate_required!(project, REQUIRED_PROJECT_KEYS, context: "projects.toml project ##{index + 1}")
|
||||
|
||||
Projects::Project.new(
|
||||
name: project["name"],
|
||||
title: project["title"],
|
||||
description: project["description"],
|
||||
url: project["url"]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def validate_required!(hash, keys, context:)
|
||||
missing = keys.reject do |key|
|
||||
hash[key].is_a?(String) && !hash[key].strip.empty?
|
||||
end
|
||||
|
||||
return if missing.empty?
|
||||
|
||||
raise ValidationError, "Missing required #{context} keys: #{missing.join(", ")}"
|
||||
end
|
||||
|
||||
def validate_no_legacy_output_keys!(site_config)
|
||||
if site_config.key?("output")
|
||||
raise ValidationError, "Legacy key 'output' is no longer supported; use 'outputs'"
|
||||
end
|
||||
|
||||
if site_config.key?("mastodon_url") || site_config.key?("github_url")
|
||||
raise ValidationError, "Legacy keys 'mastodon_url'/'github_url' are no longer supported; use outputs.html.remote_links or outputs.gemini.home_links"
|
||||
end
|
||||
end
|
||||
|
||||
def build_plugins(site_config, output_format:)
|
||||
plugin_names = parse_plugin_names(site_config["plugins"])
|
||||
|
||||
plugin_names.map.with_index do |plugin_name, index|
|
||||
case plugin_name
|
||||
when "posts"
|
||||
posts_plugin_for(output_format)
|
||||
when "projects"
|
||||
build_projects_plugin(site_config, output_format:)
|
||||
else
|
||||
raise ValidationError, "Unknown plugin '#{plugin_name}' at site.toml plugins[#{index}]"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def build_renderers(output_format:)
|
||||
case output_format
|
||||
when "html"
|
||||
[Utils::MarkdownRenderer.new]
|
||||
when "gemini"
|
||||
[Utils::GeminiMarkdownRenderer.new]
|
||||
else
|
||||
raise ValidationError, "Unsupported output format '#{output_format}'"
|
||||
end
|
||||
end
|
||||
|
||||
def posts_plugin_for(output_format)
|
||||
case output_format
|
||||
when "html"
|
||||
Posts::HTMLPlugin.new
|
||||
when "gemini"
|
||||
Posts::GeminiPlugin.new
|
||||
else
|
||||
raise ValidationError, "Unsupported output format '#{output_format}'"
|
||||
end
|
||||
end
|
||||
|
||||
def parse_plugin_names(value)
|
||||
return [] if value.nil?
|
||||
raise ValidationError, "Expected site.toml plugins to be an array" unless value.is_a?(Array)
|
||||
|
||||
seen = {}
|
||||
|
||||
value.map.with_index do |plugin_name, index|
|
||||
unless plugin_name.is_a?(String) && !plugin_name.strip.empty?
|
||||
raise ValidationError, "Expected site.toml plugins[#{index}] to be a non-empty String"
|
||||
end
|
||||
|
||||
normalized_plugin_name = plugin_name.strip
|
||||
if seen[normalized_plugin_name]
|
||||
raise ValidationError, "Duplicate plugin '#{normalized_plugin_name}' in site.toml plugins"
|
||||
end
|
||||
seen[normalized_plugin_name] = true
|
||||
|
||||
normalized_plugin_name
|
||||
end
|
||||
end
|
||||
|
||||
def build_projects_plugin(site_config, output_format:)
|
||||
projects_plugin = hash_or_empty(site_config["projects_plugin"], "site.toml projects_plugin")
|
||||
projects_config = load_toml("projects.toml")
|
||||
projects = build_projects(projects_config)
|
||||
|
||||
case output_format
|
||||
when "html"
|
||||
Projects::HTMLPlugin.new(
|
||||
projects:,
|
||||
scripts: build_scripts(projects_plugin["scripts"], context: "site.toml projects_plugin.scripts"),
|
||||
styles: build_styles(projects_plugin["styles"], context: "site.toml projects_plugin.styles")
|
||||
)
|
||||
when "gemini"
|
||||
Projects::GeminiPlugin.new(projects:)
|
||||
else
|
||||
raise ValidationError, "Unsupported output format '#{output_format}'"
|
||||
end
|
||||
end
|
||||
|
||||
def hash_or_empty(value, context)
|
||||
return {} if value.nil?
|
||||
return value if value.is_a?(Hash)
|
||||
|
||||
raise ValidationError, "Expected #{context} to be a table"
|
||||
end
|
||||
|
||||
def build_output_options(site_config:, output_format:)
|
||||
outputs_config = hash_or_empty(site_config["outputs"], "site.toml outputs")
|
||||
validate_allowed_keys!(
|
||||
outputs_config,
|
||||
allowed_keys: %w[html gemini],
|
||||
context: "site.toml outputs"
|
||||
)
|
||||
format_config = hash_or_empty(outputs_config[output_format], "site.toml outputs.#{output_format}")
|
||||
|
||||
case output_format
|
||||
when "html"
|
||||
build_html_output_options(format_config:)
|
||||
when "gemini"
|
||||
build_gemini_output_options(format_config:)
|
||||
else
|
||||
raise ValidationError, "Unsupported output format '#{output_format}'"
|
||||
end
|
||||
end
|
||||
|
||||
def build_html_output_options(format_config:)
|
||||
validate_allowed_keys!(
|
||||
format_config,
|
||||
allowed_keys: %w[exclude_public remote_links],
|
||||
context: "site.toml outputs.html"
|
||||
)
|
||||
public_excludes = build_public_excludes(
|
||||
format_config["exclude_public"],
|
||||
context: "site.toml outputs.html.exclude_public"
|
||||
)
|
||||
remote_links = build_output_links(
|
||||
format_config["remote_links"],
|
||||
context: "site.toml outputs.html.remote_links",
|
||||
allow_icon: true,
|
||||
allow_label_optional: false,
|
||||
allow_string_entries: false
|
||||
)
|
||||
|
||||
HTMLOutputOptions.new(
|
||||
public_excludes:,
|
||||
remote_links:
|
||||
)
|
||||
end
|
||||
|
||||
def build_gemini_output_options(format_config:)
|
||||
validate_allowed_keys!(
|
||||
format_config,
|
||||
allowed_keys: %w[exclude_public recent_posts_limit home_links],
|
||||
context: "site.toml outputs.gemini"
|
||||
)
|
||||
public_excludes = build_public_excludes(
|
||||
format_config["exclude_public"],
|
||||
context: "site.toml outputs.gemini.exclude_public"
|
||||
)
|
||||
home_links = build_output_links(
|
||||
format_config["home_links"],
|
||||
context: "site.toml outputs.gemini.home_links",
|
||||
allow_icon: false,
|
||||
allow_label_optional: true,
|
||||
allow_string_entries: true
|
||||
)
|
||||
recent_posts_limit = build_recent_posts_limit(
|
||||
format_config["recent_posts_limit"],
|
||||
context: "site.toml outputs.gemini.recent_posts_limit"
|
||||
)
|
||||
|
||||
GeminiOutputOptions.new(
|
||||
public_excludes:,
|
||||
recent_posts_limit:,
|
||||
home_links:
|
||||
)
|
||||
end
|
||||
|
||||
def build_scripts(value, context:)
|
||||
entries = array_or_empty(value, context)
|
||||
|
||||
entries.map.with_index do |item, index|
|
||||
case item
|
||||
when String
|
||||
validate_asset_path!(
|
||||
item,
|
||||
context: "#{context}[#{index}]"
|
||||
)
|
||||
Script.new(src: item, defer: true)
|
||||
when Hash
|
||||
src = item["src"]
|
||||
raise ValidationError, "Expected #{context}[#{index}].src to be a String" unless src.is_a?(String) && !src.empty?
|
||||
validate_asset_path!(
|
||||
src,
|
||||
context: "#{context}[#{index}].src"
|
||||
)
|
||||
|
||||
defer = item.key?("defer") ? item["defer"] : true
|
||||
unless [true, false].include?(defer)
|
||||
raise ValidationError, "Expected #{context}[#{index}].defer to be a Boolean"
|
||||
end
|
||||
|
||||
Script.new(src:, defer:)
|
||||
else
|
||||
raise ValidationError, "Expected #{context}[#{index}] to be a String or table"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def build_styles(value, context:)
|
||||
entries = array_or_empty(value, context)
|
||||
|
||||
entries.map.with_index do |item, index|
|
||||
case item
|
||||
when String
|
||||
validate_asset_path!(
|
||||
item,
|
||||
context: "#{context}[#{index}]"
|
||||
)
|
||||
Stylesheet.new(href: item)
|
||||
when Hash
|
||||
href = item["href"]
|
||||
raise ValidationError, "Expected #{context}[#{index}].href to be a String" unless href.is_a?(String) && !href.empty?
|
||||
validate_asset_path!(
|
||||
href,
|
||||
context: "#{context}[#{index}].href"
|
||||
)
|
||||
|
||||
Stylesheet.new(href:)
|
||||
else
|
||||
raise ValidationError, "Expected #{context}[#{index}] to be a String or table"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def array_or_empty(value, context)
|
||||
return [] if value.nil?
|
||||
return value if value.is_a?(Array)
|
||||
|
||||
raise ValidationError, "Expected #{context} to be an array"
|
||||
end
|
||||
|
||||
def normalize_image_url(value, site_url)
|
||||
return nil if value.nil?
|
||||
return value if value.start_with?("http://", "https://")
|
||||
|
||||
normalized = value.start_with?("/") ? value : "/#{value}"
|
||||
"#{site_url}#{normalized}"
|
||||
end
|
||||
|
||||
def validate_asset_path!(value, context:)
|
||||
return if value.start_with?("/", "http://", "https://")
|
||||
|
||||
raise ValidationError, "Expected #{context} to start with / or use http(s) scheme"
|
||||
end
|
||||
|
||||
def build_public_excludes(value, context:)
|
||||
entries = array_or_empty(value, context)
|
||||
entries.map.with_index do |entry, index|
|
||||
unless entry.is_a?(String) && !entry.strip.empty?
|
||||
raise ValidationError, "Expected #{context}[#{index}] to be a non-empty String"
|
||||
end
|
||||
|
||||
entry.strip
|
||||
end
|
||||
end
|
||||
|
||||
def build_output_links(value, context:, allow_icon:, allow_label_optional:, allow_string_entries:)
|
||||
entries = array_or_empty(value, context)
|
||||
entries.map.with_index do |entry, index|
|
||||
if allow_string_entries && entry.is_a?(String)
|
||||
href = entry
|
||||
unless !href.strip.empty?
|
||||
raise ValidationError, "Expected #{context}[#{index}] to be a non-empty String"
|
||||
end
|
||||
validate_link_href!(href.strip, context: "#{context}[#{index}]")
|
||||
|
||||
next OutputLink.new(label: nil, href: href.strip, icon: nil)
|
||||
end
|
||||
|
||||
unless entry.is_a?(Hash)
|
||||
raise ValidationError, "Expected #{context}[#{index}] to be a String or table"
|
||||
end
|
||||
|
||||
allowed_keys = allow_icon ? %w[label href icon] : %w[label href]
|
||||
validate_allowed_keys!(
|
||||
entry,
|
||||
allowed_keys:,
|
||||
context: "#{context}[#{index}]"
|
||||
)
|
||||
|
||||
href = entry["href"]
|
||||
unless href.is_a?(String) && !href.strip.empty?
|
||||
raise ValidationError, "Expected #{context}[#{index}].href to be a non-empty String"
|
||||
end
|
||||
validate_link_href!(href.strip, context: "#{context}[#{index}].href")
|
||||
|
||||
label = entry["label"]
|
||||
if label.nil?
|
||||
unless allow_label_optional
|
||||
raise ValidationError, "Expected #{context}[#{index}].label to be a non-empty String"
|
||||
end
|
||||
else
|
||||
unless label.is_a?(String) && !label.strip.empty?
|
||||
raise ValidationError, "Expected #{context}[#{index}].label to be a non-empty String"
|
||||
end
|
||||
end
|
||||
|
||||
icon = entry["icon"]
|
||||
unless allow_icon
|
||||
if entry.key?("icon")
|
||||
raise ValidationError, "Unexpected #{context}[#{index}].icon; icons are only supported for outputs.html.remote_links"
|
||||
end
|
||||
|
||||
icon = nil
|
||||
end
|
||||
|
||||
if allow_icon && !icon.nil? && (!icon.is_a?(String) || icon.strip.empty?)
|
||||
raise ValidationError, "Expected #{context}[#{index}].icon to be a non-empty String"
|
||||
end
|
||||
|
||||
OutputLink.new(label: label&.strip, href: href.strip, icon: icon&.strip)
|
||||
end
|
||||
end
|
||||
|
||||
def validate_link_href!(value, context:)
|
||||
return if value.start_with?("/")
|
||||
return if value.match?(/\A[a-z][a-z0-9+\-.]*:/i)
|
||||
|
||||
raise ValidationError, "Expected #{context} to start with / or include a URI scheme"
|
||||
end
|
||||
|
||||
def build_recent_posts_limit(value, context:)
|
||||
return 20 if value.nil?
|
||||
return value if value.is_a?(Integer) && value.positive?
|
||||
|
||||
raise ValidationError, "Expected #{context} to be a positive Integer"
|
||||
end
|
||||
|
||||
def normalize_output_format(output_format)
|
||||
value = output_format.to_s.strip.downcase
|
||||
return value if %w[html gemini].include?(value)
|
||||
|
||||
raise ValidationError, "Unsupported output format '#{output_format}'"
|
||||
end
|
||||
|
||||
def build_optional_string(value, context:)
|
||||
return nil if value.nil?
|
||||
return value if value.is_a?(String) && !value.strip.empty?
|
||||
|
||||
raise ValidationError, "Expected #{context} to be a non-empty String"
|
||||
end
|
||||
|
||||
def validate_allowed_keys!(hash, allowed_keys:, context:)
|
||||
unknown = hash.keys - allowed_keys
|
||||
return if unknown.empty?
|
||||
|
||||
raise ValidationError, "Unknown key(s) in #{context}: #{unknown.join(", ")}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
224
lib/pressa/config/simple_toml.rb
Normal file
224
lib/pressa/config/simple_toml.rb
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
require "json"
|
||||
|
||||
module Pressa
|
||||
module Config
|
||||
class ParseError < StandardError; end
|
||||
|
||||
class SimpleToml
|
||||
def self.load_file(path)
|
||||
new.parse(File.read(path))
|
||||
rescue Errno::ENOENT
|
||||
raise ParseError, "Config file not found: #{path}"
|
||||
end
|
||||
|
||||
def parse(content)
|
||||
root = {}
|
||||
current_table = root
|
||||
lines = content.each_line.to_a
|
||||
|
||||
line_index = 0
|
||||
while line_index < lines.length
|
||||
line = lines[line_index]
|
||||
line_number = line_index + 1
|
||||
source = strip_comments(line).strip
|
||||
if source.empty?
|
||||
line_index += 1
|
||||
next
|
||||
end
|
||||
|
||||
if source =~ /\A\[\[(.+)\]\]\z/
|
||||
current_table = start_array_table(root, Regexp.last_match(1), line_number)
|
||||
line_index += 1
|
||||
next
|
||||
end
|
||||
|
||||
if source =~ /\A\[(.+)\]\z/
|
||||
current_table = start_table(root, Regexp.last_match(1), line_number)
|
||||
line_index += 1
|
||||
next
|
||||
end
|
||||
|
||||
key, raw_value = parse_assignment(source, line_number)
|
||||
while needs_continuation?(raw_value)
|
||||
line_index += 1
|
||||
raise ParseError, "Unterminated value for key '#{key}' at line #{line_number}" if line_index >= lines.length
|
||||
|
||||
continuation = strip_comments(lines[line_index]).strip
|
||||
next if continuation.empty?
|
||||
|
||||
raw_value = "#{raw_value} #{continuation}"
|
||||
end
|
||||
|
||||
if current_table.key?(key)
|
||||
raise ParseError, "Duplicate key '#{key}' at line #{line_number}"
|
||||
end
|
||||
|
||||
current_table[key] = parse_value(raw_value, line_number)
|
||||
line_index += 1
|
||||
end
|
||||
|
||||
root
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def start_array_table(root, raw_path, line_number)
|
||||
keys = parse_path(raw_path, line_number)
|
||||
parent = ensure_path(root, keys[0..-2], line_number)
|
||||
table_name = keys.last
|
||||
|
||||
parent[table_name] ||= []
|
||||
array = parent[table_name]
|
||||
unless array.is_a?(Array)
|
||||
raise ParseError, "Expected array for '[[#{raw_path}]]' at line #{line_number}"
|
||||
end
|
||||
|
||||
table = {}
|
||||
array << table
|
||||
table
|
||||
end
|
||||
|
||||
def start_table(root, raw_path, line_number)
|
||||
keys = parse_path(raw_path, line_number)
|
||||
ensure_path(root, keys, line_number)
|
||||
end
|
||||
|
||||
def ensure_path(root, keys, line_number)
|
||||
cursor = root
|
||||
|
||||
keys.each do |key|
|
||||
cursor[key] ||= {}
|
||||
unless cursor[key].is_a?(Hash)
|
||||
raise ParseError, "Expected table path '#{keys.join(".")}' at line #{line_number}"
|
||||
end
|
||||
|
||||
cursor = cursor[key]
|
||||
end
|
||||
|
||||
cursor
|
||||
end
|
||||
|
||||
def parse_path(raw_path, line_number)
|
||||
keys = raw_path.split(".").map(&:strip)
|
||||
if keys.empty? || keys.any? { |part| part.empty? || part !~ /\A[A-Za-z0-9_]+\z/ }
|
||||
raise ParseError, "Invalid table path '#{raw_path}' at line #{line_number}"
|
||||
end
|
||||
keys
|
||||
end
|
||||
|
||||
def parse_assignment(source, line_number)
|
||||
separator = index_of_unquoted(source, "=")
|
||||
raise ParseError, "Invalid assignment at line #{line_number}" unless separator
|
||||
|
||||
key = source[0...separator].strip
|
||||
value = source[(separator + 1)..].strip
|
||||
|
||||
if key.empty? || key !~ /\A[A-Za-z0-9_]+\z/
|
||||
raise ParseError, "Invalid key '#{key}' at line #{line_number}"
|
||||
end
|
||||
raise ParseError, "Missing value for key '#{key}' at line #{line_number}" if value.empty?
|
||||
|
||||
[key, value]
|
||||
end
|
||||
|
||||
def parse_value(raw_value, line_number)
|
||||
JSON.parse(raw_value)
|
||||
rescue JSON::ParserError
|
||||
raise ParseError, "Unsupported TOML value '#{raw_value}' at line #{line_number}"
|
||||
end
|
||||
|
||||
def needs_continuation?(source)
|
||||
in_string = false
|
||||
escaped = false
|
||||
depth = 0
|
||||
|
||||
source.each_char do |char|
|
||||
if in_string
|
||||
if escaped
|
||||
escaped = false
|
||||
elsif char == "\\"
|
||||
escaped = true
|
||||
elsif char == '"'
|
||||
in_string = false
|
||||
end
|
||||
|
||||
next
|
||||
end
|
||||
|
||||
case char
|
||||
when '"'
|
||||
in_string = true
|
||||
when "[", "{"
|
||||
depth += 1
|
||||
when "]", "}"
|
||||
depth -= 1
|
||||
end
|
||||
end
|
||||
|
||||
in_string || depth.positive?
|
||||
end
|
||||
|
||||
def strip_comments(line)
|
||||
output = +""
|
||||
in_string = false
|
||||
escaped = false
|
||||
|
||||
line.each_char do |char|
|
||||
if in_string
|
||||
output << char
|
||||
|
||||
if escaped
|
||||
escaped = false
|
||||
elsif char == "\\"
|
||||
escaped = true
|
||||
elsif char == '"'
|
||||
in_string = false
|
||||
end
|
||||
|
||||
next
|
||||
end
|
||||
|
||||
case char
|
||||
when '"'
|
||||
in_string = true
|
||||
output << char
|
||||
when "#"
|
||||
break
|
||||
else
|
||||
output << char
|
||||
end
|
||||
end
|
||||
|
||||
output
|
||||
end
|
||||
|
||||
def index_of_unquoted(source, target)
|
||||
in_string = false
|
||||
escaped = false
|
||||
|
||||
source.each_char.with_index do |char, index|
|
||||
if in_string
|
||||
if escaped
|
||||
escaped = false
|
||||
elsif char == "\\"
|
||||
escaped = true
|
||||
elsif char == '"'
|
||||
in_string = false
|
||||
end
|
||||
|
||||
next
|
||||
end
|
||||
|
||||
if char == '"'
|
||||
in_string = true
|
||||
next
|
||||
end
|
||||
|
||||
return index if char == target
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
11
lib/pressa/plugin.rb
Normal file
11
lib/pressa/plugin.rb
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
module Pressa
|
||||
class Plugin
|
||||
def setup(site:, source_path:)
|
||||
raise NotImplementedError, "#{self.class}#setup must be implemented"
|
||||
end
|
||||
|
||||
def render(site:, target_path:)
|
||||
raise NotImplementedError, "#{self.class}#render must be implemented"
|
||||
end
|
||||
end
|
||||
end
|
||||
111
lib/pressa/posts/gemini_writer.rb
Normal file
111
lib/pressa/posts/gemini_writer.rb
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
require "pressa/utils/file_writer"
|
||||
require "pressa/utils/gemtext_renderer"
|
||||
|
||||
module Pressa
|
||||
module Posts
|
||||
class GeminiWriter
|
||||
RECENT_POSTS_LIMIT = 20
|
||||
|
||||
def initialize(site:, posts_by_year:)
|
||||
@site = site
|
||||
@posts_by_year = posts_by_year
|
||||
end
|
||||
|
||||
def write_posts(target_path:)
|
||||
@posts_by_year.all_posts.each do |post|
|
||||
write_post(post:, target_path:)
|
||||
end
|
||||
end
|
||||
|
||||
def write_recent_posts(target_path:, limit: RECENT_POSTS_LIMIT)
|
||||
rows = ["# #{@site.title}", ""]
|
||||
home_links.each do |link|
|
||||
label = link.label&.strip
|
||||
rows << if label.nil? || label.empty?
|
||||
"=> #{link.href}"
|
||||
else
|
||||
"=> #{link.href} #{label}"
|
||||
end
|
||||
end
|
||||
rows << "" unless home_links.empty?
|
||||
rows << "## Recent posts"
|
||||
rows << ""
|
||||
|
||||
@posts_by_year.recent_posts(limit).each do |post|
|
||||
rows << post_link_line(post)
|
||||
end
|
||||
|
||||
rows << ""
|
||||
rows << "=> #{web_url_for("/")} Website"
|
||||
rows << ""
|
||||
|
||||
file_path = File.join(target_path, "index.gmi")
|
||||
Utils::FileWriter.write(path: file_path, content: rows.join("\n"))
|
||||
end
|
||||
|
||||
def write_posts_index(target_path:)
|
||||
rows = ["# #{@site.title} posts", "## Feed", ""]
|
||||
|
||||
@posts_by_year.all_posts.each do |post|
|
||||
rows.concat(post_listing_lines(post))
|
||||
end
|
||||
|
||||
rows << ""
|
||||
rows << "=> / Home"
|
||||
rows << "=> #{web_url_for("/posts/")} Read on the web"
|
||||
rows << ""
|
||||
|
||||
content = rows.join("\n")
|
||||
Utils::FileWriter.write(path: File.join(target_path, "posts", "index.gmi"), content:)
|
||||
Utils::FileWriter.write(path: File.join(target_path, "posts", "feed.gmi"), content:)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def write_post(post:, target_path:)
|
||||
rows = ["# #{post.title}", "", "#{post.formatted_date} by #{post.author}", ""]
|
||||
|
||||
if post.link_post?
|
||||
rows << "=> #{post.link}"
|
||||
rows << ""
|
||||
end
|
||||
|
||||
gemtext_body = Utils::GemtextRenderer.render(post.markdown_body)
|
||||
rows << gemtext_body unless gemtext_body.empty?
|
||||
rows << "" unless rows.last.to_s.empty?
|
||||
|
||||
rows << "=> /posts Back to posts"
|
||||
rows << "=> #{web_url_for("#{post.path}/")} Read on the web" if include_web_link?(post)
|
||||
rows << ""
|
||||
|
||||
file_path = File.join(target_path, post.path.sub(%r{^/}, ""), "index.gmi")
|
||||
Utils::FileWriter.write(path: file_path, content: rows.join("\n"))
|
||||
end
|
||||
|
||||
def post_link_line(post)
|
||||
"=> #{post.path}/ #{post.date.strftime("%Y-%m-%d")} - #{post.title}"
|
||||
end
|
||||
|
||||
def post_listing_lines(post)
|
||||
rows = [post_link_line(post)]
|
||||
rows << "=> #{post.link}" if post.link_post?
|
||||
rows
|
||||
end
|
||||
|
||||
def include_web_link?(post)
|
||||
markdown_without_fences = post.markdown_body.gsub(/```.*?```/m, "")
|
||||
markdown_without_fences.match?(
|
||||
%r{<\s*(?:a|p|div|span|ul|ol|li|audio|video|source|img|h[1-6]|blockquote|pre|code|table|tr|td|th|em|strong|br)\b}i
|
||||
)
|
||||
end
|
||||
|
||||
def web_url_for(path)
|
||||
@site.url_for(path)
|
||||
end
|
||||
|
||||
def home_links
|
||||
@site.gemini_output_options&.home_links || []
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
76
lib/pressa/posts/json_feed.rb
Normal file
76
lib/pressa/posts/json_feed.rb
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
require "json"
|
||||
require "pressa/utils/file_writer"
|
||||
require "pressa/views/feed_post_view"
|
||||
|
||||
module Pressa
|
||||
module Posts
|
||||
class JSONFeedWriter
|
||||
FEED_VERSION = "https://jsonfeed.org/version/1.1"
|
||||
|
||||
def initialize(site:, posts_by_year:)
|
||||
@site = site
|
||||
@posts_by_year = posts_by_year
|
||||
end
|
||||
|
||||
def write_feed(target_path:, limit: 30)
|
||||
recent = @posts_by_year.recent_posts(limit)
|
||||
|
||||
feed = build_feed(recent)
|
||||
|
||||
json = JSON.pretty_generate(feed)
|
||||
file_path = File.join(target_path, "feed.json")
|
||||
Utils::FileWriter.write(path: file_path, content: json)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_feed(posts)
|
||||
author = {
|
||||
name: @site.author,
|
||||
url: @site.url,
|
||||
avatar: @site.image_url
|
||||
}
|
||||
|
||||
items = posts.map { |post| feed_item(post) }
|
||||
|
||||
{
|
||||
icon: icon_url,
|
||||
favicon: favicon_url,
|
||||
items: items,
|
||||
home_page_url: @site.url,
|
||||
author:,
|
||||
version: FEED_VERSION,
|
||||
authors: [author],
|
||||
feed_url: @site.url_for("/feed.json"),
|
||||
language: "en-CA",
|
||||
title: @site.title
|
||||
}
|
||||
end
|
||||
|
||||
def icon_url
|
||||
@site.url_for("/images/apple-touch-icon-300.png")
|
||||
end
|
||||
|
||||
def favicon_url
|
||||
@site.url_for("/images/apple-touch-icon-80.png")
|
||||
end
|
||||
|
||||
def feed_item(post)
|
||||
content_html = Views::FeedPostView.new(post:, site: @site).call
|
||||
permalink = @site.url_for(post.path)
|
||||
|
||||
item = {}
|
||||
item[:url] = permalink
|
||||
item[:external_url] = post.link if post.link_post?
|
||||
item[:tags] = post.tags unless post.tags.empty?
|
||||
item[:content_html] = content_html
|
||||
item[:title] = post.link_post? ? "→ #{post.title}" : post.title
|
||||
item[:author] = {name: post.author}
|
||||
item[:date_published] = post.date.iso8601
|
||||
item[:id] = permalink
|
||||
|
||||
item
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
50
lib/pressa/posts/metadata.rb
Normal file
50
lib/pressa/posts/metadata.rb
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
require "yaml"
|
||||
require "date"
|
||||
|
||||
module Pressa
|
||||
module Posts
|
||||
class PostMetadata
|
||||
REQUIRED_FIELDS = %w[Title Author Date Timestamp].freeze
|
||||
|
||||
attr_reader :title, :author, :date, :formatted_date, :link, :tags
|
||||
|
||||
def initialize(yaml_hash)
|
||||
@raw = yaml_hash
|
||||
validate_required_fields!
|
||||
parse_fields
|
||||
end
|
||||
|
||||
def self.parse(content)
|
||||
if content =~ /\A---\s*\n(.*?)\n---\s*\n/m
|
||||
yaml_content = $1
|
||||
yaml_hash = YAML.safe_load(yaml_content, permitted_classes: [Date, Time])
|
||||
new(yaml_hash)
|
||||
else
|
||||
raise "No YAML front-matter found in post"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_required_fields!
|
||||
missing = REQUIRED_FIELDS.reject { |field| @raw.key?(field) }
|
||||
raise "Missing required fields: #{missing.join(", ")}" unless missing.empty?
|
||||
end
|
||||
|
||||
def parse_fields
|
||||
@title = @raw["Title"]
|
||||
@author = @raw["Author"]
|
||||
timestamp = @raw["Timestamp"]
|
||||
@date = timestamp.is_a?(String) ? DateTime.parse(timestamp) : timestamp.to_datetime
|
||||
@formatted_date = @raw["Date"]
|
||||
@link = @raw["Link"]
|
||||
@tags = parse_tags(@raw["Tags"])
|
||||
end
|
||||
|
||||
def parse_tags(value)
|
||||
return [] if value.nil?
|
||||
value.is_a?(Array) ? value : value.split(",").map(&:strip)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
96
lib/pressa/posts/models.rb
Normal file
96
lib/pressa/posts/models.rb
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
require "dry-struct"
|
||||
require "pressa/site"
|
||||
|
||||
module Pressa
|
||||
module Posts
|
||||
class Post < Dry::Struct
|
||||
attribute :slug, Types::String
|
||||
attribute :title, Types::String
|
||||
attribute :author, Types::String
|
||||
attribute :date, Types::Params::DateTime
|
||||
attribute :formatted_date, Types::String
|
||||
attribute :link, Types::String.optional.default(nil)
|
||||
attribute :tags, Types::Array.of(Types::String).default([].freeze)
|
||||
attribute :body, Types::String
|
||||
attribute :markdown_body, Types::String.default("".freeze)
|
||||
attribute :excerpt, Types::String
|
||||
attribute :path, Types::String
|
||||
|
||||
def link_post?
|
||||
!link.nil?
|
||||
end
|
||||
|
||||
def year
|
||||
date.year
|
||||
end
|
||||
|
||||
def month
|
||||
date.month
|
||||
end
|
||||
|
||||
def formatted_month
|
||||
date.strftime("%B")
|
||||
end
|
||||
|
||||
def padded_month
|
||||
format("%02d", month)
|
||||
end
|
||||
end
|
||||
|
||||
class Month < Dry::Struct
|
||||
attribute :name, Types::String
|
||||
attribute :number, Types::Integer
|
||||
attribute :padded, Types::String
|
||||
|
||||
def self.from_date(date)
|
||||
new(
|
||||
name: date.strftime("%B"),
|
||||
number: date.month,
|
||||
padded: format("%02d", date.month)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
class MonthPosts < Dry::Struct
|
||||
attribute :month, Month
|
||||
attribute :posts, Types::Array.of(Post)
|
||||
|
||||
def sorted_posts
|
||||
posts.sort_by(&:date).reverse
|
||||
end
|
||||
end
|
||||
|
||||
class YearPosts < Dry::Struct
|
||||
attribute :year, Types::Integer
|
||||
attribute :by_month, Types::Hash.map(Types::Integer, MonthPosts)
|
||||
|
||||
def sorted_months
|
||||
by_month.keys.sort.reverse.map { |month_num| by_month[month_num] }
|
||||
end
|
||||
|
||||
def all_posts
|
||||
by_month.values.flat_map(&:posts).sort_by(&:date).reverse
|
||||
end
|
||||
end
|
||||
|
||||
class PostsByYear < Dry::Struct
|
||||
attribute :by_year, Types::Hash.map(Types::Integer, YearPosts)
|
||||
|
||||
def sorted_years
|
||||
by_year.keys.sort.reverse
|
||||
end
|
||||
|
||||
def all_posts
|
||||
by_year.values.flat_map(&:all_posts).sort_by(&:date).reverse
|
||||
end
|
||||
|
||||
def recent_posts(limit = 10)
|
||||
all_posts.take(limit)
|
||||
end
|
||||
|
||||
def earliest_year
|
||||
by_year.keys.min
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
60
lib/pressa/posts/plugin.rb
Normal file
60
lib/pressa/posts/plugin.rb
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
require "pressa/plugin"
|
||||
require "pressa/posts/repo"
|
||||
require "pressa/posts/writer"
|
||||
require "pressa/posts/gemini_writer"
|
||||
require "pressa/posts/json_feed"
|
||||
require "pressa/posts/rss_feed"
|
||||
|
||||
module Pressa
|
||||
module Posts
|
||||
class BasePlugin < Pressa::Plugin
|
||||
attr_reader :posts_by_year
|
||||
|
||||
def setup(site:, source_path:)
|
||||
posts_dir = File.join(source_path, "posts")
|
||||
return unless Dir.exist?(posts_dir)
|
||||
|
||||
repo = PostRepo.new
|
||||
@posts_by_year = repo.read_posts(posts_dir)
|
||||
end
|
||||
end
|
||||
|
||||
class HTMLPlugin < BasePlugin
|
||||
def render(site:, target_path:)
|
||||
return unless @posts_by_year
|
||||
|
||||
writer = PostWriter.new(site:, posts_by_year: @posts_by_year)
|
||||
writer.write_posts(target_path:)
|
||||
writer.write_recent_posts(target_path:, limit: 10)
|
||||
writer.write_archive(target_path:)
|
||||
writer.write_year_indexes(target_path:)
|
||||
writer.write_month_rollups(target_path:)
|
||||
|
||||
json_feed = JSONFeedWriter.new(site:, posts_by_year: @posts_by_year)
|
||||
json_feed.write_feed(target_path:, limit: 30)
|
||||
|
||||
rss_feed = RSSFeedWriter.new(site:, posts_by_year: @posts_by_year)
|
||||
rss_feed.write_feed(target_path:, limit: 30)
|
||||
end
|
||||
end
|
||||
|
||||
class GeminiPlugin < BasePlugin
|
||||
def render(site:, target_path:)
|
||||
return unless @posts_by_year
|
||||
|
||||
writer = GeminiWriter.new(site:, posts_by_year: @posts_by_year)
|
||||
writer.write_posts(target_path:)
|
||||
writer.write_recent_posts(target_path:, limit: gemini_recent_posts_limit(site))
|
||||
writer.write_posts_index(target_path:)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def gemini_recent_posts_limit(site)
|
||||
site.gemini_output_options&.recent_posts_limit || GeminiWriter::RECENT_POSTS_LIMIT
|
||||
end
|
||||
end
|
||||
|
||||
Plugin = HTMLPlugin
|
||||
end
|
||||
end
|
||||
125
lib/pressa/posts/repo.rb
Normal file
125
lib/pressa/posts/repo.rb
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
require "kramdown"
|
||||
require "pressa/posts/models"
|
||||
require "pressa/posts/metadata"
|
||||
|
||||
module Pressa
|
||||
module Posts
|
||||
class PostRepo
|
||||
EXCERPT_LENGTH = 300
|
||||
|
||||
def initialize(output_path: "posts")
|
||||
@output_path = output_path
|
||||
@posts_by_year = {}
|
||||
end
|
||||
|
||||
def read_posts(posts_dir)
|
||||
enumerate_markdown_files(posts_dir) do |file_path|
|
||||
post = read_post(file_path)
|
||||
add_post_to_hierarchy(post)
|
||||
end
|
||||
|
||||
PostsByYear.new(by_year: @posts_by_year)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def enumerate_markdown_files(dir, &block)
|
||||
Dir.glob(File.join(dir, "**", "*.md")).each(&block)
|
||||
end
|
||||
|
||||
def read_post(file_path)
|
||||
content = File.read(file_path)
|
||||
metadata = PostMetadata.parse(content)
|
||||
|
||||
body_markdown = content.sub(/\A---\s*\n.*?\n---\s*\n/m, "")
|
||||
|
||||
html_body = render_markdown(body_markdown)
|
||||
|
||||
slug = File.basename(file_path, ".md")
|
||||
path = generate_path(slug, metadata.date)
|
||||
excerpt = generate_excerpt(body_markdown)
|
||||
|
||||
Post.new(
|
||||
slug:,
|
||||
title: metadata.title,
|
||||
author: metadata.author,
|
||||
date: metadata.date,
|
||||
formatted_date: metadata.formatted_date,
|
||||
link: metadata.link,
|
||||
tags: metadata.tags,
|
||||
body: html_body,
|
||||
markdown_body: body_markdown,
|
||||
excerpt:,
|
||||
path:
|
||||
)
|
||||
end
|
||||
|
||||
def render_markdown(markdown)
|
||||
Kramdown::Document.new(
|
||||
markdown,
|
||||
input: "GFM",
|
||||
hard_wrap: false,
|
||||
syntax_highlighter: "rouge",
|
||||
syntax_highlighter_opts: {
|
||||
line_numbers: false,
|
||||
wrap: true
|
||||
}
|
||||
).to_html
|
||||
end
|
||||
|
||||
def generate_path(slug, date)
|
||||
year = date.year
|
||||
month = format("%02d", date.month)
|
||||
"/#{@output_path}/#{year}/#{month}/#{slug}"
|
||||
end
|
||||
|
||||
def generate_excerpt(markdown)
|
||||
text = markdown.dup
|
||||
|
||||
text.gsub!(/!\[[^\]]*\]\([^)]+\)/, "")
|
||||
text.gsub!(/!\[[^\]]*\]\[[^\]]+\]/, "")
|
||||
|
||||
text.gsub!(/\[([^\]]+)\]\([^)]+\)/, '\1')
|
||||
text.gsub!(/\[([^\]]+)\]\[[^\]]+\]/, '\1')
|
||||
|
||||
text.gsub!(/(?m)^\[[^\]]+\]:\s*\S.*$/, "")
|
||||
|
||||
text.gsub!(/<[^>]+>/, "")
|
||||
|
||||
text.gsub!(/\s+/, " ")
|
||||
text.strip!
|
||||
|
||||
return "..." if text.empty?
|
||||
|
||||
"#{text[0...EXCERPT_LENGTH]}..."
|
||||
end
|
||||
|
||||
def add_post_to_hierarchy(post)
|
||||
year = post.year
|
||||
month_num = post.month
|
||||
|
||||
@posts_by_year[year] ||= create_year_posts(year)
|
||||
year_posts = @posts_by_year[year]
|
||||
|
||||
month_posts = year_posts.by_month[month_num]
|
||||
if month_posts
|
||||
updated_posts = month_posts.posts + [post]
|
||||
year_posts.by_month[month_num] = MonthPosts.new(
|
||||
month: month_posts.month,
|
||||
posts: updated_posts
|
||||
)
|
||||
else
|
||||
month = Month.from_date(post.date)
|
||||
year_posts.by_month[month_num] = MonthPosts.new(
|
||||
month:,
|
||||
posts: [post]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def create_year_posts(year)
|
||||
YearPosts.new(year:, by_month: {})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
53
lib/pressa/posts/rss_feed.rb
Normal file
53
lib/pressa/posts/rss_feed.rb
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
require "builder"
|
||||
require "pressa/utils/file_writer"
|
||||
require "pressa/views/feed_post_view"
|
||||
|
||||
module Pressa
|
||||
module Posts
|
||||
class RSSFeedWriter
|
||||
def initialize(site:, posts_by_year:)
|
||||
@site = site
|
||||
@posts_by_year = posts_by_year
|
||||
end
|
||||
|
||||
def write_feed(target_path:, limit: 30)
|
||||
recent = @posts_by_year.recent_posts(limit)
|
||||
|
||||
xml = Builder::XmlMarkup.new(indent: 2)
|
||||
xml.instruct! :xml, version: "1.0", encoding: "UTF-8"
|
||||
|
||||
xml.rss :version => "2.0",
|
||||
"xmlns:atom" => "http://www.w3.org/2005/Atom",
|
||||
"xmlns:content" => "http://purl.org/rss/1.0/modules/content/" do
|
||||
xml.channel do
|
||||
xml.title @site.title
|
||||
xml.link @site.url
|
||||
xml.description @site.description
|
||||
xml.pubDate recent.first.date.rfc822 if recent.any?
|
||||
xml.tag! "atom:link", href: @site.url_for("/feed.xml"), rel: "self", type: "application/rss+xml"
|
||||
|
||||
recent.each do |post|
|
||||
xml.item do
|
||||
title = post.link_post? ? "→ #{post.title}" : post.title
|
||||
permalink = @site.url_for(post.path)
|
||||
xml.title title
|
||||
xml.link permalink
|
||||
xml.guid permalink, isPermaLink: "true"
|
||||
xml.pubDate post.date.rfc822
|
||||
xml.author post.author
|
||||
xml.tag!("content:encoded") { xml.cdata!(render_feed_post(post)) }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
file_path = File.join(target_path, "feed.xml")
|
||||
Utils::FileWriter.write(path: file_path, content: xml.target!)
|
||||
end
|
||||
|
||||
def render_feed_post(post)
|
||||
Views::FeedPostView.new(post:, site: @site).call
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
137
lib/pressa/posts/writer.rb
Normal file
137
lib/pressa/posts/writer.rb
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
require "pressa/utils/file_writer"
|
||||
require "pressa/views/layout"
|
||||
require "pressa/views/post_view"
|
||||
require "pressa/views/recent_posts_view"
|
||||
require "pressa/views/archive_view"
|
||||
require "pressa/views/year_posts_view"
|
||||
require "pressa/views/month_posts_view"
|
||||
|
||||
module Pressa
|
||||
module Posts
|
||||
class PostWriter
|
||||
def initialize(site:, posts_by_year:)
|
||||
@site = site
|
||||
@posts_by_year = posts_by_year
|
||||
end
|
||||
|
||||
def write_posts(target_path:)
|
||||
@posts_by_year.all_posts.each do |post|
|
||||
write_post(post:, target_path:)
|
||||
end
|
||||
end
|
||||
|
||||
def write_recent_posts(target_path:, limit: 10)
|
||||
recent = @posts_by_year.recent_posts(limit)
|
||||
content_view = Views::RecentPostsView.new(posts: recent, site: @site)
|
||||
|
||||
html = render_layout(
|
||||
page_subtitle: nil,
|
||||
canonical_url: @site.url,
|
||||
content: content_view,
|
||||
page_description: "Recent posts",
|
||||
page_type: "article"
|
||||
)
|
||||
|
||||
file_path = File.join(target_path, "index.html")
|
||||
Utils::FileWriter.write(path: file_path, content: html)
|
||||
end
|
||||
|
||||
def write_archive(target_path:)
|
||||
content_view = Views::ArchiveView.new(posts_by_year: @posts_by_year, site: @site)
|
||||
|
||||
html = render_layout(
|
||||
page_subtitle: "Archive",
|
||||
canonical_url: @site.url_for("/posts/"),
|
||||
content: content_view,
|
||||
page_description: "Archive of all posts"
|
||||
)
|
||||
|
||||
file_path = File.join(target_path, "posts", "index.html")
|
||||
Utils::FileWriter.write(path: file_path, content: html)
|
||||
end
|
||||
|
||||
def write_year_indexes(target_path:)
|
||||
@posts_by_year.sorted_years.each do |year|
|
||||
year_posts = @posts_by_year.by_year[year]
|
||||
write_year_index(year:, year_posts:, target_path:)
|
||||
end
|
||||
end
|
||||
|
||||
def write_month_rollups(target_path:)
|
||||
@posts_by_year.by_year.each do |year, year_posts|
|
||||
year_posts.by_month.each do |_month_num, month_posts|
|
||||
write_month_rollup(year:, month_posts:, target_path:)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def write_post(post:, target_path:)
|
||||
content_view = Views::PostView.new(post:, site: @site, article_class: "container")
|
||||
|
||||
html = render_layout(
|
||||
page_subtitle: post.title,
|
||||
canonical_url: @site.url_for(post.path),
|
||||
content: content_view,
|
||||
page_description: post.excerpt,
|
||||
page_type: "article"
|
||||
)
|
||||
|
||||
file_path = File.join(target_path, post.path.sub(/^\//, ""), "index.html")
|
||||
Utils::FileWriter.write(path: file_path, content: html)
|
||||
end
|
||||
|
||||
def write_year_index(year:, year_posts:, target_path:)
|
||||
content_view = Views::YearPostsView.new(year:, year_posts:, site: @site)
|
||||
|
||||
html = render_layout(
|
||||
page_subtitle: year.to_s,
|
||||
canonical_url: @site.url_for("/posts/#{year}/"),
|
||||
content: content_view,
|
||||
page_description: "Archive of all posts from #{year}",
|
||||
page_type: "article"
|
||||
)
|
||||
|
||||
file_path = File.join(target_path, "posts", year.to_s, "index.html")
|
||||
Utils::FileWriter.write(path: file_path, content: html)
|
||||
end
|
||||
|
||||
def write_month_rollup(year:, month_posts:, target_path:)
|
||||
month = month_posts.month
|
||||
content_view = Views::MonthPostsView.new(year:, month_posts:, site: @site)
|
||||
|
||||
title = "#{month.name} #{year}"
|
||||
html = render_layout(
|
||||
page_subtitle: title,
|
||||
canonical_url: @site.url_for("/posts/#{year}/#{month.padded}/"),
|
||||
content: content_view,
|
||||
page_description: "Archive of all posts from #{title}",
|
||||
page_type: "article"
|
||||
)
|
||||
|
||||
file_path = File.join(target_path, "posts", year.to_s, month.padded, "index.html")
|
||||
Utils::FileWriter.write(path: file_path, content: html)
|
||||
end
|
||||
|
||||
def render_layout(
|
||||
page_subtitle:,
|
||||
canonical_url:,
|
||||
content:,
|
||||
page_description: nil,
|
||||
page_type: "website"
|
||||
)
|
||||
layout = Views::Layout.new(
|
||||
site: @site,
|
||||
page_subtitle:,
|
||||
canonical_url:,
|
||||
page_description:,
|
||||
page_type:,
|
||||
content:
|
||||
)
|
||||
|
||||
layout.call
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
22
lib/pressa/projects/models.rb
Normal file
22
lib/pressa/projects/models.rb
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
require "dry-struct"
|
||||
require "pressa/site"
|
||||
|
||||
module Pressa
|
||||
module Projects
|
||||
class Project < Dry::Struct
|
||||
attribute :name, Types::String
|
||||
attribute :title, Types::String
|
||||
attribute :description, Types::String
|
||||
attribute :url, Types::String
|
||||
|
||||
def github_path
|
||||
uri = URI.parse(url)
|
||||
uri.path.sub(/^\//, "")
|
||||
end
|
||||
|
||||
def path
|
||||
"/projects/#{name}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
138
lib/pressa/projects/plugin.rb
Normal file
138
lib/pressa/projects/plugin.rb
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
require "pressa/plugin"
|
||||
require "pressa/utils/file_writer"
|
||||
require "pressa/views/layout"
|
||||
require "pressa/views/projects_view"
|
||||
require "pressa/views/project_view"
|
||||
require "pressa/projects/models"
|
||||
|
||||
module Pressa
|
||||
module Projects
|
||||
class HTMLPlugin < Pressa::Plugin
|
||||
attr_reader :scripts, :styles
|
||||
|
||||
def initialize(projects: [], scripts: [], styles: [])
|
||||
@projects = projects
|
||||
@scripts = scripts
|
||||
@styles = styles
|
||||
end
|
||||
|
||||
def setup(site:, source_path:)
|
||||
end
|
||||
|
||||
def render(site:, target_path:)
|
||||
write_projects_index(site:, target_path:)
|
||||
|
||||
@projects.each do |project|
|
||||
write_project_page(project:, site:, target_path:)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def write_projects_index(site:, target_path:)
|
||||
content_view = Views::ProjectsView.new(projects: @projects, site:)
|
||||
|
||||
html = render_layout(
|
||||
site:,
|
||||
page_subtitle: "Projects",
|
||||
canonical_url: site.url_for("/projects/"),
|
||||
content: content_view
|
||||
)
|
||||
|
||||
file_path = File.join(target_path, "projects", "index.html")
|
||||
Utils::FileWriter.write(path: file_path, content: html)
|
||||
end
|
||||
|
||||
def write_project_page(project:, site:, target_path:)
|
||||
content_view = Views::ProjectView.new(project:, site:)
|
||||
|
||||
html = render_layout(
|
||||
site:,
|
||||
page_subtitle: project.title,
|
||||
canonical_url: site.url_for(project.path),
|
||||
content: content_view,
|
||||
page_scripts: @scripts,
|
||||
page_styles: @styles,
|
||||
page_description: project.description
|
||||
)
|
||||
|
||||
file_path = File.join(target_path, "projects", project.name, "index.html")
|
||||
Utils::FileWriter.write(path: file_path, content: html)
|
||||
end
|
||||
|
||||
def render_layout(
|
||||
site:,
|
||||
page_subtitle:,
|
||||
canonical_url:,
|
||||
content:,
|
||||
page_scripts: [],
|
||||
page_styles: [],
|
||||
page_description: nil
|
||||
)
|
||||
layout = Views::Layout.new(
|
||||
site:,
|
||||
page_subtitle:,
|
||||
canonical_url:,
|
||||
page_scripts:,
|
||||
page_styles:,
|
||||
page_description:,
|
||||
content:
|
||||
)
|
||||
|
||||
layout.call
|
||||
end
|
||||
end
|
||||
|
||||
class GeminiPlugin < Pressa::Plugin
|
||||
def initialize(projects: [])
|
||||
@projects = projects
|
||||
end
|
||||
|
||||
def setup(site:, source_path:)
|
||||
end
|
||||
|
||||
def render(site:, target_path:)
|
||||
write_projects_index(site:, target_path:)
|
||||
|
||||
@projects.each do |project|
|
||||
write_project_page(project:, site:, target_path:)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def write_projects_index(site:, target_path:)
|
||||
rows = ["# Projects", ""]
|
||||
@projects.each do |project|
|
||||
rows << "## #{project.title}"
|
||||
rows << project.description
|
||||
rows << "=> #{project.url}"
|
||||
rows << ""
|
||||
end
|
||||
rows << "=> / Home"
|
||||
rows << "=> #{site.url_for("/projects/")} Read on the web"
|
||||
rows << ""
|
||||
|
||||
file_path = File.join(target_path, "projects", "index.gmi")
|
||||
Utils::FileWriter.write(path: file_path, content: rows.join("\n"))
|
||||
end
|
||||
|
||||
def write_project_page(project:, site:, target_path:)
|
||||
rows = [
|
||||
"# #{project.title}",
|
||||
"",
|
||||
project.description,
|
||||
"",
|
||||
"=> #{project.url}",
|
||||
"=> /projects/ Back to projects",
|
||||
""
|
||||
]
|
||||
|
||||
file_path = File.join(target_path, "projects", project.name, "index.gmi")
|
||||
Utils::FileWriter.write(path: file_path, content: rows.join("\n"))
|
||||
end
|
||||
end
|
||||
|
||||
Plugin = HTMLPlugin
|
||||
end
|
||||
end
|
||||
76
lib/pressa/site.rb
Normal file
76
lib/pressa/site.rb
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
require "dry-struct"
|
||||
|
||||
module Pressa
|
||||
module Types
|
||||
include Dry.Types()
|
||||
end
|
||||
|
||||
class OutputLink < Dry::Struct
|
||||
# label is required for HTML remote links, but Gemini home_links may omit it.
|
||||
attribute :label, Types::String.optional.default(nil)
|
||||
attribute :href, Types::String
|
||||
attribute :icon, Types::String.optional.default(nil)
|
||||
end
|
||||
|
||||
class Script < Dry::Struct
|
||||
attribute :src, Types::String
|
||||
attribute :defer, Types::Bool.default(true)
|
||||
end
|
||||
|
||||
class Stylesheet < Dry::Struct
|
||||
attribute :href, Types::String
|
||||
end
|
||||
|
||||
class OutputOptions < Dry::Struct
|
||||
attribute :public_excludes, Types::Array.of(Types::String).default([].freeze)
|
||||
end
|
||||
|
||||
class HTMLOutputOptions < OutputOptions
|
||||
attribute :remote_links, Types::Array.of(OutputLink).default([].freeze)
|
||||
end
|
||||
|
||||
class GeminiOutputOptions < OutputOptions
|
||||
attribute :recent_posts_limit, Types::Integer.default(20)
|
||||
attribute :home_links, Types::Array.of(OutputLink).default([].freeze)
|
||||
end
|
||||
|
||||
class Site < Dry::Struct
|
||||
OUTPUT_OPTIONS = Types.Instance(OutputOptions)
|
||||
|
||||
attribute :author, Types::String
|
||||
attribute :email, Types::String
|
||||
attribute :title, Types::String
|
||||
attribute :description, Types::String
|
||||
attribute :url, Types::String
|
||||
attribute :fediverse_creator, Types::String.optional.default(nil)
|
||||
attribute :image_url, Types::String.optional.default(nil)
|
||||
attribute :copyright_start_year, Types::Integer.optional.default(nil)
|
||||
attribute :scripts, Types::Array.of(Script).default([].freeze)
|
||||
attribute :styles, Types::Array.of(Stylesheet).default([].freeze)
|
||||
attribute :plugins, Types::Array.default([].freeze)
|
||||
attribute :renderers, Types::Array.default([].freeze)
|
||||
attribute :output_format, Types::String.default("html".freeze).enum("html", "gemini")
|
||||
attribute :output_options, OUTPUT_OPTIONS.default { HTMLOutputOptions.new }
|
||||
|
||||
def url_for(path)
|
||||
"#{url}#{path}"
|
||||
end
|
||||
|
||||
def image_url_for(path)
|
||||
return nil unless image_url
|
||||
"#{image_url}#{path}"
|
||||
end
|
||||
|
||||
def public_excludes
|
||||
output_options.public_excludes
|
||||
end
|
||||
|
||||
def html_output_options
|
||||
output_options if output_options.is_a?(HTMLOutputOptions)
|
||||
end
|
||||
|
||||
def gemini_output_options
|
||||
output_options if output_options.is_a?(GeminiOutputOptions)
|
||||
end
|
||||
end
|
||||
end
|
||||
147
lib/pressa/site_generator.rb
Normal file
147
lib/pressa/site_generator.rb
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
require "fileutils"
|
||||
require "pressa/utils/file_writer"
|
||||
|
||||
module Pressa
|
||||
class SiteGenerator
|
||||
attr_reader :site
|
||||
|
||||
def initialize(site:)
|
||||
@site = site
|
||||
end
|
||||
|
||||
def generate(source_path:, target_path:)
|
||||
validate_paths!(source_path:, target_path:)
|
||||
|
||||
FileUtils.rm_rf(target_path)
|
||||
FileUtils.mkdir_p(target_path)
|
||||
|
||||
setup_site = site
|
||||
setup_site.plugins.each { |plugin| plugin.setup(site: setup_site, source_path:) }
|
||||
|
||||
@site = site_with_copyright_start_year(setup_site)
|
||||
site.plugins.each { |plugin| plugin.render(site:, target_path:) }
|
||||
|
||||
copy_static_files(source_path, target_path)
|
||||
process_public_directory(source_path, target_path)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_paths!(source_path:, target_path:)
|
||||
source_abs = absolute_path(source_path)
|
||||
target_abs = absolute_path(target_path)
|
||||
return unless contains_path?(container: target_abs, path: source_abs)
|
||||
|
||||
raise ArgumentError, "target_path must not be the same as or contain source_path"
|
||||
end
|
||||
|
||||
def absolute_path(path)
|
||||
File.exist?(path) ? File.realpath(path) : File.expand_path(path)
|
||||
end
|
||||
|
||||
def contains_path?(container:, path:)
|
||||
path == container || path.start_with?("#{container}#{File::SEPARATOR}")
|
||||
end
|
||||
|
||||
def copy_static_files(source_path, target_path)
|
||||
public_dir = File.join(source_path, "public")
|
||||
return unless Dir.exist?(public_dir)
|
||||
|
||||
Dir.glob(File.join(public_dir, "**", "*"), File::FNM_DOTMATCH).each do |source_file|
|
||||
next if File.directory?(source_file)
|
||||
next if skip_file?(source_file)
|
||||
next if skip_for_output_format?(source_file:, public_dir:)
|
||||
|
||||
filename = File.basename(source_file)
|
||||
ext = File.extname(source_file)[1..]
|
||||
|
||||
if can_render?(filename, ext)
|
||||
next
|
||||
end
|
||||
|
||||
relative_path = source_file.sub("#{public_dir}/", "")
|
||||
target_file = File.join(target_path, relative_path)
|
||||
|
||||
FileUtils.mkdir_p(File.dirname(target_file))
|
||||
FileUtils.cp(source_file, target_file)
|
||||
end
|
||||
end
|
||||
|
||||
def can_render?(filename, ext)
|
||||
site.renderers.any? { |renderer| renderer.can_render_file?(filename:, extension: ext) }
|
||||
end
|
||||
|
||||
def process_public_directory(source_path, target_path)
|
||||
public_dir = File.join(source_path, "public")
|
||||
return unless Dir.exist?(public_dir)
|
||||
|
||||
site.renderers.each do |renderer|
|
||||
Dir.glob(File.join(public_dir, "**", "*"), File::FNM_DOTMATCH).each do |source_file|
|
||||
next if File.directory?(source_file)
|
||||
next if skip_file?(source_file)
|
||||
next if skip_for_output_format?(source_file:, public_dir:)
|
||||
|
||||
filename = File.basename(source_file)
|
||||
ext = File.extname(source_file)[1..]
|
||||
|
||||
if renderer.can_render_file?(filename:, extension: ext)
|
||||
dir_name = File.dirname(source_file)
|
||||
relative_path = if dir_name == public_dir
|
||||
""
|
||||
else
|
||||
dir_name.sub("#{public_dir}/", "")
|
||||
end
|
||||
target_dir = File.join(target_path, relative_path)
|
||||
|
||||
renderer.render(site:, file_path: source_file, target_dir:)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def skip_file?(source_file)
|
||||
basename = File.basename(source_file)
|
||||
basename.start_with?(".")
|
||||
end
|
||||
|
||||
def skip_for_output_format?(source_file:, public_dir:)
|
||||
relative_path = source_file.sub("#{public_dir}/", "")
|
||||
site.public_excludes.any? do |pattern|
|
||||
excluded_by_pattern?(relative_path:, pattern:)
|
||||
end
|
||||
end
|
||||
|
||||
def excluded_by_pattern?(relative_path:, pattern:)
|
||||
normalized = pattern.sub(%r{\A/+}, "")
|
||||
|
||||
if normalized.end_with?("/**")
|
||||
prefix = normalized.delete_suffix("/**")
|
||||
return relative_path.start_with?("#{prefix}/") || relative_path == prefix
|
||||
end
|
||||
|
||||
File.fnmatch?(normalized, relative_path, File::FNM_PATHNAME)
|
||||
end
|
||||
|
||||
def site_with_copyright_start_year(base_site)
|
||||
start_year = find_copyright_start_year(base_site)
|
||||
attrs = base_site.to_h.merge(
|
||||
output_options: base_site.output_options,
|
||||
copyright_start_year: start_year
|
||||
)
|
||||
Site.new(**attrs)
|
||||
end
|
||||
|
||||
def find_copyright_start_year(base_site)
|
||||
years = base_site.plugins.filter_map do |plugin|
|
||||
next unless plugin.respond_to?(:posts_by_year)
|
||||
|
||||
posts_by_year = plugin.posts_by_year
|
||||
next unless posts_by_year.respond_to?(:earliest_year)
|
||||
|
||||
posts_by_year.earliest_year
|
||||
end
|
||||
|
||||
years.min || Time.now.year
|
||||
end
|
||||
end
|
||||
end
|
||||
20
lib/pressa/utils/file_writer.rb
Normal file
20
lib/pressa/utils/file_writer.rb
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
require "fileutils"
|
||||
|
||||
module Pressa
|
||||
module Utils
|
||||
class FileWriter
|
||||
def self.write(path:, content:, permissions: 0o644)
|
||||
FileUtils.mkdir_p(File.dirname(path))
|
||||
File.write(path, content, mode: "w")
|
||||
File.chmod(permissions, path)
|
||||
end
|
||||
|
||||
def self.write_data(path:, data:, permissions: 0o644)
|
||||
FileUtils.mkdir_p(File.dirname(path))
|
||||
|
||||
File.binwrite(path, data)
|
||||
File.chmod(permissions, path)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
67
lib/pressa/utils/gemini_markdown_renderer.rb
Normal file
67
lib/pressa/utils/gemini_markdown_renderer.rb
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
require "yaml"
|
||||
require "pressa/utils/file_writer"
|
||||
require "pressa/utils/gemtext_renderer"
|
||||
|
||||
module Pressa
|
||||
module Utils
|
||||
class GeminiMarkdownRenderer
|
||||
def can_render_file?(filename:, extension:)
|
||||
extension == "md"
|
||||
end
|
||||
|
||||
def render(site:, file_path:, target_dir:)
|
||||
content = File.read(file_path)
|
||||
metadata, body_markdown = parse_content(content)
|
||||
|
||||
page_title = presence(metadata["Title"]) || File.basename(file_path, ".md").capitalize
|
||||
show_extension = ["true", "yes", true].include?(metadata["Show extension"])
|
||||
slug = File.basename(file_path, ".md")
|
||||
|
||||
relative_dir = File.dirname(file_path).sub(/^.*?\/public\/?/, "")
|
||||
relative_dir = "" if relative_dir == "."
|
||||
|
||||
canonical_html_path = if show_extension
|
||||
"/#{relative_dir}/#{slug}.html".squeeze("/")
|
||||
else
|
||||
"/#{relative_dir}/#{slug}/".squeeze("/")
|
||||
end
|
||||
|
||||
rows = ["# #{page_title}", ""]
|
||||
gemtext_body = GemtextRenderer.render(body_markdown)
|
||||
rows << gemtext_body unless gemtext_body.empty?
|
||||
rows << "" unless rows.last.to_s.empty?
|
||||
rows << "=> #{site.url_for(canonical_html_path)} Read on the web"
|
||||
rows << ""
|
||||
|
||||
output_filename = if show_extension
|
||||
"#{slug}.gmi"
|
||||
else
|
||||
File.join(slug, "index.gmi")
|
||||
end
|
||||
|
||||
output_path = File.join(target_dir, output_filename)
|
||||
FileWriter.write(path: output_path, content: rows.join("\n"))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def parse_content(content)
|
||||
if content =~ /\A---\s*\n(.*?)\n---\s*\n(.*)/m
|
||||
yaml_content = Regexp.last_match(1)
|
||||
markdown = Regexp.last_match(2)
|
||||
metadata = YAML.safe_load(yaml_content) || {}
|
||||
[metadata, markdown]
|
||||
else
|
||||
[{}, content]
|
||||
end
|
||||
end
|
||||
|
||||
def presence(value)
|
||||
return value unless value.respond_to?(:strip)
|
||||
|
||||
stripped = value.strip
|
||||
stripped.empty? ? nil : stripped
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
257
lib/pressa/utils/gemtext_renderer.rb
Normal file
257
lib/pressa/utils/gemtext_renderer.rb
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
require "cgi"
|
||||
|
||||
module Pressa
|
||||
module Utils
|
||||
class GemtextRenderer
|
||||
class << self
|
||||
def render(markdown)
|
||||
lines = markdown.to_s.gsub("\r\n", "\n").split("\n")
|
||||
link_reference_definitions = extract_link_reference_definitions(lines)
|
||||
output_lines = []
|
||||
in_preformatted_block = false
|
||||
|
||||
lines.each do |line|
|
||||
if line.start_with?("```")
|
||||
output_lines << "```"
|
||||
in_preformatted_block = !in_preformatted_block
|
||||
next
|
||||
end
|
||||
|
||||
if in_preformatted_block
|
||||
output_lines << line
|
||||
next
|
||||
end
|
||||
|
||||
next if link_reference_definition?(line)
|
||||
|
||||
converted_lines = convert_line(line, link_reference_definitions)
|
||||
output_lines.concat(converted_lines)
|
||||
end
|
||||
|
||||
squish_blank_lines(output_lines).join("\n").strip
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def convert_line(line, link_reference_definitions)
|
||||
stripped = line.strip
|
||||
return [""] if stripped.empty?
|
||||
|
||||
return convert_heading(stripped, link_reference_definitions) if heading_line?(stripped)
|
||||
return convert_list_item(stripped, link_reference_definitions) if list_item_line?(stripped)
|
||||
return convert_quote_line(stripped, link_reference_definitions) if quote_line?(stripped)
|
||||
|
||||
convert_text_line(line, link_reference_definitions)
|
||||
end
|
||||
|
||||
def convert_heading(line, link_reference_definitions)
|
||||
marker, text = line.split(/\s+/, 2)
|
||||
heading_text, links = extract_links(text.to_s, link_reference_definitions)
|
||||
rows = []
|
||||
rows << "#{marker} #{clean_inline_text(heading_text)}".strip
|
||||
rows.concat(render_link_rows(links))
|
||||
rows
|
||||
end
|
||||
|
||||
def convert_list_item(line, link_reference_definitions)
|
||||
text = line.sub(/\A[-*+]\s+/, "")
|
||||
if link_only_list_item?(text, link_reference_definitions)
|
||||
_clean_text, links = extract_links(text, link_reference_definitions)
|
||||
return render_link_rows(links)
|
||||
end
|
||||
|
||||
clean_text, links = extract_links(text, link_reference_definitions)
|
||||
rows = []
|
||||
rows << "* #{clean_inline_text(clean_text)}".strip
|
||||
rows.concat(render_link_rows(links))
|
||||
rows
|
||||
end
|
||||
|
||||
def convert_quote_line(line, link_reference_definitions)
|
||||
text = line.sub(/\A>\s?/, "")
|
||||
clean_text, links = extract_links(text, link_reference_definitions)
|
||||
rows = []
|
||||
rows << "> #{clean_inline_text(clean_text)}".strip
|
||||
rows.concat(render_link_rows(links))
|
||||
rows
|
||||
end
|
||||
|
||||
def convert_text_line(line, link_reference_definitions)
|
||||
clean_text, links = extract_links(line, link_reference_definitions)
|
||||
if !links.empty? && clean_inline_text(strip_links_from_text(line)).empty?
|
||||
return render_link_rows(links)
|
||||
end
|
||||
|
||||
rows = []
|
||||
inline_text = clean_inline_text(clean_text)
|
||||
rows << inline_text unless inline_text.empty?
|
||||
rows.concat(render_link_rows(links))
|
||||
rows.empty? ? [""] : rows
|
||||
end
|
||||
|
||||
def extract_links(text, link_reference_definitions)
|
||||
links = []
|
||||
work = text.dup
|
||||
|
||||
work.gsub!(%r{<a\s+[^>]*href=["']([^"']+)["'][^>]*>(.*?)</a>}i) do
|
||||
url = Regexp.last_match(1)
|
||||
label = clean_inline_text(strip_html_tags(Regexp.last_match(2)))
|
||||
links << [url, label]
|
||||
label
|
||||
end
|
||||
|
||||
work.gsub!(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/) do
|
||||
label = clean_inline_text(Regexp.last_match(1))
|
||||
url = Regexp.last_match(2)
|
||||
links << [url, label]
|
||||
label
|
||||
end
|
||||
|
||||
work.gsub!(/\[([^\]]+)\]\[([^\]]*)\]/) do
|
||||
label_text = Regexp.last_match(1)
|
||||
reference_key = Regexp.last_match(2)
|
||||
reference_key = label_text if reference_key.strip.empty?
|
||||
url = resolve_link_reference(link_reference_definitions, reference_key)
|
||||
next Regexp.last_match(0) unless url
|
||||
|
||||
label = clean_inline_text(label_text)
|
||||
links << [url, label]
|
||||
label
|
||||
end
|
||||
|
||||
work.scan(/(?:href|src)=["']([^"']+)["']/i) do |match|
|
||||
url = match.first
|
||||
next if links.any? { |(existing_url, _)| existing_url == url }
|
||||
|
||||
links << [url, fallback_label(url)]
|
||||
end
|
||||
|
||||
[work, links]
|
||||
end
|
||||
|
||||
def resolve_link_reference(link_reference_definitions, key)
|
||||
link_reference_definitions[normalize_link_reference_key(key)]
|
||||
end
|
||||
|
||||
def link_only_list_item?(text, link_reference_definitions)
|
||||
_clean_text, links = extract_links(text, link_reference_definitions)
|
||||
return false if links.empty?
|
||||
|
||||
remaining_text = strip_links_from_text(text)
|
||||
normalized_remaining = clean_inline_text(remaining_text)
|
||||
return true if normalized_remaining.empty?
|
||||
|
||||
links_count = links.length
|
||||
links_count == 1 && normalized_remaining.match?(/\A[\w@.+\-\/ ]+:\z/)
|
||||
end
|
||||
|
||||
def extract_link_reference_definitions(lines)
|
||||
links = {}
|
||||
lines.each do |line|
|
||||
match = line.match(/\A\s{0,3}\[([^\]]+)\]:\s*(\S+)/)
|
||||
next unless match
|
||||
|
||||
key = normalize_link_reference_key(match[1])
|
||||
value = match[2]
|
||||
value = value[1..-2] if value.start_with?("<") && value.end_with?(">")
|
||||
links[key] = value
|
||||
end
|
||||
links
|
||||
end
|
||||
|
||||
def normalize_link_reference_key(key)
|
||||
key.to_s.strip.downcase.gsub(/\s+/, " ")
|
||||
end
|
||||
|
||||
def strip_links_from_text(text)
|
||||
work = text.dup
|
||||
work.gsub!(%r{<a\s+[^>]*href=["'][^"']+["'][^>]*>.*?</a>}i, "")
|
||||
work.gsub!(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/, "")
|
||||
work.gsub!(/\[([^\]]+)\]\[([^\]]*)\]/, "")
|
||||
work
|
||||
end
|
||||
|
||||
def render_link_rows(links)
|
||||
links.filter_map do |url, label|
|
||||
next nil if url.nil? || url.strip.empty?
|
||||
"=> #{url}"
|
||||
end
|
||||
end
|
||||
|
||||
def clean_inline_text(text)
|
||||
cleaned = text.to_s.dup
|
||||
cleaned = strip_html_tags(cleaned)
|
||||
cleaned.gsub!(/`([^`]+)`/, '\1')
|
||||
cleaned.gsub!(/\*\*([^*]+)\*\*/, '\1')
|
||||
cleaned.gsub!(/__([^_]+)__/, '\1')
|
||||
cleaned.gsub!(/\*([^*]+)\*/, '\1')
|
||||
cleaned.gsub!(/_([^_]+)_/, '\1')
|
||||
cleaned.gsub!(/\s+/, " ")
|
||||
cleaned = CGI.unescapeHTML(cleaned)
|
||||
cleaned = decode_named_html_entities(cleaned)
|
||||
cleaned.strip
|
||||
end
|
||||
|
||||
def decode_named_html_entities(text)
|
||||
text.gsub(/&([A-Za-z]+);/) do
|
||||
entity = Regexp.last_match(1).downcase
|
||||
|
||||
case entity
|
||||
when "darr" then "\u2193"
|
||||
when "uarr" then "\u2191"
|
||||
when "larr" then "\u2190"
|
||||
when "rarr" then "\u2192"
|
||||
when "hellip" then "..."
|
||||
when "nbsp" then " "
|
||||
else
|
||||
"&#{Regexp.last_match(1)};"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def strip_html_tags(text)
|
||||
text.gsub(/<[^>]+>/, "")
|
||||
end
|
||||
|
||||
def fallback_label(url)
|
||||
uri_path = url.split("?").first
|
||||
basename = File.basename(uri_path.to_s)
|
||||
return url if basename.nil? || basename.empty? || basename == "/"
|
||||
|
||||
basename
|
||||
end
|
||||
|
||||
def heading_line?(line)
|
||||
line.match?(/\A\#{1,3}\s+/)
|
||||
end
|
||||
|
||||
def list_item_line?(line)
|
||||
line.match?(/\A[-*+]\s+/)
|
||||
end
|
||||
|
||||
def quote_line?(line)
|
||||
line.start_with?(">")
|
||||
end
|
||||
|
||||
def link_reference_definition?(line)
|
||||
line.match?(/\A\s{0,3}\[[^\]]+\]:\s+\S/)
|
||||
end
|
||||
|
||||
def squish_blank_lines(lines)
|
||||
output = []
|
||||
previous_blank = false
|
||||
|
||||
lines.each do |line|
|
||||
blank = line.strip.empty?
|
||||
next if blank && previous_blank
|
||||
|
||||
output << line
|
||||
previous_blank = blank
|
||||
end
|
||||
|
||||
output
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
148
lib/pressa/utils/markdown_renderer.rb
Normal file
148
lib/pressa/utils/markdown_renderer.rb
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
require "kramdown"
|
||||
require "yaml"
|
||||
require "pressa/utils/file_writer"
|
||||
require "pressa/site"
|
||||
require "pressa/views/layout"
|
||||
require "pressa/views/icons"
|
||||
|
||||
module Pressa
|
||||
module Utils
|
||||
class MarkdownRenderer
|
||||
EXCERPT_LENGTH = 300
|
||||
|
||||
def can_render_file?(filename:, extension:)
|
||||
extension == "md"
|
||||
end
|
||||
|
||||
def render(site:, file_path:, target_dir:)
|
||||
content = File.read(file_path)
|
||||
metadata, body_markdown = parse_content(content)
|
||||
|
||||
html_body = render_markdown(body_markdown)
|
||||
|
||||
page_title = presence(metadata["Title"]) || File.basename(file_path, ".md").capitalize
|
||||
page_type = presence(metadata["Page type"]) || "website"
|
||||
page_description = presence(metadata["Description"]) || generate_excerpt(body_markdown)
|
||||
show_extension = ["true", "yes", true].include?(metadata["Show extension"])
|
||||
|
||||
slug = File.basename(file_path, ".md")
|
||||
|
||||
relative_dir = File.dirname(file_path).sub(/^.*?\/public\/?/, "")
|
||||
relative_dir = "" if relative_dir == "."
|
||||
|
||||
canonical_path = if show_extension
|
||||
"/#{relative_dir}/#{slug}.html".squeeze("/")
|
||||
else
|
||||
"/#{relative_dir}/#{slug}/".squeeze("/")
|
||||
end
|
||||
|
||||
html = render_layout(
|
||||
site:,
|
||||
page_subtitle: page_title,
|
||||
canonical_url: site.url_for(canonical_path),
|
||||
body: html_body,
|
||||
page_description:,
|
||||
page_type:
|
||||
)
|
||||
|
||||
output_filename = if show_extension
|
||||
"#{slug}.html"
|
||||
else
|
||||
File.join(slug, "index.html")
|
||||
end
|
||||
|
||||
output_path = File.join(target_dir, output_filename)
|
||||
FileWriter.write(path: output_path, content: html)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def parse_content(content)
|
||||
if content =~ /\A---\s*\n(.*?)\n---\s*\n(.*)/m
|
||||
yaml_content = $1
|
||||
markdown = $2
|
||||
metadata = YAML.safe_load(yaml_content) || {}
|
||||
[metadata, markdown]
|
||||
else
|
||||
[{}, content]
|
||||
end
|
||||
end
|
||||
|
||||
def render_markdown(markdown)
|
||||
Kramdown::Document.new(
|
||||
markdown,
|
||||
input: "GFM",
|
||||
hard_wrap: false,
|
||||
syntax_highlighter: "rouge",
|
||||
syntax_highlighter_opts: {
|
||||
line_numbers: false,
|
||||
wrap: true
|
||||
}
|
||||
).to_html
|
||||
end
|
||||
|
||||
def render_layout(site:, page_subtitle:, canonical_url:, body:, page_description:, page_type:)
|
||||
layout = Views::Layout.new(
|
||||
site:,
|
||||
page_subtitle:,
|
||||
canonical_url:,
|
||||
page_description:,
|
||||
page_type:,
|
||||
content: PageView.new(page_title: page_subtitle, body:)
|
||||
)
|
||||
|
||||
layout.call
|
||||
end
|
||||
|
||||
class PageView < Phlex::HTML
|
||||
def initialize(page_title:, body:)
|
||||
@page_title = page_title
|
||||
@body = body
|
||||
end
|
||||
|
||||
def view_template
|
||||
article(class: "container") do
|
||||
h1 { @page_title }
|
||||
raw(safe(@body))
|
||||
end
|
||||
|
||||
div(class: "row clearfix") do
|
||||
p(class: "fin") do
|
||||
raw(safe(Views::Icons.code))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def generate_excerpt(markdown)
|
||||
text = markdown.dup
|
||||
|
||||
# Drop inline and reference-style images before links are simplified.
|
||||
text.gsub!(/!\[[^\]]*\]\([^)]+\)/, "")
|
||||
text.gsub!(/!\[[^\]]*\]\[[^\]]+\]/, "")
|
||||
|
||||
# Replace inline and reference links with just their text.
|
||||
text.gsub!(/\[([^\]]+)\]\([^)]+\)/, '\1')
|
||||
text.gsub!(/\[([^\]]+)\]\[[^\]]+\]/, '\1')
|
||||
|
||||
# Remove link reference definitions such as: [foo]: http://example.com
|
||||
text.gsub!(/(?m)^\[[^\]]+\]:\s*\S.*$/, "")
|
||||
|
||||
text.gsub!(/<[^>]+>/, "")
|
||||
text.gsub!(/\s+/, " ")
|
||||
text.strip!
|
||||
|
||||
return nil if text.empty?
|
||||
|
||||
"#{text[0...EXCERPT_LENGTH]}..."
|
||||
end
|
||||
|
||||
def presence(value)
|
||||
return value unless value.respond_to?(:strip)
|
||||
|
||||
stripped = value.strip
|
||||
stripped.empty? ? nil : stripped
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
24
lib/pressa/views/archive_view.rb
Normal file
24
lib/pressa/views/archive_view.rb
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
require "phlex"
|
||||
require "pressa/views/year_posts_view"
|
||||
|
||||
module Pressa
|
||||
module Views
|
||||
class ArchiveView < Phlex::HTML
|
||||
def initialize(posts_by_year:, site:)
|
||||
@posts_by_year = posts_by_year
|
||||
@site = site
|
||||
end
|
||||
|
||||
def view_template
|
||||
div(class: "container") do
|
||||
h1 { "Archive" }
|
||||
end
|
||||
|
||||
@posts_by_year.sorted_years.each do |year|
|
||||
year_posts = @posts_by_year.by_year[year]
|
||||
render Views::YearPostsView.new(year:, year_posts:, site: @site)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
33
lib/pressa/views/feed_post_view.rb
Normal file
33
lib/pressa/views/feed_post_view.rb
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
require "phlex"
|
||||
|
||||
module Pressa
|
||||
module Views
|
||||
class FeedPostView < Phlex::HTML
|
||||
def initialize(post:, site:)
|
||||
@post = post
|
||||
@site = site
|
||||
end
|
||||
|
||||
def view_template
|
||||
div do
|
||||
p(class: "time") { @post.formatted_date }
|
||||
raw(safe(normalized_body))
|
||||
p do
|
||||
a(class: "permalink", href: @site.url_for(@post.path)) { "∞" }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalized_body
|
||||
@post.body.gsub(/(href|src)=(['"])(\/(?!\/)[^'"]*)\2/) do
|
||||
attr = Regexp.last_match(1)
|
||||
quote = Regexp.last_match(2)
|
||||
path = Regexp.last_match(3)
|
||||
%(#{attr}=#{quote}#{@site.url_for(path)}#{quote})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
34
lib/pressa/views/icons.rb
Normal file
34
lib/pressa/views/icons.rb
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
module Pressa
|
||||
module Views
|
||||
module Icons
|
||||
module_function
|
||||
|
||||
def mastodon
|
||||
svg(class_name: "icon icon-mastodon", view_box: "0 0 448 512", path: IconPath::MASTODON)
|
||||
end
|
||||
|
||||
def github
|
||||
svg(class_name: "icon icon-github", view_box: "0 0 496 512", path: IconPath::GITHUB)
|
||||
end
|
||||
|
||||
def rss
|
||||
svg(class_name: "icon icon-rss", view_box: "0 0 448 512", path: IconPath::RSS)
|
||||
end
|
||||
|
||||
def code
|
||||
svg(class_name: "icon icon-code", view_box: "0 0 640 512", path: IconPath::CODE)
|
||||
end
|
||||
|
||||
private_class_method def svg(class_name:, view_box:, path:)
|
||||
"<svg class=\"#{class_name}\" viewBox=\"#{view_box}\" aria-hidden=\"true\" focusable=\"false\"><path transform=\"translate(0,448) scale(1,-1)\" d=\"#{path}\"/></svg>"
|
||||
end
|
||||
|
||||
module IconPath
|
||||
MASTODON = "M433 268.89c0 0 0.799805 -71.6992 -9 -121.5c-6.23047 -31.5996 -55.1104 -66.1992 -111.23 -72.8994c-20.0996 -2.40039 -93.1191 -14.2002 -178.75 6.7002c0 -0.116211 -0.00390625 -0.119141 -0.00390625 -0.235352c0 -4.63281 0.307617 -9.19434 0.904297 -13.665 c6.62988 -49.5996 49.2197 -52.5996 89.6299 -54c40.8105 -1.2998 77.1201 10.0996 77.1201 10.0996l1.7002 -36.8994s-28.5098 -15.2998 -79.3203 -18.1006c-28.0098 -1.59961 -62.8193 0.700195 -103.33 11.4004c-112.229 29.7002 -105.63 173.4 -105.63 289.1 c0 97.2002 63.7197 125.7 63.7197 125.7c61.9209 28.4004 227.96 28.7002 290.48 0c0 0 63.71 -28.5 63.71 -125.7zM357.88 143.69c0 122 5.29004 147.71 -18.4199 175.01c-25.71 28.7002 -79.7197 31 -103.83 -6.10059l-11.5996 -19.5l-11.6006 19.5 c-24.0098 36.9004 -77.9297 35 -103.83 6.10059c-23.6094 -27.1006 -18.4092 -52.9004 -18.4092 -175h46.7295v114.2c0 49.6992 64 51.5996 64 -6.90039v-62.5098h46.3301v62.5c0 58.5 64 56.5996 64 6.89941v-114.199h46.6299z"
|
||||
GITHUB = "M165.9 50.5996c0 -2 -2.30078 -3.59961 -5.2002 -3.59961c-3.2998 -0.299805 -5.60059 1.2998 -5.60059 3.59961c0 2 2.30078 3.60059 5.2002 3.60059c3 0.299805 5.60059 -1.2998 5.60059 -3.60059zM134.8 55.0996c0.700195 2 3.60059 3 6.2002 2.30078 c3 -0.900391 4.90039 -3.2002 4.2998 -5.2002c-0.599609 -2 -3.59961 -3 -6.2002 -2c-3 0.599609 -5 2.89941 -4.2998 4.89941zM179 56.7998c2.90039 0.299805 5.59961 -1 5.90039 -2.89941c0.299805 -2 -1.7002 -3.90039 -4.60059 -4.60059 c-3 -0.700195 -5.59961 0.600586 -5.89941 2.60059c-0.300781 2.2998 1.69922 4.19922 4.59961 4.89941zM244.8 440c138.7 0 251.2 -105.3 251.2 -244c0 -110.9 -67.7998 -205.8 -167.8 -239c-12.7002 -2.2998 -17.2998 5.59961 -17.2998 12.0996 c0 8.2002 0.299805 49.9004 0.299805 83.6006c0 23.5 -7.7998 38.5 -17 46.3994c55.8994 6.30078 114.8 14 114.8 110.5c0 27.4004 -9.7998 41.2002 -25.7998 58.9004c2.59961 6.5 11.0996 33.2002 -2.60059 67.9004c-20.8994 6.59961 -69 -27 -69 -27 c-20 5.59961 -41.5 8.5 -62.7998 8.5s-42.7998 -2.90039 -62.7998 -8.5c0 0 -48.0996 33.5 -69 27c-13.7002 -34.6006 -5.2002 -61.4004 -2.59961 -67.9004c-16 -17.5996 -23.6006 -31.4004 -23.6006 -58.9004c0 -96.1992 56.4004 -104.3 112.3 -110.5 c-7.19922 -6.59961 -13.6992 -17.6992 -16 -33.6992c-14.2998 -6.60059 -51 -17.7002 -72.8994 20.8994c-13.7002 23.7998 -38.6006 25.7998 -38.6006 25.7998c-24.5 0.300781 -1.59961 -15.3994 -1.59961 -15.3994c16.4004 -7.5 27.7998 -36.6006 27.7998 -36.6006 c14.7002 -44.7998 84.7002 -29.7998 84.7002 -29.7998c0 -21 0.299805 -55.2002 0.299805 -61.3994c0 -6.5 -4.5 -14.4004 -17.2998 -12.1006c-99.7002 33.4004 -169.5 128.3 -169.5 239.2c0 138.7 106.1 244 244.8 244zM97.2002 95.0996 c1.2998 1.30078 3.59961 0.600586 5.2002 -1c1.69922 -1.89941 2 -4.19922 0.699219 -5.19922c-1.2998 -1.30078 -3.59961 -0.600586 -5.19922 1c-1.7002 1.89941 -2 4.19922 -0.700195 5.19922zM86.4004 103.2c0.699219 1 2.2998 1.2998 4.2998 0.700195 c2 -1 3 -2.60059 2.2998 -3.90039c-0.700195 -1.40039 -2.7002 -1.7002 -4.2998 -0.700195c-2 1 -3 2.60059 -2.2998 3.90039zM118.8 67.5996c1.2998 1.60059 4.2998 1.30078 6.5 -1c2 -1.89941 2.60059 -4.89941 1.2998 -6.19922 c-1.2998 -1.60059 -4.19922 -1.30078 -6.5 1c-2.2998 1.89941 -2.89941 4.89941 -1.2998 6.19922zM107.4 82.2998c1.59961 1.2998 4.19922 0.299805 5.59961 -2c1.59961 -2.2998 1.59961 -4.89941 0 -6.2002c-1.2998 -1 -4 0 -5.59961 2.30078 c-1.60059 2.2998 -1.60059 4.89941 0 5.89941z"
|
||||
RSS = "M128.081 32.041c0 -35.3691 -28.6719 -64.041 -64.041 -64.041s-64.04 28.6719 -64.04 64.041s28.6719 64.041 64.041 64.041s64.04 -28.6729 64.04 -64.041zM303.741 -15.209c0.494141 -9.13477 -6.84668 -16.791 -15.9951 -16.79h-48.0693 c-8.41406 0 -15.4707 6.49023 -16.0176 14.8867c-7.29883 112.07 -96.9404 201.488 -208.772 208.772c-8.39648 0.545898 -14.8867 7.60254 -14.8867 16.0176v48.0693c0 9.14746 7.65625 16.4883 16.791 15.9941c154.765 -8.36328 278.596 -132.351 286.95 -286.95z M447.99 -15.4971c0.324219 -9.03027 -6.97168 -16.5029 -16.0049 -16.5039h-48.0684c-8.62598 0 -15.6455 6.83496 -15.999 15.4531c-7.83789 191.148 -161.286 344.626 -352.465 352.465c-8.61816 0.354492 -15.4531 7.37402 -15.4531 15.999v48.0684 c0 9.03418 7.47266 16.3301 16.5029 16.0059c234.962 -8.43555 423.093 -197.667 431.487 -431.487z"
|
||||
CODE = "M278.9 -63.5l-61 17.7002c-6.40039 1.7998 -10 8.5 -8.2002 14.8994l136.5 470.2c1.7998 6.40039 8.5 10 14.8994 8.2002l61 -17.7002c6.40039 -1.7998 10 -8.5 8.2002 -14.8994l-136.5 -470.2c-1.89941 -6.40039 -8.5 -10.1006 -14.8994 -8.2002zM164.9 48.7002 c-4.5 -4.90039 -12.1006 -5.10059 -17 -0.5l-144.101 135.1c-5.09961 4.7002 -5.09961 12.7998 0 17.5l144.101 135c4.89941 4.60059 12.5 4.2998 17 -0.5l43.5 -46.3994c4.69922 -4.90039 4.2998 -12.7002 -0.800781 -17.2002l-90.5996 -79.7002l90.5996 -79.7002 c5.10059 -4.5 5.40039 -12.2998 0.800781 -17.2002zM492.1 48.0996c-4.89941 -4.5 -12.5 -4.2998 -17 0.600586l-43.5 46.3994c-4.69922 4.90039 -4.2998 12.7002 0.800781 17.2002l90.5996 79.7002l-90.5996 79.7998c-5.10059 4.5 -5.40039 12.2998 -0.800781 17.2002 l43.5 46.4004c4.60059 4.7998 12.2002 5 17 0.5l144.101 -135.2c5.09961 -4.7002 5.09961 -12.7998 0 -17.5z"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
347
lib/pressa/views/layout.rb
Normal file
347
lib/pressa/views/layout.rb
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
require "phlex"
|
||||
require "pressa/views/icons"
|
||||
|
||||
module Pressa
|
||||
module Views
|
||||
class Layout < Phlex::HTML
|
||||
attr_reader :site,
|
||||
:page_subtitle,
|
||||
:page_description,
|
||||
:page_type,
|
||||
:canonical_url,
|
||||
:page_scripts,
|
||||
:page_styles,
|
||||
:content
|
||||
|
||||
def initialize(
|
||||
site:,
|
||||
canonical_url:, page_subtitle: nil,
|
||||
page_description: nil,
|
||||
page_type: "website",
|
||||
page_scripts: [],
|
||||
page_styles: [],
|
||||
content: nil
|
||||
)
|
||||
@site = site
|
||||
@page_subtitle = page_subtitle
|
||||
@page_description = page_description
|
||||
@page_type = page_type
|
||||
@canonical_url = canonical_url
|
||||
@page_scripts = page_scripts
|
||||
@page_styles = page_styles
|
||||
@content = content
|
||||
end
|
||||
|
||||
def view_template
|
||||
doctype
|
||||
|
||||
html(lang: "en") do
|
||||
comment { "meow" }
|
||||
|
||||
head do
|
||||
meta(charset: "UTF-8")
|
||||
title { full_title }
|
||||
meta(name: "twitter:title", content: full_title)
|
||||
meta(property: "og:title", content: full_title)
|
||||
meta(name: "description", content: description)
|
||||
meta(name: "twitter:description", content: description)
|
||||
meta(property: "og:description", content: description)
|
||||
meta(property: "og:site_name", content: site.title)
|
||||
|
||||
link(rel: "canonical", href: canonical_url)
|
||||
meta(name: "twitter:url", content: canonical_url)
|
||||
meta(property: "og:url", content: canonical_url)
|
||||
meta(property: "og:image", content: og_image_url) if og_image_url
|
||||
meta(property: "og:type", content: page_type)
|
||||
meta(property: "article:author", content: site.author)
|
||||
meta(name: "twitter:card", content: "summary")
|
||||
|
||||
link(
|
||||
rel: "alternate",
|
||||
href: site.url_for("/feed.xml"),
|
||||
type: "application/rss+xml",
|
||||
title: site.title
|
||||
)
|
||||
link(
|
||||
rel: "alternate",
|
||||
href: site.url_for("/feed.json"),
|
||||
type: "application/json",
|
||||
title: site.title
|
||||
)
|
||||
|
||||
meta(name: "fediverse:creator", content: site.fediverse_creator) if site.fediverse_creator
|
||||
link(rel: "author", type: "text/plain", href: site.url_for("/humans.txt"))
|
||||
link(rel: "icon", type: "image/png", href: site.url_for("/images/favicon-32x32.png"))
|
||||
link(rel: "shortcut icon", href: site.url_for("/images/favicon.icon"))
|
||||
link(rel: "apple-touch-icon", href: site.url_for("/images/apple-touch-icon.png"))
|
||||
link(rel: "mask-icon", color: "#aa0000", href: site.url_for("/images/safari-pinned-tab.svg"))
|
||||
link(rel: "manifest", href: site.url_for("/images/manifest.json"))
|
||||
meta(name: "msapplication-config", content: site.url_for("/images/browserconfig.xml"))
|
||||
meta(name: "theme-color", content: "#121212")
|
||||
meta(name: "viewport", content: "width=device-width, initial-scale=1.0, viewport-fit=cover")
|
||||
link(rel: "dns-prefetch", href: "https://gist.github.com")
|
||||
|
||||
all_styles.each do |style|
|
||||
link(rel: "stylesheet", type: "text/css", href: style_href(style.href))
|
||||
end
|
||||
end
|
||||
|
||||
body do
|
||||
render_header
|
||||
render(content) if content
|
||||
render_footer
|
||||
render_scripts
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def description
|
||||
page_description || site.description
|
||||
end
|
||||
|
||||
def full_title
|
||||
return site.title unless page_subtitle
|
||||
|
||||
"#{site.title}: #{page_subtitle}"
|
||||
end
|
||||
|
||||
def og_image_url
|
||||
site.image_url
|
||||
end
|
||||
|
||||
def all_styles
|
||||
site.styles + page_styles
|
||||
end
|
||||
|
||||
def all_scripts
|
||||
site.scripts + page_scripts
|
||||
end
|
||||
|
||||
def render_header
|
||||
header(class: "primary") do
|
||||
div(class: "title") do
|
||||
h1 do
|
||||
a(href: site.url) { site.title }
|
||||
end
|
||||
|
||||
h4 do
|
||||
plain "By "
|
||||
a(href: site.url_for("/about")) { site.author }
|
||||
end
|
||||
end
|
||||
|
||||
nav(class: "remote") do
|
||||
ul do
|
||||
remote_nav_links.each do |link|
|
||||
li(class: remote_link_class(link)) do
|
||||
attrs = {"aria-label": link.label, href: remote_link_href(link.href)}
|
||||
attrs[:rel] = "me" if mastodon_link?(link)
|
||||
|
||||
a(**attrs) do
|
||||
icon_markup = remote_link_icon_markup(link)
|
||||
if icon_markup
|
||||
raw(safe(icon_markup))
|
||||
else
|
||||
plain link.label
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
nav(class: "local") do
|
||||
ul do
|
||||
li { a(href: site.url_for("/about")) { "About" } }
|
||||
li { a(href: site.url_for("/posts")) { "Archive" } }
|
||||
li { a(href: site.url_for("/projects")) { "Projects" } }
|
||||
end
|
||||
end
|
||||
|
||||
div(class: "clearfix")
|
||||
end
|
||||
end
|
||||
|
||||
def render_footer
|
||||
footer do
|
||||
plain "© #{footer_years} "
|
||||
a(href: site.url_for("/about")) { site.author }
|
||||
end
|
||||
end
|
||||
|
||||
def render_scripts
|
||||
all_scripts.each do |scr|
|
||||
attrs = {src: script_src(scr.src)}
|
||||
attrs[:defer] = true if scr.defer
|
||||
script(**attrs)
|
||||
end
|
||||
|
||||
render_gemini_fallback_script
|
||||
end
|
||||
|
||||
def render_gemini_fallback_script
|
||||
# Inline so the behavior ships with the base HTML layout without needing
|
||||
# separate asset management for one small handler.
|
||||
script do
|
||||
raw(safe(<<~JS))
|
||||
(function () {
|
||||
function isPlainLeftClick(e) {
|
||||
return (
|
||||
e.button === 0 &&
|
||||
!e.defaultPrevented &&
|
||||
!e.metaKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.shiftKey &&
|
||||
!e.altKey
|
||||
);
|
||||
}
|
||||
|
||||
function setupGeminiFallback() {
|
||||
var links = document.querySelectorAll(
|
||||
'header.primary nav.remote a[href^="gemini://"]'
|
||||
);
|
||||
if (!links || links.length === 0) return;
|
||||
|
||||
for (var i = 0; i < links.length; i++) {
|
||||
(function (link) {
|
||||
link.addEventListener("click", function (e) {
|
||||
if (!isPlainLeftClick(e)) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
var geminiHref = link.getAttribute("href");
|
||||
var fallbackHref = "https://geminiprotocol.net";
|
||||
|
||||
var done = false;
|
||||
var fallbackTimer = null;
|
||||
|
||||
function cleanup() {
|
||||
if (fallbackTimer) window.clearTimeout(fallbackTimer);
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
window.removeEventListener("pagehide", onPageHide);
|
||||
window.removeEventListener("blur", onBlur);
|
||||
}
|
||||
|
||||
function markDone() {
|
||||
done = true;
|
||||
cleanup();
|
||||
}
|
||||
|
||||
function onVisibilityChange() {
|
||||
// If a handler opens and the browser backgrounded, consider it "successful".
|
||||
if (document.visibilityState === "hidden") markDone();
|
||||
}
|
||||
|
||||
function onPageHide() {
|
||||
markDone();
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
// Some browsers blur the page when a protocol handler is invoked.
|
||||
markDone();
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
window.addEventListener("pagehide", onPageHide, { once: true });
|
||||
window.addEventListener("blur", onBlur, { once: true });
|
||||
|
||||
// If we're still here shortly after attempting navigation, assume it failed.
|
||||
fallbackTimer = window.setTimeout(function () {
|
||||
if (done) return;
|
||||
window.location.href = fallbackHref;
|
||||
}, 900);
|
||||
|
||||
window.location.href = geminiHref;
|
||||
});
|
||||
})(links[i]);
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", setupGeminiFallback);
|
||||
} else {
|
||||
setupGeminiFallback();
|
||||
}
|
||||
})();
|
||||
JS
|
||||
end
|
||||
end
|
||||
|
||||
def script_src(src)
|
||||
return src if src.start_with?("http://", "https://")
|
||||
|
||||
absolute_asset(src)
|
||||
end
|
||||
|
||||
def style_href(href)
|
||||
return href if href.start_with?("http://", "https://")
|
||||
|
||||
absolute_asset(href)
|
||||
end
|
||||
|
||||
def absolute_asset(path)
|
||||
normalized = path.start_with?("/") ? path : "/#{path}"
|
||||
site.url_for(normalized)
|
||||
end
|
||||
|
||||
def footer_years
|
||||
current_year = Time.now.year
|
||||
start_year = site.copyright_start_year || current_year
|
||||
return current_year.to_s if start_year >= current_year
|
||||
|
||||
"#{start_year} - #{current_year}"
|
||||
end
|
||||
|
||||
def html_remote_links
|
||||
site.html_output_options&.remote_links || []
|
||||
end
|
||||
|
||||
def remote_nav_links
|
||||
html_remote_links
|
||||
end
|
||||
|
||||
def remote_link_href(href)
|
||||
return href if href.match?(/\A[a-z][a-z0-9+\-.]*:/i)
|
||||
|
||||
absolute_asset(href)
|
||||
end
|
||||
|
||||
def remote_link_class(link)
|
||||
slug = link.icon || link.label.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/^-|-$/, "")
|
||||
"remote-link #{slug}"
|
||||
end
|
||||
|
||||
def remote_link_icon_markup(link)
|
||||
# Gemini doesn't have an obvious, widely-recognized protocol icon.
|
||||
# Use a simple custom SVG mark so it aligns like the other SVG icons.
|
||||
if link.icon == "gemini"
|
||||
return <<~SVG.strip
|
||||
<svg class="icon icon-gemini-protocol" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path transform="translate(12 12) scale(0.84 1.04) translate(-12 -12)" d="M18,5.3C19.35,4.97 20.66,4.54 21.94,4L21.18,2.14C18.27,3.36 15.15,4 12,4C8.85,4 5.73,3.38 2.82,2.17L2.06,4C3.34,4.54 4.65,4.97 6,5.3V18.7C4.65,19.03 3.34,19.46 2.06,20L2.82,21.86C8.7,19.42 15.3,19.42 21.18,21.86L21.94,20C20.66,19.46 19.35,19.03 18,18.7V5.3M8,18.3V5.69C9.32,5.89 10.66,6 12,6C13.34,6 14.68,5.89 16,5.69V18.31C13.35,17.9 10.65,17.9 8,18.31V18.3Z"/>
|
||||
</svg>
|
||||
SVG
|
||||
end
|
||||
|
||||
icon_renderer = remote_link_icon_renderer(link.icon)
|
||||
return nil unless icon_renderer
|
||||
|
||||
Icons.public_send(icon_renderer)
|
||||
end
|
||||
|
||||
def remote_link_icon_renderer(icon)
|
||||
case icon
|
||||
when "mastodon" then :mastodon
|
||||
when "github" then :github
|
||||
when "rss" then :rss
|
||||
when "code" then :code
|
||||
end
|
||||
end
|
||||
|
||||
def mastodon_link?(link)
|
||||
link.icon == "mastodon"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
26
lib/pressa/views/month_posts_view.rb
Normal file
26
lib/pressa/views/month_posts_view.rb
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
require "phlex"
|
||||
require "pressa/views/post_view"
|
||||
|
||||
module Pressa
|
||||
module Views
|
||||
class MonthPostsView < Phlex::HTML
|
||||
def initialize(year:, month_posts:, site:)
|
||||
@year = year
|
||||
@month_posts = month_posts
|
||||
@site = site
|
||||
end
|
||||
|
||||
def view_template
|
||||
div(class: "container") do
|
||||
h1 { "#{@month_posts.month.name} #{@year}" }
|
||||
end
|
||||
|
||||
@month_posts.sorted_posts.each do |post|
|
||||
div(class: "container") do
|
||||
render PostView.new(post:, site: @site)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
46
lib/pressa/views/post_view.rb
Normal file
46
lib/pressa/views/post_view.rb
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
require "phlex"
|
||||
require "pressa/views/icons"
|
||||
|
||||
module Pressa
|
||||
module Views
|
||||
class PostView < Phlex::HTML
|
||||
def initialize(post:, site:, article_class: nil)
|
||||
@post = post
|
||||
@site = site
|
||||
@article_class = article_class
|
||||
end
|
||||
|
||||
def view_template
|
||||
article(**article_attributes) do
|
||||
header do
|
||||
h2 do
|
||||
if @post.link_post?
|
||||
a(href: @post.link) { "→ #{@post.title}" }
|
||||
else
|
||||
a(href: @post.path) { @post.title }
|
||||
end
|
||||
end
|
||||
time { @post.formatted_date }
|
||||
a(href: @post.path, class: "permalink") { "∞" }
|
||||
end
|
||||
|
||||
raw(safe(@post.body))
|
||||
end
|
||||
|
||||
div(class: "row clearfix") do
|
||||
p(class: "fin") do
|
||||
raw(safe(Icons.code))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def article_attributes
|
||||
return {} unless @article_class
|
||||
|
||||
{class: @article_class}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
63
lib/pressa/views/project_view.rb
Normal file
63
lib/pressa/views/project_view.rb
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
require "phlex"
|
||||
require "pressa/views/icons"
|
||||
|
||||
module Pressa
|
||||
module Views
|
||||
class ProjectView < Phlex::HTML
|
||||
def initialize(project:, site:)
|
||||
@project = project
|
||||
@site = site
|
||||
end
|
||||
|
||||
def view_template
|
||||
article(class: "container project") do
|
||||
h1(id: "project", data: {title: @project.title}) { @project.title }
|
||||
h4 { @project.description }
|
||||
|
||||
div(class: "project-stats") do
|
||||
p do
|
||||
a(href: @project.url) { "GitHub" }
|
||||
plain " • "
|
||||
a(id: "nstar", href: stargazers_url)
|
||||
plain " • "
|
||||
a(id: "nfork", href: network_url)
|
||||
end
|
||||
|
||||
p do
|
||||
plain "Last updated on "
|
||||
span(id: "updated")
|
||||
end
|
||||
end
|
||||
|
||||
div(class: "project-info row clearfix") do
|
||||
div(class: "column half") do
|
||||
h3 { "Contributors" }
|
||||
div(id: "contributors")
|
||||
end
|
||||
|
||||
div(class: "column half") do
|
||||
h3 { "Languages" }
|
||||
div(id: "langs")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
div(class: "row clearfix") do
|
||||
p(class: "fin") do
|
||||
raw(safe(Icons.code))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def stargazers_url
|
||||
"#{@project.url}/stargazers"
|
||||
end
|
||||
|
||||
def network_url
|
||||
"#{@project.url}/network/members"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
34
lib/pressa/views/projects_view.rb
Normal file
34
lib/pressa/views/projects_view.rb
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
require "phlex"
|
||||
require "pressa/views/icons"
|
||||
|
||||
module Pressa
|
||||
module Views
|
||||
class ProjectsView < Phlex::HTML
|
||||
def initialize(projects:, site:)
|
||||
@projects = projects
|
||||
@site = site
|
||||
end
|
||||
|
||||
def view_template
|
||||
article(class: "container") do
|
||||
h1 { "Projects" }
|
||||
|
||||
@projects.each do |project|
|
||||
div(class: "project-listing") do
|
||||
h4 do
|
||||
a(href: @site.url_for(project.path)) { project.title }
|
||||
end
|
||||
p(class: "description") { project.description }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
div(class: "row clearfix") do
|
||||
p(class: "fin") do
|
||||
raw(safe(Icons.code))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
21
lib/pressa/views/recent_posts_view.rb
Normal file
21
lib/pressa/views/recent_posts_view.rb
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
require "phlex"
|
||||
require "pressa/views/post_view"
|
||||
|
||||
module Pressa
|
||||
module Views
|
||||
class RecentPostsView < Phlex::HTML
|
||||
def initialize(posts:, site:)
|
||||
@posts = posts
|
||||
@site = site
|
||||
end
|
||||
|
||||
def view_template
|
||||
div(class: "container") do
|
||||
@posts.each do |post|
|
||||
render PostView.new(post:, site: @site)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
66
lib/pressa/views/year_posts_view.rb
Normal file
66
lib/pressa/views/year_posts_view.rb
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
require "phlex"
|
||||
|
||||
module Pressa
|
||||
module Views
|
||||
class YearPostsView < Phlex::HTML
|
||||
def initialize(year:, year_posts:, site:)
|
||||
@year = year
|
||||
@year_posts = year_posts
|
||||
@site = site
|
||||
end
|
||||
|
||||
def view_template
|
||||
div(class: "container") do
|
||||
h2(class: "year") do
|
||||
a(href: year_path) { @year.to_s }
|
||||
end
|
||||
|
||||
@year_posts.sorted_months.each do |month_posts|
|
||||
render_month(month_posts)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def year_path
|
||||
@site.url_for("/posts/#{@year}/")
|
||||
end
|
||||
|
||||
def render_month(month_posts)
|
||||
month = month_posts.month
|
||||
|
||||
h3(class: "month") do
|
||||
a(href: @site.url_for("/posts/#{@year}/#{month.padded}/")) do
|
||||
month.name
|
||||
end
|
||||
end
|
||||
|
||||
ul(class: "archive") do
|
||||
month_posts.sorted_posts.each do |post|
|
||||
render_post_item(post)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def render_post_item(post)
|
||||
if post.link_post?
|
||||
li do
|
||||
a(href: post.link) { "→ #{post.title}" }
|
||||
time { short_date(post.date) }
|
||||
a(class: "permalink", href: post.path) { "∞" }
|
||||
end
|
||||
else
|
||||
li do
|
||||
a(href: post.path) { post.title }
|
||||
time { short_date(post.date) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def short_date(date)
|
||||
date.strftime("%-d %b")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
22
package.json
22
package.json
|
|
@ -1,22 +0,0 @@
|
|||
{ "name" : "samhuri.net"
|
||||
, "description" : "samhuri.net"
|
||||
, "version" : "1.0.0"
|
||||
, "homepage" : "http://samhuri.net/proj/samhuri.net"
|
||||
, "author" : "Sami Samhuri <sami@samhuri.net>"
|
||||
, "repository" :
|
||||
{ "type" : "git"
|
||||
, "url" : "https://github.com/samsonjs/samhuri.net.git"
|
||||
}
|
||||
, "bugs" :
|
||||
{ "mail" : "sami@samhuri.net"
|
||||
, "url" : "https://github.com/samsonjs/samhuri.net/issues"
|
||||
}
|
||||
, "dependencies" : { "mustache" : "0.3.x" }
|
||||
, "bin" : { "discussd" : "./discussd/discussd.js" }
|
||||
, "engines" : { "node" : ">=0.2.0" }
|
||||
, "licenses" :
|
||||
[ { "type" : "MIT"
|
||||
, "url" : "http://github.com/samsonjs/samhuri.net/raw/master/LICENSE"
|
||||
}
|
||||
]
|
||||
}
|
||||
12
posts/2006/02/first-post.md
Normal file
12
posts/2006/02/first-post.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
Title: "First Post!"
|
||||
Author: Sami Samhuri
|
||||
Date: "8th February, 2006"
|
||||
Timestamp: 2006-02-07T19:21:00-08:00
|
||||
Tags: life
|
||||
---
|
||||
|
||||
so it's 2am and i should be asleep, but instead i'm setting up a blog. i got a new desk last night and so today i finally got my apartment re-arranged and it's much better now. that's it for now... time to sleep.
|
||||
|
||||
(speaking of sleep, this new [sleeping bag](http://www.musuchouse.com/) design makes so much sense. awesome.)
|
||||
|
||||
14
posts/2006/02/girlfriend-x.md
Normal file
14
posts/2006/02/girlfriend-x.md
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
---
|
||||
Title: "Girlfriend X"
|
||||
Author: Sami Samhuri
|
||||
Date: "18th February, 2006"
|
||||
Timestamp: 2006-02-18T11:50:00-08:00
|
||||
Tags: crazy, funny
|
||||
---
|
||||
|
||||
This is hilarious! Someone wrote software that manages a "parallel" dating style.
|
||||
|
||||
> In addition to storing each woman's contact information and picture, the Girlfriend profiles include a Score Card where you track her sexual preferences, her menstrual cycles and how she styles her pubic hair.
|
||||
|
||||
It's called [Girlfriend X](http://www.wired.com/news/columns/0,70231-0.html), but that's a link to an article about it. I didn't go to the actual website. I just thing it's amusing someone went through the trouble to do this. Maybe there's a demand for it. *\*shrug\**
|
||||
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
---
|
||||
Title: "Intelligent Migration Snippets 0.1 for TextMate"
|
||||
Author: Sami Samhuri
|
||||
Date: "22nd February, 2006"
|
||||
Timestamp: 2006-02-22T03:28:00-08:00
|
||||
Tags: mac os x, textmate, rails, hacking, migrations, snippets
|
||||
---
|
||||
|
||||
*This should be working now. I've tested it under a new user account here.*
|
||||
|
||||
*This does requires the syncPeople bundle to be installed to work. That's ok, because you should get the [syncPeople on Rails bundle][syncPeople] anyways.*
|
||||
|
||||
When writing database migrations in Ruby on Rails it is common to create a table in the `self.up` method and then drop it in `self.down`. The same goes for adding, removing and renaming columns.
|
||||
|
||||
I wrote a Ruby program to insert code into both methods with a single snippet. All the TextMate commands and macros that you need are included.
|
||||
|
||||
### See it in action ###
|
||||
|
||||
I think this looks cool in action. Plus I like to show off what what TextMate can do to people who may not use it, or don't have a Mac. It's just over 30 seconds long and weighs in at around 700kb.
|
||||
|
||||
<p style="text-align: center">
|
||||
<img src="/images/download.png" title="Download" alt="Download">
|
||||
<a href="/f/ims-demo.mov">Download Demo Video</a>
|
||||
</p>
|
||||
|
||||
### Features ###
|
||||
|
||||
There are 3 snippets which are activated by the following tab triggers:
|
||||
|
||||
* __mcdt__: Migration Create and Drop Table
|
||||
* __marc__: Migration Add and Remove Column
|
||||
* __mnc__: Migration Rename Column
|
||||
|
||||
### Installation ###
|
||||
|
||||
Run **Quick Install.app** to install these commands to your <a [syncPeople on Rails bundle](syncPeople) if it exists, and to the default Rails bundle otherwise. (I highly recommend you get the syncPeople bundle if you haven't already.)
|
||||
|
||||
<p style="text-align: center">
|
||||
<img src="/images/download.png" title="Download" alt="Download">
|
||||
<a href="/f/IntelligentMigrationSnippets-0.1.dmg">Download Intelligent Migration Snippets</a>
|
||||
</p>
|
||||
|
||||
This is specific to Rails migrations, but there are probably other uses for something like this. You are free to use and distribute this code.
|
||||
|
||||
[syncPeople]: http://blog.inquirylabs.com/
|
||||
|
||||
10
posts/2006/02/jump-to-viewcontroller-in-textmate.md
Normal file
10
posts/2006/02/jump-to-viewcontroller-in-textmate.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
Title: "Jump to view/controller in TextMate"
|
||||
Author: Sami Samhuri
|
||||
Date: "18th February, 2006"
|
||||
Timestamp: 2006-02-18T14:51:00-08:00
|
||||
Tags: hacking, rails, textmate, rails, textmate
|
||||
---
|
||||
|
||||
<a href="http://blog.inquirylabs.com/2006/02/17/controller-to-view-and-back-again-in-textmate/trackback/">Duane</a> came up with a way to jump to the controller method for the view you're editing, or vice versa in TextMate while coding using Rails. This is a huge time-saver, thanks!
|
||||
|
||||
172
posts/2006/02/obligatory-post-about-ruby-on-rails.md
Normal file
172
posts/2006/02/obligatory-post-about-ruby-on-rails.md
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
---
|
||||
Title: "Obligatory Post about Ruby on Rails"
|
||||
Author: Sami Samhuri
|
||||
Date: "20th February, 2006"
|
||||
Timestamp: 2006-02-20T00:31:00-08:00
|
||||
Tags: rails, coding, hacking, migration, rails, testing
|
||||
---
|
||||
|
||||
<p><em>I'm a Rails newbie and eager to learn. I welcome any suggestions or criticism you have. You can direct them to <a href="mailto:sjs@uvic.ca">my inbox</a> or leave me a comment below.</em></p>
|
||||
|
||||
<p>I finally set myself up with a blog. I mailed my dad the address and mentioned that it was running <a href="http://www.typosphere.org/">Typo</a>, which is written in <a href="http://www.rubyonrails.com/">Ruby on Rails</a>. The fact that it is written in Rails was a big factor in my decision. I am currently reading <a href="http://www.pragmaticprogrammer.com/titles/rails/">Agile Web Development With Rails</a> and it will be great to use Typo as a learning tool, since I will be modifying my blog anyways regardless of what language it's written in.</p>
|
||||
|
||||
<p>Clearly Rails made an impression on me somehow or I wouldn't be investing this time on it. But my dad asked me a very good question:</p>
|
||||
|
||||
> Rails? What is so special about it? I looked at your page and it looks pretty normal to me. I miss the point of this new Rails technique for web development.
|
||||
|
||||
<p>It's unlikely that he was surprised at my lengthy response, but I was. I have been known to write him long messages on topics that interest me. However, I've only been learning Rails for two weeks or so. Could I possibly have so much to say about it already? Apparently I do.</p><h2>Ruby on Rails background</h2>
|
||||
|
||||
<p>I assume a pretty basic knowledge of what Rails is, so if you're not familiar with it now's a good time to read something on the official <a href="http://www.rubyonrails.com/">Rails website</a> and watch the infamous <a href="http://www.rubyonrails.com/screencasts">15-minute screencast</a>, where Rails creator, <a href="http://www.loudthinking.com/">David Heinemeier Hansson</a>, creates a simple blog application.</p>
|
||||
|
||||
<p>The screencasts are what sparked my curiosity, but they hardly scratch the surface of Rails. After that I spent hours reading whatever I could find about Rails before deciding to take the time to learn it well. As a result, a lot of what you read here will sound familiar if you've read other blogs and articles about Rails. This post wasn't planned so there's no list of references yet. I hope to add some links though so please contact me if any ideas or paraphrasing here is from your site, or if you know who I should give credit to.</p>
|
||||
|
||||
<h2>Rails through my eyes</h2>
|
||||
|
||||
<p>Rails is like my Black & Decker toolkit. I have a hammer, power screwdriver, tape measure, needle-nose pliers, wire cutters, a level, etc. This is exactly what I need—no more, no less. It helps me get things done quickly and easily that would otherwise be painful and somewhat difficult. I can pick up the tools and use them without much training. Therefore I am instantly productive with them.</p>
|
||||
|
||||
<p>The kit is suitable for many people who need these things at home, such as myself. Companies build skyscrapers and huge malls and apartments, and they clearly need more powerful tools than I. There are others that just need to drive in a nail to hang a picture, in which case the kit I have is overkill. They're better off just buying and using a single hammer. I happen to fall in the big grey middle <a href="http://web.archive.org/web/20070316171839/http://poignantguide.net/ruby/chapter-3.html#section2">chunk</a>, not the other two.</p>
|
||||
|
||||
<p>I'm a university student. I code because it's satisfying and fun to create software. I do plan on coding for a living when I graduate. I don't work with ancient databases, or create monster sites like Amazon, Google, or Ebay. The last time I started coding a website from scratch I was using <a href="http://www.php.net/">PHP</a>, that was around the turn of the millennium. [It was a fan site for a <a href="http://www.nofx.org/">favourite band</a> of mine.]</p>
|
||||
|
||||
<p>After a year or so I realized I didn't have the time to do it properly (ie. securely and cleanly) if I wanted it to be done relatively soon. A slightly customized <a href="http://www.mediawiki.org/wiki/MediaWiki">MediaWiki</a> promptly took it's place. It did all that I needed quite well, just in a less specific way.</p>
|
||||
|
||||
<p>The wiki is serving my site extremely well, but there's still that itch to create my <strong>own</strong> site. I feel if Rails was around back then I may have been able to complete the project in a timely manner. I was also frustrated with PHP. Part of that is likely due to a lack of experience and of formal programming education at that time, but it was still not fun for me. It wasn't until I started learning Rails that I thought "<em>hey, I could create that site pretty quickly using this!</em>"</p>
|
||||
|
||||
<p>Rails fits my needs like a glove, and this is where it shines. Many professionals are making money creating sites in Rails, so I'm not trying to say it's for amateurs only or something equally silly.</p>
|
||||
|
||||
<h2>Web Frameworks and iPods?</h2>
|
||||
|
||||
<p>Some might say I have merely been swept up in hype and am following the herd. You may be right, and that's okay. I'm going to tell you a story. There was a guy who didn't get one of the oh-so-shiny iPods for a long time, though they looked neat. His discman plays mp3 CDs, and that was good enough for him. The latest iPod, which plays video, was sufficiently cool enough for him to forget that <strong>everyone</strong> at his school has an iPod and he would be trendy just like them now.</p>
|
||||
|
||||
<p>Shocker ending: he is I, and I am him. Now I know why everyone has one of those shiny devices. iPods and web frameworks have little in common except that many believe both the iPod and Rails are all hype and flash. I've realized that something creating this kind of buzz may actually just be a good product. I feel that this is the only other thing the iPod and Rails have in common: they are both <strong>damn good</strong>. Enough about the iPod, everyone hates hearing about it. My goal is to write about the other thing everyone is tired of hearing about.</p>
|
||||
|
||||
<h2>Why is Rails special?</h2>
|
||||
|
||||
<p><strong>Rails is not magic.</strong> There are no exclusive JavaScript libraries or HTML tags. We all have to produce pages that render in the same web browsers. My dad was correct, there <em>is</em> nothing special about my website either. It's more or less a stock Typo website.</p>
|
||||
|
||||
<p>So what makes developing with Rails different? For me there are four big things that set Rails apart from the alternatives:</p>
|
||||
|
||||
<ol>
|
||||
<li>Separating data, function, and design</li>
|
||||
<li>Readability (which is underrated) </li>
|
||||
<li>Database migrations</li>
|
||||
<li>Testing is so easy it hurts</li>
|
||||
</ol>
|
||||
|
||||
<h3>MVC 101 <em>(or, Separating data, function, and design)</em></h3>
|
||||
|
||||
<p>Now I'm sure you've heard about separating content from design. Rails takes that one step further from just using CSS to style your website. It uses what's known as the MVC paradigm: <strong>Model-View-Controller</strong>. This is a tried and tested development method. I'd used MVC before in Cocoa programming on Mac OS X, so I was already sold on this point.</p>
|
||||
|
||||
<ul>
|
||||
<li>The model deals with your data. If you're creating an online store you have a product model, a shopping cart model, a customer model, etc. The model takes care of storing this data in the database (persistence), and presenting it to you as an object you can manipulate at runtime.</li>
|
||||
</ul>
|
||||
|
||||
<ul>
|
||||
<li>The view deals <em>only</em> with presentation. That's it, honestly. An interface to your app.</li>
|
||||
</ul>
|
||||
|
||||
<ul>
|
||||
<li>The controller binds the model to the view, so that when the user clicks on the <strong>Add to cart</strong> link the controller is wired to call the <code>add_product</code> method of the cart model and tell it which product to add. Then the controller takes the appropriate action such as redirecting the user to the shopping cart view.</li>
|
||||
</ul>
|
||||
|
||||
<p>Of course this is not exclusive to Rails, but it's an integral part of it's design.</p>
|
||||
|
||||
<h3>Readability</h3>
|
||||
|
||||
<p>Rails, and <a href="http://www.ruby-lang.org/">Ruby</a>, both read amazingly like spoken English. This code is more or less straight out of Typo. You define relationships between objects like this:</p>
|
||||
|
||||
```ruby
|
||||
class Article < Content
|
||||
has_many :comments, :dependent => true, :order => "created_at ASC"
|
||||
has_many :trackbacks, :dependent => true, :order => "created_at ASC"
|
||||
has_and_belongs_to_many :categories, :foreign_key => 'article_id'
|
||||
has_and_belongs_to_many :tags, :foreign_key => 'article_id'
|
||||
belongs_to :user
|
||||
...
|
||||
```
|
||||
|
||||
<p><code>dependent => true</code> means <em>if an article is deleted, it's comments go with it</em>. Don't worry if you don't understand it all, this is just for you to see some actual Rails code.</p>
|
||||
|
||||
<p>In the Comment model you have:</p>
|
||||
|
||||
```ruby
|
||||
class Comment < Content
|
||||
belongs_to :article
|
||||
belongs_to :user
|
||||
|
||||
validates_presence_of :author, :body
|
||||
validates_against_spamdb :body, :url, :ip
|
||||
validates_age_of :article_id
|
||||
...
|
||||
```
|
||||
|
||||
<p>(I snuck in some validations as well)</p>
|
||||
|
||||
<p>But look how it reads! Read it out loud. I'd bet that my mom would more or less follow this, and she's anything but a programmer. That's not to say programming should be easy for grandma, <strong>but code should be easily understood by humans</strong>. Let the computer understand things that are natural for me to type, since we're making it understand a common language anyways.</p>
|
||||
|
||||
<p>Ruby and Ruby on Rails allow and encourage you to write beautiful code. That is so much more important than you may realize, because it leads to many other virtues. Readability is obvious, and hence maintainability. You must read code to understand and modify it. Oh, and happy programmers will be more productive than frustrated programmers.</p>
|
||||
|
||||
<h3 id="migrations">Database Migrations</h3>
|
||||
|
||||
<p>Here's one more life-saver: migrations. Migrations are a way to version your database schema from within Rails. So you have a table, call it <code>albums</code>, and you want to add the date the album was released. You could modify the database directly, but that's not fun. Even if you only have one server, all your configuration will be in one central place, the app. And Rails doesn't care if you have PostgreSQL, MySQL, or SQLite behind it. You can develop and test on SQLite and deploy on MySQL and the migrations will just work in both environments.</p>
|
||||
|
||||
```ruby
|
||||
class AddDateReleased < ActiveRecord::Migration
|
||||
def self.up
|
||||
add_column "albums", "date_released", :datetime
|
||||
Albums.update_all "date_released = now()"
|
||||
end
|
||||
|
||||
def self.down
|
||||
remove_column "albums", "date_released"
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
<p>Then you run the migration (<code>rake migrate</code> does that) and boom, your up to date. If you're wondering, the <code>self.down</code> method indeed implies that you can take this the other direction as well. Think <code>rake migrate VERSION=X</code>.</p>
|
||||
|
||||
<p><em>Along with the other screencasts is one on <a href="http://www.rubyonrails.org/screencasts">migrations</a> featuring none other than David Hansson. You should take a look, it's the third video.</em></p>
|
||||
|
||||
<h3>Testing so easy it hurts</h3>
|
||||
|
||||
<p>To start a rails project you type <code>rails project_name</code> and it creates a directory structure with a fresh project in it. This includes a directory appropriately called <em>test</em> which houses unit tests for the project. When you generate models and controllers it creates test stubs for you in that directory. Basically, it makes it so easy to test that you're a fool not to do it. As someone wrote on their site: <em>It means never having to say "<strong>I introduced a new bug while fixing another.</strong>"</em></p>
|
||||
|
||||
<p>Rails builds on the unit testing that comes with Ruby. On a larger scale, that means that Rails is unlikely to flop on you because it is regularly tested using the same method. Ruby is unlikely to flop for the same reason. That makes me look good as a programmer. If you code for a living then it's of even more value to you.</p>
|
||||
|
||||
<p><em>I don't know why it hurts. Maybe it hurts developers working with other frameworks or languages to see us have it so nice and easy.</em></p>
|
||||
|
||||
<h2>Wrapping up</h2>
|
||||
|
||||
<p>Rails means I have fun doing web development instead of being frustrated (CSS hacks aside). David Hansson may be right when he said you have to have been soured by Java or PHP to fully appreciate Rails, but that doesn't mean you won't enjoy it if you <em>do</em> like Java or PHP.</p>
|
||||
|
||||
<p><a href="http://www.relevancellc.com/blogs/wp-trackback.php?p=31">Justin Gehtland</a> rewrote a Java app using Rails and the number of lines of code of the Rails version was very close to that of the XML configuration for the Java version. Java has strengths, libraries available <strong>now</strong> seems to be a big one, but it's too big for my needs. If you're like me then maybe you'll enjoy Rails as much as I do.</p>
|
||||
|
||||
<h2>You're not done, you lied to me!</h2>
|
||||
|
||||
<p>Sort of... there are a few things that it seems standard to include when someone writes about how Rails saved their life and gave them hope again. For completeness sake, I feel compelled to mention some principles common amongst those who develop Rails, and those who develop on Rails. It's entirely likely that there's nothing new for you here unless you're new to Rails or to programming, in which case I encourage you to read on.</p>
|
||||
|
||||
<h3>DRY</h3>
|
||||
|
||||
<p>Rails follows the DRY principle religiously. That is, <strong>Don't Repeat Yourself</strong>. Like MVC, I was already sold on this. I had previously encountered it in <a href="http://www.pragmaticprogrammer.com/ppbook/index.shtml">The Pragmatic Programmer</a>. Apart from telling <em>some_model</em> it <code>belongs_to :other_model</code> and <em>other_model</em> that it <code>has_many :some_models</code> nothing has jumped out at me which violates this principle. However, I feel that reading a model's code and seeing it's relationships to other models right there is a Good Thing™.</p>
|
||||
|
||||
<h3>Convention over configuration <em>(or, Perceived intelligence)</em></h3>
|
||||
|
||||
<p>Rails' developers also have the mantra "<em>convention over configuration</em>", which you can see from the video there. (you did watch it, didn't you? ;) Basically that just means Rails has sane defaults, but is still flexible if you don't like the defaults. You don't have to write even one line of SQL with Rails, but if you need greater control then you <em>can</em> write your own SQL. A standard cliché: <em>it makes the simple things easy and the hard possible</em>.</p>
|
||||
|
||||
<p>Rails seems to have a level of intelligence which contributes to the wow-factor. After <a href="#migrations">these relationships</a> are defined I can now filter certain negative comments like so:</p>
|
||||
|
||||
```ruby
|
||||
article = Article.find :first
|
||||
for comment in article.comments do
|
||||
print comment unless comment.downcase == 'you suck!'
|
||||
end
|
||||
```
|
||||
|
||||
<p>Rails knows to look for the field <strong>article_id</strong> in the <strong>comments</strong> table of the database. This is just a convention. You can call it something else but then you have to tell Rails what you like to call it.</p>
|
||||
|
||||
<p>Rails understands pluralization, which is a detail but it makes everything feel more natural. If you have a <strong>Person</strong> model then it will know to look for the table named <strong>people</strong>.</p>
|
||||
|
||||
<h3>Code as you learn</h3>
|
||||
|
||||
<p>I love how I've only been coding in Rails for a week or two and I can do so much already. It's natural, concise and takes care of the inane details. I love how I <em>know</em> that I don't even have to explain that migration example. It's plainly clear what it does to the database. It doesn't take long to get the basics down and once you do it goes <strong>fast</strong>.</p>
|
||||
|
||||
34
posts/2006/02/sjs-rails-bundle-0_2-for-textmate.md
Normal file
34
posts/2006/02/sjs-rails-bundle-0_2-for-textmate.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
---
|
||||
Title: "SJ's Rails Bundle 0.2 for TextMate"
|
||||
Author: Sami Samhuri
|
||||
Date: "23rd February, 2006"
|
||||
Timestamp: 2006-02-23T17:18:00-08:00
|
||||
Tags: textmate, rails, coding, bundle, macros, rails, snippets, textmate
|
||||
---
|
||||
|
||||
Everything that you've seen posted on my blog is now available in one bundle. Snippets for Rails database migrations and assertions are all included in this bundle.
|
||||
|
||||
There are 2 macros for class-end and def-end blocks, bound to <strong>⌃C</strong> and <strong>⌃D</strong> respectively. Type the class or method definition, except for <code>class</code> or <code>def</code>, and then type the keyboard shortcut and the rest is filled in for you.
|
||||
|
||||
I use an underscore to denote the position of the cursor in the following example:
|
||||
|
||||
```ruby
|
||||
method(arg1, arg2_)
|
||||
```
|
||||
|
||||
Typing <strong>⌃D</strong> at this point results in this code:
|
||||
|
||||
```ruby
|
||||
def method(arg1, arg2)
|
||||
_
|
||||
end
|
||||
```
|
||||
|
||||
There is a list of the snippets in Features.rtf, which is included in the disk image. Of course you can also browse them in the Snippets Editor built into TextMate.
|
||||
|
||||
Without further ado, here is the bundle:
|
||||
|
||||
<p style="text-align: center;"><img src="/images/download.png" title="Download" alt="Download"> <a href="/f/SJRailsBundle-0.2.dmg">Download SJ's Rails Bundle 0.2</a></p>
|
||||
|
||||
This is a work in progress, so any feedback you have is very helpful in making the next release better.
|
||||
|
||||
107
posts/2006/02/some-textmate-snippets-for-rails-migrations.md
Normal file
107
posts/2006/02/some-textmate-snippets-for-rails-migrations.md
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
---
|
||||
Title: "Some TextMate snippets for Rails Migrations"
|
||||
Author: Sami Samhuri
|
||||
Date: "18th February, 2006"
|
||||
Timestamp: 2006-02-18T22:48:00-08:00
|
||||
Tags: textmate, rails, hacking, rails, snippets, textmate
|
||||
---
|
||||
|
||||
My arsenal of snippets and macros in TextMate is building as I read through the rails canon, <a href="http://www.pragmaticprogrammer.com/titles/rails/" title="Agile Web Development With Rails">Agile Web Development...</a> I'm only 150 pages in so I haven't had to add much so far because I started with the bundle found on the <a href="http://wiki.rubyonrails.org/rails/pages/TextMate">rails wiki</a>. The main ones so far are for migrations.
|
||||
|
||||
Initially I wrote a snippet for adding a table and one for dropping a table, but I don't want to write it twice every time! If I'm adding a table in **up** then I probably want to drop it in **down**.
|
||||
|
||||
What I did was create one snippet that writes both lines, then it's just a matter of cut & paste to get it in **down**. The drop_table line should be inserted in the correct method, but that doesn't seem possible. I hope I'm wrong!
|
||||
|
||||
Scope should be *source.ruby.rails* and the triggers I use are above the snippets.
|
||||
|
||||
mcdt: **M**igration **C**reate and **D**rop **T**able
|
||||
|
||||
```ruby
|
||||
create_table "${1:table}" do |t|
|
||||
$0
|
||||
end
|
||||
${2:drop_table "$1"}
|
||||
```
|
||||
|
||||
mcc: **M**igration **C**reate **C**olumn
|
||||
|
||||
```ruby
|
||||
t.column "${1:title}", :${2:string}
|
||||
```
|
||||
|
||||
marc: **M**igration **A**dd and **R**emove **C**olumn
|
||||
|
||||
```ruby
|
||||
add_column "${1:table}", "${2:column}", :${3:string}
|
||||
${4:remove_column "$1", "$2"}
|
||||
```
|
||||
|
||||
I realize this might not be for everyone, so here are my original 4 snippets that do the work of *marc* and *mcdt*.
|
||||
|
||||
mct: **M**igration **C**reate **T**able
|
||||
|
||||
```ruby
|
||||
create_table "${1:table}" do |t|
|
||||
$0
|
||||
end
|
||||
```
|
||||
|
||||
mdt: **M**igration **D**rop **T**able
|
||||
|
||||
```ruby
|
||||
drop_table "${1:table}"
|
||||
```
|
||||
|
||||
mac: **M**igration **A**dd **C**olumn
|
||||
|
||||
```ruby
|
||||
add_column "${1:table}", "${2:column}", :${3:string}
|
||||
```
|
||||
|
||||
mrc: **M**igration **R**remove **C**olumn
|
||||
|
||||
```ruby
|
||||
remove_column "${1:table}", "${2:column}"
|
||||
```
|
||||
|
||||
I'll be adding more snippets and macros. There should be a central place where the rails bundle can be improved and extended. Maybe there is...
|
||||
|
||||
----
|
||||
|
||||
#### Comments
|
||||
|
||||
<div id="comment-1" class="comment">
|
||||
<div class="name">
|
||||
<a href="http://blog.inquirylabs.com/">Duane Johnson</a>
|
||||
</div>
|
||||
<span class="date" title="2006-02-19 06:48:00 -0800">Feb 19, 2006</span>
|
||||
<div class="body">
|
||||
<p>This looks great! I agree, we should have some sort of central place for these things, and
|
||||
preferably something that's not under the management of the core Rails team as they have too
|
||||
much to worry about already.</p>
|
||||
|
||||
<p>Would you mind if I steal your snippets and put them in the syncPeople on Rails bundle?</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="comment-2" class="comment">
|
||||
<div class="name">
|
||||
<a href="https://samhuri.net">Sami Samhuri</a>
|
||||
</div>
|
||||
<span class="date" title="2006-02-19 18:48:00 -0800">Feb 19, 2006</span>
|
||||
<div class="body">
|
||||
<p>Not at all. I'm excited about this bundle you've got. Keep up the great work.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="comment-3" class="comment">
|
||||
<div class="name">
|
||||
<a href="http://blog.inquirylabs.com/">Duane Johnson</a>
|
||||
</div>
|
||||
<span class="date" title="2006-02-20 02:48:00 -0800">Feb 20, 2006</span>
|
||||
<div class="body">
|
||||
<p>Just added the snippets, Sami. I'll try to make a release tonight. Great work, and keep it coming!</p>
|
||||
|
||||
<p>P.S. I tried several ways to get the combo-snippets to put the pieces inside the right functions but failed. We'll see tomorrow if Allan (creator of TextMate) has any ideas.</p>
|
||||
</div>
|
||||
</div>
|
||||
58
posts/2006/02/textmate-insert-text-into-self-down.md
Normal file
58
posts/2006/02/textmate-insert-text-into-self-down.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
---
|
||||
Title: "TextMate: Insert text into self.down"
|
||||
Author: Sami Samhuri
|
||||
Date: "21st February, 2006"
|
||||
Timestamp: 2006-02-21T14:55:00-08:00
|
||||
Tags: textmate, rails, hacking, commands, macro, rails, snippets, textmate
|
||||
---
|
||||
|
||||
<p><em><strong>UPDATE:</strong> I got everything working and it's all packaged up <a href="/posts/2006/02/intelligent-migration-snippets-0_1-for-textmate">here</a>. There's an installation script this time as well.</em></p>
|
||||
|
||||
<p>Thanks to <a href="http://thread.gmane.org/gmane.editors.textmate.general/8520">a helpful thread</a> on the TextMate mailing list I have the beginning of a solution to insert text at 2 (or more) locations in a file.</p>
|
||||
|
||||
<p>I implemented this for a new snippet I was working on for migrations, <code>rename_column</code>. Since the command is the same in self.up and self.down simply doing a reverse search for <code>rename_column</code> in my <a href="/posts/2006/02/textmate-move-selection-to-self-down">hackish macro</a> didn't return the cursor the desired location.</p><p>That's enough introduction, here's the program to do the insertion:</p>
|
||||
|
||||
```ruby
|
||||
#!/usr/bin/env ruby
|
||||
def indent(s)
|
||||
s =~ /^(\s*)/
|
||||
' ' * $1.length
|
||||
end
|
||||
|
||||
up_line = 'rename_column "${1:table}", "${2:column}", "${3:new_name}"$0'
|
||||
down_line = "rename_column \"$$1\", \"$$3\", \"$$2\"\n"
|
||||
|
||||
# find the end of self.down and insert 2nd line
|
||||
lines = STDIN.read.to_a.reverse
|
||||
ends_seen = 0
|
||||
lines.each_with_index do |line, i|
|
||||
ends_seen += 1 if line =~ /^\s*end\b/
|
||||
if ends_seen == 2
|
||||
lines[i..i] = [lines[i], indent(lines[i]) * 2 + down_line]
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
# return the new text, escaping special chars
|
||||
print up_line + lines.reverse.to_s.gsub(/([$`\\])/, '\\\\\1').gsub(/\$\$/, '$')
|
||||
```
|
||||
|
||||
<p>Save this as a command in your Rails, or <a href="http://blog.inquirylabs.com/">syncPeople on Rails</a>, bundle. The command options should be as follows:</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>Save:</strong> Nothing</li>
|
||||
<li><strong>Input:</strong> Selected Text or Nothing</li>
|
||||
<li><strong>Output:</strong> Insert as Snippet</li>
|
||||
<li><strong>Activation:</strong> Whatever you want, I'm going to use a macro described below and leave this empty</li>
|
||||
<li><strong>Scope Selector:</strong> source.ruby.rails</li>
|
||||
</ul>
|
||||
|
||||
<p>The first modification it needs is to get the lines to insert as command line arguments so we can use it for other snippets. Secondly, regardless of the <strong>Re-indent pasted text</strong> setting the text returned is indented incorrectly.</p>
|
||||
|
||||
The macro I'm thinking of to invoke this is tab-triggered and will simply:
|
||||
<ul>
|
||||
<li>Select word (<code><strong>⌃W</strong></code>)</li>
|
||||
<li>Delete (<code><strong>⌫</strong></code>)</li>
|
||||
<li>Select to end of file (<code><strong>⇧⌘↓</strong></code>)</li>
|
||||
<li>Run command "Put in self.down"</li>
|
||||
</ul>
|
||||
29
posts/2006/02/textmate-move-selection-to-self-down.md
Normal file
29
posts/2006/02/textmate-move-selection-to-self-down.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
---
|
||||
Title: "TextMate: Move selection to self.down"
|
||||
Author: Sami Samhuri
|
||||
Date: "21st February, 2006"
|
||||
Timestamp: 2006-02-21T00:26:00-08:00
|
||||
Tags: textmate, rails, hacking, hack, macro, rails, textmate
|
||||
---
|
||||
|
||||
<p><strong>UPDATE:</strong> <em>This is obsolete, see <a href="/posts/2006/02/textmate-insert-text-into-self-down">this post</a> for a better solution.</em></p>
|
||||
|
||||
<p><a href="/posts/2006/02/some-textmate-snippets-for-rails-migrations.html#comment-3">Duane's comment</a> prompted me to think about how to get the <code>drop_table</code> and <code>remove_column</code> lines inserted in the right place. I don't think TextMate's snippets are built to do this sort of text manipulation. It would be nicer, but a quick hack will suffice for now.</p><p>Use <acronym title="Migration Create and Drop Table">MCDT</acronym> to insert:</p>
|
||||
|
||||
```ruby
|
||||
create_table "table" do |t|
|
||||
|
||||
end
|
||||
drop_table "table"
|
||||
```
|
||||
|
||||
<p>Then press tab once more after typing the table name to select the code <code>drop_table "table"</code>. I created a macro that cuts the selected text, finds <code>def self.down</code> and pastes the line there. Then it searches for the previous occurence of <code>create_table</code> and moves the cursor to the next line, ready for you to add some columns.</p>
|
||||
|
||||
<p>I have this bound to <strong>⌃⌥⌘M</strong> because it wasn't in use. If your Control key is to the left the A key it's quite comfortable to hit this combo. Copy the following file into <strong>~/Library/Application Support/TextMate/Bundles/Rails.tmbundle/Macros</strong>.</p>
|
||||
|
||||
<p style="text-align: center;"><a href="http://sami.samhuri.net/files/move-to-self.down.plist">Move selection to self.down</a></p>
|
||||
|
||||
<p>This works for the <acronym title="Migration Add and Remove Column">MARC</acronym> snippet as well. I didn't tell you the whole truth, the macro actually finds the previous occurence of <code>(create_table|add_column)</code>.</p>
|
||||
|
||||
<p>The caveat here is that if there is a <code>create_table</code> or <code>add_column</code> between <code>self.down</code> and the table you just added, it will jump back to the wrong spot. It's still faster than doing it all manually, but should be improved. If you use these exclusively, the order they occur in <code>self.down</code> will be opposite of that in <code>self.up</code>. That means either leaving things backwards or doing the re-ordering manually. =/</p>
|
||||
|
||||
18
posts/2006/02/textmate-snippets-for-rails-assertions.md
Normal file
18
posts/2006/02/textmate-snippets-for-rails-assertions.md
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
---
|
||||
Title: "TextMate Snippets for Rails Assertions"
|
||||
Author: Sami Samhuri
|
||||
Date: "20th February, 2006"
|
||||
Timestamp: 2006-02-20T23:52:00-08:00
|
||||
Tags: textmate, rails, coding, rails, snippets, testing, textmate
|
||||
---
|
||||
|
||||
This time I've got a few snippets for assertions. Using these to type up your tests quickly, and then hitting **⌘R** to run the tests without leaving TextMate, makes testing your Rails app that much more convenient. Just when you thought it was already too easy! (Don't forget that you can use **⌥⌘↓** to move between your code and the corresponding test case.)
|
||||
|
||||
This time I'm posting the .plist files to make it easier for you to add them to TextMate. All you need to do is copy these to **~/Library/Application Support/TextMate/Bundles/Rails.tmbundle/Snippets**.
|
||||
|
||||
<p style="text-align: center;"><a href="/f/assert_snippets.zip">Assertion Snippets for Rails</a></p>
|
||||
|
||||
If anyone would rather I list them all here I can do that as well. Just leave a comment.
|
||||
|
||||
*(I wanted to include a droplet in the zip file that will copy the snippets to the right place, but my 3-hour attempt at writing the AppleScript to do so left me feeling quite bitter. Maybe I was just mistaken in thinking it would be easy to pick up AppleScript.)*
|
||||
|
||||
16
posts/2006/02/touch-screen-on-steroids.md
Normal file
16
posts/2006/02/touch-screen-on-steroids.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
---
|
||||
Title: "Touch Screen on Steroids"
|
||||
Author: Sami Samhuri
|
||||
Date: "8th February, 2006"
|
||||
Timestamp: 2006-02-08T06:06:00-08:00
|
||||
Tags: technology, touch
|
||||
---
|
||||
|
||||
If you thought the PowerBook's two-finger scrolling was cool check out this touch screen:
|
||||
|
||||
<a href="http://mrl.nyu.edu/~jhan/ftirtouch/">Multi-Touch Interaction Research</a>
|
||||
|
||||
> "While touch sensing is commonplace for single points of contact, multi-touch sensing enables a user to interact with a system with more than one finger at a time, as in chording and bi-manual operations. Such sensing devices are inherently also able to accommodate multiple users simultaneously, which is especially useful for larger interaction scenarios such as interactive walls and tabletops."
|
||||
|
||||
This is really amazing. Forget traditional tablet PCs... <i>this</i> is revolutionary and useful in so many applications. I hope this kind of technology is mainstream by 2015.
|
||||
|
||||
12
posts/2006/02/urban-extreme-gymnastics.md
Normal file
12
posts/2006/02/urban-extreme-gymnastics.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
Title: "Urban Extreme Gymnastics?"
|
||||
Author: Sami Samhuri
|
||||
Date: "15th February, 2006"
|
||||
Timestamp: 2006-02-15T10:41:00-08:00
|
||||
Tags: amusement
|
||||
---
|
||||
|
||||
This crazy russian goes all over the place scaling buildings, doing all sorts of flips, bouncing off the walls literally. He'd be impossible to catch.
|
||||
|
||||
<a href="http://www.videobomb.com/posts/show/46">Russian parkour (urban extreme gymnastics)</a>
|
||||
|
||||
10
posts/2006/03/generate-selfdown-in-your-rails-migrations.md
Normal file
10
posts/2006/03/generate-selfdown-in-your-rails-migrations.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
Title: "Generate self.down in your Rails migrations"
|
||||
Author: Sami Samhuri
|
||||
Date: "3rd March, 2006"
|
||||
Timestamp: 2006-03-03T21:38:00-08:00
|
||||
Tags: rails, textmate, migrations, rails, textmate
|
||||
---
|
||||
|
||||
<a href="http://lunchboxsoftware.com/">Scott</a> wrote a really <a href="http://lunchroom.lunchboxsoftware.com/articles/2005/11/29/auto-fill-your-reverse-migrations">cool program</a> that will scan `self.up` and then consult db/schema.rb to automatically fill in `self.down` for you. Brilliant!
|
||||
|
||||
24
posts/2006/03/i-dont-mind-fairplay-either.md
Normal file
24
posts/2006/03/i-dont-mind-fairplay-either.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
Title: "I don't mind FairPlay either"
|
||||
Author: Sami Samhuri
|
||||
Date: "3rd March, 2006"
|
||||
Timestamp: 2006-03-03T21:56:00-08:00
|
||||
Tags: apple, mac os x, life, drm, fairplay, ipod, itunes
|
||||
---
|
||||
|
||||
I think that <a href="http://jim.roepcke.com/2006/03/02#item7471">Jim is right</a> about Apple's DRM not being all that evil.
|
||||
|
||||
I buy music from the iTunes Music Store *because* I bought an iPod. The fact I can't play them on another device doesn't matter to me. With my purchased songs I can:
|
||||
|
||||
* listen to the songs I buy all I want
|
||||
* burn them to CD
|
||||
* stream them to my amplifier via AirPort Express
|
||||
|
||||
I don't buy a ton of music from the iTMS, but I can't tell the difference between any of those songs and the songs I ripped from CDs and they're all mixed in one collection. That's good enough for me.
|
||||
|
||||
I dislike DRM as much as the next guy, but like CSS encryption on DVDs, FairPlay is something I can live with.
|
||||
|
||||
It reminds me of how here in North America I have to live with the crappy cell phone companies that lock their phones to their networks. If it's something I need or want, sometimes I'll live with restrictions because there are no alternatives yet.
|
||||
|
||||
*__Update:__ It's almost settled. The pope <a href="http://www.catholicnews.com/data/stories/cns/0601282.htm">got an iPod</a> so all that's left is to see if he buys any music off of iTunes. If he does, then it can't be evil. heh...*
|
||||
|
||||
12
posts/2006/03/spore.md
Normal file
12
posts/2006/03/spore.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
Title: "Spore"
|
||||
Author: Sami Samhuri
|
||||
Date: "3rd March, 2006"
|
||||
Timestamp: 2006-03-03T21:43:00-08:00
|
||||
Tags: amusement, technology, cool, fun, games
|
||||
---
|
||||
|
||||
<a href="http://video.google.com/videoplay?docid=8372603330420559198&q=spore">This game</a> that <a href="http://jim.roepcke.com/">Jim</a> <a href="http://jim.roepcke.com/2006/03/01#item7470">blogged about</a> is probably the coolest game I've seen.
|
||||
|
||||
You really just have to watch the video, I won't bother explaining it here. I don't really play games much, but this I would play.
|
||||
|
||||
12
posts/2006/04/zsh-terminal-goodness-on-os-x.md
Normal file
12
posts/2006/04/zsh-terminal-goodness-on-os-x.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
Title: "zsh terminal goodness on OS X"
|
||||
Author: Sami Samhuri
|
||||
Date: "4th April, 2006"
|
||||
Timestamp: 2006-04-04T14:57:00-07:00
|
||||
Tags: mac os x, apple, osx, terminal, zsh
|
||||
---
|
||||
|
||||
<a href="http://www.apple.com/">Apple</a> released the <a href="http://docs.info.apple.com/article.html?artnum=303411">OS X 10.4.6 update</a> which fixed a <strong>really</strong> annoying bug for me. Terminal (and <a href="http://iterm.sourceforge.net/">iTerm</a>) would fail to open a new window/tab when your shell is <a href="http://zsh.sourceforge.net/">zsh</a>. iTerm would just open then immediately close the window, while Terminal would display the message: <code>[Command completed]</code> in a now-useless window.
|
||||
|
||||
Rebooting twice to get the fix was reminiscent of <a href="http://www.microsoft.com/windows/default.mspx">Windows</a>, but well worth it.
|
||||
|
||||
10
posts/2006/05/os-x-and-fitts-law.md
Normal file
10
posts/2006/05/os-x-and-fitts-law.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
Title: "OS X and Fitt's law"
|
||||
Author: Sami Samhuri
|
||||
Date: "7th May, 2006"
|
||||
Timestamp: 2006-05-07T20:43:00-07:00
|
||||
Tags: mac os x, apple, mac, os, usability, x
|
||||
---
|
||||
|
||||
I've realized that OS X really does obey Fitt's law in all 4 corners now. Apple menu in the top left, Spotlight top right, and the bottom 2 are always accessible for drag n drop, unless the dock is hidden. I rarely ever use it because I usually have pretty good chunks of the desktop showing, but it is useful.
|
||||
|
||||
12
posts/2006/05/wikipediafs-on-linux-in-python.md
Normal file
12
posts/2006/05/wikipediafs-on-linux-in-python.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
Title: "WikipediaFS on Linux, in Python"
|
||||
Author: Sami Samhuri
|
||||
Date: "7th May, 2006"
|
||||
Timestamp: 2006-05-07T20:49:00-07:00
|
||||
Tags: hacking, python, linux, fuse, linux, mediawiki, python, wikipediafs
|
||||
---
|
||||
|
||||
Until now I've been using my own version of <a href="http://meta.wikimedia.org/wiki/Pywikipedia">pywikipedia</a> for scripting MediaWiki, and it works well. But I read about <a href="http://wikipediafs.sourceforge.net/">WikipediaFS</a> and had to check it out. It's a user space filesystem for Linux that's built using the <a href="http://fuse.sourceforge.net/wiki/index.php/LanguageBindings">Python bindings</a> for <a href="http://fuse.sourceforge.net/">FUSE</a>. What it does is mounts a filesystem that represents your wiki, with articles as text files. You can use them just like any other files with mv, cp, ls, vim, and so on.
|
||||
|
||||
There hasen't been any action on that project for 13 months though, and it doesn't work on my wiki (MediaWiki 1.4.15) so I'm going to try and make it work after I upgrade to MediaWiki 1.6.3 tonight. This will be pretty cool when it works. I haven't looked at the code yet but it's only 650 lines.
|
||||
|
||||
10
posts/2006/06/apple-pays-attention-to-detail.md
Normal file
10
posts/2006/06/apple-pays-attention-to-detail.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
Title: "Apple pays attention to detail"
|
||||
Author: Sami Samhuri
|
||||
Date: "11th June, 2006"
|
||||
Timestamp: 2006-06-11T01:30:00-07:00
|
||||
Tags: technology, mac os x, apple
|
||||
---
|
||||
|
||||
I think this has to be one of the big reasons why people who love their Mac, love their Mac (or other Apple product). I usually just have cheap PC speakers plugged into my Mac mini, but I didn't bring any with me to Munich and the internal Mac mini speaker isn't very loud, so I'm using headphones to watch movies. My Mac remembers the volume setting when the headphones ore plugged in, and when they're not, so I don't accidentally blow my ears. It's like my iPod pausing when the headphones are unplugged. It's excruciating attention to the smallest, (seemingly) most unimportant detail. I love it, and I'm hooked.
|
||||
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
---
|
||||
Title: "Ich bin Ausländer und spreche nicht gut Deutsch"
|
||||
Author: Sami Samhuri
|
||||
Date: "5th June, 2006"
|
||||
Timestamp: 2006-06-05T10:11:00-07:00
|
||||
Tags: life, munich, seekport, work
|
||||
---
|
||||
|
||||
How's this for an update: I'm working in Munich for the summer at a European search engine called <a href="http://www.seekport.co.uk/">Seekport</a>. The search engine isn't all they do, as right now I'm programming a desktop widget that shows live scores & news from World Cup matches (in English and Arabic). I'm building it on top of the <a href="http://widgets.yahoo.com/">Yahoo! Widget Engine</a> because it needs to run on Windows. Even though I quite like the Y! Engine, I would still prefer to be coding in straight HTML, CSS & JavaScript like Dashboard programmers get to use. The Y! Engine uses XML (it is somewhat HTML-like) and JavaScript.
|
||||
|
||||
The place I'm living in is like a dormitory for younger people. I share a bathroom & kitchen with a German guy named Sebastian who is 21 and an artist; a stonecutter actually. I only met him briefly yesterday, but he seems nice. I'm going to teach him English, and he'll teach me German, though his English is much better than my German. It's a pretty quiet place, and we get breakfast included, dinner can be bought for €2,50, and Internet access is included as well. I brought my Mac Mini with me, and as soon as I find an AC adapter I'll be ready to go with the 'net at home. I probably won't blog again until then, since I'm at work right now.
|
||||
|
||||
Germany is great so far, and as soon as I get learning some German I'll be a much happier person. I consider it rude of me to expect everyone to converse with me in English, like I have to do right now.
|
||||
|
||||
(Oh, and they sell beer by the litre in Germany! They call it a maß.)
|
||||
|
||||
16
posts/2006/06/never-buy-a-german-keyboard.md
Normal file
16
posts/2006/06/never-buy-a-german-keyboard.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
---
|
||||
Title: "Never buy a German keyboard!"
|
||||
Author: Sami Samhuri
|
||||
Date: "9th June, 2006"
|
||||
Timestamp: 2006-06-09T01:17:00-07:00
|
||||
Tags: apple, apple, german, keyboard
|
||||
---
|
||||
|
||||
Nothing personal, but the backtick/tilde is located where the rest of the left shift key should be, and the return key is double-height, forcing the backslash/bar to the right of the dash/underscore (that'd be the apostrophe/double quote for pretty much everyone else who types qwerty). Note that I'm talking about using a German keyboard with an English layout. The German layout is flat out impossible for coding.
|
||||
|
||||
<a href="/images/keyboard.jpg"><img src="/images/keyboard.jpg" title="German Apple Keyboard" alt="German Apple Keyboard"></a>
|
||||
|
||||
For some reason it gets even worse with a German Apple keyboard. Square brackets, where for art though? Through trial and error I found them using Alt/Opt+5/6... non-Apple German keyboards I've seen use Alt Gr+8/9, which is just as bad but at least they were <strong>labeled</strong>. I know why coders here don't use the German layout! I feel uneasy just talking about it.
|
||||
|
||||
Here's a <a href="/f/german_keys.txt">text file</a> with each character of the 4 rows in it, normal and then shifted, in qwerty, qwertz, and dvorak. I personally think that some ways the German keys change must be some sick joke (double quote moved up to shift-2, single quote almost staying put, angle brackets being shoved aside only to put the semi-colon and colon on different keys as well). If you ask me lots of that could be avoided by getting rid of the key that replaced the backtick/tilde, and putting the 3 vowels with the umlaut (ü, ö, and ä) on Alt Gr/Opt+[aou]. But hey, I don't type in German so what do I know.
|
||||
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
Title: "There's nothing regular about regular expressions"
|
||||
Author: Sami Samhuri
|
||||
Date: "10th June, 2006"
|
||||
Timestamp: 2006-06-10T01:28:00-07:00
|
||||
Tags: technology, book, regex
|
||||
---
|
||||
|
||||
I'm almost half way reading Jeffrey Friedl's book <a href="http://www.oreilly.com/catalog/regex2/">Mastering Regular Expressions</a> and I have to say that for a book on something that could potentially bore you to tears, he really does an excellent job of keeping it interesting. Even though a lot of the examples are contrived (I'm sure out of necessity), he also uses real examples of regexes that he's actually used at <a href="http://www.yahoo.com/">Yahoo!</a>.
|
||||
|
||||
As someone who has to know how everything works it's also an excellent lesson in patience, as he frequently says "here, take this knowledge and just accept it for now until I can explain why in the next chapter (or in 3 chapters!)". But it's all with good reason and when he does explain he does it well.
|
||||
|
||||
Reading about the different NFA and DFA engines and which tools use which made me go "ahhh, /that's/ why I can't do that in grep!" It's not just that I like to know how things work either, he's 100% correct about having to know information like that to wield the power of regexes in all situations. This book made me realize that regex implementations can be wildly different and that you really need to consider the job before jumping into using a specific regex flavour, as he calls them. I'm fascinated by learning why DFA regex implementations would successfully allow `^\w+=.(\\\n.)*` to match certain lines, allowing for trailing backslashes to mean continuation but why NFA engines would fail to do the same without tweaking it a bit.
|
||||
|
||||
It requires more thinking than the last 2 computer books I read, *Programming Ruby* (the "pixaxe" book) and *Agile Web Development With Rails* so it's noticeably slower reading. It's also the kind of book I will read more than once, for sure. There's just no way I can glean everything from it in one reading. If you use regular expressions at all then you need this book. This is starting to sound like an advertisement so I'll say no more.
|
||||
|
||||
QOTD, p. 329, about matching nested pairs of parens:
|
||||
|
||||
```conf
|
||||
\(([^()]|\(([^()]|\(([^()]|\(([^()])*\))*\))*\))*\)
|
||||
Wow, that's ugly.
|
||||
```
|
||||
|
||||
(Don't worry, there's a much better solution on the next 2 pages after that quote.)
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
---
|
||||
Title: "Class method? Instance method? It doesn't matter to PHP"
|
||||
Author: Sami Samhuri
|
||||
Date: "21st July, 2006"
|
||||
Timestamp: 2006-07-21T07:56:00-07:00
|
||||
Tags: php, coding
|
||||
---
|
||||
|
||||
*Update: This has <a href="http://www.php.net/~derick/meeting-notes.html#method-calls">been discussed</a> for PHP6. A little late, but I guess better than never.*
|
||||
|
||||
I made a mistake while I was coding, for shame! Anyway this particular mistake was that I invoked a class method on the wrong class. The funny part was that this method was an instance method in the class which I typed by mistake. In the error log I saw something like "Invalid use of $this in class function."
|
||||
|
||||
I knew for a fact I hadn't used $this in a class method, so it was kind of a confusing error. I went to the file in question and found out that it was calling an instance method as a class method. Now that is some crazy shit.
|
||||
|
||||
I would fully expect the PHP parser to give me an error like "No class method [foo] in class [blah]", rather than try and execute it as a class method. The syntax is completely different; you use :: to call a class method and -> to call an instance method. And you use the name of a <em>class</em> when you call a class method.
|
||||
|
||||
This code:
|
||||
|
||||
```php
|
||||
class Foo {
|
||||
public static function static_fun()
|
||||
{
|
||||
return "This is a class method!\n";
|
||||
}
|
||||
|
||||
public function not_static()
|
||||
{
|
||||
return "This is an instance method!\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo '<pre>';
|
||||
echo "From Foo:\n";
|
||||
echo Foo::static_fun();
|
||||
echo Foo::not_static();
|
||||
echo "\n";
|
||||
|
||||
echo "From \$foo = new Foo():\n";
|
||||
$foo = new Foo();
|
||||
echo $foo->static_fun();
|
||||
echo $foo->not_static();
|
||||
echo '</pre>';
|
||||
```
|
||||
|
||||
Produces:
|
||||
|
||||
```php
|
||||
From Foo:
|
||||
This is a class method!
|
||||
This is an instance method!
|
||||
|
||||
From $foo = new Foo():
|
||||
This is a class method!
|
||||
This is an instance method!
|
||||
```
|
||||
|
||||
What the fuck?! <a href="http://www.php.net/manual/en/language.oop5.static.php">http://www.php.net/manual/en/language.oop5.static.php</a> is lying to everyone.
|
||||
|
||||
45
posts/2006/07/late-static-binding.md
Normal file
45
posts/2006/07/late-static-binding.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
---
|
||||
Title: "Late static binding"
|
||||
Author: Sami Samhuri
|
||||
Date: "19th July, 2006"
|
||||
Timestamp: 2006-07-19T10:23:00-07:00
|
||||
Tags: php, coding, coding, php
|
||||
---
|
||||
|
||||
*Update: This has <a href="http://www.php.net/~derick/meeting-notes.html#late-static-binding-using-this-without-or-perhaps-with-a-different-name">been discussed</a> and will be uh, sort of fixed, in PHP6. You'll be able to use static::my_method() to get the real reference to self in class methods. Not optimal, but still a solution I guess.*
|
||||
|
||||
As colder on ##php (freenode) told me today, class methods in PHP don't have what they call late static binding. What's that? It means that this code:
|
||||
|
||||
```php
|
||||
class Foo
|
||||
{
|
||||
public static function my_method()
|
||||
{
|
||||
echo "I'm a " . get_class() . "!\n";
|
||||
}
|
||||
}
|
||||
|
||||
class Bar extends Foo
|
||||
{}
|
||||
|
||||
Bar::my_method();
|
||||
```
|
||||
|
||||
outputs "I'm a Foo!", instead of "I'm a Bar!". That's not fun.
|
||||
|
||||
Using <code>__CLASS__</code> in place of <code>get_class()</code> makes zero difference. You end up with proxy methods in each subclass of Foo that pass in the real name of the calling class, which sucks.
|
||||
|
||||
```php
|
||||
class Bar extends Foo
|
||||
{
|
||||
public static function my_method()
|
||||
{
|
||||
return parent::my_method( get_class() );
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
I was told that they had a discussion about this on the internal PHP list, so at least they're thinking about this stuff. Too bad PHP5 doesn't have it. I guess I should just be glad I won't be maintaining this code.
|
||||
|
||||
The resident PHP coder said "just make your code simpler", which is what I was trying to do by removing duplication. Too bad that plan sort of backfired. I guess odd things like this are where PHP starts to show that OO was tacked on as an after-thought.
|
||||
|
||||
18
posts/2006/07/ruby-and-rails-have-spoiled-me-rotten.md
Normal file
18
posts/2006/07/ruby-and-rails-have-spoiled-me-rotten.md
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
---
|
||||
Title: "Ruby and Rails have spoiled me rotten"
|
||||
Author: Sami Samhuri
|
||||
Date: "17th July, 2006"
|
||||
Timestamp: 2006-07-17T05:40:00-07:00
|
||||
Tags: rails, ruby, php, coding, framework, php, rails, ruby, zend
|
||||
---
|
||||
|
||||
It's true. I'm sitting here coding in PHP using the <a href="http://framework.zend.com/">Zend Framework</a> and all I can think about is how much nicer Rails is, or how much easier it is to do [x] in Ruby. It's not that the Zend Framework is bad or anything, it's quite nice, but you just can't match Ruby's expressiveness in a language like PHP. Add the amazing convenience Rails builds on top of Ruby and that's a really hard combo to compete with.
|
||||
|
||||
I'd love to be using mixins instead of mucking around with abstract classes and interfaces, neither of which will just let you share a method between different classes. Writing proxy methods in these tiny in-between classes is annoying. (ie. inherit from Zend_class, then my real classes inherit from the middle-man class) I *could* add things to Zend's classes, but then upgrades are a bitch. I miss Ruby. I could use something like <a href="http://www.advogato.org/article/470.html">whytheluckystiff's PHP mixins</a>, which is a clever hack, but still a hack.
|
||||
|
||||
I keep looking at Rails code to see how things are done there, and I already coded a nearly complete prototype in Rails as a reference. I could have finished the thing in Rails by now, seriously. I'm still playing catch-up writing validations and model classes for all my objects, stuff I could've had for free using Rails, with an extra 10 mins to add validations and make sure they're all working nicely.
|
||||
|
||||
It's no wonder <a href="http://www.loudthinking.com/">David H. Hansson</a> wasn't able to write a framework he was happy with in PHP. After using Rails everything seems like a chore. I'm just coding solved problems over again in an inferior language.
|
||||
|
||||
But hey, I'm learning things and I still got to use Ruby even if the code won't be used later. I guess this experience will just make me appreciate the richness of Ruby and Rails even more.
|
||||
|
||||
16
posts/2006/07/ubuntu-linux-for-linux-users-please.md
Normal file
16
posts/2006/07/ubuntu-linux-for-linux-users-please.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
---
|
||||
Title: "Ubuntu: Linux for Linux users please"
|
||||
Author: Sami Samhuri
|
||||
Date: "13th July, 2006"
|
||||
Timestamp: 2006-07-13T08:34:00-07:00
|
||||
Tags: linux, linux, ubuntu
|
||||
---
|
||||
|
||||
<a href="http://www.ubuntu.com/">Ubuntu</a> is a fine Linux distro, which is why it's popular. I still use <a href="http://www.gentoo.org/">Gentoo</a> on my servers but Ubuntu is fast to set up for a desktop. Linux for humans it certainly is, but dammit sometimes I want Linux like I'm used to.
|
||||
|
||||
It should ship with build-essentials (gcc & others) installed. It *shouldn't* ask me if I'm sure I want to restart at the GDM login screen. I have no session open and already clicked twice to choose Restart.
|
||||
|
||||
Other things aren't quite ready for humans yet. Network config needs some work. It's very slow to apply changes. Connecting to Windows printers should be easier (ie. let us browse to find them, or just search and present a list). Fonts aren't amazing yet, though Mac OS X has spoiled me as far as fonts are concerned.
|
||||
|
||||
Other than these things I think Ubuntu Dapper is a fine release. It installed on my work laptop without a problem and detected the volume keys and wireless network card flawlessly.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue