Compare commits

..

1 Commits

Author SHA1 Message Date
Max Richter
66ab9ca7fc WIP 2025-09-24 21:32:27 +02:00
144 changed files with 890 additions and 6229 deletions

View File

@@ -8,43 +8,13 @@ Bidirectional mapping between Markdown and JSON (Schema.org-style) via small, de
* **Declarative Templates:** Define your mappings with concise, easy-to-understand templates. * **Declarative Templates:** Define your mappings with concise, easy-to-understand templates.
* **JSON Schema Validation:** Validate parsed JSON against Schema.org entities using JSON Schema. * **JSON Schema Validation:** Validate parsed JSON against Schema.org entities using JSON Schema.
## How it works
Marka uses a custom template file to enable bidirectional between Markdown and Data. A minimal `marka` schema could look like:
```markdown
# { articleTitle }
{ articleBody }
```
Then it could parse the following markdown:
```markdown
# MyArticle
The article body which could
contain newlines.
```
Into the following data:
```json
{
"articleTitle": "MyArticle",
"articleBody": "The article body which could\ncontain newlines.",
}
```
And reversely if you give it the json it could reproduce the input markdown. If you want to dive into the deeper workings of the template look at [template/README.md](./template/README.md)
## Docker Image (CRUD API) ## Docker Image (CRUD API)
A Docker image is available to transform a specified directory into a CRUD API. This allows you to expose your Markdown-to-JSON mappings as a web service. A Docker image is available to transform a specified directory into a CRUD API. This allows you to expose your Markdown-to-JSON mappings as a web service.
To run the Docker image: To run the Docker image:
```bash ```bash
docker run -p 8080:8080 -v /path/to/your/data:/app/data max/marka-server docker run -p 8080:8080 -v /path/to/your/data:/app/data marka-crud-api
``` ```
*(Replace `/path/to/your/data` with the absolute path to the directory you want to expose.)* *(Replace `/path/to/your/data` with the absolute path to the directory you want to expose.)*

View File

@@ -1,5 +1,5 @@
--- ---
_type: Article @type: Article
author.name: Erin Mckean author.name: Erin Mckean
url: https://dressaday.com/2006/10/20/you-dont-have-to-be-pretty/ url: https://dressaday.com/2006/10/20/you-dont-have-to-be-pretty/
rating: 5 rating: 5

View File

@@ -1,14 +1,15 @@
--- ---
_type: Review @context: https://schema.org
@type: Review
author: author:
_type: Person @type: Person
name: Alice name: Alice
datePublished: 2025-08-01 datePublished: 2025-08-01
itemReviewed: itemReviewed:
_type: Book @type: Book
name: Untitled name: Untitled
author: author:
_type: Person @type: Person
name: Unknown name: Unknown
--- ---

View File

@@ -1,11 +1,12 @@
--- ---
_type: Review @context: https://schema.org
@type: Review
author: author:
_type: Person @type: Person
name: Eve name: Eve
datePublished: 2025-08-15 datePublished: 2025-08-15
itemReviewed: itemReviewed:
_type: Book @type: Book
name: Anonymous Poems name: Anonymous Poems
reviewBody: "Short, haunting, and powerful verses." reviewBody: "Short, haunting, and powerful verses."
reviewRating.ratingValue: 4 reviewRating.ratingValue: 4

View File

@@ -1,14 +1,15 @@
--- ---
_type: Review @context: https://schema.org
@type: Review
author: author:
_type: Person @type: Person
name: Clara name: Clara
datePublished: 2025-08-10 datePublished: 2025-08-10
itemReviewed: itemReviewed:
_type: Book @type: Book
name: 1984 name: 1984
author: author:
_type: Person @type: Person
name: George Orwell name: George Orwell
reviewRating: 5 reviewRating: 5
--- ---

View File

@@ -1,14 +1,15 @@
--- ---
_type: Review @context: https://schema.org
@type: Review
author: author:
_type: Person @type: Person
name: Bob name: Bob
datePublished: 2025-08-05 datePublished: 2025-08-05
itemReviewed: itemReviewed:
_type: Book @type: Book
name: War and Peace name: War and Peace
author: author:
_type: Person @type: Person
name: Leo Tolstoy name: Leo Tolstoy
reviewAspect: reviewAspect:
- Length - Length

View File

@@ -1,9 +1,9 @@
--- ---
_type: Review @type: Review
author: author:
name: Max Richter name: Max Richter
itemReviewed: itemReviewed:
_type: Book @type: Book
author: author:
name: F. Scott Fitzgerald name: F. Scott Fitzgerald
reviewRating: 5 reviewRating: 5

View File

@@ -1,11 +1,12 @@
--- ---
_type: Review @context: https://schema.org
@type: Review
author: author:
_type: Person @type: Person
name: Frank name: Frank
datePublished: 2025-08-01 datePublished: 2025-08-01
itemReviewed: itemReviewed:
_type: Movie @type: Movie
name: Untitled Film name: Untitled Film
--- ---

View File

@@ -1,14 +1,15 @@
--- ---
_type: Review @context: https://schema.org
@type: Review
author: author:
_type: Person @type: Person
name: Grace name: Grace
datePublished: 2025-08-02 datePublished: 2025-08-02
itemReviewed: itemReviewed:
_type: Movie @type: Movie
name: The Room name: The Room
author: author:
_type: Person @type: Person
name: Tommy Wiseau name: Tommy Wiseau
negativeNotes: negativeNotes:
- Awkward dialogue - Awkward dialogue

View File

@@ -1,14 +1,15 @@
--- ---
_type: Review @context: https://schema.org
@type: Review
author: author:
_type: Person @type: Person
name: Jack name: Jack
datePublished: 2025-08-06 datePublished: 2025-08-06
itemReviewed: itemReviewed:
_type: Movie @type: Movie
name: Blade Runner name: Blade Runner
author: author:
_type: Person @type: Person
name: Ridley Scott name: Ridley Scott
reviewRating: 4.5/5 reviewRating: 4.5/5
reviewAspect: reviewAspect:

View File

@@ -1,14 +1,15 @@
--- ---
_type: Review @context: https://schema.org
@type: Review
author: author:
_type: Person @type: Person
name: Henry name: Henry
datePublished: 2025-08-03 datePublished: 2025-08-03
itemReviewed: itemReviewed:
_type: Movie @type: Movie
name: Inception name: Inception
author: author:
_type: Person @type: Person
name: Christopher Nolan name: Christopher Nolan
reviewAspect: reviewAspect:
- Story - Story

View File

@@ -1,5 +1,4 @@
--- ---
_type: Recipe
author.name: Max Richter author.name: Max Richter
--- ---

View File

@@ -1,6 +1,6 @@
--- ---
author.name: Jane Doe author.name: Jane Doe
author._type: Organization author.@type: Organization
recipeCategory: ["Dessert", "Vegan", "Quick"] recipeCategory: ["Dessert", "Vegan", "Quick"]
--- ---

View File

@@ -1,11 +1,11 @@
go 1.24.7 go 1.25.1
use ( use (
./parser ./parser
./playground/wasm
./registry ./registry
./renderer ./renderer
./server ./server
./server-new
./template ./template
./testdata ./testdata
./validator ./validator

View File

@@ -1,8 +1,4 @@
git.max-richter.dev/max/marka/parser v0.0.0-20250819170608-69c2550f448e/go.mod h1:xQK6tsgr9BOoeFw8JxjBwDkVENlOqapmcRkYyf/L+SQ= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
git.max-richter.dev/max/marka/registry v0.0.0-20250819170608-69c2550f448e/go.mod h1:n793S7TENIfgHpZLz0lm0qorM7eCx3zBLby3Fb++hZA=
git.max-richter.dev/max/marka/template v0.0.0-20250819170608-69c2550f448e/go.mod h1:Uxi5xcxtnjopsIZjjMlFaWJGuglB9JNL++FuaSbOf6U=
git.max-richter.dev/max/marka/testdata v0.0.0-20250819195334-b3c01bb43d9a/go.mod h1:88SkY5pTONkgfBy1FT10LoqRC8rt36iF1fk/rupjuJY=
git.max-richter.dev/max/marka/validator v0.0.0-20250819170608-69c2550f448e/go.mod h1:qdGfCFRzsGedmnd77vb7pu/EMx0W0DcQBMEfvNxMYsw=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=

View File

@@ -1,2 +0,0 @@
[tools]
go = "1.24.7"

View File

@@ -20,27 +20,21 @@ func ParseBlock(input string, block template.Block) (any, error) {
case template.CodecHashtags: case template.CodecHashtags:
return Keywords(input, block) return Keywords(input, block)
} }
fmt.Printf("%#v\n", block) return nil, fmt.Errorf("unknown codec: %s", block.Codec)
return nil, fmt.Errorf("unknown codec '%s'", block.Codec)
} }
func Parse(matches []matcher.Block) (any, error) { func Parse(matches []matcher.Block) (any, error) {
var result any var result any
for i, m := range matches { for _, m := range matches {
if m.Block.Path == "@index" { if m.Block.Path == "@index" {
continue continue
} }
input := m.GetContent() input := m.GetContent()
value, err := ParseBlock(input, m.Block) value, err := ParseBlock(input, m.Block)
var blockIdentifier any
blockIdentifier = m.Block.Path
if blockIdentifier == "" {
blockIdentifier = fmt.Sprintf("#%d", i)
}
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse block(%s) -> %w", blockIdentifier, err) return nil, fmt.Errorf("failed to parse block(%s): %w", m.Block.Path, err)
} }
result = utils.SetPathValue(m.Block.Path, value, result) result = utils.SetPathValue(m.Block.Path, value, result)
} }

View File

