From b3c01bb43d9a5badd899d481dafdeed79de03090 Mon Sep 17 00:00:00 2001 From: Max Richter Date: Sun, 17 Aug 2025 16:35:52 +0200 Subject: [PATCH] feat: whole bunch of shit --- examples/Article/A dress a day.md | 28 ++++ examples/Books/Minimal.md | 19 +++ examples/Books/NoAuthor.md | 18 +++ examples/Books/NoBody.md | 19 +++ examples/Books/NoRating.md | 28 ++++ examples/Books/TheGreatGatsby.md | 17 +++ examples/Movies/Minimal.md | 16 +++ examples/Movies/Negative.md | 23 ++++ examples/Movies/NoBody.md | 22 +++ examples/Movies/Structured.md | 24 ++++ examples/Recipes/baguette.md | 16 +++ examples/Recipes/cake.md | 21 +++ examples/Recipes/caprese-salad.md | 20 +++ examples/Recipes/cheese-sandwich.md | 18 +++ examples/Recipes/complex.md | 20 +++ examples/Recipes/lentil-curry.md | 21 +++ examples/Recipes/multiline.md | 23 ++++ examples/Recipes/optional-fields.md | 17 +++ examples/Recipes/pancakes.md | 21 +++ examples/Recipes/salad.md | 22 +++ examples/Recipes/spaghetti-carbonara.md | 21 +++ examples/Recipes/stir-fry.md | 21 +++ examples/Recipes/tomato-soup.md | 21 +++ examples/Recipes/unicode.md | 21 +++ examples/Recipes/water.md | 11 ++ go.work | 1 + registry/schema-org/Recipe.schema.json | 54 ++++++-- registry/templates/Recipe.marka | 4 + registry/templates/_default.marka | 2 - server/cmd/marka-server/main.go | 37 +++++ server/go.mod | 3 + server/internal/fsx/contenttype.go | 23 ++++ server/internal/fsx/path.go | 56 ++++++++ server/internal/handlers/file.go | 171 ++++++++++++++++++++++++ server/internal/httpx/error.go | 20 +++ server/internal/httpx/router.go | 11 ++ 36 files changed, 878 insertions(+), 12 deletions(-) create mode 100644 examples/Article/A dress a day.md create mode 100644 examples/Books/Minimal.md create mode 100644 examples/Books/NoAuthor.md create mode 100644 examples/Books/NoBody.md create mode 100644 examples/Books/NoRating.md create mode 100644 examples/Books/TheGreatGatsby.md create mode 100644 examples/Movies/Minimal.md create mode 100644 examples/Movies/Negative.md create mode 100644 examples/Movies/NoBody.md create mode 100644 examples/Movies/Structured.md create mode 100644 examples/Recipes/baguette.md create mode 100644 examples/Recipes/cake.md create mode 100644 examples/Recipes/caprese-salad.md create mode 100644 examples/Recipes/cheese-sandwich.md create mode 100644 examples/Recipes/complex.md create mode 100644 examples/Recipes/lentil-curry.md create mode 100644 examples/Recipes/multiline.md create mode 100644 examples/Recipes/optional-fields.md create mode 100644 examples/Recipes/pancakes.md create mode 100644 examples/Recipes/salad.md create mode 100644 examples/Recipes/spaghetti-carbonara.md create mode 100644 examples/Recipes/stir-fry.md create mode 100644 examples/Recipes/tomato-soup.md create mode 100644 examples/Recipes/unicode.md create mode 100644 examples/Recipes/water.md create mode 100644 server/cmd/marka-server/main.go create mode 100644 server/go.mod create mode 100644 server/internal/fsx/contenttype.go create mode 100644 server/internal/fsx/path.go create mode 100644 server/internal/handlers/file.go create mode 100644 server/internal/httpx/error.go create mode 100644 server/internal/httpx/router.go diff --git a/examples/Article/A dress a day.md b/examples/Article/A dress a day.md new file mode 100644 index 0000000..7025759 --- /dev/null +++ b/examples/Article/A dress a day.md @@ -0,0 +1,28 @@ +--- +@type: Article +author.name: Erin Mckean +url: https://dressaday.com/2006/10/20/you-dont-have-to-be-pretty/ +rating: 5 +date: December 7, 2023 +image: https://i0.wp.com/old-dressaday-images.s3-website-us-west-2.amazonaws.com/6a0133ed1b1479970b0134809d9f8b970c.jpg +--- + +# You Dont Have to Be Pretty – A Dress A Day + +#positivity + +![Vreeland](https://i0.wp.com/old-dressaday-images.s3-website-us-west-2.amazonaws.com/6a0133ed1b1479970b0134809d9f8b970c.jpg) + +*image is by [Andy Warhol](http://www.warholfoundation.org/) © 2015 The Andy Warhol Foundation for the Visual Arts, Inc. / Artists Rights Society (ARS), New York* + +So the other day, folks in the comments were [talking about leggings](http://www.dressaday.com/2006/10/open-letter-to-mr-mizrahi.html). I’m pretty agnostic about leggings, but the whole discussion (which centered on the fact that it can be \*really\* hard to look good in leggings) got me thinking about the pervasive idea that women owe it to onlookers to maintain a certain standard of decorativeness. + +Now, this may seem strange from someone who writes about pretty dresses (mostly) every day, but: You Don’t Have to Be Pretty. You don’t _owe_ prettiness to anyone. Not to your boyfriend/spouse/partner, not to your co-workers, especially not to random men on the street. You don’t owe it to your mother, you don’t owe it to your children, you don’t owe it to civilization in general. Prettiness is not a rent you pay for occupying a space marked “female”. + +I’m not saying that you SHOULDN’T be pretty if you want to. (You don’t owe UN-prettiness to feminism, in other words.) Pretty is pleasant, and fun, and satisfying, and makes people smile, often even at you. But in the hierarchy of importance, _pretty_ stands several rungs down from _happy_, is way below _healthy_, and if done as a penance, or an obligation, can be so far away from _independent_ that you may have to squint really hard to see it in the haze. + +But what does you-don’t-have-to-be-pretty mean in practical, everyday terms? It means that you don’t have to apologize for wearing things that are held to be “unflattering” or “unfashionable” — especially if, in fact, they make you happy on some level deeper than just being pretty does. So what if your favorite color isn’t a “good” color on you? So what if you are “too fat” (by some arbitrary measure) for a sleeveless top? If you are clean, are covered enough to avoid a citation for public indecency, and have bandaged any open wounds, you can wear any color or style you please, if it makes you happy. + +I was going to make a handy prettiness decision tree, but pretty much the end of every branch was a bubble that said “tell complainers to go to hell” so it wasn’t much of a tool. + +Pretty, it’s sad to say, can have a shelf life. It’s so tied up with youth that, at some point (if you’re lucky), you’re going to have to graduate from pretty. Sometimes (as in the case with Diana Vreeland, above, you can go so far past pretty that you end up in _stylish_, or even _striking_ (or the fashion-y term _jolie laide_) before you know it. But you won’t get there if you think you have to follow all the signs that say “this way to _Pretty_.” You get there by traveling the route you find most interesting. (And to hell with the naysayers who say “But that’s not PRETTY”!) diff --git a/examples/Books/Minimal.md b/examples/Books/Minimal.md new file mode 100644 index 0000000..2a03152 --- /dev/null +++ b/examples/Books/Minimal.md @@ -0,0 +1,19 @@ +--- +@context: https://schema.org +@type: Review +author: + @type: Person + name: Alice +datePublished: 2025-08-01 +itemReviewed: + @type: Book + name: Untitled + author: + @type: Person + name: Unknown +--- + +# Untitled + +## Review +A mysterious book that leaves everything to the imagination. diff --git a/examples/Books/NoAuthor.md b/examples/Books/NoAuthor.md new file mode 100644 index 0000000..fece565 --- /dev/null +++ b/examples/Books/NoAuthor.md @@ -0,0 +1,18 @@ +--- +@context: https://schema.org +@type: Review +author: + @type: Person + name: Eve +datePublished: 2025-08-15 +itemReviewed: + @type: Book + name: Anonymous Poems +reviewBody: "Short, haunting, and powerful verses." +reviewRating: 4/5 +--- + +# Anonymous Poems + +## Review +Short, haunting, and powerful verses. diff --git a/examples/Books/NoBody.md b/examples/Books/NoBody.md new file mode 100644 index 0000000..0953908 --- /dev/null +++ b/examples/Books/NoBody.md @@ -0,0 +1,19 @@ +--- +@context: https://schema.org +@type: Review +author: + @type: Person + name: Clara +datePublished: 2025-08-10 +itemReviewed: + @type: Book + name: 1984 + author: + @type: Person + name: George Orwell +reviewRating: 10/10 +--- + +# 1984 + +## Review diff --git a/examples/Books/NoRating.md b/examples/Books/NoRating.md new file mode 100644 index 0000000..94571b4 --- /dev/null +++ b/examples/Books/NoRating.md @@ -0,0 +1,28 @@ +--- +@context: https://schema.org +@type: Review +author: + @type: Person + name: Bob +datePublished: 2025-08-05 +itemReviewed: + @type: Book + name: War and Peace + author: + @type: Person + name: Leo Tolstoy +reviewAspect: + - Length + - Complexity + - Characters +positiveNotes: + - Rich storytelling + - Deep philosophical themes +negativeNotes: + - Overwhelmingly long +--- + +# War and Peace + +## Review +An epic novel that rewards patient readers but can feel exhausting at times. diff --git a/examples/Books/TheGreatGatsby.md b/examples/Books/TheGreatGatsby.md new file mode 100644 index 0000000..b32be88 --- /dev/null +++ b/examples/Books/TheGreatGatsby.md @@ -0,0 +1,17 @@ +--- +@type: Review +author: + name: Max Richter +itemReviewed: + @type: Book + author: + name: F. Scott Fitzgerald +reviewRating: 5 +--- + +# The Great Gatsby + +## Review +A brilliant novel that captures the glamour and disillusionment of the Jazz Age. +The writing is lyrical and immersive, and the themes of wealth, love, and loss remain relevant today. +While some characters come across as emotionally flat, the overall impact is unforgettable. diff --git a/examples/Movies/Minimal.md b/examples/Movies/Minimal.md new file mode 100644 index 0000000..90c95f6 --- /dev/null +++ b/examples/Movies/Minimal.md @@ -0,0 +1,16 @@ +--- +@context: https://schema.org +@type: Review +author: + @type: Person + name: Frank +datePublished: 2025-08-01 +itemReviewed: + @type: Movie + name: Untitled Film +--- + +# Untitled Film + +## Review +Unclear, experimental, and strange. diff --git a/examples/Movies/Negative.md b/examples/Movies/Negative.md new file mode 100644 index 0000000..0447d31 --- /dev/null +++ b/examples/Movies/Negative.md @@ -0,0 +1,23 @@ +--- +@context: https://schema.org +@type: Review +author: + @type: Person + name: Grace +datePublished: 2025-08-02 +itemReviewed: + @type: Movie + name: The Room + author: + @type: Person + name: Tommy Wiseau +negativeNotes: + - Awkward dialogue + - Strange pacing + - Inconsistent acting +--- + +# The Room + +## Review +So bad it’s fascinating. diff --git a/examples/Movies/NoBody.md b/examples/Movies/NoBody.md new file mode 100644 index 0000000..524bbc4 --- /dev/null +++ b/examples/Movies/NoBody.md @@ -0,0 +1,22 @@ +--- +@context: https://schema.org +@type: Review +author: + @type: Person + name: Jack +datePublished: 2025-08-06 +itemReviewed: + @type: Movie + name: Blade Runner + author: + @type: Person + name: Ridley Scott +reviewRating: 4.5/5 +reviewAspect: + - Atmosphere + - Visual style +--- + +# Blade Runner + +## Review diff --git a/examples/Movies/Structured.md b/examples/Movies/Structured.md new file mode 100644 index 0000000..ca851e8 --- /dev/null +++ b/examples/Movies/Structured.md @@ -0,0 +1,24 @@ +--- +@context: https://schema.org +@type: Review +author: + @type: Person + name: Henry +datePublished: 2025-08-03 +itemReviewed: + @type: Movie + name: Inception + author: + @type: Person + name: Christopher Nolan +reviewAspect: + - Story + - Visuals + - Soundtrack +reviewRating: 9/10 +--- + +# Inception + +## Review +A mind-bending thriller that keeps you hooked. diff --git a/examples/Recipes/baguette.md b/examples/Recipes/baguette.md new file mode 100644 index 0000000..28c932b --- /dev/null +++ b/examples/Recipes/baguette.md @@ -0,0 +1,16 @@ +--- +author.name: Max Richter +--- + +# Baguette + +My favourite baguette recipe + +## Ingredients +- Flour +- Water +- Salt + +## Steps +1. Mix Flour Water and Salt +2. Bake the bread diff --git a/examples/Recipes/cake.md b/examples/Recipes/cake.md new file mode 100644 index 0000000..bd961ff --- /dev/null +++ b/examples/Recipes/cake.md @@ -0,0 +1,21 @@ +--- +author.name: Max Richter +--- + +# Chocolate Cake + +A moist, rich cake for any occasion. + +## Ingredients +- 200 g flour +- 200 g sugar +- 100 g cocoa powder +- 3 eggs +- 200 ml milk + +## Steps +1. Preheat oven to 180°C. +2. Mix dry ingredients in a bowl. +3. Add eggs and milk, stir until smooth. +4. Pour into a cake tin. +5. Bake for 35 minutes. diff --git a/examples/Recipes/caprese-salad.md b/examples/Recipes/caprese-salad.md new file mode 100644 index 0000000..57b1a60 --- /dev/null +++ b/examples/Recipes/caprese-salad.md @@ -0,0 +1,20 @@ +--- +author.name: Max Richter +--- + +# Caprese Salad + +A simple Italian starter with tomato, mozzarella, and basil. + +## Ingredients +- 2 tomatoes +- 125 g mozzarella +- Fresh basil +- Olive oil +- Balsamic vinegar + +## Steps +1. Slice tomatoes and mozzarella. +2. Arrange on a plate with basil leaves. +3. Drizzle with olive oil and balsamic vinegar. +4. Serve immediately. diff --git a/examples/Recipes/cheese-sandwich.md b/examples/Recipes/cheese-sandwich.md new file mode 100644 index 0000000..3be4fab --- /dev/null +++ b/examples/Recipes/cheese-sandwich.md @@ -0,0 +1,18 @@ +--- +author.name: Max Richter +--- + +# Grilled Cheese Sandwich + +Crispy, golden bread filled with melted cheese. + +## Ingredients +- 2 slices bread +- 2 slices cheddar +- Butter + +## Steps +1. Butter the bread slices. +2. Place cheese between bread. +3. Grill in a pan until golden on both sides. +4. Serve hot. diff --git a/examples/Recipes/complex.md b/examples/Recipes/complex.md new file mode 100644 index 0000000..91a49b3 --- /dev/null +++ b/examples/Recipes/complex.md @@ -0,0 +1,20 @@ +--- +author.name: Jane Doe +author.@type: Organization +recipeCategory: ["Dessert", "Vegan", "Quick"] +--- + +# Vegan Brownies + +Rich chocolate brownies with no animal products. + +## Ingredients +- 200 g flour +- 100 g cocoa powder +- 150 g sugar +- 250 ml almond milk + +## Steps +1. Mix all dry ingredients. +2. Add almond milk and stir into batter. +3. Bake at 180°C for 30 minutes. diff --git a/examples/Recipes/lentil-curry.md b/examples/Recipes/lentil-curry.md new file mode 100644 index 0000000..51a3391 --- /dev/null +++ b/examples/Recipes/lentil-curry.md @@ -0,0 +1,21 @@ +--- +author.name: Max Richter +--- + +# Lentil Curry + +A hearty Indian-inspired curry with red lentils. + +## Ingredients +- 200 g red lentils +- 1 onion +- 2 cloves garlic +- 1 tbsp curry powder +- 400 ml coconut milk + +## Steps +1. Sauté onion and garlic with curry powder. +2. Add lentils and coconut milk. +3. Simmer for 20 minutes. +4. Stir occasionally until thick. +5. Serve with rice. diff --git a/examples/Recipes/multiline.md b/examples/Recipes/multiline.md new file mode 100644 index 0000000..39977c0 --- /dev/null +++ b/examples/Recipes/multiline.md @@ -0,0 +1,23 @@ +--- +author.name: Max Richter +--- + +# Ramen Broth + +A rich broth requiring multiple steps in one ingredient description. + +## Ingredients +- Chicken bones, roasted until golden +- Water, enough to cover bones +- Aromatics: + - Onion + - Ginger + - Garlic +- Soy sauce +- Mirin + +## Steps +1. Roast bones until golden brown. +2. Simmer bones with aromatics and water for 4–6 hours. +3. Strain broth. +4. Add soy sauce and mirin before serving. diff --git a/examples/Recipes/optional-fields.md b/examples/Recipes/optional-fields.md new file mode 100644 index 0000000..5513842 --- /dev/null +++ b/examples/Recipes/optional-fields.md @@ -0,0 +1,17 @@ +--- +author.name: Max Richter +prepTime: +cookTime: +recipeYield: +cookingMethod: +--- + +# Mystery Dish + +A recipe with missing optional metadata. + +## Ingredients +- ??? + +## Steps +1. ??? diff --git a/examples/Recipes/pancakes.md b/examples/Recipes/pancakes.md new file mode 100644 index 0000000..ce0f13d --- /dev/null +++ b/examples/Recipes/pancakes.md @@ -0,0 +1,21 @@ +--- +author.name: Max Richter +--- + +# Pancakes + +Fluffy breakfast pancakes perfect with maple syrup. + +## Ingredients +- 200 g flour +- 2 eggs +- 300 ml milk +- 1 tsp baking powder +- Butter for frying + +## Steps +1. Mix flour, baking powder, eggs, and milk into a smooth batter. +2. Heat butter in a pan. +3. Pour in batter and cook until bubbles form. +4. Flip and cook the other side. +5. Serve with syrup or fruit. diff --git a/examples/Recipes/salad.md b/examples/Recipes/salad.md new file mode 100644 index 0000000..182b38a --- /dev/null +++ b/examples/Recipes/salad.md @@ -0,0 +1,22 @@ +--- +author.name: Max Richter +--- + +# Greek Salad + +A fresh salad with feta, olives, and crisp vegetables. + +## Ingredients +- 2 tomatoes +- 1 cucumber +- 1 red onion +- 100 g feta cheese +- Olives +- Olive oil and oregano + +## Steps +1. Chop tomatoes, cucumber, and onion. +2. Mix with olives in a bowl. +3. Crumble feta on top. +4. Drizzle with olive oil and sprinkle oregano. +5. Serve chilled. diff --git a/examples/Recipes/spaghetti-carbonara.md b/examples/Recipes/spaghetti-carbonara.md new file mode 100644 index 0000000..cf5e206 --- /dev/null +++ b/examples/Recipes/spaghetti-carbonara.md @@ -0,0 +1,21 @@ +--- +author.name: Max Richter +--- + +# Spaghetti Carbonara + +A creamy Roman pasta classic with pancetta, eggs, and cheese. + +## Ingredients +- 200 g spaghetti +- 100 g pancetta +- 2 eggs +- 50 g pecorino romano +- Black pepper + +## Steps +1. Boil the pasta until al dente. +2. Fry pancetta until crisp. +3. Whisk eggs and cheese in a bowl. +4. Combine hot pasta, pancetta, and egg mixture. +5. Season generously with pepper and serve. diff --git a/examples/Recipes/stir-fry.md b/examples/Recipes/stir-fry.md new file mode 100644 index 0000000..0e1986d --- /dev/null +++ b/examples/Recipes/stir-fry.md @@ -0,0 +1,21 @@ +--- +author.name: Max Richter +--- + +# Chicken Stir-Fry + +Quick stir-fried chicken with vegetables and soy sauce. + +## Ingredients +- 200 g chicken breast +- 1 bell pepper +- 1 carrot +- 2 tbsp soy sauce +- 1 tbsp sesame oil + +## Steps +1. Slice chicken and vegetables. +2. Heat oil in a wok. +3. Stir-fry chicken until browned. +4. Add vegetables and soy sauce. +5. Cook until tender-crisp. diff --git a/examples/Recipes/tomato-soup.md b/examples/Recipes/tomato-soup.md new file mode 100644 index 0000000..7ac890e --- /dev/null +++ b/examples/Recipes/tomato-soup.md @@ -0,0 +1,21 @@ +--- +author.name: Max Richter +--- + +# Tomato Soup + +A simple, comforting soup made from fresh tomatoes. + +## Ingredients +- 800 g tomatoes +- 1 onion +- 2 cloves garlic +- Olive oil +- Salt and pepper + +## Steps +1. Sauté onion and garlic in olive oil. +2. Add chopped tomatoes and cook until softened. +3. Blend until smooth. +4. Simmer for 10 minutes. +5. Season and serve warm. diff --git a/examples/Recipes/unicode.md b/examples/Recipes/unicode.md new file mode 100644 index 0000000..f5cdfcc --- /dev/null +++ b/examples/Recipes/unicode.md @@ -0,0 +1,21 @@ +--- +author.name: Max Richter +datePublished: 2025-08-17 +--- + +# Crème brûlée + +A French dessert with tricky accents and formatting. + +## Ingredients +- 500 ml cream +- 100 g sugar +- 4 egg yolks +- 1 vanilla pod + +## Steps +1. Heat cream & vanilla until steaming. +2. Whisk yolks + sugar until pale. +3. Combine gently → do not curdle! +4. Bake in water bath at 150 °C. +5. Chill, then caramelize sugar with a torch 🔥. diff --git a/examples/Recipes/water.md b/examples/Recipes/water.md new file mode 100644 index 0000000..84ac2bf --- /dev/null +++ b/examples/Recipes/water.md @@ -0,0 +1,11 @@ +--- +author.name: Max +--- + +# Water + +## Ingredients +- Water + +## Steps +1. Drink it diff --git a/go.work b/go.work index 13c90e5..3fa22ab 100644 --- a/go.work +++ b/go.work @@ -4,6 +4,7 @@ use ( ./parser ./registry ./renderer + ./server ./template ./testdata ./validator diff --git a/registry/schema-org/Recipe.schema.json b/registry/schema-org/Recipe.schema.json index 73a967d..0c1668a 100644 --- a/registry/schema-org/Recipe.schema.json +++ b/registry/schema-org/Recipe.schema.json @@ -23,8 +23,15 @@ "ingredients": { "description": "A single ingredient used in the recipe, e.g. sugar, flour or garlic.", "oneOf": [ - { "type": "string" }, - { "type": "array", "items": { "type": "string" } } + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } ] }, "nutrition": { @@ -42,27 +49,54 @@ "recipeIngredient": { "description": "A single ingredient used in the recipe, e.g. sugar, flour or garlic.", "oneOf": [ - { "type": "string" }, - { "type": "array", "items": { "type": "string" } } + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } ] }, "recipeInstructions": { "description": "A step in making the recipe, in the form of a single item (document, video, etc.) or an ordered list with HowToStep and/or HowToSection items.", "anyOf": [ - { "type": "string" }, - { "$ref": "schema:CreativeWork" }, - { "$ref": "schema:ItemList" } + { + "type": "string" + }, + { + "$ref": "schema:CreativeWork" + }, + { + "$ref": "schema:ItemList" + } ] }, "recipeYield": { "description": "The quantity produced by the recipe (for example, number of people served, number of servings, etc).", - "anyOf": [{ "type": "string" }, { "$ref": "schema:QuantitativeValue" }] + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "schema:QuantitativeValue" + } + ] }, "suitableForDiet": { "description": "Indicates a dietary restriction or guideline for which this recipe or menu item is suitable, e.g. diabetic, halal etc.", "oneOf": [ - { "$ref": "schema:RestrictedDiet" }, - { "type": "array", "items": { "$ref": "schema:RestrictedDiet" } } + { + "$ref": "schema:RestrictedDiet" + }, + { + "type": "array", + "items": { + "$ref": "schema:RestrictedDiet" + } + } ] } } diff --git a/registry/templates/Recipe.marka b/registry/templates/Recipe.marka index 94e83ce..12436a1 100644 --- a/registry/templates/Recipe.marka +++ b/registry/templates/Recipe.marka @@ -25,6 +25,10 @@ - path: prepTime - path: cookTime - path: recipeYield + - path: cookingMethod + - path: nutrition + - path: recipeCategory + - path: suitableForDiet } --- diff --git a/registry/templates/_default.marka b/registry/templates/_default.marka index 92d86bb..dee840e 100644 --- a/registry/templates/_default.marka +++ b/registry/templates/_default.marka @@ -4,7 +4,5 @@ codec: yaml fields: - path: "@type" - codec: const - value: Recipe } --- diff --git a/server/cmd/marka-server/main.go b/server/cmd/marka-server/main.go new file mode 100644 index 0000000..ee01e49 --- /dev/null +++ b/server/cmd/marka-server/main.go @@ -0,0 +1,37 @@ +package main + +import ( + "flag" + "log" + "net/http" + "os" + "path/filepath" + + "git.max-richter.dev/max/marka/server/internal/handlers" +) + +func main() { + root := flag.String("root", ".", "filesystem root to serve") + addr := flag.String("addr", ":8080", "listen address") + flag.Parse() + + absRoot, err := filepath.Abs(*root) + must(err) + info, err := os.Stat(absRoot) + must(err) + if !info.IsDir() { + log.Fatal("root is not a directory") + } + + // Catch-all route from "/" → serve files rooted in absRoot + http.Handle("/", handlers.NewFile(absRoot)) + + log.Printf("listening on %s, root=%s", *addr, absRoot) + log.Fatal(http.ListenAndServe(*addr, nil)) +} + +func must(err error) { + if err != nil { + log.Fatal(err) + } +} diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..85f4d32 --- /dev/null +++ b/server/go.mod @@ -0,0 +1,3 @@ +module git.max-richter.dev/max/marka/server + +go 1.24.3 diff --git a/server/internal/fsx/contenttype.go b/server/internal/fsx/contenttype.go new file mode 100644 index 0000000..87a9d8b --- /dev/null +++ b/server/internal/fsx/contenttype.go @@ -0,0 +1,23 @@ +package fsx + +import ( + "mime" + "path/filepath" + "strings" +) + +func ContentTypeFor(name string) string { + ext := strings.ToLower(filepath.Ext(name)) + switch ext { + case ".md", ".markdown", ".mdown": + return "application/markdown" + } + if ct := mime.TypeByExtension(ext); ct != "" { + return ct + } + switch ext { + case ".txt", ".log", ".json", ".yaml", ".yml", ".toml", ".xml", ".csv": + return "text/plain; charset=utf-8" + } + return "application/octet-stream" +} diff --git a/server/internal/fsx/path.go b/server/internal/fsx/path.go new file mode 100644 index 0000000..3d8d089 --- /dev/null +++ b/server/internal/fsx/path.go @@ -0,0 +1,56 @@ +package fsx + +import ( + "errors" + "path/filepath" + "strings" +) + +func CleanURLLike(p string) string { + p = strings.TrimSpace(p) + if p == "" || p == "/" { + return "/" + } + parts := []string{} + for _, seg := range strings.Split(strings.ReplaceAll(p, "\\", "/"), "/") { + switch seg { + case "", ".": + continue + case "..": + if len(parts) > 0 { + parts = parts[:len(parts)-1] + } + default: + parts = append(parts, seg) + } + } + return "/" + strings.Join(parts, "/") +} + +func SafeRel(root, requested string) (string, error) { + s := CleanURLLike(requested) + if strings.HasPrefix(s, "/") { + s = strings.TrimPrefix(s, "/") + } + full := filepath.Join(root, filepath.FromSlash(s)) + rel, err := filepath.Rel(root, full) + if err != nil { + return "", err + } + if rel == "." { + return "/", nil + } + sep := string(filepath.Separator) + if strings.HasPrefix(rel, "..") || strings.Contains(rel, ".."+sep) { + return "", errors.New("path escapes root") + } + return "/" + filepath.ToSlash(rel), nil +} + +func ResponsePath(root, full string) string { + rel, err := filepath.Rel(root, full) + if err != nil || rel == "." { + return "/" + } + return "/" + filepath.ToSlash(rel) +} diff --git a/server/internal/handlers/file.go b/server/internal/handlers/file.go new file mode 100644 index 0000000..cd7f8e1 --- /dev/null +++ b/server/internal/handlers/file.go @@ -0,0 +1,171 @@ +// Package handlers provides HTTP handlers for the file system. +package handlers + +import ( + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" + + "git.max-richter.dev/max/marka/parser" + "git.max-richter.dev/max/marka/server/internal/fsx" + "git.max-richter.dev/max/marka/server/internal/httpx" +) + +type File struct{ root string } + +func NewFile(root string) http.Handler { return &File{root: root} } + +func (h *File) ServeHTTP(w http.ResponseWriter, r *http.Request) { + reqPath := r.URL.Path + if reqPath == "" { + reqPath = "/" + } + cleanRel, err := fsx.SafeRel(h.root, reqPath) + if err != nil { + httpx.WriteError(w, http.StatusBadRequest, err) + return + } + target := filepath.Join(h.root, filepath.FromSlash(cleanRel)) + + switch r.Method { + case http.MethodGet: + h.get(w, r, target) + case http.MethodPost: + h.post(w, r, target) + default: + httpx.WriteError(w, http.StatusMethodNotAllowed, errors.New("method not allowed")) + } +} + +func read(path string) any { + data, err := os.ReadFile(path) + if err != nil { + return nil + } + res, err := parser.ParseFile(string(data)) + if err != nil { + return nil + } + + return res +} + +func (h *File) get(w http.ResponseWriter, r *http.Request, target string) { + fi, err := os.Stat(target) + if err != nil { + if os.IsNotExist(err) { + httpx.WriteError(w, http.StatusNotFound, errors.New("not found")) + } else { + httpx.WriteError(w, http.StatusInternalServerError, err) + } + return + } + + if fi.IsDir() { + entries, err := os.ReadDir(target) + if err != nil { + httpx.WriteError(w, http.StatusInternalServerError, err) + return + } + type item struct { + Name string `json:"name"` + Path string `json:"path"` + Content any `json:"content"` + IsDir bool `json:"isDir"` + Size int64 `json:"size"` + ModTime time.Time `json:"modTime"` + } + out := make([]item, 0, len(entries)) + for _, e := range entries { + info, _ := e.Info() + out = append(out, item{ + Name: e.Name(), + Path: fsx.ResponsePath(h.root, filepath.Join(target, e.Name())), + IsDir: e.IsDir(), + Content: read(filepath.Join(target, e.Name())), + Size: sizeOrZero(info), + ModTime: modTimeOrZero(info), + }) + } + httpx.WriteJSON(w, http.StatusOK, out) + return + } + + f, err := os.Open(target) + if err != nil { + httpx.WriteError(w, http.StatusInternalServerError, err) + return + } + defer f.Close() + + contentType := fsx.ContentTypeFor(target) + + if contentType == "application/markdown" { + data, err := io.ReadAll(f) + if err != nil { + httpx.WriteError(w, http.StatusInternalServerError, err) + return + } + res, err := parser.ParseFile(string(data)) + if err != nil { + fmt.Println(err) + } + w.Header().Set("Content-Type", "application/json") + httpx.WriteJSON(w, http.StatusOK, res) + return + } + + w.Header().Set("Content-Type", contentType) + http.ServeContent(w, r, filepath.Base(target), fi.ModTime(), f) +} + +func (h *File) post(w http.ResponseWriter, r *http.Request, target string) { + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + httpx.WriteError(w, http.StatusInternalServerError, err) + return + } + tmp := target + ".tmp~" + f, err := os.Create(tmp) + if err != nil { + httpx.WriteError(w, http.StatusInternalServerError, err) + return + } + + if _, err := io.Copy(f, r.Body); err != nil { + _ = f.Close() + _ = os.Remove(tmp) + httpx.WriteError(w, http.StatusInternalServerError, err) + return + } + if err := f.Close(); err != nil { + _ = os.Remove(tmp) + httpx.WriteError(w, http.StatusInternalServerError, err) + return + } + if err := os.Rename(tmp, target); err != nil { + _ = os.Remove(tmp) + httpx.WriteError(w, http.StatusInternalServerError, err) + return + } + + w.Header().Set("Location", r.URL.Path) + w.WriteHeader(http.StatusCreated) +} + +func sizeOrZero(fi os.FileInfo) int64 { + if fi == nil { + return 0 + } + return fi.Size() +} + +func modTimeOrZero(fi os.FileInfo) time.Time { + if fi == nil { + return time.Time{} + } + return fi.ModTime() +} diff --git a/server/internal/httpx/error.go b/server/internal/httpx/error.go new file mode 100644 index 0000000..7c88320 --- /dev/null +++ b/server/internal/httpx/error.go @@ -0,0 +1,20 @@ +package httpx + +import ( + "encoding/json" + "net/http" +) + +type ErrorResponse struct { + Error string `json:"error"` +} + +func WriteJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} + +func WriteError(w http.ResponseWriter, code int, err error) { + WriteJSON(w, code, ErrorResponse{Error: err.Error()}) +} diff --git a/server/internal/httpx/router.go b/server/internal/httpx/router.go new file mode 100644 index 0000000..dfdeec7 --- /dev/null +++ b/server/internal/httpx/router.go @@ -0,0 +1,11 @@ +package httpx + +import "net/http" + +type Router struct{ mux *http.ServeMux } + +func NewRouter() *Router { return &Router{mux: http.NewServeMux()} } +func (r *Router) Handle(pattern string, h http.Handler) { r.mux.Handle(pattern, h) } +func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { + r.mux.ServeHTTP(w, req) +}