Compare commits
No commits in common. "main" and "spa" have entirely different histories.
|
|
@ -1,57 +0,0 @@
|
|||
name: Publish Nostr Events
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['content/posts/**']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force_all:
|
||||
description: 'Publish all posts (--force-all)'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: denoland/setup-deno@v2
|
||||
with:
|
||||
deno-version: v2.x
|
||||
|
||||
- name: Pre-Flight Check
|
||||
working-directory: ./publish
|
||||
env:
|
||||
BUNKER_URL: ${{ secrets.BUNKER_URL }}
|
||||
AUTHOR_PUBKEY_HEX: ${{ secrets.AUTHOR_PUBKEY_HEX }}
|
||||
BOOTSTRAP_RELAY: ${{ secrets.BOOTSTRAP_RELAY }}
|
||||
CLIENT_SECRET_HEX: ${{ secrets.CLIENT_SECRET_HEX }}
|
||||
run: |
|
||||
deno run --allow-env --allow-read --allow-net src/cli.ts check
|
||||
|
||||
- name: Publish
|
||||
working-directory: ./publish
|
||||
env:
|
||||
BUNKER_URL: ${{ secrets.BUNKER_URL }}
|
||||
AUTHOR_PUBKEY_HEX: ${{ secrets.AUTHOR_PUBKEY_HEX }}
|
||||
BOOTSTRAP_RELAY: ${{ secrets.BOOTSTRAP_RELAY }}
|
||||
CLIENT_SECRET_HEX: ${{ secrets.CLIENT_SECRET_HEX }}
|
||||
GITHUB_EVENT_BEFORE: ${{ github.event.before }}
|
||||
run: |
|
||||
if [ "${{ github.event.inputs.force_all }}" = "true" ]; then
|
||||
deno run --allow-env --allow-read --allow-write=./logs --allow-net --allow-run=git src/cli.ts publish --force-all
|
||||
else
|
||||
deno run --allow-env --allow-read --allow-write=./logs --allow-net --allow-run=git src/cli.ts publish
|
||||
fi
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: publish-log
|
||||
path: ./publish/logs/publish-*.json
|
||||
retention-days: 30
|
||||
87
README.md
|
|
@ -1,97 +1,46 @@
|
|||
# joerg-lohrer.de
|
||||
|
||||
Persönliche Webseite. Nach einer Transition von einer Hugo-basierten,
|
||||
statischen Seite läuft `joerg-lohrer.de` jetzt als SvelteKit-SPA, die
|
||||
Blog-Posts live aus signierten Nostr-Events (NIP-23, `kind:30023`) rendert.
|
||||
Persönliche Webseite. In Transition von einer Hugo-basierten, statischen Seite
|
||||
hin zu einer SvelteKit-SPA, die Blog-Posts live aus signierten Nostr-Events
|
||||
(NIP-23, `kind:30023`) rendert.
|
||||
|
||||
## Aktueller Stand
|
||||
|
||||
- **`https://joerg-lohrer.de/`** — SvelteKit-SPA, Cutover am 2026-04-18 erfolgt.
|
||||
- **`https://staging.joerg-lohrer.de/`** — Staging (gleicher Build, ein Schritt vor Prod).
|
||||
- **`https://svelte.joerg-lohrer.de/`** — Entwicklungs-Deploy-Target der Pipeline.
|
||||
- **`https://spa.joerg-lohrer.de/`** — Vanilla-HTML-Mini-Spike (Proof of Concept, historisch).
|
||||
- **`https://joerg-lohrer.de/`** — Hugo-Seite, läuft noch.
|
||||
- **`https://spa.joerg-lohrer.de/`** — Vanilla-HTML-Mini-Spike (Proof of Concept).
|
||||
- **`https://svelte.joerg-lohrer.de/`** — produktive SvelteKit-SPA (Ziel).
|
||||
|
||||
Detailliert in [`docs/STATUS.md`](docs/STATUS.md).
|
||||
|
||||
## Wie die Seite funktioniert
|
||||
|
||||
1. **Inhalte** liegen als Markdown in `content/posts/<slug>/index.md` mit
|
||||
strukturierten Bild-Metadaten im Frontmatter (Alt-Text, Lizenz, Autor:innen).
|
||||
2. **Publish-Pipeline** (`publish/`, Deno) lädt Bilder auf Blossom-Server
|
||||
(content-addressed) und publiziert signierte `kind:30023`-Events via
|
||||
NIP-46-Bunker (Amber) auf 5 Relays.
|
||||
3. **SvelteKit-SPA** (`app/`) lädt diese Events zur Laufzeit und rendert
|
||||
Post-Liste + Detailseiten. Keine Server-Komponente, Static-Hosting reicht.
|
||||
4. **CI**: GitHub Actions triggert die Publish-Pipeline bei Push auf `main`
|
||||
(via Forgejo→GitHub Push-Mirror).
|
||||
|
||||
Identität und Assets:
|
||||
- **Pubkey:** `npub1f7jar3qnu269uyx5p0e4v24hqxjnxysxudvujza2ur5ehltvdeqsly2fx9`
|
||||
- **NIP-05:** `joerglohrer@joerg-lohrer.de` (statisches `.well-known/nostr.json`)
|
||||
- **Blossom-Server:** `blossom.edufeed.org`, `blossom.primal.net`
|
||||
- **Relays:** `relay.damus.io`, `nos.lol`, `relay.primal.net`, `relay.tchncs.de`, `relay.edufeed.org`
|
||||
|
||||
## Navigation
|
||||
|
||||
- 📍 **Stand und Live-URLs:** [`docs/STATUS.md`](docs/STATUS.md)
|
||||
- 🔜 **Wie es weitergeht:** [`docs/HANDOFF.md`](docs/HANDOFF.md)
|
||||
- 📐 **SPA-Spec:** [`docs/superpowers/specs/2026-04-15-nostr-page-design.md`](docs/superpowers/specs/2026-04-15-nostr-page-design.md)
|
||||
- 📐 **Publish-Pipeline-Spec:** [`docs/superpowers/specs/2026-04-15-publish-pipeline-design.md`](docs/superpowers/specs/2026-04-15-publish-pipeline-design.md)
|
||||
- 📐 **Bild-Metadaten-Konvention:** [`docs/superpowers/specs/2026-04-16-image-metadata-convention.md`](docs/superpowers/specs/2026-04-16-image-metadata-convention.md)
|
||||
- 🛠 **SvelteKit-SPA-Plan:** [`docs/superpowers/plans/2026-04-15-spa-sveltekit.md`](docs/superpowers/plans/2026-04-15-spa-sveltekit.md) (35 Tasks, abgeschlossen)
|
||||
- 🛠 **Publish-Pipeline-Plan:** [`docs/superpowers/plans/2026-04-16-publish-pipeline.md`](docs/superpowers/plans/2026-04-16-publish-pipeline.md) (24 Tasks, abgeschlossen)
|
||||
- 🤖 **Claude-Workflow-Skill:** [`.claude/skills/joerglohrerde-workflow.md`](.claude/skills/joerglohrerde-workflow.md)
|
||||
|
||||
## Branches
|
||||
|
||||
- **`main`** — kanonisch. Seit Cutover (2026-04-18) Produktions-Quelle.
|
||||
- **`spa`** — historischer SvelteKit-Arbeitszweig, inzwischen gemerged.
|
||||
- **`main`** — kanonisch (Content, Specs, Pläne, Deploy-Scripts, Skill).
|
||||
- **`spa`** — aktueller Arbeitszweig mit allen SvelteKit-Commits. Wird beim
|
||||
Cutover nach `main` gemerged.
|
||||
- **`hugo-archive`** — eingefrorener Hugo-Zustand als Orphan-Branch.
|
||||
Rollback-Option über `git checkout hugo-archive && hugo build`.
|
||||
Rollback über `git checkout hugo-archive && hugo build`.
|
||||
|
||||
## Repo-Struktur
|
||||
|
||||
```
|
||||
content/posts/ Markdown-Posts (Quelle für Nostr-Events, 18 Stück)
|
||||
content/impressum.md Statisches Impressum (wird von SPA geladen)
|
||||
app/ SvelteKit-SPA (Laufzeit-Renderer)
|
||||
publish/ Deno-Publish-Pipeline (Blossom + Nostr)
|
||||
preview/spa-mini/ Vanilla-HTML-Mini-Spike (historische Referenz)
|
||||
scripts/deploy-svelte.sh FTPS-Deploy, Targets: svelte/staging/prod
|
||||
static/ Site-Assets (Favicons, Profilbild, .well-known/)
|
||||
docs/ Specs, Pläne, Status, Handoff, Wiki-Entwürfe
|
||||
.github/workflows/ GitHub-Actions CI (Publish-Pipeline-Trigger)
|
||||
.claude/ Claude-Code-Sessions (Transparenz) + Skills
|
||||
```
|
||||
|
||||
## Entwicklung
|
||||
|
||||
```sh
|
||||
# SPA lokal
|
||||
cd app && npm run dev
|
||||
|
||||
# SPA testen
|
||||
cd app && npm run test:unit
|
||||
cd app && npm run test:e2e
|
||||
cd app && npm run check
|
||||
|
||||
# Publish-Pipeline
|
||||
cd publish && deno task check # pre-flight
|
||||
cd publish && deno task publish --dry-run # Simulation
|
||||
cd publish && deno task publish # diff-modus echt
|
||||
cd publish && deno task publish --post <slug> # ein Post
|
||||
cd publish && deno task test # Tests
|
||||
|
||||
# Deploy
|
||||
DEPLOY_TARGET=staging ./scripts/deploy-svelte.sh
|
||||
DEPLOY_TARGET=prod ./scripts/deploy-svelte.sh
|
||||
content/posts/ Markdown-Posts (Quelle für Nostr-Events)
|
||||
app/ SvelteKit-SPA (Ziel-Implementation)
|
||||
preview/spa-mini/ Vanilla-HTML-Mini-Spike (Referenz)
|
||||
scripts/deploy-svelte.sh FTPS-Deploy nach svelte.joerg-lohrer.de
|
||||
static/ Site-Assets (Favicons, Profilbild)
|
||||
docs/ Specs, Pläne, Status, Handoff
|
||||
.claude/ Claude-Code-Sessions (transparenz) + Skills
|
||||
```
|
||||
|
||||
## Lizenz
|
||||
|
||||
Inhalte: [CC0 1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/deed.de)
|
||||
(Namensnennung erwünscht, aber rechtlich nicht erforderlich), sofern nicht
|
||||
anders vermerkt. Drittinhalte sind beim jeweiligen Bild mit Autor:innen und
|
||||
Lizenz gekennzeichnet.
|
||||
|
||||
Code: siehe [LICENSE](LICENSE).
|
||||
Siehe [LICENSE](LICENSE).
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
# Öffentliche Site-URL für Canonical-Link und og:url-Meta-Tags.
|
||||
# Zur Build-Zeit fest; gilt domain-übergreifend (svelte./staging./haupt-
|
||||
# domain). Für jeden Deploy-Zweck kann eine andere URL gesetzt werden.
|
||||
#
|
||||
# Beispiele:
|
||||
# PUBLIC_SITE_URL=https://svelte.joerg-lohrer.de
|
||||
# PUBLIC_SITE_URL=https://staging.joerg-lohrer.de
|
||||
# PUBLIC_SITE_URL=https://joerg-lohrer.de
|
||||
PUBLIC_SITE_URL=https://joerg-lohrer.de
|
||||
|
|
@ -6,16 +6,9 @@
|
|||
<meta name="description" content="Jörg Lohrer – Blog (Nostr-basiert)" />
|
||||
<meta property="og:title" content="Jörg Lohrer – Blog" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="__SITE_URL__/" />
|
||||
<meta property="og:url" content="https://svelte.joerg-lohrer.de/" />
|
||||
<meta property="og:description" content="Jörg Lohrer – Blog (Nostr-basiert)" />
|
||||
<link rel="canonical" href="__SITE_URL__/" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/android-chrome-192x192.png" />
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="/android-chrome-512x512.png" />
|
||||
<title>Jörg Lohrer</title>
|
||||
<style>
|
||||
:root {
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
<script lang="ts">
|
||||
// CC-Zero-Badge: kombination aus CC-Heart + Zero-Logo, monochrom via
|
||||
// currentColor. Icons aus dem offiziellen CC-Press-Kit
|
||||
// (creativecommons.org/mission/branding/). Inline hier, weil statische
|
||||
// svg-imports mit ?raw in vite problematisch sind.
|
||||
</script>
|
||||
|
||||
<span class="cc-badge" aria-hidden="true">
|
||||
<!-- CC-Heart (vereinfachtes herz aus dem offiziellen logo) -->
|
||||
<svg viewBox="0 0 46296 40689" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M23204.91 7530.98c2944.63,-3188.84 6384.04,-4639.01 10366.38,-4077.21 4110.34,579.88 7609.97,3518.41 8854.17,7479.01 957.39,3047.58 559.96,6460.83 -722.09,9573.35 -1993.98,4840.97 -7886.31,11722.09 -18555.24,16532.85 -10668.92,-4810.76 -16561.25,-11691.88 -18555.24,-16532.85 -1282.05,-3112.52 -1679.47,-6525.77 -722.09,-9573.35 1244.19,-3960.6 4743.83,-6899.13 8854.17,-7479.01 3982.46,-561.82 7421.94,888.46 10366.64,4077.48 5.4,5.84 56.52,61.37 56.53,61.36 0.04,0.04 51.9,-56.36 56.79,-61.63zm-56.79 -4522.44c-6431.69,-5048.01 -16512.25,-3730.83 -21147.65,3855.94 -1539.08,2519.03 -2117.14,5447.75 -1981.3,8355.45 235.64,5043.59 2412.75,9452.27 5610.61,13256.78 4306.02,5122.9 10531.26,9148.59 17382.21,12152.72 9.53,4.18 88.63,38.56 136.13,59.69 41.66,-17.53 114.6,-50.41 137.01,-60.3 6815.65,-3004.07 13075.56,-7030.12 17381.33,-12152.12 3198.08,-3804.33 5374.97,-8213.2 5610.61,-13256.78 135.85,-2907.7 -442.2,-5836.43 -1981.3,-8355.45 -4635.4,-7586.77 -14715.95,-8903.95 -21147.65,-3855.94z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22983.64 21630.19l-2928.01 -1451.38c-1017.73,1483.99 -1758.21,2488.33 -3897.08,1902.25 -1678.91,-460.05 -2175.85,-2300.18 -2239.67,-3843.76 -87.17,-2108.39 649.94,-4543.46 3168.15,-4413.24 1609.13,83.19 2294.75,1032.23 2661.15,1885.36l3196.99 -1638.9c-1574.75,-3004.31 -5265.13,-4026.05 -8393.32,-3188.81 -3328.66,890.9 -5014.61,3952.95 -4955.5,7255.23 60.43,3375.58 1680.8,6291.51 5161.55,7052.54 1697.16,371.06 3545.13,284.81 5116.74,-503.18 1216.27,-609.83 2567.56,-1786.86 3109,-3056.12zm13802.46 0l-2928.01 -1451.38c-1017.73,1483.99 -1758.21,2488.33 -3897.08,1902.25 -1678.91,-460.05 -2175.86,-2300.18 -2239.67,-3843.76 -87.18,-2108.39 649.94,-4543.46 3168.15,-4413.24 1609.13,83.19 2294.74,1032.23 2661.15,1885.36l3196.99 -1638.9c-1574.75,-3004.31 -5265.14,-4026.05 -8393.32,-3188.81 -3328.66,890.9 -5014.61,3952.95 -4955.5,7255.23 60.42,3375.58 1680.8,6291.51 5161.55,7052.54 1697.16,371.06 3545.13,284.81 5116.74,-503.18 1216.27,-609.83 2567.56,-1786.86 3109,-3056.12z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- CC-Zero (kreis + 0 aus dem cc-0-logo) -->
|
||||
<svg viewBox="-0.5 0.5 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M31.5,14.08c-10.565,0-13.222,9.969-13.222,18.42c0,8.452,2.656,18.42,13.222,18.42c10.564,0,13.221-9.968,13.221-18.42C44.721,24.049,42.064,14.08,31.5,14.08z M31.5,21.026c0.429,0,0.82,0.066,1.188,0.157c0.761,0.656,1.133,1.561,0.403,2.823l-7.036,12.93c-0.216-1.636-0.247-3.24-0.247-4.437C25.808,28.777,26.066,21.026,31.5,21.026z M36.766,26.987c0.373,1.984,0.426,4.056,0.426,5.513c0,3.723-0.258,11.475-5.69,11.475c-0.428,0-0.822-0.045-1.188-0.136c-0.07-0.021-0.134-0.043-0.202-0.067c-0.112-0.032-0.23-0.068-0.336-0.11c-1.21-0.515-1.972-1.446-0.874-3.093L36.766,26.987z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M31.433,0.5c-8.877,0-16.359,3.09-22.454,9.3c-3.087,3.087-5.443,6.607-7.082,10.532C0.297,24.219-0.5,28.271-0.5,32.5c0,4.268,0.797,8.32,2.397,12.168c1.6,3.85,3.921,7.312,6.969,10.396c3.085,3.049,6.549,5.399,10.398,7.037c3.886,1.602,7.939,2.398,12.169,2.398c4.229,0,8.34-0.826,12.303-2.465c3.962-1.639,7.496-3.994,10.621-7.081c3.011-2.933,5.289-6.297,6.812-10.106C62.73,41,63.5,36.883,63.5,32.5c0-4.343-0.77-8.454-2.33-12.303c-1.562-3.885-3.848-7.32-6.857-10.33C48.025,3.619,40.385,0.5,31.433,0.5z M31.567,6.259c7.238,0,13.412,2.566,18.554,7.709c2.477,2.477,4.375,5.31,5.67,8.471c1.296,3.162,1.949,6.518,1.949,10.061c0,7.354-2.516,13.454-7.506,18.33c-2.592,2.516-5.502,4.447-8.74,5.781c-3.2,1.334-6.498,1.994-9.927,1.994c-3.468,0-6.788-0.653-9.949-1.948c-3.163-1.334-6.001-3.238-8.516-5.716c-2.515-2.514-4.455-5.353-5.826-8.516c-1.333-3.199-2.017-6.498-2.017-9.927c0-3.467,0.684-6.787,2.017-9.949c1.371-3.2,3.312-6.074,5.826-8.628C18.092,8.818,24.252,6.259,31.567,6.259z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.cc-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.15em;
|
||||
color: var(--accent);
|
||||
vertical-align: -0.2em;
|
||||
}
|
||||
.cc-badge svg {
|
||||
width: 1.1em;
|
||||
height: 1.1em;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
<script lang="ts">
|
||||
// Inline-SVGs als kleine social-icon-leiste. bewusst keine zusätzlichen
|
||||
// image-requests, kein icon-font. hover-color via currentColor + fill=currentColor.
|
||||
|
||||
const AUTHOR_PUBKEY_HEX =
|
||||
'4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41';
|
||||
|
||||
type Entry = { label: string; href: string; icon: 'nostr' | 'mastodon' | 'linkedin' | 'bluesky' | 'mail' | 'orcid' };
|
||||
|
||||
const entries: Entry[] = [
|
||||
{ label: 'Nostr', href: `https://edufeed.org/p/${AUTHOR_PUBKEY_HEX}`, icon: 'nostr' },
|
||||
{ label: 'Mastodon', href: 'https://reliverse.social/@joerglohrer', icon: 'mastodon' },
|
||||
{ label: 'Bluesky', href: 'https://bsky.app/profile/joerglohrer.bsky.social', icon: 'bluesky' },
|
||||
{ label: 'LinkedIn', href: 'https://www.linkedin.com/in/joerglohrer', icon: 'linkedin' },
|
||||
{ label: 'ORCID', href: 'https://orcid.org/0000-0002-9282-0406', icon: 'orcid' },
|
||||
{ label: 'E-Mail', href: 'mailto:webmaster@joerg-lohrer.de', icon: 'mail' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<nav class="social" aria-label="Soziale Profile und Kontakt">
|
||||
{#each entries as e (e.href)}
|
||||
<a
|
||||
href={e.href}
|
||||
target={e.icon === 'mail' ? undefined : '_blank'}
|
||||
rel={e.icon === 'mail' ? undefined : 'me noopener'}
|
||||
aria-label={e.label}
|
||||
title={e.label}
|
||||
>
|
||||
{#if e.icon === 'nostr'}
|
||||
<!-- Nostr-Logo (Outline) von satscoffee/nostr_icons, CC0 -->
|
||||
<svg
|
||||
viewBox="0 0 875 875"
|
||||
aria-hidden="true"
|
||||
width="20"
|
||||
height="20"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="40"
|
||||
stroke-miterlimit="10"
|
||||
>
|
||||
<path
|
||||
d="m684.72,485.57c.22,12.59-11.93,51.47-38.67,81.3-26.74,29.83-56.02,20.85-58.42,20.16s-3.09-4.46-7.89-3.77-9.6,6.17-18.86,7.2-17.49,1.71-26.06-1.37c-4.46.69-5.14.71-7.2,2.24s-17.83,10.79-21.6,11.47c0,7.2-1.37,44.57,0,55.89s3.77,25.71,7.54,36c3.77,10.29,2.74,10.63,7.54,9.94s13.37.34,15.77,4.11c2.4,3.77,1.37,6.51,5.49,8.23s60.69,17.14,99.43,19.2c26.74.69,42.86,2.74,52.12,19.54,1.37,7.89,7.54,13.03,11.31,14.06s8.23,2.06,12,5.83,1.03,8.23,5.49,11.66c4.46,3.43,14.74,8.57,25.37,13.71,10.63,5.14,15.09,13.37,15.77,16.11s1.71,10.97,1.71,10.97c0,0-8.91,0-10.97-2.06s-2.74-5.83-2.74-5.83c0,0-6.17,1.03-7.54,3.43s.69,2.74-7.89.69-11.66-3.77-18.17-8.57c-6.51-4.8-16.46-17.14-25.03-16.8,4.11,8.23,5.83,8.23,10.63,10.97s8.23,5.83,8.23,5.83l-7.2,4.46s-4.46,2.06-14.74-.69-11.66-4.46-12.69-10.63,0-9.26-2.74-14.4-4.11-15.77-22.29-21.26c-18.17-5.49-66.52-21.26-100.12-24.69s-22.63-2.74-28.11-1.37-15.77,4.46-26.4-1.37c-10.63-5.83-16.8-13.71-17.49-20.23s-1.71-10.97,0-19.2,3.43-19.89,1.71-26.74-14.06-55.89-19.89-64.12c-13.03,1.03-50.74-.69-50.74-.69,0,0-2.4-.69-17.49,5.83s-36.48,13.76-46.77,19.93-14.4,9.7-16.12,13.13c.12,3-1.23,7.72-2.79,9.06s-12.48,2.42-12.48,2.42c0,0-5.85,5.86-8.25,9.97-6.86,9.6-55.2,125.14-66.52,149.83-13.54,32.57-9.77,27.43-37.71,27.43s-8.06.3-8.06.3c0,0-12.34,5.88-16.8,5.88s-18.86-2.4-26.4,0-16.46,9.26-23.31,10.29-4.95-1.34-8.38-3.74c-4-.21-14.27-.12-14.27-.12,0,0,1.74-6.51,7.91-10.88,8.23-5.83,25.37-16.11,34.63-21.26s17.49-7.89,23.31-9.26,18.51-6.17,30.51-9.94,19.54-8.23,29.83-31.54c10.29-23.31,50.4-111.43,51.43-116.23.63-2.96,3.73-6.48,4.8-15.09.66-5.35-2.49-13.04,1.71-22.63,10.97-25.03,21.6-20.23,26.4-20.23s17.14.34,26.4-1.37,15.43-2.74,24.69-7.89,11.31-8.91,11.31-8.91l-19.89-3.43s-18.51.69-25.03-4.46-15.43-15.77-15.43-15.77l-7.54-7.2,1.03,8.57s-5.14-8.91-6.51-10.29-8.57-6.51-11.31-11.31-7.54-25.03-7.54-25.03l-6.17,13.03-1.71-18.86-5.14,7.2-2.74-16.11-4.8,8.23-3.43-14.4-5.83,4.46-2.4-10.29-5.83-3.43s-14.06-9.26-16.46-9.6-4.46,3.43-4.46,3.43l1.37,12-12.2-6.27-7-11.9s2.36,4.01-9.62,7.53c-20.55,0-21.89-2.28-24.93-3.94-1.31-6.56-5.57-10.11-5.57-10.11h-20.57l-.34-6.86-7.89,3.09.69-10.29h-14.06l1.03-11.31h-8.91s3.09-9.26,25.71-22.97,25.03-16.46,46.29-17.14c21.26-.69,32.91,2.74,46.29,8.23s38.74,13.71,43.89,17.49c11.31-9.94,28.46-19.89,34.29-19.89,1.03-2.4,6.19-12.33,17.96-17.6,35.31-15.81,108.13-34,131.53-35.54,31.2-2.06,7.89-1.37,39.09,2.06,31.2,3.43,54.17,7.54,69.6,12.69,12.58,4.19,25.03,9.6,34.29,2.06,4.33-1.81,11.81-1.34,17.83-5.14,30.69-25.09,34.72-32.35,43.63-41.95s20.14-24.91,22.54-45.14,4.46-58.29-10.63-88.12-28.8-45.26-34.63-69.26c-5.83-24-8.23-61.03-6.17-73.03,2.06-12,5.14-22.29,6.86-30.51s9.94-14.74,19.89-16.46c9.94-1.71,17.83,1.37,22.29,4.8,4.46,3.43,11.65,6.28,13.37,10.29.34,1.71-1.37,6.51,8.23,8.23,9.6,1.71,16.05,4.16,16.05,4.16,0,0,15.64,4.29,3.11,7.73-12.69,2.06-20.52-.71-24.29,1.69s-7.21,10.08-9.61,11.1-7.2.34-12,4.11-9.6,6.86-12.69,14.4-5.49,15.77-3.43,26.74,8.57,31.54,14.4,43.2c5.83,11.66,20.23,40.8,24.34,47.66s15.77,29.49,16.8,53.83,1.03,44.23,0,54.86-10.84,51.65-35.53,85.94c-8.16,14.14-23.21,31.9-24.67,35.03-1.45,3.13-3.02,4.88-1.61,7.65,4.62,9.05,12.87,22.13,14.71,29.22,2.29,6.64,6.99,16.13,7.22,28.72Z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if e.icon === 'mastodon'}
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" width="20" height="20">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M21.58 13.9c-.3 1.53-2.67 3.22-5.4 3.54-1.42.17-2.82.33-4.32.26-2.45-.11-4.39-.58-4.39-.58 0 .24.01.47.05.68.32 2.4 2.39 2.55 4.34 2.61 1.97.07 3.74-.49 3.74-.49l.08 1.78s-1.39.75-3.87.88c-1.37.08-3.07-.03-5.05-.56C2.46 20.86 1.72 16.2 1.61 11.47 1.57 10.07 1.59 8.76 1.59 7.66c0-4.85 3.18-6.27 3.18-6.27 1.6-.73 4.35-1.04 7.21-1.07h.07c2.86.02 5.61.33 7.22 1.07 0 0 3.18 1.42 3.18 6.27 0 0 .04 3.59-.45 6.08-.03.16-.18.16-.42.16zm-3.01-5.53c0-1.18-.3-2.11-.9-2.81-.62-.7-1.43-1.05-2.44-1.05-1.17 0-2.06.45-2.65 1.35l-.58.97-.58-.97c-.59-.9-1.48-1.35-2.65-1.35-1.01 0-1.82.36-2.44 1.05-.6.7-.9 1.63-.9 2.81v5.78h2.3V8.54c0-1.18.5-1.78 1.5-1.78 1.1 0 1.65.71 1.65 2.13v3.09h2.28V8.88c0-1.41.55-2.13 1.65-2.13 1 0 1.5.6 1.5 1.78v5.61h2.3V8.37z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if e.icon === 'bluesky'}
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" width="20" height="20">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.2 3.5c2.4 1.8 5 5.4 5.9 7.4.9-2 3.5-5.6 5.9-7.4 1.8-1.3 4.7-2.3 4.7 1 0 .7-.4 5.6-.6 6.4-.8 2.8-3.6 3.5-6.1 3.1 4.4.7 5.5 3.2 3.1 5.7-4.6 4.8-6.6-1.2-7.1-2.7-.1-.3-.1-.4-.1-.3 0-.1-.1 0-.1.3-.5 1.5-2.5 7.5-7.1 2.7-2.5-2.5-1.3-5 3.1-5.7-2.5.4-5.3-.3-6.1-3.1C-.1 10.1-.5 5.2-.5 4.5c0-3.3 2.9-2.3 4.7-1l1 0z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if e.icon === 'linkedin'}
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" width="20" height="20">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M19 3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14zM8.34 18.34v-8.4H5.67v8.4h2.67zM7 8.67a1.55 1.55 0 1 0 0-3.1 1.55 1.55 0 0 0 0 3.1zm11.34 9.67v-4.6c0-2.46-1.31-3.6-3.06-3.6-1.41 0-2.04.78-2.39 1.32v-1.13h-2.67c.04.75 0 8.01 0 8.01h2.67v-4.47c0-.24.02-.48.09-.65.19-.48.63-.97 1.37-.97.97 0 1.36.74 1.36 1.82v4.27h2.63z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if e.icon === 'orcid'}
|
||||
<!-- offizielles ORCID-symbol stark vereinfacht, monochrom via currentColor -->
|
||||
<svg viewBox="0 0 256 256" aria-hidden="true" width="20" height="20">
|
||||
<circle cx="128" cy="128" r="128" fill="currentColor" opacity="0.15" />
|
||||
<circle cx="128" cy="128" r="118" fill="none" stroke="currentColor" stroke-width="10" />
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M86 76a8 8 0 1 1-16 0 8 8 0 0 1 16 0zM72 96h14v96H72V96zm34 0h38c28 0 50 21 50 48s-22 48-50 48h-38V96zm14 14v68h22c22 0 36-14 36-34s-14-34-36-34h-22z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if e.icon === 'mail'}
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" width="20" height="20">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2zm0 2v.01L12 13l8-6.99V6H4zm0 2.7V18h16V8.7l-8 6.99L4 8.7z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.social {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
.social a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: var(--code-bg);
|
||||
color: var(--muted);
|
||||
transition:
|
||||
color 140ms,
|
||||
background 140ms,
|
||||
transform 140ms;
|
||||
}
|
||||
.social a:hover,
|
||||
.social a:focus-visible {
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 15%, var(--code-bg));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.social svg {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,163 +1,32 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import { bootstrapReadRelays } from '$lib/stores/readRelays';
|
||||
import CcZeroBadge from '$lib/components/CcZeroBadge.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// Normalisierter pfad ohne trailing slash für aktiv-erkennung ("/" bleibt "/")
|
||||
const currentPath = $derived((page.url?.pathname ?? '/').replace(/\/$/, '') || '/');
|
||||
|
||||
function isActive(path: string): boolean {
|
||||
const normalized = path.replace(/\/$/, '') || '/';
|
||||
return currentPath === normalized;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
bootstrapReadRelays();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- favicon-Tags liegen in src/app.html — hier nichts nötig. -->
|
||||
|
||||
<header class="site-header">
|
||||
<div class="header-inner">
|
||||
<a href="/" class="brand" aria-label="Zur Startseite">Jörg Lohrer</a>
|
||||
<nav aria-label="Hauptnavigation">
|
||||
<a href="/" class:active={isActive('/')}>Home</a>
|
||||
<a href="/archiv/" class:active={isActive('/archiv/')}>Archiv</a>
|
||||
<a href="/impressum/" class:active={isActive('/impressum/')}>Impressum</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
</svelte:head>
|
||||
|
||||
<main>
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="footer-inner">
|
||||
<span class="footer-license">
|
||||
<a
|
||||
href="https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
target="_blank"
|
||||
rel="license noopener"
|
||||
aria-label="CC0 1.0 Universal Public Domain Dedication"
|
||||
title="CC0 1.0 Universal"
|
||||
>
|
||||
<CcZeroBadge />
|
||||
<span class="cc-label">CC0</span>
|
||||
</a>
|
||||
Jörg Lohrer
|
||||
</span>
|
||||
<span class="footer-sep">·</span>
|
||||
<a href="/impressum/">Impressum</a>
|
||||
<span class="footer-sep">·</span>
|
||||
<a
|
||||
href="https://github.com/joerglohrer/joerglohrerde"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
title="Quellcode, Making-of und Nostr-Publish-Pipeline"
|
||||
>Nostr-basiert – Making-of im Repo</a>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
.site-header {
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.header-inner {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 0.75rem 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.brand {
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
color: var(--fg);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
nav a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-size: 0.95rem;
|
||||
padding: 0.25rem 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color 120ms, border-color 120ms;
|
||||
}
|
||||
nav a:hover {
|
||||
color: var(--fg);
|
||||
}
|
||||
nav a.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1rem;
|
||||
min-height: calc(100vh - 200px);
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
main {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 3rem;
|
||||
}
|
||||
.footer-inner {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
align-items: center;
|
||||
}
|
||||
.footer-inner a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
.footer-inner a:hover {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.footer-sep {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.footer-license a {
|
||||
color: var(--accent);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25em;
|
||||
text-decoration: none;
|
||||
}
|
||||
.footer-license a:hover .cc-label {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.cc-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -4,14 +4,9 @@
|
|||
import { loadPostList } from '$lib/nostr/loaders';
|
||||
import { getProfile } from '$lib/nostr/profileCache';
|
||||
import { AUTHOR_PUBKEY_HEX } from '$lib/nostr/config';
|
||||
import ProfileCard from '$lib/components/ProfileCard.svelte';
|
||||
import PostCard from '$lib/components/PostCard.svelte';
|
||||
import LoadingOrError from '$lib/components/LoadingOrError.svelte';
|
||||
import SocialIcons from '$lib/components/SocialIcons.svelte';
|
||||
|
||||
// Lokales Profilbild aus static/ — schneller als der Nostr-kind:0-Roundtrip
|
||||
// fürs kind:0 -> picture-Feld (URL wäre identisch, aber Netzwerk-Latenz).
|
||||
const HERO_AVATAR = '/joerg-profil-2024.webp';
|
||||
const LATEST_COUNT = 5;
|
||||
|
||||
let profile: Profile | null = $state(null);
|
||||
let posts: NostrEvent[] = $state([]);
|
||||
|
|
@ -34,141 +29,24 @@
|
|||
});
|
||||
|
||||
$effect(() => {
|
||||
const p = profile;
|
||||
const name = (p && (p.display_name ?? p.name)) ?? 'Jörg Lohrer';
|
||||
const name = profile?.display_name ?? profile?.name ?? 'Jörg Lohrer';
|
||||
document.title = `${name} – Blog`;
|
||||
});
|
||||
|
||||
const displayName = $derived.by(() => {
|
||||
const p = profile;
|
||||
return (p && (p.display_name ?? p.name)) ?? 'Jörg Lohrer';
|
||||
});
|
||||
const avatarSrc = HERO_AVATAR;
|
||||
const about = $derived.by(() => profile?.about ?? '');
|
||||
const website = $derived.by(() => profile?.website ?? '');
|
||||
const latest = $derived(posts.slice(0, LATEST_COUNT));
|
||||
const hasMore = $derived(posts.length > LATEST_COUNT);
|
||||
</script>
|
||||
|
||||
<section class="hero">
|
||||
<div class="hero-left">
|
||||
<img class="avatar" src={avatarSrc} alt={displayName} />
|
||||
<SocialIcons />
|
||||
</div>
|
||||
<div class="hero-text">
|
||||
<h1 class="hero-name">{displayName}</h1>
|
||||
<p class="hero-greeting">
|
||||
Hi <span aria-hidden="true">🖖</span> Willkommen auf meinem Blog
|
||||
<span aria-hidden="true">🤗</span>
|
||||
</p>
|
||||
{#if about}
|
||||
<p class="hero-about">{about}</p>
|
||||
{/if}
|
||||
{#if website}
|
||||
<div class="meta-line">
|
||||
<a href={website} target="_blank" rel="noopener">
|
||||
{website.replace(/^https?:\/\//, '').replace(/\/$/, '')}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
<ProfileCard {profile} />
|
||||
|
||||
<section class="latest">
|
||||
<h2 class="section-title">Neueste Beiträge</h2>
|
||||
<LoadingOrError {loading} {error} />
|
||||
{#each latest as post (post.id)}
|
||||
<PostCard event={post} />
|
||||
{/each}
|
||||
{#if hasMore}
|
||||
<div class="more">
|
||||
<a href="/archiv/" class="more-link">Alle Beiträge im Archiv →</a>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
<h1 class="list-title">Beiträge</h1>
|
||||
|
||||
<LoadingOrError {loading} {error} />
|
||||
|
||||
{#each posts as post (post.id)}
|
||||
<PostCard event={post} />
|
||||
{/each}
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
align-items: flex-start;
|
||||
padding: 1rem 0 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.hero-left {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.avatar {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
background: var(--code-bg);
|
||||
border: 2px solid var(--accent);
|
||||
}
|
||||
.hero-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.hero-name {
|
||||
margin: 0 0 0.3rem;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.hero-greeting {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.05rem;
|
||||
color: var(--fg);
|
||||
}
|
||||
.hero-about {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--muted);
|
||||
font-size: 1rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.meta-line {
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.meta-line a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.meta-line a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.section-title {
|
||||
.list-title {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.more {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
.more-link {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
.more-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.hero {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
.hero-left {
|
||||
align-items: center;
|
||||
}
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,80 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { NostrEvent } from '$lib/nostr/loaders';
|
||||
import { loadPostList } from '$lib/nostr/loaders';
|
||||
import PostCard from '$lib/components/PostCard.svelte';
|
||||
import LoadingOrError from '$lib/components/LoadingOrError.svelte';
|
||||
|
||||
let posts: NostrEvent[] = $state([]);
|
||||
let loading = $state(true);
|
||||
let error: string | null = $state(null);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
posts = await loadPostList();
|
||||
loading = false;
|
||||
if (posts.length === 0) {
|
||||
error = 'Keine Posts gefunden auf den abgefragten Relays.';
|
||||
}
|
||||
} catch (e) {
|
||||
loading = false;
|
||||
error = e instanceof Error ? e.message : 'Unbekannter Fehler';
|
||||
}
|
||||
});
|
||||
|
||||
// Posts nach Jahr gruppieren (neueste zuerst)
|
||||
type YearGroup = { year: number; posts: NostrEvent[] };
|
||||
const groupsByYear = $derived.by<YearGroup[]>(() => {
|
||||
const byYear = new Map<number, NostrEvent[]>();
|
||||
for (const p of posts) {
|
||||
const ts = Number(p.tags.find((t) => t[0] === 'published_at')?.[1] ?? p.created_at);
|
||||
const year = new Date(ts * 1000).getUTCFullYear();
|
||||
if (!byYear.has(year)) byYear.set(year, []);
|
||||
byYear.get(year)!.push(p);
|
||||
}
|
||||
return [...byYear.entries()]
|
||||
.map(([year, p]) => ({ year, posts: p }))
|
||||
.sort((a, b) => b.year - a.year);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Archiv – Jörg Lohrer</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1 class="title">Archiv</h1>
|
||||
<p class="meta">Alle Beiträge, nach Jahr gruppiert.</p>
|
||||
|
||||
<LoadingOrError {loading} {error} />
|
||||
|
||||
{#each groupsByYear as group (group.year)}
|
||||
<section class="year-group">
|
||||
<h2 class="year">{group.year}</h2>
|
||||
{#each group.posts as post (post.id)}
|
||||
<PostCard event={post} />
|
||||
{/each}
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
<style>
|
||||
.title {
|
||||
margin: 0 0 0.3rem;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
.meta {
|
||||
color: var(--muted);
|
||||
margin: 0 0 2rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.year-group {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
.year {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 1rem;
|
||||
padding-bottom: 0.3rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { renderMarkdown } from '$lib/render/markdown';
|
||||
import impressumRaw from '../../../../content/impressum.md?raw';
|
||||
|
||||
// Frontmatter abtrennen, nur Body rendern.
|
||||
// Toleriert trailing spaces auf den ---/--- trenner-zeilen.
|
||||
const match = impressumRaw.match(/^---[ \t]*\r?\n[\s\S]*?\r?\n---[ \t]*\r?\n([\s\S]*)$/);
|
||||
const body = match ? match[1] : impressumRaw;
|
||||
const html = renderMarkdown(body);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Impressum – Jörg Lohrer</title>
|
||||
<meta name="robots" content="index, follow" />
|
||||
</svelte:head>
|
||||
|
||||
<article class="impressum">
|
||||
{@html html}
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.impressum :global(h1) {
|
||||
font-size: 1.8rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
.impressum :global(h2) {
|
||||
font-size: 1.3rem;
|
||||
margin: 2rem 0 0.6rem;
|
||||
}
|
||||
.impressum :global(h3) {
|
||||
font-size: 1.05rem;
|
||||
margin: 1.4rem 0 0.4rem;
|
||||
}
|
||||
.impressum :global(p) {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
.impressum :global(a) {
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -4,13 +4,6 @@ RewriteEngine On
|
|||
RewriteCond %{HTTPS} !=on
|
||||
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
||||
|
||||
# NIP-05-Verifikation: CORS-Header für .well-known/nostr.json, sonst
|
||||
# lehnen nostr-clients die verifizierung ab.
|
||||
<FilesMatch "nostr\.json$">
|
||||
Header set Access-Control-Allow-Origin "*"
|
||||
Header set Content-Type "application/json"
|
||||
</FilesMatch>
|
||||
|
||||
# Existierende Datei oder Verzeichnis? Direkt ausliefern.
|
||||
RewriteCond %{REQUEST_FILENAME} -f [OR]
|
||||
RewriteCond %{REQUEST_FILENAME} -d
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
{
|
||||
"names": {
|
||||
"joerglohrer": "4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41"
|
||||
},
|
||||
"relays": {
|
||||
"4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41": [
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
"wss://relay.primal.net",
|
||||
"wss://relay.tchncs.de",
|
||||
"wss://relay.edufeed.org"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 231 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 844 B |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
|
@ -1,22 +1,8 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test('Home zeigt Hero (Name + Avatar) und neueste Beiträge', async ({ page }) => {
|
||||
test('Home zeigt Profil und mindestens einen Post', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// Hero: Name als h1
|
||||
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
||||
// Hero: Avatar (lokaler fallback oder nostr-profil)
|
||||
await expect(page.locator('.hero .avatar')).toBeVisible({ timeout: 15_000 });
|
||||
// Neueste-Beiträge-Sektion
|
||||
await expect(page.getByRole('heading', { level: 2, name: /Neueste Beiträge/i })).toBeVisible();
|
||||
// Mindestens ein Post lädt
|
||||
await expect(page.getByRole('heading', { level: 1, name: /Beiträge/ })).toBeVisible();
|
||||
await expect(page.locator('.profile .name')).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.locator('a.card').first()).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('Navigation erreicht Archiv und Impressum', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('link', { name: 'Archiv', exact: true }).click();
|
||||
await expect(page.getByRole('heading', { level: 1, name: /Archiv/i })).toBeVisible();
|
||||
|
||||
await page.getByRole('link', { name: 'Impressum', exact: true }).first().click();
|
||||
await expect(page.getByRole('heading', { level: 1, name: /Impressum/i })).toBeVisible();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ Die Inhalte unserer Seiten wurden mit größter Sorgfalt erstellt. Für die Rich
|
|||
Mein Angebot enthält Links zu externen Webseiten Dritter, auf deren Inhalte ich keinen Einfluss habe. Deshalb kann ich für diese fremden Inhalte auch keine Gewähr übernehmen. Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten verantwortlich. Die verlinkten Seiten wurden zum Zeitpunkt der Verlinkung auf mögliche Rechtsverstöße überprüft. Rechtswidrige Inhalte waren zum Zeitpunkt der Verlinkung nicht erkennbar. Eine permanente inhaltliche Kontrolle der verlinkten Seiten ist jedoch ohne konkrete Anhaltspunkte einer Rechtsverletzung nicht zumutbar. Bei Bekanntwerden von Rechtsverletzungen werde ich derartige Links umgehend entfernen.
|
||||
|
||||
### Urheberrecht
|
||||
Die durch den Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Soweit nicht anders angegeben, stelle ich eigene Inhalte und Werke unter der Creative-Commons-Lizenz [CC0 1.0 Universal (Public Domain Dedication)](https://creativecommons.org/publicdomain/zero/1.0/deed.de) zur Verfügung — sie dürfen ohne Rückfrage für jeden Zweck, auch kommerziell, kopiert, bearbeitet, verbreitet und weiterverwendet werden. Eine Namensnennung ist rechtlich nicht erforderlich, aber ich freue mich natürlich, wenn Du mich als Quelle nennst. Wo eine abweichende Lizenz gilt, ist sie beim jeweiligen Inhalt vermerkt. Soweit die Inhalte auf dieser Seite nicht vom Betreiber erstellt wurden, werden die Urheberrechte Dritter beachtet. Insbesondere werden Inhalte Dritter als solche gekennzeichnet. Solltest Du trotzdem auf eine Urheberrechtsverletzung aufmerksam werden, bitte ich um einen entsprechenden Hinweis. Bei Bekanntwerden von Rechtsverletzungen werde ich derartige Inhalte umgehend entfernen.
|
||||
Die durch den Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen jedoch nicht der schriftlichen Zustimmung des jeweiligen Autors bzw. Erstellers. Downloads und Kopien dieser Seite sind sowohl für den privaten, als auch für den kommerziellen Gebrauch unter Namensnennung und der Creative Commons Lizenz [CC BY-SA](https://creativecommons.org/licenses/by-sa/4.0/deed.de) gestattet. Soweit die Inhalte auf dieser Seite nicht vom Betreiber erstellt wurden, werden die Urheberrechte Dritter beachtet. Insbesondere werden Inhalte Dritter als solche gekennzeichnet. Solltest Du trotzdem auf eine Urheberrechtsverletzung aufmerksam werden, bitten ich um einen entsprechenden Hinweis. Bei Bekanntwerden von Rechtsverletzungen werde ich derartige Inhalte umgehend entfernen.
|
||||
|
||||
### Datenschutz
|
||||
Die Nutzung meiner Webseite ist in der Regel ohne Angabe personenbezogener Daten möglich. Soweit auf meiner Seiten personenbezogene Daten (beispielsweise Name, Anschrift oder eMail-Adressen) erhoben werden, erfolgt dies, soweit möglich, stets auf freiwilliger Basis. Diese Daten werden ohne Deine ausdrückliche Zustimmung nicht an Dritte weitergegeben. Ich weise darauf hin, dass die Datenübertragung im Internet (z.B. bei der Kommunikation per E-Mail) Sicherheitslücken aufweisen kann. Ein lückenloser Schutz der Daten vor dem Zugriff durch Dritte ist nicht möglich. Der Nutzung von im Rahmen der Impressumspflicht veröffentlichten Kontaktdaten durch Dritte zur Übersendung von nicht ausdrücklich angeforderter Werbung und Informationsmaterialien wird hiermit ausdrücklich widersprochen. Die Betreiber der Seiten behalten sich ausdrücklich rechtliche Schritte im Falle der unverlangten Zusendung von Werbeinformationen, etwa durch Spam-Mails, vor.
|
||||
|
|
@ -11,14 +11,6 @@ author: Jörg Lohrer
|
|||
slug: "premium-freemium-mium-mium-mium"
|
||||
lang: de
|
||||
dir: ltr
|
||||
images:
|
||||
- file: my-very-hungry-caterpillar.jpg
|
||||
role: cover
|
||||
alt: "Kleine Raupe aus Papier gefaltet, Anspielung auf das Kinderbuch 'Die kleine Raupe Nimmersatt'"
|
||||
license: "https://creativecommons.org/licenses/by-nc-sa/3.0/"
|
||||
authors:
|
||||
- name: "Relly Annett-Baker"
|
||||
source_url: "https://www.flickr.com/photos/fizzkitten/4454153264/"
|
||||
---
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@ dir: ltr
|
|||
|
||||
# Erlebnispädagogik im Handbuch Jugend – Evangelische Perspektiven
|
||||
|
||||
Das [**Handbuch Jugend – Evangelische Perspektiven**](http://ci-muenster.de/bookshop/artikel/buecher/Bildung-im-Kindes-und-Jugendalter/A40097_Handbuch_Jugend_2013.php), welches 2013 erschienen ist, hat auf Seite 343-345 folgenden Artikel von mir zur Erlebnispädagogik abgedruckt.
|
||||
Das
|
||||
[](http://ci-muenster.de/bookshop/artikel/buecher/Bildung-im-Kindes-und-Jugendalter/A40097_Handbuch_Jugend_2013.php)
|
||||
**Handbuch Jugend – Evangelische Perspektiven**, welches 2013 erschienen ist, hat auf Seite 343-345 folgenden Artikel von mir zur Erlebnispädagogik abgedruckt.
|
||||
[CC BY](http://creativecommons.org/licenses/by/2.0/de/) Jörg Lohrer
|
||||
|
||||
## Erlebnispädagogik
|
||||
|
|
@ -42,10 +44,14 @@ Die Wirkungsforschung erlebnispädagogischer Lernarrangements untersucht deren S
|
|||
Im Kontext evangelischer Jugendbildung stellt sich bei der Verwendung erlebnispädagogischer Methoden immer auch die Frage nach einer Verknüpfung von christlichen Inhalten mit der handlungsorientierten Pädagogik. Die Assoziation von Natur und Schöpfung ist beispielsweise ebenso naheliegend wie die Reflexion von religiösen Erfahrungen in erlebnispädagogischen Lernsettings. Inwiefern sich dabei Unverfügbares inszenieren lässt, bleibt dabei ebenso eine Herausforderung, wie bei der oben beschriebenen Wirkungsforschung. In den vergangenen Jahren haben sich einige Konzepte zur Erprobung erlebnispädagogischer Methoden im christlichen Kontext entwickelt, die zunehmend auch theologisch und religionsdidaktisch reflektiert werden. Eine überregionale Organisation dieser Einzelinitiativen auf Bundesebene wäre der nächste Schritt hin zu einem evangelischen Profil erlebnispädagogischer Jugendbildungsarbeit.
|
||||
|
||||
### Literatur
|
||||
- Großer, Achim/Oberländer, Rainer/Lohrer, Jörg/Wiedmayer, Jörg (2005/2011): Sinn gesucht – Gott erfahren. Erlebnispädagogik im christlichen Kontext (Band 1 (2005)/Band 2 (2011)). Stuttgart: Buch und Musik.
|
||||
- Heckmair, Bernd/Michl, Werner (2008): Erleben und Lernen. Einführung in die Erlebnispädagogik. München: Reinhardt.
|
||||
- Reiners, Annette (2007): Praktische Erlebnispädagogik. Augsburg: ZIEL.
|
||||
- Pum, Viktoria/Pirner, Manfred L./Lohrer, Jörg (Hrsg.) (2011): Erlebnispädagogik im christlichen Kontext. Dokumentation einer Tagung der Evangelischen Akademie Bad Boll, 2. bis 4. März 2009. Bad Boll: Evangelische Akademie.
|
||||

|
||||
Großer, Achim/Oberländer, Rainer/Lohrer, Jörg/Wiedmayer, Jörg (2005/2011): Sinn gesucht – Gott erfahren. Erlebnispädagogik im christlichen Kontext (Band 1 (2005)/Band 2 (2011)). Stuttgart: Buch und Musik.
|
||||

|
||||
Heckmair, Bernd/Michl, Werner (2008): Erleben und Lernen. Einführung in die Erlebnispädagogik. München: Reinhardt.
|
||||

|
||||
Reiners, Annette (2007): Praktische Erlebnispädagogik. Augsburg: ZIEL.
|
||||

|
||||
Pum, Viktoria/Pirner, Manfred L./Lohrer, Jörg (Hrsg.) (2011): Erlebnispädagogik im christlichen Kontext. Dokumentation einer Tagung der Evangelischen Akademie Bad Boll, 2. bis 4. März 2009. Bad Boll: Evangelische Akademie.
|
||||
|
||||
#### Links
|
||||
- Bundesverband Individual- und Erlebnispädagogik e.V. (BE): [https://www.bundesverband-erlebnispaedagogik.de/](https://www.bundesverband-erlebnispaedagogik.de/)
|
||||
|
|
|
|||
|
|
@ -11,31 +11,6 @@ author: Jörg Lohrer
|
|||
slug: "telegram-octopi"
|
||||
lang: de
|
||||
dir: ltr
|
||||
images:
|
||||
- file: octopi1.png
|
||||
role: cover
|
||||
alt: "Screenshot der OctoPrint-Plugin-Verwaltung während der Installation des Telegram-Plugins — Fortschrittsanzeige läuft"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: octopi2.png
|
||||
alt: "Screenshot der Konfigurationsmaske des OctoPrint-Telegram-Plugins mit Eingabefeld für den Telegram-Bot-Token"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: octopi3.png
|
||||
alt: "Screenshot der OctoPrint-Telegram-Plugin-Oberfläche nach erfolgreichem Token-Eintrag — Benutzerliste wird angezeigt, Rechte fehlen noch"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: octopi4.png
|
||||
alt: "Screenshot der Benutzer-Rechte-Konfiguration mit gesetzten Häkchen bei 'Command' und 'Notify'"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
---
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -11,45 +11,6 @@ author: Jörg Lohrer
|
|||
slug: "lutherkuerbis"
|
||||
lang: de
|
||||
dir: ltr
|
||||
images:
|
||||
- file: kuerbis-titelbild.jpg
|
||||
role: cover
|
||||
alt: "Fertig geschnitzter Kürbis mit dem Muster einer Lutherrose, innen beleuchtet — glüht warm in der Dunkelheit"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: lutherrose.png
|
||||
alt: "Schwarz-weiße Vektorgrafik der Lutherrose: Kreuz im Herzen, umgeben von fünfblättriger Rose in einem Ring — als Schnitzschablone aufbereitet"
|
||||
caption: "Vektorisierte Schablone, abgeleitet von einer Fotovorlage aus dem Web"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
modifications: "Vektorisierung per online-convert.com aus gemeinfreier Fotovorlage; Originalurheber der Fotovorlage unbekannt"
|
||||
|
||||
- file: kuerbis-aufschneiden.jpg
|
||||
alt: "Hände schneiden mit großem Messer den Deckel von einem orangenen Kürbis ab"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: kuerbis-entkernen.jpg
|
||||
alt: "Mit einem Löffel wird das Fruchtfleisch und die Kerne aus dem Inneren des aufgeschnittenen Kürbis herausgekratzt"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: schablone-aufbringen.jpg
|
||||
alt: "Papier-Schablone mit Lutherrosen-Motiv wird auf die Außenhaut des entkernten Kürbis geklebt"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: kuerbis-ausschneiden.jpg
|
||||
alt: "Mit einem Schnitzwerkzeug wird die Lutherrose entlang der Schablone aus der Kürbishaut herausgeschnitten"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
---
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -11,19 +11,6 @@ slug: "pflanzenschild-qr-code"
|
|||
author: Jörg Lohrer
|
||||
lang: de
|
||||
dir: ltr
|
||||
images:
|
||||
- file: cura-plugin-change-filment-at-z.png
|
||||
role: cover
|
||||
alt: "Screenshot des Cura-Slicers mit aktiviertem 'Change Filament at Z'-Plugin — Konfiguration eines Filamentwechsels in bestimmten Layern"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: qr-code-pflanzenschild.jpg
|
||||
alt: "Dreieckiges 3D-gedrucktes Pflanzenschild mit aufgedrucktem zweifarbigem QR-Code, steckt in einem Pflanztopf"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
---
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -15,48 +15,6 @@ toc: true
|
|||
toc_label: "Inhaltsverzeichnis"
|
||||
toc_icon: "vr-cardboard"
|
||||
toc_sticky: "true"
|
||||
images:
|
||||
- file: 04-aframe.jpg
|
||||
role: cover
|
||||
alt: "Screenshot einer A-Frame-WebVR-Szene: 3D-Objekte in einem Browser-Viewport, erstellt mit A-Frame-Framework"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
source_url: "https://codepen.io/joerglohrer/full/dyXQqWG"
|
||||
|
||||
- file: 01-immersion-wikipedia.jpg
|
||||
alt: "Screenshot des Wikipedia-Artikels 'Immersive learning' mit Einstiegsdefinition"
|
||||
license: UNKNOWN
|
||||
authors: UNKNOWN
|
||||
source_url: "https://en.wikipedia.org/wiki/Immersive_learning"
|
||||
|
||||
- file: 02-mittelalterliche-kirche.jpg
|
||||
alt: "Screenshot eines 3D-Modells einer mittelalterlichen Kirche (Calatrava la Nueva, Spanien) auf Sketchfab, erstellt aus 76 Laser-Scans und 4100 Fotos"
|
||||
license: "https://creativecommons.org/licenses/by-nc/4.0/"
|
||||
authors: UNKNOWN
|
||||
source_url: "https://sketchfab.com/3d-models/medieval-church-calatrava-la-nueva-spain-171a047c08bc4dd588cca5ac744e8065"
|
||||
|
||||
- file: 03-avatare-erstellen.jpg
|
||||
alt: "Screenshot der Avatar-Erstellung im Ready Player Me Web-Interface"
|
||||
license: UNKNOWN
|
||||
authors: UNKNOWN
|
||||
|
||||
- file: 05-pupillendistanz.jpg
|
||||
alt: "Screenshot der iOS-App 'EyeMeasure' bei der Messung des Pupillenabstands mittels iPhone-Kamera"
|
||||
license: UNKNOWN
|
||||
authors: UNKNOWN
|
||||
|
||||
- file: 06-vr-adapter-3ddruck.jpg
|
||||
alt: "3D-gedruckter Adapter zur Befestigung einer VIVE Deluxe Audio Strap an der Oculus Quest 2, frisch aus dem 3D-Drucker"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: 07-vive-straps-3ddruck.jpg
|
||||
alt: "3D-gedruckte Halterungen der VIVE Deluxe Audio Strap, montiert an der Oculus Quest 2"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
---
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -11,49 +11,6 @@ author: Jörg Lohrer
|
|||
slug: "wordpress-werkstatt"
|
||||
lang: de
|
||||
dir: ltr
|
||||
images:
|
||||
- file: 04-termine-neu.png
|
||||
role: cover
|
||||
alt: "Screenshot der WordPress-Beitragsübersicht mit eingefügtem Shortcode [relilab_termine], der eine Terminliste als Block rendert"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: 01-json-import.png
|
||||
alt: "Screenshot der ACF-Plugin-Oberfläche beim Import einer JSON-Datei mit Feldgruppen-Definitionen"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: 02-terminfelder.png
|
||||
alt: "Screenshot eines WordPress-Beitrags mit zwei neuen ACF-Terminfeldern 'Startet am' und 'Endet am' als Datum-/Zeit-Picker"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: 03-kategorien.png
|
||||
alt: "Screenshot der WordPress-Kategorieverwaltung mit neu angelegter Kategorie 'Termine' samt Unterkategorien"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: 05-php-storm.png
|
||||
alt: "Screenshot der PhpStorm-IDE mit geöffneter PHP-Datei zum add_shortcode()-Aufruf"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: 06-termine-listen.png
|
||||
alt: "Screenshot des PHP-Codes für die Funktion 'termineAusgeben' mit get_posts()-Abfrage und Shortcode-Registrierung"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: 07-external-library.png
|
||||
alt: "Screenshot der PhpStorm-Konfiguration zur Einbindung von WordPress als External Library für Auto-Complete"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
---
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -15,13 +15,6 @@ toc: true
|
|||
toc_label: "Inhaltsverzeichnis"
|
||||
toc_icon: "futbol"
|
||||
toc_sticky: "true"
|
||||
images:
|
||||
- file: bibelfussball1.png
|
||||
role: cover
|
||||
alt: "Tafel-Skizze eines Fußballfeldes mit Mittellinie, Strafräumen und zwei Toren — Magnetknopf markiert die aktuelle Ballposition"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
---
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -11,31 +11,6 @@ author: Jörg Lohrer
|
|||
slug: "moodle-iomad-linux"
|
||||
lang: de
|
||||
dir: ltr
|
||||
images:
|
||||
- file: title-gif.gif
|
||||
role: cover
|
||||
alt: "Animiertes Titelbild des Artikels zur Moodle-Server-Installation mit Iomad unter Ubuntu"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: 01-netzwerkbruecke.png
|
||||
alt: "Screenshot der VirtualBox-Netzwerkeinstellungen mit aktivierter Netzwerkbrücke für die Ubuntu-VM"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: 02-hosts-eintragen.png
|
||||
alt: "Terminal-Screenshot mit geöffneter /etc/hosts-Datei im nano-Editor, neuer Eintrag 'moodle.local' wird hinzugefügt"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: "03-config generieren.png"
|
||||
alt: "Screenshot des Moodle-Installationsassistenten beim automatischen Generieren der config.php"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
---
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -15,193 +15,6 @@ toc: true
|
|||
toc_label: "Inhaltsverzeichnis"
|
||||
toc_icon: "house-laptop"
|
||||
toc_sticky: "true"
|
||||
images:
|
||||
- file: 29-autostartordner.jpg
|
||||
role: cover
|
||||
alt: "Screenshot des Windows-Autostart-Ordners mit verknüpften OBS- und Zoom-Startlinks für automatischen Start beim Systemstart"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: 01-deutsche-tastatur-ubuntu.png
|
||||
alt: "Screenshot der Ubuntu-Terminal-Dialog zur Konfiguration der deutschen Tastatur via dpkg-reconfigure"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: 02-chrome-remote-desktop.png
|
||||
alt: "Screenshot der Chrome-Remote-Desktop-Installation im Ubuntu-Terminal"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: 03-status-chrome-remote.png
|
||||
alt: "Screenshot des systemctl-Status des chrome-remote-desktop-Dienstes als 'active (running)'"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: 04-remotezugriff.png
|
||||
alt: "Screenshot der Chrome-Remote-Desktop-Konfigurationsseite mit SSH-Befehl und PIN-Eingabe"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: 05-systemctl-status.png
|
||||
alt: "Screenshot der systemctl-status-Ausgabe für chrome-remote-desktop mit aktivem Dienst"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: 06-cannot-open-video-device.png
|
||||
alt: "Terminal-Screenshot der Fehlermeldung 'Cannot open device /dev/video0' bei v4l2-ctl --list-devices"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: 07-jetzt-v412-ctl.png
|
||||
alt: "Terminal-Screenshot der erfolgreichen v4l2-ctl-Geräteliste nach Installation von v4l2loopback"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: 08-dummy-video-device.png
|
||||
alt: "Terminal-Screenshot nach Reboot: virtuelle Kamera fehlt, Dummy-Video-Device muss neu geladen werden"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: 09-relilab-technical-host.png
|
||||
alt: "Screenshot der Chrome-Remote-Desktop-Geräteübersicht mit dem VM-Eintrag 'relilab-technical-host'"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: 10-pin-remote-desktop.png
|
||||
alt: "Screenshot des Chrome-Remote-Desktop-PIN-Eingabefelds für die Remote-Verbindung"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: 11-keyboard-tastatur-umstellen.png
|
||||
alt: "Screenshot der Linux-Keyboard-Einstellungen mit Umstellung auf deutsche Tastaturbelegung"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: 12-apps-verknuepfen.png
|
||||
alt: "Screenshot der Cinnamon-Desktop-Umgebung mit Drag-and-Drop-Verknüpfung von Anwendungen auf den Desktop"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: 13-startvirtualcam.png
|
||||
alt: "Screenshot der OBS-Verknüpfung mit dem Zusatzparameter --startvirtualcam im Startbefehl"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: 14-OBS-deutsch-umstellen.png
|
||||
alt: "Screenshot der OBS-Studio-Einstellungen beim Umschalten der Benutzeroberfläche auf Deutsch"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: 15-obs-mit-virtual-cam-starten.png
|
||||
alt: "Screenshot der OBS-Startbefehl-Konfiguration mit --startvirtualcam-Parameter für automatischen Kamera-Start"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: 16-startup-application.png
|
||||
alt: "Screenshot der Cinnamon-Startup-Applications-Verwaltung mit neu hinzugefügtem OBS-Eintrag"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: 17-i-will-only-be-using-OBS.png
|
||||
alt: "Screenshot des OBS-Auto-Configuration-Wizard mit ausgewählter Option 'I will only be using the virtual camera'"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: 18-video1920.png
|
||||
alt: "Screenshot der OBS-Video-Einstellungen mit Auflösung 1920x1080"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: 19-szenensammlung-importieren-OBS.png
|
||||
alt: "Screenshot des OBS-Menüs 'Szenensammlung importieren' mit Auswahl einer JSON-Datei"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: 20-chrome-einrichten.png
|
||||
alt: "Screenshot des Ubuntu-Keyring-Passwort-Dialogs beim ersten Chrome-Start"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: 21-chrome-standard.png
|
||||
alt: "Screenshot der Google-Chrome-Einstellungen mit gesetzter Option 'Als Standardbrowser festlegen'"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: 22-chrome-anmeldung.png
|
||||
alt: "Screenshot der Google-Account-Anmeldung in Chrome mit aktiviertem Sync"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: 23-zoom-anmeldung.png
|
||||
alt: "Screenshot der Zoom-Client-Anmeldemaske unter Linux"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: 24-zoom-sprache-aendern.png
|
||||
alt: "Screenshot des Zoom-Tray-Menüs mit Sprachauswahl-Untermenü zur Umstellung auf Deutsch"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: 25-slides-emojis.png
|
||||
alt: "Screenshot einer Präsentationsfolie im Chrome-Browser mit fehlenden Emoji-Zeichen als leere Platzhalter"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: 26-keyring-problem.png
|
||||
alt: "Screenshot der Ubuntu-GUI-Fehlermeldung beim Versuch, sich als Root einzuloggen"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: 27-startvirtualcam-verknuepft-OBS.jpg
|
||||
alt: "Screenshot der Windows-Eigenschaften einer OBS-Desktop-Verknüpfung mit --startvirtualcam-Parameter"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: 28-shell-startup.png
|
||||
alt: "Screenshot des Windows-Run-Dialogs mit Befehl 'shell:startup' zum Öffnen des Autostart-Ordners"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: v412-ctl-fehlermeldung.png
|
||||
alt: "Terminal-Screenshot der v4l2-ctl-Fehlermeldung beim Öffnen des Video-Gerätes"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: virtueller-desktop-titelbild.jpg
|
||||
alt: "Stilisiertes Titelbild: virtueller Desktop-Arbeitsplatz mit mehreren Bildschirmen und Remote-Verbindung"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
---
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -11,43 +11,6 @@ date: "2023-02-26"
|
|||
slug: "jojos-schoko-zimt-schnecken"
|
||||
lang: de
|
||||
dir: ltr
|
||||
images:
|
||||
- file: schneckennudeln-titel.jpg
|
||||
role: cover
|
||||
alt: "Goldbraun gebackene Hefeschnecken in einer Kuchenform, Titelbild des Rezepts"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: Hefeteig-mit-Fuellung.jpg
|
||||
alt: "Ausgerollter Hefeteig, bestrichen mit cremiger Kakao-Zimt-Zucker-Füllung, bereit zum Einrollen"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: 16-Schneckennudeln.jpg
|
||||
alt: "16 dicht an dicht aufgestellte, rohe Hefeschnecken in einer runden Kuchenform"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: hefeschnecken-in-capelle-backform.jpg
|
||||
alt: "Gegangene, mit Eimilch bestrichene Hefeschnecken in Kapellen-Backform, bereit für den Ofen"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: schneckennudeln-im-ofen.jpg
|
||||
alt: "Hefeschnecken im Ofen während des Backens, Oberseite beginnt goldbraun zu werden"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: schneckennudeln-fertig.jpg
|
||||
alt: "Fertig gebackene, goldbraune Hefeschnecken in der Kuchenform, bereit zum Servieren"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
---
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -11,70 +11,6 @@ date: "2023-03-23"
|
|||
slug: "gleichnis-vom-saemann"
|
||||
lang: de
|
||||
dir: ltr
|
||||
images:
|
||||
- file: saemann-title.jpg
|
||||
role: cover
|
||||
alt: "Titelbild zum Gleichnis vom Sämann: Collage der fünf KI-generierten Illustrationen im Stil von Eric Carle"
|
||||
license: "https://creativecommons.org/licenses/by-sa/3.0/de/"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
modifications: "Collage aus Midjourney-generierten Bildern im Stil von Eric Carle, Prompts siehe Artikel"
|
||||
|
||||
- file: bild1-saemann.jpeg
|
||||
alt: "Illustration im Stil von Eric Carle: Ein freundlicher Bauer streut Samen in einem offenen Feld, im Hintergrund vier Böden — felsig, dornig, vogelreich und fruchtbar"
|
||||
license: "https://creativecommons.org/licenses/by-sa/3.0/de/"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
modifications: "KI-generiert mit Midjourney v5, Prompt im Stil von Eric Carle"
|
||||
|
||||
- file: bild1-alternativ-saemann.jpeg
|
||||
alt: "Alternative Illustration im Stil von Eric Carle: Bauer beim Säen mit verschiedenen Bodenarten im Hintergrund"
|
||||
license: "https://creativecommons.org/licenses/by-sa/3.0/de/"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
modifications: "KI-generiert mit Midjourney v5, alternative Variante zu Bild 1"
|
||||
|
||||
- file: bild2-saemann.jpeg
|
||||
alt: "Illustration im Stil von Eric Carle: Kleine, schwache Pflanzen, die mit wenig Erde auf felsigem Boden zu wachsen beginnen"
|
||||
license: "https://creativecommons.org/licenses/by-sa/3.0/de/"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
modifications: "KI-generiert mit Midjourney v5"
|
||||
|
||||
- file: bild2-alternativ-saemann.jpeg
|
||||
alt: "Alternative Illustration im Stil von Eric Carle: Keimende Pflanzen auf steinigem Grund"
|
||||
license: "https://creativecommons.org/licenses/by-sa/3.0/de/"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
modifications: "KI-generiert mit Midjourney v5, alternative Variante zu Bild 2"
|
||||
|
||||
- file: bild3-saemann.jpeg
|
||||
alt: "Illustration im Stil von Eric Carle: Junge Pflanzen werden von Dornen umklammert und erstickt"
|
||||
license: "https://creativecommons.org/licenses/by-sa/3.0/de/"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
modifications: "KI-generiert mit Midjourney v5"
|
||||
|
||||
- file: bild4-saemann.jpeg
|
||||
alt: "Illustration im Stil von Eric Carle: Fröhliche Vögel picken Samen vom Boden und fressen sie, bevor sie keimen können"
|
||||
license: "https://creativecommons.org/licenses/by-sa/3.0/de/"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
modifications: "KI-generiert mit Midjourney v5"
|
||||
|
||||
- file: bild5-saemann.jpeg
|
||||
alt: "Illustration im Stil von Eric Carle: Große, gesunde Pflanzen tragen reiche Früchte auf fruchtbarem Boden, der Bauer steht lächelnd daneben"
|
||||
license: "https://creativecommons.org/licenses/by-sa/3.0/de/"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
modifications: "KI-generiert mit Midjourney v5"
|
||||
|
||||
- file: screen-chatgpt-saemann.png
|
||||
alt: "Screenshot des ChatGPT-Dialogs: Eingabe der Anfrage zum Gleichnis vom Sämann für einen 8-Jährigen und KI-generierte Antwort in fünf Bildbeschreibungen"
|
||||
license: "https://creativecommons.org/licenses/by-sa/3.0/de/"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
modifications: "Screenshot einer ChatGPT-4-Interaktion, Prompt vom Autor verfasst"
|
||||
---
|
||||
|
||||
# Das Gleichnis vom Sämann
|
||||
|
|
|
|||
|
|
@ -11,43 +11,6 @@ date: "2023-04-07"
|
|||
slug: "dampfnudeln"
|
||||
lang: de
|
||||
dir: ltr
|
||||
images:
|
||||
- file: Hefefreuden.jpg
|
||||
role: cover
|
||||
alt: "Titelbild: Dampfnudeln und Hefezopf auf einem Tisch, frisch aus Dampfgarer und Ofen"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: Hefeteig.jpg
|
||||
alt: "Aufgegangener Hefeteig in einer Rührschüssel, glatt und elastisch, nach 30 Minuten Ruhezeit"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: Dampfnudeln-auf-Lochblech.jpg
|
||||
alt: "Sechs runde Hefeteigstücke zum Dampfgaren auf einem gelochten Dampfgarblech"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: Dampfnudeln-im-Dampfgarer.jpg
|
||||
alt: "Gegarte, aufgegangene Dampfnudeln im geöffneten Dampfgarer, glänzend und flaumig"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: Dampfnudel-mit-Vanillesosse.jpg
|
||||
alt: "Dampfnudel auf Teller angerichtet, übergossen mit goldgelber Vanillesoße"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: Hefezopf.jpg
|
||||
alt: "Frisch gebackener, dreifach geflochtener Hefezopf, goldbraun glänzend nach dem Einpinseln mit Ei"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
---
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -11,25 +11,7 @@ cover:
|
|||
tags: [ "WordPress", "Padlet", "Kanban", "TaskCards", "horizontales Scrollen" ]
|
||||
lang: de
|
||||
dir: ltr
|
||||
images:
|
||||
- file: wordpress-horizontales-scrollen.gif
|
||||
role: cover
|
||||
alt: "Animierter Screenshot: WordPress-Seite mit horizontal scrollbaren Spalten, die Beiträge im Kanban-Stil nebeneinander zeigen"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: spalten-als-posts-block.png
|
||||
alt: "Screenshot des Stackable 'posts block'-Plugins in WordPress mit Spaltenansicht nach Kategorien"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: posts-per-drag-and-drop-sortieren.png
|
||||
alt: "Screenshot der WordPress-Beitragsliste mit aktiviertem 'Intuitive Custom Post Order'-Plugin — Beiträge werden per Drag & Drop sortiert"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
---
|
||||
|
||||
Eine **Sammlung von Liedern für den Religionsunterricht** ist auf Instagram in Zusammenarbeit von [@relimomente](https://www.instagram.com/relimomente/) [@ezpz.lemon.sqz](https://www.instagram.com/ezpz.lemon.sqz) und [@colibri260](https://www.instagram.com/colibri260) entstanden und wurde zunächst **[hier auf TaskCards](https://www.taskcards.de/#/board/16af7347-ec26-468e-a093-34549dd2dae3/view)** veröffentlicht.
|
||||
|
|
|
|||
|
|
@ -11,14 +11,6 @@ author: Jörg Lohrer
|
|||
tags: [ "Offenheit", "OER", "WordPress", "Nextcloud", "Element", "Community" ]
|
||||
lang: de
|
||||
dir: ltr
|
||||
images:
|
||||
- file: offenheit-wesentlich.png
|
||||
role: cover
|
||||
alt: "KI-generierte Aquarell-Illustration: Silhouetten von Menschen aller Geschlechter und Altersgruppen, die ineinander übergehen und sich überlappen — Symbol einer Community of Trust"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
modifications: "KI-generiert mit Midjourney v6.0, Prompt: A Community of Trust based on Openness, silhouettes of people of all genders and ages that merge into each other and overlap, watercolors --v 6.0 --seed 1235164279"
|
||||
---
|
||||
|
||||
# Offenheit - das Wesentliche
|
||||
|
|
|
|||
|
|
@ -11,13 +11,6 @@ author: Jörg Lohrer
|
|||
tags: [ "Offenheit", "OER", "MarkDown", "Community" ]
|
||||
lang: de
|
||||
dir: ltr
|
||||
images:
|
||||
- file: bottomup-markdown.png
|
||||
role: cover
|
||||
alt: "Titelbild zur OER-Camp-Session 'BottomUp MarkDown' — Symbol für die 5V-Freiheiten von Open Content in Verbindung mit der Markdown-Sprache"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
---
|
||||
|
||||
## Eine Session-Idee auf dem #OERcamp 24 in Hamburg
|
||||
|
|
|
|||
|
|
@ -27,15 +27,6 @@ creator:
|
|||
name: Comenius-Institut
|
||||
id: https://ror.org/025e8aw85
|
||||
type: Organization
|
||||
images:
|
||||
- file: kibedenken.png
|
||||
role: cover
|
||||
alt: "Ein junger Roboterjunge mit gesenktem Kopf betrachtet seine Spiegelung im Wasser, im fotorealistischen Stil einer Canon EOS 5D Mark IV"
|
||||
caption: "Referenziert auf Narziss aus der griechischen Mythologie und die Illustration von Caravaggio (siehe [Wikipedia #Narziss](https://de.wikipedia.org/wiki/Narziss#))"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
modifications: "KI-generiert mit Midjourney v6.0, Prompt: photographed with the Canon EOS 5D Mark IV a young robot boy with his head down, looking at his reflection in water --v6.0"
|
||||
---
|
||||
|
||||
# #KIBedenken - Bewusstsein
|
||||
|
|
|
|||
|
|
@ -60,16 +60,6 @@ learningResourceType:
|
|||
educationalLevel:
|
||||
- https://w3id.org/kim/educationalLevel/level_A
|
||||
datePublished: '2025-03-04'
|
||||
images:
|
||||
- file: dezentrale-oep-oer.png
|
||||
role: cover
|
||||
alt: "Ein in den Sand gezeichneter Strauß mit den Buchstaben 'OER' — Sinnbild für offene Bildung und freien Wissensaustausch, gleichzeitig Wortspiel-Verbindung zu Nostr (Ostrich = Strauß)"
|
||||
caption: "Analog zum Ichthys-Fisch als geheimem Erkennungszeichen: Symbol einer Gemeinschaft, die Wissen offen, unabhängig und widerstandsfähig teilt"
|
||||
license: "https://creativecommons.org/licenses/by/4.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
- name: "Steffen Rörtgen"
|
||||
- name: "Bastian Granas"
|
||||
---
|
||||
|
||||
# Gemeinsam die Bildungszukunft gestalten: Dezentrale OEP und OER als Wegbereiter
|
||||
|
|
|
|||
|
|
@ -1,98 +0,0 @@
|
|||
---
|
||||
layout: post
|
||||
title: "KI-Kompetenzen in der religionsbezogenen Bildung – Mitmachplattform"
|
||||
slug: "ki-kompetenzen-mitmachplattform"
|
||||
date: 2025-04-16
|
||||
description: "Die Mitmachplattform 'KI-Kompetenzen für die religionsbezogene Bildung' ist ein offenes Lernangebot für Lehrkräfte, Hochschulangehörige und Engagierte in der kirchlichen Bildungsarbeit. Zentrales Element ist ein Kreismodell mit vier Kompetenzbereichen: Verstehen, Anwenden, Reflektieren, Gestalten. Inhalte sind über Nostr vernetzbar."
|
||||
image: https://r2.primal.net/cache/e/2b/d0/e2bd042a0dd1961b5a6b91562d313fb33b4527875dee87b81eebab7e125d356f.png
|
||||
tags:
|
||||
- KI
|
||||
- KI-Kompetenzen
|
||||
- Religionspädagogik
|
||||
- Mitmachplattform
|
||||
- Nostr
|
||||
- relilab
|
||||
- OER
|
||||
lang: de
|
||||
license: https://creativecommons.org/publicdomain/zero/1.0/deed.de
|
||||
---
|
||||
|
||||
# Willkommen zur Mitmachplattform: KI-Kompetenzen für die religionsbezogene Bildung
|
||||
|
||||
Diese Plattform ist ein offenes, gemeinschaftliches Lernmodul für alle, die KI-Kompetenzen im Kontext der religionsbezogenen Bildung entdecken, vertiefen und weitergeben möchten.\
|
||||
Hier können **Religionslehrende**, **Hochschulangehörige** und **ehrenamtlich Engagierte** gemeinsam Inhalte entwickeln, teilen und weiterentwickeln.
|
||||
|
||||
***
|
||||
|
||||
## Was ist das Ziel?
|
||||
|
||||
Wir schaffen eine offene Lernumgebung, in der Materialien zu KI-Kompetenzen für Schule, Hochschule und außerschulische Jugendarbeit kollaborativ entstehen.\
|
||||
Die Plattform richtet sich an Lehrkräfte, Fortbildner:innen und Ehrenamtliche, die KI-Themen in der religiösen Bildung verantwortungsvoll erschließbar machen wollen.
|
||||
|
||||
***
|
||||
|
||||
## Unser Kompetenzmodell
|
||||
|
||||
Im Zentrum steht ein **Kreismodell** mit vier Kompetenzbereichen, die auf drei Niveaustufen ausdifferenziert sind:\
|
||||
**Verstehen**, **Anwenden**, **Reflektieren** und **Gestalten**.\
|
||||
Diese Bereiche bauen aufeinander auf, beeinflussen sich gegenseitig und werden durch die zentrale Kompetenz **AI Leadership** verbunden.
|
||||
|
||||
| Kompetenzbereich | Beschreibung |
|
||||
| -------------------------------------------------------------------- | ---------------------------------------- |
|
||||
| 🟢 [VERSTEHEN](https://ki-kompetenzen.npub.pro/tag/verstehen/) | Grundlagen und Mechanismen von KI |
|
||||
| 🔵 [ANWENDEN](https://ki-kompetenzen.npub.pro/tag/anwenden/) | KI-Tools im Lernprozess nutzen |
|
||||
| 🟠 [REFLEKTIEREN](https://ki-kompetenzen.npub.pro/tag/reflektieren/) | Kritische Auseinandersetzung mit KI |
|
||||
| 🟣 [GESTALTEN](https://ki-kompetenzen.npub.pro/tag/gestalten/) | Aktive Weiterentwicklung von KI-Systemen |
|
||||
|
||||
Jeder Lerninhalt ist einem Kompetenzbereich (und wenn möglich einer Niveaustufe) zugeordnet – farblich und inhaltlich klar erkennbar.
|
||||
|
||||

|
||||
|
||||
***
|
||||
|
||||
## So funktioniert die Mitmachplattform
|
||||
|
||||
* **Offene Inhalte:** Alle Materialien werden als Markdown-Dateien über Nostr gepflegt.
|
||||
|
||||
* **Mitmachen leicht gemacht:** Egal wo auf Nostr du publizierst, mit den Hashtags #relilab und dem jeweiligen Kompetenzbereich als Schlagwort (also #verstehen, #anwenden, #gestalten, #reflektieren) finden wir diese Hashtagkombination und können die Webseite mit deiner Idee oder Weiterentwicklung ergänzen.
|
||||
|
||||
* **Anleitungen:** Eine eigene Seite erklärt Schritt für Schritt, wie du dich beteiligen kannst – ganz ohne Vorkenntnisse im Programmieren.
|
||||
|
||||
* **Suchfunktion:** Finde gezielt Inhalte nach Kompetenzbereich, Niveaustufe oder Schlagworten.
|
||||
|
||||
***
|
||||
|
||||
## Für wen ist diese Plattform?
|
||||
|
||||
* **Lehrkräfte** an Schulen und Hochschulen in religionsbezogenen Fächern
|
||||
|
||||
* **Ehrenamtliche** und Multiplikator:innen in der kirchlichen Jugendarbeit
|
||||
|
||||
* **Fort- und Weiterbildner:innen** im Bereich KI und Religion
|
||||
|
||||
***
|
||||
|
||||
## Mitmachen & Teilen
|
||||
|
||||
\> **"Sharing is Caring":**\
|
||||
\> Unser KI-Kompetenzmodell und alle Lernmaterialien stehen unter der offenen Lizenz \[CC-BY 4.0].\
|
||||
\> Du darfst sie frei nutzen, anpassen und weitergeben – mit Nennung der Urheber:innen.
|
||||
|
||||
***
|
||||
|
||||
## Starte jetzt!
|
||||
|
||||
***
|
||||
|
||||
***Gemeinsam gestalten wir KI-Kompetenzen für eine reflektierte, verantwortungsvolle und kreative religiöse Bildung im KI-Zeitalter.***
|
||||
|
||||
***
|
||||
|
||||
**Kontakt & Feedback:**\
|
||||
Für Fragen, Anregungen oder Feedback
|
||||
|
||||
[https://ki-kompetenzen.npub.pro](https://ki-kompetenzen.npub.pro/)
|
||||
|
||||
\#OER #relilab
|
||||
|
||||
antworte einfach[ auf diese Nachricht](https://primal.net/e/nevent1qqsgpu7laypuzwvpyyuh9qwpmc43hgazh385mmd0qc0hapmsdfeqhms207uck) oder komm' in den Matrix/Element-Raum https://matrix.to/#/#relilab-ki:rpi-virtuell.de> zum Online-Austausch!
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
---
|
||||
layout: post
|
||||
title: "Bibel-Selfies"
|
||||
slug: "bibel-selfies"
|
||||
date: 2025-04-17
|
||||
description: "Bibel-Selfies mit Midjourney — Prompts und Ergebnisse zu biblischen Figuren als Selfies aus der Ich-Perspektive."
|
||||
image: https://cdn.midjourney.com/41d706d7-15ed-40ca-b507-5a2d727e312f/0_2.png
|
||||
tags:
|
||||
- KI-Bilder
|
||||
- Midjourney
|
||||
- Bibel
|
||||
- Selfie
|
||||
- Religionspädagogik
|
||||
- relilab
|
||||
lang: de
|
||||
license: https://creativecommons.org/publicdomain/zero/1.0/deed.de
|
||||
---
|
||||
|
||||
# Bibel-Selfies
|
||||
<iframe title="Pixelfed Post Embed" src="https://pixelfed.social/p/joerglohrer/724534050330570248/embed?caption=true&likes=false&layout=full" class="pixelfed__embed" style="max-width: 100%; border: 0" width="400" height="600" allowfullscreen="allowfullscreen"></iframe><script async defer src="https://pixelfed.social/embed.js"></script>
|
||||
|
||||
## Prompts
|
||||
### Eva, Schlange, Apfel und Adam
|
||||

|
||||
```
|
||||
A selfie of a woman resembling eve in the time of old testament, blurred body, holding an apple, kneeling in front adam. he has a shocked expression with his mouth open and wide eyes, evoking a sense of both fear and surprise. The scene appears surreal, with a huge snake behind her. The photo is taken with a wide-angle lens, adding to the dramatic and humorous effect. The setting is reminiscent of a scene with adam and eve, possibly at a place like garden eden. The style of the photo blends surrealism with humor, similar to the style of Yasir Khan Chaudha --v 6.0
|
||||
```
|
||||
|
||||
### Tochter des Pharao mit Mose
|
||||

|
||||
```A selfie of the biblical figure moabite woman with her baby in front of an oasis. She is wearing traditional and has black hair. The background shows water from the desert oasis and grasses around it. In the Background a wicker basket on the water. The photo was taken in the style of a selfie shot with GoPro camera```
|
||||
|
||||
### Simon Petrus
|
||||

|
||||
```A selfie of a man resembling Simon Petrus, wearing a white robe, surrounded by waves and thunderstorm. He has a shocked expression with his mouth open and wide eyes, evoking a sense of both humor and surprise. The scene appears surreal, with many waves behind him. The photo is taken with a wide-angle lens, adding to the dramatic and humorous effect. The setting is reminiscent of a scene with jesus at the dead sea, possibly at a place like the sea. The style of the photo blends surrealism with humor, similar to the style of Yasir Khan Chaudhary --v 6.0```
|
||||
|
||||
### Zachäus auf dem Baum
|
||||

|
||||
```A selfie of a man resembling a roman in the time of jesus, wearing a glamorous robe, surrounded by the crown of a tree. He has a shocked expression with his mouth open and wide eyes, evoking a sense of both humor and surprise. The scene appears surreal, with many leaves behind him. The photo is taken with a wide-angle lens, adding to the dramatic and humorous effect. The setting is reminiscent of a scene with jesus walking by, possibly at a place like the jerusalem. The style of the photo blends surrealism with humor, similar to the style of Yasir Khan Chaudhary --v 6.0```
|
||||
|
||||
### Maria am Ostermorgen
|
||||

|
||||
```A selfie of a woman resembling maria in the time of jesus, wearing a robe, kneeling in front of stone grave. she has a shocked expression with her mouth open and wide eyes, evoking a sense of both fear and surprise. The scene appears surreal, with the open glowing grave behind her. The photo is taken with a wide-angle lens, adding to the dramatic and humorous effect. The setting is reminiscent of a scene with jesus resurrection, possibly at a place like the jerusalem. The style of the photo blends surrealism with humor, similar to the style of Yasir Khan Chaudhary --v 6.0```
|
||||
|
||||
### Der verlorene Sohn bei den Schweinen
|
||||

|
||||
```A young ancient arabic man with short hair in the time of jesus, brown eyes, and a dirty face, covered in mud from working on his pig farm, takes an amateur selfie at dusk. He is surrounded by pig stables, with a barn visible in the background and pigs seen near the front. The photo captures a raw, authentic moment, as he gazes directly into the camera with an expression of excitement or wonder. The image has a realistic style, akin to Unsplash photography, and is meant to be posted on a primitive-themed social network. The resolution of the photo is high, style of selfie with gopro --v 6.0```
|
||||
|
||||
### Vater und Sohn vereint
|
||||

|
||||
A selfie of an Arab father in simple garments in the time of jesus, embracing and hugging a young man. The father's face, visible in the foreground, radiates joy and relief. Only the back of the son's head is visible, as he faces away from the camera, returning the embrace in tattered clothing. In the background, a large ancient house and other family members can be seen watching from a distance, blurred. The photo is taken with a wide-angle lens using a GoPro, enhancing the dramatic and overwhelming effect of the scene --v 6.0
|
||||
|
||||
### Bartimäus
|
||||

|
||||
```A selfie of a man resembling blind bartimaeus in the time of jesus, black and brown and white bandages on his head over his eyes and face, wearing a robe, kneeling in front of a market place. he has a shocked expression with his mouth open and wide eyes still covered with black and brown and white bandages on his head, evoking a sense of both fear and surprise. The scene appears surreal, with many sand behind him. The photo is taken with a wide-angle lens, adding to the dramatic and humorous effect. The setting is reminiscent of a scene with jesus healing the blind, possibly at a place like the jerusalem. The style of the photo blends surrealism with humor, similar to the style of Yasir Khan Chaudha --v 6.0```
|
||||
|
||||
### Daniel in der Löwengrube
|
||||

|
||||
```A selfie of a man resembling Jesus, wearing a beige hoodie, surrounded by lions and cheetahs. He has a shocked expression with his mouth open and wide eyes, evoking a sense of both humor and surprise. The scene appears surreal, with many lions behind him. The photo is taken with a wide-angle lens, adding to the dramatic and humorous effect. The setting is reminiscent of a lion's den, possibly at a place like the Grand Tabahar. The style of the photo blends surrealism with humor, similar to the style of Yasir Khan Chaudhary```
|
||||
|
||||
### David und Goliath
|
||||

|
||||
```selfie of a the boy and shepherd david holding his slingshot resembling a fight with the giant goliath in the time of old testament, wearing a glamorous sligshot focusing on his giant opponent. David has a shocked expression with his mouth open and wide eyes, evoking a sense of both humor and surprise. The scene appears surreal, with a desert surrounding him. The photo is taken with a wide-angle lens, adding to the dramatic and humorous effect. The setting is reminiscent of the scene of David fighting with the giant goliath with his slingshot, possibly at a place like the jerusalem. The style of the photo blends surrealism with humor, similar to the style of Yasir Khan Chaudhary --v 6.0```
|
||||
|
||||
### Simson im Philistertempel
|
||||

|
||||
```A selfie of a man resembling simson in the time of old testament, wearing a glamorous beard and long hair, surrounded by thousands of ancient fighters. He has a shocked expression with his mouth open and wide eyes, evoking a sense of both humor and surprise. The scene appears surreal, with a temple surrounding him. The photo is taken with a wide-angle lens, adding to the dramatic and humorous effect. The setting is reminiscent of a scene with jesus walking by, possibly at a place like the jerusalem. The style of the photo blends surrealism with humor, similar to the style of Yasir Khan Chaudhary --v 6.0 ```
|
||||
|
||||
### Jona und der Wal
|
||||

|
||||
```A selfie of a man resembling israeli jona in the time of old testament,`wearing a glamorous beard and long hair, inside the body of a whale. He has a shocked expression with his mouth open and wide eyes, evoking a sense of both humor and surprise. The scene appears surreal, with the ocean surrounding him. The photo is taken with a wide-angle lens, adding to the dramatic and humorous effect. The setting is reminiscent of a scene in the bible. The style of the photo blends surrealism with humor, similar to the style of Yasir Khan Chaudhary```
|
||||

|
||||
|
||||
### Jakob und Isaak
|
||||

|
||||
```A selfie of a young man resembling an ancient Arabic in clothes made of skins of goats and furs of the goats, looking overwhelmed and distressed as he betrays his father, who blesses him. The scene shows a dawn sky with hints of the sunrise, evoking a surreal and dramatic atmosphere. The scene is set in ancient Jerusalem, with stone buildings. in the background an old man with a gesture of blessing, rising his hands to the sky, The photo is taken with a wide-angle lens, blending surrealism with humor. The style is reminiscent of a GoPro selfie, capturing the intense moment with a sense of both fear and surprise```
|
||||
|
||||
### Petrus und der Hahn
|
||||

|
||||
```A selfie of a man resembling ancient young arabic man saint in traditional biblical attire, being eaten by a whale,. he has a shocked expression with his mouth pressed and wide eyes, evoking a sense of both fear and surprise. The scene appears surreal, with one rooster crowing out loud behind the man. The photo is taken with a wide-angle lens, adding to the dramatic and humorous effect. The setting is reminiscent of a scene with peter and the rooster, possibly at a place in jerusalem . The style of the photo blends surrealism with humor, go pro selfie, morning dawn near sunrise setting```
|
||||
|
||||
### Josef im Brunnen
|
||||

|
||||
```A selfie of an ancient israelian man with a magical dreamcoat clothing in a deep well, looking at the camera from above, captured in the style of a go pro selfie stick```
|
||||
|
||||
### Elia und die Raben
|
||||

|
||||
```A close-up selfie of a bearded man (Elijah) in biblical clothing, smiling gratefully. He is standing near a stream in a secluded, rocky area. Several black ravens are perched on his shoulders and arms, holding pieces of bread and meat in their beaks. The scene has a warm, golden light, symbolizing God's provision. Photorealistic style, high detail.```
|
||||
|
||||
### Absalom im Baum
|
||||

|
||||
```A selfie of a man resembling of a young man (Absalom) with long hair knotted arount the branches of a large oak tree.. He has a shocked expression with his mouth open and wide eyes, evoking a sense of both humor and surprise. The scene appears surreal, with all of his hairs knotted around the tree. The photo is taken with a wide-angle lens, adding to the dramatic and humorous effect. The setting is reminiscent of a scene of a robin hood movie in the forest . The style of the photo blends surrealism with humor```
|
||||
|
||||
|
||||
## Ruth und Boas im Weizenfeld
|
||||

|
||||
```A selfie of a young woman resembling Ruth, with a radiant smile and sun-kissed skin. She's standing in a golden wheat field at sunset, her arms filled with freshly gathered sheaves of wheat. Her hair is partially covered with a simple headscarf, with loose strands blowing in the wind. She has a look of joy and gratitude in her eyes. The scene appears idyllic, with wheat stalks seeming to embrace her. In the background, a distinguished older man (Boaz) can be seen watching from a distance, his expression a mix of curiosity and admiration. The photo is taken with a wide-angle lens, capturing the vastness of the field, the warmth of the setting sun, and Boaz in the distance. The setting is reminiscent of a biblical harvest scene. The style of the photo blends realism with a touch of romantic nostalgia.```
|
||||
|
||||
|
||||
<p class="attribution">"<a target="_blank" rel="noopener noreferrer" href="https://git.rpi-virtuell.de/Comenius-Institut/KI-religionsbezogen/src/branch/main/bibel-selfies.md">Bibel-Selfies</a>" von <a target="_blank" rel="noopener noreferrer" href="https://reliverse.social/@joerglohrer">Jörg Lohrer</a> Lizenz: <a target="_blank" rel="noopener noreferrer" href="https://creativecommons.org/publicdomain/zero/1.0/deed.de">CC0 1.0 <img src="https://mirrors.creativecommons.org/presskit/icons/cc.svg" style="height: 1em; margin-right: 0.125em; display: inline;"></img><img src="https://mirrors.creativecommons.org/presskit/icons/zero.svg" style="height: 1em; margin-right: 0.125em; display: inline;"></img></a>.</p>`
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
---
|
||||
layout: post
|
||||
title: "📢 Empowering Learners for the Age of AI – der neue Review Draft des AI Literacy Frameworks für Schule ist da!"
|
||||
slug: "ai-literacy-framework-review-draft"
|
||||
date: 2025-05-22
|
||||
description: "Das neue Review Draft des AI Literacy Frameworks für Schule von OECD und EU-Kommission beschreibt vier zentrale Domänen der KI-Kompetenz – jede mit einem klaren Profil aus Wissen, Fertigkeiten und Haltungen. Sie lassen sich hervorragend mit vier Kompetenzbereichen verbinden."
|
||||
image: https://blossom.primal.net/07b7736254aa6cbdf5423c542ad131a9fd4115909674a8ef5c350b315a35b18a.png
|
||||
tags:
|
||||
- KI
|
||||
- AI-Literacy
|
||||
- OECD
|
||||
- EU-Kommission
|
||||
- Kompetenzrahmen
|
||||
- Schule
|
||||
- Bildung
|
||||
- relilab
|
||||
lang: de
|
||||
license: https://creativecommons.org/publicdomain/zero/1.0/deed.de
|
||||
---
|
||||
|
||||
🧠 Entwickelt von OECD & EU-Kommission – jetzt zur Rückmeldung freigegeben:\
|
||||
👉 <https://ailiteracyframework.org/>
|
||||
|
||||
Das Framework beschreibt vier zentrale **Domänen der KI-Kompetenz** – jede mit einem klaren Profil aus **Wissen**, **Fertigkeiten** und **Haltungen**. Diese lassen sich hervorragend mit den **vier Kompetenzbereichen** verbinden:
|
||||
|
||||
🔹 **Engaging with AI** ↔ 🟢 *Verstehen*
|
||||
|
||||
Lernende erkennen KI in ihrem Alltag, verstehen ihre technischen Grundlagen (📘 *Knowledge*) und entwickeln die Fähigkeit, Ausgaben kritisch zu analysieren (🛠️ *Skills*), begleitet von einer neugierigen und verantwortungsbewussten Einstellung (🧭 *Attitudes*).
|
||||
|
||||
🔹 **Creating with AI** ↔ 🔵 *Anwenden*
|
||||
|
||||
Durch den kreativen Einsatz generativer KI entstehen neue Lernprodukte. Benötigt werden technisches Verständnis (📘 z. B. zu Trainingsdaten), Anwendungskompetenz (🛠️ z. B. Promptgestaltung), sowie eine innovationsorientierte Haltung (🧭 Ownership, Urheberrecht, Attribution).
|
||||
|
||||
🔹 **Managing AI** ↔ 🟠 *Reflektieren*
|
||||
|
||||
Hier geht es um bewusste Entscheidungen: Wann ist KI sinnvoll? Wie wirken sich ihre Vorschläge auf mein Denken aus? Das verlangt (📘) Orientierungswissen, (🛠️) strategisches Problemlösen und (🧭) eine ethisch begründbare Reflexion.
|
||||
|
||||
🔹 **Designing AI** ↔ 🟣 *Gestalten*
|
||||
|
||||
Lernende analysieren und entwerfen KI-Systeme: Welche Daten nutze ich? Wer profitiert? Mit welchen Folgen? Die Verbindung aus (📘) systemischem Wissen, (🛠️) Gestaltungskompetenz und (🧭) ethischer Haltung eröffnet Bildungsräume im digitalen Wandel.
|
||||
|
||||
📬 Rückmeldungen zum Entwurf sind willkommen – eure Expertise aus der Praxis zählt!
|
||||
|
||||
👉 \[<https://teachai.org/ailiteracy/review](https://teachai.org/ailiteracy/review)>
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
---
|
||||
layout: post
|
||||
title: "Religionsbezogene Bildung mit Rollenkarten: KI-Bilder als Impulsgeber"
|
||||
slug: "rollenkarten-ki-bilder-impulsgeber"
|
||||
date: 2025-06-02
|
||||
description: "In diesem Beitrag verweise ich auf zwei inspirierende Blogartikel von Frau Schütze zum Einsatz von KI-Bildern und Rollenkarten im Ethik-/Philosophieunterricht und zeige, wie sich diese Methoden auf die religionsbezogene Bildung übertragen lassen."
|
||||
image: https://blossom.primal.net/f099c12882809b51620342738116f9e211cb76b2b8575493376932c5e53eb298.png
|
||||
tags:
|
||||
- relilab
|
||||
- Religionsunterricht
|
||||
- KI-Bilder
|
||||
- Rollenkarten
|
||||
- Bildung
|
||||
- Reflektieren
|
||||
- Gestalten
|
||||
lang: de
|
||||
license: https://creativecommons.org/publicdomain/zero/1.0/deed.de
|
||||
---
|
||||
|
||||
Im Rahmen unseres Workshops möchte ich auf zwei inspirierende Beiträge von [Frau Schütze](https://frauschuetze.de) hinweisen und dich einladen, deren Ansätze für die religionsbezogene Bildung weiterzudenken. Die beiden Ausgangspunkte sind:
|
||||
|
||||
1. [**Werkstattbericht: KI-Bilder im Ethik/Philosophie-Unterricht**](https://frauschuetze.de/?p=7946)\
|
||||
In diesem Beitrag beschreibt Frau Schütze, wie sie mithilfe verschiedener KI-Modelle (Midjourney, DALL·E, Ideogram.ai) Bilder generiert, um Schülerinnen und Schüler zum Nachdenken über philosophische und ethische Theorien anzuregen. So wurden zum Beispiel KI-Bilder erstellt, die eine Glückstheorie illustrieren, oder symbolische Darstellungen zum ontologischen und kosmologischen Gottesbeweis (etwa das „größte denkbare Wesen“ bzw. den Laplaceschen Dämon). Dabei erfahren die Lernenden nicht nur einen visuellen Zugang zu abstrakten Gedankenexperimenten, sondern reflektieren zugleich die Grenzen und Tücken von KI-gestützter Bildbeschreibung und -verarbeitung.\
|
||||
<https://frauschuetze.de/?p=7946>
|
||||
|
||||
2. [**Rollenkarten Moralphilosophie / angewandte Ethik**](https://frauschuetze.de/?p=7640)\
|
||||
Hier stellt Frau Schütze Rollenkarten vor, die sie für den Oberstufenunterricht entwickelt hat, um Diskussionen zur angewandten Ethik zu strukturieren. Die Karten enthalten Porträts wichtiger Moralphilosoph*innen (z. B. Diogenes, Spaemann) und weitere Rollen wie Hinterfrager*innen oder Zweifler\*innen, die sich schnell in Fallanalysen, Fishbowl-Formate oder Philosophencafés einbringen lassen. Die Porträts wurden ebenfalls mit Midjourney erstellt, wobei die Prompts konkret beschreiben, wie die Figuren dargestellt werden sollen (z. B. „xxx as a character in a fantasy story, portrait“).\
|
||||
<https://frauschuetze.de/?p=7640>
|
||||
|
||||
***
|
||||
|
||||
## Übertragung auf die religionsbezogene Bildung
|
||||
|
||||
Für religionsbezogene Bildungsszenarien eröffnen sich hier viele spannende Möglichkeiten. Im Folgenden findest du ein paar Ideenimpulse, wie du KI-Bilder und Rollenkarten gezielt einsetzen kannst, um religiöse Fragen, Traditionen und Identitäten in den Mittelpunkt zu rücken:
|
||||
|
||||
1. **KI-Bilder zur Visualisierung religiöser Konzepte**
|
||||
|
||||
* **Symbolik und Ikonographie erschließen**\
|
||||
Nutze KI-Modelle, um die Symbolwelten verschiedener Religionen (z. B. Christentum, Islam, Buddhismus, Hinduismus) visuell zu erkunden. Überlege: Wie lässt sich das Kreuz in unterschiedlichen Stilrichtungen (klassisch, modern, abstrakt) darstellen? Welche Bildwelten entstehen, wenn du nach einer fusionierten Ikonographie fragst, die christliche, buddhistische und indigene Symbole kombiniert? Durch den kreativen Prozess mit KI lernst du, welche Metaphern und Traditionen hinter religiösen Zeichen stehen und wie sie – bewusst oder unbewusst – von Algorithmen interpretiert werden. Dabei kannst du auch die **Grenzen von KI** thematisieren: Welche Vorurteile oder Fehldeutungen schleichen sich in die Bildgenerierung ein, wenn religiöse Themen verarbeitet werden?
|
||||
|
||||
* **Gedankenexperimente zu Gott und Transzendenz**\
|
||||
Analog zu den KI-Bildern für Gottesbeweise könntest du Aufgaben stellen wie:
|
||||
|
||||
1. „Erstelle ein KI-Bild, das die Idee von Theodizee visuell darstellt.“
|
||||
|
||||
2. „Lass die KI eine Szene generieren, in der gläubige und atheistische Perspektiven im Dialog stehen.“\
|
||||
Anschließend diskutierst du mit der Gruppe, inwiefern die Bilder die jeweiligen Konzepte treffend abbilden oder eher stereotyp und eindimensional bleiben. Auf diese Weise vermittelst du nicht nur Content-Wissen, sondern entwickel auch die Medienkompetenz, indem du Fragen nach Intention, Deutungshoheit und algorithmischer Verzerrung aufwirfst.
|
||||
|
||||
2. **Rollenkarten für religiöse Perspektiven und Diskussionsformate**
|
||||
|
||||
* **Personen aus religiösen Traditionen als Rollen**\
|
||||
Statt ausschließlich Moralphilosoph*innen *zu berücksichtigen, kannst du Rollenkarten mit Porträts von Religions*stifter*innen (z. B. Jesus von Nazareth, Maria, Mohammed, Buddha, Krishna), Reformern (Martin Luther, Savonarola) oder zeitgenössischen Theolog\*innen (z. B. Dorothee Sölle) gestalten. Die KI-gestützten Bilder können dabei in unterschiedlichen künstlerischen Stilrichtungen entstehen – von historischer Malerei bis hin zu zeitgenössischer Street-Art-Adaption. Jede Rolle enthält einen kurzen Steckbrief mit zentralen Glaubensvorstellungen, biografischen Eckpunkten und einem charakteristischen Argument oder Zitat. So kannst du die Lernenden in Rollendebatten schicken, etwa:
|
||||
|
||||
* „Wie würde Luther heute auf die Klimakrise blicken?“
|
||||
|
||||
* „Welche theologische Argumentation könnte Dorothee Sölle zum Thema Gewaltlosigkeit einbringen?“
|
||||
|
||||
* **Szenarien für kontroverse Debatten**
|
||||
|
||||
1. **Religiöse Vielfalt versus Säkularismus**\
|
||||
Verteilt Karten, in denen Rollen wie „konservativer Christ“, „liberaler Muslim“, „selbstbewusste/r Konfessionslose/r“, „Theologieprofessor/in“ oder „politische/r Aktivist/in“ eingenommen werden. Die KI-Porträts unterstützen die Visualisierung, verleihen den Rollen ein Gesicht und erleichtern das Einfühlen in andere Perspektiven.
|
||||
|
||||
3. **Interaktive Formate im Religionsunterricht**
|
||||
|
||||
* **Glaubenscafé (analog zum Philosophencafé)**\
|
||||
In Kleingruppen diskutiert ihr verschiedene Glaubenspositionen. KI-Bilder dienen als Ausgangspunkt: Ein Bild, das etwa die Drei-Tage-Phase von Tod und Auferstehung Jesu künstlerisch darstellt, oder ein generiertes Motiv zu einem hinduistischen Fest (z. B. Holi), wird an die Wand projiziert. Anschließend reflektiert ihr gemeinsam: Welche Emotionen, Symbole, Bedeutungen nehmt ihr wahr? Danach schlüpft ihr in Rollenkarten (z. B. theologische/r Fachreferent/in, Religionskritiker/in, Gemeindemitglied) und erarbeitet Positionen, die ihr in einem moderierten Glaubenscafé präsentiert.
|
||||
|
||||
* **Fishbowl-Diskussionen**\
|
||||
Nutzt Rollenkarten zu religionsspezifischen Rollen (z. B. Rabbiner/in, Pfarrerin, Atheist/in, Kleriker/in einer traditionellen Religion, spirituelle/r Influencer/in) für eine Fishbowl-Diskussion zum Thema „Sinnsuche in der Postmoderne“. Die KI-Bilder dienen zu Beginn als visuelle Reize: „Welche Elemente im Bild sprechen für Spiritualität, welche eher für Skepsis?“ So wird die Distanz zwischen digitaler Darstellung und gelebter religiöser Erfahrung erlebbar.
|
||||
|
||||
4. **Methodische Hinweise und Reflexion**
|
||||
|
||||
* **Prompt-Kompetenz schulen**\
|
||||
Wie schon im Ethikunterricht festgestellt, erfordert das präzise Beschreiben von Bildwünschen viel Übung. Die Lernenden lernen, welche Schlüsselbegriffe notwendig sind und wie kulturelle Vorannahmen in Prompts stecken bleiben. Im religionspädagogischen Kontext könnt ihr dies gezielt thematisieren: Wie formuliert man z. B. den Prompt „Stelle eine friedvolle interreligiöse Konferenz zwischen Christentum, Islam und Judentum dar“ so, dass keine Stereotype reproduziert werden?
|
||||
|
||||
* **Ethik des Bilderzeugens**\
|
||||
Diskutiert gemeinsam, inwiefern KI-Bilder beim Umgang mit heiklen religiösen Themen (z. B. Darstellungen des Propheten Mohammed im Islam) kulturelle oder religiöse Grenzen überschreiten können. Legt zusammen ethische Leitlinien fest, bevor ihr KI zur Bildproduktion nutzt: Welche religiösen Bilder sind "sakrosankt", welche dürfen manipuliert werden und was bedeutet das für Religionsfreiheit und Respekt?
|
||||
|
||||
* **Reflexion über Urheberrecht und OER**\
|
||||
Wie Frau Schütze in den Rollenkarten-Anleitungen betont, sind ihre Materialien unter CC-BY-Lizenz verfügbar. Erörtert, was es bedeutet, religiöse Bild- und Textmaterialien unter Open-Content-Lizenzen zu verwenden und welche Implikationen das für Schule, Gemeinde und Zivilgesellschaft hat. Das sensibilisiert für Fragen von Teilhabe und Gemeineigentum im digitalen Raum.
|
||||
|
||||
***
|
||||
|
||||
## Einladung zum Weiterdenken
|
||||
|
||||
Ich lade dich herzlich ein, die vorgestellten Methoden und Materialien in eigenen Projekten auszuprobieren und weiterzuentwickeln. Diskutiere in Kleingruppen oder in einer offenen Runde:
|
||||
|
||||
* **Welche religiösen Themen lassen sich besonders gut mit KI-Bildern visualisieren?**\
|
||||
Beispiele: Zehn Gebote, Fastenrituale, Schöpfungsmythen, Visionen von Heiligen oder Heiligenschauen. Welche Prompts würdest du verwenden, um diese Szenen zu erzeugen?
|
||||
|
||||
* **Wie könnten Rollenkarten zu spezifischen religiösen Traditionen aussehen?**\
|
||||
Entwickle gemeinsam mit anderen kurze Steckbriefe und Bildprompts für Rollen wie „Sufi-Derwisch“, „Gottesleugner/in der Aufklärung“, „Ökumenische/r Pastor/in“, „Religionspädagog\*in“, „Katholischer Laienbruder“ oder „Jüdische Rabbinerin“. Achte dabei auf unterschiedliche religiöse Sichtweisen und Geschlechterperspektiven.
|
||||
|
||||
* **Welche Herausforderungen ergeben sich beim Einsatz von KI-Bildern im Religionsunterricht?**\
|
||||
Erörtert mögliche Missverständnisse, kulturelle Fehlinterpretationen oder ethische Konflikte (z. B. pietätslose Darstellungen von Figuren, die in bestimmten Glaubensgemeinschaften als heilig gelten). Entwickelt gemeinsam Kriterien oder einen Leitfaden, um solche Risiken zu minimieren.
|
||||
|
||||
* **Wie lassen sich interreligiöse Dialoge durch digitale Methoden fördern?**\
|
||||
Gebt Impulse, wie man mit KI-Bildern und Rollenkarten einen „virtuellen Tempelraum“ gestalten kann, in dem Symbole unterschiedlicher Religionen nebeneinanderstehen und zu Dialog anregen. Welche Fragen stellen sich dabei hinsichtlich Toleranz, Respekt und theologischer Pluralität?
|
||||
|
||||
***
|
||||
|
||||
### Zusammenfassung und Ausblick
|
||||
|
||||
Die beiden Beiträge von Frau Schütze bieten hervorragende Grundlagen, um im religionsbezogenen Unterricht visuelle und interaktive Zugänge zu schaffen. Durch KI-Bilder gewinnen die Teilnehmenden neue Zugänge zu Symbolik, Theodizee und Gottesvorstellungen, während Rollenkarten Dialogkompetenz und Empathie für unterschiedliche religiöse Perspektiven stärken. Diese Ideenimpulse sollen dich ermutigen, die Methoden selbst zu erproben und weiterzuentwickeln. Im digitalen Zeitalter können wir so das gemeinsame Lernen und interkulturelle Verständnis gerade im sensiblen Feld der Religionsbildung bereichern.
|
||||
|
||||
Ich freue mich auf deine kreativen Umsetzungen und den Austausch über Erfahrungen, Herausforderungen und Fortschritte in diesem spannenden Feld!Antworte gerne auf diesen Beitrag und kommentiere mit deinen Gedanken und Assoziationen oder poste selbst etwas als Impuls, das andere anregt und weiterverwendet werden darf mit dem Hashtag #relilab.\
|
||||
\
|
||||
Template: Avery Re <https://opengameart.org/content/trading-card-template> unter der Lizenz CC-by <https://creativecommons.org/licenses/by/4.0/deed.de> / Einige Texte und Bezeichnungen von Frank Schlegel <https://digitaldurstig.de/rollenkarten/> unter der Lizenz CC-by-SA 4.0, <https://creativecommons.org/licenses/by-sa/4.0/deed.de>\
|
||||
Lizenz: cc-by-sa 4.0 / [frauschuetze.de](http://frauschuetze.de) / Juli 2023
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
---
|
||||
layout: post
|
||||
title: "Nostr und Open Educational Practices (OEP)"
|
||||
slug: "nostr-und-open-educational-practices"
|
||||
date: 2025-06-04
|
||||
description: "Der Beitrag zeigt, wie das dezentrale Protokoll Nostr mit seiner clientseitigen Architektur und kryptografischen Identitätsverwaltung eine zensurresistente, transparente und partizipative Alternative zu zentralisierten Bildungsplattformen bietet. Durch die Integration von Nostr in Open Educational Practices (OEP) können Lernende und Lehrende volle Kontrolle über Inhalte und Identitäten übernehmen und gemeinsam an frei zugänglichen Bildungsressourcen arbeiten."
|
||||
image: https://blossom.primal.net/a93ea30b4651700874b7817e5aabf1755e1c1b07b312e4f1b8a18d70f3d17335.jpg
|
||||
tags:
|
||||
- Nostr
|
||||
- OEP
|
||||
- Open Educational Practices
|
||||
- OER
|
||||
- Dezentral
|
||||
- Bildung
|
||||
- Kryptografie
|
||||
- Zensurresistenz
|
||||
- Partizipation
|
||||
- Commons
|
||||
lang: de
|
||||
license: https://creativecommons.org/publicdomain/zero/1.0/deed.de
|
||||
---
|
||||
|
||||
## 1. Einleitung
|
||||
|
||||
Dieser Beitrag bietet eine detaillierte Einführung in Nostr - ein dezentrales Protokoll für soziale Medien, das sich grundlegend von traditionellen und föderierten Plattformen unterscheidet - und erweitert seine Diskussion, um das Potenzial für die Integration von Nostr in die Entwicklung von Bildungsplattformen im Sinne von Open Educational Practices (OEP) zu untersuchen. Nostr ist als leichtgewichtiger, zensurresistenter Mechanismus konzipiert, der in erster Linie auf einer kryptografischen Schlüsselverwaltung und einer Client-zentrierten Architektur basiert, die zusammen eine dezentrale Identitätskontrolle und Autonomie über digitale Inhalte ermöglichen. Im Gegensatz zu konventionellen zentralisierten Modellen, die sich auf servergestützte Kontrolle oder föderierte Systeme wie Mastodon, die Moderation und Identitätsspeicherung zusammenfassen, stützt sich Nostr auf ein Netzwerk von "Relais", die ohne direkte Interkommunikation arbeiten, wobei alle Funktionen der Datenaggregation, des Austauschs von Inhalten zwischen den Räumen und der Benutzerinteraktion an die Client-Anwendungen delegiert werden. Ziel dieses Beitrags ist es daher, einen umfassenden technischen und konzeptionellen Überblick über die Architektur des Protokolls und eine kritische Diskussion seiner Anwendbarkeit im Kontext dezentraler Bildungsplattformen zu geben, die eine offene, nutzergesteuerte Erstellung, Verwaltung und Verteilung von Inhalten fördern und damit den Prinzipien des OEP entsprechen.
|
||||
|
||||
## 2. Hintergrund und Motivation
|
||||
|
||||
Die Entwicklung der sozialen Medien in den letzten zwei Jahrzehnten war geprägt von zunehmend zentralisierten Plattformen, die sowohl die digitale Identität der Nutzer als auch die von ihnen erstellten Inhalte kontrollieren, was oft zu Zensur, algorithmischer Manipulation und Einzelausfällen führt. Im Gegensatz dazu haben sich dezentralisierte Social-Media-Protokolle wie Nostr als innovative Antwort auf diese Zentralisierungsfallen entwickelt, die offene, kryptografisch sichere und verteilte Architekturen verwenden. Nostr wurde mit dem ausdrücklichen Schwerpunkt konzipiert, den Nutzern die volle Kontrolle über ihre Kontoauthentifizierung und digitale Identität zu geben, was durch die Verwaltung von kryptografischen Schlüsselpaaren analog zu denen in Bitcoin-Wallets erreicht wird. Dieses Modell verbessert nicht nur die zensurresistenten Eigenschaften der Plattform, sondern verändert auch grundlegend die Machtdynamik in traditionellen sozialen Netzwerken, da es die Abhängigkeit von zentralen Behörden minimiert und die Möglichkeiten der Ausbeutung und Überwachung verringert. Im Kontext von Bildungsökosystemen bieten diese Merkmale eine solide Grundlage für die Entwicklung von Plattformen, die transparent, partizipatorisch und widerstandsfähig sind, da sie die gemeinsame Nutzung und kontinuierliche Weiterentwicklung von Bildungsressourcen ohne zentralisierte Kontrolle oder Unternehmenssteuerung unterstützen.
|
||||
|
||||
## 3. Technische Architektur von Nostr
|
||||
|
||||
Die Architektur von Nostr basiert in erster Linie auf zwei integralen Komponenten: Clients und Relays. Clients sind die benutzerseitigen Anwendungen, die die Erstellung, Veröffentlichung und Aggregation von Inhalten ermöglichen, während Relays Server sind, die von den Benutzern generierte Nachrichten, so genannte "Events", empfangen, speichern und weiterleiten. Eine der wichtigsten Neuerungen des Nostr-Protokolls ist die radikale Entkopplung der traditionellen Client-Server-Beziehung: Die Relais müssen sich nicht synchronisieren oder miteinander kommunizieren, was bedeutet, dass die Replikation von Inhalten und die Aggregation von Daten zwischen den Räumen vollständig auf der Client-Seite stattfindet. Dies minimiert die zentrale Kontrolle und schafft ein verteiltes, relaisbasiertes System, das von Natur aus resistent gegen Zensur ist, da die Dichte der verfügbaren Relays und die Möglichkeit der Nutzer, sich mit mehreren Relays zu verbinden, sicherstellen, dass die Inhalte auch dann zugänglich bleiben, wenn bestimmte Relays ausfallen oder kompromittiert sind.
|
||||
|
||||
In Nostr sind Ereignisse als JSON-Objekte strukturiert, die einem definierten Schema mit verschiedenen "Arten" (NIPs) entsprechen, um spezifische Funktionalitäten und andere zukünftige Erweiterungen zu unterstützen. Jedes Ereignis wird mit dem privaten kryptografischen Schlüssel des Nutzers digital signiert, was die Authentizität und Integrität der Daten garantiert und gleichzeitig eine sichere, pseudonyme Interaktion ermöglicht. Da innerhalb des Protokolls keine zentrale Behörde oder ein einziger Identitätsspeicher existiert, behalten die Nutzer die vollständige Kontrolle über ihre digitalen Identitäten, und wenn der private Schlüssel verloren geht, ist das entsprechende Konto unwiederbringlich. Dieser Ansatz verankert den Grundsatz, dass Identität sowohl dezentralisiert als auch unveränderlich ist, und stärkt die individuelle Kontrolle über persönliche Daten und Inhalte.
|
||||
|
||||
Da die Relays unabhängig voneinander von verschiedenen Betreibern verwaltet und nicht von zentralen Diensten koordiniert werden, können die Benutzer mehrere Relays auswählen, um die Bereitstellung und Replikation von Inhalten zu optimieren. Diese architektonische Entscheidung verbessert die Redundanz und Ausfallsicherheit erheblich, bringt aber gleichzeitig technische Herausforderungen in Bezug auf Standardisierung, Moderation und effiziente Kommunikation zwischen den Clients mit sich. Nostr integriert auch Bitcoin-basierte Mikrozahlungen (bekannt als "Zaps"), um finanzielle Anreize für den Betrieb von Relay-Servern zu schaffen und so die Herausforderungen der wirtschaftlichen Nachhaltigkeit in einer dezentralen Infrastruktur zu bewältigen. Die Design-Entscheidungen in Nostr fördern Offenheit und Innovation, indem sie es jeder Person oder Organisation mit ausreichenden technischen Fähigkeiten erlauben, ein Relay zu hosten oder einen Client zu entwickeln, was zu einem lebendigen Ökosystem von verschiedenen Anwendungen und Diensten führt.
|
||||
|
||||
## 4. Identitätsmanagement und Moderation
|
||||
|
||||
Im Mittelpunkt des dezentralen Paradigmas von Nostr steht der Ansatz der Identitätsverwaltung, der sich von traditionellen Systemen unterscheidet, bei denen Identitäten als Kontodaten von zentralen Servern verwaltet werden. Bei Nostr verfügt jeder Benutzer über ein einzigartiges kryptografisches Schlüsselpaar, wobei der öffentliche Schlüssel als dauerhafte digitale Kennung und der private Schlüssel als Mittel zum Signieren aller Aktionen und Ereignisse dient. Dieses kryptonative Modell gewährleistet nicht nur robuste Sicherheit und Authentizität, sondern überlässt den Nutzern auch die alleinige Verantwortung für die Verwaltung ihrer Schlüssel - was sowohl die Freiheit als auch das Risiko eines vollständig dezentralen Identitätssystems unterstreicht.Dieses empfindliche Gleichgewicht unterstreicht die Autonomie der Nutzer und steht im Einklang mit dem breiteren Ethos dezentraler sozialer Medien, in denen keine einzelne Instanz die Identität einer Person ohne Zugang zu ihrem privaten Schlüssel außer Kraft setzen oder manipulieren kann.
|
||||
|
||||
Die Moderation in Nostr ist aufgrund des dezentralen Kuratierungsmodells noch komplexer. Die Inhalte werden nicht von einer zentralen Behörde moderiert, sondern durch eine Kombination aus relaisspezifischen Richtlinien und clientseitigen Kontrollen, wie z. B. benutzerdefinierte Listen für persönliche Kuratoren oder Sperr-/Stummschaltungsfunktionen. Dieser Ansatz fördert zwar die freie Meinungsäußerung und unterstützt eine Vielzahl von Standpunkten, kann aber auch zu uneinheitlichen Moderationsrichtlinien und fragmentierten Nutzererfahrungen auf verschiedenen Relays und Clients führen, was für die Nutzer eine erhebliche technische und verhaltensbezogene Belastung darstellt, wenn sie ihre digitalen Umgebungen kuratieren wollen.
|
||||
|
||||
## 5. Skalierbarkeit und Widerstandsfähigkeit gegen Zensur
|
||||
|
||||
Ein entscheidendes Merkmal des Nostr-Protokolls ist sein hohes Maß an Skalierbarkeit und Zensurresistenz, das durch seine verteilte, relaisbasierte Architektur erreicht wird. Durch die Dezentralisierung der Speicherung und Verbreitung von Inhalten auf zahlreiche unabhängige Relays entschärft Nostr die üblichen Schwachstellen zentralisierter sozialer Plattformen, wie z. B. Single Points of Failure oder das Potenzial für staatliche oder unternehmensgesteuerte Zensur. Mehrere Relays können gleichzeitig arbeiten, wodurch sichergestellt wird, dass die Stabilität des Netzwerks auch dann erhalten bleibt, wenn einige von ihnen vom Netz genommen werden oder behördlichem Druck ausgesetzt sind, und dass Inhalte für Kunden, die mit alternativen Relays verbunden sind, zugänglich bleiben.
|
||||
|
||||
Die Skalierbarkeit von Nostr wird dadurch verbessert, dass sich die Nutzenden gleichzeitig mit mehreren Relays verbinden können, was die Replikation von Inhalten über ein globales Netz von Servern ermöglicht, das sich über zahlreiche geografische Regionen und autonome Systeme erstreckt. Dieser Replikationsmechanismus bringt jedoch Herausforderungen mit sich, wie z. B. eine erhöhte Redundanz bei der Speicher- und Bandbreitennutzung, die eine sorgfältige Optimierung und die Entwicklung innovativer clientseitiger Strategien erfordern, um den Datenabruf zu verwalten und den Overhead zu reduzieren, ohne die Verfügbarkeit zu beeinträchtigen. Darüber hinaus trägt die Dezentralisierung der Zuständigkeiten für Kuratierung und Moderation zu einer dynamischen Umgebung bei, in der Inhalte sowohl gegen erzwungene Löschungen resistent als auch an die Bedürfnisse der Nutzer anpassbar sind, wenn auch auf Kosten von Einheitlichkeit und vorhersehbarer Nutzererfahrung.
|
||||
|
||||
## 6. Vergleich mit föderierten und zentralisierten Modellen
|
||||
|
||||
Das Aufkommen dezentraler Protokolle wie Nostr steht in scharfem Kontrast zu föderierten Netzwerken wie Mastodon, die das ActivityPub-Protokoll verwenden. Während föderierte Systeme die Kontrolle über unabhängige Instanzen verteilen, verlassen sie sich immer noch auf ein gewisses Maß an Zentralisierung auf der Ebene jeder einzelnen Instanz, von denen jede ihre eigenen Richtlinien bezüglich Moderation, Identitätsüberprüfung und Inhaltszensur durchsetzt. In diesen Systemen ist die Verwaltung zwar auf mehrere Knotenpunkte verteilt, aber es besteht eine Abhängigkeit von Serveradministratoren, die erhebliche Macht über den Inhalt und die Identität der Benutzer haben; dies steht im Gegensatz zu Nostrs vollständig kundenorientiertem Kurations- und Identitätsverwaltungsmodell, das darauf abzielt, jeden einzelnen Kontrollpunkt zu eliminieren.
|
||||
|
||||
In zentralisierten Architekturen werden Benutzerdaten und -identitäten auf proprietäre und undurchsichtige Weise verwaltet, wodurch Inhalte den Launen von Unternehmensrichtlinien und externem Druck ausgesetzt werden, was zu Überwachung, gezielter Zensur oder einseitiger algorithmischer Manipulation von Inhalten führen kann. Das Design von Nostr - das auf kryptografischer Authentifizierung, clientseitiger Datenaggregation und dem erlaubnisfreien Betrieb von Relay-Servern beruht - stellt eine radikale Abkehr dar, die das Gleichgewicht der Macht fest in die Hände der einzelnen Nutzer und nicht in die Hände zentraler Behörden legt.
|
||||
|
||||
## 7. Implikationen für Bildungsplattformen
|
||||
|
||||
Das Potenzial für die Integration des dezentralen Protokolls von Nostr in Bildungsplattformen bietet eine transformative Gelegenheit zur Unterstützung von Open Educational Practices (OEP). OEP legt den Schwerpunkt auf Inklusion, Autonomie der Lernenden, kollaborative Wissenserstellung und offenen Zugang zu Bildungsressourcen. Herkömmliche Bildungsplattformen sind oft durch zentralisierte Architekturen eingeschränkt, die den Zugang behindern und Innovationen unterdrücken können, da sie auf einzelne administrative Kontrollen und proprietäre Content-Management-Systeme angewiesen sind.
|
||||
|
||||
Durch die Nutzung der dezentralen Infrastruktur von Nostr können Bildungsplattformen aufgebaut werden, die es Lehrenden und Lernenden ermöglichen, die volle Kontrolle über ihre digitalen Identitäten und Bildungsinhalte zu behalten, ohne das Risiko von Zensur oder externer Einmischung. In solchen Systemen verwaltet jeder Teilnehmer seine eigene Identität mithilfe von kryptografischen Schlüsseln, wodurch sichergestellt wird, dass Bildungsleistungen, Beiträge und Zeugnisse auf überprüfbare und unveränderliche Weise aufgezeichnet werden. Dies fördert ein Umfeld, in dem die Schaffung und Verbreitung von Wissen wirklich von den Teilnehmern selbst bestimmt wird, frei von den Zwängen, die von zentralen Torwächtern auferlegt werden.
|
||||
|
||||
Darüber hinaus bietet die relaisbasierte Architektur von Nostr eine beispiellose Skalierbarkeit und Ausfallsicherheit - Eigenschaften, die für ein Bildungsökosystem, das eine Vielzahl von Nutzern mit unterschiedlichem geografischen und sozioökonomischen Hintergrund unterstützen muss, unerlässlich sind. Bildungsinhalte - von digitalen Lehrbüchern und Multimedia-Vorlesungen bis hin zu interaktiven Diskursen und gemeinschaftlichen Projekten - können gespeichert und über mehrere Relays repliziert werden, um sicherzustellen, dass der Zugang auch bei lokalen Netzwerkausfällen oder Zensurversuchen erhalten bleibt.
|
||||
|
||||
Das offene Protokoll von Nostr ist außerdem eine ideale Grundlage für die Entwicklung interoperabler Bildungsplattformen, auf denen Drittentwickler und Bildungseinrichtungen maßgeschneiderte Clients und Anwendungen für pädagogische Zwecke erstellen können. Dieser Ansatz fördert ein vielfältiges Ökosystem von Bildungswerkzeugen und -diensten, die nahtlos zusammenarbeiten, ohne auf eine einzige proprietäre Plattform beschränkt zu sein, und steht damit im Einklang mit dem Ethos des OEP. Pädagogen können digitale Lernumgebungen entwerfen, die die kollaborative Erstellung von Inhalten, Peer-Reviews und Community-gesteuerte Kuration unterstützen und gleichzeitig von der Sicherheit und Transparenz profitieren, die das kryptografische Grundgerüst von Nostr bietet.
|
||||
|
||||
Darüber hinaus gewährleistet die inhärente Zensurresistenz von Nostr, dass Bildungsinhalte zugänglich und frei von staatlichen oder unternehmerischen Eingriffen bleiben - ein besonders wichtiger Aspekt für Lernende in Regionen, in denen der Zugang zu Informationen eingeschränkt oder überwacht wird. Diese Widerstandsfähigkeit untermauert nicht nur die demokratische Verbreitung von Wissen, sondern unterstützt auch den Grundsatz eines digitalen Gemeinguts, bei dem Bildungsressourcen kollektiv verwaltet und gemeinsam genutzt werden. In diesem Zusammenhang kann Nostr genutzt werden, um Bildungsplattformen zu entwickeln, die nicht nur robust und integrativ sind, sondern auch auf die Bedürfnisse marginalisierter oder unterrepräsentierter Gemeinschaften abgestimmt sind.
|
||||
|
||||
Zusätzlich zu diesen strukturellen Vorteilen kann die finanzielle Nachhaltigkeit dezentraler Bildungsplattformen durch die Integration von auf Kryptowährungen basierenden Anreizmechanismen gestärkt werden - wie die Unterstützung von Nostr für Bitcoin Lightning "Zaps" zeigt. Diese Micropayment-Systeme eröffnen Möglichkeiten für neuartige Finanzierungsmodelle, die Pädagogen und Inhaltsersteller direkt belohnen und so die Abhängigkeit von werbebasierten Einnahmen oder zentralisierten Finanzierungsströmen verringern, die häufig die Integrität der Bildung gefährden. Solche wirtschaftlichen Anreize können auch den Betrieb und die Wartung von Relay-Servern unterstützen und so sicherstellen, dass die dezentrale Infrastruktur belastbar und kontinuierlich für Bildungszwecke verfügbar bleibt.
|
||||
|
||||
## 8. Möglichkeiten für kollaboratives und kultursensibles Lernen
|
||||
|
||||
Über die technische Stabilität und Skalierbarkeit hinaus bietet die dezentrale Natur von Nostr bedeutende Möglichkeiten zur Förderung von kollaborativem Lernen und kultursensibler Pädagogik. Dezentralisierte Bildungsplattformen, die auf Nostr aufbauen, können lokale Gemeinschaften in die Lage versetzen, Inhalte zu erstellen und zu kuratieren, die kontextuell relevant und kulturell angemessen sind - ein entscheidender Faktor bei der Förderung von Bildungsgerechtigkeit und Inklusivität. In Umgebungen, in denen Mainstream-Bildungstechnologien als von oben nach unten betrachtet werden und nicht mit lokalen Werten oder pädagogischen Ansätzen übereinstimmen, ermöglicht eine dezentralisierte Infrastruktur die Entwicklung maßgeschneiderter digitaler Lernumgebungen, die lokale Kulturen, Sprachen und Traditionen widerspiegeln.
|
||||
|
||||
Dieser gemeinschaftsorientierte Bildungsansatz fördert das Gefühl der Eigenverantwortung bei Lernenden und Lehrenden gleichermaßen und ermutigt zur gemeinschaftlichen Schaffung von Wissen, das unterschiedliche Erkenntnistheorien respektiert. So können Pädagogen beispielsweise dezentrale "Lernräume" einrichten, in denen Open-Source-Lernmaterialien, Werkzeuge für die digitale Kompetenz und innovative Lehrmethoden gemeinsam genutzt und durch partizipatives Engagement kontinuierlich weiterentwickelt werden. In einem solchen Modell verschwimmen die Grenzen zwischen Lernenden und Lehrenden, was einen fließenden Ideenaustausch und die gemeinsame Schaffung von Wissen ermöglicht, die im Mittelpunkt offener Bildungspraktiken stehen.
|
||||
|
||||
Darüber hinaus ermöglicht die Flexibilität der Client-zentrierten Architektur von Nostr die Entwicklung von spezialisierten Bildungsclients, die auf die jeweiligen Lernstile und Disziplinen zugeschnitten sind. Diese Clients könnten Funktionen wie erweiterte Such- und Filterfunktionen für Bildungsinhalte, integrierte Anmerkungswerkzeuge für die gemeinsame Bearbeitung von Dokumenten und interoperable Identitätsmanagementsysteme enthalten, die es den Lernenden ermöglichen, ihre Anmeldedaten nahtlos in verschiedene digitale Lernumgebungen zu übertragen. Solche Innovationen würden nicht nur die Rolle der Nutzerautonomie beim digitalen Lernen stärken, sondern auch die Entwicklung von Bildungsnetzwerken fördern, die belastbar und anpassungsfähig sind und auf die sich verändernden pädagogischen Herausforderungen reagieren können.
|
||||
|
||||
## 9. Herausforderungen und Abhilfestrategien
|
||||
|
||||
Trotz der vielversprechenden Möglichkeiten ist die Integration von Nostr in Bildungsplattformen nicht unproblematisch. Gerade die Funktionen, die den Nutzern mehr Möglichkeiten bieten - wie selbstverwaltete kryptografische Schlüssel und dezentralisierte Inhaltsmoderation - führen auch Komplexitäten ein, die die Benutzerfreundlichkeit und Zugänglichkeit behindern können, insbesondere für Personen ohne fortgeschrittene technische Kenntnisse. Die Schlüsselverwaltung stellt beispielsweise eine kritische Schwachstelle dar; der Verlust eines privaten Schlüssels führt unwiderruflich zum Verlust der Identität und des Zugriffs auf die eigenen digitalen Beiträge - ein Risiko, das durch solide Bildungsinitiativen und Mechanismen zur Unterstützung der Nutzer angegangen werden muss.
|
||||
|
||||
Die technische Kompetenz von Lehrenden und Lernenden ist in diesem Zusammenhang von größter Bedeutung und erfordert die Entwicklung umfassender Einführungsverfahren, benutzerfreundlicher Schnittstellen und Hilfsmittel, die kryptografische Konzepte entmystifizieren und die Interaktion mit dezentralen Netzwerken vereinfachen. Zu diesem Zweck könnte der Einsatz intuitiver Bildungsmodule, interaktiver Tutorials und von der Community geleiteter Schulungen technische Barrieren abbauen und gleichzeitig die Entwicklung digitaler Kompetenzen fördern, die für die Navigation in dezentralen Netzwerken unerlässlich sind. Darüber hinaus muss die Herausforderung einer inkonsistenten Moderation und fragmentierten Nutzererfahrung aufgrund der heterogenen Richtlinien unabhängiger Relais und Clients durch die Einrichtung von Community-Governance-Rahmenwerken und interoperablen Standards angegangen werden, die eine Grundlinie der Qualität und Sicherheit auf der gesamten Plattform gewährleisten.
|
||||
|
||||
Strategien zur Bewältigung des mit der Skalierbarkeit verbundenen Overheads - wie etwa die redundante Replikation von Beiträgen über eine übermäßige Anzahl von Relays - sollten sich auf die Implementierung clientseitiger Optimierungstechniken konzentrieren, die die Ineffizienz von Bandbreite und Speicherplatz verringern, ohne die Belastbarkeit und Zugänglichkeit des Netzwerks zu beeinträchtigen. Kontinuierliche Forschung und iterative Verfeinerung werden notwendig sein, um ein Gleichgewicht zwischen den Vorteilen der Dezentralisierung und der ihr innewohnenden betrieblichen Komplexität zu finden und sicherzustellen, dass auf Nostr basierende Bildungsplattformen sowohl robust als auch benutzerfreundlich bleiben können.
|
||||
|
||||
## 10. Zukünftige Richtungen und Forschungsmöglichkeiten
|
||||
|
||||
Die Einbindung von Nostr in Bildungsplattformen stellt ein fruchtbares Gebiet für Forschung und Entwicklung dar, das zahlreiche Möglichkeiten zur Erkundung bietet. Zukünftige Arbeiten könnten das Design interoperabler Bildungsrahmenwerke erforschen, die die dezentrale Architektur von Nostr mit aufkommenden Technologien wie künstlicher Intelligenz, Blockchain-basiertem Credentialing und fortschrittlichen kryptographischen Techniken integrieren, um die Sicherheit, Zugänglichkeit und Authentizität in digitalen Lernumgebungen weiter zu verbessern. Die Erforschung gemeinschaftsbasierter Governance-Modelle ist ebenfalls gerechtfertigt, da dezentrale Plattformen Mechanismen für die kollektive Entscheidungsfindung und Konfliktlösung erfordern, die mit den Grundsätzen einer offenen, partizipativen Bildung vereinbar sind.
|
||||
|
||||
Ein möglicher Weg ist die Entwicklung modularer, quelloffener Bildungs-Clients, die sich nahtlos mit verschiedenen Relais verbinden lassen und den Lehrkräften leistungsstarke Tools zur Kuratierung und Moderation von Inhalten bieten. Diese Clients könnten anpassbare Dashboards, Module für die Zusammenarbeit in Echtzeit und integrierte Unterstützung für Mikrozahlungen oder Anreizstrukturen bieten, um sicherzustellen, dass Bildungsinhalte sowohl dynamisch als auch finanziell nachhaltig bleiben. Darüber hinaus könnte die Einrichtung digitaler Identitätsrahmen, die auf den kryptografischen Prinzipien von Nostr aufbauen, zu innovativen Modellen für die Überprüfung akademischer Zeugnisse, die Verfolgung des Lernfortschritts und die Erleichterung der plattformübergreifenden Anerkennung von Bildungsleistungen führen, und das alles ohne die Aufsicht zentralisierter Registrierungsbehörden.
|
||||
|
||||
Die Schnittstelle zwischen digitaler Kompetenz und ethischer digitaler Bürgerschaft ist ein weiteres vielversprechendes Forschungsgebiet. In dem Maße, in dem Pädagogen dezentrale Bildungstechnologien in die Lehrpläne integrieren, wird es zwingend notwendig, Lehrmethoden zu entwickeln, die nicht nur technische Kenntnisse, sondern auch die kritischen und moralischen Fähigkeiten vermitteln, die für die Arbeit in einem dezentralen digitalen Ökosystem erforderlich sind. Solche pädagogischen Strategien sollten Schulungen zum Schlüsselmanagement, zur Verifizierung digitaler Identitäten und zum Verständnis der Kompromisse umfassen, die mit dezentralisierten und zentralisierten Technologiemodellen einhergehen, um den Schülern eine ausgewogene Perspektive auf digitale Rechte, Verantwortlichkeiten und Möglichkeiten zu vermitteln.
|
||||
|
||||
Die Zusammenarbeit zwischen akademischen Institutionen, Open-Source-Gemeinschaften und Innovatoren dezentraler Technologien wird entscheidend sein, um diese Forschungsagenden voranzutreiben und die Entwicklung von Bildungslabors und Pilotprojekten zu fördern, die die praktischen Auswirkungen und Vorteile dezentraler Bildungsplattformen auf der Grundlage von Nostr erforschen. Solche Initiativen könnten als lebende Laboratorien zum Testen neuer Werkzeuge, Methoden und Governance-Strategien dienen, mit dem Ziel, das Gleichgewicht zwischen dem Versprechen der Dezentralisierung und ihren praktischen Herausforderungen schrittweise zu verfeinern.
|
||||
|
||||
## 11. Schlußfolgerung
|
||||
|
||||
Zusammenfassend lässt sich sagen, dass Nostr eine bahnbrechende Entwicklung in der Architektur sozialer Medien darstellt, die die Beziehung zwischen Nutzern, Inhalten und Netzwerkinfrastruktur durch ein dezentralisiertes, kryptographisch gesichertes und klientenzentriertes Modell neu definiert. Das relaisbasierte Design, die dezentrale Identitätsverwaltung und die offene Architektur bieten erhebliche Vorteile gegenüber herkömmlichen zentralisierten und föderierten Systemen in Bezug auf Zensurresistenz, Skalierbarkeit und Benutzerautonomie. Diese technischen Innovationen haben weitreichende Auswirkungen auf die Entwicklung von Bildungsplattformen, die den Grundsätzen der Open Educational Practices entsprechen. Durch die Unterstützung von nutzergesteuerten Identitäten, stabiler Replikation von Inhalten und gemeinschaftsgesteuerter Verwaltung bietet Nostr eine Infrastruktur für dezentrale Bildungsökosysteme, die Inklusion, Zusammenarbeit und kontinuierliche Innovation fördern.
|
||||
|
||||
Der Einsatz von Nostr in Bildungskontexten verspricht, Lernende und Lehrende in die Lage zu versetzen, Bildungsinhalte frei von zentralisierten Einschränkungen und externer Zensur zu erstellen, zu teilen und gemeinschaftlich weiterzuentwickeln. Gleichzeitig erfordern die Herausforderungen, die mit der technischen Komplexität, der Schlüsselverwaltung und der inkonsistenten Moderation verbunden sind, kontinuierliche Forschung und die Entwicklung unterstützender Werkzeuge. Wenn diese Probleme durch gezielte Maßnahmen wie benutzerfreundliche Schnittstellen, umfassende Programme für digitale Kompetenz und gemeinschaftsbasierte Governance-Modelle angegangen werden, können die Beteiligten das volle Potenzial von Nostr nutzen, um dezentrale, offene und partizipative Bildungsplattformen zu schaffen, die den Geist von Open Educational Practices wirklich verkörpern.
|
||||
|
||||
Letztendlich stellt die Integration von Nostr in dezentralisierte Bildungsplattformen nicht nur eine technische Entwicklung dar, sondern auch einen gesellschaftspolitischen Wandel hin zu größerer Dezentralisierung, Ermächtigung der Nutzer und digitaler Souveränität. Diese Ausrichtung an den Werten des OEP definiert nicht nur die Verbreitung und den Konsum von Bildungsinhalten neu, sondern ist auch Katalysator für breitere Diskussionen über digitale Rechte, Inklusion und ethische Teilhabe an der digitalen Allmende. Das Potenzial solcher Plattformen, kontextrelevante, zensurresistente und kollaborativ kuratierte Bildungsressourcen bereitzustellen, ist ein entscheidender Schritt, um die Zukunft der digitalen Bildung neu zu gestalten.
|
||||
|
||||
## Fazit
|
||||
|
||||
Zusammenfassend lässt sich sagen, dass die innovative Architektur von Nostr, die auf einer kryptografischen Schlüsselverwaltung und dezentralen Relay-Netzwerken basiert, den Grundstein für eine neue Generation von Bildungsplattformen legt, die die Einschränkungen zentralisierter Systeme überwinden können. Ihre Fähigkeit, belastbare, sichere und nutzerzentrierte digitale Umgebungen bereitzustellen, steht in perfektem Einklang mit den Grundsätzen offener Bildungspraktiken und bietet Lehrenden und Lernenden eine noch nie dagewesene Kontrolle über ihre digitalen Interaktionen und Inhalte. Kontinuierliche Forschung, interdisziplinäre Zusammenarbeit und gezielte Bildungsinitiativen werden unerlässlich sein, um dieses Potenzial voll auszuschöpfen und sicherzustellen, dass die Vorteile der Dezentralisierung allen Mitgliedern der Bildungsgemeinschaft zugänglich sind.
|
||||
|
||||
Durch den strategischen Einsatz von Nostr-basierten Bildungsplattformen kann die Zukunft des Lernens als ein Bereich neu konzipiert werden, in dem offener Zugang, Autonomie der Lernenden und gemeinschaftsgesteuerte Wissensbildung nicht nur möglich, sondern unvermeidlich sind - eine Zukunft, in der dezentrale Infrastrukturen eine gerechtere, transparentere und partizipativere digitale Bildungslandschaft ermöglichen.
|
||||
|
||||
Dieser Beitrag hat daher eine ausführliche Einführung in die technischen und konzeptionellen Grundlagen von Nostr gegeben, die Mechanismen beschrieben, mit denen Nostr Dezentralisierung und Zensurresistenz erreicht, und seine bedeutenden Auswirkungen auf die Zukunft von Bildungsplattformen im Rahmen von Open Educational Practices untersucht. Die Synergie zwischen dem dezentralen Paradigma von Nostr und dem demokratischen, inklusiven Ethos von OEP birgt ein transformatives Versprechen für die Neugestaltung der digitalen Bildung in einer Weise, die Offenheit, Sicherheit und lebenslanges Lernen für eine globale Gemeinschaft fördert.
|
||||
|
||||
Indem sie aufkommende dezentralisierte Protokolle wie Nostr als grundlegende Infrastrukturkomponenten von Bildungsplattformen nutzen, können Stakeholder eine ganzheitliche digitale Transformation vorantreiben, die Technologie, Pädagogik und Ethik vereint und ein Paradigma einführt, in dem Bildungsgerechtigkeit und digitale Selbstbestimmung keine Wünsche, sondern realisierte Prinzipien unserer vernetzten Gesellschaft sind.
|
||||
|
||||
***
|
||||
|
||||
Verwendete Quellen:
|
||||
|
||||
* Seeing the Politics of Decentralized Social Media Protocols. S Hwang, AX Zhang. ArXiv (2025). <<https://doi.org/10.48550/arxiv.2505.22962>>
|
||||
|
||||
* Navigating Decentralized Online Social Networks: An Overview of Technical and Societal Challenges in Architectural Choices. Ujun Jeong, L. Ng, K. Carley, Huan Liu. ArXiv (2504). <<https://doi.org/10.48550/arxiv.2504.00071>>
|
||||
|
||||
* Towards Decentralized Applications. X Ma. 2024. <<https://escholarship.org/content/qt0j7044w0/qt0j7044w0_noSplash_c638cdfda170244b9abf750f654399f1.pdf>>
|
||||
|
||||
* Decentralized Social Networking Protocol (DSNP) and User Empowerment: An Analysis of Online Identity Ownership, Data Privacy, and Comparative Assessment with Other Decentralized Protocols. M Nay . 2024. <<https://dspace.mit.edu/bitstream/handle/1721.1/156782/nay-mnay-meng-eecs-2024-thesis.pdf?sequence=1&isAllowed=y>>
|
||||
|
||||
* A Kantian Right to Fediverse Access, or: for a digital enlightenment on the social web. JN Bingemann . <<https://philarchive.org/archive/BINAKR>>
|
||||
|
||||
* Exploring the Nostr Ecosystem: A Study of Decentralization and Resilience. Yiluo Wei, Gareth Tyson. ArXiv (2402). <<https://doi.org/10.48550/arxiv.2402.05709>>
|
||||
|
||||
* Money and Trust in Metaverses, Bitcoin and Stablecoins in global social XR. John O'Hare, Allen J. Fairchild, U. Ali. ArXiv (2207). <<https://doi.org/10.48550/arxiv.2207.09460>>
|
||||
|
||||
* FEDSTR: Money-In AI-Out | A Decentralized Marketplace for Federated Learning and LLM Training on the NOSTR Protocol. Konstantinos E. Nikolakakis, George Chantzialexiou, Dionysis Kalogerias. ArXiv (2024). <<https://doi.org/10.48550/arxiv.2404.15834>>
|
||||
|
||||
* "Sumud" as Connected Learning: Towards a Collective Digital Commons in Palestine.. H Scott, M Ujvari, AMA Bakeer. 2025. <<https://files.eric.ed.gov/fulltext/EJ1464483.pdf>>
|
||||
|
||||
***
|
||||
|
||||
Erstellt mit Unterstützung von [FutureHouse](https://platform.futurehouse.org/trajectories/08dc7ad8-9767-49b6-a795-1e9c305fabd1), [Perplexity](https://www.perplexity.ai/search/erstelle-mir-eine-detaillierte-4WWgUToWQamxryM370lgag), [Deepl](https://www.deepl.com/de/translator), [Ideogram](https://ideogram.ai/g/JH8xVlA4Scy8lGHELEE-1Q/0) und [ChatGPT](https://chatgpt.com/).
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
---
|
||||
layout: post
|
||||
title: "Die Kraft der Gemeinschaft: Wahre Stärke liegt nicht in Strukturen, sondern in Prozessen"
|
||||
slug: "kraft-der-gemeinschaft-prozesse"
|
||||
date: 2025-08-22
|
||||
description: "Der Artikel im FOERBICO-Projekt betont die Überlegenheit von anpassungsfähigen Prozessen über starre Strukturen für langlebige Bildungs-Communities. Inspiriert vom Ise-Schrein in Japan und Open-Source-Prinzipien wie Linux, wird die Kraft der Gemeinschaft durch kollektive Fürsorge und Erneuerung hervorgehoben."
|
||||
image: https://blossom.primal.net/83d6a7144e5e939c9bc93b8653fd8a15ef584976663038790671cd7cdcbfab7c.jpg
|
||||
tags:
|
||||
- FOERBICO
|
||||
- Community
|
||||
- OEP
|
||||
- Nostr
|
||||
- Dezentral
|
||||
- Bildung
|
||||
- Ise-Schrein
|
||||
- Open-Source
|
||||
lang: de
|
||||
license: https://creativecommons.org/publicdomain/zero/1.0/deed.de
|
||||
---
|
||||
|
||||
Zur Entwicklung unseres Community-Hubs untersuchen wir im FOERBICO-Projekt, wie langfristig erfolgreiche Kooperationsmodelle gelingen können. Eine wichtige Erkenntnis, die wir bisher gewinnen konnten: Die Robustheit eines Systems hängt weniger von seinen organisatorischen oder technischen Strukturen ab, sondern vor allem von der Kontinuität und Anpassungsfähigkeit seiner zugrunde liegenden Prozesse. Wenn wir mit einer Hub-Entwicklung die Bildungscommunities dabei unterstützen wollen, dass ihre Prozesse der OEP (Open Educational Practice) "[alles tragen, allem standhalten und niemals zu Fall kommen](https://offene-bibel.de/wiki/1_Korinther_13#l7)", brauchen wir eine Technik, die die zyklischen Erneuerungsprozesse dieser Communities nachhaltig unterstützt. Lasst uns einen Blick über den Tellerrand wagen und uns Inspiration aus jahrtausendealten Traditionen und Open-Source-Prinzipien schöpfen:
|
||||
|
||||
## Prozess statt Bauwerk – Die Kraft der Gemeinschaft am Beispiel des Ise-Schreins
|
||||
|
||||
Stell dir vor, du würdest alle 20 Jahre dein Haus abreißen und identisch wieder aufbauen. Verrückt? In Japan passiert genau das seit 1.300 Jahren mit dem Großen Schrein von Ise, dem heiligsten Ort des Landes. Das Geheimnis seiner Beständigkeit liegt also nicht im Bauwerk selbst, sondern im gemeinsamen Ritual seiner Erneuerung.
|
||||
|
||||
Nicht das solide Gebäude selbst stiftet hier die Gemeinschaft, sondern die Zuverlässigkeit ihres kontinuierlichen Bauprozesses. Die Manifestation und Struktur des Schreins unterliegt also einem steten Wandel, während die Qualität und Verlässlichkeit im Prozess seiner Erneuerung durch die ritualisierten Abläufe über Generationen hinweg erhalten bleibt.
|
||||
|
||||
## Open Source: Liebe als erneuerbares Baumaterial
|
||||
|
||||
Clay Shirky beschreibt Open-Source-Projekte wie das Betriebssysten Linux als moderne Entsprechung zum Ise-Schrein: Ihre Beständigkeit beruhe nicht auf kommerzieller Unterstützung, sondern resultiere aus einem "Akt der Liebe" – sie sei getragen von Menschen, die sich umeinander kümmerten und gemeinsam etwas schaffen würden.
|
||||
|
||||
Die entscheidende Frage für die Langlebigkeit eines Systems sei daher nicht die nach dem das Geschäftsmodell, sondern vielmehr: "Kümmern sich die Menschen, die es lieben, umeinander?" Dieser Indikator könnte sich als ein überlegener Prädiktor für nachhaltige Kooperationserfolge und die Langlebigkeit eines Community-Hubs erweisen.
|
||||
|
||||
## Unsere digitalen Kathedralen der Bildung
|
||||
|
||||
Schauen wir auf unsere Bildungslandschaft, sehen wir oft das Gegenteil: abgeschlossene Plattformen und getrennte Datensilos. Wir bauen digitale Festungen statt lebendige Gemeinschaften.
|
||||
|
||||
Anstatt Materialien gemeinsam zu ***v***erwenden, zu ***v***erarbeiten, zu ***v***ermischen, zu ***v***ervielfältigen und zu ***v***erbreiten, bleiben Bildungsmedien in Plattformen gefangen und ***v***erwahrt. Statt offener Kollaboration haben wir Insellösungen.
|
||||
|
||||
## Eine Infrastruktur, die atmet
|
||||
|
||||
Was wäre, wenn wir Bildungsinfrastruktur wie den Ise-Schrein denken würden? Protokolle wie [Nostr](https://nostr.how/de/what-is-nostr) zeigen, wie das technisch möglich wird: dezentral, offen und von der Gemeinschaft getragen.
|
||||
|
||||
Das Resultat wäre eine Infrastruktur, die nicht von einzelnen Plattformen, Institutionen oder "Internet-Gebäuden" abhängig ist, sondern von der kollektiven Fürsorge und dem Engagement der Community getragen werden kann – resilient, erneuerbar und offen für alle.
|
||||
|
||||
## Mach mit beim Bauen!
|
||||
|
||||
Die Geschichte des Ise-Schreins lehrt uns: Das beständigste Fundament sind die Menschen, die sich umeinander kümmern. Lasst uns gemeinsam ein lebendiges Ökosystem für die Bildung schaffen, das uns miteinander in Verbindung bringt!
|
||||
|
||||
Hier kannst du mitmachen:
|
||||
|
||||
* Im Matrix [Space OERcommunity](https://matrix.to/#/#oercommunity:rpi-virtuell.de) Offene Räume für Austausch und Experimente
|
||||
|
||||
* vor allem [im Raum "edufeed"](https://matrix.to/#/#edufeed:rpi-virtuell.de), wo wir OER & NOSTR zusammendenken
|
||||
|
||||
* auf Nostr
|
||||
|
||||
* [hier eine Starthilfe zur Profilerstellung](https://nstart.me/de?an=Primal\&am=light\&aa=203a8f\&asb=yes\&s=npub1k85m3haymj3ggjknfrxm5kwtf5umaze4nyghnp29a80lcpmg2k2q54v05a)
|
||||
|
||||
* Hier ein paar Accounts z.B. von [Jörg](https://njump.me/npub1f7jar3qnu269uyx5p0e4v24hqxjnxysxudvujza2ur5ehltvdeqsly2fx9) oder [Steffen](https://njump.me/npub1r30l8j4vmppvq8w23umcyvd3vct4zmfpfkn4c7h2h057rmlfcrmq9xt9ma)
|
||||
|
||||
* GitHub [Edufeed](https://github.com/edufeed-org): Wo wir gemeinsam an der Zukunft bauen.
|
||||
|
||||
**Inspirationen:**
|
||||
|
||||
* [Clay Shirky: Love, Internet Style](https://www.youtube.com/watch?v=Xe1TZaElTAs)
|
||||
|
||||
* [Steffen Rörtgen: Just calling it Open is not enough](https://habla.news/u/laoc42@getalby.com/h-k72fOoZmf_SOC3cUpqc)
|
||||
|
||||
* [Ise-Schrein – Japanliebe](https://japanliebe.de/alltaegliches/ise-jingu-schrein-neubau-alle-20-jahre/)
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
---
|
||||
layout: post
|
||||
title: "Richter oder Rächer? Banksy als moderner Prophet vor dem High Court"
|
||||
slug: "banksy-high-court-prophet"
|
||||
date: 2025-09-09
|
||||
description: "Banksys neues Mural vor dem Londoner High Court zeigt einen Richter, der auf einen Demonstranten einschlägt. Als moderner Prophet prangert Banksy staatliche Repression an und fordert zur Auseinandersetzung mit Fragen von Gerechtigkeit, Gewalt und Barmherzigkeit heraus. Die schnelle Verhüllung des Werks unterstreicht die Brisanz seiner Botschaft."
|
||||
image: https://blossom.primal.net/c8dd447472a9d764711e089fba71d962f10cb6fc9cc98b0dbd080f07894f2ca6.jpg
|
||||
tags:
|
||||
- Banksy
|
||||
- Streetart
|
||||
- London
|
||||
- Justiz
|
||||
- Gerechtigkeit
|
||||
- Prophetie
|
||||
- Bildung
|
||||
license: https://creativecommons.org/publicdomain/zero/1.0/deed.de
|
||||
---
|
||||
|
||||
Am 8. September 2025 tauchte ein neues Mural von Banksy vor dem **Royal Courts of Justice** in London auf. Es zeigt einen Richter in Perücke und Robe, der mit seinem Hammer auf einen am Boden liegenden Demonstranten einschlägt, dessen Plakat blutverschmiert ist. Banksy bestätigte die Echtheit über Instagram mit dem schlichten Titel *„Royal Courts of Justice“*.
|
||||
|
||||
## Politischer Kontext und Timing
|
||||
|
||||
Das Werk erscheint kurz nach den Massenverhaftungen von fast 900 Aktivist:innen bei pro-palästinensischen Protesten. Die britische Regierung hatte zuvor die Gruppe **Palestine Action** als Terrororganisation eingestuft. Das Bild wird weithin als Kritik an staatlicher Repression und dem Missbrauch juristischer Macht gelesen.
|
||||
|
||||
## Kunsthistorische Einordnung
|
||||
|
||||
Banksy bleibt seiner Linie treu: Schablonentechnik, klare Botschaft, öffentlicher Raum als Bühne. Schon frühere Werke thematisierten Machtverhältnisse und Unterdrückung. Das neue Stück reiht sich in diese Tradition ein – nur noch unmittelbarer, da es die Justiz selbst ins Zentrum der Kritik rückt.
|
||||
|
||||
## Symbolische Kraft
|
||||
|
||||
Der Standort ist nicht zufällig gewählt. Direkt an einer Außenwand des Queen's Building, Teil des High-Court-Komplexes, prangert Banksy staatliche Gewalt an. Damit verwandelt er das Symbol der Rechtsstaatlichkeit in ein Sinnbild für Unterdrückung – eine subtile, aber kraftvolle Umkehr der Rollen.
|
||||
|
||||
## Schnelle Zensur
|
||||
|
||||
Wie so oft bei Banksy wurde auch dieses Werk binnen Stunden abgedeckt. Mitarbeiter:innen des Gerichts verhüllten es mit schwarzer Folie und Metallgittern; die Behörden kündigten an, es zu entfernen – mit Verweis auf den Denkmalschutz des Gebäudes. Ironischerweise bestätigt dieses Vorgehen die Kritik: eine unmittelbare „Cover-up"-Reaktion gegen unliebsame Kunst.
|
||||
|
||||
## Impulse für die Bildung
|
||||
|
||||
Banksy beweist erneut, wie politisch aufgeladene Street Art öffentliche Debatten auslösen kann. Das Werk wirft unbequeme Fragen auf:
|
||||
|
||||
* Welche Rolle spielt die Justiz im Umgang mit Protestbewegungen?
|
||||
|
||||
* Wo liegen die Grenzen von Meinungs- und Versammlungsfreiheit?
|
||||
|
||||
* Und wie reagiert der Staat auf kritische Kunst im öffentlichen Raum?
|
||||
|
||||
***
|
||||
|
||||
### Quellen
|
||||
|
||||
[Banksy on Instagram](https://www.instagram.com/p/DOVoHlVDMIU/) · [AP News](https://apnews.com/article/c08b2cef093ea6a0520302eacfbd871f) · [The Guardian](https://www.theguardian.com/artanddesign/2025/sep/08/court-staff-cover-up-banksy-image-of-judge-beating-a-protester) · [El País](https://elpais.com/cultura/2025-09-08/banksy-regresa-a-londres-con-un-mural-en-el-que-un-juez-golpea-a-un-manifestante-tirado-en-el-suelo.html)
|
||||
281
docs/HANDOFF.md
|
|
@ -3,233 +3,138 @@
|
|||
Du (Claude, nächste Session) oder ich (Jörg, später) kommen hier zurück.
|
||||
Dieses Dokument sagt: was ist der Zustand, was wartet, wo liegen die Fäden.
|
||||
|
||||
## Zustand (Details in `STATUS.md`)
|
||||
## Zustand (siehe `STATUS.md` für Details)
|
||||
|
||||
**Cutover + Reimport am 2026-04-18 abgeschlossen.** `joerg-lohrer.de`
|
||||
läuft als SvelteKit-SPA, rendert 26 Nostr-Langform-Posts live aus 5
|
||||
Relays, Bilder auf Blossom. Repo ist alleinige Quelle der Wahrheit.
|
||||
Pipeline-Subcommands `publish` + `delete` decken den kompletten
|
||||
Content-Lifecycle ab.
|
||||
Die SvelteKit-SPA unter `svelte.joerg-lohrer.de` ist **fertig und live**.
|
||||
35 geplante Tasks + einige Erweiterungen abgeschlossen. Branch `spa` hat
|
||||
alle Commits. Ein Git-Merge nach `main` und Deploy auf die Hauptdomain ist
|
||||
**noch nicht** erfolgt — das kommt erst nach dem Cutover-Plan.
|
||||
|
||||
**Das inhaltliche Kernziel des Gesamtprojekts ist erreicht.** Der Rest
|
||||
sind optionale Verbesserungen.
|
||||
## Was als Nächstes ansteht
|
||||
|
||||
## Alltags-Workflow: neuen Post veröffentlichen
|
||||
Drei Optionen, ordered by natürlichkeit der Fortsetzung:
|
||||
|
||||
**Kompletter Happy-Path, kein manueller Publish nötig:**
|
||||
### Option 1 — Publish-Pipeline bauen
|
||||
|
||||
1. Neuen Ordner anlegen: `content/posts/YYYY-MM-DD-<slug>/`
|
||||
2. `index.md` schreiben mit Frontmatter (siehe Template unten).
|
||||
3. Bilder in den Ordner legen und im Markdown als ``
|
||||
referenzieren.
|
||||
4. Lokal validieren: `cd publish && deno task validate-post ../content/posts/<dir>/index.md`
|
||||
5. Commit + `git push origin main` — fertig.
|
||||
**Warum:** aktuell muss Jörg jeden neuen Post manuell mit `nak event` signieren
|
||||
und publishen (siehe `preview/spa-mini/README.md`, Referenzbefehl in den
|
||||
Brainstorm-Notizen). Eine Publish-Pipeline automatisiert:
|
||||
|
||||
**Was automatisch passiert:**
|
||||
- Forgejo-Push-Mirror synct nach GitHub.
|
||||
- GitHub Actions triggert auf `content/posts/**`-Änderung.
|
||||
- Workflow läuft diff-modus: nur geänderte/neue Posts werden publiziert.
|
||||
- Pipeline hasht lokale Bilder → Upload auf beide Blossom-Server → URLs
|
||||
im Event ersetzen.
|
||||
- Event wird signiert (Amber-Bunker via `CLIENT_SECRET_HEX`) und auf alle
|
||||
5 Write-Relays publiziert.
|
||||
- SPA holt den neuen Post beim nächsten Besuch automatisch vom Relay.
|
||||
1. Markdown-Post in `content/posts/` bearbeiten / neu anlegen
|
||||
2. Git-Commit + push auf `main`
|
||||
3. GitHub Action signiert Event via NIP-46 (Amber-Bunker), pushed zu allen
|
||||
Relays aus `kind:10002`, lädt Bilder zu Blossom, lädt Altbild-Assets
|
||||
ggf. zu All-Inkl via SSH/rsync.
|
||||
|
||||
**Vorbedingung:** Amber muss für den Client-Key (aus `CLIENT_SECRET_HEX`)
|
||||
die Permissions `get_public_key` + `sign_event` auf „Allow + Always"
|
||||
gesetzt haben. Das gilt so lange, bis der Client-Key rotiert wird.
|
||||
**Was existiert:** Spec vollständig unter
|
||||
`docs/superpowers/specs/2026-04-15-publish-pipeline-design.md`. Plan
|
||||
**noch nicht geschrieben.**
|
||||
|
||||
**Minimal-Frontmatter für einen neuen Post:**
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: "Titel des Posts"
|
||||
slug: "url-freundlicher-slug"
|
||||
date: 2026-04-18
|
||||
description: "Kurzbeschreibung für SEO und den summary-Tag im Event."
|
||||
image: hauptbild.jpg
|
||||
tags:
|
||||
- Tag1
|
||||
- Tag2
|
||||
lang: de
|
||||
license: https://creativecommons.org/publicdomain/zero/1.0/deed.de
|
||||
---
|
||||
|
||||
Body in Markdown…
|
||||
**Nächster Konkreter Schritt:**
|
||||
```
|
||||
|
||||
Bilder mit voller Attribution (NIP-standardisiert nach unserer Konvention,
|
||||
siehe `docs/superpowers/specs/2026-04-16-image-metadata-convention.md`):
|
||||
|
||||
```yaml
|
||||
images:
|
||||
- file: hauptbild.jpg
|
||||
role: cover
|
||||
alt: "Alt-Text für Barrierefreiheit"
|
||||
caption: "Bildunterschrift (optional)"
|
||||
license: https://creativecommons.org/licenses/by/4.0/deed.de
|
||||
authors:
|
||||
- name: "Autor:in"
|
||||
superpowers:writing-plans
|
||||
```
|
||||
mit dem Publish-Spec als Input.
|
||||
|
||||
**Manuell publizieren** (falls CI aus ist oder einzelner Post nochmal):
|
||||
**Vorarbeiten:**
|
||||
- SSH-Zugang zu All-Inkl klären (Premium-Tarif angefragt, Status prüfen)
|
||||
- Deno ≥ 2.x installiert?
|
||||
- GitHub Actions-Repo-Secrets vorbereiten (`BUNKER_URL`, `ALLINKL_DEPLOY_ROOT`,
|
||||
`SSH_DEPLOY_KEY`, `AUTHOR_PUBKEY_HEX`)
|
||||
|
||||
```sh
|
||||
cd publish
|
||||
deno task publish --post <slug> # einzelner Post
|
||||
deno task publish --dry-run # was würde der diff-modus publisht?
|
||||
deno task publish # diff-modus real
|
||||
deno task publish --force-all # alle 26 Posts neu
|
||||
```
|
||||
### Option 2 — Menü-Navigation + Impressum auf der SPA
|
||||
|
||||
## Was optional als Nächstes ansteht
|
||||
**Warum:** kleine UX-Ergänzung, die das SPA-Erlebnis runder macht.
|
||||
|
||||
### Option B — SPA respektiert NIP-09-Deletion-Events
|
||||
- Header-Navigation in `app/src/routes/+layout.svelte` ergänzen (Home, Archiv,
|
||||
Impressum, evtl. Mastodon-Link)
|
||||
- `/impressum/`-Route anlegen mit rechtlichem Text
|
||||
- ggf. Archives-Route als eigene Liste mit Gruppierung nach Jahr
|
||||
|
||||
**Status:** aktuell filtert die SPA nicht nach NIP-09. Wenn ein Event per
|
||||
`kind:5`-Referenz gelöscht wurde, zeigen Relays es meist nicht mehr aus —
|
||||
aber die SPA würde es trotzdem rendern, falls ein Relay es doch liefert.
|
||||
**Aufwand:** ~30-60 min je nach Layout-Wunsch. Kein Spec-Update nötig,
|
||||
ist in SPA-Spec §2 bereits als Ziel erwähnt.
|
||||
|
||||
**Zu tun:** im `kind:30023`-Loader (`app/src/lib/nostr/…`) einen
|
||||
Cross-Check auf `kind:5`-Events einbauen. Events, deren Addressable-Pointer
|
||||
(`30023:pubkey:d-tag`) in einem `kind:5` referenziert ist, werden
|
||||
gefiltert. Defensive Maßnahme für zukünftige Duplikate / Soft-Deletes.
|
||||
### Option 3 — Cutover auf Hauptdomain
|
||||
|
||||
### Option C — Postfach `webmaster@joerg-lohrer.de`
|
||||
**Warum:** `joerg-lohrer.de` liefert aktuell noch Hugo aus. Sobald genug
|
||||
Altposts als Events publiziert sind und die Publish-Pipeline läuft, kann die
|
||||
SvelteKit-SPA auf die Hauptdomain umziehen. Das ist aber **kein Task jetzt**
|
||||
— muss auf Publish-Pipeline warten, sonst brechen Backlinks zu Posts, die
|
||||
noch nicht als Events existieren.
|
||||
|
||||
User-Task: im All-Inkl KAS als Weiterleitung anlegen. Der Link im
|
||||
Footer und in den Social-Icons zeigt bereits darauf.
|
||||
|
||||
### Option D — Mehrsprachigkeit (Translation-of)
|
||||
|
||||
**Grundlage steht:** Pipeline taggt seit 2026-04-18 jedes Event mit
|
||||
NIP-32 `['L', 'ISO-639-1']` + `['l', 'de', 'ISO-639-1']` (default),
|
||||
überschreibbar per `lang:`-Frontmatter.
|
||||
|
||||
**Zu tun für einen bilingualen Post:**
|
||||
1. Zweiter Markdown-Ordner, z. B. `content/posts/<date>-<slug>-en/index.md`,
|
||||
mit `slug: <slug>-en`, `lang: en`, englischem Body.
|
||||
2. Publish → eigenes `kind:30023`-Event mit `lang=en`.
|
||||
3. (Noch zu bauen) Pipeline erweitern: `translation_of:`-Frontmatter-Feld,
|
||||
das ein `['a', '30023:pubkey:<slug-de>']`-Tag ins Event setzt. Damit
|
||||
erkennen Clients wie Habla die Verwandtschaft.
|
||||
4. (Optional) SPA bekommt Language-Switcher auf der Post-Detailseite.
|
||||
|
||||
Nicht dringend, erst wenn echter englischer Content entsteht.
|
||||
|
||||
### Option E — Pipeline weg von GitHub (self-hosted CI)
|
||||
|
||||
**Wann:** Wenn der Optiplex-Server steht und ein zentraler Ort für Dienste
|
||||
existiert.
|
||||
|
||||
**Varianten:**
|
||||
- **Cron / systemd-Timer** auf dem Optiplex, der alle X Minuten `git pull &&
|
||||
deno task publish` macht. Einfach, minimaler Setup.
|
||||
- **Woodpecker-CI** als Docker-Container neben Forgejo. Volle Push-getriggerte
|
||||
Pipeline ohne GitHub.
|
||||
|
||||
Der Pipeline-Code selbst (`publish/src/**`) ist CI-agnostisch — nur die
|
||||
Trigger-Konfiguration ändert sich.
|
||||
|
||||
### Option F — Design-Refinements
|
||||
|
||||
**Wann:** irgendwann, wenn Lust drauf ist.
|
||||
|
||||
- Parallax-Effekte, Animationen
|
||||
- Dark-Mode-Toggle (aktuell nur `prefers-color-scheme`)
|
||||
- Typografie-Experimente (Variable Fonts)
|
||||
- Bildergalerie-Komponente für Posts mit vielen Bildern
|
||||
|
||||
Alles nicht-blockierend, die SPA funktioniert solide.
|
||||
**Reihenfolge:** Option 1 → Publish-Pipeline + einmaliger Massen-Import der
|
||||
übrigen 17 Altposts → dann Option 3.
|
||||
|
||||
## Schnell-Orientierung für die nächste Claude-Session
|
||||
|
||||
Lies in dieser Reihenfolge:
|
||||
1. `docs/STATUS.md` (5 min)
|
||||
2. `docs/HANDOFF.md` (= dieses Dokument)
|
||||
3. Für CI-Themen: `docs/github-ci-setup.md`
|
||||
4. Für Pipeline-Fragen: `docs/superpowers/specs/2026-04-15-publish-pipeline-design.md`
|
||||
3. Die relevante Spec, je nachdem was drankommt:
|
||||
- Publish-Pipeline: `docs/superpowers/specs/2026-04-15-publish-pipeline-design.md`
|
||||
- SPA-Anpassungen: `docs/superpowers/specs/2026-04-15-nostr-page-design.md`
|
||||
|
||||
Nutze den Skill unter `.claude/skills/joerglohrerde-workflow.md` für
|
||||
wiederkehrende Kommandos.
|
||||
|
||||
## Dev-Kommandos
|
||||
|
||||
```sh
|
||||
# SPA-Tests
|
||||
# Unit-Tests (Vitest)
|
||||
cd app && npm run test:unit
|
||||
|
||||
# E2E-Tests (Playwright)
|
||||
cd app && npm run test:e2e
|
||||
|
||||
# Type-Check
|
||||
cd app && npm run check
|
||||
|
||||
# Dev-Server (Port 5173)
|
||||
cd app && npm run dev
|
||||
|
||||
# SPA-Build + Deploy
|
||||
DEPLOY_TARGET=staging ./scripts/deploy-svelte.sh
|
||||
DEPLOY_TARGET=prod ./scripts/deploy-svelte.sh
|
||||
# Production-Build + Deploy
|
||||
cd app && npm run build && cd .. && ./scripts/deploy-svelte.sh
|
||||
```
|
||||
|
||||
# Publish-Pipeline
|
||||
cd publish && deno task check # pre-flight
|
||||
cd publish && deno task publish --dry-run # diff-modus simulation
|
||||
cd publish && deno task publish # diff-modus echt
|
||||
cd publish && deno task publish --force-all # alle posts
|
||||
cd publish && deno task publish --post <slug> # einen post
|
||||
cd publish && deno task delete --event-id <hex> [--event-id <hex>] [--reason "text"]
|
||||
cd publish && deno task validate-post ../content/posts/<dir>/index.md
|
||||
cd publish && deno task test # tests
|
||||
## Manuelles Publishen (bis Publish-Pipeline fertig ist)
|
||||
|
||||
Einen Post aus `content/posts/<ordner>/index.md` als kind:30023-Event
|
||||
publizieren:
|
||||
|
||||
```sh
|
||||
# Body ohne Frontmatter extrahieren
|
||||
awk 'BEGIN{in_fm=0; past_fm=0} NR==1 && /^---$/ {in_fm=1; next} in_fm && /^---$/ {in_fm=0; past_fm=1; next} past_fm {print}' content/posts/<ordner>/index.md > /tmp/body.md
|
||||
|
||||
# Bunker-URL aus .env.local
|
||||
BUNKER_URL=$(grep -E '^BUNKER_URL=' .env.local | sed 's/^BUNKER_URL=//')
|
||||
|
||||
# Event bauen, signieren, zu Relays pushen
|
||||
# (Tags: d, title, summary, image, published_at, t×n)
|
||||
# Siehe "dezentrale-oep-oer"-Beispiel in der Brainstorm-Historie
|
||||
```
|
||||
|
||||
Für Bilder: Upload zu Blossom mit `nak blossom upload`:
|
||||
```sh
|
||||
nak blossom upload --server https://blossom.edufeed.org --sec "$BUNKER_URL" <bild>
|
||||
```
|
||||
|
||||
## Bekannte Stolperfallen
|
||||
|
||||
- **Amber-Bunker:** bei neuer Bunker-URL müssen die zwei Permissions
|
||||
(`get_public_key`, `sign_event`) in Amber auf „Allow + Always" gesetzt
|
||||
werden, bevor Publish-Requests verarbeitet werden. Siehe
|
||||
`docs/github-ci-setup.md` für Details.
|
||||
- **`CLIENT_SECRET_HEX`** in `.env.local` identisch mit GitHub-Secret —
|
||||
sorgt dafür, dass sich beide Umgebungen bei Amber mit derselben App
|
||||
anmelden. Rotieren nur bei bewusstem Neu-Pairing in Amber.
|
||||
- **`relay.damus.io`** bestätigt Events manchmal nicht mit `OK`. Bekanntes
|
||||
Damus-Verhalten, wird toleriert (MIN_RELAY_ACKS=2, andere 4 Relays sind
|
||||
zuverlässig).
|
||||
- **Svelte 5 Runes:** `$props()`-Werte via `$derived()` in lokale Variablen.
|
||||
- **Hugo-quotierte Dates:** `date: "2023-02-26"` ist ein YAML-String, nicht
|
||||
ein Date-Objekt. `validatePost` coerced das automatisch; in neuen Posts
|
||||
am besten ohne Quotes schreiben.
|
||||
- **Deploy-Targets:** `svelte` → Entwicklung, `staging` → Pre-Prod,
|
||||
`prod` → `joerglohrer26/` (Produktion seit Cutover). Script parst
|
||||
`.env.local` per awk (wegen Sonderzeichen in FTP-Passwörtern).
|
||||
- **Slug-Hygiene:** nur `[a-z0-9-]`, keine Umlaute/Emojis/Doppelpunkte.
|
||||
Der Slug landet als `d`-Tag im Event und wird zur URL. Einmal
|
||||
publiziert, ist Umbenennen nur über Delete + Re-Publish mit neuem Slug
|
||||
möglich.
|
||||
- **Clients, die Markdown ignorieren:** Yakihonne/Habla kennen NIP-32
|
||||
Sprach-Tags; kurzen Text in `description:` halten, damit die Vorschau
|
||||
überall sinnvoll aussieht.
|
||||
|
||||
## Offene UNKNOWN-Einträge zur späteren Recherche
|
||||
|
||||
Im VR-Post (`content/posts/2021-08-15-virtual-reality/index.md`) sind
|
||||
4 Bilder als `license: UNKNOWN / authors: UNKNOWN` markiert:
|
||||
- `01-immersion-wikipedia.jpg` (Wikipedia-Screenshot)
|
||||
- `02-mittelalterliche-kirche.jpg` (Sketchfab — Lizenz ist CC BY-NC, Fotograf fehlt)
|
||||
- `03-avatare-erstellen.jpg` (Ready Player Me)
|
||||
- `05-pupillendistanz.jpg` (EyeMeasure iOS App)
|
||||
|
||||
Pipeline loggt Warnungen, publisht aber trotzdem. Recherche-Notizen in
|
||||
`docs/redaktion-bild-metadaten.md`.
|
||||
- **Amber-Bunker:** bei neuer Bunker-URL müssen globale Permissions in Amber
|
||||
zurückgesetzt werden, sonst hängt `nak` auf den Signatur-Request.
|
||||
- **All-Inkl FTPS:** bricht mit TLS 1.3 die Data-Connection ab. Script
|
||||
nutzt `--tls-max 1.2`. Bei SSH-Umstellung: rsync fixen, TLS-Flag raus.
|
||||
- **Svelte 5 Runes:** `$props()`-Werte müssen via `$derived()` in lokale
|
||||
Variablen, sonst `state_referenced_locally`-Warning.
|
||||
- **applesauce-relay API:** ist RxJS-basiert. `pool.request(relays, filter)`
|
||||
returned `Observable<NostrEvent>` (nicht die Tupel-`subscribe({next: msg
|
||||
if msg[0]==='EVENT'})`-Form).
|
||||
- **Slug-Normalisierung:** alle Frontmatter-Slugs sind lowercase (Commit
|
||||
`d17410f`). Beim Publishen 1:1 übernehmen, keine Runtime-Transformation.
|
||||
|
||||
## Session-Kontext
|
||||
|
||||
Hilfreich beim Wiedereinstieg mit Claude:
|
||||
- Branch-Check: `git log --oneline -10 main`
|
||||
- Live-Check: `curl -sI https://joerg-lohrer.de/`
|
||||
- Event-Count Repo vs. Relays:
|
||||
```sh
|
||||
ls content/posts/ | wc -l
|
||||
nak req -k 30023 -a 4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41 wss://relay.edufeed.org 2>/dev/null | jq -r '.tags[]|select(.[0]=="d")|.[1]' | sort -u | wc -l
|
||||
```
|
||||
- Pipeline-Tests: `cd publish && deno task test`
|
||||
|
||||
## Community-Wiki-Entwürfe
|
||||
|
||||
Liegen im Repo, noch nicht extern veröffentlicht:
|
||||
- `docs/wiki-entwurf-nostr-bild-metadaten.md` — DE
|
||||
- `docs/wiki-draft-nostr-image-metadata.md` — EN
|
||||
|
||||
Können als NIP-Proposal oder auf nostrbook.dev eingebracht werden, jetzt wo
|
||||
die Konvention in der Praxis validiert ist.
|
||||
- Branch-Check: `git log --oneline -10 spa main hugo-archive`
|
||||
- Live-Check: `curl -sI https://svelte.joerg-lohrer.de/`
|
||||
- Publish-Status: `nak req -k 30023 -a 4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41 wss://relay.damus.io 2>/dev/null | jq -c '{d: (.tags[] | select(.[0]=="d") | .[1]), title: (.tags[] | select(.[0]=="title") | .[1])}'`
|
||||
|
|
|
|||
162
docs/STATUS.md
|
|
@ -1,153 +1,105 @@
|
|||
# Projekt-Status: joerg-lohrer.de → Nostr-basierte SPA
|
||||
|
||||
**Stand:** 2026-04-18 (Cutover abgeschlossen)
|
||||
**Stand:** 2026-04-15
|
||||
|
||||
## Kurzfassung
|
||||
|
||||
`joerg-lohrer.de` läuft als SvelteKit-SPA, die Blog-Posts live aus
|
||||
signierten Nostr-Events (NIP-23, `kind:30023`) auf 5 Public-Relays rendert.
|
||||
Bilder liegen content-addressed auf 2 Blossom-Servern. Die Hugo-basierte
|
||||
Altseite ist als `hugo-archive`-Branch eingefroren.
|
||||
Jörg Lohrers persönliche Webseite wird von einem Hugo-basierten statischen
|
||||
Site-Generator zu einer dezentralen Nostr-basierten SPA überführt. Posts
|
||||
existieren als signierte Events (NIP-23, `kind:30023`) auf Public-Relays und
|
||||
werden zur Laufzeit im Browser gerendert.
|
||||
|
||||
**Das inhaltliche Kernziel des Gesamtprojekts ist erreicht.**
|
||||
|
||||
## Live-URLs
|
||||
## Drei parallele Webseiten
|
||||
|
||||
| URL | Status | Rolle |
|
||||
|---|---|---|
|
||||
| `https://joerg-lohrer.de/` | live | **Produktion**, SvelteKit-SPA (Cutover 2026-04-18) |
|
||||
| `https://staging.joerg-lohrer.de/` | live | **Staging**, letzter Pre-Prod-Build |
|
||||
| `https://svelte.joerg-lohrer.de/` | live | **Entwicklung**, Deploy-Target der Pipeline |
|
||||
| `https://spa.joerg-lohrer.de/` | live | **Historisch**, Vanilla-HTML-Mini-Spike |
|
||||
| `https://joerg-lohrer.de/` | live, unverändert | **Hugo-Altbestand** (wird noch nicht ersetzt) |
|
||||
| `https://spa.joerg-lohrer.de/` | live | **Vanilla-HTML-Mini-Spike** (Proof of Concept, ~250 Zeilen HTML+JS) |
|
||||
| `https://svelte.joerg-lohrer.de/` | live | **SvelteKit-SPA** (35-Task-Plan komplett) |
|
||||
|
||||
Die SvelteKit-SPA unter `svelte.joerg-lohrer.de` ist die Ziel-Implementierung.
|
||||
`spa.joerg-lohrer.de` bleibt als schlanke Referenz erhalten. Hugo läuft weiter,
|
||||
bis die Publish-Pipeline steht und der Cutover auf die Hauptdomain erfolgt.
|
||||
|
||||
## Was auf Nostr liegt
|
||||
|
||||
- **Autoren-Pubkey:** `npub1f7jar3qnu269uyx5p0e4v24hqxjnxysxudvujza2ur5ehltvdeqsly2fx9`
|
||||
(hex: `4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41`)
|
||||
- **NIP-05:** `joerglohrer@joerg-lohrer.de` (via `/.well-known/nostr.json`)
|
||||
- **Publizierte Events:** **26 Langform-Posts** (`kind:30023`), alle mit
|
||||
sauberen ASCII-slugs, alle aus dem Repo publiziert. 18 Alt-Posts aus der
|
||||
Hugo-Migration plus 8 re-importierte Client-Posts (Habla/Yakihonne), die
|
||||
mit bereinigten d-tags neu publiziert und alte Duplikate per NIP-09
|
||||
gelöscht wurden (Commit `7186c32`).
|
||||
- **NIP-32-Sprach-Tags:** Alle Events tragen `['L', 'ISO-639-1']` +
|
||||
`['l', 'de', 'ISO-639-1']`. Grundlage für spätere Mehrsprachigkeit.
|
||||
- **Publizierte Events:** ~10 Langform-Posts (`kind:30023`), darunter
|
||||
`dezentrale-oep-oer`, `offenheit-das-wesentliche`, `gleichnis-vom-saemann`,
|
||||
`bibelfussball`, `dampfnudeln` u. a.
|
||||
- **Relay-Liste** (`kind:10002`): `relay.damus.io`, `nos.lol`,
|
||||
`relay.primal.net`, `relay.tchncs.de`, `relay.edufeed.org`
|
||||
- **Blossom-Server** (`kind:10063`): `blossom.edufeed.org`, `blossom.primal.net`
|
||||
- **91 Bilder** auf beiden Blossom-Servern, alle Events enthalten
|
||||
hash-basierte Blossom-URLs.
|
||||
|
||||
Bilder des ersten „experimentell publizierten" Posts (`dezentrale-oep-oer`)
|
||||
liegen auf Blossom. Weitere 17 Altposts haben ihre Bilder noch unter dem
|
||||
ursprünglichen Hugo-Permalink auf All-Inkl.
|
||||
|
||||
## Repo-Struktur
|
||||
|
||||
```
|
||||
joerglohrerde/
|
||||
├── content/posts/ # 18 Markdown-Posts, alle mit strukturierten images:
|
||||
├── content/impressum.md # Statisches Impressum (wird von SPA geladen)
|
||||
├── app/ # SvelteKit-SPA (Laufzeit-Renderer)
|
||||
├── publish/ # Deno-Publish-Pipeline (Blossom + Nostr)
|
||||
├── preview/spa-mini/ # Vanilla-HTML-Mini-Spike (historisch)
|
||||
├── content/posts/ # Markdown-Quelle (18 Posts, wird vom Publish-Skript gelesen)
|
||||
├── app/ # SvelteKit-SPA (Ziel-Implementation)
|
||||
├── preview/spa-mini/ # Vanilla-HTML-Mini-Spike (Referenz)
|
||||
├── scripts/
|
||||
│ └── deploy-svelte.sh # FTPS-Deploy, Targets: svelte/staging/prod
|
||||
│ └── deploy-svelte.sh # FTPS-Deploy nach svelte.joerg-lohrer.de
|
||||
├── docs/
|
||||
│ ├── STATUS.md # Dieses Dokument
|
||||
│ ├── HANDOFF.md # Wie man hier weitermacht
|
||||
│ ├── redaktion-bild-metadaten.md
|
||||
│ ├── wiki-entwurf-nostr-bild-metadaten.md
|
||||
│ ├── wiki-draft-nostr-image-metadata.md
|
||||
│ ├── github-ci-setup.md
|
||||
│ └── superpowers/
|
||||
│ ├── specs/ # SPA + Publish-Pipeline + Bild-Metadaten-Konvention
|
||||
│ └── plans/
|
||||
│ ├── 2026-04-15-spa-sveltekit.md # erledigt
|
||||
│ └── 2026-04-16-publish-pipeline.md # erledigt
|
||||
├── .github/workflows/ # publish.yml (Forgejo→GitHub Push-Mirror-Trigger)
|
||||
│ ├── specs/ # SPA-Spec + Publish-Pipeline-Spec
|
||||
│ └── plans/ # SPA-Implementation-Plan (35 Tasks, abgeschlossen)
|
||||
├── .claude/
|
||||
│ ├── skills/ # Repo-spezifischer Claude-Skill
|
||||
│ └── settings.local.json # Claude-Session-State (gitignored)
|
||||
└── .env.local # Gitignored: FTP-Creds, Bunker-URL, Publish-Pipeline-Keys
|
||||
│ └── settings.local.json # Claude-Session-State (nicht committen? aktuell schon)
|
||||
└── .env.local # Gitignored: FTP-Creds + Bunker-URL
|
||||
```
|
||||
|
||||
## Branch-Layout (Git)
|
||||
|
||||
- **`main`** — kanonischer Zweig, Produktions-Quelle seit Cutover.
|
||||
- **`spa`** — historischer SvelteKit-Arbeitszweig, gemerged.
|
||||
- **`hugo-archive`** — Orphan-Branch mit Hugo-Zustand, eingefroren.
|
||||
- **`main`** — kanonischer Zweig. Enthält Content, Specs, Pläne, Deploy-Scripts,
|
||||
`.claude/`-Skill. Schlanker als früher (kein Hugo-Artefakt mehr).
|
||||
- **`spa`** — aktueller Arbeits-Branch. SvelteKit-SPA in `app/` komplett
|
||||
implementiert und live. **Aktuell vor `main` mit allen `spa:`-Commits.**
|
||||
- **`hugo-archive`** — Orphan-Branch mit dem letzten funktionierenden
|
||||
Hugo-Zustand, eingefroren. Rollback über `git checkout hugo-archive && hugo build`.
|
||||
|
||||
## Setup-Zustand
|
||||
|
||||
Einmalig manuell erledigt (gitignored in `.env.local`):
|
||||
- ✅ Amber-Bunker-URL als `BUNKER_URL`
|
||||
- ✅ FTP-Creds für alle Targets (SVELTE/STAGING/PROD)
|
||||
- ✅ `AUTHOR_PUBKEY_HEX` und `BOOTSTRAP_RELAY=wss://relay.primal.net`
|
||||
- ✅ `CLIENT_SECRET_HEX` (identisch mit GitHub-Secret für stabile App-ID in Amber)
|
||||
- ✅ `kind:10002`-Event publiziert (Relay-Liste)
|
||||
- ✅ `kind:10063`-Event publiziert (Blossom-Server)
|
||||
- ✅ Subdomains mit TLS + HSTS
|
||||
- ✅ Staging → Webroot `joerglohrer26/`
|
||||
- ✅ Prod → Webroot `joerglohrer26/` (Cutover 2026-04-18)
|
||||
- ✅ NIP-05-JSON mit CORS-Header via `.htaccess`
|
||||
Einmalig manuell erledigt:
|
||||
- ✅ Amber-Bunker-URL in `.env.local` als `BUNKER_URL`
|
||||
- ✅ SPA-FTP-Creds (`spa.joerg-lohrer.de`) in `.env.local` als `SPA_FTP_*`
|
||||
- ✅ SvelteKit-FTP-Creds (`svelte.joerg-lohrer.de`) in `.env.local` als `SVELTE_FTP_*`
|
||||
- ✅ `kind:10002`-Event publiziert
|
||||
- ✅ `kind:10063`-Event publiziert
|
||||
- ✅ Subdomains mit TLS + HSTS (`max-age=300`)
|
||||
|
||||
## Offene Punkte (Details in HANDOFF.md)
|
||||
Alles in `.env.local` — gitignored, nicht committet.
|
||||
|
||||
Nach Priorität:
|
||||
1. **Postfach `webmaster@joerg-lohrer.de`** als Weiterleitung in KAS anlegen.
|
||||
2. **SPA respektiert NIP-09-Deletion-Events** (defensiver kind:5-Filter).
|
||||
3. **Mehrsprachigkeit** — parallele `lang:en`-Versionen bei Bedarf anlegen,
|
||||
per `a`-Tag als `translation_of` verlinken (NIP-32-Grundlage steht).
|
||||
4. **Self-hosted CI** (Woodpecker / Cron auf Optiplex), weg von GitHub.
|
||||
5. **5 UNKNOWN-Einträge** im VR-Post zur späteren Recherche.
|
||||
## Offene Punkte / Nicht-in-Scope
|
||||
|
||||
## Erledigt (chronologisch seit 2026-04-15)
|
||||
|
||||
- ✅ Content-Migration: alle 18 Posts haben strukturierte `images:`-Liste
|
||||
im Frontmatter (91 Bilder, mit Alt-Text, Lizenz, Autor:innen, ggf.
|
||||
Caption und Modifications).
|
||||
- ✅ Spec, Plan und Bild-Metadaten-Konvention geschrieben.
|
||||
- ✅ Community-Wiki-Entwürfe (DE + EN) für Nostr-Bildattribution.
|
||||
- ✅ **Publish-Pipeline komplett implementiert** (24 Tasks, 59 Tests grün).
|
||||
- ✅ **Alle 18 Altposts publiziert** als `kind:30023`-Events.
|
||||
- ✅ **91 Bilder** auf beiden Blossom-Servern.
|
||||
- ✅ **GitHub-Actions-Workflow** + Forgejo→GitHub Push-Mirror + Secrets.
|
||||
- ✅ **Duplikat-Event via NIP-09 gelöscht** (`bibel-selfies` Unix-Timestamp-dup).
|
||||
- ✅ **Staging-Deploy-Infrastruktur** mit `__SITE_URL__`-Templating.
|
||||
- ✅ **Homepage** mit Hero, Profilbild, Social-Icons (Nostr/Mastodon/
|
||||
Bluesky/LinkedIn/ORCID/Mail), Latest-Posts.
|
||||
- ✅ **Archiv-Seite**, **Impressum-Seite**, Menü-Navigation im Layout.
|
||||
- ✅ **CC0-Footer-Badge** (Heart+Zero inline SVG, monochrom).
|
||||
- ✅ **Impressum auf CC0 umgestellt** (mit freundlichem Namensnennungs-Hinweis).
|
||||
- ✅ **Cutover 2026-04-18** — `joerg-lohrer.de` von Hugo (`joerglohrer24/`)
|
||||
auf SvelteKit-SPA (`joerglohrer26/`) umgehängt.
|
||||
- ✅ **Nostr-Reimport 2026-04-18** — 8 direkt-auf-Nostr erstellte Posts
|
||||
(Habla/Yakihonne) mit sauberen ASCII-slugs ins Repo geholt und neu
|
||||
publiziert, alte Events per NIP-09 gelöscht. 26 `kind:30023`-Events
|
||||
aktuell publiziert.
|
||||
- ✅ **Delete-Subcommand** in der Pipeline (`deno task delete --event-id …`),
|
||||
nutzt stabile Bunker-Identität via `CLIENT_SECRET_HEX`.
|
||||
- ✅ **NIP-32 Sprach-Tags** in `buildKind30023` (Default `de`, über
|
||||
`lang:`-Frontmatter überschreibbar).
|
||||
- **Publish-Pipeline** (Spec vorhanden unter `docs/superpowers/specs/2026-04-15-publish-pipeline-design.md`, Plan noch nicht geschrieben)
|
||||
- **Menü-Navigation** in der SPA (Home / Archiv / Impressum / Kontakt)
|
||||
- **Impressum-Seite** (braucht rechtlichen Text)
|
||||
- **Meta-Stubs für Social-Previews und SEO** (wird Teil der Publish-Pipeline)
|
||||
- **SSH-Zugang zu All-Inkl** (laut Notiz von Jörg: Premium-Tarif im Kommen → rsync statt FTPS möglich)
|
||||
- **Cutover auf `joerg-lohrer.de`** (Hauptdomain bekommt dann die SvelteKit-SPA)
|
||||
|
||||
## Live-Verifikation
|
||||
|
||||
Jederzeit:
|
||||
```sh
|
||||
curl -sI https://joerg-lohrer.de/ | head -3
|
||||
curl -sI https://staging.joerg-lohrer.de/ | head -3
|
||||
curl -s https://joerg-lohrer.de/.well-known/nostr.json | jq .
|
||||
curl -sI https://svelte.joerg-lohrer.de/ | head -3
|
||||
curl -sI https://spa.joerg-lohrer.de/ | head -3
|
||||
```
|
||||
|
||||
## Pipeline-Quick-Check
|
||||
|
||||
```sh
|
||||
# Event-Count pro Relay
|
||||
for r in wss://relay.damus.io wss://nos.lol wss://relay.primal.net wss://relay.tchncs.de wss://relay.edufeed.org; do
|
||||
echo -n "$r: "; nak req -k 30023 -a 4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41 $r 2>/dev/null | wc -l
|
||||
done
|
||||
```
|
||||
|
||||
## Design-Referenzen
|
||||
## Kontakt zur Implementierung
|
||||
|
||||
Alle Design-Entscheidungen in:
|
||||
- `docs/superpowers/specs/2026-04-15-nostr-page-design.md` (SPA)
|
||||
- `docs/superpowers/specs/2026-04-15-publish-pipeline-design.md` (Publish, Blossom-only)
|
||||
- `docs/superpowers/specs/2026-04-16-image-metadata-convention.md` (Bild-Metadaten-YAML)
|
||||
- `docs/superpowers/specs/2026-04-15-publish-pipeline-design.md` (Publish)
|
||||
- `docs/superpowers/plans/2026-04-15-spa-sveltekit.md` (35-Task-Plan, abgeschlossen)
|
||||
|
||||
Für die nächste Session: **`docs/HANDOFF.md`** lesen.
|
||||
Für die nächste Session: `docs/HANDOFF.md` lesen.
|
||||
|
|
|
|||
|
|
@ -1,87 +0,0 @@
|
|||
# GitHub-CI-Setup für die Publish-Pipeline
|
||||
|
||||
**Kontext:** Das primäre Repo liegt in **Forgejo** (self-hosted). Für CI nutzen
|
||||
wir GitHub als **Push-Mirror-Ziel**, weil Forgejo keine Woodpecker-Integration
|
||||
hat. GitHub Actions triggert automatisch bei Push auf `main` mit Änderungen
|
||||
unter `content/posts/**`.
|
||||
|
||||
## Setup-Schritte
|
||||
|
||||
### 1. Forgejo → GitHub Push-Mirror
|
||||
|
||||
In Forgejo:
|
||||
- Repo → **Settings → Mirrors → Push Mirror hinzufügen**
|
||||
- Ziel-URL: das entsprechende GitHub-Repo (z. B. `https://github.com/<user>/joerglohrerde.git`)
|
||||
- Authentifizierung: GitHub-Personal-Access-Token (`repo`-Scope)
|
||||
- Intervall: nach Belieben (z. B. alle 8 Stunden, oder „bei jedem Push")
|
||||
|
||||
### 2. GitHub-Repository-Secrets
|
||||
|
||||
In GitHub, Repo → **Settings → Secrets and variables → Actions**:
|
||||
|
||||
Vier Repository-Secrets anlegen (nicht Environment-Secrets — wir haben keine Environments):
|
||||
|
||||
| Name | Wert | Quelle |
|
||||
|---|---|---|
|
||||
| `BUNKER_URL` | `bunker://<hex>?relay=wss://...&secret=...` | aus `.env.local` |
|
||||
| `AUTHOR_PUBKEY_HEX` | `4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41` | aus `.env.local` |
|
||||
| `BOOTSTRAP_RELAY` | `wss://relay.primal.net` | aus `.env.local` |
|
||||
| `CLIENT_SECRET_HEX` | `929f0cd946fd5266e63ccdb066ce7a0cc93391133bfce6098fe633fc72e03e96` | aus `.env.local` |
|
||||
|
||||
**Wichtig:**
|
||||
- Alle vier **müssen** gesetzt sein, sonst schlägt der Workflow fehl.
|
||||
- Der `CLIENT_SECRET_HEX` ist **identisch** mit dem in `.env.local` — damit sich
|
||||
CI-Runner und lokaler Rechner bei Amber mit **derselben Client-Identität**
|
||||
anmelden. Die Permissions in Amber gelten dann für beide.
|
||||
|
||||
### 3. Workflow-Datei
|
||||
|
||||
Liegt in `.github/workflows/publish.yml`. Triggert auf:
|
||||
- `push` auf `main` mit Änderungen unter `content/posts/**`
|
||||
- `workflow_dispatch` (manuelles Triggern über das GitHub-UI, optional mit `force_all=true`)
|
||||
|
||||
### 4. Secrets rotieren
|
||||
|
||||
Wenn der Bunker-Pairing-Secret mal kompromittiert wird oder Amber neu
|
||||
eingerichtet wird:
|
||||
|
||||
1. In Amber neue Bunker-URL erzeugen
|
||||
2. Lokale `.env.local` aktualisieren
|
||||
3. GitHub-Secret `BUNKER_URL` ebenfalls aktualisieren (Settings → Secrets → edit)
|
||||
4. In Amber für die neue App wieder "Allow + Always" für
|
||||
`get_public_key` + `sign_event` setzen
|
||||
|
||||
Der `CLIENT_SECRET_HEX` muss in der Regel **nicht** rotiert werden — nur wenn
|
||||
du die App in Amber komplett neu pairen willst. Wenn du ihn doch änderst, muss
|
||||
Amber die App neu registrieren (siehe Setup).
|
||||
|
||||
## Monitoring
|
||||
|
||||
- **Workflow-Runs:** GitHub → Actions → "Publish Nostr Events"
|
||||
- **Logs pro Run:** pro Run ein Artefakt `publish-log` mit der `publish-*.json`,
|
||||
30 Tage Aufbewahrung
|
||||
- **Lokal laufen bleibt möglich** via `cd publish && deno task publish …` —
|
||||
CI ist eine zusätzliche Automatisierung, kein Zwang.
|
||||
|
||||
## Bekannte Einschränkungen
|
||||
|
||||
- **Amber muss online sein** während CI-Runs, sonst scheitert die Bunker-
|
||||
Signatur. Wenn das Handy tot ist: Workflow failed → einfach neu triggern,
|
||||
sobald Amber wieder erreichbar.
|
||||
- **`relay.damus.io`** antwortet gelegentlich nicht mit OK; das ist
|
||||
ein bekanntes Damus-Verhalten und wird von `MIN_RELAY_ACKS=2` toleriert.
|
||||
- **Staging-Subdomain (`staging.joerg-lohrer.de`)** hat nichts mit dieser
|
||||
Pipeline zu tun — sie gehört zum SPA-Deploy. Die Publish-Pipeline nutzt
|
||||
ausschließlich Blossom für Bild-Hosting.
|
||||
|
||||
## Migration weg von GitHub (später)
|
||||
|
||||
Wenn Woodpecker oder ein anderer self-hosted Runner aufgesetzt wird, bleibt
|
||||
der Deno-Workflow derselbe — nur die CI-Konfiguration ändert sich:
|
||||
|
||||
- `.github/workflows/publish.yml` → `.woodpecker.yaml` (oder `.gitea/workflows/`)
|
||||
- Secrets in Woodpecker statt GitHub
|
||||
- Trigger-Bedingungen analog (push main + path filter)
|
||||
|
||||
Der Pipeline-Code selbst (`publish/src/**`) ist CI-agnostisch und braucht keine
|
||||
Änderung.
|
||||
|
|
@ -1,372 +0,0 @@
|
|||
{
|
||||
"run_id": "6356c2c9-37c6-4927-b906-7943bb59d3c0",
|
||||
"started_at": "2026-04-18T04:44:43.558Z",
|
||||
"ended_at": "2026-04-18T04:47:34.238Z",
|
||||
"mode": "force-all",
|
||||
"posts": [
|
||||
{
|
||||
"slug": "premium-freemium-mium-mium-mium",
|
||||
"status": "success",
|
||||
"action": "new",
|
||||
"event_id": "7f18e5fbc825f16d118281e4c56ce8a989d2647fedcd3d4e45a8df0e897a395c",
|
||||
"relays_ok": [
|
||||
"wss://nos.lol/",
|
||||
"wss://relay.damus.io/",
|
||||
"wss://relay.primal.net/",
|
||||
"wss://relay.tchncs.de/",
|
||||
"wss://relay.edufeed.org/"
|
||||
],
|
||||
"relays_failed": [],
|
||||
"blossom_servers_ok": [
|
||||
"https://blossom.edufeed.org",
|
||||
"https://blossom.primal.net"
|
||||
],
|
||||
"images_uploaded": 1,
|
||||
"duration_ms": 4294
|
||||
},
|
||||
{
|
||||
"slug": "erlebnispadagogik-im-handbuch-jugend-evangelische-perspektive",
|
||||
"status": "success",
|
||||
"action": "new",
|
||||
"event_id": "ee5ecc397b4bfbff268ec3c53187f0b2b4a03958e14df2728c27145a72b85941",
|
||||
"relays_ok": [
|
||||
"wss://nos.lol/",
|
||||
"wss://relay.damus.io/",
|
||||
"wss://relay.primal.net/",
|
||||
"wss://relay.tchncs.de/",
|
||||
"wss://relay.edufeed.org/"
|
||||
],
|
||||
"relays_failed": [],
|
||||
"blossom_servers_ok": [],
|
||||
"images_uploaded": 0,
|
||||
"duration_ms": 3111
|
||||
},
|
||||
{
|
||||
"slug": "telegram-octopi",
|
||||
"status": "success",
|
||||
"action": "new",
|
||||
"event_id": "bee5a9150ce2055e17d729772b4a998afbd3333e6224beb72fd2152ee2d0c05e",
|
||||
"relays_ok": [
|
||||
"wss://nos.lol/",
|
||||
"wss://relay.damus.io/",
|
||||
"wss://relay.primal.net/",
|
||||
"wss://relay.tchncs.de/",
|
||||
"wss://relay.edufeed.org/"
|
||||
],
|
||||
"relays_failed": [],
|
||||
"blossom_servers_ok": [
|
||||
"https://blossom.edufeed.org",
|
||||
"https://blossom.primal.net"
|
||||
],
|
||||
"images_uploaded": 4,
|
||||
"duration_ms": 5169
|
||||
},
|
||||
{
|
||||
"slug": "lutherkuerbis",
|
||||
"status": "success",
|
||||
"action": "new",
|
||||
"event_id": "7715c4359da95c0459a6e65fd25c17c3c8c169e83fe68acb9f98ed34dd44e4e6",
|
||||
"relays_ok": [
|
||||
"wss://nos.lol/",
|
||||
"wss://relay.damus.io/",
|
||||
"wss://relay.primal.net/",
|
||||
"wss://relay.tchncs.de/",
|
||||
"wss://relay.edufeed.org/"
|
||||
],
|
||||
"relays_failed": [],
|
||||
"blossom_servers_ok": [
|
||||
"https://blossom.edufeed.org",
|
||||
"https://blossom.primal.net"
|
||||
],
|
||||
"images_uploaded": 6,
|
||||
"duration_ms": 7921
|
||||
},
|
||||
{
|
||||
"slug": "pflanzenschild-qr-code",
|
||||
"status": "success",
|
||||
"action": "new",
|
||||
"event_id": "f66520b363e568bf2714b6d6bd63543dd4f79f8e834d40566656ea35d37ecdf2",
|
||||
"relays_ok": [
|
||||
"wss://nos.lol/",
|
||||
"wss://relay.damus.io/",
|
||||
"wss://relay.primal.net/",
|
||||
"wss://relay.tchncs.de/",
|
||||
"wss://relay.edufeed.org/"
|
||||
],
|
||||
"relays_failed": [],
|
||||
"blossom_servers_ok": [
|
||||
"https://blossom.edufeed.org",
|
||||
"https://blossom.primal.net"
|
||||
],
|
||||
"images_uploaded": 2,
|
||||
"duration_ms": 4436
|
||||
},
|
||||
{
|
||||
"slug": "virtual-reality",
|
||||
"status": "success",
|
||||
"action": "new",
|
||||
"event_id": "28b75d85774056e6e59097ebf58c44c9e8badadbd25084d0eebc3f95a9a90439",
|
||||
"relays_ok": [
|
||||
"wss://nos.lol/",
|
||||
"wss://relay.primal.net/",
|
||||
"wss://relay.tchncs.de/",
|
||||
"wss://relay.edufeed.org/"
|
||||
],
|
||||
"relays_failed": [
|
||||
"wss://relay.damus.io/"
|
||||
],
|
||||
"blossom_servers_ok": [
|
||||
"https://blossom.edufeed.org",
|
||||
"https://blossom.primal.net"
|
||||
],
|
||||
"images_uploaded": 7,
|
||||
"duration_ms": 14102
|
||||
},
|
||||
{
|
||||
"slug": "wordpress-werkstatt",
|
||||
"status": "success",
|
||||
"action": "new",
|
||||
"event_id": "5ff5ca9dcc4098e042c7bfe0808f05aea6d7ea871dbb69696594a7d7682b3115",
|
||||
"relays_ok": [
|
||||
"wss://nos.lol/",
|
||||
"wss://relay.damus.io/",
|
||||
"wss://relay.primal.net/",
|
||||
"wss://relay.tchncs.de/",
|
||||
"wss://relay.edufeed.org/"
|
||||
],
|
||||
"relays_failed": [],
|
||||
"blossom_servers_ok": [
|
||||
"https://blossom.edufeed.org",
|
||||
"https://blossom.primal.net"
|
||||
],
|
||||
"images_uploaded": 7,
|
||||
"duration_ms": 9300
|
||||
},
|
||||
{
|
||||
"slug": "bibelfussball",
|
||||
"status": "success",
|
||||
"action": "new",
|
||||
"event_id": "ee019e772f2c8e52a3d77041495508f5d65ab34bb46ccaff3a74f34173cc194f",
|
||||
"relays_ok": [
|
||||
"wss://nos.lol/",
|
||||
"wss://relay.primal.net/",
|
||||
"wss://relay.tchncs.de/",
|
||||
"wss://relay.edufeed.org/"
|
||||
],
|
||||
"relays_failed": [
|
||||
"wss://relay.damus.io/"
|
||||
],
|
||||
"blossom_servers_ok": [
|
||||
"https://blossom.edufeed.org",
|
||||
"https://blossom.primal.net"
|
||||
],
|
||||
"images_uploaded": 1,
|
||||
"duration_ms": 8627
|
||||
},
|
||||
{
|
||||
"slug": "moodle-iomad-linux",
|
||||
"status": "success",
|
||||
"action": "new",
|
||||
"event_id": "707e98d43993778d8e5ffb87a29262d32decc6d0518399991ed7e6c7b4dedf1d",
|
||||
"relays_ok": [
|
||||
"wss://nos.lol/",
|
||||
"wss://relay.primal.net/",
|
||||
"wss://relay.tchncs.de/",
|
||||
"wss://relay.edufeed.org/"
|
||||
],
|
||||
"relays_failed": [
|
||||
"wss://relay.damus.io/"
|
||||
],
|
||||
"blossom_servers_ok": [
|
||||
"https://blossom.edufeed.org",
|
||||
"https://blossom.primal.net"
|
||||
],
|
||||
"images_uploaded": 4,
|
||||
"duration_ms": 10653
|
||||
},
|
||||
{
|
||||
"slug": "ob-virtualcam",
|
||||
"status": "success",
|
||||
"action": "new",
|
||||
"event_id": "1e62cf1f6375fc3988024a3f3d02c041a3065ad69f53b32cb085858fff3c8ed0",
|
||||
"relays_ok": [
|
||||
"wss://nos.lol/",
|
||||
"wss://relay.damus.io/",
|
||||
"wss://relay.primal.net/",
|
||||
"wss://relay.tchncs.de/",
|
||||
"wss://relay.edufeed.org/"
|
||||
],
|
||||
"relays_failed": [],
|
||||
"blossom_servers_ok": [
|
||||
"https://blossom.edufeed.org",
|
||||
"https://blossom.primal.net"
|
||||
],
|
||||
"images_uploaded": 31,
|
||||
"duration_ms": 32549
|
||||
},
|
||||
{
|
||||
"slug": "jojos-schoko-zimt-schnecken",
|
||||
"status": "success",
|
||||
"action": "new",
|
||||
"event_id": "8561994ee97ebec8775e60106cd8c58b3b24df27c8c0f442e03c2ad384003df2",
|
||||
"relays_ok": [
|
||||
"wss://nos.lol/",
|
||||
"wss://relay.damus.io/",
|
||||
"wss://relay.primal.net/",
|
||||
"wss://relay.tchncs.de/",
|
||||
"wss://relay.edufeed.org/"
|
||||
],
|
||||
"relays_failed": [],
|
||||
"blossom_servers_ok": [
|
||||
"https://blossom.edufeed.org",
|
||||
"https://blossom.primal.net"
|
||||
],
|
||||
"images_uploaded": 6,
|
||||
"duration_ms": 9240
|
||||
},
|
||||
{
|
||||
"slug": "gleichnis-vom-saemann",
|
||||
"status": "success",
|
||||
"action": "new",
|
||||
"event_id": "a9b49cbf601b7dba4cb7b63e26c281308fee2f722b4c2c0bb7485d339cd9364e",
|
||||
"relays_ok": [
|
||||
"wss://nos.lol/",
|
||||
"wss://relay.damus.io/",
|
||||
"wss://relay.primal.net/",
|
||||
"wss://relay.tchncs.de/",
|
||||
"wss://relay.edufeed.org/"
|
||||
],
|
||||
"relays_failed": [],
|
||||
"blossom_servers_ok": [
|
||||
"https://blossom.edufeed.org",
|
||||
"https://blossom.primal.net"
|
||||
],
|
||||
"images_uploaded": 9,
|
||||
"duration_ms": 14335
|
||||
},
|
||||
{
|
||||
"slug": "dampfnudeln",
|
||||
"status": "success",
|
||||
"action": "new",
|
||||
"event_id": "51e032c62bc228ace874321f4bcb4f872fe4841258aba3d6d7208ce476664b43",
|
||||
"relays_ok": [
|
||||
"wss://nos.lol/",
|
||||
"wss://relay.damus.io/",
|
||||
"wss://relay.primal.net/",
|
||||
"wss://relay.tchncs.de/",
|
||||
"wss://relay.edufeed.org/"
|
||||
],
|
||||
"relays_failed": [],
|
||||
"blossom_servers_ok": [
|
||||
"https://blossom.edufeed.org",
|
||||
"https://blossom.primal.net"
|
||||
],
|
||||
"images_uploaded": 6,
|
||||
"duration_ms": 8128
|
||||
},
|
||||
{
|
||||
"slug": "wordpress-statt-padlet-oder-taskcards",
|
||||
"status": "success",
|
||||
"action": "update",
|
||||
"event_id": "8bd17088cb93d4b9868ac4764057f1963c9878a88abc528152649ff2d7b425ef",
|
||||
"relays_ok": [
|
||||
"wss://nos.lol/",
|
||||
"wss://relay.damus.io/",
|
||||
"wss://relay.primal.net/",
|
||||
"wss://relay.tchncs.de/",
|
||||
"wss://relay.edufeed.org/"
|
||||
],
|
||||
"relays_failed": [],
|
||||
"blossom_servers_ok": [
|
||||
"https://blossom.edufeed.org",
|
||||
"https://blossom.primal.net"
|
||||
],
|
||||
"images_uploaded": 3,
|
||||
"duration_ms": 7449
|
||||
},
|
||||
{
|
||||
"slug": "offenheit-das-wesentliche",
|
||||
"status": "success",
|
||||
"action": "update",
|
||||
"event_id": "45472a71074ed5fdb2c654c8500a9fb581bcf48221be3d565fb6b9ea088c9ab0",
|
||||
"relays_ok": [
|
||||
"wss://nos.lol/",
|
||||
"wss://relay.primal.net/",
|
||||
"wss://relay.tchncs.de/",
|
||||
"wss://relay.edufeed.org/"
|
||||
],
|
||||
"relays_failed": [
|
||||
"wss://relay.damus.io/"
|
||||
],
|
||||
"blossom_servers_ok": [
|
||||
"https://blossom.edufeed.org",
|
||||
"https://blossom.primal.net"
|
||||
],
|
||||
"images_uploaded": 1,
|
||||
"duration_ms": 8235
|
||||
},
|
||||
{
|
||||
"slug": "bottomup-markdown",
|
||||
"status": "success",
|
||||
"action": "update",
|
||||
"event_id": "a8030ba0f9c62a5787a84c607b11f3eaf9c0c694adcc245b9969522cff9f0e30",
|
||||
"relays_ok": [
|
||||
"wss://nos.lol/",
|
||||
"wss://relay.primal.net/",
|
||||
"wss://relay.tchncs.de/",
|
||||
"wss://relay.edufeed.org/"
|
||||
],
|
||||
"relays_failed": [
|
||||
"wss://relay.damus.io/"
|
||||
],
|
||||
"blossom_servers_ok": [
|
||||
"https://blossom.edufeed.org",
|
||||
"https://blossom.primal.net"
|
||||
],
|
||||
"images_uploaded": 1,
|
||||
"duration_ms": 7143
|
||||
},
|
||||
{
|
||||
"slug": "kibedenken-bewusstsein",
|
||||
"status": "success",
|
||||
"action": "update",
|
||||
"event_id": "a05458fc79f4192fef6902e8c55c465f3d9c26cede5772259d5f2d5879734dd2",
|
||||
"relays_ok": [
|
||||
"wss://nos.lol/",
|
||||
"wss://relay.damus.io/",
|
||||
"wss://relay.primal.net/",
|
||||
"wss://relay.tchncs.de/",
|
||||
"wss://relay.edufeed.org/"
|
||||
],
|
||||
"relays_failed": [],
|
||||
"blossom_servers_ok": [
|
||||
"https://blossom.edufeed.org",
|
||||
"https://blossom.primal.net"
|
||||
],
|
||||
"images_uploaded": 1,
|
||||
"duration_ms": 5147
|
||||
},
|
||||
{
|
||||
"slug": "dezentrale-oep-oer",
|
||||
"status": "success",
|
||||
"action": "update",
|
||||
"event_id": "4db003fd8c144fe1b0528c8cfbfb075ff6a8f203fd327c10c4c46e42fcac2a40",
|
||||
"relays_ok": [
|
||||
"wss://nos.lol/",
|
||||
"wss://relay.primal.net/",
|
||||
"wss://relay.tchncs.de/",
|
||||
"wss://relay.edufeed.org/"
|
||||
],
|
||||
"relays_failed": [
|
||||
"wss://relay.damus.io/"
|
||||
],
|
||||
"blossom_servers_ok": [
|
||||
"https://blossom.edufeed.org",
|
||||
"https://blossom.primal.net"
|
||||
],
|
||||
"images_uploaded": 1,
|
||||
"duration_ms": 8489
|
||||
}
|
||||
],
|
||||
"exit_code": 0
|
||||
}
|
||||
|
|
@ -1,602 +0,0 @@
|
|||
# Redaktion: Bild-Metadaten-Durchgang
|
||||
|
||||
**Zweck:** 91 Bilder in 18 Posts visuell prüfen und Alt-Texte, Lizenzen, Autor:innen-Angaben gegen das echte Bild abgleichen.
|
||||
|
||||
**Arbeitsweise:**
|
||||
- Pro Bild: Checkbox `[ ]` → `[x]` wenn geprüft.
|
||||
- Im **NOTIZ**-Feld: freie Änderungswünsche, Korrekturen, Klarstellungen.
|
||||
- Bei `UNKNOWN`: Recherche-Ergebnis eintragen oder „bleibt UNKNOWN".
|
||||
- Ich mache den Abgleich am Ende und schreibe alle Änderungen ins Frontmatter zurück.
|
||||
|
||||
**Links:**
|
||||
- `📝 Frontmatter` → öffnet `index.md` zum Direkt-Editieren
|
||||
- `🖼 Bild` → öffnet die Bilddatei lokal im Finder/Preview (mit `file://`)
|
||||
|
||||
---
|
||||
|
||||
## 2013-02-07 — premium-freemium-mium-mium-mium
|
||||
📝 [Frontmatter](../content/posts/2013-02-07-premium-freemium-mium-mium-mium/index.md)
|
||||
|
||||
- [X] **my-very-hungry-caterpillar.jpg** (Cover, FREMD)
|
||||
🖼 [Bild](../content/posts/2013-02-07-premium-freemium-mium-mium-mium/my-very-hungry-caterpillar.jpg)
|
||||
Alt: „Kleine Raupe aus Papier gefaltet, Anspielung auf das Kinderbuch 'Die kleine Raupe Nimmersatt'"
|
||||
Lizenz: CC BY-NC-SA 3.0 · Autor: Relly Annett-Baker · Quelle: flickr.com/photos/fizzkitten/4454153264
|
||||
**NOTIZ:**
|
||||
Bild ist leider nicht mehr verfügbar online - ich hoffe jedoch es gibt keine Abmahnung weil nicht mehr nachweisbar das cc-lizenz
|
||||
|
||||
---
|
||||
|
||||
## 2013-05-29 — erlebnispadagogik-im-handbuch-jugend-evangelische-perspektive
|
||||
📝 [Frontmatter](../content/posts/2013-05-29-erlebnispadagogik-im-handbuch-jugend-evangelische-perspektive/index.md)
|
||||
|
||||
_Keine lokalen Bilder. Body enthält tote Amazon-Hotlinks zu Buchcovern (Affiliate-Programm 2018 eingestellt)._
|
||||
|
||||
- [X] **Amazon-Hotlinks entfernen / durch Text ersetzen?** Entscheidung später.
|
||||
**NOTIZ:**
|
||||
Ja Hotlink entfernen
|
||||
|
||||
---
|
||||
|
||||
## 2017-10-23 — telegram-octopi
|
||||
📝 [Frontmatter](../content/posts/2017-10-23-telegram-octopi/index.md)
|
||||
|
||||
- [ ] **octopi1.png** (Cover)
|
||||
🖼 [Bild](../content/posts/2017-10-23-telegram-octopi/octopi1.png)
|
||||
Alt: „Screenshot der OctoPrint-Plugin-Verwaltung während der Installation des Telegram-Plugins — Fortschrittsanzeige läuft"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **octopi2.png**
|
||||
🖼 [Bild](../content/posts/2017-10-23-telegram-octopi/octopi2.png)
|
||||
Alt: „Screenshot der Konfigurationsmaske des OctoPrint-Telegram-Plugins mit Eingabefeld für den Telegram-Bot-Token"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **octopi3.png**
|
||||
🖼 [Bild](../content/posts/2017-10-23-telegram-octopi/octopi3.png)
|
||||
Alt: „Screenshot der OctoPrint-Telegram-Plugin-Oberfläche nach erfolgreichem Token-Eintrag — Benutzerliste wird angezeigt, Rechte fehlen noch"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **octopi4.png**
|
||||
🖼 [Bild](../content/posts/2017-10-23-telegram-octopi/octopi4.png)
|
||||
Alt: „Screenshot der Benutzer-Rechte-Konfiguration mit gesetzten Häkchen bei 'Command' und 'Notify'"
|
||||
**NOTIZ:**
|
||||
|
||||
Alle CC0
|
||||
---
|
||||
|
||||
## 2017-10-31 — lutherkuerbis
|
||||
📝 [Frontmatter](../content/posts/2017-10-31-lutherkuerbis/index.md)
|
||||
|
||||
- [ ] **kuerbis-titelbild.jpg** (Cover)
|
||||
🖼 [Bild](../content/posts/2017-10-31-lutherkuerbis/kuerbis-titelbild.jpg)
|
||||
Alt: „Fertig geschnitzter Kürbis mit dem Muster einer Lutherrose, innen beleuchtet — glüht warm in der Dunkelheit"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **lutherrose.png** (Vektorschablone, Vorlage aus dem Web)
|
||||
🖼 [Bild](../content/posts/2017-10-31-lutherkuerbis/lutherrose.png)
|
||||
Alt: „Schwarz-weiße Vektorgrafik der Lutherrose: Kreuz im Herzen, umgeben von fünfblättriger Rose in einem Ring — als Schnitzschablone aufbereitet"
|
||||
Caption: „Vektorisierte Schablone, abgeleitet von einer Fotovorlage aus dem Web"
|
||||
Modifications: „Vektorisierung per online-convert.com aus gemeinfreier Fotovorlage; Originalurheber der Fotovorlage unbekannt"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **kuerbis-aufschneiden.jpg**
|
||||
🖼 [Bild](../content/posts/2017-10-31-lutherkuerbis/kuerbis-aufschneiden.jpg)
|
||||
Alt: „Hände schneiden mit großem Messer den Deckel von einem orangenen Kürbis ab"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **kuerbis-entkernen.jpg**
|
||||
🖼 [Bild](../content/posts/2017-10-31-lutherkuerbis/kuerbis-entkernen.jpg)
|
||||
Alt: „Mit einem Löffel wird das Fruchtfleisch und die Kerne aus dem Inneren des aufgeschnittenen Kürbis herausgekratzt"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **schablone-aufbringen.jpg**
|
||||
🖼 [Bild](../content/posts/2017-10-31-lutherkuerbis/schablone-aufbringen.jpg)
|
||||
Alt: „Papier-Schablone mit Lutherrosen-Motiv wird auf die Außenhaut des entkernten Kürbis geklebt"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **kuerbis-ausschneiden.jpg**
|
||||
🖼 [Bild](../content/posts/2017-10-31-lutherkuerbis/kuerbis-ausschneiden.jpg)
|
||||
Alt: „Mit einem Schnitzwerkzeug wird die Lutherrose entlang der Schablone aus der Kürbishaut herausgeschnitten"
|
||||
**NOTIZ:**
|
||||
Alle CC0
|
||||
---
|
||||
|
||||
## 2019-03-26 — Pflanzenschild-QR-Code
|
||||
📝 [Frontmatter](../content/posts/2019-03-26-Pflanzenschild-QR-Code/index.md)
|
||||
|
||||
- [ ] **cura-plugin-change-filment-at-z.png** (Cover)
|
||||
🖼 [Bild](../content/posts/2019-03-26-Pflanzenschild-QR-Code/cura-plugin-change-filment-at-z.png)
|
||||
Alt: „Screenshot des Cura-Slicers mit aktiviertem 'Change Filament at Z'-Plugin — Konfiguration eines Filamentwechsels in bestimmten Layern"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **qr-code-pflanzenschild.jpg**
|
||||
🖼 [Bild](../content/posts/2019-03-26-Pflanzenschild-QR-Code/qr-code-pflanzenschild.jpg)
|
||||
Alt: „Dreieckiges 3D-gedrucktes Pflanzenschild mit aufgedrucktem zweifarbigem QR-Code, steckt in einem Pflanztopf"
|
||||
**NOTIZ:**
|
||||
Alle CC0
|
||||
---
|
||||
|
||||
## 2021-08-15 — virtual-reality (⚠️ 4× UNKNOWN zur Recherche)
|
||||
📝 [Frontmatter](../content/posts/2021-08-15-virtual-reality/index.md)
|
||||
|
||||
- [ ] **04-aframe.jpg** (Cover, EIGEN)
|
||||
🖼 [Bild](../content/posts/2021-08-15-virtual-reality/04-aframe.jpg)
|
||||
Alt: „Screenshot einer A-Frame-WebVR-Szene: 3D-Objekte in einem Browser-Viewport, erstellt mit A-Frame-Framework"
|
||||
Quelle: codepen.io/joerglohrer/full/dyXQqWG
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **01-immersion-wikipedia.jpg** (⚠️ UNKNOWN)
|
||||
🖼 [Bild](../content/posts/2021-08-15-virtual-reality/01-immersion-wikipedia.jpg)
|
||||
Alt: „Screenshot des Wikipedia-Artikels 'Immersive learning' mit Einstiegsdefinition"
|
||||
Lizenz: UNKNOWN · Autor: UNKNOWN · Quelle: en.wikipedia.org/wiki/Immersive_learning
|
||||
Wikipedia-Text ist CC BY-SA — soll ich das so setzen?
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **02-mittelalterliche-kirche.jpg** (FREMD, Sketchfab)
|
||||
🖼 [Bild](../content/posts/2021-08-15-virtual-reality/02-mittelalterliche-kirche.jpg)
|
||||
Alt: „Screenshot eines 3D-Modells einer mittelalterlichen Kirche (Calatrava la Nueva, Spanien) auf Sketchfab, erstellt aus 76 Laser-Scans und 4100 Fotos"
|
||||
Lizenz: CC BY-NC 4.0 · Autor: UNKNOWN · Quelle: sketchfab.com/3d-models/medieval-church-…
|
||||
Im Post-Body Zeile 120–122 genannt: „Processed in Reality Capture from 76 Faro laser scans and 4100 photographs" — aber kein Urhebername. Recherche möglich?
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **03-avatare-erstellen.jpg** (⚠️ UNKNOWN, Ready Player Me)
|
||||
🖼 [Bild](../content/posts/2021-08-15-virtual-reality/03-avatare-erstellen.jpg)
|
||||
Alt: „Screenshot der Avatar-Erstellung im Ready Player Me Web-Interface"
|
||||
Lizenz: UNKNOWN · Autor: UNKNOWN
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **05-pupillendistanz.jpg** (⚠️ UNKNOWN, EyeMeasure-App)
|
||||
🖼 [Bild](../content/posts/2021-08-15-virtual-reality/05-pupillendistanz.jpg)
|
||||
Alt: „Screenshot der iOS-App 'EyeMeasure' bei der Messung des Pupillenabstands mittels iPhone-Kamera"
|
||||
Lizenz: UNKNOWN · Autor: UNKNOWN
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **06-vr-adapter-3ddruck.jpg** (EIGEN)
|
||||
🖼 [Bild](../content/posts/2021-08-15-virtual-reality/06-vr-adapter-3ddruck.jpg)
|
||||
Alt: „3D-gedruckter Adapter zur Befestigung einer VIVE Deluxe Audio Strap an der Oculus Quest 2, frisch aus dem 3D-Drucker"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **07-vive-straps-3ddruck.jpg** (EIGEN)
|
||||
🖼 [Bild](../content/posts/2021-08-15-virtual-reality/07-vive-straps-3ddruck.jpg)
|
||||
Alt: „3D-gedruckte Halterungen der VIVE Deluxe Audio Strap, montiert an der Oculus Quest 2"
|
||||
**NOTIZ:**
|
||||
|
||||
---
|
||||
|
||||
## 2021-11-17 — WordPress-Werkstatt
|
||||
📝 [Frontmatter](../content/posts/2021-11-17-WordPress-Werkstatt/index.md)
|
||||
|
||||
- [ ] **04-termine-neu.png** (Cover)
|
||||
🖼 [Bild](../content/posts/2021-11-17-WordPress-Werkstatt/04-termine-neu.png)
|
||||
Alt: „Screenshot der WordPress-Beitragsübersicht mit eingefügtem Shortcode [relilab_termine], der eine Terminliste als Block rendert"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **01-json-import.png**
|
||||
🖼 [Bild](../content/posts/2021-11-17-WordPress-Werkstatt/01-json-import.png)
|
||||
Alt: „Screenshot der ACF-Plugin-Oberfläche beim Import einer JSON-Datei mit Feldgruppen-Definitionen"
|
||||
(Hinweis: im Body fälschlich `` mit Tippfehler — Body-Fix später)
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **02-terminfelder.png**
|
||||
🖼 [Bild](../content/posts/2021-11-17-WordPress-Werkstatt/02-terminfelder.png)
|
||||
Alt: „Screenshot eines WordPress-Beitrags mit zwei neuen ACF-Terminfeldern 'Startet am' und 'Endet am' als Datum-/Zeit-Picker"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **03-kategorien.png**
|
||||
🖼 [Bild](../content/posts/2021-11-17-WordPress-Werkstatt/03-kategorien.png)
|
||||
Alt: „Screenshot der WordPress-Kategorieverwaltung mit neu angelegter Kategorie 'Termine' samt Unterkategorien"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **05-php-storm.png**
|
||||
🖼 [Bild](../content/posts/2021-11-17-WordPress-Werkstatt/05-php-storm.png)
|
||||
Alt: „Screenshot der PhpStorm-IDE mit geöffneter PHP-Datei zum add_shortcode()-Aufruf"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **06-termine-listen.png**
|
||||
🖼 [Bild](../content/posts/2021-11-17-WordPress-Werkstatt/06-termine-listen.png)
|
||||
Alt: „Screenshot des PHP-Codes für die Funktion 'termineAusgeben' mit get_posts()-Abfrage und Shortcode-Registrierung"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **07-external-library.png**
|
||||
🖼 [Bild](../content/posts/2021-11-17-WordPress-Werkstatt/07-external-library.png)
|
||||
Alt: „Screenshot der PhpStorm-Konfiguration zur Einbindung von WordPress als External Library für Auto-Complete"
|
||||
**NOTIZ:**
|
||||
|
||||
---
|
||||
|
||||
## 2021-12-03 — bibelfussball
|
||||
📝 [Frontmatter](../content/posts/2021-12-03-bibelfussball/index.md)
|
||||
|
||||
- [ ] **bibelfussball1.png** (Cover)
|
||||
🖼 [Bild](../content/posts/2021-12-03-bibelfussball/bibelfussball1.png)
|
||||
Alt: „Tafel-Skizze eines Fußballfeldes mit Mittellinie, Strafräumen und zwei Toren — Magnetknopf markiert die aktuelle Ballposition"
|
||||
**NOTIZ:**
|
||||
|
||||
---
|
||||
|
||||
## 2022-02-16 — Moodle-Iomad-Linux
|
||||
📝 [Frontmatter](../content/posts/2022-02-16-Moodle-Iomad-Linux/index.md)
|
||||
|
||||
- [ ] **title-gif.gif** (Cover)
|
||||
🖼 [Bild](../content/posts/2022-02-16-Moodle-Iomad-Linux/title-gif.gif)
|
||||
Alt: „Animiertes Titelbild des Artikels zur Moodle-Server-Installation mit Iomad unter Ubuntu"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **01-netzwerkbruecke.png**
|
||||
🖼 [Bild](../content/posts/2022-02-16-Moodle-Iomad-Linux/01-netzwerkbruecke.png)
|
||||
Alt: „Screenshot der VirtualBox-Netzwerkeinstellungen mit aktivierter Netzwerkbrücke für die Ubuntu-VM"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **02-hosts-eintragen.png**
|
||||
🖼 [Bild](../content/posts/2022-02-16-Moodle-Iomad-Linux/02-hosts-eintragen.png)
|
||||
Alt: „Terminal-Screenshot mit geöffneter /etc/hosts-Datei im nano-Editor, neuer Eintrag 'moodle.local' wird hinzugefügt"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **03-config generieren.png** (Datei mit Leerzeichen im Namen!)
|
||||
🖼 [Bild](<../content/posts/2022-02-16-Moodle-Iomad-Linux/03-config generieren.png>)
|
||||
Alt: „Screenshot des Moodle-Installationsassistenten beim automatischen Generieren der config.php"
|
||||
**NOTIZ:**
|
||||
|
||||
---
|
||||
|
||||
## 2022-03-19 — OB-virtualcam (31 Bilder)
|
||||
📝 [Frontmatter](../content/posts/2022-03-19-OB-virtualcam/index.md)
|
||||
|
||||
- [ ] **29-autostartordner.jpg** (Cover)
|
||||
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/29-autostartordner.jpg)
|
||||
Alt: „Screenshot des Windows-Autostart-Ordners mit verknüpften OBS- und Zoom-Startlinks für automatischen Start beim Systemstart"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **01-deutsche-tastatur-ubuntu.png**
|
||||
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/01-deutsche-tastatur-ubuntu.png)
|
||||
Alt: „Screenshot der Ubuntu-Terminal-Dialog zur Konfiguration der deutschen Tastatur via dpkg-reconfigure"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **02-chrome-remote-desktop.png**
|
||||
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/02-chrome-remote-desktop.png)
|
||||
Alt: „Screenshot der Chrome-Remote-Desktop-Installation im Ubuntu-Terminal"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **03-status-chrome-remote.png**
|
||||
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/03-status-chrome-remote.png)
|
||||
Alt: „Screenshot des systemctl-Status des chrome-remote-desktop-Dienstes als 'active (running)'"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **04-remotezugriff.png**
|
||||
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/04-remotezugriff.png)
|
||||
Alt: „Screenshot der Chrome-Remote-Desktop-Konfigurationsseite mit SSH-Befehl und PIN-Eingabe"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **05-systemctl-status.png**
|
||||
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/05-systemctl-status.png)
|
||||
Alt: „Screenshot der systemctl-status-Ausgabe für chrome-remote-desktop mit aktivem Dienst"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **06-cannot-open-video-device.png**
|
||||
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/06-cannot-open-video-device.png)
|
||||
Alt: „Terminal-Screenshot der Fehlermeldung 'Cannot open device /dev/video0' bei v4l2-ctl --list-devices"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **07-jetzt-v412-ctl.png**
|
||||
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/07-jetzt-v412-ctl.png)
|
||||
Alt: „Terminal-Screenshot der erfolgreichen v4l2-ctl-Geräteliste nach Installation von v4l2loopback"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **08-dummy-video-device.png**
|
||||
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/08-dummy-video-device.png)
|
||||
Alt: „Terminal-Screenshot nach Reboot: virtuelle Kamera fehlt, Dummy-Video-Device muss neu geladen werden"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **09-relilab-technical-host.png**
|
||||
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/09-relilab-technical-host.png)
|
||||
Alt: „Screenshot der Chrome-Remote-Desktop-Geräteübersicht mit dem VM-Eintrag 'relilab-technical-host'"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **10-pin-remote-desktop.png**
|
||||
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/10-pin-remote-desktop.png)
|
||||
Alt: „Screenshot des Chrome-Remote-Desktop-PIN-Eingabefelds für die Remote-Verbindung"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **11-keyboard-tastatur-umstellen.png**
|
||||
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/11-keyboard-tastatur-umstellen.png)
|
||||
Alt: „Screenshot der Linux-Keyboard-Einstellungen mit Umstellung auf deutsche Tastaturbelegung"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **12-apps-verknuepfen.png**
|
||||
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/12-apps-verknuepfen.png)
|
||||
Alt: „Screenshot der Cinnamon-Desktop-Umgebung mit Drag-and-Drop-Verknüpfung von Anwendungen auf den Desktop"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **13-startvirtualcam.png**
|
||||
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/13-startvirtualcam.png)
|
||||
Alt: „Screenshot der OBS-Verknüpfung mit dem Zusatzparameter --startvirtualcam im Startbefehl"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **14-OBS-deutsch-umstellen.png**
|
||||
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/14-OBS-deutsch-umstellen.png)
|
||||
Alt: „Screenshot der OBS-Studio-Einstellungen beim Umschalten der Benutzeroberfläche auf Deutsch"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **15-obs-mit-virtual-cam-starten.png**
|
||||
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/15-obs-mit-virtual-cam-starten.png)
|
||||
Alt: „Screenshot der OBS-Startbefehl-Konfiguration mit --startvirtualcam-Parameter für automatischen Kamera-Start"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **16-startup-application.png**
|
||||
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/16-startup-application.png)
|
||||
Alt: „Screenshot der Cinnamon-Startup-Applications-Verwaltung mit neu hinzugefügtem OBS-Eintrag"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **17-i-will-only-be-using-OBS.png**
|
||||
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/17-i-will-only-be-using-OBS.png)
|
||||
Alt: „Screenshot des OBS-Auto-Configuration-Wizard mit ausgewählter Option 'I will only be using the virtual camera'"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **18-video1920.png**
|
||||
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/18-video1920.png)
|
||||
Alt: „Screenshot der OBS-Video-Einstellungen mit Auflösung 1920x1080"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **19-szenensammlung-importieren-OBS.png**
|
||||
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/19-szenensammlung-importieren-OBS.png)
|
||||
Alt: „Screenshot des OBS-Menüs 'Szenensammlung importieren' mit Auswahl einer JSON-Datei"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **20-chrome-einrichten.png**
|
||||
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/20-chrome-einrichten.png)
|
||||
Alt: „Screenshot des Ubuntu-Keyring-Passwort-Dialogs beim ersten Chrome-Start"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **21-chrome-standard.png**
|
||||
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/21-chrome-standard.png)
|
||||
Alt: „Screenshot der Google-Chrome-Einstellungen mit gesetzter Option 'Als Standardbrowser festlegen'"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **22-chrome-anmeldung.png**
|
||||
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/22-chrome-anmeldung.png)
|
||||
Alt: „Screenshot der Google-Account-Anmeldung in Chrome mit aktiviertem Sync"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **23-zoom-anmeldung.png**
|
||||
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/23-zoom-anmeldung.png)
|
||||
Alt: „Screenshot der Zoom-Client-Anmeldemaske unter Linux"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **24-zoom-sprache-aendern.png**
|
||||
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/24-zoom-sprache-aendern.png)
|
||||
Alt: „Screenshot des Zoom-Tray-Menüs mit Sprachauswahl-Untermenü zur Umstellung auf Deutsch"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **25-slides-emojis.png**
|
||||
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/25-slides-emojis.png)
|
||||
Alt: „Screenshot einer Präsentationsfolie im Chrome-Browser mit fehlenden Emoji-Zeichen als leere Platzhalter"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **26-keyring-problem.png**
|
||||
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/26-keyring-problem.png)
|
||||
Alt: „Screenshot der Ubuntu-GUI-Fehlermeldung beim Versuch, sich als Root einzuloggen"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **27-startvirtualcam-verknuepft-OBS.jpg**
|
||||
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/27-startvirtualcam-verknuepft-OBS.jpg)
|
||||
Alt: „Screenshot der Windows-Eigenschaften einer OBS-Desktop-Verknüpfung mit --startvirtualcam-Parameter"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **28-shell-startup.png**
|
||||
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/28-shell-startup.png)
|
||||
Alt: „Screenshot des Windows-Run-Dialogs mit Befehl 'shell:startup' zum Öffnen des Autostart-Ordners"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **v412-ctl-fehlermeldung.png**
|
||||
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/v412-ctl-fehlermeldung.png)
|
||||
Alt: „Terminal-Screenshot der v4l2-ctl-Fehlermeldung beim Öffnen des Video-Gerätes"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **virtueller-desktop-titelbild.jpg**
|
||||
🖼 [Bild](../content/posts/2022-03-19-OB-virtualcam/virtueller-desktop-titelbild.jpg)
|
||||
Alt: „Stilisiertes Titelbild: virtueller Desktop-Arbeitsplatz mit mehreren Bildschirmen und Remote-Verbindung"
|
||||
**NOTIZ:**
|
||||
|
||||
---
|
||||
|
||||
## 2023-02-26 — jojos-schoko-zimt-schnecken
|
||||
📝 [Frontmatter](../content/posts/2023-02-26-jojos-schoko-zimt-schnecken/index.md)
|
||||
|
||||
- [ ] **schneckennudeln-titel.jpg** (Cover)
|
||||
🖼 [Bild](../content/posts/2023-02-26-jojos-schoko-zimt-schnecken/schneckennudeln-titel.jpg)
|
||||
Alt: „Goldbraun gebackene Hefeschnecken in einer Kuchenform, Titelbild des Rezepts"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **Hefeteig-mit-Fuellung.jpg**
|
||||
🖼 [Bild](../content/posts/2023-02-26-jojos-schoko-zimt-schnecken/Hefeteig-mit-Fuellung.jpg)
|
||||
Alt: „Ausgerollter Hefeteig, bestrichen mit cremiger Kakao-Zimt-Zucker-Füllung, bereit zum Einrollen"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **16-Schneckennudeln.jpg**
|
||||
🖼 [Bild](../content/posts/2023-02-26-jojos-schoko-zimt-schnecken/16-Schneckennudeln.jpg)
|
||||
Alt: „16 dicht an dicht aufgestellte, rohe Hefeschnecken in einer runden Kuchenform"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **hefeschnecken-in-capelle-backform.jpg**
|
||||
🖼 [Bild](../content/posts/2023-02-26-jojos-schoko-zimt-schnecken/hefeschnecken-in-capelle-backform.jpg)
|
||||
Alt: „Gegangene, mit Eimilch bestrichene Hefeschnecken in Kapellen-Backform, bereit für den Ofen"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **schneckennudeln-im-ofen.jpg**
|
||||
🖼 [Bild](../content/posts/2023-02-26-jojos-schoko-zimt-schnecken/schneckennudeln-im-ofen.jpg)
|
||||
Alt: „Hefeschnecken im Ofen während des Backens, Oberseite beginnt goldbraun zu werden"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **schneckennudeln-fertig.jpg**
|
||||
🖼 [Bild](../content/posts/2023-02-26-jojos-schoko-zimt-schnecken/schneckennudeln-fertig.jpg)
|
||||
Alt: „Fertig gebackene, goldbraune Hefeschnecken in der Kuchenform, bereit zum Servieren"
|
||||
**NOTIZ:**
|
||||
|
||||
---
|
||||
|
||||
## 2023-03-23 — saemann (Midjourney, CC BY-SA 3.0 DE)
|
||||
📝 [Frontmatter](../content/posts/2023-03-23-saemann/index.md)
|
||||
|
||||
- [ ] **saemann-title.jpg** (Cover, Collage)
|
||||
🖼 [Bild](../content/posts/2023-03-23-saemann/saemann-title.jpg)
|
||||
Alt: „Titelbild zum Gleichnis vom Sämann: Collage der fünf KI-generierten Illustrationen im Stil von Eric Carle"
|
||||
Modifications: „Collage aus Midjourney-generierten Bildern im Stil von Eric Carle, Prompts siehe Artikel"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **bild1-saemann.jpeg**
|
||||
🖼 [Bild](../content/posts/2023-03-23-saemann/bild1-saemann.jpeg)
|
||||
Alt: „Illustration im Stil von Eric Carle: Ein freundlicher Bauer streut Samen in einem offenen Feld, im Hintergrund vier Böden — felsig, dornig, vogelreich und fruchtbar"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **bild1-alternativ-saemann.jpeg**
|
||||
🖼 [Bild](../content/posts/2023-03-23-saemann/bild1-alternativ-saemann.jpeg)
|
||||
Alt: „Alternative Illustration im Stil von Eric Carle: Bauer beim Säen mit verschiedenen Bodenarten im Hintergrund"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **bild2-saemann.jpeg**
|
||||
🖼 [Bild](../content/posts/2023-03-23-saemann/bild2-saemann.jpeg)
|
||||
Alt: „Illustration im Stil von Eric Carle: Kleine, schwache Pflanzen, die mit wenig Erde auf felsigem Boden zu wachsen beginnen"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **bild2-alternativ-saemann.jpeg**
|
||||
🖼 [Bild](../content/posts/2023-03-23-saemann/bild2-alternativ-saemann.jpeg)
|
||||
Alt: „Alternative Illustration im Stil von Eric Carle: Keimende Pflanzen auf steinigem Grund"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **bild3-saemann.jpeg**
|
||||
🖼 [Bild](../content/posts/2023-03-23-saemann/bild3-saemann.jpeg)
|
||||
Alt: „Illustration im Stil von Eric Carle: Junge Pflanzen werden von Dornen umklammert und erstickt"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **bild4-saemann.jpeg**
|
||||
🖼 [Bild](../content/posts/2023-03-23-saemann/bild4-saemann.jpeg)
|
||||
Alt: „Illustration im Stil von Eric Carle: Fröhliche Vögel picken Samen vom Boden und fressen sie, bevor sie keimen können"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **bild5-saemann.jpeg**
|
||||
🖼 [Bild](../content/posts/2023-03-23-saemann/bild5-saemann.jpeg)
|
||||
Alt: „Illustration im Stil von Eric Carle: Große, gesunde Pflanzen tragen reiche Früchte auf fruchtbarem Boden, der Bauer steht lächelnd daneben"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **screen-chatgpt-saemann.png**
|
||||
🖼 [Bild](../content/posts/2023-03-23-saemann/screen-chatgpt-saemann.png)
|
||||
Alt: „Screenshot des ChatGPT-Dialogs: Eingabe der Anfrage zum Gleichnis vom Sämann für einen 8-Jährigen und KI-generierte Antwort in fünf Bildbeschreibungen"
|
||||
**NOTIZ:**
|
||||
|
||||
---
|
||||
|
||||
## 2023-04-07 — Dampfnudeln
|
||||
📝 [Frontmatter](../content/posts/2023-04-07-Dampfnudeln/index.md)
|
||||
|
||||
- [ ] **Hefefreuden.jpg** (Cover)
|
||||
🖼 [Bild](../content/posts/2023-04-07-Dampfnudeln/Hefefreuden.jpg)
|
||||
Alt: „Titelbild: Dampfnudeln und Hefezopf auf einem Tisch, frisch aus Dampfgarer und Ofen"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **Hefeteig.jpg**
|
||||
🖼 [Bild](../content/posts/2023-04-07-Dampfnudeln/Hefeteig.jpg)
|
||||
Alt: „Aufgegangener Hefeteig in einer Rührschüssel, glatt und elastisch, nach 30 Minuten Ruhezeit"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **Dampfnudeln-auf-Lochblech.jpg**
|
||||
🖼 [Bild](../content/posts/2023-04-07-Dampfnudeln/Dampfnudeln-auf-Lochblech.jpg)
|
||||
Alt: „Sechs runde Hefeteigstücke zum Dampfgaren auf einem gelochten Dampfgarblech"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **Dampfnudeln-im-Dampfgarer.jpg**
|
||||
🖼 [Bild](../content/posts/2023-04-07-Dampfnudeln/Dampfnudeln-im-Dampfgarer.jpg)
|
||||
Alt: „Gegarte, aufgegangene Dampfnudeln im geöffneten Dampfgarer, glänzend und flaumig"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **Dampfnudel-mit-Vanillesosse.jpg**
|
||||
🖼 [Bild](../content/posts/2023-04-07-Dampfnudeln/Dampfnudel-mit-Vanillesosse.jpg)
|
||||
Alt: „Dampfnudel auf Teller angerichtet, übergossen mit goldgelber Vanillesoße"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **Hefezopf.jpg**
|
||||
🖼 [Bild](../content/posts/2023-04-07-Dampfnudeln/Hefezopf.jpg)
|
||||
Alt: „Frisch gebackener, dreifach geflochtener Hefezopf, goldbraun glänzend nach dem Einpinseln mit Ei"
|
||||
**NOTIZ:**
|
||||
|
||||
---
|
||||
|
||||
## 2023-07-25 — wordpress-statt-padlet-oder-taskcards
|
||||
📝 [Frontmatter](../content/posts/2023-07-25-wordpress-statt-padlet-oder-taskcards/index.md)
|
||||
|
||||
- [ ] **wordpress-horizontales-scrollen.gif** (Cover)
|
||||
🖼 [Bild](../content/posts/2023-07-25-wordpress-statt-padlet-oder-taskcards/wordpress-horizontales-scrollen.gif)
|
||||
Alt: „Animierter Screenshot: WordPress-Seite mit horizontal scrollbaren Spalten, die Beiträge im Kanban-Stil nebeneinander zeigen"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **spalten-als-posts-block.png**
|
||||
🖼 [Bild](../content/posts/2023-07-25-wordpress-statt-padlet-oder-taskcards/spalten-als-posts-block.png)
|
||||
Alt: „Screenshot des Stackable 'posts block'-Plugins in WordPress mit Spaltenansicht nach Kategorien"
|
||||
**NOTIZ:**
|
||||
|
||||
- [ ] **posts-per-drag-and-drop-sortieren.png**
|
||||
🖼 [Bild](../content/posts/2023-07-25-wordpress-statt-padlet-oder-taskcards/posts-per-drag-and-drop-sortieren.png)
|
||||
Alt: „Screenshot der WordPress-Beitragsliste mit aktiviertem 'Intuitive Custom Post Order'-Plugin — Beiträge werden per Drag & Drop sortiert"
|
||||
**NOTIZ:**
|
||||
|
||||
---
|
||||
|
||||
## 2024-01-16 — offenheit-das-wesentliche (Midjourney, CC0)
|
||||
📝 [Frontmatter](../content/posts/2024-01-16-offenheit-das-wesentliche/index.md)
|
||||
|
||||
- [ ] **offenheit-wesentlich.png** (Cover)
|
||||
🖼 [Bild](../content/posts/2024-01-16-offenheit-das-wesentliche/offenheit-wesentlich.png)
|
||||
Alt: „KI-generierte Aquarell-Illustration: Silhouetten von Menschen aller Geschlechter und Altersgruppen, die ineinander übergehen und sich überlappen — Symbol einer Community of Trust"
|
||||
Modifications: „KI-generiert mit Midjourney v6.0, Prompt: A Community of Trust based on Openness, silhouettes of people of all genders and ages that merge into each other and overlap, watercolors --v 6.0 --seed 1235164279"
|
||||
**NOTIZ:**
|
||||
|
||||
---
|
||||
|
||||
## 2024-03-05 — bottomup-markdown
|
||||
📝 [Frontmatter](../content/posts/2024-03-05-bottomup-markdown/index.md)
|
||||
|
||||
- [ ] **bottomup-markdown.png** (Cover)
|
||||
🖼 [Bild](../content/posts/2024-03-05-bottomup-markdown/bottomup-markdown.png)
|
||||
Alt: „Titelbild zur OER-Camp-Session 'BottomUp MarkDown' — Symbol für die 5V-Freiheiten von Open Content in Verbindung mit der Markdown-Sprache"
|
||||
**NOTIZ:**
|
||||
|
||||
---
|
||||
|
||||
## 2024-04-03 — kibedenken-bewusstsein (Midjourney, CC0)
|
||||
📝 [Frontmatter](../content/posts/2024-04-03-kibedenken-bewusstsein/index.md)
|
||||
|
||||
- [ ] **kibedenken.png** (Cover)
|
||||
🖼 [Bild](../content/posts/2024-04-03-kibedenken-bewusstsein/kibedenken.png)
|
||||
Alt: „Ein junger Roboterjunge mit gesenktem Kopf betrachtet seine Spiegelung im Wasser, im fotorealistischen Stil einer Canon EOS 5D Mark IV"
|
||||
Caption: „Referenziert auf Narziss aus der griechischen Mythologie und die Illustration von Caravaggio (siehe [Wikipedia #Narziss](https://de.wikipedia.org/wiki/Narziss#))"
|
||||
Modifications: „KI-generiert mit Midjourney v6.0, Prompt: photographed with the Canon EOS 5D Mark IV a young robot boy with his head down, looking at his reflection in water --v6.0"
|
||||
**NOTIZ:**
|
||||
|
||||
---
|
||||
|
||||
## 2025-03-04 — dezentrale-oep-oer (3 Autoren, CC BY 4.0)
|
||||
📝 [Frontmatter](../content/posts/2025-03-04-dezentrale-oep-oer/index.md)
|
||||
|
||||
- [ ] **dezentrale-oep-oer.png** (Cover)
|
||||
🖼 [Bild](../content/posts/2025-03-04-dezentrale-oep-oer/dezentrale-oep-oer.png)
|
||||
Alt: „Ein in den Sand gezeichneter Strauß mit den Buchstaben 'OER' — Sinnbild für offene Bildung und freien Wissensaustausch, gleichzeitig Wortspiel-Verbindung zu Nostr (Ostrich = Strauß)"
|
||||
Caption: „Analog zum Ichthys-Fisch als geheimem Erkennungszeichen: Symbol einer Gemeinschaft, die Wissen offen, unabhängig und widerstandsfähig teilt"
|
||||
Autoren: Jörg Lohrer, Steffen Rörtgen, Bastian Granas
|
||||
**NOTIZ:**
|
||||
|
||||
---
|
||||
|
||||
## Globale Anmerkungen / Änderungswünsche
|
||||
|
||||
_Alles was nicht bildspezifisch ist (Lizenz-Defaults, Regeln für UNKNOWN, Generalvorschläge) kann hier rein:_
|
||||
|
||||
**NOTIZ:**
|
||||
|
||||
---
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
- **91 Bilder** in 18 Posts
|
||||
- **1 Post** ohne lokale Bilder (Erlebnispädagogik, tote Amazon-Hotlinks)
|
||||
- **4 UNKNOWN-Einträge** zur Recherche (alle im VR-Post)
|
||||
- **1 Fremdbild** (Flickr, CC BY-NC-SA, Raupe)
|
||||
- **1 teilfremdes Bild** (Sketchfab, CC BY-NC, Fotograf UNKNOWN)
|
||||
- **Rest Eigenaufnahmen** (CC0 oder CC BY-SA)
|
||||
|
|
@ -1,10 +1,8 @@
|
|||
# Publish-Pipeline für Nostr-Events — Design-Spec
|
||||
|
||||
**Datum:** 2026-04-15 (aktualisiert 2026-04-16: Blossom für alle Bilder, kein All-Inkl-rsync-Pfad mehr)
|
||||
**Datum:** 2026-04-15
|
||||
**Status:** Entwurf, ausstehende User-Freigabe
|
||||
**Scope:** Toolchain, die Markdown-Posts aus `content/posts/*/index.md` in signierte Nostr-Events (`kind:30023`, NIP-23) umwandelt, zu Relays publiziert, und die zugehörigen Bilder zu Blossom hochlädt.
|
||||
|
||||
**Designentscheidung 2026-04-16:** Alle Bilder (auch die der 18 Altposts) werden zu Blossom hochgeladen. Kein rsync-Legacy-Pfad, kein `image_source`-Flag im Frontmatter. Die SPA rendert alle Posts über denselben Code-Pfad (Event-Text → Bild-URLs aus Blossom). Repo = Source-of-Truth für Content, Pipeline = Nostr-Export-Routine.
|
||||
**Scope:** Toolchain, die Markdown-Posts aus `content/posts/*/index.md` in signierte Nostr-Events (`kind:30023`, NIP-23) umwandelt, zu Relays publiziert, und die zugehörigen Bilder zum Asset-Host (All-Inkl für Altposts, Blossom für neue) hochlädt.
|
||||
|
||||
Diese Spec ist die Schwester-Spec zu [`2026-04-15-nostr-page-design.md`](2026-04-15-nostr-page-design.md) und teilt sich mit ihr den Event-Kontrakt für `kind:30023` und die Konfiguration über `kind:10002` / `kind:10063`.
|
||||
|
||||
|
|
@ -36,22 +34,23 @@ Diese Spec ist die Schwester-Spec zu [`2026-04-15-nostr-page-design.md`](2026-04
|
|||
│ (Git-Diff oder force) │
|
||||
│ 3. Pro Post: │
|
||||
│ a. Frontmatter parsen │
|
||||
│ b. Bilder aus Ordner → │
|
||||
│ Blossom upload │
|
||||
│ c. Markdown body: bild- │
|
||||
│ pfade → Blossom-URLs │
|
||||
│ b. Markdown transform │
|
||||
│ c. Bilder upload │
|
||||
│ (legacy/blossom) │
|
||||
│ d. Event bauen │
|
||||
│ e. Via NIP-46 signieren │
|
||||
│ f. Zu Relays pushen │
|
||||
└──────┬──────────────────────┘
|
||||
│
|
||||
┌──────────┼──────────────┐
|
||||
▼ ▼ ▼
|
||||
Amber Public Blossom-
|
||||
(NIP-46 Nostr- Server
|
||||
Signer Relays aus kind:10063
|
||||
via aus (primal,
|
||||
Relay) kind:10002 später eigener)
|
||||
┌──────────┼──────────────┬──────────────┐
|
||||
▼ ▼ ▼ ▼
|
||||
Amber Public Blossom- All-Inkl
|
||||
(NIP-46 Nostr- Server (rsync
|
||||
Signer Relays (primal, over SSH,
|
||||
via aus später eigen) Altbilder
|
||||
Relay) kind:10002 aus der 18
|
||||
kind:10063) Migrations-
|
||||
posts)
|
||||
```
|
||||
|
||||
### Kernprinzipien
|
||||
|
|
@ -153,14 +152,34 @@ Einmalig manuell publizieren. Phase-1-Inhalt: ein Server.
|
|||
|
||||
Phase-5-Erweiterung (eigener Blossom-Server): zusätzliches `["server", "https://blossom.joerg-lohrer.de"]` wird vorne in die Liste aufgenommen, neues Event publiziert.
|
||||
|
||||
### 2.5 `deno task check`
|
||||
### 2.5 SSH-Deploy-Key für All-Inkl
|
||||
|
||||
1. Lokal Keypair erzeugen, **dediziert für Deploys**, nicht persönlicher SSH-Key:
|
||||
```
|
||||
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_joerglohrerde_deploy -C "deploy-joerglohrerde"
|
||||
```
|
||||
Ohne Passphrase (CI braucht non-interactive Zugang).
|
||||
2. Public-Key-Inhalt (`*.pub`) in All-Inkl-KAS unter „SSH-Zugänge" → „Authorized Keys" eintragen.
|
||||
3. Verbindung testen: `ssh -i ~/.ssh/id_ed25519_joerglohrerde_deploy w00xxxxx@ssh.all-inkl.com`
|
||||
4. Private-Key bereitstellen:
|
||||
- **Lokal:** liegt in `~/.ssh/` und wird von rsync automatisch genutzt.
|
||||
- **CI:** als GitHub-Actions-Secret `SSH_DEPLOY_KEY` (Inhalt der privaten Key-Datei). Im Workflow wird er in `~/.ssh/id_ed25519` gechrieben und `chmod 600` gesetzt.
|
||||
|
||||
### 2.6 All-Inkl Deploy-Root
|
||||
|
||||
Nach Tarifwechsel auf Premium: Pfad im KAS unter „Dateiverwaltung" ablesen. Typisch: `w00xxxxx@ssh.all-inkl.com:joerg-lohrer.de/`.
|
||||
|
||||
- **Lokal:** in `.env` als `ALLINKL_DEPLOY_ROOT`
|
||||
- **CI:** als GitHub-Actions-Secret
|
||||
|
||||
### 2.7 `deno task check`
|
||||
|
||||
Dieser Subcommand verifiziert alle obigen Punkte:
|
||||
|
||||
- `BUNKER_URL` gesetzt, Bunker antwortet auf Ping, Pubkey stimmt mit `AUTHOR_PUBKEY_HEX` überein.
|
||||
- `kind:10002` auf Bootstrap-Relay gefunden, mindestens 1 Relay eingetragen.
|
||||
- `kind:10063` auf Bootstrap-Relay gefunden, mindestens 1 Server eingetragen.
|
||||
- Blossom-Server aus `kind:10063` antwortet auf HEAD / (Healthcheck).
|
||||
- SSH-Verbindung zu `ALLINKL_DEPLOY_ROOT` erfolgreich (`ssh ... echo ok`).
|
||||
- Deno-Version und benötigte Permissions.
|
||||
|
||||
Bei jedem Fehler: klare Text-Meldung, was zu tun ist (z. B. „kind:10002 fehlt — publiziere es manuell mit folgendem Schema: ...").
|
||||
|
|
@ -230,47 +249,72 @@ Slug kommt als **lowercase String** aus dem Frontmatter-Feld `slug:`. Ist bereit
|
|||
|
||||
### 4.2 Bild-URL-Transformation
|
||||
|
||||
Ziel: alle relativen Bild-Referenzen im Markdown-Body werden durch Blossom-URLs ersetzt. Ablauf:
|
||||
Ziel: alle relativen Bild-Referenzen im Markdown-Body werden zu absoluten URLs.
|
||||
|
||||
1. Pipeline sammelt alle Bilder aus dem Post-Ordner (Datei-Scan nach gängigen Bild-Extensions).
|
||||
2. Jedes Bild wird zu allen Servern aus `kind:10063` hochgeladen (siehe §5).
|
||||
3. Blossom liefert eine hash-basierte URL zurück (Format: `<server>/<sha256>` oder `<server>/<sha256>.<ext>`).
|
||||
4. Pipeline baut eine Mapping-Tabelle `<dateiname> → <blossom-url>`.
|
||||
5. Markdown-Body wird traversiert, alle erkannten Bild-Patterns werden ersetzt:
|
||||
- `` → ``
|
||||
- `[](link)` → `[](link)`
|
||||
- `` → `` (Größen-Suffix entfernt; SPA skaliert per CSS)
|
||||
6. Wenn `filename` bereits ein Schema enthält (`http://`, `https://`, `//`), bleibt die URL unverändert — ist schon absolut.
|
||||
**Erkannte Muster:**
|
||||
- `` — reguläre Markdown-Bild-Syntax.
|
||||
- `[](link)` — Bild-in-Link-Konstrukt.
|
||||
- `` — mit Größen-Suffix (Obsidian/PaperMod-Erweiterung).
|
||||
|
||||
**Konsequenz:** Es gibt nur **einen** Upload-Pfad (Blossom). Kein Legacy-Pfad mehr. Kein `image_source`-Flag, keine Datum-basierten URL-Strukturen.
|
||||
**Regeln:**
|
||||
1. Wenn `filename` ein Schema enthält (`http://`, `https://`, `//`), nicht transformieren — ist schon absolut.
|
||||
2. Ansonsten zu absoluter URL machen; URL-Kodierung pro Pfad-Segment via `encodeURIComponent()`.
|
||||
3. `=WxH`-Suffix entfernen; die SPA skaliert Bilder per CSS responsiv.
|
||||
|
||||
### 4.3 Cover-Image-Tag
|
||||
**Basis-URL je nach `image_source`-Frontmatter:**
|
||||
|
||||
Das `image`-Tag im Event (für Listen-Previews/OG-Vorschau in Nostr-Clients) kommt aus dem Frontmatter:
|
||||
- Wenn `image_source: legacy` → `https://joerg-lohrer.de/<YYYY>/<MM>/<DD>/<dtag>.html/<encoded-filename>`
|
||||
- `YYYY/MM/DD` aus `date:`-Frontmatter, nicht aus dem Signatur-Zeitpunkt.
|
||||
- `<dtag>` ist identisch mit `slug`.
|
||||
- Wenn `image_source` fehlt oder `image_source: blossom` → Blossom-URL; siehe Abschnitt 5.
|
||||
|
||||
### 4.3 `image_source`-Flag
|
||||
|
||||
**Einmaliger Migrationsschritt (vor erstem Publish-Lauf):** Die 18 Altposts bekommen `image_source: legacy` ins Frontmatter geschrieben. Das ist ein separater Commit, kein Pipeline-Feature.
|
||||
|
||||
**Neue Posts:** kein Flag nötig, Default = `blossom`. Wenn ein zukünftiger Post explizit auf All-Inkl zeigen soll (außergewöhnlich), kann `image_source: legacy` gesetzt werden.
|
||||
|
||||
### 4.4 Cover-Image-Tag
|
||||
|
||||
Das `image`-Tag im Event (für Listen-Previews/OG-Vorschau in Nostr-Clients) kommt aus dem Frontmatter (nicht aus dem Markdown-Body):
|
||||
|
||||
- Quelle: `cover.image:` (Hugo-Page-Bundle-Konvention); Fallback `image:` auf Top-Level.
|
||||
- Ist typischerweise ein relativer Dateiname, der als Bild auch im Post-Ordner liegt und damit ohnehin zu Blossom hochgeladen wird.
|
||||
- Wird nach dem Upload über die Mapping-Tabelle auf die Blossom-URL umgeschrieben.
|
||||
- Wenn der Wert bereits absolut ist (http/https), bleibt er unverändert.
|
||||
- Ist typischerweise ein relativer Dateiname.
|
||||
- Wird durch denselben URL-Bauer wie die Body-Bilder geschickt (Abschnitt 4.2), aber der Input ist ein direkter Dateiname aus YAML, nicht aus Markdown-Syntax. Keine `=WxH`-Suffix-Erkennung nötig.
|
||||
- Ergebnis: absolute URL gemäß `image_source`-Policy.
|
||||
|
||||
---
|
||||
|
||||
## 5. Upload-Pfad
|
||||
## 5. Upload-Pfade
|
||||
|
||||
### 5.1 Blossom-Upload (einheitlich für alle Posts)
|
||||
### 5.1 Legacy-Upload (All-Inkl)
|
||||
|
||||
Betrifft: alle Bilder aller Posts, ohne Unterscheidung zwischen Alt- und Neu-Post.
|
||||
Betrifft: die 18 Altposts, Bilder darin.
|
||||
|
||||
**Mechanik:** `rsync` over SSH via `Deno.Command("rsync", [...])`.
|
||||
|
||||
**Befehlsschema:**
|
||||
|
||||
```
|
||||
rsync -avz --no-perms --no-times \
|
||||
-e "ssh -i $DEPLOY_KEY_PATH -o StrictHostKeyChecking=accept-new" \
|
||||
<post-folder>/*.{png,jpg,jpeg,gif,webp,svg} \
|
||||
$ALLINKL_DEPLOY_ROOT<YYYY>/<MM>/<DD>/<dtag>.html/
|
||||
```
|
||||
|
||||
- **Idempotent:** rsync überträgt nur neue/geänderte Dateien.
|
||||
- **Nicht-löschend:** ohne `--delete`. Alte Bilder bleiben auf dem Server liegen, keine automatische Bereinigung. Manueller Aufräum-Bedarf wird hingenommen (Tote Dateien verursachen keinen Schaden, Storage ist billig).
|
||||
- **Zielordner erzeugen:** rsync legt fehlende Ordner per `--mkpath` oder (wenn Version zu alt) per vorgeschaltetem `ssh ... mkdir -p` an.
|
||||
|
||||
**Neuer Post-Edit mit alten Bildern:** falls jemand mal einen Post editiert, der `image_source: legacy` hat und neue Bilder hinzufügt → diese werden auch zu All-Inkl geschoben. Das ist okay. Das Flag steuert nur den URL-Basispfad, nicht die Intention „nie wieder All-Inkl".
|
||||
|
||||
### 5.2 Blossom-Upload
|
||||
|
||||
Betrifft: alle neuen Posts (`image_source: blossom` oder fehlend).
|
||||
|
||||
**Mechanik:** BUD-01 HTTP-Upload zu allen Servern aus `kind:10063`-Liste, parallel.
|
||||
|
||||
**Ablauf pro Post:**
|
||||
|
||||
1. Alle Dateien im Post-Ordner mit Bild-Extensions (`.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.svg`) sammeln.
|
||||
2. Hugo-generierte Resize-Varianten (`*_hu_*.png` etc.) werden **ignoriert** — das sind Derivate, keine Originale. Nur die Originaldateien, wie sie im Markdown referenziert werden, zählen.
|
||||
3. Pro Bild SHA-256 berechnen, zu allen Servern parallel hochladen.
|
||||
4. Mapping `<filename> → <primary-blossom-url>` aufbauen (primär = erster Server aus Liste).
|
||||
|
||||
**Schritte pro Bild (intern):**
|
||||
**Schritte pro Bild:**
|
||||
|
||||
1. SHA256-Hash der Datei berechnen.
|
||||
2. Authorization-Event (`kind:24242`) bauen und via Bunker signieren (enthält Hash, Verb `upload`, Expiration).
|
||||
|
|
@ -285,17 +329,6 @@ Betrifft: alle Bilder aller Posts, ohne Unterscheidung zwischen Alt- und Neu-Pos
|
|||
|
||||
**Retry:** 2 Versuche pro Server mit exponentiellem Backoff.
|
||||
|
||||
**Idempotenz:** Blossom dedupliziert per SHA-256. Ein erneuter Upload derselben Datei ist ein No-Op (Server antwortet 200 mit derselben URL). Daher ist wiederholtes `--force-all` unproblematisch.
|
||||
|
||||
### 5.2 Kein Legacy-Upload mehr
|
||||
|
||||
Frühere Versionen dieser Spec sahen einen rsync-Pfad zu All-Inkl für Altposts vor. Das ist entfallen. Begründung:
|
||||
|
||||
- Repo ist Source-of-Truth; alle Bilder liegen in `content/posts/<ordner>/`.
|
||||
- Einheitlicher Render-Pfad in der SPA (keine Sonderlogik für Altposts).
|
||||
- Blossom dedupliziert per Hash; wiederholter Upload ist billig.
|
||||
- Nach Cutover verwaisen die alten `joerg-lohrer.de/YYYY/MM/DD/…`-URLs — das ist akzeptiert, da sie nur in der weggehenden Hugo-Site referenziert sind.
|
||||
|
||||
---
|
||||
|
||||
## 6. Change-Detection und Workflow
|
||||
|
|
@ -373,9 +406,13 @@ Pro Post wird das signierte Event an alle Relays aus der `kind:10002`-Liste para
|
|||
|
||||
### 7.2 Blossom-Upload
|
||||
|
||||
Siehe Abschnitt 5.1. Pro Server 2 Retries, mindestens 1 Server muss akzeptieren.
|
||||
Siehe Abschnitt 5.2. Pro Server 2 Retries, mindestens 1 Server muss akzeptieren.
|
||||
|
||||
### 7.3 Bunker-Signing
|
||||
### 7.3 Legacy-Upload
|
||||
|
||||
rsync-Aufruf wird bei Exit-Code != 0 einmal wiederholt (1 Retry, 3 s Pause). Bleibt der Aufruf fehlerhaft, wird der Post als failed markiert und die Pipeline fährt mit dem nächsten fort.
|
||||
|
||||
### 7.4 Bunker-Signing
|
||||
|
||||
- Timeout 30 Sekunden pro Signatur-Request (Handy-Wake-up berücksichtigen).
|
||||
- 1 Retry bei Timeout.
|
||||
|
|
@ -433,6 +470,7 @@ publish/
|
|||
│ │ ├── signer.ts # NIP-46 Bunker-Wrapper
|
||||
│ │ ├── relays.ts # loadOutboxRelays, publishEvent
|
||||
│ │ ├── blossom.ts # loadServerList, uploadBlob
|
||||
│ │ ├── legacy-upload.ts # rsync SSH wrapper
|
||||
│ │ ├── change-detection.ts # gitDiff, allPostFiles, forceMode
|
||||
│ │ └── log.ts # structured logger + JSON writer
|
||||
│ └── subcommands/
|
||||
|
|
@ -510,18 +548,25 @@ jobs:
|
|||
with:
|
||||
deno-version: v2.x
|
||||
|
||||
- name: Setup SSH-Deploy-Key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.SSH_DEPLOY_KEY }}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan ssh.all-inkl.com >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Pre-Flight Check
|
||||
env:
|
||||
BUNKER_URL: ${{ secrets.BUNKER_URL }}
|
||||
ALLINKL_DEPLOY_ROOT: ${{ secrets.ALLINKL_DEPLOY_ROOT }}
|
||||
AUTHOR_PUBKEY_HEX: ${{ secrets.AUTHOR_PUBKEY_HEX }}
|
||||
BOOTSTRAP_RELAY: ${{ secrets.BOOTSTRAP_RELAY }}
|
||||
run: deno task check
|
||||
|
||||
- name: Publish
|
||||
env:
|
||||
BUNKER_URL: ${{ secrets.BUNKER_URL }}
|
||||
ALLINKL_DEPLOY_ROOT: ${{ secrets.ALLINKL_DEPLOY_ROOT }}
|
||||
AUTHOR_PUBKEY_HEX: ${{ secrets.AUTHOR_PUBKEY_HEX }}
|
||||
BOOTSTRAP_RELAY: ${{ secrets.BOOTSTRAP_RELAY }}
|
||||
GITHUB_EVENT_BEFORE: ${{ github.event.before }}
|
||||
run: |
|
||||
if [ "${{ inputs.force_all }}" = "true" ]; then
|
||||
|
|
@ -549,7 +594,7 @@ Diese Publish-Pipeline und die SPA sind komplementär, aber voneinander entkoppe
|
|||
- `kind:30023` Event-Schema — Publish produziert, SPA konsumiert.
|
||||
- `kind:10002` Relay-Liste — Publish liest, SPA liest.
|
||||
- `kind:10063` Blossom-Liste — Publish liest beim Upload, SPA liest für Bild-Fallback (zukünftig).
|
||||
- Alle Bild-URLs zeigen auf Blossom (hash-basiert) — einheitlich für alle Posts.
|
||||
- Bild-URL-Konvention für Altposts `/YYYY/MM/DD/<dtag>.html/<file>` — Publish schreibt, SPA erwartet.
|
||||
|
||||
**Unabhängige Entwicklung möglich:**
|
||||
|
||||
|
|
@ -559,7 +604,7 @@ Diese Publish-Pipeline und die SPA sind komplementär, aber voneinander entkoppe
|
|||
**Abhängigkeit beim Cutover (SPA-Migrationsschritte C + D):**
|
||||
|
||||
- SPA kann erst live gehen, wenn die 18 Altposts als Events auf Relays liegen.
|
||||
- **Schritt C** der SPA-Migration bedeutet konkret: einmaliger lokaler Lauf `deno task publish --force-all` mit dem vollständigen Altbestand. Dieser Schritt liegt zeitlich **vor** Schritt D (dem tatsächlichen Cutover der Hauptdomain).
|
||||
- **Schritt C** der SPA-Migration bedeutet konkret: einmaliger lokaler Lauf `deno task publish --force-all` mit dem vollständigen Altbestand. Dieser Schritt liegt zeitlich **vor** Schritt D (dem tatsächlichen Cutover auf All-Inkl).
|
||||
- Voraussetzung ist, dass die Publish-Pipeline zu diesem Zeitpunkt vollständig implementiert und durch `deno task check` validiert ist.
|
||||
|
||||
**Laufender Betrieb:**
|
||||
|
|
@ -576,11 +621,12 @@ Diese Publish-Pipeline und die SPA sind komplementär, aber voneinander entkoppe
|
|||
|---|---|---|---|
|
||||
| Amber offline während CI | mittel | hoch (Pipeline bricht ab) | Clear Error; Nutzer retriggert manuell nachdem Handy verfügbar |
|
||||
| Bunker-Secret leakt (Repo-Secret) | niedrig | mittel | Secret rotierbar: in Amber Pairing löschen, neu pairen, Secret aktualisieren |
|
||||
| SSH-Deploy-Key leakt | niedrig | mittel | Dedicated Key, in All-Inkl-KAS revokebar |
|
||||
| `kind:10002` versehentlich überschrieben (Relay-Liste leer) | niedrig | hoch | check-Subcommand prüft vor jedem Run; Pipeline bricht bei leerer Liste ab |
|
||||
| Relay-Zensur (Events werden gelöscht) | niedrig | mittel | Multi-Relay-Push; zusätzlich bezahltes nostr.wine als Durability-Anker |
|
||||
| Git-Diff übersieht Post (Rebase, Force-Push) | niedrig | niedrig | `--force-all` als Fallback, dokumentiert |
|
||||
| Blossom-Server löscht Bild | mittel | mittel | Multi-Upload zu mehreren Servern sobald kind:10063 erweitert ist; `nak blossom mirror` als Ausgleich |
|
||||
| Blossom-Server komplett weg, kein Mirror | niedrig | hoch | eigener Blossom-Server auf Optiplex (Phase 5) als dauerhafter Anker |
|
||||
| Blossom-Server löscht Bild | mittel | mittel | Multi-Upload zu mehreren Servern sobald kind:10063 erweitert ist |
|
||||
| `encodeURIComponent` vs. All-Inkl Apache: URL-Matching fällt auseinander | niedrig | mittel | Tests gegen reale URLs; Normalisierungs-Regel (lowercase Slugs, ASCII-Filenames bevorzugt) |
|
||||
| Privater Schlüssel-Recovery | niedrig | **katastrophal** | Amber hat Backup-Mechanismus; `nsec` zusätzlich offline auf Hardware sichern |
|
||||
|
||||
---
|
||||
|
|
@ -590,7 +636,7 @@ Diese Publish-Pipeline und die SPA sind komplementär, aber voneinander entkoppe
|
|||
**Jetzt (Bunker-Stufe Amber, Phase 1 Blossom):**
|
||||
- Handy mit Amber als einziger Signer, online während Publish-Runs.
|
||||
- Ein Blossom-Server in `kind:10063` (primal).
|
||||
- Alle Bilder (auch die der 18 Altposts) auf Blossom.
|
||||
- Legacy-Bilder auf All-Inkl für die 18 Altposts.
|
||||
- Relay-Liste mit 4 Public-Relays.
|
||||
|
||||
**Bunker-Stufe Optiplex (sobald Proxmox-Container läuft):**
|
||||
|
|
@ -613,7 +659,7 @@ Diese Publish-Pipeline und die SPA sind komplementär, aber voneinander entkoppe
|
|||
- `deno task check` ohne Fehler.
|
||||
- 18 Altposts via einmaligem `deno task publish --force-all` publiziert.
|
||||
- Jeder Post in mindestens 2 Public-Relays abrufbar, in Habla.news korrekt gerendert.
|
||||
- Alle Bilder auf Blossom erreichbar (Hash-URL liefert die Datei).
|
||||
- Bilder der 18 Posts via `/YYYY/MM/DD/<dtag>.html/<bildname>` auf All-Inkl erreichbar.
|
||||
- Ein neuer Test-Post via CI auf `main`-Push publiziert in unter 90 Sekunden ab Push.
|
||||
- `publish-log.json` enthält aussagekräftige Einträge pro Post.
|
||||
- Pipeline läuft ohne nsec-Exposition in irgendeiner Umgebung.
|
||||
|
|
|
|||
|
|
@ -1,277 +0,0 @@
|
|||
# Konvention: Bild-Metadaten im Post-Frontmatter (Phase 1)
|
||||
|
||||
**Datum:** 2026-04-16
|
||||
**Status:** Phase-1-Minimal — fokussiert auf sichere Attribution und `alt`-Vollständigkeit. Caption-Rendering, Reverse-Routine, License-Katalog und strikte Validierung sind explizit Phase 2.
|
||||
**Scope:** YAML-Frontmatter-Schema für Bildmetadaten in Markdown-Posts. Wird von der Publish-Pipeline in `kind:30023`-Events (NIP-23) plus `imeta`-Tags (NIP-92) + `license`-Tag abgebildet.
|
||||
|
||||
## Ziele
|
||||
|
||||
1. **Sichere Attribution** — keine stille Fehlattribuierung. Fehlende Kenntnis wird explizit als `UNKNOWN` markiert, nie implizit geerbt.
|
||||
2. **Menschlich lesbares, minimal-invasives YAML** — Defaults kommen aus Env, Frontmatter enthält nur das Abweichende.
|
||||
3. **Blaupausen-Tauglichkeit** — funktioniert für beliebige Repos mit 1..n Autoren, Eigen- und Fremdbildern.
|
||||
4. **Eine Datenstruktur pro Konzept** — Cover ist nur ein Bild mit Rolle. Kein paralleler Schema-Zweig.
|
||||
|
||||
---
|
||||
|
||||
## 1. Post-Ebene
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: "Schokoschnecken"
|
||||
slug: "jojos-schoko-zimt-schnecken"
|
||||
date: 2023-02-26
|
||||
|
||||
# Lizenz des Post-TEXTES. Gilt NICHT automatisch für Bilder.
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
|
||||
# Text-Autoren. Weglassen, wenn DEFAULT_AUTHORS aus Env gelten soll.
|
||||
# Immer Array, auch bei einem Autor.
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
url: "https://joerg-lohrer.de/" # optional
|
||||
orcid: "..." # optional, frei erweiterbar
|
||||
---
|
||||
```
|
||||
|
||||
**Regeln:**
|
||||
|
||||
- `license` fehlt → Env-Default `DEFAULT_LICENSE` greift für den Text.
|
||||
- `authors` fehlt → Env-Default `DEFAULT_AUTHORS` greift für den Text.
|
||||
- **Diese Werte gelten ausschließlich für den Post-TEXT.** Für Bilder gibt es keine automatische Vererbung. Bilder haben eigene Lizenz- und Autor-Felder (siehe Abschnitt 2).
|
||||
|
||||
### 1.1 `date`
|
||||
|
||||
Erlaubtes Format: `YYYY-MM-DD` (wird als `00:00:00 UTC` interpretiert) oder ISO-8601 mit Uhrzeit (`YYYY-MM-DDTHH:MM:SSZ`). Zeitzone immer UTC, keine lokale TZ. Die Pipeline leitet daraus `published_at` (Unix-Sekunden) ab, stabil über Edits.
|
||||
|
||||
---
|
||||
|
||||
## 2. Bilder — einheitliche Liste
|
||||
|
||||
**Alle** Bilder eines Posts (Cover wie Body-Bilder) leben in einer einzigen `images`-Liste. Das Cover ist ein Bild mit `role: cover`.
|
||||
|
||||
```yaml
|
||||
images:
|
||||
# Cover-Bild
|
||||
- file: cover.jpg
|
||||
role: cover
|
||||
alt: "Goldbraune Hefeschnecken auf Kuchenblech, frisch gebacken"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
# Body-Bild, eigenes Foto
|
||||
- file: Hefeteig-mit-Fuellung.jpg
|
||||
alt: "Hefeteig mit Kakao-Zimt-Zucker-Füllung, ausgerollt auf Backpapier"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
# Body-Bild, Herkunft unklar (Altpost, noch zu recherchieren)
|
||||
- file: altes-bild.jpg
|
||||
alt: "Screenshot der Startseite eines Lern-Portals"
|
||||
license: UNKNOWN
|
||||
authors: UNKNOWN
|
||||
|
||||
# Body-Bild, Fremdbild mit vollen Angaben
|
||||
- file: fremdfoto.jpg
|
||||
alt: "Osterküken mit Osterei"
|
||||
authors:
|
||||
- name: "Vera Kratochvil"
|
||||
source_url: "https://www.publicdomainpictures.net/de/view-image.php?image=13188"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/"
|
||||
modifications: "beschnitten" # optional (das B in TULLU-BA)
|
||||
```
|
||||
|
||||
### 2.1 Feld-Referenz
|
||||
|
||||
| Feld | Pflicht | Wert | Semantik |
|
||||
|---|---|---|---|
|
||||
| `file` | ja | String | Dateiname relativ zum Post-Ordner. Datei muss existieren. |
|
||||
| `role` | nein | `cover` | Genau ein Bild pro Post darf `role: cover` haben. Dessen URL landet im Event-`image`-Tag. Kein `role` → Body-Bild. |
|
||||
| `alt` | ja | String | Accessibility-Beschreibung. Leerstring `""` ist erlaubt (Dekorationsbild), fehlendes Feld ist ein Validierungsfehler. |
|
||||
| `caption` | nein | String | Optionaler menschlicher Kontext (z. B. „Teig vor dem Einrollen"). Wird in Phase 1 nur in `imeta` als `caption`-Feld eingetragen. |
|
||||
| `license` | ja | URL \| `UNKNOWN` | Volle URL im schema.org-Stil **oder** `UNKNOWN` als expliziter Marker. Kein Inheritance. |
|
||||
| `authors` | ja | Array \| `UNKNOWN` | Array von `{name, url?, orcid?, ...}` **oder** `UNKNOWN`. Kein Inheritance. |
|
||||
| `source_url` | nein | URL | Originalquelle / Fundstelle des Bildes. |
|
||||
| `modifications` | nein | String | Freitext-Beschreibung einer Bearbeitung („beschnitten", „Kontrast angehoben", …). |
|
||||
|
||||
### 2.2 `UNKNOWN`-Semantik
|
||||
|
||||
`UNKNOWN` ist ein **einzelner** sauberer Marker — kein leeres Feld, kein `null`, kein Weglassen. Nutzen:
|
||||
|
||||
- Pipeline schreibt das Feld **nicht** in den `imeta`-Tag.
|
||||
- Pipeline **loggt eine Warnung** pro `UNKNOWN`-Vorkommen (mit Post-Slug + Dateiname) — dient als Recherche-Liste.
|
||||
- In Phase 1 ist `STRICT_MODE` default `false`: Events werden trotzdem publiziert.
|
||||
- In Phase 2 kann `STRICT_MODE=true` Events mit `UNKNOWN` blockieren.
|
||||
|
||||
### 2.3 Bilder im Body
|
||||
|
||||
Im Markdown-Body werden Bilder weiterhin schlicht referenziert:
|
||||
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
oder (für Migration tolerant):
|
||||
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
Der Alt-Text im Markdown ist **niedriger priorisiert** als `alt` aus `images[]`. Er dient nur als Fallback für Bilder, die nicht in `images[]` stehen.
|
||||
|
||||
**Reihenfolge:** `images[]` ist ein Metadaten-Lookup per `file`, **keine** Sequenz. Die YAML-Reihenfolge muss nicht der Body-Reihenfolge entsprechen. Die Pipeline sortiert für Log-Output alphabetisch nach `file`.
|
||||
|
||||
### 2.4 Body-Captions aus Altposts
|
||||
|
||||
Bestehende in-body-Captions (z. B. Lead-in-Sätze vor Bildern, italic-Attributionen nach Bildern) bleiben unberührt. Phase 1 injiziert **nichts** in den Body. Redundanz oder Entfernen ist eine Phase-2-Entscheidung.
|
||||
|
||||
---
|
||||
|
||||
## 3. Abbildung auf das Nostr-Event (kind:30023)
|
||||
|
||||
### 3.1 Pflicht- und Standard-Tags (NIP-23)
|
||||
|
||||
| Tag | Quelle |
|
||||
|---|---|
|
||||
| `["d", slug]` | Frontmatter `slug` |
|
||||
| `["title", title]` | Frontmatter `title` |
|
||||
| `["published_at", unix]` | Frontmatter `date` (stabil über Edits) |
|
||||
| `["summary", ...]` | Frontmatter `description` |
|
||||
| `["image", url]` | URL des Bildes mit `role: cover` nach Blossom-Upload |
|
||||
| `["t", tag]` | je ein Eintrag aus Frontmatter `tags[]` |
|
||||
|
||||
### 3.2 Lizenz und Autoren (Post-Text-Ebene)
|
||||
|
||||
| Tag | Quelle |
|
||||
|---|---|
|
||||
| `["license", url]` | Post-`license` (einmal pro Event, nur für Text-Lizenz) |
|
||||
| `["p", pubkey, relay-hint, role]` | optional, wenn Text-Autoren einen Nostr-Pubkey haben — Phase 2 |
|
||||
|
||||
Für Phase 1 wird **nur** der `license`-Tag des Post-Textes geschrieben.
|
||||
|
||||
### 3.3 `imeta`-Felder pro Bild (NIP-92 plus Extensions)
|
||||
|
||||
Pro hochgeladenem Bild ein Tag:
|
||||
|
||||
```
|
||||
["imeta",
|
||||
"url <blossom-url>",
|
||||
"m <mime>",
|
||||
"x <sha256>",
|
||||
"alt <alt>", // nur wenn nicht leer
|
||||
"caption <caption>", // nur wenn vorhanden
|
||||
"license <url>", // nur wenn konkrete URL (nicht UNKNOWN)
|
||||
"author <name>", // eins pro Autor, nur wenn konkret (nicht UNKNOWN)
|
||||
"source_url <url>", // nur wenn vorhanden
|
||||
"modifications <text>" // nur wenn vorhanden
|
||||
]
|
||||
```
|
||||
|
||||
**Regeln:**
|
||||
|
||||
- `url`, `m`, `x` sind Pflicht und kommen aus dem Blossom-Upload.
|
||||
- `UNKNOWN`-Werte werden **weggelassen** (kein Feld im Tag).
|
||||
- Leerer `alt` wird weggelassen.
|
||||
- Mehrere Autoren → mehrere `author`-Einträge im selben Tag.
|
||||
|
||||
### 3.4 NIP-89 `client`-Tag
|
||||
|
||||
Wenn Env `CLIENT_TAG` gesetzt ist: `["client", "<name>"]`. Default leer → kein Tag. Opt-in für Blaupausen, die Provenance markieren wollen.
|
||||
|
||||
### 3.5 Referenzen (`a`, `e`) — Phase 2
|
||||
|
||||
Aus optionalem Frontmatter `references:` (Array von `nostr:naddr…` / `nostr:nevent…`) werden `a`/`e`-Tags dekodiert. In Phase 1 nicht implementiert.
|
||||
|
||||
### 3.6 Body-Caption-Injektion — Phase 2
|
||||
|
||||
Automatische Injektion menschenlesbarer Attribution unter jedes Bild im Event-`content`. In Phase 1 nicht implementiert — reine `imeta`-Tags reichen für NIP-23-konforme Clients. Ob/wie in Phase 2 gebaut, wird anhand konkreter Client-Lücken entschieden.
|
||||
|
||||
### 3.7 Reverse-Routine — Phase 2
|
||||
|
||||
Rekonstruktion von strukturierten `images[]`-Einträgen aus nacktem Markdown mit injizierten Captions. In Phase 1 nicht benötigt.
|
||||
|
||||
---
|
||||
|
||||
## 4. Env-Defaults (Blaupause)
|
||||
|
||||
| Env | Default | Zweck |
|
||||
|---|---|---|
|
||||
| `DEFAULT_LICENSE` | `https://creativecommons.org/publicdomain/zero/1.0/deed.de` | Post-Text-Lizenz, wenn Frontmatter `license` fehlt |
|
||||
| `DEFAULT_AUTHORS` | `[]` | Post-Text-Autoren als JSON-Array `[{"name":"…"}]`, wenn Frontmatter `authors` fehlt |
|
||||
| `CLIENT_TAG` | *(leer)* | NIP-89 client-Provenance, opt-in |
|
||||
| `STRICT_MODE` | `false` | Phase 1: Warnungen statt Fehler bei `UNKNOWN`. Phase 2: kann auf `true` gesetzt werden |
|
||||
|
||||
**Wichtig:** Env-Defaults greifen nur für die **Post-Text-Lizenz und Post-Text-Autoren**. Sie greifen **nicht** für Bilder. Bilder brauchen explizite `license` und `authors` pro Eintrag (oder `UNKNOWN`).
|
||||
|
||||
---
|
||||
|
||||
## 5. Validierung (Phase 1 — minimal)
|
||||
|
||||
Der `validate-post`-Subcommand prüft:
|
||||
|
||||
1. Jedes Bild in `images[]` hat ein `alt`-Feld (Leerstring erlaubt, fehlendes Feld verboten).
|
||||
2. Jeder `file`-Wert referenziert eine existierende Datei im Post-Ordner.
|
||||
3. Jedes im Body mit `` referenzierte Bild existiert als Datei.
|
||||
4. Maximal ein Bild hat `role: cover`.
|
||||
|
||||
**Explizit NICHT geprüft in Phase 1:**
|
||||
|
||||
- `license` vorhanden oder well-formed (Env-Default für Text greift; Bilder dürfen `UNKNOWN` sein)
|
||||
- `authors` vorhanden oder non-empty (dito)
|
||||
- URL-Wohlgeformtheit über `string.startsWith('http')` hinaus
|
||||
- Orphan-Bilder (Bilder im Ordner, die nicht in `images[]` stehen und nicht im Body referenziert sind)
|
||||
|
||||
---
|
||||
|
||||
## 6. Migrations-Workflow (die 18 Altposts)
|
||||
|
||||
**Vor** der Pipeline-Implementierung wird einmalig ein Redaktions-Durchlauf gemacht, Claude-assistiert. Pro Post:
|
||||
|
||||
1. Bestehendes Frontmatter lesen.
|
||||
2. Bilder im Post-Ordner listen. Hugo-Derivate (`*_hu_*.ext`) ignorieren.
|
||||
3. Body-Kontext extrahieren (Text vor/nach jedem Bild + Dateiname).
|
||||
4. Für jedes Bild schlägt Claude vor:
|
||||
- `alt` (aus Kontext + Dateiname abgeleitet)
|
||||
- `role: cover` für das Frontmatter-Cover-Bild
|
||||
- `license` + `authors` = Eigenwerte, **wenn** der Kontext klar auf Eigenaufnahme hindeutet; sonst `UNKNOWN` mit Notiz
|
||||
5. Jörg reviewt, korrigiert, nickt ab.
|
||||
6. Pipeline-Autor schreibt Frontmatter-Patch.
|
||||
7. Commit pro Post oder gebündelt nach Batch.
|
||||
|
||||
**Minimaler Fall pro Post:**
|
||||
|
||||
```yaml
|
||||
---
|
||||
# bisheriges Frontmatter bleibt
|
||||
# ergänzt wird:
|
||||
|
||||
images:
|
||||
- file: cover.jpg
|
||||
role: cover
|
||||
alt: "..."
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: bild1.jpg
|
||||
alt: "..."
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
---
|
||||
```
|
||||
|
||||
Fremdbilder bekommen `source_url`, Bilder mit unklarer Provenienz `UNKNOWN`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Was in Phase 2 entschieden wird
|
||||
|
||||
- **Caption-Rendering-Format** (Kurzform-Katalog, Host-Extraktion, Locale-Normalisierung)
|
||||
- **Body-Caption-Injektion** oder Verzicht
|
||||
- **Reverse-Routine** aus Caption → YAML
|
||||
- **`STRICT_MODE=true`** als Standard
|
||||
- **Orphan-Bild-Detection** in der Validierung
|
||||
- **`references:`-Feld** für `a`/`e`-Cross-References
|
||||
- **`p`-Tags** für Text-Autoren mit Nostr-Pubkeys
|
||||
|
|
@ -1,274 +0,0 @@
|
|||
# Structured Image Metadata for Markdown-Sourced Nostr Long-Form Content
|
||||
|
||||
**Status:** Working draft — a practice convention, not (yet) a NIP.
|
||||
**Scope:** Authors who maintain Markdown long-form posts (`kind:30023`, NIP-23) in a git repository and publish them to Nostr via a build pipeline. The convention defines how image metadata (author, license, source, alt text, caption) lives in the repository, how it becomes `imeta` tags (NIP-92) in the event, and how to round-trip between the two.
|
||||
**Goal:** Zero data loss between repository and event. Human-readable in raw Markdown. Machine-readable in the published event. Safe defaults against accidental misattribution.
|
||||
|
||||
---
|
||||
|
||||
## Why this exists
|
||||
|
||||
Markdown's native image syntax — `` — only carries two fields: the target and an alt text. Everything else a properly attributed image needs (author, license, license link, source, modifications — the "TULLU-BA" rule in German copyright practice) has nowhere to go.
|
||||
|
||||
Authors have three unsatisfying options today:
|
||||
|
||||
1. **Stuff everything into a visible caption line** under each image. Good for human readers, bad for machine parsing, risky because easily forgotten or inconsistent.
|
||||
2. **Inline HTML `<figure>` blocks** with `<figcaption>`. Breaks Markdown lint tooling, hard to re-edit.
|
||||
3. **Lose the metadata entirely.** Silent misattribution risk when the post is re-published without provenance.
|
||||
|
||||
NIP-92's `imeta` tag fixes the event-side machine-readability problem (url, mime, sha256, alt, etc. per image). But it doesn't answer where the data lives *before* the event exists.
|
||||
|
||||
This convention proposes: **structured YAML frontmatter as source of truth, free-form Markdown body for prose, deterministic bidirectional mapping between them.**
|
||||
|
||||
---
|
||||
|
||||
## The convention in one example
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: "Schoko-Zimt-Schnecken"
|
||||
slug: "schoko-schnecken"
|
||||
date: 2023-02-26
|
||||
|
||||
# Text license (the post body). Image licenses are set per image.
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/"
|
||||
|
||||
# Post text authors (array, even for single author).
|
||||
authors:
|
||||
- name: "Jane Doe"
|
||||
url: "https://jane.example/"
|
||||
|
||||
images:
|
||||
- file: cover.jpg
|
||||
role: cover
|
||||
alt: "Golden baked yeast buns in a round pan, fresh from the oven"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/"
|
||||
authors:
|
||||
- name: "Jane Doe"
|
||||
|
||||
- file: dough-filling.jpg
|
||||
alt: "Rolled-out yeast dough, spread with cocoa-cinnamon-sugar filling"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/"
|
||||
authors:
|
||||
- name: "Jane Doe"
|
||||
|
||||
# Foreign image with full TULLU-BA attribution:
|
||||
- file: flickr-buns.jpg
|
||||
alt: "Basket of freshly baked cinnamon rolls"
|
||||
caption: "On a market stall in Lyon"
|
||||
authors:
|
||||
- name: "Max Mustermann"
|
||||
source_url: "https://www.flickr.com/photos/mustermann/12345/"
|
||||
license: "https://creativecommons.org/licenses/by-sa/4.0/"
|
||||
modifications: "cropped"
|
||||
---
|
||||
|
||||
Roll out the dough and spread the filling evenly:
|
||||

|
||||
|
||||
Slice into 16 pieces and arrange in the pan...
|
||||
```
|
||||
|
||||
The Markdown body stays clean. The YAML carries the truth.
|
||||
|
||||
---
|
||||
|
||||
## Field reference
|
||||
|
||||
### Post-level (applies to the post text, not images)
|
||||
|
||||
| Field | Required | Type | Semantics |
|
||||
|---|---|---|---|
|
||||
| `license` | yes | URL | License of the post **text**. Does **not** cascade to images. |
|
||||
| `authors` | yes | Array of `{name, url?, orcid?, ...}` | Authors of the post text. Array even with one author. |
|
||||
|
||||
Pipeline implementations may provide env-level defaults (`DEFAULT_LICENSE`, `DEFAULT_AUTHORS`) so single-author blogs don't repeat the same block on every post.
|
||||
|
||||
### Per-image (under the `images:` list)
|
||||
|
||||
| Field | Required | Type | Semantics |
|
||||
|---|---|---|---|
|
||||
| `file` | yes | String | Filename relative to the post directory. Must exist on disk. |
|
||||
| `role` | no | `cover` | At most one image per post may carry `role: cover`. Its URL becomes the event's `image` tag. |
|
||||
| `alt` | yes | String | Accessibility description. Empty string is allowed (decorative image); missing field is a validation error. |
|
||||
| `caption` | no | String | Optional human context beyond the alt text. |
|
||||
| `license` | yes | URL or `UNKNOWN` | Full schema.org-style license URL, or the literal `UNKNOWN`. No cascading from post-level. |
|
||||
| `authors` | yes | Array or `UNKNOWN` | Author list, or the literal `UNKNOWN`. No cascading from post-level. |
|
||||
| `source_url` | no | URL | Where the image was originally sourced (Flickr, Sketchfab, self-reference, etc.). |
|
||||
| `modifications` | no | String | Free-text description of any derivative work ("cropped", "color-adjusted", "AI-generated with prompt: ..."). The "BA" in TULLU-BA. |
|
||||
|
||||
### Why no cascading
|
||||
|
||||
Cascading license/author from post to images was rejected after early prototypes: it makes **silent misattribution** the easy default. If a post is tagged `license: CC0` and a contributor adds a foreign image without noticing, the image inherits CC0 implicitly and ships to Nostr with a false attribution.
|
||||
|
||||
Explicit per-image fields cost a few extra lines of YAML and prevent an entire class of attribution bugs.
|
||||
|
||||
### `UNKNOWN` as an explicit value
|
||||
|
||||
For legacy content where provenance has been lost:
|
||||
|
||||
```yaml
|
||||
- file: old-screenshot.png
|
||||
alt: "Screenshot of a now-defunct learning portal's homepage"
|
||||
license: UNKNOWN
|
||||
authors: UNKNOWN
|
||||
```
|
||||
|
||||
Pipeline behavior:
|
||||
|
||||
- Fields set to `UNKNOWN` are **not** written into the `imeta` tag (they are simply absent, not wrongly stated).
|
||||
- A warning is logged per `UNKNOWN` field with post slug + filename — this becomes a research backlog.
|
||||
- A strict mode can block publication when `UNKNOWN` values are present (opt-in).
|
||||
|
||||
---
|
||||
|
||||
## Mapping to the Nostr event
|
||||
|
||||
A post with this frontmatter produces a `kind:30023` event containing:
|
||||
|
||||
### Standard NIP-23 tags
|
||||
|
||||
- `["d", "<slug>"]`
|
||||
- `["title", "<title>"]`
|
||||
- `["published_at", "<unix-seconds>"]`
|
||||
- `["summary", "<description>"]` if present
|
||||
- `["image", "<cover-blossom-url>"]` — from the image marked `role: cover`
|
||||
- `["t", "<tag>"]` per entry in `tags:`
|
||||
|
||||
### Text license
|
||||
|
||||
- `["license", "<url>"]` — once per event, from post-level `license`
|
||||
|
||||
### Per-image `imeta` (NIP-92 + extensions)
|
||||
|
||||
Each uploaded image yields one `imeta` tag:
|
||||
|
||||
```
|
||||
["imeta",
|
||||
"url <blossom-url>",
|
||||
"m <mime-type>",
|
||||
"x <sha256>",
|
||||
"alt <alt>", if non-empty
|
||||
"caption <caption>", if present
|
||||
"license <url>", if set (not UNKNOWN)
|
||||
"author <name>", one entry per author, if set (not UNKNOWN)
|
||||
"source_url <url>", if present
|
||||
"modifications <text>" if present
|
||||
]
|
||||
```
|
||||
|
||||
NIP-92 explicitly allows implementers to add fields beyond its core set; clients ignore unknown fields. `license`, `author`, `source_url`, `modifications` are extensions this convention uses to carry TULLU-BA data inline with the image reference.
|
||||
|
||||
### Markdown body transformation
|
||||
|
||||
The Markdown body is traversed: each `` is replaced with `` after the image has been uploaded. Size hints (``) are stripped. Absolute URLs in the source are preserved.
|
||||
|
||||
---
|
||||
|
||||
## Round-trip: YAML ↔ Markdown
|
||||
|
||||
The convention is designed so authors can work in **either direction**:
|
||||
|
||||
### Forward: YAML → published event
|
||||
|
||||
1. Pipeline parses frontmatter.
|
||||
2. For each `images[]` entry, uploads `file` to Blossom, receives `{url, sha256}`.
|
||||
3. Builds mapping `filename → blossom-url`.
|
||||
4. Rewrites Markdown body image references.
|
||||
5. Assembles `imeta` tags from the structured fields + upload results.
|
||||
6. Signs and publishes.
|
||||
|
||||
### Reverse: "flat" Markdown → YAML
|
||||
|
||||
Some authors write Markdown with visible attribution lines underneath images, like:
|
||||
|
||||
```markdown
|
||||

|
||||
*Photo: Jane Doe, [CC0](https://creativecommons.org/publicdomain/zero/1.0/)*
|
||||
|
||||

|
||||
*Photo: Max Mustermann via [Flickr](https://www.flickr.com/photos/mustermann/12345/), [CC BY-SA](https://creativecommons.org/licenses/by-sa/4.0/), cropped*
|
||||
```
|
||||
|
||||
A round-trip parser can reconstruct the `images[]` YAML from this pattern because it follows a predictable shape:
|
||||
|
||||
```
|
||||

|
||||
*Photo: <name>{, <name2>}{ via [<source-label>](<source-url>)}, [<license-label>](<license-url>){, <modifications>}.*
|
||||
```
|
||||
|
||||
**Recognizable tokens** for the reverse parser:
|
||||
|
||||
- Image reference: standard Markdown `` on its own line.
|
||||
- Attribution line: starts on the next line, wrapped in `*...*`, begins with a role word (`Photo`, `Foto`, `Image`, `Abb.`, etc.), ends with a period.
|
||||
- **Authors**: comma-separated names between the role word and either `via` or the license bracket.
|
||||
- **Source**: `via [<label>](<url>)`. The label is derived from the hostname if generated forward; on reverse, it's discarded and only the URL is kept.
|
||||
- **License**: `[<short>](<url>)`. On reverse, only the URL is kept.
|
||||
- **Modifications**: a trailing fragment after the license link, before the final period.
|
||||
|
||||
### Canonical caption format
|
||||
|
||||
Forward generation (YAML → caption string) uses a deterministic template:
|
||||
|
||||
```
|
||||
{caption + ". "}Photo: {authors joined by " / "}{ via [<source-host>](<source_url>)}, [<license-short>]({license_url}){, <modifications>}.
|
||||
```
|
||||
|
||||
With a license short-form catalog:
|
||||
|
||||
| License URL prefix | Short form |
|
||||
|---|---|
|
||||
| `https://creativecommons.org/publicdomain/zero/1.0/` | `CC0` |
|
||||
| `https://creativecommons.org/licenses/by/4.0/` | `CC BY 4.0` |
|
||||
| `https://creativecommons.org/licenses/by-sa/4.0/` | `CC BY-SA 4.0` |
|
||||
| `https://creativecommons.org/licenses/by-nd/4.0/` | `CC BY-ND 4.0` |
|
||||
| `https://creativecommons.org/licenses/by-nc/4.0/` | `CC BY-NC 4.0` |
|
||||
| `https://creativecommons.org/licenses/by-nc-sa/4.0/` | `CC BY-NC-SA 4.0` |
|
||||
| `https://creativecommons.org/licenses/by-nc-nd/4.0/` | `CC BY-NC-ND 4.0` |
|
||||
| *anything else* | hostname of the URL |
|
||||
|
||||
Locale suffixes (`/deed.de`, `/deed.en`) are collapsed to the base URL for short-form lookup.
|
||||
|
||||
---
|
||||
|
||||
## Why this is forward-safe
|
||||
|
||||
Three properties make the convention robust over time:
|
||||
|
||||
1. **Events are replaceable.** A post re-published with improved metadata (better alt text, filled-in `UNKNOWN` fields) simply overrides the previous event via NIP-23's `d`-tag identity.
|
||||
2. **`imeta` extensions degrade gracefully.** Clients that don't read `license`/`author`/`source_url` in `imeta` ignore them; they still get the standard `url`/`m`/`x`/`alt` fields.
|
||||
3. **Reverse parsing is optional.** A pipeline can publish without ever supporting the reverse direction; the YAML is always the source of truth.
|
||||
|
||||
---
|
||||
|
||||
## What this convention does **not** do
|
||||
|
||||
- **Does not inject captions into the event body.** Early drafts did; it turned into a fragile regex workout across Markdown variants (link-wrapped images, list-embedded images, block quotes). Recommended approach: let clients render attribution from `imeta` fields. Inject body captions only if a concrete client gap makes it necessary.
|
||||
- **Does not define new Nostr kinds.** It uses `kind:30023` (NIP-23), `kind:10063` (Blossom user server list, BUD-03), and `kind:10002` (NIP-65 relay list) as-is.
|
||||
- **Does not mandate Blossom.** The convention maps cleanly to any content-addressed image host. Blossom is just the most interoperable option in the Nostr ecosystem today.
|
||||
|
||||
---
|
||||
|
||||
## Open questions for the community
|
||||
|
||||
1. **License in `imeta` — convention or its own tag?** Should per-image license info live in `imeta` as a non-standard field, or should there be a companion `license` tag per image with an `x <sha256>` back-reference? The `imeta` approach keeps everything per-image in one tag. A separate tag decouples concerns but duplicates the binding.
|
||||
|
||||
2. **Multiple licenses per image.** CC dual-licensing exists (e.g. "CC BY-SA or GFDL"). Should the spec allow `license` as an array, or repeat the `license` field multiple times in `imeta`?
|
||||
|
||||
3. **Canonical short-form catalog.** The table above is practical but not authoritative. Should a registry of license-URL-to-short-form mappings live somewhere reference-able?
|
||||
|
||||
4. **Attribution in languages other than English.** The reverse-parser pattern uses role words like `Photo`, `Foto`, `Image`. A language-agnostic marker (e.g. a leading emoji or a structured sigil like `⸻ credit ⸻`) would sidestep i18n, at the cost of readability.
|
||||
|
||||
5. **Machine-readable attribution in client rendering.** Long-form clients (Habla, Flycat, etc.) vary in how (and whether) they surface `imeta.license` / `imeta.author`. Adoption of this convention is only valuable if clients pick it up — a reference renderer implementation would lower the bar.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [NIP-23 — Long-form Content](https://github.com/nostr-protocol/nips/blob/master/23.md)
|
||||
- [NIP-92 — Media Attachments (`imeta`)](https://github.com/nostr-protocol/nips/blob/master/92.md)
|
||||
- [NIP-65 — Relay List Metadata (`kind:10002`)](https://github.com/nostr-protocol/nips/blob/master/65.md)
|
||||
- [Blossom BUD-01 — Server Requirements](https://github.com/hzrd149/blossom/blob/master/buds/01.md)
|
||||
- [Blossom BUD-03 — User Server List (`kind:10063`)](https://github.com/hzrd149/blossom/blob/master/buds/03.md)
|
||||
- [TULLU / TULLU-BA attribution rule (German, Wikimedia practice)](https://commons.wikimedia.org/wiki/Commons:Lizenzhinweisgenerator)
|
||||
- [schema.org/CreativeWork — `license` field convention](https://schema.org/license)
|
||||
|
|
@ -1,283 +0,0 @@
|
|||
# Strukturierte Bild-Metadaten für Markdown-basierte Nostr-Langform-Beiträge
|
||||
|
||||
**Status:** Arbeitsentwurf — eine Praxis-Konvention, (noch) kein NIP.
|
||||
**Scope:** Eine Inline-Markdown-Konvention zur Bildattribution (Urheber, Lizenz, Quelle, Bearbeitung), die in jedem Markdown-Editor direkt nutzbar ist und sich verlustfrei auf NIP-92-`imeta`-Tags in `kind:30023`-Events abbilden lässt.
|
||||
**Ziel:** Ein einheitliches, menschlich lesbares und maschinell parsbares Attributions-Format für Bilder in Nostr-Langform-Beiträgen. TULLU-BA-konform. Zero-Tool: funktioniert ohne Build-Pipeline. Zero-Loss: bidirektional konvertierbar zu `imeta`-Tags, sobald Publishing dazukommt.
|
||||
|
||||
---
|
||||
|
||||
## Warum es das braucht
|
||||
|
||||
Markdowns native Bild-Syntax — `` — trägt nur zwei Felder: das Ziel und einen Alt-Text. Alles andere, was ein korrekt attribuiertes Bild braucht (Urheber, Lizenz, Link zur Lizenz, Quelle, Bearbeitungen — die TULLU-BA-Regel aus der deutschen Urheberrechtspraxis), hat keinen Platz.
|
||||
|
||||
Autor:innen haben heute drei unbefriedigende Optionen:
|
||||
|
||||
1. **Attribution als freier Fließtext** unter jedem Bild. Gut für Menschen, nicht parsbar.
|
||||
2. **Inline-HTML-`<figure>`-Blöcke** mit `<figcaption>`. Bricht Markdown-Lint-Tools, schwer editierbar.
|
||||
3. **Metadaten weglassen.** Risiko stiller Fehlattribution.
|
||||
|
||||
NIP-92s `imeta`-Tag löst die Event-seitige Maschinenlesbarkeit (url, mime, sha256, alt usw. pro Bild). Diese Konvention liefert das fehlende Gegenstück: **wie dieselben Informationen bereits im Markdown stehen können — einheitlich, lesbar, parsbar**.
|
||||
|
||||
---
|
||||
|
||||
## Konvention zur Bildattribution
|
||||
|
||||
### Maximale Beispiel-Darstellung
|
||||
|
||||

|
||||
[garden rhubarb, Speise-Rhabarber](https://www.inaturalist.org/photos/71812633), [John Sankey](https://www.inaturalist.org/users/2831535), [CC0](https://creativecommons.org/publicdomain/zero/1.0/), beschnitten
|
||||
|
||||
### Maximale Beispiel-Konstruktion
|
||||
|
||||
```markdown
|
||||

|
||||
[title](sourceUrl), [author](authorUrl), [licence](licenceUrl), modification
|
||||
```
|
||||
|
||||
Die Caption-Zeile steht **auf der Zeile direkt nach dem Bild** (Zeilenumbruch, kein Leerzeichen dazwischen).
|
||||
|
||||
---
|
||||
|
||||
## Regeln
|
||||
|
||||
1. **Reihenfolge der Felder:** `alt`, `imageUrl`, `title`, `sourceUrl`, `author`, `authorUrl`, `licence`, `licenceUrl`, `modification`. Die Reihenfolge ist **normativ**, damit Parser sich darauf verlassen können.
|
||||
2. **Trenner:** Komma + Leerzeichen (`, `) zwischen den Caption-Feldern. Einheitlich, kein Mix aus „von", „via", Pipe usw.
|
||||
3. **Verlinkungen:**
|
||||
- `title` → `sourceUrl`
|
||||
- `author` → `authorUrl`
|
||||
- `licence` → `licenceUrl`
|
||||
4. **URL-Disziplin:** Alle URL-Felder sind absolut (`https://…`), niemals relativ.
|
||||
5. **CC0 / Public Domain:** `sourceUrl` darf entfallen. Urheber:in und Lizenz bleiben aus Transparenzgründen empfohlen.
|
||||
6. **Bearbeitungen:** Bei CC-BY-Lizenzen ist die Änderung anzugeben, sobald das Werk verändert wurde (Zuschnitt, Farbe, Skalierung, Kombination usw.). Bei CC0 optional.
|
||||
7. **Barrierefreiheit:** `alt` ist formal optional, aber für WCAG/BITV-Konformität faktisch Pflicht. Leere eckige Klammern `![]` nur bei rein dekorativen Bildern.
|
||||
|
||||
---
|
||||
|
||||
## (Pflicht-)Felder
|
||||
|
||||
| Feld | Status | Bedeutung / Form |
|
||||
|---|---|---|
|
||||
| `licence` | **Pflicht** | Lizenz-Kurzform (`CC0`, `CC BY`, `CC BY-SA`, `©`, …) |
|
||||
| `licenceUrl` | **Pflicht** | Kanonische Lizenz-URL, z. B. `https://creativecommons.org/publicdomain/zero/1.0/` |
|
||||
| `imageUrl` | **Pflicht** | Absolute URL zur Bilddatei (sonst nicht renderbar) |
|
||||
| `sourceUrl` | **Pflicht** außer bei CC0 | URL zur Quellseite (Link in `title`) |
|
||||
| `author` | **Pflicht** außer bei CC0 | Name der Urheber:in |
|
||||
| `authorUrl` | optional | Profil-/Homepage-URL der Urheber:in |
|
||||
| `modification` | optional (Pflicht bei Bearbeitung von CC-BY-Werken) | Freitext zur Bearbeitung |
|
||||
| `title` | optional | Titel des Werks |
|
||||
| `alt` | optional (faktisch Pflicht für Accessibility) | Screen-Reader-Beschreibung |
|
||||
|
||||
---
|
||||
|
||||
## Minimale Beispiel-Darstellung
|
||||
|
||||

|
||||
[CC0](https://creativecommons.org/publicdomain/zero/1.0/)
|
||||
|
||||
### Minimale Beispiel-Konstruktion
|
||||
|
||||
```markdown
|
||||

|
||||
[licence](licenceUrl)
|
||||
```
|
||||
|
||||
Die harte Mindestanforderung: **Bild + Lizenz-Link**. Alles andere darf weg, wenn es die Lizenz erlaubt (z. B. CC0).
|
||||
|
||||
---
|
||||
|
||||
## Zwischenformen
|
||||
|
||||
Zwischen Minimum und Maximum sind alle Teilmengen erlaubt, solange die Reihenfolge eingehalten wird und die Pflichtfelder der jeweiligen Lizenz erfüllt sind.
|
||||
|
||||
**CC0-Eigenbild mit Urheberangabe (empfohlen für Transparenz):**
|
||||
```markdown
|
||||

|
||||
Comenius-Institut, [CC0](https://creativecommons.org/publicdomain/zero/1.0/)
|
||||
```
|
||||
|
||||
**CC-BY-Fremdbild ohne Titel:**
|
||||
```markdown
|
||||

|
||||
[Jörg Lohrer](https://www.flickr.com/photos/empeiria/8553607289/), [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parsing-Regeln (für Tooling)
|
||||
|
||||
Die Konvention ist für **Menschen** geschrieben. Parser haben die Aufgabe, sich daran möglichst anzupassen — **nicht umgekehrt**. Sonderzeichen, Rollen-Wörter oder sprach-abhängige Marker werden bewusst nicht vorgeschrieben, weil sie den Schreibfluss behindern würden.
|
||||
|
||||
Ein Parser erkennt eine Attributions-Caption anhand dieser Merkmale:
|
||||
|
||||
- **Position:** direkt nach einer Markdown-Bild-Zeile (``), auf der nächsten Zeile ohne Leerzeile dazwischen.
|
||||
- **Struktur:** eine oder mehrere `[label](url)`-Markdown-Links, getrennt durch `, `, optional abschließender Freitext-Teil für `modification`.
|
||||
- **Feld-Zuordnung** nach Position in der Reihenfolge gemäß Regel 1:
|
||||
- Erster Link vor einem eventuellen Personennamen-Link = `title` + `sourceUrl`
|
||||
- Zweiter Link (Personenname) = `author` + `authorUrl`
|
||||
- Dritter Link (CC-Kürzel) = `licence` + `licenceUrl`
|
||||
- Alles danach (ohne Klammer-Syntax) = `modification`
|
||||
|
||||
**Eindeutige Fälle:**
|
||||
|
||||
- **Drei Links** → `title`/`sourceUrl`, `author`/`authorUrl`, `licence`/`licenceUrl` in dieser Reihenfolge. Der letzte Link muss auf ein Lizenz-URL-Pattern matchen.
|
||||
- **Zwei Links**, zweiter matcht Lizenz-Pattern → `author`/`authorUrl` + `licence`/`licenceUrl`. Ein Titel ohne Autor:in wird konventionell nicht vergeben — das erste `[Text](url)` ist in zwei-Link-Fällen immer `author`.
|
||||
- **Ein Link + unverlinkter Text + Lizenz-Link** → unverlinkter Text ist `author`, Link vor der Lizenz wäre `title+sourceUrl`.
|
||||
- **Nur ein Link**, matcht Lizenz-Pattern → `licence`/`licenceUrl`. Minimal-Form.
|
||||
- **Unverlinkter String vor der Lizenz** → `author` (ohne URL).
|
||||
- **Freitext nach der Lizenz** → `modification`.
|
||||
|
||||
**Mehrdeutige Fälle** (z. B. `[Etwas](url), [CC0](url)` — Autor oder Titel?):
|
||||
|
||||
- **Parser-Empfehlung:** LLM-gestützter Parser nimmt Kontext dazu (Bild-Alt-Text, Body-Kontext, Plattform-Muster der URL) und ordnet zu.
|
||||
- **Reiner Regex-Parser:** markiert die Caption als **ambigue** und eskaliert zur redaktionellen Prüfung (statt zu raten).
|
||||
- **Schreibende:** können Mehrdeutigkeit jederzeit selbst auflösen, indem sie beide Felder setzen (`[Titel](url), [Autor](url), [Lizenz](url)`). Ein Titel ohne Autor:in ist die Ausnahme; wer Eindeutigkeit braucht, ergänzt die Urheber:in.
|
||||
|
||||
Der Parser bricht nie stillschweigend. Eine Caption ist entweder eindeutig geparst, eindeutig Minimal-Form, oder **wird als prüfbedürftig markiert** — nie still falsch interpretiert.
|
||||
|
||||
---
|
||||
|
||||
## Abbildung auf das Nostr-Event (`imeta`, NIP-92)
|
||||
|
||||
Jedes Bild im Beitrag wird als eigener `imeta`-Tag im `kind:30023`-Event codiert:
|
||||
|
||||
```
|
||||
["imeta",
|
||||
"url <imageUrl>",
|
||||
"m <mime>",
|
||||
"x <sha256>",
|
||||
"alt <alt>", wenn nicht leer
|
||||
"title <title>", wenn vorhanden
|
||||
"source_url <sourceUrl>", wenn vorhanden
|
||||
"author <author>", wenn vorhanden; ein Eintrag pro Autor:in
|
||||
"author_url <authorUrl>", wenn vorhanden
|
||||
"license <licenceUrl>", Pflicht
|
||||
"modification <modification>" wenn vorhanden
|
||||
]
|
||||
```
|
||||
|
||||
**Normativ:**
|
||||
|
||||
- `url`, `m`, `x`, `license` sind **Pflicht** im `imeta`.
|
||||
- `license` ist immer die volle URL, nicht die Kurzform (maschinenlesbar, Clients können daraus die Kurzform zur Anzeige ableiten).
|
||||
- `m` (mime) und `x` (sha256) kommen nicht aus der Caption, sondern werden beim Upload zum Blob-Host (z. B. Blossom) ermittelt.
|
||||
|
||||
**Erweiterung über NIP-92 hinaus:** Die Felder `title`, `source_url`, `author`, `author_url`, `modification` sind keine NIP-92-Kernfelder. NIP-92 erlaubt Implementierenden ausdrücklich, zusätzliche Felder einzuführen; Clients ignorieren unbekannte Felder. Diese Konvention nutzt diese Erweiterungsmöglichkeit, um TULLU-BA-Daten direkt beim Bild mitzuführen.
|
||||
|
||||
---
|
||||
|
||||
## Bidirektionale Abbildung (Markdown ↔ `imeta`)
|
||||
|
||||
### Hinweg: Markdown → `imeta`
|
||||
|
||||
1. Parser findet `` im Body.
|
||||
2. Nächste Zeile wird als Caption interpretiert, Felder nach Reihenfolge-Regel extrahiert.
|
||||
3. Bild wird hochgeladen (z. B. Blossom), `url`/`mime`/`sha256` werden aus der Upload-Antwort ergänzt.
|
||||
4. `imeta`-Tag wird aus Caption-Feldern + Upload-Daten gebaut.
|
||||
5. Markdown-Body wird angepasst: ursprüngliche `imageUrl` → Upload-URL. Die Caption-Zeile bleibt erhalten (oder wird entfernt, wenn der Client sie aus `imeta` rendert — Entscheidung des Publishing-Tools).
|
||||
|
||||
### Rückweg: `imeta` → Markdown
|
||||
|
||||
1. Client liest Event, extrahiert pro `imeta`-Tag die Felder.
|
||||
2. Rendert `` mit `alt` aus dem Tag.
|
||||
3. Rendert darunter eine Caption-Zeile mit den vorhandenen Feldern in der normativen Reihenfolge aus Regel 1.
|
||||
4. `license` (URL) wird über einen Kurzform-Katalog (siehe Anhang) in eine lesbare Kurzform übersetzt (`CC0`, `CC BY 4.0`, …).
|
||||
|
||||
Weil die Reihenfolge normativ ist und die Trennzeichen einheitlich, lässt sich beides verlustfrei ineinander übersetzen.
|
||||
|
||||
---
|
||||
|
||||
## Beispiel: End-to-End
|
||||
|
||||
### Markdown im Editor
|
||||
|
||||
```markdown
|
||||

|
||||
[garden rhubarb, Speise-Rhabarber](https://www.inaturalist.org/photos/71812633), [John Sankey](https://www.inaturalist.org/users/2831535), [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/), beschnitten
|
||||
```
|
||||
|
||||
### Geparst
|
||||
|
||||
| Feld | Wert |
|
||||
|---|---|
|
||||
| `alt` | Rhabarberpflanze mit großen grünen Blättern … |
|
||||
| `imageUrl` | https://inaturalist-open-data.s3.amazonaws.com/photos/71812633/medium.jpg |
|
||||
| `title` | garden rhubarb, Speise-Rhabarber |
|
||||
| `sourceUrl` | https://www.inaturalist.org/photos/71812633 |
|
||||
| `author` | John Sankey |
|
||||
| `authorUrl` | https://www.inaturalist.org/users/2831535 |
|
||||
| `licence` | CC BY-SA 4.0 |
|
||||
| `licenceUrl` | https://creativecommons.org/licenses/by-sa/4.0/ |
|
||||
| `modification` | beschnitten |
|
||||
|
||||
### Als `imeta`-Tag im `kind:30023`-Event (nach Blossom-Upload)
|
||||
|
||||
```
|
||||
["imeta",
|
||||
"url https://blossom.example/abc123…def.jpg",
|
||||
"m image/jpeg",
|
||||
"x abc123…def",
|
||||
"alt Rhabarberpflanze mit großen grünen Blättern und roten Stielen in einem Gartenbeet mit Mulch",
|
||||
"title garden rhubarb, Speise-Rhabarber",
|
||||
"source_url https://www.inaturalist.org/photos/71812633",
|
||||
"author John Sankey",
|
||||
"author_url https://www.inaturalist.org/users/2831535",
|
||||
"license https://creativecommons.org/licenses/by-sa/4.0/",
|
||||
"modification beschnitten"
|
||||
]
|
||||
```
|
||||
|
||||
### Beim Rendern in einem Nostr-Client
|
||||
|
||||
Der Client, der dieses `imeta` versteht, rekonstruiert die Caption nach derselben Konvention:
|
||||
|
||||
```markdown
|
||||

|
||||
[garden rhubarb, Speise-Rhabarber](https://www.inaturalist.org/photos/71812633), [John Sankey](https://www.inaturalist.org/users/2831535), [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/), beschnitten
|
||||
```
|
||||
|
||||
Ein Client, der die erweiterten `imeta`-Felder nicht kennt, zeigt immerhin `` korrekt an und ignoriert den Rest — Graceful Degradation.
|
||||
|
||||
---
|
||||
|
||||
## Anhang: Lizenz-URL → Kurzform-Katalog
|
||||
|
||||
| Lizenz-URL-Präfix | Kurzform |
|
||||
|---|---|
|
||||
| `https://creativecommons.org/publicdomain/zero/1.0/` | `CC0` |
|
||||
| `https://creativecommons.org/publicdomain/mark/1.0/` | `Public Domain` |
|
||||
| `https://creativecommons.org/licenses/by/4.0/` | `CC BY 4.0` |
|
||||
| `https://creativecommons.org/licenses/by-sa/4.0/` | `CC BY-SA 4.0` |
|
||||
| `https://creativecommons.org/licenses/by-nd/4.0/` | `CC BY-ND 4.0` |
|
||||
| `https://creativecommons.org/licenses/by-nc/4.0/` | `CC BY-NC 4.0` |
|
||||
| `https://creativecommons.org/licenses/by-nc-sa/4.0/` | `CC BY-NC-SA 4.0` |
|
||||
| `https://creativecommons.org/licenses/by-nc-nd/4.0/` | `CC BY-NC-ND 4.0` |
|
||||
| *alles andere* | Host der URL als Kurzform-Fallback |
|
||||
|
||||
Locale-Suffixe (`/deed.de`, `/deed.en`) werden bei der Kurzform-Auflösung auf die Basis-URL reduziert. Für Versionen (`3.0` statt `4.0`) wird die Version mit angezeigt.
|
||||
|
||||
---
|
||||
|
||||
## Offene Fragen an die Community
|
||||
|
||||
1. **Reihenfolge normativ oder locker?** Die normative Reihenfolge macht den Parser einfach. Eine lockere Variante (Felder an beliebiger Position, Erkennung per URL-Pattern) wäre toleranter, aber fragiler. Empfehlung: normativ. Meinungen?
|
||||
|
||||
2. **Mehrere Autor:innen pro Bild.** Ein Bild mit Ko-Autorenschaft: `[Jane Doe](…) / [John Doe](…)`? Oder Komma-getrennt `[Jane Doe](…), [John Doe](…)`? Letzteres kollidiert mit dem Feld-Trenner. Empfehlung: `/` als Autor:innen-Trenner innerhalb des `author`-Slots.
|
||||
|
||||
3. **Mehrere Lizenzen pro Bild.** CC-Dual-Licensing (z. B. „CC BY-SA **oder** GFDL") — `[CC BY-SA](url) / [GFDL](url)` analog zu Autor:innen?
|
||||
|
||||
4. **Kanonischer Kurzform-Katalog.** Die Tabelle ist praktikabel, aber nicht normativ. Eine Registry von Lizenz-URL-zu-Kurzform-Mappings, referenzierbar an einer Stelle, würde Interop erleichtern.
|
||||
|
||||
5. **Sprach-Rollen-Wörter.** Diese Konvention verzichtet auf einleitende Wörter wie „Foto:", „Photo:", „Bild:". Das macht sie sprach-agnostisch. Will jemand ein optionales Rollen-Wort erlauben (`*Foto: [title](url), …*`), damit Attributionen in langen Texten klarer identifizierbar sind?
|
||||
|
||||
6. **Repo-Workflow-Ergänzung.** Wer Markdown in einem Git-Repo mit Build-Pipeline pflegt, möchte manchmal Metadaten **strukturiert im YAML-Frontmatter** statt im Body. Ein paralleler YAML-Mapping (gleiche Felder, gleiche Semantik, Array unter `images:`) kann als Ergänzung leben, wobei die Inline-Markdown-Form die Basis bleibt und beides bidirektional konvertierbar ist.
|
||||
|
||||
---
|
||||
|
||||
## Referenzen
|
||||
|
||||
- [NIP-23 — Long-form Content](https://github.com/nostr-protocol/nips/blob/master/23.md)
|
||||
- [NIP-92 — Media Attachments (`imeta`)](https://github.com/nostr-protocol/nips/blob/master/92.md)
|
||||
- [Blossom BUD-01 — Server Requirements](https://github.com/hzrd149/blossom/blob/master/buds/01.md)
|
||||
- [TULLU / TULLU-BA Attributions-Regel (Wikimedia Deutschland)](https://commons.wikimedia.org/wiki/Commons:Lizenzhinweisgenerator)
|
||||
- [schema.org/CreativeWork — `license`-Feld](https://schema.org/license)
|
||||
- [WCAG 2.1 — Accessible Alt Text](https://www.w3.org/WAI/WCAG21/Understanding/non-text-content.html)
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
# ==== PFLICHT ====
|
||||
|
||||
# NIP-46-Bunker-URL vom Signer (Amber, nak bunker, nsite.run, …)
|
||||
BUNKER_URL=bunker://<hex>?relay=wss://...&secret=...
|
||||
|
||||
# Autor-Pubkey als 64 Zeichen lowercase hex (entspricht dem Bunker-Account)
|
||||
AUTHOR_PUBKEY_HEX=
|
||||
|
||||
# Bootstrap-Relay zum Laden von kind:10002 und kind:10063
|
||||
BOOTSTRAP_RELAY=wss://relay.damus.io
|
||||
|
||||
# ==== OPTIONAL ====
|
||||
|
||||
# Wurzel der Markdown-Posts, relativ zu diesem publish/-Ordner.
|
||||
# Default: ../content/posts
|
||||
CONTENT_ROOT=../content/posts
|
||||
|
||||
# Wird als ["client", "<wert>"]-Tag in jedes kind:30023-Event eingetragen.
|
||||
# Hilft bei der Zuordnung der Event-Herkunft. Default leer (kein client-Tag).
|
||||
CLIENT_TAG=
|
||||
|
||||
# Minimal geforderte Relay-ACKs pro Post (default: 2)
|
||||
MIN_RELAY_ACKS=2
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
.env
|
||||
logs/
|
||||
deno.lock
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
# publish — Nostr-Publish-Pipeline
|
||||
|
||||
Markdown-Posts aus einem Hugo-ähnlichen Content-Ordner zu `kind:30023`-Events,
|
||||
Bilder zu Blossom, Signatur via NIP-46-Bunker.
|
||||
|
||||
Blaupause für Nostr-Repos: keinerlei Projekt-Konstanten im Code, alles über
|
||||
Env-Variablen konfigurierbar.
|
||||
|
||||
## Setup
|
||||
|
||||
1. `cp .env.example .env` und Werte eintragen.
|
||||
2. Oder: `.env.local` im Eltern-Ordner pflegen und `deno.jsonc` anpassen
|
||||
(siehe `--env-file=../.env.local`-Tasks).
|
||||
3. `deno task check` — verifiziert Bunker, Relay-Liste, Blossom-Server.
|
||||
|
||||
## Befehle
|
||||
|
||||
- `deno task publish` — Git-Diff-Modus: publisht nur geänderte Posts.
|
||||
- `deno task publish --force-all` — alle Posts (Migration / Reimport).
|
||||
- `deno task publish --post <slug>` — nur ein Post.
|
||||
- `deno task publish --dry-run` — zeigt, was publiziert würde, ohne Uploads.
|
||||
- `deno task validate-post content/posts/<ordner>/index.md` — Frontmatter-Check.
|
||||
- `deno task test` — Tests.
|
||||
|
||||
## Struktur
|
||||
|
||||
- `src/core/` — Library (Frontmatter, Markdown, Events, Signer, Relays, Blossom).
|
||||
- `src/subcommands/` — CLI-Befehle.
|
||||
- `src/cli.ts` — Entrypoint, Subcommand-Dispatcher.
|
||||
- `tests/` — Unit- und Integration-Tests.
|
||||
- `.github/workflows/publish.yml` — CI-Workflow.
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
{
|
||||
"tasks": {
|
||||
"publish": "deno run --env-file=../.env.local --allow-env --allow-read --allow-write=./logs --allow-net --allow-run=git src/cli.ts publish",
|
||||
"delete": "deno run --env-file=../.env.local --allow-env --allow-read --allow-net src/cli.ts delete",
|
||||
"check": "deno run --env-file=../.env.local --allow-env --allow-read --allow-net src/cli.ts check",
|
||||
"validate-post": "deno run --allow-env --allow-read src/cli.ts validate-post",
|
||||
"test": "deno test --allow-env --allow-read --allow-write --allow-net --allow-run",
|
||||
"fmt": "deno fmt",
|
||||
"lint": "deno lint"
|
||||
},
|
||||
"imports": {
|
||||
"@std/yaml": "jsr:@std/yaml@^1.0.5",
|
||||
"@std/cli": "jsr:@std/cli@^1.0.6",
|
||||
"@std/fs": "jsr:@std/fs@^1.0.4",
|
||||
"@std/path": "jsr:@std/path@^1.0.6",
|
||||
"@std/testing": "jsr:@std/testing@^1.0.3",
|
||||
"@std/assert": "jsr:@std/assert@^1.0.6",
|
||||
"@std/encoding": "jsr:@std/encoding@^1.0.5",
|
||||
"nostr-tools": "npm:nostr-tools@^2.10.4",
|
||||
"applesauce-signers": "npm:applesauce-signers@^2.0.0",
|
||||
"applesauce-relay": "npm:applesauce-relay@^2.0.0",
|
||||
"rxjs": "npm:rxjs@^7.8.1"
|
||||
},
|
||||
"fmt": {
|
||||
"lineWidth": 100,
|
||||
"indentWidth": 2,
|
||||
"semiColons": false,
|
||||
"singleQuote": true
|
||||
},
|
||||
"lint": {
|
||||
"rules": {
|
||||
"tags": ["recommended"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,233 +0,0 @@
|
|||
import { parseArgs } from '@std/cli/parse-args'
|
||||
import { join } from '@std/path'
|
||||
import { loadConfig } from './core/config.ts'
|
||||
import { createBunkerSigner } from './core/signer.ts'
|
||||
import { loadOutbox } from './core/outbox.ts'
|
||||
import { loadBlossomServers } from './core/blossom-list.ts'
|
||||
import { parseFrontmatter } from './core/frontmatter.ts'
|
||||
import { checkExisting, publishToRelays } from './core/relays.ts'
|
||||
import { uploadBlob } from './core/blossom.ts'
|
||||
import { collectImages } from './core/image-collector.ts'
|
||||
import { allPostDirs, changedPostDirs } from './core/change-detection.ts'
|
||||
import { createLogger, type RunMode } from './core/log.ts'
|
||||
import { type PostDeps, processPost } from './subcommands/publish.ts'
|
||||
import { printCheckResult, runCheck } from './subcommands/check.ts'
|
||||
import { validatePostFile } from './subcommands/validate-post.ts'
|
||||
import { runDelete } from './subcommands/delete.ts'
|
||||
|
||||
function uuid(): string {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
|
||||
async function cmdCheck(): Promise<number> {
|
||||
const config = loadConfig()
|
||||
const result = await runCheck(config)
|
||||
printCheckResult(result)
|
||||
return result.ok ? 0 : 1
|
||||
}
|
||||
|
||||
async function cmdValidatePost(path: string | undefined): Promise<number> {
|
||||
if (!path) {
|
||||
console.error('usage: validate-post <path-to-index.md>')
|
||||
return 2
|
||||
}
|
||||
const result = await validatePostFile(path)
|
||||
if (result.ok) {
|
||||
console.log(`✓ ${path} ok (slug: ${result.slug})`)
|
||||
return 0
|
||||
}
|
||||
console.error(`✗ ${path}: ${result.error}`)
|
||||
return 1
|
||||
}
|
||||
|
||||
async function findBySlug(dirs: string[], slug: string): Promise<string | undefined> {
|
||||
for (const d of dirs) {
|
||||
try {
|
||||
const text = await Deno.readTextFile(join(d, 'index.md'))
|
||||
const { fm } = parseFrontmatter(text)
|
||||
if (fm.slug === slug) return d
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function resolvePostDirs(
|
||||
mode: RunMode,
|
||||
contentRoot: string,
|
||||
single?: string,
|
||||
): Promise<string[]> {
|
||||
if (mode === 'post-single' && single) {
|
||||
if (single.startsWith(contentRoot + '/')) return [single]
|
||||
const all = await allPostDirs(contentRoot)
|
||||
const match = all.find((d) => d.endsWith(`/${single}`)) ?? (await findBySlug(all, single))
|
||||
if (!match) throw new Error(`post mit slug "${single}" nicht gefunden`)
|
||||
return [match]
|
||||
}
|
||||
if (mode === 'force-all') return await allPostDirs(contentRoot)
|
||||
const before = Deno.env.get('GITHUB_EVENT_BEFORE') ?? 'HEAD~1'
|
||||
return await changedPostDirs({ from: before, to: 'HEAD', contentRoot })
|
||||
}
|
||||
|
||||
async function cmdPublish(flags: {
|
||||
forceAll: boolean
|
||||
post?: string
|
||||
dryRun: boolean
|
||||
}): Promise<number> {
|
||||
const config = loadConfig()
|
||||
const mode: RunMode = flags.post ? 'post-single' : flags.forceAll ? 'force-all' : 'diff'
|
||||
const runId = uuid()
|
||||
const logger = createLogger({ mode, runId })
|
||||
|
||||
console.log('[1/3] signer…')
|
||||
const signer = await createBunkerSigner(config.bunkerUrl, {
|
||||
clientSecretHex: config.clientSecretHex,
|
||||
})
|
||||
console.log('[2/3] outbox…')
|
||||
const outbox = await loadOutbox(config.bootstrapRelay, config.authorPubkeyHex)
|
||||
console.log('[3/3] blossom-server-liste…')
|
||||
const blossomServers = await loadBlossomServers(config.bootstrapRelay, config.authorPubkeyHex)
|
||||
console.log('setup done')
|
||||
if (outbox.write.length === 0) {
|
||||
console.error('no write relays in kind:10002')
|
||||
return 1
|
||||
}
|
||||
if (blossomServers.length === 0) {
|
||||
console.error('no blossom servers in kind:10063')
|
||||
return 1
|
||||
}
|
||||
|
||||
const postDirs = await resolvePostDirs(mode, config.contentRoot, flags.post)
|
||||
console.log(
|
||||
`mode=${mode} posts=${postDirs.length} runId=${runId} contentRoot=${config.contentRoot}`,
|
||||
)
|
||||
|
||||
if (flags.dryRun) {
|
||||
for (const d of postDirs) console.log(` dry-run: ${d}`)
|
||||
return 0
|
||||
}
|
||||
|
||||
const deps: PostDeps = {
|
||||
readPostFile: async (p) => parseFrontmatter(await Deno.readTextFile(p)),
|
||||
collectImages: (dir) => collectImages(dir),
|
||||
uploadBlossom: (a) =>
|
||||
uploadBlob({
|
||||
data: a.data,
|
||||
fileName: a.fileName,
|
||||
mimeType: a.mimeType,
|
||||
servers: blossomServers,
|
||||
signer,
|
||||
}),
|
||||
sign: (ev) => signer.signEvent(ev),
|
||||
publish: (ev, relays) => publishToRelays(relays, ev),
|
||||
checkExisting: (slug, relays) => checkExisting(slug, config.authorPubkeyHex, relays),
|
||||
}
|
||||
|
||||
let anyFailed = false
|
||||
for (const dir of postDirs) {
|
||||
const result = await processPost({
|
||||
postDir: dir,
|
||||
writeRelays: outbox.write,
|
||||
blossomServers,
|
||||
pubkeyHex: config.authorPubkeyHex,
|
||||
clientTag: config.clientTag,
|
||||
minRelayAcks: config.minRelayAcks,
|
||||
deps,
|
||||
})
|
||||
if (result.status === 'success') {
|
||||
logger.postSuccess({
|
||||
slug: result.slug,
|
||||
action: result.action!,
|
||||
eventId: result.eventId!,
|
||||
relaysOk: result.relaysOk,
|
||||
relaysFailed: result.relaysFailed,
|
||||
blossomServersOk: result.blossomServersOk,
|
||||
imagesUploaded: result.imagesUploaded,
|
||||
durationMs: result.durationMs,
|
||||
})
|
||||
} else if (result.status === 'skipped-draft') {
|
||||
logger.postSkippedDraft(result.slug)
|
||||
} else {
|
||||
anyFailed = true
|
||||
logger.postFailed({
|
||||
slug: result.slug,
|
||||
error: result.error ?? 'unknown',
|
||||
durationMs: result.durationMs,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const exitCode = anyFailed ? 1 : 0
|
||||
const summary = logger.finalize(exitCode)
|
||||
await Deno.mkdir('./logs', { recursive: true })
|
||||
const logPath = `./logs/publish-${new Date().toISOString().replace(/[:.]/g, '-')}.json`
|
||||
await logger.writeJson(logPath, summary)
|
||||
console.log(`log: ${logPath}`)
|
||||
return exitCode
|
||||
}
|
||||
|
||||
async function cmdDelete(flags: {
|
||||
eventIds: string[]
|
||||
reason?: string
|
||||
}): Promise<number> {
|
||||
if (flags.eventIds.length === 0) {
|
||||
console.error('usage: delete --event-id <hex> [--event-id <hex> ...] [--reason "text"]')
|
||||
return 2
|
||||
}
|
||||
const config = loadConfig()
|
||||
console.log('[1/2] signer…')
|
||||
const signer = await createBunkerSigner(config.bunkerUrl, {
|
||||
clientSecretHex: config.clientSecretHex,
|
||||
})
|
||||
console.log('[2/2] outbox…')
|
||||
const outbox = await loadOutbox(config.bootstrapRelay, config.authorPubkeyHex)
|
||||
if (outbox.write.length === 0) {
|
||||
console.error('no write relays in kind:10002')
|
||||
return 1
|
||||
}
|
||||
console.log(`deleting ${flags.eventIds.length} event(s) on ${outbox.write.length} relay(s)…`)
|
||||
const result = await runDelete({
|
||||
eventIds: flags.eventIds,
|
||||
reason: flags.reason,
|
||||
signer,
|
||||
writeRelays: outbox.write,
|
||||
pubkeyHex: config.authorPubkeyHex,
|
||||
clientTag: config.clientTag,
|
||||
minRelayAcks: config.minRelayAcks,
|
||||
})
|
||||
console.log(`delete-event id: ${result.deleteEventId}`)
|
||||
console.log(`relays ok: ${result.relaysOk.join(', ')}`)
|
||||
console.log(`relays failed: ${result.relaysFailed.join(', ') || '(none)'}`)
|
||||
return result.ok ? 0 : 1
|
||||
}
|
||||
|
||||
async function main(): Promise<number> {
|
||||
const args = parseArgs(Deno.args, {
|
||||
boolean: ['force-all', 'dry-run'],
|
||||
string: ['post', 'event-id', 'reason'],
|
||||
collect: ['event-id'],
|
||||
})
|
||||
const sub = args._[0]
|
||||
if (sub === 'check') return cmdCheck()
|
||||
if (sub === 'validate-post') return cmdValidatePost(args._[1] as string | undefined)
|
||||
if (sub === 'publish') {
|
||||
return cmdPublish({
|
||||
forceAll: args['force-all'] === true,
|
||||
post: args.post,
|
||||
dryRun: args['dry-run'] === true,
|
||||
})
|
||||
}
|
||||
if (sub === 'delete') {
|
||||
return cmdDelete({
|
||||
eventIds: (args['event-id'] as string[]) ?? [],
|
||||
reason: args.reason,
|
||||
})
|
||||
}
|
||||
console.error('usage: cli.ts <publish | check | validate-post | delete> [flags]')
|
||||
return 2
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
Deno.exit(await main())
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { Relay } from 'applesauce-relay'
|
||||
import { firstValueFrom, timeout } from 'rxjs'
|
||||
import type { SignedEvent } from './relays.ts'
|
||||
|
||||
export function parseBlossomServers(ev: { tags: string[][] }): string[] {
|
||||
return ev.tags
|
||||
.filter((t) => t[0] === 'server' && t[1])
|
||||
.map((t) => t[1].replace(/\/$/, ''))
|
||||
}
|
||||
|
||||
export async function loadBlossomServers(
|
||||
bootstrapRelay: string,
|
||||
authorPubkeyHex: string,
|
||||
): Promise<string[]> {
|
||||
const relay = new Relay(bootstrapRelay)
|
||||
const ev = await firstValueFrom(
|
||||
relay
|
||||
.request({ kinds: [10063], authors: [authorPubkeyHex], limit: 1 })
|
||||
.pipe(timeout({ first: 10_000 })),
|
||||
) as SignedEvent
|
||||
return parseBlossomServers(ev)
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
import { encodeBase64 } from '@std/encoding/base64'
|
||||
import type { Signer } from './signer.ts'
|
||||
import type { UnsignedEvent } from './event.ts'
|
||||
|
||||
export interface BlossomClient {
|
||||
fetch(url: string, init: RequestInit): Promise<Response>
|
||||
}
|
||||
|
||||
export interface UploadArgs {
|
||||
data: Uint8Array
|
||||
fileName: string
|
||||
mimeType: string
|
||||
servers: string[]
|
||||
signer: Signer
|
||||
client?: BlossomClient
|
||||
}
|
||||
|
||||
export interface UploadReport {
|
||||
ok: string[]
|
||||
failed: string[]
|
||||
primaryUrl: string
|
||||
sha256: string
|
||||
}
|
||||
|
||||
async function sha256Hex(data: Uint8Array): Promise<string> {
|
||||
const hash = await crypto.subtle.digest('SHA-256', data as BufferSource)
|
||||
return Array.from(new Uint8Array(hash))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
}
|
||||
|
||||
async function buildAuth(signer: Signer, hash: string): Promise<string> {
|
||||
const pubkey = await signer.getPublicKey()
|
||||
const auth: UnsignedEvent = {
|
||||
kind: 24242,
|
||||
pubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
['t', 'upload'],
|
||||
['x', hash],
|
||||
['expiration', String(Math.floor(Date.now() / 1000) + 300)],
|
||||
],
|
||||
content: '',
|
||||
}
|
||||
const signed = await signer.signEvent(auth)
|
||||
return 'Nostr ' + encodeBase64(new TextEncoder().encode(JSON.stringify(signed)))
|
||||
}
|
||||
|
||||
async function uploadOne(
|
||||
server: string,
|
||||
data: Uint8Array,
|
||||
mimeType: string,
|
||||
auth: string,
|
||||
client: BlossomClient,
|
||||
): Promise<{ ok: boolean; url?: string }> {
|
||||
try {
|
||||
const resp = await client.fetch(server + '/upload', {
|
||||
method: 'PUT',
|
||||
headers: { authorization: auth, 'content-type': mimeType },
|
||||
body: data as BodyInit,
|
||||
})
|
||||
if (!resp.ok) return { ok: false }
|
||||
const json = await resp.json()
|
||||
return { ok: true, url: json.url }
|
||||
} catch {
|
||||
return { ok: false }
|
||||
}
|
||||
}
|
||||
|
||||
const defaultClient: BlossomClient = { fetch: (u, i) => fetch(u, i) }
|
||||
|
||||
export async function uploadBlob(args: UploadArgs): Promise<UploadReport> {
|
||||
const client = args.client ?? defaultClient
|
||||
const hash = await sha256Hex(args.data)
|
||||
const auth = await buildAuth(args.signer, hash)
|
||||
const results = await Promise.all(
|
||||
args.servers.map((s) =>
|
||||
uploadOne(s, args.data, args.mimeType, auth, client).then((r) => ({ server: s, ...r }))
|
||||
),
|
||||
)
|
||||
const ok = results.filter((r) => r.ok).map((r) => r.server)
|
||||
const failed = results.filter((r) => !r.ok).map((r) => r.server)
|
||||
if (ok.length === 0) {
|
||||
throw new Error(`all blossom servers failed for ${args.fileName}`)
|
||||
}
|
||||
const first = results.find((r) => r.ok && r.url)!
|
||||
return { ok, failed, primaryUrl: first.url!, sha256: hash }
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
export type GitRunner = (args: string[]) => Promise<string>
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
export function filterPostDirs(lines: string[], contentRoot: string): string[] {
|
||||
const root = contentRoot.replace(/\/$/, '')
|
||||
const prefix = root + '/'
|
||||
const indexRe = new RegExp(`^${escapeRegex(prefix)}([^/]+)/index\\.md$`)
|
||||
const assetRe = new RegExp(`^${escapeRegex(prefix)}([^/]+)/`)
|
||||
const drafts = prefix + '_'
|
||||
const dirs = new Set<string>()
|
||||
for (const line of lines) {
|
||||
const l = line.trim()
|
||||
if (!l) continue
|
||||
if (l.startsWith(drafts)) continue
|
||||
const indexMatch = l.match(indexRe)
|
||||
if (indexMatch) {
|
||||
dirs.add(`${prefix}${indexMatch[1]}`)
|
||||
continue
|
||||
}
|
||||
const assetMatch = l.match(assetRe)
|
||||
if (assetMatch && !l.endsWith('.md')) {
|
||||
dirs.add(`${prefix}${assetMatch[1]}`)
|
||||
}
|
||||
}
|
||||
return [...dirs].sort()
|
||||
}
|
||||
|
||||
const defaultRunner: GitRunner = async (args) => {
|
||||
const proc = new Deno.Command('git', { args, stdout: 'piped', stderr: 'piped' })
|
||||
const out = await proc.output()
|
||||
if (out.code !== 0) {
|
||||
throw new Error(`git ${args.join(' ')} failed: ${new TextDecoder().decode(out.stderr)}`)
|
||||
}
|
||||
return new TextDecoder().decode(out.stdout)
|
||||
}
|
||||
|
||||
export interface DiffArgs {
|
||||
from: string
|
||||
to: string
|
||||
contentRoot: string
|
||||
runner?: GitRunner
|
||||
}
|
||||
|
||||
export async function changedPostDirs(args: DiffArgs): Promise<string[]> {
|
||||
const runner = args.runner ?? defaultRunner
|
||||
const stdout = await runner(['diff', '--name-only', `${args.from}..${args.to}`])
|
||||
return filterPostDirs(stdout.split('\n'), args.contentRoot)
|
||||
}
|
||||
|
||||
export async function allPostDirs(contentRoot: string): Promise<string[]> {
|
||||
const result: string[] = []
|
||||
for await (const entry of Deno.readDir(contentRoot)) {
|
||||
if (entry.isDirectory && !entry.name.startsWith('_')) {
|
||||
const indexPath = `${contentRoot}/${entry.name}/index.md`
|
||||
try {
|
||||
const stat = await Deno.stat(indexPath)
|
||||
if (stat.isFile) result.push(`${contentRoot}/${entry.name}`)
|
||||
} catch {
|
||||
// skip folders without index.md
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.sort()
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
export interface Config {
|
||||
bunkerUrl: string
|
||||
authorPubkeyHex: string
|
||||
bootstrapRelay: string
|
||||
contentRoot: string
|
||||
clientTag: string
|
||||
minRelayAcks: number
|
||||
clientSecretHex?: string
|
||||
}
|
||||
|
||||
type EnvReader = (key: string) => string | undefined
|
||||
|
||||
const REQUIRED = ['BUNKER_URL', 'AUTHOR_PUBKEY_HEX', 'BOOTSTRAP_RELAY'] as const
|
||||
|
||||
const DEFAULTS = {
|
||||
CONTENT_ROOT: '../content/posts',
|
||||
CLIENT_TAG: '',
|
||||
MIN_RELAY_ACKS: '2',
|
||||
}
|
||||
|
||||
export function loadConfig(read: EnvReader = (k) => Deno.env.get(k)): Config {
|
||||
const missing: string[] = []
|
||||
const values: Record<string, string> = {}
|
||||
for (const key of REQUIRED) {
|
||||
const v = read(key)
|
||||
if (!v) missing.push(key)
|
||||
else values[key] = v
|
||||
}
|
||||
if (missing.length) {
|
||||
throw new Error(`Missing env: ${missing.join(', ')}`)
|
||||
}
|
||||
if (!/^[0-9a-f]{64}$/.test(values.AUTHOR_PUBKEY_HEX)) {
|
||||
throw new Error('AUTHOR_PUBKEY_HEX must be 64 lowercase hex characters')
|
||||
}
|
||||
const minAcksRaw = read('MIN_RELAY_ACKS') ?? DEFAULTS.MIN_RELAY_ACKS
|
||||
const minAcks = Number(minAcksRaw)
|
||||
if (!Number.isInteger(minAcks) || minAcks < 1) {
|
||||
throw new Error(`MIN_RELAY_ACKS must be a positive integer, got "${minAcksRaw}"`)
|
||||
}
|
||||
const clientSecretHex = read('CLIENT_SECRET_HEX')
|
||||
if (clientSecretHex && !/^[0-9a-f]{64}$/.test(clientSecretHex)) {
|
||||
throw new Error('CLIENT_SECRET_HEX must be 64 lowercase hex characters')
|
||||
}
|
||||
return {
|
||||
bunkerUrl: values.BUNKER_URL,
|
||||
authorPubkeyHex: values.AUTHOR_PUBKEY_HEX,
|
||||
bootstrapRelay: values.BOOTSTRAP_RELAY,
|
||||
contentRoot: read('CONTENT_ROOT') ?? DEFAULTS.CONTENT_ROOT,
|
||||
clientTag: read('CLIENT_TAG') ?? DEFAULTS.CLIENT_TAG,
|
||||
minRelayAcks: minAcks,
|
||||
clientSecretHex,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
import type { Frontmatter } from './frontmatter.ts'
|
||||
|
||||
export interface UnsignedEvent {
|
||||
kind: number
|
||||
pubkey: string
|
||||
created_at: number
|
||||
tags: string[][]
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface BuildArgs {
|
||||
fm: Frontmatter
|
||||
rewrittenBody: string
|
||||
coverUrl: string | undefined
|
||||
pubkeyHex: string
|
||||
clientTag: string
|
||||
nowSeconds: number
|
||||
additionalTags?: string[][]
|
||||
}
|
||||
|
||||
export function buildKind30023(args: BuildArgs): UnsignedEvent {
|
||||
const { fm, rewrittenBody, coverUrl, pubkeyHex, clientTag, nowSeconds, additionalTags } = args
|
||||
const publishedAt = Math.floor(fm.date.getTime() / 1000)
|
||||
const tags: string[][] = [
|
||||
['d', fm.slug],
|
||||
['title', fm.title],
|
||||
['published_at', String(publishedAt)],
|
||||
]
|
||||
if (fm.description) tags.push(['summary', fm.description])
|
||||
if (coverUrl) tags.push(['image', coverUrl])
|
||||
if (Array.isArray(fm.tags)) {
|
||||
for (const t of fm.tags) tags.push(['t', String(t)])
|
||||
}
|
||||
const lang = (fm.lang ?? 'de').toLowerCase()
|
||||
if (/^[a-z]{2}$/.test(lang)) {
|
||||
tags.push(['L', 'ISO-639-1'])
|
||||
tags.push(['l', lang, 'ISO-639-1'])
|
||||
}
|
||||
if (clientTag) tags.push(['client', clientTag])
|
||||
if (additionalTags) tags.push(...additionalTags)
|
||||
return {
|
||||
kind: 30023,
|
||||
pubkey: pubkeyHex,
|
||||
created_at: nowSeconds,
|
||||
tags,
|
||||
content: rewrittenBody,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
import { parse as parseYaml } from '@std/yaml'
|
||||
|
||||
export interface Author {
|
||||
name: string
|
||||
url?: string
|
||||
orcid?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface ImageEntry {
|
||||
file: string
|
||||
role?: 'cover'
|
||||
alt: string
|
||||
caption?: string
|
||||
license: string | 'UNKNOWN'
|
||||
authors: Author[] | 'UNKNOWN'
|
||||
source_url?: string
|
||||
modifications?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface Frontmatter {
|
||||
title: string
|
||||
slug: string
|
||||
date: Date
|
||||
description?: string
|
||||
image?: string
|
||||
cover?: { image?: string; alt?: string; caption?: string }
|
||||
tags?: string[]
|
||||
draft?: boolean
|
||||
license?: string
|
||||
lang?: string
|
||||
authors?: Author[]
|
||||
images?: ImageEntry[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export function parseFrontmatter(md: string): { fm: Frontmatter; body: string } {
|
||||
const match = md.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/)
|
||||
if (!match) {
|
||||
throw new Error('Frontmatter: no leading --- / --- block found')
|
||||
}
|
||||
const fm = parseYaml(match[1]) as Frontmatter
|
||||
if (!fm || typeof fm !== 'object') {
|
||||
throw new Error('Frontmatter: YAML did not produce an object')
|
||||
}
|
||||
return { fm, body: match[2] }
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import { extname, join } from '@std/path'
|
||||
|
||||
const IMG_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'])
|
||||
|
||||
const MIME_MAP: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.svg': 'image/svg+xml',
|
||||
}
|
||||
|
||||
const HUGO_DERIVATIVE = /_hu_[0-9a-f]+\./
|
||||
|
||||
export function mimeFromExt(filename: string): string {
|
||||
return MIME_MAP[extname(filename).toLowerCase()] ?? 'application/octet-stream'
|
||||
}
|
||||
|
||||
export interface ImageFile {
|
||||
fileName: string
|
||||
absolutePath: string
|
||||
data: Uint8Array
|
||||
mimeType: string
|
||||
}
|
||||
|
||||
export async function collectImages(postDir: string): Promise<ImageFile[]> {
|
||||
const results: ImageFile[] = []
|
||||
for await (const entry of Deno.readDir(postDir)) {
|
||||
if (!entry.isFile) continue
|
||||
if (HUGO_DERIVATIVE.test(entry.name)) continue
|
||||
const ext = extname(entry.name).toLowerCase()
|
||||
if (!IMG_EXTS.has(ext)) continue
|
||||
const abs = join(postDir, entry.name)
|
||||
const data = await Deno.readFile(abs)
|
||||
results.push({
|
||||
fileName: entry.name,
|
||||
absolutePath: abs,
|
||||
data,
|
||||
mimeType: mimeFromExt(entry.name),
|
||||
})
|
||||
}
|
||||
results.sort((a, b) => a.fileName.localeCompare(b.fileName))
|
||||
return results
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
export type RunMode = 'diff' | 'force-all' | 'post-single'
|
||||
|
||||
export interface PostLog {
|
||||
slug: string
|
||||
status: 'success' | 'failed' | 'skipped-draft'
|
||||
action?: 'new' | 'update'
|
||||
event_id?: string
|
||||
relays_ok?: string[]
|
||||
relays_failed?: string[]
|
||||
blossom_servers_ok?: string[]
|
||||
images_uploaded?: number
|
||||
duration_ms?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface RunLog {
|
||||
run_id: string
|
||||
started_at: string
|
||||
ended_at: string
|
||||
mode: RunMode
|
||||
posts: PostLog[]
|
||||
exit_code: number
|
||||
}
|
||||
|
||||
export interface SuccessArgs {
|
||||
slug: string
|
||||
action: 'new' | 'update'
|
||||
eventId: string
|
||||
relaysOk: string[]
|
||||
relaysFailed: string[]
|
||||
blossomServersOk: string[]
|
||||
imagesUploaded: number
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
export interface FailedArgs {
|
||||
slug: string
|
||||
error: string
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
export interface LoggerOptions {
|
||||
mode: RunMode
|
||||
runId: string
|
||||
print?: (line: string) => void
|
||||
now?: () => Date
|
||||
}
|
||||
|
||||
export interface Logger {
|
||||
postSuccess(args: SuccessArgs): void
|
||||
postFailed(args: FailedArgs): void
|
||||
postSkippedDraft(slug: string): void
|
||||
finalize(exitCode: number): RunLog
|
||||
writeJson(path: string, summary: RunLog): Promise<void>
|
||||
}
|
||||
|
||||
export function createLogger(opts: LoggerOptions): Logger {
|
||||
const print = opts.print ?? ((line: string) => console.log(line))
|
||||
const now = opts.now ?? (() => new Date())
|
||||
const posts: PostLog[] = []
|
||||
const startedAt = now().toISOString()
|
||||
return {
|
||||
postSuccess(a) {
|
||||
posts.push({
|
||||
slug: a.slug,
|
||||
status: 'success',
|
||||
action: a.action,
|
||||
event_id: a.eventId,
|
||||
relays_ok: a.relaysOk,
|
||||
relays_failed: a.relaysFailed,
|
||||
blossom_servers_ok: a.blossomServersOk,
|
||||
images_uploaded: a.imagesUploaded,
|
||||
duration_ms: a.durationMs,
|
||||
})
|
||||
print(
|
||||
`✓ ${a.slug} (${a.action}) — relays:${a.relaysOk.length}ok/${a.relaysFailed.length}fail — ${a.durationMs}ms`,
|
||||
)
|
||||
},
|
||||
postFailed(a) {
|
||||
posts.push({
|
||||
slug: a.slug,
|
||||
status: 'failed',
|
||||
error: a.error,
|
||||
duration_ms: a.durationMs,
|
||||
})
|
||||
print(`✗ ${a.slug} — ${a.error}`)
|
||||
},
|
||||
postSkippedDraft(slug) {
|
||||
posts.push({ slug, status: 'skipped-draft' })
|
||||
print(`- ${slug} (draft, skipped)`)
|
||||
},
|
||||
finalize(exitCode) {
|
||||
return {
|
||||
run_id: opts.runId,
|
||||
started_at: startedAt,
|
||||
ended_at: now().toISOString(),
|
||||
mode: opts.mode,
|
||||
posts,
|
||||
exit_code: exitCode,
|
||||
}
|
||||
},
|
||||
writeJson(path, summary) {
|
||||
return Deno.writeTextFile(path, JSON.stringify(summary, null, 2))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
const IMG_RE = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+=\d+x\d+)?\)/g
|
||||
|
||||
function isAbsolute(url: string): boolean {
|
||||
return /^(https?:)?\/\//i.test(url)
|
||||
}
|
||||
|
||||
export function rewriteImageUrls(md: string, mapping: Map<string, string>): string {
|
||||
return md.replace(IMG_RE, (full, alt: string, url: string) => {
|
||||
if (isAbsolute(url)) return full.replace(/\s+=\d+x\d+\)$/, ')')
|
||||
let decoded: string
|
||||
try {
|
||||
decoded = decodeURIComponent(url)
|
||||
} catch {
|
||||
decoded = url
|
||||
}
|
||||
const target = mapping.get(decoded) ?? mapping.get(url)
|
||||
if (!target) return full.replace(/\s+=\d+x\d+\)$/, ')')
|
||||
return ``
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveCoverUrl(
|
||||
coverRaw: string | undefined,
|
||||
mapping: Map<string, string>,
|
||||
): string | undefined {
|
||||
if (!coverRaw) return undefined
|
||||
if (isAbsolute(coverRaw)) return coverRaw
|
||||
return mapping.get(coverRaw)
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import { Relay } from 'applesauce-relay'
|
||||
import { firstValueFrom, timeout } from 'rxjs'
|
||||
import type { SignedEvent } from './relays.ts'
|
||||
|
||||
export interface Outbox {
|
||||
read: string[]
|
||||
write: string[]
|
||||
}
|
||||
|
||||
export function parseOutbox(ev: { tags: string[][] }): Outbox {
|
||||
const read: string[] = []
|
||||
const write: string[] = []
|
||||
for (const t of ev.tags) {
|
||||
if (t[0] !== 'r' || !t[1]) continue
|
||||
const marker = t[2]
|
||||
if (marker === 'read') read.push(t[1])
|
||||
else if (marker === 'write') write.push(t[1])
|
||||
else {
|
||||
read.push(t[1])
|
||||
write.push(t[1])
|
||||
}
|
||||
}
|
||||
return { read, write }
|
||||
}
|
||||
|
||||
export async function loadOutbox(
|
||||
bootstrapRelay: string,
|
||||
authorPubkeyHex: string,
|
||||
): Promise<Outbox> {
|
||||
const relay = new Relay(bootstrapRelay)
|
||||
const ev = await firstValueFrom(
|
||||
relay
|
||||
.request({ kinds: [10002], authors: [authorPubkeyHex], limit: 1 })
|
||||
.pipe(timeout({ first: 10_000 })),
|
||||
) as SignedEvent
|
||||
return parseOutbox(ev)
|
||||
}
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
import { Relay, RelayPool } from 'applesauce-relay'
|
||||
import { firstValueFrom, timeout } from 'rxjs'
|
||||
|
||||
export interface SignedEvent {
|
||||
id: string
|
||||
pubkey: string
|
||||
created_at: number
|
||||
kind: number
|
||||
tags: string[][]
|
||||
content: string
|
||||
sig: string
|
||||
}
|
||||
|
||||
export interface PublishResult {
|
||||
ok: boolean
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export type PublishFn = (url: string, ev: SignedEvent) => Promise<PublishResult>
|
||||
|
||||
export interface PublishOptions {
|
||||
publishFn?: PublishFn
|
||||
retries?: number
|
||||
timeoutMs?: number
|
||||
backoffMs?: number
|
||||
}
|
||||
|
||||
export interface RelaysReport {
|
||||
ok: string[]
|
||||
failed: string[]
|
||||
}
|
||||
|
||||
const defaultPool = new RelayPool()
|
||||
|
||||
const defaultPublish: PublishFn = async (url, ev) => {
|
||||
try {
|
||||
const relay = defaultPool.relay(url)
|
||||
const result = await firstValueFrom(relay.publish(ev).pipe(timeout({ first: 10_000 })))
|
||||
return { ok: result.ok, reason: result.message }
|
||||
} catch (err) {
|
||||
return { ok: false, reason: err instanceof Error ? err.message : String(err) }
|
||||
}
|
||||
}
|
||||
|
||||
async function publishOne(
|
||||
url: string,
|
||||
ev: SignedEvent,
|
||||
opts: Required<PublishOptions>,
|
||||
): Promise<boolean> {
|
||||
const total = opts.retries + 1
|
||||
for (let i = 0; i < total; i++) {
|
||||
let timerId: number | undefined
|
||||
const timeoutPromise = new Promise<PublishResult>((resolve) => {
|
||||
timerId = setTimeout(() => resolve({ ok: false, reason: 'timeout' }), opts.timeoutMs)
|
||||
})
|
||||
const res = await Promise.race([opts.publishFn(url, ev), timeoutPromise])
|
||||
if (timerId !== undefined) clearTimeout(timerId)
|
||||
if (res.ok) return true
|
||||
if (i < total - 1) await new Promise((r) => setTimeout(r, opts.backoffMs * Math.pow(3, i)))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export async function publishToRelays(
|
||||
urls: string[],
|
||||
ev: SignedEvent,
|
||||
options: PublishOptions = {},
|
||||
): Promise<RelaysReport> {
|
||||
const opts: Required<PublishOptions> = {
|
||||
publishFn: options.publishFn ?? defaultPublish,
|
||||
retries: options.retries ?? 2,
|
||||
timeoutMs: options.timeoutMs ?? 10_000,
|
||||
backoffMs: options.backoffMs ?? 1000,
|
||||
}
|
||||
const results = await Promise.all(
|
||||
urls.map(async (url) => ({ url, ok: await publishOne(url, ev, opts) })),
|
||||
)
|
||||
return {
|
||||
ok: results.filter((r) => r.ok).map((r) => r.url),
|
||||
failed: results.filter((r) => !r.ok).map((r) => r.url),
|
||||
}
|
||||
}
|
||||
|
||||
export type ExistingQuery = (url: string, pubkey: string, slug: string) => Promise<boolean>
|
||||
|
||||
const defaultExistingQuery: ExistingQuery = async (url, pubkey, slug) => {
|
||||
try {
|
||||
const relay = new Relay(url)
|
||||
const ev = await firstValueFrom(
|
||||
relay
|
||||
.request({ kinds: [30023], authors: [pubkey], '#d': [slug], limit: 1 })
|
||||
.pipe(timeout({ first: 5_000 })),
|
||||
)
|
||||
return !!ev
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkExisting(
|
||||
slug: string,
|
||||
pubkey: string,
|
||||
urls: string[],
|
||||
opts: { query?: ExistingQuery } = {},
|
||||
): Promise<boolean> {
|
||||
const query = opts.query ?? defaultExistingQuery
|
||||
const results = await Promise.all(urls.map((u) => query(u, pubkey, slug)))
|
||||
return results.some((r) => r)
|
||||
}
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
import { NostrConnectSigner, SimpleSigner } from 'applesauce-signers'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import type { UnsignedEvent } from './event.ts'
|
||||
import type { SignedEvent } from './relays.ts'
|
||||
|
||||
export interface Signer {
|
||||
getPublicKey(): Promise<string>
|
||||
signEvent(ev: UnsignedEvent): Promise<SignedEvent>
|
||||
}
|
||||
|
||||
const signerPool = new RelayPool()
|
||||
|
||||
NostrConnectSigner.subscriptionMethod = (relays, filters) => signerPool.req(relays, filters)
|
||||
NostrConnectSigner.publishMethod = (relays, event) => signerPool.event(relays, event)
|
||||
|
||||
// Workaround: amber sendet bei wiederholten connect-requests mit bereits
|
||||
// bekanntem secret "already connected" oder "no permission". applesauce-
|
||||
// signers wirft daraufhin unhandled rejections, weil der request intern
|
||||
// schon aufgelöst wurde. wir schlucken diese benannten fehler prozessweit.
|
||||
const BENIGN_CONNECT_ERRORS = ['already connected', 'no permission']
|
||||
|
||||
function isBenignConnectError(msg: string): boolean {
|
||||
const lower = msg.toLowerCase()
|
||||
return BENIGN_CONNECT_ERRORS.some((e) => lower.includes(e))
|
||||
}
|
||||
|
||||
globalThis.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => {
|
||||
const reason = e.reason
|
||||
const msg = reason instanceof Error ? reason.message : String(reason)
|
||||
if (isBenignConnectError(msg)) {
|
||||
e.preventDefault()
|
||||
}
|
||||
})
|
||||
|
||||
function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
|
||||
let timerId: number | undefined
|
||||
const timeoutPromise = new Promise<never>((_r, rej) => {
|
||||
timerId = setTimeout(() => rej(new Error(`${label} timeout`)), ms)
|
||||
})
|
||||
return Promise.race([p, timeoutPromise]).finally(() => {
|
||||
if (timerId !== undefined) clearTimeout(timerId)
|
||||
}) as Promise<T>
|
||||
}
|
||||
|
||||
export interface CreateSignerOptions {
|
||||
clientSecretHex?: string
|
||||
}
|
||||
|
||||
export async function createBunkerSigner(
|
||||
bunkerUrl: string,
|
||||
options: CreateSignerOptions = {},
|
||||
): Promise<Signer> {
|
||||
const { remote, relays, secret } = NostrConnectSigner.parseBunkerURI(bunkerUrl)
|
||||
console.log(` signer: setup (remote=${remote.slice(0, 8)}…, relays=${relays.length})`)
|
||||
// Stabile client-identität: ohne festen CLIENT_SECRET_HEX erzeugt
|
||||
// applesauce pro lauf einen zufälligen key, und amber sieht jeden lauf
|
||||
// als neue app → permissions greifen nie. mit festem key bleibt die
|
||||
// identität über läufe erhalten.
|
||||
const clientSigner = options.clientSecretHex
|
||||
? SimpleSigner.fromKey(options.clientSecretHex)
|
||||
: undefined
|
||||
const signer = new NostrConnectSigner({ relays, remote, signer: clientSigner })
|
||||
const clientPubkey = await signer.signer.getPublicKey()
|
||||
console.log(` signer: client-pubkey=${clientPubkey.slice(0, 8)}…`)
|
||||
// connect() beim ersten mal nötig (damit amber die app registriert);
|
||||
// bei späteren runs ist amber schon gepaired mit diesem client-pubkey
|
||||
// und antwortet auf get_public_key / sign_event ohne erneuten connect.
|
||||
// wir versuchen connect, schlucken benign errors, und fallen-back auf
|
||||
// manuelles open().
|
||||
try {
|
||||
await withTimeout(signer.connect(secret), 60_000, 'Bunker connect')
|
||||
console.log(' signer: connect ok')
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
if (!isBenignConnectError(msg)) throw err
|
||||
console.log(` signer: connect benign "${msg}", fallback to open+force`)
|
||||
await signer.open()
|
||||
;(signer as unknown as { isConnected: boolean }).isConnected = true
|
||||
}
|
||||
console.log(' signer: getPublicKey…')
|
||||
const pubkey = await withTimeout(signer.getPublicKey(), 30_000, 'Bunker getPublicKey')
|
||||
console.log(` signer: pubkey ok (${pubkey.slice(0, 8)}…)`)
|
||||
return {
|
||||
getPublicKey: () => Promise.resolve(pubkey),
|
||||
signEvent: async (ev: UnsignedEvent) => {
|
||||
const signed = await withTimeout(signer.signEvent(ev), 30_000, 'Bunker signEvent')
|
||||
return signed as SignedEvent
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
import type { Frontmatter } from './frontmatter.ts'
|
||||
|
||||
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/
|
||||
const DATE_STRING_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?$/
|
||||
|
||||
export function validateSlug(slug: string): void {
|
||||
if (!SLUG_RE.test(slug)) {
|
||||
throw new Error(`invalid slug: "${slug}" (must match ${SLUG_RE})`)
|
||||
}
|
||||
}
|
||||
|
||||
export function validatePost(fm: Frontmatter): void {
|
||||
if (!fm.title || typeof fm.title !== 'string') {
|
||||
throw new Error('missing/invalid title')
|
||||
}
|
||||
if (!fm.slug || typeof fm.slug !== 'string') {
|
||||
throw new Error('missing/invalid slug')
|
||||
}
|
||||
validateSlug(fm.slug)
|
||||
// Coerce string-dates (YAML `date: "2023-02-26"`) in-place zu Date.
|
||||
// Native YAML-Dates (`date: 2023-02-26` ohne quotes) kommen bereits als
|
||||
// Date-instanz aus dem yaml-parser.
|
||||
if (typeof fm.date === 'string' && DATE_STRING_RE.test(fm.date)) {
|
||||
const coerced = new Date(fm.date)
|
||||
if (!isNaN(coerced.getTime())) fm.date = coerced
|
||||
}
|
||||
if (!(fm.date instanceof Date) || isNaN(fm.date.getTime())) {
|
||||
throw new Error('missing/invalid date (expected YAML date or ISO-string)')
|
||||
}
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
import type { Config } from '../core/config.ts'
|
||||
import { createBunkerSigner } from '../core/signer.ts'
|
||||
import { loadOutbox } from '../core/outbox.ts'
|
||||
import { loadBlossomServers } from '../core/blossom-list.ts'
|
||||
|
||||
export interface CheckResult {
|
||||
ok: boolean
|
||||
issues: string[]
|
||||
}
|
||||
|
||||
export async function runCheck(config: Config): Promise<CheckResult> {
|
||||
const issues: string[] = []
|
||||
|
||||
try {
|
||||
const signer = await createBunkerSigner(config.bunkerUrl, {
|
||||
clientSecretHex: config.clientSecretHex,
|
||||
})
|
||||
const pk = await signer.getPublicKey()
|
||||
if (pk !== config.authorPubkeyHex) {
|
||||
issues.push(
|
||||
`bunker-pubkey (${pk}) matcht AUTHOR_PUBKEY_HEX (${config.authorPubkeyHex}) nicht`,
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
issues.push(`bunker-ping fehlgeschlagen: ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const outbox = await loadOutbox(config.bootstrapRelay, config.authorPubkeyHex)
|
||||
if (outbox.write.length === 0) {
|
||||
issues.push('kind:10002 hat keine write-relays — publiziere zuerst ein gültiges Event')
|
||||
}
|
||||
} catch (err) {
|
||||
issues.push(`kind:10002 laden: ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const servers = await loadBlossomServers(config.bootstrapRelay, config.authorPubkeyHex)
|
||||
if (servers.length === 0) {
|
||||
issues.push('kind:10063 hat keine server — publiziere zuerst ein gültiges Event')
|
||||
} else {
|
||||
for (const server of servers) {
|
||||
try {
|
||||
const resp = await fetch(server + '/', { method: 'HEAD' })
|
||||
if (!resp.ok && resp.status !== 405) {
|
||||
issues.push(`blossom-server ${server}: HTTP ${resp.status}`)
|
||||
}
|
||||
} catch (err) {
|
||||
issues.push(`blossom-server ${server}: ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
issues.push(`kind:10063 laden: ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
|
||||
return { ok: issues.length === 0, issues }
|
||||
}
|
||||
|
||||
export function printCheckResult(result: CheckResult): void {
|
||||
if (result.ok) {
|
||||
console.log('✓ pre-flight ok')
|
||||
return
|
||||
}
|
||||
console.error('✗ pre-flight issues:')
|
||||
for (const i of result.issues) console.error(` - ${i}`)
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
import type { Signer } from '../core/signer.ts'
|
||||
import type { UnsignedEvent } from '../core/event.ts'
|
||||
import { publishToRelays } from '../core/relays.ts'
|
||||
|
||||
export interface DeleteArgs {
|
||||
eventIds: string[]
|
||||
reason?: string
|
||||
signer: Signer
|
||||
writeRelays: string[]
|
||||
pubkeyHex: string
|
||||
clientTag: string
|
||||
minRelayAcks: number
|
||||
}
|
||||
|
||||
export interface DeleteResult {
|
||||
ok: boolean
|
||||
deleteEventId: string
|
||||
relaysOk: string[]
|
||||
relaysFailed: string[]
|
||||
}
|
||||
|
||||
export async function runDelete(args: DeleteArgs): Promise<DeleteResult> {
|
||||
const { eventIds, reason, signer, writeRelays, pubkeyHex, clientTag, minRelayAcks } = args
|
||||
const tags: string[][] = eventIds.map((id) => ['e', id])
|
||||
if (clientTag) tags.push(['client', clientTag])
|
||||
const unsigned: UnsignedEvent = {
|
||||
kind: 5,
|
||||
pubkey: pubkeyHex,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags,
|
||||
content: reason ?? '',
|
||||
}
|
||||
const signed = await signer.signEvent(unsigned)
|
||||
const report = await publishToRelays(writeRelays, signed)
|
||||
return {
|
||||
ok: report.ok.length >= minRelayAcks,
|
||||
deleteEventId: signed.id,
|
||||
relaysOk: report.ok,
|
||||
relaysFailed: report.failed,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
import { join } from '@std/path'
|
||||
import { type Frontmatter } from '../core/frontmatter.ts'
|
||||
import { validatePost } from '../core/validation.ts'
|
||||
import { buildKind30023, type UnsignedEvent } from '../core/event.ts'
|
||||
import { resolveCoverUrl, rewriteImageUrls } from '../core/markdown.ts'
|
||||
import type { ImageFile } from '../core/image-collector.ts'
|
||||
import type { RelaysReport, SignedEvent } from '../core/relays.ts'
|
||||
import type { UploadReport } from '../core/blossom.ts'
|
||||
|
||||
export interface PostDeps {
|
||||
readPostFile(path: string): Promise<{ fm: Frontmatter; body: string }>
|
||||
collectImages(postDir: string): Promise<ImageFile[]>
|
||||
uploadBlossom(args: {
|
||||
data: Uint8Array
|
||||
fileName: string
|
||||
mimeType: string
|
||||
}): Promise<UploadReport>
|
||||
sign(ev: UnsignedEvent): Promise<SignedEvent>
|
||||
publish(ev: SignedEvent, relays: string[]): Promise<RelaysReport>
|
||||
checkExisting(slug: string, relays: string[]): Promise<boolean>
|
||||
}
|
||||
|
||||
export interface ProcessArgs {
|
||||
postDir: string
|
||||
writeRelays: string[]
|
||||
blossomServers: string[]
|
||||
pubkeyHex: string
|
||||
clientTag: string
|
||||
minRelayAcks: number
|
||||
deps: PostDeps
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
export interface ProcessResult {
|
||||
status: 'success' | 'failed' | 'skipped-draft'
|
||||
action?: 'new' | 'update'
|
||||
slug: string
|
||||
eventId?: string
|
||||
relaysOk: string[]
|
||||
relaysFailed: string[]
|
||||
blossomServersOk: string[]
|
||||
imagesUploaded: number
|
||||
durationMs: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export async function processPost(args: ProcessArgs): Promise<ProcessResult> {
|
||||
const started = performance.now()
|
||||
const now = args.now ?? (() => Math.floor(Date.now() / 1000))
|
||||
let slug = '?'
|
||||
try {
|
||||
const { fm, body } = await args.deps.readPostFile(join(args.postDir, 'index.md'))
|
||||
validatePost(fm)
|
||||
slug = fm.slug
|
||||
|
||||
if (fm.draft === true) {
|
||||
return {
|
||||
status: 'skipped-draft',
|
||||
slug,
|
||||
relaysOk: [],
|
||||
relaysFailed: [],
|
||||
blossomServersOk: [],
|
||||
imagesUploaded: 0,
|
||||
durationMs: Math.round(performance.now() - started),
|
||||
}
|
||||
}
|
||||
|
||||
const images = await args.deps.collectImages(args.postDir)
|
||||
const blossomOkServers = new Set<string>()
|
||||
const mapping = new Map<string, string>()
|
||||
for (const img of images) {
|
||||
const rep = await args.deps.uploadBlossom({
|
||||
data: img.data,
|
||||
fileName: img.fileName,
|
||||
mimeType: img.mimeType,
|
||||
})
|
||||
for (const s of rep.ok) blossomOkServers.add(s)
|
||||
mapping.set(img.fileName, rep.primaryUrl)
|
||||
}
|
||||
|
||||
const rewrittenBody = rewriteImageUrls(body, mapping)
|
||||
const coverRaw = fm.cover?.image ?? fm.image
|
||||
const coverUrl = resolveCoverUrl(coverRaw, mapping)
|
||||
|
||||
const unsigned = buildKind30023({
|
||||
fm,
|
||||
rewrittenBody,
|
||||
coverUrl,
|
||||
pubkeyHex: args.pubkeyHex,
|
||||
clientTag: args.clientTag,
|
||||
nowSeconds: now(),
|
||||
})
|
||||
|
||||
const existing = await args.deps.checkExisting(fm.slug, args.writeRelays)
|
||||
const signed = await args.deps.sign(unsigned)
|
||||
const pubRep = await args.deps.publish(signed, args.writeRelays)
|
||||
if (pubRep.ok.length < args.minRelayAcks) {
|
||||
throw new Error(
|
||||
`insufficient relays acked (${pubRep.ok.length} < ${args.minRelayAcks})`,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
action: existing ? 'update' : 'new',
|
||||
slug,
|
||||
eventId: signed.id,
|
||||
relaysOk: pubRep.ok,
|
||||
relaysFailed: pubRep.failed,
|
||||
blossomServersOk: [...blossomOkServers],
|
||||
imagesUploaded: images.length,
|
||||
durationMs: Math.round(performance.now() - started),
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
status: 'failed',
|
||||
slug,
|
||||
relaysOk: [],
|
||||
relaysFailed: [],
|
||||
blossomServersOk: [],
|
||||
imagesUploaded: 0,
|
||||
durationMs: Math.round(performance.now() - started),
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import { parseFrontmatter } from '../core/frontmatter.ts'
|
||||
import { validatePost } from '../core/validation.ts'
|
||||
|
||||
export interface ValidateResult {
|
||||
ok: boolean
|
||||
slug?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export async function validatePostFile(path: string): Promise<ValidateResult> {
|
||||
let text: string
|
||||
try {
|
||||
text = await Deno.readTextFile(path)
|
||||
} catch (err) {
|
||||
return { ok: false, error: `cannot read ${path}: ${err instanceof Error ? err.message : err}` }
|
||||
}
|
||||
try {
|
||||
const { fm } = parseFrontmatter(text)
|
||||
validatePost(fm)
|
||||
return { ok: true, slug: fm.slug }
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
import { assertEquals } from '@std/assert'
|
||||
import { parseBlossomServers } from '../src/core/blossom-list.ts'
|
||||
|
||||
Deno.test('parseBlossomServers: extrahiert server-urls in reihenfolge', () => {
|
||||
const ev = {
|
||||
kind: 10063,
|
||||
tags: [
|
||||
['server', 'https://a.example'],
|
||||
['server', 'https://b.example'],
|
||||
['other', 'ignored'],
|
||||
],
|
||||
}
|
||||
assertEquals(parseBlossomServers(ev), ['https://a.example', 'https://b.example'])
|
||||
})
|
||||
|
||||
Deno.test('parseBlossomServers: leere liste bei fehlenden tags', () => {
|
||||
assertEquals(parseBlossomServers({ tags: [] }), [])
|
||||
})
|
||||
|
||||
Deno.test('parseBlossomServers: entfernt trailing-slash normalisierung', () => {
|
||||
const ev = {
|
||||
kind: 10063,
|
||||
tags: [
|
||||
['server', 'https://a.example/'],
|
||||
],
|
||||
}
|
||||
assertEquals(parseBlossomServers(ev), ['https://a.example'])
|
||||
})
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
import { assertEquals } from '@std/assert'
|
||||
import { type BlossomClient, uploadBlob } from '../src/core/blossom.ts'
|
||||
import type { Signer } from '../src/core/signer.ts'
|
||||
import type { UnsignedEvent } from '../src/core/event.ts'
|
||||
import type { SignedEvent } from '../src/core/relays.ts'
|
||||
|
||||
function fakeSigner(): Signer {
|
||||
return {
|
||||
getPublicKey: () => Promise.resolve('p'),
|
||||
signEvent: (ev: UnsignedEvent) =>
|
||||
Promise.resolve({ ...ev, id: 'id', sig: 'sig', pubkey: 'p' } as SignedEvent),
|
||||
}
|
||||
}
|
||||
|
||||
Deno.test('uploadBlob: pusht zu allen servern, gibt erste url zurück', async () => {
|
||||
const data = new Uint8Array([1, 2, 3])
|
||||
const client: BlossomClient = {
|
||||
fetch: (url, _init) => {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ url: url + '/hash.png', sha256: 'hash' }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
const result = await uploadBlob({
|
||||
data,
|
||||
fileName: 'x.png',
|
||||
mimeType: 'image/png',
|
||||
servers: ['https://a.example', 'https://b.example'],
|
||||
signer: fakeSigner(),
|
||||
client,
|
||||
})
|
||||
assertEquals(result.ok.length, 2)
|
||||
assertEquals(result.primaryUrl, 'https://a.example/upload/hash.png')
|
||||
})
|
||||
|
||||
Deno.test('uploadBlob: akzeptiert wenn mindestens ein server ok', async () => {
|
||||
const data = new Uint8Array([1])
|
||||
const client: BlossomClient = {
|
||||
fetch: (url) => {
|
||||
if (url.startsWith('https://fail.example')) {
|
||||
return Promise.resolve(new Response('nope', { status: 500 }))
|
||||
}
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ url: url + '/h.png', sha256: 'h' }), { status: 200 }),
|
||||
)
|
||||
},
|
||||
}
|
||||
const result = await uploadBlob({
|
||||
data,
|
||||
fileName: 'x.png',
|
||||
mimeType: 'image/png',
|
||||
servers: ['https://fail.example', 'https://ok.example'],
|
||||
signer: fakeSigner(),
|
||||
client,
|
||||
})
|
||||
assertEquals(result.ok, ['https://ok.example'])
|
||||
assertEquals(result.failed, ['https://fail.example'])
|
||||
})
|
||||
|
||||
Deno.test('uploadBlob: wirft wenn alle server ablehnen', async () => {
|
||||
const data = new Uint8Array([1])
|
||||
const client: BlossomClient = {
|
||||
fetch: () => Promise.resolve(new Response('err', { status: 500 })),
|
||||
}
|
||||
let threw = false
|
||||
try {
|
||||
await uploadBlob({
|
||||
data,
|
||||
fileName: 'x.png',
|
||||
mimeType: 'image/png',
|
||||
servers: ['https://a.example'],
|
||||
signer: fakeSigner(),
|
||||
client,
|
||||
})
|
||||
} catch (err) {
|
||||
threw = true
|
||||
assertEquals(String(err).includes('all blossom servers failed'), true)
|
||||
}
|
||||
assertEquals(threw, true)
|
||||
})
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
import { assertEquals } from '@std/assert'
|
||||
import {
|
||||
changedPostDirs,
|
||||
filterPostDirs,
|
||||
type GitRunner,
|
||||
} from '../src/core/change-detection.ts'
|
||||
|
||||
Deno.test('filterPostDirs: extrahiert post-ordner aus dateipfaden (content/posts)', () => {
|
||||
const lines = [
|
||||
'content/posts/a/index.md',
|
||||
'content/posts/b/image.png',
|
||||
'content/posts/c/other.md',
|
||||
'README.md',
|
||||
'app/src/lib/x.ts',
|
||||
]
|
||||
assertEquals(
|
||||
filterPostDirs(lines, 'content/posts').sort(),
|
||||
['content/posts/a', 'content/posts/b'],
|
||||
)
|
||||
})
|
||||
|
||||
Deno.test('filterPostDirs: respektiert alternativen root (blog/)', () => {
|
||||
const lines = [
|
||||
'blog/x/index.md',
|
||||
'blog/y/pic.png',
|
||||
'content/posts/z/index.md',
|
||||
'README.md',
|
||||
]
|
||||
assertEquals(filterPostDirs(lines, 'blog').sort(), ['blog/x', 'blog/y'])
|
||||
})
|
||||
|
||||
Deno.test('filterPostDirs: ignoriert _drafts und non-index.md', () => {
|
||||
const lines = [
|
||||
'content/posts/a/index.md',
|
||||
'content/posts/a/extra.md',
|
||||
'content/posts/_drafts/x/index.md',
|
||||
]
|
||||
assertEquals(filterPostDirs(lines, 'content/posts'), ['content/posts/a'])
|
||||
})
|
||||
|
||||
Deno.test('changedPostDirs: nutzt git diff --name-only A..B', async () => {
|
||||
const runner: GitRunner = (args) => {
|
||||
assertEquals(args[0], 'diff')
|
||||
assertEquals(args[1], '--name-only')
|
||||
assertEquals(args[2], 'HEAD~1..HEAD')
|
||||
return Promise.resolve('content/posts/x/index.md\nREADME.md\n')
|
||||
}
|
||||
const dirs = await changedPostDirs({
|
||||
from: 'HEAD~1',
|
||||
to: 'HEAD',
|
||||
contentRoot: 'content/posts',
|
||||
runner,
|
||||
})
|
||||
assertEquals(dirs, ['content/posts/x'])
|
||||
})
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
import { assertEquals, assertThrows } from '@std/assert'
|
||||
import { loadConfig } from '../src/core/config.ts'
|
||||
|
||||
const REQUIRED = {
|
||||
BUNKER_URL: 'bunker://abc?relay=wss://r.example&secret=s',
|
||||
AUTHOR_PUBKEY_HEX: '4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41',
|
||||
BOOTSTRAP_RELAY: 'wss://relay.damus.io',
|
||||
}
|
||||
|
||||
Deno.test('loadConfig: liest alle pflicht-keys aus env', () => {
|
||||
const cfg = loadConfig((k) => REQUIRED[k as keyof typeof REQUIRED])
|
||||
assertEquals(cfg.bunkerUrl, REQUIRED.BUNKER_URL)
|
||||
assertEquals(cfg.authorPubkeyHex, REQUIRED.AUTHOR_PUBKEY_HEX)
|
||||
assertEquals(cfg.bootstrapRelay, REQUIRED.BOOTSTRAP_RELAY)
|
||||
})
|
||||
|
||||
Deno.test('loadConfig: liefert defaults für optionale keys', () => {
|
||||
const cfg = loadConfig((k) => REQUIRED[k as keyof typeof REQUIRED])
|
||||
assertEquals(cfg.contentRoot, '../content/posts')
|
||||
assertEquals(cfg.clientTag, '')
|
||||
assertEquals(cfg.minRelayAcks, 2)
|
||||
})
|
||||
|
||||
Deno.test('loadConfig: optionale keys können überschrieben werden', () => {
|
||||
const env = {
|
||||
...REQUIRED,
|
||||
CONTENT_ROOT: '../blog',
|
||||
CLIENT_TAG: 'my-site',
|
||||
MIN_RELAY_ACKS: '3',
|
||||
}
|
||||
const cfg = loadConfig((k) => env[k as keyof typeof env])
|
||||
assertEquals(cfg.contentRoot, '../blog')
|
||||
assertEquals(cfg.clientTag, 'my-site')
|
||||
assertEquals(cfg.minRelayAcks, 3)
|
||||
})
|
||||
|
||||
Deno.test('loadConfig: wirft bei fehlender pflicht-variable', () => {
|
||||
assertThrows(() => loadConfig(() => undefined), Error, 'BUNKER_URL')
|
||||
})
|
||||
|
||||
Deno.test('loadConfig: validiert pubkey-format (64 hex)', () => {
|
||||
const env = { ...REQUIRED, AUTHOR_PUBKEY_HEX: 'zzz' }
|
||||
assertThrows(
|
||||
() => loadConfig((k) => env[k as keyof typeof env]),
|
||||
Error,
|
||||
'AUTHOR_PUBKEY_HEX',
|
||||
)
|
||||
})
|
||||
|
||||
Deno.test('loadConfig: MIN_RELAY_ACKS muss positiv sein', () => {
|
||||
const env = { ...REQUIRED, MIN_RELAY_ACKS: '0' }
|
||||
assertThrows(
|
||||
() => loadConfig((k) => env[k as keyof typeof env]),
|
||||
Error,
|
||||
'MIN_RELAY_ACKS',
|
||||
)
|
||||
})
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
import { assertEquals } from '@std/assert'
|
||||
import { buildKind30023 } from '../src/core/event.ts'
|
||||
import type { Frontmatter } from '../src/core/frontmatter.ts'
|
||||
|
||||
const PUBKEY = '4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41'
|
||||
|
||||
Deno.test('buildKind30023: minimaler Post liefert alle Pflicht-Tags', () => {
|
||||
const fm: Frontmatter = {
|
||||
title: 'Hello',
|
||||
slug: 'hello',
|
||||
date: new Date('2024-01-15T00:00:00Z'),
|
||||
}
|
||||
const ev = buildKind30023({
|
||||
fm,
|
||||
rewrittenBody: 'body text',
|
||||
coverUrl: undefined,
|
||||
pubkeyHex: PUBKEY,
|
||||
clientTag: 'test-client',
|
||||
nowSeconds: 1_700_000_000,
|
||||
})
|
||||
assertEquals(ev.kind, 30023)
|
||||
assertEquals(ev.pubkey, PUBKEY)
|
||||
assertEquals(ev.created_at, 1_700_000_000)
|
||||
assertEquals(ev.content, 'body text')
|
||||
const tags = ev.tags
|
||||
assertEquals(tags.find((t) => t[0] === 'd'), ['d', 'hello'])
|
||||
assertEquals(tags.find((t) => t[0] === 'title'), ['title', 'Hello'])
|
||||
assertEquals(
|
||||
tags.find((t) => t[0] === 'published_at')?.[1],
|
||||
String(Math.floor(Date.UTC(2024, 0, 15) / 1000)),
|
||||
)
|
||||
assertEquals(tags.find((t) => t[0] === 'client'), ['client', 'test-client'])
|
||||
})
|
||||
|
||||
Deno.test('buildKind30023: mapping summary / image / tags', () => {
|
||||
const fm: Frontmatter = {
|
||||
title: 'T',
|
||||
slug: 's',
|
||||
date: new Date('2024-01-01'),
|
||||
description: 'Summary text',
|
||||
tags: ['Foo', 'Bar Baz'],
|
||||
}
|
||||
const ev = buildKind30023({
|
||||
fm,
|
||||
rewrittenBody: 'b',
|
||||
coverUrl: 'https://bl.example/cover-hash.png',
|
||||
pubkeyHex: PUBKEY,
|
||||
clientTag: 'x',
|
||||
nowSeconds: 1,
|
||||
})
|
||||
assertEquals(ev.tags.find((t) => t[0] === 'summary'), ['summary', 'Summary text'])
|
||||
assertEquals(
|
||||
ev.tags.find((t) => t[0] === 'image'),
|
||||
['image', 'https://bl.example/cover-hash.png'],
|
||||
)
|
||||
assertEquals(
|
||||
ev.tags.filter((t) => t[0] === 't'),
|
||||
[['t', 'Foo'], ['t', 'Bar Baz']],
|
||||
)
|
||||
})
|
||||
|
||||
Deno.test('buildKind30023: ohne coverUrl kein image-tag', () => {
|
||||
const fm: Frontmatter = {
|
||||
title: 'T',
|
||||
slug: 's',
|
||||
date: new Date('2024-01-01'),
|
||||
}
|
||||
const ev = buildKind30023({
|
||||
fm,
|
||||
rewrittenBody: 'b',
|
||||
coverUrl: undefined,
|
||||
pubkeyHex: PUBKEY,
|
||||
clientTag: 'x',
|
||||
nowSeconds: 1,
|
||||
})
|
||||
assertEquals(ev.tags.some((t) => t[0] === 'image'), false)
|
||||
})
|
||||
|
||||
Deno.test('buildKind30023: leerer clientTag wird weggelassen', () => {
|
||||
const fm: Frontmatter = {
|
||||
title: 'T',
|
||||
slug: 's',
|
||||
date: new Date('2024-01-01'),
|
||||
}
|
||||
const ev = buildKind30023({
|
||||
fm,
|
||||
rewrittenBody: 'b',
|
||||
coverUrl: undefined,
|
||||
pubkeyHex: PUBKEY,
|
||||
clientTag: '',
|
||||
nowSeconds: 1,
|
||||
})
|
||||
assertEquals(ev.tags.some((t) => t[0] === 'client'), false)
|
||||
})
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
---
|
||||
layout: post
|
||||
title: "Sample Title"
|
||||
slug: "sample-slug"
|
||||
description: "A short summary"
|
||||
image: cover.png
|
||||
cover:
|
||||
image: cover.png
|
||||
alt: "Alt text"
|
||||
date: 2024-01-15
|
||||
tags: ["Foo", "Bar"]
|
||||
draft: false
|
||||
---
|
||||
|
||||
Body content here.
|
||||
|
||||

|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import { assertEquals, assertThrows } from '@std/assert'
|
||||
import { parseFrontmatter } from '../src/core/frontmatter.ts'
|
||||
|
||||
Deno.test('parseFrontmatter: zerlegt Frontmatter und Body', async () => {
|
||||
const md = await Deno.readTextFile('./tests/fixtures/sample-post.md')
|
||||
const { fm, body } = parseFrontmatter(md)
|
||||
assertEquals(fm.title, 'Sample Title')
|
||||
assertEquals(fm.slug, 'sample-slug')
|
||||
assertEquals(fm.date instanceof Date, true)
|
||||
assertEquals(fm.tags, ['Foo', 'Bar'])
|
||||
assertEquals(fm.cover?.image, 'cover.png')
|
||||
assertEquals(body.trim().startsWith('Body content here.'), true)
|
||||
})
|
||||
|
||||
Deno.test('parseFrontmatter: wirft bei fehlendem Frontmatter', () => {
|
||||
assertThrows(() => parseFrontmatter('no frontmatter here'), Error, 'Frontmatter')
|
||||
})
|
||||
|
||||
Deno.test('parseFrontmatter: wirft bei unvollständigem Frontmatter', () => {
|
||||
assertThrows(() => parseFrontmatter('---\ntitle: x\n'), Error, 'Frontmatter')
|
||||
})
|
||||
|
||||
Deno.test('parseFrontmatter: erhält Leerzeichen in String-Werten', () => {
|
||||
const md = '---\ntitle: "Hello World"\nslug: "h-w"\ndate: 2024-01-01\n---\n\nbody'
|
||||
const { fm } = parseFrontmatter(md)
|
||||
assertEquals(fm.title, 'Hello World')
|
||||
})
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import { assertEquals } from '@std/assert'
|
||||
import { collectImages, mimeFromExt } from '../src/core/image-collector.ts'
|
||||
|
||||
Deno.test('mimeFromExt: erkennt gängige formate', () => {
|
||||
assertEquals(mimeFromExt('a.png'), 'image/png')
|
||||
assertEquals(mimeFromExt('a.jpg'), 'image/jpeg')
|
||||
assertEquals(mimeFromExt('a.jpeg'), 'image/jpeg')
|
||||
assertEquals(mimeFromExt('a.gif'), 'image/gif')
|
||||
assertEquals(mimeFromExt('a.webp'), 'image/webp')
|
||||
assertEquals(mimeFromExt('a.svg'), 'image/svg+xml')
|
||||
})
|
||||
|
||||
Deno.test('collectImages: liest alle bild-dateien im ordner, ignoriert hugo-derivate', async () => {
|
||||
const tmp = await Deno.makeTempDir()
|
||||
try {
|
||||
await Deno.writeTextFile(`${tmp}/index.md`, '# hi')
|
||||
await Deno.writeFile(`${tmp}/a.png`, new Uint8Array([1]))
|
||||
await Deno.writeFile(`${tmp}/b.jpg`, new Uint8Array([2]))
|
||||
await Deno.writeFile(`${tmp}/a_hu_deadbeef.png`, new Uint8Array([3]))
|
||||
await Deno.writeTextFile(`${tmp}/notes.txt`, 'ignore me')
|
||||
const imgs = await collectImages(tmp)
|
||||
assertEquals(imgs.map((i) => i.fileName).sort(), ['a.png', 'b.jpg'])
|
||||
assertEquals(imgs.find((i) => i.fileName === 'a.png')?.mimeType, 'image/png')
|
||||
} finally {
|
||||
await Deno.remove(tmp, { recursive: true })
|
||||
}
|
||||
})
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import { assertEquals } from '@std/assert'
|
||||
import { createLogger } from '../src/core/log.ts'
|
||||
|
||||
Deno.test('logger: sammelt post-einträge und schreibt summary', () => {
|
||||
const sink: string[] = []
|
||||
const logger = createLogger({
|
||||
mode: 'force-all',
|
||||
runId: 'run-1',
|
||||
print: (line) => sink.push(line),
|
||||
now: () => new Date('2026-04-16T10:00:00Z'),
|
||||
})
|
||||
logger.postSuccess({
|
||||
slug: 's1',
|
||||
action: 'new',
|
||||
eventId: 'ev1',
|
||||
relaysOk: ['wss://r1'],
|
||||
relaysFailed: [],
|
||||
blossomServersOk: [],
|
||||
imagesUploaded: 0,
|
||||
durationMs: 10,
|
||||
})
|
||||
logger.postSkippedDraft('s2')
|
||||
const summary = logger.finalize(0)
|
||||
assertEquals(summary.run_id, 'run-1')
|
||||
assertEquals(summary.mode, 'force-all')
|
||||
assertEquals(summary.posts.length, 2)
|
||||
assertEquals(summary.posts[0].status, 'success')
|
||||
assertEquals(summary.posts[1].status, 'skipped-draft')
|
||||
assertEquals(summary.exit_code, 0)
|
||||
assertEquals(sink.some((s) => s.includes('s1')), true)
|
||||
})
|
||||
|
||||
Deno.test('logger: writeJson schreibt datei', async () => {
|
||||
const tmp = await Deno.makeTempDir()
|
||||
try {
|
||||
const logger = createLogger({
|
||||
mode: 'diff',
|
||||
runId: 'run-2',
|
||||
print: () => {},
|
||||
now: () => new Date('2026-04-16T10:00:00Z'),
|
||||
})
|
||||
const summary = logger.finalize(0)
|
||||
await logger.writeJson(`${tmp}/out.json`, summary)
|
||||
const text = await Deno.readTextFile(`${tmp}/out.json`)
|
||||
const parsed = JSON.parse(text)
|
||||
assertEquals(parsed.run_id, 'run-2')
|
||||
} finally {
|
||||
await Deno.remove(tmp, { recursive: true })
|
||||
}
|
||||
})
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
import { assertEquals } from '@std/assert'
|
||||
import { rewriteImageUrls } from '../src/core/markdown.ts'
|
||||
|
||||
Deno.test('rewriteImageUrls: ersetzt  durch Mapping', () => {
|
||||
const mapping = new Map([['cat.png', 'https://blossom.example/hash.png']])
|
||||
const input = ''
|
||||
assertEquals(rewriteImageUrls(input, mapping), '')
|
||||
})
|
||||
|
||||
Deno.test('rewriteImageUrls: absolute URL bleibt unverändert', () => {
|
||||
const mapping = new Map([['cat.png', 'https://blossom.example/hash.png']])
|
||||
const input = ''
|
||||
assertEquals(rewriteImageUrls(input, mapping), input)
|
||||
})
|
||||
|
||||
Deno.test('rewriteImageUrls: entfernt =WxH-Suffix', () => {
|
||||
const mapping = new Map([['cat.png', 'https://blossom.example/hash.png']])
|
||||
const input = ''
|
||||
assertEquals(rewriteImageUrls(input, mapping), '')
|
||||
})
|
||||
|
||||
Deno.test('rewriteImageUrls: bild-in-link [](link)', () => {
|
||||
const mapping = new Map([['cat.png', 'https://blossom.example/hash.png']])
|
||||
const input = '[](https://target.example.com)'
|
||||
assertEquals(
|
||||
rewriteImageUrls(input, mapping),
|
||||
'[](https://target.example.com)',
|
||||
)
|
||||
})
|
||||
|
||||
Deno.test('rewriteImageUrls: mehrere Bilder im Text', () => {
|
||||
const mapping = new Map([
|
||||
['a.png', 'https://bl/a-hash.png'],
|
||||
['b.jpg', 'https://bl/b-hash.jpg'],
|
||||
])
|
||||
const input = 'Text  more  end'
|
||||
assertEquals(
|
||||
rewriteImageUrls(input, mapping),
|
||||
'Text  more  end',
|
||||
)
|
||||
})
|
||||
|
||||
Deno.test('rewriteImageUrls: lässt unbekannte Dateinamen stehen', () => {
|
||||
const mapping = new Map([['cat.png', 'https://bl/c.png']])
|
||||
const input = ''
|
||||
assertEquals(rewriteImageUrls(input, mapping), input)
|
||||
})
|
||||
|
||||
Deno.test('rewriteImageUrls: URL-Dekodierung für Leerzeichen-Namen', () => {
|
||||
const mapping = new Map([['file with spaces.png', 'https://bl/hash.png']])
|
||||
const input = ''
|
||||
assertEquals(rewriteImageUrls(input, mapping), '')
|
||||
})
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import { assertEquals } from '@std/assert'
|
||||
import { parseOutbox } from '../src/core/outbox.ts'
|
||||
|
||||
Deno.test('parseOutbox: r-tags ohne marker → beide', () => {
|
||||
const ev = {
|
||||
kind: 10002,
|
||||
tags: [
|
||||
['r', 'wss://damus'],
|
||||
['r', 'wss://nos'],
|
||||
],
|
||||
}
|
||||
assertEquals(parseOutbox(ev), {
|
||||
read: ['wss://damus', 'wss://nos'],
|
||||
write: ['wss://damus', 'wss://nos'],
|
||||
})
|
||||
})
|
||||
|
||||
Deno.test('parseOutbox: marker read ignoriert schreib-nutzung', () => {
|
||||
const ev = {
|
||||
kind: 10002,
|
||||
tags: [
|
||||
['r', 'wss://r-only', 'read'],
|
||||
['r', 'wss://w-only', 'write'],
|
||||
['r', 'wss://both'],
|
||||
],
|
||||
}
|
||||
assertEquals(parseOutbox(ev), {
|
||||
read: ['wss://r-only', 'wss://both'],
|
||||
write: ['wss://w-only', 'wss://both'],
|
||||
})
|
||||
})
|
||||
|
||||
Deno.test('parseOutbox: ignoriert andere tag-namen', () => {
|
||||
const ev = {
|
||||
kind: 10002,
|
||||
tags: [
|
||||
['r', 'wss://x'],
|
||||
['p', 'someone'],
|
||||
],
|
||||
}
|
||||
assertEquals(parseOutbox(ev), { read: ['wss://x'], write: ['wss://x'] })
|
||||
})
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
import { assertEquals } from '@std/assert'
|
||||
import { type PostDeps, processPost } from '../src/subcommands/publish.ts'
|
||||
import type { Frontmatter } from '../src/core/frontmatter.ts'
|
||||
|
||||
function makeDeps(overrides: Partial<PostDeps> = {}): PostDeps {
|
||||
return {
|
||||
readPostFile: () =>
|
||||
Promise.resolve({
|
||||
fm: {
|
||||
title: 'T',
|
||||
slug: 's',
|
||||
date: new Date('2024-01-01'),
|
||||
} as Frontmatter,
|
||||
body: 'body',
|
||||
}),
|
||||
collectImages: () => Promise.resolve([]),
|
||||
uploadBlossom: (args) =>
|
||||
Promise.resolve({
|
||||
ok: ['https://b1'],
|
||||
failed: [],
|
||||
primaryUrl: `https://b1/${args.fileName}-hash`,
|
||||
sha256: 'hash',
|
||||
}),
|
||||
sign: (ev) => Promise.resolve({ ...ev, id: 'ev-id', sig: 'sig' }),
|
||||
publish: () => Promise.resolve({ ok: ['wss://r1', 'wss://r2'], failed: [] }),
|
||||
checkExisting: () => Promise.resolve(false),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function baseArgs(deps = makeDeps()) {
|
||||
return {
|
||||
postDir: '/p/s',
|
||||
writeRelays: ['wss://r1', 'wss://r2'],
|
||||
blossomServers: ['https://b1'],
|
||||
pubkeyHex: 'a'.repeat(64),
|
||||
clientTag: 'test-client',
|
||||
minRelayAcks: 2,
|
||||
deps,
|
||||
}
|
||||
}
|
||||
|
||||
Deno.test('processPost: happy-path neu, ohne bilder', async () => {
|
||||
const result = await processPost(baseArgs())
|
||||
assertEquals(result.status, 'success')
|
||||
assertEquals(result.action, 'new')
|
||||
assertEquals(result.eventId, 'ev-id')
|
||||
assertEquals(result.relaysOk.length, 2)
|
||||
})
|
||||
|
||||
Deno.test('processPost: draft wird geskippt', async () => {
|
||||
const deps = makeDeps({
|
||||
readPostFile: () =>
|
||||
Promise.resolve({
|
||||
fm: {
|
||||
title: 'T',
|
||||
slug: 's',
|
||||
date: new Date('2024-01-01'),
|
||||
draft: true,
|
||||
} as Frontmatter,
|
||||
body: 'b',
|
||||
}),
|
||||
})
|
||||
const result = await processPost({ ...baseArgs(deps), writeRelays: ['wss://r1'] })
|
||||
assertEquals(result.status, 'skipped-draft')
|
||||
})
|
||||
|
||||
Deno.test('processPost: zu wenig relay-acks → failed', async () => {
|
||||
const deps = makeDeps({
|
||||
publish: () =>
|
||||
Promise.resolve({ ok: ['wss://r1'], failed: ['wss://r2', 'wss://r3', 'wss://r4'] }),
|
||||
})
|
||||
const result = await processPost({
|
||||
...baseArgs(deps),
|
||||
writeRelays: ['wss://r1', 'wss://r2', 'wss://r3', 'wss://r4'],
|
||||
})
|
||||
assertEquals(result.status, 'failed')
|
||||
assertEquals(String(result.error).includes('relays'), true)
|
||||
})
|
||||
|
||||
Deno.test('processPost: konfigurierbarer minRelayAcks', async () => {
|
||||
const deps = makeDeps({
|
||||
publish: () => Promise.resolve({ ok: ['wss://r1'], failed: ['wss://r2'] }),
|
||||
})
|
||||
const result = await processPost({
|
||||
...baseArgs(deps),
|
||||
writeRelays: ['wss://r1', 'wss://r2'],
|
||||
minRelayAcks: 1,
|
||||
})
|
||||
assertEquals(result.status, 'success')
|
||||
})
|
||||
|
||||
Deno.test('processPost: bestehender d-tag → action = update', async () => {
|
||||
const result = await processPost(
|
||||
baseArgs(makeDeps({ checkExisting: () => Promise.resolve(true) })),
|
||||
)
|
||||
assertEquals(result.status, 'success')
|
||||
assertEquals(result.action, 'update')
|
||||
})
|
||||
|
||||
Deno.test('processPost: bilder landen auf blossom, body wird rewritten', async () => {
|
||||
const uploaded: string[] = []
|
||||
const deps = makeDeps({
|
||||
readPostFile: () =>
|
||||
Promise.resolve({
|
||||
fm: {
|
||||
title: 'T',
|
||||
slug: 's',
|
||||
date: new Date('2024-01-01'),
|
||||
cover: { image: 'cover.png' },
|
||||
} as Frontmatter,
|
||||
body: 'Pic:  cover ',
|
||||
}),
|
||||
collectImages: () =>
|
||||
Promise.resolve([
|
||||
{
|
||||
fileName: 'a.png',
|
||||
absolutePath: '/p/s/a.png',
|
||||
data: new Uint8Array([1]),
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
{
|
||||
fileName: 'cover.png',
|
||||
absolutePath: '/p/s/cover.png',
|
||||
data: new Uint8Array([2]),
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
]),
|
||||
uploadBlossom: (args) => {
|
||||
uploaded.push(args.fileName)
|
||||
return Promise.resolve({
|
||||
ok: ['https://b1'],
|
||||
failed: [],
|
||||
primaryUrl: `https://b1/${args.fileName}-hash`,
|
||||
sha256: 'h',
|
||||
})
|
||||
},
|
||||
})
|
||||
const result = await processPost(baseArgs(deps))
|
||||
assertEquals(result.status, 'success')
|
||||
assertEquals(uploaded.sort(), ['a.png', 'cover.png'])
|
||||
assertEquals(result.imagesUploaded, 2)
|
||||
})
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
import { assertEquals } from '@std/assert'
|
||||
import { publishToRelays } from '../src/core/relays.ts'
|
||||
|
||||
const sampleEvent = {
|
||||
kind: 1,
|
||||
pubkey: 'p',
|
||||
created_at: 1,
|
||||
tags: [],
|
||||
content: 'x',
|
||||
id: 'i',
|
||||
sig: 's',
|
||||
}
|
||||
|
||||
Deno.test('publishToRelays: meldet OK-Antworten je relay', async () => {
|
||||
const injected = async (url: string, _ev: unknown) => {
|
||||
if (url.includes('fail')) return { ok: false, reason: 'nope' }
|
||||
return { ok: true }
|
||||
}
|
||||
const result = await publishToRelays(
|
||||
['wss://ok1.example', 'wss://ok2.example', 'wss://fail.example'],
|
||||
sampleEvent,
|
||||
{ publishFn: injected, retries: 0, timeoutMs: 100 },
|
||||
)
|
||||
assertEquals(result.ok.sort(), ['wss://ok1.example', 'wss://ok2.example'])
|
||||
assertEquals(result.failed, ['wss://fail.example'])
|
||||
})
|
||||
|
||||
Deno.test('publishToRelays: retry bei Fehler', async () => {
|
||||
let attempts = 0
|
||||
const injected = async () => {
|
||||
attempts++
|
||||
if (attempts < 2) return { ok: false, reason: 'transient' }
|
||||
return { ok: true }
|
||||
}
|
||||
const result = await publishToRelays(
|
||||
['wss://flaky.example'],
|
||||
sampleEvent,
|
||||
{ publishFn: injected, retries: 1, timeoutMs: 100, backoffMs: 1 },
|
||||
)
|
||||
assertEquals(result.ok, ['wss://flaky.example'])
|
||||
assertEquals(attempts, 2)
|
||||
})
|
||||
|
||||
Deno.test('publishToRelays: timeout → failed', async () => {
|
||||
const pendingTimers: number[] = []
|
||||
const injected = () =>
|
||||
new Promise<{ ok: boolean }>((resolve) => {
|
||||
const t = setTimeout(() => resolve({ ok: true }), 500)
|
||||
pendingTimers.push(t)
|
||||
})
|
||||
try {
|
||||
const result = await publishToRelays(
|
||||
['wss://slow.example'],
|
||||
sampleEvent,
|
||||
{ publishFn: injected, retries: 0, timeoutMs: 10 },
|
||||
)
|
||||
assertEquals(result.failed, ['wss://slow.example'])
|
||||
} finally {
|
||||
for (const t of pendingTimers) clearTimeout(t)
|
||||
}
|
||||
})
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import { assertEquals } from '@std/assert'
|
||||
import { validatePostFile } from '../src/subcommands/validate-post.ts'
|
||||
|
||||
Deno.test('validatePostFile: ok bei fixture-post', async () => {
|
||||
const result = await validatePostFile('./tests/fixtures/sample-post.md')
|
||||
assertEquals(result.ok, true)
|
||||
assertEquals(result.slug, 'sample-slug')
|
||||
})
|
||||
|
||||
Deno.test('validatePostFile: fehler bei fehlender datei', async () => {
|
||||
const result = await validatePostFile('./does-not-exist.md')
|
||||
assertEquals(result.ok, false)
|
||||
assertEquals(result.error?.includes('read'), true)
|
||||
})
|
||||
|
||||
Deno.test('validatePostFile: fehler bei ungültigem slug', async () => {
|
||||
const tmp = await Deno.makeTempFile({ suffix: '.md' })
|
||||
try {
|
||||
await Deno.writeTextFile(
|
||||
tmp,
|
||||
'---\ntitle: "T"\nslug: "Bad Slug"\ndate: 2024-01-01\n---\n\nbody',
|
||||
)
|
||||
const result = await validatePostFile(tmp)
|
||||
assertEquals(result.ok, false)
|
||||
assertEquals(result.error?.includes('slug'), true)
|
||||
} finally {
|
||||
await Deno.remove(tmp)
|
||||
}
|
||||
})
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
import { assertEquals, assertThrows } from '@std/assert'
|
||||
import { validatePost, validateSlug } from '../src/core/validation.ts'
|
||||
import type { Frontmatter } from '../src/core/frontmatter.ts'
|
||||
|
||||
Deno.test('validateSlug: akzeptiert lowercase/digits/hyphen', () => {
|
||||
validateSlug('abc-123')
|
||||
validateSlug('a')
|
||||
validateSlug('dezentrale-oep-oer')
|
||||
})
|
||||
|
||||
Deno.test('validateSlug: lehnt Großbuchstaben ab', () => {
|
||||
assertThrows(() => validateSlug('Abc'), Error, 'slug')
|
||||
})
|
||||
|
||||
Deno.test('validateSlug: lehnt Unterstriche/Leerzeichen ab', () => {
|
||||
assertThrows(() => validateSlug('a_b'), Error, 'slug')
|
||||
assertThrows(() => validateSlug('a b'), Error, 'slug')
|
||||
})
|
||||
|
||||
Deno.test('validateSlug: lehnt führenden Bindestrich ab', () => {
|
||||
assertThrows(() => validateSlug('-abc'), Error, 'slug')
|
||||
})
|
||||
|
||||
Deno.test('validatePost: ok bei vollständigem Frontmatter', () => {
|
||||
const fm: Frontmatter = {
|
||||
title: 'T',
|
||||
slug: 'ok-slug',
|
||||
date: new Date('2024-01-01'),
|
||||
}
|
||||
validatePost(fm)
|
||||
})
|
||||
|
||||
Deno.test('validatePost: fehlt title', () => {
|
||||
const fm = { slug: 'ok', date: new Date() } as unknown as Frontmatter
|
||||
assertThrows(() => validatePost(fm), Error, 'title')
|
||||
})
|
||||
|
||||
Deno.test('validatePost: lehnt beliebige strings als date ab', () => {
|
||||
const fm = { title: 'T', slug: 'ok', date: 'not-a-date' } as unknown as Frontmatter
|
||||
assertThrows(() => validatePost(fm), Error, 'date')
|
||||
})
|
||||
|
||||
Deno.test('validatePost: akzeptiert YYYY-MM-DD string-date (coerce zu Date)', () => {
|
||||
const fm = { title: 'T', slug: 'ok', date: '2023-02-26' } as unknown as Frontmatter
|
||||
validatePost(fm)
|
||||
assertEquals(fm.date instanceof Date, true)
|
||||
assertEquals((fm.date as Date).toISOString().startsWith('2023-02-26'), true)
|
||||
})
|
||||
|
||||
Deno.test('validatePost: akzeptiert ISO-string-date', () => {
|
||||
const fm = {
|
||||
title: 'T',
|
||||
slug: 'ok',
|
||||
date: '2024-01-15T10:30:00Z',
|
||||
} as unknown as Frontmatter
|
||||
validatePost(fm)
|
||||
assertEquals(fm.date instanceof Date, true)
|
||||
})
|
||||
|
|
@ -1,15 +1,6 @@
|
|||
#!/usr/bin/env bash
|
||||
# Deploy: SvelteKit-Build per FTPS nach einem der All-Inkl-Webroots.
|
||||
# Credentials kommen aus ./.env.local (gitignored), Variablen-Prefix je Ziel.
|
||||
#
|
||||
# Zielauswahl via DEPLOY_TARGET-Env-Variable:
|
||||
# - DEPLOY_TARGET=svelte (default) → svelte.joerg-lohrer.de via SVELTE_FTP_*
|
||||
# - DEPLOY_TARGET=staging → staging.joerg-lohrer.de via STAGING_FTP_*
|
||||
#
|
||||
# Beispiele:
|
||||
# ./scripts/deploy-svelte.sh # default: svelte
|
||||
# DEPLOY_TARGET=staging ./scripts/deploy-svelte.sh # staging-probelauf
|
||||
# DEPLOY_TARGET=svelte ./scripts/deploy-svelte.sh # explizit
|
||||
# Deploy: SvelteKit-Build nach svelte.joerg-lohrer.de per FTPS.
|
||||
# Credentials kommen aus ./.env.local (gitignored), Variablen-Prefix SVELTE_FTP_.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
|
|
@ -21,99 +12,44 @@ if [ ! -f .env.local ]; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
TARGET="${DEPLOY_TARGET:-svelte}"
|
||||
case "$TARGET" in
|
||||
svelte)
|
||||
PREFIX="SVELTE_FTP_"
|
||||
PUBLIC_URL="https://svelte.joerg-lohrer.de/"
|
||||
SITE_URL="https://svelte.joerg-lohrer.de"
|
||||
;;
|
||||
staging)
|
||||
PREFIX="STAGING_FTP_"
|
||||
PUBLIC_URL="https://staging.joerg-lohrer.de/"
|
||||
SITE_URL="https://staging.joerg-lohrer.de"
|
||||
;;
|
||||
prod)
|
||||
# Deploy auf staging-ftp (joerglohrer26 = aktueller cutover-webroot),
|
||||
# aber mit og:url auf der hauptdomain.
|
||||
PREFIX="STAGING_FTP_"
|
||||
PUBLIC_URL="https://joerg-lohrer.de/"
|
||||
SITE_URL="https://joerg-lohrer.de"
|
||||
;;
|
||||
*)
|
||||
echo "FEHLER: unbekanntes DEPLOY_TARGET='$TARGET' (erlaubt: svelte, staging, prod)." >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
# nur SVELTE_FTP_* exportieren (via Tempfile — process substitution ist nicht
|
||||
# überall verfügbar, je nach Shell/Sandbox).
|
||||
_ENV_TMP="$(mktemp)"
|
||||
trap 'rm -f "$_ENV_TMP"' EXIT
|
||||
grep -E '^SVELTE_FTP_' .env.local > "$_ENV_TMP" || true
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "$_ENV_TMP"
|
||||
set +a
|
||||
|
||||
# Werte direkt aus .env.local lesen (nicht via `source`, weil
|
||||
# password-shell-metazeichen wie ( ) & kein sourcing überstehen).
|
||||
read_env() {
|
||||
local key="$1"
|
||||
# nimmt die erste zeile, die exakt mit KEY= beginnt, schneidet alles nach
|
||||
# dem ersten = ab, gibt den rest 1:1 zurück (auch mit sonderzeichen).
|
||||
awk -F= -v k="$key" 'BEGIN{found=0} $1==k && !found { sub("^" k "=",""); print; found=1 }' .env.local
|
||||
}
|
||||
|
||||
FTP_HOST_KEY="${PREFIX}HOST"
|
||||
FTP_USER_KEY="${PREFIX}USER"
|
||||
FTP_PASS_KEY="${PREFIX}PASS"
|
||||
FTP_PATH_KEY="${PREFIX}REMOTE_PATH"
|
||||
|
||||
FTP_HOST="$(read_env "$FTP_HOST_KEY")"
|
||||
FTP_USER="$(read_env "$FTP_USER_KEY")"
|
||||
FTP_PASS="$(read_env "$FTP_PASS_KEY")"
|
||||
FTP_REMOTE_PATH="$(read_env "$FTP_PATH_KEY")"
|
||||
|
||||
for pair in "$FTP_HOST_KEY:$FTP_HOST" "$FTP_USER_KEY:$FTP_USER" \
|
||||
"$FTP_PASS_KEY:$FTP_PASS" "$FTP_PATH_KEY:$FTP_REMOTE_PATH"; do
|
||||
key="${pair%%:*}"
|
||||
val="${pair#*:}"
|
||||
if [ -z "$val" ]; then
|
||||
echo "FEHLER: $key fehlt in .env.local." >&2
|
||||
for v in SVELTE_FTP_HOST SVELTE_FTP_USER SVELTE_FTP_PASS SVELTE_FTP_REMOTE_PATH; do
|
||||
if [ -z "${!v:-}" ]; then
|
||||
echo "FEHLER: $v fehlt in .env.local." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
BUILD_DIR="$ROOT/app/build"
|
||||
|
||||
echo "Baue SvelteKit …"
|
||||
(cd "$ROOT/app" && npm run build >/dev/null 2>&1) || {
|
||||
echo "FEHLER: Build fehlgeschlagen. 'cd app && npm run build' manuell ausführen zum Debuggen." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [ ! -d "$BUILD_DIR" ]; then
|
||||
echo "FEHLER: app/build nicht vorhanden nach build." >&2
|
||||
echo "FEHLER: app/build nicht vorhanden. Bitte vorher 'npm run build' in app/ ausführen." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# __SITE_URL__-Platzhalter in allen HTML-Dateien durch die ziel-spezifische
|
||||
# SITE_URL ersetzen (für og:url / canonical). Nicht im quellcode hart
|
||||
# setzen, damit ein builder einmal baut und mehrere domains damit bedienen
|
||||
# kann.
|
||||
echo "Patche __SITE_URL__ → $SITE_URL in HTML-Dateien …"
|
||||
find "$BUILD_DIR" -type f -name "*.html" -print0 | while IFS= read -r -d '' html_file; do
|
||||
# sed -i '' für macOS-kompatibilität (bsd sed braucht leeres backup-arg)
|
||||
sed -i '' "s|__SITE_URL__|$SITE_URL|g" "$html_file"
|
||||
done
|
||||
|
||||
echo "Ziel: $TARGET ($PUBLIC_URL)"
|
||||
echo "Lade Build von $BUILD_DIR nach ftp://$FTP_HOST$FTP_REMOTE_PATH"
|
||||
echo "Lade Build von $BUILD_DIR nach ftp://$SVELTE_FTP_HOST$SVELTE_FTP_REMOTE_PATH"
|
||||
|
||||
# pro Datei ein curl-Upload (zuverlässig auf macOS ohne lftp)
|
||||
find "$BUILD_DIR" -type f -print0 | while IFS= read -r -d '' local_file; do
|
||||
rel="${local_file#$BUILD_DIR/}"
|
||||
remote="ftp://$FTP_HOST${FTP_REMOTE_PATH%/}/$rel"
|
||||
remote="ftp://$SVELTE_FTP_HOST${SVELTE_FTP_REMOTE_PATH%/}/$rel"
|
||||
echo " → $rel"
|
||||
# --tls-max 1.2: All-Inkl/Kasserver FTPS schließt bei TLS 1.3 die Data-
|
||||
# Connection mit "426 Transfer aborted" — mit 1.2 läuft es sauber durch.
|
||||
curl -sSf --ssl-reqd --tls-max 1.2 --ftp-create-dirs \
|
||||
--retry 3 --retry-delay 2 --retry-all-errors \
|
||||
--connect-timeout 15 \
|
||||
--user "$FTP_USER:$FTP_PASS" \
|
||||
--user "$SVELTE_FTP_USER:$SVELTE_FTP_PASS" \
|
||||
-T "$local_file" "$remote"
|
||||
done
|
||||
|
||||
echo "Upload fertig. Live-Check:"
|
||||
curl -sIL "$PUBLIC_URL" | head -5
|
||||
curl -sIL "https://svelte.joerg-lohrer.de/" | head -5
|
||||
|
|
|
|||