@@ -1,6 +1,6 @@
module git.max-richter.dev/max/marka/parser module git.max-richter.dev/max/marka/parser
go 1.24.7 go 1.25.1
require ( require (
git.max-richter.dev/max/marka/registry v0.0.0-20250817132016-6db87db32567 git.max-richter.dev/max/marka/registry v0.0.0-20250817132016-6db87db32567

View File

@@ -50,6 +50,7 @@ func MatchBlocksFuzzy(markdown string, templateBlocks []template.Block, maxDist
} }
} }
// Handle the last block
if len(templateBlocks) > 0 { if len(templateBlocks) > 0 {
lastBlock := templateBlocks[len(templateBlocks)-1] lastBlock := templateBlocks[len(templateBlocks)-1]
if lastBlock.Type == template.DataBlock { if lastBlock.Type == template.DataBlock {

View File

@@ -10,21 +10,21 @@ import (
"git.max-richter.dev/max/marka/testdata" "git.max-richter.dev/max/marka/testdata"
) )
func TestMatch_FuzzyFindAll(t *testing.T) { func TestFuzzyFindAll(t *testing.T) {
recipeMd := testdata.Read(t, "baguette/input.md") recipeMd := testdata.Read(t, "baguette/input.md")
tests := []struct { tests := []struct {
Needle string Needle string
Start, End, StartIndex int Start, End, StartIndex int
}{ }{
{StartIndex: 0, Needle: "## Ingredients\n", Start: 90, End: 105}, {StartIndex: 0, Needle: "# Ingredients\n", Start: 77, End: 91},
{StartIndex: 0, Needle: "## Ingrdients\n", Start: 90, End: 105}, {StartIndex: 0, Needle: "# Ingrdients\n", Start: 77, End: 91},
{StartIndex: 0, Needle: "## Inrdients\n", Start: 90, End: 105}, {StartIndex: 0, Needle: "# Inrdients\n", Start: 77, End: 91},
{StartIndex: 0, Needle: "---\n", Start: 0, End: 4}, {StartIndex: 0, Needle: "---\n", Start: 0, End: 4},
{StartIndex: 4, Needle: "---\n", Start: 43, End: 47}, {StartIndex: 4, Needle: "---\n", Start: 29, End: 33},
{StartIndex: 0, Needle: "## Steps\n", Start: 129, End: 138}, {StartIndex: 0, Needle: "# Steps\n", Start: 116, End: 124},
{StartIndex: 0, Needle: "## Stps\n", Start: 129, End: 138}, {StartIndex: 0, Needle: "# Stps\n", Start: 116, End: 124},
{StartIndex: 0, Needle: "## Step\n", Start: 129, End: 138}, {StartIndex: 0, Needle: "# Step\n", Start: 116, End: 124},
} }
for _, test := range tests { for _, test := range tests {
@@ -36,14 +36,13 @@ func TestMatch_FuzzyFindAll(t *testing.T) {
} }
} }
func TestMatch_FuzzyBlockBaguette(t *testing.T) { func TestFuzzyBlockMatch(t *testing.T) {
recipeMd := testdata.Read(t, "baguette/input.md") recipeMd := testdata.Read(t, "baguette/input.md")
schemaMd, err := registry.GetTemplate("Recipe") schemaMd, err := registry.GetTemplate("Recipe")
if err != nil { if err != nil {
t.Errorf("Failed to load template: %s", err.Error()) t.Errorf("Failed to load template: %s", err.Error())
t.FailNow() t.FailNow()
} }
blocks, err := template.CompileTemplate(schemaMd) blocks, err := template.CompileTemplate(schemaMd)
if err != nil { if err != nil {
t.Errorf("Failed to compile template: %s", err.Error()) t.Errorf("Failed to compile template: %s", err.Error())
@@ -52,21 +51,20 @@ func TestMatch_FuzzyBlockBaguette(t *testing.T) {
matches := matcher.MatchBlocksFuzzy(string(recipeMd), blocks, 0.3) matches := matcher.MatchBlocksFuzzy(string(recipeMd), blocks, 0.3)
for _, m := range matches {
fmt.Printf("Content: '%s'->'%q'\n\n", m.Block.Path, m.GetContent())
}
expected := []struct { expected := []struct {
value string value string
}{ }{
{ {
value: "_type: Recipe\nauthor.name: Max Richter", value: "@type: Recipe\nauthor.name: Max Richter",
}, },
{ {
value: "Baguette", value: "Baguette",
}, },
{ {
value: "My favourite baguette recipe", value: "\nMy favourite baguette recipe",
},
{
value: "",
}, },
{ {
value: "- Flour\n- Water\n- Salt", value: "- Flour\n- Water\n- Salt",
@@ -82,12 +80,13 @@ func TestMatch_FuzzyBlockBaguette(t *testing.T) {
t.FailNow() t.FailNow()
} }
if expected[i].value != m.GetContent() { if expected[i].value != m.GetContent() {
t.Errorf("Match %d did not match expected: %q", i, expected[i].value) t.Errorf("Match %d did not match expected: %q", i, m.GetContent())
} }
fmt.Printf("match: %s->%q\n", m.Block.Path, m.GetContent())
} }
} }
func TestMatch_FuzzyBlockSalad(t *testing.T) { func TestFuzzyBlockMatchSalad(t *testing.T) {
recipeMd := testdata.Read(t, "recipe_salad/input.md") recipeMd := testdata.Read(t, "recipe_salad/input.md")
schemaMd, err := registry.GetTemplate("Recipe") schemaMd, err := registry.GetTemplate("Recipe")
if err != nil { if err != nil {
@@ -96,7 +95,7 @@ func TestMatch_FuzzyBlockSalad(t *testing.T) {
} }
blocks, err := template.CompileTemplate(schemaMd) blocks, err := template.CompileTemplate(schemaMd)
if err != nil { if err != nil {
t.Errorf("failed to compile template: %s", err.Error()) t.Errorf("Failed to compile template: %s", err.Error())
t.FailNow() t.FailNow()
} }
@@ -106,11 +105,14 @@ func TestMatch_FuzzyBlockSalad(t *testing.T) {
value string value string
}{ }{
{ {
value: "_type: Recipe\nauthor.name: Alex Chef\ncookTime: PT0M\nimage: https://example.com/salad.jpg\nprepTime: PT10M\nrecipeYield: 2 servings", value: "@type: Recipe\nauthor.name: Alex Chef\ncookTime: PT0M\nimage: https://example.com/salad.jpg\nprepTime: PT10M\nrecipeYield: 2 servings",
}, },
{ {
value: "Simple Salad", value: "Simple Salad",
}, },
{
value: "#healthy #salad",
},
{ {
value: "A quick green salad.", value: "A quick green salad.",
}, },

View File

@@ -15,69 +15,66 @@ import (
func DetectType(markdownContent string) (string, error) { func DetectType(markdownContent string) (string, error) {
defaultSchemaContent, err := registry.GetTemplate("_default") defaultSchemaContent, err := registry.GetTemplate("_default")
if err != nil { if err != nil {
return "", fmt.Errorf("could not get schema -> %w", err) return "", fmt.Errorf("could not get schema: %w", err)
} }
defaultSchema, err := template.CompileTemplate(defaultSchemaContent) defaultSchema, err := template.CompileTemplate(defaultSchemaContent)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to compile template -> %w", err) return "", fmt.Errorf("failed to compile template: %w", err)
} }
fmt.Println("Content:", markdownContent)
blocks := matcher.MatchBlocksFuzzy(markdownContent, defaultSchema, 0.3) blocks := matcher.MatchBlocksFuzzy(markdownContent, defaultSchema, 0.3)
result, err := decoders.Parse(blocks) result, err := decoders.Parse(blocks)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to parse blocks -> %w", err) return "", fmt.Errorf("failed to parse blocks: %w", err)
} }
fmt.Println("Result: ", result)
if result, ok := result.(map[string]any); ok { if result, ok := result.(map[string]any); ok {
if contentType, ok := result["_type"]; ok { if contentType, ok := result["@type"]; ok {
return contentType.(string), nil return contentType.(string), nil
} else {
return "", fmt.Errorf("frontmatter did not contain '@type'")
} }
return "", fmt.Errorf("frontmatter did not contain '_type'") } else {
}
return "", fmt.Errorf("could not parse frontmatter") return "", fmt.Errorf("could not parse frontmatter")
}
} }
func MatchBlocks(markdownContent, templateContent string) ([]matcher.Block, error) { func prepareMarkdown(input string) string {
markdownContent = strings.TrimSuffix(markdownContent, "\n") input = strings.TrimSuffix(input, "\n")
input = strings.ReplaceAll(input, "@type:", `"@type":`)
tpl, err := template.CompileTemplate(templateContent) input = strings.ReplaceAll(input, "@context:", `"@context":`)
if err != nil { return input
return nil, fmt.Errorf("failed to compile template -> %w", err)
}
return matcher.MatchBlocksFuzzy(markdownContent, tpl, 0.3), nil
} }
func ParseFile(markdownContent string) (any, error) { func ParseFile(markdownContent string) (any, error) {
markdownContent = prepareMarkdown(markdownContent)
contentType, err := DetectType(markdownContent) contentType, err := DetectType(markdownContent)
if err != nil { if err != nil {
return nil, fmt.Errorf("could not detect type -> %w", err) return nil, fmt.Errorf("could not detect type: %w", err)
} }
templateContent, err := registry.GetTemplate(contentType) templateContent, err := registry.GetTemplate(contentType)
if err != nil { if err != nil {
return nil, fmt.Errorf("could not get template -> %w", err) return nil, fmt.Errorf("could not get schema: %w", err)
} }
return ParseFileWithTemplate(markdownContent, templateContent) template, err := template.CompileTemplate(templateContent)
}
func ParseFileWithTemplate(markdownContent string, templateContent string) (any, error) {
markdownContent = strings.TrimSuffix(markdownContent, "\n")
tpl, err := template.CompileTemplate(templateContent)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to compile template -> %w", err) return nil, fmt.Errorf("failed to compile template: %w", err)
} }
blocks := matcher.MatchBlocksFuzzy(markdownContent, tpl, 0.3) blocks := matcher.MatchBlocksFuzzy(markdownContent, template, 0.3)
result, err := decoders.Parse(blocks) result, err := decoders.Parse(blocks)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to compile blocks -> %w", err) return nil, fmt.Errorf("failed to parse blocks: %w", err)
} }
return result, nil return result, nil

View File

@@ -9,30 +9,7 @@ import (
"github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp"
) )
func TestParse_DetectType(t *testing.T) { func TestParseRecipe_Golden(t *testing.T) {
recipe := testdata.Read(t, "recipe_salad/input.md")
article := testdata.Read(t, "article_simple/input.md")
recipeType, err := parser.DetectType(string(recipe))
if err != nil {
t.Fatalf("failed to detect recipeType: %v", err)
}
articleType, err := parser.DetectType(string(article))
if err != nil {
t.Fatalf("failed to detect articleType: %v", err)
}
if recipeType != "Recipe" {
t.Errorf("recipeType did not match expected type 'Recipe' -> %s", recipeType)
}
if articleType != "Article" {
t.Errorf("articleType did not match expected type 'Article' -> %s", articleType)
}
}
func TestParse_RecipeSalad(t *testing.T) {
inputContent := testdata.Read(t, "recipe_salad/input.md") inputContent := testdata.Read(t, "recipe_salad/input.md")
output := testdata.Read(t, "recipe_salad/output.json") output := testdata.Read(t, "recipe_salad/output.json")
@@ -51,7 +28,7 @@ func TestParse_RecipeSalad(t *testing.T) {
} }
} }
func TestParse_RecipeNoDescription(t *testing.T) { func TestParseRecipe_NoDescription(t *testing.T) {
inputContent := testdata.Read(t, "recipe_no_description/input.md") inputContent := testdata.Read(t, "recipe_no_description/input.md")
got, err := parser.ParseFile(string(inputContent)) got, err := parser.ParseFile(string(inputContent))
@@ -70,7 +47,7 @@ func TestParse_RecipeNoDescription(t *testing.T) {
} }
} }
func TestParse_Baguette(t *testing.T) { func TestParseRecipe_Baguette(t *testing.T) {
inputContent := testdata.Read(t, "baguette/input.md") inputContent := testdata.Read(t, "baguette/input.md")
got, err := parser.ParseFile(string(inputContent)) got, err := parser.ParseFile(string(inputContent))
@@ -89,7 +66,7 @@ func TestParse_Baguette(t *testing.T) {
} }
} }
func TestParse_Article(t *testing.T) { func TestParseArticle_Simple(t *testing.T) {
inputContent := testdata.Read(t, "article_simple/input.md") inputContent := testdata.Read(t, "article_simple/input.md")
got, err := parser.ParseFile(string(inputContent)) got, err := parser.ParseFile(string(inputContent))

23
playground/.gitignore vendored
View File

@@ -1,23 +0,0 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

View File

@@ -1 +0,0 @@
engine-strict=true

View File

@@ -1,9 +0,0 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Miscellaneous
/static/

View File

@@ -1,16 +0,0 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
],
"tailwindStylesheet": "./src/app.css"
}

View File

@@ -1,38 +0,0 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

View File

@@ -1,42 +0,0 @@
import prettier from 'eslint-config-prettier';
import { fileURLToPath } from 'node:url';
import { includeIgnoreFile } from '@eslint/compat';
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import { defineConfig } from 'eslint/config';
import globals from 'globals';
import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default defineConfig(
includeIgnoreFile(gitignorePath),
{ ignores: ['static/wasm_exec.js'] },
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
prettier,
...svelte.configs.prettier,
{
languageOptions: {
globals: { ...globals.browser, ...globals.node }
},
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off'
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser,
svelteConfig
}
}
}
);

View File

@@ -1,58 +0,0 @@
{
"name": "playground",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint ."
},
"devDependencies": {
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.22.0",
"@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/adapter-static": "^3.0.9",
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0",
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^22",
"eslint": "^9.22.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0",
"globals": "^16.0.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.0.0",
"typescript-eslint": "^8.20.0",
"vite": "^7.0.4"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild"
]
},
"dependencies": {
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.3.4",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.11.3",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.38.3",
"@fsegurai/codemirror-theme-github-light": "^6.2.2",
"codemirror": "^6.0.2",
"codemirror-theme-github": "^1.1.0",
"lucide-svelte": "^0.544.0",
"svelte-codemirror-editor": "^2.0.0"
}
}

