feat(notes): formateringen overlever, og monospace bliver til en terminal

Konverteringen læser nu Apple Noters HTML frem for at flade den ud med textutil.
Overskrifter, fed, kursiv, understreget, lister og monospace kommer med. En note er
ikke kun sine ord — et referat med overskrifter er ikke det samme referat som en
klump — og ti prøvenoter viste hvad det kostede.

Skrevet om til Python, fordi HTML skal læses og ikke gættes med regulære udtryk.

Den alvorligste fejl, prøvekørslen fandt: navn og krop blev hentet i hvert sit
kald, og Noter sorterer listen efter ændringsdato. Flyttede noget sig imellem de
to kald, fik en note én titel og en andens indhold. På ti noter er det synligt; på
tolv hundrede ville det være ubemærket rod. Der hentes nu ved id, og navn og krop
kommer i samme kald.

Sammenhængende monospace-linjer bliver til én blok. Hver linje i Noter er pakket
ind i sin egen div, hvis starttag tømmer den forrige — så mellem to tt-linjer kom
der altid en tom flush forbi, og hver linje i en terminaludskrift blev sin egen
blok.

Og i editoren ser en kodeblok ud som en terminal: mørk flade, monospace, sproget i
hjørnet, farver på strenge, tal, kommentarer, nøgleord og prompten. Monospace ER
kode i praksis, og en mørk flade siger det på afstand.

Farverne lægges på de blokke, markøren IKKE står i. At farve mens nogen skriver
betyder at bygge indholdet om ved hvert tastetryk og sætte markøren tilbage
bagefter — det virker ni gange ud af ti og flytter markøren midt i en sætning den
tiende.

