Compare commits
5 Commits
c535e63b70
...
a83634da36
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a83634da36 | ||
|
|
1f0423841a | ||
|
|
83eb8f7d50 | ||
|
|
2b7cf0a3b9 | ||
|
|
ab7a520e9e |
@ -11,3 +11,23 @@
|
||||
{"timestamp":"2026-04-29T20:34:21.540Z","provider":"llama.cpp","model":"unsloth/Qwen3.6-27B-GGUF:Q4_K_M","turnCount":4,"inputTokens":641,"outputTokens":497,"totalTokens":1138,"prefillTokensPerSec":80.86,"generationTokensPerSec":16.65,"combinedTokensPerSec":30.12,"totalDurationMs":37779,"timeToFirstTokenMs":7927,"rawTimestamps":{"ttftMs":7927,"allTtftMs":[7927,7791],"generationDurationMs":29852,"turns":[{"turnId":"turn-0","durationMs":13323,"ttftMs":7927},{"turnId":"turn-1","durationMs":5975},{"turnId":"turn-2","durationMs":7432},{"turnId":"turn-3","durationMs":11049,"ttftMs":7791}]}}
|
||||
{"timestamp":"2026-04-29T21:00:14.163Z","provider":"llama.cpp","model":"unsloth/Qwen3.6-27B-GGUF:Q4_K_M","turnCount":1,"inputTokens":130,"outputTokens":754,"totalTokens":884,"prefillTokensPerSec":18.2,"generationTokensPerSec":22.65,"combinedTokensPerSec":21.87,"totalDurationMs":40429,"timeToFirstTokenMs":7141,"rawTimestamps":{"ttftMs":7141,"allTtftMs":[7141],"generationDurationMs":33288,"turns":[{"turnId":"turn-0","durationMs":40429,"ttftMs":7141}]}}
|
||||
{"timestamp":"2026-04-30T08:14:53.444Z","provider":"llama.cpp","model":"Qwen3.6-35B-A3B-MXFP4_MOE.gguf","turnCount":19,"inputTokens":12970,"outputTokens":3205,"totalTokens":16175,"prefillTokensPerSec":1449.65,"generationTokensPerSec":44.97,"combinedTokensPerSec":201.65,"totalDurationMs":80212,"timeToFirstTokenMs":8947,"rawTimestamps":{"ttftMs":8947,"allTtftMs":[8947,3426,526,1162],"generationDurationMs":71265,"turns":[{"turnId":"turn-0","durationMs":6818},{"turnId":"turn-1","durationMs":1695},{"turnId":"turn-2","durationMs":1595},{"turnId":"turn-3","durationMs":3043},{"turnId":"turn-4","durationMs":2759},{"turnId":"turn-5","durationMs":3602},{"turnId":"turn-6","durationMs":4147},{"turnId":"turn-7","durationMs":3449},{"turnId":"turn-8","durationMs":7692},{"turnId":"turn-9","durationMs":5670},{"turnId":"turn-10","durationMs":1713},{"turnId":"turn-11","durationMs":1593},{"turnId":"turn-12","durationMs":4806},{"turnId":"turn-13","durationMs":1737},{"turnId":"turn-14","durationMs":4030},{"turnId":"turn-15","durationMs":12934,"ttftMs":8947},{"turnId":"turn-16","durationMs":6340,"ttftMs":3426},{"turnId":"turn-17","durationMs":2325,"ttftMs":526},{"turnId":"turn-18","durationMs":4264,"ttftMs":1162}]}}
|
||||
{"timestamp":"2026-05-04T11:46:24.369Z","provider":"faster-mlx","model":"mlx-community/Qwen3.6-35B-A3B-8bit","turnCount":6,"inputTokens":54016,"outputTokens":1558,"totalTokens":55574,"prefillTokensPerSec":12304.33,"generationTokensPerSec":21.46,"combinedTokensPerSec":721.94,"totalDurationMs":76979,"timeToFirstTokenMs":4390,"rawTimestamps":{"ttftMs":4390,"allTtftMs":[4390,4350,5857,8964,12347,14311],"generationDurationMs":72589,"turns":[{"turnId":"turn-0","durationMs":7310,"ttftMs":4390},{"turnId":"turn-1","durationMs":7610,"ttftMs":4350},{"turnId":"turn-2","durationMs":10860,"ttftMs":5857},{"turnId":"turn-3","durationMs":15082,"ttftMs":8964},{"turnId":"turn-4","durationMs":17713,"ttftMs":12347},{"turnId":"turn-5","durationMs":18404,"ttftMs":14311}]}}
|
||||
{"timestamp":"2026-05-04T11:47:12.784Z","provider":"faster-mlx","model":"mlx-community/Qwen3.6-35B-A3B-8bit","turnCount":1,"inputTokens":15483,"outputTokens":527,"totalTokens":16010,"prefillTokensPerSec":915.45,"generationTokensPerSec":77.75,"combinedTokensPerSec":675.78,"totalDurationMs":23691,"timeToFirstTokenMs":16913,"rawTimestamps":{"ttftMs":16913,"allTtftMs":[16913],"generationDurationMs":6778,"turns":[{"turnId":"turn-0","durationMs":23691,"ttftMs":16913}]}}
|
||||
{"timestamp":"2026-05-04T11:48:28.522Z","provider":"faster-mlx","model":"mlx-community/Qwen3.6-35B-A3B-8bit","turnCount":2,"inputTokens":33498,"outputTokens":1509,"totalTokens":35007,"prefillTokensPerSec":1806.99,"generationTokensPerSec":35.09,"combinedTokensPerSec":568.89,"totalDurationMs":61536,"timeToFirstTokenMs":18538,"rawTimestamps":{"ttftMs":18538,"allTtftMs":[18538,19230],"generationDurationMs":42998,"turns":[{"turnId":"turn-0","durationMs":21065,"ttftMs":18538},{"turnId":"turn-1","durationMs":40471,"ttftMs":19230}]}}
|
||||
{"timestamp":"2026-05-04T11:55:38.472Z","provider":"faster-mlx","model":"mlx-community/Qwen3.6-35B-A3B-8bit","turnCount":2,"inputTokens":39338,"outputTokens":570,"totalTokens":39908,"prefillTokensPerSec":1902.59,"generationTokensPerSec":22.16,"combinedTokensPerSec":860.14,"totalDurationMs":46397,"timeToFirstTokenMs":20676,"rawTimestamps":{"ttftMs":20676,"allTtftMs":[20676,19136],"generationDurationMs":25721,"turns":[{"turnId":"turn-0","durationMs":25951,"ttftMs":20676},{"turnId":"turn-1","durationMs":20446,"ttftMs":19136}]}}
|
||||
{"timestamp":"2026-05-04T11:56:06.171Z","provider":"faster-mlx","model":"mlx-community/Qwen3.6-35B-A3B-8bit","turnCount":1,"inputTokens":20697,"outputTokens":122,"totalTokens":20819,"prefillTokensPerSec":1032.58,"generationTokensPerSec":64.14,"combinedTokensPerSec":948.65,"totalDurationMs":21946,"timeToFirstTokenMs":20044,"rawTimestamps":{"ttftMs":20044,"allTtftMs":[20044],"generationDurationMs":1902,"turns":[{"turnId":"turn-0","durationMs":21946,"ttftMs":20044}]}}
|
||||
{"timestamp":"2026-05-06T16:39:45.645Z","provider":"faster-mlx","model":"mlx-community/Qwen3.6-35B-A3B-8bit","turnCount":1,"inputTokens":4174,"outputTokens":325,"totalTokens":4499,"prefillTokensPerSec":792.63,"generationTokensPerSec":66.8,"combinedTokensPerSec":444.08,"totalDurationMs":10131,"timeToFirstTokenMs":5266,"rawTimestamps":{"ttftMs":5266,"allTtftMs":[5266],"generationDurationMs":4865,"turns":[{"turnId":"turn-0","durationMs":10131,"ttftMs":5266}]}}
|
||||
{"timestamp":"2026-05-06T16:42:07.071Z","provider":"faster-mlx","model":"mlx-community/Qwen3.6-35B-A3B-8bit","turnCount":7,"inputTokens":9779,"outputTokens":2117,"totalTokens":11896,"prefillTokensPerSec":4159.51,"generationTokensPerSec":41.52,"combinedTokensPerSec":223.01,"totalDurationMs":53342,"timeToFirstTokenMs":2351,"rawTimestamps":{"ttftMs":2351,"allTtftMs":[2351,9533],"generationDurationMs":50991,"turns":[{"turnId":"turn-0","durationMs":6041,"ttftMs":2351},{"turnId":"turn-1","durationMs":5709},{"turnId":"turn-2","durationMs":4712},{"turnId":"turn-3","durationMs":10280},{"turnId":"turn-4","durationMs":4285},{"turnId":"turn-5","durationMs":5823},{"turnId":"turn-6","durationMs":16492,"ttftMs":9533}]}}
|
||||
{"timestamp":"2026-05-06T16:49:48.369Z","provider":"faster-mlx","model":"mlx-community/Qwen3.6-35B-A3B-8bit","turnCount":7,"inputTokens":311,"outputTokens":1074,"totalTokens":1385,"prefillTokensPerSec":36.18,"generationTokensPerSec":61.74,"combinedTokensPerSec":53.29,"totalDurationMs":25992,"timeToFirstTokenMs":8597,"rawTimestamps":{"ttftMs":8597,"allTtftMs":[8597],"generationDurationMs":17395,"turns":[{"turnId":"turn-0","durationMs":11317,"ttftMs":8597},{"turnId":"turn-1","durationMs":2369},{"turnId":"turn-2","durationMs":2161},{"turnId":"turn-3","durationMs":2403},{"turnId":"turn-4","durationMs":4231},{"turnId":"turn-5","durationMs":2551},{"turnId":"turn-6","durationMs":960}]}}
|
||||
{"timestamp":"2026-05-06T16:56:43.506Z","provider":"faster-mlx","model":"mlx-community/Qwen3.6-35B-A3B-8bit","turnCount":11,"inputTokens":1781,"outputTokens":1747,"totalTokens":3528,"prefillTokensPerSec":495.82,"generationTokensPerSec":49.55,"combinedTokensPerSec":90.81,"totalDurationMs":38852,"timeToFirstTokenMs":3592,"rawTimestamps":{"ttftMs":3592,"allTtftMs":[3592,694,1252],"generationDurationMs":35260,"turns":[{"turnId":"turn-0","durationMs":4830,"ttftMs":3592},{"turnId":"turn-1","durationMs":2331},{"turnId":"turn-2","durationMs":3344},{"turnId":"turn-3","durationMs":2239},{"turnId":"turn-4","durationMs":4882,"ttftMs":694},{"turnId":"turn-5","durationMs":4150},{"turnId":"turn-6","durationMs":5061},{"turnId":"turn-7","durationMs":3934},{"turnId":"turn-8","durationMs":1503},{"turnId":"turn-9","durationMs":2303},{"turnId":"turn-10","durationMs":4275,"ttftMs":1252}]}}
|
||||
{"timestamp":"2026-05-06T16:59:33.370Z","provider":"faster-mlx","model":"mlx-community/Qwen3.6-35B-A3B-8bit","turnCount":8,"inputTokens":357,"outputTokens":949,"totalTokens":1306,"prefillTokensPerSec":222.57,"generationTokensPerSec":46.43,"combinedTokensPerSec":59.25,"totalDurationMs":22044,"timeToFirstTokenMs":1604,"rawTimestamps":{"ttftMs":1604,"allTtftMs":[1604],"generationDurationMs":20440,"turns":[{"turnId":"turn-0","durationMs":2025},{"turnId":"turn-1","durationMs":3837},{"turnId":"turn-2","durationMs":1860},{"turnId":"turn-3","durationMs":3942},{"turnId":"turn-4","durationMs":1846},{"turnId":"turn-5","durationMs":3096},{"turnId":"turn-6","durationMs":1495},{"turnId":"turn-7","durationMs":3943,"ttftMs":1604}]}}
|
||||
{"timestamp":"2026-05-06T17:31:10.634Z","provider":"faster-mlx","model":"mlx-community/Qwen3.6-35B-A3B-8bit","turnCount":92,"inputTokens":80382,"outputTokens":40770,"totalTokens":121152,"prefillTokensPerSec":34827.56,"generationTokensPerSec":21.57,"combinedTokensPerSec":64.01,"totalDurationMs":1892815,"timeToFirstTokenMs":2308,"rawTimestamps":{"ttftMs":2308,"allTtftMs":[2308,5231,2214,13471,19063,46574,152271,61194,48786,7235,28151,26069,9052,1985,1952,1437,10416,14830,1134,16993,12759,52435,92275,13142,123464,5492,21599,22940,67932,2646,19419,9988,11058,6667,3583,6608,1500],"generationDurationMs":1890507,"turns":[{"turnId":"turn-0","durationMs":4003,"ttftMs":2308},{"turnId":"turn-1","durationMs":41405},{"turnId":"turn-2","durationMs":9163,"ttftMs":5231},{"turnId":"turn-3","durationMs":15549},{"turnId":"turn-4","durationMs":24263,"ttftMs":2214},{"turnId":"turn-5","durationMs":6306},{"turnId":"turn-6","durationMs":58263,"ttftMs":13471},{"turnId":"turn-7","durationMs":2270},{"turnId":"turn-8","durationMs":28312,"ttftMs":19063},{"turnId":"turn-9","durationMs":15839},{"turnId":"turn-10","durationMs":3190},{"turnId":"turn-11","durationMs":62636,"ttftMs":46574},{"turnId":"turn-12","durationMs":29406},{"turnId":"turn-13","durationMs":13332},{"turnId":"turn-14","durationMs":162150,"ttftMs":152271},{"turnId":"turn-15","durationMs":9085},{"turnId":"turn-16","durationMs":67115,"ttftMs":61194},{"turnId":"turn-17","durationMs":12812},{"turnId":"turn-18","durationMs":3990},{"turnId":"turn-19","durationMs":55146,"ttftMs":48786},{"turnId":"turn-20","durationMs":21407},{"turnId":"turn-21","durationMs":21800,"ttftMs":7235},{"turnId":"turn-22","durationMs":2180},{"turnId":"turn-23","durationMs":41363,"ttftMs":28151},{"turnId":"turn-24","durationMs":1791},{"turnId":"turn-25","durationMs":32993,"ttftMs":26069},{"turnId":"turn-26","durationMs":6916},{"turnId":"turn-27","durationMs":1731},{"turnId":"turn-28","durationMs":22931,"ttftMs":9052},{"turnId":"turn-29","durationMs":1821},{"turnId":"turn-30","durationMs":4633,"ttftMs":1985},{"turnId":"turn-31","durationMs":4862,"ttftMs":1952},{"turnId":"turn-32","durationMs":4595,"ttftMs":1437},{"turnId":"turn-33","durationMs":13428,"ttftMs":10416},{"turnId":"turn-34","durationMs":25682,"ttftMs":14830},{"turnId":"turn-35","durationMs":17933,"ttftMs":1134},{"turnId":"turn-36","durationMs":20492,"ttftMs":16993},{"turnId":"turn-37","durationMs":31332,"ttftMs":12759},{"turnId":"turn-38","durationMs":56269,"ttftMs":52435},{"turnId":"turn-39","durationMs":8502},{"turnId":"turn-40","durationMs":6420},{"turnId":"turn-41","durationMs":25318},{"turnId":"turn-42","durationMs":9494},{"turnId":"turn-43","durationMs":95912,"ttftMs":92275},{"turnId":"turn-44","durationMs":34948,"ttftMs":13142},{"turnId":"turn-45","durationMs":8642},{"turnId":"turn-46","durationMs":130233,"ttftMs":123464},{"turnId":"turn-47","durationMs":38380},{"turnId":"turn-48","durationMs":9214},{"turnId":"turn-49","durationMs":11870,"ttftMs":5492},{"turnId":"turn-50","durationMs":6218},{"turnId":"turn-51","durationMs":7383},{"turnId":"turn-52","durationMs":4064},{"turnId":"turn-53","durationMs":27853,"ttftMs":21599},{"turnId":"turn-54","durationMs":11404},{"turnId":"turn-55","durationMs":17718},{"turnId":"turn-56","durationMs":32005,"ttftMs":22940},{"turnId":"turn-57","durationMs":14470},{"turnId":"turn-58","durationMs":76489,"ttftMs":67932},{"turnId":"turn-59","durationMs":7167},{"turnId":"turn-60","durationMs":21798,"ttftMs":2646},{"turnId":"turn-61","durationMs":5036},{"turnId":"turn-62","durationMs":27297,"ttftMs":19419},{"turnId":"turn-63","durationMs":2917},{"turnId":"turn-64","durationMs":18622},{"turnId":"turn-65","durationMs":4121},{"turnId":"turn-66","durationMs":17758,"ttftMs":9988},{"turnId":"turn-67","durationMs":14293},{"turnId":"turn-68","durationMs":10149},{"turnId":"turn-69","durationMs":21838,"ttftMs":11058},{"turnId":"turn-70","durationMs":33425,"ttftMs":6667},{"turnId":"turn-71","durationMs":8877},{"turnId":"turn-72","durationMs":4817},{"turnId":"turn-73","durationMs":14984},{"turnId":"turn-74","durationMs":3183},{"turnId":"turn-75","durationMs":13013},{"turnId":"turn-76","durationMs":4295},{"turnId":"turn-77","durationMs":11146},{"turnId":"turn-78","durationMs":4254},{"turnId":"turn-79","durationMs":22982},{"turnId":"turn-80","durationMs":2358},{"turnId":"turn-81","durationMs":9871},{"turnId":"turn-82","durationMs":2366},{"turnId":"turn-83","durationMs":10509},{"turnId":"turn-84","durationMs":2354},{"turnId":"turn-85","durationMs":7532,"ttftMs":3583},{"turnId":"turn-86","durationMs":11922,"ttftMs":6608},{"turnId":"turn-87","durationMs":6071},{"turnId":"turn-88","durationMs":7534},{"turnId":"turn-89","durationMs":4058},{"turnId":"turn-90","durationMs":6229},{"turnId":"turn-91","durationMs":15108,"ttftMs":1500}]}}
|
||||
{"timestamp":"2026-05-06T21:04:32.534Z","provider":"faster-mlx","model":"mlx-community/Qwen3.6-35B-A3B-8bit","turnCount":1,"inputTokens":29,"outputTokens":507,"totalTokens":536,"prefillTokensPerSec":4.5,"generationTokensPerSec":43.58,"combinedTokensPerSec":29.64,"totalDurationMs":18082,"timeToFirstTokenMs":6448,"rawTimestamps":{"ttftMs":6448,"allTtftMs":[6448],"generationDurationMs":11634,"turns":[{"turnId":"turn-0","durationMs":18082,"ttftMs":6448}]}}
|
||||
{"timestamp":"2026-05-06T21:27:50.071Z","provider":"faster-mlx","model":"mlx-community/Qwen3.6-35B-A3B-8bit","turnCount":1,"inputTokens":45,"outputTokens":546,"totalTokens":591,"prefillTokensPerSec":5.14,"generationTokensPerSec":43.29,"combinedTokensPerSec":27.67,"totalDurationMs":21362,"timeToFirstTokenMs":8748,"rawTimestamps":{"ttftMs":8748,"allTtftMs":[8748],"generationDurationMs":12614,"turns":[{"turnId":"turn-0","durationMs":21362,"ttftMs":8748}]}}
|
||||
{"timestamp":"2026-05-06T21:31:47.562Z","provider":"faster-mlx","model":"mlx-community/Qwen3.6-35B-A3B-8bit","turnCount":4,"inputTokens":331,"outputTokens":712,"totalTokens":1043,"prefillTokensPerSec":127.6,"generationTokensPerSec":24.75,"combinedTokensPerSec":33.26,"totalDurationMs":31358,"timeToFirstTokenMs":2594,"rawTimestamps":{"ttftMs":2594,"allTtftMs":[2594],"generationDurationMs":28764,"turns":[{"turnId":"turn-0","durationMs":7885},{"turnId":"turn-1","durationMs":13632},{"turnId":"turn-2","durationMs":3642},{"turnId":"turn-3","durationMs":6199,"ttftMs":2594}]}}
|
||||
{"timestamp":"2026-05-06T21:34:33.835Z","provider":"faster-mlx","model":"mlx-community/Qwen3.6-35B-A3B-8bit","turnCount":2,"inputTokens":537,"outputTokens":457,"totalTokens":994,"prefillTokensPerSec":186.07,"generationTokensPerSec":36.49,"combinedTokensPerSec":64.5,"totalDurationMs":15410,"timeToFirstTokenMs":2886,"rawTimestamps":{"ttftMs":2886,"allTtftMs":[2886,4622],"generationDurationMs":12524,"turns":[{"turnId":"turn-0","durationMs":10034,"ttftMs":2886},{"turnId":"turn-1","durationMs":5376,"ttftMs":4622}]}}
|
||||
{"timestamp":"2026-05-07T09:58:26.890Z","provider":"llama.cpp","model":"unsloth/Qwen3.6-27B-GGUF:Q4_K_M","turnCount":8,"inputTokens":6451,"outputTokens":1301,"totalTokens":7752,"prefillTokensPerSec":1185.41,"generationTokensPerSec":5.62,"combinedTokensPerSec":32.73,"totalDurationMs":236828,"timeToFirstTokenMs":5442,"rawTimestamps":{"ttftMs":5442,"allTtftMs":[5442,1468],"generationDurationMs":231386,"turns":[{"turnId":"turn-0","durationMs":110526},{"turnId":"turn-1","durationMs":7296},{"turnId":"turn-2","durationMs":14916,"ttftMs":5442},{"turnId":"turn-3","durationMs":12938},{"turnId":"turn-4","durationMs":11185},{"turnId":"turn-5","durationMs":8236},{"turnId":"turn-6","durationMs":10174},{"turnId":"turn-7","durationMs":61557,"ttftMs":1468}]}}
|
||||
{"timestamp":"2026-05-07T10:53:17.649Z","provider":"llama.cpp","model":"unsloth/Qwen3.6-27B-GGUF:Q4_K_M","turnCount":32,"inputTokens":18704,"outputTokens":5545,"totalTokens":24249,"prefillTokensPerSec":2125.45,"generationTokensPerSec":15.7,"combinedTokensPerSec":66.97,"totalDurationMs":362085,"timeToFirstTokenMs":8800,"rawTimestamps":{"ttftMs":8800,"allTtftMs":[8800,19803,2],"generationDurationMs":353285,"turns":[{"turnId":"turn-0","durationMs":4811},{"turnId":"turn-1","durationMs":6333},{"turnId":"turn-2","durationMs":18593},{"turnId":"turn-3","durationMs":17961,"ttftMs":8800},{"turnId":"turn-4","durationMs":13068},{"turnId":"turn-5","durationMs":14593},{"turnId":"turn-6","durationMs":12481},{"turnId":"turn-7","durationMs":9793},{"turnId":"turn-8","durationMs":21642},{"turnId":"turn-9","durationMs":22537},{"turnId":"turn-10","durationMs":9008},{"turnId":"turn-11","durationMs":12334},{"turnId":"turn-12","durationMs":39276},{"turnId":"turn-13","durationMs":17312},{"turnId":"turn-14","durationMs":19481},{"turnId":"turn-15","durationMs":10121},{"turnId":"turn-16","durationMs":6445},{"turnId":"turn-17","durationMs":6207},{"turnId":"turn-18","durationMs":9938},{"turnId":"turn-19","durationMs":16812},{"turnId":"turn-20","durationMs":27727,"ttftMs":19803},{"turnId":"turn-21","durationMs":18264},{"turnId":"turn-22","durationMs":16482},{"turnId":"turn-23","durationMs":10849},{"turnId":"turn-24","durationMs":4},{"turnId":"turn-25","durationMs":0},{"turnId":"turn-26","durationMs":5},{"turnId":"turn-27","durationMs":0},{"turnId":"turn-28","durationMs":3},{"turnId":"turn-29","durationMs":0},{"turnId":"turn-30","durationMs":2},{"turnId":"turn-31","durationMs":3,"ttftMs":2}]}}
|
||||
{"timestamp":"2026-05-07T10:56:15.344Z","provider":"llama.cpp","model":"unsloth/Qwen3.6-27B-GGUF:Q4_K_M","turnCount":7,"inputTokens":1120,"outputTokens":1046,"totalTokens":2166,"prefillTokensPerSec":50.27,"generationTokensPerSec":15.14,"combinedTokensPerSec":23.71,"totalDurationMs":91350,"timeToFirstTokenMs":22281,"rawTimestamps":{"ttftMs":22281,"allTtftMs":[22281],"generationDurationMs":69069,"turns":[{"turnId":"turn-0","durationMs":6803},{"turnId":"turn-1","durationMs":9002},{"turnId":"turn-2","durationMs":9423},{"turnId":"turn-3","durationMs":8839},{"turnId":"turn-4","durationMs":21020},{"turnId":"turn-5","durationMs":7403},{"turnId":"turn-6","durationMs":28860,"ttftMs":22281}]}}
|
||||
{"timestamp":"2026-05-07T10:57:51.083Z","provider":"llama.cpp","model":"unsloth/Qwen3.6-27B-GGUF:Q4_K_M","turnCount":11,"inputTokens":847,"outputTokens":1039,"totalTokens":1886,"prefillTokensPerSec":458.83,"generationTokensPerSec":13.33,"combinedTokensPerSec":23.63,"totalDurationMs":79819,"timeToFirstTokenMs":1846,"rawTimestamps":{"ttftMs":1846,"allTtftMs":[1846],"generationDurationMs":77973,"turns":[{"turnId":"turn-0","durationMs":4602},{"turnId":"turn-1","durationMs":4772},{"turnId":"turn-2","durationMs":9277},{"turnId":"turn-3","durationMs":7327},{"turnId":"turn-4","durationMs":6075},{"turnId":"turn-5","durationMs":11215},{"turnId":"turn-6","durationMs":5261},{"turnId":"turn-7","durationMs":7154},{"turnId":"turn-8","durationMs":8922},{"turnId":"turn-9","durationMs":5088},{"turnId":"turn-10","durationMs":10126,"ttftMs":1846}]}}
|
||||
{"timestamp":"2026-05-07T11:44:31.762Z","provider":"llama.cpp","model":"unsloth/Qwen3.6-27B-GGUF:Q4_K_M","turnCount":1,"inputTokens":29,"outputTokens":1453,"totalTokens":1482,"prefillTokensPerSec":0.63,"generationTokensPerSec":25.21,"combinedTokensPerSec":14.34,"totalDurationMs":103354,"timeToFirstTokenMs":45712,"rawTimestamps":{"ttftMs":45712,"allTtftMs":[45712],"generationDurationMs":57642,"turns":[{"turnId":"turn-0","durationMs":103354,"ttftMs":45712}]}}
|
||||
|
||||
4118
block-agent-idea.html
Normal file
4118
block-agent-idea.html
Normal file
File diff suppressed because one or more lines are too long
@ -53,6 +53,9 @@ defmodule Blogex.Blog do
|
||||
|> Enum.map(&Map.put(&1, :blog, unquote(blog_id)))
|
||||
|> Enum.sort_by(& &1.date, {:desc, Date})
|
||||
|
||||
# Validate internal link semantics at compile time via before_compile
|
||||
@before_compile Blogex.Blog
|
||||
|
||||
# Collect all unique tags
|
||||
@tags @posts |> Enum.flat_map(& &1.tags) |> Enum.uniq() |> Enum.sort()
|
||||
|
||||
@ -135,4 +138,28 @@ defmodule Blogex.Blog do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
defmacro __before_compile__(env) do
|
||||
blog_id = Module.get_attribute(env.module, :blog_id)
|
||||
|
||||
quote do
|
||||
unquote(__MODULE__)._validate_links(@posts, unquote(blog_id))
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec _validate_links([Blogex.Post.t()], atom()) :: :ok
|
||||
def _validate_links(posts, blog_id) do
|
||||
Enum.each(posts, fn post ->
|
||||
case Blogex.LinkValidator.validate_body(post.body, blog_id, post_id: post.id) do
|
||||
:ok -> :ok
|
||||
{:error, errors} ->
|
||||
raise Blogex.LinkError,
|
||||
blog: blog_id,
|
||||
post_id: post.id,
|
||||
errors: errors
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
53
blogex/lib/blogex/link_error.ex
Normal file
53
blogex/lib/blogex/link_error.ex
Normal file
@ -0,0 +1,53 @@
|
||||
defmodule Blogex.LinkError do
|
||||
@moduledoc """
|
||||
Exception raised when a blog post contains invalid internal links.
|
||||
|
||||
Raised at compile time by `Blogex.Blog` when `LinkValidator` finds
|
||||
semantic errors in post body links.
|
||||
|
||||
## Fields
|
||||
|
||||
* `:blog` — the blog identifier atom (e.g., `:engineering`)
|
||||
* `:post_id` — the post slug/id that contains the invalid link
|
||||
* `:errors` — list of `{line, link, reason, post_id}` tuples
|
||||
|
||||
## Example
|
||||
|
||||
raise Blogex.LinkError,
|
||||
blog: :engineering,
|
||||
post_id: "my-post",
|
||||
errors: [
|
||||
{1, "/blog/unknown/broken", "unknown blog ID: unknown", post_id: "my-post"}
|
||||
]
|
||||
"""
|
||||
|
||||
defexception blog: nil, post_id: nil, errors: []
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
blog: atom(),
|
||||
post_id: String.t() | nil,
|
||||
errors: [{integer(), String.t(), String.t(), keyword()}]
|
||||
}
|
||||
|
||||
@impl true
|
||||
def message(%__MODULE__{blog: blog, post_id: post_id, errors: errors}) do
|
||||
post_label =
|
||||
case post_id do
|
||||
nil -> ""
|
||||
id -> " (post: #{id})"
|
||||
end
|
||||
|
||||
errors_list =
|
||||
errors
|
||||
|> Enum.map(fn {line, link, reason, _meta} ->
|
||||
" line #{line}: #{link} — #{reason}"
|
||||
end)
|
||||
|> Enum.join("\n")
|
||||
|
||||
"""
|
||||
invalid internal blog links in #{blog}#{post_label}
|
||||
|
||||
#{errors_list}
|
||||
"""
|
||||
end
|
||||
end
|
||||
213
blogex/lib/blogex/link_validator.ex
Normal file
213
blogex/lib/blogex/link_validator.ex
Normal file
@ -0,0 +1,213 @@
|
||||
defmodule Blogex.LinkValidator do
|
||||
@moduledoc """
|
||||
Validates internal blog link semantics in markdown post bodies.
|
||||
|
||||
Checks that internal links follow the pattern `/blog/{blog_id}/{slug}`
|
||||
where `blog_id` is a valid blog identifier and `slug` matches the
|
||||
expected format (lowercase alphanumeric with hyphens).
|
||||
|
||||
This module is pure and does not depend on any stored posts — it validates
|
||||
link format and semantics only. Post existence checking is handled separately.
|
||||
|
||||
## Valid blog IDs
|
||||
|
||||
* `:engineering` — maps to `/blog/engineering/`
|
||||
* `:release_notes` — maps to `/blog/releases/`
|
||||
|
||||
## Valid slug format
|
||||
|
||||
* Lowercase alphanumeric characters and hyphens only
|
||||
* Must not start or end with a hyphen
|
||||
* Must not contain consecutive hyphens
|
||||
* Query strings and anchor fragments are allowed after the slug
|
||||
|
||||
## Usage
|
||||
|
||||
# Validate a single link
|
||||
LinkValidator.validate_link("/blog/engineering/hello-world")
|
||||
# => :ok
|
||||
|
||||
# Validate multiple links with line numbers
|
||||
LinkValidator.validate_links(["/blog/engineering/a", "/blog/bad/b"])
|
||||
# => {:error, [{2, "/blog/bad/b", "unknown blog ID: bad"}]}
|
||||
|
||||
# Validate all links in a post body
|
||||
LinkValidator.validate_body(body, :engineering, post_id: "my-post")
|
||||
# => :ok or {:error, [...]}
|
||||
"""
|
||||
|
||||
@valid_blog_ids %{
|
||||
"engineering" => :engineering,
|
||||
"releases" => :release_notes
|
||||
}
|
||||
|
||||
@slug_regex ~r/^[a-z0-9]+(-[a-z0-9]+)*$/
|
||||
|
||||
@doc """
|
||||
Extracts internal blog links from a markdown body.
|
||||
|
||||
Returns a list of link paths (strings) that match the pattern
|
||||
`/blog/{engineering|releases}/{slug}`. External links and non-blog
|
||||
internal links are ignored.
|
||||
|
||||
Handles markdown link syntax `[text](url)`.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> extract_links("[link](/blog/engineering/post)")
|
||||
["/blog/engineering/post"]
|
||||
|
||||
iex> extract_links("See [GitHub](https://github.com)")
|
||||
[]
|
||||
"""
|
||||
@spec extract_links(String.t()) :: [String.t()]
|
||||
def extract_links(body) when is_binary(body) do
|
||||
~r/\[([^\]]+)\]\(([^)]+)\)/
|
||||
|> Regex.scan(body)
|
||||
|> Enum.map(fn [_, _, path] -> path end)
|
||||
|> Enum.filter(&internal_blog_link?/1)
|
||||
end
|
||||
|
||||
defp internal_blog_link?(path) do
|
||||
case parse_blog_link(path) do
|
||||
{_, _} -> true
|
||||
nil -> false
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validates a single link path.
|
||||
|
||||
Returns `:ok` if the link has valid semantics, or `{:error, reason}` otherwise.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> validate_link("/blog/engineering/hello-world")
|
||||
:ok
|
||||
|
||||
iex> validate_link("/blog/unknown/post")
|
||||
{:error, "unknown blog ID: unknown"}
|
||||
|
||||
iex> validate_link("/blog/engineering/My-Post")
|
||||
{:error, "slug must be lowercase alphanumeric with hyphens: My-Post"}
|
||||
"""
|
||||
@spec validate_link(String.t()) :: :ok | {:error, String.t()}
|
||||
def validate_link(link) when is_binary(link) do
|
||||
case parse_blog_link(link) do
|
||||
nil ->
|
||||
{:error, "not a blog link: #{link}"}
|
||||
|
||||
{blog_id_str, slug_part} ->
|
||||
case Map.fetch(@valid_blog_ids, blog_id_str) do
|
||||
{:ok, _blog_atom} -> validate_slug(slug_part)
|
||||
:error -> {:error, "unknown blog ID: #{blog_id_str}"}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validates a list of links, returning errors with line numbers.
|
||||
|
||||
Returns `:ok` if all links are valid, or `{:error, errors}` where each
|
||||
error is `{line_number, link, reason}`.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> validate_links(["/blog/engineering/a", "/blog/bad/b"])
|
||||
{:error, [{2, "/blog/bad/b", "unknown blog ID: bad"}]}
|
||||
"""
|
||||
@spec validate_links([String.t()]) :: :ok | {:error, [{integer(), String.t(), String.t()}]}
|
||||
def validate_links(links) when is_list(links) do
|
||||
links
|
||||
|> Enum.with_index(1)
|
||||
|> Enum.map(fn {link, line} ->
|
||||
case validate_link(link) do
|
||||
:ok -> nil
|
||||
{:error, reason} -> {line, link, reason}
|
||||
end
|
||||
end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> case do
|
||||
[] -> :ok
|
||||
errors -> {:error, errors}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validates all internal blog links in a post body.
|
||||
|
||||
Extracts links from the body, validates each one, and returns the result
|
||||
with optional post context.
|
||||
|
||||
## Options
|
||||
|
||||
* `:post_id` — the post slug/id, included in error tuples for debugging
|
||||
|
||||
## Examples
|
||||
|
||||
iex> validate_body("<p>[link](/blog/engineering/post)</p>", :engineering)
|
||||
:ok
|
||||
|
||||
iex> validate_body("<p>[link](/blog/unknown/post)</p>", :engineering)
|
||||
{:error, [{1, "/blog/unknown/post", "unknown blog ID: unknown", post_id: nil}]}
|
||||
|
||||
iex> validate_body("<p>[link](/blog/unknown/post)</p>", :engineering, post_id: "my-post")
|
||||
{:error, [{1, "/blog/unknown/post", "unknown blog ID: unknown", post_id: "my-post"}]}
|
||||
"""
|
||||
@spec validate_body(String.t(), atom(), keyword()) ::
|
||||
:ok | {:error, [{integer(), String.t(), String.t(), keyword()}]}
|
||||
def validate_body(body, blog, opts \\ []) when is_binary(body) and is_atom(blog) do
|
||||
post_id = Keyword.get(opts, :post_id)
|
||||
links = extract_links(body)
|
||||
|
||||
case validate_links(links) do
|
||||
:ok ->
|
||||
:ok
|
||||
|
||||
{:error, errors} ->
|
||||
enriched_errors =
|
||||
Enum.map(errors, fn {line, link, reason} ->
|
||||
{line, link, reason, post_id: post_id}
|
||||
end)
|
||||
|
||||
{:error, enriched_errors}
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
# --- Private helpers ---
|
||||
|
||||
@doc false
|
||||
@spec parse_blog_link(String.t()) :: {String.t(), String.t()} | nil
|
||||
def parse_blog_link(path) do
|
||||
with ["", "blog", blog_id, rest] <- String.split(path, "/", parts: 4) do
|
||||
slug = String.slice(rest, 0, slug_end(rest))
|
||||
{blog_id, slug}
|
||||
else
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec slug_end(String.t()) :: integer()
|
||||
defp slug_end(str) do
|
||||
case String.split(str, ["?", "#"], parts: 2) do
|
||||
[slug | _] -> String.length(slug)
|
||||
_ -> String.length(str)
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec validate_slug(String.t()) :: :ok | {:error, String.t()}
|
||||
defp validate_slug(slug) when slug == "" do
|
||||
{:error, "empty slug"}
|
||||
end
|
||||
|
||||
defp validate_slug(slug) do
|
||||
if Regex.match?(@slug_regex, slug) do
|
||||
:ok
|
||||
else
|
||||
{:error, "slug must be lowercase alphanumeric with hyphens: #{slug}"}
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -75,11 +75,14 @@ defmodule Blogex.Router do
|
||||
else
|
||||
conn
|
||||
|> put_resp_content_type("application/json")
|
||||
|> send_resp(200, Jason.encode!(%{
|
||||
blog: blog.blog_id(),
|
||||
tag: tag,
|
||||
posts: Enum.map(posts, &post_json/1)
|
||||
}))
|
||||
|> send_resp(
|
||||
200,
|
||||
Jason.encode!(%{
|
||||
blog: blog.blog_id(),
|
||||
tag: tag,
|
||||
posts: Enum.map(posts, &post_json/1)
|
||||
})
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@ -121,14 +124,17 @@ defmodule Blogex.Router do
|
||||
else
|
||||
conn
|
||||
|> put_resp_content_type("application/json")
|
||||
|> send_resp(200, Jason.encode!(%{
|
||||
blog: blog.blog_id(),
|
||||
title: blog.title(),
|
||||
posts: Enum.map(result.entries, &post_json/1),
|
||||
page: result.page,
|
||||
total_pages: result.total_pages,
|
||||
total_entries: result.total_entries
|
||||
}))
|
||||
|> send_resp(
|
||||
200,
|
||||
Jason.encode!(%{
|
||||
blog: blog.blog_id(),
|
||||
title: blog.title(),
|
||||
posts: Enum.map(result.entries, &post_json/1),
|
||||
page: result.page,
|
||||
total_pages: result.total_pages,
|
||||
total_entries: result.total_entries
|
||||
})
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
7
blogex/priv/blog/test/2026/01-01-valid-post.md
Normal file
7
blogex/priv/blog/test/2026/01-01-valid-post.md
Normal file
@ -0,0 +1,7 @@
|
||||
%{
|
||||
title: "Valid Post",
|
||||
author: "Test Author",
|
||||
description: "A post with valid links"
|
||||
}
|
||||
---
|
||||
This post has [valid links](/blog/engineering/hello-world) and [release notes](/blog/releases/v1-0-0).
|
||||
7
blogex/priv/blog/test/2026/01-02-invalid-post.md
Normal file
7
blogex/priv/blog/test/2026/01-02-invalid-post.md
Normal file
@ -0,0 +1,7 @@
|
||||
%{
|
||||
title: "Invalid Post",
|
||||
author: "Test Author",
|
||||
description: "A post with invalid links"
|
||||
}
|
||||
---
|
||||
This post has [invalid links](/blog/unknown/broken) and [bad slug](/blog/engineering/My-Post).
|
||||
96
blogex/test/blogex/blog_integration_test.exs
Normal file
96
blogex/test/blogex/blog_integration_test.exs
Normal file
@ -0,0 +1,96 @@
|
||||
defmodule Blogex.BlogIntegrationTest do
|
||||
use ExUnit.Case
|
||||
|
||||
describe "compile-time link validation" do
|
||||
test "raises LinkError for invalid blog ID in link" do
|
||||
tmp_file = Path.join(System.tmp_dir!(), "test_blog_invalid.ex")
|
||||
|
||||
File.write!(tmp_file, """
|
||||
defmodule TestBlogInvalidBlogId do
|
||||
use Blogex.Blog,
|
||||
blog_id: :test,
|
||||
app: :blogex,
|
||||
from: "priv/blog/test/2026/01-02-invalid-post.md",
|
||||
title: "Test Blog",
|
||||
base_path: "/blog/test"
|
||||
end
|
||||
""")
|
||||
|
||||
assert_raise Blogex.LinkError, fn ->
|
||||
Code.compile_file(tmp_file, __ENV__.file)
|
||||
end
|
||||
|
||||
File.rm!(tmp_file)
|
||||
end
|
||||
|
||||
test "raises LinkError for invalid slug in link" do
|
||||
tmp_file = Path.join(System.tmp_dir!(), "test_blog_invalid_slug.ex")
|
||||
|
||||
File.write!(tmp_file, """
|
||||
defmodule TestBlogInvalidSlug do
|
||||
use Blogex.Blog,
|
||||
blog_id: :test,
|
||||
app: :blogex,
|
||||
from: "priv/blog/test/2026/01-02-invalid-post.md",
|
||||
title: "Test Blog",
|
||||
base_path: "/blog/test"
|
||||
end
|
||||
""")
|
||||
|
||||
assert_raise Blogex.LinkError, fn ->
|
||||
Code.compile_file(tmp_file, __ENV__.file)
|
||||
end
|
||||
|
||||
File.rm!(tmp_file)
|
||||
end
|
||||
|
||||
test "compiles successfully with valid links" do
|
||||
tmp_file = Path.join(System.tmp_dir!(), "test_blog_valid.ex")
|
||||
|
||||
File.write!(tmp_file, """
|
||||
defmodule TestBlogValid do
|
||||
use Blogex.Blog,
|
||||
blog_id: :test,
|
||||
app: :blogex,
|
||||
from: "priv/blog/test/2026/01-01-valid-post.md",
|
||||
title: "Test Blog",
|
||||
base_path: "/blog/test"
|
||||
end
|
||||
""")
|
||||
|
||||
[{TestBlogValid, _bytecode}] = Code.compile_file(tmp_file, __ENV__.file)
|
||||
assert TestBlogValid.title() == "Test Blog"
|
||||
|
||||
File.rm!(tmp_file)
|
||||
end
|
||||
|
||||
test "LinkError message includes post context" do
|
||||
tmp_file = Path.join(System.tmp_dir!(), "test_blog_msg.ex")
|
||||
|
||||
File.write!(tmp_file, """
|
||||
defmodule TestBlogErrorMsg do
|
||||
use Blogex.Blog,
|
||||
blog_id: :test,
|
||||
app: :blogex,
|
||||
from: "priv/blog/test/2026/01-02-invalid-post.md",
|
||||
title: "Test Blog",
|
||||
base_path: "/blog/test"
|
||||
end
|
||||
""")
|
||||
|
||||
exception =
|
||||
assert_raise Blogex.LinkError, fn ->
|
||||
Code.compile_file(tmp_file, __ENV__.file)
|
||||
end
|
||||
|
||||
msg = Exception.message(exception)
|
||||
|
||||
assert msg =~ "invalid internal blog links"
|
||||
assert msg =~ "invalid-post"
|
||||
assert msg =~ "/blog/unknown/broken"
|
||||
assert msg =~ "/blog/engineering/My-Post"
|
||||
|
||||
File.rm!(tmp_file)
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -22,10 +22,11 @@ defmodule Blogex.BlogTest do
|
||||
end
|
||||
|
||||
test "includes today-dated published posts" do
|
||||
{:ok, _} = FakeBlog.start([
|
||||
build(id: "today", date: Date.utc_today(), published: true),
|
||||
build(id: "tomorrow", date: Date.add(Date.utc_today(), 1), published: true)
|
||||
])
|
||||
{:ok, _} =
|
||||
FakeBlog.start([
|
||||
build(id: "today", date: Date.utc_today(), published: true),
|
||||
build(id: "tomorrow", date: Date.add(Date.utc_today(), 1), published: true)
|
||||
])
|
||||
|
||||
ids = FakeBlog.all_posts() |> Enum.map(& &1.id)
|
||||
|
||||
@ -77,10 +78,11 @@ defmodule Blogex.BlogTest do
|
||||
end
|
||||
|
||||
test "excludes drafts even if tag matches" do
|
||||
{:ok, _} = FakeBlog.start([
|
||||
build(id: "pub", tags: ["elixir"], published: true),
|
||||
build(id: "draft", tags: ["elixir"], published: false)
|
||||
])
|
||||
{:ok, _} =
|
||||
FakeBlog.start([
|
||||
build(id: "pub", tags: ["elixir"], published: true),
|
||||
build(id: "draft", tags: ["elixir"], published: false)
|
||||
])
|
||||
|
||||
ids = FakeBlog.posts_by_tag("elixir") |> Enum.map(& &1.id)
|
||||
|
||||
@ -103,10 +105,11 @@ defmodule Blogex.BlogTest do
|
||||
end
|
||||
|
||||
test "excludes tags only appearing on drafts" do
|
||||
{:ok, _} = FakeBlog.start([
|
||||
build(tags: ["visible"], published: true),
|
||||
build(id: "d", tags: ["hidden"], published: false)
|
||||
])
|
||||
{:ok, _} =
|
||||
FakeBlog.start([
|
||||
build(tags: ["visible"], published: true),
|
||||
build(id: "d", tags: ["hidden"], published: false)
|
||||
])
|
||||
|
||||
refute "hidden" in FakeBlog.all_tags()
|
||||
end
|
||||
|
||||
@ -109,10 +109,11 @@ defmodule Blogex.FeedTest do
|
||||
|
||||
describe "XML escaping" do
|
||||
test "escapes special characters in titles" do
|
||||
{:ok, _} = FakeBlog.start(
|
||||
[build(title: "Foo & Bar <Baz>")],
|
||||
title: "A & B"
|
||||
)
|
||||
{:ok, _} =
|
||||
FakeBlog.start(
|
||||
[build(title: "Foo & Bar <Baz>")],
|
||||
title: "A & B"
|
||||
)
|
||||
|
||||
xml = Feed.rss(FakeBlog, @base_url)
|
||||
|
||||
|
||||
236
blogex/test/blogex/link_validator_test.exs
Normal file
236
blogex/test/blogex/link_validator_test.exs
Normal file
@ -0,0 +1,236 @@
|
||||
defmodule Blogex.LinkValidatorTest do
|
||||
use ExUnit.Case
|
||||
alias Blogex.LinkValidator
|
||||
|
||||
describe "extract_links/1" do
|
||||
test "extracts internal blog links from markdown body" do
|
||||
body =
|
||||
"Check out [hello world](/blog/engineering/hello-world) and [release v1](/blog/releases/v1-0-0)."
|
||||
|
||||
assert LinkValidator.extract_links(body) == [
|
||||
"/blog/engineering/hello-world",
|
||||
"/blog/releases/v1-0-0"
|
||||
]
|
||||
end
|
||||
|
||||
test "ignores external links" do
|
||||
body = "See [GitHub](https://github.com) and [internal](/blog/engineering/post)."
|
||||
|
||||
assert LinkValidator.extract_links(body) == ["/blog/engineering/post"]
|
||||
end
|
||||
|
||||
test "ignores non-blog internal links" do
|
||||
body = "See [/about](/about) and [/blog/engineering/post](/blog/engineering/post)."
|
||||
|
||||
assert LinkValidator.extract_links(body) == ["/blog/engineering/post"]
|
||||
end
|
||||
|
||||
test "returns empty list when no internal blog links" do
|
||||
body = "Just external links: [GitHub](https://github.com)."
|
||||
|
||||
assert LinkValidator.extract_links(body) == []
|
||||
end
|
||||
|
||||
test "handles multiple links on one line" do
|
||||
body = "[a](/blog/engineering/a) [b](/blog/releases/b) [c](/blog/engineering/c)"
|
||||
|
||||
assert LinkValidator.extract_links(body) == [
|
||||
"/blog/engineering/a",
|
||||
"/blog/releases/b",
|
||||
"/blog/engineering/c"
|
||||
]
|
||||
end
|
||||
|
||||
test "handles links with query strings" do
|
||||
body = "[link](/blog/engineering/post?foo=bar)"
|
||||
|
||||
assert LinkValidator.extract_links(body) == ["/blog/engineering/post?foo=bar"]
|
||||
end
|
||||
|
||||
test "handles links with anchor fragments" do
|
||||
body = "[link](/blog/engineering/post#section)"
|
||||
|
||||
assert LinkValidator.extract_links(body) == ["/blog/engineering/post#section"]
|
||||
end
|
||||
|
||||
test "handles empty body" do
|
||||
assert LinkValidator.extract_links("") == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "validate_link/1" do
|
||||
test "validates correct engineering link" do
|
||||
assert LinkValidator.validate_link("/blog/engineering/my-post") == :ok
|
||||
end
|
||||
|
||||
test "validates correct releases link" do
|
||||
assert LinkValidator.validate_link("/blog/releases/v1-0-0") == :ok
|
||||
end
|
||||
|
||||
test "rejects unknown blog ID" do
|
||||
assert LinkValidator.validate_link("/blog/unknown/post") ==
|
||||
{:error, "unknown blog ID: unknown"}
|
||||
end
|
||||
|
||||
test "rejects uppercase blog ID" do
|
||||
assert LinkValidator.validate_link("/blog/Engineering/post") ==
|
||||
{:error, "unknown blog ID: Engineering"}
|
||||
end
|
||||
|
||||
test "rejects empty slug" do
|
||||
assert LinkValidator.validate_link("/blog/engineering/") ==
|
||||
{:error, "empty slug"}
|
||||
end
|
||||
|
||||
test "rejects slug with uppercase letters" do
|
||||
assert LinkValidator.validate_link("/blog/engineering/My-Post") ==
|
||||
{:error, "slug must be lowercase alphanumeric with hyphens: My-Post"}
|
||||
end
|
||||
|
||||
test "rejects slug with special characters" do
|
||||
assert LinkValidator.validate_link("/blog/engineering/hello@world") ==
|
||||
{:error, "slug must be lowercase alphanumeric with hyphens: hello@world"}
|
||||
end
|
||||
|
||||
test "rejects slug with spaces" do
|
||||
assert LinkValidator.validate_link("/blog/engineering/hello world") ==
|
||||
{:error, "slug must be lowercase alphanumeric with hyphens: hello world"}
|
||||
end
|
||||
|
||||
test "allows single-word slug" do
|
||||
assert LinkValidator.validate_link("/blog/engineering/hello") == :ok
|
||||
end
|
||||
|
||||
test "allows hyphenated slug" do
|
||||
assert LinkValidator.validate_link("/blog/engineering/my-cool-post") == :ok
|
||||
end
|
||||
|
||||
test "allows slug with numbers" do
|
||||
assert LinkValidator.validate_link("/blog/releases/v1-2-3") == :ok
|
||||
end
|
||||
|
||||
test "rejects slug starting with hyphen" do
|
||||
assert LinkValidator.validate_link("/blog/engineering/-post") ==
|
||||
{:error, "slug must be lowercase alphanumeric with hyphens: -post"}
|
||||
end
|
||||
|
||||
test "rejects slug ending with hyphen" do
|
||||
assert LinkValidator.validate_link("/blog/engineering/post-") ==
|
||||
{:error, "slug must be lowercase alphanumeric with hyphens: post-"}
|
||||
end
|
||||
|
||||
test "rejects consecutive hyphens" do
|
||||
assert LinkValidator.validate_link("/blog/engineering/post--name") ==
|
||||
{:error, "slug must be lowercase alphanumeric with hyphens: post--name"}
|
||||
end
|
||||
|
||||
test "returns :ok for link with query string and valid slug" do
|
||||
assert LinkValidator.validate_link("/blog/engineering/post?foo=bar") == :ok
|
||||
end
|
||||
|
||||
test "returns :ok for link with anchor fragment and valid slug" do
|
||||
assert LinkValidator.validate_link("/blog/engineering/post#section") == :ok
|
||||
end
|
||||
|
||||
test "rejects non-blog path" do
|
||||
assert LinkValidator.validate_link("/about") ==
|
||||
{:error, "not a blog link: /about"}
|
||||
end
|
||||
|
||||
test "rejects malformed link" do
|
||||
assert LinkValidator.validate_link("not-a-url") ==
|
||||
{:error, "not a blog link: not-a-url"}
|
||||
end
|
||||
end
|
||||
|
||||
describe "validate_links/1" do
|
||||
test "returns :ok when all links are valid" do
|
||||
links = [
|
||||
"/blog/engineering/hello-world",
|
||||
"/blog/releases/v1-0-0"
|
||||
]
|
||||
|
||||
assert LinkValidator.validate_links(links) == :ok
|
||||
end
|
||||
|
||||
test "returns errors for invalid links" do
|
||||
links = [
|
||||
"/blog/engineering/hello-world",
|
||||
"/blog/unknown/post",
|
||||
"/blog/releases/My-Post"
|
||||
]
|
||||
|
||||
assert LinkValidator.validate_links(links) == {
|
||||
:error,
|
||||
[
|
||||
{2, "/blog/unknown/post", "unknown blog ID: unknown"},
|
||||
{3, "/blog/releases/My-Post",
|
||||
"slug must be lowercase alphanumeric with hyphens: My-Post"}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
test "returns :ok for empty list" do
|
||||
assert LinkValidator.validate_links([]) == :ok
|
||||
end
|
||||
|
||||
test "reports line numbers correctly" do
|
||||
links = [
|
||||
"/blog/engineering/ok",
|
||||
"/blog/bad/slug",
|
||||
"/blog/releases/ok"
|
||||
]
|
||||
|
||||
assert LinkValidator.validate_links(links) == {
|
||||
:error,
|
||||
[{2, "/blog/bad/slug", "unknown blog ID: bad"}]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
describe "validate_body/2" do
|
||||
test "returns :ok when body has no internal blog links" do
|
||||
body = "Just text, no links."
|
||||
|
||||
assert LinkValidator.validate_body(body, :engineering) == :ok
|
||||
end
|
||||
|
||||
test "returns :ok when all links are valid" do
|
||||
body = "[link](/blog/engineering/post)"
|
||||
|
||||
assert LinkValidator.validate_body(body, :engineering) == :ok
|
||||
end
|
||||
|
||||
test "returns errors with post context" do
|
||||
body = "[link](/blog/unknown/post)"
|
||||
|
||||
assert LinkValidator.validate_body(body, :engineering) == {
|
||||
:error,
|
||||
[
|
||||
{
|
||||
1,
|
||||
"/blog/unknown/post",
|
||||
"unknown blog ID: unknown",
|
||||
post_id: nil
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
test "includes post_id in error tuples when provided" do
|
||||
body = "[link](/blog/unknown/post)"
|
||||
|
||||
assert LinkValidator.validate_body(body, :engineering, post_id: "test-post") == {
|
||||
:error,
|
||||
[
|
||||
{
|
||||
1,
|
||||
"/blog/unknown/post",
|
||||
"unknown blog ID: unknown",
|
||||
post_id: "test-post"
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -6,19 +6,30 @@ defmodule Blogex.RegistryTest do
|
||||
|
||||
defmodule AlphaBlog do
|
||||
def blog_id, do: :alpha
|
||||
def all_posts, do: [Blogex.Test.PostBuilder.build(id: "a1", date: ~D[2026-03-01], blog: :alpha)]
|
||||
|
||||
def all_posts,
|
||||
do: [Blogex.Test.PostBuilder.build(id: "a1", date: ~D[2026-03-01], blog: :alpha)]
|
||||
|
||||
def all_tags, do: ["elixir"]
|
||||
|
||||
def unfiltered_posts,
|
||||
do: [
|
||||
Blogex.Test.PostBuilder.build(id: "a1", date: ~D[2026-03-01], blog: :alpha),
|
||||
Blogex.Test.PostBuilder.build(id: "a-draft", date: ~D[2026-03-05], blog: :alpha, published: false)
|
||||
Blogex.Test.PostBuilder.build(
|
||||
id: "a-draft",
|
||||
date: ~D[2026-03-05],
|
||||
blog: :alpha,
|
||||
published: false
|
||||
)
|
||||
]
|
||||
end
|
||||
|
||||
defmodule BetaBlog do
|
||||
def blog_id, do: :beta
|
||||
def all_posts, do: [Blogex.Test.PostBuilder.build(id: "b1", date: ~D[2026-03-15], blog: :beta)]
|
||||
|
||||
def all_posts,
|
||||
do: [Blogex.Test.PostBuilder.build(id: "b1", date: ~D[2026-03-15], blog: :beta)]
|
||||
|
||||
def all_tags, do: ["devops"]
|
||||
|
||||
def unfiltered_posts,
|
||||
|
||||
@ -10,15 +10,22 @@ defmodule Blogex.RouterTest do
|
||||
build(id: "first-post", title: "First", tags: ["elixir"], date: ~D[2026-03-10]),
|
||||
build(id: "second-post", title: "Second", tags: ["otp"], date: ~D[2026-02-01]),
|
||||
build(id: "draft", published: false, date: ~D[2026-03-12]),
|
||||
build(id: "future-post", title: "Future", tags: ["elixir"], date: ~D[2099-01-01], published: true)
|
||||
build(
|
||||
id: "future-post",
|
||||
title: "Future",
|
||||
tags: ["elixir"],
|
||||
date: ~D[2099-01-01],
|
||||
published: true
|
||||
)
|
||||
]
|
||||
|
||||
{:ok, _} = FakeBlog.start(posts,
|
||||
blog_id: :test,
|
||||
title: "Test Blog",
|
||||
description: "Test",
|
||||
base_path: "/blog/test"
|
||||
)
|
||||
{:ok, _} =
|
||||
FakeBlog.start(posts,
|
||||
blog_id: :test,
|
||||
title: "Test Blog",
|
||||
description: "Test",
|
||||
base_path: "/blog/test"
|
||||
)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@ -35,7 +35,9 @@ defmodule Blogex.Test.FakeBlog do
|
||||
}
|
||||
|
||||
case Agent.start(fn -> state end, name: __MODULE__) do
|
||||
{:ok, pid} -> {:ok, pid}
|
||||
{:ok, pid} ->
|
||||
{:ok, pid}
|
||||
|
||||
{:error, {:already_started, pid}} ->
|
||||
Agent.update(__MODULE__, fn _ -> state end)
|
||||
{:ok, pid}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user