2933
playground/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +0,0 @@
@import 'tailwindcss';
.cm-foldGutter + .cm-foldGutter {
display: none !important;
}

View File

@@ -1,30 +0,0 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
class Go {
new(): {
run: (inst: WebAssembly.Instance) => Promise<void>;
importObject: WebAssembly.Imports;
};
}
const marka: {
matchBlocks(s: string, t: string): string;
detectType(markdown: string): string;
parseFile(input: string): string;
parseFileWithTemplate(markdown: string, template: string): string;
listTemplates(): string;
getTemplate(name: string): string;
compileTemplate(source: string): string;
};
}
export {};

View File

@@ -1,11 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,94 +0,0 @@
<script lang="ts">
import CheckCircleIcon from '$lib/icons/CheckCircleIcon.svelte';
import MinusCircleIcon from '$lib/icons/MinusCircleIcon.svelte';
import XCircleIcon from '$lib/icons/XCircleIcon.svelte';
import type { Extension } from '@codemirror/state';
import { githubLight } from '@fsegurai/codemirror-theme-github-light';
import { basicSetup } from 'codemirror';
import type { Snippet } from 'svelte';
import CodeMirror from 'svelte-codemirror-editor';
interface Props {
title: string;
value: string;
placeholder?: string;
readonly?: boolean;
children?: Snippet;
headerActions?: Snippet;
subtitle?: string;
error?: string;
status?: 'success' | 'error' | 'indeterminate';
timing?: number;
pillText?: string;
langExtension?: Extension;
}
let {
title,
error,
value = $bindable(),
placeholder = '',
readonly = false,
children,
headerActions,
subtitle,
status,
timing,
pillText,
langExtension
}: Props = $props();
</script>
<div class="relative flex flex-1 flex-col overflow-hidden border-r border-gray-200 last:border-r-0">
<div class="flex items-center border-b border-gray-200 bg-gray-50/50 px-4 py-3">
{#if status === 'success'}
<CheckCircleIcon class="mr-2 h-5 w-5 text-green-500" />
{:else if status === 'error'}
<XCircleIcon class="mr-2 h-5 w-5 text-red-500" />
{:else if status === 'indeterminate'}
<MinusCircleIcon class="mr-2 h-5 w-5 text-gray-400" />
{/if}
<div class="flex-1">
<h2 class="flex items-center text-sm font-semibold tracking-wide text-gray-900 uppercase">
{title}
{#if pillText}
<span
class="ml-2 inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800"
>
{pillText}
</span>
{/if}
</h2>
{#if subtitle}
<p class="text-xs text-gray-500">{subtitle}</p>
{/if}
</div>
{#if headerActions}
<div class="ml-4">
{@render headerActions()}
</div>
{/if}
{#if timing !== undefined}
<div class="ml-4 text-xs text-gray-500">{timing}ms</div>
{/if}
</div>
{#if error}
<div class="border-b border-gray-200 bg-red-300 p-4 text-xs">
<pre>{error}</pre>
</div>
{/if}
<CodeMirror
bind:value
extensions={[basicSetup, langExtension].filter(Boolean) as Extension[]}
theme={githubLight}
{placeholder}
{readonly}
class="text-sm"
/>
{#if children}
{@render children()}
{/if}
</div>

View File

@@ -1,21 +0,0 @@
<script lang="ts">
import Logo from '$lib/icons/Logo.svelte';
import { GithubIcon } from 'lucide-svelte';
</script>
<header class="sticky top-0 z-10 border-b border-gray-200 bg-white/80 backdrop-blur-sm">
<div class="px-6 py-4">
<div class="flex w-full items-center justify-between">
<div class="flex items-center gap-3">
<Logo />
<div>
<h1 class="text-2xl font-bold text-black">Marka</h1>
<p class="text-sm text-gray-600">Bidirectional Markdown ↔ JSON Parser</p>
</div>
</div>
<a href="https://github.com/jim-fx/marka" target="_blank" rel="noopener noreferrer">
<GithubIcon class="h-6 w-6 text-gray-600 transition-colors duration-200 hover:text-black" />
</a>
</div>
</div>
</header>

View File

@@ -1,197 +0,0 @@
<script lang="ts">
import { json } from '@codemirror/lang-json';
import { markdown } from '@codemirror/lang-markdown';
import {
compileTemplate,
getTemplate,
listTemplates,
parseMarkdown,
parseMarkdownWithTemplate,
wasmReady,
type ParseResultSuccess
} from '../wasm';
import EditorPanel from './EditorPanel.svelte';
let templates = $state([] as string[]);
const DEFAULT_MARKDOWN_VALUE = `---
_type: Recipe
author.name: Max Richter
---
# Baguette
My favourite baguette recipe
## Ingredients
- Flour
- Water
- Salt
## Steps
1. Mix Flour Water and Salt
2. Bake the bread`;
const DEFAULT_TEMPLATE_VALUE = '';
let templateValue = $state(
typeof window !== 'undefined'
? localStorage.getItem('templateValue') || DEFAULT_TEMPLATE_VALUE
: DEFAULT_TEMPLATE_VALUE
);
let markdownValue = $state(
typeof window !== 'undefined'
? localStorage.getItem('markdownValue') || DEFAULT_MARKDOWN_VALUE
: DEFAULT_MARKDOWN_VALUE
);
let jsonOutput = $state('');
let detectedSchemaName = $derived.by(() => {
try {
return JSON.parse(jsonOutput)['_schema'];
} catch {
return undefined;
}
});
let timings = $state<ParseResultSuccess['timings'] | null>(null);
let templateStatus = $state<'success' | 'error' | 'indeterminate' | undefined>(undefined);
let dataStatus = $state<'success' | 'error' | 'indeterminate' | undefined>(undefined);
let templateError = $state<string | undefined>();
$effect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem('templateValue', templateValue);
}
});
$effect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem('markdownValue', markdownValue);
}
});
$effect(() => {
if ($wasmReady) {
try {
templates = listTemplates();
} catch (e) {
console.error(e);
}
}
if (!$wasmReady) {
jsonOutput = 'Loading wasm...';
timings = null;
templateStatus = undefined;
dataStatus = undefined;
return;
}
try {
compileTemplate(templateValue);
const result = templateValue
? parseMarkdownWithTemplate(markdownValue, templateValue)
: parseMarkdown(markdownValue);
if ('error' in result) {
jsonOutput = '';
if (result.error.startsWith('failed to compile template')) {
templateError = result.error.replaceAll(' -> ', '\n ⟶ ');
templateStatus = 'error';
dataStatus = 'indeterminate';
} else {
templateError = undefined;
templateStatus = undefined;
dataStatus = 'error';
}
} else {
templateError = undefined;
jsonOutput = JSON.stringify(result, null, 2);
timings = result.timings;
templateStatus = 'success';
dataStatus = 'success';
}
} catch (e: unknown) {
console.log({ e });
jsonOutput = (e as Error).message;
timings = null;
if (jsonOutput.startsWith('failed to compile template')) {
templateStatus = 'error';
dataStatus = 'indeterminate';
} else {
templateStatus = undefined;
dataStatus = 'error';
}
}
});
function loadTemplate(name: string) {
if (!name) return;
try {
templateValue = getTemplate(name);
} catch (e) {
console.error(e);
}
}
function resetMarkdown() {
markdownValue = DEFAULT_MARKDOWN_VALUE;
}
</script>
<div class="flex flex-1 overflow-hidden">
<div class="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-3">
<EditorPanel
title="Template"
bind:value={templateValue}
placeholder="Enter your Marka template here..."
error={templateError}
status={templateStatus}
timing={timings?.template_compilation}
subtitle="Define your mapping schema"
langExtension={markdown()}
>
{#snippet headerActions()}
<select
onchange={(e) => loadTemplate(e.currentTarget.value)}
class="rounded border border-gray-300 bg-white px-2 py-1 text-xs text-gray-700 shadow-sm focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 focus:outline-none"
>
<option value="">Load a template</option>
{#each templates as template (template)}
<option value={template}>{template}</option>
{/each}
</select>
{/snippet}
</EditorPanel>
<EditorPanel
title="Markdown"
bind:value={markdownValue}
placeholder="Enter your markdown content here..."
timing={timings?.markdown_parsing}
subtitle="Your source content"
langExtension={markdown()}
>
{#snippet headerActions()}
<button
onclick={resetMarkdown}
class="rounded border border-gray-300 bg-white px-2 py-1 text-xs text-gray-700 shadow-sm focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 focus:outline-none"
>
Reset
</button>
{/snippet}
</EditorPanel>
<EditorPanel
title="Data"
value={jsonOutput}
readonly={true}
status={dataStatus}
subtitle="Parsed JSON output"
pillText={!templateValue && detectedSchemaName
? `Detected Template: ${detectedSchemaName}`
: undefined}
langExtension={json()}
/>
</div>
</div>

View File

@@ -1,25 +0,0 @@
<script lang="ts">
// import { CheckCircleIcon, AlertCircleIcon } from "lucide-svelte";
let status = $state<'success' | 'error' | 'idle'>('success');
let message = $state('Template parsed successfully');
</script>
<div class="border-t border-gray-200 bg-gray-50/50 px-6 py-2">
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
{#if status === 'success'}
<!-- <CheckCircleIcon class="h-4 w-4 text-green-600" /> -->
<span class="text-green-700">{message}</span>
{:else if status === 'error'}
<!-- <AlertCircleIcon class="h-4 w-4 text-red-600" /> -->
<span class="text-red-700">{message}</span>
{:else}
<div class="h-4 w-4 rounded-full bg-gray-300"></div>
<span class="text-gray-600">Ready</span>
{/if}
</div>
<div class="text-gray-500">Schema.org validation enabled</div>
</div>
</div>

View File

@@ -1,18 +0,0 @@
<script lang="ts">
let { class: className = '' } = $props();
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class={className}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>

View File

@@ -1,14 +0,0 @@
<script>
import favicon from '$lib/assets/favicon.svg';
</script>
<img src={favicon} alt="logo" width="100%" />
<style>
img {
height: 64px;
width: 64px;
aspect-ratio: 1;
filter: drop-shadow(0px 0px 8px #0002);
}
</style>

View File

@@ -1,14 +0,0 @@
<script lang="ts">
let { class: className = '' } = $props();
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class={className}
>
<path stroke-linecap="round" stroke-linejoin="round" d="M18 12H6" />
</svg>

View File

@@ -1,18 +0,0 @@
<script lang="ts">
let { class: className = '' } = $props();
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class={className}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>

View File

@@ -1 +0,0 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@@ -1,114 +0,0 @@
import { readable } from "svelte/store";
export const wasmReady = readable(false, (set) => {
if (typeof window === "undefined") {
return;
}
const loadWasm = async () => {
const go = new globalThis.Go();
try {
const result = await WebAssembly.instantiateStreaming(
fetch("/main.wasm"),
go.importObject,
);
go.run(result.instance);
set(true);
} catch (error) {
console.error("Error loading wasm module:", error);
}
};
if (document.readyState === "complete") {
loadWasm();
} else {
globalThis.addEventListener("load", loadWasm);
}
});
export type ParseResultSuccess = {
data: unknown;
timings: { [key: string]: number };
};
export type ParseResultError = {
error: string;
};
export type ParseResult = ParseResultSuccess | ParseResultError;
export function parseMarkdown(markdown: string): ParseResult {
if (typeof globalThis.marka?.parseFile !== "function") {
throw new Error("Wasm module not ready");
}
const resultString = globalThis.marka.parseFile(markdown);
return JSON.parse(resultString);
}
export function compileTemplate(templateSource: string) {
if (typeof globalThis.marka?.compileTemplate !== "function") {
throw new Error("Wasm module not ready");
}
const resultString = globalThis.marka.compileTemplate(templateSource);
const result = JSON.parse(resultString);
console.log({ result });
return result;
}
export function matchBlocks(markdown: string, template: string): ParseResult {
if (typeof globalThis.marka?.matchBlocks !== "function") {
throw new Error("Wasm module not ready");
}
const resultString = globalThis.marka.matchBlocks(markdown, template);
return JSON.parse(resultString);
}
export function parseMarkdownWithTemplate(
markdown: string,
template: string,
): ParseResult {
if (typeof globalThis.marka?.parseFileWithTemplate !== "function") {
throw new Error("Wasm module not ready");
}
const resultString = globalThis.marka.parseFileWithTemplate(
markdown,
template,
);
return JSON.parse(resultString);
}
export function listTemplates(): string[] {
if (typeof globalThis.marka?.listTemplates !== "function") {
throw new Error("Wasm module not ready");
}
const resultString = globalThis.marka.listTemplates();
return JSON.parse(resultString);
}
export function getTemplate(name: string): string {
if (typeof globalThis.marka?.getTemplate !== "function") {
throw new Error("Wasm module not ready");
}
return globalThis.marka.getTemplate(name);
}
export function detectType(markdown: string): string | ParseResultError {
if (typeof globalThis.marka?.detectType !== "function") {
throw new Error("Wasm module not ready");
}
const result = globalThis.marka.detectType(markdown);
try {
// If the result is a JSON string with an error, parse and return it
const parsed = JSON.parse(result);
if (parsed.error) {
return parsed;
}
} catch (e) {
// Otherwise, it's a plain string for success
return result;
}
return result;
}

View File

@@ -1,15 +0,0 @@
<script lang="ts">
import { asset } from '$app/paths';
import favicon from '$lib/assets/favicon.svg';
import '../app.css';
let { children } = $props();
</script>
<svelte:head>
<link rel="icon" href={favicon} />
<title>Marka Playground</title>
<script src={asset('wasm_exec.js')}></script>
</svelte:head>
{@render children?.()}

View File

@@ -1 +0,0 @@
export const prerender = true;

View File

@@ -1,33 +0,0 @@
<script lang="ts">
import Header from '$lib/components/Header.svelte';
import Playground from '$lib/components/Playground.svelte';
</script>
<div class="bg-background text-foreground flex min-h-screen flex-col">
<Header />
<div class="flex flex-1 overflow-hidden">
<Playground />
</div>
</div>
<style>
:global(body) {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: white;
}
:global(*) {
box-sizing: border-box;
}
:global(textarea:focus) {
outline: none;
}
:global(pre) {
margin: 0;
font-family:
'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
}
</style>

View File

@@ -1,3 +0,0 @@
<svg width="202" height="202" viewBox="0 0 202 202" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M198 101C198 47.4284 154.572 4 101 4C63.6769 4 31.2771 25.0794 15.0566 55.9824C31.5802 36.8304 58.3585 25 87.5 25C107.135 25 125.868 30.5962 141.081 40.3643C163.17 54.5475 177.915 77.5995 176.999 105.066C176.961 122.231 171.229 138.064 161.595 150.771L159.861 153.057L133.999 112.606L100.778 164.568L65.0068 108.618L42.0176 144.873C57.3037 166.504 84.4388 181 114.5 181C149.395 181 180.331 159.267 193.398 130.6C196.385 121.268 198 111.323 198 101ZM29.0127 106.222C29.2632 118.862 33.0866 130.771 39.5967 141.223L64.9932 101.171L100.778 157.143L134 105.182L160.106 146.016C168.233 134.357 173 120.186 173 104.895H173.002C173.825 79.7876 160.762 58.4662 140.614 44.8486L101 108.688L61.3604 44.7793C41.8587 57.6651 29 79.7788 29 104.895L29.0127 106.222ZM202 101C202 156.781 156.781 202 101 202V198C134.1 198 163.327 181.42 180.831 156.113C164.258 173.559 140.379 185 114.5 185C82.6725 185 53.8784 169.41 37.9658 146.05C30.0365 134.409 25.3017 120.829 25.0137 106.303L25 104.895C25 77.6227 39.3659 53.7077 60.9336 40.3018L62.6338 39.2441L101 101.101L137.249 42.6855C123.002 33.9863 105.671 29 87.5 29C51.3264 29 19.6837 47.7188 7.41992 75.377C5.19019 83.5392 4 92.1306 4 101C4 154.572 47.4284 198 101 198V202C45.2192 202 0 156.781 0 101C0 45.2192 45.2192 0 101 0C156.781 0 202 45.2192 202 101Z" fill="#555"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1,15 +0,0 @@
<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 50.0001C0 28.9476 20.1238 13 43.5 13C53.0351 13 71.5842 16.8812 80 30C80 30 75.9901 41.3368 62.8713 43.5645C49.7525 45.7922 40.5941 30.4457 30.9406 30.4457C21.2871 30.4457 13.3663 38.7833 13.3663 51.9283C13.3663 59.1669 16.0505 65.702 20 71.5C27.6856 82.7837 41.5584 90 57 90C80.3762 90 100 71.3259 100 50.0001C100 77.6145 77.6144 100 50 100C22.3856 100 0 77.6145 0 50.0001Z" fill="url(#paint0_radial_13_17)"/>
<path d="M86.6337 51.9282C86.6337 60.2351 82.1782 70.1022 72.7723 74.505C63.3663 78.9077 57.4257 68.0693 43.3168 64.1089C29.2079 60.1485 19.6134 71.7441 19.6134 71.7441C27.299 83.0277 41.2416 90.5941 56.6832 90.5941C80.0594 90.5941 100 71.3257 100 50C100 22.3856 77.6144 0 50 0C22.3856 0 0 22.3856 0 50C0 28.9475 19.9406 13.3663 43.3168 13.3663C52.852 13.3663 61.9391 16.0842 69.3069 20.8153C80.0015 27.6822 87.0733 38.7896 86.6337 51.9802" fill="url(#paint1_radial_13_17)"/>
<defs>
<radialGradient id="paint0_radial_13_17" cx="0" cy="0" r="1" gradientTransform="matrix(-59 -89 76.5985 -50.3819 82 91)" gradientUnits="userSpaceOnUse">
<stop stop-color="#8E8E8E"/>
<stop offset="1" stop-color="#2C2C2C"/>
</radialGradient>
<radialGradient id="paint1_radial_13_17" cx="0" cy="0" r="1" gradientTransform="matrix(58 89.5 -74.0064 52.2522 26 5.5)" gradientUnits="userSpaceOnUse">
<stop stop-color="#8E8E8E"/>
<stop offset="1" stop-color="#2C2C2C"/>
</radialGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

View File

@@ -1,3 +0,0 @@
# allow crawling everything by default
User-agent: *
Disallow:

View File

@@ -1,553 +0,0 @@
// Copyright 2018 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//
// This file has been modified for use by the TinyGo compiler.
(() => {
// Map multiple JavaScript environments to a single common API,
// preferring web standards over Node.js API.
//
// Environments considered:
// - Browsers
// - Node.js
// - Electron
// - Parcel
if (typeof global !== "undefined") {
// global already exists
} else if (typeof window !== "undefined") {
window.global = window;
} else if (typeof self !== "undefined") {
self.global = self;
} else {
throw new Error("cannot export Go (neither global, window nor self is defined)");
}
if (!global.require && typeof require !== "undefined") {
global.require = require;
}
if (!global.fs && global.require) {
global.fs = require("node:fs");
}
const enosys = () => {
const err = new Error("not implemented");
err.code = "ENOSYS";
return err;
};
if (!global.fs) {
let outputBuf = "";
global.fs = {
constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1 }, // unused
writeSync(fd, buf) {
outputBuf += decoder.decode(buf);
const nl = outputBuf.lastIndexOf("\n");
if (nl != -1) {
console.log(outputBuf.substr(0, nl));
outputBuf = outputBuf.substr(nl + 1);
}
return buf.length;
},
write(fd, buf, offset, length, position, callback) {
if (offset !== 0 || length !== buf.length || position !== null) {
callback(enosys());
return;
}
const n = this.writeSync(fd, buf);
callback(null, n);
},
chmod(path, mode, callback) { callback(enosys()); },
chown(path, uid, gid, callback) { callback(enosys()); },
close(fd, callback) { callback(enosys()); },
fchmod(fd, mode, callback) { callback(enosys()); },
fchown(fd, uid, gid, callback) { callback(enosys()); },
fstat(fd, callback) { callback(enosys()); },
fsync(fd, callback) { callback(null); },
ftruncate(fd, length, callback) { callback(enosys()); },
lchown(path, uid, gid, callback) { callback(enosys()); },
link(path, link, callback) { callback(enosys()); },
lstat(path, callback) { callback(enosys()); },
mkdir(path, perm, callback) { callback(enosys()); },
open(path, flags, mode, callback) { callback(enosys()); },
read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
readdir(path, callback) { callback(enosys()); },
readlink(path, callback) { callback(enosys()); },
rename(from, to, callback) { callback(enosys()); },
rmdir(path, callback) { callback(enosys()); },
stat(path, callback) { callback(enosys()); },
symlink(path, link, callback) { callback(enosys()); },
truncate(path, length, callback) { callback(enosys()); },
unlink(path, callback) { callback(enosys()); },
utimes(path, atime, mtime, callback) { callback(enosys()); },
};
}
if (!global.process) {
global.process = {
getuid() { return -1; },
getgid() { return -1; },
geteuid() { return -1; },
getegid() { return -1; },
getgroups() { throw enosys(); },
pid: -1,
ppid: -1,
umask() { throw enosys(); },
cwd() { throw enosys(); },
chdir() { throw enosys(); },
}
}
if (!global.crypto) {
const nodeCrypto = require("node:crypto");
global.crypto = {
getRandomValues(b) {
nodeCrypto.randomFillSync(b);
},
};
}
if (!global.performance) {
global.performance = {
now() {
const [sec, nsec] = process.hrtime();
return sec * 1000 + nsec / 1000000;
},
};
}
if (!global.TextEncoder) {
global.TextEncoder = require("node:util").TextEncoder;
}
if (!global.TextDecoder) {
global.TextDecoder = require("node:util").TextDecoder;
}
// End of polyfills for common API.
const encoder = new TextEncoder("utf-8");
const decoder = new TextDecoder("utf-8");
let reinterpretBuf = new DataView(new ArrayBuffer(8));
var logLine = [];
const wasmExit = {}; // thrown to exit via proc_exit (not an error)
global.Go = class {
constructor() {
this._callbackTimeouts = new Map();
this._nextCallbackTimeoutID = 1;
const mem = () => {
// The buffer may change when requesting more memory.
return new DataView(this._inst.exports.memory.buffer);
}
const unboxValue = (v_ref) => {
reinterpretBuf.setBigInt64(0, v_ref, true);
const f = reinterpretBuf.getFloat64(0, true);
if (f === 0) {
return undefined;
}
if (!isNaN(f)) {
return f;
}
const id = v_ref & 0xffffffffn;
return this._values[id];
}
const loadValue = (addr) => {
let v_ref = mem().getBigUint64(addr, true);
return unboxValue(v_ref);
}
const boxValue = (v) => {
const nanHead = 0x7FF80000n;
if (typeof v === "number") {
if (isNaN(v)) {
return nanHead << 32n;
}
if (v === 0) {
return (nanHead << 32n) | 1n;
}
reinterpretBuf.setFloat64(0, v, true);
return reinterpretBuf.getBigInt64(0, true);
}
switch (v) {
case undefined:
return 0n;
case null:
return (nanHead << 32n) | 2n;
case true:
return (nanHead << 32n) | 3n;
case false:
return (nanHead << 32n) | 4n;
}
let id = this._ids.get(v);
if (id === undefined) {
id = this._idPool.pop();
if (id === undefined) {
id = BigInt(this._values.length);
}
this._values[id] = v;
this._goRefCounts[id] = 0;
this._ids.set(v, id);
}
this._goRefCounts[id]++;
let typeFlag = 1n;
switch (typeof v) {
case "string":
typeFlag = 2n;
break;
case "symbol":
typeFlag = 3n;
break;
case "function":
typeFlag = 4n;
break;
}
return id | ((nanHead | typeFlag) << 32n);
}
const storeValue = (addr, v) => {
let v_ref = boxValue(v);
mem().setBigUint64(addr, v_ref, true);
}
const loadSlice = (array, len, cap) => {
return new Uint8Array(this._inst.exports.memory.buffer, array, len);
}
const loadSliceOfValues = (array, len, cap) => {
const a = new Array(len);
for (let i = 0; i < len; i++) {
a[i] = loadValue(array + i * 8);
}
return a;
}
const loadString = (ptr, len) => {
return decoder.decode(new DataView(this._inst.exports.memory.buffer, ptr, len));
}
const timeOrigin = Date.now() - performance.now();
this.importObject = {
wasi_snapshot_preview1: {
// https://github.com/WebAssembly/WASI/blob/main/phases/snapshot/docs.md#fd_write
fd_write: function(fd, iovs_ptr, iovs_len, nwritten_ptr) {
let nwritten = 0;
if (fd == 1) {
for (let iovs_i=0; iovs_i<iovs_len;iovs_i++) {
let iov_ptr = iovs_ptr+iovs_i*8; // assuming wasm32
let ptr = mem().getUint32(iov_ptr + 0, true);
let len = mem().getUint32(iov_ptr + 4, true);
nwritten += len;
for (let i=0; i<len; i++) {
let c = mem().getUint8(ptr+i);
if (c == 13) { // CR
// ignore
} else if (c == 10) { // LF
// write line
let line = decoder.decode(new Uint8Array(logLine));
logLine = [];
console.log(line);
} else {
logLine.push(c);
}
}
}
} else {
console.error('invalid file descriptor:', fd);
}
mem().setUint32(nwritten_ptr, nwritten, true);
return 0;
},
fd_close: () => 0, // dummy
fd_fdstat_get: () => 0, // dummy
fd_seek: () => 0, // dummy
proc_exit: (code) => {
this.exited = true;
this.exitCode = code;
this._resolveExitPromise();
throw wasmExit;
},
random_get: (bufPtr, bufLen) => {
crypto.getRandomValues(loadSlice(bufPtr, bufLen));
return 0;
},
},
gojs: {
// func ticks() float64
"runtime.ticks": () => {
return timeOrigin + performance.now();
},
// func sleepTicks(timeout float64)
"runtime.sleepTicks": (timeout) => {
// Do not sleep, only reactivate scheduler after the given timeout.
setTimeout(() => {
if (this.exited) return;
try {
this._inst.exports.go_scheduler();
} catch (e) {
if (e !== wasmExit) throw e;
}
}, timeout);
},
// func finalizeRef(v ref)
"syscall/js.finalizeRef": (v_ref) => {
// Note: TinyGo does not support finalizers so this is only called
// for one specific case, by js.go:jsString. and can/might leak memory.
const id = v_ref & 0xffffffffn;
if (this._goRefCounts?.[id] !== undefined) {
this._goRefCounts[id]--;
if (this._goRefCounts[id] === 0) {
const v = this._values[id];
this._values[id] = null;
this._ids.delete(v);
this._idPool.push(id);
}
} else {
console.error("syscall/js.finalizeRef: unknown id", id);
}
},
// func stringVal(value string) ref
"syscall/js.stringVal": (value_ptr, value_len) => {
value_ptr >>>= 0;
const s = loadString(value_ptr, value_len);
return boxValue(s);
},
// func valueGet(v ref, p string) ref
"syscall/js.valueGet": (v_ref, p_ptr, p_len) => {
let prop = loadString(p_ptr, p_len);
let v = unboxValue(v_ref);
let result = Reflect.get(v, prop);
return boxValue(result);
},
// func valueSet(v ref, p string, x ref)
"syscall/js.valueSet": (v_ref, p_ptr, p_len, x_ref) => {
const v = unboxValue(v_ref);
const p = loadString(p_ptr, p_len);
const x = unboxValue(x_ref);
Reflect.set(v, p, x);
},
// func valueDelete(v ref, p string)
"syscall/js.valueDelete": (v_ref, p_ptr, p_len) => {
const v = unboxValue(v_ref);
const p = loadString(p_ptr, p_len);
Reflect.deleteProperty(v, p);
},
// func valueIndex(v ref, i int) ref
"syscall/js.valueIndex": (v_ref, i) => {
return boxValue(Reflect.get(unboxValue(v_ref), i));
},
// valueSetIndex(v ref, i int, x ref)
"syscall/js.valueSetIndex": (v_ref, i, x_ref) => {
Reflect.set(unboxValue(v_ref), i, unboxValue(x_ref));
},
// func valueCall(v ref, m string, args []ref) (ref, bool)
"syscall/js.valueCall": (ret_addr, v_ref, m_ptr, m_len, args_ptr, args_len, args_cap) => {
const v = unboxValue(v_ref);
const name = loadString(m_ptr, m_len);
const args = loadSliceOfValues(args_ptr, args_len, args_cap);
try {
const m = Reflect.get(v, name);
storeValue(ret_addr, Reflect.apply(m, v, args));
mem().setUint8(ret_addr + 8, 1);
} catch (err) {
storeValue(ret_addr, err);
mem().setUint8(ret_addr + 8, 0);
}
},
// func valueInvoke(v ref, args []ref) (ref, bool)
"syscall/js.valueInvoke": (ret_addr, v_ref, args_ptr, args_len, args_cap) => {
try {
const v = unboxValue(v_ref);
const args = loadSliceOfValues(args_ptr, args_len, args_cap);
storeValue(ret_addr, Reflect.apply(v, undefined, args));
mem().setUint8(ret_addr + 8, 1);
} catch (err) {
storeValue(ret_addr, err);
mem().setUint8(ret_addr + 8, 0);
}
},
// func valueNew(v ref, args []ref) (ref, bool)
"syscall/js.valueNew": (ret_addr, v_ref, args_ptr, args_len, args_cap) => {
const v = unboxValue(v_ref);
const args = loadSliceOfValues(args_ptr, args_len, args_cap);
try {
storeValue(ret_addr, Reflect.construct(v, args));
mem().setUint8(ret_addr + 8, 1);
} catch (err) {
storeValue(ret_addr, err);
mem().setUint8(ret_addr+ 8, 0);
}
},
// func valueLength(v ref) int
"syscall/js.valueLength": (v_ref) => {
return unboxValue(v_ref).length;
},
// valuePrepareString(v ref) (ref, int)
"syscall/js.valuePrepareString": (ret_addr, v_ref) => {
const s = String(unboxValue(v_ref));
const str = encoder.encode(s);
storeValue(ret_addr, str);
mem().setInt32(ret_addr + 8, str.length, true);
},
// valueLoadString(v ref, b []byte)
"syscall/js.valueLoadString": (v_ref, slice_ptr, slice_len, slice_cap) => {
const str = unboxValue(v_ref);
loadSlice(slice_ptr, slice_len, slice_cap).set(str);
},
// func valueInstanceOf(v ref, t ref) bool
"syscall/js.valueInstanceOf": (v_ref, t_ref) => {
return unboxValue(v_ref) instanceof unboxValue(t_ref);
},
// func copyBytesToGo(dst []byte, src ref) (int, bool)
"syscall/js.copyBytesToGo": (ret_addr, dest_addr, dest_len, dest_cap, src_ref) => {
let num_bytes_copied_addr = ret_addr;
let returned_status_addr = ret_addr + 4; // Address of returned boolean status variable
const dst = loadSlice(dest_addr, dest_len);
const src = unboxValue(src_ref);
if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
mem().setUint8(returned_status_addr, 0); // Return "not ok" status
return;
}
const toCopy = src.subarray(0, dst.length);
dst.set(toCopy);
mem().setUint32(num_bytes_copied_addr, toCopy.length, true);
mem().setUint8(returned_status_addr, 1); // Return "ok" status
},
// copyBytesToJS(dst ref, src []byte) (int, bool)
// Originally copied from upstream Go project, then modified:
// https://github.com/golang/go/blob/3f995c3f3b43033013013e6c7ccc93a9b1411ca9/misc/wasm/wasm_exec.js#L404-L416
"syscall/js.copyBytesToJS": (ret_addr, dst_ref, src_addr, src_len, src_cap) => {
let num_bytes_copied_addr = ret_addr;
let returned_status_addr = ret_addr + 4; // Address of returned boolean status variable
const dst = unboxValue(dst_ref);
const src = loadSlice(src_addr, src_len);
if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
mem().setUint8(returned_status_addr, 0); // Return "not ok" status
return;
}
const toCopy = src.subarray(0, dst.length);
dst.set(toCopy);
mem().setUint32(num_bytes_copied_addr, toCopy.length, true);
mem().setUint8(returned_status_addr, 1); // Return "ok" status
},
}
};
// Go 1.20 uses 'env'. Go 1.21 uses 'gojs'.
// For compatibility, we use both as long as Go 1.20 is supported.
this.importObject.env = this.importObject.gojs;
}
async run(instance) {
this._inst = instance;
this._values = [ // JS values that Go currently has references to, indexed by reference id
NaN,
0,
null,
true,
false,
global,
this,
];
this._goRefCounts = []; // number of references that Go has to a JS value, indexed by reference id
this._ids = new Map(); // mapping from JS values to reference ids
this._idPool = []; // unused ids that have been garbage collected
this.exited = false; // whether the Go program has exited
this.exitCode = 0;
if (this._inst.exports._start) {
let exitPromise = new Promise((resolve, reject) => {
this._resolveExitPromise = resolve;
});
// Run program, but catch the wasmExit exception that's thrown
// to return back here.
try {
this._inst.exports._start();
} catch (e) {
if (e !== wasmExit) throw e;
}
await exitPromise;
return this.exitCode;
} else {
this._inst.exports._initialize();
}
}
_resume() {
if (this.exited) {
throw new Error("Go program has already exited");
}
try {
this._inst.exports.resume();
} catch (e) {
if (e !== wasmExit) throw e;
}
if (this.exited) {
this._resolveExitPromise();
}
}
_makeFuncWrapper(id) {
const go = this;
return function () {
const event = { id: id, this: this, args: arguments };
go._pendingEvent = event;
go._resume();
return event.result;
};
}
}
if (
global.require &&
global.require.main === module &&
global.process &&
global.process.versions &&
!global.process.versions.electron
) {
if (process.argv.length != 3) {
console.error("usage: go_js_wasm_exec [wasm binary] [arguments]");
process.exit(1);
}
const go = new Go();
WebAssembly.instantiate(fs.readFileSync(process.argv[2]), go.importObject).then(async (result) => {
let exitCode = await go.run(result.instance);
process.exit(exitCode);
}).catch((err) => {
console.error(err);
process.exit(1);
});
}
})();

View File

@@ -1,23 +0,0 @@
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
import adapter from "@sveltejs/adapter-static";
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter(),
paths: {
base: "/_playground",
relative: true,
},
},
};
export default config;

View File

@@ -1,19 +0,0 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}

View File

@@ -1,14 +0,0 @@
import tailwindcss from "@tailwindcss/vite";
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
optimizeDeps: {
exclude: [
"svelte-codemirror-editor",
"codemirror",
"@codemirror/language",
],
},
});