Et API-token er ét ord, der er bredere end skærmen. Uden min-width: 0 skubbede en
sådan note hele listen bredere og klemte editoren sammen — på en note, man ikke
engang havde åben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-19 20:31:10 +02:00
co-authored by Claude Opus 5
parent 08fc355bf9
commit 43ad60a84a
7 changed files with 633 additions and 141 deletions
+308
View File
@@ -0,0 +1,308 @@
#!/usr/bin/env python3
"""Apple Noter → Markdown, med formateringen i behold.
Den første udgave af det her brugte `textutil -convert txt`, som er hurtigt at
skrive og smider alt væk: overskrifter, fed, monospace, lister. Ti prøvenoter
viste hvad det koster — en note er ikke kun sine ord, den er også hvordan de er
sat op, og et referat med overskrifter er ikke det samme referat som en klump.
Apple Noter er HTML indeni, og HTML kan læses. Kortet er lille, fordi Noter selv
bruger et lille sæt:
<h1> <h2> <h3> overskrifter → # ## ###
<b> <i> <u> fremhævning → ** * <u>
<tt> monospace → ```-blok
<ul><li> punktliste → -
<ol><li> nummereret → 1.
<div> en linje → linjeskift
<br> en tom linje → tom linje
Kører på din egen maskine og sender ingenting nogen steder.
./apple-notes-til-markdown.py ~/Desktop/noter # alle
./apple-notes-til-markdown.py ~/Desktop/noter 10 # de første ti
"""
import html
import os
import re
import subprocess
import sys
from html.parser import HTMLParser
BLOCK_PREFIX = {"h1": "# ", "h2": "## ", "h3": "### ", "h4": "#### "}
FORBIDDEN = set('/\\:*?"<>|')
def osascript(script: str) -> str:
"""Ét kald til Noter. Fejl er tomme frem for fatale: en enkelt note, der ikke
kan læses, skal ikke stoppe en kørsel på tolv hundrede."""
try:
out = subprocess.run(
["osascript", "-e", script],
capture_output=True, text=True, timeout=60, check=False,
)
return out.stdout.rstrip("\n")
except subprocess.SubprocessError:
return ""
class NoteParser(HTMLParser):
"""Apple Noters HTML til Markdown.
Linjebaseret, fordi Noter er linjebaseret: hver <div> er en linje, og det er
dét, personen skrev. En rigtig Markdown-gengiver ville slå linjer sammen til
afsnit, hvilket er korrekt Markdown og forkert i forhold til noten.
"""
def __init__(self):
super().__init__(convert_charrefs=True)
self.lines: list[str] = []
self.line: list[str] = []
self.block: str | None = None # h1, h2 …
self.mono = False # den aktuelle linje er monospace
self.mono_lines: list[str] = [] # samlet op til én kodeblok
self.list_kind: list[str] = [] # ul/ol i lag
self.list_no: list[int] = []
self.marks: list[str] = [] # b, i, u i lag
# --- linjer ---------------------------------------------------------------
def _flush_line(self):
text = "".join(self.line).rstrip()
self.line = []
mono, self.mono = self.mono, False
if mono:
# Monospace samles op og skrives som én blok, ikke som en række
# enkeltlinjer: en terminaludskrift er én ting.
self.mono_lines.append(text)
return
# En tom linje lukker ikke en kodeblok. Hver linje i Noter er pakket ind i
# sin egen <div>, og dens starttag tømmer den forrige linje — så mellem to
# <tt>-linjer kommer der altid en tom flush forbi. Lod man den lukke
# blokken, blev hver eneste linje i en terminaludskrift sin egen blok.
if not text and self.mono_lines:
return
self._close_mono()
if self.block:
self.lines.append(BLOCK_PREFIX[self.block] + text if text else "")
return
if self.list_kind:
if not text:
return
if self.list_kind[-1] == "ol":
self.list_no[-1] += 1
self.lines.append(f"{self.list_no[-1]}. {text}")
else:
self.lines.append(f"- {text}")
return
self.lines.append(text)
def _close_mono(self):
if not self.mono_lines:
return
# Tomme linjer i enderne er afstand omkring blokken, ikke en del af koden.
block = self.mono_lines
while block and not block[0].strip():
block.pop(0)
while block and not block[-1].strip():
block.pop()
if block:
self.lines.append("```")
self.lines.extend(block)
self.lines.append("```")
# Luft efter blokken. Uden den klæber den næste sætning til den
# afsluttende hegnslinje, og en Markdown-læser er i sin gode ret til
# at læse dem som ét.
self.lines.append("")
self.mono_lines = []
# --- tags -----------------------------------------------------------------
def handle_starttag(self, tag, attrs):
if tag in BLOCK_PREFIX:
self._flush_line()
self.block = tag
elif tag in ("ul", "ol"):
self._flush_line()
self.list_kind.append(tag)
self.list_no.append(0)
elif tag == "li":
self._flush_line()
elif tag in ("tt", "code", "pre"):
# Ikke et linjeskift: <tt> står inde i den <div>, der ER linjen.
# Mærket sættes på linjen og aflæses, når den skrives ud.
self.mono = True
elif tag in ("b", "strong"):
self.marks.append("**")
self.line.append("**")
elif tag in ("i", "em"):
self.marks.append("*")
self.line.append("*")
elif tag == "u":
self.marks.append("</u>")
self.line.append("<u>")
elif tag == "br":
self._flush_line()
elif tag == "div":
self._flush_line()
def handle_endtag(self, tag):
if tag in BLOCK_PREFIX:
self._flush_line()
self.block = None
elif tag in ("ul", "ol"):
self._flush_line()
if self.list_kind:
self.list_kind.pop()
self.list_no.pop()
elif tag == "li":
self._flush_line()
elif tag in ("tt", "code", "pre"):
# Blokken lukkes IKKE her. To <tt>-linjer efter hinanden er én
# terminaludskrift, ikke to; den lukkes, når en linje uden monospace
# skrives ud, hvilket _flush_line sørger for.
pass
elif tag in ("b", "strong", "i", "em", "u"):
if self.marks:
self.line.append(self.marks.pop())
elif tag == "div":
self._flush_line()
def handle_data(self, data):
self.line.append(data)
def result(self) -> str:
self._flush_line()
self._close_mono()
# Tomme linjer i stimer bliver til én: Noter er fuld af <div><br></div>,
# og fem tomme linjer i træk er ikke afstand, det er rod.
out, blanks = [], 0
for line in self.lines:
if line.strip():
blanks = 0
out.append(line)
else:
blanks += 1
if blanks == 1:
out.append("")
while out and not out[-1].strip():
out.pop()
return "\n".join(out)
def to_markdown(body: str) -> str:
p = NoteParser()
p.feed(body)
p.close()
return p.result()
def safe_name(title: str, fallback: str) -> str:
name = "".join("-" if c in FORBIDDEN or ord(c) < 32 else c for c in title).strip()
name = name.rstrip(" .")
return name[:80] or fallback
def main() -> int:
out_dir = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser("~/Desktop/apple-noter")
limit = int(sys.argv[2]) if len(sys.argv) > 2 else 0
os.makedirs(out_dir, exist_ok=True)
# Id'erne først, i ét kald.
#
# Ikke "note 1", "note 2" …: Noter sorterer listen efter ændringsdato, så et
# indeks peger på noget andet, hvis noget flytter sig imellem to kald — og
# navn og krop blev hentet hver for sig. Resultatet var en note med én titel
# og en andens indhold, hvilket først ses, når man læser dem igennem. Et id
# peger på den samme note hver gang.
ids = osascript(
'tell application "Notes" to return id of every note'
)
if not ids:
print("Noter svarer ikke. Første gang skal macOS have lov — se efter dialogen.")
return 1
ids = [x.strip() for x in ids.split(",") if x.strip()]
total = len(ids)
print(f"Fandt {total} noter.")
if limit and limit < total:
ids = ids[:limit]
total = limit
print(f"Tager de første {total}.")
used: dict[str, int] = {}
attachments_dir = os.path.join(out_dir, "vedhaeftninger")
for i, note_id in enumerate(ids, start=1):
# Navn og krop i ét kald, adskilt af noget, der ikke står i en note.
# Hentet hver for sig kunne de nå at komme fra hver sin note.
both = osascript(
f'tell application "Notes" to tell note id "{note_id}" '
f'to return name & "\u241E" & body'
)
title, _, body = both.partition("\u241E")
title = title.strip() or f"uden titel {i}"
text = to_markdown(body)
# Kroppen begynder med notens egen titel. Skrives der en overskrift
# ovenover, står titlen to gange i hver eneste note.
#
# De tomme linjer skal forbi først: <h1> ligger inde i en <div>, 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![](vedhaeftninger/{os.path.basename(target)})\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())
-139
View File
@@ -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![](vedhaeftninger/%s)\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")\""
+54
View File
@@ -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([]);
});
+111 -2
View File
@@ -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) : '<h1><br></h1>';
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) {
+118
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
/**
* 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 += `<span class="tok-${kind}">${escape(m[0])}</span>`;
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);
}
+34
View File
@@ -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('</code></pre>');
fence = null;
} else {
out.push(escapeHtml(line) + '\n');
}
continue;
}
if (fenced) {
closeList();
fence = fenced[1] ?? '';
out.push(`<pre data-lang="${escapeHtml(fence)}"><code>`);
continue;
}
const bullet = /^[-*+]\s+(.*)$/.exec(line);
const numbered = /^\d+\.\s+(.*)$/.exec(line);
@@ -96,6 +116,7 @@ export function markdownToHtml(markdown) {
out.push(`<p>${line.trim() === '' ? '<br>' : inlineToHtml(line)}</p>`);
}
closeList();
if (fence !== null) out.push('</code></pre>');
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);
+8
View File
@@ -289,6 +289,12 @@
<style>
.notes {
/* En grid-kolonne er som udgangspunkt mindst så bred som sit bredeste
indhold, og et API-token uden et mellemrum i er ét meget bredt ord. Uden
min-width: 0 skubber sådan en note hele listen bredere, og editoren
klemmes sammen — på en note man ikke engang har åben. */
min-width: 0;
/* Air on all sides. The heading, the search box and the list all sat flush
against the sidebar's rule, which reads as a rendering fault rather than
as a layout. */
@@ -305,6 +311,7 @@
flex-direction: column;
gap: var(--s2);
min-height: 0;
min-width: 0;
border-right: 1px solid var(--line);
padding-right: var(--s3);
}
@@ -407,6 +414,7 @@
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
}
/* Everything below exists to keep two layers in lockstep. The shared rules are