diff --git a/astro.config.mjs b/astro.config.mjs index dad198a..895f5eb 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -140,6 +140,9 @@ export default defineConfig({ sitemap({ changefreq: 'weekly', priority: 0.7, + // /tarballz/ er ikke en Astro-side (genereres på webhosten), men oversigten må + // gerne indekseres; selve pakkefilerne er noindex der. + customPages: ['https://asger.xn--weirse-fya.dk/tarballz/'], // Tag-siderne er noindex (tynde lister), så de hører heller ikke i sitemap'et. // /peppol er kun en omdirigering til /nemhandel og skal ikke indekseres. filter: (page) => { diff --git a/src/components/Head.astro b/src/components/Head.astro index 1df2d23..e07dc59 100644 --- a/src/components/Head.astro +++ b/src/components/Head.astro @@ -14,11 +14,17 @@ interface Props { // Tynde oversigtssider (fx tags med 1-2 indlæg) skal ikke i Googles indeks, // men links fra dem skal stadig følges. noindex?: boolean; + // Sidens sprog (standard: dansk) og dens oversættelser, som hreflang-links. + lang?: 'da' | 'en'; + alternates?: { lang: 'da' | 'en'; path: string }[]; } const { title, description, image, type = 'website', publishedTime, modifiedTime, tags = [], jsonLd = [], noindex = false, + lang = 'da', alternates = [], } = Astro.props; +const hreflang = alternates.map((a) => ({ lang: a.lang, href: new URL(a.path, SITE.url).href })); +const xDefault = hreflang.find((a) => a.lang === 'da'); const pageTitle = title ? `${title} · ${SITE.name}` : `${SITE.title} · Datalog & Technical Solution Architect`; const desc = description ?? SITE.description; @@ -45,7 +51,9 @@ const graphs = [baseGraph, ...jsonLd]; - + +{hreflang.map((a) => )} +{xDefault && } diff --git a/src/components/ProjektSide.astro b/src/components/ProjektSide.astro new file mode 100644 index 0000000..ef3d08b --- /dev/null +++ b/src/components/ProjektSide.astro @@ -0,0 +1,82 @@ +--- +// Én projektside, på dansk eller engelsk. Ruterne /projekter/[slug] og +// /en/projects/[slug] giver bare indgangen og evt. oversættelsen med. +import type { CollectionEntry } from 'astro:content'; +import { render } from 'astro:content'; +import Base from '../layouts/Base.astro'; +import { SITE } from '../consts'; +import { softwareSourceCode, breadcrumb } from '../seo'; + +interface Props { + side: CollectionEntry<'projekter'>; + oversat?: CollectionEntry<'projekter'>; // samme projekt på det andet sprog +} +const { side, oversat } = Astro.props; +const { Content } = await render(side); +const d = side.data; +const en = d.lang === 'en'; +const slug = side.id.replace(/^en\//, ''); +const sti = (lang: 'da' | 'en') => (lang === 'en' ? `/en/projects/${slug}` : `/projekter/${slug}`); +const oversigt = en ? '/en/projects' : '/projekter'; + +const T = en + ? { hjem: 'Home', projekter: 'Projects', sprog: 'Language', licens: 'Licence', koerer: 'Runs on', + kilde: 'Source on Gitea', bin: 'Prebuilt packages', skift: 'Dansk' } + : { hjem: 'Forside', projekter: 'Projekter', sprog: 'Sprog', licens: 'Licens', koerer: 'Kører på', + kilde: 'Kildekode på Gitea', bin: 'Færdigbyggede pakker', skift: 'English' }; + +const url = new URL(sti(d.lang), SITE.url).href; +const repo = `${SITE.giteaBase}/${SITE.giteaUser}/${d.repo}`; +const aur = d.aur.map((n) => ({ navn: n, url: `https://aur.archlinux.org/packages/${n}` })); +const published = d.date.toISOString(); +const modified = (d.updated ?? d.date).toISOString(); +const ld = softwareSourceCode({ + url, name: d.title, description: d.description, codeRepository: repo, + language: d.sprog, license: d.licens, platforms: d.platforme, + sameAs: aur.map((a) => a.url), dateCreated: published, dateModified: modified, keywords: d.tags, + lang: d.lang, +}); +const crumbs = breadcrumb([ + { name: T.hjem, url: `${SITE.url}/` }, + { name: T.projekter, url: `${SITE.url}${oversigt}` }, + { name: d.title, url }, +]); +const alternates = oversat + ? [{ lang: d.lang, path: sti(d.lang) }, { lang: oversat.data.lang, path: sti(oversat.data.lang) }] + : []; +--- + +
+ + {oversat && ( + {T.skift} + )} +
+
+
+

{d.title}

+

{d.description}

+
+
{T.sprog}
{d.sprog}
+
{T.licens}
{d.licens}
+ {d.platforme.length > 0 &&
{T.koerer}
{d.platforme.join(', ')}
} +
+
+ {T.kilde} + {aur.map((a) => ( + AUR: {a.navn} + ))} + {d.aur.some((n) => n.endsWith('-bin')) && ( + {T.bin} + )} +
+
+
+
+ diff --git a/src/content.config.ts b/src/content.config.ts index 87ae8d9..c29250d 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -14,7 +14,8 @@ const blog = defineCollection({ }), }); -// Projektsider (/projekter/): én markdown-fil pr. værktøj jeg udgiver. +// Projektsider: én markdown-fil pr. værktøj jeg udgiver. Dansk i roden +// (/projekter/), engelsk i en/ (/en/projects/) med samme filnavn. // Frontmatter bærer de faste links, så siden og søgeindekset kan vise dem ens. const projekter = defineCollection({ loader: glob({ pattern: '**/*.md', base: './src/content/projekter' }), @@ -31,6 +32,7 @@ const projekter = defineCollection({ date: z.coerce.date(), // første udgivelse updated: z.coerce.date().optional(), draft: z.boolean().default(false), + lang: z.enum(['da', 'en']).default('da'), }), }); diff --git a/src/content/projekter/en/ahfail.md b/src/content/projekter/en/ahfail.md new file mode 100644 index 0000000..9daace1 --- /dev/null +++ b/src/content/projekter/en/ahfail.md @@ -0,0 +1,56 @@ +--- +title: "ahfail" +description: "A screen locker for Linux and macOS. Type the wrong password and Dennis Nedry shows up to tell you \"ah ah ah, you didn't say the magic word\"." +repo: gtk-ahfail +sprog: Rust +licens: AGPL-3.0-only +platforme: [Linux (Wayland), Linux (X11), macOS] +aur: [ahfail, ahfail-bin] +tags: [linux, wayland, x11, macos, pam, rust] +date: 2026-09-30 +updated: 2026-09-30 +lang: en +--- + +Get the password wrong and a looping animation of my own face photoshopped onto Dennis Nedry from Jurassic Park appears, while the "ah ah ah, you didn't say the magic word" clip plays. The volume goes all the way up, so whoever tried to sneak into your machine gets the full experience (`--no-max-volume` if you'd rather be discreet). Under the joke it's a real screen locker. + +```bash +ahfail # lock the screen +ahfail test # see the effect without locking +ahfail doctor # what is this machine missing? +``` + +It's one program on Linux, and it works out by itself whether you run Wayland or X11. On Wayland it locks through `ext-session-lock-v1`, so it works in sway, Hyprland, niri, river, labwc and KDE Plasma 6. GNOME doesn't let other programs lock the screen, so it's out there. On X11 it does what i3lock does: full-screen windows on every monitor and a grab on keyboard and pointer, and if it can't get the grab it refuses to lock rather than show a lock screen your keystrokes go straight through. + +The password is checked through its own PAM service, `/etc/pam.d/ahfail`, which the package installs. It doesn't touch any other package's PAM configuration, the same way swaylock and gtklock don't. It includes `login`, so the system's `pam_faillock` policy applies, and on Arch that means three wrong passwords within fifteen minutes lock the account for ten. Worth knowing before you show it off on a Friday afternoon. + +A screen locker sits right in the authentication path, so a bug in it could weaken the lock in the worst case. There are no guarantees, and you install it at your own risk. The package says so too. + +## Install + +On Arch Linux, from source or prebuilt: + +```bash +yay -S ahfail +yay -S ahfail-bin +``` + +Run `ahfail doctor` afterwards. It tells you whether the session type is supported, whether the PAM file is in place, whether GStreamer can play the clip and whether the volume can be controlled, with the exact fix if something is missing. + +To lock automatically, point your idle daemon at it: + +```bash +swayidle -w timeout 300 'ahfail' before-sleep 'ahfail' # Wayland +xss-lock -- ahfail # X11 +``` + +## macOS + +On a Mac the system lock screen does the locking, and the effect comes from a PAM hook installed through Homebrew: + +```bash +brew tap asger-weirsoee/tap +brew install ahfail +``` + +Homebrew then prints the one line to add to `/etc/pam.d/screensaverui`. That's the only manual step. diff --git a/src/content/projekter/en/pi3-smart-workspace.md b/src/content/projekter/en/pi3-smart-workspace.md new file mode 100644 index 0000000..983db3b --- /dev/null +++ b/src/content/projekter/en/pi3-smart-workspace.md @@ -0,0 +1,48 @@ +--- +title: "pi3-smart-workspace" +description: "A small i3 helper that makes $mod+1 the first workspace on the screen your mouse is on, instead of workspace 1 somewhere else entirely." +repo: pi3-smart-workspace +sprog: Python +licens: Apache-2.0 +platforme: [Linux (i3)] +aur: [pi3-smart-workspace] +tags: [i3, linux, python] +date: 2020-09-04 +updated: 2026-09-30 +lang: en +--- + +With several monitors in i3, workspaces are numbered globally. Press `$mod+1` and you jump to workspace 1, even if it lives on the left screen and you're working on the right one. `pi3-smart-workspace` turns that around: the key picks the N-th workspace on the screen your mouse is on. `$mod+1` is always the first workspace wherever the mouse is, and a multi-monitor setup feels a lot more coherent. + +It reads your running i3 config and uses three things from it: output names (`set $ `), workspace names, and the lines that bind a workspace to an output. The order of those lines decides which workspace is number 1, 2 and 3 on each screen. It can also move the focused window there, and follow it if you like. + +The first version is from 2020. + +## Install + +From the AUR or PyPI: + +```bash +yay -S pi3-smart-workspace +pip install pi3-smart-workspace +``` + +It needs Python 3.14 and a running i3 session. + +## Setup + +The easy way is to let it write the config itself: + +```bash +pi3-smart-workspace init +``` + +It finds every active monitor through i3 and writes a folder with a `workspaces.conf` per output and a `bindings.conf` with `$mod+N`, `$mod+Shift+N` and `$mod+Ctrl+N`. Each output gets its own contiguous range of global numbers, and the index after the colon (for example `9:1`) means "first workspace on this monitor". Add the include lines `init` prints to your i3 config and reload with `$mod+Shift+r`. + +If you'd rather bind the keys yourself, it looks like this: + +``` +bindsym $mod+1 exec --no-startup-id pi3-smart-workspace -i 1 +bindsym $mod+Shift+1 exec --no-startup-id pi3-smart-workspace -i 1 -s +bindsym $mod+Ctrl+1 exec --no-startup-id pi3-smart-workspace -i 1 -s -k +``` diff --git a/src/content/projekter/en/ubl-tools.md b/src/content/projekter/en/ubl-tools.md new file mode 100644 index 0000000..99de2d6 --- /dev/null +++ b/src/content/projekter/en/ubl-tools.md @@ -0,0 +1,58 @@ +--- +title: "ubl-tools" +description: "Read UBL e-invoices (Peppol BIS 3.0 and OIOUBL) in the terminal or as a printable page, and pull out the attached PDF. Everything happens on your own machine." +repo: ubl-tools +sprog: Rust +licens: MIT OR Apache-2.0 +platforme: [Linux, macOS, Windows] +aur: [ubl-tools, ubl-tools-bin] +tags: [peppol, oioubl, nemhandel, e-invoicing, rust] +date: 2026-09-30 +updated: 2026-10-01 +lang: en +--- + +An e-invoice is an XML file. Reading one means scrolling through a few hundred lines of `cbc:` and `cac:` elements, or uploading it to an online viewer. The viewers work, but they want the whole invoice, and that includes names, VAT numbers, bank details and what the customer bought. I'm not keen on sending all that to a stranger's server just to read it. + +So I wrote `ubl`. It reads the file locally and shows it as an ordinary invoice: seller and buyer with their identifiers named properly (CVR, GLN, VAT numbers and Peppol endpoint IDs instead of bare scheme codes like `0184`), the lines with quantity, unit, price and VAT, the VAT breakdown per category and the totals. Payment details come along too, including Danish FI cards in the familiar `+71<…+…<` notation. If the invoice is wrapped in a Peppol envelope (SBDH), it unwraps it by itself. + +It understands Peppol BIS Billing 3.0, OIOUBL 2.1 (the Danish NemHandel format) and other EN 16931 invoices and credit notes. OIOUBL has a small trap where `TaxExclusiveAmount` is actually the VAT amount and not the amount before VAT.. `ubl` knows about it, so the totals add up. + +## Install + +On Arch Linux it's on the AUR, as a source package and as a prebuilt binary: + +```bash +yay -S ubl-tools # builds from source +yay -S ubl-tools-bin # prebuilt binary +``` + +On macOS from my Homebrew tap: + +```bash +brew tap asger-weirsoee/tap +brew install ubl-tools +``` + +On Windows 10/11 with Scoop: + +```powershell +scoop install https://asger.weirsoe.dk/tarballz/ubl-tools.json +``` + +You can also grab the zip from [tarballz](/tarballz/) and unpack it. The binary isn't code signed, so Windows may ask before the first run. + +## Usage + +```bash +ubl view invoice.xml # in the terminal +ubl view invoice.xml --open # as a page in the browser +ubl view invoice.xml --lang da # Danish labels +ubl extract invoice.xml other.xml # save the PDF next to each file +ubl extract *.xml -o ./bilag # or all of them into one folder +ubl open invoice.xml # save attachments, open in browser +``` + +`ubl extract` never overwrites anything. An identical file that's already there is reused, and a different one gets a new name. On Windows you can drop one or more XML files straight onto `ubl.exe` in Explorer: the PDFs are saved, the invoices open, and the window stays until you've read the result. + +The HTML page is a single self-contained file with no external requests, laid out for A4 so it prints cleanly. diff --git a/src/pages/en/projects/[slug].astro b/src/pages/en/projects/[slug].astro new file mode 100644 index 0000000..ac970b3 --- /dev/null +++ b/src/pages/en/projects/[slug].astro @@ -0,0 +1,17 @@ +--- +import { getCollection } from 'astro:content'; +import ProjektSide from '../../../components/ProjektSide.astro'; + +export async function getStaticPaths() { + const alle = await getCollection('projekter', ({ data }) => !data.draft); + const da = new Map(alle.filter((p) => p.data.lang === 'da').map((p) => [p.id, p])); + return alle + .filter((p) => p.data.lang === 'en') + .map((side) => { + const slug = side.id.replace(/^en\//, ''); + return { params: { slug }, props: { side, oversat: da.get(slug) } }; + }); +} +const { side, oversat } = Astro.props; +--- + diff --git a/src/pages/en/projects/index.astro b/src/pages/en/projects/index.astro new file mode 100644 index 0000000..9271edc --- /dev/null +++ b/src/pages/en/projects/index.astro @@ -0,0 +1,34 @@ +--- +import { getCollection } from 'astro:content'; +import Base from '../../../layouts/Base.astro'; +import { SITE } from '../../../consts'; +import { collectionPage } from '../../../seo'; + +const sider = (await getCollection('projekter', ({ data }) => !data.draft && data.lang === 'en')) + .sort((a, b) => a.data.title.localeCompare(b.data.title)); +const ld = collectionPage({ + url: `${SITE.url}/en/projects`, name: 'Projects', + description: 'Tools I write and publish on the AUR, Homebrew and Scoop.', +}); +--- + +
+

Projects

+ Dansk +
+

Tools I maintain and publish as packages. Everything else lives on my Gitea.

+ + diff --git a/src/pages/projekter/[slug].astro b/src/pages/projekter/[slug].astro index 1c64ae7..02a696a 100644 --- a/src/pages/projekter/[slug].astro +++ b/src/pages/projekter/[slug].astro @@ -1,59 +1,14 @@ --- -import { getCollection, render } from 'astro:content'; -import Base from '../../layouts/Base.astro'; -import { SITE } from '../../consts'; -import { softwareSourceCode, breadcrumb } from '../../seo'; +import { getCollection } from 'astro:content'; +import ProjektSide from '../../components/ProjektSide.astro'; export async function getStaticPaths() { - const sider = await getCollection('projekter', ({ data }) => !data.draft); - return sider.map((side) => ({ params: { slug: side.id }, props: { side } })); + const alle = await getCollection('projekter', ({ data }) => !data.draft); + const en = new Map(alle.filter((p) => p.data.lang === 'en').map((p) => [p.id.replace(/^en\//, ''), p])); + return alle + .filter((p) => p.data.lang === 'da') + .map((side) => ({ params: { slug: side.id }, props: { side, oversat: en.get(side.id) } })); } -const { side } = Astro.props; -const { Content } = await render(side); -const d = side.data; - -const url = new URL(`/projekter/${side.id}`, SITE.url).href; -const repo = `${SITE.giteaBase}/${SITE.giteaUser}/${d.repo}`; -const aur = d.aur.map((n) => ({ navn: n, url: `https://aur.archlinux.org/packages/${n}` })); -const published = d.date.toISOString(); -const modified = (d.updated ?? d.date).toISOString(); -const ld = softwareSourceCode({ - url, name: d.title, description: d.description, codeRepository: repo, - language: d.sprog, license: d.licens, platforms: d.platforme, - sameAs: aur.map((a) => a.url), dateCreated: published, dateModified: modified, keywords: d.tags, -}); -const crumbs = breadcrumb([ - { name: 'Forside', url: `${SITE.url}/` }, - { name: 'Projekter', url: `${SITE.url}/projekter` }, - { name: d.title, url }, -]); +const { side, oversat } = Astro.props; --- - - -
-
-

{d.title}

-

{d.description}

-
-
Sprog
{d.sprog}
-
Licens
{d.licens}
- {d.platforme.length > 0 &&
Kører på
{d.platforme.join(', ')}
} -
-
- Kildekode på Gitea - {aur.map((a) => ( - AUR: {a.navn} - ))} - {d.aur.some((n) => n.endsWith('-bin')) && ( - Færdigbyggede pakker - )} -
-
-
-
- + diff --git a/src/pages/projekter/index.astro b/src/pages/projekter/index.astro index 809d50d..6b00516 100644 --- a/src/pages/projekter/index.astro +++ b/src/pages/projekter/index.astro @@ -5,7 +5,7 @@ import { SITE, PROJECTS } from '../../consts'; // Repos med en projektside her på sitet linker dertil; resten går direkte til Gitea. const sider = new Map( - (await getCollection('projekter', ({ data }) => !data.draft)).map((p) => [p.data.repo.toLowerCase(), p]), + (await getCollection('projekter', ({ data }) => !data.draft && data.lang === 'da')).map((p) => [p.data.repo.toLowerCase(), p]), ); type Repo = { name: string; description: string | null; html_url: string; language: string | null; stars_count: number; updated_at: string; fork: boolean; archived: boolean; private: boolean; }; @@ -33,7 +33,8 @@ try { } else { error = true; } } catch { error = true; } --- - +

Projekter

Hentet live fra min Gitea.

diff --git a/src/pages/search-index.json.js b/src/pages/search-index.json.js index 67a1a8a..e17fa32 100644 --- a/src/pages/search-index.json.js +++ b/src/pages/search-index.json.js @@ -8,10 +8,11 @@ export async function GET() { { type: 'page', title: 'Forside', url: '/' }, { type: 'page', title: 'CV', url: '/cv' }, { type: 'page', title: 'Projekter', url: '/projekter' }, - ...projekter.map((p) => ({ - type: 'page', title: `Projekt: ${p.data.title}`, url: `/projekter/${p.id}`, - extra: [p.data.sprog, ...p.data.aur, ...p.data.tags].join(' '), - })), + ...projekter.map((p) => p.data.lang === 'en' + ? { type: 'page', title: `Project: ${p.data.title} (English)`, url: `/en/projects/${p.id.replace(/^en\//, '')}`, + extra: [p.data.sprog, ...p.data.aur, ...p.data.tags].join(' ') } + : { type: 'page', title: `Projekt: ${p.data.title}`, url: `/projekter/${p.id}`, + extra: [p.data.sprog, ...p.data.aur, ...p.data.tags].join(' ') }), { type: 'page', title: 'Færdigbyggede pakker (tarballz)', url: '/tarballz/' }, { type: 'page', title: 'Blog', url: '/blog' }, { type: 'page', title: 'Tags', url: '/tags' }, diff --git a/src/seo.ts b/src/seo.ts index 20bb89c..86d442a 100644 --- a/src/seo.ts +++ b/src/seo.ts @@ -165,7 +165,7 @@ export function collectionPage(opts: { url: string; name: string; description: s export function softwareSourceCode(opts: { url: string; name: string; description: string; codeRepository: string; language: string; license: string; platforms: string[]; sameAs: string[]; - dateCreated: string; dateModified: string; keywords: string[]; + dateCreated: string; dateModified: string; keywords: string[]; lang?: 'da' | 'en'; }) { return { '@context': 'https://schema.org', @@ -182,7 +182,7 @@ export function softwareSourceCode(opts: { dateCreated: opts.dateCreated, dateModified: opts.dateModified, keywords: opts.keywords, - inLanguage: 'da-DK', + inLanguage: opts.lang === 'en' ? 'en' : 'da-DK', author: { '@id': personId }, maintainer: { '@id': personId }, };