View File

@@ -1,18 +0,0 @@
#!/usr/bin/env bash
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OUT_DIR="$SCRIPT_DIR/../static"
OUT_WASM="$OUT_DIR/main.wasm"
mkdir -p "$OUT_DIR"
tinygo build -target=wasm \
-no-debug -panic=print -gc=leaking \
-o "$OUT_WASM" "$SCRIPT_DIR"
command -v wasm-opt >/dev/null && wasm-opt -Oz --strip-debug --strip-dwarf --strip-producers \
-o "$OUT_WASM.tmp" "$OUT_WASM" && mv "$OUT_WASM.tmp" "$OUT_WASM"
# command -v wasm-strip >/dev/null && wasm-strip "$OUT_WASM"
cp -f "$(tinygo env TINYGOROOT)/targets/wasm_exec.js" "$OUT_DIR/wasm_exec.js"

View File

@@ -1,3 +0,0 @@
module git.max-richter.dev/max/marka/playground-wasm
go 1.24.7

View File

@@ -1,115 +0,0 @@
//go:build js && wasm
package main
import (
"encoding/json"
"syscall/js"
p "git.max-richter.dev/max/marka/parser"
"git.max-richter.dev/max/marka/registry"
"git.max-richter.dev/max/marka/template"
)
func wrapError(err error) string {
errMap := map[string]any{"error": err.Error()}
errJSON, _ := json.Marshal(errMap)
return string(errJSON)
}
func MatchBlocks(this js.Value, args []js.Value) any {
s := args[0].String()
t := args[1].String()
matched, err := p.MatchBlocks(s, t)
if err != nil {
return wrapError(err)
}
jsonString, _ := json.Marshal(matched)
return string(jsonString)
}
func DetectType(this js.Value, args []js.Value) any {
markdown := args[0].String()
t, err := p.DetectType(markdown)
if err != nil {
return wrapError(err)
}
return t
}
func ParseFile(this js.Value, args []js.Value) any {
markdown := args[0].String()
res, err := p.ParseFile(markdown)
if err != nil {
return wrapError(err)
}
b, err := json.Marshal(res)
if err != nil {
return wrapError(err)
}
return string(b)
}
func ParseFileWithTemplate(this js.Value, args []js.Value) any {
markdown := args[0].String()
template := args[1].String()
res, err := p.ParseFileWithTemplate(markdown, template)
if err != nil {
return wrapError(err)
}
b, err := json.Marshal(res)
if err != nil {
return wrapError(err)
}
return string(b)
}
func ListTemplates(this js.Value, args []js.Value) any {
templates, err := registry.ListTemplates()
if err != nil {
return wrapError(err)
}
b, err := json.Marshal(templates)
if err != nil {
return wrapError(err)
}
return string(b)
}
func GetTemplate(this js.Value, args []js.Value) any {
name := args[0].String()
template, err := registry.GetTemplate(name)
if err != nil {
return wrapError(err)
}
return template
}
func CompileTemplate(this js.Value, args []js.Value) any {
source := args[0].String()
template, err := template.CompileTemplate(source)
if err != nil {
return wrapError(err)
}
b, err := json.Marshal(template)
if err != nil {
return wrapError(err)
}
return string(b)
}
func main() {
marka := js.Global().Get("Object").New()
marka.Set("matchBlocks", js.FuncOf(MatchBlocks))
marka.Set("detectType", js.FuncOf(DetectType))
marka.Set("parseFile", js.FuncOf(ParseFile))
marka.Set("parseFileWithTemplate", js.FuncOf(ParseFileWithTemplate))
marka.Set("listTemplates", js.FuncOf(ListTemplates))
marka.Set("getTemplate", js.FuncOf(GetTemplate))
marka.Set("compileTemplate", js.FuncOf(CompileTemplate))
js.Global().Set("marka", marka)
select {}
}

