, hvis
+ # starttag tømmer linjen før den — så første linje er tom, og
+ # sammenligningen så på ingenting.
+ lines = text.split("\n")
+ while lines and not lines[0].strip():
+ lines.pop(0)
+ if lines and lines[0].lstrip("# ").strip() == title.strip():
+ lines = lines[1:]
+ while lines and not lines[0].strip():
+ lines.pop(0)
+ text = "\n".join(lines)
+
+ name = safe_name(title, f"note-{i}")
+ used[name] = used.get(name, 0) + 1
+ path = os.path.join(out_dir, name if used[name] == 1 else f"{name} ({used[name]})") + ".md"
+
+ with open(path, "w", encoding="utf-8") as f:
+ f.write(f"# {title}\n\n{text}\n")
+
+ # Billeder og bilag. En note fra Noter er ofte fuld af fotografier, og en
+ # note, der kommer over som ord alene, er ikke den note.
+ count = osascript(
+ f'tell application "Notes" to return count of attachments of note id "{note_id}"'
+ )
+ if count.isdigit() and int(count) > 0:
+ os.makedirs(attachments_dir, exist_ok=True)
+ with open(path, "a", encoding="utf-8") as f:
+ for j in range(1, int(count) + 1):
+ a_name = osascript(
+ f'tell application "Notes" to return name of attachment {j} '
+ f'of note id "{note_id}"'
+ ) or f"bilag-{i}-{j}"
+ a_file = f"{i}-{j}-" + safe_name(a_name, f"bilag-{i}-{j}")
+ target = os.path.join(attachments_dir, a_file)
+ # Nogle vedhæftninger — links til websider, kort — har ingen
+ # fil bag sig og fejler her. De springes over.
+ osascript(
+ f'tell application "Notes" to save attachment {j} '
+ f'of note id "{note_id}" in POSIX file "{target}"'
+ )
+ if os.path.exists(target):
+ f.write(f"\n})\n")
+
+ if i % 25 == 0:
+ print(f" {i} / {total}")
+
+ print(f"\nFærdig. {total} noter ligger i:\n {out_dir}\n")
+ print("Pak dem og importér zip'en under Indstillinger → Data:")
+ print(f' cd "{os.path.dirname(out_dir)}" && zip -r "{os.path.basename(out_dir)}.zip" "{os.path.basename(out_dir)}"')
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tools/apple-notes-til-markdown.sh b/tools/apple-notes-til-markdown.sh
deleted file mode 100755
index b63335d..0000000
--- a/tools/apple-notes-til-markdown.sh
+++ /dev/null
@@ -1,139 +0,0 @@
-#!/bin/bash
-#
-# Apple Noter → en mappe med Markdown-filer.
-#
-# Apple har ingen eksport, der er værd at bruge: "Eksportér som PDF" er et billede
-# af en note, ikke noten. Det her læser Notes.app gennem AppleScript, som er den
-# eneste understøttede vej ind, og skriver én .md-fil pr. note.
-#
-# Det kører på din egen maskine og sender ingenting nogen steder. Resultatet er en
-# mappe, du kan kigge igennem, før du beslutter dig — og et zip af den mappe er
-# præcis det, Verdande importerer.
-#
-# ./apple-notes-til-markdown.sh ~/Desktop/noter # alle
-# ./apple-notes-til-markdown.sh ~/Desktop/noter 10 # de første ti
-# cd ~/Desktop && zip -r noter.zip noter
-# → Indstillinger → Data → Importér noter
-#
-# Første gang spørger macOS, om Terminal må styre Noter. Det skal den.
-
-set -euo pipefail
-
-ud="${1:-$HOME/Desktop/apple-noter}"
-graense="${2:-0}" # 0 betyder alle
-mkdir -p "$ud"
-
-echo "Læser Noter. Første gang kan det tage et minut, før macOS svarer."
-
-# Én note ad gangen frem for at bygge én kæmpe streng: AppleScript bliver meget
-# langsomt på lange strenge, og på et bibliotek af nogen størrelse ser det ud som
-# om den er gået i stå.
-antal=$(osascript -e 'tell application "Notes" to return count of notes')
-echo "Fandt $antal noter."
-
-# En prøvekørsel først er en god idé: konverteringen taber Apple Noters
-# formatering, og det er billigere at opdage på ti noter end på tolv hundrede.
-if [ "$graense" -gt 0 ] && [ "$graense" -lt "$antal" ]; then
- antal="$graense"
- echo "Tager de første $antal."
-fi
-
-i=1
-while [ "$i" -le "$antal" ]; do
- titel=$(osascript -e "tell application \"Notes\" to return name of note $i" 2>/dev/null || echo "")
- krop=$(osascript -e "tell application \"Notes\" to return body of note $i" 2>/dev/null || echo "")
-
- if [ -z "$titel" ]; then
- titel="uden titel $i"
- fi
-
- # Tegn, der ikke kan være i et filnavn på de tre systemer, folk bruger.
- fil=$(printf '%s' "$titel" | tr '/\\:*?"<>|' '-')
- sti="$ud/$fil.md"
- n=2
- while [ -e "$sti" ]; do
- sti="$ud/$fil ($n).md"
- n=$((n + 1))
- done
-
- # Noter er HTML indeni. textutil følger med macOS og kan lave det om til ren
- # tekst; overskrifter og fed går tabt, men ordene og linjerne overlever, og det
- # er dem, man kommer efter. Titlen sættes som første linje, fordi det er sådan
- # Verdande navngiver en note.
- #
- # To ting, en prøvekørsel på ti noter afslørede med det samme:
- #
- # `-inputencoding UTF-8` — uden den gætter textutil på inddataets kodning og
- # gætter forkert, så "Ansøgning" kommer ud som "Ansøgning". Det er den slags,
- # der ser ud som en enkelt underlig note, indtil man opdager, at det er alle
- # noter med et dansk bogstav i.
- #
- # Og kroppen af en Apple-note begynder med notens titel. Skriver man en
- # overskrift ovenover, står titlen to gange i hver eneste note.
- tekst=$(printf '%s' "$krop" \
- | textutil -stdin -stdout -format html -inputencoding UTF-8 -convert txt -encoding UTF-8 2>/dev/null \
- || true)
-
- # Apples lister kommer ud som tabulator, tegn, tabulator — "\t•\tPulp Fiction".
- # Det er en liste for et menneske og ingenting for en Markdown-læser, så en
- # huskeliste ville ankomme som en klump linjer. Punkttegnene bliver til "- " og
- # tallene til "1. ", som er det, alt andet i verden læser som en liste.
- #
- # Perl og ikke sed: punkttegnet er flerbyte, og BSD sed hakkede det midt over,
- # så hver linje begyndte med to halve tegn. -CSD siger, at ind- og uddata er
- # UTF-8, hvilket er hele forskellen.
- tekst=$(printf '%s' "$tekst" | perl -CSD -pe '
- s/^\t+(\d+)[.)]\t*/$1. /; # nummereret liste
- s/^\t+[^\t]{1,3}\t+/- /; # punkt- eller stregliste
- ')
-
- foerste=$(printf '%s' "$tekst" | head -1)
- if [ "$foerste" = "$titel" ]; then
- tekst=$(printf '%s' "$tekst" | tail -n +2)
- fi
-
- {
- printf '# %s\n' "$titel"
- printf '%s' "$tekst"
- } > "$sti"
-
- # Billeder og bilag.
- #
- # En note fra Apple Noter er ofte fuld af fotografier, scanninger og indsatte
- # skærmbilleder, og en note, der kommer over som ord alene, er ikke den note —
- # den er et referat af den. AppleScript kan gemme hver vedhæftning som en fil;
- # de lægges i en undermappe, og der skrives en henvisning til hver i bunden af
- # noten, så Verdande kan hægte dem på ved importen.
- bilag=$(osascript -e "tell application \"Notes\" to return count of attachments of note $i" 2>/dev/null || echo 0)
- if [ "${bilag:-0}" -gt 0 ]; then
- mkdir -p "$ud/vedhaeftninger"
- j=1
- while [ "$j" -le "$bilag" ]; do
- navn=$(osascript -e "tell application \"Notes\" to return name of attachment $j of note $i" 2>/dev/null || echo "")
- [ -z "$navn" ] && navn="bilag-$i-$j"
- # Samme rensning som titlen: et skråstreg i et filnavn laver en mappe.
- navn=$(printf '%s' "$navn" | tr '/\\:*?"<>|' '-')
- mål="$ud/vedhaeftninger/$i-$j-$navn"
-
- # `save` skriver filen. Nogle vedhæftninger — links til websider, kort —
- # har ingen fil bag sig og fejler her; de springes over frem for at
- # stoppe hele kørslen.
- if osascript -e "tell application \"Notes\" to save attachment $j of note $i in POSIX file \"$mål\"" >/dev/null 2>&1; then
- printf '\n\n' "$(basename "$mål")" >> "$sti"
- fi
- j=$((j + 1))
- done
- fi
-
- if [ $((i % 25)) -eq 0 ]; then
- echo " $i / $antal"
- fi
- i=$((i + 1))
-done
-
-echo
-echo "Færdig. $antal noter ligger i:"
-echo " $ud"
-echo
-echo "Kig dem igennem, og pak dem så:"
-echo " cd \"$(dirname "$ud")\" && zip -r \"$(basename "$ud").zip\" \"$(basename "$ud")\""
diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js
index 6708c22..d330b91 100644
--- a/web/e2e/smoke.spec.js
+++ b/web/e2e/smoke.spec.js
@@ -2258,3 +2258,57 @@ test('en opgave viser de noter, der nævner den, og linket åbner noten', async
expect(trouble).toEqual([]);
});
+
+test('en kodeblok ser ud som en terminal og bliver farvet', async ({ page }) => {
+ const trouble = watchForTrouble(page);
+ await page.goto('/noter');
+
+ // Skrevet ind som Markdown, sådan som importen fra Apple Noter leverer den.
+ const id = await page.evaluate(async () => {
+ const r = await fetch('/api/v1/notes', {
+ method: 'POST',
+ credentials: 'include',
+ headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
+ body: JSON.stringify({
+ body: '# Terminalprøven\n\nHer er udskriften:\n\n```bash\nkw@shell:~$ ls -la\n# en kommentar\necho "hej"\n```\n\nog resten.'
+ })
+ });
+ return (await r.json()).id;
+ });
+ await page.goto('/noter?note=' + id);
+
+ const ed = page.getByRole('textbox', { name: 'Notens tekst' });
+ const pre = ed.locator('pre');
+ await expect(pre).toBeVisible();
+
+ // Mørk flade uanset tema: en terminal er sort, også midt på dagen.
+ const look = await pre.evaluate((el) => {
+ const s = getComputedStyle(el);
+ return { bg: s.backgroundColor, font: s.fontFamily };
+ });
+ expect(look.bg).toBe('rgb(20, 24, 27)');
+ expect(look.font.toLowerCase()).toMatch(/mono/);
+
+ // Og farvet: prompten, kommentaren og strengen skal kunne skelnes.
+ await expect(pre.locator('.tok-prompt')).toBeVisible();
+ await expect(pre.locator('.tok-comment')).toContainText('en kommentar');
+ await expect(pre.locator('.tok-string')).toContainText('hej');
+
+ // Teksten omkring er ikke blevet til kode.
+ await expect(ed.locator('h1')).toHaveText('Terminalprøven');
+ await expect(ed).toContainText('og resten.');
+
+ // Og blokken overlever turen tilbage til Markdown: den skal komme ud som en
+ // hegnet blok med sit sprog, ikke som en klump linjer.
+ const body = await page.evaluate(async (noteId) => {
+ const r = await fetch('/api/v1/notes/' + noteId, {
+ credentials: 'include',
+ headers: { 'Sec-Fetch-Site': 'same-origin' }
+ });
+ return (await r.json()).body;
+ }, id);
+ expect(body).toContain('```bash');
+ expect(body).toContain('kw@shell:~$ ls -la');
+
+ expect(trouble).toEqual([]);
+});
diff --git a/web/src/lib/components/NoteEditor.svelte b/web/src/lib/components/NoteEditor.svelte
index fd32b95..e363cc3 100644
--- a/web/src/lib/components/NoteEditor.svelte
+++ b/web/src/lib/components/NoteEditor.svelte
@@ -14,6 +14,7 @@
* is the file to change.
*/
import { markdownToHtml, htmlToMarkdown } from '$lib/richtext.js';
+ import { highlight, guessLanguage } from '$lib/highlight.js';
import { t } from '$lib/i18n.svelte.js';
import { app } from '$lib/stores.svelte.js';
import { colorVar } from '$lib/colors.js';
@@ -40,6 +41,7 @@
editor.innerHTML = note.body.trim() ? markdownToHtml(note.body) : '
';
loadedId = note.id;
active = {};
+ colourCode();
});
const SYNTAX = [
@@ -102,6 +104,32 @@
}
}
+ /**
+ * Farver kodeblokkene.
+ *
+ * Kun dem, markøren ikke står i. At farve blokken, mens nogen skriver i den,
+ * betyder at bygge dens indhold om ved hvert tastetryk og sætte markøren
+ * tilbage bagefter — det er den slags, der virker i ni tilfælde ud af ti og
+ * flytter markøren midt i en sætning i det tiende. Blokken er mørk og
+ * monospace hele tiden; farverne kommer, når man forlader den, hvilket er når
+ * man læser den.
+ */
+ function colourCode() {
+ if (!editor) return;
+ const inside = currentBlock();
+ for (const pre of editor.querySelectorAll('pre')) {
+ if (pre === inside || pre.contains(inside)) continue;
+ const code = pre.textContent ?? '';
+ const lang = pre.getAttribute('data-lang') || guessLanguage(code);
+ if (lang) pre.setAttribute('data-lang', lang);
+ const painted = highlight(code, lang);
+ // Kun hvis der er noget at ændre: at skrive innerHTML uændret ville
+ // stadig rive noden op og lægge den tilbage, og det ses som et blink.
+ const target = pre.querySelector('code') ?? pre;
+ if (target.innerHTML !== painted) target.innerHTML = painted;
+ }
+ }
+
function changed() {
if (!editor) return;
onchange?.(htmlToMarkdown(editor));
@@ -320,11 +348,15 @@
aria-label={t('notes.body')}
spellcheck="true"
oninput={changed}
- onblur={onsave}
+ onblur={() => {
+ colourCode();
+ onsave?.();
+ }}
onkeyup={() => {
readState();
readSuggestions();
}}
+ onclick={colourCode}
onmouseup={readState}
{onkeydown}
{onpaste}
@@ -521,6 +553,9 @@
}
.page {
+ /* Et token eller en URL er ét ord, der er bredere end skærmen. Uden det her
+ får siden en vandret rullebjælke, og teksten forsvinder ud til højre. */
+ overflow-wrap: anywhere;
flex: 1;
min-height: 50vh;
padding: var(--s3) 0;
@@ -572,10 +607,84 @@
color: var(--ink-muted);
}
+ /* En kodeblok ser ud som en terminal, fordi det er hvad den er.
+ Monospace er altid kode i praksis — Apple Noters "Monotype" er stort set kun
+ brugt til kommandoer og udskrifter — og en mørk flade siger det på afstand,
+ uden at man skal læse et ord. Farverne er de samme i lys og mørk tilstand:
+ en terminal er sort, også midt på dagen. */
.page :global(pre) {
- margin: 0 0 var(--s2);
+ margin: 0 0 var(--s3);
+ padding: var(--s3);
+ border-radius: var(--radius);
+ background: #14181b;
+ color: #d7dde3;
font-family: var(--mono, ui-monospace, monospace);
+ font-size: 0.8125rem;
+ line-height: 1.5;
white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ position: relative;
+ }
+
+ /* Sproget i hjørnet, når blokken siger hvad den er. */
+ .page :global(pre[data-lang]:not([data-lang=''])::after) {
+ content: attr(data-lang);
+ position: absolute;
+ top: 4px;
+ right: 8px;
+ font-size: 0.625rem;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: #5a656e;
+ }
+
+ .page :global(pre code) {
+ font: inherit;
+ color: inherit;
+ }
+
+ .page :global(.tok-comment) {
+ color: #6b7a85;
+ font-style: italic;
+ }
+
+ .page :global(.tok-string) {
+ color: #9ecb8a;
+ }
+
+ .page :global(.tok-number) {
+ color: #d9a25f;
+ }
+
+ .page :global(.tok-keyword) {
+ color: #7fb3e8;
+ }
+
+ .page :global(.tok-key) {
+ color: #7fb3e8;
+ }
+
+ .page :global(.tok-bool) {
+ color: #c48fd6;
+ }
+
+ .page :global(.tok-variable) {
+ color: #d9a25f;
+ }
+
+ .page :global(.tok-flag) {
+ color: #c48fd6;
+ }
+
+ .page :global(.tok-punct) {
+ color: #8a949c;
+ }
+
+ /* Prompten i en terminaludskrift: det første øjet finder, når man leder efter
+ hvor en kommando begynder. */
+ .page :global(.tok-prompt) {
+ color: #6fbf8f;
+ font-weight: 600;
}
.page :global(code) {
diff --git a/web/src/lib/highlight.js b/web/src/lib/highlight.js
new file mode 100644
index 0000000..0ef55be
--- /dev/null
+++ b/web/src/lib/highlight.js
@@ -0,0 +1,118 @@
+/**
+ * Farver til en kodeblok.
+ *
+ * Ikke et bibliotek: et fuldt syntaksbibliotek er hundredvis af kilobytes for at
+ * gøre nogle ord blå i en note. Det her er et lille sæt regler pr. sprog, der
+ * rammer det, øjet leder efter — strenge, tal, kommentarer, nøgleord — og som er
+ * ligeglad med resten. En farve for lidt er ingenting; en forkert er heller ikke
+ * meget, når teksten stadig står der, som den blev skrevet.
+ *
+ * Reglerne prøves i rækkefølge, og den første, der rammer, vinder. Strenge og
+ * kommentarer står derfor først: et nøgleord inde i en streng er ikke et nøgleord.
+ */
+
+const COMMON = {
+ string: /^("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)/,
+ number: /^\b\d+(\.\d+)?\b/
+};
+
+const LANGUAGES = {
+ yaml: [
+ ['comment', /^#[^\n]*/],
+ ['string', COMMON.string],
+ // Nøglen, som er det, man scanner efter i en YAML-fil.
+ ['key', /^[ \t]*[-\w.]+(?=\s*:)/],
+ ['punct', /^[:\-|>]/],
+ ['number', COMMON.number],
+ ['bool', /^\b(true|false|null|yes|no|on|off)\b/i]
+ ],
+ json: [
+ ['string', COMMON.string],
+ ['punct', /^[{}[\],:]/],
+ ['number', COMMON.number],
+ ['bool', /^\b(true|false|null)\b/]
+ ],
+ bash: [
+ ['comment', /^#[^\n]*/],
+ ['string', COMMON.string],
+ // Prompten er ikke kode, men den er det første øjet finder i en udskrift.
+ ['prompt', /^\S+@\S+:[^$#]*[$#]/],
+ ['variable', /^\$\{?\w+\}?/],
+ ['keyword', /^\b(if|then|else|fi|for|in|do|done|while|case|esac|function|return|export|local|sudo)\b/],
+ ['flag', /^\s-{1,2}[\w-]+/],
+ ['number', COMMON.number]
+ ],
+ go: [
+ ['comment', /^\/\/[^\n]*/],
+ ['string', COMMON.string],
+ ['keyword', /^\b(func|package|import|var|const|type|struct|interface|if|else|for|range|return|defer|go|chan|select|switch|case|default|map|nil|true|false)\b/],
+ ['number', COMMON.number]
+ ],
+ js: [
+ ['comment', /^\/\/[^\n]*/],
+ ['string', COMMON.string],
+ ['keyword', /^\b(const|let|var|function|return|if|else|for|while|import|export|from|class|new|await|async|try|catch|throw|typeof|null|undefined|true|false)\b/],
+ ['number', COMMON.number]
+ ],
+ sql: [
+ ['comment', /^--[^\n]*/],
+ ['string', COMMON.string],
+ ['keyword', /^\b(select|from|where|insert|into|values|update|set|delete|create|table|index|join|left|inner|on|and|or|not|null|primary|key|references|order|by|group|limit)\b/i],
+ ['number', COMMON.number]
+ ]
+};
+
+// Det, folk skriver, mod det, filen hedder.
+const ALIASES = {
+ sh: 'bash', shell: 'bash', zsh: 'bash', console: 'bash', terminal: 'bash',
+ yml: 'yaml', javascript: 'js', ts: 'js', typescript: 'js', golang: 'go'
+};
+
+const escape = (s) =>
+ s.replace(/&/g, '&').replace(//g, '>');
+
+/**
+ * Gætter sproget, når blokken ikke siger det.
+ *
+ * Med vilje forsigtigt: at gætte forkert farver de forkerte ord, og en
+ * terminaludskrift uden farver er stadig læselig. Kun mønstre, der er svære at
+ * forveksle, tæller med.
+ */
+export function guessLanguage(code) {
+ if (/^\s*[{[]/.test(code) && /["}\]]\s*$/.test(code.trim())) return 'json';
+ if (/^\s*\S+@\S+:.*[$#]/m.test(code)) return 'bash';
+ if (/^\s*(package|func)\s+\w/m.test(code)) return 'go';
+ if (/^\s*[-\w.]+:\s*(\S|$)/m.test(code) && !/[;{}]/.test(code)) return 'yaml';
+ if (/^\s*(SELECT|INSERT|UPDATE|CREATE TABLE)\b/im.test(code)) return 'sql';
+ return '';
+}
+
+/** Kode til HTML med spans. Ukendt sprog giver teksten uændret, blot undsluppet. */
+export function highlight(code, language) {
+ const rules = LANGUAGES[ALIASES[language] ?? language];
+ if (!rules) return escape(code);
+
+ let out = '';
+ let rest = code;
+ let guard = 0;
+
+ while (rest && guard++ < 20000) {
+ let matched = false;
+ for (const [kind, re] of rules) {
+ const m = re.exec(rest);
+ if (!m || !m[0]) continue;
+ out += `
${escape(m[0])}`;
+ rest = rest.slice(m[0].length);
+ matched = true;
+ break;
+ }
+ if (!matched) {
+ // Ét tegn frem. Langsommere end at springe til næste interessante sted og
+ // umuligt at få til at gå i stå, hvilket er den rigtige byttehandel i noget,
+ // der kører på hvert tastetryk i en note, nogen sidder og skriver i.
+ out += escape(rest[0]);
+ rest = rest.slice(1);
+ }
+ }
+ return out + escape(rest);
+}
diff --git a/web/src/lib/richtext.js b/web/src/lib/richtext.js
index 5e21a0b..f444c89 100644
--- a/web/src/lib/richtext.js
+++ b/web/src/lib/richtext.js
@@ -59,6 +59,7 @@ export function markdownToHtml(markdown) {
const lines = (markdown ?? '').split('\n');
const out = [];
let list = null;
+ let fence = null; // sproget, mens vi er inde i en ```-blok
const closeList = () => {
if (list) {
@@ -68,6 +69,25 @@ export function markdownToHtml(markdown) {
};
for (const line of lines) {
+ // Hegnede blokke først: alt indeni er tekst, ikke Markdown, og en linje der
+ // begynder med - inde i et shell-script er ikke et punkt i en liste.
+ const fenced = /^```(\w*)\s*$/.exec(line);
+ if (fence !== null) {
+ if (fenced) {
+ out.push('');
+ fence = null;
+ } else {
+ out.push(escapeHtml(line) + '\n');
+ }
+ continue;
+ }
+ if (fenced) {
+ closeList();
+ fence = fenced[1] ?? '';
+ out.push(`
`);
+ continue;
+ }
+
const bullet = /^[-*+]\s+(.*)$/.exec(line);
const numbered = /^\d+\.\s+(.*)$/.exec(line);
@@ -96,6 +116,7 @@ export function markdownToHtml(markdown) {
out.push(`${line.trim() === '' ? '
' : inlineToHtml(line)}
`);
}
closeList();
+ if (fence !== null) out.push('
');
return out.join('');
}
@@ -176,6 +197,19 @@ export function htmlToMarkdown(root) {
lines.push(inlineToMarkdown(child));
}
break;
+ case 'PRE': {
+ // Back out as a fence, with the language it came in with. The old
+ // four-space form is still read on the way in; it is not written
+ // out any more, because a fence survives being pasted somewhere
+ // else and an indent does not.
+ const lang = child.getAttribute('data-lang') ?? '';
+ lines.push('```' + lang);
+ for (const l of (child.textContent ?? '').replace(/\n$/, '').split('\n')) {
+ lines.push(l);
+ }
+ lines.push('```');
+ break;
+ }
default: {
const block = BLOCKS.find((b) => b.tag === child.tagName.toLowerCase());
const text = inlineToMarkdown(child);
diff --git a/web/src/routes/noter/+page.svelte b/web/src/routes/noter/+page.svelte
index c236d38..1e90501 100644
--- a/web/src/routes/noter/+page.svelte
+++ b/web/src/routes/noter/+page.svelte
@@ -289,6 +289,12 @@