Forum integration: Flarum extension, local test stack, embedded comments

- forum/extension: oc:import command (archived comments -> one thread per
  machine) and embed styling
- forum/docker: local Flarum 2 test stack
- site: optional params.forum.url adds a Forum menu entry and shows the
  machine thread in the Comments tab

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDRjtmAiNpo5mK5N3d7QwZ
This commit is contained in:
2026-10-11 21:21:08 +02:00
co-authored by Claude Opus 5.5
parent 2b5c9df929
commit 32b735283e
17 changed files with 436 additions and 2 deletions
+1
View File
@@ -4,3 +4,4 @@
.hugo_build.lock
__pycache__/
node_modules/
/forum/import/
+16 -2
View File
@@ -7,10 +7,10 @@ PAGEFIND ?= npx -y pagefind@1
DEPLOY_DIR ?= /run/user/$(shell id -u)/gvfs/smb-share:server=barbel-lescar,share=web/oldcomputers
DEPLOY_URL ?= https://barbel.synology.me/oldcomputers/
.PHONY: content build check serve deploy post
.PHONY: content build check serve deploy post forum-up forum-import forum-down
content: ## regenerate content/ and static/media/ from the extraction repo
python3 $(OC)/scripts/05_hugo.py --target $(CURDIR)
python3 $(OC)/scripts/05_hugo.py --target $(CURDIR) $(if $(wildcard data/forum.json),--forum)
build: ## build the site and its search index into $(BUILD)/
rm -rf $(BUILD)
@@ -32,3 +32,17 @@ deploy: check ## build, check, and copy $(BUILD)/ to DEPLOY_DIR (served at DEPL
mkdir -p "$(DEPLOY_DIR)"
rsync -r --size-only --info=stats1 $(BUILD)/media/ "$(DEPLOY_DIR)/media/"
rsync -rc --delete --exclude=/media/ --info=stats1 $(BUILD)/ "$(DEPLOY_DIR)/"
# --- local test forum (Flarum in Docker, see forum/README.md) ---
FORUM = docker compose -f forum/docker-compose.yml
SITE_URL ?= http://localhost:1313
forum-up: ## start the test forum on http://localhost:8081 (admin / adminadmin)
$(FORUM) up -d --build
forum-import: ## export the archived comments and import them into the test forum
python3 $(OC)/scripts/06_forum.py --target $(CURDIR)
$(FORUM) exec -T flarum php flarum oc:import /import/machines.json --site-url=$(SITE_URL) --map=/import/forum.json
forum-down: ## stop the test forum (forum-db volume keeps its data)
$(FORUM) down
+10
View File
@@ -10,6 +10,8 @@ Static rebuild of old-computers.com with [Hugo](https://gohugo.io/) (0.158 or ne
| `static/media/` | generated (copied images and documents) |
| `data/slugs.json` | generated, keeps URLs stable between runs — do not delete |
| `content/post/` | written by hand: news posts, see below |
| `data/forum.json` | machine → forum thread map, written by the forum import (absent = no forum) |
| `forum/` | forum extension, local test stack, install notes |
| `content/_index.md`, `layouts/`, `assets/`, `hugo.toml`, `tools/` | written by hand |
| `build/`, `public/` | build output (`make build`, `hugo server`), not versioned |
@@ -39,6 +41,14 @@ sends to `/search/`, where results can be filtered by section. Content pages are
visitor-comments tabs are not. `hugo server` has no index: use `make build` and serve `build/`
to try the search locally.
## Forum and comments
A [Flarum](https://flarum.org/) forum can run next to the site and serve as its comment system:
with `params.forum.url` set in `hugo.toml`, the menu gets a Forum entry and the Comments tab of
each machine shows that machine's forum thread (archived comments first, new replies after).
With the url empty the site has no forum and shows the archived comments as static text.
Everything about the forum is in [`forum/README.md`](forum/README.md).
## Usage
make content # regenerate from ../oc (OC=path to use another location)
+2
View File
@@ -162,3 +162,5 @@ table { border-collapse: collapse; }
.pager { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; margin: 24px 0 0; }
.pager a, .pager .current { padding: 4px 10px; border: 1px solid var(--line); border-radius: 4px; text-decoration: none; background: var(--panel); }
.pager .current { font-weight: 700; border-color: var(--accent); }
.forum-thread { display: block; width: 100%; min-height: 400px; border: 0; }
+21
View File
File diff suppressed because one or more lines are too long
+71
View File
@@ -0,0 +1,71 @@
# Forum and comments (Flarum)
The forum is a separate PHP application ([Flarum](https://flarum.org/) 2, PHP 8.3+, MariaDB or
MySQL). It is not part of the static build: the site only links to it and shows its threads in a
frame.
| Path | Role |
|---|---|
| `extension/` | Flarum extension `oc/flarum-museum`: the `oc:import` command and the look of embedded threads |
| `docker/`, `docker-compose.yml` | local test forum |
| `import/` | not versioned: `machines.json` (export from the extraction repo) and `forum.json` (thread map) |
## How comments work
- `../oc/scripts/06_forum.py` writes `import/machines.json`: one entry per machine with its
archived comments, oldest first.
- `php flarum oc:import` creates one discussion per machine: an opening post linking to the
museum page, then one post per archived comment, all by the user `Archive`, with the original
author and date at the top of each post and the original date as the post date. Discussions go
under three tags (Computers, Consoles, Pongs) that are hidden from "All discussions".
- The command records what it imported (table `oc_threads`) and skips it on later runs, so it
can be run again when new machines arrive. It writes the map machine → discussion to
`forum.json`.
- Copied to `../data/forum.json`, that map makes `make content` give every machine a Comments
tab pointing at its thread (`forum_thread` in the page front matter); the old comments are
then no longer written into the pages.
- With `params.forum.url` set, the Comments tab shows `<forum>/embed/<id>` (Flarum's Embed
extension) in a frame that follows the height of the thread.
## Local test
make forum-up # http://localhost:8081, admin / adminadmin (first start takes a minute)
make forum-import # export + import; safe to repeat
To see it in the site, copy `forum/import/forum.json` to `data/forum.json`, run `make content`,
and build with `HUGO_PARAMS_FORUM_URL=http://localhost:8081 hugo server`. Do not commit a
`data/forum.json` made from the test forum: its thread numbers are not those of the real one.
## Server install (outline)
The forum must live **outside** the folder `make deploy` writes to (that folder is synchronised
with deletion). On the test server: `web/oldcomputers-forum/`, next to `web/oldcomputers/`.
1. PHP 8.3 or newer with curl, dom, fileinfo, gd, json, mbstring, openssl, pdo_mysql, tokenizer,
zip; an empty database and a user for it.
2. `composer create-project flarum/flarum oldcomputers-forum "^2.0"`, then in that folder
`composer require flarum/embed "^2.0"`.
3. Web server: requests that are not files go to `public/index.php`, and nothing outside `public/`
is served. Flarum ships rules for Apache (`public/.htaccess`) and nginx (`.nginx.conf`); a
sub-folder install is described in the Flarum documentation ("Customizing paths").
4. Open the forum address and finish the installer (database, administrator account).
5. Copy `extension/` to `packages/oc-museum/` in the forum folder, then:
composer config repositories.oc path "packages/*"
composer require "oc/flarum-museum:*@dev"
php flarum migrate
php flarum extension:enable flarum-tags
php flarum extension:enable flarum-embed
php flarum extension:enable oc-museum
6. In the admin panel: e-mail settings (registration confirms addresses by e-mail), and a spam
filter extension. Schedule a database dump.
7. Import: copy `import/machines.json` to the server and run
php flarum oc:import machines.json --site-url=https://example.org/oldcomputers --map=forum.json
then bring `forum.json` back as `data/forum.json`, set `params.forum.url`, `make content`,
`make deploy`.
The forum and the site should share one domain: logging in from the embedded thread relies on
the forum's cookie, which browsers restrict across domains.
+24
View File
@@ -0,0 +1,24 @@
# Local test forum on http://localhost:8081 (admin / adminadmin). Data in named volumes.
services:
db:
image: mariadb:11
environment:
MARIADB_DATABASE: flarum
MARIADB_USER: flarum
MARIADB_PASSWORD: flarum
MARIADB_RANDOM_ROOT_PASSWORD: "1"
volumes:
- forum-db:/var/lib/mysql
flarum:
build: docker
depends_on: [db]
environment:
FORUM_URL: http://localhost:8081
ADMIN_PASSWORD: adminadmin
ports:
- "127.0.0.1:8081:80"
volumes:
- ./extension:/var/www/flarum/packages/oc-museum
- ./import:/import
volumes:
forum-db:
+19
View File
@@ -0,0 +1,19 @@
# Local test forum: Flarum 2 on Apache. Not the production install (see forum/README.md).
FROM php:8.3-apache
RUN apt-get update && apt-get install -y --no-install-recommends git unzip libzip-dev libpng-dev libjpeg-dev libfreetype6-dev \
&& docker-php-ext-configure gd --with-jpeg --with-freetype \
&& docker-php-ext-install pdo_mysql gd zip \
&& a2enmod rewrite && rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
ENV COMPOSER_ALLOW_SUPERUSER=1
RUN composer create-project flarum/flarum /var/www/flarum "^2.0" --no-interaction --prefer-dist \
&& cd /var/www/flarum && mkdir packages \
&& composer require --no-interaction flarum/embed "^2.0" \
&& composer config repositories.oc '{"type": "path", "url": "packages/*", "options": {"symlink": true}}' \
&& chown -R www-data:www-data /var/www/flarum
RUN sed -ri 's#/var/www/html#/var/www/flarum/public#g' /etc/apache2/sites-available/000-default.conf \
&& printf '<Directory /var/www/flarum/public>\n AllowOverride All\n Require all granted\n</Directory>\n' > /etc/apache2/conf-enabled/flarum.conf
COPY entrypoint.sh /entrypoint.sh
WORKDIR /var/www/flarum
ENTRYPOINT ["/entrypoint.sh"]
CMD ["apache2-foreground"]
+33
View File
@@ -0,0 +1,33 @@
#!/bin/sh
# First start: install Flarum against the db service, enable the extensions the site relies on.
set -e
cd /var/www/flarum
if [ ! -f config.php ]; then
until php -r 'new PDO("mysql:host=db;dbname=flarum", "flarum", "flarum");' >/dev/null 2>&1; do sleep 2; done
cat > /tmp/install.yml <<Y
debug: true
baseUrl: ${FORUM_URL}
databaseConfiguration:
driver: mysql
host: db
port: 3306
database: flarum
username: flarum
password: flarum
prefix:
adminUser:
username: admin
password: ${ADMIN_PASSWORD}
email: admin@example.com
settings:
forum_title: Old-Computers.com forum
Y
php flarum install --file=/tmp/install.yml
fi
# the museum extension is mounted from the repository (forum/extension)
composer require --no-interaction "oc/flarum-museum:*@dev" >/dev/null 2>&1 || composer require --no-interaction "oc/flarum-museum:*@dev"
php flarum migrate
for ext in flarum-tags flarum-markdown flarum-embed oc-museum; do php flarum extension:enable "$ext" || true; done
php flarum cache:clear
chown -R www-data:www-data storage public/assets
exec docker-php-entrypoint "$@"
+26
View File
@@ -0,0 +1,26 @@
{
"name": "oc/flarum-museum",
"description": "Old-Computers.com museum integration: imports the archived visitor comments as discussions and styles the embedded threads.",
"type": "flarum-extension",
"license": "MIT",
"require": {
"flarum/core": "^2.0",
"flarum/tags": "^2.0",
"flarum/embed": "^2.0"
},
"autoload": {
"psr-4": {
"Oc\\Museum\\": "src/"
}
},
"extra": {
"flarum-extension": {
"title": "Old-Computers museum",
"icon": {
"name": "fas fa-landmark",
"backgroundColor": "#9a3412",
"color": "#fff"
}
}
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
use Flarum\Extend;
use Oc\Museum\Console\ImportCommand;
return [
(new Extend\Console())->command(ImportCommand::class),
// threads shown inside the museum pages (flarum/embed) take the look of the static site
(new Extend\Frontend('embed'))->css(__DIR__.'/less/embed.less'),
];
+9
View File
@@ -0,0 +1,9 @@
// Embedded thread, as shown in the Comments tab of the museum pages: full width, on the
// light panel colour of the static site (--panel in its assets/css/main.css).
@oc-accent: #9a3412;
@oc-panel: #fffdf7;
body, .App { background: @oc-panel; }
.Page-content { max-width: none; }
.Post-body a, .PostUser-name a, .Button--link { color: @oc-accent; }
.Button--primary { background: @oc-accent; }
@@ -0,0 +1,10 @@
<?php
use Flarum\Database\Migration;
use Illuminate\Database\Schema\Blueprint;
// One discussion per museum machine; oc_id is the machine id of the original site.
return Migration::createTable('oc_threads', function (Blueprint $table) {
$table->unsignedInteger('oc_id')->primary();
$table->unsignedInteger('discussion_id')->unique();
});
@@ -0,0 +1,167 @@
<?php
namespace Oc\Museum\Console;
use Carbon\Carbon;
use Flarum\Console\AbstractCommand;
use Flarum\Discussion\Discussion;
use Flarum\Post\CommentPost;
use Flarum\Tags\Tag;
use Flarum\User\User;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Support\Str;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
/**
* Creates one discussion per museum machine from the file written by the extraction
* pipeline (oc/scripts/06_forum.py), with the archived visitor comments as its posts.
*
* Safe to run again: machines already imported (table oc_threads) are skipped.
*/
class ImportCommand extends AbstractCommand
{
private const ARCHIVE_USER = 'Archive';
private const TAGS = [
'Computers' => ['#9a3412', 'fas fa-desktop'],
'Consoles' => ['#1d4ed8', 'fas fa-gamepad'],
'Pongs' => ['#15803d', 'fas fa-table-tennis-paddle-ball'],
];
public function __construct(private ConnectionInterface $db)
{
parent::__construct();
}
protected function configure(): void
{
$this->setName('oc:import')
->setDescription('Import the museum machines and their archived comments as discussions')
->addArgument('file', InputArgument::REQUIRED, 'machines.json written by 06_forum.py')
->addOption('site-url', null, InputOption::VALUE_REQUIRED, 'Address of the static site, e.g. https://example.org/oldcomputers', '')
->addOption('map', null, InputOption::VALUE_REQUIRED, 'Where to write the machine -> discussion map (forum.json)');
}
protected function fire(): int
{
$machines = json_decode((string) file_get_contents($this->input->getArgument('file')), true);
if (! is_array($machines)) {
$this->error('Cannot read the import file.');
return 1;
}
$site = rtrim((string) $this->input->getOption('site-url'), '/');
$archive = $this->archiveUser();
$tags = $this->tags();
$done = $this->db->table('oc_threads')->pluck('discussion_id', 'oc_id');
$created = $posts = 0;
foreach ($machines as $machine) {
if (isset($done[$machine['oc_id']])) {
continue;
}
$this->db->transaction(function () use ($machine, $site, $archive, $tags, &$posts, &$done) {
$id = $this->importMachine($machine, $site, $archive, $tags[$machine['section']] ?? $tags['Computers']);
$this->db->table('oc_threads')->insert(['oc_id' => $machine['oc_id'], 'discussion_id' => $id]);
$done[$machine['oc_id']] = $id;
$posts += count($machine['comments']);
});
if (++$created % 50 === 0) {
$this->info("$created discussions…");
}
}
$archive->refreshCommentCount()->refreshDiscussionCount()->save();
foreach ($tags as $tag) {
$tag->discussion_count = $tag->discussions()->count();
$tag->refreshLastPostedDiscussion()->save();
}
if ($map = $this->input->getOption('map')) {
file_put_contents($map, json_encode($done, JSON_PRETTY_PRINT)."\n");
}
$this->info("$created discussions created with $posts archived comments; ".count($done).' machines mapped.');
return 0;
}
private function importMachine(array $machine, string $site, User $archive, Tag $tag): int
{
$comments = $machine['comments'];
// the thread opens just before its first comment (or now, when there is none)
$opened = $comments ? Carbon::parse($comments[0]['date'])->subMinute() : Carbon::now();
$page = $site.$machine['path'];
$discussion = Discussion::start($machine['title'], $archive);
$discussion->created_at = $opened;
$discussion->save();
$first = $this->post($discussion, $archive, $opened,
"Comments about the [{$machine['title']}]($page) page of the museum.\n\n"
.($comments ? 'The first messages below were left by visitors of the original site.' : 'Be the first to comment!'));
foreach ($comments as $comment) {
$header = '**'.$this->plain($comment['author']).'**'
.($comment['original_date'] ? ' — '.$this->plain($comment['original_date']) : '');
$this->post($discussion, $archive, Carbon::parse($comment['date']), $header."\n\n".$comment['text']);
}
$discussion->setFirstPost($first)->refreshCommentCount()->refreshLastPost()->refreshParticipantCount()->save();
$discussion->tags()->sync([$tag->id]);
return $discussion->id;
}
private function post(Discussion $discussion, User $user, Carbon $when, string $markdown): CommentPost
{
$post = new CommentPost();
$post->discussion_id = $discussion->id;
$post->user_id = $user->id;
$post->created_at = $when;
$post->type = CommentPost::$type;
// the pipeline writes hard line breaks as "\" + newline; the forum keeps plain newlines
$post->setContentAttribute(str_replace("\\\n", "\n", $markdown), $user);
$post->save(); // events raised by the model are not dispatched: no notifications
return $post;
}
private function plain(string $text): string
{
return preg_replace('/([\\\\*_`\[\]@])/', '\\\\$1', trim($text));
}
private function archiveUser(): User
{
$user = User::query()->where('username', self::ARCHIVE_USER)->first();
if (! $user) {
$user = new User();
$user->username = self::ARCHIVE_USER;
$user->email = 'archive@invalid.invalid';
$user->password = Str::random(40); // nobody logs in as the archive
$user->is_email_confirmed = true;
$user->joined_at = Carbon::now();
$user->save();
}
return $user;
}
/** @return array<string, Tag> */
private function tags(): array
{
$tags = [];
$position = (int) Tag::query()->max('position');
foreach (self::TAGS as $name => [$color, $icon]) {
$tag = Tag::query()->where('slug', Str::slug($name))->first();
if (! $tag) {
// hidden from "All discussions": one thread per machine would bury the forum topics
$tag = Tag::build($name, Str::slug($name), "Comments on the $name pages of the museum", $color, $icon, true);
$tag->is_primary = true;
$tag->position = ++$position;
$tag->save();
}
$tags[$name] = $tag;
}
return $tags;
}
}
+5
View File
@@ -19,6 +19,11 @@ capitalizeListTitles = false
listPosts = 10
description = "The Old-Computers.com museum of computers, consoles and pongs, rebuilt from the Wayback Machine archive."
# Forum (Flarum, see forum/README.md). Empty url = no forum: no menu entry, and machine pages
# show the archived comments as static text. With a url, the Comments tab shows the forum thread.
[params.forum]
url = ""
[taxonomies]
category = "categories"
type = "types"
+1
View File
@@ -17,6 +17,7 @@
{{ range site.Menus.main }}
<a href="{{ .URL }}"{{ if or ($current.IsMenuCurrent .Menu .) ($current.HasMenuCurrent .Menu .) (eq $current.Section .Page.Section) }} class="active"{{ end }}>{{ .Name }}</a>
{{ end }}
{{ with site.Params.forum.url }}<a href="{{ . }}">Forum</a>{{ end }}
</nav>
<form class="search" role="search" action="{{ "search/" | relURL }}" method="get">
<input type="search" name="q" placeholder="Search…" aria-label="Search the site">
+10
View File
@@ -33,6 +33,16 @@
{{ end }}
</div>
{{ else if and (eq $tab "comments") $p.forum_thread site.Params.forum.url }}
{{/* live comments: the machine's forum thread, shown in a frame sized to its content */}}
{{ $thread := printf "%s/embed/%d" (strings.TrimSuffix "/" site.Params.forum.url) (int $p.forum_thread) }}
<p class="note">The first comments were left by visitors of the original site. Log in to the forum to add yours. <a href="{{ replace $thread "/embed/" "/d/" }}">Open this discussion in the forum</a>.</p>
<iframe class="forum-thread" id="forum-thread" src="{{ $thread }}" title="Comments on {{ $m.Title }}" loading="lazy"></iframe>
{{ with resources.Get "js/iframeResizer.min.js" | fingerprint }}
<script src="{{ .RelPermalink }}"></script>
<script>iFrameResize({ checkOrigin: false, heightCalculationMethod: "lowestElement" }, "#forum-thread");</script>
{{ end }}
{{ else if eq $tab "comments" }}
<p class="note">Comments left by visitors of the original site. New comments are closed.</p>
<ol class="comments">