View File

@@ -1,3 +1,3 @@
module git.max-richter.dev/max/marka/registry module git.max-richter.dev/max/marka/registry
go 1.24.7 go 1.25.1

View File

@@ -4,9 +4,6 @@ package registry
import ( import (
"embed" "embed"
"io" "io"
"io/fs"
"path/filepath"
"strings"
) )
//go:embed templates/*.marka //go:embed templates/*.marka
@@ -26,20 +23,3 @@ func GetTemplate(name string) (string, error) {
return string(templateBytes), nil return string(templateBytes), nil
} }
func ListTemplates() ([]string, error) {
var templateNames []string
err := fs.WalkDir(templates, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && strings.HasSuffix(path, ".marka") {
templateNames = append(templateNames, strings.TrimSuffix(filepath.Base(path), ".marka"))
}
return nil
})
if err != nil {
return nil, err
}
return templateNames, nil
}

View File

@@ -3,18 +3,20 @@
path: . path: .
codec: yaml codec: yaml
fields: fields:
- path: "_schema" - path: "@context"
codec: const
value: https://schema.org
hidden: true
- path: "@schema"
codec: const codec: const
value: Article value: Article
hidden: true hidden: true
- path: "_type" - path: "@type"
codec: const codec: const
value: Article value: Article
- path: image - path: image
- path: about
- path: url
- path: author.name - path: author.name
- path: author._type - path: author.@type
codec: const codec: const
value: Person value: Person
hidden: true hidden: true
@@ -24,13 +26,16 @@
pathAlias: rating pathAlias: rating
- path: reviewRating.bestRating - path: reviewRating.bestRating
codec: const codec: const
value: 5
hidden: true hidden: true
- path: reviewRating.worstRating - path: reviewRating.worstRating
codec: const codec: const
value: 1
hidden: true hidden: true
} }
--- ---
# { headline } # { headline }
{ keywords | hashtags }
{ articleBody } { articleBody }

