diff --git a/tools/apple-notes-til-markdown.py b/tools/apple-notes-til-markdown.py new file mode 100755 index 0000000..6361793 --- /dev/null +++ b/tools/apple-notes-til-markdown.py @@ -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: + +

overskrifter → # ## ### + fremhævning → ** * + monospace → ```-blok +
  • punktliste → - +
    1. nummereret → 1. +
      en linje → linjeskift +
      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
      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
      , og dens starttag tømmer den forrige linje — så mellem to + # -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: står inde i den
      , 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("") + self.line.append("") + 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 -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

      , + # 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:

      ligger inde i en
      , 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()) 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![](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")\"" 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 @@