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:
Asger Geel Weirsøe
2026-10-04 07:28:09 +02:00
parent 2915d0a928
commit 58d68aa95f
13 changed files with 329 additions and 64 deletions
+3
View File
@@ -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) => {
+9 -1
View File
@@ -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" />
+82
View File
@@ -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>
+3 -1
View File
@@ -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'),
}),
});
+56
View File
@@ -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
```
+58
View File
@@ -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.
+17
View File
@@ -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} />
+34
View File
@@ -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>
+9 -54
View File
@@ -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} />
+3 -2
View File
@@ -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>
+5 -4
View File
@@ -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
View File
@@ -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 },
};