View File

@@ -3,16 +3,19 @@
path: . path: .
codec: yaml codec: yaml
fields: fields:
- path: "_schema" - path: "@context"
codec: const
value: https://schema.org
hidden: true
- path: "@schema"
codec: const codec: const
value: Recipe value: Recipe
hidden: true hidden: true
- path: "_type" - path: "@type"
codec: const codec: const
value: Recipe value: Recipe
- path: link
- path: image - path: image
- path: author._type - path: author.@type
codec: const codec: const
hidden: true hidden: true
value: Person value: Person
@@ -30,6 +33,7 @@
--- ---
# { name | text } # { name | text }
{ keywords | hashtags }
{ description | text } { description | text }

View File

@@ -3,17 +3,21 @@
path: . path: .
codec: yaml codec: yaml
fields: fields:
- path: "_schema" - path: "@context"
codec: const
value: https://schema.org
hidden: true
- path: "@schema"
codec: const codec: const
value: Review value: Review
hidden: true hidden: true
- path: "_type" - path: "@type"
codec: const codec: const
value: Review value: Review
- path: tmdbId - path: tmdbId
- path: image - path: image
- path: author.name - path: author.name
- path: author._type - path: author.@type
codec: const codec: const
value: Person value: Person
hidden: true hidden: true

View File

@@ -3,6 +3,6 @@
path: . path: .
codec: yaml codec: yaml
fields: fields:
- path: "_type" - path: "@type"
} }
--- ---

View File

@@ -13,7 +13,8 @@ import (
const emptyBlock = "\uE000" const emptyBlock = "\uE000"
func fixRenderedBlock(input string) string { func fixRenderedBlock(input string) string {
input = strings.ReplaceAll(input, "'_type':", "_type:") input = strings.ReplaceAll(input, "'@type':", "@type:")
input = strings.ReplaceAll(input, "'@context':", "@context:")
if len(input) == 0 { if len(input) == 0 {
return emptyBlock return emptyBlock
} }

View File

@@ -1,6 +1,6 @@
module git.max-richter.dev/max/marka/renderer module git.max-richter.dev/max/marka/renderer
go 1.24.7 go 1.25.1
require ( require (
git.max-richter.dev/max/marka/parser v0.0.0-20250819170608-69c2550f448e git.max-richter.dev/max/marka/parser v0.0.0-20250819170608-69c2550f448e

View File

@@ -18,10 +18,10 @@ func RenderFile(rawJSON []byte) ([]byte, error) {
return nil, fmt.Errorf("failed to parse JSON: %w", err) return nil, fmt.Errorf("failed to parse JSON: %w", err)
} }
// 2) extract type from "_type" Property // 2) extract type from "@type" Property
contentType, ok := data["_type"].(string) contentType, ok := data["@type"].(string)
if !ok || contentType == "" { if !ok || contentType == "" {
return nil, fmt.Errorf("JSON does not contain a valid '_type' property") return nil, fmt.Errorf("JSON does not contain a valid '@type' property")
} }
// 3) get the template from the registry // 3) get the template from the registry

View File

@@ -41,10 +41,10 @@ func TestRenderFile_MissingType(t *testing.T) {
rawJSON := []byte(`{"name": "Test"}`) rawJSON := []byte(`{"name": "Test"}`)
_, err := renderer.RenderFile(rawJSON) _, err := renderer.RenderFile(rawJSON)
if err == nil { if err == nil {
t.Fatal("expected error for missing _type, got nil") t.Fatal("expected error for missing @type, got nil")
} }
if !strings.Contains(err.Error(), "JSON does not contain a valid '_type' property") { if !strings.Contains(err.Error(), "JSON does not contain a valid '@type' property") {
t.Errorf("expected missing _type error, got: %v", err) t.Errorf("expected missing @type error, got: %v", err)
} }
} }

29
server-new/.air.toml Normal file
View File

@@ -0,0 +1,29 @@
root = "."
tmp_dir = "tmp"
[build]
# Command to build the application.
cmd = "go build -o ./tmp/marka-server ./cmd/marka-server"
# The binary to run.
bin = "./tmp/marka-server"
# Command to run the application with arguments.
full_bin = "./tmp/marka-server -root=../examples -addr=:8080"
# Watch these file extensions.
include_ext = ["go", "http"]
# Ignore these directories.
exclude_dir = ["tmp"]
# Log file for build errors.
log = "air_errors.log"
[log]
# Show time in logs.
time = true
[misc]
# Delete tmp directory on exit.
clean_on_exit = true

1
server-new/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
tmp/

View File

@@ -0,0 +1,41 @@
package main
import (
"flag"
"log"
"net/http"
"os"
"path/filepath"
"git.max-richter.dev/max/marka/server-new/internal/adapters"
"git.max-richter.dev/max/marka/server-new/internal/handler"
)
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")
}
fsAdapter, err := adapters.NewLocalFsAdapter(absRoot)
must(err)
http.Handle("/", handler.NewHandler(fsAdapter))
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)
}
}

3
server-new/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module git.max-richter.dev/max/marka/server-new
go 1.25.1

View File

@@ -0,0 +1,97 @@
package adapters
import (
"os"
"path/filepath"
)
type LocalFsAdapter struct {
root string
}
func (l LocalFsAdapter) readDir(path string) (FsResponse, error) {
dirInfo, _ := os.Stat(path)
entries, err := os.ReadDir(path)
if err != nil {
return FsResponse{}, err
}
out := make([]FsDirEntry, 0, len(entries))
for _, e := range entries {
info, _ := e.Info()
entryType := "dir"
if !e.IsDir() {
entryType = contentTypeFor(e.Name())
}
out = append(out, FsDirEntry{
Name: e.Name(),
Type: entryType,
ModTime: info.ModTime(),
})
}
return FsResponse{
Dir: &FsDir{
Files: out,
Name: ResponsePath(l.root, path),
ModTime: dirInfo.ModTime(),
},
}, nil
}
func (l LocalFsAdapter) readFile(path string) (FsResponse, error) {
fi, err := os.Stat(path)
if err != nil {
return FsResponse{}, err
}
data, err := os.ReadFile(path)
if err != nil {
return FsResponse{}, err
}
return FsResponse{
File: &FsFile{
Name: ResponsePath(l.root, path),
Type: contentTypeFor(path),
ModTime: fi.ModTime(),
Content: data,
},
}, nil
}
func (l LocalFsAdapter) Read(path string) (FsResponse, error) {
cleanRel, err := SafeRel(l.root, path)
if err != nil {
return FsResponse{}, err
}
target := filepath.Join(l.root, filepath.FromSlash(cleanRel))
fi, err := os.Stat(target)
if err != nil {
if os.IsNotExist(err) {
return FsResponse{}, ErrNotFound
}
return FsResponse{}, err
}
if fi.IsDir() {
return l.readDir(target)
}
return l.readFile(target)
}
func (LocalFsAdapter) Write(path string, content []byte) error {
return nil
}
func NewLocalFsAdapter(root string) (FileAdapter, error) {
return LocalFsAdapter{
root: root,
}, nil
}

