feat: flyt sitet fra Astro til Zola

Samme indhold, URL'er og udseende, bygget af en enkelt zola-binær og Tailwind
standalone-CLI uden Node. Kanonisk domæne er asger.weirsoe.dk.

Blogindlæg ligger som page bundles med frontmatter Zola forstår, så de gamle
stempler er ugyldige, indtil scripts/omstempl.py er kørt med PUBLISH_KEY.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Asger Geel Weirsøe
2026-11-01 16:47:08 +01:00
co-authored by Claude Opus 5.5
parent 7e13068dd2
commit e58a11c2b4
159 changed files with 8031 additions and 3574 deletions
+8
View File
@@ -0,0 +1,8 @@
{% extends "base.html" %}
{% block main %}
<div class="py-20 text-center">
<h1 class="text-6xl font-bold tracking-tight">404</h1>
<p class="mt-4 text-muted">Siden findes ikke.</p>
<a href="/" class="mt-8 wt-btn wt-btn--primary wt-btn--lg">Til forsiden</a>
</div>
{% endblock %}
+112
View File
@@ -0,0 +1,112 @@
{%- set preview = get_env(name="PW_PREVIEW", default="") == "1" -%}
{%- set seo = load_data(path="data/seo.json") -%}
{%- set lang = "da" -%}
{%- set noindex = preview -%}
{%- set alternates = [] -%}
{%- set titel = "" -%}
{%- set desc = config.description -%}
{%- set sti = "/404" -%}
{%- set artikel = false -%}
{%- if page -%}{%- set meta = page -%}{%- elif section -%}{%- set meta = section -%}{%- endif -%}
{%- if current_path -%}{%- set sti = current_path | trim_end(pat="/") -%}{%- endif -%}
{%- if sti == "" -%}{%- set sti = "/" -%}{%- endif -%}
{%- if meta -%}
{%- if meta.title -%}{%- set titel = meta.title -%}{%- endif -%}
{%- if meta.description -%}{%- set desc = meta.description -%}{%- endif -%}
{%- if meta.extra.lang -%}{%- set lang = meta.extra.lang -%}{%- endif -%}
{%- if meta.extra.noindex -%}{%- set noindex = true -%}{%- endif -%}
{%- if meta.extra.alternates -%}{%- set alternates = meta.extra.alternates -%}{%- endif -%}
{%- if page and page.date -%}{%- set artikel = true -%}{%- endif -%}
{%- elif term -%}
{%- set titel = "#" ~ term.name -%}{%- set desc = "Indlæg om " ~ term.name ~ "." -%}{%- set noindex = true -%}
{%- elif taxonomy -%}
{%- set titel = "Tags" -%}{%- set desc = "Alle emner på bloggen." -%}{%- set noindex = true -%}
{%- else -%}
{%- set titel = "404" -%}
{%- endif -%}
{%- if titel -%}{%- set sidetitel = titel ~ " · " ~ config.title -%}{%- else -%}{%- set sidetitel = config.title ~ " · Datalog & Technical Solution Architect" -%}{%- endif -%}
{%- set kanonisk = config.extra.site ~ sti -%}
<!doctype html>
<html lang="{{ lang }}"{% if preview %} data-base="/udkast"{% endif %}>
<head>
{% include "partials/banner.html" %}
{% include "partials/theme.html" %}
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ sidetitel }}</title>
<meta name="description" content="{{ desc }}" />
<meta name="author" content="Asger Weirsøe" />
<meta name="google-site-verification" content="l5LfnJZFSkVCauhwN9pND_gvxdnZJbx3FM6fpEK8nzE" />
<meta name="robots" content="{% if noindex %}noindex, follow{% else %}index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1{% endif %}" />
<link rel="canonical" href="{{ kanonisk | safe }}" />
<!-- Open Graph -->
<meta property="og:type" content="{% if artikel %}article{% else %}website{% endif %}" />
<meta property="og:site_name" content="{{ config.title }}" />
<meta property="og:title" content="{{ sidetitel }}" />
<meta property="og:description" content="{{ desc }}" />
<meta property="og:url" content="{{ kanonisk | safe }}" />
<meta property="og:locale" content="{% if lang == "en" %}en_GB{% else %}da_DK{% endif %}" />
{%- for a in alternates %}
<link rel="alternate" hreflang="{{ a.lang }}" href="{{ (config.extra.site ~ a.path) | safe }}" />
{%- if a.lang == "da" %}
<link rel="alternate" hreflang="x-default" href="{{ (config.extra.site ~ a.path) | safe }}" />
{%- endif %}
{%- endfor %}
<meta property="og:image" content="{{ (config.extra.site ~ "/og.png") | safe }}" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="{{ sidetitel }}" />
{%- if artikel %}
<meta property="article:published_time" content="{{ page.date | date(format="%Y-%m-%dT%H:%M:%S.000Z") }}" />
<meta property="article:modified_time" content="{{ (page.updated or page.date) | date(format="%Y-%m-%dT%H:%M:%S.000Z") }}" />
<meta property="article:author" content="Asger Weirsøe" />
{%- for t in page.taxonomies.tags or page.extra.tags or [] %}
<meta property="article:tag" content="{{ t }}" />
{%- endfor %}
{%- endif %}
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="{{ sidetitel }}" />
<meta name="twitter:description" content="{{ desc }}" />
<meta name="twitter:image" content="{{ (config.extra.site ~ "/og.png") | safe }}" />
<meta name="theme-color" content="#0b0f17" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#f6f8fb" media="(prefers-color-scheme: light)" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="sitemap" href="/sitemap-index.xml" />
<link rel="alternate" type="application/rss+xml" title="{{ config.title }}" href="/rss.xml" />
<link rel="me" href="https://mastodon.social/@asgerweirsoee" />
<link rel="stylesheet" href="{{ get_url(path="css/site.css", cachebust=true) | replace(from=config.base_url, to="") | safe }}" />
{{ <ld g={seo.baseGraph} /> }}
{%- block jsonld %}{% endblock %}
{% include "partials/matomo.html" %}
<script type="speculationrules">{"prefetch":[{"where":{"href_matches":"/*"},"eagerness":"moderate"}]}</script>
{%- if not preview %}
<script src="/registerSW.js"></script>
{%- endif %}
</head>
<body class="min-h-screen flex flex-col antialiased">
{%- if preview %}
<div class="sticky top-0 z-50 bg-amber-400 px-4 py-2 text-center text-sm font-semibold text-black">
UDKAST, ikke udgivet. <a href="/udkast/" class="underline">Tilbage til godkendelse</a>
</div>
{%- endif %}
{%- set nav = config.extra.nav_en if lang == "en" else config.extra.nav %}
{%- set hjem = "/en" if lang == "en" else "/" %}
{%- set andet = [a for a in alternates if a.lang != lang] | first %}
{% include "partials/header.html" %}
<main class="mx-auto w-full max-w-3xl flex-1 px-5 py-12">
{% block main %}{% endblock %}
</main>
{% include "partials/footer.html" %}
{% include "partials/palette.html" %}
{% include "partials/cookie.html" %}
{% include "partials/konami.html" %}
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
{% component ld(g) %}<script type="application/ld+json">{{ g | json_encode | safe }}</script>{% endcomponent ld %}
{% component faq(items) %}<script type="application/ld+json">{"@context":"https://schema.org","@type":"FAQPage","inLanguage":"da-DK","mainEntity":[{% for it in items %}{"@type":"Question","name":{{ it.q | json_encode | safe }},"acceptedAnswer":{"@type":"Answer","text":{{ it.a | json_encode | safe }}}}{% if not loop.last %},{% endif %}{% endfor %}]}</script>{% endcomponent faq %}
{% component crumbs(n1, u1, n2, u2, n3 = "", u3 = "") %}<script type="application/ld+json">{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":{{ n1 | json_encode | safe }},"item":{{ u1 | json_encode | safe }}},{"@type":"ListItem","position":2,"name":{{ n2 | json_encode | safe }},"item":{{ u2 | json_encode | safe }}}{% if n3 %},{"@type":"ListItem","position":3,"name":{{ n3 | json_encode | safe }},"item":{{ u3 | json_encode | safe }}}{% endif %}]}</script>{% endcomponent crumbs %}
{% component collection(url, name, description) %}<script type="application/ld+json">{"@context":"https://schema.org","@type":"CollectionPage","@id":{{ url | json_encode | safe }},"url":{{ url | json_encode | safe }},"name":{{ name | json_encode | safe }},"description":{{ description | json_encode | safe }},"inLanguage":"da-DK","isPartOf":{"@id":"https://asger.weirsoe.dk/#website"},"about":{"@id":"https://asger.weirsoe.dk/#person"}}</script>{% endcomponent collection %}
{% component postcard(p) %}
<article class="group wt-card wt-card--hover">
<a href="{{ p.path | trim_end(pat="/") | safe }}" class="block">
<div class="text-xs text-muted">{{ p.date | date(format="d. MMMM y", locale="da") }}</div>
<h3 class="mt-1 text-lg font-semibold tracking-tight group-hover:text-accent">{{ p.title }}</h3>
{% if p.description %}<p class="mt-2 text-sm text-muted">{{ p.description }}</p>{% endif %}
</a>
{%- if p.taxonomies.tags %}
<div class="mt-3 flex flex-wrap gap-1.5">
{% for t in p.taxonomies.tags %}<a href="/tags/{{ t }}" class="wt-chip">#{{ t }}</a>{% endfor %}
</div>
{%- endif %}
</article>
{% endcomponent postcard %}
+94
View File
@@ -0,0 +1,94 @@
{% extends "base.html" %}
{% block jsonld %}
{%- set url = config.extra.site ~ (page.path | trim_end(pat="/")) -%}
{%- set tags = page.taxonomies.tags or [] -%}
<script type="application/ld+json">{"@context":"https://schema.org","@type":"BlogPosting","headline":{{ page.title | json_encode | safe }},"description":{{ (page.description or "") | json_encode | safe }},"url":{{ url | json_encode | safe }},"mainEntityOfPage":{{ url | json_encode | safe }},"datePublished":"{{ page.date | date(format="%Y-%m-%dT%H:%M:%S.000Z") }}","dateModified":"{{ (page.updated or page.date) | date(format="%Y-%m-%dT%H:%M:%S.000Z") }}","inLanguage":"da-DK","image":"https://asger.weirsoe.dk/og.png","keywords":{{ tags | join(sep=", ") | json_encode | safe }},"articleSection":{{ (tags[0] if tags else "Blog") | json_encode | safe }},"wordCount":{{ page.word_count }},"author":{"@id":"https://asger.weirsoe.dk/#person"},"publisher":{"@id":"https://asger.weirsoe.dk/#person"},"isPartOf":{"@id":"https://asger.weirsoe.dk/#website"}}</script>
{{ <crumbs n1="Forside" u1={config.extra.site ~ "/"} n2="Blog" u2={config.extra.site ~ "/blog"} n3={page.title} u3={url} /> }}
{% endblock %}
{% block main %}
<nav aria-label="Brødkrumme" class="text-sm text-muted">
<a href="/" class="hover:text-accent">Forside</a>
<span class="px-1.5 text-muted/60">/</span>
<a href="/blog" class="hover:text-accent">Blog</a>
</nav>
<article class="mt-4">
<header class="border-b border-border pb-6">
<div class="text-sm text-muted">{{ page.date | date(format="d. MMMM y", locale="da") }}</div>
<h1 class="mt-2 text-4xl font-bold tracking-tight">{{ page.title }}</h1>
{%- if page.taxonomies.tags %}
<div class="mt-4 flex flex-wrap gap-1.5">
{% for t in page.taxonomies.tags %}<a href="/tags/{{ t }}" class="wt-chip">#{{ t }}</a>{% endfor %}
</div>
{%- endif %}
</header>
{#- Billeder i bundlen serveres som webp; markdown bruger bare ![tekst](fil.png). -#}
{%- set indhold = page.content %}
{%- for a in page.assets %}
{%- if a is ending_with(pat=".png") or a is ending_with(pat=".jpg") or a is ending_with(pat=".jpeg") or a is ending_with(pat=".webp") or a is ending_with(pat=".avif") %}
{%- set fil = a | split(pat="/") | last %}
{%- set b = resize_image(path="blog/" ~ page.slug ~ "/" ~ fil, width=1200, height=10000, op="fit", format="webp", quality=80) %}
{%- set_global indhold = indhold | replace(from='src="' ~ fil ~ '"', to='src="' ~ (b.url | replace(from=config.base_url, to="")) ~ '" width="' ~ b.width ~ '" height="' ~ b.height ~ '" loading="lazy" decoding="async"') %}
{%- endif %}
{%- endfor %}
<div class="prose mt-8">{{ indhold | safe }}</div>
</article>
{%- if not preview %}
<section class="mt-16 border-t border-border pt-8">
<h2 class="text-2xl font-bold tracking-tight">Kommentarer</h2>
<comentario-comments class="mt-6 block" live-update="true"></comentario-comments>
</section>
<script>
(function () {
if (!window.__comentarioLoaded) {
window.__comentarioLoaded = true;
var s = document.createElement('script');
s.src = 'https://comentario.weircon.dk/comentario.js';
s.defer = true;
document.head.appendChild(s);
}
})();
</script>
{%- endif %}
<script>
// Mermaid-diagrammer: ```mermaid-blokke rendres client-side. Scriptet (self-hosted)
// hentes KUN på indlæg der faktisk har diagrammer.
(function () {
var blocks = document.querySelectorAll('code[data-lang="mermaid"]');
if (!blocks.length) return;
var s = document.createElement('script');
s.src = (document.documentElement.dataset.base || '') + '/js/mermaid.min.js';
s.onload = async function () {
mermaid.initialize({ startOnLoad: false,
theme: document.documentElement.dataset.theme === 'light' ? 'default' : 'dark' });
var i = 0;
for (var code of blocks) {
try {
var r = await mermaid.render('mmd-' + i++, code.textContent);
var el = document.createElement('div');
el.className = 'mermaid-diagram';
el.innerHTML = r.svg;
code.closest('pre').replaceWith(el);
} catch (_) { /* lad kodeblokken stå hvis diagrammet ikke parser */ }
}
};
document.head.appendChild(s);
})();
</script>
<script>
(function () {
document.querySelectorAll('.prose pre').forEach(function (pre) {
if (pre.querySelector('.copy-btn')) return;
var b = document.createElement('button');
b.className = 'copy-btn'; b.type = 'button'; b.textContent = 'kopiér';
b.addEventListener('click', function () {
var code = pre.querySelector('code');
navigator.clipboard.writeText(code ? code.innerText : pre.innerText).then(function () {
b.textContent = 'kopieret ✓';
setTimeout(function () { b.textContent = 'kopiér'; }, 1500);
});
});
pre.appendChild(b);
});
})();
</script>
{% endblock %}
+10
View File
@@ -0,0 +1,10 @@
<!--
_ ____ ____ _____ ____
/ \ / ___| / ___| ____| _ \
/ _ \ \___ \| | _| _| | |_) |
/ ___ \ ___) | |_| | |___| _ <
/_/ \_\____/ \____|_____|_| \_\
Statisk Astro, self-hosted på Proxmox.
Prøv Cmd/Ctrl+K.. eller den gamle kode: op op ned ned venstre højre venstre højre B A.
-->
+64
View File
@@ -0,0 +1,64 @@
<div id="chat-log" class="mt-8 space-y-3" aria-live="polite"></div>
<form id="chat-form" class="mt-6 flex gap-3" data-thinking="{% if lang == "en" %}…thinking{% else %}…tænker{% endif %}" data-offline="{% if lang == "en" %}The chat is offline right now.. try again later.{% else %}Chatten er offline lige nu.. prøv senere.{% endif %}" data-unreachable="{% if lang == "en" %}Could not reach the chat.. try again in a moment.{% else %}Kunne ikke nå chatten.. prøv igen om lidt.{% endif %}">
<input id="chat-input" type="text" required maxlength="500" autocomplete="off"
placeholder="{% if lang == "en" %}Ask about experience, incident response, Peppol…{% else %}Spørg om erfaring, Peppol, homelab…{% endif %}"
class="flex-1 wt-input wt-input--lg" />
<button type="submit" class="wt-btn wt-btn--primary wt-btn--lg">Send</button>
</form>
<p class="mt-3 text-xs text-muted/80">{% if lang == "en" %}Answers are written by Claude (Anthropic) from my public CV.. check important details with me directly.{% else %}Svarene skrives af Claude (Anthropic) ud fra mit offentlige CV.. tjek vigtige detaljer med mig direkte.{% endif %}</p>
<script>
(function () {
var form = document.getElementById('chat-form');
if (!form || form.__wired) return; form.__wired = true;
var history = [];
function esc(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function linkify(text) {
var html = esc(text);
html = html.replace(/\b(https?:\/\/[^\s<]+[^\s<.,!?:;)])/g,
'<a href="$1" target="_blank" rel="noopener" class="text-accent underline">$1</a>');
html = html.replace(/\b([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})\b/g,
'<a href="mailto:$1" class="text-accent underline">$1</a>');
return html;
}
function track(action, q) {
try { (window._paq = window._paq || []).push(['trackEvent', 'Chat', action, q]); } catch (_) {}
}
function bubble(role, text) {
var log = document.getElementById('chat-log');
var d = document.createElement('div');
d.className = 'wt-card text-sm whitespace-pre-wrap ' + (role === 'user' ? 'wt-card--alt ml-8' : 'mr-8');
if (role === 'user') d.textContent = text; else d.innerHTML = linkify(text);
log.appendChild(d);
d.scrollIntoView({ behavior: 'smooth', block: 'end' });
return d;
}
form.addEventListener('submit', async function (e) {
e.preventDefault();
var input = document.getElementById('chat-input');
var q = input.value.trim(); if (!q) return;
input.value = '';
bubble('user', q);
history.push({ role: 'user', content: q });
var wait = bubble('assistant', form.dataset.thinking);
var btn = form.querySelector('button'); btn.disabled = true;
try {
var r = await fetch('/api/chat', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: history.slice(-10) }),
});
var data = await r.json();
if (!r.ok) { wait.textContent = data.error || form.dataset.offline; track('afvist', q); return; }
wait.innerHTML = linkify(data.reply);
history.push({ role: 'assistant', content: data.reply });
track('besvaret', q);
} catch (_) {
wait.textContent = form.dataset.unreachable;
track('fejl', q);
} finally { btn.disabled = false; input.focus(); }
});
})();
</script>
+40
View File
@@ -0,0 +1,40 @@
<div
id="cookie-banner"
hidden
class="fixed inset-x-0 bottom-0 z-[60] px-4 pb-4"
role="dialog"
aria-live="polite"
aria-label="{% if lang == "en" %}Cookie notice{% else %}Cookie-besked{% endif %}"
>
<div class="mx-auto flex max-w-3xl flex-col gap-3 wt-card wt-card--raised sm:flex-row sm:items-center sm:justify-between">
<p class="text-sm text-muted">
{% if lang == "en" %}This site uses cookies to improve your experience and keep anonymous visitor statistics. By using the site you accept this.{% else %}Denne side bruger cookies til at forbedre din oplevelse og lave anonym statistik over besøg. Ved at bruge siden accepterer du det.{% endif %}
<a href="/cookies" class="text-accent underline">{% if lang == "en" %}Read more (Danish){% else %}Læs mere{% endif %}</a>.
</p>
<button
id="cookie-ok"
class="shrink-0 self-start wt-btn wt-btn--primary sm:self-auto"
>OK</button>
</div>
</div>
<script>
(function () {
var KEY = 'wec_cookie_ack';
function wire() {
var b = document.getElementById('cookie-banner');
if (!b) return;
try { if (localStorage.getItem(KEY)) { b.hidden = true; return; } } catch (_) {}
b.hidden = false;
var ok = document.getElementById('cookie-ok');
if (ok && !ok.__wired) {
ok.__wired = true;
ok.addEventListener('click', function () {
try { localStorage.setItem(KEY, '1'); } catch (_) {}
b.hidden = true;
});
}
}
wire();
})();
</script>
+17
View File
@@ -0,0 +1,17 @@
<footer class="mt-24 border-t border-border/70">
<p class="mx-auto max-w-3xl px-5 pt-6 text-center text-xs text-muted/70 sm:text-left">
<span class="font-mono">{% if lang == "en" %}static html · no js frameworks · self-hosted on proxmox · works offline{% else %}statisk html · ingen js-frameworks · self-hosted på proxmox · virker offline{% endif %}</span>
</p>
<div class="mx-auto flex max-w-3xl flex-col items-center gap-3 px-5 py-6 text-sm text-muted sm:flex-row sm:justify-between">
<p>© {{ now() | date(format="%Y") }} {{ config.title }}</p>
<div class="flex flex-wrap justify-center gap-4">
{%- for s in config.extra.socials %}
<a class="transition-colors hover:text-accent" href="{{ s.href | safe }}" rel="me noopener" target="_blank">{{ s.label }}</a>
{%- endfor %}
<a class="transition-colors hover:text-accent" href="/rss.xml">RSS</a>
<a class="transition-colors hover:text-accent" href="/uses">Uses</a>
<a class="transition-colors hover:text-accent" href="/colophon">{% if lang == "en" %}Colophon{% else %}Kolofon{% endif %}</a>
<a class="transition-colors hover:text-accent" href="/cookies">Cookies</a>
</div>
</div>
</footer>
+42
View File
@@ -0,0 +1,42 @@
<header class="sticky top-0 z-50 border-b border-border/70 backdrop-blur-md bg-bg/70">
<nav class="mx-auto flex max-w-3xl items-center justify-between px-5 py-3.5">
<a href="{{ hjem }}" class="font-bold tracking-tight text-lg">{{ config.title }}<span class="text-accent">.</span></a>
<div class="flex items-center gap-1">
<ul class="hidden sm:flex items-center gap-1 text-sm">
{%- for item in nav %}
<li>
<a href="{{ item.href | safe }}" class="wt-nav-link"{% if item.href == "/" or item.href == "/en" %}{% if sti == item.href %} aria-current="page"{% endif %}{% elif sti is starting_with(pat=item.href) %} aria-current="page"{% endif %}>{{ item.label }}</a>
</li>
{%- endfor %}
</ul>
{%- if andet %}
<a href="{{ andet.path | safe }}" hreflang="{{ andet.lang }}" lang="{{ andet.lang }}" class="wt-chip mr-1">{% if andet.lang == "en" %}English{% else %}Dansk{% endif %}</a>
{%- endif %}
<button type="button" data-palette-open class="theme-toggle" title="Søg (⌘K / Ctrl K)" aria-label="Åbn søgning og kommandopalette">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</button>
<button type="button" data-theme-toggle class="theme-toggle ml-1" title="Skift mellem lyst og mørkt tema" aria-label="Skift tema">
<svg class="sun-and-moon" aria-hidden="true" width="22" height="22" viewBox="0 0 24 24">
<mask class="moon" id="moon-mask">
<rect x="0" y="0" width="100%" height="100%" fill="white" />
<circle cx="24" cy="10" r="6" fill="black" />
</mask>
<circle class="sun" cx="12" cy="12" r="6" mask="url(#moon-mask)" fill="currentColor" />
<g class="sun-beams" stroke="currentColor">
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</g>
</svg>
</button>
</div>
</nav>
</header>
+19
View File
@@ -0,0 +1,19 @@
<script>
// Konami-kode → terminal-tema
(() => {
if (window.__konami) return; window.__konami = true;
const seq = ['ArrowUp','ArrowUp','ArrowDown','ArrowDown','ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','b','a'];
let pos = 0;
document.addEventListener('keydown', (e) => {
pos = e.key === seq[pos] ? pos + 1 : (e.key === seq[0] ? 1 : 0);
if (pos !== seq.length) return;
pos = 0;
document.documentElement.dataset.theme = 'terminal';
localStorage.setItem('theme', 'terminal');
document.body.animate(
[{ transform: 'rotate(0)' }, { transform: 'rotate(360deg)' }],
{ duration: 900, easing: 'ease-in-out' });
console.log('%c> adgang givet. velkommen til terminalen.', 'color:#4bff7e;font-family:monospace;font-size:14px');
});
})();
</script>
+38
View File
@@ -0,0 +1,38 @@
<script>
var _paq = (window._paq = window._paq || []);
_paq.push(['setDomains', ['*.weirsoe.dk', '*.xn--weirse-fya.dk']]);
_paq.push(['setVisitorCookieTimeout', 31536000]);
_paq.push(['enableLinkTracking']);
(function () {
try {
var w = screen.width, h = screen.height, dpr = Math.round(window.devicePixelRatio || 1);
var k = Math.min(w, h) + 'x' + Math.max(w, h) + '@' + dpr;
var ua = navigator.userAgent || '';
var isApple = /iPhone|iPad|iPod/.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1);
var iphone = {
'320x568@2': 'iPhone SE(1)/5s',
'375x667@2': 'iPhone SE(2/3)/6/7/8',
'414x736@3': 'iPhone 6+/7+/8+ Plus',
'375x812@3': 'iPhone X/XS/11Pro/12mini/13mini',
'390x844@3': 'iPhone 12/13/14/16e (6,1 tomme notch)',
'393x852@3': 'iPhone 14Pro/15/15Pro/16',
'402x874@3': 'iPhone 16Pro',
'414x896@2': 'iPhone XR/11',
'414x896@3': 'iPhone XSMax/11ProMax',
'428x926@3': 'iPhone 12ProMax/13ProMax/14Plus',
'430x932@3': 'iPhone 14ProMax/15Plus/15ProMax/16Plus',
'440x956@3': 'iPhone 16ProMax'
};
var model = iphone[k] ? iphone[k] : (isApple ? ('Apple ukendt ' + k) : ('Andet ' + k + ' ' + (navigator.platform || '?')));
_paq.push(['setCustomDimension', 2, model]);
} catch (e) {}
})();
_paq.push(['trackPageView']);
(function () {
var u = '/_a/';
_paq.push(['setTrackerUrl', u + 'a']);
_paq.push(['setSiteId', '2']);
var d = document, g = d.createElement('script'), s = d.getElementsByTagName('script')[0];
g.async = true; g.src = u + 'a.js'; s.parentNode.insertBefore(g, s);
})();
</script>
+98
View File
@@ -0,0 +1,98 @@
<div id="palette" class="palette-overlay" hidden>
<div class="palette-box" role="dialog" aria-modal="true" aria-label="Kommandopalette">
<input id="palette-input" class="palette-input" type="text" placeholder="Søg sider, indlæg eller kommandoer…"
autocomplete="off" spellcheck="false" />
<ul id="palette-list" class="palette-list" role="listbox"></ul>
<div class="palette-hint">↑↓ navigér · ↵ vælg · esc luk</div>
</div>
</div>
<script>
(() => {
if (window.__palette) return; window.__palette = true;
let index = null, items = [], sel = 0;
const base = document.documentElement.dataset.base || '';
const staticActions = [
{ type: 'action', title: 'Tema: mørkt', run: () => setTheme('dark') },
{ type: 'action', title: 'Tema: lyst', run: () => setTheme('light') },
{ type: 'action', title: 'Tema: terminal', run: () => setTheme('terminal') },
{ type: 'action', title: 'Kopiér email (asger@weirsoe.dk)', run: () => navigator.clipboard?.writeText('asger@weirsoe.dk') },
{ type: 'link', title: 'LinkedIn ↗', url: 'https://www.linkedin.com/in/ag-weirs%C3%B8e/', ext: true },
{ type: 'link', title: 'Mastodon ↗', url: 'https://mastodon.social/@asgerweirsoee', ext: true },
{ type: 'link', title: 'Gitea ↗', url: 'https://gitea.weircon.dk/agw', ext: true },
{ type: 'link', title: 'RSS-feed', url: '/rss.xml' },
];
function setTheme(t) {
document.documentElement.dataset.theme = t;
localStorage.setItem('theme', t);
}
const el = (id) => document.getElementById(id);
async function ensureIndex() {
if (index) return;
try { index = await (await fetch(base + '/search-index.json')).json(); } catch { index = []; }
}
function score(q, it) {
const hay = (it.title + ' ' + (it.extra || '')).toLowerCase();
if (!q) return it.type === 'page' || it.type === 'action' ? 1 : 0.5;
if (hay.startsWith(q)) return 3;
if (hay.includes(q)) return 2;
// simpel subsekvens-match (fzf-agtig)
let i = 0; for (const c of hay) if (c === q[i]) i++;
return i === q.length ? 1 : 0;
}
function refresh() {
const q = el('palette-input').value.trim().toLowerCase();
items = [...(index || []), ...staticActions]
.map((it) => ({ it, s: score(q, it) }))
.filter((x) => x.s > 0)
.sort((a, b) => b.s - a.s)
.slice(0, 9)
.map((x) => x.it);
sel = 0;
el('palette-list').innerHTML = items.map((it, i) => {
const badge = it.type === 'post' ? 'blog' : it.type === 'action' ? 'kommando' : it.ext ? 'link' : 'side';
return `<li class="palette-item${i === sel ? ' is-selected' : ''}" data-i="${i}" role="option">
<span>${it.title.replace(/</g, '&lt;')}</span><span class="palette-badge">${badge}</span></li>`;
}).join('') || '<li class="palette-item palette-empty">Ingen resultater</li>';
}
function paint() {
el('palette-list').querySelectorAll('.palette-item').forEach((li, i) =>
li.classList.toggle('is-selected', i === sel));
}
function choose(i) {
const it = items[i]; if (!it) return;
close();
if (it.run) it.run();
else if (it.ext) window.open(it.url, '_blank', 'noopener');
else window.location.href = base + it.url;
}
async function open() {
await ensureIndex();
el('palette').hidden = false;
el('palette-input').value = '';
refresh();
el('palette-input').focus();
}
function close() { const p = el('palette'); if (p) p.hidden = true; }
document.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); el('palette')?.hidden ? open() : close(); return; }
const p = el('palette');
if (!p || p.hidden) return;
if (e.key === 'Escape') close();
else if (e.key === 'ArrowDown') { e.preventDefault(); sel = Math.min(sel + 1, items.length - 1); paint(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); sel = Math.max(sel - 1, 0); paint(); }
else if (e.key === 'Enter') { e.preventDefault(); choose(sel); }
});
document.addEventListener('input', (e) => { if (e.target?.id === 'palette-input') refresh(); });
document.addEventListener('click', (e) => {
const trigger = e.target.closest?.('[data-palette-open]');
if (trigger) { e.preventDefault(); el('palette')?.hidden ? open() : close(); return; }
if (e.target === el('palette')) close();
const li = e.target.closest?.('.palette-item[data-i]');
if (li) choose(+li.dataset.i);
});
})();
</script>
+3
View File
@@ -0,0 +1,3 @@
{%- set a1 = resize_image(path="asger.png", width=260, op="fit_width", format="webp", quality=80) -%}
{%- set a2 = resize_image(path="asger.png", width=520, op="fit_width", format="webp", quality=80) -%}
<img src="{{ a1.url | replace(from=config.base_url, to="") | safe }}" srcset="{{ a1.url | replace(from=config.base_url, to="") | safe }} 1x, {{ a2.url | replace(from=config.base_url, to="") | safe }} 2x" alt="Asger Weirsøe" loading="eager" decoding="async" width="{{ a1.width }}" height="{{ a1.height }}" class="mx-auto h-44 w-44 rounded-2xl border border-border object-cover shadow-xl sm:h-56 sm:w-56" />
+17
View File
@@ -0,0 +1,17 @@
<script>
(() => {
const apply = () => {
const t = localStorage.getItem('theme') ||
(window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
document.documentElement.dataset.theme = t;
};
apply();
document.addEventListener('click', (e) => {
const btn = e.target.closest('[data-theme-toggle]');
if (!btn) return;
const next = document.documentElement.dataset.theme === 'light' ? 'dark' : 'light';
document.documentElement.dataset.theme = next;
localStorage.setItem('theme', next);
});
})();
</script>
+45
View File
@@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block jsonld %}
{%- set en = page.extra.lang == "en" -%}
{%- set url = config.extra.site ~ (page.path | trim_end(pat="/")) -%}
{%- set repo = "https://gitea.weircon.dk/agw/" ~ page.extra.repo -%}
<script type="application/ld+json">{"@context":"https://schema.org","@type":"SoftwareSourceCode","@id":{{ (url ~ "#software") | json_encode | safe }},"url":{{ url | json_encode | safe }},"name":{{ page.title | json_encode | safe }},"description":{{ page.description | json_encode | safe }},"codeRepository":{{ repo | json_encode | safe }},"programmingLanguage":{{ page.extra.sprog | json_encode | safe }},"license":{{ page.extra.licens | json_encode | safe }},"runtimePlatform":{{ page.extra.platforme | json_encode | safe }},"sameAs":[{% for n in page.extra.aur %}"https://aur.archlinux.org/packages/{{ n }}"{% if not loop.last %},{% endif %}{% endfor %}],"dateCreated":"{{ page.date | date(format="%Y-%m-%dT%H:%M:%S.000Z") }}","dateModified":"{{ (page.updated or page.date) | date(format="%Y-%m-%dT%H:%M:%S.000Z") }}","keywords":{{ page.extra.tags | json_encode | safe }},"inLanguage":"{% if en %}en{% else %}da-DK{% endif %}","author":{"@id":"https://asger.weirsoe.dk/#person"},"maintainer":{"@id":"https://asger.weirsoe.dk/#person"}}</script>
{%- if en %}
{{ <crumbs n1="Home" u1={config.extra.site ~ "/en"} n2="Projects" u2={config.extra.site ~ "/en/projects"} n3={page.title} u3={url} /> }}
{%- else %}
{{ <crumbs n1="Forside" u1={config.extra.site ~ "/"} n2="Projekter" u2={config.extra.site ~ "/projekter"} n3={page.title} u3={url} /> }}
{%- endif %}
{% endblock %}
{% block main %}
{%- set en = page.extra.lang == "en" -%}
<div class="text-sm text-muted">
<nav aria-label="{% if en %}Breadcrumb{% else %}Brødkrumme{% endif %}">
<a href="{% if en %}/en{% else %}/{% endif %}" class="hover:text-accent">{% if en %}Home{% else %}Forside{% endif %}</a>
<span class="px-1.5 text-muted/60">/</span>
<a href="{% if en %}/en/projects{% else %}/projekter{% endif %}" class="hover:text-accent">{% if en %}Projects{% else %}Projekter{% endif %}</a>
</nav>
</div>
<article class="mt-4">
<header class="border-b border-border pb-6">
<h1 class="text-4xl font-bold tracking-tight">{{ page.title }}</h1>
<p class="mt-3 max-w-2xl text-lg text-muted">{{ page.description }}</p>
<dl class="mt-5 flex flex-wrap gap-x-6 gap-y-2 text-sm">
<div><dt class="inline text-muted">{% if en %}Language{% else %}Sprog{% endif %} </dt><dd class="inline">{{ page.extra.sprog }}</dd></div>
<div><dt class="inline text-muted">{% if en %}Licence{% else %}Licens{% endif %} </dt><dd class="inline">{{ page.extra.licens }}</dd></div>
{%- if page.extra.platforme %}
<div><dt class="inline text-muted">{% if en %}Runs on{% else %}Kører på{% endif %} </dt><dd class="inline">{{ page.extra.platforme | join(sep=", ") }}</dd></div>
{%- endif %}
</dl>
<div class="mt-5 flex flex-wrap gap-2">
<a href="https://gitea.weircon.dk/agw/{{ page.extra.repo }}" class="wt-chip">{% if en %}Source on Gitea{% else %}Kildekode på Gitea{% endif %}</a>
{%- for n in page.extra.aur %}
<a href="https://aur.archlinux.org/packages/{{ n }}" class="wt-chip">AUR: {{ n }}</a>
{%- endfor %}
{%- if [n for n in page.extra.aur if n is ending_with(pat="-bin")] %}
<a href="/tarballz/" class="wt-chip">{% if en %}Prebuilt packages{% else %}Færdigbyggede pakker{% endif %}</a>
{%- endif %}
</div>
</header>
<div class="prose mt-8">{{ page.content | safe }}</div>
</article>
{% endblock %}
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title>{{ config.title }}</title><description>{{ config.description }}</description><link>{{ config.extra.site | safe }}</link><language>da-dk</language>
{%- for p in pages %}{% if p.components[0] == "blog" %}<item><title>{{ p.title }}</title><link>{{ config.extra.site ~ p.path | trim_end(pat="/") | safe }}</link><guid isPermaLink="true">{{ config.extra.site ~ p.path | trim_end(pat="/") | safe }}</guid><description>{{ p.description }}</description><pubDate>{{ p.date | date(format="%a, %d %b %Y %H:%M:%S GMT") }}</pubDate></item>{% endif %}{% endfor %}</channel></rss>
+4
View File
@@ -0,0 +1,4 @@
[{"type":"page","title":"Forside","url":"/"},{"type":"page","title":"CV","url":"/cv"},{"type":"page","title":"CV (English)","url":"/en/cv"},{"type":"page","title":"Home (English)","url":"/en"},{"type":"page","title":"Ask my CV (English)","url":"/en/chat"},{"type":"page","title":"Projekter","url":"/projekter"},
{%- for p in pages %}{% if p.components[0] == "projekter" %}{"type":"page","title":{{ ("Projekt: " ~ p.title) | json_encode | safe }},"url":"{{ p.path | trim_end(pat="/") }}","extra":{{ [p.extra.sprog, ...p.extra.aur, ...p.extra.tags] | join(sep=" ") | json_encode | safe }}},{% elif p.components[0] == "en" %}{"type":"page","title":{{ ("Project: " ~ p.title ~ " (English)") | json_encode | safe }},"url":"{{ p.path | trim_end(pat="/") }}","extra":{{ [p.extra.sprog, ...p.extra.aur, ...p.extra.tags] | join(sep=" ") | json_encode | safe }}},{% endif %}{% endfor -%}
{"type":"page","title":"Færdigbyggede pakker (tarballz)","url":"/tarballz/"},{"type":"page","title":"Blog","url":"/blog"},{"type":"page","title":"Tags","url":"/tags"},{"type":"page","title":"NemHandel-opslag","url":"/nemhandel"},{"type":"page","title":"Profil-opslag (Nemhandel & Peppol)","url":"/profiler"},{"type":"page","title":"UBL-tjek & identifikator-afkoder","url":"/ubl"},{"type":"page","title":"Spørg mit CV (AI-chat)","url":"/chat"},{"type":"page","title":"Status (homelab)","url":"/status"},{"type":"page","title":"Now","url":"/now"},{"type":"page","title":"Uses","url":"/uses"},{"type":"page","title":"Kolofon","url":"/colophon"},{"type":"page","title":"Cookies","url":"/cookies"}
{%- for p in pages %}{% if p.components[0] == "blog" %},{"type":"post","title":{{ p.title | json_encode | safe }},"url":"{{ p.path | trim_end(pat="/") }}","extra":{{ p.taxonomies.tags | default(value=[]) | join(sep=" ") | json_encode | safe }}}{% endif %}{% endfor %}]
+17
View File
@@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block main %}
<div class="flex items-baseline justify-between">
<h1 class="text-4xl font-bold tracking-tight">Blog</h1>
<a href="/tags" class="text-sm text-accent hover:underline">Tags</a>
</div>
<div class="mt-8 grid gap-4">
{% for p in section.pages %}
{{ <postcard p={p} /> }}
{% endfor %}
{% if not section.pages %}<p class="text-muted">Ingen indlæg endnu.</p>{% endif %}
</div>
{% endblock %}
{% block jsonld %}
{{ <collection url={config.extra.site ~ "/blog"} name="Blog · Asger Weirsøe" description="Indlæg om selvhosting, Proxmox, AI og udvikling." /> }}
{{ <crumbs n1="Forside" u1={config.extra.site ~ "/"} n2="Blog" u2={config.extra.site ~ "/blog"} /> }}
{% endblock %}
+11
View File
@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block main %}
<h1 class="text-4xl font-bold tracking-tight">Ask my CV</h1>
<p class="mt-4 max-w-2xl text-muted">
A small chat with <strong class="text-text">Claude</strong> from Anthropic. It has read my CV and this
site and answers questions about me.. try <em>“what incident response experience does Asger have?”</em>
or <em>“what runs in his homelab?”</em>. It only answers questions about me.
</p>
{% include "partials/chat.html" %}
{% endblock %}
+11
View File
@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block main %}
<h1 class="text-4xl font-bold tracking-tight">Spørg mit CV</h1>
<p class="mt-4 max-w-2xl text-muted">
En lille chat med <strong class="text-text">Claude</strong> fra Anthropic, der har læst mit CV og denne
side og svarer på spørgsmål om mig.. fx <em>“hvad har Asger lavet med Peppol?”</em> eller
<em>“hvilken stack kører hans homelab?”</em>. Den svarer kun på spørgsmål om mig.
</p>
{% include "partials/chat.html" %}
{% endblock %}
+34
View File
@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block main %}
{% set d = load_data(path="data/colophon.json") %}
<h1 class="text-4xl font-bold tracking-tight">Kolofon</h1>
<p class="mt-4 max-w-2xl text-muted">
Hele kæden fra commit til din skærm kører på udstyr jeg selv drifter. Ingen CDN’er,
ingen tredjeparts-fonts, ingen eksterne trackere.. bare en statisk side der gør hvad den får besked på.
</p>
<div class="mt-8 wt-card wt-card--flush">
<table class="w-full text-sm">
<tbody>
{% for row in d.stack %}
<tr class="border-b border-border/60 last:border-0">
<td class="w-40 bg-surface/60 px-4 py-3 font-semibold align-top">{{ row.k }}</td>
<td class="px-4 py-3 text-muted">{{ row.v }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<section class="mt-10">
<h2 class="text-sm font-semibold uppercase tracking-widest text-accent">Sikkerhed</h2>
<p class="mt-3 max-w-2xl text-sm text-muted">
Fundet noget der ser forkert ud? Skriv til <a class="text-accent hover:underline" href="mailto:asger@weirsoe.dk">asger@weirsoe.dk</a>.
Der ligger også en <a class="text-accent hover:underline" href="/.well-known/security.txt">security.txt</a> (RFC 9116).
</p>
</section>
<p class="mt-10 text-sm text-muted">
Se også <a class="text-accent hover:underline" href="/uses">uses</a>.
</p>
{% endblock %}
+41
View File
@@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block main %}
<h1 class="text-4xl font-bold tracking-tight">Cookies</h1>
<p class="mt-4 max-w-2xl text-muted">
Denne side bruger cookies til at forbedre din oplevelse og lave anonym statistik over,
hvordan siden bruges. <strong class="text-text">Ved at bruge siden accepterer du, at der
sættes cookies.</strong> Du kan altid slette dem igen i din browser.
</p>
<h2 class="mt-10 text-2xl font-bold tracking-tight">Hvad bruger vi dem til?</h2>
<div class="mt-5 grid gap-4">
<div class="wt-card">
<h3 class="font-semibold tracking-tight">Statistik &amp; analyse</h3>
<p class="mt-2 text-sm text-muted">
Jeg bruger <strong class="text-text">Matomo</strong> til at se hvor mange der besøger siden,
og hvordan den bruges, så jeg kan gøre den bedre. Matomo kører på mine egne servere, dine
data bliver <strong class="text-text">ikke delt med tredjeparter</strong> og ikke solgt.
</p>
<ul class="mt-3 space-y-1 text-sm text-muted">
<li><code class="rounded bg-surface-2 px-1.5 py-0.5">_pk_id</code>: et tilfældigt id der tæller dig som samme besøgende over tid (op til 1 år).</li>
<li><code class="rounded bg-surface-2 px-1.5 py-0.5">_pk_ses</code>: markerer dit aktuelle besøg (udløber efter ca. 30 minutter).</li>
<li><code class="rounded bg-surface-2 px-1.5 py-0.5">WEC_VID</code>: et tilsvarende besøgs-id på mine egne tjenester.</li>
</ul>
</div>
<div class="wt-card">
<h3 class="font-semibold tracking-tight">Funktionelt</h3>
<p class="mt-2 text-sm text-muted">
Husker dine valg på siden. Det er ikke sporing, og det forlader aldrig din browser.
</p>
<ul class="mt-3 space-y-1 text-sm text-muted">
<li><code class="rounded bg-surface-2 px-1.5 py-0.5">theme</code>: dit valgte tema (lyst/mørkt/terminal).</li>
<li><code class="rounded bg-surface-2 px-1.5 py-0.5">wec_cookie_ack</code>: husker at du har set denne cookie-besked, så den ikke dukker op igen.</li>
</ul>
</div>
</div>
<p class="mt-8 text-sm text-muted">
Spørgsmål? Skriv til <a href="mailto:asger@weirsoe.dk" class="text-accent underline">asger@weirsoe.dk</a>.
</p>
{% endblock %}
+142
View File
@@ -0,0 +1,142 @@
{% extends "base.html" %}
{% block main %}
{% set d = load_data(path="data/cv-en.json") %}
<h1 class="text-4xl font-bold tracking-tight">CV</h1>
<div class="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted">
<span>Aalborg, Denmark</span>
<a class="text-accent hover:underline" href="mailto:asger@weirsoe.dk">asger@weirsoe.dk</a>
<a class="text-accent hover:underline" href="https://www.linkedin.com/in/ag-weirs%C3%B8e/">LinkedIn</a>
</div>
<p class="mt-6 max-w-2xl text-muted">
Technical Solution Architect who has worked on Danish national e-invoicing infrastructure
since 2021. I have led responses to production outages and security vulnerabilities,
coordinating engineers, hosting providers and the client, and I built an
information security management system around ISO/IEC 27001 with procedures for incident
response, escalation and post-incident review. M.Sc. in Computer Science, specialising in
security in distributed systems, and currently studying business administration alongside work.
</p>
<section class="mt-10">
<h2 class="text-sm font-semibold uppercase tracking-widest text-accent">Areas of expertise</h2>
<p class="mt-4 max-w-2xl text-muted">
I work with <strong class="text-text">electronic invoicing</strong> and e-procurement. At CGI I helped
develop the open source eDelivery solution for NemHandel, built on the open source access point
<strong class="text-text">Oxalis</strong> (Java). Today I am a Technical Solution Architect at Digisense A/S,
working on solution architecture in the same field: <strong class="text-text">Peppol</strong> (BIS,
Access Point and eDelivery/AS4), <strong class="text-text">NemHandel</strong>,
<strong class="text-text">OIOUBL/UBL</strong> and the European e-invoicing standard EN 16931.
</p>
<div class="mt-4 flex flex-wrap gap-2">
{% for k in ["Peppol", "Peppol Access Point", "Oxalis", "NemHandel", "E-invoicing", "OIOUBL / UBL", "eDelivery (AS4)", "EN 16931", "E-procurement"] %}
<span class="wt-tag">{{ k }}</span>
{% endfor %}
</div>
</section>
<section class="mt-10">
<h2 class="text-sm font-semibold uppercase tracking-widest text-accent">Incident response and compliance</h2>
<p class="mt-4 max-w-2xl text-muted">
When something breaks in production, the technical fix is only half of it. The other half is
getting the right people moving, keeping the client informed and learning from it afterwards.
During the <strong class="text-text">July 2024 CrowdStrike outage</strong> a database failure hit our
hosted production platform just as most of the team’s workstations went down. As tech lead on
CGI’s NemHandel team I coordinated by phone with the hosting provider and the one developer whose
machine still worked, and the database was restored that afternoon. When the critical SnakeYAML
vulnerability <strong class="text-text">CVE-2022-1471</strong> affected the NemHandel reference
implementation, we notified the client immediately, mitigation guidance went out to operators,
and we shipped a fixed release within two weeks.
</p>
<p class="mt-3 max-w-2xl text-muted">
At Digisense I built the information security management system around
<strong class="text-text">ISO/IEC 27001</strong>: incident management and escalation procedures,
post-incident reviews and an incident register. That is the work that has to be in place before
anything is on fire, so nobody has to invent the response as it happens.
</p>
</section>
<section class="mt-10">
<h2 class="text-sm font-semibold uppercase tracking-widest text-accent">Experience</h2>
<div class="mt-4 space-y-4">
{% for e in d.work %}
<div class="wt-card">
<h3 class="font-semibold">{{ e.org }} <span class="font-normal text-muted">· {{ e.place }}</span></h3>
<div class="mt-3 space-y-3">
{% for r in e.roles %}
<div>
<div class="flex flex-wrap items-baseline justify-between gap-2">
<span class="font-medium text-text">{{ r.title }}</span>
<span class="text-xs text-muted">{{ r.when }}</span>
</div>
<ul class="mt-1 list-disc pl-5 text-sm text-muted">
{% for d in r.details %}<li>{{ d }}</li>{% endfor %}
</ul>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</section>
<section class="mt-10">
<h2 class="text-sm font-semibold uppercase tracking-widest text-accent">Education</h2>
<div class="mt-4 space-y-4">
{% for u in d.education %}
<div class="wt-card">
<div class="flex flex-wrap items-baseline justify-between gap-2">
<h3 class="font-semibold">{{ u.what }}</h3><span class="text-xs text-muted">{{ u.when }}</span>
</div>
<p class="text-sm text-muted">{{ u.where }}</p>
{% if u.note %}<p class="mt-1 text-sm text-muted/80">{{ u.note }}</p>{% endif %}
</div>
{% endfor %}
</div>
</section>
<section class="mt-10">
<h2 class="text-sm font-semibold uppercase tracking-widest text-accent">Publication</h2>
<div class="mt-4 wt-card">
<a href="https://arxiv.org/abs/2310.00999" target="_blank" rel="noopener" class="font-medium hover:text-accent">
CGAAL: Distributed On-The-Fly ATL Model Checker with Heuristics
</a>
<p class="mt-1 text-sm text-muted">
Carlsen, Frydenskov, Jensen, Rasmussen, Sørensen, <span class="text-text">Weirsøe</span>, Jensen &amp; Larsen.
<em>EPTCS</em>, vol. 390 (2023), pp. 99–114. <span class="text-muted/80">arXiv:2310.00999</span>
</p>
</div>
</section>
<section class="mt-10">
<h2 class="text-sm font-semibold uppercase tracking-widest text-accent">Skills</h2>
<div class="mt-4 space-y-4">
{% for s in d.skills %}
<div>
<h3 class="text-sm font-medium text-text">{{ s.k }}</h3>
<div class="mt-2 flex flex-wrap gap-2">
{% for k in s.v %}<span class="wt-tag">{{ k }}</span>{% endfor %}
</div>
</div>
{% endfor %}
</div>
</section>
<section class="mt-10 grid gap-4 sm:grid-cols-2">
<div>
<h2 class="text-sm font-semibold uppercase tracking-widest text-accent">Languages</h2>
<ul class="mt-4 space-y-2 text-sm">
<li class="wt-card wt-card--compact">Danish (native)</li>
<li class="wt-card wt-card--compact">English (fluent, spoken and written)</li>
</ul>
</div>
<div>
<h2 class="text-sm font-semibold uppercase tracking-widest text-accent">Projects and interests</h2>
<p class="mt-4 text-sm text-muted">
I run a self-hosted Proxmox environment with around 50 containers and virtual machines,
single sign-on through Authentik, central logging and monitoring. My family uses it every day.
I have contributed to qutebrowser and Stregsystemet,
and I am a licensed radio amateur (call sign <strong class="text-text">OZ0AGW</strong>).
</p>
</div>
</section>
{% endblock %}
+193
View File
@@ -0,0 +1,193 @@
{% extends "base.html" %}
{% block main %}
{% set d = load_data(path="data/cv.json") %}
<h1 class="text-4xl font-bold tracking-tight">CV</h1>
<div class="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted">
<span>Nørresundby, Danmark</span>
<a class="text-accent hover:underline" href="mailto:asger@weirsoe.dk">asger@weirsoe.dk</a>
<a class="text-accent hover:underline" href="https://www.linkedin.com/in/ag-weirs%C3%B8e/">LinkedIn</a>
</div>
<p class="mt-6 max-w-2xl text-muted">
Datalog (cand.scient.) fra Aalborg Universitet med en bred profil. Jeg forener teknisk
indsigt med forretningsforståelse og er god til at få komplekse løsninger til at lande i
praksis, særligt inden for elektronisk fakturering. Jeg har stået for hændelseshåndtering
på national e-faktura-infrastruktur og bygget et ISMS efter ISO/IEC 27001. Karrieren gik fra fuld-stack udvikling
hos BilligVVS til konsulent- og Teknisk Lead-rollen hos CGI, og i dag er jeg Technical
Solution Architect hos Digisense A/S, hvor jeg bygger bro mellem kunder, forretning og
udviklingsteams. Ved siden af læser jeg HD i Erhvervsøkonomi for at koble teknik og
forretning endnu tættere.
</p>
<section class="mt-10">
<h2 class="text-sm font-semibold uppercase tracking-widest text-accent">Fagområder</h2>
<p class="mt-4 max-w-2xl text-muted">
Jeg arbejder med <strong class="text-text">elektronisk fakturering</strong> (e-faktura / electronic
invoicing) og e-procurement. Hos CGI var jeg med til at udvikle den open source eDelivery-løsning
til NemHandel, der er bygget på den open source-baserede access point <strong class="text-text">Oxalis</strong> (Java). I dag er jeg
Technical Solution Architect hos Digisense A/S og arbejder med løsningsarkitektur på området:
<strong class="text-text">Peppol</strong> (BIS, Access Point og eDelivery/AS4),
<strong class="text-text">NemHandel</strong>, <strong class="text-text">OIOUBL/UBL</strong> og den
europæiske e-faktura-standard EN 16931.
</p>
<div class="mt-4 flex flex-wrap gap-2">
{% for k in ["Peppol", "Peppol Access Point", "Oxalis", "NemHandel", "e-faktura", "Electronic invoicing", "OIOUBL / UBL", "e-Delivery (AS4)", "EN 16931", "e-procurement"] %}
<span class="wt-tag">{{ k }}</span>
{% endfor %}
</div>
</section>
<section class="mt-10">
<h2 class="text-sm font-semibold uppercase tracking-widest text-accent">Hændelseshåndtering og compliance</h2>
<p class="mt-4 max-w-2xl text-muted">
Når noget går ned i produktion, er det tekniske kun halvdelen. Den anden halvdel er at få de
rigtige folk i gang, holde kunden orienteret og bagefter lære noget af det. Det har jeg gjort i
praksis som teknisk lead på NemHandel hos CGI. Under <strong class="text-text">CrowdStrike-nedbruddet
i juli 2024</strong> ramte en databasefejl produktionen, samtidig med at de fleste af teamets maskiner
var nede.. så vi koordinerede over telefonen med hostingleverandøren og den ene udvikler, hvis maskine
stadig virkede, og databasen var tilbage samme eftermiddag. Da den kritiske SnakeYAML-sårbarhed
<strong class="text-text">CVE-2022-1471</strong> ramte NemHandel-referenceimplementeringen, orienterede
vi kunden med det samme, der gik vejledning ud til dem der kørte løsningen, og vi havde en rettet
version ude inden for to uger.
</p>
<p class="mt-3 max-w-2xl text-muted">
Hos Digisense har jeg bygget virksomhedens ledelsessystem for informationssikkerhed efter
<strong class="text-text">ISO/IEC 27001</strong>, med procedurer for hændelseshåndtering og eskalering,
evaluering efter hændelser og et hændelsesregister. Det er arbejdet der skal ligge klar før det brænder,
så ingen skal opfinde responsen undervejs. Interessen er ikke ny: min kandidat er specialiseret i
sikkerhed i distribuerede systemer.
</p>
<div class="mt-4 flex flex-wrap gap-2">
{% for k in ["Incident response", "Hændelseshåndtering", "Eskalering", "Post-incident review", "Sårbarhedshåndtering", "Interessentkommunikation", "ISO/IEC 27001", "ISMS", "Informationssikkerhed"] %}
<span class="wt-tag">{{ k }}</span>
{% endfor %}
</div>
</section>
<section class="mt-10">
<h2 class="text-sm font-semibold uppercase tracking-widest text-accent">Erhvervserfaring</h2>
<div class="mt-4 space-y-4">
{% for e in d.erhverv %}
<div class="wt-card">
<h3 class="font-semibold">{{ e.org }} <span class="font-normal text-muted">· {{ e.sted }}</span></h3>
<div class="mt-3 space-y-3">
{% for r in e.roller %}
<div>
<div class="flex flex-wrap items-baseline justify-between gap-2">
<span class="font-medium text-text">{{ r.titel }}</span>
<span class="text-xs text-muted">{{ r.naar }}</span>
</div>
<ul class="mt-1 list-disc pl-5 text-sm text-muted">
{% for d in r.detaljer %}<li>{{ d }}</li>{% endfor %}
</ul>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</section>
<section class="mt-10">
<h2 class="text-sm font-semibold uppercase tracking-widest text-accent">Uddannelse</h2>
<div class="mt-4 space-y-4">
{% for u in d.uddannelse %}
<div class="wt-card">
<div class="flex flex-wrap items-baseline justify-between gap-2">
<h3 class="font-semibold">{{ u.hvad }}</h3><span class="text-xs text-muted">{{ u.naar }}</span>
</div>
<p class="text-sm text-muted">{{ u.hvor }}</p>
{% if u.note %}<p class="mt-1 text-sm text-muted/80">{{ u.note }}</p>{% endif %}
</div>
{% endfor %}
</div>
</section>
<section class="mt-10">
<h2 class="text-sm font-semibold uppercase tracking-widest text-accent">Licenser</h2>
<div class="mt-4 space-y-4">
{% for l in d.licenser %}
<div class="wt-card">
<div class="flex flex-wrap items-baseline justify-between gap-2">
<h3 class="font-semibold">{{ l.hvad }}</h3><span class="text-xs text-muted">{{ l.naar }}</span>
</div>
<p class="text-sm text-muted">{{ l.hvor }}</p>
{% if l.note %}<p class="mt-1 text-sm text-muted">{{ l.note }}</p>{% endif %}
</div>
{% endfor %}
</div>
</section>
<section class="mt-10">
<h2 class="text-sm font-semibold uppercase tracking-widest text-accent">Publikationer</h2>
<div class="mt-4 wt-card">
<a href="https://arxiv.org/abs/2310.00999" target="_blank" rel="noopener" class="font-medium hover:text-accent">
CGAAL: Distributed On-The-Fly ATL Model Checker with Heuristics
</a>
<p class="mt-1 text-sm text-muted">
Carlsen, Frydenskov, Jensen, Rasmussen, Sørensen, <span class="text-text">Weirsøe</span>, Jensen &amp; Larsen.
<em>EPTCS</em>, vol. 390 (2023), s. 99–114. <span class="text-muted/80">arXiv:2310.00999</span>
</p>
</div>
</section>
<section class="mt-10">
<h2 class="text-sm font-semibold uppercase tracking-widest text-accent">Frivilligt arbejde</h2>
<div class="mt-4 space-y-4">
{% for f in d.frivilligt %}
<div class="wt-card">
<div class="flex flex-wrap items-baseline justify-between gap-2">
<h3 class="font-semibold">{{ f.hvad }}</h3><span class="text-xs text-muted">{{ f.naar }}</span>
</div>
<p class="mt-1 text-sm text-muted">{{ f.note }}</p>
</div>
{% endfor %}
</div>
</section>
<section class="mt-10 grid gap-4 sm:grid-cols-2">
<div>
<h2 class="text-sm font-semibold uppercase tracking-widest text-accent">Primære kompetencer</h2>
<div class="mt-4 flex flex-wrap gap-2">
{% for k in d.primaere %}<span class="wt-tag">{{ k }}</span>{% endfor %}
</div>
<h2 class="mt-8 text-sm font-semibold uppercase tracking-widest text-accent">Sekundære</h2>
<div class="mt-4 flex flex-wrap gap-2">
{% for k in d.sekundaere %}<span class="wt-tag wt-muted">{{ k }}</span>{% endfor %}
</div>
</div>
<div>
<h2 class="text-sm font-semibold uppercase tracking-widest text-accent">Sprog</h2>
<ul class="mt-4 space-y-2 text-sm">
{% for s in d.sprog %}<li class="wt-card wt-card--compact">{{ s }}</li>{% endfor %}
</ul>
<h2 class="mt-8 text-sm font-semibold uppercase tracking-widest text-accent">Interesser</h2>
<p class="mt-4 text-sm text-muted">
Selvhosting og drift: jeg har gennem mange år bygget og vedligeholdt et homelab med
<a class="text-accent hover:underline" href="https://www.proxmox.com/" target="_blank" rel="noopener"> Proxmox</a>,
bag SSO via <a class="text-accent hover:underline" href="https://goauthentik.io/" target="_blank" rel="noopener">Authentik</a>.
Det har ført til bidrag i open source-projekter som Qutebrowser og Stregsystemet (IT-studerendes betalingssystem på AAU).
</p>
<p class="mt-3 text-sm text-muted">
Jeg er desuden licenseret radioamatør med <strong class="text-text">B-licens</strong> og kaldesignalet
<strong class="text-text">OZ0AGW</strong>.
</p>
</div>
</section>
<section class="mt-10">
<h2 class="text-sm font-semibold uppercase tracking-widest text-accent">Natur og jagt</h2>
<div class="mt-4 wt-card">
<p class="text-muted">
Jeg har jagttegn, men jagten er mest anledningen. Det jeg tager ud efter, er at stå op
længe før resten af landet og træde ud i den stilhed, der kun findes tidligt om morgenen,
og så se solen komme op og disen lette hen over markerne.
</p>
<p class="mt-3 text-muted">
Planter, fugle, årstidernes skiften: jeg kan lide at kunne sætte navn på det jeg ser, og
at lægge mærke til hvad der har ændret sig siden sidst. Ender turen uden at der bliver
nedlagt noget, har den været lige så meget værd.
</p>
</div>
</section>
{% endblock %}
+59
View File
@@ -0,0 +1,59 @@
{% extends "base.html" %}
{% block main %}
{% set d = load_data(path="data/forside-en.json") %}
<section class="grid items-center gap-8 py-6 sm:grid-cols-[1fr_auto]">
<div>
<p class="font-medium text-accent">Hi, I am</p>
<h1 class="mt-1 text-5xl font-bold tracking-tight sm:text-6xl">Asger Weirsøe</h1>
<p class="mt-3 text-xl font-semibold text-accent sm:text-2xl">Computer scientist &amp; Technical Solution Architect</p>
<p class="mt-5 max-w-xl text-lg text-muted">
M.Sc. in Computer Science from Aalborg University and Technical Solution Architect at Digisense A/S.
I have spent my career on Danish national <strong class="text-text">e-invoicing infrastructure</strong>,
where I have led the response when production broke and built the processes that make the next
response better. In my spare time I run my own Proxmox homelab, tinker with AI projects and get on
the air as a radio amateur (OZ0AGW).
</p>
<div class="mt-7 flex flex-wrap gap-3">
<a href="/en/cv" class="wt-btn wt-btn--primary wt-btn--lg">See my CV</a>
<a href="/en/chat" class="wt-btn wt-btn--lg">Ask my CV</a>
<a href="/en/projects" class="wt-btn wt-btn--lg">Projects</a>
</div>
</div>
{% include "partials/portraet.html" %}
</section>
<section class="mt-14 grid gap-4 sm:grid-cols-3">
{% for h in d.highlights %}
<div class="wt-card">
<h2 class="font-semibold tracking-tight">{{ h.title }}</h2>
<p class="mt-2 text-sm text-muted">{{ h.body }}</p>
</div>
{% endfor %}
</section>
<section class="mt-16">
<h2 class="text-2xl font-bold tracking-tight">More than a CV</h2>
<div class="mt-5 grid gap-4 sm:grid-cols-2">
{% for c in d.kort %}
<a href="{{ c.href | safe }}" class="group wt-card wt-card--hover">
<h3 class="font-semibold tracking-tight group-hover:text-accent">{{ c.title }} →</h3>
<p class="mt-2 text-sm text-muted">{{ c.desc }}</p>
</a>
{% endfor %}
</div>
<p class="mt-3 text-xs text-muted/80">Press <kbd class="wt-kbd">Ctrl/⌘ K</kbd> to jump anywhere, or run <code class="rounded bg-surface-2 px-1.5 py-0.5">curl asger.weirsoe.dk</code> in a terminal.</p>
</section>
<section class="mt-16">
<h2 class="text-2xl font-bold tracking-tight">Frequently asked questions</h2>
<div class="mt-5 grid gap-3">
{% for f in d.faqs %}
<details class="group wt-card">
<summary class="cursor-pointer list-none font-semibold tracking-tight marker:hidden">{{ f.q }}</summary>
<p class="mt-3 text-sm text-muted">{{ f.a }}</p>
</details>
{% endfor %}
</div>
<p class="mt-6 text-sm text-muted">The blog and the e-invoicing tools are in Danish.</p>
</section>
{% endblock %}
+80
View File
@@ -0,0 +1,80 @@
{% extends "base.html" %}
{% block main %}
{% set d = load_data(path="data/forside.json") %}
<section class="grid items-center gap-8 py-6 sm:grid-cols-[1fr_auto]">
<div>
<p class="font-medium text-accent">Hej, jeg er</p>
<h1 class="mt-1 text-5xl font-bold tracking-tight sm:text-6xl">Asger Weirsøe</h1>
<p class="mt-3 text-xl font-semibold text-accent sm:text-2xl">Datalog &amp; Technical Solution Architect</p>
<p class="mt-5 max-w-xl text-lg text-muted">
Datalog (cand.scient.) fra Aalborg Universitet og Technical Solution Architect hos Digisense A/S.
Til daglig bygger jeg bro mellem forretning og teknik og får <strong class="text-text">elektronisk fakturering</strong>
som Peppol og NemHandel til at virke i praksis. I min fritid drifter jeg mit eget
Proxmox-homelab, roder med AI-projekter og går på luften som radioamatør (OZ0AGW).
</p>
<div class="mt-7 flex flex-wrap gap-3">
<a href="/cv" class="wt-btn wt-btn--primary wt-btn--lg">Se mit CV</a>
<a href="/projekter" class="wt-btn wt-btn--lg">Projekter</a>
<a href="/blog" class="wt-btn wt-btn--lg">Blog</a>
</div>
</div>
{% include "partials/portraet.html" %}
</section>
<section class="mt-14 grid gap-4 sm:grid-cols-3">
{% for h in d.highlights %}
<div class="wt-card">
<h2 class="font-semibold tracking-tight">{{ h.title }}</h2>
<p class="mt-2 text-sm text-muted">{{ h.body }}</p>
</div>
{% endfor %}
</section>
<section class="mt-16">
<h2 class="text-2xl font-bold tracking-tight">Mere end et CV</h2>
<div class="mt-5 grid gap-4 sm:grid-cols-2">
{% for c in d.kort %}
<a href="{{ c.href | safe }}" class="group wt-card wt-card--hover">
<h3 class="font-semibold tracking-tight group-hover:text-accent">{{ c.title }} →</h3>
<p class="mt-2 text-sm text-muted">{{ c.desc }}</p>
</a>
{% endfor %}
</div>
<p class="mt-3 text-xs text-muted/80">Tryk <kbd class="wt-kbd">Ctrl/⌘ K</kbd> og hop direkte til alt, eller <code class="rounded bg-surface-2 px-1.5 py-0.5">curl asger.weirsoe.dk</code> i en terminal.</p>
</section>
<section class="mt-16">
<h2 class="text-2xl font-bold tracking-tight">Ofte stillede spørgsmål</h2>
<div class="mt-5 grid gap-3">
{% for f in d.faqs %}
<details class="group wt-card">
<summary class="cursor-pointer list-none font-semibold tracking-tight marker:hidden">
{{ f.q }}
</summary>
<p class="mt-3 text-sm text-muted">{{ f.a }}</p>
</details>
{% endfor %}
</div>
</section>
{% set blog = get_section(path="blog/_index.md") %}
{% set posts = blog.pages[:3] %}
{% if posts %}
<section class="mt-16">
<div class="flex items-baseline justify-between">
<h2 class="text-2xl font-bold tracking-tight">Seneste indlæg</h2>
<a href="/blog" class="text-sm text-accent hover:underline">Alle indlæg →</a>
</div>
<div class="mt-5 grid gap-4">
{% for p in posts %}
{{ <postcard p={p} /> }}
{% endfor %}
</div>
</section>
{% endif %}
{% endblock %}
{% block jsonld %}
{{ <ld g={seo.profilePage} /> }}
{% set d = load_data(path="data/forside.json") %}
{{ <faq items={d.faqs} /> }}
{% endblock %}
+113
View File
@@ -0,0 +1,113 @@
{% extends "base.html" %}
{% block main %}
<h1 class="text-4xl font-bold tracking-tight">NemHandel-opslag</h1>
<p class="mt-4 max-w-2xl text-muted">
Tast et CVR-nummer og se om virksomheden er registreret i NemHandelsregisteret, og hvilke
<strong class="text-text">profilpakker</strong> den understøtter. Det er et øjebliksbillede,
ikke en autoritativ kilde.
</p>
<form id="pep-form" class="mt-8 flex gap-3">
<input id="pep-cvr" type="text" inputmode="numeric" pattern="[0-9]{{ 8 }}" maxlength="8" required
placeholder="CVR (8 cifre)" autocomplete="off"
class="w-44 wt-input wt-input--lg" />
<button type="submit" class="wt-btn wt-btn--primary wt-btn--lg">Slå op</button>
</form>
<div id="pep-result" class="mt-8" hidden>
<div class="wt-card">
<div class="flex flex-wrap items-baseline justify-between gap-2">
<h2 id="pep-name" class="text-xl font-semibold tracking-tight"></h2>
<span id="pep-badge" class="wt-tag"></span>
</div>
<p id="pep-cvr-line" class="mt-1 text-sm text-muted"></p>
<div id="pep-caps" class="mt-4"></div>
</div>
</div>
<p id="pep-error" class="mt-8 text-sm text-muted" hidden></p>
<div id="peppol-net" class="mt-4" hidden></div>
<script>
(function () {
var form = document.getElementById('pep-form');
if (!form || form.__wired) return; form.__wired = true;
var esc = function (s) { return String(s).replace(/[&<>"]/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; }); };
var PROF = {
'urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0': 'Peppol BIS Billing 3.0',
'urn:fdc:peppol.eu:poacc:trns:order:3': 'Ordering (Order)',
'urn:fdc:peppol.eu:poacc:trns:order_response:3': 'Order Response',
'urn:fdc:peppol.eu:poacc:trns:despatch_advice:3': 'Despatch Advice',
'urn:fdc:peppol.eu:poacc:trns:catalogue:3': 'Catalogue',
'urn:fdc:peppol.eu:poacc:trns:invoice_response:3': 'Invoice Response',
'urn:fdc:peppol.eu:poacc:trns:mlr:3': 'Message Level Response',
};
async function loadPeppol(cvr) {
var box = document.getElementById('peppol-net'); box.hidden = false;
box.innerHTML = '<div class="wt-card"><p class="text-sm text-muted">Slår op i Peppol-netværket…</p></div>';
try {
var r = await fetch('/api/peppol/smp/' + encodeURIComponent(cvr));
var d = await r.json();
if (!r.ok) { box.innerHTML = '<div class="wt-card"><p class="text-sm text-muted">Peppol-opslag ikke tilgængeligt lige nu.</p></div>'; return; }
if (!d.on_peppol) {
box.innerHTML = '<div class="wt-card"><div class="flex items-baseline justify-between gap-2"><h2 class="text-xl font-semibold tracking-tight">Peppol-netværk</h2><span class="wt-tag wt-muted">Ikke fundet</span></div><p class="mt-2 text-sm text-muted">Virksomheden blev ikke fundet i Peppol Directory via CVR-opslag (kan stadig være på Peppol under et andet identifikator-scheme).</p></div>';
return;
}
var docs = (d.doctypes || []).map(function (dt) {
var label = PROF[dt.customization] || dt.customization;
return '<span class="wt-tag">' + esc(dt.document) + ' · ' + esc(label) + '</span>';
}).join('');
box.innerHTML = '<div class="wt-card">' +
'<div class="flex flex-wrap items-baseline justify-between gap-2"><h2 class="text-xl font-semibold tracking-tight">Peppol-netværk</h2>' +
'<span class="wt-tag wt-tag--accent">På Peppol</span></div>' +
'<p class="mt-1 text-sm text-muted">' + esc(d.name || '') + (d.country ? ' · ' + esc(d.country) : '') +
' · <span class="font-mono text-xs">' + esc(d.participant_id || '') + '</span>' + (d.reg_date ? ' · reg. ' + esc(d.reg_date) : '') + '</p>' +
'<p class="mt-4 text-xs font-semibold uppercase tracking-widest text-accent">Kan modtage</p>' +
'<div class="mt-2 flex flex-wrap gap-2">' + (docs || '<span class="text-sm text-muted">ingen dokumenttyper</span>') + '</div>' +
'<p class="mt-3 text-xs text-muted/80">Live fra Peppol Directory via egen proxy, intet gemt.</p></div>';
} catch (_) {
box.innerHTML = '<div class="wt-card"><p class="text-sm text-muted">Kunne ikke nå Peppol-opslaget.</p></div>';
}
}
form.addEventListener('submit', async function (e) {
e.preventDefault();
var res = document.getElementById('pep-result'), err = document.getElementById('pep-error');
res.hidden = true; err.hidden = true;
var cvr = document.getElementById('pep-cvr').value.trim();
var btn = form.querySelector('button'); btn.disabled = true; btn.textContent = 'Slår op…';
loadPeppol(cvr);
try {
var r = await fetch('/api/nemhandel/' + encodeURIComponent(cvr));
var data = await r.json();
if (!r.ok) { err.textContent = data.error || 'Noget gik galt.. prøv igen.'; err.hidden = false; return; }
document.getElementById('pep-name').textContent = data.name || 'Ukendt navn';
document.getElementById('pep-cvr-line').textContent = 'CVR ' + data.cvr;
var badge = document.getElementById('pep-badge');
badge.textContent = data.registered ? 'Registreret i NemHandel' : 'Ikke registreret';
badge.className = 'wt-tag ' + (data.registered ? 'wt-tag--accent' : 'wt-muted');
var caps = document.getElementById('pep-caps');
if (data.registered && (data.profiles.length || data.networks.length)) {
caps.innerHTML =
(data.networks.length ? '<p class="text-xs font-semibold uppercase tracking-widest text-accent">Netværk</p>' +
'<div class="mt-2 flex flex-wrap gap-2">' + data.networks.map(function (n) {
return '<span class="wt-tag">' + esc(n) + '</span>'; }).join('') + '</div>' : '') +
(data.profiles.length ? '<p class="mt-4 text-xs font-semibold uppercase tracking-widest text-accent">Profilpakker</p>' +
'<div class="mt-2 flex flex-wrap gap-2">' + data.profiles.map(function (p) {
return '<span class="wt-tag">' + esc(p) + '</span>'; }).join('') + '</div>' : '');
} else {
caps.innerHTML = '<p class="text-sm text-muted">' + (data.registered
? 'Registreret, men ingen profildata i seneste opdatering.'
: 'Virksomheden findes i CVR, men er ikke registreret som e-faktura-modtager.') + '</p>';
}
res.hidden = false;
} catch (_) {
err.textContent = 'Kunne ikke nå opslagstjenesten.. prøv igen om lidt.'; err.hidden = false;
} finally { btn.disabled = false; btn.textContent = 'Slå op'; }
});
})();
</script>
<p class="mt-10 text-xs text-muted/80">
API: <code class="rounded bg-surface-2 px-1.5 py-0.5">curl https://asger.weirsoe.dk/api/nemhandel/&lt;cvr&gt;</code> (rate-limited).
</p>
{% endblock %}
+32
View File
@@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block main %}
{% set d = load_data(path="data/now.json") %}
{% set g = load_data(path="data/gitea.json") %}
<h1 class="text-4xl font-bold tracking-tight">Now</h1>
<p class="mt-2 text-sm text-muted">Senest opdateret {{ g.updated }} · à la <a class="text-accent hover:underline" href="https://nownownow.com/about" target="_blank" rel="noopener">nownownow.com</a></p>
<section class="mt-8">
<h2 class="text-sm font-semibold uppercase tracking-widest text-accent">Lige nu</h2>
<ul class="mt-4 space-y-3">
{% for f in d.focus %}
<li class="wt-card text-sm">{{ f }}</li>
{% endfor %}
</ul>
</section>
<section class="mt-10">
<h2 class="text-sm font-semibold uppercase tracking-widest text-accent">Git-aktivitet</h2>
<p class="mt-2 text-sm text-muted">{{ g.total }} bidrag i mit eget Gitea det seneste år, de seneste {{ g.weeks_shown }} uger:</p>
<div class="mt-4 overflow-x-auto wt-card">
<div class="heatmap" role="img" aria-label="Aktivitets-heatmap over git-bidrag">
{% for w in g.weeks %}
<div class="hm-col">
{% for c in w %}<span class="hm-cell hm-{{ c.level }}" title="{{ c.date }}: {{ c.n }} bidrag"></span>{% endfor %}
</div>
{% endfor %}
</div>
</div>
<p class="mt-2 text-xs text-muted/80">Offentlige projekter ligger på <a class="text-accent hover:underline" href="https://gitea.weircon.dk/agw" target="_blank" rel="noopener">gitea.weircon.dk</a>.</p>
</section>
{% endblock %}
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="da">
<head>
<meta charset="utf-8" />
<meta name="robots" content="noindex, follow" />
<meta http-equiv="refresh" content="0; url=/nemhandel" />
<link rel="canonical" href="https://asger.weirsoe.dk/nemhandel" />
<title>NemHandel-opslag</title>
</head>
<body>
<p>Siden er flyttet til <a href="/nemhandel">/nemhandel</a>…</p>
<script>location.replace('/nemhandel');</script>
</body>
</html>
+156
View File
@@ -0,0 +1,156 @@
{% extends "base.html" %}
{% block main %}
{% set d = load_data(path="data/profiles.json") %}
<h1 class="text-4xl font-bold tracking-tight">Profil-opslag</h1>
<p class="mt-4 max-w-2xl text-muted">
Browse og søg i e-dokumentprofilerne for <strong class="text-text">Nemhandel OIOUBL</strong> og
<strong class="text-text">Peppol BIS/PINT</strong>: profil-/proces-id, customization-id, hvilke
dokumenter profilen indeholder og typisk anvendelse. Klik på et id for at kopiere det.
</p>
<p class="mt-3 max-w-2xl text-sm text-muted">
De danske profiler står i samme rækkefølge og under samme gruppenavne som i
<strong class="text-text">Nemhandelsregistrets</strong> profil-dropdown (“Profiler for: …”), så du kan
slå direkte op mellem registrering og dokumentindhold.
</p>
<div class="mt-8 flex flex-col gap-4 sm:flex-row sm:items-center">
<div class="relative flex-1">
<span class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted">⌕</span>
<input
id="p-search"
type="search"
placeholder="Søg profil, id, dokument…"
autocomplete="off"
aria-label="Søg profiler"
class="w-full wt-input wt-input--lg pl-9"
/>
</div>
<span id="p-count" class="text-sm text-muted">{{ d.profiles | length }} profiler</span>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<button type="button" class="wt-btn wt-btn--ghost wt-btn--sm" data-std="" aria-pressed="true">Alle</button>
{% for s in d.standarder %}<button type="button" class="wt-btn wt-btn--ghost wt-btn--sm" data-std="{{ s }}" aria-pressed="false">{{ s }}</button>{% endfor %}
</div>
<div class="mt-3">
<span class="mr-2 text-xs text-muted">Nemhandelsregistrets kategori:</span>
<span class="mt-1 inline-flex flex-wrap gap-2 align-middle">
<button type="button" class="wt-btn wt-btn--ghost wt-btn--sm" data-kat="" aria-pressed="true">Alle</button>
{% for k in d.kategorier %}<button type="button" class="wt-btn wt-btn--ghost wt-btn--sm" data-kat="{{ k }}" aria-pressed="false">{{ k }}</button>{% endfor %}
</span>
</div>
<div class="mt-6 space-y-3">
{%- for p in d.profiles %}
<article
data-profile
data-standard="{{ p.standard }}"
data-kategori="{{ p.kategori }}"
data-search="{{ (p.standard ~ " " ~ p.kategori ~ " " ~ p.navn ~ " " ~ p.id ~ " " ~ p.customization ~ " " ~ (p.dokumenter | join(sep=" ")) ~ " " ~ p.anvendelse ~ " " ~ p.bemaerkninger) | lower }}"
class="wt-card wt-card--hover"
>
<div class="flex flex-wrap items-center gap-2">
<span class="wt-tag wt-tag--accent">
{{ p.standard }}
</span>
{% if p.kategori %}
<span
class="wt-tag wt-muted"
title="Gruppen profilen står under i Nemhandelsregistrets dropdown"
>
Register: {{ p.kategori }}
</span>
{% endif %}
</div>
<h2 class="mt-2 text-lg font-semibold tracking-tight">{{ p.navn }}</h2>
<dl class="mt-3 grid gap-1 text-xs sm:grid-cols-[10rem_1fr]">
<dt class="text-muted">Profil/proces-id</dt>
<dd>
<button type="button" data-copy="{{ p.id }}" title="Klik for at kopiere" class="copy break-all text-left font-mono">
{{ p.id }}
</button>
</dd>
<dt class="text-muted">Customization-id</dt>
<dd>
<button type="button" data-copy="{{ p.customization }}" title="Klik for at kopiere" class="copy break-all text-left font-mono">
{{ p.customization }}
</button>
</dd>
</dl>
<div class="mt-3 flex flex-wrap gap-1.5">
{% for doc in p.dokumenter %}
<span class="wt-tag">{{ doc }}</span>
{% endfor %}
</div>
<p class="mt-3 text-sm text-muted">{{ p.anvendelse }}</p>
<p class="mt-2 text-xs text-muted">
<span class="font-semibold text-muted">Bemærk:</span> {{ p.bemaerkninger }}
</p>
<p class="mt-3 text-xs"><a href="{{ p.kilde.url | safe }}" target="_blank" rel="noopener" class="text-accent/80 transition-colors hover:text-accent">Kilde: {{ p.kilde.label }} ↗</a></p>
</article>
{%- endfor %}
</div>
<p id="p-none" class="mt-6 text-sm text-muted" hidden>Ingen profiler matcher.</p>
<p class="mt-10 text-xs text-muted">
Kilder: OIOUBL Profiler-guiden (G26) v1.3 (dokumentlister, tabel 1-3), Nemhandelsregistret
(gruppering og profilnavne) og Peppol BIS/PINT (docs.peppol.eu). * = ApplicationResponse bruges kun
til afvisning; det forretningsmæssige svar på en ordre er altid OrderResponse/OrderResponseSimple.
Øjebliksbillede.. verificér altid id'er mod den officielle dokumentation før brug.
</p>
<script>
(function () {
var cards = [].slice.call(document.querySelectorAll('[data-profile]'));
var search = document.getElementById('p-search');
var stdChips = [].slice.call(document.querySelectorAll('button[data-std]'));
var katChips = [].slice.call(document.querySelectorAll('button[data-kat]'));
var count = document.getElementById('p-count');
var none = document.getElementById('p-none');
var q = '', std = '', kat = '';
function apply() {
var shown = 0;
cards.forEach(function (c) {
var okS = !std || c.getAttribute('data-standard') === std;
var okK = !kat || c.getAttribute('data-kategori') === kat;
var okQ = !q || c.getAttribute('data-search').indexOf(q) > -1;
var hit = okS && okK && okQ;
c.hidden = !hit;
if (hit) shown++;
});
count.textContent = shown + (shown === 1 ? ' profil' : ' profiler');
none.hidden = shown > 0;
}
search.addEventListener('input', function () { q = search.value.trim().toLowerCase(); apply(); });
function wire(group, attr, set) {
group.forEach(function (b) {
b.addEventListener('click', function () {
set(b.getAttribute(attr) || '');
group.forEach(function (x) { x.setAttribute('aria-pressed', x === b ? 'true' : 'false'); });
apply();
});
});
}
wire(stdChips, 'data-std', function (v) { std = v; });
wire(katChips, 'data-kat', function (v) { kat = v; });
document.addEventListener('click', function (e) {
var btn = e.target.closest && e.target.closest('[data-copy]');
if (!btn || !navigator.clipboard) return;
navigator.clipboard.writeText(btn.getAttribute('data-copy')).then(function () {
var orig = btn.textContent;
btn.textContent = 'kopieret ✓';
setTimeout(function () { btn.textContent = orig; }, 1200);
}).catch(function () {});
});
var qp = new URLSearchParams(location.search).get('q');
if (qp) { search.value = qp; q = qp.trim().toLowerCase(); }
apply();
})();
</script>
{% endblock %}
+21
View File
@@ -0,0 +1,21 @@
{% extends "base.html" %}
{% block main %}
<h1 class="text-4xl font-bold tracking-tight">Projects</h1>
<p class="mt-3 text-muted">Tools I maintain and publish as packages. Everything else lives on my <a class="text-accent hover:underline" href="https://gitea.weircon.dk/agw">Gitea</a>.</p>
<div class="mt-8 grid gap-4 sm:grid-cols-2">
{% for p in section.pages %}
<a href="{{ p.path | trim_end(pat="/") | safe }}"
class="group wt-card wt-card--hover">
<div class="flex items-center justify-between">
<h3 class="font-semibold tracking-tight group-hover:text-accent">{{ p.title }}</h3>
{% if p.extra.aur %}<span class="text-xs text-muted">AUR</span>{% endif %}
</div>
<p class="mt-2 line-clamp-3 text-sm text-muted">{{ p.description }}</p>
<div class="mt-3 text-xs text-accent">{{ p.extra.sprog }} · Read more →</div>
</a>
{% endfor %}
</div>
{% endblock %}
{% block jsonld %}
{{ <collection url={config.extra.site ~ "/en/projects"} name="Projects" description="Tools I write and publish on the AUR, Homebrew and Scoop." /> }}
{% endblock %}
+35
View File
@@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block main %}
{% set g = load_data(path="data/gitea.json") %}
<h1 class="text-4xl font-bold tracking-tight">Projekter</h1>
<p class="mt-3 text-muted">Hentet live fra min <a class="text-accent hover:underline" href="https://gitea.weircon.dk/agw">Gitea</a>.</p>
{% if g.error %}<p class="mt-8 wt-card text-muted">Kunne ikke hente repositories lige nu.</p>{% endif %}
<div class="mt-8 grid gap-4 sm:grid-cols-2">
{%- for r in g.repos %}
{%- set side = [s for s in section.pages if s.extra.repo == r.name] | first %}
{%- if side %}
<a href="{{ side.path | trim_end(pat="/") | safe }}"
class="group wt-card wt-card--hover">
<div class="flex items-center justify-between">
<h3 class="font-semibold tracking-tight group-hover:text-accent">{{ side.title }}</h3>
{% if side.extra.aur %}<span class="text-xs text-muted">AUR</span>{% endif %}
</div>
<p class="mt-2 line-clamp-3 text-sm text-muted">{{ side.description }}</p>
<div class="mt-3 text-xs text-accent">{{ side.extra.sprog }} · Læs mere →</div>
</a>
{%- else %}
<a href="{{ r.html_url | safe }}" target="_blank" rel="noopener"
class="group wt-card wt-card--hover">
<div class="flex items-center justify-between">
<h3 class="font-semibold tracking-tight group-hover:text-accent">{{ r.name }}</h3>
{% if r.stars_count > 0 %}<span class="text-xs text-muted">★ {{ r.stars_count }}</span>{% endif %}
</div>
<p class="mt-2 line-clamp-3 text-sm text-muted">{% if r.description %}{{ r.description }}{% else %}Ingen beskrivelse.{% endif %}</p>
{% if r.language %}<div class="mt-3 text-xs text-accent">{{ r.language }}</div>{% endif %}
</a>
{%- endif %}
{%- endfor %}
</div>
{% endblock %}
+16
View File
@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block main %}
<h1 class="text-4xl font-bold tracking-tight">Status</h1>
<p class="mt-4 max-w-2xl text-muted">
Live status for de offentlige dele af mit homelab, målt af en self-hosted
<a class="text-accent hover:underline" href="https://uptime.kuma.pet" target="_blank" rel="noopener">Uptime Kuma</a>.
</p>
<div class="mt-8 wt-card wt-card--flush">
<iframe src="https://status.weircon.dk/status/weircon" title="Uptime Kuma statusside" loading="lazy"
class="h-[70vh] w-full" style="border:0"></iframe>
</div>
<p class="mt-3 text-sm text-muted">
Vises siden ikke? Åbn den direkte: <a class="text-accent hover:underline" href="https://status.weircon.dk/status/weircon" target="_blank" rel="noopener">https://status.weircon.dk/status/weircon</a>
</p>
{% endblock %}
+184
View File
@@ -0,0 +1,184 @@
{% extends "base.html" %}
{% block main %}
{% set d = load_data(path="data/profiles.json") %}
<h1 class="text-4xl font-bold tracking-tight">UBL-tjek</h1>
<p class="mt-4 max-w-2xl text-muted">
Træk en <strong class="text-text">UBL/OIOUBL-faktura</strong> (eller andet UBL-dokument) ind, eller indsæt en
<strong class="text-text">CustomizationID/ProfileID</strong>, så afkoder den profilen, syntaksen og
EN 16931-status og kobler til <a href="/profiler" class="text-accent">profil-opslaget</a>.
Alt sker <strong class="text-text">lokalt i din browser</strong>; intet sendes nogen steder.
</p>
<p class="mt-3 max-w-2xl text-muted">
Vil du læse hele fakturaen eller have den vedhæftede PDF ud, så har jeg skrevet
<a href="/projekter/ubl-tools" class="text-accent hover:underline">ubl-tools</a> til terminalen,
også på Windows og macOS.
</p>
<div id="drop" class="mt-8 wt-dropzone">
<p class="text-muted">Træk en <code class="rounded bg-surface-2 px-1.5 py-0.5">.xml</code>-fil hertil, eller
<label class="cursor-pointer text-accent hover:underline">vælg en fil<input id="file" type="file" accept=".xml,text/xml,application/xml" class="hidden" /></label>.
</p>
</div>
<details class="mt-3">
<summary class="cursor-pointer text-sm text-muted">…eller indsæt XML / en identifikator manuelt</summary>
<textarea id="paste" rows="6" placeholder="Indsæt UBL-XML, eller bare en CustomizationID/ProfileID…"
class="mt-2 w-full wt-input wt-input--lg font-mono text-xs"></textarea>
<p class="mt-2"><button id="analyze" type="button" class="wt-btn wt-btn--primary">Afkod</button></p>
</details>
<div id="result" class="mt-6" hidden></div>
<p id="err" class="mt-6 text-sm" hidden></p>
<p class="mt-10 text-xs text-muted/80">
Heuristik mod kendte profiler (samme datagrundlag som <a href="/profiler" class="text-accent">/profiler</a>) +
EN 16931-/Peppol-/OIOUBL-mønstre. Privat: ingen upload, ingen sporing af indholdet.
</p>
<script type="application/json" id="profiles-data" >[{% for p in d.profiles %}{"standard":{{ p.standard | json_encode | safe }},"navn":{{ p.navn | json_encode | safe }},"id":{{ p.id | json_encode | safe }},"customization":{{ p.customization | json_encode | safe }},"dokumenter":{{ p.dokumenter | json_encode | safe }}}{% if not loop.last %},{% endif %}{% endfor %}]</script>
<script>
(function () {
var PROFILES = JSON.parse(document.getElementById('profiles-data').textContent || '[]');
var drop = document.getElementById('drop'), fileInput = document.getElementById('file'),
paste = document.getElementById('paste'), result = document.getElementById('result'),
err = document.getElementById('err');
var esc = function (s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; }); };
// Electronic Address Scheme (ISO 6523): de gængse, DK/Norden i fokus.
var EAS = {
'0088': 'GLN (GS1 Global Location Number)',
'0184': 'DK:CVR (dansk CVR-nummer)',
'0198': 'DK:SE (dansk SE-nummer)',
'0096': 'DK:P (dansk P-nummer)',
'0192': 'NO:ORG (norsk organisasjonsnummer)',
'0007': 'SE:ORG (svensk organisationsnummer)',
'0208': 'BE:CBE (belgisk virksomhedsnr.)',
'0151': 'AU:ABN', '0060': 'DUNS', '0235': 'JP', '0211': 'IT:PA',
'9915': 'Peppol test-deltager', '9930': 'DE:LWID (Leitweg-ID)',
};
function findEl(doc, name) {
var all = doc.getElementsByTagName('*');
for (var i = 0; i < all.length; i++) if (all[i].localName === name) return all[i];
return null;
}
function txt(doc, name) { var e = findEl(doc, name); return e ? (e.textContent || '').trim() : ''; }
// afkod en CustomizationID/ProfileID-streng til menneskesprog
function decode(custom, profile) {
var notes = [], standard = null, match = null;
var c = (custom || '').trim(), p = (profile || '').trim();
// 1) eksakt match mod kendte profiler
match = PROFILES.find(function (x) { return c && x.customization === c; })
|| PROFILES.find(function (x) { return p && x.id === p; })
|| PROFILES.find(function (x) { return c && x.id === c; });
// 2) mønster-heuristik (også når der ikke er fuldt match)
var blob = (c + ' ' + p).toLowerCase();
if (blob.indexOf('en16931') > -1) notes.push('EN 16931-kompatibel (CIUS af den europæiske kernefaktura)');
if (blob.indexOf('peppol.eu') > -1 || blob.indexOf('fdc:peppol') > -1) { standard = 'Peppol BIS'; notes.push('Peppol-netværket'); }
if (blob.indexOf('pint') > -1) { standard = 'Peppol PINT'; notes.push('PINT (Peppol International)'); }
if (blob.indexOf('billing:3.0') > -1) notes.push('Billing 3.0-proces');
if (blob.indexOf('selfbilling') > -1) notes.push('Selvfakturering (køber udsteder)');
if (blob.indexOf('oioubl') > -1) { standard = standard || 'OIOUBL (NemHandel)'; notes.push('OIOUBL, dansk UBL-tilpasning'); }
if (/procurement-/i.test(p)) { standard = standard || 'OIOUBL (NemHandel)'; if (/r-|r$|simr|advr/i.test(p)) notes.push("'R' = svar påkrævet"); }
return { match: match, standard: match ? match.standard : standard, notes: notes };
}
function badge(text, kind) {
return '<span class="wt-tag' + (kind === 'ok' ? ' wt-tag--accent' : '') + '">' + esc(text) + '</span>';
}
function row(dt, dd) {
return '<div class="grid gap-1 sm:grid-cols-[10rem_1fr]"><dt class="text-muted">' + esc(dt) +
'</dt><dd class="break-all font-mono">' + (dd || '<span class="text-muted">—</span>') + '</dd></div>';
}
function renderXml(doc) {
var root = doc.documentElement;
var docType = root ? root.localName : '?';
var ublv = txt(doc, 'UBLVersionID');
var custom = txt(doc, 'CustomizationID');
var profile = txt(doc, 'ProfileID');
var id = txt(doc, 'ID');
var dec = decode(custom, profile);
// EAS på parter
var endpoints = [];
var eps = doc.getElementsByTagName('*');
for (var i = 0; i < eps.length; i++) {
if (eps[i].localName === 'EndpointID') {
var scheme = eps[i].getAttribute('schemeID') || '';
endpoints.push({ scheme: scheme, val: (eps[i].textContent || '').trim() });
}
}
var epHtml = endpoints.slice(0, 4).map(function (e) {
return '<div>' + badge((EAS[e.scheme] || ('EAS ' + e.scheme || 'ukendt')), '') +
' <span class="font-mono text-xs">' + esc(e.val) + '</span></div>';
}).join('');
var head = dec.match
? '<div class="flex flex-wrap items-center gap-2">' + badge(dec.match.standard, 'ok') +
'</div><h2 class="mt-2 text-xl font-semibold tracking-tight">' + esc(dec.match.navn) + '</h2>'
: '<h2 class="text-xl font-semibold tracking-tight">' + esc(docType) +
(dec.standard ? ' <span class="text-muted text-base font-normal">· ' + esc(dec.standard) + '</span>' : '') + '</h2>';
result.innerHTML =
'<div class="wt-card">' + head +
'<dl class="mt-3 space-y-1 text-xs">' +
row('Dokumenttype', '<span class="font-semibold">' + esc(docType) + '</span> (UBL ' + (esc(ublv) || '2.x') + ')') +
row('ID', esc(id)) +
row('ProfileID', esc(profile)) +
row('CustomizationID', esc(custom)) +
'</dl>' +
(dec.notes.length ? '<div class="mt-3 flex flex-wrap gap-1.5">' + dec.notes.map(function (n) { return badge(n, ''); }).join('') + '</div>' : '') +
(epHtml ? '<p class="mt-3 text-xs text-muted">Parter (EAS):</p><div class="mt-1 space-y-1">' + epHtml + '</div>' : '') +
(dec.match ? '<p class="mt-3 text-xs"><a class="text-accent hover:underline" href="/profiler?q=' + encodeURIComponent(dec.match.id) + '">Se profilen i /profiler ↗</a></p>'
: '<p class="mt-3 text-xs text-muted">Ingen eksakt match i profil-databasen, afkodet ud fra mønstre.</p>') +
'</div>';
result.hidden = false; err.hidden = true;
}
function renderId(s) {
var dec = decode(s, s);
result.innerHTML = '<div class="wt-card">' +
(dec.match ? '<div class="flex flex-wrap items-center gap-2">' + badge(dec.match.standard, 'ok') +
'</div><h2 class="mt-2 text-xl font-semibold tracking-tight">' + esc(dec.match.navn) + '</h2>'
: '<h2 class="text-xl font-semibold tracking-tight">Identifikator</h2>') +
'<dl class="mt-3 space-y-1 text-xs">' + row('Streng', esc(s)) + '</dl>' +
(dec.notes.length ? '<div class="mt-3 flex flex-wrap gap-1.5">' + dec.notes.map(function (n) { return badge(n, ''); }).join('') + '</div>'
: '<p class="mt-3 text-xs text-muted">Genkendte ingen kendte mønstre.</p>') +
(dec.match ? '<p class="mt-3 text-xs"><a class="text-accent hover:underline" href="/profiler?q=' + encodeURIComponent(dec.match.id) + '">Se profilen i /profiler ↗</a></p>' : '') +
'</div>';
result.hidden = false; err.hidden = true;
}
function analyze(text) {
text = (text || '').trim();
if (!text) return;
if (text.indexOf('<') === 0 || text.indexOf('<?xml') === 0) {
var doc = new DOMParser().parseFromString(text, 'application/xml');
if (doc.getElementsByTagName('parsererror').length) {
err.textContent = 'Kunne ikke parse XML.. er filen velformet?'; err.hidden = false; result.hidden = true; return;
}
renderXml(doc);
} else {
renderId(text);
}
}
// fil-input + drag/drop
function readFile(f) { var r = new FileReader(); r.onload = function () { analyze(String(r.result)); }; r.readAsText(f); }
fileInput.addEventListener('change', function () { if (fileInput.files[0]) readFile(fileInput.files[0]); });
['dragenter', 'dragover'].forEach(function (ev) { drop.addEventListener(ev, function (e) {
e.preventDefault(); drop.classList.add('is-over'); }); });
['dragleave', 'drop'].forEach(function (ev) { drop.addEventListener(ev, function (e) {
e.preventDefault(); drop.classList.remove('is-over'); }); });
drop.addEventListener('drop', function (e) { var f = e.dataTransfer && e.dataTransfer.files[0]; if (f) readFile(f); });
document.getElementById('analyze').addEventListener('click', function () { analyze(paste.value); });
paste.addEventListener('keydown', function (e) { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') analyze(paste.value); });
})();
</script>
{% endblock %}
+23
View File
@@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block main %}
{% set d = load_data(path="data/uses.json") %}
<h1 class="text-4xl font-bold tracking-tight">Uses</h1>
<p class="mt-4 max-w-2xl text-muted">
Inspireret af <a class="text-accent hover:underline" href="https://uses.tech" target="_blank" rel="noopener">uses.tech</a>:
det jeg faktisk bruger. Kort version: alt der kan selvhostes, bliver selvhostet.
</p>
{% for s in d.sections %}
<section class="mt-10">
<h2 class="text-sm font-semibold uppercase tracking-widest text-accent">{{ s.title }}</h2>
<div class="mt-4 space-y-3">
{% for it in s.items %}
<div class="wt-card">
<span class="font-semibold">{{ it.name }}</span>
<p class="mt-1 text-sm text-muted">{{ it.note }}</p>
</div>
{% endfor %}
</div>
</section>
{% endfor %}
{% endblock %}
+11
View File
@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block main %}
<h1 class="text-4xl font-bold tracking-tight">Tags</h1>
<div class="mt-8 flex flex-wrap gap-2">
{%- for t in terms | sort(attribute="page_count") | reverse %}
<a href="/tags/{{ t.slug }}" class="wt-chip">#{{ t.name }} <span class="text-muted">({{ t.page_count }})</span></a>
{%- else %}
<p class="text-muted">Ingen tags endnu.</p>
{%- endfor %}
</div>
{% endblock %}
+8
View File
@@ -0,0 +1,8 @@
{% extends "base.html" %}
{% block main %}
<a href="/tags" class="text-sm text-muted hover:text-accent">← Alle tags</a>
<h1 class="mt-3 text-4xl font-bold tracking-tight">#{{ term.name }}</h1>
<div class="mt-8 grid gap-4">
{% for p in term.pages %}{{ <postcard p={p} /> }}{% endfor %}
</div>
{% endblock %}