git: 692a5241427c - main - www/linkwarden: New port

From: Mark Felder <feld_at_FreeBSD.org>
Date: Wed, 30 Sep 2026 23:51:34 UTC
The branch main has been updated by feld:

URL: https://cgit.FreeBSD.org/ports/commit/?id=692a5241427cc84bf7eee327137bd0b49f00457f

commit 692a5241427cc84bf7eee327137bd0b49f00457f
Author:     Mark Felder <feld@FreeBSD.org>
AuthorDate: 2026-09-30 23:23:10 +0000
Commit:     Mark Felder <feld@FreeBSD.org>
CommitDate: 2026-09-30 23:51:30 +0000

    www/linkwarden: New port
    
    Linkwarden is a self-hosted, collaborative bookmark manager that collects,
    organizes and preserves webpages.  Every saved link is archived as a
    screenshot, a PDF, a single file HTML copy and a readable article, so the
    content stays available even when the original page is gone.
    
    Co-authored-by: Jochen Neumeister <joneum@FreeBSD.org>
---
 GIDs                                               |   2 +-
 UIDs                                               |   2 +-
 www/Makefile                                       |   1 +
 www/linkwarden/Makefile                            | 142 +++++++++++++++++++++
 www/linkwarden/distinfo                            |   5 +
 www/linkwarden/files/linkwarden.env.sample         |  41 ++++++
 www/linkwarden/files/linkwarden_web.in             | 116 +++++++++++++++++
 www/linkwarden/files/linkwarden_worker.in          |  94 ++++++++++++++
 ...ps_web_components_Preservation_ReadableView.tsx |  25 ++++
 ...patch-apps_web_components_TextStyleDropdown.tsx |  24 ++++
 www/linkwarden/files/patch-apps_web_next.config.js |  10 ++
 www/linkwarden/files/patch-apps_web_package.json   |  19 +++
 www/linkwarden/files/patch-package.json            |  20 +++
 .../files/patch-packages_filesystem_createFile.ts  |  11 ++
 .../patch-packages_filesystem_createFolder.ts      |  11 ++
 .../files/patch-packages_filesystem_fileExists.ts  |  11 ++
 .../files/patch-packages_filesystem_moveFile.ts    |  11 ++
 .../files/patch-packages_filesystem_readFile.ts    |  11 ++
 .../files/patch-packages_filesystem_removeFile.ts  |  11 ++
 .../patch-packages_filesystem_removeFolder.ts      |  11 ++
 www/linkwarden/files/pkg-message.in                |  33 +++++
 www/linkwarden/pkg-descr                           |  10 ++
 22 files changed, 619 insertions(+), 2 deletions(-)

diff --git a/GIDs b/GIDs
index f17deb934ed0..368db66fd314 100644
--- a/GIDs
+++ b/GIDs
@@ -340,7 +340,7 @@ comentario:*:395:
 # free: 396
 rtorrent:*:397:
 tiarra:*:398:
-# free: 399
+linkwarden:*:399:
 evcc:*:400:
 spectrumknx:*:401:
 # free: 402
diff --git a/UIDs b/UIDs
index 3453960c31e5..ae98d3437a11 100644
--- a/UIDs
+++ b/UIDs
@@ -345,7 +345,7 @@ comentario:*:395:395::0:0:Comentario Daemon:/var/db/comentario:/usr/sbin/nologin
 # free: 396
 rtorrent:*:397:397::0:0:rtorrent Daemon User:/nonexistent:/usr/sbin/nologin
 tiarra:*:398:398::0:0:Tiarra IRC Proxy:/nonexistent:/usr/sbin/nologin
-# free: 399
+linkwarden:*:399:399::0:0:Linkwarden Daemon:/var/db/linkwarden:/usr/sbin/nologin
 evcc:*:400:400::0:0:evcc Daemon:/nonexistent:/usr/sbin/nologin
 spectrumknx:*:401:401::0:0:SpectrumKNX Daemon:/var/db/spectrumknx:/usr/sbin/nologin
 # free: 402
diff --git a/www/Makefile b/www/Makefile
index fd94fb0e7f4f..c7cdc0b9fa72 100644
--- a/www/Makefile
+++ b/www/Makefile
@@ -369,6 +369,7 @@
     SUBDIR += linklint
     SUBDIR += links
     SUBDIR += links1
+    SUBDIR += linkwarden
     SUBDIR += linux-brave
     SUBDIR += linux-c7-qtwebkit
     SUBDIR += linux-chrome