View File

@@ -3,8 +3,10 @@ package adapters
import ( import (
"errors" "errors"
"mime" "mime"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
) )
func SafeRel(root, requested string) (string, error) { func SafeRel(root, requested string) (string, error) {
@@ -35,6 +37,20 @@ func ResponsePath(root, full string) string {
return "/" + filepath.ToSlash(rel) return "/" + filepath.ToSlash(rel)
} }
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()
}
var textPlainExtensions = map[string]bool{ var textPlainExtensions = map[string]bool{
".txt": true, ".txt": true,
".log": true, ".log": true,

View File

@@ -0,0 +1,34 @@
// Package adapters are the backend to that connects the marka server to a storage
package adapters
import "time"
type FileAdapter interface {
Read(path string) (FsResponse, error)
Write(path string, content []byte) error
}
type FsFile struct {
Name string `json:"name"`
Type string `json:"type"`
Content []byte `json:"content"`
ModTime time.Time `json:"modTime"`
}
type FsDirEntry struct {
Name string `json:"name"`
Type string `json:"type"`
IsDir bool `json:"isDir,omitempty"`
ModTime time.Time `json:"modTime"`
}
type FsDir struct {
Files []FsDirEntry `json:"files"`
Name string `json:"name"`
ModTime time.Time `json:"modTime"`
}
type FsResponse struct {
Dir *FsDir `json:"dir,omitempty"`
File *FsFile `json:"file,omitempty"`
}

View File

@@ -0,0 +1,104 @@
// Package handler provides the HTTP handler for the marka server
package handler
import (
"errors"
"net/http"
"time"
"git.max-richter.dev/max/marka/parser"
"git.max-richter.dev/max/marka/server-new/internal/adapters"
)
type ResponseItem struct {
Name string `json:"name"`
Content any `json:"content,omitempty"`
Type string `json:"type,omitempty"`
IsDir bool `json:"isDir"`
Size int64 `json:"size,omitempty"`
ModTime time.Time `json:"modTime"`
}
type Handler struct {
adapter adapters.FileAdapter
}
func (h *Handler) get(w http.ResponseWriter, target string) {
fsEntry, err := h.adapter.Read(target)
if err != nil {
writeError(w, 500, err)
return
}
if fsEntry.File != nil {
if fsEntry.File.Content != nil && fsEntry.File.Type == "application/markdown" {
data, err := parser.ParseFile(string(fsEntry.File.Content))
if err != nil {
writeError(w, 500, err)
return
}
res := ResponseItem{
Name: fsEntry.File.Name,
Type: fsEntry.File.Type,
Content: data,
IsDir: false,
Size: int64(len(fsEntry.File.Content)),
ModTime: fsEntry.File.ModTime,
}
writeJSON(w, 200, res)
return
}
res := ResponseItem{
Name: fsEntry.File.Name,
Content: fsEntry.File.Content,
Type: fsEntry.File.Type,
IsDir: false,
Size: int64(len(fsEntry.File.Content)),
ModTime: fsEntry.File.ModTime,
}
writeJSON(w, 200, res)
return
}
if fsEntry.Dir != nil {
res := ResponseItem{
Name: fsEntry.Dir.Name,
Content: fsEntry.Dir.Files,
IsDir: true,
ModTime: fsEntry.Dir.ModTime,
}
writeJSON(w, 200, res)
return
}
}
func (h *Handler) post(w http.ResponseWriter, target string) {
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
reqPath := r.URL.Path
if reqPath == "" {
reqPath = "/"
}
target := cleanURLLike(reqPath)
switch r.Method {
case http.MethodGet:
h.get(w, target)
case http.MethodPost:
h.post(w, target)
default:
writeError(w, http.StatusMethodNotAllowed, errors.New("method not allowed"))
}
}
func NewHandler(adapter adapters.FileAdapter) http.Handler {
return &Handler{
adapter: adapter,
}
}

View File

@@ -1,3 +1,6 @@
# Config file for Air - https://github.com/air-verse/air
# This file is for the marka-server application.
root = "." root = "."
tmp_dir = "tmp" tmp_dir = "tmp"

View File

@@ -1,17 +0,0 @@
FROM golang:1.24.7 AS build
WORKDIR /src
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags="-s -w" \
-o /out/server ./server/cmd/marka-server
FROM scratch
WORKDIR /app
COPY --from=build /out/server /app/server
COPY --from=build /src/server/playground /app/playground
USER 65532:65532
EXPOSE 8080
CMD ["/app/server","-root=/app/data","-addr=:8080","-playground-root=/app/playground"]

View File

@@ -1,15 +0,0 @@
## Marka Server
```json
{
"name": "Recipe",
"modTime": 123123123123,
"content": [
{
"name": "Baguette",
"modTime": 123123123123,
}
]
}
```

View File

@@ -6,61 +6,27 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"git.max-richter.dev/max/marka/server/internal/adapters" "git.max-richter.dev/max/marka/server/internal/handlers"
"git.max-richter.dev/max/marka/server/internal/handler"
) )
type multi []string
func (m *multi) String() string { return strings.Join(*m, ",") }
func (m *multi) Set(v string) error {
*m = append(*m, v)
return nil
}
func main() { func main() {
var roots multi root := flag.String("root", ".", "filesystem root to serve")
flag.Var(&roots, "root", "repeatable; specify multiple -root flags")
addr := flag.String("addr", ":8080", "listen address") addr := flag.String("addr", ":8080", "listen address")
playgroundRoot := flag.String("playground-root", "/app/playground", "path to playground build directory")
flag.Parse() flag.Parse()
if *playgroundRoot != "" { absRoot, err := filepath.Abs(*root)
absPlaygroundRoot, err := filepath.Abs(*playgroundRoot)
must(err) must(err)
info, err := os.Stat(absPlaygroundRoot)
info, err := os.Stat(absRoot)
must(err) must(err)
if !info.IsDir() { if !info.IsDir() {
log.Fatalf("playground-root %s is not a directory", *playgroundRoot) log.Fatal("root is not a directory")
}
log.Printf("serving playground from %s", absPlaygroundRoot)
http.Handle("/_playground/", http.StripPrefix("/_playground/", http.FileServer(http.Dir(absPlaygroundRoot))))
} }
if len(roots) == 0 { http.Handle("/", handlers.NewFile(absRoot))
log.Fatal("at least one -root flag must be specified")
}
absRoots := make([]string, len(roots)) log.Printf("listening on %s, root=%s", *addr, absRoot)
for i, r := range roots {
abs, err := filepath.Abs(r)
must(err)
info, err := os.Stat(abs)
must(err)
if !info.IsDir() {
log.Fatalf("root %s is not a directory", r)
}
absRoots[i] = abs
}
fsAdapter, err := adapters.NewLocalFsAdapter(absRoots)
must(err)
http.Handle("/", handler.NewHandler(fsAdapter))
log.Printf("listening on %s, roots=%s", *addr, strings.Join(absRoots, ", "))
log.Fatal(http.ListenAndServe(*addr, nil)) log.Fatal(http.ListenAndServe(*addr, nil))
} }

View File

