Engelske projektsider under /en/projects
Hver projektside findes nu også på engelsk (/en/projects/<navn>) med en oversigt på /en/projects. Siderne deler komponenten ProjektSide, peger på hinanden med hreflang og har et sprogskift øverst. Head og Base tager lang og alternates, så andre sider kan oversættes på samme måde. /tarballz/ er med i sitemap'et, nu hvor oversigten der må indekseres. Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
This commit is contained in:
@@ -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];
|
||||
<meta property="og:title" content={pageTitle} />
|
||||
<meta property="og:description" content={desc} />
|
||||
<meta property="og:url" content={canonical} />
|
||||
<meta property="og:locale" content="da_DK" />
|
||||
<meta property="og:locale" content={lang === 'en' ? 'en_GB' : 'da_DK'} />
|
||||
{hreflang.map((a) => <link rel="alternate" hreflang={a.lang} href={a.href} />)}
|
||||
{xDefault && <link rel="alternate" hreflang="x-default" href={xDefault.href} />}
|
||||
<meta property="og:image" content={ogImage} />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
|
||||
@@ -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) }]
|
||||
: [];
|
||||
---
|
||||
<Base title={d.title} description={d.description} type="article" lang={d.lang} {alternates}
|
||||
publishedTime={published} modifiedTime={modified} tags={d.tags} jsonLd={[ld, crumbs]}>
|
||||
<div class="flex items-center justify-between gap-4 text-sm text-muted">
|
||||
<nav aria-label={en ? 'Breadcrumb' : 'Brødkrumme'}>
|
||||
<a href="/" class="hover:text-accent">{T.hjem}</a>
|
||||
<span class="px-1.5 text-muted/60">/</span>
|
||||
<a href={oversigt} class="hover:text-accent">{T.projekter}</a>
|
||||
</nav>
|
||||
{oversat && (
|
||||
<a href={sti(oversat.data.lang)} hreflang={oversat.data.lang} lang={oversat.data.lang}
|
||||
class="rounded-full border border-border px-2.5 py-0.5 hover:border-accent hover:text-accent">{T.skift}</a>
|
||||
)}
|
||||
</div>
|
||||
<article class="mt-4">
|
||||
<header class="border-b border-border pb-6">
|
||||
<h1 class="text-4xl font-bold tracking-tight">{d.title}</h1>
|
||||
<p class="mt-3 max-w-2xl text-lg text-muted">{d.description}</p>
|
||||
<dl class="mt-5 flex flex-wrap gap-x-6 gap-y-2 text-sm">
|
||||
<div><dt class="inline text-muted">{T.sprog} </dt><dd class="inline">{d.sprog}</dd></div>
|
||||
<div><dt class="inline text-muted">{T.licens} </dt><dd class="inline">{d.licens}</dd></div>
|
||||
{d.platforme.length > 0 && <div><dt class="inline text-muted">{T.koerer} </dt><dd class="inline">{d.platforme.join(', ')}</dd></div>}
|
||||
</dl>
|
||||
<div class="mt-5 flex flex-wrap gap-2">
|
||||
<a href={repo} class="rounded-full border border-border px-3 py-1 text-sm hover:border-accent hover:text-accent">{T.kilde}</a>
|
||||
{aur.map((a) => (
|
||||
<a href={a.url} class="rounded-full border border-border px-3 py-1 text-sm hover:border-accent hover:text-accent">AUR: {a.navn}</a>
|
||||
))}
|
||||
{d.aur.some((n) => n.endsWith('-bin')) && (
|
||||
<a href="/tarballz/" class="rounded-full border border-border px-3 py-1 text-sm hover:border-accent hover:text-accent">{T.bin}</a>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<div class="prose mt-8"><Content /></div>
|
||||
</article>
|
||||
</Base>
|
||||
@@ -14,7 +14,8 @@ const blog = defineCollection({
|
||||
}),
|
||||
});
|
||||
|
||||
// Projektsider (/projekter/<slug>): én markdown-fil pr. værktøj jeg udgiver.
|
||||
// Projektsider: én markdown-fil pr. værktøj jeg udgiver. Dansk i roden
|
||||
// (/projekter/<slug>), engelsk i en/ (/en/projects/<slug>) 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'),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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 $<var> <output>`), 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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
---
|
||||
<ProjektSide {side} {oversat} />
|
||||
@@ -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.',
|
||||
});
|
||||
---
|
||||
<Base title="Projects" description="Tools I write and publish on the AUR, Homebrew and Scoop." lang="en"
|
||||
alternates={[{ lang: 'da', path: '/projekter' }, { lang: 'en', path: '/en/projects' }]} jsonLd={[ld]}>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h1 class="text-4xl font-bold tracking-tight">Projects</h1>
|
||||
<a href="/projekter" hreflang="da" lang="da" class="rounded-full border border-border px-2.5 py-0.5 text-sm text-muted hover:border-accent hover:text-accent">Dansk</a>
|
||||
</div>
|
||||
<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={`${SITE.giteaBase}/${SITE.giteaUser}`}>Gitea</a>.</p>
|
||||
<div class="mt-8 grid gap-4 sm:grid-cols-2">
|
||||
{sider.map((p) => (
|
||||
<a href={`/en/projects/${p.id.replace(/^en\//, '')}`}
|
||||
class="group rounded-2xl border border-border bg-surface/60 p-5 transition-colors hover:border-accent/50">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-semibold tracking-tight group-hover:text-accent">{p.data.title}</h3>
|
||||
{p.data.aur.length > 0 && <span class="text-xs text-muted">AUR</span>}
|
||||
</div>
|
||||
<p class="mt-2 line-clamp-3 text-sm text-muted">{p.data.description}</p>
|
||||
<div class="mt-3 text-xs text-accent">{p.data.sprog} · Read more →</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</Base>
|
||||
@@ -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;
|
||||
---
|
||||
<Base title={d.title} description={d.description} type="article"
|
||||
publishedTime={published} modifiedTime={modified} tags={d.tags} jsonLd={[ld, crumbs]}>
|
||||
<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="/projekter" class="hover:text-accent">Projekter</a>
|
||||
</nav>
|
||||
<article class="mt-4">
|
||||
<header class="border-b border-border pb-6">
|
||||
<h1 class="text-4xl font-bold tracking-tight">{d.title}</h1>
|
||||
<p class="mt-3 max-w-2xl text-lg text-muted">{d.description}</p>
|
||||
<dl class="mt-5 flex flex-wrap gap-x-6 gap-y-2 text-sm">
|
||||
<div><dt class="inline text-muted">Sprog </dt><dd class="inline">{d.sprog}</dd></div>
|
||||
<div><dt class="inline text-muted">Licens </dt><dd class="inline">{d.licens}</dd></div>
|
||||
{d.platforme.length > 0 && <div><dt class="inline text-muted">Kører på </dt><dd class="inline">{d.platforme.join(', ')}</dd></div>}
|
||||
</dl>
|
||||
<div class="mt-5 flex flex-wrap gap-2">
|
||||
<a href={repo} class="rounded-full border border-border px-3 py-1 text-sm hover:border-accent hover:text-accent">Kildekode på Gitea</a>
|
||||
{aur.map((a) => (
|
||||
<a href={a.url} class="rounded-full border border-border px-3 py-1 text-sm hover:border-accent hover:text-accent">AUR: {a.navn}</a>
|
||||
))}
|
||||
{d.aur.some((n) => n.endsWith('-bin')) && (
|
||||
<a href="/tarballz/" class="rounded-full border border-border px-3 py-1 text-sm hover:border-accent hover:text-accent">Færdigbyggede pakker</a>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<div class="prose mt-8"><Content /></div>
|
||||
</article>
|
||||
</Base>
|
||||
<ProjektSide {side} {oversat} />
|
||||
|
||||
@@ -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; }
|
||||
---
|
||||
<Base title="Projekter" description="Udvalgte offentlige projekter fra min Gitea.">
|
||||
<Base title="Projekter" description="Udvalgte offentlige projekter fra min Gitea."
|
||||
alternates={[{ lang: 'da', path: '/projekter' }, { lang: 'en', path: '/en/projects' }]}>
|
||||
<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={`${SITE.giteaBase}/${SITE.giteaUser}`}>Gitea</a>.</p>
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
+2
-2
@@ -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 },
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user