diff --git a/www/linkwarden/Makefile b/www/linkwarden/Makefile
new file mode 100644
index 000000000000..100b59dab23f
--- /dev/null
+++ b/www/linkwarden/Makefile
@@ -0,0 +1,142 @@
+PORTNAME=	linkwarden
+DISTVERSIONPREFIX=	v
+DISTVERSION=	2.16.3
+CATEGORIES=	www
+MASTER_SITES=	LOCAL/feld:buildkit
+DISTFILES=	linkwarden-freebsd-buildkit-${DISTVERSION}${EXTRACT_SUFX}:buildkit
+
+MAINTAINER=	feld@FreeBSD.org
+COMMENT=	Self-hosted collaborative bookmark manager and web archive
+WWW=		https://linkwarden.app/
+
+LICENSE=	AGPLv3
+LICENSE_FILE=	${WRKSRC}/LICENSE.md
+
+ONLY_FOR_ARCHS=	amd64
+ONLY_FOR_ARCHS_REASON=	Next.js only ships SWC bindings for x86_64-unknown-freebsd
+
+BUILD_DEPENDS=	${LOCALBASE}/libexec/prisma-engines/query-engine:databases/prisma-engines
+RUN_DEPENDS=	${LOCALBASE}/bin/chrome:www/chromium \
+		${LOCALBASE}/bin/monolith:www/monolith \
+		${LOCALBASE}/libexec/prisma-engines/query-engine:databases/prisma-engines
+
+USES=		cpe gmake nodejs:22,build,run python:build ssl
+CPE_VENDOR=	linkwarden
+
+USE_GITHUB=	yes
+GH_ACCOUNT=	linkwarden
+
+USE_RC_SUBR=	linkwarden_web linkwarden_worker
+
+USERS=		linkwarden
+GROUPS=		linkwarden
+
+# The buildkit distfile is generated by the maintainer and uploaded to his
+# LOCAL distfiles area.  It carries the Yarn Berry cache, the Yarn 4 bundle, the
+# lockfile pinned to the prisma version of databases/prisma-engines and the
+# WebAssembly SWC bindings, so that the build needs no network. It is made by
+# unpacking the release tarball, applying the patches of this port and running,
+# with the yarn version from packageManager in package.json:
+#   npm pack @yarnpkg/cli-dist@4.12.0	   -> yarn.js
+#   npm pack @next/swc-wasm-nodejs@15.5.21 -> swc-wasm-nodejs/
+#   cacheFolder and enableGlobalCache in .yarnrc.yml pointed at <kit>/yarn-cache
+#   node <kit>/yarn.js workspaces focus linkwarden @linkwarden/web \
+#	@linkwarden/worker
+#   cp yarn.lock <kit>/
+# plus the two fonts that next/font would otherwise fetch from Google at build
+# time, taken from the google/fonts repository (OFL):
+#   fonts/Bentham-Regular.ttf, fonts/Caveat-Variable.ttf and their OFL.txt
+BUILDKIT=	${WRKDIR}/linkwarden-freebsd-buildkit-${DISTVERSION}
+YARN=		${LOCALBASE}/bin/node ${BUILDKIT}/yarn.js
+PRISMA_ENGINES=	${LOCALBASE}/libexec/prisma-engines
+
+MAKE_ENV=	CI=1 \
+		NEXT_TELEMETRY_DISABLED=1 \
+		NODE_OPTIONS=--max-old-space-size=8192 \
+		PLAYWRIGHT_BROWSERS_PATH=${WRKDIR}/playwright \
+		PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 \
+		PRISMA_CLI_QUERY_ENGINE_TYPE=binary \
+		PRISMA_FMT_BINARY=${PRISMA_ENGINES}/prisma-fmt \
+		PRISMA_HIDE_UPDATE_MESSAGE=1 \
+		PRISMA_QUERY_ENGINE_BINARY=${PRISMA_ENGINES}/query-engine \
+		PRISMA_QUERY_ENGINE_LIBRARY=${WRKDIR}/libquery_engine.node \
+		PRISMA_SCHEMA_ENGINE_BINARY=${PRISMA_ENGINES}/schema-engine \
+		PYTHON=${PYTHON_CMD} \
+		npm_config_nodedir=${LOCALBASE} \
+		npm_config_python=${PYTHON_CMD}
+
+PLIST_SUB=	BUILDID=linkwarden-${DISTVERSIONPREFIX}${DISTVERSION}
+
+SUB_FILES=	pkg-message
+SUB_LIST=	NODE=${LOCALBASE}/bin/node \
+		WWWDIR=${WWWDIR}
+
+pre-build:
+	${CP} ${BUILDKIT}/yarn.lock ${WRKSRC}/yarn.lock
+	${LN} -sf ${PRISMA_ENGINES}/libquery_engine.so ${WRKDIR}/libquery_engine.node
+	${ECHO_CMD} 'cacheFolder: "${BUILDKIT}/yarn-cache"' >> ${WRKSRC}/.yarnrc.yml
+	${ECHO_CMD} 'enableGlobalCache: false' >> ${WRKSRC}/.yarnrc.yml
+	${ECHO_CMD} 'enableNetwork: false' >> ${WRKSRC}/.yarnrc.yml
+	${ECHO_CMD} 'enableTelemetry: false' >> ${WRKSRC}/.yarnrc.yml
+	@${MKDIR} ${WRKSRC}/apps/web/fonts
+	${INSTALL_DATA} ${BUILDKIT}/fonts/*.ttf ${WRKSRC}/apps/web/fonts/
+
+do-build:
+	cd ${WRKSRC} && ${SETENV} ${MAKE_ENV} ${YARN} workspaces focus \
+		linkwarden @linkwarden/web @linkwarden/worker
+	@${MKDIR} ${WRKSRC}/node_modules/next/wasm/@next
+	${CP} -R ${BUILDKIT}/swc-wasm-nodejs \
+		${WRKSRC}/node_modules/next/wasm/@next/swc-wasm-nodejs
+	cd ${WRKSRC} && ${SETENV} ${MAKE_ENV} ${YARN} workspace \
+		@linkwarden/prisma generate
+	cd ${WRKSRC} && ${SETENV} ${MAKE_ENV} ${YARN} workspace \
+		@linkwarden/web build
+	cd ${WRKSRC} && ${SETENV} ${MAKE_ENV} ${YARN} workspaces focus \
+		--production linkwarden @linkwarden/web @linkwarden/worker
+	@${MKDIR} ${WRKSRC}/node_modules/next/wasm/@next
+	${CP} -R ${BUILDKIT}/swc-wasm-nodejs \
+		${WRKSRC}/node_modules/next/wasm/@next/swc-wasm-nodejs
+	cd ${WRKSRC} && ${SETENV} ${MAKE_ENV} ${YARN} workspace \
+		@linkwarden/prisma generate
+	${RM} -r ${WRKSRC}/apps/web/.next/cache
+
+do-install:
+	${MKDIR} ${STAGEDIR}${WWWDIR}
+	cd ${WRKSRC} && ${PAX} -rw -pp package.json yarn.lock .yarnrc.yml \
+		node_modules apps/web apps/worker packages ${STAGEDIR}${WWWDIR}
+	${LN} -sf ${PRISMA_ENGINES}/libquery_engine.so \
+		${STAGEDIR}${WWWDIR}/libquery_engine.node
+	${INSTALL_DATA} ${FILESDIR}/linkwarden.env.sample ${STAGEDIR}${PREFIX}/etc/
+	${MKDIR} ${STAGEDIR}/var/db/${PORTNAME} ${STAGEDIR}/var/log/${PORTNAME}
+
+post-install:
+# node-gyp is a build tool, it is of no use at runtime and only brings in
+# python shebangs, and the engine that prisma generate copied is the one of
+# databases/prisma-engines
+	@${RM} -r ${STAGEDIR}${WWWDIR}/node_modules/node-gyp \
+		${STAGEDIR}${WWWDIR}/node_modules/.bin/node-gyp
+	${LN} -sf ${PRISMA_ENGINES}/libquery_engine.so \
+		${STAGEDIR}${WWWDIR}/node_modules/.prisma/client/libquery_engine.node
+	@${RM} -r ${STAGEDIR}${WWWDIR}/apps/web/fonts
+	@${FIND} ${STAGEDIR}${WWWDIR}/node_modules -type d \
+		\( -name .deps -o -name "build-tmp-*" -o -name node-addon-api \) \
+		-exec ${RM} -r {} + 2>/dev/null || ${TRUE}
+	@${FIND} ${STAGEDIR}${WWWDIR}/node_modules -type f \
+		\( -name "*.target.mk" -o -name "*.Makefile" -o -name Makefile \
+		-o -name nothing.a -o -name config.gypi -o -name "*.stamp" \) -delete
+	@${FIND} ${STAGEDIR}${WWWDIR} -type f \
+		\( -name "*.map" -o -name "*.md" -o -name "*.markdown" \) -delete
+	@${FIND} ${STAGEDIR}${WWWDIR}/node_modules -type d \
+		\( -name test -o -name tests -o -name __tests__ -o -name docs \) \
+		-exec ${RM} -r {} + 2>/dev/null || ${TRUE}
+	@${FIND} ${STAGEDIR}${WWWDIR} -type f -name ".DS_Store" -delete
+	@${FIND} ${STAGEDIR}${WWWDIR} -type f -name "*.sh" -exec ${CHMOD} a-x {} +
+	@${FIND} ${STAGEDIR}${WWWDIR} -type d -empty -delete
+	# Dynamically build pkg-plist because it is very large
+	@(cd ${STAGEDIR}${PREFIX} ; \
+		${FIND} www \( -type f -or -type l \) | ${SORT} >> ${TMPPLIST})
+	@echo "@sample etc/linkwarden.env.sample" >> ${TMPPLIST}
+	@echo "@dir(linkwarden,linkwarden,750) /var/db/linkwarden" >> ${TMPPLIST}
+	@echo "@dir(linkwarden,linkwarden,750) /var/log/linkwarden" >> ${TMPPLIST}
+
+.include <bsd.port.mk>
diff --git a/www/linkwarden/distinfo b/www/linkwarden/distinfo
new file mode 100644
index 000000000000..16aac39e6121
--- /dev/null
+++ b/www/linkwarden/distinfo
@@ -0,0 +1,5 @@
+TIMESTAMP = 1790154217
+SHA256 (linkwarden-freebsd-buildkit-2.16.3.tar.gz) = 3865c4f06d251d706499de29d2838bc96385248cc26c5130c540491cd8e7e49b
+SIZE (linkwarden-freebsd-buildkit-2.16.3.tar.gz) = 226736331
+SHA256 (linkwarden-linkwarden-v2.16.3_GH0.tar.gz) = 88fd2010ee225724756f6f9da7841788622676e82717a9a2e0cb5625f915696a
+SIZE (linkwarden-linkwarden-v2.16.3_GH0.tar.gz) = 7182381
diff --git a/www/linkwarden/files/linkwarden.env.sample b/www/linkwarden/files/linkwarden.env.sample
new file mode 100644
index 000000000000..cfbded483466
--- /dev/null
+++ b/www/linkwarden/files/linkwarden.env.sample
@@ -0,0 +1,41 @@
+# Settings for linkwarden_web and linkwarden_worker.  Everything in here is
+# exported into the environment of both services, so any variable described in
+# https://docs.linkwarden.app/self-hosting/environment-variables can be set.
+#
+# Keep this file readable by the linkwarden user only, it holds credentials:
+#   chown root:linkwarden linkwarden.env && chmod 640 linkwarden.env
+
+# Database, see the message of this package for how to create it
+DATABASE_URL=postgresql://linkwarden:changeme@localhost:5432/linkwarden
+
+# Public address of the instance and the session secret.  Generate the secret
+# with: openssl rand -base64 32
+NEXTAUTH_URL=http://localhost:3000/api/v1/auth
+NEXTAUTH_SECRET=
+
+# Where the archived pages are kept.  An absolute path is used as it is, a
+# relative one is resolved against the directory above the installation.
+#STORAGE_FOLDER=/var/db/linkwarden/data
+
+# Username and password logins.  Set to false when only SSO should be allowed.
+NEXT_PUBLIC_CREDENTIALS_ENABLED=true
+
+# Set to true once the first account exists, so that nobody else can register.
+#NEXT_PUBLIC_DISABLE_REGISTRATION=false
+
+# Full text search over the archived pages needs a meilisearch instance,
+# see textproc/meilisearch.  Without it the search covers names, urls,
+# descriptions and tags.
+#MEILI_HOST=http://127.0.0.1:7700
+#MEILI_MASTER_KEY=
+
+# Options passed to www/monolith when a page is saved as a single file
+#MONOLITH_CUSTOM_OPTIONS=-j -F -q
+
+# Time the worker waits for a page, in milliseconds
+#BROWSER_TIMEOUT=
+
+# Mail for password resets and invitations
+#NEXT_PUBLIC_EMAIL_PROVIDER=true
+#EMAIL_FROM=linkwarden@example.org
+#EMAIL_SERVER=smtp://localhost:25
diff --git a/www/linkwarden/files/linkwarden_web.in b/www/linkwarden/files/linkwarden_web.in
new file mode 100644
index 000000000000..421b9d395db8
--- /dev/null
+++ b/www/linkwarden/files/linkwarden_web.in
@@ -0,0 +1,116 @@
+#!/bin/sh
+
+# PROVIDE: linkwarden_web
+# REQUIRE: LOGIN postgresql
+# KEYWORD: shutdown
+#
+# Add these lines to /etc/rc.conf.local or /etc/rc.conf
+# to enable this service:
+#
+# linkwarden_web_enable (bool):		Set to NO by default.
+#					Set it to YES to enable the frontend.
+# linkwarden_storage (str):		Data directory, holds the archives.
+#					Default: /var/db/linkwarden
+# linkwarden_web_host (str):		Listen address. Default: 0.0.0.0
+# linkwarden_web_port (int):		Listen port. Default: 3000
+# linkwarden_web_env_file (str):	Environment file with DATABASE_URL,
+#					NEXTAUTH_SECRET and the other settings.
+#					Default: %%PREFIX%%/etc/linkwarden.env
+# linkwarden_web_automigrate (bool):	Run "prisma migrate deploy" on start.
+#					Default: YES
+#
+# The database schema can also be brought up to date on its own with
+# "service linkwarden_web migrate".
+
+. /etc/rc.subr
+
+name=linkwarden_web
+rcvar=linkwarden_web_enable
+
+load_rc_config $name
+
+: ${linkwarden_web_enable:=NO}
+: ${linkwarden_web_user:=linkwarden}
+: ${linkwarden_web_group:=linkwarden}
+: ${linkwarden_storage:=/var/db/linkwarden}
+: ${linkwarden_web_host:=0.0.0.0}
+: ${linkwarden_web_port:=3000}
+: ${linkwarden_web_env_file:=%%PREFIX%%/etc/linkwarden.env}
+: ${linkwarden_web_automigrate:=YES}
+
+pidfile=/var/run/linkwarden/${name}.pid
+childpidfile=/var/run/linkwarden/${name}_child.pid
+required_dirs="%%WWWDIR%%/apps/web/.next"
+
+linkwarden_web_chdir="%%WWWDIR%%/apps/web/"
+start_precmd=linkwarden_web_precmd
+stop_cmd=linkwarden_web_stop
+extra_commands="migrate"
+migrate_cmd=linkwarden_web_migrate
+
+command=/usr/sbin/daemon
+command_args="-f -S -H -P ${pidfile} -p ${childpidfile} \
+	-o /var/log/linkwarden/${name}.log \
+	%%NODE%% %%WWWDIR%%/node_modules/next/dist/bin/next start \
+	%%WWWDIR%%/apps/web"
+
+linkwarden_environment()
+{
+	if [ -r "${linkwarden_web_env_file}" ]; then
+		set -a
+		. "${linkwarden_web_env_file}"
+		set +a
+	fi
+
+	# monolith and the browser are called by name, so they have to be in PATH
+	export PATH=%%PREFIX%%/sbin:%%PREFIX%%/bin:/usr/sbin:/usr/bin:/sbin:/bin
+	export NODE_ENV=production
+	export HOSTNAME=${linkwarden_web_host}
+	export PORT=${linkwarden_web_port}
+	export STORAGE_FOLDER=${STORAGE_FOLDER:-${linkwarden_storage}/data}
+	export PRISMA_HIDE_UPDATE_MESSAGE=1
+	export PRISMA_CLI_QUERY_ENGINE_TYPE=binary
+	export PRISMA_QUERY_ENGINE_BINARY=%%PREFIX%%/libexec/prisma-engines/query-engine
+	export PRISMA_SCHEMA_ENGINE_BINARY=%%PREFIX%%/libexec/prisma-engines/schema-engine
+	export PRISMA_FMT_BINARY=%%PREFIX%%/libexec/prisma-engines/prisma-fmt
+	export PRISMA_QUERY_ENGINE_LIBRARY=%%WWWDIR%%/libquery_engine.node
+	# playwright refuses to load without this one
+	export PLAYWRIGHT_BROWSERS_PATH=${linkwarden_storage}/ms-playwright
+}
+
+linkwarden_web_migrate()
+{
+	linkwarden_environment
+	echo "Applying database migrations for ${name}."
+	su -m ${linkwarden_web_user} -c "cd ${linkwarden_storage} && \
+		%%NODE%% %%WWWDIR%%/node_modules/prisma/build/index.js \
+		migrate deploy --schema %%WWWDIR%%/packages/prisma/schema.prisma"
+}
+
+linkwarden_web_stop()
+{
+	local pid
+	pid=$(check_pidfile ${pidfile} ${command})
+	if [ -z "$pid" ]; then
+		echo "${name} not running? (check ${pidfile})."
+		return 1
+	fi
+	echo "Stopping ${name}."
+	kill -TERM -- -${pid} 2>/dev/null || kill -TERM ${pid}
+	wait_for_pids ${pid}
+	rm -f ${pidfile} ${childpidfile}
+}
+
+linkwarden_web_precmd()
+{
+	umask 027
+	install -d -m 750 -o ${linkwarden_web_user} -g ${linkwarden_web_group} \
+		/var/run/linkwarden /var/log/linkwarden \
+		${linkwarden_storage} ${linkwarden_storage}/data
+	linkwarden_environment
+	if checkyesno linkwarden_web_automigrate; then
+		linkwarden_web_migrate || return 1
+	fi
+}
+
+run_rc_command "$1"
diff --git a/www/linkwarden/files/linkwarden_worker.in b/www/linkwarden/files/linkwarden_worker.in
new file mode 100644
index 000000000000..770103a315fc
--- /dev/null
+++ b/www/linkwarden/files/linkwarden_worker.in
@@ -0,0 +1,94 @@
+#!/bin/sh
+
+# PROVIDE: linkwarden_worker
+# REQUIRE: LOGIN postgresql meilisearch
+# KEYWORD: shutdown
+#
+# Add these lines to /etc/rc.conf.local or /etc/rc.conf
+# to enable this service:
+#
+# linkwarden_worker_enable (bool):	Set to NO by default.
+#					Set it to YES to enable the worker that
+#					preserves the saved pages.
+# linkwarden_worker_data (str):		Data directory, holds the archives.
+#					Default: /var/db/linkwarden
+# linkwarden_worker_env_file (str):	Environment file with DATABASE_URL and
+#					the other settings.
+#					Default: %%PREFIX%%/etc/linkwarden.env
+# linkwarden_worker_browser (str):	Browser used for screenshots and PDFs.
+#					Default: %%PREFIX%%/bin/chrome
+
+. /etc/rc.subr
+
+name=linkwarden_worker
+rcvar=linkwarden_worker_enable
+
+load_rc_config $name
+
+: ${linkwarden_worker_enable:=NO}
+: ${linkwarden_worker_user:=linkwarden}
+: ${linkwarden_worker_group:=linkwarden}
+: ${linkwarden_worker_data:=/var/db/linkwarden}
+: ${linkwarden_worker_env_file:=%%PREFIX%%/etc/linkwarden.env}
+: ${linkwarden_worker_browser:=%%PREFIX%%/bin/chrome}
+
+pidfile=/var/run/linkwarden/${name}.pid
+childpidfile=/var/run/linkwarden/${name}_child.pid
+required_files="%%WWWDIR%%/apps/worker/worker.ts"
+
+linkwarden_worker_chdir=${linkwarden_worker_data}
+start_precmd=linkwarden_worker_precmd
+stop_cmd=linkwarden_worker_stop
+
+command=/usr/sbin/daemon
+command_args="-f -S -H -P ${pidfile} -p ${childpidfile} \
+	-o /var/log/linkwarden/${name}.log \
+	%%NODE%% %%WWWDIR%%/node_modules/tsx/dist/cli.mjs \
+	%%WWWDIR%%/apps/worker/worker.ts"
+
+linkwarden_worker_stop()
+{
+	local pid
+	pid=$(check_pidfile ${pidfile} ${command})
+	if [ -z "$pid" ]; then
+		echo "${name} not running? (check ${pidfile})."
+		return 1
+	fi
+	echo "Stopping ${name}."
+	kill -TERM -- -${pid} 2>/dev/null || kill -TERM ${pid}
+	wait_for_pids ${pid}
+	rm -f ${pidfile} ${childpidfile}
+}
+
+linkwarden_worker_precmd()
+{
+	umask 027
+	install -d -m 750 -o ${linkwarden_worker_user} \
+		-g ${linkwarden_worker_group} \
+		/var/run/linkwarden /var/log/linkwarden \
+		${linkwarden_worker_data} ${linkwarden_worker_data}/data
+
+	if [ -r "${linkwarden_worker_env_file}" ]; then
+		set -a
+		. "${linkwarden_worker_env_file}"
+		set +a
+	fi
+
+	# monolith and the browser are called by name, so they have to be in PATH
+	export PATH=%%PREFIX%%/sbin:%%PREFIX%%/bin:/usr/sbin:/usr/bin:/sbin:/bin
+	export NODE_ENV=production
+	export STORAGE_FOLDER=${STORAGE_FOLDER:-${linkwarden_worker_data}/data}
+	export PRISMA_HIDE_UPDATE_MESSAGE=1
+	export PRISMA_CLI_QUERY_ENGINE_TYPE=binary
+	export PRISMA_QUERY_ENGINE_BINARY=%%PREFIX%%/libexec/prisma-engines/query-engine
+	export PRISMA_SCHEMA_ENGINE_BINARY=%%PREFIX%%/libexec/prisma-engines/schema-engine
+	export PRISMA_FMT_BINARY=%%PREFIX%%/libexec/prisma-engines/prisma-fmt
+	export PRISMA_QUERY_ENGINE_LIBRARY=%%WWWDIR%%/libquery_engine.node
+	# playwright refuses to load without a browser path, and it is told to
+	# use the chromium of the ports tree instead of downloading one
+	export PLAYWRIGHT_BROWSERS_PATH=${linkwarden_worker_data}/ms-playwright
+	export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
+	export PLAYWRIGHT_LAUNCH_OPTIONS_EXECUTABLE_PATH=${PLAYWRIGHT_LAUNCH_OPTIONS_EXECUTABLE_PATH:-${linkwarden_worker_browser}}
+}
+
+run_rc_command "$1"
diff --git a/www/linkwarden/files/patch-apps_web_components_Preservation_ReadableView.tsx b/www/linkwarden/files/patch-apps_web_components_Preservation_ReadableView.tsx
new file mode 100644
index 000000000000..01f1015289a2
--- /dev/null
+++ b/www/linkwarden/files/patch-apps_web_components_Preservation_ReadableView.tsx
@@ -0,0 +1,25 @@
+--- apps/web/components/Preservation/ReadableView.tsx.orig	2026-09-23 09:02:23 UTC
++++ apps/web/components/Preservation/ReadableView.tsx
+@@ -25,13 +25,18 @@
+ } from "@linkwarden/router/highlights";
+ import { Highlight } from "@linkwarden/prisma/client";
+ import { useUser } from "@linkwarden/router/user";
+-import { Caveat } from "next/font/google";
+-import { Bentham } from "next/font/google";
++import localFont from "next/font/local";
+ import { Separator } from "../ui/separator";
+ import { Button } from "../ui/button";
+ 
+-const caveat = Caveat({ subsets: ["latin"] });
+-const bentham = Bentham({ subsets: ["latin"], weight: "400" });
++const caveat = localFont({
++  src: "../../fonts/Caveat-Variable.ttf",
++  weight: "400 700",
++});
++const bentham = localFont({
++  src: "../../fonts/Bentham-Regular.ttf",
++  weight: "400",
++});
+ 
+ type Props = {
+   link: LinkIncludingShortenedCollectionAndTags;
diff --git a/www/linkwarden/files/patch-apps_web_components_TextStyleDropdown.tsx b/www/linkwarden/files/patch-apps_web_components_TextStyleDropdown.tsx
new file mode 100644
index 000000000000..ad2c24e7d01d
--- /dev/null
+++ b/www/linkwarden/files/patch-apps_web_components_TextStyleDropdown.tsx
@@ -0,0 +1,24 @@
+--- apps/web/components/TextStyleDropdown.tsx.orig	2026-09-23 09:02:23 UTC
++++ apps/web/components/TextStyleDropdown.tsx
+@@ -13,12 +13,17 @@
+ import { Button } from "@/components/ui/button";
+ import { FitWidth, FormatLineSpacing, FormatSize } from "@/components/ui/icons";
+ import { useUpdateUserPreference, useUser } from "@linkwarden/router/user";
+-import { Caveat } from "next/font/google";
+-import { Bentham } from "next/font/google";
++import localFont from "next/font/local";
+ import { useTranslation } from "next-i18next";
+ 
+-const caveat = Caveat({ subsets: ["latin"] });
+-const bentham = Bentham({ subsets: ["latin"], weight: "400" });
++const caveat = localFont({
++  src: "../fonts/Caveat-Variable.ttf",
++  weight: "400 700",
++});
++const bentham = localFont({
++  src: "../fonts/Bentham-Regular.ttf",
++  weight: "400",
++});
+ 
+ const fontSizes = [
+   "12px",
diff --git a/www/linkwarden/files/patch-apps_web_next.config.js b/www/linkwarden/files/patch-apps_web_next.config.js
new file mode 100644
index 000000000000..e885a0ea5a19
--- /dev/null
+++ b/www/linkwarden/files/patch-apps_web_next.config.js
@@ -0,0 +1,10 @@
+--- apps/web/next.config.js.orig	2026-09-22 11:09:01 UTC
++++ apps/web/next.config.js
+@@ -4,6 +4,7 @@ const nextConfig = {
+ 
+ const nextConfig = {
+   i18n,
++  generateBuildId: () => "linkwarden-" + version,
+   reactStrictMode: true,
+   devIndicators: false,
+   staticPageGenerationTimeout: 1000,
diff --git a/www/linkwarden/files/patch-apps_web_package.json b/www/linkwarden/files/patch-apps_web_package.json
new file mode 100644
index 000000000000..e548ac80f4bd
--- /dev/null
+++ b/www/linkwarden/files/patch-apps_web_package.json
@@ -0,0 +1,19 @@
+--- apps/web/package.json.orig	2026-09-22 09:39:55 UTC
++++ apps/web/package.json
+@@ -13,7 +13,7 @@
+     "lint": "next lint",
+     "e2e": "playwright test e2e",
+     "format": "prettier --write \"**/*.{ts,tsx,js,json,md}\"",
+-    "postinstall": "playwright install --with-deps chromium"
++    "postinstall": "true"
+   },
+   "dependencies": {
+     "@apple/app-store-server-library": "^3.1.0",
+@@ -29,6 +29,7 @@
+     "@linkwarden/prisma": "*",
+     "@linkwarden/router": "*",
+     "@linkwarden/types": "*",
++    "@next/swc-wasm-nodejs": "15.5.21",
+     "@phosphor-icons/core": "^2.1.1",
+     "@phosphor-icons/react": "^2.1.7",
+     "@radix-ui/react-checkbox": "^1.3.3",
diff --git a/www/linkwarden/files/patch-package.json b/www/linkwarden/files/patch-package.json
new file mode 100644
index 000000000000..6f9c64dfc9eb
--- /dev/null
+++ b/www/linkwarden/files/patch-package.json
@@ -0,0 +1,20 @@
+--- package.json.orig	2026-09-22 09:39:55 UTC
++++ package.json
+@@ -24,7 +24,7 @@
+     "format": "yarn workspaces run format",
+     "test": "vitest",
+     "coverage": "vitest run --coverage",
+-    "postinstall": "yarn workspace @linkwarden/web run postinstall && patch-package"
++    "postinstall": "true"
+   },
+   "devDependencies": {
+     "vitest": "^4.1.0"
+@@ -35,6 +35,8 @@
+     "patch-package": "^8.0.0"
+   },
+   "resolutions": {
++    "@prisma/client": "6.19.0",
++    "prisma": "6.19.0",
+     "react": "19.1.0",
+     "react-dom": "19.1.0"
+   }
diff --git a/www/linkwarden/files/patch-packages_filesystem_createFile.ts b/www/linkwarden/files/patch-packages_filesystem_createFile.ts
new file mode 100644
index 000000000000..1605007a991b
--- /dev/null
+++ b/www/linkwarden/files/patch-packages_filesystem_createFile.ts
@@ -0,0 +1,11 @@
+--- packages/filesystem/createFile.ts.orig	2026-09-23 10:10:21 UTC
++++ packages/filesystem/createFile.ts
+@@ -48,7 +48,7 @@
+     }
+   } else {
+     const storagePath = process.env.STORAGE_FOLDER || "data";
+-    const creationPath = path.join(
++    const creationPath = path.resolve(
+       process.cwd(),
+       "../..",
+       storagePath,
diff --git a/www/linkwarden/files/patch-packages_filesystem_createFolder.ts b/www/linkwarden/files/patch-packages_filesystem_createFolder.ts
new file mode 100644
index 000000000000..96d94708115e
--- /dev/null
+++ b/www/linkwarden/files/patch-packages_filesystem_createFolder.ts
@@ -0,0 +1,11 @@
+--- packages/filesystem/createFolder.ts.orig	2026-09-23 10:10:21 UTC
++++ packages/filesystem/createFolder.ts
+@@ -7,7 +7,7 @@
+     // Do nothing, S3 creates directories recursively
+   } else {
+     const storagePath = process.env.STORAGE_FOLDER || "data";
+-    const creationPath = path.join(
++    const creationPath = path.resolve(
+       process.cwd(),
+       "../..",
+       storagePath,
diff --git a/www/linkwarden/files/patch-packages_filesystem_fileExists.ts b/www/linkwarden/files/patch-packages_filesystem_fileExists.ts
new file mode 100644
index 000000000000..08a6c9e61a65
--- /dev/null
+++ b/www/linkwarden/files/patch-packages_filesystem_fileExists.ts
@@ -0,0 +1,11 @@
+--- packages/filesystem/fileExists.ts.orig	2026-09-23 10:10:21 UTC
++++ packages/filesystem/fileExists.ts
+@@ -20,7 +20,7 @@
+   }
+ 
+   const storagePath = process.env.STORAGE_FOLDER || "data";
+-  const creationPath = path.join(process.cwd(), "../..", storagePath, filePath);
++  const creationPath = path.resolve(process.cwd(), "../..", storagePath, filePath);
+ 
+   return fs.existsSync(creationPath);
+ }
diff --git a/www/linkwarden/files/patch-packages_filesystem_moveFile.ts b/www/linkwarden/files/patch-packages_filesystem_moveFile.ts
new file mode 100644
index 000000000000..ede787a64702
--- /dev/null
+++ b/www/linkwarden/files/patch-packages_filesystem_moveFile.ts
@@ -0,0 +1,11 @@
+--- packages/filesystem/moveFile.ts.orig	2026-09-23 10:10:21 UTC
++++ packages/filesystem/moveFile.ts
+@@ -28,7 +28,7 @@
+     const storagePath = process.env.STORAGE_FOLDER || "data";
+ 
+     const directory = (file: string) =>
+-      path.join(process.cwd(), "../..", storagePath, file);
++      path.resolve(process.cwd(), "../..", storagePath, file);
+ 
+     if (fs.existsSync(directory(from))) {
+       fs.rename(directory(from), directory(to), (err) => {
diff --git a/www/linkwarden/files/patch-packages_filesystem_readFile.ts b/www/linkwarden/files/patch-packages_filesystem_readFile.ts
new file mode 100644
index 000000000000..a12f5175563e
--- /dev/null
+++ b/www/linkwarden/files/patch-packages_filesystem_readFile.ts
@@ -0,0 +1,11 @@
+--- packages/filesystem/readFile.ts.orig	2026-09-23 10:10:21 UTC
++++ packages/filesystem/readFile.ts
+@@ -79,7 +79,7 @@
+     }
+   } else {
+     const storagePath = process.env.STORAGE_FOLDER || "data";
+-    const creationPath = path.join(
++    const creationPath = path.resolve(
+       process.cwd(),
+       "../..",
+       storagePath,
diff --git a/www/linkwarden/files/patch-packages_filesystem_removeFile.ts b/www/linkwarden/files/patch-packages_filesystem_removeFile.ts
new file mode 100644
index 000000000000..a0a74e80459b
--- /dev/null
+++ b/www/linkwarden/files/patch-packages_filesystem_removeFile.ts
@@ -0,0 +1,11 @@
+--- packages/filesystem/removeFile.ts.orig	2026-09-23 10:10:21 UTC
++++ packages/filesystem/removeFile.ts
+@@ -17,7 +17,7 @@
+     }
+   } else {
+     const storagePath = process.env.STORAGE_FOLDER || "data";
+-    const creationPath = path.join(
++    const creationPath = path.resolve(
+       process.cwd(),
+       "../..",
+       storagePath,
diff --git a/www/linkwarden/files/patch-packages_filesystem_removeFolder.ts b/www/linkwarden/files/patch-packages_filesystem_removeFolder.ts
new file mode 100644
index 000000000000..fa215b1b6c1e
--- /dev/null
+++ b/www/linkwarden/files/patch-packages_filesystem_removeFolder.ts
@@ -0,0 +1,11 @@
+--- packages/filesystem/removeFolder.ts.orig	2026-09-23 10:10:21 UTC
++++ packages/filesystem/removeFolder.ts
+@@ -49,7 +49,7 @@
+     }
+   } else {
+     const storagePath = process.env.STORAGE_FOLDER || "data";
+-    const creationPath = path.join(
++    const creationPath = path.resolve(
+       process.cwd(),
+       "../..",
+       storagePath,
diff --git a/www/linkwarden/files/pkg-message.in b/www/linkwarden/files/pkg-message.in
new file mode 100644
index 000000000000..59309616b988
--- /dev/null
+++ b/www/linkwarden/files/pkg-message.in
@@ -0,0 +1,33 @@
+[
+{ type: install
+  message: <<EOM
+Linkwarden needs a PostgreSQL database and a few settings before it starts.
+
+1. Create the database, for example on the same host:
+
+	su -l postgres -c "psql -c \"CREATE ROLE linkwarden LOGIN PASSWORD 'secret'\""
+	su -l postgres -c "createdb -O linkwarden linkwarden"
+
+2. Set DATABASE_URL, NEXTAUTH_URL and a NEXTAUTH_SECRET
+   (openssl rand -base64 32) in %%PREFIX%%/etc/linkwarden.env.  That file
+   holds credentials, so keep it to the linkwarden user:
+
+	chown root:linkwarden %%PREFIX%%/etc/linkwarden.env
+	chmod 640 %%PREFIX%%/etc/linkwarden.env
+
+3. Enable and start both services.  The frontend brings the database schema
+   up to date whenever it starts:
+
+	sysrc linkwarden_web_enable=YES linkwarden_worker_enable=YES
+	service linkwarden_web start
+	service linkwarden_worker start
+
+The frontend then listens on port 3000 and the first account is created by
+registering in the web interface.
+
+Saved pages are preserved by the worker with www/chromium and www/monolith,
+the archives end up in /var/db/linkwarden/data.  Full text search over the
+archived pages needs textproc/meilisearch, see MEILI_HOST in linkwarden.env.
+EOM
+}
+]
diff --git a/www/linkwarden/pkg-descr b/www/linkwarden/pkg-descr
new file mode 100644
index 000000000000..0d98512d6315
--- /dev/null
+++ b/www/linkwarden/pkg-descr
@@ -0,0 +1,10 @@
+Linkwarden is a self-hosted, collaborative bookmark manager that collects,
+organizes and preserves webpages.  Every saved link is archived as a
+screenshot, a PDF, a single file HTML copy and a readable article, so the
+content stays available even when the original page is gone.
+
+Links live in collections, can be tagged, shared with other users or made
+public, and are searchable including the text of the archived pages.
+
+The port runs the web frontend and the background worker as two separate
+services and keeps its data in PostgreSQL.