@@ -1,13 +1,14 @@
module git.max-richter.dev/max/marka/server module git.max-richter.dev/max/marka/server
go 1.24.7 go 1.25.1
require git.max-richter.dev/max/marka/parser v0.0.0-20251003194139-ab81c980b590 require git.max-richter.dev/max/marka/parser v0.0.0-20250819170608-69c2550f448e
require ( require (
git.max-richter.dev/max/marka/registry v0.0.0-20250817132016-6db87db32567 // indirect git.max-richter.dev/max/marka/registry v0.0.0-20250817132016-6db87db32567 // indirect
git.max-richter.dev/max/marka/renderer v0.0.0-20250819170608-69c2550f448e // indirect git.max-richter.dev/max/marka/renderer v0.0.0-20250819170608-69c2550f448e // indirect
git.max-richter.dev/max/marka/template v0.0.0-20250817132016-6db87db32567 // indirect git.max-richter.dev/max/marka/template v0.0.0-20250817132016-6db87db32567 // indirect
git.max-richter.dev/max/marka/testdata v0.0.0-20250819195334-b3c01bb43d9a // indirect
github.com/agext/levenshtein v1.2.3 // indirect github.com/agext/levenshtein v1.2.3 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
go.yaml.in/yaml/v4 v4.0.0-rc.1 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.1 // indirect

View File

@@ -1,5 +1,5 @@
git.max-richter.dev/max/marka/parser v0.0.0-20251003194139-ab81c980b590 h1:4C3b/KHD7+ru7nunpYIY6snVw+RI1QeVk9XcbzmdVY0= git.max-richter.dev/max/marka/parser v0.0.0-20250819170608-69c2550f448e h1:enZufetD3UoIVTnTNTQSFlr1Ir0jG7wObUAxb6+xwWg=
git.max-richter.dev/max/marka/parser v0.0.0-20251003194139-ab81c980b590/go.mod h1:ykBvG4AW+fNFila0ZO2TlBqsCACYKb0AJaHsAopyRVI= git.max-richter.dev/max/marka/parser v0.0.0-20250819170608-69c2550f448e/go.mod h1:xQK6tsgr9BOoeFw8JxjBwDkVENlOqapmcRkYyf/L+SQ=
git.max-richter.dev/max/marka/registry v0.0.0-20250817132016-6db87db32567 h1:oe7Xb8dE43S8mRla5hfEqagMnvhvEVHsvRlzl2v540w= git.max-richter.dev/max/marka/registry v0.0.0-20250817132016-6db87db32567 h1:oe7Xb8dE43S8mRla5hfEqagMnvhvEVHsvRlzl2v540w=
git.max-richter.dev/max/marka/registry v0.0.0-20250817132016-6db87db32567/go.mod h1:qGWl42P8mgEktfor/IjQp0aS9SqmpeIlhSuVTlUOXLQ= git.max-richter.dev/max/marka/registry v0.0.0-20250817132016-6db87db32567/go.mod h1:qGWl42P8mgEktfor/IjQp0aS9SqmpeIlhSuVTlUOXLQ=
git.max-richter.dev/max/marka/renderer v0.0.0-20250819170608-69c2550f448e h1:9Eg81l8YMTXWZC3xlZ5L/NJRuK26bksrVtEHyCTV4sM= git.max-richter.dev/max/marka/renderer v0.0.0-20250819170608-69c2550f448e h1:9Eg81l8YMTXWZC3xlZ5L/NJRuK26bksrVtEHyCTV4sM=

View File

@@ -1,266 +0,0 @@
package adapters
import (
"errors"
"os"
"path/filepath"
"strings"
"sync"
"time"
"git.max-richter.dev/max/marka/parser"
)
type LocalFsAdapter struct {
roots []string
cache map[string]*Entry
mu sync.RWMutex
}
func (l *LocalFsAdapter) readDir(path string, root string) (*Entry, error) {
dirInfo, err := os.Stat(path)
if err != nil {
return nil, err
}
entries, err := os.ReadDir(path)
if err != nil {
return nil, err
}
out := make([]*Entry, 0, len(entries))
for _, e := range entries {
info, _ := e.Info()
entryType := "dir"
if !e.IsDir() {
entryType = "file"
}
var content any
var mime string
var size int64
if !e.IsDir() {
mime = contentTypeFor(e.Name())
if info != nil {
size = info.Size()
}
if mime == "application/markdown" {
entryPath := filepath.Join(path, e.Name())
fileContent, err := os.ReadFile(entryPath)
if err == nil {
parsedContent, err := parser.ParseFile(string(fileContent))
if err == nil {
content = parsedContent
}
}
}
}
childPath, err := filepath.Rel(root, filepath.Join(path, e.Name()))
if err != nil {
return nil, err
}
responseChildPath := "/" + filepath.ToSlash(filepath.Join(filepath.Base(root), childPath))
out = append(out, &Entry{
Name: e.Name(),
Path: responseChildPath,
Type: entryType,
ModTime: info.ModTime(),
MIME: mime,
Size: size,
Content: content,
})
}
relPath, err := filepath.Rel(root, path)
if err != nil {
return nil, err
}
if relPath == "." {
relPath = ""
}
responsePath := "/" + filepath.ToSlash(filepath.Join(filepath.Base(root), relPath))
return &Entry{
Type: "dir",
Name: filepath.Base(responsePath),
Path: responsePath,
ModTime: dirInfo.ModTime(),
Content: out,
}, nil
}
func (l *LocalFsAdapter) readFile(path string, root string) (*Entry, error) {
fi, err := os.Stat(path)
if err != nil {
return nil, err
}
relPath, err := filepath.Rel(root, path)
if err != nil {
return nil, err
}
responsePath := "/" + filepath.ToSlash(filepath.Join(filepath.Base(root), relPath))
mime := contentTypeFor(path)
var content any
fileContent, err := os.ReadFile(path)
if err != nil {
return nil, err
}
if mime == "application/markdown" {
parsedContent, err := parser.ParseFile(string(fileContent))
if err == nil {
content = parsedContent
} else {
// Fallback to raw content on parsing error
content = fileContent
}
} else {
content = fileContent
}
return &Entry{
Type: "file",
Name: fi.Name(),
Path: responsePath,
ModTime: fi.ModTime(),
MIME: mime,
Size: fi.Size(),
Content: content,
}, nil
}
func (l *LocalFsAdapter) Read(path string) (*Entry, error) {
if path == "/" {
var latestModTime time.Time
for _, r := range l.roots {
info, err := os.Stat(r)
if err != nil {
continue
}
if info.ModTime().After(latestModTime) {
latestModTime = info.ModTime()
}
}
l.mu.RLock()
cached, found := l.cache[path]
l.mu.RUnlock()
if found && !latestModTime.After(cached.ModTime) {
return cached, nil
}
entries := make([]*Entry, 0, len(l.roots))
for _, r := range l.roots {
info, err := os.Stat(r)
if err != nil {
continue
}
name := filepath.Base(r)
entries = append(entries, &Entry{
Name: name,
Path: "/" + name,
Type: "dir",
ModTime: info.ModTime(),
})
}
entry := &Entry{
Type: "dir",
Name: "/",
Path: "/",
ModTime: latestModTime,
Content: entries,
}
l.mu.Lock()
l.cache[path] = entry
l.mu.Unlock()
return entry, nil
}
pathParts := strings.Split(strings.Trim(path, "/"), "/")
if len(pathParts) == 0 || pathParts[0] == "" {
return nil, ErrNotFound
}
rootIdentifier := pathParts[0]
var targetRoot string
for _, r := range l.roots {
if filepath.Base(r) == rootIdentifier {
targetRoot = r
break
}
}
if targetRoot == "" {
return nil, ErrNotFound
}
subPath := filepath.Join(pathParts[1:]...)
target := filepath.Join(targetRoot, subPath)
absTarget, err := filepath.Abs(target)
if err != nil {
return nil, err
}
absRoot, err := filepath.Abs(targetRoot)
if err != nil {
return nil, err
}
if !strings.HasPrefix(absTarget, absRoot) {
return nil, errors.New("path escapes root")
}
fi, err := os.Stat(target)
if err != nil {
if os.IsNotExist(err) {
return nil, ErrNotFound
}
return nil, err
}
l.mu.RLock()
cached, found := l.cache[path]
l.mu.RUnlock()
if found && !fi.ModTime().After(cached.ModTime) {
return cached, nil
}
var newEntry *Entry
if fi.IsDir() {
newEntry, err = l.readDir(target, targetRoot)
} else {
newEntry, err = l.readFile(target, targetRoot)
}
if err != nil {
return nil, err
}
l.mu.Lock()
l.cache[path] = newEntry
l.mu.Unlock()
return newEntry, nil
}
func (l *LocalFsAdapter) Write(path string, content []byte) error {
return nil
}
func NewLocalFsAdapter(roots []string) (FileAdapter, error) {
return &LocalFsAdapter{
roots: roots,
cache: make(map[string]*Entry),
}, nil
}

View File

@@ -1,28 +0,0 @@
// Package adapters are the backend to that connects the marka server to a storage
package adapters
import "time"
// Entry represents a file or directory in the filesystem.
type Entry struct {
Type string `json:"type"` // "file" | "dir"
Name string `json:"name"` // base name
Path string `json:"path"` // full path or virtual path
ModTime time.Time `json:"modTime"` // last modified
// File-only (optional)
MIME string `json:"mime,omitempty"` // e.g. "text/markdown"
Size int64 `json:"size,omitempty"` // bytes
// Content:
// - markdown file: any (parsed AST / arbitrary JSON)
// - directory: []*Entry (children)
// - other files: omitted
Content any `json:"content,omitempty"`
}
// FileAdapter is the interface for accessing the file system.
type FileAdapter interface {
Read(path string) (*Entry, error)
Write(path string, content []byte) error
}

View File

@@ -0,0 +1,33 @@
package fsx
import (
"mime"
"path/filepath"
"strings"
)
var textPlainExtensions = map[string]bool{
".txt": true,
".log": true,
".json": true,
".yaml": true,
".yml": true,
".toml": true,
".xml": true,
".csv": true,
}
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
}
if textPlainExtensions[ext] {
return "text/plain; charset=utf-8"
}
return "application/octet-stream"
}

View File

@@ -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.SplitSeq(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 after, ok := strings.CutPrefix(s, "/"); ok {
s = after
}
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)
}

View File

@@ -1,72 +0,0 @@
// Package handler provides the HTTP handler for the marka server
package handler
import (
"errors"
"net/http"
"git.max-richter.dev/max/marka/server/internal/adapters"
)
type Handler struct {
adapter adapters.FileAdapter
}
func (h *Handler) get(w http.ResponseWriter, target string) {
entry, err := h.adapter.Read(target)
if err != nil {
if errors.Is(err, adapters.ErrNotFound) {
writeError(w, http.StatusNotFound, err)
return
}
writeError(w, http.StatusInternalServerError, err)
return
}
if entry.Type == "file" {
// For non-markdown files, content is []byte, write it directly
if contentBytes, ok := entry.Content.([]byte); ok {
w.Header().Set("Content-Type", entry.MIME)
w.Write(contentBytes)
return
}
// For markdown files, content is parsed, return the whole Entry as JSON
writeJSON(w, http.StatusOK, entry)
return
}
// For directories, return the whole Entry as JSON
if entry.Type == "dir" {
writeJSON(w, http.StatusOK, entry)
return
}
writeError(w, http.StatusInternalServerError, errors.New("unknown entry type"))
}
func (h *Handler) post(w http.ResponseWriter, target string) {
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
reqPath := r.URL.Path
if reqPath == "" {
reqPath = "/"
}
target := cleanURLLike(reqPath)
switch r.Method {
case http.MethodGet:
h.get(w, target)
case http.MethodPost:
h.post(w, target)
default:
writeError(w, http.StatusMethodNotAllowed, errors.New("method not allowed"))
}
}
func NewHandler(adapter adapters.FileAdapter) http.Handler {
return &Handler{
adapter: adapter,
}
}

View File

@@ -0,0 +1,38 @@
// Package handlers provides HTTP handlers for the file system.
package handlers
import (
"errors"
"net/http"
"path/filepath"
"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"))
}
}

View File

@@ -0,0 +1,93 @@
package handlers
import (
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"time"
"git.max-richter.dev/max/marka/server/internal/fsx"
"git.max-richter.dev/max/marka/server/internal/httpx"
)
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() {
h.handleDir(w, r, target)
} else {
h.handleFile(w, r, target, fi)
}
}
func (h *File) handleDir(w http.ResponseWriter, r *http.Request, dirPath string) {
entries, err := os.ReadDir(dirPath)
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,omitempty"`
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() // As in original, ignore error to get partial info
entryPath := filepath.Join(dirPath, e.Name())
var content any
if !e.IsDir() && fsx.ContentTypeFor(e.Name()) == "application/markdown" {
content, _ = parseMarkdownFile(entryPath) // As in original, ignore error
}
out = append(out, item{
Name: e.Name(),
Path: fsx.ResponsePath(h.root, entryPath),
IsDir: e.IsDir(),
Content: content,
Size: sizeOrZero(info),
ModTime: modTimeOrZero(info),
})
}
httpx.WriteJSON(w, http.StatusOK, out)
}
func (h *File) handleFile(w http.ResponseWriter, r *http.Request, target string, fi os.FileInfo) {
contentType := fsx.ContentTypeFor(target)
if contentType == "application/markdown" {
res, err := parseMarkdownFile(target)
if err != nil {
httpx.WriteError(w, http.StatusInternalServerError, fmt.Errorf("failed to parse markdown: %w", err))
return
}
httpx.WriteJSON(w, http.StatusOK, res)
return
}
f, err := os.Open(target)
if err != nil {
httpx.WriteError(w, http.StatusInternalServerError, err)
return
}
defer f.Close()
w.Header().Set("Content-Type", contentType)
http.ServeContent(w, r, filepath.Base(target), fi.ModTime(), f)
}

View File

@@ -0,0 +1,30 @@
package handlers
import (
"os"
"time"
"git.max-richter.dev/max/marka/parser"
)
func parseMarkdownFile(path string) (any, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return parser.ParseFile(string(data))
}
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()
}

View File

@@ -0,0 +1,47 @@
package handlers
import (
"io"
"net/http"
"os"
"path/filepath"
"git.max-richter.dev/max/marka/server/internal/httpx"
)
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
}
tmpPath := target + ".tmp~"
f, err := os.Create(tmpPath)
if err != nil {
httpx.WriteError(w, http.StatusInternalServerError, err)
return
}
_, copyErr := io.Copy(f, r.Body)
closeErr := f.Close()
if copyErr != nil {
os.Remove(tmpPath)
httpx.WriteError(w, http.StatusInternalServerError, copyErr)
return
}
if closeErr != nil {
os.Remove(tmpPath)
httpx.WriteError(w, http.StatusInternalServerError, closeErr)
return
}
if err := os.Rename(tmpPath, target); err != nil {
os.Remove(tmpPath)
httpx.WriteError(w, http.StatusInternalServerError, err)
return
}
w.Header().Set("Location", r.URL.Path)
w.WriteHeader(http.StatusCreated)
}

View File

@@ -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()})
}

View File

@@ -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)
}

View File

@@ -1 +0,0 @@
export const env={}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
img.svelte-6uiykz{height:64px;width:64px;aspect-ratio:1;filter:drop-shadow(0px 0px 8px #0002)}.codemirror-wrapper.svelte-3fyul5 .cm-focused{outline:none}.scm-waiting.svelte-3fyul5{position:relative}.scm-waiting__loading.svelte-3fyul5{position:absolute;inset:0;background-color:#ffffff80}.scm-loading.svelte-3fyul5{display:flex;align-items:center;justify-content:center}.scm-loading__spinner.svelte-3fyul5{width:1rem;height:1rem;border-radius:100%;border:solid 2px #000;border-top-color:transparent;margin-right:.75rem;animation:svelte-3fyul5-spin 1s linear infinite}.scm-loading__text.svelte-3fyul5{font-family:sans-serif}.scm-pre.svelte-3fyul5{font-size:.85rem;font-family:monospace;tab-size:2;-moz-tab-size:2;resize:none;pointer-events:none;-webkit-user-select:none;user-select:none;overflow:auto}@keyframes svelte-3fyul5-spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;background:#fff}*{box-sizing:border-box}textarea:focus{outline:none}pre{margin:0;font-family:SF Mono,Monaco,Cascadia Code,Roboto Mono,Consolas,Courier New,monospace}

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More