This commit is contained in:
Max Richter
2025-09-26 12:42:06 +02:00
parent deae5acac8
commit ae5cd8481a
17 changed files with 649 additions and 233 deletions

View File

@@ -1,5 +1,11 @@
<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 { basicSetup } from 'codemirror';
import type { Snippet } from 'svelte';
import CodeMirror from 'svelte-codemirror-editor';
interface Props {
title: string;
@@ -9,9 +15,10 @@
children?: Snippet;
headerActions?: Snippet;
subtitle?: string;
status?: 'success' | 'error';
status?: 'success' | 'error' | 'indeterminate';
timing?: number;
pillText?: string;
langExtension?: Extension;
}
let {
@@ -24,48 +31,27 @@
subtitle,
status,
timing,
pillText
pillText,
langExtension
}: Props = $props();
</script>
<div class="flex h-full flex-col border-r border-gray-200 last:border-r-0">
<div class="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'}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="mr-2 h-5 w-5 text-green-500"
>
<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>
<CheckCircleIcon class="mr-2 h-5 w-5 text-green-500" />
{:else if status === 'error'}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="mr-2 h-5 w-5 text-red-500"
>
<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>
<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="text-sm font-semibold tracking-wide text-gray-900 uppercase flex items-center">
<h2 class="flex items-center text-sm font-semibold uppercase tracking-wide text-gray-900">
{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">
<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}
@@ -83,22 +69,17 @@
<div class="ml-4 text-xs text-gray-500">{timing}ms</div>
{/if}
</div>
<div class="group relative flex-1">
{#if readonly}
<div class="absolute inset-0 overflow-auto p-4">
<pre
class="font-mono text-sm leading-relaxed whitespace-pre-wrap text-gray-800">{value}</pre>
</div>
{:else}
<textarea
<div class="group relative flex flex-1 flex-col overflow-hidden">
<div class="flex-1 overflow-auto">
<CodeMirror
bind:value
extensions={[basicSetup, langExtension].filter(Boolean) as Extension[]}
{placeholder}
class="absolute inset-0 h-full w-full resize-none border-0 bg-transparent p-4 font-mono text-sm leading-relaxed text-gray-800 transition-colors outline-none placeholder:text-gray-400 focus:bg-gray-50/30"
spellcheck="false"
></textarea>
{/if}
{readonly}
class="text-sm"
/>
</div>
<!-- Subtle hover effect -->
<div
class="pointer-events-none absolute inset-0 bg-black opacity-0 transition-opacity duration-200 group-hover:opacity-5"
></div>

View File

@@ -1,26 +1,21 @@
<script lang="ts">
// import { CodeIcon, ArrowRightIcon } from "lucide-svelte";
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="container mx-auto px-6 py-4">
<div class="flex items-center justify-between">
<div class="container px-6 py-4">
<div class="flex w-full items-center justify-between">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-black p-2">
<!-- <CodeIcon class="h-5 w-5 text-white" /> -->
</div>
<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>
<div class="hidden items-center gap-2 text-sm text-gray-500 md:flex">
<span>Template</span>
<!-- <ArrowRightIcon class="w-4 h-4" /> -->
<span>Markdown</span>
<!-- <ArrowRightIcon class="w-4 h-4" /> -->
<span>JSON</span>
</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,18 +1,20 @@
<script lang="ts">
import type { ParseResult } from '../wasm';
import { json } from '@codemirror/lang-json';
import { markdown } from '@codemirror/lang-markdown';
import {
getTemplate,
listTemplates,
matchBlocks,
parseMarkdown,
parseMarkdownWithTemplate,
wasmReady
wasmReady,
type ParseResultSuccess
} from '../wasm';
import EditorPanel from './EditorPanel.svelte';
let templates = $state([] as string[]);
let templateValue = $state('');
let markdownValue = $state(`---
const DEFAULT_MARKDOWN_VALUE = `---
_type: Recipe
author.name: Max Richter
---
@@ -28,20 +30,45 @@ My favourite baguette recipe
## Steps
1. Mix Flour Water and Salt
2. Bake the bread`);
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 {
const parsed = JSON.parse(jsonOutput);
return parsed['_schema'];
} catch (err) {
return JSON.parse(jsonOutput)['_schema'];
} catch {
return undefined;
}
});
let timings = $state<ParseResult['timings'] | null>(null);
let status = $state<'success' | 'error' | undefined>(undefined);
let timings = $state<ParseResultSuccess['timings'] | null>(null);
let templateStatus = $state<'success' | 'error' | 'indeterminate' | undefined>(undefined);
let dataStatus = $state<'success' | 'error' | 'indeterminate' | undefined>(undefined);
$effect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem('templateValue', templateValue);
}
});
$effect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem('markdownValue', markdownValue);
}
});
$effect(() => {
if ($wasmReady) {
@@ -54,20 +81,40 @@ My favourite baguette recipe
if (!$wasmReady) {
jsonOutput = 'Loading wasm...';
timings = null;
status = undefined;
templateStatus = undefined;
dataStatus = undefined;
return;
}
try {
const result = templateValue
? parseMarkdownWithTemplate(markdownValue, templateValue)
: parseMarkdown(markdownValue);
jsonOutput = JSON.stringify(result.data, null, 2);
timings = result.timings;
status = 'success';
if ('error' in result) {
jsonOutput = result.error;
if (result.error.startsWith('failed to compile template')) {
templateStatus = 'error';
dataStatus = 'indeterminate';
} else {
templateStatus = undefined;
dataStatus = 'error';
}
} else {
jsonOutput = JSON.stringify(result.data, null, 2);
timings = result.timings;
templateStatus = 'success';
dataStatus = 'success';
}
} catch (e: unknown) {
jsonOutput = (e as Error).message;
timings = null;
status = 'error';
if (jsonOutput.startsWith('failed to compile template')) {
templateStatus = 'error';
dataStatus = 'indeterminate';
} else {
templateStatus = undefined;
dataStatus = 'error';
}
}
});
@@ -79,47 +126,64 @@ My favourite baguette recipe
console.error(e);
}
}
function resetMarkdown() {
markdownValue = DEFAULT_MARKDOWN_VALUE;
}
</script>
<div class="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-3">
<EditorPanel
title="Template"
bind:value={templateValue}
placeholder="Enter your Marka template here..."
{status}
timing={timings?.template_compilation}
subtitle="Define your mapping schema"
>
{#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:outline-none focus:ring-1 focus:ring-indigo-500"
>
<option value="">Load a template</option>
{#each templates as template (template)}
<option value={template}>{template}</option>
{/each}
</select>
{/snippet}
</EditorPanel>
<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..."
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:outline-none focus:ring-1 focus:ring-indigo-500"
>
<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..."
{status}
timing={timings?.markdown_parsing}
subtitle="Your source content"
/>
<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:outline-none focus:ring-1 focus:ring-indigo-500"
>
Reset
</button>
{/snippet}
</EditorPanel>
<EditorPanel
title="Data"
value={jsonOutput}
readonly={true}
{status}
subtitle="Parsed JSON output"
pillText={!templateValue && detectedSchemaName
? `Detected Template: ${detectedSchemaName}`
: undefined}
/>
<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

@@ -0,0 +1,18 @@
<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

@@ -0,0 +1,10 @@
<img src="/logo.svg" alt="logo" width="100%" />
<style>
img {
height: 64px;
width: 64px;
aspect-ratio: 1;
filter: drop-shadow(0px 0px 8px #0002);
}
</style>

View File

@@ -0,0 +1,14 @@
<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

@@ -0,0 +1,18 @@
<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,74 +1,99 @@
import { readable } from 'svelte/store';
import { readable } from "svelte/store";
declare global {
interface Window {
Go: {
new (): {
run: (inst: WebAssembly.Instance) => Promise<void>;
importObject: WebAssembly.Imports;
};
};
markaParseFile: (input: string) => string;
markaParseFileWithTemplate: (markdown: string, template: string) => string;
markaListTemplates: () => string;
markaGetTemplate: (name: string) => string;
}
interface Window {
Go: {
new(): {
run: (inst: WebAssembly.Instance) => Promise<void>;
importObject: WebAssembly.Imports;
};
};
markaMatchBlocks: (input: string) => unknown;
markaParseFile: (input: string) => string;
markaParseFileWithTemplate: (markdown: string, template: string) => string;
markaListTemplates: () => string;
markaGetTemplate: (name: string) => string;
}
}
export const wasmReady = readable(false, (set) => {
if (typeof window === 'undefined') {
return;
}
if (typeof window === "undefined") {
return;
}
const loadWasm = async () => {
const go = new window.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);
}
};
const loadWasm = async () => {
const go = new window.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 {
window.addEventListener('load', loadWasm);
}
if (document.readyState === "complete") {
loadWasm();
} else {
window.addEventListener("load", loadWasm);
}
});
export interface ParseResult {
data: unknown;
timings: { [key: string]: number };
}
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 window.markaParseFile !== 'function') {
throw new Error('Wasm module not ready');
}
const result = window.markaParseFile(markdown);
return JSON.parse(result);
if (typeof window.markaParseFile !== "function") {
throw new Error("Wasm module not ready");
}
const result = window.markaParseFile(markdown);
if (result.error) return result;
return JSON.parse(result);
}
export function parseMarkdownWithTemplate(markdown: string, template: string): ParseResult {
if (typeof window.markaParseFileWithTemplate !== 'function') {
throw new Error('Wasm module not ready');
}
const result = window.markaParseFileWithTemplate(markdown, template);
return JSON.parse(result);
export function matchBlocks(markdown: string): ParseResult {
if (typeof window.markaMatchBlocks !== "function") {
throw new Error("Wasm module not ready");
}
const result = window.markaMatchBlocks(markdown) as ParseResult;
if (result.error) return result;
return JSON.parse(result);
}
export function parseMarkdownWithTemplate(
markdown: string,
template: string,
): ParseResult {
if (typeof window.markaParseFileWithTemplate !== "function") {
throw new Error("Wasm module not ready");
}
const result = window.markaParseFileWithTemplate(markdown, template);
if (result.error) return result;
return JSON.parse(result);
}
export function listTemplates(): string[] {
if (typeof window.markaListTemplates !== 'function') {
throw new Error('Wasm module not ready');
}
const result = window.markaListTemplates();
return JSON.parse(result);
if (typeof window.markaListTemplates !== "function") {
throw new Error("Wasm module not ready");
}
const result = window.markaListTemplates();
return JSON.parse(result);
}
export function getTemplate(name: string): string {
if (typeof window.markaGetTemplate !== 'function') {
throw new Error('Wasm module not ready');
}
return window.markaGetTemplate(name);
if (typeof window.markaGetTemplate !== "function") {
throw new Error("Wasm module not ready");
}
return window.markaGetTemplate(name);
}

View File

@@ -1,12 +1,13 @@
<script lang="ts">
import '../app.css';
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="/wasm_exec.js"></script>
</svelte:head>

View File

@@ -5,7 +5,9 @@
<div class="bg-background text-foreground flex min-h-screen flex-col">
<Header />
<Playground />
<div class="flex flex-1 overflow-hidden">
<Playground />
</div>
</div>
<style>