diff --git a/.github/scripts/app_downloads.py b/.github/scripts/app_downloads.py index 7eff83235..b5ba5f380 100644 --- a/.github/scripts/app_downloads.py +++ b/.github/scripts/app_downloads.py @@ -1,10 +1,21 @@ #!/usr/bin/env python3 -"""Collect download metrics for ConductionNL Nextcloud apps. +"""Collect download metrics for Conduction Nextcloud apps. -Walks the ConductionNL GitHub org, identifies repositories that look like -Nextcloud apps (presence of appinfo/info.xml), sums GitHub release-asset -download counts, and joins each entry with the corresponding Nextcloud app -store record (rating, latest store version, categories) when present. +Walks BOTH source forges, identifies repositories that look like Nextcloud +apps (presence of appinfo/info.xml), sums release-asset download counts, and +merges the two per app id: + + * GitHub org ``ConductionNL`` — the live source. Every Conduction app is + developed and released here, and Nextcloud app-store installs fetch the + release tarball from here, so this is where new downloads accrue. + * Codeberg org ``Conduction`` — legacy. The apps lived on Codeberg for a + few months in 2026 before moving back to GitHub; the release tarballs + published there still carry their download counts, so we keep counting + them. + +Each app's ``downloads_total`` is ``github + codeberg``; ``totals.downloads`` +is the grand total the website StatsStrip renders. Joined with the Nextcloud +app store record (rating, latest store version, categories) when present. Writes a compound JSON to data/app-downloads.json. Stdlib only — no pip deps. """ @@ -19,27 +30,31 @@ from datetime import datetime, timezone from pathlib import Path -ORG = "ConductionNL" +GITHUB_ORG = "ConductionNL" +CODEBERG_ORG = "Conduction" PLATFORM_VERSION = "30.0.0" GITHUB_API = "https://api.github.com" +CODEBERG_BASE = "https://codeberg.org" +CODEBERG_API = f"{CODEBERG_BASE}/api/v1" STORE_API = f"https://apps.nextcloud.com/api/v1/platform/{PLATFORM_VERSION}/apps.json" OUTPUT_PATH = Path(__file__).resolve().parents[2] / "data" / "app-downloads.json" -TOKEN = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") +GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") +CODEBERG_TOKEN = os.environ.get("CODEBERG_TOKEN") or os.environ.get("CB_TOKEN") USER_AGENT = "conduction-app-downloads" -def gh_request(url, accept="application/vnd.github+json"): - headers = {"Accept": accept, "User-Agent": USER_AGENT} - if TOKEN: - headers["Authorization"] = f"Bearer {TOKEN}" - req = urllib.request.Request(url, headers=headers) - for attempt in range(3): +# --------------------------------------------------------------------------- # +# Generic HTTP helpers +# --------------------------------------------------------------------------- # +def _request(url, headers, retries=3): + req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, **headers}) + for attempt in range(retries): try: with urllib.request.urlopen(req, timeout=30) as resp: return json.loads(resp.read()), resp.headers except urllib.error.HTTPError as e: - if e.code in (403, 429) and attempt < 2: + if e.code in (403, 429) and attempt < retries - 1: wait = int(e.headers.get("Retry-After") or (2 ** attempt) * 5) print(f" rate-limited, sleeping {wait}s", file=sys.stderr) time.sleep(wait) @@ -47,10 +62,36 @@ def gh_request(url, accept="application/vnd.github+json"): raise +def fetch_text(url, headers=None): + req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, **(headers or {})}) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return resp.read().decode("utf-8", errors="replace") + except urllib.error.HTTPError as e: + if e.code == 404: + return None + raise + + +def parse_app_id(info_xml): + m = re.search(r"\s*([^<\s]+)\s*", info_xml) + return m.group(1) if m else None + + +# --------------------------------------------------------------------------- # +# GitHub (live source) +# --------------------------------------------------------------------------- # +def gh_headers(): + h = {"Accept": "application/vnd.github+json"} + if GITHUB_TOKEN: + h["Authorization"] = f"Bearer {GITHUB_TOKEN}" + return h + + def gh_paginate(url): items = [] while url: - page, headers = gh_request(url) + page, headers = _request(url, gh_headers()) items.extend(page) next_url = None for part in headers.get("Link", "").split(","): @@ -61,22 +102,130 @@ def gh_paginate(url): return items -def fetch_text(url): - req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) +def collect_github(): + """Return {app_id: github_block}. Archived repos are kept on purpose — + their release tarballs still carry historical download counts.""" + out = {} try: - with urllib.request.urlopen(req, timeout=30) as resp: - return resp.read().decode("utf-8", errors="replace") - except urllib.error.HTTPError as e: - if e.code == 404: - return None - raise + repos = gh_paginate(f"{GITHUB_API}/orgs/{GITHUB_ORG}/repos?per_page=100&type=public") + except urllib.error.URLError as e: + print(f"Warning: GitHub repo listing failed: {e}", file=sys.stderr) + return out + print(f" GitHub: {len(repos)} repos", file=sys.stderr) + for repo in repos: + if repo.get("disabled") or repo.get("fork"): + continue + info_xml = fetch_text( + f"https://raw.githubusercontent.com/{repo['full_name']}/{repo['default_branch']}/appinfo/info.xml" + ) + if not info_xml: + continue + app_id = parse_app_id(info_xml) + if not app_id: + continue -def parse_app_id(info_xml): - m = re.search(r"\s*([^<\s]+)\s*", info_xml) - return m.group(1) if m else None + releases = gh_paginate(f"{GITHUB_API}/repos/{repo['full_name']}/releases?per_page=100") + total, breakdown = _sum_releases(releases, asset_key="assets", tag_key="tag_name", + published_key="published_at") + latest = next((r for r in releases if not r.get("prerelease") and not r.get("draft")), None) + out[app_id] = { + "url": repo["html_url"], + "repo": repo["full_name"], + "stars": repo.get("stargazers_count", 0), + "downloads": total, + "latest_release": latest["tag_name"] if latest else None, + "latest_release_at": latest["published_at"] if latest else None, + "release_count": len(releases), + "releases": breakdown, + } + print(f" + GitHub {repo['full_name']} ({app_id}): {total}", file=sys.stderr) + return out + + +# --------------------------------------------------------------------------- # +# Codeberg / Forgejo (legacy source) +# --------------------------------------------------------------------------- # +def cb_headers(): + h = {"Accept": "application/json"} + if CODEBERG_TOKEN: + h["Authorization"] = f"token {CODEBERG_TOKEN}" + return h +def cb_paginate(path): + """Forgejo paginates with ?limit=&page=; stop when a short page returns.""" + items, page, limit = [], 1, 50 + while True: + sep = "&" if "?" in path else "?" + page_data, _ = _request(f"{CODEBERG_API}{path}{sep}limit={limit}&page={page}", cb_headers()) + if not page_data: + break + items.extend(page_data) + if len(page_data) < limit: + break + page += 1 + return items + + +def collect_codeberg(): + """Return {app_id: codeberg_block}.""" + out = {} + try: + repos = cb_paginate(f"/orgs/{CODEBERG_ORG}/repos") + except urllib.error.URLError as e: + print(f"Warning: Codeberg repo listing failed: {e}", file=sys.stderr) + return out + print(f" Codeberg: {len(repos)} repos", file=sys.stderr) + + for repo in repos: + if repo.get("archived") or repo.get("fork") or repo.get("private"): + continue + branch = repo.get("default_branch") or "main" + info_xml = fetch_text( + f"{CODEBERG_BASE}/{CODEBERG_ORG}/{repo['name']}/raw/branch/{branch}/appinfo/info.xml" + ) + if not info_xml: + continue + app_id = parse_app_id(info_xml) + if not app_id: + continue + + releases = cb_paginate(f"/repos/{CODEBERG_ORG}/{repo['name']}/releases") + total, breakdown = _sum_releases(releases, asset_key="assets", tag_key="tag_name", + published_key="published_at") + latest = next((r for r in releases if not r.get("prerelease") and not r.get("draft")), None) + out[app_id] = { + "url": repo["html_url"], + "repo": repo["full_name"], + "stars": repo.get("stars_count", 0), + "downloads": total, + "latest_release": latest["tag_name"] if latest else None, + "latest_release_at": latest["published_at"] if latest else None, + "release_count": len(releases), + "releases": breakdown, + } + print(f" + Codeberg {repo['full_name']} ({app_id}): {total}", file=sys.stderr) + return out + + +def _sum_releases(releases, asset_key, tag_key, published_key): + total, breakdown = 0, [] + for rel in releases: + sub = sum(a.get("download_count", 0) for a in rel.get(asset_key) or []) + total += sub + if sub: + breakdown.append({ + "tag": rel.get(tag_key), + "published_at": rel.get(published_key), + "downloads": sub, + }) + return total, breakdown + + +# --------------------------------------------------------------------------- # +# Nextcloud app store +# --------------------------------------------------------------------------- # def fetch_store_apps(): req = urllib.request.Request(STORE_API, headers={"User-Agent": USER_AGENT}) try: @@ -106,62 +255,57 @@ def store_summary(store_app): } +# --------------------------------------------------------------------------- # +# Merge + emit +# --------------------------------------------------------------------------- # def main(): - print(f"Listing public repos for org {ORG}...", file=sys.stderr) - repos = gh_paginate(f"{GITHUB_API}/orgs/{ORG}/repos?per_page=100&type=public") - print(f" {len(repos)} repos", file=sys.stderr) - - print("Fetching Nextcloud store catalog...", file=sys.stderr) - store_by_id = fetch_store_apps() - print(f" {len(store_by_id)} apps in store", file=sys.stderr) + # A download-stats fetch must never break the docs build. If any upstream + # API (GitHub, Codeberg, the Nextcloud store) is unreachable or returns an + # error, keep the committed data/app-downloads.json stub and exit cleanly. + try: + print("Collecting GitHub (live) download stats...", file=sys.stderr) + github = collect_github() + print("Collecting Codeberg (legacy) download stats...", file=sys.stderr) + codeberg = collect_codeberg() - apps = [] - for repo in repos: - if repo.get("archived") or repo.get("disabled"): - continue - info_xml = fetch_text( - f"https://raw.githubusercontent.com/{repo['full_name']}/{repo['default_branch']}/appinfo/info.xml" + print("Fetching Nextcloud store catalog...", file=sys.stderr) + store_by_id = fetch_store_apps() + print(f" {len(store_by_id)} apps in store", file=sys.stderr) + except (urllib.error.HTTPError, urllib.error.URLError, OSError) as e: + print( + f"WARNING: download-stats fetch failed ({e}); keeping existing " + f"{OUTPUT_PATH.name} and continuing the build.", + file=sys.stderr, ) - if not info_xml: - continue - app_id = parse_app_id(info_xml) - if not app_id: - continue + if not OUTPUT_PATH.exists(): + OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True) + with OUTPUT_PATH.open("w") as f: + json.dump({"generated_at": None, "totals": {}, "apps": []}, f, indent=2) + f.write("\n") + return - print(f" + {repo['full_name']} ({app_id})", file=sys.stderr) - releases = gh_paginate(f"{GITHUB_API}/repos/{repo['full_name']}/releases?per_page=100") - total = 0 - breakdown = [] - for rel in releases: - sub = sum(a.get("download_count", 0) for a in rel.get("assets") or []) - total += sub - if sub: - breakdown.append({ - "tag": rel.get("tag_name"), - "published_at": rel.get("published_at"), - "downloads": sub, - }) - - latest = next((r for r in releases if not r.get("prerelease") and not r.get("draft")), None) + apps = [] + for app_id in sorted(set(github) | set(codeberg)): + gh = github.get(app_id) + cb = codeberg.get(app_id) + gh_dl = gh["downloads"] if gh else 0 + cb_dl = cb["downloads"] if cb else 0 apps.append({ "id": app_id, - "repo": repo["full_name"], - "github": { - "url": repo["html_url"], - "stars": repo.get("stargazers_count", 0), - "downloads": total, - "latest_release": latest["tag_name"] if latest else None, - "latest_release_at": latest["published_at"] if latest else None, - "release_count": len(releases), - "releases": breakdown, - }, + # Prefer the live GitHub repo path; fall back to the Codeberg one. + "repo": (gh or cb)["repo"], + "downloads_total": gh_dl + cb_dl, + "github": gh, + "codeberg": cb, "store": store_summary(store_by_id.get(app_id)), }) - apps.sort(key=lambda a: a["id"]) - + dl_github = sum(a["github"]["downloads"] for a in apps if a["github"]) + dl_codeberg = sum(a["codeberg"]["downloads"] for a in apps if a["codeberg"]) totals = { - "downloads": sum(a["github"]["downloads"] for a in apps), + "downloads": dl_github + dl_codeberg, + "downloads_github": dl_github, + "downloads_codeberg": dl_codeberg, "apps_total": len(apps), "apps_in_store": sum(1 for a in apps if a["store"]), } @@ -169,14 +313,34 @@ def main(): output = { "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), "source": { - "org": ORG, + "github_org": GITHUB_ORG, + "codeberg_org": CODEBERG_ORG, "store_platform_version": PLATFORM_VERSION, - "notes": "github.downloads counts every release-asset fetch (CI, mirrors, re-installs) — upper bound on real installs.", + "notes": ( + "totals.downloads = GitHub (live) + Codeberg (legacy) release-asset " + "fetches per app, merged by app id. Counts every tarball fetch (CI, " + "mirrors, re-installs) — an upper bound on real installs." + ), }, "totals": totals, "apps": apps, } + # If the existing file has the same payload (everything except the + # timestamp), keep its generated_at so reruns with no real change + # don't churn the working tree. Hosted CI starts from the committed + # stub, so the payload differs there and a fresh timestamp is + # written every build. + try: + with OUTPUT_PATH.open("r") as f: + existing = json.load(f) + new_payload = {k: v for k, v in output.items() if k != "generated_at"} + old_payload = {k: v for k, v in existing.items() if k != "generated_at"} + if new_payload == old_payload and existing.get("generated_at"): + output["generated_at"] = existing["generated_at"] + except (FileNotFoundError, json.JSONDecodeError): + pass + OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True) with OUTPUT_PATH.open("w") as f: json.dump(output, f, indent=2, sort_keys=True) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..aea02b399 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,268 @@ +# Build the site and deploy it to Cloudflare Pages. +# +# Replaces the old `documentation.yml`, which published to the `gh-pages` +# branch for GitHub Pages. GitHub Pages no longer serves this site: +# www.conduction.nl is a Cloudflare Pages project (`conduction-website`), +# and DNS has pointed there for some time. The gh-pages branch was still +# being written on every push, and GitHub Pages still claimed the custom +# domain, so the repo carried a second publishing path that produced +# nothing anyone could see. Both are retired now. +# +# Requires two ORG secrets (they already exist; the names matter): +# CF_API_TOKEN - token with the "Cloudflare Pages: Edit" permission +# CF_ACCOUNT_ID - the account owning the conduction-website project +# +# ConductionNL/.github's deploy-docs.yml uses the same two names; this workflow +# asked for CLOUDFLARE_API_TOKEN, which is not a secret anywhere, so wrangler +# got an empty token. +# +# Until those exist the deploy step fails loudly, which is the intended +# behaviour: a deploy that cannot authenticate must not look like a +# success. Builds on pull requests do not need them. + +name: Deploy + +on: + push: + branches: [development] + pull_request: + branches: [development] + # Nightly rebuild so app-downloads.json (regenerated by the npm prebuild + # step) reflects fresh GitHub release-asset counts. 07:00 UTC = 09:00 + # Amsterdam (CEST) / 08:00 (CET). Inherited from the workflow this + # replaces; the counts are fetched at build time, so a rebuild is the + # only way they change. + schedule: + - cron: '0 7 * * *' + workflow_dispatch: + +# A newer push should win rather than race an in-flight deploy to the same +# production URL. PRs are grouped per-branch so they cancel their own runs. +concurrency: + group: deploy-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + # Build, a Chromium download, and an e2e suite whose slowest test spends + # 40 seconds proving the game cannot end. + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + + # The prebuild step shells out to python3 to refresh app-downloads.json. + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dependencies + run: npm ci --legacy-peer-deps + + # Runs prebuild (app-downloads) then `docusaurus build`, producing + # build/ with the English site and build/nl/ with the Dutch one. + # + # GITHUB_TOKEN is not optional in practice. The prebuild step counts + # release-asset downloads across the ConductionNL repos, and an + # unauthenticated caller is rate-limited long before it finishes: the + # fetch then fails soft, keeps whatever app-downloads.json is + # committed, and the build goes green with frozen numbers. Since + # refreshing those numbers is the entire purpose of the nightly run, + # a silent fallback would make this job theatre. + # PIPELINQ_ENQUIRY_ENDPOINT is what makes the forms work at all. Without + # it the build falls back to the localhost dev default, EnquiryForm sees + # an http endpoint on an https page, and every visitor is shown the email + # fallback instead of a form that submits. That is not hypothetical: it + # is what production did until this line existed, which is why no website + # enquiry ever reached the CRM. + # + # A repository VARIABLE, not a secret: it is a public URL that every + # visitor's browser sends a request to, so hiding it buys nothing and + # costs the ability to read the deploy log and see where leads went. + - name: Build + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PIPELINQ_ENQUIRY_ENDPOINT: ${{ vars.PIPELINQ_ENQUIRY_ENDPOINT }} + # Traffic measurement, from our own Portaliq portal. Unset and the + # plugin is not wired at all, which is what production did until + # now: the app was installed on the portal and the site sent it + # nothing, so there was no measurement and nothing said so. + # + # A variable rather than a secret, for the same reason as the + # endpoint above: it is an origin every visitor's browser fetches a + # script from, so it is public by construction. + PORTALIQ_TRAFFIC_ORIGIN: ${{ vars.PORTALIQ_TRAFFIC_ORIGIN }} + PORTALIQ_TRAFFIC_PORTAL: ${{ vars.PORTALIQ_TRAFFIC_PORTAL }} + run: npm run build + + # The build cannot fail on a missing endpoint, because a preview build of + # a docs-only PR has no reason to carry one. It CAN refuse to deploy a + # production build that would ship forms nobody can submit, which is the + # failure this whole change exists to end. + - name: The production build must carry a real enquiry endpoint + # Same condition as the Deploy step below, deliberately: this guards + # what actually ships, so if the two ever disagree the guard is either + # blocking a build nobody deploys or waving through one they do. + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/development' + run: | + # Matched on the CONFIG KEY, never on the bare host. `localhost:8080` + # on its own appears in 19 bundle files, because the academy + # tutorials are full of "Host http://localhost:8080, basic auth + # admin:admin" and they are supposed to be. Grepping for the host + # alone made this step fail every single deploy while looking like a + # careful safety check, which is worse than not having one. Measured + # against a real build, not assumed: key+host matches exactly one + # file, the config in main.*.js. + if grep -rqF 'pipelinqEnquiryEndpoint:"http://localhost' build/assets/js/; then + echo "::error::The build baked in the localhost dev endpoint. Set the PIPELINQ_ENQUIRY_ENDPOINT repository variable, or every form on the site degrades to the email fallback and no enquiry reaches the CRM." + exit 1 + fi + echo "Enquiry endpoint is not the dev default." + + # A variable that is set but does nothing is worse than one that is + # unset, because it reads as measurement being on. The plugin only + # emits its tag when the origin is present, so if the variable is set + # the built pages must carry the script; if it is unset, they must not. + # Both directions, because either way round is a lie about whether + # visitors are being measured. + - name: Traffic measurement is on exactly when it is configured + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/development' + run: | + # Matched on the client ROUTE, which nothing else on the site + # requests, rather than on the portal host: that host is also the + # enquiry endpoint and appears in the bundle regardless. + if grep -rqF 'api/traffic-client.js' build/index.html; then + found=yes + else + found=no + fi + if [ -n "${{ vars.PORTALIQ_TRAFFIC_ORIGIN }}" ] && [ "$found" = no ]; then + echo "::error::PORTALIQ_TRAFFIC_ORIGIN is set but the build carries no traffic client, so nothing is being measured." + exit 1 + fi + if [ -z "${{ vars.PORTALIQ_TRAFFIC_ORIGIN }}" ] && [ "$found" = yes ]; then + echo "::error::The build carries a traffic client with no origin configured." + exit 1 + fi + echo "Traffic client present: $found, which matches the configuration." + + - name: Check the build produced both locales + run: | + test -f build/index.html || { echo "::error::build/index.html missing"; exit 1; } + test -f build/nl/index.html || { echo "::error::build/nl/index.html missing"; exit 1; } + + # WebKit is Safari's engine and therefore most phones. The suite ran + # zero percent of it until 2026-09-22, because the mobile project was + # pinned to chromium on the grounds that layout is engine-agnostic. + # Largely, not entirely: the one difference that showed up was a race + # the drawer test was losing on WebKit and winning on Chromium. + # Both engines are installed in one step now that both gate the deploy. + # A failed install should look like the infrastructure problem it is, + # not like a silent gap in coverage. + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium webkit + + # Runs against the build produced above, not a dev server, because the + # failures worth catching here only exist in a production build: + # hydration mismatches need a server-rendered page to hydrate, and the + # cookie CLI's styling depends on hashed CSS-module class names. + # This gates the deploy on purpose. A page that fails to hydrate looks + # perfect in a screenshot, so "it built" is not evidence it works. + - name: End-to-end tests + run: npx playwright test --project=chromium --project=mobile --project=mobile-safari + + - name: Upload the Playwright report on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 + + # `--branch main` is load-bearing and has nothing to do with this + # repo's branches. Cloudflare Pages treats one branch name per project + # as production; for conduction-website that name is `main`. Deploying + # from `development` under its own name would create a *preview* + # deployment on a random *.pages.dev URL, succeed, and never reach + # www.conduction.nl. Push events on development are production. + # DIAGNOSTIC, and deliberately non-fatal. This workflow has never once + # deployed successfully: `--project-name=conduction-website` is the only + # name it has ever used, and Cloudflare answers + # `Authentication error [code: 10000]` on + # /accounts/*/pages/projects/conduction-website — while the SAME + # CF_API_TOKEN deploys the `conduction-docs` project from + # ConductionNL/.github without complaint. So the token is valid and the + # name or its scope is wrong. Listing the projects the token can actually + # reach turns that guess into a measurement, in the log, without anyone + # opening the dashboard. + # + # `continue-on-error` on purpose: a diagnostic that can fail the build is + # a second failure mode, not a diagnosis. + # PREFLIGHT, and deliberately non-fatal on its own — the deploy below is + # what fails. It exists because the deploy's own error says only + # "Authentication error [code: 10000]", which reads as a bad token and is + # not one. + # + # MEASURED 2026-08-23, after this workflow had failed 30 runs out of 30: + # /accounts/*/pages/projects -> Authentication error 10000 + # /accounts/*/workers/scripts -> ok, 42 scripts + # /zones -> ok + # So CF_API_TOKEN carries Workers and zone-read and NO Pages permission at + # all. Not a wrong project name: the Pages LIST endpoint, which names no + # project, is refused too. `conduction-website` is real — it exists and + # serves www.conduction.nl, conduction.nl, academy., commonground. and + # connext. — the token just cannot see it. + # + # That also explains why ConductionNL/.github deploys docs.conduction.nl + # with this same token: every docs site in the fleet is a WORKER + # (`wrangler deploy`, name `-docs`), and Workers is the permission + # this token has. + # + # THE FIX IS ONE DASHBOARD EDIT: add `Cloudflare Pages: Edit` to the org + # secret CF_API_TOKEN. Until then this step says so in the log, so the + # failure below names its cause instead of looking like a bad secret. + - name: Preflight — can this token see Cloudflare Pages + if: github.event_name != 'pull_request' + continue-on-error: true + env: + CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }} + CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID || secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + set -u + if curl -sf -H "Authorization: Bearer $CF_API_TOKEN" \ + "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/pages/projects" \ + | grep -q '"success":true'; then + echo "Pages is readable with this token." + else + echo "::error title=CF_API_TOKEN has no Cloudflare Pages permission::The deploy below will fail with Authentication error 10000. This is NOT a wrong project name — the Pages list endpoint, which names no project, is refused as well, while Workers and zones both answer. Add 'Cloudflare Pages: Edit' to the org secret CF_API_TOKEN. The conduction-website project exists and serves www.conduction.nl." + fi + + # Production publishing happens from `development` only. A + # workflow_dispatch on any other ref would otherwise push that branch + # straight to the production `main` alias below and replace + # www.conduction.nl with it. + - name: Deploy to Cloudflare Pages + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/development' + uses: cloudflare/wrangler-action@v3 + with: + # The org secrets are CF_API_TOKEN / CF_ACCOUNT_ID. This step asked + # for CLOUDFLARE_API_TOKEN, which does not exist, so wrangler received + # an empty token and every deploy since ~15 Aug failed — while PR + # checks stayed green, because this step is skipped on pull_request. + # The repo-level CLOUDFLARE_ACCOUNT_ID is kept as a fallback so this + # keeps working if the org secret is ever scoped away. + apiToken: ${{ secrets.CF_API_TOKEN }} + accountId: ${{ secrets.CF_ACCOUNT_ID || secrets.CLOUDFLARE_ACCOUNT_ID }} + command: >- + pages deploy build + --project-name=conduction-website + --branch main + --commit-dirty=true diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml deleted file mode 100644 index 81cf271c6..000000000 --- a/.github/workflows/documentation.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Documentation - -on: - push: - branches: [development, documentation] - pull_request: - branches: [development, documentation] - # Nightly rebuild so app-downloads.json (regenerated by the npm - # prebuild step) reflects fresh GitHub release-asset counts. 07:00 - # UTC = 09:00 Amsterdam (CEST) / 08:00 (CET). Replaces the old - # app-downloads.yml that committed the JSON to development; the - # PR-required ruleset blocked the bot push. Build-time fetch - # sidesteps the bot-push problem entirely. - schedule: - - cron: '0 7 * * *' - workflow_dispatch: - -jobs: - deploy: - uses: ConductionNL/.github/.github/workflows/documentation.yml@main - with: - cname: www.conduction.nl - # The Docusaurus build for this site lives at the repo root - # (not under docs/ like the product-page repos), since this IS - # the docs build itself rather than docs alongside an app. - # Override the centralized workflow's `source-folder` default - # ("docs") to point at the repo root. - source-folder: . diff --git a/.github/workflows/validate-ai-baseline.yml b/.github/workflows/validate-ai-baseline.yml new file mode 100644 index 000000000..6daa4bc19 --- /dev/null +++ b/.github/workflows/validate-ai-baseline.yml @@ -0,0 +1,55 @@ +name: AI baseline validation + +# Runs on every pull request so the AI-crawler baseline (robots.txt, +# llms.txt, sitemaps, JSON-LD on the homepage and per-app pages, +# FAQPage on /support /install /iso, og:image, twitter meta) is +# re-asserted at PR time, not just after merge. The centralised +# ConductionNL/.github documentation workflow only builds on push, +# so without this job, validation regressions reach development. +# +# What runs: `npm run ci` -> `npm ci --legacy-peer-deps && npm run build`, +# which triggers prebuild (app-downloads sync) + docusaurus build + +# postbuild (scripts/validate-ai-baseline.mjs). Any of the 15 checks +# failing exits non-zero and the workflow fails. +# +# The build is the same one production uses, so a green check here +# also tells reviewers the site actually builds with their changes. + +on: + pull_request: + branches: [development, documentation] + workflow_dispatch: + +jobs: + validate: + name: Build and validate AI baseline + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies and build + env: + # Pass the run-scoped token to the prebuild script's + # app-downloads fetch so it stays under GitHub's 60-req/hr + # unauthenticated limit. The token is auto-scoped to this + # job and expires when it ends. + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: npm run ci + + - name: Confirm validation gate ran + # `npm run ci` -> `npm run build` -> postbuild runs the + # 15-check validator and exits non-zero on regression. This + # extra step is a belt-and-braces guarantee: if anyone ever + # removes the postbuild hook from package.json by accident, + # the explicit invocation here still catches it at PR time. + run: node scripts/validate-ai-baseline.mjs diff --git a/.gitignore b/.gitignore index 0c5615bf3..ec1671ce4 100644 --- a/.gitignore +++ b/.gitignore @@ -10,5 +10,25 @@ node_modules/ # Misc .DS_Store *.log + +# Env +.env +.env.* +!.env.example .env.local .env.*.local + +# Secrets / credentials — never commit +*.pem +*.key +*_rsa +*.crt + +__pycache__/ + +# Playwright +test-results/ +playwright-report/ + +# Wrangler local cache — contains account identifiers, never committed. +.wrangler/ diff --git a/.npmrc b/.npmrc index 3942d3489..69c5bfc75 100644 --- a/.npmrc +++ b/.npmrc @@ -2,4 +2,8 @@ # 24h ago. Compromised first-party-Conduction packages are excluded via # Dependabot cooldown (.github/dependabot.yml); for fresh @conduction/* # releases, override per-install with `npm install --min-release-age=0`. -min-release-age=1 +# +# TEMPORARILY DISABLED 2026-05-22 to install @conduction/docusaurus-preset@3.20.0 +# (SetupSteps component) before its 24h cooldown elapsed. Re-enable tomorrow. +# Tracking issue: ConductionNL/conduction-website#115 (re-enable tomorrow). +# min-release-age=1 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..209e3ef4b --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20 diff --git a/README.md b/README.md index fc0bac8cf..e432892fa 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ The Docusaurus 3 site for [conduction.nl](https://conduction.nl). Built on `@con It also serves the `connext.conduction.nl` and `commonground.conduction.nl` vanity entry points (a Cloudflare worker — configured in the dashboard, not in this repo — 301-redirects to the `/connext` and `/commonground` paths on this site, with locale awareness via `Accept-Language`). -> **Vanity-domain redirect target:** the worker must redirect to `https://www.conduction.nl/connext/` — with the `www.` and the **trailing slash**. The site is built with `url: https://www.conduction.nl` (matching `static/CNAME`) and `trailingSlash: true`. Targeting the bare apex (`conduction.nl`) or the slash-less path (`/connext`) stacks extra GitHub Pages 301s on every request and, if the worker route also matches `www`, can produce a redirect loop. If you see a redirect loop on `connext.conduction.nl`, also check Cloudflare's SSL/TLS mode is **Full** (not "Flexible") — Flexible + GitHub Pages "Enforce HTTPS" is the classic infinite HTTP↔HTTPS loop. +> **Vanity-domain redirect target:** the worker must redirect to `https://www.conduction.nl/connext/` — with the `www.` and the **trailing slash**. The site is built with `url: https://www.conduction.nl` (matching `static/CNAME`) and `trailingSlash: true`. Targeting the bare apex (`conduction.nl`) or the slash-less path (`/connext`) stacks extra 301s on every request and, if the worker route also matches `www`, can produce a redirect loop. If you see a redirect loop on `connext.conduction.nl`, check Cloudflare's SSL/TLS mode is **Full**, not "Flexible". (The GitHub Pages leg of this warning is historical: Pages was retired on 2026-08-05 and the site is served entirely by Cloudflare.) ## Local development @@ -20,7 +20,7 @@ npm start npm run build ``` -Output lands in `build/`. That folder is what gets deployed to GitHub Pages by `.github/workflows/documentation.yml`, which calls the centralized `ConductionNL/.github` documentation workflow. +Output lands in `build/`. That folder is what `.github/workflows/deploy.yml` uploads to Cloudflare Pages. ## Local Nextcloud integration @@ -38,7 +38,7 @@ The plugin is wired into [docusaurus.config.js](docusaurus.config.js) but commen ## Deployment -GitHub Pages, served from the `build/` output via `.github/workflows/documentation.yml`. The `static/CNAME` file claims `www.conduction.nl`. +Cloudflare Pages (project `conduction-website`), deployed by `.github/workflows/deploy.yml` on every push to `development`. GitHub Pages was retired on 2026-08-05. See [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) — in particular why the deploy passes `--branch main` from the `development` branch. ## Structure @@ -72,6 +72,20 @@ Two locales, English default: Markdown overrides per locale live in `i18n//docusaurus-plugin-content-pages/`. UI strings come from `i18n//code.json` (generated by `npm run write-translations`). +### Previewing both languages locally + +The Docusaurus dev server (`docusaurus start`) only serves **one language at a time**. `npm start` runs English; `npm run start:nl` runs Dutch. If you start the English server and click the language switcher, `/nl/` will render Docusaurus's internal "Page Not Found" — the route exists in the SPA but the locale isn't compiled in this dev process. The Dutch content is fine; the dev server just isn't serving it. + +To test the language switcher end-to-end (both locales on the same port, like production), build once and serve: + +```bash +npm run preview +# → http://localhost:3000/ (English) +# → http://localhost:3000/nl/ (Dutch) +``` + +`npm run preview` = `docusaurus build && docusaurus serve`. After a build exists in `build/`, `npm run serve` alone reuses it without rebuilding. Trade-off: no hot-reload — re-run for every change. + ## History -This repository was migrated from the legacy Docusaurus v2.4.3 site in 2026 by extracting the new preset-based build from `design-system/sites/www/`. +This repository was migrated from the legacy Docusaurus v2.4.3 site in 2026 by extracting the new preset-based build from `design-system/sites/www/`. \ No newline at end of file diff --git a/academy/2026-04-20-build-an-app/index.mdx b/academy/2026-04-20-build-an-app/index.mdx deleted file mode 100644 index 726dcb8c5..000000000 --- a/academy/2026-04-20-build-an-app/index.mdx +++ /dev/null @@ -1,32 +0,0 @@ ---- -slug: build-an-app -title: Build your first Conduction app, end to end -contentType: tutorial -authors: [conduction] -date: 2026-04-20 -summary: From a blank Nextcloud to a published app in the store. Schemas, registers, and an OpenConnector flow. -tags: [App development, Schemas, OpenRegister, OpenConnector] -apps: [openregister, openconnector] -durationMinutes: 30 ---- - -A 30-minute tutorial that takes you from a fresh Nextcloud install to a published app. Placeholder while we draft the full version. - -{/* truncate */} - -## What you build - -A small "incident reporter" app: one register, two schemas, a public form that posts to OpenRegister via OpenConnector, and a private inbox in Nextcloud. - -## Steps - -1. **Install OpenRegister and OpenConnector** from the Nextcloud app store -2. **Create the register** with two schemas (`incident`, `responder`) -3. **Wire the public form** as an OpenConnector source -4. **Map the form fields** to the `incident` schema -5. **Build a small Vue panel** in the app dir to list incidents -6. **Publish** to your own app store or to apps.conduction.nl - -## When you are done - -You have an installable Nextcloud app, a published API, and a working form. Total time: about half an hour if Nextcloud is already running. diff --git a/academy/2026-04-23-semantic-conventions/index.mdx b/academy/2026-04-23-semantic-conventions/index.mdx deleted file mode 100644 index 95de2389d..000000000 --- a/academy/2026-04-23-semantic-conventions/index.mdx +++ /dev/null @@ -1,32 +0,0 @@ ---- -slug: semantic-conventions -title: Managing OpenTelemetry semantic convention migrations with the collector -contentType: webinar -authors: [conduction] -date: 2026-04-23 -summary: A 45-minute walk-through with live Q&A on rolling out semconv 1.27 across a fleet of services. -tags: [OpenTelemetry, Instrumentation] -durationMinutes: 45 -videoUrl: https://example.com/webinars/semconv-1.27 ---- - -Recording and Q&A from the webinar. Placeholder while the video is uploaded and the transcript is prepared. - -{/* truncate */} - -## What we covered - -- Why semconv 1.27 matters and what changed -- Three migration strategies (in-process, collector-side, hybrid) -- Live demo: rolling out a collector-side rename across a 30-service fleet -- Audience Q&A on edge cases (non-OTel libraries, vendor drift, legacy fields) - -## Speakers - -Conduction's observability lead and a guest from a Dutch municipality that recently completed the migration on production traffic. - -## Resources - -- Slide deck (linked once the recording is up) -- Collector config snippets used in the demo -- The customer's post-migration retrospective diff --git a/academy/2026-04-27-parhelion/index.mdx b/academy/2026-04-27-parhelion/index.mdx deleted file mode 100644 index a39a6a7ce..000000000 --- a/academy/2026-04-27-parhelion/index.mdx +++ /dev/null @@ -1,30 +0,0 @@ ---- -slug: parhelion -title: Replacing four SaaS subscriptions with five Conduction apps -contentType: case-study -authors: [conduction] -date: 2026-04-27 -summary: How a Frisian municipality moved from four hosted services to a single Nextcloud workspace, in eleven weeks. -tags: [MKB, Migration, OpenCatalogi, MyDash] -apps: [opencatalogi, mydash] -durationMinutes: 6 ---- - -A short summary of the migration. Placeholder while we draft the full version with the customer's permission. - -{/* truncate */} - -## The starting point - -Four SaaS subscriptions, three vendor contracts, and a fourth on the way. The IT lead estimated 9 percent of operating budget went to per-seat licensing on tools that did not talk to each other. - -## What we did - -Stood up Nextcloud on the existing on-prem hardware, installed OpenRegister, OpenCatalogi, OpenConnector, DocuDesk, and MyDash from the app store. Migrated the data with two OpenConnector flows. Trained the team in two half-day sessions. - -## The numbers - -- Eleven weeks from kickoff to cutover -- Five apps replacing four SaaS subscriptions -- One vendor contract instead of three -- Annual savings: roughly two FTE-months of admin time, plus the licence delta diff --git a/academy/2026-04-29-mcp-server/index.mdx b/academy/2026-04-29-mcp-server/index.mdx index b2326caaa..a51638715 100644 --- a/academy/2026-04-29-mcp-server/index.mdx +++ b/academy/2026-04-29-mcp-server/index.mdx @@ -1,36 +1,382 @@ --- slug: mcp-server title: Set up the Conduction MCP server -contentType: guide +contentType: tutorial +heroIcon: plug authors: [conduction] date: 2026-04-29 -summary: Point Claude or any MCP-aware client at OpenRegister, then write your first MCP-driven query. -tags: [AI & LLMs, MCP, OpenRegister] +summary: The OpenRegister Nextcloud app ships an MCP server that gives Claude direct access to your local Conduction data layer. This tutorial wires it into the same .mcp.json the Playwright pool lives in, walks through Nextcloud app-password auth, and ends with the first MCP-driven query. +tags: [AI & LLMs, MCP, OpenRegister, Tutorial] apps: [openregister] -durationMinutes: 8 +durationMinutes: 20 --- -A short walk-through of the Model Context Protocol server we ship for OpenRegister. Placeholder while we draft the full version. +import {Outcomes, Outcome, Prerequisites, PrerequisiteItem, Troubleshooting, TroubleshootingItem, NextSteps, NextStep, ContactCta} from '@conduction/docusaurus-preset/components'; +import Details from '@theme/Details'; + +The OpenRegister Nextcloud app exposes an **MCP server** at `/index.php/apps/openregister/api/mcp`. Once you point Claude at it, you can ask questions like *"list every schema in the woo register"* or *"give me the audit trail for object X"* and Claude will fetch the answer through MCP instead of you opening the admin UI. This tutorial assumes you already have a local Nextcloud with OpenRegister running and a Playwright `.mcp.json` in place — what we add here is one more entry to that file, plus the Nextcloud app-password that makes the call work. {/* truncate */} -## Before you start +> **Coming from the Workstation Setup chain?** [Part 4 — Connect the MCP server](/academy/workstation-tutorial-4-mcp-server) is where you got the Playwright browser pool wired into `.mcp.json`. This tutorial adds a second server entry to the same file. The browsers stay; the OpenRegister entry sits beside them. + + + An understanding of what the OpenRegister MCP server exposes — three tool families (registers, schemas, objects) and a set of read-only resources. + A Nextcloud app-password generated specifically for Claude (revocable, scoped to one device). + An openregister entry added to your project's .mcp.json, using HTTP transport and basic-auth from an environment variable so credentials stay out of git. + The server verified in VS Code's MCP servers panel, and one real MCP-driven query answered in your Claude Code session. + + + + A running local Nextcloud with the OpenRegister app installed and at least one register + schema defined. The fastest way there is Workstation Part 5 — Run Nextcloud locally. + Workstation Part 4 done — a project-root .mcp.json exists, the Playwright browser pool is connected, and .claude/settings.json has enableAllProjectMcpServers: true. + Claude Code (CLI or VS Code extension), Claude Desktop, or any other MCP-aware client that supports Streamable HTTP transport. + About 20 minutes. + + +## In one sentence + +The OpenRegister Nextcloud app ships an MCP server as part of its Nextcloud routes. There's nothing extra to install: if your local Nextcloud is running and OpenRegister is enabled, the MCP server is already reachable at `/index.php/apps/openregister/api/mcp`. What's left is auth, one `.mcp.json` entry, and a reload. + +A few things to know up front: + +- **Transport:** Streamable HTTP, JSON-RPC 2.0. Not stdio — there's no `npx` command to run. +- **Auth:** standard Nextcloud auth. We use **basic-auth with a Nextcloud app-password**, which you can revoke later from the user's security settings. +- **What's exposed:** + - Three tools: `registers`, `schemas`, `objects`. Each one is a CRUD multiplexer — you call it with an `action` parameter (`list`, `get`, `create`, `update`, `delete`). + - Resources under the `openregister://` URI scheme: `openregister://registers`, `openregister://schemas`, and one per register+schema combo for object lists. + +## Step 1: sanity-check the endpoint is alive + +The OpenRegister MCP server has a **public discovery endpoint** (Tier 1) that needs no authentication. Hit it once before doing anything else — if this fails, nothing later will work. + +> **Which URL?** If you followed [Workstation Part 5](/academy/workstation-tutorial-5-nextcloud-locally), your local Nextcloud is served by `nextcloud-docker-dev`'s nginx-proxy at **`http://nextcloud.local/`** (port 80, with `nextcloud.local` mapped to `127.0.0.1` in `/etc/hosts`). That's the URL this tutorial uses. If you're running a bare Nextcloud with a different port-mapping (say `http://localhost:8080/`), swap the host — the path (`/index.php/apps/openregister/api/mcp`) is the same on every install. + +First check Nextcloud itself is up *and* its database is current: + +```bash +curl -sS http://nextcloud.local/status.php +``` + +If the JSON includes `"needsDbUpgrade":true`, run the upgrade once before continuing — first-boot of `nextcloud-docker-dev` after a core bump or an apps-extra pull lands here regularly: + +```bash +docker compose -f /path/to/nextcloud-docker-dev/docker-compose.yml exec -u www-data nextcloud php occ upgrade +``` + +Now the MCP discovery call: + +```bash +curl -sS http://nextcloud.local/index.php/apps/openregister/api/mcp/v1/discover | head -c 400 +``` + +You should get a JSON document listing the capability areas the server advertises (registers, schemas, objects, …). If you get an HTML login page back instead, your local Nextcloud is up but the OpenRegister app isn't enabled — fix that with: + +```bash +docker compose -f /path/to/nextcloud-docker-dev/docker-compose.yml exec -u www-data nextcloud php occ app:enable openregister +``` + +(Or enable it from the Apps page in the Nextcloud UI.) + +## Step 2: create a dedicated Nextcloud app-password + +Logging Claude in with your real account password works, but it's the wrong default — that one credential carries your full account scope and rotating it later is painful. An **app-password** is a Nextcloud feature designed for exactly this: a long-random secret bound to a label, revocable from one screen. + +1. Open your local Nextcloud in a browser (`http://nextcloud.local/`, or whichever URL Step 1 confirmed worked) and log in as the user you want Claude to act as. For most local-dev setups that's `admin`. +2. Top-right avatar → **Personal settings** → **Security**. +3. Scroll to **Devices & sessions** → **App name**: type `claude-mcp-openregister` (or anything you'll recognise later). +4. Click **Create new app password**. Nextcloud generates a long string — **copy it now**, it's not shown again. + +Store the password somewhere you can paste it back into a shell. We're about to base64-encode it. + +## Step 3: build the basic-auth header + +Claude Code's `.mcp.json` supports environment-variable expansion, so we won't paste the raw secret into the file. Build it once, export it, and reference the variable name from `.mcp.json`. + +```bash +# Replace `admin` with your Nextcloud username, and paste the app-password when prompted. +read -srp "App password: " OPENREGISTER_APP_PASSWORD; echo +export OPENREGISTER_BASIC_AUTH=$(printf "admin:%s" "$OPENREGISTER_APP_PASSWORD" | base64 -w0) +``` + +Verify it's set: + +```bash +echo "${OPENREGISTER_BASIC_AUTH:0:12}…" +``` + +You'll see the first twelve characters of the base64 string — enough to confirm the variable is populated without printing the secret in full. + +> **Make it persistent.** That `export` line only lives in the current shell. Put it in your `~/.bashrc` (or `~/.zshrc`) — with the app-password read from a more permanent store like `pass`, `gopass`, or a `.env` file outside the repo — so Claude Code picks it up automatically next time you open VS Code. + +## Step 4: add the OpenRegister entry to `.mcp.json` + +Open the `.mcp.json` you built in [Workstation Part 4](/academy/workstation-tutorial-4-mcp-server). Inside the existing `mcpServers` block, add one new entry next to the browsers. The full file ends up looking like this (browsers collapsed for readability): + +```json +{ + "mcpServers": { + "browser-1": { "command": "npx", "args": ["-y", "@playwright/mcp@latest", "--browser", "chromium", "--headless", "--isolated"] }, + "browser-2": { "command": "npx", "args": ["-y", "@playwright/mcp@latest", "--browser", "chromium", "--headless", "--isolated"] }, + "browser-3": { "command": "npx", "args": ["-y", "@playwright/mcp@latest", "--browser", "chromium", "--headless", "--isolated"] }, + "browser-4": { "command": "npx", "args": ["-y", "@playwright/mcp@latest", "--browser", "chromium", "--headless", "--isolated"] }, + "browser-5": { "command": "npx", "args": ["-y", "@playwright/mcp@latest", "--browser", "chromium", "--headless", "--isolated"] }, + "browser-6": { "command": "npx", "args": ["-y", "@playwright/mcp@latest", "--browser", "chromium", "--isolated"] }, + "browser-7": { "command": "npx", "args": ["-y", "@playwright/mcp@latest", "--browser", "chromium", "--headless", "--isolated"] }, + + "openregister": { + "type": "http", + "url": "http://nextcloud.local/index.php/apps/openregister/api/mcp", + "headers": { + "Authorization": "Basic ${OPENREGISTER_BASIC_AUTH}" + } + } + } +} +``` + +A few things worth knowing: + +- **`"type": "http"`** is Claude Code's HTTP transport selector. The MCP spec calls the same transport `streamable-http`; Claude Code accepts both spellings and treats them as aliases. +- **`${OPENREGISTER_BASIC_AUTH}`** is read from your shell environment at server-start time. If the variable isn't set when Claude Code launches, the server entry fails to parse — that's the symptom telling you to re-run the `export`. +- The Nextcloud session ID (the `Mcp-Session-Id` header you'd otherwise have to manage) is handled by Claude Code's MCP client automatically as part of the `initialize` handshake. You don't set it. + +## Step 5: trust the new server in `.claude/settings.json` + +Your project's `.claude/settings.json` already has `enableAllProjectMcpServers: true` (from Workstation Part 4) — that part stays. What you add now is a permission rule for the openregister tool calls so background agents don't get silently denied: + +```json +{ + "enableAllProjectMcpServers": true, + "permissions": { + "allow": [ + "mcp__browser-*", + "mcp__openregister" + ] + } +} +``` + +`mcp__openregister` covers every tool the server exposes (`registers`, `schemas`, `objects`) without listing each one. If you want to be stricter — say, allow `objects` read-only but require an approval for register edits — the per-tool form is `mcp__openregister__registers`, `mcp__openregister__schemas`, `mcp__openregister__objects`. + +## Step 6: reload VS Code and verify + +`Ctrl+Shift+P` → **"Developer: Reload Window"**. + +Open the MCP servers panel (`/MCP servers` in the chat input, or `Ctrl+Shift+P` → "MCP servers"). You should now see **eight** entries: the seven Playwright browsers from before, plus a new `openregister` row marked **Connected**. + +If `openregister` is the only one showing red: + +- Run the `curl` from Step 1 again — confirm the server itself is reachable. +- Print `echo "${OPENREGISTER_BASIC_AUTH:0:12}…"` in a *fresh* terminal. If it's empty there, VS Code was launched from a shell that didn't have the export — re-launch VS Code from a terminal that has the variable set, or move the `export` into `~/.bashrc`. +- Check the Output panel (`Ctrl+Shift+P` → "Output: Focus on Output" → **Claude VSCode**). A 401 means the basic-auth header is wrong; a 403 usually means the app-password was revoked or the user lacks permission on the register you're targeting. + +## Step 7: ask Claude your first MCP-driven question + +In a Claude Code session inside the same project: + +> List every register on my local OpenRegister, with object count per schema. + +Claude should call `mcp__openregister__registers` with `action: list`, then for each register call `mcp__openregister__schemas` with the same action, and assemble the answer. You'll see the tool calls in the output panel as they happen. + +A useful follow-up — to confirm reads are honest, not made up: + +> For the first register, get the most recent object and print its UUID and updated-at timestamp. + +If the answer matches what you see in the OpenRegister UI for the same register, you're wired up correctly. + +## What the server actually exposes + +If you ever need to remember what's on offer without reading the source, ask Claude: + +> Use the openregister MCP server's `tools/list` and `resources/list` and summarise them. + +At time of writing the tools are: + +| Tool | Actions | Notes | +| --- | --- | --- | +| `registers` | `list`, `get`, `create`, `update`, `delete` | Top-level data containers. | +| `schemas` | `list`, `get`, `create`, `update`, `delete` | JSON Schema definitions inside a register. | +| `objects` | `list`, `get`, `create`, `update`, `delete` | Objects validated against a schema, stored in a register. All actions require both `register` and `schema` (integer IDs). | + +And the resources (read-only, addressable via `@openregister:openregister://…`): + +| URI | What it returns | +| --- | --- | +| `openregister://registers` | All registers. | +| `openregister://schemas` | All schemas. | +| `openregister://objects/{register}/{schema}` | All objects in one register + schema combination. | +| `openregister://registers/{id}` | One register by ID. | +| `openregister://schemas/{id}` | One schema by ID. | +| `openregister://objects/{register}/{schema}/{id}` | One object by composite key. | + +The audit trail isn't a separate MCP tool — it's served as object history through the same `objects` family. Ask Claude for "the audit trail of object X" and it picks the right call. + + + + Re-run curl http://nextcloud.local/index.php/apps/openregister/api/mcp/v1/discover (swap the host if yours differs). If that fails, the Nextcloud app isn't enabled or Nextcloud is in maintenance mode after a partial upgrade — re-run occ upgrade from Step 1. If it works, the failure is auth — see the next two items. + + + The basic-auth header didn't reach the server, or it's malformed. Re-export OPENREGISTER_BASIC_AUTH in the same shell you'll launch VS Code from, then reload. Double-check the username is the one you generated the app-password for. + + + App-password revoked. Open Nextcloud → Personal → Security → Devices & sessions; if your claude-mcp-openregister entry is gone (or shows as expired), generate a new app-password and rebuild the base64 string with the new value. + + + Your .claude/settings.json is missing the openregister allow rule. Confirm the permissions.allow array contains "mcp__openregister" (covers all tools) or the specific tool name, then reload. + + + The wiring is fine — the failure is upstream in OpenRegister. Pull your apps-extra/openregister checkout to its latest, re-run occ upgrade, and retry. If it still fails, the Nextcloud logs usually surface the real exception: docker compose -f /path/to/nextcloud-docker-dev/docker-compose.yml logs nextcloud | grep -i openregister | tail -50. + + + {'${OPENREGISTER_BASIC_AUTH}'} only expands if the variable is set when Claude Code starts. If you can't keep it in ~/.bashrc, use the default-value form {'${OPENREGISTER_BASIC_AUTH:-not-set}'} so the entry parses; the server will then fail with a clearer auth error instead of a JSON one. + + + +## Test yourself + +Five short questions to check the mental model. Stuck? Click **Hint**. Curious about the answer? Click **Answer**. + +
+ +**1. Why does this MCP server use HTTP transport instead of the stdio transport the Playwright browsers use?** + +
+ +Where does the OpenRegister code actually run? And how does Claude reach a running process versus a remote service? + +
+ +
+ +The Playwright browsers are launched **by Claude Code itself** — each `browser-N` entry is an `npx` command that spawns a fresh local process Claude can speak to over stdin/stdout. That's the "stdio" transport: Claude owns the lifecycle. + +The OpenRegister MCP server, by contrast, lives **inside the Nextcloud PHP app** that's already running on your local Nextcloud. It's a long-lived HTTP endpoint serving JSON-RPC over POST. Claude can't (and shouldn't) launch it — it just calls it. That's exactly the use case HTTP transport exists for. + +The rule of thumb: stdio for things Claude should spawn on demand, HTTP for things that are already running and serve many clients. + +
+ +
+ +
+ +**2. Why a Nextcloud app-password and not your regular account password?** + +
+ +Think about scope, revocability, and what a leaked credential lets an attacker do. + +
+ +
+ +Three reasons, in order of importance: + +1. **Revocable in one click.** If Claude logs end up in a bug report or your laptop is stolen, you go to Personal → Security → Devices & sessions and delete the `claude-mcp-openregister` entry. Your account password keeps working everywhere else. +2. **Labelled.** An app-password has a name, so when you look at your Nextcloud security page six months from now you can tell which secret is which without guessing. +3. **No 2FA dance.** Account passwords get caught by Nextcloud's two-factor flow on each new session, which an MCP server can't complete. App-passwords are designed to bypass that interactively-impossible step safely. + +This isn't OpenRegister-specific — it's the same logic that says you should use SSH keys instead of passwords for git remotes. + +
+ +
+ +
+ +**3. The credentials live in an environment variable, not in `.mcp.json` directly. Why does that matter, and what failure mode does it trade for?** + +
+ +`.mcp.json` is checked in by convention. Environment variables aren't. What's the failure if the variable isn't set? + +
+ +
+ +`.mcp.json` is **project-scoped and committed to git** so the whole team gets the same MCP setup with a clone. Putting `Authorization: Basic ` directly in that file would leak the secret into version history the first time someone pushed. + +Using `${OPENREGISTER_BASIC_AUTH}` keeps the file safe to commit: each developer generates their own app-password and exports their own variable. The file is identical across machines; the secret is per-user. + +The trade is a **silent setup failure**: if the variable isn't set when Claude Code launches, the server entry fails to parse or returns 401 on first call, and the developer has no idea why. That's why the troubleshooting section above leans on a `curl` sanity-check and a partial `echo` of the variable before assuming anything else is wrong. + +The default-value form `${OPENREGISTER_BASIC_AUTH:-not-set}` is a useful intermediate — the entry parses, the auth call fails loudly, and the error message points straight at the cause. + +
+ +
+ +
+ +**4. The OpenRegister MCP server exposes three tools, each a CRUD multiplexer with an `action` parameter, rather than fifteen separate tools (`register.list`, `register.get`, `register.create`, …). What's the trade-off?** + +
+ +Think about what Claude sees in its context window per server, and how it picks which tool to call. + +
+ +
+ +Three tools each with an `action` parameter keeps the **per-server tool count low**, which matters because every MCP tool definition consumes context tokens at session start (unless tool search is enabled). Three tools is fifteen lines of schema; fifteen tools is seventy-five lines of schema. Across all your MCP servers, that adds up. + +The cost is that Claude has to reason about the `action` parameter itself — it can't just "see" that `register.list` is a thing; it has to look at the `registers` tool's schema, see the `action` enum, and pick `list`. For a capable model that's a non-issue; for a smaller or older one it can cause occasional missed calls. + +With Claude Code's tool search enabled by default, this trade flips slightly: tools are deferred until needed anyway, so the context-saving win is smaller. The multiplexed shape still wins on consistency — every CRUD operation looks the same regardless of which entity it's against. + +
+ +
+ +
+ +**5. When would you write your own MCP server instead of, say, a Claude Skill that calls the OpenRegister REST API directly?** + +
+ +Skills live inside Claude's process. MCP servers live outside it and follow a protocol. What changes when you cross that boundary? + +
+ +
+ +Write a **Skill** when the behaviour you want is *Claude-side*: a prompt, a procedure, a checklist, a transform that operates on the conversation. Skills are markdown + optional scripts; they ship with the repo and version with your code. + +Write an **MCP server** when the behaviour you want is *service-side* and: -You need: +1. **Multiple clients need the same access.** OpenRegister's MCP server is useful for Claude Code, Claude Desktop, and any future MCP-aware editor. A Skill would have to be re-implemented in each one. +2. **The data is on the other side of a network boundary.** A Skill that talks to a remote service still needs an HTTP client, error handling, and auth — all of which the MCP protocol already standardises. +3. **You want to expose composable resources and tools to whatever assistant asks.** An MCP server defines a stable contract (`tools/list`, `resources/list`); a Skill is private to your installation. -- A running OpenRegister with at least one register and schema -- Claude Code, Claude Desktop, or any other MCP-aware client -- The MCP server running on `http://localhost:8080/index.php/apps/openregister/mcp` +The Conduction MCP server in OpenRegister is a textbook example of the right call: the data layer is a long-lived service that many AI clients will want to query, and the protocol surface is small enough (CRUD over registers, schemas, objects) that the contract is stable. A Skill for the same thing would be 5× the code and only Claude-shaped. -## Wiring the client +For an in-depth tour of when each one fits, see [Claude Skills tutorial — Part 1: What are Claude Skills?](/academy/claude-skills-tutorial-1-what-are-claude-skills). -Add the OpenRegister MCP endpoint to your client's config. The server exposes: +
-- `register.list`, `register.get` -- `schema.list`, `schema.get` -- `object.search`, `object.get`, `object.create` -- `audit.read` for read-only access to the audit trail +
-## What you can ask + + + + + -Once wired, the assistant can answer questions like "show me all WOO documents from 2024 with category-2 redactions" without you writing the query by hand. + diff --git a/academy/2026-05-03-welcome/index.mdx b/academy/2026-05-03-welcome/index.mdx index e58ae3dfd..b08da5063 100644 --- a/academy/2026-05-03-welcome/index.mdx +++ b/academy/2026-05-03-welcome/index.mdx @@ -2,6 +2,7 @@ slug: welcome title: Conduction is online contentType: blog +heroIcon: flag authors: [conduction] date: 2026-05-03 summary: First post on the new Conduction site. The brand book moves here once the OpenCatalogi content plugin lands. diff --git a/academy/2026-05-04-tame-log-noise/index.mdx b/academy/2026-05-04-tame-log-noise/index.mdx deleted file mode 100644 index e43bc5500..000000000 --- a/academy/2026-05-04-tame-log-noise/index.mdx +++ /dev/null @@ -1,24 +0,0 @@ ---- -slug: tame-log-noise -title: Taming log noise with OpenTelemetry's drain processor -contentType: blog -authors: [conduction] -date: 2026-05-04 -summary: How a single processor cut our trace volume by 40 percent on the OpenRegister API. -tags: [OpenTelemetry, Instrumentation] -apps: [openregister] ---- - -A short note on a single OpenTelemetry collector processor that disproportionately moved the needle for us. This post is a placeholder while we draft the full version. - -{/* truncate */} - -## What changed - -We added the `drain` processor to the OpenRegister API's collector pipeline. It clusters near-identical log lines and emits one templated trace instead of N near-duplicates. Trace volume dropped 40 percent, alert noise dropped further, and the dashboards still answered every question we asked of them. - -## What is next - -- Roll out to OpenConnector and DocuDesk -- Document the templating rules so app authors can self-serve -- Add a guard against over-clustering on rare error paths diff --git a/academy/2026-05-05-build-an-app-tutorial-0-three-paths/index.mdx b/academy/2026-05-05-build-an-app-tutorial-0-three-paths/index.mdx new file mode 100644 index 000000000..19d91f60e --- /dev/null +++ b/academy/2026-05-05-build-an-app-tutorial-0-three-paths/index.mdx @@ -0,0 +1,170 @@ +--- +slug: build-an-app-tutorial-0-three-paths +title: "Build a Nextcloud app on the Conduction stack — Part 0: Three paths, one curriculum" +contentType: tutorial +heroIcon: blocks +authors: [conduction] +date: 2026-05-05 +summary: Before you write a line of code, decide which of the three Conduction app-building paths fits the app you have in mind. Traditional Nextcloud, OpenRegister + nextcloud-vue, or full manifest. Each one builds on what the previous gives you, and the rest of the series teaches the manifest path because that is where the curve becomes steep in our favour. +tags: [App development, Architecture, OpenRegister, nextcloud-vue, Manifest, Tutorial series] +apps: [openregister] +durationMinutes: 15 +series: build-an-app-tutorial +partNumber: 0 +--- + +import { + Outcomes, + Outcome, + Prerequisites, + PrerequisiteItem, + NextSteps, + NextStep, + HexCard, +} from '@conduction/docusaurus-preset/components'; + +This is **Part 0** of the nine-part app-building tutorial series, the orientation lap. Before scaffolds, schemas, or manifests, the most useful thing we can hand a new developer is an honest map of the three ways Conduction apps actually get built, what each one costs, and what each one gives back. The rest of the series picks one path and shows the receipts. Part 0 explains why. + +A second thing matters from minute one: **the manifest system and the `@conduction/nextcloud-vue` component library are two different surfaces**. The manifest is the *declarative what*, a single `manifest.json` that describes navigation, pages, configuration. `nextcloud-vue` is the *rendering how*, the `Cn*` components the manifest dispatches to (`CnAppRoot`, `CnPageRenderer`, `CnIndexPage`, `CnDetailPage`, `CnDashboardPage`, and the rest). They are technically separable. They are also so intertwined in practice that the rest of this series teaches them together, and every lesson names which surface it is touching. + +The series uses the OpenAPI **Pet Store** as the running example: pets, orders, categories, the same entities every API developer has met before. It's the canonical sample domain across the Conduction academy. By the end of the series you have a working PetStore app on the Conduction stack and the mental model to build your own real app on the same patterns. + +{/* truncate */} + + + A clear picture of the three Conduction app-building paths (traditional Nextcloud, OpenRegister + nextcloud-vue, and full manifest) with honest trade-offs for each. + A mental model for which path to choose based on app scope, team size, and how much of your behaviour is genuinely bespoke. + An understanding that OpenRegister alone already gives you RBAC, audit logging, GraphQL, and cross-app integrations, even if you write no manifest at all. + A clear sense of how the manifest and nextcloud-vue relate as two surfaces of the same curriculum. + + + + Comfort reading a JSON Schema and a Vue single-file component. You won't write either in Part 0; you'll see both in Parts 1–8. + A working Nextcloud dev environment (Docker Compose is fine). You won't run anything in Part 0; Part 1 is where the hands-on work begins. + Familiarity with the term "Pet Store" as the OpenAPI canonical example, or willingness to skim the swagger pet-store reference while you read. + + +## The three paths at a glance + +The same PetStore feature, a list of pets with a detail page, an order dialog, and a sidebar showing audit history, can be built three ways. Pick the table cell that sounds like your team. + +| | **Path 1: Traditional Nextcloud** | **Path 2: OpenRegister + nextcloud-vue** | **Path 3: Manifest + nextcloud-vue** | +|---|---|---|---| +| **What you write** | PHP controllers, Doctrine entities + migrations, hand-rolled Vue views, custom routes, your own CRUD endpoints | Schemas in JSON, register configuration, hand-rolled Vue views built from `Cn*` components, your own `App.vue` + router | Schemas in JSON, **one `manifest.json`** describing pages + config, only the genuinely bespoke views as `type: 'custom'` | +| **Per-app code for the PetStore feature above** | ~1500 LOC | ~600 LOC | ~30 LOC + the manifest | +| **Where the data lives** | App-private database tables you defined | A shared OpenRegister register. Every other app on the same Nextcloud can read/write it through the registry | Same as Path 2 | +| **RBAC, audit log, GraphQL, cross-app references, file attachments, calendar/contacts integrations** | Build each one yourself | **Free.** OpenRegister ships them. See "What OpenRegister gives you regardless" below | **Free.** Same as Path 2 | +| **Listing pages, detail pages, filter sidebars, mass-action dialogs** | Build each one yourself | Reuse `CnIndexPage`, `CnDetailPage`, `CnDashboardPage`. Schema drives the columns and forms | Reuse the same components, dispatched declaratively by `CnPageRenderer` from your `manifest.json` | +| **Adding a `priority` field to orders** | New migration, new controller code, new Vue field, new validation | Add field to schema. Forms and tables pick it up automatically | Same as Path 2. Schema-driven all the way through | +| **Adding a kanban board (genuinely bespoke)** | Build it | Build it as a Vue component, mount it in your router | Build it as a Vue component, declare `{type:'custom', component:'PipelineBoard'}` in the manifest | +| **Maintenance burden across 10 similar apps** | Each app drifts. 10 list pages, 10 detail pages, all slightly different. | The Vue surface still drifts. Every app's `App.vue` and router are hand-written. Schema migrations stay clean. | One manifest shape across the fleet. A lib bump fixes every app at once. | +| **Learning curve to first working page** | Lowest if you already know Nextcloud app dev. The patterns are documented. | Medium. Schemas + `Cn*` components are new but well-trodden. | Steepest *initially*: you have to grok manifest dispatch first. After Part 2 the curve inverts. | +| **Ceiling** | Whatever you can build in PHP + Vue. No ceiling but no leverage either. | Schema-driven CRUD scales beautifully. Bespoke flows are still hand-coded Vue. | Same as Path 2 plus: every Conduction app upgrades together when the manifest schema or `nextcloud-vue` evolves. | + +### When each path is the right pick + +**Path 1, Traditional Nextcloud**, is the right pick when: +- You're shipping a Nextcloud-native feature with no use of cross-app shared data (e.g. a Talk command extension, an OAuth provider, a calendar provider plugin). +- You need behaviour that lives below the data layer: file storage hooks, dav endpoints, sharing extensions. +- You're maintaining a legacy app whose patterns predate OpenRegister and a rewrite isn't justified. +- You explicitly need the freedom to depart from the Conduction stack: bring-your-own-database, bring-your-own-frontend-framework, anything where the constraints would slow you down. + +**Path 2, OpenRegister + nextcloud-vue**, is the right pick when: +- Your data is fundamentally CRUD against a small set of schemas, but you want to keep the *page composition* hand-written for now. Maybe because the existing app already has a custom `App.vue`, or because you want to migrate incrementally. +- You're prototyping and you want to feel out a few hand-rolled views before committing to a manifest-driven shape. +- The team has not yet learned the manifest mechanics and you have a deadline. + +**Path 3, Manifest + nextcloud-vue**, is the right pick when: +- The app is mostly a set of register-backed CRUD surfaces with a few bespoke flows on top, which describes the overwhelming majority of Conduction apps. +- You want the app to stay on the upgrade train: lib bumps, schema enhancements, accessibility improvements all flow in without per-app work. +- You're starting fresh. The total time to a working app on Path 3 is *less* than on Path 2 once you've done it once, even though the first hour is steeper. +- You want to be able to redesign the navigation, swap a list for a dashboard, or add a wiki section by editing JSON instead of rewriting Vue. + +The tutorial series teaches **Path 3**. Part 1 lays the chassis. Part 2 introduces the manifest. Part 3 shows how a single schema annotation makes order ship-dates appear in Nextcloud Calendar with no glue code. Part 4 adds a care-guide sidebar fed from xWiki and ships the app. Parts 5–8 deepen the manifest, tour the component library, integrate with other systems, and document the result. + +## What OpenRegister gives you regardless of path + +This part surprises people. OpenRegister is not just "a register with CRUD endpoints". It ships several Nextcloud-native superpowers the moment you put a schema in it. Whether you build on Path 2 or Path 3, you inherit all of these without writing them: + + + Schemas declare per-property read/write permissions, group restrictions, and ownership rules. The OpenRegister API enforces them; CnIndexPage and CnDetailPage respect them; CnFormDialog hides fields the current user cannot write. Add a property to a schema, mark it readOnly: true for the users group, and every surface in every app respects that rule. + + + + Every create, update, and delete writes an audit entry. The audit trail is visible in the standard CnObjectSidebar tab on every detail page. Querying the audit trail is a normal OR API call. Your manifest can compose audit views without writing logging code. + + + + Each register exposes a GraphQL schema generated from the JSON Schema. Cross-app queries ("give me every order where the pet is in category 'Dogs' and the customer is in the engineering group") are one query, not a controller. The endpoint is enabled at the register level. + + + + An order schema can reference a pet schema in a different app's register. The reference is a typed UUID relation that OR resolves on read. CnDetailGrid renders the related object's title as a link to the other app's detail page. No HTTP roundtrip in your code. + + + + Every register-backed object can have files attached, stored in the user's Nextcloud Files. CnObjectSidebar's built-in Files tab handles upload, preview, and download. Your schema declares the attachment property; you write zero file-handling code. + + + + Annotate a schema property as format: "calendar" and order ship-dates appear in the user's Nextcloud Calendar. Tag a person reference with format: "contact" and the linked vCard becomes a click-through. Pluggable integration registry (ADR-019) lets you add Talk threads, signed documents, or any other Nextcloud surface to a register without touching the apps that consume the register. + + + + OR's register lifecycle handles tenant isolation at the register level. Object visibility, user scoping, and group-based read filters are configuration, not code. Apps inherit the tenant model from their register. + + +Paths 2 and 3 both build on this foundation. Path 1 does not. Every one of the bullets above is a separate engineering project if you start from raw Nextcloud. + +## Two surfaces, one curriculum + +A common confusion when reading the rest of this series: **the manifest is not the same thing as the component library**, even though we teach them together. + +- The **manifest** is `src/manifest.json`. It is a JSON document validated by a schema (`https://github.com/ConductionNL/nextcloud-vue/raw/main/src/schemas/app-manifest-v2.schema.json`, currently version 2.7.0). It describes navigation entries, routes, and what each page renders. It has no runtime behaviour on its own. +- The **component library** is `@conduction/nextcloud-vue`. It is a set of Vue 2 components prefixed `Cn*`. `CnAppRoot` mounts the app shell, `CnPageRenderer` reads the manifest and dispatches the right page component, `CnIndexPage`/`CnDetailPage`/`CnDashboardPage` render the actual page surfaces. + +You can use the library without the manifest. Path 2 in the table above does exactly that. A hand-rolled `App.vue` mounts `Cn*` components directly in a hand-written router. You write more Vue, but you keep the schema-driven listing/detail/form behaviour and you keep all the OpenRegister perks. + +You cannot use the manifest without the library. The manifest is meaningless until something reads it. `CnPageRenderer` is that something. So the manifest path is always a *manifest + library* curriculum. + +The implication for how to read this series: + +| Lesson | Manifest surface | Library surface | +|---|---|---| +| **Part 1: Scaffold** | none yet | `CnAppRoot` chassis | +| **Part 2: Schemas + manifest** | introduce `manifest.json`, page types, `config` props | `CnPageRenderer` dispatch, `CnIndexPage` + `CnDetailPage` props | +| **Part 3: Schema-driven integrations** | unchanged | calendar provider annotation flows through `CnDetailGrid` | +| **Part 4: Knowledge tab + ship** | sidebar tabs in `config.sidebar`, `type: 'custom'` for the wiki view | host-app SFC registered as a custom component, `CnObjectSidebar` consumes manifest tabs | + +If you only remember one thing about the relationship: **the manifest answers "what should this app render"; the library answers "how does that get rendered". The library makes the manifest live.** + +## A note on choosing once vs choosing later + +You do not have to pick a path forever. Apps move between Path 2 and Path 3 routinely. A few of the Conduction apps started life on Path 2 and moved to Path 3 once the team learned the manifest. The migration is usually 1–2 days for an app with 5–10 schemas, because the schemas don't change at all; only `App.vue` + the router are replaced by `CnAppRoot` + a manifest. + +Moving from Path 1 to Path 2 or Path 3 is harder because you have to migrate the data layer to a register. Plan that as a real project, not as a refactor between sprints. + +## Where to go from here + + + + + + + diff --git a/academy/2026-05-07-nextcloud-lokaal-draaien/index.mdx b/academy/2026-05-07-nextcloud-lokaal-draaien/index.mdx index 07395d416..d692cdd66 100644 --- a/academy/2026-05-07-nextcloud-lokaal-draaien/index.mdx +++ b/academy/2026-05-07-nextcloud-lokaal-draaien/index.mdx @@ -1,61 +1,206 @@ --- -slug: nextcloud-lokaal-draaien -title: Nextcloud lokaal draaien met Docker +slug: run-nextcloud-locally +title: Run Nextcloud locally with Docker contentType: tutorial +heroIcon: container authors: [conduction] date: 2026-05-07 -summary: Drie commando's, vier stappen, een kwartier werk. Daarna draait er een schone Nextcloud op je laptop, klaar om Conduction-apps op te installeren. -tags: [Nextcloud, Docker, Setup, Demo, Lokaal] -durationMinutes: 15 +summary: Five steps, half an hour. After that you have a clean Nextcloud running on your laptop — even if you have never used Docker or a terminal before. +tags: [Nextcloud, Docker, Setup, Demo, Local] +durationMinutes: 30 --- -import {Outcomes, Outcome, Prerequisites, PrerequisiteItem, ContactCta} from '@conduction/docusaurus-preset/components'; - -Voor elke andere tutorial in deze academy heb je een werkende Nextcloud nodig. Dit is de snelste route: drie commando's en je draait de officiële Nextcloud-image lokaal, identiek aan productie. Geen sales-call, geen demo-omgeving die offline kan, geen custom Dockerfile. Daarna installeer je de Conduction-apps via de Nextcloud app store, hetzelfde pad als productie. +import {useState, useEffect} from 'react'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; +import {Outcomes, Outcome, Prerequisites, PrerequisiteItem, ContactCta, Troubleshooting, TroubleshootingItem, NextSteps, NextStep} from '@conduction/docusaurus-preset/components'; + +export function OSTabs({children}) { + const [os, setOs] = useState(() => { + if (typeof window === 'undefined') return 'windows'; + const stored = localStorage.getItem('docusaurus.tab.os'); + if (stored) { try { return JSON.parse(stored); } catch (e) {} } + const ua = navigator.userAgent; + return /Mac/.test(ua) ? 'mac' : /Linux/.test(ua) ? 'linux' : 'windows'; + }); + useEffect(() => { + const ua = navigator.userAgent; + const detected = /Mac/.test(ua) ? 'mac' : /Linux/.test(ua) ? 'linux' : 'windows'; + if (!localStorage.getItem('docusaurus.tab.os')) { + localStorage.setItem('docusaurus.tab.os', JSON.stringify(detected)); + } + setOs(detected); + }, []); + return ( + + {children} + + ); +} + +Every other tutorial in this academy needs a working Nextcloud. This is the fastest route: a few commands and you run the official Nextcloud locally on your laptop, identical to production. **You do not need to be a programmer** — if you can install an app and copy and paste text, you will get through this. After that you install the Conduction apps from the Nextcloud app store, the same path as production. {/* truncate */} - - Een werkende Nextcloud op http://localhost:8080 opzetten met Docker. - Postgres voor data, Redis voor cache, Nextcloud voor het werk en notify_push voor realtime updates in één compose-bestand combineren. - Een admin-account aanmaken waar je naar believen Conduction-apps op installeert. - Met één commando de hele stack weer opruimen. +> **Day-to-day developer setup?** This standalone tutorial is the beginner-friendly route. For actual Conduction app development, [Part 5](/academy/workstation-tutorial-5-nextcloud-locally) of the [Workstation Setup tutorial chain](/academy/workstation-tutorial-1-what-to-install) covers the `nextcloud-docker-dev` + `apps-extra/` flow — where Conduction apps are cloned (not installed from the app store) so your local code changes hot-reload into Nextcloud. + + + How to open a terminal and run simple commands. + How to run a working Nextcloud on http://localhost:8080 with Docker. + How to create an admin account where you can install Conduction apps at will. + How to tear down the whole stack with one command. -Daarna kun je verder met [Een Woo-register opzetten](/academy/woo-register-opzetten), [Bestanden uploaden bij een Woo-publicatie](/academy/woo-bestanden-uploaden), of een eigen experiment. +After this you can move on to [Set up a Woo register](/academy/woo-set-up-register), [Upload files to a Woo publication](/academy/woo-upload-files), or your own experiment. - - Een laptop met minimaal 4 GB vrij geheugen. - Docker, lokaal draaiend. - Een terminal en een teksteditor. + + A laptop with at least 4 GB of free memory. + Docker Desktop — a free program you install in step 0. + A text editor: Notepad (Windows), TextEdit (Mac), or nano (Linux) is enough. -Op een schone laptop is stap 0 (Docker installeren) ongeveer tien minuten. Daarna is stap 1 t/m 3 samen een kwartier. +> **This is a local development environment.** It runs the same software as a real Nextcloud installation and behaves the same way, so everything you learn here applies directly to a production deployment. However, data you store here is not backed up, is not shared with anyone, and can be wiped at any time — for example, running `docker compose down -v` permanently deletes everything. Use this environment to explore, experiment, and learn. Do not store real, sensitive, or production data here. + +No prior experience needed. On a clean laptop, step 0 (installing Docker and learning the terminal) takes about ten minutes. Steps 1 through 5 together take half an hour after that. + +If something does not work as expected at any point, the [Troubleshooting](#troubleshooting) section at the bottom of this page has solutions for the most common problems. + +## Step 0: Install Docker and open a terminal + +### What is Docker? + +Docker is a free program that lets you run other programs — like Nextcloud — in an isolated environment on your laptop. Think of virtual boxes: Nextcloud, the database, and the cache each run in their own box and do not affect anything outside it. When you are done, you throw the boxes away and your laptop is clean again. + +### Install Docker + + + + +Windows needs WSL 2 installed before Docker Desktop — skip this and the most common Docker error appears. It only takes a minute. + +1. Press the **Windows key**, type `cmd`, then **right-click** Command Prompt and choose **Run as administrator**. Click **Yes** when Windows asks for permission. +2. Type the command below and press **Enter**. This installs WSL 2 with Ubuntu automatically: + ``` + wsl --install + ``` +3. When it finishes, **restart your computer**. +4. After the restart, go to the [Docker Desktop download page](https://www.docker.com/products/docker-desktop/) and click **Download for Windows**. This downloads an `.exe` installer. +5. Open the downloaded file. If Windows asks "Do you want to allow this app to make changes to your device?" click **Yes**. +6. Follow the steps in the installer — click **OK**, **Next**, and **Finish** when asked. +7. Open **Docker Desktop** from the Start menu. +8. Wait until the whale icon in the taskbar stops moving. Docker is ready. + + + + +1. Go to the Docker Desktop download page. +2. You will see two download buttons — **Apple Silicon** (for M1, M2, M3, and M4 chips) and **Intel** (for older Macs). Not sure which you have? Click the **Apple logo (🍎)** in the top-left corner of your screen and choose **About This Mac**. If it says "Apple M1", "Apple M2", or similar, click **Apple Silicon**. If it says "Intel Core", click **Intel**. +3. Open the downloaded `.dmg` file. Drag the Docker icon into the **Applications** folder when prompted. +4. Open Docker from your Applications folder. macOS may ask if you are sure you want to open it — click **Open**. +5. Wait until the whale icon in the menu bar stops moving. Docker is ready. + + + + +Follow the Docker Engine installation instructions for your distribution. + + + + +### Open a terminal + +A **terminal** (also called a command prompt or console) is a text window where you type commands. You type a command, press **Enter**, and the computer runs it. You do not need to know the terminal beforehand — every command in this tutorial can be copied and pasted. + + + -## Stap 0: Zorg dat Docker draait +1. Press the **Windows key** and type `cmd`. +2. Click **Command Prompt** in the results, or press **Enter**. +3. A black window opens — that is your Command Prompt (terminal). -Eenmalig. + + -- **Mac of Linux:** installeer [Docker Desktop](https://docs.docker.com/get-docker/). -- **Windows:** zet eerst [WSL 2](https://learn.microsoft.com/nl-nl/windows/wsl/install) aan en installeer daarna Docker Desktop. +1. Press **Cmd + Space** to open Spotlight. +2. Type `Terminal` and press **Enter**. +3. A window with a blinking cursor opens — that is your terminal. -Test of het werkt: + + + +1. Press **Ctrl + Alt + T**, or search for "Terminal" in your application menu. + + + + +### Check that Docker works + +Type the following in the terminal (press **Enter** after each line): ```bash docker --version docker compose version ``` -Allebei moeten een versie teruggeven, geen foutmelding. +You should see something like `Docker version 27.0.3` and `Docker Compose version v2.x`. If you see an error, check that Docker Desktop is open and active (whale icon in the taskbar or menu bar). + +## Step 1: Create a working directory and the compose file -## Stap 1: Maak een werkmap en het compose-bestand +### Create a directory + +Type in the terminal: ```bash mkdir conduction-demo cd conduction-demo ``` -Sla het volgende op als `docker-compose.yml` in die map: +- `mkdir conduction-demo` creates a new folder called `conduction-demo`. +- `cd conduction-demo` steps into that folder ("cd" stands for "change directory"). + +### Create the compose file + +The compose file tells Docker which programs to start and how they talk to each other. You create it once and never need to touch it again. + +Open a text editor from the terminal so the filename and folder are set automatically — no manual navigation needed when saving: + + + + +Type in the terminal: + +```bash +notepad docker-compose.yml +``` + +Windows asks whether to create the file — click **Yes**. Notepad opens. Paste the content below, then press **Ctrl+S** to save. + + + + +Type in the terminal: + +```bash +touch docker-compose.yml && open -e docker-compose.yml +``` + +TextEdit opens with the file already created. Paste the content below, then press **Cmd+S** to save. + + + + +Type in the terminal: + +```bash +nano docker-compose.yml +``` + +The nano text editor opens. Paste the content below, then press **Ctrl+X**, then **Y**, then **Enter** to save. + + + + +Copy the full text below and paste it into the editor. The correct filename (`docker-compose.yml`) and folder are already set — you do not need to change anything. ```yaml title="docker-compose.yml" services: @@ -88,7 +233,7 @@ services: POSTGRES_USER: nextcloud POSTGRES_PASSWORD: nextcloud REDIS_HOST: redis - NEXTCLOUD_TRUSTED_DOMAINS: "localhost" + NEXTCLOUD_TRUSTED_DOMAINS: "localhost nextcloud" notify_push: image: icewind1991/notify_push:latest @@ -108,123 +253,327 @@ volumes: nextcloud: ``` -Vier services, allemaal officiële images, geen custom build. De `./apps`-bind-mount is optioneel: handig als je een Conduction-app als zip naast de installatie wilt zetten in plaats van via de app store. `notify_push` is de WebSocket push-server die Conduction-apps gebruiken voor realtime updates — meer daarover in stap 5. +The file describes four services: Nextcloud itself, a database (Postgres), a cache (Redis), and a live-update system (notify_push — also known as **Client Push** in the Nextcloud app store). All official, free images — Docker downloads them automatically in the next step. + +Close the editor and go back to the terminal that is still open. + +## Step 2: Start the stack -## Stap 2: Start de stack +Make sure your terminal is still in the `conduction-demo` folder (it will be if you just finished step 1). Type: ```bash docker compose up -d ``` -De eerste keer trekt Docker ongeveer 750 MB aan images binnen. Daarna start de stack in seconden. +Docker downloads the required programs — about 750 MB the first time — and then starts them. The `-d` keeps everything running in the background so your terminal stays free. -Volg de logs terwijl Nextcloud zichzelf bootstrapt: +Want to see what is happening behind the scenes? Follow the progress with: ```bash docker compose logs -f nextcloud ``` -Wacht tot je `apache2 -D FOREGROUND` of vergelijkbaar ziet, en `Ctrl+C` om de log-volger af te sluiten. De containers blijven draaien. +You will see lines of text scroll by while Nextcloud sets itself up. Wait until you see a line with `apache2 -D FOREGROUND` or similar — that means Nextcloud is ready. Then press **Ctrl+C** to close the log view. The containers keep running. + +**Keep the terminal open** — you will need it again in step 5. + +## Step 3: Finish the Nextcloud setup wizard + +Open your web browser (Chrome, Firefox, Edge — whatever you prefer) and go to: + +``` +http://localhost:8080 +``` + +You see the Nextcloud setup screen. We will create an admin account using `admin` as the username and `admin` as the password. + +Nextcloud will warn you that the password is too weak — that warning is correct, but this installation runs **only on your own laptop** and cannot be reached by anyone else, so it is fine for a local demo. You can ignore the warning. + +If you ever run a Nextcloud that others can reach — over the internet or within a network — always choose a strong, unique password. A good rule of thumb: at least 12 characters, mixing uppercase letters, numbers, and a special character. + +> **Note:** below the password field you will see a collapsed section called **Storage & database**. You do not need to open it — the database configuration is already taken care of through the `docker-compose.yml` file. + +1. Fill in `admin` as the **username** and `admin` as the **password**. **Do not press Enter while filling in the fields** — wait until both fields are filled in before you continue. +2. Click **Install** or press Enter after filling in the password. The screen disappears immediately and Nextcloud starts installing. +3. Wait about half a minute while Nextcloud finishes setting up. +4. Nextcloud may ask if you want to install recommended apps. Click **Skip** — we will install only the Conduction apps you need in the next step. + +You land on the Nextcloud dashboard, signed in as admin. + +## Step 4: Install the Conduction apps + +First open the Nextcloud app store: + +1. Click your **avatar** — the circle with your initials in the top right corner of the screen. +2. Choose **Apps** from the dropdown menu. +3. In the left sidebar, click **Featured apps** — if you skip this the search may show no results because Nextcloud only searches within the currently active category. + +Now install the Conduction apps one by one. Start with **Open Register** — OpenCatalogi and Integriq both depend on it. + +> **Note:** when you click **Download and enable**, Nextcloud may ask you to confirm your password before it allows the installation. Enter `admin` and confirm. + +--- + +**Open Register** + +The foundation for all other Conduction apps. Install this one first — OpenCatalogi and Integriq both read from it. + +1. Click the **search field** at the top of the Apps page (the magnifying glass icon) so the cursor appears inside it. +2. Type `Open Register` — the results update as you type. +3. Click **Download and enable** on the Open Register card. +4. Wait for the installation to finish before moving on. + +--- + +**OpenCatalogi** + +Makes every register searchable as a public catalogue and federates to `data.overheid.nl`. + +1. Clear the search field, type `OpenCatalogi`, and press **Enter**. +2. Click **Download and enable** on the OpenCatalogi card. + +--- + +**Integriq** + +Pulls in sample data via REST, SOAP, and file drops. + +1. Clear the search field, type `Integriq`, and press **Enter**. +2. Click **Download and enable** on the Integriq card. + +--- -## Stap 3: Voltooi de Nextcloud setup-wizard +After every Conduction app install, the setup runs automatically and sample data becomes visible within seconds. -Open `http://localhost:8080` in je browser. Nextcloud toont een setup-wizard: +### Also install: Client Push -1. Vul een **admin-naam** en **wachtwoord** in. `admin` en `admin` is prima voor lokaal werk; in productie natuurlijk niet. -2. Onder **Storage & database** kies je niets, want de Postgres-config zit al in `docker-compose.yml`. -3. Klik op **Install**. +**Client Push** is a standard Nextcloud app — not a Conduction app — that provides live browser updates so the page refreshes automatically when data changes. You need it for step 5. -Na een halve minuut land je op het dashboard, ingelogd als admin. +1. Clear the search field, type `Client Push`, and press **Enter**. +2. Click **Download and enable** on the Client Push card. -## Stap 4: Installeer de Conduction-apps +## Step 5: Wire Client Push to the push server -Klik je avatar rechtsboven en kies **Apps**. De Conduction-apps staan onder **Integration** en **Office & text**. Onze suggestie voor een eerste demo: +This is the only step that requires a bit more terminal work. Go back to the terminal you kept open and make sure you are still in the `conduction-demo` folder. -- **OpenRegister.** De typed-data foundation. Installeer deze als eerste, alle andere apps lezen ervan af. -- **OpenCatalogi.** Maakt elk register doorzoekbaar als publieke entry. Federation naar `data.overheid.nl`. -- **OpenConnector.** Trekt voorbeelddata binnen via REST, SOAP en file-drops. +Copy the commands below one line at a time, paste them into the terminal, and press **Enter** after each one. How to paste depends on your system: -Na elke install draait de schema-bootstrap automatisch. Voorbeelddata is binnen seconden zichtbaar. + + -Installeer ook de `notify_push`-app (onder **Integration**). Die is de Nextcloud-zijde van de WebSocket push-server die je in stap 1 al hebt gestart — je hoeft hem alleen te enabelen, geen verdere config nodig. +**Right-click** inside the terminal window to paste. **Ctrl+V** may also work on Windows 10 and 11, but right-click is more reliable. -## Stap 5: Koppel `notify_push` aan de push-server + + -`notify_push` heeft één eenmalige setup-stap zodat de Nextcloud-app weet waar de Rust-binary draait. Voer in de werkmap uit: +Press **Cmd+V** to paste. + + + +Press **Ctrl+Shift+V** to paste, or right-click and choose **Paste**. + + + + +Run each command below one at a time — copy it, paste it into the terminal, and press **Enter**. Wait for the terminal to return a new prompt before pasting the next one. + +**Command 1 of 5** — tell Nextcloud which server handles the shared cache: ```bash docker compose exec --user www-data nextcloud php occ config:system:set redis host --value redis +``` + +**Command 2 of 5** — set the port for the cache server: +```bash docker compose exec --user www-data nextcloud php occ config:system:set redis port --value 6379 --type integer +``` + +**Command 3 of 5** — tell Nextcloud to use the cache for shared data: +```bash docker compose exec --user www-data nextcloud php occ config:system:set memcache.distributed --value '\OC\Memcache\Redis' +``` + +**Command 4 of 5** — mark the internal Docker network as trusted so Client Push can talk back to Nextcloud: +```bash docker compose exec --user www-data nextcloud php occ config:system:set trusted_proxies 0 --value 172.16.0.0/12 -docker compose exec --user www-data nextcloud php occ notify_push:setup http://notify_push:7867 ``` -De eerste drie regels vertellen Nextcloud dat Redis de gedeelde cache is — `notify_push` luistert daarop voor change-events. De vierde regel markeert het Docker-netwerk als trusted zodat de push-server bij Nextcloud terugmag bellen voor authenticatie. De laatste regel registreert de URL bij de Nextcloud-app. +**Command 5 of 5** — register the Client Push server address with Nextcloud: +```bash +docker compose exec --user www-data nextcloud php occ notify_push:setup http://notify_push:7867 +``` -Verifieer dat alles werkt: +Once all five have run, check that everything is connected: ```bash docker compose exec --user www-data nextcloud php occ notify_push:self-test ``` -Je krijgt een lijstje vinkjes — `redis is configured`, `push server is receiving redis messages`, `push server can connect to the Nextcloud server`, et cetera. De waarschuwing over `unencrypted http` is voor lokaal werk normaal; in productie zit `notify_push` achter dezelfde reverse proxy als Nextcloud, dus dan is dat geen issue. +You should see a list of green checkmarks (✓). One warning about `unencrypted http` is expected for local use — you can ignore it. If any other item shows a red ✗, check the troubleshooting section at the bottom of this page. -Vanaf nu krijgen apps die op `notify_push` luisteren — zoals OpenRegister's live-update-integratie via `@conduction/nextcloud-vue` — direct doorduwingen op object-wijzigingen, zonder dat de browser hoeft te pollen. +## What you get -## Wat je krijgt +Here is a quick overview of what is now running on your laptop: -| Eigenschap | Waarde | +| | | | --- | --- | -| Eerste boot | Ongeveer drie minuten (eerste keer 750 MB download) | -| Volgende boots | Seconden | -| Image | Officiële `nextcloud:latest`, identiek aan productie | -| Geheugen | Rond 1 GB onder lichte load | -| Reset | `docker compose down -v` (verwijdert volumes) | +| **Browser address** | `http://localhost:8080` | +| **First startup** | About 3 minutes (downloads ~750 MB once) | +| **Starting again later** | A few seconds | +| **Nextcloud version** | Official latest, identical to production | +| **Memory used** | Around 1 GB | +| **To wipe everything and start fresh** | Run `docker compose down -v` — ⚠️ deletes all data | + +This is a real, fully working Nextcloud — not a recording or a shared demo that can go offline. -Geen vooraf opgenomen screencast, geen demo-omgeving die offline kan. Wat je hier ziet, is precies wat een echte klant ziet. +## Stopping and starting -## Reset of stoppen +When you are done for the day you do not need to leave everything running. +**Pause for now** — stops the containers but keeps all your data: ```bash -# tijdelijk stoppen, data behouden docker compose stop +``` -# weer starten +**Start again after pausing:** +```bash docker compose start +``` -# helemaal opruimen, ook volumes +**Wipe everything and start from scratch:** +```bash docker compose down -v ``` -Na `down -v` is je laptop weer schoon. Twee minuten later kun je opnieuw beginnen. +> ⚠️ **This deletes everything.** All your Nextcloud data, every installed app, every setting, and every file you uploaded will be permanently gone. There is no undo. Only run this if you are sure you want to start completely fresh. + +After `docker compose down -v` your laptop is back to its original state. To start over, run `docker compose up -d` from the `conduction-demo` folder and go through the full setup again from step 3. + +## Troubleshooting + + + + + +Docker Desktop is probably not running. Open Docker Desktop and wait for the whale icon to stop moving, then try again. On Windows: close the Command Prompt and open a new one after Docker Desktop has started. + + + + + +Virtualization is probably disabled in your computer's BIOS. The BIOS is a settings screen that exists below Windows and controls your hardware — it looks different from everything you normally see. On most modern laptops (2018+) virtualization is already on by default, so this error is uncommon. + +> **Not comfortable making changes in the BIOS?** That is completely understandable — ask someone with IT knowledge to help you with the steps below. + +**Step 1 — get into the BIOS.** The easiest way on Windows 10 and 11: +1. Click **Start** → **Settings** (the gear icon) → **System** → **Recovery**. +2. Under **Advanced startup**, click **Restart now**. +3. After the restart, click **Troubleshoot** → **Advanced options** → **UEFI Firmware Settings** → **Restart**. +4. Your PC restarts directly into the BIOS screen. + +If that option is not available, restart your computer and immediately press the key for your laptop brand — you only have a few seconds before Windows starts loading: + +| Brand | Key | +| --- | --- | +| Dell | F2 | +| HP | F10 or Esc | +| Lenovo | F1 or F2 | +| ASUS | Del or F2 | +| Acer | Del or F2 | +| Microsoft Surface | Hold **Volume Up** while pressing the power button | + +Not sure? Watch the screen right after startup — it usually shows a message like "Press F2 to enter Setup". + +**Step 2 — find the virtualization setting.** Once inside the BIOS: +1. Use the **arrow keys** on your keyboard to navigate — your mouse usually does not work here. +2. Look for a tab or section called **Advanced**, **Configuration**, or **CPU Configuration**. +3. Find a setting named **Intel Virtualization Technology**, **Intel VT-x**, **AMD-V**, **SVM Mode**, or **Virtualization Technology**. +4. Change it from **Disabled** to **Enabled**. + +**Step 3 — save and exit.** Press **F10** to save and exit, or look for a **Save & Exit** tab and press **Enter** on **Save Changes and Exit**. Confirm with **Yes** when asked. + +> **Important:** only change the virtualization setting. Do not touch anything else. + +Your computer will restart. Open Docker Desktop and it should start normally now. + + + + + +Open Command Prompt as administrator and run `wsl --update`, then restart Docker Desktop. + + + + + +The port may already be in use by another program. Open `docker-compose.yml` in your text editor, change `"8080:80"` to `"8081:80"`, save, and try `http://localhost:8081` instead. + + + + + +The database was wiped, probably after a `docker compose down -v`. Run `docker compose up -d` to start fresh — you will need to go through the setup wizard again. + + + + + +The address you are using is not on Nextcloud's allowed list. Open `docker-compose.yml`, find `NEXTCLOUD_TRUSTED_DOMAINS`, add your address to the value (space-separated), save, and run `docker compose up -d`. + + + + + +Nextcloud may not be able to reach the internet. This sometimes happens on corporate networks. Try switching to a mobile hotspot to confirm, then ask your IT department about proxy settings. + + + + + +The internal hostname `nextcloud` is missing from Nextcloud's trusted list. Run this one command to fix it: + +```bash +docker compose exec --user www-data nextcloud php occ config:system:set trusted_domains 1 --value nextcloud +``` + +Then re-run command 5 from step 5. If you are starting fresh, this is already fixed in the `docker-compose.yml` above. -## Probleemoplossing + -**Apache port 8080 in gebruik:** wijzig `"8080:80"` naar bijvoorbeeld `"8081:80"` in `docker-compose.yml`. Vergeet niet de URL aan te passen naar `http://localhost:8081`. + -**Setup-wizard wil opnieuw beginnen:** waarschijnlijk is de Postgres-database leeg geraakt. Voer `docker compose down -v` uit en start opnieuw met `docker compose up -d`. +The internal Docker network address range on your machine is different from the default. This is rare — if it happens, contact us and we will help you sort it out. -**Nextcloud toont "Trusted domain"-foutmelding:** voeg de host toe aan `NEXTCLOUD_TRUSTED_DOMAINS` (komma-gescheiden) en herstart met `docker compose up -d`. + -**App-store install werkt niet:** controleer of Nextcloud naar buiten kan. In een corporate netwerk soms blokkade op `apps.nextcloud.com`. Een proxy via `HTTP_PROXY`-env-variabelen lost dit op. + -**`notify_push` self-test faalt op `is not trusted as a reverse proxy`:** het Docker-netwerk-bereik in jouw setup wijkt af van `172.16.0.0/12`. Bekijk het bereik met `docker network inspect $(docker network ls --filter name=conduction-demo -q) --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}'` en pas de `trusted_proxies`-regel uit stap 5 aan met die subnet. +Run `docker compose logs notify_push` and look for `× No redis server is configured`. If you see it, run commands 1, 2, and 3 from step 5 again, then restart with: -**`notify_push` blijft restarten na `docker compose up -d`:** kijk in de logs (`docker compose logs notify_push`) of je `× No redis server is configured` ziet. Dat betekent dat Nextcloud's `memcache.distributed`-config nog niet op Redis staat — voer de eerste drie `occ config:system:set`-commando's uit stap 5 uit, en herstart dan met `docker compose up -d notify_push`. +```bash +docker compose up -d notify_push +``` -## Volgende stap + -Een werkende Nextcloud is de aanleiding voor de andere tutorials in deze serie: + -- [Een Woo-register opzetten](/academy/woo-register-opzetten) — importeer het canonieke Woo-register in OpenRegister -- [Bestanden uploaden bij een Woo-publicatie](/academy/woo-bestanden-uploaden) — vier upload-modes voor documenten -- [Build je eerste Conduction-app](/academy/build-an-app) — eindigt met een geïnstalleerde app op deze stack + + + Import the canonical Woo register into OpenRegister. + + + Four upload modes for documents. + + + Nine-part app-building series, using the OpenAPI Pet Store as the running domain. Picks the right app-building path for your project, then ships a working app end to end. + + -Stuur het `docker-compose.yml` naar een collega als je dit samen wilt zien werken. +Send the `docker-compose.yml` to a colleague if you want to see this work together. diff --git a/academy/2026-05-07-woo-bestanden-uploaden/01-object-files-upload.png b/academy/2026-05-07-woo-bestanden-uploaden/01-object-files-upload.png new file mode 100644 index 000000000..a754dd9df Binary files /dev/null and b/academy/2026-05-07-woo-bestanden-uploaden/01-object-files-upload.png differ diff --git a/academy/2026-05-07-woo-bestanden-uploaden/index.mdx b/academy/2026-05-07-woo-bestanden-uploaden/index.mdx index 37d2799fb..04757e4c3 100644 --- a/academy/2026-05-07-woo-bestanden-uploaden/index.mdx +++ b/academy/2026-05-07-woo-bestanden-uploaden/index.mdx @@ -1,348 +1,174 @@ --- -slug: woo-bestanden-uploaden -title: Bestanden bij een Woo-publicatie uploaden +slug: woo-upload-files +title: Upload files to a Woo publication contentType: tutorial +heroIcon: document authors: [conduction] date: 2026-05-07 -summary: Vier manieren om documenten aan een Woo-publicatie te koppelen, van één PDF tot een 6 MB rapport in chunks. Met geteste API-aanroepen. -tags: [Woo, OpenRegister, Bestanden, Upload, Publicaties] +summary: Attach documents to a Woo publication through the OpenRegister interface, then publish them. With an API appendix for automation and large files. +tags: [Woo, OpenRegister, Files, Upload, Publications, Tutorial series] apps: [openregister, openwoo] -durationMinutes: 18 +durationMinutes: 15 +series: woo-tutorial +partNumber: 2 --- import {Outcomes, Outcome, Prerequisites, PrerequisiteItem, Troubleshooting, TroubleshootingItem, NextSteps, NextStep, ContactCta} from '@conduction/docusaurus-preset/components'; -Een Woo-publicatie zonder bestanden is metadata zonder bewijs. In deze tutorial koppel je documenten aan een Woo-publicatie via vier upload-modes: één bestand, meerdere bestanden tegelijk, en grote bestanden in chunks. Plus publiceren, depubliceren en opruimen. +A Woo publication without files is metadata without evidence. In this tutorial you attach documents to a publication through the OpenRegister interface, choose which ones are public, and remove them again. An appendix covers the API, for scripts and for files too large for a browser upload. {/* truncate */} - - Vier upload-modes voor documenten bij een Woo-publicatie, van klein naar groot. - Wanneer je JSON+base64, multipart of WebDAV chunked upload gebruikt. - Hoe je bestanden publiceert, depubliceert en weer opruimt. + + How to upload one or more files to a publication through the interface. + How to publish a file, so it gets a public link, and unpublish it again. + How to list, download, and delete files on a publication. + How to upload via the API for automation and for large files. - + - Een werkend Woo-register. Volg eerst Een Woo-register opzetten als je dat nog niet hebt. + A working Woo register with at least one publication. Follow Set up Woo and DCAT registers first. - Een publicatie-object met bekende UUID. - curl, basisauth, en een paar testbestanden. + A user with rights to edit objects in the register. + A few test documents to upload, for example a PDF. -In de voorbeelden gebruiken we: +## Where files live -- Host: `http://localhost:8080` -- Auth: `admin:admin` -- Register-slug: `woo` -- Schema-slug: `onderzoeksrapporten` -- Object-UUID: `3422e6cd-1ba5-478c-b842-8f206f9d7358` +Every file is attached to a specific object. OpenRegister keeps the files of an object together in a folder under `Open Registers/{Register Title}/{Object UUID}/` in Nextcloud Files. You do not manage that folder yourself; the interface does it for you. -Vervang het object-UUID door de jouwe. +## Step 1: Open the publication -![Het Woo-register met de geïmporteerde TOOI-schemas](./01-woo-register-context.png) +In OpenRegister, go to **Data → Registers**, open your Woo register and the `onderzoeksrapporten` schema, and click the publication you created in Part 1 to open it. The object's **Files** panel lists the documents attached to this publication. It is empty for a new object. -## Het basispatroon +## Step 2: Upload a file -Alle bestanden worden gekoppeld aan een specifiek object. De URL volgt altijd hetzelfde patroon: +In the Files panel, drop a file onto the **Drop files here or click to browse** area, or click it to pick a document, for example `onderzoeksrapport-2026.pdf`. The file uploads and appears in the list with its name and size. -``` -POST /index.php/apps/openregister/api/objects/{register}/{schema}/{id}/files -``` +![A document uploaded to a publication through the Files panel, with the share toggle](./01-object-files-upload.png) -OpenRegister maakt automatisch een map aan voor het object onder `Open Registers/{Register Title}/{Object UUID}/`. Daar landen alle bestanden. De map ontstaat zodra je het eerste bestand uploadt. +To attach more than one document, drop several files at once. Each one gets its own row. -## Mode 1: één bestand via JSON en base64 +## Step 3: Choose what is public -De eenvoudigste route, prima voor kleine documenten tot enkele megabytes: +The **Share uploaded files** toggle decides whether files become public the moment you upload them. Turn it on for documents that may be released right away. Leave it off for attachments that need redaction first, then publish them later. -```bash -OBJ=3422e6cd-1ba5-478c-b842-8f206f9d7358 -B64=$(echo "Inhoud van het rapport." | base64 -w0) +Each file row has an **Actions** menu to **Open** the file or **Delete** it. To publish or unpublish a file after upload, use the files API in the appendix: publishing gives the file a public share link, unpublishing revokes it. -curl -u admin:admin \ - -X POST "http://localhost:8080/index.php/apps/openregister/api/objects/woo/onderzoeksrapporten/$OBJ/files" \ - -H "OCS-APIRequest: true" \ - -H "Content-Type: application/json" \ - -d "{ - \"name\": \"rapport-2026.txt\", - \"content\": \"$B64\", - \"share\": true - }" -``` +## Step 4: List, download, and remove -De respons: - -```json -{ - "id": 3306, - "path": "/admin/files/Open Registers/Woo Register/3422e6cd-.../rapport-2026.txt", - "title": "rapport-2026.txt", - "accessUrl": "http://localhost:8080/index.php/s/CH5JsTKtrczyQgw", - "downloadUrl": "http://localhost:8080/index.php/s/CH5JsTKtrczyQgw/download", - "type": "text/plain", - "size": 44, - "hash": "4bdf3a838bc59f367824ad59a14630c0", - "published": "1970-01-01T00:00:00+00:00", - "labels": [], - "object": "3422e6cd-..." -} -``` +The **Files** tab is also where you manage what is there: -`share: true` maakt direct een publieke share-link aan. De `accessUrl` is dan meteen de publieke landings-URL. Wil je het bestand vooralsnog privé houden, laat `share` weg of zet hem op `false`. +- **Download** a file from its **Actions** menu, or open its public link once published. +- **Delete** a file from the same menu. It is removed from the object folder. -**Wanneer wel:** scripts en synchronisaties waar je tekstinhoud genereert, kleine PDF's, of CI-pipelines. **Wanneer niet:** bestanden boven enkele MB. Base64 maakt de payload 33% groter en je raakt al snel de PHP `post_max_size`-grens. +Everything you do here is reflected in Nextcloud Files under the object's folder, and the other way around. -## Mode 2: één bestand binair via multipart +## Appendix: upload via the API -Voor binaire bestanden (PDF, DOCX, afbeeldingen) gebruik je het `filesMultipart`-endpoint. Dat scheelt de base64-overhead en is sneller voor middelgrote bestanden. - -```bash -OBJ=3422e6cd-1ba5-478c-b842-8f206f9d7358 +For automation (scripts, synchronisations, CI) and for files too large for a browser upload, OpenRegister exposes a files API. Files always attach to one object: -curl -u admin:admin \ - -X POST "http://localhost:8080/index.php/apps/openregister/api/objects/woo/onderzoeksrapporten/$OBJ/filesMultipart" \ - -H "OCS-APIRequest: true" \ - -F "files[]=@/pad/naar/rapport.pdf" \ - -F "share=true" ``` - -De respons is een array met één element: - -```json -[ - { - "id": 3307, - "path": "/admin/files/Open Registers/Woo Register/3422e6cd-.../rapport.pdf", - "title": "rapport.pdf", - "type": "application/pdf", - "size": 482310, - "hash": "a4abb3649eff2d9ba4f80675371a56b8", - "object": "3422e6cd-..." - } -] +POST /index.php/apps/openregister/api/objects/{register}/{schema}/{id}/files ``` -> **`files[]` werkt voor één en voor meerdere bestanden.** Het endpoint accepteert ook een enkelvoudig `file=`-veld, maar `files[]` is consistenter: precies dezelfde aanroep schaalt door naar Mode 3 hieronder zonder dat je je veldnaam hoeft aan te passen. +In the examples: host `http://localhost:8080`, auth `admin:admin`, register `woo`, schema `onderzoeksrapporten`, and an object UUID you replace with your own. -## Mode 3: meerdere bestanden in één request +### One file via JSON and base64 -Het `filesMultipart`-endpoint accepteert meerdere bestanden in één enkele request. Eén HTTP-call, één transactie, één respons-array. Handig voor publicaties met meerdere bijlagen. +For small documents you generate in a script: ```bash -OBJ=3422e6cd-1ba5-478c-b842-8f206f9d7358 +OBJ=your-object-uuid +B64=$(echo "Report content." | base64 -w0) curl -u admin:admin \ - -X POST "http://localhost:8080/index.php/apps/openregister/api/objects/woo/onderzoeksrapporten/$OBJ/filesMultipart" \ - -H "OCS-APIRequest: true" \ - -F "files[]=@/pad/naar/bijlage-1.pdf" \ - -F "files[]=@/pad/naar/bijlage-2.pdf" \ - -F "files[]=@/pad/naar/bijlage-3.pdf" \ - -F "share=true" -``` - -Respons: - -```json -[ - { "id": 3308, "title": "bijlage-1.pdf", "size": 29, "hash": "b753b747...", ... }, - { "id": 3309, "title": "bijlage-2.pdf", "size": 32, "hash": "f370ad27...", ... }, - { "id": 3310, "title": "bijlage-3.pdf", "size": 28, "hash": "80a9a215...", ... } -] + -X POST "http://localhost:8080/index.php/apps/openregister/api/objects/woo/onderzoeksrapporten/$OBJ/files" \ + -H "OCS-APIRequest: true" -H "Content-Type: application/json" \ + -d "{ \"name\": \"rapport-2026.txt\", \"content\": \"$B64\", \"share\": true }" ``` -Elk bestand krijgt een eigen entry in de respons. De `share`-vlag geldt voor het hele batch: alle bestanden worden tegelijk publiek gemaakt of houden tegelijk privé. - -**Praktische limiet:** PHP's `post_max_size` is meestal 100 MB. Het totaal van alle bestanden in één request mag daar niet overheen. Voor grotere uploads spring je naar Mode 4. - -## Mode 4: grote bestanden in chunks via WebDAV +`share: true` creates a public link right away. Base64 inflates the payload by a third, so keep this for small files. -Bestanden boven de PHP-uploadlimiet (typisch 100 MB) splits je in chunks en verstuur je via Nextcloud's WebDAV chunked upload-protocol. Daarna verplaats je het samengestelde bestand naar de objectmap. +### One or more binary files via multipart -Dit is hetzelfde protocol dat de Nextcloud webclient en desktop-app gebruiken. Het werkt voor alle bestandsgroottes en past zich aan netwerkonderbrekingen aan. - -### Stap 4.1: maak een transfer-map - -Geef de transfer een unieke ID, bijvoorbeeld een tijdstempel: +For PDFs, DOCX, and images, `filesMultipart` avoids the base64 overhead and accepts several files in one request: ```bash -TRANSFER_ID="woo-chunked-$(date +%s)" -DAV_BASE="http://localhost:8080/remote.php/dav" - curl -u admin:admin \ - -X MKCOL "$DAV_BASE/uploads/admin/$TRANSFER_ID" + -X POST "http://localhost:8080/index.php/apps/openregister/api/objects/woo/onderzoeksrapporten/$OBJ/filesMultipart" \ + -H "OCS-APIRequest: true" \ + -F "files[]=@/path/to/attachment-1.pdf" \ + -F "files[]=@/path/to/attachment-2.pdf" \ + -F "share=true" ``` -Een 201 betekent dat de transfer-map klaar staat. +The total of all files in one request must stay under PHP's `post_max_size` (usually 100 MB). -### Stap 4.2: split het bestand en upload de chunks +### Large files in chunks via WebDAV -We splitsen een testbestand van 6 MB in drie chunks van 2 MB: +For files above the upload limit, use Nextcloud's chunked upload protocol, the same one the desktop app uses. Create a transfer folder, send the file in chunks, then assemble it into the object folder: ```bash -split -b 2M /pad/naar/groot-rapport.pdf /tmp/chunk- - -i=1 -for chunk in /tmp/chunk-*; do - CHUNK_NUM=$(printf "%05d" $i) - curl -u admin:admin \ - -X PUT --data-binary "@$chunk" \ - "$DAV_BASE/uploads/admin/$TRANSFER_ID/$CHUNK_NUM" - i=$((i+1)) -done -``` - -De chunks krijgen vijfcijferige nummers (`00001`, `00002`, `00003`). Houd de volgorde in de bestandsnamen aan, want Nextcloud assembleert op basis daarvan. - -### Stap 4.3: assembleer en plaats in de objectmap - -Een MOVE-call combineert de chunks tot één bestand op de uiteindelijke locatie: +TRANSFER_ID="woo-chunked-$(date +%s)" +DAV="http://localhost:8080/remote.php/dav" -```bash -OBJ=3422e6cd-1ba5-478c-b842-8f206f9d7358 -DEST="$DAV_BASE/files/admin/Open%20Registers/Woo%20Register/$OBJ/groot-rapport.pdf" -TOTAL_LENGTH=$(wc -c < /pad/naar/groot-rapport.pdf) +curl -u admin:admin -X MKCOL "$DAV/uploads/admin/$TRANSFER_ID" +split -b 10M /path/to/large-report.pdf /tmp/chunk- +i=1; for c in /tmp/chunk-*; do + curl -u admin:admin -X PUT --data-binary "@$c" \ + "$DAV/uploads/admin/$TRANSFER_ID/$(printf %05d $i)"; i=$((i+1)); done -curl -u admin:admin \ - -X MOVE "$DAV_BASE/uploads/admin/$TRANSFER_ID/.file" \ +DEST="$DAV/files/admin/Open%20Registers/Woo/$OBJ/large-report.pdf" +curl -u admin:admin -X MOVE "$DAV/uploads/admin/$TRANSFER_ID/.file" \ -H "Destination: $DEST" \ - -H "OC-Total-Length: $TOTAL_LENGTH" + -H "OC-Total-Length: $(wc -c < /path/to/large-report.pdf)" ``` -Een 201 betekent dat het bestand op de bestemming staat. OpenRegister pikt het automatisch op: zodra je de files-lijst van het object opvraagt, staat het bestand erbij. +OpenRegister picks the file up automatically once it lands in the object folder. Chunks must go to the uploading user's folder; for a service account, upload as that account. -### Stap 4.4: verifieer in OpenRegister +### Publish and manage via the API -```bash -curl -u admin:admin \ - "http://localhost:8080/index.php/apps/openregister/api/objects/woo/onderzoeksrapporten/$OBJ/files" \ - -H "OCS-APIRequest: true" -``` - -Je ziet het nieuwe bestand met zijn echte grootte: - -```json -{ - "id": 3316, - "path": "/admin/files/Open Registers/Woo Register/3422e6cd-.../groot-rapport.pdf", - "title": "groot-rapport.pdf", - "size": 6291456, - "hash": "d7d7eec53f6d70d1bdceea08c10d0bb1", - "modified": "2026-05-07T05:14:34+00:00" -} -``` - -**Belangrijk:** chunks moeten naar de map van de actuele gebruiker (`admin` in dit voorbeeld). Een service-account upload je dus eerst in dat account; de MOVE plaatst het daarna in de gedeelde register-map. - -## Publiceren en depubliceren - -Een geüpload bestand staat standaard niet publiek (`accessUrl: null`). Bij Woo-publicaties wil je expliciet kiezen welke bestanden publiek zijn en welke niet, bijvoorbeeld omdat een bijlage privacygevoelig is en eerst gelakt moet worden. - -Publiek maken: - -```bash -FID=3306 -curl -u admin:admin \ - -X POST "http://localhost:8080/index.php/apps/openregister/api/objects/woo/onderzoeksrapporten/$OBJ/files/$FID/publish" \ - -H "OCS-APIRequest: true" -``` - -In de respons verschijnt een `accessUrl`: - -```json -{ - "id": 3306, - "title": "rapport-2026.txt", - "accessUrl": "http://localhost:8080/index.php/s/CH5JsTKtrczyQgw", - "downloadUrl": "http://localhost:8080/index.php/s/CH5JsTKtrczyQgw/download" -} -``` - -Weer privé maken: - -```bash -curl -u admin:admin \ - -X POST "http://localhost:8080/index.php/apps/openregister/api/objects/woo/onderzoeksrapporten/$OBJ/files/$FID/depublish" \ - -H "OCS-APIRequest: true" -``` - -Na depublicatie zijn `accessUrl` en `downloadUrl` weer `null` en is de share-link ingetrokken. - -## Lijst, downloaden, verwijderen - -| Actie | Methode | URL | +| Action | Method | URL | | --- | --- | --- | -| Lijst van bestanden bij een object | `GET` | `/api/objects/{register}/{schema}/{id}/files` | -| Detail van één bestand | `GET` | `/api/objects/{register}/{schema}/{id}/files/{fileId}` | -| Download (alle bestanden als zip) | `GET` | `/api/objects/{register}/{schema}/{id}/files/download` | -| Download direct via id | `GET` | `/api/files/{fileId}/download` | -| Verwijder één bestand | `DELETE` | `/api/objects/{register}/{schema}/{id}/files/{fileId}` | - -Voorbeeld voor een lijst: - -```bash -curl -u admin:admin \ - "http://localhost:8080/index.php/apps/openregister/api/objects/woo/onderzoeksrapporten/$OBJ/files" \ - -H "OCS-APIRequest: true" -``` - -Verwijderen van één bestand: - -```bash -curl -u admin:admin \ - -X DELETE "http://localhost:8080/index.php/apps/openregister/api/objects/woo/onderzoeksrapporten/$OBJ/files/3306" \ - -H "OCS-APIRequest: true" -``` - -## Welke mode kies je wanneer? - -| Mode | Beste voor | Limiet | Codecomplexiteit | -| --- | --- | --- | --- | -| 1. JSON + base64 | Server-naar-server scripts, kleine PDF's | ≈ 75 MB ruwe inhoud (33% overhead op `post_max_size`) | Laag | -| 2. Multipart, één bestand | Browser-uploads, gewone PDF's en DOCX | `post_max_size`, meestal 100 MB | Laag | -| 3. Multipart, meerdere bestanden | Publicaties met meerdere bijlagen | Som van bestanden onder `post_max_size` | Laag | -| 4. WebDAV chunked + MOVE | Video, scans, datasets > 100 MB | Geen praktische bovengrens | Hoog | - -Voor de meeste Woo-publicaties pak je Mode 2 of 3. Pas op het moment dat een bestand groter dan 100 MB binnenkomt, schakel je over naar Mode 4. - - - - Verhoog upload_max_filesize en post_max_size in php.ini, of stap over op Mode 4. +| Publish a file | `POST` | `/api/objects/{register}/{schema}/{id}/files/{fileId}/publish` | +| Unpublish a file | `POST` | `/api/objects/{register}/{schema}/{id}/files/{fileId}/depublish` | +| List files | `GET` | `/api/objects/{register}/{schema}/{id}/files` | +| Download all as zip | `GET` | `/api/objects/{register}/{schema}/{id}/files/download` | +| Delete a file | `DELETE` | `/api/objects/{register}/{schema}/{id}/files/{fileId}` | + + + + The file is larger than PHP allows for a direct upload. Raise upload_max_filesize and post_max_size in php.ini, or use the chunked upload in the appendix. - - Controleer dat de Destination-header naar {'Open Registers/{Register Title}/{Object UUID}/'} wijst en dat de UUID overeenkomt met een bestaand object. OpenRegister scant alleen die map. + + Check that the Destination points to {'Open Registers/{Register Title}/{Object UUID}/'} and that the UUID matches an existing object. OpenRegister scans only that folder. - - Controleer of share-links aanstaan in de Nextcloud admin (Settings → Sharing → "Allow apps to use the share API"). Zonder deze instelling kunnen publicaties geen publieke link krijgen. + + Check that share links are enabled under Settings → Sharing → "Allow apps to use the share API". Without it, publications cannot get a public link. - - - De publicatie samen met andere apps tonen op een portaal. - + - Documenten automatisch laten lakken op privacygevoelige passages. + Make your publications public and expose them as a DCAT-AP-NL feed. - Publicaties periodiek synchroniseren naar open.overheid.nl. + Automatically redact documents on privacy-sensitive passages before you publish. diff --git a/academy/2026-05-07-woo-register-opzetten/01-import-from-url.png b/academy/2026-05-07-woo-register-opzetten/01-import-from-url.png new file mode 100644 index 000000000..f80287a08 Binary files /dev/null and b/academy/2026-05-07-woo-register-opzetten/01-import-from-url.png differ diff --git a/academy/2026-05-07-woo-register-opzetten/02-woo-register.png b/academy/2026-05-07-woo-register-opzetten/02-woo-register.png new file mode 100644 index 000000000..454802411 Binary files /dev/null and b/academy/2026-05-07-woo-register-opzetten/02-woo-register.png differ diff --git a/academy/2026-05-07-woo-register-opzetten/03-dcat-register.png b/academy/2026-05-07-woo-register-opzetten/03-dcat-register.png new file mode 100644 index 000000000..7eb3d10ef Binary files /dev/null and b/academy/2026-05-07-woo-register-opzetten/03-dcat-register.png differ diff --git a/academy/2026-05-07-woo-register-opzetten/index.mdx b/academy/2026-05-07-woo-register-opzetten/index.mdx index f3051fcd1..381ebbaa1 100644 --- a/academy/2026-05-07-woo-register-opzetten/index.mdx +++ b/academy/2026-05-07-woo-register-opzetten/index.mdx @@ -1,225 +1,123 @@ --- -slug: woo-register-opzetten -title: Een Woo-register opzetten in OpenRegister +slug: woo-set-up-register +title: Set up Woo and DCAT registers in OpenRegister contentType: tutorial +heroIcon: document authors: [conduction] date: 2026-05-07 -summary: Importeer het canonieke Woo-register in OpenRegister. Eén API-aanroep, tien TOOI-categorieën, klaar om publicaties op te slaan. -tags: [Woo, OpenRegister, Publicaties, TOOI, Open data] +summary: Import the canonical Woo register and a DCAT open-data register into OpenRegister, entirely through the user interface. +tags: [Woo, DCAT, OpenRegister, Publications, TOOI, Open data, Tutorial series] apps: [openregister, openwoo] -durationMinutes: 10 +durationMinutes: 12 +series: woo-tutorial +partNumber: 1 --- import {Outcomes, Outcome, Prerequisites, PrerequisiteItem, ContactCta} from '@conduction/docusaurus-preset/components'; -De [Wet open overheid (Woo)](https://www.rijksoverheid.nl/onderwerpen/wet-open-overheid-woo) verplicht overheden om documenten openbaar te maken in elf informatiecategorieën. OpenRegister levert het opslagmodel daarvoor. In deze tutorial importeer je het canonieke Woo-register, met alle [TOOI](https://standaarden.overheid.nl/tooi)-categorieën, in een paar minuten. +The [Dutch Open Government Act (Woo)](https://www.rijksoverheid.nl/onderwerpen/wet-open-overheid-woo) asks government bodies to publish documents in set information categories. Open data is published as datasets in the [DCAT-AP-NL](https://data.overheid.nl/dcat-ap-nl) standard. OpenRegister stores both. In this tutorial you import two registers, the Woo register and a DCAT register, using only the OpenRegister interface. {/* truncate */} - - Eén register met de slug woo in je OpenRegister-installatie aanmaken. - Tien schemas importeren, één per TOOI-informatiecategorie. - Een werkend startpunt opzetten om publicaties aan te maken en bestanden te koppelen. + + How to import the canonical Woo register, with all ten TOOI categories, through the OpenRegister interface. + How to import a DCAT register for open-data datasets the same way. + How to read what the import created: registers, schemas, and their fields. + A working starting point for creating publications, datasets, and attaching files. -De vervolgtutorial over bestanden uploaden bouwt hierop voort. +The follow-up tutorial on uploading files builds on this. - - Een draaiende Nextcloud met OpenRegister geïnstalleerd. Heb je dat nog niet, volg dan eerst Nextcloud lokaal draaien met Docker. - Een gebruiker met admin-rechten, of een gebruiker die registers mag aanmaken. - curl of een vergelijkbare HTTP-client. + + A running Nextcloud with OpenRegister installed. If you do not have that yet, first follow Run Nextcloud locally with Docker. + A user with admin rights, or a user allowed to create registers. + Open the OpenRegister app from the Nextcloud app menu. -In de voorbeelden gebruiken we de lokale dev-omgeving op `http://localhost:8080` met `admin:admin`. Vervang dat door je eigen host en credentials. +## The canonical sources -## De canonieke bron +Both registers are published as OpenRegister configuration files, hosted by Conduction at [conduction.nl/oas](https://conduction.nl/oas/). You do not edit them; you point OpenRegister at the URL and it does the rest. -Het Woo-register bestaat al, gepubliceerd door Conduction op GitHub: +| Register | Configuration URL | +| --- | --- | +| Woo | `https://conduction.nl/oas/woo_register.json` | +| DCAT | `https://conduction.nl/oas/dcat_register.json` | -``` -https://raw.githubusercontent.com/ConductionNL/woo-website/main/website/static/oas/woo_register.json -``` +The Woo file holds one register (`woo`) and ten schemas, one per Woo information category, each carrying the [TOOI metadata](https://standaarden.overheid.nl/tooi). The DCAT file holds one register (`dcat`) with a `Dataset` schema aligned to DCAT-AP-NL. -Het is een OpenAPI-bundel van 1.0.1, met één register (`woo`) en tien schemas. Elk schema bevat de [TOOI-metadata](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen) van één Woo-informatiecategorie: +## Step 1: Import the Woo register -| Slug | Categorie | -| --- | --- | -| `vergaderstukken_decentrale_overheden` | Vergaderstukken decentrale overheden | -| `subsidieverplichtingen_anders_dan_met_beschikking` | Subsidieverplichtingen zonder beschikking | -| `overige_besluiten_van_algemene_strekking` | Overige besluiten van algemene strekking | -| `organisatie_en_werkwijze` | Organisatie en werkwijze | -| `ontwerpen_van_wet_en_regelgeving_met_adviesaanvraag` | Ontwerpen van wet- en regelgeving | -| `onderzoeksrapporten` | Onderzoeksrapporten | -| `klachtoordelen` | Klachtoordelen | -| `wetten_en_algemeen_verbindende_voorschriften` | Wetten en algemeen verbindende voorschriften | -| `woo_verzoeken_en_besluiten` | Woo-verzoeken en -besluiten | -| `convenanten` | Convenanten | -| `agendas_en_besluitenlijsten_bestuurscolleges` | Agenda's en besluitenlijsten bestuurscolleges | -| `vergaderstukken_staten_generaal` | Vergaderstukken Staten-Generaal | -| `adviezen` | Adviezen | - -## Stap 1: Importeer het register - -OpenRegister heeft een `import/url`-endpoint dat een OpenAPI-bundel ophaalt en in één transactie omzet naar register, schemas en (optioneel) seed-objecten. - -```bash -curl -u admin:admin \ - -X POST "http://localhost:8080/index.php/apps/openregister/api/configurations/import/url" \ - -H "OCS-APIRequest: true" \ - -H "Content-Type: application/json" \ - -d '{ - "url": "https://raw.githubusercontent.com/ConductionNL/woo-website/main/website/static/oas/woo_register.json" - }' -``` - -Als alles goed gaat, krijg je terug: - -```json -{ - "success": true, - "message": "Configuration imported successfully from url", - "configurationId": 271, - "result": { - "registersCount": 1, - "schemasCount": 10, - "objectsCount": 0 - } -} -``` - -`registersCount: 1` en `schemasCount: 10` bevestigen dat alles is aangemaakt. De `configurationId` heb je later nodig als je de bron wilt re-syncen. - -## Stap 2: Controleer wat is aangemaakt - -Vraag de lijst van registers op en filter op slug `woo`: - -```bash -curl -u admin:admin \ - "http://localhost:8080/index.php/apps/openregister/api/registers" \ - -H "OCS-APIRequest: true" -``` - -In de respons vind je een entry zoals: - -```json -{ - "id": 924, - "uuid": "cd44ca36-e3de-4d6f-9af9-abbb49710c83", - "slug": "woo", - "title": "Woo", - "version": "1.0.1", - "description": "Woo-register met TOOI-informatiecategorieën", - "schemas": [1619, 1620, 1621, 1622, 1623, 1624, 1625, 1626, 1627, 1628] -} -``` - -De `id` is je interne register-id (924 in dit voorbeeld; bij jou kan het verschillen). De slug `woo` is stabiel en gebruik je verder in alle API-aanroepen. - -In de UI vind je het register onder **Apps → OpenRegister → Registers**: - -![Het Woo-register in de OpenRegister UI, met de tien TOOI-schemas in de zijkant](./02-or-woo-register-card.png) - -Klik op **View Details** in het Actions-menu van de kaart voor een statistiekenpaneel: - -![Het detailpaneel met statistieken voor het Woo-register: aantal objecten, logs, files en schemas](./04-or-woo-register-detail.png) - -## Stap 3: Bekijk de schemas - -De TOOI-schemas zijn metadata-schemas. Elk schema bevat zeven velden: - -| Veld | Type | Beschrijving | -| --- | --- | --- | -| `tooiCategorieNaam` | string (verplicht) | Naam van de categorie | -| `tooiCategorieId` | string | TOOI-id, bijvoorbeeld `c_fdaee95e` | -| `tooiCategorieUri` | string | Volledige TOOI-URI | -| `tooiThemaNaam` | string | Optioneel thema | -| `tooiThemaId` | string | Optioneel thema-id | -| `tooiThemaUri` | string | Optioneel thema-URI | -| `values` | array | Vrij in te vullen sleutel-waarde-paren | +In OpenRegister, open **Administration → Configurations** in the left menu. Click **Import Configuration**, then choose the **Import from URL** tab. Paste the Woo configuration URL and leave the synchronisation settings on their defaults. + +![The Import from URL tab in OpenRegister with the Woo configuration URL filled in](./01-import-from-url.png) + +Click **Import**. OpenRegister fetches the file and, in one transaction, creates the register, its schemas, and any seed objects. A confirmation appears when it finishes. + +## Step 2: Import the DCAT register -De `categorieId` en `categorieUri` zijn `const` in het schema. Je hoeft ze niet zelf te vullen. OpenRegister vult ze automatisch zodra je `tooiCategorieNaam` zet. +Repeat Step 1 with the DCAT configuration URL. Open **Import Configuration → Import from URL**, paste `https://conduction.nl/oas/dcat_register.json`, and click **Import**. -![De Schemas-pagina in OpenRegister](./03-or-tooi-schemas.png) +You now have two registers. Open data and Woo publications live side by side, each in its own register. -## Stap 4: Maak een eerste publicatie +## Step 3: See what was created -Test je register met één publicatie. We pakken de categorie `onderzoeksrapporten`: +Open **Data → Registers** in the left menu. The Woo register shows its ten TOOI schemas, one per information category. -```bash -curl -u admin:admin \ - -X POST "http://localhost:8080/index.php/apps/openregister/api/objects/woo/onderzoeksrapporten" \ - -H "OCS-APIRequest: true" \ - -H "Content-Type: application/json" \ - -d '{ - "tooiCategorieNaam": "onderzoeksrapporten" - }' -``` +![The Woo register card in OpenRegister, listing its ten TOOI schemas](./02-woo-register.png) -De respons toont onder andere: +The DCAT register shows its single `Dataset` schema. -```json -{ - "id": "3422e6cd-1ba5-478c-b842-8f206f9d7358", - "tooiCategorieNaam": "onderzoeksrapporten", - "tooiCategorieId": "c_fdaee95e", - "tooiCategorieUri": "https://identifier.overheid.nl/tooi/def/thes/kern/c_fdaee95e", - "@self": { - "register": "924", - "schema": "1624", - "uri": "http://localhost:8080/apps/openregister/api/objects/924/1624/3422e6cd-...", - "owner": "admin", - "created": "2026-05-07T05:12:24+00:00" - } -} -``` +![The DCAT register card in OpenRegister with its Dataset schema](./03-dcat-register.png) + +Each card has an **Actions** menu with **View Details** for a statistics panel: number of objects, files, logs, and schemas per register. + +## Step 4: Look at the schemas + +The Woo TOOI schemas are metadata schemas. Each holds the same seven fields: + +| Field | Type | Description | +| --- | --- | --- | +| `tooiCategorieNaam` | string (required) | Name of the category | +| `tooiCategorieId` | string | TOOI id, for example `c_fdaee95e` | +| `tooiCategorieUri` | string | Full TOOI URI | +| `tooiThemaNaam` | string | Optional theme | +| `tooiThemaId` | string | Optional theme id | +| `tooiThemaUri` | string | Optional theme URI | +| `values` | array | Free-form key-value pairs | -OpenRegister heeft de TOOI-id en URI zelf ingevuld. Het object heeft een UUID. Onthoud die UUID, want je hebt hem nodig om bestanden aan deze publicatie te koppelen. +`tooiCategorieId` and `tooiCategorieUri` are fixed per schema. OpenRegister fills them automatically once you set `tooiCategorieNaam`. -## Stap 5 (optioneel): Voeg eigen velden toe +The DCAT `Dataset` schema holds the DCAT-AP-NL dataset fields: `title`, `description`, `tags` (keywords), `category` (theme), `license`, `publicatiedatum`, `landingPage`, and `publisher`. A dataset's files become its distributions. -De canonieke schemas dekken de TOOI-metadata, niet de inhoud van de publicatie zelf. Voor titel, omschrijving, datum en verantwoordelijke organisatie maak je een eigen aanvullend schema, of breid je een bestaand schema uit. +To inspect a schema, open **Data → Schemas** and click any schema to see its fields. -Een minimale uitbreiding voor `onderzoeksrapporten` ziet er bijvoorbeeld zo uit: +## Step 5: Create your first object -```json -{ - "title": "Onderzoeksrapport publicatie", - "properties": { - "titel": { "type": "string", "maxLength": 255 }, - "omschrijving": { "type": "string", "maxLength": 2000 }, - "openbaarmakingsdatum": { "type": "string", "format": "date" }, - "verantwoordelijkeOrganisatie": { "type": "string" } - }, - "required": ["titel", "openbaarmakingsdatum"] -} -``` +Test a register with one object, without leaving the interface. -Je kunt zo'n schema via `POST /api/schemas` aanmaken en daarna in de Woo-register-configuratie toevoegen. Houd het zoveel mogelijk dichtbij de [DiWoo-metadata-standaard](https://standaarden.overheid.nl/diwoo) zodat je publicaties ook door [PLOOI](https://open.overheid.nl) en [woogle.nl](https://woogle.nl) opgepikt kunnen worden. +Open **Data → Registers**, open the Woo register, and pick the `onderzoeksrapporten` (Research reports) schema. Click **Add object**, set `tooiCategorieNaam` to `onderzoeksrapporten`, and save. OpenRegister fills in the TOOI id and URI for you and assigns the object a UUID. -## En nu +Do the same in the DCAT register: open the `Dataset` schema, click **Add object**, and give it a `title`, a `category`, and a `publicatiedatum`. You now have one Woo publication and one open-data dataset. -Je hebt: +Keep the UUIDs in mind. You need them in the next tutorial to attach files. -- Een werkend Woo-register met tien TOOI-schemas -- Eén testpublicatie met UUID -- Een patroon om eigen velden toe te voegen waar nodig +## And now -In de [vervolgtutorial koppel je bestanden aan deze publicatie](/academy/woo-bestanden-uploaden), van enkele PDF's tot grote videobestanden in chunks. +You have: -## Opruimen +- A Woo register with ten TOOI schemas +- A DCAT register with a Dataset schema +- One test publication and one test dataset +- Everything created through the interface, no command line -Als je het testregister wilt verwijderen: +In the [follow-up tutorial you attach files to a publication](/academy/woo-upload-files), from a few PDFs to large video files. -```bash -curl -u admin:admin \ - -X DELETE "http://localhost:8080/index.php/apps/openregister/api/registers/924" \ - -H "OCS-APIRequest: true" -``` +## Cleanup -Dit verwijdert het register, alle schemas en alle objecten. De configuratie-import zelf blijft bestaan, zodat je hem opnieuw kunt afspelen. +To remove a register, open **Data → Registers**, open the register's **Actions** menu, and choose **Delete**. This removes the register, its schemas, and its objects. The configuration import remains, so you can replay it later. diff --git a/academy/2026-05-10-build-an-app-tutorial-1-scaffold/index.mdx b/academy/2026-05-10-build-an-app-tutorial-1-scaffold/index.mdx new file mode 100644 index 000000000..542ffafc4 --- /dev/null +++ b/academy/2026-05-10-build-an-app-tutorial-1-scaffold/index.mdx @@ -0,0 +1,229 @@ +--- +slug: build-an-app-tutorial-1-scaffold +title: "Build a Nextcloud app on the Conduction stack — Part 1: Scaffold" +contentType: tutorial +heroIcon: blocks +authors: [conduction] +date: 2026-05-10 +summary: Clone the Conduction app template, rename it to PetStore, build, enable, and see the canonical app chassis. The first hands-on part of the nine-part app-building series, using the OpenAPI Pet Store as the running domain. +tags: [App development, Nextcloud, OpenRegister, nextcloud-vue, Tutorial series] +apps: [openregister] +durationMinutes: 20 +series: build-an-app-tutorial +partNumber: 1 +--- + +import { + Outcomes, + Outcome, + Prerequisites, + PrerequisiteItem, + NextSteps, + NextStep, + HexCard, + AppMock, + Troubleshooting, + TroubleshootingItem, +} from '@conduction/docusaurus-preset/components'; + +This is **Part 1 of the app-building tutorial series**, the hands-on chassis lap. If you haven't read [Part 0: Three paths, one curriculum](/academy/build-an-app-tutorial-0-three-paths) yet, that's the orientation that explains *why* we build on this stack rather than raw Nextcloud, and how the manifest and `@conduction/nextcloud-vue` work as two intertwined surfaces. Part 1 picks up after that decision is made. + +You'll build **PetStore**, a Nextcloud app on top of the OpenAPI Pet Store domain (pets, orders, categories, tags) on the full Conduction Nextcloud stack. The end product: add pets to the store, place orders, see order ship-dates in your Nextcloud Calendar, and surface category-specific care guides from xWiki right next to each pet. Every piece reuses what `@conduction/nextcloud-vue` and OpenRegister already give you for free. + +In Part 1 you scaffold the app, rename it, build it, and see the canonical app chassis on screen. No data, no integrations yet. We get the bones right first. + +{/* truncate */} + + + A working PetStore app installed in your local Nextcloud, navigable from the app shelf. + A clear mental model of the canonical Conduction app chassis: topbar, left navigation, main column, page header, sidebar. + Confidence with the nextcloud-app-template rename ritual: id, namespace, the few files you have to touch, the rest you can leave for later. + A clean foundation for Part 2, where the chassis starts holding real data. + + +## What we're building, across the series + +| Part | Title | Adds | +|---|---|---| +| 0 | [Three paths, one curriculum](/academy/build-an-app-tutorial-0-three-paths) | The orientation lap. Which path to pick and why | +| 1 | Scaffold (you're here) | Empty app shell, chassis visible, navigable | +| 2 | [Schemas + manifest](/academy/build-an-app-tutorial-2-schemas-manifest) | Pet, order, category schemas, full CRUD, dashboard | +| 3 | [Schema-driven integrations](/academy/build-an-app-tutorial-3-calendar) | Order ship-dates appear in NC Calendar via the OpenRegister calendar provider | +| 4 | [External knowledge + ship](/academy/build-an-app-tutorial-4-knowledge-and-ship) | xWiki care-guides via Integriq, sidebar tab, package, publish | +| 5 | [Advanced manifest features](/academy/build-an-app-tutorial-5-advanced-manifest) | actionToggles, fieldWidgets, public-mode pages, the other page types | +| 6 | [Integrate](/academy/build-an-app-tutorial-6-integrate) | Cross-register reads, Integriq source/sync, two-way webhook back into OR | +| 7 | [The nc-vue component library](/academy/build-an-app-tutorial-7-nc-vue) | Capability-driven tour: forms, dialogs, status, filtering, composables, theming | +| 8 | [Document and showcase](/academy/build-an-app-tutorial-8-document-and-showcase) | Stand up a docs site for the app you built, with screen mocks + Playwright capture | + +Same shape, two repos: + +- **App source:** [`ConductionNL/petstore`](https://github.com/ConductionNL/petstore). The actual Nextcloud app you're forking from the template. +- **Tutorial source:** [`ConductionNL/conduction-website`](https://github.com/ConductionNL/conduction-website). The post you're reading right now. + +:::note Repo creation +At the time of writing, `ConductionNL/petstore` is the canonical reference repo for this tutorial. If it doesn't exist yet on GitHub, fork from the template into your own org as shown in Step 1; the tutorial body uses `petstore` throughout as the example slug. +::: + + + A running Nextcloud (28+) with admin access. If you don't have one, follow Nextcloud lokaal draaien met Docker first. + OpenRegister installed and enabled in that Nextcloud (Apps → search "OpenRegister" → Download). + Node.js 20+ and PHP 8.1+ on your host, plus Composer and the GitHub CLI (gh). The template ships with PHPCS, Psalm, PHPStan, and ESLint configs ready to go. + A GitHub account so you can fork the template into your own org (or ConductionNL if you have access). + + +## Step 1: Use the template + +[`ConductionNL/nextcloud-app-template`](https://github.com/ConductionNL/nextcloud-app-template) is a repository template. The "Use this template" button on GitHub gives you a fresh repo with the full starter kit: Vue 2 + Pinia frontend, PHP backend, OpenRegister wiring, quality pipeline, OpenSpec scaffolding, CI workflows. + +```bash title="Pick a name. We're calling ours petstore." +gh repo create ConductionNL/petstore \ + --template ConductionNL/nextcloud-app-template \ + --public \ + --description "Pet store: pets, orders, categories on the Conduction stack" +``` + +Then clone it next to your other Nextcloud apps. Most workspaces keep them in a single `apps-extra/` directory the dev container mounts. + +```bash +cd /path/to/your/nextcloud/workspace/apps-extra +git clone https://github.com/ConductionNL/petstore.git +cd petstore +``` + +You now have a directory with the full template content under the new name. Nothing inside has been renamed yet. The directory is `petstore/`, but every file still says `app-template`, `AppTemplate`, `OCA\AppTemplate`. Step 2 fixes that. + +## Step 2: Rename the app + +Nextcloud requires three identifiers to line up: + +- The directory name (`petstore/`) ✅ already +- The `` in `appinfo/info.xml` +- The PHP namespace (`OCA\AppTemplate` → `OCA\PetStore`) + +Plus a handful of supporting files reference the old id. The full list is small enough to do by hand, and doing it by hand is the right move. Project memory: **never use `sed` or scripted edits on code files**. Use a real editor with project-aware refactoring, or just read each file once before you change it. + +### 2a. The boot-critical files + +These are the ones that prevent Nextcloud from booting the app at all. Edit each with `Find & replace all` in your editor (`AppTemplate` → `PetStore` and `app-template` → `petstore`): + +| File | Replace | +|---|---| +| `appinfo/info.xml` | ``, ``, ``, ``, `` paths | +| `composer.json` | `name`, `description`, the `psr-4` autoload prefix | +| `package.json` | `name` | +| `webpack.config.js` | the `appId` constant | +| `templates/index.php` and `templates/settings/admin.php` | `OCA\AppTemplate` → `OCA\PetStore`, the `data-*` element id | +| `lib/AppInfo/Application.php` | namespace, `APP_ID` constant, docblock | +| Every other PHP file in `lib/` | `namespace OCA\AppTemplate\…` → `namespace OCA\PetStore\…`, every `use OCA\AppTemplate\…` import, every docblock | +| `appinfo/routes.php` | docblock only | +| Every Vue file in `src/` | the `t('app-template', '…')` translation namespace becomes `t('petstore', '…')` | +| `src/router/index.js` | `generateUrl('/apps/app-template')` → `generateUrl('/apps/petstore')` | +| `src/store/store.js`, `src/store/modules/settings.js` | `'/apps/app-template/api/settings'` → `'/apps/petstore/api/settings'`, fallback register slug | +| `src/settings.js` | `loadTranslations('app-template', …)` and the `#app-template-settings` mount selector | +| `lib/Settings/app_template_register.json` | rename to `lib/Settings/petstore_register.json` AND change the `"app": "app-template"` field inside it to `"app": "petstore"` (it's easy to miss after the file rename) | +| `appinfo/info.xml` `` block | the template already ships this block. Update the FQNs inside from `OCA\AppTemplate\Repair\InitializeSettings` and `InitializeActions` to the `OCA\PetStore\Repair\…` equivalents | +| `appinfo/info.xml` `` block | update the `OCA\AppTemplate\Settings\AdminSettings` FQN to the `OCA\PetStore\…` equivalent | + +### 2b. The "you can do later" files + +Tests (`tests/Unit/AppTemplateTest.php`, the integration Postman collection), `phpcs.xml` / `phpmd.xml` / `REUSE.toml` headers, `README.md`, and the `.github/` or `.forgejo/` workflow inputs. They reference the template id in metadata only; the app boots fine without them touched. Fix them when you set up CI. + +### 2c. One Nextcloud-version compatibility nip + +The template's `lib/AppInfo/Application.php` also registers the repair step at runtime, in addition to the `` block you updated in 2a: + +```php title="lib/AppInfo/Application.php. The duplicate runtime registration" +$context->registerRepairStep(InitializeSettings::class); +``` + +`registerRepairStep()` is missing from `IRegistrationContext` on a few Nextcloud builds (you'll see `Call to undefined method` in `nextcloud.log`). Since `appinfo/info.xml` already has the equivalent `` block, the runtime registration is redundant. Drop the `registerRepairStep()` line and the `use OCA\PetStore\Repair\InitializeSettings;` import from `lib/AppInfo/Application.php`. + +## Step 3: Build and enable + +The template ships its build output uncommitted. So the first thing to do in a fresh clone is install dependencies and build the JS bundles, otherwise the app UI is just a blank `
`. + +```bash +composer install --no-dev +composer dump-autoload +npm install +npm run build +``` + +:::tip Don't use `--legacy-peer-deps` +A plain `npm install` resolves the template's peer dependencies cleanly and the build succeeds out of the box. `--legacy-peer-deps` skips peer resolution, which has two bad consequences: webpack-vue-config's peers (`sass`, `node-polyfill-webpack-plugin`, `terser-webpack-plugin`, `vue-loader`) silently don't install and the build fails with `Cannot find module 'node-polyfill-webpack-plugin'`, AND the resolved transitive dependency graph leaves webpack's module-concatenation optimisation confused on the `@nextcloud/axios` ESM bundle (`Cannot read properties of undefined (reading 'module')`). Skip the flag. +::: + +Then make sure your Nextcloud container can see the directory. In a typical Docker setup the `apps-extra/` host directory is mounted at `/var/www/html/custom_apps/` in the container; if your compose file uses one bind mount per app, add a line for `petstore` and restart. Otherwise: + +```bash title="Quick path: tar + copy + chown (excludes node_modules to keep the transfer small)" +tar --exclude=node_modules --exclude=vendor -czf /tmp/petstore.tgz petstore/ +docker cp /tmp/petstore.tgz nextcloud:/tmp/petstore.tgz +docker exec -u root nextcloud bash -c 'cd /var/www/html/custom_apps && tar xzf /tmp/petstore.tgz && chown -R www-data:www-data petstore && rm /tmp/petstore.tgz' +``` + +:::tip Why tar + cp instead of `docker cp ./petstore …` directly +A fresh `node_modules/` can be 400+ MB. A direct `docker cp` on the whole directory takes minutes per iteration and silently hangs without progress output. The container doesn't need `node_modules/` because Nextcloud only serves the built `js/` bundle. Tar with the excludes keeps each rebuild cycle under 10 seconds. +::: + +Now enable: + +```bash +docker exec nextcloud php occ app:enable petstore +``` + +You should see (the version comes from `appinfo/info.xml`, usually `0.1.x` on a fresh template): + +``` +petstore 0.1.2 enabled +``` + +Open `http://localhost:8080/apps/petstore/` and log in. You see a placeholder dashboard with sample KPI cards, two empty panels, three nav items (Dashboard / Items / Documentation) and a Settings entry pinned to the bottom of the rail. That's the chassis. + +## The chassis: the whole point of Part 1 + +Every Conduction app (PetStore, OpenRegister, OpenCatalogi, Dossiq, LaunchPad, the dozen others) looks the same way on first sight. Same five structural pieces, same place, same behaviour. That recognisability is what `@conduction/nextcloud-vue` enforces: a user who learnt one app navigates the next one without docs. + +The chassis is **one shape, five atoms**, the same five structural pieces in the same place, app after app. + + + +The diagram above shows the LaunchPad reference (one of the canonical fleet variants) with the chassis you just enabled rendered in design tokens. Your fresh PetStore install lays out the same way: topbar, left nav, main column with a page header, optional right sidebar. + +In Part 2 you'll learn how `manifest.json` plus a JSON schema fills these atoms with real data. The placeholder `Items` nav entry, the empty dashboard, and the "article" schema you saw on screen will all become **pets**, **orders**, and a real inventory dashboard, without you laying out a single atom by hand. + +## Troubleshooting + + + + The js/ build output is not committed. Run npm run build before you enable the app, or after every frontend change. The blank-rectangle symptom always means "the bundle is missing". + + + Nextcloud requires the directory name to exactly match the <id> in appinfo/info.xml. If you cloned the repo under a different name, either rename the directory or add a relative symlink: ln -s your-clone-name ../petstore. + + + Step 2c covers this. The fix is to move the repair-step registration from lib/AppInfo/Application.php into appinfo/info.xml's <repair-steps> block. + + + Run composer dump-autoload from the app directory. PHP's classmap caches the old OCA\AppTemplate map until you regenerate it. + + + +## What's next + + + + + + diff --git a/academy/2026-05-10-deskdesk-tutorial-1-scaffold/index.mdx b/academy/2026-05-10-deskdesk-tutorial-1-scaffold/index.mdx deleted file mode 100644 index 50889b464..000000000 --- a/academy/2026-05-10-deskdesk-tutorial-1-scaffold/index.mdx +++ /dev/null @@ -1,216 +0,0 @@ ---- -slug: deskdesk-tutorial-1-scaffold -title: "Build a Nextcloud app on the Conduction stack — Part 1: Scaffold" -contentType: tutorial -authors: [conduction] -date: 2026-05-10 -summary: Clone the Conduction app template, rename it to DeskDesk, build, enable, and see the canonical app chassis. The first of four parts that build a working desk-booking app on the full Conduction stack. -tags: [App development, Nextcloud, OpenRegister, nextcloud-vue, Tutorial series] -apps: [openregister] -durationMinutes: 20 -series: deskdesk-tutorial -partNumber: 1 ---- - -import { - Outcomes, - Outcome, - Prerequisites, - PrerequisiteItem, - AtomZones, - NextSteps, - NextStep, - HexCard, -} from '@conduction/docusaurus-preset/components'; - -This is **Part 1 of a four-part tutorial** that builds **DeskDesk**, a flexible desk-booking app for an open-office environment, on the full Conduction Nextcloud stack. The end product: pick a desk on a floor, book a slot, see your booking in your Nextcloud Calendar, and surface zone-specific knowledge articles from xWiki right next to the booking. Every piece reuses what `@conduction/nextcloud-vue` and OpenRegister already give you for free. - -In Part 1 you scaffold the app, rename it, build it, and see the canonical app chassis on screen. No data, no integrations yet. We get the bones right first. - -{/* truncate */} - - - A working DeskDesk app installed in your local Nextcloud, navigable from the app shelf. - A clear mental model of the canonical Conduction app chassis: topbar, left navigation, main column, page header, sidebar. - Confidence with the nextcloud-app-template rename ritual: id, namespace, the few files you have to touch, the rest you can leave for later. - A clean foundation for Part 2, where the chassis starts holding real data. - - -## What we're building, across all four parts - -| Part | Title | Adds | -|---|---|---| -| 1 | Scaffold (you're here) | Empty app shell, chassis visible, navigable | -| 2 | Schemas + manifest | Desk and booking schemas, full CRUD, dashboard | -| 3 | Schema-driven integrations | Bookings appear in NC Calendar via the OpenRegister calendar provider | -| 4 | External knowledge + ship | xWiki source via OpenConnector, sidebar tab, package, publish | - -Same shape, two repos: - -- **App source:** [`ConductionNL/deskdesk`](https://github.com/ConductionNL/deskdesk) — the actual Nextcloud app you're forking from the template. -- **Tutorial source:** [`ConductionNL/conduction-website`](https://github.com/ConductionNL/conduction-website) — the post you're reading right now. - - - A running Nextcloud (28+) with admin access. If you don't have one, follow Nextcloud lokaal draaien met Docker first. - OpenRegister installed and enabled in that Nextcloud (Apps → search "OpenRegister" → Download). - Node.js 20+ and PHP 8.1+ on your host, plus Composer and the GitHub CLI (gh) — the template ships with PHPCS, Psalm, PHPStan, and ESLint configs ready to go. - A GitHub account so you can fork the template into your own org (or ConductionNL if you have access). - - -## Step 1: Use the template - -[`ConductionNL/nextcloud-app-template`](https://github.com/ConductionNL/nextcloud-app-template) is a GitHub *repository template*. The "Use this template" button gives you a fresh repo with the full starter kit: Vue 2 + Pinia frontend, PHP backend, OpenRegister wiring, quality pipeline, OpenSpec scaffolding, GitHub Actions. - -```bash title="Pick a name. We're calling ours deskdesk." -gh repo create ConductionNL/deskdesk \ - --template ConductionNL/nextcloud-app-template \ - --public \ - --description "Flexible desk booking for open-office environments" -``` - -Then clone it next to your other Nextcloud apps. Most workspaces keep them in a single `apps-extra/` directory the dev container mounts. - -```bash -cd /path/to/your/nextcloud/workspace/apps-extra -git clone https://github.com/ConductionNL/deskdesk.git -cd deskdesk -``` - -You now have a directory with the full template content under the new name. Nothing inside has been renamed yet — the directory is `deskdesk/`, but every file still says `app-template`, `AppTemplate`, `OCA\AppTemplate`. Step 2 fixes that. - -## Step 2: Rename the app - -Nextcloud requires three identifiers to line up: - -- The directory name (`deskdesk/`) ✅ already -- The `` in `appinfo/info.xml` -- The PHP namespace (`OCA\AppTemplate` → `OCA\DeskDesk`) - -Plus a handful of supporting files reference the old id. The full list is small enough to do by hand, and doing it by hand is the right move. Project memory: **never use `sed` or scripted edits on code files** — use a real editor with project-aware refactoring, or just read each file once before you change it. - -### 2a. The boot-critical files - -These are the ones that prevent Nextcloud from booting the app at all. Edit each with `Find & replace all` in your editor — `AppTemplate` → `DeskDesk` and `app-template` → `deskdesk`: - -| File | Replace | -|---|---| -| `appinfo/info.xml` | ``, ``, ``, ``, `` paths | -| `composer.json` | `name`, `description`, the `psr-4` autoload prefix | -| `package.json` | `name` | -| `webpack.config.js` | the `appId` constant | -| `templates/index.php` and `templates/settings/admin.php` | `OCA\AppTemplate` → `OCA\DeskDesk`, the `data-*` element id | -| `lib/AppInfo/Application.php` | namespace, `APP_ID` constant, docblock | -| Every other PHP file in `lib/` | `namespace OCA\AppTemplate\…` → `namespace OCA\DeskDesk\…`, every `use OCA\AppTemplate\…` import, every docblock | -| `appinfo/routes.php` | docblock only | -| Every Vue file in `src/` | the `t('app-template', '…')` translation namespace becomes `t('deskdesk', '…')` | -| `src/router/index.js` | `generateUrl('/apps/app-template')` → `generateUrl('/apps/deskdesk')` | -| `src/store/store.js`, `src/store/modules/settings.js` | `'/apps/app-template/api/settings'` → `'/apps/deskdesk/api/settings'`, fallback register slug | -| `src/settings.js` | `loadTranslations('app-template', …)` and the `#app-template-settings` mount selector | -| `lib/Settings/app_template_register.json` | rename to `lib/Settings/deskdesk_register.json` (and the `app` field inside it) | - -### 2b. The "you can do later" files - -Tests (`tests/Unit/AppTemplateTest.php`, the integration Postman collection), `phpcs.xml` / `phpmd.xml` / `REUSE.toml` headers, and the `.github/` workflow inputs. They reference the template id in metadata only; the app boots fine without them touched. Fix them when you set up CI. - -### 2c. One Nextcloud-version compatibility nip - -The template's `lib/AppInfo/Application.php` registers a repair step at runtime: - -```php title="Original — fails on some Nextcloud builds" -$context->registerRepairStep(InitializeSettings::class); -``` - -`registerRepairStep()` is missing from `IRegistrationContext` on a few Nextcloud builds (you'll see `Call to undefined method` in `nextcloud.log`). Move it to `appinfo/info.xml` instead — same effect, more portable: - -```xml title="appinfo/info.xml — append after the block" - - - OCA\DeskDesk\Repair\InitializeSettings - - - OCA\DeskDesk\Repair\InitializeSettings - - -``` - -Then drop the `registerRepairStep()` line and the `use OCA\DeskDesk\Repair\InitializeSettings;` import from `lib/AppInfo/Application.php`. - -## Step 3: Build and enable - -The template ships its build output uncommitted. So the first thing to do in a fresh clone is install dependencies and build the JS bundles, otherwise the app UI is just a blank `
`. - -```bash -composer install --no-dev -composer dump-autoload -npm install --legacy-peer-deps -npm run build -``` - -Then make sure your Nextcloud container can see the directory. In a typical Docker setup the `apps-extra/` host directory is mounted at `/var/www/html/custom_apps/` in the container; if your compose file uses one bind mount per app, add a line for `deskdesk` and restart. Otherwise: - -```bash title="Quick path: copy + chown for a one-shot install" -docker cp ./deskdesk nextcloud:/var/www/html/custom_apps/ -docker exec -u root nextcloud chown -R www-data:www-data /var/www/html/custom_apps/deskdesk -``` - -Now enable: - -```bash -docker exec nextcloud php occ app:enable deskdesk -``` - -You should see: - -``` -deskdesk 0.1.0 enabled -``` - -Open `http://localhost:8080/apps/deskdesk/` and log in. You see a placeholder dashboard with sample KPI cards, two empty panels, three nav items (Dashboard / Items / Documentation) and a Settings entry pinned to the bottom of the rail. That's the chassis. - -## The chassis: the whole point of Part 1 - -Every Conduction app — DeskDesk, OpenRegister, OpenCatalogi, Procest, MyDash, the dozen others — looks the same way on first sight. Same five structural pieces, same place, same behaviour. That recognisability is what `@conduction/nextcloud-vue` enforces: a user who learnt one app navigates the next one without docs. - -The chassis is **one shape, five atoms**. Each card below focuses on one atom: the focused zone is at full opacity with a KNVB-orange outline, the rest fades to 25% so you see *where* the atom sits. - - - -In Part 2, you'll learn how `manifest.json` plus a JSON schema fills these atoms with real data. The placeholder `Items` nav entry, the empty dashboard, and the "article" schema you saw on screen will all become **desks**, **bookings**, and a real occupancy dashboard — without you laying out a single atom by hand. - -## Troubleshooting - - - The js/ build output is not committed. Run npm run build before you enable the app, or after every frontend change. The blank-rectangle symptom always means "the bundle is missing". - - - - Nextcloud requires the directory name to exactly match the <id> in appinfo/info.xml. If you cloned the repo under a different name, either rename the directory or add a relative symlink: ln -s your-clone-name ../deskdesk. - - - - Step 2c covers this. The fix is to move the repair-step registration from lib/AppInfo/Application.php into appinfo/info.xml's <repair-steps> block. - - - - Run composer dump-autoload from the app directory. PHP's classmap caches the old OCA\AppTemplate map until you regenerate it. - - -## What's next - - - - - - diff --git a/academy/2026-05-11-nextcloud-app-store-publiceren/index.mdx b/academy/2026-05-11-nextcloud-app-store-publiceren/index.mdx new file mode 100644 index 000000000..586a62b3f --- /dev/null +++ b/academy/2026-05-11-nextcloud-app-store-publiceren/index.mdx @@ -0,0 +1,404 @@ +--- +slug: publish-to-nextcloud-app-store +title: Een Nextcloud-app publiceren in de App Store +contentType: tutorial +heroIcon: store +authors: [conduction] +date: 2026-05-11 +summary: Van nul naar gepubliceerde app in de Nextcloud App Store. CSR aanvragen, signen, registreren, releasen, en de hele release-pijplijn automatiseren via GitHub Actions. +tags: [Nextcloud, App Store, Publishing, OpenSSL, GitHub Actions, Code signing] +durationMinutes: 60 +--- + +import {Outcomes, Outcome, Prerequisites, PrerequisiteItem, ContactCta} from '@conduction/docusaurus-preset/components'; + +Een Nextcloud-app in de [App Store](https://apps.nextcloud.com) zetten is geen knop, maar een keten. Je hebt een certificaat nodig om je code te signen, dat certificaat krijg je via een pull request op een Nextcloud-repo, en pas met dat certificaat mag je je app registreren en releases uploaden. De officiële documentatie beschrijft elke stap, maar verspreid over vier pagina's. Deze tutorial loopt het in één keer door, van licentiekeuze tot een GitHub Action die elke release automatisch publiceert. + +{/* truncate */} + + + Een CSR (certificate signing request) genereren met OpenSSL en indienen via PR op nextcloud/app-certificate-requests. + Je app signen met occ integrity:sign-app zodat de Nextcloud-integriteitscheck hem accepteert. + De app registreren op apps.nextcloud.com en de eerste release uploaden. + Releases automatiseren via GitHub Actions, zodat een git tag de App Store update. + Een procedure voor het scenario waarin je je private key kwijtraakt. + + + + Een werkende Nextcloud-app op je laptop. Geen app? Begin bij Build a Nextcloud app on the Conduction stack — Part 1: Scaffold. + Een lokale Nextcloud waarop je de app kunt installeren en signen. Zie Nextcloud lokaal draaien met Docker. + OpenSSL (standaard op macOS en Linux, op Windows via Git Bash of WSL). + Een GitHub-account met gh CLI geconfigureerd. + Toegang tot de occ-CLI van je lokale Nextcloud (binnen Docker via docker compose exec --user www-data nextcloud php occ ...). + + +In de voorbeelden noemen we de app `myapp`. Vervang dat overal door je eigen app ID. We slaan certificaten op in `~/.nextcloud/certificates/`, dezelfde plek die de officiële release-automation gebruikt. + +## Stap 1: Voorbereiding + +Drie dingen op orde brengen voor je iets indient. + +### 1a. Licentie + +De Nextcloud App Store accepteert alleen apps onder een [OSI-goedgekeurde](https://opensource.org/licenses) open-sourcelicentie. In de praktijk is dat **AGPL-3.0-or-later** voor het overgrote deel van het ecosysteem, inclusief alle Conduction-apps. Leg de licentie vast in twee plaatsen: + +- Een `LICENSE`-bestand in de repo-root met de volledige tekst. +- De ``-tag in `appinfo/info.xml` (let op de Britse spelling, dat is geen typo). + +```xml title="appinfo/info.xml" +agpl +``` + +### 1b. App ID-regels + +Het `` in `appinfo/info.xml` is je identifier in elk Nextcloud-systeem en in de App Store. Een paar harde regels: + +- Lowercase, alleen `a-z`, `0-9` en underscores. Geen streepjes, geen hoofdletters. +- Moet overeenkomen met de naam van de directory in `custom_apps/` en met de PHP-namespace prefix (`OCA\Myapp`). +- Eenmaal in de App Store geregistreerd kun je hem niet hernoemen; alleen overdragen aan een andere eigenaar. + +Kies dus iets dat je drie jaar later nog logisch vindt. `myapp` is alleen oké voor de tutorial. + +### 1c. Code checker + +Voor je iets richting App Store stuurt, draai de officiële code checker tegen je app. Die controleert dingen die in CI-time stuk zouden gaan: ongeldige `info.xml`, ontbrekende velden, verboden gebruik van private API's. + +```bash +docker compose exec --user www-data nextcloud php occ app:check-code myapp +``` + +Errors fix je voor je verder gaat. Warnings mag je negeren, maar reviewers van de App Store kunnen je erop wijzen — beter nu opruimen. + +## Stap 2: Account op apps.nextcloud.com + +Ga naar [apps.nextcloud.com](https://apps.nextcloud.com), klik **Sign up**, en gebruik bij voorkeur het GitHub-account waaronder de app-repo staat. Bevestig het e-mailadres dat je opgeeft — alle App Store-meldingen (release goedgekeurd, kritieke review-comments) gaan daarheen. + +Twee dingen die je meteen wilt instellen: + +- **API-token** voor de release-automation. Te vinden op [apps.nextcloud.com/account/token](https://apps.nextcloud.com/account/token). We hebben hem in stap 8 nodig. +- **Public profile**, met op zijn minst je naam en organisatie. Dat is wat eindgebruikers zien bij de app. + +## Stap 3: CSR genereren met OpenSSL + +De CSR (certificate signing request) is een onderdeel van een sleutelpaar dat *jij* genereert en waarvan je het publieke deel naar Nextcloud stuurt. Nextcloud signeert dat tot een certificaat (`.crt`) waarmee je je app mag signen. De private key (`.key`) blijft bij jou — als die uitlekt, kan iemand anders fake releases van jouw app uploaden. + +Maak een vaste plek voor certificaten en genereer het paar: + +```bash +mkdir -p ~/.nextcloud/certificates +cd ~/.nextcloud/certificates + +openssl req -nodes -newkey rsa:4096 \ + -keyout myapp.key \ + -out myapp.csr \ + -subj "/CN=myapp" +``` + +Drie files horen hier eindelijk te ontstaan, maar nu pas twee: + +| File | Wat | Geheim? | +| --- | --- | --- | +| `myapp.key` | Private key, RSA 4096 | Ja. Niet committen, niet delen. | +| `myapp.csr` | Certificate signing request | Nee, dit gaat zo naar Nextcloud. | +| `myapp.crt` | Signed certificate (komt na stap 4) | Nee, publiek. | + +Beveilig de key meteen: + +```bash +chmod 600 ~/.nextcloud/certificates/myapp.key +``` + +Maak ook nu al een back-up van `myapp.key` op een aparte, vertrouwde plek (password manager, vault, encrypted USB). Als die file weg is, kun je niet meer signen — zie de [verloren-key-procedure](#verloren-key-scenario) onderaan. + +## Stap 4: CSR indienen via PR + +Het signen door Nextcloud gebeurt via een merge op de publieke repo [`nextcloud/app-certificate-requests`](https://github.com/nextcloud/app-certificate-requests). De structuur is strikt: per app één map met daarin één `.csr` met exact dezelfde naam. + +``` +nextcloud/app-certificate-requests/ +└── myapp/ + └── myapp.csr +``` + +Doe het via de CLI of via de GitHub-webinterface — beide werken. Met `gh`: + +```bash +gh repo fork nextcloud/app-certificate-requests --clone +cd app-certificate-requests +git checkout -b add-myapp + +mkdir myapp +cp ~/.nextcloud/certificates/myapp.csr myapp/myapp.csr + +git add myapp/myapp.csr +git commit -m "Add CSR for myapp" +git push -u origin add-myapp + +gh pr create \ + --title "Add CSR for myapp" \ + --body "Source: https://github.com/your-org/myapp" +``` + +De PR-body hoeft niets bijzonders te bevatten. Een link naar de app-source is geen verplichting, wel een courtesy — het scheelt reviewers een zoekslag. + +### Wachten en certificate ophalen + +Een Nextcloud-maintainer merged de PR meestal binnen een paar werkdagen. Daarna staat het bestand `myapp/myapp.crt` in dezelfde repo, in dezelfde map als jouw CSR. Trek het op: + +```bash +curl -o ~/.nextcloud/certificates/myapp.crt \ + https://raw.githubusercontent.com/nextcloud/app-certificate-requests/master/myapp/myapp.crt + +chmod 644 ~/.nextcloud/certificates/myapp.crt +``` + +Verifieer dat het certificaat bij jouw key hoort — beide MD5-hashes moeten gelijk zijn: + +```bash +openssl rsa -modulus -noout -in ~/.nextcloud/certificates/myapp.key | openssl md5 +openssl x509 -modulus -noout -in ~/.nextcloud/certificates/myapp.crt | openssl md5 +``` + +Als de hashes verschillen, hoort dit certificaat bij een andere key — niet aan jezelf je app proberen te signen, dat faalt sowieso. Open een issue op de cert-repo. + +## Stap 5: App signen met `occ integrity:sign-app` + +`occ integrity:sign-app` leest je app-directory, berekent SHA-512 hashes per file, signeert het geheel met je private key, en schrijft het resultaat naar `appinfo/signature.json` in de app. Zonder die file weigert de App Store je upload. + +Vanuit de root van je lokale Nextcloud (of via `docker compose exec` als je in Docker draait): + +```bash +docker compose exec --user www-data nextcloud php occ integrity:sign-app \ + --path=/var/www/html/custom_apps/myapp \ + --privateKey=/path/in/container/to/myapp.key \ + --certificate=/path/in/container/to/myapp.crt +``` + +Drie flags: + +- `--path`: de app-directory **inside** de Nextcloud-installatie (`apps/`, `custom_apps/`, of een aangemounte path). +- `--privateKey`: pad naar je `.key`-file. Als je in Docker werkt, moet die file inside de container zichtbaar zijn — een bind-mount van `~/.nextcloud/certificates` naar `/certs` in `docker-compose.yml` is de simpelste truc. +- `--certificate`: pad naar je `.crt`-file, idem. + +Na een succesvolle run staat er een `appinfo/signature.json` in je app. Die file moet je **committen** naar de app-repo — hij hoort bij de release. + +Voor lokale ontwikkeling kun je apps draaien zonder signature.json (Nextcloud klaagt in de logs maar boot ze wel). Voor publicatie is hij verplicht. + +## Stap 6: App registreren op de App Store + +Eénmalig per app: meld hem aan op de App Store. Daarvoor heb je twee dingen nodig: het complete certificaat en een **signature van de app-id** met je private key. + +Genereer de signature van de app-id: + +```bash +echo -n "myapp" | openssl dgst -sha512 \ + -sign ~/.nextcloud/certificates/myapp.key \ + | openssl base64 +``` + +De output is een lange base64-string, allemaal op één regel — kopieer 'm in z'n geheel. + +Ga naar [apps.nextcloud.com/developer/apps/new](https://apps.nextcloud.com/developer/apps/new) en vul in: + +| Veld | Waarde | +| --- | --- | +| **Certificate** | De volledige inhoud van `myapp.crt`, inclusief de `-----BEGIN CERTIFICATE-----` en `-----END CERTIFICATE-----` regels. | +| **Signature** | De base64-string die je hierboven genereerde. | + +Klik **Register**. De backend verifieert: (a) dat het certificaat door Nextcloud is gesigned, (b) dat de signature klopt met de app-id en de public key uit het certificaat. Vanaf nu ben jij eigenaar van de app-id `myapp` op de App Store. + +Een app overdragen aan iemand anders kan via [apps.nextcloud.com/account/transfer-apps](https://apps.nextcloud.com/account/transfer-apps) — je ontgrendelt 'm daar, de nieuwe eigenaar registreert opnieuw met hun eigen certificaat en signature. + +## Stap 7: Release uploaden + +Een release in de App Store is geen file-upload — het is een URL plus een signature. De App Store haalt je tarball zelf op vanaf een publieke URL (typisch een GitHub Release asset), en valideert de signature tegen jouw certificate. + +### 7a. Tarball bouwen + +Bouw je app zoals voor productie. Voor de meeste Conduction-apps: + +```bash +cd /path/to/your/myapp +make appstore +``` + +Het `Makefile` uit `nextcloud-app-template` schrijft `build/artifacts/appstore/myapp.tar.gz` met daarin de app-directory, gestripped van dev-files (`node_modules/`, `tests/`, `.git/`, et cetera). + +### 7b. Tarball publiceren + +Upload de tarball naar een publiek bereikbare HTTPS-URL. GitHub Releases is de standaard: + +```bash +gh release create v1.0.0 \ + build/artifacts/appstore/myapp.tar.gz \ + --title "v1.0.0" \ + --notes "First release." +``` + +De URL die je nodig hebt heeft de vorm: + +``` +https://github.com/your-org/myapp/releases/download/v1.0.0/myapp.tar.gz +``` + +### 7c. Signature van de tarball + +```bash +openssl dgst -sha512 \ + -sign ~/.nextcloud/certificates/myapp.key \ + build/artifacts/appstore/myapp.tar.gz \ + | openssl base64 +``` + +Weer een base64-string op één regel. + +### 7d. Release indienen + +Via de webinterface ([apps.nextcloud.com/developer/apps/releases/new](https://apps.nextcloud.com/developer/apps/releases/new)): + +| Veld | Waarde | +| --- | --- | +| **Download** | De GitHub-release-URL. | +| **Signature** | De base64-signature uit 7c. | +| **Nightly** | Aan voor pre-releases, uit voor stable. | + +Of via de REST API in één commando: + +```bash +curl -X POST \ + -u "your-username:your-password" \ + -H "Content-Type: application/json" \ + -d '{ + "download": "https://github.com/your-org/myapp/releases/download/v1.0.0/myapp.tar.gz", + "signature": "" + }' \ + https://apps.nextcloud.com/api/v1/apps/releases +``` + +De App Store fetcht je tarball, controleert de signature, valideert `info.xml`, en — als alles klopt — publiceert hem. Vanaf dat moment is je app zichtbaar in de **Apps**-pagina van elke Nextcloud-installatie. + +## Stap 8: Release-automatisering via GitHub Actions + +Stap 7 hoef je niet vaker handmatig te doen dan één keer. Daarna laat je een GitHub Action elke `git tag` automatisch tot een release maken én aan de App Store doorgeven. + +### 8a. Repository-secrets instellen + +In je app-repo, **Settings → Secrets and variables → Actions** (of via `gh`): + +```bash +gh secret set APP_PRIVATE_KEY < ~/.nextcloud/certificates/myapp.key +gh secret set APP_PUBLIC_CRT < ~/.nextcloud/certificates/myapp.crt +gh secret set APPSTORE_TOKEN # plak de token uit apps.nextcloud.com/account/token +``` + +Voor repo's onder de `nextcloud`-organisatie eist het Nextcloud-beleid dat deze secrets in een GitHub **environment** staan (typisch `release`), met manual approval. Voor je eigen org volstaan repository-secrets, maar overweeg ook daar een environment — één klik manual approval voorkomt accidental publishes. + +### 8b. Workflow toevoegen + +Plaats het volgende als `.github/workflows/appstore-release.yml`: + +```yaml title=".github/workflows/appstore-release.yml" +name: Publish to App Store +on: + release: + types: [published] + +jobs: + publish: + runs-on: ubuntu-latest + environment: release # verwijder als je geen environment gebruikt + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + + - name: Build appstore tarball + run: make appstore + + - name: Upload tarball to GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "${{ github.event.release.tag_name }}" \ + build/artifacts/appstore/myapp.tar.gz + + - name: Publish to Nextcloud App Store + uses: R0Wi/nextcloud-appstore-push-action@v1 + with: + app_name: myapp + appstore_token: ${{ secrets.APPSTORE_TOKEN }} + download_url: https://github.com/${{ github.repository }}/releases/download/${{ github.event.release.tag_name }}/myapp.tar.gz + app_private_key: ${{ secrets.APP_PRIVATE_KEY }} + nightly: ${{ github.event.release.prerelease }} +``` + +Wat de workflow doet, in volgorde: + +1. **Checkout** je code op de tag. +2. **Build** een appstore-tarball (`make appstore` uit `nextcloud-app-template`). +3. **Upload** de tarball als asset op de GitHub Release. +4. **Publish**: `R0Wi/nextcloud-appstore-push-action` regenereert de signature met je private key, doet de POST naar `apps.nextcloud.com`, en wacht op de bevestiging. + +### 8c. Eerste run + +```bash +git tag v1.0.1 +git push origin v1.0.1 +gh release create v1.0.1 --notes "Patch release." +``` + +Op `release: published` start de workflow. Slaagt hij, dan staat v1.0.1 binnen een minuut in de App Store. Faalt hij, dan zie je in de Actions-log precies wat er stuk ging — meestal een verlopen certificate of een typo in de download-URL. + +## Probleemoplossing + +**`occ integrity:sign-app` faalt met "Certificate is not valid"** — verificatie van key vs. cert overslaan; draai de twee MD5-commando's uit stap 4. Ongelijke hashes? Dan staan key en cert van verschillende paren bij elkaar; haal opnieuw de juiste `.crt` op. + +**App Store-upload faalt met "Signature is invalid"** — drie verdachten: (1) je hebt de signature met een andere key gegenereerd dan in het certificaat staat; (2) de tarball is veranderd nadat je signed had (een nieuwe build, ander hash); (3) je base64-string heeft een line-break gekregen tijdens kopiëren. Gebruik `tr -d '\n'` om dat laatste uit te sluiten. + +**App Store-upload faalt met "Download URL must be HTTPS"** — controleer dat je geen `http://` URL hebt opgegeven. GitHub Releases is altijd HTTPS, dus dit gebeurt typisch bij een private S3 met een verkeerde URL. + +**`integrity:sign-app` zegt "App not found"** — `--path` moet wijzen naar de app-directory zoals Nextcloud die ziet, niet je host-pad. In Docker is dat doorgaans `/var/www/html/custom_apps/myapp` of `/var/www/html/apps/myapp`. + +**App verschijnt niet in App Store na succesvolle upload** — de App Store fetcht je tarball asynchroon en logt fouten op je release-detailpagina. Check [apps.nextcloud.com/developer/apps](https://apps.nextcloud.com/developer/apps) voor je app en kijk bij de specifieke release. + +**Code checker klaagt over "Private API"** — Nextcloud-server-classes onder `OC\` zijn private; gebruik alleen `OCP\`-interfaces. Refactor de roep, of als het echt niet anders kan, accepteer dat reviewers er commentaar op kunnen geven. + +## Verloren key-scenario + +Raak je `myapp.key` kwijt — uit een onversleutelde back-up gewist, harde schijf overleden, en geen kopie elders — dan kun je niet meer signen voor je app-id. De procedure: + +1. **Nieuwe key + CSR genereren** (stap 3, identieke commando's). +2. **Nieuwe PR** op `nextcloud/app-certificate-requests`. In de PR-body **expliciet vermelden** dat dit een vervanging is voor een verloren key, en bij voorkeur via je App Store-developer-account een [issue op de cert-repo](https://github.com/nextcloud/app-certificate-requests/issues) openen waarin je de eigendom van de app-id staaft (link naar je app op `apps.nextcloud.com`, vanuit het account dat eigenaar is). +3. **Wachten op merge**. De maintainers checken dat de aanvragende GitHub-user dezelfde is als de App Store-eigenaar; mismatch betekent afwijzing. +4. **Nieuwe `.crt` ophalen**, **`APP_PRIVATE_KEY`** en **`APP_PUBLIC_CRT`** secrets in je GitHub-repo bijwerken, en de eerstvolgende release gebruikt de nieuwe key. + +Bestaande releases in de App Store blijven werken — die zijn destijds met de oude key gesigned en de App Store heeft de signature al gevalideerd op upload-moment. + +De moraal: maak nu een back-up van `~/.nextcloud/certificates/myapp.key`. Twee minuten werk, jaren ellende voorkomen. + +## Officiële documentatie + +Deze tutorial is een wegwijzer; bij twijfel zijn de Nextcloud-docs leidend. + +- [App publishing and maintenance](https://docs.nextcloud.com/server/latest/developer_manual/app_publishing_maintenance/index.html) — overzicht +- [Code signing](https://docs.nextcloud.com/server/latest/developer_manual/app_publishing_maintenance/code_signing.html) — CSR, certificate, `occ integrity:sign-app` +- [App Store developer guide](https://nextcloudappstore.readthedocs.io/en/latest/developer.html) — registratie, release-upload, API +- [`nextcloud/app-certificate-requests`](https://github.com/nextcloud/app-certificate-requests) — de CSR-PR-repo +- [Release automation](https://docs.nextcloud.com/server/latest/developer_manual/app_publishing_maintenance/release_automation.html) — de GitHub Actions setup + +## Volgende stap + +Eén release in de App Store is een mijlpaal. Twee zijn een patroon. Plan vooruit: + +- **CHANGELOG.md** in je repo bijhouden, zodat elke `gh release create` direct release-notes meekrijgt. +- **Semver** strikt aanhouden — App Store-gebruikers krijgen ook patch-updates automatisch, dus een breaking change in een patch-release maakt ze terecht boos. +- **`max-version`** in `info.xml` bijhouden bij elke nieuwe Nextcloud-major; anders verdwijn je app uit de Apps-pagina van nieuwere installs. + + diff --git a/academy/2026-05-11-nextcloud-hosten/index.mdx b/academy/2026-05-11-nextcloud-hosten/index.mdx new file mode 100644 index 000000000..df553aab0 --- /dev/null +++ b/academy/2026-05-11-nextcloud-hosten/index.mdx @@ -0,0 +1,195 @@ +--- +slug: nextcloud-hosten +title: Nextcloud hosten — bare metal, Docker of Kubernetes? +contentType: tutorial +heroIcon: container +authors: [conduction] +date: 2026-05-11 +summary: Drie manieren om Nextcloud te draaien, één onderbouwde keuze. Bare metal voor controle, Docker (of AIO) voor de meeste teams, Kubernetes alleen als je dat al draait. Plus een beslismatrix en de aandachtspunten die elke productie-installatie raakt. +tags: [Nextcloud, Hosting, Docker, Kubernetes, Helm, AIO, Architectuur] +durationMinutes: 20 +--- + +import {Outcomes, Outcome, Prerequisites, PrerequisiteItem, ContactCta} from '@conduction/docusaurus-preset/components'; + +Een Nextcloud opzetten kan op drie manieren, en de "beste" hangt af van wie je bent en wat je al draait. Een homelab op een mini-pc vraagt iets anders dan een gemeente met tien bestuurslagen en een ISO-audit. Deze tutorial zet de drie hoofdopties naast elkaar, met voor- en nadelen en een beslismatrix om snel te kiezen. Niets vervangt de officiële [Nextcloud admin docs](https://docs.nextcloud.com/server/latest/admin_manual/), maar dit is de korte route naar de juiste hoofdstukken. + +{/* truncate */} + + + De drie hoofdopties voor Nextcloud-hosting onderscheiden: bare metal / VM, containers (Docker + AIO), Kubernetes (Helm). + Per optie weten wanneer je hem kiest, wat hij kost aan complexiteit, en hoe hij schaalt. + Een beslismatrix toepassen die past bij jouw scenario — homelab, team, of multi-tenant productie. + De zes aandachtspunten kennen die elke productie-Nextcloud raakt: backups, updates, TLS, object storage, caching, en performance tuning. + + + + Een idee van je doelgroep: alleen jezelf, een klein team, of een productiedienst voor meerdere gebruikers. + Basiskennis Linux-server-admin (SSH, een package manager, systemd) helpt, maar is geen harde eis voor optie 2. + Voor optie 3: een werkend Kubernetes-cluster (eigen of beheerd). Geen cluster? Optie 3 is niet voor jou — kies optie 2. + + +Wil je gewoon snel testen op je laptop? Sla deze tutorial over en volg [Nextcloud lokaal draaien met Docker](/academy/nextcloud-lokaal-draaien). Dat is een snel-start, geen productie-keuze. + +## De drie hoofdopties in één oogopslag + +| Optie | Wie het draait | Kostprijs | Schaalbaarheid | +| --- | --- | --- | --- | +| **1. Bare metal / VM** | Jij, met de hand of via een script | Lage software-kosten, hoge ops-tijd | Verticaal (groter VM); horizontaal kan, maar is werk | +| **2. Containers (Docker / AIO)** | Docker op één host, of een handvol hosts | Lage drempel, gemiddelde ops-tijd | Verticaal en bescheiden horizontaal; AIO is single-host | +| **3. Kubernetes (Helm)** | Kubernetes-cluster met persistent storage | Hoge complexiteit, lage marginale ops-tijd | Horizontaal, multi-tenant, multi-region | + +Ieder antwoord is een trade-off. Hieronder per optie wat dat in de praktijk betekent. + +## Optie 1: Bare metal of VM + +De klassieke route: LAMP-stack op een Linux-server, Nextcloud uitpakken in `/var/www/nextcloud`, Apache of Nginx ervoor, Postgres of MariaDB ernaast, Redis voor cache, een cronjob voor `cron.php`. Volledige controle, geen abstractielagen. + +**Wanneer wel:** + +- Je hebt een single-tenant productie-installatie met strenge dataregels (lokale schijf, geen container-runtime in scope van je audit). +- Je beheert al een vloot Linux-servers met je eigen config-management (Ansible, Salt, Puppet) en wil Nextcloud daarin vouwen. +- Je wil tunen op systeemniveau (kernel parameters, opcache-paden, PHP-OPcache tuning) zonder dat een container-runtime ertussen zit. + +**Wanneer niet:** + +- Je werkt alleen en hebt geen zin in package-management voor PHP, Apache, en MariaDB. Optie 2. +- Je wil meerdere Nextclouds isoleren op één host. Containers zijn daar gewoon beter in. + +**Complexiteit:** Hoog initieel (LAMP-stack opzetten, TLS-certificaten, php-fpm tunen). Daarna middelmatig (Nextcloud-major-upgrades vereisen handwerk via `occ upgrade`). + +**Schaalbaarheid:** Verticaal door je VM groter te maken. Voor horizontaal schaal-uit gebruik je Nextcloud's [scale-out-aanwijzingen](https://docs.nextcloud.com/server/latest/admin_manual/installation/deployment_recommendations.html): meerdere Nextcloud-frontends achter een load balancer, gedeelde Redis, gedeelde S3-compatibele object storage, en een aparte database-host. Concrete sizing hangt af van je werklast — raadpleeg de officiële documentatie voor richtgetallen. + +**Documentatie:** [Manual installation on Linux](https://docs.nextcloud.com/server/latest/admin_manual/installation/source_installation.html), en de [example installation on Ubuntu 24.04](https://docs.nextcloud.com/server/latest/admin_manual/installation/example_ubuntu.html). + +## Optie 2: Containers — Docker Compose of Nextcloud AIO + +Het brede midden. Twee smaken: + +### 2a. Docker Compose + +Eigen `docker-compose.yml`, de officiële [`nextcloud`-image](https://hub.docker.com/_/nextcloud), Postgres en Redis ernaast. Hetzelfde patroon dat je gebruikt voor [lokaal Nextcloud draaien](/academy/nextcloud-lokaal-draaien). Volledige config-controle, je eigen reverse proxy ervoor (Traefik, Caddy, Nginx Proxy Manager), je eigen TLS-routine. + +### 2b. Nextcloud AIO (All-in-One) + +Eén "mastercontainer" die alle andere services beheert: Nextcloud, Postgres, Redis, Apache proxy, Notify Push, plus optioneel Office, Talk, ClamAV, en zo'n dertig andere community-add-ons. Een webinterface op poort 8080 doet de install, backups, en updates. Officiële Nextcloud-deployment, opgezet om "in vijf minuten" een werkende Nextcloud op te leveren. + +**AIO is een goede default als** je één Nextcloud op één host draait, één publiek domein hebt, en de built-in backup/update-flow accepteert. + +**AIO is **niet** geschikt voor:** + +- Airgapped installs (heeft internet nodig voor de master container). +- Self-signed certificaten of toegang via IP-adres alleen. +- Sub-directory installs (`example.com/cloud`). +- Meerdere domeinen achter dezelfde install. +- Non-standaard poorten — 443 is verplicht. +- SELinux zonder workarounds. + +Voor die scenario's: Docker Compose, met je eigen wiring. + +**Wanneer wel (containers in het algemeen):** + +- Je draait één of een handvol Nextcloud-installs op een hypervisor of dedicated host. +- Je wil snel kunnen wisselen tussen Nextcloud-major-versies (image-tag wijzigen, container herstarten). +- Je hebt een vloot apps in containers en wil Nextcloud daar consistent naast. + +**Wanneer niet:** + +- Je hebt geen Docker-host of wil er geen onderhouden. Optie 1. +- Je draait al Kubernetes voor andere workloads. Optie 3. + +**Complexiteit:** Laag voor AIO (één commando, browser-wizard). Middelmatig voor eigen compose (compose-bestand schrijven, TLS regelen, backups bedenken). + +**Schaalbaarheid:** Beide opties zijn primair single-host. Voor horizontale schaal-uit ga je richting Kubernetes of bare-metal scale-out. Voor de meeste teams blijft single-host op een goed gesized VM ruim voldoende; concrete sizing hangt af van werklast en is een kwestie van meten. + +**Documentatie:** [Docker image](https://hub.docker.com/_/nextcloud) en [Nextcloud AIO repo](https://github.com/nextcloud/all-in-one). + +## Optie 3: Kubernetes met de officiële Helm chart + +[`nextcloud/helm`](https://github.com/nextcloud/helm) is de community-maintained chart, met de letterlijke waarschuwing in de README: *"⚠️⚠️⚠️ This project is maintained by community volunteers and designed for expert use."* De maintainers raden expliciet AIO aan voor wie "quick and easy" wil. Lees: kies dit alleen als je al een productioneel Kubernetes-cluster hebt en weet wat een PersistentVolumeClaim is. + +```bash +helm repo add nextcloud https://nextcloud.github.io/helm/ +helm repo update +helm install my-nextcloud nextcloud/nextcloud +``` + +Drie commando's, maar de echte werk zit in de `values.yaml`: ingress, certmanager-annotaties, externe MariaDB of Postgres, externe Redis, S3-compatibele primary storage, replica-counts, resource requests/limits, anti-affinity zodat replicas niet op dezelfde node landen. + +**Wanneer wel:** + +- Je draait al Kubernetes met cert-manager, een ingress controller, en monitored persistent storage. +- Je wilt multi-tenant: meerdere Nextcloud-instanties, elk in een eigen namespace, gedeelde infra. +- Je hebt HA-eisen: meerdere replicas, automatische failover, rolling updates zonder downtime. +- Je werkt vanuit GitOps (ArgoCD / Flux) en wil Nextcloud daar in passen. + +**Wanneer niet:** + +- Je hebt nog geen Kubernetes draaien. De drempel om dit *speciaal voor Nextcloud* op te tuigen is veel te hoog. Begin bij optie 2. +- Je hebt één Nextcloud nodig en niet meer. Single-pod Helm is meer werk dan Docker Compose, voor minder controle. + +**Complexiteit:** Hoog. Begin niet aan deze chart als je niet zelfstandig een `kubectl describe pod` kunt interpreteren en weet wat een StorageClass doet. + +**Schaalbaarheid:** Hoog. Meerdere Nextcloud-pods achter een service, gedeelde primary storage op S3, gedeelde Redis, externe gemanagede database. Dit is de route die multi-region en multi-tenant realistisch ondersteunt, met horizontale schaal-uit als eerste-klas concept in plaats van als nabouw. + +**Documentatie:** [`nextcloud/helm` README](https://github.com/nextcloud/helm) en de [scaling deployment recommendations](https://docs.nextcloud.com/server/latest/admin_manual/installation/deployment_recommendations.html). + +## Beslismatrix + +Lees van links naar rechts. Eerste match wint. + +| Scenario | Aantal users | Schaal-eis | Ops-capaciteit | Kies | +| --- | --- | --- | --- | --- | +| Homelab, eigen gebruik | 1 — 5 | Geen | Beperkt | **Optie 2b (AIO)** of [lokaal Docker](/academy/nextcloud-lokaal-draaien) | +| Klein team, één organisatie | 5 — 50 | Verticaal | Beperkt | **Optie 2b (AIO)** als wizard-flow oké is, anders **2a (Compose)** | +| Klein team, custom integraties | 5 — 50 | Verticaal | Gemiddeld | **Optie 2a (Docker Compose)** — je hebt control nodig die AIO niet geeft | +| Eén productie-dienst | 50 — 500 | Verticaal | Gemiddeld | **Optie 1 (VM)** of **Optie 2a** met externe DB/Redis | +| Eén productie-dienst, strict compliance | 50 — 500 | Verticaal | Hoog | **Optie 1 (bare metal)** voor audit-leesbaarheid | +| Multi-tenant productie | 100+ per tenant | Horizontaal | Hoog, K8s-kennis aanwezig | **Optie 3 (Kubernetes)** | +| Multi-tenant productie | 100+ per tenant | Horizontaal | K8s niet in huis | **Optie 1 scale-out** of een gemanagede aanbieder | + +Als je twijfelt tussen 2a en 2b: begin met **AIO**. Loop je tegen de [AIO-limitaties](https://github.com/nextcloud/all-in-one#limitations) (sub-directory, meerdere domeinen, non-standaard poort), migreer dan naar 2a met een database-export. + +## Aandachtspunten voor elke productie-Nextcloud + +Onafhankelijk van de gekozen hostingsmaak: deze zes onderwerpen móet je expliciet adresseren voor je iets productie noemt. + +### Backups + +Drie dingen om in een back-up te krijgen, niet één: de Nextcloud-databasedump, de `config/config.php`, en de `data/`-directory (of de S3-bucket als je object storage gebruikt). Restore-test minimaal per kwartaal. AIO heeft een ingebouwde [Borg-backup](https://github.com/nextcloud/all-in-one#how-to-do-a-backup-and-restore); voor de andere opties is `nextcloud-borgbackup` of een eigen script de standaard. + +### Updates + +Nextcloud doet één major-release per half jaar (~april en oktober). Patch-releases tussen-door. Major-upgrades zijn niet skippable: 27 → 29 doe je via 28. Plan een onderhoudsvenster per half jaar. AIO automatiseert dit grotendeels; bij Docker Compose werk je met image-tags; bij bare metal draai je `occ upgrade` na elke pull. + +### TLS + +Verplicht voor productie, ook intern. [Let's Encrypt](https://letsencrypt.org/) met cert-manager (K8s), Traefik / Caddy (Docker), of [`certbot`](https://certbot.eff.org/) (bare metal). AIO doet het automatisch via de built-in Apache. Vergeet HSTS-header en TLS 1.2-minimum niet. + +### Object storage (primary storage) + +Default schrijft Nextcloud user-files naar de `data/`-directory op lokale schijf. Voor productie boven ~500 GB wil je [S3 als primary storage](https://docs.nextcloud.com/server/latest/admin_manual/configuration_files/primary_storage.html): MinIO zelf-gehost of een gemanagede S3 zoals AWS, Cloudflare R2, Wasabi. Verandert de schalingscurve volkomen. + +### Caching + +Redis als distributed memcache is verplicht zodra je meer dan één PHP-proces hebt — wat al het geval is bij default-config van zowat elke optie. APCu voor local memcache, Redis voor distributed + file-locking. Zonder Redis-file-locking krijg je inconsistenties bij gelijktijdige edits. + +### Performance tuning + +`php.ini`: `memory_limit=512M`, `opcache.enable=1`, `opcache.interned_strings_buffer=16`. Nextcloud's [scaling deployment recommendations](https://docs.nextcloud.com/server/latest/admin_manual/installation/deployment_recommendations.html) zijn de canonieke checklist — werk hem af voor productie, ongeacht hostingoptie. + +Conduction-apps draaien op de officiële Nextcloud-image en zijn agnostisch over de gekozen hostingoptie. Geen van de drie routes is "de juiste" in absolute zin — de beslismatrix hierboven is de juiste, voor jouw scenario. + +## Volgende stap + +Je weet welke hostingoptie bij je past. De vervolg-vraag is wat erop komt: + +- [Nextcloud lokaal draaien met Docker](/academy/nextcloud-lokaal-draaien) — drie commando's voor een lokale demo. +- [Een Woo-register opzetten in OpenRegister](/academy/woo-register-opzetten) — Conduction's data-foundation, draait op elke hostingoptie hierboven. +- [Build a Nextcloud app on the Conduction stack — Part 1: Scaffold](/academy/deskdesk-tutorial-1-scaffold) — een eigen app bouwen die op deze stack landt. + + diff --git a/academy/2026-05-12-haven-en-kind/index.mdx b/academy/2026-05-12-haven-en-kind/index.mdx new file mode 100644 index 000000000..a526894ed --- /dev/null +++ b/academy/2026-05-12-haven-en-kind/index.mdx @@ -0,0 +1,482 @@ +--- +slug: haven-and-haven-plus +title: Haven en Haven+ — van VNG-standaard naar lokale Kubernetes-praktijk +contentType: tutorial +heroIcon: cluster +authors: [conduction] +date: 2026-05-12 +summary: Wat Haven voorschrijft, waarom het bestaat en hoe je de bouwstenen lokaal namaakt met kind. Geen compliant cluster op je laptop, wel een werkende oefenomgeving waarin de standaard tastbaar wordt. +tags: [Haven, Kubernetes, Common Ground, kind, Hosting, Architectuur] +durationMinutes: 30 +--- + +import {Outcomes, Outcome, Prerequisites, PrerequisiteItem, ContactCta} from '@conduction/docusaurus-preset/components'; + +Haven is een VNG-standaard voor gemeentelijke Kubernetes-clusters. Deze module legt uit wat de standaard voorschrijft, waarom hij bestaat, en hoe je de bouwstenen lokaal nabootst met kind — zodat de term tastbaar wordt voordat je 'm in een aanbesteding tegenkomt. + +{/* truncate */} + + + Wat Haven en Haven+ zijn en in welke context ze ontstaan (Common Ground, 342 gemeenten, "pas toe of leg uit"). + Welke concrete Kubernetes-bouwstenen de standaard verwacht. + Het verschil tussen Haven en Haven+, en wanneer je welke kiest. + Wat een applicatie nodig heeft om op een Haven-cluster te kunnen draaien. + Hoe je een Haven-achtige oefenomgeving lokaal opzet met kind en Docker. + Waarom je de Haven Compliancy Checker níet zinvol op kind draait, en waar je 'm wél gebruikt. + + + + Basiskennis Kubernetes (pods, services, ingress, manifests). + Docker lokaal draaiend. + `kubectl` en `kind` geïnstalleerd — installatie komt in stap 5. + + +## 1. Waarom Haven bestaat + +Nederland heeft 342 gemeenten en die hebben min of meer dezelfde primaire processen — vergunningen, meldingen, registers, aanvragen. Twintig jaar lang werden die processen toch elk apart uitgevraagd en ingekocht, leverancier per leverancier. Het resultaat: applicaties die niet uitwisselbaar zijn, elk met eigen runtime-aannames en elk met een leverancier die de touwtjes in handen houdt. + +[Common Ground](https://commonground.nl/) is de tegenbeweging: gedeelde standaarden, herbruikbare componenten, geen vendor lock-in. Een applicatie die voor één gemeente geschreven is, moet bij een andere gemeente kunnen draaien zonder dat de hostingomgeving opnieuw uitgevonden wordt. Daarvoor moet vaststaan wat een productiecluster minimaal kan — en daar gaat [Haven](https://haven.commonground.nl/) over. + +Haven is een door [VNG](https://vng.nl/projecten/haven) vastgestelde standaard voor gemeentelijke Kubernetes-clusters. De status is ["pas toe of leg uit"](https://developer.overheid.nl/kennisbank/devops/standaarden/haven): kies je als gemeente voor Kubernetes, dan volg je Haven — tenzij je gemotiveerd uitlegt waarom niet. Het is geen aanbestedingseis op zichzelf, maar wel de impliciete benchmark waarmee inkopers, architecten en leveranciers elkaar wegen. + +## 2. Wat schrijft Haven concreet voor + +Haven definieert compliancy als een lijst geautomatiseerde checks: een cluster is Haven Compliant zodra alle verplichte checks slagen. De canonieke lijst leeft in [`checks.yaml`](https://gitlab.com/commonground/haven/haven/-/blob/main/haven/cli/pkg/compliancy/static/checks.yaml) in de Haven-repo en is de single source of truth — de [Compliancy Checker-pagina](https://haven.commonground.nl/techniek/checks) rendert dezelfde lijst. + +Op hoofdlijnen, gegroepeerd zoals de checker dat zelf doet: + +**Infrastructuur** +- Verkeer over meerdere availability zones (`multiaz`). +- Minimaal drie master nodes (`hamasters`) — hoge beschikbaarheid van de control plane. +- Minimaal drie worker nodes (`haworkers`) — ruimte voor high-availability workloads. +- Hardening op node-niveau: SELinux, AppArmor, Grsecurity, LKRG, Talos of Flatcar (`nodehardening`). +- Private networking-topologie — masters en workers niet direct aan het publieke internet (`privatenetworking`). + +**Cluster** +- Recente Kubernetes-versie: laatste stable of maximaal drie minor versies erachter (`kubernetesversion`). +- RBAC ingeschakeld (`rbac`). +- `ReadWriteMany` persistent volume support (`rwxvolumes`) — gedeelde storage voor HA-deployments. + +**Externe conformiteit** +- CNCF Kubernetes Conformance (`cncf`) — het cluster voldoet aan de standaard Kubernetes-API's. + +**Haven+ (categorie binnen Haven, geen aparte standaard — zie sectie 3)** +- Automatisch HTTPS-certificaten kunnen uitgeven en vernieuwen (`autocerts`). +- Log aggregation — alle container-logs naar een centrale bestemming (`logs`). +- Metrics-server draait — Prometheus-compatibele cluster-metrics (`metrics`). + +Aanvullend draait de checker enkele zelf-checks: of de Haven CLI up-to-date is (`havenversion`), of de checker met cluster-admin-rechten draait (`clusteradmin`), en of de binary SHA-gevalideerd is (`shavalidation`). + +Optioneel — niet verplicht voor Haven Compliancy — zijn [CIS Kubernetes Benchmark](https://www.cisecurity.org/benchmark/kubernetes) en [Kubescape](https://kubescape.io/), die je expliciet aanzet met `--cis` of `--kubescape` in de checker. + +Wat Haven uitdrukkelijk **niet** is, volgens de officiële `wat-is-haven`-pagina: + +- **Geen "all inclusive" cloud-oplossing.** Haven richt zich specifiek op de hostingcomponent. +- **Geen security baseline.** Letterlijk: *"Haven is ook geen security baseline. Dat kan ook niet, want security betreft de som der delen."* Voor het complete beveiligingsverhaal verwijst Haven naar BIO, ISO 2700x en CIS-benchmarks. +- **Geen leveranciers- of cloud-keuze.** De standaard is platformonafhankelijk. + +> **Ingress en Gateway API.** De checker valideert geen specifieke ingress-implementatie. De Kubernetes-community schuift van de klassieke `Ingress`-resource naar [Gateway API](https://gateway-api.sigs.k8s.io/) (GA sinds 2024); `ingress-nginx` zit in maintenance mode. Voor nieuwe applicaties is Gateway API de toekomstvaste keuze, en dat is wat we in stap 5 lokaal opzetten. + +## 3. Eén standaard, geen aparte "Haven+" + +In sommige publicaties verschijnt "Haven+" als een aparte, strengere standaard naast Haven. **De officiële Haven-documentatie kent die scheiding niet.** Er is één standaard: Haven, [tot standaard verklaard door VNG op 25 maart 2022](https://vng.nl/nieuws/bestuur-vng-verklaart-haven-tot-standaard). + +De [Compliancy Checker](https://haven.commonground.nl/techniek/compliancy-checker) groepeert checks intern in categorieën — onder andere *Fundamental*, *Infrastructure*, *Cluster*, *External*, en een categorie die **`Haven+`** heet. Maar al die categorieën staan in dezelfde verplichte lijst (`compliancychecks` in [`checks.yaml`](https://gitlab.com/commonground/haven/haven/-/blob/main/haven/cli/pkg/compliancy/static/checks.yaml)). Een cluster is Haven Compliant zodra ze allemaal slagen. + +Drie veelvoorkomende misverstanden, naast elkaar gezet: + +- **"Haven+ is strenger qua infrastructuur (multi-AZ, 3 masters, private networking)."** Niet waar — die eisen zitten gewoon in baseline Haven, in de categorie *Infrastructure*. +- **"De *Haven+*-categorie in de checker is een upgrade-pad."** Niet exact — het is een aanduiding voor operationele functies die later aan de standaard zijn toegevoegd: automatische HTTPS (`autocerts`), log aggregatie (`logs`) en metrics-server (`metrics`). Ze zijn verplicht, net als de rest. +- **"[Project Haven+](https://blog.brightcubes.nl/nieuws/project-haven-plus) is een nieuwe standaard."** Het is een community- en adoptie-initiatief van Bright Cubes en gemeente Utrecht rond Haven, geen tweede formele standaard. Het [interview met het Haven+-team](https://developer.overheid.nl/blog/2025/06/25/interview-haven) gaat over diezelfde adoptie en doorontwikkeling. + +Praktische conclusie voor lezers, inkopers en architecten: **één Haven, één checklist, één checker.** Een cluster is óf Haven Compliant, óf niet. Wat wél evolueert is de versie — Haven werkt met major.minor.patch versionering, met ongeveer elk kwartaal een nieuwe major. Om compliant te blijven moet de checker minimaal elk kwartaal opnieuw slagen, met een venster van drie maanden om bij een nieuwe major bij te trekken. + +## 4. Wat betekent dit voor jouw applicatie + +Een Haven-cluster is breed inzetbaar, maar dat werkt twee kanten op: jouw applicatie moet ook ergens aan voldoen om er thuis op te zijn. Concreet: + +- **Containerized.** De applicatie draait als één of meer OCI-compatibele container-images. Geen `apt-get install` op de host, geen aannames over wat er op een specifieke node staat. +- **Manifests of Helm chart.** Deployment-configuratie staat in `Deployment`, `Service`, `Ingress`-manifests of als Helm chart. Geen handmatige `kubectl create`-recepten. +- **Metrics endpoint.** Een Prometheus-compatibel `/metrics`-endpoint, zodat de cluster-monitoring je applicatie kan bevragen zonder leveranciersspecifieke integratie. +- **Replica-veilig.** Meerdere replica's tegelijk draaien zonder elkaar in de weg te zitten. Sessies in een gedeelde store (Redis, database), geen lokale geheugen-state die alleen voor één pod geldig is. +- **Geen lokale filesystem-aannames.** Wat naar disk moet, gaat naar een `PersistentVolume` of object storage (S3-compatibel). Container filesystem is efemeer. +- **Configuratie via environment of `ConfigMap`/`Secret`.** Geen hardcoded URLs, geen credentials in images. +- **Liveness en readiness probes.** Het cluster moet kunnen meten of een pod draait en verkeer kan ontvangen. + +Voldoet je applicatie hier niet aan, dan is de migratie naar Haven feitelijk een herontwerp — niet een hosting-keuze. + +## 5. Lokaal oefenen: een Haven-achtig cluster met kind + +Doel van deze sectie: een lokaal Kubernetes-cluster dat de bouwstenen levert die Haven verwacht. Niet compliant — dat kan op één laptop niet — wel de structuur waarin je manifests kunt valideren, met de Common Ground-vriendelijke keuzes (Gateway API in plaats van klassieke Ingress). + +We gebruiken één control-plane en drie workers. Haven+ vraagt drie masters voor productie, maar dat heeft op een laptop geen meerwaarde — verlies van de hostmachine neemt alle masters tegelijk mee. Drie workers is wel zinvol: je kunt replica-spreiding en `topologySpreadConstraints` ermee uitproberen. + +Wat je gaat installeren: + +- **kind** — Kubernetes-in-Docker, een cluster in containers +- **Gateway API CRDs + Envoy Gateway** — Gateway API als ingress-laag +- **metrics-server** — Prometheus-compatibele cluster-metrics +- Een testapplicatie om de keten te verifiëren + +### Stap 1: kind en kubectl installeren + +Op macOS via Homebrew: + +```bash +brew install kind kubectl helm +``` + +Op Linux: + +```bash +# kind +curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.24.0/kind-linux-amd64 +chmod +x ./kind && sudo mv ./kind /usr/local/bin/kind + +# kubectl +curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" +chmod +x kubectl && sudo mv ./kubectl /usr/local/bin/kubectl + +# helm (zie https://helm.sh/docs/intro/install/ voor jouw distributie) +curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash +``` + +Verifieer: + +```bash +kind version +kubectl version --client +helm version +``` + +### Stap 2: cluster-config schrijven + +Maak `kind-haven.yaml` aan: + +```yaml +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +name: haven-oefen +nodes: + - role: control-plane + - role: worker + - role: worker + - role: worker +``` + +Sober gehouden — geen `extraPortMappings`, want we exposen het verkeer straks via `kubectl port-forward`. Dat houdt het cluster dichter bij wat Haven in productie doet: een Gateway met een externe `LoadBalancer`, niet een poort die direct in de host hangt. + +### Stap 3: cluster opspinnen + +```bash +kind create cluster --config kind-haven.yaml +``` + +Dit duurt ongeveer een minuut. Controleer: + +```bash +kubectl get nodes +``` + +Je ziet vier nodes: één `control-plane` en drie `worker`. + +### Stap 4: Envoy Gateway installeren + +We gebruiken [Envoy Gateway](https://gateway.envoyproxy.io/) als Gateway API-implementatie — een CNCF-project op basis van de gegradueerde Envoy proxy, met Gateway API als enige API (geen erfenis-`Ingress`). De Helm chart installeert de Gateway API CRDs (`GatewayClass`, `Gateway`, `HTTPRoute`, etc.) zelf, dus je hoeft die niet apart te apply'en. + +```bash +helm install eg oci://docker.io/envoyproxy/gateway-helm \ + --version v1.2.0 \ + -n envoy-gateway-system \ + --create-namespace + +kubectl wait --timeout=5m -n envoy-gateway-system \ + deployment/envoy-gateway --for=condition=Available +``` + +Controleer dat de CRDs er staan: + +```bash +kubectl get crd | grep gateway.networking.k8s.io +``` + +Je ziet de standard channel-CRDs (`gatewayclasses`, `gateways`, `httproutes`, `grpcroutes`, `referencegrants`) plus de experimental-channel CRDs die Envoy Gateway meelevert (`tcproutes`, `tlsroutes`, `udproutes`, `backendlbpolicies`, `backendtlspolicies`). + +### Stap 5: metrics-server installeren + +Standaard manifest: + +```bash +kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml +``` + +In een kind-cluster zijn de kubelet-certificaten self-signed. metrics-server moet daarom TLS-verificatie uitzetten — dat doe je met één patch op het deployment: + +```bash +kubectl patch -n kube-system deployment metrics-server --type=json \ + -p='[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]' +``` + +Na een minuut werkt `kubectl top nodes` en levert het cluster metrics. + +### Stap 6: storage class verifiëren + +kind levert standaard een lokale storage class: + +```bash +kubectl get storageclass +``` + +Je ziet `standard (default)`. Een `PersistentVolumeClaim` zonder expliciete `storageClassName` krijgt deze automatisch toegewezen — precies wat Haven verwacht. + +### Stap 7: testdeployment via Gateway API + +Een minimale nginx achter een `Gateway` met een `HTTPRoute`. Save dit als `test.yaml`: + +```yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: GatewayClass +metadata: + name: eg +spec: + controllerName: gateway.envoyproxy.io/gatewayclass-controller +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: eg + namespace: default +spec: + gatewayClassName: eg + listeners: + - name: http + protocol: HTTP + port: 80 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: hello +spec: + replicas: 2 + selector: + matchLabels: + app: hello + template: + metadata: + labels: + app: hello + spec: + containers: + - name: nginx + image: nginx:1.27-alpine + ports: + - containerPort: 80 +--- +apiVersion: v1 +kind: Service +metadata: + name: hello +spec: + selector: + app: hello + ports: + - port: 80 + targetPort: 80 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: hello +spec: + parentRefs: + - name: eg + hostnames: + - "hello.localhost" + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: hello + port: 80 +``` + +Apply: + +```bash +kubectl apply -f test.yaml +``` + +Envoy Gateway maakt op de achtergrond een `Service` van type `LoadBalancer` aan voor de data plane. In een productie-cluster geeft de cloud-provider die service een echt IP. In kind blijft dat IP `` — geen probleem, we testen met port-forward. + +Wacht eerst tot Envoy de Gateway heeft uitgerold en de data plane pod Ready is: + +```bash +kubectl wait --timeout=3m -n envoy-gateway-system \ + --for=condition=Ready pod \ + -l gateway.envoyproxy.io/owning-gateway-name=eg +``` + +Daarna de service vinden (naam bevat een hash) en port-forwarden: + +```bash +ENVOY_SVC=$(kubectl -n envoy-gateway-system get svc \ + -l gateway.envoyproxy.io/owning-gateway-name=eg \ + -o jsonpath='{.items[0].metadata.name}') + +kubectl -n envoy-gateway-system port-forward "svc/$ENVOY_SVC" 18080:80 +``` + +In een ander shell-venster: + +```bash +curl -H "Host: hello.localhost" http://localhost:18080/ +``` + +Je ziet de nginx welcome-pagina. Dat betekent: Gateway API werkt, je deployment draait, en de routing van extern → gateway → service → pod is rond. + +> **Let op — Gateway-status in kind.** Wanneer je `kubectl get gateway eg` draait, ziet de Gateway er als volgt uit: `ACCEPTED=True`, `PROGRAMMED=False`, met reden `AddressNotAssigned`. Dat is verwacht in kind — er is geen cloud-provider die het LoadBalancer-IP toekent. Verkeer route't desondanks via de cluster-IP van de Envoy service. In een echt cluster gaat `PROGRAMMED` naar `True` zodra de cloud-provider een adres uitdeelt. + +### Wat je nu hebt + +Een cluster met de bouwstenen die Haven verwacht: een Gateway API-gebaseerde ingress-laag, metrics-server voor cluster-monitoring, een werkende default storage class, RBAC out of the box. Geen Haven-cluster, wel een omgeving waar je Haven-conforme manifests kunt schrijven en valideren. + +Opruimen wanneer je klaar bent: + +```bash +kind delete cluster --name haven-oefen +``` + +## 6. De Compliancy Checker — installeren en draaien + +De [Haven Compliancy Checker](https://haven.commonground.nl/techniek/compliancy-checker) is een command-line tool, onderdeel van de **Haven CLI**. Geen registratie, geen tokens, geen agents — je downloadt de binary, geeft 'm een kubeconfig met cluster-admin-rechten, en draait `haven check`. + +### Installeren + +Download een release van de [Haven CLI packages](https://gitlab.com/commonground/haven/haven/-/packages). Binaries zijn er voor macOS, Linux en Windows. + +```bash +# Voorbeeld voor Linux amd64 (controleer de actuele versie en jouw architectuur) +unzip haven-v12.8.0-linux-amd64.zip +sudo mv _dist/linux-amd64/haven /usr/local/bin/haven +haven version +``` + +### Draaien + +Op een cluster waar je cluster-admin bent: + +```bash +haven check +``` + +De checker loopt door de verplichte checks uit [`checks.yaml`](https://gitlab.com/commonground/haven/haven/-/blob/main/haven/cli/pkg/compliancy/static/checks.yaml) en print de uitslagen. Een cluster is Haven Compliant als alle verplichte checks slagen, inclusief de CNCF Kubernetes Conformance-check. + +Nuttige flags (uit `haven check --help`): + +- `haven check --rationale` — print de rationale per check en stop, zonder uit te voeren. Goed eerste commando om te zien wat de checker meet. +- `haven check --output json` — JSON-uitvoer voor automatisering. +- `haven --log-file output.log check` — uitgebreide logs naar bestand (aanrader bij JSON-output of lange runs). +- `haven check --cis` — voeg de optionele CIS-benchmark toe. +- `haven check --kubescape` — voeg de optionele Kubescape-benchmark toe. + +### Wat dit oplevert op een kind-cluster + +`haven check` draait gewoon op kind (de checker installeert ongevraagd de `compliancies.haven.commonground.nl` CRD om resultaten op te slaan) en geeft je een concreet beeld van wat al goed staat. Op de cluster die we in stap 5 hebben opgezet is de uitslag: + +```text +[E] Results: 7 out of 15 checks passed, 1 checks skipped, 0 checks unknown. + This is NOT a Haven Compliant cluster. + +┌────────────────┬──────────────────────────────────────────────────────────────────────────┬─────────┐ +│ CATEGORY │ NAME │ PASSED │ +├────────────────┼──────────────────────────────────────────────────────────────────────────┼─────────┤ +│ Fundamental │ Self test: HCC version is latest major or within 3 months upgrade window │ YES │ +│ Fundamental │ Self test: does HCC have cluster-admin │ YES │ +│ Infrastructure │ Multiple availability zones in use │ NO │ +│ Infrastructure │ Running at least 3 master nodes │ NO │ +│ Infrastructure │ Running at least 3 worker nodes │ YES │ +│ Infrastructure │ Nodes have SELinux, Grsecurity, AppArmor, LKRG, Talos or Flatcar enabled │ NO │ +│ Infrastructure │ Private networking topology │ YES │ +│ Cluster │ Kubernetes version is latest stable or max 3 minor versions behind │ NO │ +│ Cluster │ Role Based Access Control is enabled │ YES │ +│ Cluster │ ReadWriteMany persistent volumes support │ NO │ +│ External │ CNCF Kubernetes Conformance │ SKIPPED │ +│ Haven+ │ Automated HTTPS certificate provisioning │ NO │ +│ Haven+ │ Log aggregation is running │ NO │ +│ Haven+ │ Metrics-server is running │ YES │ +│ Validation │ SHA has been validated │ YES │ +└────────────────┴──────────────────────────────────────────────────────────────────────────┴─────────┘ +``` + +Lezing van de NO's: + +- **`multiaz`** — alle nodes draaien op één host. Niet op te lossen op een laptop. +- **`hamasters`** — kind levert standaard één control-plane. Drie masters in één Docker host is niet zinvol om te oefenen. +- **`nodehardening`** — kind-nodes zijn containers zonder SELinux/AppArmor/Talos/Flatcar profile. +- **`kubernetesversion`** — kind v0.24 ships met Kubernetes v1.31; de checker verwacht binnen drie minor versies van de laatste stable. Op te lossen door een nieuwere `kindest/node`-image te kiezen. +- **`rwxvolumes`** — de ingebouwde `local-path` storage class doet alleen `ReadWriteOnce`. Voor `ReadWriteMany` zou je iets als NFS-CSI of Rook moeten installeren. +- **`autocerts`** — geen cert-manager + ClusterIssuer geïnstalleerd. Wel haalbaar op kind, maar buiten scope voor deze module. +- **`logs`** — geen centrale log-aggregator. Ook haalbaar op kind (Loki, Fluent Bit), buiten scope. + +De **SKIPPED** CNCF-check is geen falen: de checker probeert het platform te matchen tegen de [CNCF-conformance lijst](https://github.com/cncf/k8s-conformance) en `kind` staat daar niet op, dus slaat hij de check over (`platform: Unknown could not be matched`). Op een gehoste provider (AKS, GKE, EKS) draait deze check wél. + +Wat slaagt op je kind-cluster — een goede sanity check voor je manifests: + +- Cluster-admin werkt (`clusteradmin`), Haven CLI versie up-to-date (`havenversion`), SHA validatie (`shavalidation`). +- 3+ workers (`haworkers`) — dankzij de multi-node config uit stap 2. +- Private networking (`privatenetworking`) — kind-nodes hebben geen publieke IPs. +- RBAC out of the box (`rbac`). +- Metrics-server na de TLS-patch uit stap 5 (`metrics`). + +Wat hier doorheen komt is nog steeds geen Haven Compliancy — daarvoor heb je een echte cluster nodig waar `multiaz`, `hamasters`, `nodehardening`, `kubernetesversion`, `rwxvolumes`, `autocerts` en `logs` allemaal slagen. Maar je hebt nu wel het patroon onder de knie, en `haven check` zelf werkt zoals het op productie zal werken. + +## 7. Grenzen van de simulatie + +Wat kind je leert is de *vorm* van Haven: welke manifests werken, welke patronen zijn herbruikbaar, hoe controllers naast elkaar leven. Wat het je niet leert: + +- **Multi-AZ-gedrag.** Alle nodes draaien op dezelfde host. Een echte zone-uitval test je hiermee niet. +- **Private networking.** Haven vraagt om nodes zonder directe publieke IP's en netwerksegmentatie tussen control plane en workers (de `privatenetworking`-check). Op kind staat alles in één Docker-bridge. +- **Geo-redundantie en failover.** Geen tweede datacenter, geen DNS-failover, geen disaster recovery te oefenen. +- **Productie-load.** Een laptop is geen lasttest. Performance, latency en resource-pressure-gedrag zijn anders. +- **LoadBalancer met extern IP.** In kind blijft de Gateway-service ``. Echte L4/L7-load balancing van een cloud-provider zit daar niet bij. +- **Cloud-specifieke integraties.** Object storage, secret managers, identity providers — je hebt op kind alleen wat je zelf installeert. + +Praktisch: kind is goed om Haven-conforme manifests te schrijven en te valideren dat ze deployen. Het is geen vervanging van een staging-cluster bij een echte provider. + +## 8. Vervolgstappen naar een echte Haven-omgeving + +Werken je manifests op kind, dan is de volgende stap een echte cluster bij een provider die als Haven Compliant geverifieerd is. De [Aan de slag-pagina](https://haven.commonground.nl/techniek/aan-de-slag) en de [`reference/`-map in de Haven-repo](https://gitlab.com/commonground/haven/haven/-/tree/main/reference) zijn de canonieke bronnen. Op het moment van schrijven staan op de Aan de slag-pagina: + +- Amazon EKS +- Avisi AME *(Nederlandse provider)* +- Cyso Cloud *(Nederlandse provider)* +- Edgeless Systems Constellation +- Google GKE +- Kops on OpenStack +- Microsoft Azure AKS +- OVHcloud Kubernetes +- Oracle OKE +- Previder *(Nederlandse provider)* +- Red Hat OpenShift +- SUSE Rancher Kubernetes Engine (RKE) +- VMware Tanzu Kubernetes Grid + +Haven zegt expliciet "no preferences and/or recommendations are expressed" — de lijst is geen ranking en niet uitputtend. Controleer de Aan de slag-pagina voor de actuele stand. + +Wat verandert er ten opzichte van je kind-cluster: + +- De Gateway/LoadBalancer krijgt een echt extern IP via de cloud-provider; geen port-forward meer. +- Multi-AZ-spreiding werkt — node pools in verschillende zones, en de `multiaz`-check uit Haven kan slagen. +- Storage class wijst naar cloud-storage van de provider (Azure Disk, GCE PD, vSphere CSI), niet een lokaal volume. Voor `rwxvolumes` heb je een driver nodig die `ReadWriteMany` ondersteunt. +- `haven check` kan volledig draaien en feitelijk vaststellen of het cluster Haven Compliant is. + +Voor het Haven-traject zelf is de [Haven GitLab-repo](https://gitlab.com/commonground/haven/haven) het canonieke startpunt — met de specificatie (`checks.yaml`), de checker (`haven/cli`), de reference implementations, en een issue tracker waar standaard-wijzigingen via een issue met label `Standard Change` worden voorgesteld. + + diff --git a/academy/2026-05-12-hydra-tutorial-1-wat-is-hydra/index.mdx b/academy/2026-05-12-hydra-tutorial-1-wat-is-hydra/index.mdx new file mode 100644 index 000000000..d7435beba --- /dev/null +++ b/academy/2026-05-12-hydra-tutorial-1-wat-is-hydra/index.mdx @@ -0,0 +1,259 @@ +--- +slug: hydra-tutorial-1-what-is-hydra +title: "Hydra tutorial series — Part 1: What is Hydra?" +contentType: tutorial +heroIcon: pipeline +authors: [conduction] +date: 2026-05-12 +summary: A short introduction to Hydra, Conduction's agentic CI/CD platform. What it does, why it exists, and where it sits inside our app factory. The first of seven short modules. +tags: [Hydra, AI, CI/CD, Tutorial series] +apps: [] +durationMinutes: 10 +series: hydra-tutorial +partNumber: 1 +--- + +import {Outcomes, Outcome, Prerequisites, PrerequisiteItem, NextSteps, NextStep, HexCard, ContactCta} from '@conduction/docusaurus-preset/components'; +import Details from '@theme/Details'; + +:::tip Recommended first — the OpenSpec tutorial series +Hydra runs on OpenSpec changes. If you're not yet familiar with terms like **spec**, **change**, **requirement** and **scenario**, take the [OpenSpec tutorial series](/academy/openspec-tutorial-1-what-is-openspec) first (parts 1 and 2, roughly 30 minutes together). It will save you a lot of backtracking in the rest of these modules. +::: + +:::tip Also useful first — the Claude Skills tutorial series +A large part of Hydra's internals leans on **Claude Skills**: `opsx-*`, `hydra-gate-*`, `team-*`, `test-*`. If you've never written or read a skill, take [part 1 of the Claude Skills tutorial series](/academy/claude-skills-tutorial-1-what-are-claude-skills) first (10 minutes). That makes part 4 of this series — where we open up the skill families inside Hydra — a lot easier to follow. +::: + +Hydra is Conduction's internal **agentic CI/CD platform**: a factory that turns OpenSpec changes into code on a feature branch, checked by mechanical gates and, when you ask for it, reviewed by three AI stages. This module explains in ten minutes *what* Hydra is, *why* it exists, and how it fits inside our app factory. It is the first part of a seven-part series; by the end you're ready for part 2 on the three pipelines. + +{/* truncate */} + + + What Hydra is in one sentence, and why we built it. + The four personas at a high level: Al Gorithm, Juan Claude, Clyde, Axel. + When to use Hydra, and when not to. + + + + Basic Git knowledge, familiarity with GitHub issues and PRs, and a notion of what a Nextcloud app is. + A rough familiarity with the Claude Code CLI (we don't dive into commands here; see part 4 for that). + Optional — the public one-pager at .github/docs/hydra/README.md. A good warm-up if you've never heard of Hydra before. + Only needed if you want to run Hydra yourself (rather than just read the series): access to the private ConductionNL/hydra repo. Ask your team lead. + + +> **Want to follow along in your editor?** Once you have repo access, clone Hydra now so you can open files referenced in the series as we go: +> +> ```bash +> git clone git@github.com:ConductionNL/hydra.git +> cd hydra +> ``` +> +> Reading-only is totally fine too — the clone only becomes a hard requirement in [Part 5](/academy/hydra-tutorial-5-starting-a-run), where you actually trigger a run. + +## The two repos: Hydra vs `.github` + +Before we dive in, a quick orientation that will make every later module easier to read. Two Conduction repos come up constantly across the series, and they have **separate jobs**: + +- **[`ConductionNL/hydra`](https://github.com/ConductionNL/hydra)** *(private)* — the **factory**. The pipeline flows (`flows/`), the agent personas (Al Gorithm, Juan Claude, Clyde, Axel), the remaining operator scripts, the `.claude/skills/` catalogue (`hydra-gate-*`, `opsx-*`, `test-*`), OpenSpec changes. Everything that *runs*. Clone it only if you want to run Hydra locally or modify a skill. +- **[`ConductionNL/.github`](https://github.com/ConductionNL/.github)** *(public)* — the **manual**. Developer guides, way-of-work, ISO compliance, the public Hydra one-pager you're (optionally) reading as warm-up, and shared tooling: the global Claude settings, and the `conduction/hydra-gates` package that holds the gate logic (part 3). Everything you *read or install*. + +**Hydra is the factory, `.github` is the manual.** Two repos, two purposes. The full breakdown lives in [`.github/docs/hydra/README.md`](https://github.com/ConductionNL/.github/blob/main/docs/hydra/README.md#hydra-repo-vs-github-repo). + +## In one sentence + +Hydra is a **label-driven pipeline** that turns an OpenSpec change into a feature branch with code and a PR carrying the gate results — ready for human review and approval. + +Not an "AI assistant that helps the developer", but a **factory**. You put an OpenSpec change in one end and a PR comes out the other end. Hydra never merges it. + +Concretely: Hydra works on the `openspec/changes//` folder — `proposal.md`, `design.md`, `tasks.md` and the spec delta. If those terms are new, take the OpenSpec series first (see tip at the top). + + + You prepare an OpenSpec change (by hand or with /opsx-ff) and commit it on the repo's development branch. You name it in the tracking issue's body (openspec/changes/<slug>) and label that issue build:queued. That label is the trigger. The hydra-sequencer flow finds the issue, reads the change name from the issue body, checks that the change directory exists, and has an agent implement it from proposal.md, tasks.md, design.md and specs/. An issue that names no change, or names a change that isn't there, gets build:blocked instead of a build. + + +## How a change travels, end to end + +A short overview; part 2 goes through every step. + +1. **Build.** An agent implements the change in a checkout of the base branch. The pipeline, not the agent, commits and pushes the result to `feature//hydra-build`. +2. **Gates.** The pipeline runs the `hydra-gates` suite over the diff. If any gate fails, there is **one** fix pass: the agent gets the gate output and fixes only what it names, and the gates run once more. +3. **PR.** Hydra opens a PR whether the gates ended green (`build:pass`) or red (`build:fail`). The gate output is in the PR body. +4. **Review, when asked.** Nothing queues a review automatically. When someone labels the issue `code-review:queued`, three stages run one after another, each only if the previous one passed: code review, security review, applier. +5. **A human merges.** No flow merges a PR, and no flow sets a `done` label. + +## Why does Hydra exist? + +Conduction builds a lot of open-source apps for the Nextcloud workplace. The rhythm of that factory is determined by two things: how fast we can write **changes**, and how fast those changes become **code**. Hydra automates that second step, with three explicit goals: + +1. **Shorten lead time.** The step from a written change to a first reviewable PR is done by an agent, not by hand. +2. **Lock in quality.** Every build runs the mechanical `hydra-gates` suite before its PR is opened, and the PR shows the result. On top of that sit three independent AI review stages you can queue. +3. **Preserve the four-eyes principle.** Hydra **does not merge**. There is no auto-merge path: a human reads the PR and presses the button. + +## The four personas + +Hydra has four personas. Each plays one role and has its own brief under `agents//`. You don't have to memorise their names now — they come back in part 2 — but it helps to spot the role pattern early: + + + Builds the code from the change. Never sees reviews. Has no shell and no forge credential: it edits files, and the pipeline commits and pushes them. + + + + Reviews the PR diff. His brief allows small, mechanical fixes within scope. + + + + Runs after Juan Claude, and only when code review passed. Same shape, with a security brief: CWE classes such as injection, XSS, path traversal and hardcoded secrets. + + + + Decides binarily: does this pass or not? Runs after a passed security review. His brief gives him no fix authority. + + +About models: each persona's `agents//config.yaml` names **Sonnet**. The flows themselves pass no model flag, so a stage runs on whatever model the hermiq runner that executes it is configured with. Environment variables such as `HYDRA_BUILDER_MODEL` do not steer the flows; `HYDRA_REVIEWER_MODEL` is read only by the manual `scripts/manual-review.sh`. + +> **History note:** an earlier design ran the builder on **Haiku** — building looked like cheap pattern-following. In practice Haiku was caught *skipping quality gates* despite explicit instructions, so the builder's config was moved to Sonnet. If you read older docs that say "builder = Haiku", this is why they're out of date. + +## When to use, when not + +Hydra is no silver bullet. The rule of thumb: + +**Use it when:** + +- The work fits inside one coherent OpenSpec change (one feature, not a refactor of the whole system). +- The acceptance criteria can be checked mechanically (tests exist, or can be written quickly with PHPUnit/Newman/Playwright). +- The target repo is **ready** for Hydra: it has the stage labels (`scripts/create-labels.sh` creates them), the change is committed on its `development` branch, and the `hydra-gates` suite gives a meaningful result on it. + +**Don't use it when:** + +- It's a one-off spike, prototype, or "let's try something" without reviewer discipline. +- The architecture changes in a way that doesn't fit one PR (multi-repo refactor, breaking schema change with data migration). +- You don't trust the gates on the target repo — Hydra is only as safe as its quality checks. +- The work requires human negotiation with an external party (customer, vendor) before it can start. + +## What Hydra does **not** do + +A common mistake: thinking Hydra invents your whole product. The opposite is true. + +- **Hydra doesn't write changes (or specs).** Those are provided up front, or a human writes them by hand (with `/opsx-ff`). +- **Hydra doesn't do vision or strategy.** What features we build is a human choice. +- **Hydra doesn't decide architecture.** That's what ADRs are for, maintained by humans (see `hydra/openspec/architecture/`). +- **Hydra doesn't merge.** Not after a green build, not after three passed reviews, and not for an issue labelled `yolo`: no flow reads that label. + +## Test yourself + +Four short questions to check whether you've understood this part. Stuck? Click **Hint**. Curious about the answer? Click **Answer**. + +
+ +**1. What is Hydra's primary purpose?** + +
+ +Hydra does one specific step in our factory. Which step, and what does it hand over at the end? + +
+ +
+ +Hydra automates the step from **OpenSpec change to feature branch + PR**. Three explicit goals: + +1. **Shorten lead time** — an agent does the implementation step, not a person. +2. **Lock in quality** — every build runs the mechanical `hydra-gates` suite and the PR shows the result; three AI review stages can be queued on top. +3. **Preserve the four-eyes principle** — Hydra never merges; a human presses the button. There is no exception for `yolo`. + +
+ +
+ +
+ +**2. Why does Hydra rely on mechanical quality gates rather than only AI review?** + +
+ +AI reviewers can sound convincing without being factually correct. Mechanical checks have one property AI review by definition lacks. + +
+ +
+ +Mechanical gates are **deterministic and unpersuadable**. The same diff gives the same result, and a well-reasoned story doesn't change it. Two consequences: + +1. **Every PR carries an objective result** — the gate output is in the PR body, and a build whose gates stay red is labelled `build:fail`, so a human sees that before deciding whether to queue a review. +2. **Standards are enforceable across the whole fleet** — the same gates run on every repo, whoever reviews that day. A review stage that can't show it ran the gates is not accepted as a pass. + +AI review sits on top, not in place of. The two layers catch different classes of problems. + +
+ +
+ +
+ +**3. What are Hydra's four personas, and which one may NOT change code?** + +
+ +One persona's role is a binary gate ("does this pass or not?"). Its brief gives it no fix authority. + +
+ +
+ +The four personas: + +- **Al Gorithm** — builder. Writes code; the pipeline commits and pushes it. +- **Juan Claude van Damme** — code reviewer. His brief allows bounded, mechanical fixes. +- **Clyde Barcode** — security reviewer. The same, with a security brief. +- **Axel Pliér** — applier. Gives one binary answer: pass or fail. He judges, he doesn't intervene. + +
+ +
+ +
+ +**4. Name two situations in which you would NOT use Hydra and work by hand instead.** + +
+ +Think about: does the work fit in one change, is the target repo ready, and is there a non-technical issue that has to be resolved first? + +
+ +
+ +A few common ones: + +- **The work doesn't fit in one OpenSpec change** — multi-repo refactor, breaking schema change with data migration. +- **The target repo isn't ready** — no stage labels, no change committed on `development`, or gates you don't trust on that repo. Hydra is only as safe as its gates. +- **Spike or prototype without reviewer discipline** — "just trying something out". The overhead isn't worth the gain. +- **Human negotiation required** — a customer, vendor or legal point has to be resolved first before the work can begin. + +
+ +
+ + + + + + + + diff --git a/academy/2026-05-12-hydra-tutorial-2-drie-pipelines/index.mdx b/academy/2026-05-12-hydra-tutorial-2-drie-pipelines/index.mdx new file mode 100644 index 000000000..24b98b93e --- /dev/null +++ b/academy/2026-05-12-hydra-tutorial-2-drie-pipelines/index.mdx @@ -0,0 +1,284 @@ +--- +slug: hydra-tutorial-2-three-pipelines +title: "Hydra tutorial series — Part 2: The three pipelines" +contentType: tutorial +heroIcon: pipeline +authors: [conduction] +date: 2026-05-12 +summary: Build, code review and security review. Who does what, in what order, and how labels on the issue stitch the whole ride together. The second of seven short modules. +tags: [Hydra, Pipelines, Personas, Tutorial series] +apps: [] +durationMinutes: 15 +series: hydra-tutorial +partNumber: 2 +--- + +import {Outcomes, Outcome, Prerequisites, PrerequisiteItem, NextSteps, NextStep, HexCard} from '@conduction/docusaurus-preset/components'; +import Details from '@theme/Details'; + +In part 1 you saw that Hydra has four personas. In this part we look at the **pipeline**: how those personas work on one issue one after another, which labels mark the transitions, and where the pipeline stops. By the end you'll be able to read an issue's labels and know exactly what Hydra did, what it will do next, and what it is waiting for you to do. + +{/* truncate */} + + + The build lane and its four outcomes: build:pass, build:fail, build:no-change, build:blocked. + The review lane (code review → security review → applier) and how each verdict is routed. + How one scheduled flow, hydra-sequencer, picks one unit of work per tick. + Which labels no flow acts on, including retry:queued, rebuild:queued and yolo. + + + + + Part 1 read: What is Hydra? + + A notion of what labels on a GitHub issue are and how you add them. + + +## Three pipelines, two lanes + +Hydra has three pipelines: **build**, **code review** and **security review**, with the applier as a third review stage after security. They are all driven by labels on the tracking issue, and they fall into two lanes. The build lane starts when you label an issue `build:queued`. The review lane starts only when someone labels it `code-review:queued` — **nothing queues it for you**, not even a green build. + +``` +build:queued ← you apply it + ↓ build → gates → (one fix pass → gates) +build:pass PR opened, gates green +build:fail PR opened, gates still red +build:no-change the agent changed nothing, nothing pushed +build:blocked no change named, change not found, or the build could not finish + +code-review:queued ← you apply it + ↓ +code-review:pass → security-review:queued +code-review:fail → stop +security-review:pass → applier:queued +security-review:fail → stop +applier:pass → end: a human reviews and merges the PR +applier:fail → stop + +needs-input ← a review stage with no readable verdict, a verdict that + doesn't show the gates ran, or a stage that could not run +``` + +Every stop is final as far as Hydra is concerned: there is no automatic retry and no fix loop after review. A human reads the PR and the labels and decides what happens next. Part 6 goes into that. + +## Pipeline 1: Build (Al Gorithm) + +The `hydra-sequencer` flow picks up an issue labelled `build:queued` and works through it in these steps: + +1. **Find the change.** The change name is read from the issue **body**: the first `openspec/changes/` it mentions. No change named → `build:blocked`. +2. **Pick the base branch.** `development` if the repo has one, otherwise the repo's default branch. The change directory must exist on that branch, otherwise → `build:blocked`. +3. **Claim capacity.** A slot from the shared pool and a per-issue lock (see [Labels](#labels-one-source-of-truth) below). +4. **Build.** An agent implements the change in a checkout of the base branch, reading `proposal.md`, `tasks.md`, `design.md` and `specs/`. It can read and edit files but has no shell and is told not to run git. The **pipeline** commits and pushes its work to `feature//hydra-build`, limited to `lib/`, `src/`, `tests/`, `openspec/`, `appinfo/`, `openapi.json`, `CHANGELOG.md` and `README.md`. If the agent changed nothing → `build:no-change`. If the build could not finish (for example because it touched a file outside that list and the push was refused) → `build:blocked`. +5. **Gates.** The pipeline runs `scripts/run-hydra-gates.sh --scope-to-diff --base origin/`. Exit code 0 means zero failures → `build:pass`. +6. **One fix pass.** Otherwise the agent gets the gate output and fixes only what the gates named. If it changes nothing → `build:fail`. If it changes something, the gates run again: 0 → `build:pass`, anything else → `build:fail`. +7. **PR.** On both `build:pass` and `build:fail` Hydra opens a PR from `feature//hydra-build` to the base branch, titled `: built by hydra for #`, with `Closes #` and the gate output in the body. It is not merged. +8. **Clean up.** `build:queued` is removed, lock and slot are released, and the stage is recorded in `hydra.json`. + +The flow runs only the `hydra-gates` suite. PHPUnit, Psalm, PHPStan, Newman and browser tests are **not** part of the build lane; `scripts/run-quality.sh` and `scripts/run-browser-tests.sh` still exist, but no flow calls them. Part 3 covers what the gates do check. + +`build:fail` does **not** lead to `needs-input`. The PR is there, with the red gate output in its body, and it's up to a human to read it. + + + No reviews, no verdict history, no labels. The build prompt carries the change and the issue; the fix pass carries only the gate output. Reviewer findings never reach the builder: no flow feeds them back (see retry and rebuild below). + + +## Pipeline 2: Code review (Juan Claude van Damme) + +Nothing moves an issue from `build:pass` to review. You decide that, by applying `code-review:queued` — for example with the operator script, which also moves the project-board card: + +```bash +scripts/hydra-label.sh ConductionNL/ add code-review:queued +``` + +The flows only read bare label names, so run it without a `HYDRA_LABEL_PREFIX` set. + +When the sequencer picks the issue up, it starts Juan Claude as an asynchronous agent on `feature//hydra-build`: + +1. He reads his own brief first: `agents/juan-claude-van-damme/` plus the reviewer brief under `images/reviewer/`. That brief is the authority on what he checks and what he may fix. +2. His review is scoped to the PR diff (ADR-020), so every line he comments on is actually touched in this PR. +3. He runs the mechanical gates and walks the ADRs and gate skills (see part 3). +4. His brief allows **bounded, mechanical fixes** (ADR-021). Missing licence header? He may add it. A naming preference? Not his department. Unlike the build, the review stage has no push step of its own; committing a bounded fix is left to his brief. +5. He writes his answer to `hydra-verdict.json` in the repository root. A later sequencer tick collects that file. + +## Pipeline 3: Security review (Clyde Barcode) + +`security-review:queued` is added only when code review **passed**. A failed code review stops there. Clyde then runs on the branch as code review left it: + +- Same shape as code review, with a security brief (`agents/clyde-barcode/` and `images/security/`): CWE classes such as SQL injection, XSS, path traversal and hardcoded secrets, with Semgrep scanning in his brief's toolset. +- His brief also allows bounded fixes. +- His verdict goes into `hydra-verdict.json` too. + +Why sequential and not parallel? Because Clyde reviews what Juan Claude left behind, and only work that passed code review is worth a security review. Parallel would mean Clyde looks at code that code review might still reject or change. + +## How verdicts are persisted + +Each review stage writes one file, **`hydra-verdict.json`**, in the root of its checkout: + +```json +{"verdict": "pass", "checks_run": ["hydra-gates"], "checks_skipped": [], "findings": [], "fixes_applied": [], "unfixed": []} +``` + +The stage's filesystem is thrown away when it ends, so the flow collects that file by name. The sequencer then routes on it: + +- `verdict: pass` → `:pass`, and the next stage's `:queued` label is added. +- `verdict: fail` → `:fail`, and nothing further is queued. +- File missing or unreadable, `checks_run` without `hydra-gates`, or a stage that could not run → **`needs-input`**. This is the only place `needs-input` is set; the build lane never sets it. + +After a pass or fail the stage's `:queued` label is removed, the opposite verdict label is cleared, and lock and slot are released. After `needs-input` the stage's `:queued` label **stays**, so the sequencer picks the issue up again on every tick until someone removes that label. + +Separately, the `hydra-record-stage` flow writes a stage record into `openspec/changes//hydra.json` after the label has moved, and refuses a stage result that carries no owner. Known gap: review stages are currently recorded under the stage name `build`. Part 6 shows how to read `hydra.json`. + +## The applier: Axel Pliér's binary gate + +`applier:queued` is added when security review passes. There is no rule that skips the applier; every issue that passes security review reaches Axel. He runs through the same generic stage step as the reviewers, reads his brief (`agents/axel-plier/` and `images/applier/`), and answers with one binary verdict in `hydra-verdict.json`: pass or fail. + +His brief gives him **no fix authority**: he judges, he doesn't intervene. (In the retired container pipeline his container had no Write or Edit tools at all; in the flow the stage step is shared, so "no fixes" is his brief's rule.) + +`applier:pass` is the end of the lane. Nothing merges the PR and no `done` label is set: a human reviews the PR and merges it. + +## Labels: one source of truth + +The pipeline's state lives in the **issue labels** on GitHub, plus a per-issue lock while a stage is in flight. There are no `:running` labels: "in progress" is the lock, not a label. + + + Both live in hydra's flows/ folder and run in OpenRegister, with the stages executed by the hermiq ExApp. hydra-sequencer is the pipeline. It runs every five minutes, searches the whole organisation for queued issues, claims a slot and a per-issue lock, runs the stage, and writes the labels itself. hydra-dispatch does not search the forge and does not hand work to the sequencer: given an issue reference, it claims a slot (capacity 10) and runs one hermiq agent step. Both ship with enabled: false, so they run only on an instance where someone switched them on. The retired shell supervisor and orchestrate.sh did this work until 2026. Part 7 shows how the flows run unattended. + + +**One tick, one unit of work.** Each sequencer run does at most one thing, in this priority order: + +1. Collect a review stage that is already in flight. +2. Otherwise the first open issue labelled `applier:queued`, +3. then `security-review:queued`, +4. then `code-review:queued`, +5. then `build:queued`. + +Each lookup is a GitHub search, `org:ConductionNL is:issue is:open label:"
+ + +
+ +
+
+ {/* Eén regel per alinea, en het e-mailadres als JSX-string. Over + meerdere regels leest MDX de inhoud van een blok-tag als markdown + en zet er een eigen alinea omheen, dus

in

; de parser van + de browser splitst dat en de pagina mislukt in hydration. Kale + tekst als info@conduction.nl wordt bovendien automatisch gelinkt, + wat een in deze zette. */} +

Conduction B.V.

+

Lauriergracht 14h
1016 RR Amsterdam
Nederland

+

+31 85 303 6840
{'info@conduction.nl'}

+
+
+

Bedrijfsgegevens

+

KvK: 76741850
BTW: NL860784241B01
IBAN: NL51 ABNA 0868951550

+
+
+
diff --git a/i18n/nl/docusaurus-plugin-content-pages/demo.mdx b/i18n/nl/docusaurus-plugin-content-pages/demo.mdx index d89fe851e..4ca8f0041 100644 --- a/i18n/nl/docusaurus-plugin-content-pages/demo.mdx +++ b/i18n/nl/docusaurus-plugin-content-pages/demo.mdx @@ -41,7 +41,7 @@ docker compose up -d`} Log in als admin, klik je avatar rechtsboven, kies Apps. De Conduction-apps staan onder Integration en Office & text. Eén klik per app installeert. - OpenRegister levert een schema-bootstrap met voorbeelddata. OpenCatalogi indexeert de eerste registers. MyDash bouwt een dashboard van wat je hebt. Je kunt nu rondklikken zoals een bestaande klant. + OpenRegister levert een schema-bootstrap met voorbeelddata. OpenCatalogi indexeert de eerste registers. LaunchPad bouwt een dashboard van wat je hebt. Je kunt nu rondklikken zoals een bestaande klant. @@ -107,7 +107,7 @@ docker compose down -v`} eyebrow="Kies de apps" title="Drie installs voor de eerste demo." align="stack" - lede="Onze suggestie: begin met OpenRegister (de typed-data backbone), OpenCatalogi (publieke surface), en OpenConnector (haalt voorbeelddata binnen). Vijftien minuten samen, en je ziet de pipeline werken." + lede="Onze suggestie: begin met OpenRegister (de typed-data backbone), OpenCatalogi (publieke surface), en Integriq (haalt voorbeelddata binnen). Vijftien minuten samen, en je ziet de pipeline werken." /> why="Maak elk register doorzoekbaar als publieke entry. Federation naar data.overheid.nl." /> } - name="OpenConnector" + name="Integriq" why="Trekt voorbeelddata binnen via REST/SOAP/file drops. Alle 12 apps profiteren." /> diff --git a/i18n/nl/docusaurus-plugin-content-pages/index.mdx b/i18n/nl/docusaurus-plugin-content-pages/index.mdx index d1ce9109f..730e3c6d4 100644 --- a/i18n/nl/docusaurus-plugin-content-pages/index.mdx +++ b/i18n/nl/docusaurus-plugin-content-pages/index.mdx @@ -6,7 +6,18 @@ hide_table_of_contents: true import {Hero, StatsStrip, AppsPreview, CtaBanner, PairRow, PairCard, Section, SectionHead, Clients, DEFAULT_CLIENTS, PartnerDirectory, CommonGroundPlus, HexRain} from '@conduction/docusaurus-preset/components'; import {totalDownloads, formatDownloads} from '@conduction/docusaurus-preset/data'; -import {PARTNERS, totalPartners} from '@site/src/data/partners-catalog'; +import {usePartners, useBecomePartner, totalPartners} from '@site/src/data/partners-catalog'; + +export function HomePartnerDirectory() { + const partners = usePartners(); + const becomePartner = useBecomePartner(); + return ( + + ); +} {/* Conduction main landing. Public Tech framing: tech to serve people. Three doors out: install an app yourself, get it as managed SaaS, or @@ -56,11 +67,11 @@ import {PARTNERS, totalPartners} from '@site/src/data/partners-catalog'; icon: , }, { - name: 'OpenConnector', + name: 'Integriq', tagline: <>Verbind Nextcloud met alles. REST, SOAP, GraphQL, file drops, message queues. Geen middleware., status: 'BETA', version: 'v1.2 · NL · EN', - href: '/apps/openconnector', + href: '/apps/integriq', icon: , }, ]} @@ -101,16 +112,7 @@ import {PARTNERS, totalPartners} from '@site/src/data/partners-catalog'; align="stack" lede="Implementatie-, hosting- en integratiepartners die Conduction-apps bij klanten uitrollen. Vraag aan via /support om opgenomen te worden." /> - + , }, { - name: 'OpenConnector', + name: 'Integriq', tagline: <>Verbind Nextcloud met alles: REST, SOAP, GraphQL, file drops, message queues., status: 'BETA', version: 'v1.2', - href: '/apps/openconnector', + href: '/apps/integriq', icon: , }, ]} diff --git a/i18n/nl/docusaurus-plugin-content-pages/iso.mdx b/i18n/nl/docusaurus-plugin-content-pages/iso.mdx deleted file mode 100644 index 2123ceec4..000000000 --- a/i18n/nl/docusaurus-plugin-content-pages/iso.mdx +++ /dev/null @@ -1,56 +0,0 @@ ---- -title: ISO-certificeringen -description: Conduction is ISO 9001:2015 en ISO 27001:2022 gecertificeerd. Wat valt eronder, waar zit het bewijs, wat is de scope. -hide_table_of_contents: true ---- - -import {Section, SectionHead} from '@conduction/docusaurus-preset/components'; - -
- -
- -
- -## ISO 9001:2015, kwaliteitsmanagement - -ISO 9001:2015 is de internationale norm voor kwaliteitsmanagementsystemen. De certificering bevestigt dat Conduction een gedocumenteerd KMS hanteert, interne audits uitvoert en directiebeoordelingen houdt. De auditcyclus dekt klantgerichtheid, leiderschap, planning, ondersteuning, uitvoering, prestatiebeoordeling en verbetering. - -- **Scope.** Softwareontwikkeling, hosting en advieswerk voor publieke en MKB-klanten in Nederland. -- **Certificerende instelling.** _Vermeld op het certificaat. Vraag ons om een kopie van het actuele certificaat of de scope-verklaring._ -- **Eerste afgifte.** _Op het certificaat._ -- **Vernieuwingscyclus.** Jaarlijkse tussentijdse audit, hercertificering elke drie jaar. - -## ISO 27001:2022, informatiebeveiliging - -ISO 27001:2022 is de internationale norm voor informatiebeveiligingsmanagementsystemen. De certificering bevestigt dat Conduction een gedocumenteerd ISMS hanteert dat aansluit op de 2022-controleset, met annex-A controls geïmplementeerd en getoetst. - -- **Scope.** Alle systemen van Conduction, de apps die we ontwikkelen, de managed Common Ground componenten gehost op commonground.nu, en de ontwikkel- en operatieprocessen daaromheen. -- **Certificerende instelling.** _Vermeld op het certificaat. Vraag ons om een kopie van het actuele certificaat, de scope-verklaring of de Statement of Applicability._ -- **Eerste afgifte.** _Op het certificaat._ -- **Vernieuwingscyclus.** Jaarlijkse tussentijdse audit, hercertificering elke drie jaar. - -De privacykant van het ISMS staat in het [privacybeleid](/privacy). De DPIA-aanpak vind je daar ook. - -
- -
- -## Aanverwante compliance-signalen - -Naast de twee ISO-certificeringen ziet het inkoop-relevante compliance-beeld voor Conduction er zo uit: - -- **ISAE 3402.** De managed hosting op commonground.nu draait op infrastructuur van [Cyso](https://www.cyso.nl/) onder ISAE 3402 Type II. Hun verklaring sturen we op verzoek toe. -- **BIO.** De afstemming op de Baseline Informatiebeveiliging Overheid loopt. Statusupdates komen hier zodra die rond is. -- **DigiD.** Buiten de scope van onze huidige portfolio. We integreren met DigiD-systemen, maar hebben zelf geen DigiD-assessment. - -## Bewijs opvragen - -Heeft je inkoopdossier het echte certificaat, de SoA of een auditrapport nodig, mail dan naar [info@conduction.nl](mailto:info@conduction.nl) met je contract- of aanbestedingsreferentie. We sturen de stukken direct toe. - -
diff --git a/i18n/nl/docusaurus-plugin-content-pages/partners.mdx b/i18n/nl/docusaurus-plugin-content-pages/partners.mdx index d8875f3c7..b48638300 100644 --- a/i18n/nl/docusaurus-plugin-content-pages/partners.mdx +++ b/i18n/nl/docusaurus-plugin-content-pages/partners.mdx @@ -5,15 +5,19 @@ hide_table_of_contents: true --- import {Section, SectionHead, PartnerDirectory} from '@conduction/docusaurus-preset/components'; -import {PARTNERS, BECOME_PARTNER} from '@site/src/data/partners-catalog'; +import {usePartners, useBecomePartner} from '@site/src/data/partners-catalog'; -export const BECOME_PARTNER_NL = { - ...BECOME_PARTNER, - eyebrow: 'Word partner', - title: 'Lever Conduction aan jouw klanten.', - body: 'Sluit je aan als Partner, Gecertificeerd of Strategisch. Conduction traint je team, jij doet de uitrol. De apps blijven open source, de relatie blijft direct.', - ctaLabel: 'Aanvragen via Support', -}; +export function PartnerDirectoryBlock() { + const partners = usePartners(); + const becomePartner = useBecomePartner(); + return ( + + ); +}
De apps zijn open source en gratis. De meeste teams installeren en draaien ze zelf. Wil je hulp bij hosting, maatwerk-integraties of een uitrol in een gereguleerde sector? Onze gecertificeerde partners pakken het op. Ze draaien dezelfde Nextcloud als jij en bouwen op dezelfde apps.} /> - +
diff --git a/i18n/nl/docusaurus-plugin-content-pages/partners/acato.mdx b/i18n/nl/docusaurus-plugin-content-pages/partners/acato.mdx index 85d8a0af6..b3d459bc5 100644 --- a/i18n/nl/docusaurus-plugin-content-pages/partners/acato.mdx +++ b/i18n/nl/docusaurus-plugin-content-pages/partners/acato.mdx @@ -1,6 +1,6 @@ --- title: Acato, Strategisch partner, Conduction -description: Nederlandse open-source-specialist uit Almere. Bouwt en host Nextcloud-omgevingen voor gemeenten en zorginstellingen. +description: Nederlandse open-source-specialist uit Den Haag. Bouwt en host Nextcloud-omgevingen voor gemeenten en zorginstellingen. hide_table_of_contents: true --- @@ -8,11 +8,11 @@ import {DetailHero, Section, SectionHead, FeatureList, FeatureItem, ReferenceGri Nederlandse open-source-specialist uit Almere. Bouwt en host Nextcloud-omgevingen voor gemeenten en zorginstellingen, al meer dan tien jaar, met focus op AVG-compliance, NLDS-conformiteit en langetermijn-beheer.} + tagline={<>Nederlandse open-source-specialist uit Den Haag. Bouwt en host Nextcloud-omgevingen voor gemeenten en zorginstellingen, al meer dan tien jaar, met focus op AVG-compliance, NLDS-conformiteit en langetermijn-beheer.} primaryCta={{label: 'Praat met Acato', href: 'mailto:hello@acato.nl'}} secondaryCta={{label: 'Bezoek acato.nl', href: 'https://acato.nl'}} tertiaryCta={{label: 'Alle partners', href: '/partners'}} @@ -43,7 +43,7 @@ import {DetailHero, Section, SectionHead, FeatureList, FeatureItem, ReferenceGri } - title="DocuDesk voor inkomende zaakcorrespondentie." + title="Filinq voor inkomende zaakcorrespondentie." > Auto-classificatie, anonimisering en routing voor de hoge volumes inkomende post bij uitvoeringsorganisaties. Sjablonen voor jaarverslagen, beschikkingen en bezwaar. @@ -62,19 +62,19 @@ import {DetailHero, Section, SectionHead, FeatureList, FeatureItem, ReferenceGri sector="Gemeente" name="Gemeente Rotterdam" what="Woo-portaal in productie sinds Q2 2025. Elf categorieën live, 18.000 documenten publiek doorzoekbaar, audit log gekoppeld aan het DMS." - stack={['OpenCatalogi', 'OpenRegister', 'OpenConnector']} + stack={['OpenCatalogi', 'OpenRegister', 'Integriq']} /> @@ -86,14 +86,14 @@ import {DetailHero, Section, SectionHead, FeatureList, FeatureItem, ReferenceGri align="stack" lede="Past Acato niet bij jouw sector of stack? Deze partners dekken de gaten af." /> -
+
diff --git a/i18n/nl/docusaurus-plugin-content-pages/partners/procolix.mdx b/i18n/nl/docusaurus-plugin-content-pages/partners/procolix.mdx index a00145a7d..c9d440e88 100644 --- a/i18n/nl/docusaurus-plugin-content-pages/partners/procolix.mdx +++ b/i18n/nl/docusaurus-plugin-content-pages/partners/procolix.mdx @@ -1,6 +1,6 @@ --- title: Procolix, Gecertificeerd partner, Conduction -description: Officieel Nextcloud-distributeur. Gecertificeerd op DocuDesk en OpenConnector voor procesintegraties bij gemeenten en uitvoeringsorganisaties. +description: Officieel Nextcloud-distributeur. Gecertificeerd op Filinq en Integriq voor procesintegraties bij gemeenten en uitvoeringsorganisaties. hide_table_of_contents: true --- @@ -12,7 +12,7 @@ import {DetailHero, Section, SectionHead, FeatureList, FeatureItem, ReferenceGri version="Opgericht 2007" locales="Hilversum, NL · 35 medewerkers" title="Procolix." - tagline={<>Nederlandse hoster en leverancier van proces- en zaakmanagementsoftware. Officieel Nextcloud-distributeur. Gecertificeerd op DocuDesk en OpenConnector voor procesintegraties bij gemeenten en uitvoeringsorganisaties.} + tagline={<>Nederlandse hoster en leverancier van proces- en zaakmanagementsoftware. Officieel Nextcloud-distributeur. Gecertificeerd op Filinq en Integriq voor procesintegraties bij gemeenten en uitvoeringsorganisaties.} primaryCta={{label: 'Praat met Procolix', href: 'mailto:hello@procolix.com'}} secondaryCta={{label: 'Bezoek procolix.com', href: 'https://procolix.com'}} tertiaryCta={{label: 'Alle partners', href: '/partners'}} @@ -31,13 +31,13 @@ import {DetailHero, Section, SectionHead, FeatureList, FeatureItem, ReferenceGri } - title="Gecertificeerd op DocuDesk." + title="Gecertificeerd op Filinq." > - Procolix is door Conduction gecertificeerd op DocuDesk-implementaties. Documentflows voor gemeenten, jaarverslagen, beschikkingen, bezwaarbrieven. + Procolix is door Conduction gecertificeerd op Filinq-implementaties. Documentflows voor gemeenten, jaarverslagen, beschikkingen, bezwaarbrieven. } - title="Gecertificeerd op OpenConnector voor zaaksystemen." + title="Gecertificeerd op Integriq voor zaaksystemen." > Officiële koppelingen met de Nederlandse zaaksystemen-stack: Atos, Centric, GovUnited, plus de open ZGW APIs. Getest en gecertificeerd. @@ -60,20 +60,20 @@ import {DetailHero, Section, SectionHead, FeatureList, FeatureItem, ReferenceGri @@ -84,7 +84,7 @@ import {DetailHero, Section, SectionHead, FeatureList, FeatureItem, ReferenceGri title="Aangrenzende specialisten." align="stack" /> -
+
diff --git a/i18n/nl/docusaurus-plugin-content-pages/partners/yard.mdx b/i18n/nl/docusaurus-plugin-content-pages/partners/yard.mdx index 5a1e3c7c8..9c5bb3111 100644 --- a/i18n/nl/docusaurus-plugin-content-pages/partners/yard.mdx +++ b/i18n/nl/docusaurus-plugin-content-pages/partners/yard.mdx @@ -1,6 +1,6 @@ --- title: YARD, Partner, Conduction -description: Digitaal design- en developmentbureau uit Utrecht. Sterk op MyDash en OpenCatalogi voor citizen-facing surfaces. +description: Digitaal design- en developmentbureau uit Utrecht. Sterk op LaunchPad en OpenCatalogi voor citizen-facing surfaces. hide_table_of_contents: true --- @@ -8,7 +8,7 @@ import {DetailHero, Section, SectionHead, FeatureList, FeatureItem, ReferenceGri } - title="MyDash voor het burgerportaal." + title="LaunchPad voor het burgerportaal." > - Dashboards voor inwoners: subsidiestatus, vergunningstatus, zaakvolgportaal. YARD is het referentiebureau om MyDash op de gemeente te laten lijken in plaats van op Conduction. + Dashboards voor inwoners: subsidiestatus, vergunningstatus, zaakvolgportaal. YARD is het referentiebureau om LaunchPad op de gemeente te laten lijken in plaats van op Conduction. } @@ -60,8 +60,8 @@ import {DetailHero, Section, SectionHead, FeatureList, FeatureItem, ReferenceGri @@ -84,7 +84,7 @@ import {DetailHero, Section, SectionHead, FeatureList, FeatureItem, ReferenceGri title="Aangrenzende specialisten." align="stack" /> -
+
diff --git a/i18n/nl/docusaurus-plugin-content-pages/privacy.mdx b/i18n/nl/docusaurus-plugin-content-pages/privacy.mdx index da07aece4..427b6af8f 100644 --- a/i18n/nl/docusaurus-plugin-content-pages/privacy.mdx +++ b/i18n/nl/docusaurus-plugin-content-pages/privacy.mdx @@ -1,37 +1,295 @@ --- title: Privacy -description: Het AVG-conforme privacybeleid van Conduction. Hoe we omgaan met persoonsgegevens, wat je rechten zijn, hoe we risico's wegen. +description: Het AVG-conforme privacybeleid van Conduction. Doelgebonden gebruik, minimale bewaartermijn, sterke beveiliging en jouw rechten, gekoppeld aan een ISMS en ISO 27001-conforme maatregelen. hide_table_of_contents: true --- -import {Section} from '@conduction/docusaurus-preset/components'; +import {DetailHero, Section, Showcase, FAQ, FAQItem} from '@conduction/docusaurus-preset/components'; +import CookieSettings from '@site/src/components/CookieSettings'; -
+export const PrivacyIcon = ( + + + + +); + +export const PROMISE_PANELS = { + purpose: ( +
+
Doelbinding
+
+ {[ + {k: 'Verzameld voor', v: 'Een bepaald doel'}, + {k: 'Gebruikt voor', v: 'Datzelfde doel'}, + {k: 'Hergebruikt voor', v: 'Niets anders'}, + ].map((row, i) => ( +
+ {row.k} + {row.v} +
+ ))} +
+
AVG art. 5(1)(b), "doelbinding".
+
+ ), + sharing: ( +
+
Wat we met jouw gegevens doen
+
    + {[ + {ok: false, l: 'Verkocht aan databrokers'}, + {ok: false, l: 'Gebruikt voor advertentietargeting'}, + {ok: false, l: 'Standaard met derden gedeeld'}, + {ok: true, l: 'Alleen gedeeld als nodig voor het opslagdoel'}, + {ok: true, l: 'Verwerkersovereenkomst op verzoek'}, + ].map((row, i) => ( +
  • + {row.ok ? '✓' : '✕'} + {row.l} +
  • + ))} +
+
+ ), + retention: ( +
+
Bewaarbeginsel
+
+ Gegevens worden niet langer bewaard dan het opslagdoel vereist. Eindigt het doel, dan eindigen de gegevens. +
+
Aanleidingen voor verwijdering
+
    + {['Doel is vervuld', 'Wettelijke bewaartermijn verstreken', 'Recht op vergetelheid uitgeoefend', 'Contract beëindigd'].map((l, i) => ( +
  • + + {l} +
  • + ))} +
+
+ ), + security: ( +
+ ISO 27001:2022-certificaat +
ISO/IEC 27001:2022 + ISMS
+
+ ), + consent: ( +
+
Wat we wanneer vertellen
+
+ {[ + {step: 'Voor opslag', text: 'We vragen om toestemming.'}, + {step: 'Bij verzameling', text: 'We benoemen het doel.'}, + {step: 'Altijd', text: 'We informeren over je rechten.'}, + {step: 'Bij wijziging', text: 'We melden ongebruikelijke verwerkingen vooraf.'}, + ].map((row, i) => ( +
+ {row.step} + {row.text} +
+ ))} +
+
+ ), + rights: ( +
+ {[ + {n: 'Inzage', l: 'Wat hebben wij van jou?'}, + {n: 'Rectificatie', l: 'Onjuistheden herstellen'}, + {n: 'Vergetelheid', l: 'Verwijderen'}, + {n: 'Beperking', l: 'Verwerking inperken'}, + {n: 'Overdraagbaarheid', l: 'Meenemen van data'}, + {n: 'Bezwaar', l: 'Verwerking stoppen'}, + ].map((b, i) => ( +
+
{b.n}
+
{b.l}
+
+ ))} +
+ ), +}; + +Jouw gegevens,
jouw voorwaarden.} + tagline={<>Privacy is een belofte. Deze pagina is het privacybeleid waar Conduction zich aan committeert voor elk contact, elke klant en elke bezoeker: doelgebonden gebruik, minimale bewaartermijn, ISO 27001-conforme beveiliging en de rechten die de AVG je geeft. Deze Nederlandse tekst is juridisch bindend.} + primaryCta={{label: 'Lees de beloftes', href: '#promises', tone: 'orange'}} + secondaryCta={{label: 'Quality & ISO →', href: '/quality'}} + iconColor="var(--c-orange-knvb)" + icon={PrivacyIcon} +/> + +
+ + +
+
-# Privacy +## Privacybeleid -Dit is de Nederlandse en juridisch bindende versie van ons privacybeleid. +
-## Privacybeleid (op grond van de AVG, afgeleid van EU-privacyverordening 2016/679) +
+
Beleidsverklaring
+

Privacybeleid & DPIA, Conduction B.V.

+
+
+
Standaard
+
AVG · EU 2016/679
+
+
+
Gekoppeld aan
+
ISO/IEC 27001:2022 ISMS
+
+
+
Kantoor
+
Lauriergracht 14h, Amsterdam
+
+
+
Contact
+
info@conduction.nl
+
+
+
+ +### Privacybeleid (op grond van de AVG, afgeleid van EU-privacyverordening 2016/679) Conduction B.V. gebruikt persoonsgegevens alleen voor het doel waarvoor ze zijn opgeslagen. We delen persoonsgegevens niet met derden, tenzij dat delen nodig is voor het opslagdoel. We bewaren persoonsgegevens niet langer dan het opslagdoel vereist. We beschermen persoonsgegevens met alle beschikbare middelen en maatregelen tegen toegang door onbevoegden. We vragen onze contacten om toestemming voordat we hun persoonsgegevens opslaan. We informeren klanten over hun rechten met betrekking tot hun persoonsgegevens. We informeren onze contacten over het doel van de gegevensverwerking. We informeren contacten wanneer we ongebruikelijke handelingen willen uitvoeren op hun persoonsgegevens. -## Risicobeoordeling (Data Protection Impact Assessment, DPIA) +### Risicobeoordeling (Data Protection Impact Assessment, DPIA) Het risico dat we beheersen is dat Conduction B.V. onbedoeld informatie wijzigt, lekt of verliest, waardoor onze externe stakeholders schade lopen. Tegen dit risico passen we de maatregelen uit dit privacybeleid en ons ISMS toe. We voeren ze uit en toetsen hun effectiviteit. De procedures in het privacybeleid en ISMS worden continu geëvalueerd en verbeterd. Alle medewerkers zijn betrokken bij de beveiligingsprocedures, op de manier die in dit privacybeleid en ISMS staat beschreven. -## Procedure risicobeoordeling +### Procedure risicobeoordeling Conduction B.V. verkleint de bovenstaande risico's door te werken vanuit het privacybeleid en ISMS. Bij elke interne audit en directiebeoordeling hoort een risicobeoordeling op gegevensbeveiliging. Een restrisico blijft buiten de scope van het privacybeleid en ISMS. De bekende restrisico's voor Conduction B.V. analyseren we in onze interne audits en directiebeoordelingen. De maatregelen voor die risico's zijn onderdeel van het privacybeleid en ISMS, en worden daar beheerd en uitgevoerd. Restrisico bestaat uit extreme veranderingen in omstandigheden die Conduction B.V. niet kan voorzien. Die risico's beschouwen we als onvermijdelijk. Na een onverwacht incident voeren we een nieuwe risicobeoordeling uit. Maatregelen die daaruit volgen, nemen we op in het privacybeleid en ISMS. -## Contact +### Cookies {#cookies} + +Deze site bewaart zo min mogelijk op je apparaat. Er wordt niets opgeslagen voor advertentiedoeleinden, en er worden geen gegevens verkocht of gedeeld met derden. + +- **Essentieel** — noodzakelijk, en niet uit te zetten. Hieronder valt het vastleggen van je eigen cookiekeuze. Die staat in de local storage van je browser, onder `conduction:cookie-cli`, zodat we het niet op elke pagina opnieuw hoeven te vragen. +- **Voorkeuren** — optioneel. Onthoudt weergave-instellingen, zoals de lichte of donkere modus. +- **Analytics** — optioneel, en op dit moment ongebruikt: deze site laadt geen enkel analytics- of trackingscript. We bieden de keuze aan zodat je toestemming al is vastgelegd op het moment dat we dat wel zouden doen. + +We plaatsen geen marketing- of advertentiecookies. + +Je keuze wordt opgeslagen in je browser, niet op onze servers, en geldt alleen voor dit apparaat en deze browser. Je kunt je keuze op elk moment wijzigen. + + + +### Contact Vragen over dit beleid of over je persoonsgegevens kun je sturen naar [info@conduction.nl](mailto:info@conduction.nl). Kantoor: Lauriergracht 14h, 1016 RR Amsterdam. +
+ Vastgesteld door de directie van Conduction B.V., Amsterdam, Nederland. Deze Nederlandse tekst is de juridisch bindende versie van het privacybeleid. +
+ +
+ +
+ +
+ + +
+ + + + Het recht op inzage in de gegevens die wij over je hebben, op rectificatie, op vergetelheid (verwijdering), op beperking van de verwerking, op overdraagbaarheid van je gegevens en op bezwaar. Stuur een verzoek naar info@conduction.nl om een van deze rechten uit te oefenen. Conduction is de verwerkingsverantwoordelijke voor de gegevens die je rechtstreeks met ons deelt. + + + Stuur een verzoek naar info@conduction.nl vanaf het e-mailadres dat bij je gegevens hoort, of voeg voldoende informatie toe zodat we je kunnen identificeren. We streven ernaar binnen 2 werkdagen ontvangst te bevestigen en binnen de wettelijke termijn van 30 dagen te reageren, sneller waar mogelijk. + + + Nee. We verkopen geen persoonsgegevens en gebruiken ze niet voor advertentietargeting. We delen gegevens met derden alleen als dat nodig is voor het beoogde opslagdoel (bijvoorbeeld een betalingsverwerker of hostingpartij). In die gevallen wordt een verwerkersovereenkomst gesloten; we leveren die op verzoek aan. + + + Als je zelf host op je eigen Nextcloud, ligt de data op jouw infrastructuur en ben jij de verwerkingsverantwoordelijke. Het privacybeleid en ISO 27001 van Conduction dekken Conduction's eigen systemen en de apps die we ontwikkelen. De beveiliging van de gegevens die je op je eigen server verwerkt, is jouw verantwoordelijkheid. Wil je dat ons beleid ook de hosting dekt, gebruik dan onze managed Common Ground-tenant op commonground.nu. + + + Incidenten worden getriageerd via hetzelfde ISMS dat de dagelijkse pentest-scans aanstuurt. Als een datalek persoonsgegevens raakt, melden we dat binnen 72 uur na ontdekking bij de Autoriteit Persoonsgegevens en informeren we betrokkenen als het lek waarschijnlijk een hoog risico voor hun rechten en vrijheden oplevert. + + + De gegevens die Conduction over je heeft, staan bij EU-verwerkers. De managed Common Ground-tenant op commonground.nu draait op infrastructuur van Cyso (Nederland) onder ISAE 3402 Type II. Verandert het verwerkerslandschap, dan wordt het privacybeleid bijgewerkt en communiceren we de wijziging. + + diff --git a/i18n/nl/docusaurus-plugin-content-pages/quality.mdx b/i18n/nl/docusaurus-plugin-content-pages/quality.mdx new file mode 100644 index 000000000..d9471c9ce --- /dev/null +++ b/i18n/nl/docusaurus-plugin-content-pages/quality.mdx @@ -0,0 +1,592 @@ +--- +title: Kwaliteit, certificeringen en beleidsverklaring +description: Het kwaliteitsmanagementsysteem van Conduction. ISO 9001:2015 en ISO 27001:2022 gecertificeerd, doorlopende pentest-scans en een GitHub-native kwaliteitsworkflow. Volledige beleidsverklaring en bewijs op aanvraag. +hide_table_of_contents: true +--- + +import {DetailHero, Section, SectionHead, Showcase, WidgetShelf, FAQ, FAQItem} from '@conduction/docusaurus-preset/components'; + +export const WORKFLOW_FILE = 'https://github.com/ConductionNL/.github/blob/main/.github/workflows/quality.yml'; + +export function QualityPipelineFlow() { + const card = {background: 'var(--conduction-color-surface-card)', border: '1px solid var(--c-cobalt-100)', borderRadius: 8, padding: '14px 14px 16px', boxShadow: '0 1px 3px rgba(33, 70, 139, 0.04)'}; + const cardTitle = {fontSize: 12, fontWeight: 700, color: 'var(--conduction-color-text-strong)', marginBottom: 4, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 6}; + const cardSubtitle = {fontSize: 10, fontFamily: 'var(--conduction-typography-font-family-code)', color: 'var(--conduction-color-text-muted)', marginBottom: 10, letterSpacing: '0.04em'}; + const stageWrap = {background: 'var(--conduction-color-surface-subtle)', border: '1px solid var(--c-cobalt-100)', borderRadius: 12, padding: '18px 18px 22px'}; + const stageHead = {display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: 14, gap: 12, flexWrap: 'wrap'}; + const stageLabel = {fontSize: 10, fontWeight: 700, letterSpacing: '0.12em', textTransform: 'uppercase', color: 'var(--c-orange-knvb)', fontFamily: 'var(--conduction-typography-font-family-code)'}; + const stageTitle = {fontSize: 16, fontWeight: 700, color: 'var(--conduction-color-text-strong)'}; + const stageNote = {fontSize: 12, color: 'var(--conduction-color-text-body)'}; + const arrowRow = {height: 24, display: 'flex', justifyContent: 'center', alignItems: 'center', color: 'var(--c-cobalt-300)', fontSize: 20, fontWeight: 700}; + const hex = (color) => ({width: 10, height: 12, clipPath: 'polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%)', background: color, flexShrink: 0}); + const list = (items, color) => ( +
    + {items.map((it, i) => { + const [label, tip] = Array.isArray(it) ? it : [it, null]; + return ( +
  • + + {label} +
  • + ); + })} +
+ ); + return ( +
+
+
+
Fase 1 · parallel
Kwaliteitsgates
+ Vier matrix-jobs, fail-fast uit, bij elke PR. +
+
+
+
PHP Qualitymatrix
+
composer scripts
+ {list([ + ['lint', 'PHP -l syntax-lint via `composer lint`. Controleert dat elk PHP-bestand parseert voordat de zwaardere analyses draaien.'], + ['phpcs', 'squizlabs/php_codesniffer. Dwingt de coding standard uit phpcs.xml af (meestal PSR-12-basis plus de Nextcloud-ruleset).'], + ['phpmd', 'phpmd/phpmd. Mess Detector. Vindt problematische patronen: te lange methodes, hoge complexiteit, ongebruikte variabelen, naming-violations.'], + ['psalm', 'vimeo/psalm. Statische type-checker. Vangt type-mismatches, null-dereferences, dode code, ontbrekende return types.'], + ['phpstan', 'phpstan/phpstan. Statische analyser. Vindt type-fouten, ongedefinieerde methodes, onbereikbare branches; level per app in phpstan.neon.'], + ['phpmetrics', 'phpmetrics/phpmetrics. Genereert een code-metrics-rapport (maintainability index, cyclomatic complexity, LOC). Output als artifact.'], + ], 'var(--c-cobalt-700)')} +
+
+
Vue Qualitymatrix
+
npm scripts
+ {list([ + ['eslint', 'eslint. JavaScript/TypeScript/Vue-linter. Project-regels in eslint.config.js (flat config) of .eslintrc.'], + ['stylelint', 'stylelint. CSS/SCSS-linter. Project-regels in stylelint.config.js. Dwingt token-gebruik af en verbiedt hardgecodeerde brand-kleuren.'], + ], 'var(--c-blue-cobalt)')} +
+
+
Securitymatrix
+
audit
+ {list([ + ['composer audit', 'composer audit --format=plain --abandoned=report. Toetst geïnstalleerde packages aan de FriendsOfPHP/security-advisories-database.'], + ['npm audit', 'npm audit --audit-level=critical --omit=dev. Toetst productie-deps aan de GitHub Advisory Database; faalt op kritieke CVE\'s.'], + ], 'var(--c-orange-knvb)')} +
+
+
Licensematrix
+
SPDX-allowlist
+ {list([ + ['composer', 'composer licenses --format=json door een SPDX-allowlist (EUPL-1.2 / AGPL-3.0 compatibel). Per-package uitzonderingen via .license-overrides.json. Levert markdown + JSON-rapport.'], + ['npm', 'npm-package license-checker. Zelfde SPDX-allowlist + .license-overrides.json als de composer-kant. Levert markdown + JSON-rapport als artifact.'], + ], 'var(--c-mint-500)')} +
+
+
+ +
↓
+ +
+
+
Fase 2 · gated
Tests
+ Pas zodra PHP Quality + Security groen zijn. +
+
+
+
PHPUnitmatrix
+
PHP × Nextcloud
+ {list([ + ['php 8.3 / 8.4', 'Twee PHP-versies in de test-matrix, opgezet via shivammathur/setup-php@v2. Beide versies moeten slagen.'], + ['nc stable31 / 32', 'Twee Nextcloud server-branches (stable31 + stable32) uitgechecked uit nextcloud/server. PHPUnit draait tegen beide; beide moeten slagen.'], + ['pgsql 16 service', 'Een echte Postgres 16-container als GitHub Actions-service (postgres:16 image, health-checked met pg_isready voor de tests draaien).'], + ], 'var(--c-lavender-300)')} +
+
+
Integratienewman
+
Postman-collections
+ {list([ + ['newman run *.postman_collection.json', 'Newman 6 (postmanlabs/newman) — de Postman CLI test-runner. Draait elke *.postman_collection.json onder tests/integration tegen de Nextcloud built-in server op :8080.'], + ], 'var(--c-lavender-300)')} +
+
+
E2Eplaywright
+
Chromium + V8-coverage
+ {list([ + ['npx playwright test', '@playwright/test op headless Chromium. Draait de *.spec.ts-bestanden onder tests/e2e tegen de actieve Nextcloud op http://localhost:8080.'], + ['spec-to-test ≥ 75%', 'Maatwerk coverage-gate. Loopt openspec/specs + openspec/changes door, telt scenarios (S1, S2, REQ-…) en matcht ze tegen test()-calls in de Playwright-suites. Waarschuwt onder 75%.'], + ], 'var(--c-lavender-300)')} +
+
+
+ +
↓
+ +
+
+
+
Fase 3 · altijd
Rapportage
+
+
+
+
Kwaliteitsrapport
+
PDF + PR-comment + step summary
+ {list([ + ['aggregeert result-* artifacts', 'Gebruikt actions/download-artifact@v4 met pattern result-*. Elke matrix-job uploadt zijn job.status naar zo\'n artifact aan het einde van de run.'], + ['pandoc → wkhtmltopdf', 'pandoc zet de gegenereerde quality-report.md om naar PDF via de wkhtmltopdf-engine (op de runner geïnstalleerd via apt-get).'], + ['90 dagen bewaard', 'actions/upload-artifact@v4 met retention-days: 90. De PDF + Markdown blijven drie maanden lang downloadbaar vanuit de workflow-run.'], + ], 'var(--c-mint-500)')} +
+
+
Coverage-baseline
+
guard + auto-update
+ {list([ + ['PR: handmatige edits geblokt', 'baseline-protection-job: diffs de PR tegen de base-branch en faalt als .coverage-baseline in de gewijzigde bestanden staat. Voorkomt dat iemand de lat stilletjes verlaagt.'], + ['main / development: bijwerken als beter', 'update-baseline-job: draait php scripts/coverage-guard.php --update-baseline na PHPUnit; commit de nieuwe baseline alleen als coverage daadwerkelijk verbeterde.'], + ], 'var(--c-mint-500)')} +
+
+
+ +
+
+
Fase 4 · beschermde branches
Bill of Materials
+
+
+
SBOMCycloneDX
+
composer + npm, samengevoegd
+ {list([ + ['composer CycloneDX:make-sbom', 'cyclonedx/cyclonedx-php-composer-plugin. Genereert een CycloneDX 1.5 JSON-SBOM uit composer.lock (--omit=dev, --omit=plugin).'], + ['cyclonedx-npm', '@cyclonedx/cyclonedx-npm. Genereert een CycloneDX 1.5 JSON-SBOM uit package-lock.json (--package-lock-only, --omit dev). PHP- en npm-SBOMs worden samengevoegd met jq.'], + ['Grype CVE-scan (fail op critical)', 'anchore/grype, geïnstalleerd via anchore/scan-action/download-grype@v5. Scant de samengevoegde SBOM tegen de Grype-vulnerability-database; faalt op severity critical.'], + ['publiceer: artifact + release-asset', 'Geüpload als actions/upload-artifact@v4 (90 dagen retentie) bij elke push naar een beschermde branch, en als release-asset gehangen aan de GitHub Release via softprops/action-gh-release@v2 bij elke tag-push.'], + ], 'var(--c-forest-300)')} +
+
+
+
+ ); +} + +export const QualityIcon = ( + + + + +); + +export function HandbookPanel({iconPath, title, lines, accent}) { + return ( +
+ +
{title}
+
    + {lines.map((l, i) => ( +
  • {l}
  • + ))} +
+
+ ); +} + +export const HANDBOOK_ICONS = { + identity: <>, + wayOfWork: <>, + hydra: <>, + claude: <>, + iso: <>, + roadmap: <>, +}; + +export const QualityHeroIllustration = ( +
+
+ Certificaten · handboek · scans · workflow + ● live +
+
+
+ ISO 9001:2015 certificaat +
ISO 9001:2015
+
+
+ ISO 27001:2022 certificaat +
ISO 27001:2022
+
+
+
+
+ docs.conduction.nl · operationeel handboek + LIVE +
+
+ pentest-tools.com · dagelijkse scan + SCHOON +
+
+ GitHub PR #482 · code- en security-review + GEMERGED +
+
+
+); + +Kwaliteit,
met een K.} + tagline={<>Eén managementsysteem, vijf tools die het waarmaken. ISO 9001:2015 en ISO 27001:2022 leggen vast hoe Conduction moet werken en wat we meten. Het How We Work-handboek op docs.conduction.nl vertaalt dat naar de uitvoering. pentest-tools.com en de GitHub-native kwaliteitsworkflow toetsen de uitkomst. Elke regel code, elke dienst — dezelfde voorspelbare kwaliteit.} + primaryCta={{label: 'Lees de beleidsverklaring', href: '#iso-certifications', tone: 'orange'}} + secondaryCta={{label: 'Compliance FAQ', href: '#compliance-faq'}} + iconColor="var(--c-orange-knvb)" + icon={QualityIcon} + illustration={QualityHeroIllustration} +/> + +
+ + ISO 9001:2015 certificaat, Conduction B.V. +
+ ), + }, + { + id: 'iso-27001', + title: 'ISO 27001:2022 — Informatiebeveiliging.', + summary: "De internationale norm voor informatiebeveiligingsmanagementsystemen. Bevestigt een gedocumenteerd ISMS dat aansluit op de 2022-controleset, met Annex A-controls geïmplementeerd en getoetst. Scope: alle systemen van Conduction, de apps die we ontwikkelen, de managed Common Ground-omgeving op commonground.nu en de ontwikkel- en operatieprocessen daaromheen. Verklaring van Toepasselijkheid op aanvraag.", + cta: {label: 'Lees het informatiebeveiligingsbeleid', href: '#iso-certifications'}, + panel: ( +
+ ISO 27001:2022 certificaat, Conduction B.V. +
+ ), + }, + { + id: 'how-we-work', + title: 'How We Work — operationeel handboek.', + summary: "docs.conduction.nl is de operationele laag onder het managementsysteem. Het vertaalt het ISO-beleid naar de dagelijkse procedures die elke (digitale) medewerker volgt: onboarding, rollen, software bouwen, klantondersteuning, de Hydra-pipeline en ISO-mapping clause voor clause. Publiek, in versiebeheer, en dezelfde bron die elke reviewer en auditor leest.", + cta: {label: 'Open het handboek', href: 'https://docs.conduction.nl/'}, + panel: ( +
+
docs.conduction.nl — handboek-onderdelen
+
+ {[ + {hex: 'var(--c-blue-cobalt)', label: 'Identity'}, + {hex: 'var(--c-orange-knvb)', label: 'Way of Work'}, + {hex: 'var(--c-mint-500)', label: 'Hydra-pipeline'}, + {hex: 'var(--c-lavender-300)', label: 'Claude-workflow'}, + {hex: 'var(--c-cobalt-700)', label: 'ISO-compliance'}, + {hex: 'var(--c-forest-300)', label: 'Roadmap'}, + ].map((s, i) => ( +
+ + {s.label} +
+ ))} +
+
+ ), + }, + { + id: 'pentest', + title: 'Pentest-tools.com — Doorlopende externe scans.', + summary: "Conduction draait dagelijks penetratietests tegen elke applicatie via het commerciële SaaS-scanplatform pentest-tools.com. De dekking beslaat de OWASP Top 10, bekende CVE's, TLS-hygiëne, netwerk-exposure en subdomain enumeration. Komt er een bevinding boven, dan triëren we hem en starten de opvolging binnen 8 werkuren.", + cta: {label: 'Bekijk hoe we scannen', href: '#pentest-tools'}, + panel: ( +
+
pentest-tools.com — laatste scan
+ {[ + {label: 'Website scanner (OWASP Top 10)', state: 'schoon'}, + {label: 'TLS-configuratie audit', state: 'schoon'}, + {label: 'Netwerk vulnerability scan', state: 'schoon'}, + {label: 'Subdomain enumeration', state: 'schoon'}, + {label: 'CVE-check op actieve stack', state: 'schoon'}, + ].map((row, i) => ( +
0 ? '1px solid var(--c-cobalt-100)' : 'none'}}> + {row.label} + {row.state} +
+ ))} +
+ ), + }, + { + id: 'github-workflow', + title: 'GitHub-native kwaliteitsworkflow.', + summary: "Elke Conduction-app staat publiek op GitHub onder de ConductionNL-organisatie. Drie organisatie-brede branch-protection rulesets dwingen elke wijziging door een pull request met verplichte review. Code-review en security-review worden gestart via labels op de PR, geauditeerd door Hydra-automatisering en gekoppeld aan de canonieke spec. Het spoor van feature request tot gemergde code is end-to-end te volgen, in het openbaar.", + cta: {label: 'Bekijk de workflow', href: '#github-workflow'}, + panel: ( +
+
Pull request #482 — feat: openregister/audit-log
+ {[ + {step: 'Branch protection', tag: 'afgedwongen'}, + {step: 'Geautomatiseerde CI-gate (PHPCS, Psalm, PHPStan)', tag: 'geslaagd'}, + {step: 'Code review (label: code-review:queued)', tag: 'akkoord'}, + {step: 'Security review (label: security-review:queued)', tag: 'akkoord'}, + {step: 'ADR + OpenSpec change gekoppeld', tag: 'gevolgd'}, + {step: 'Gemerged naar development', tag: 'geaudit'}, + ].map((row, i) => ( +
0 ? '1px solid var(--c-cobalt-100)' : 'none'}}> + {row.step} + {row.tag} +
+ ))} +
+ ), + }, + ]} +/> + +
+
+ +## ISO-certificeringen + +
+
+ + ISO 9001:2015 certificaat, Conduction B.V., kwaliteitsmanagementsysteem + +
ISO 9001:2015 — Kwaliteitsmanagement
+
+
+ + ISO 27001:2022 certificaat, Conduction B.V., informatiebeveiligingsmanagementsysteem + +
ISO 27001:2022 — Informatiebeveiliging
+
+
+ +
+ +
+
Beleidsverklaring
+

Kwaliteits- en informatieveiligheidsmanagementsysteem

+
+
+
Norm
+
ISO 9001:2015 · ISO/IEC 27001:2022
+
+
+
Vastgesteld
+
januari 2026
+
+
+
Certificaten
+
24 jul 2025 – 21 jul 2028
+
+
+
Volgende review
+
januari 2027
+
+
+
+ +Conduction is in 2018 opgericht met als doel bij te dragen aan een betere digitale wereld. Wij ontwikkelen en beheren democratische, inclusieve en transparante digitale oplossingen volgens de Common Ground-principes. Als Digital Socials werken wij aan maatschappelijke vraagstukken voor publieke organisaties en overheden, met de mens centraal en *Tech to serve people* als leidraad. + +Kwaliteit en informatiebeveiliging zijn essentieel voor het vertrouwen van onze klanten, partners en gebruikers. Conduction werkt volgens een geïntegreerd kwaliteits- en informatieveiligheidssysteem dat is ingericht conform ISO 9001:2015 en ISO/IEC 27001:2022. Dit systeem ondersteunt ons bij het beheersen van risico's, het oppakken van kansen en het continu verbeteren van onze dienstverlening volgens de Plan-Do-Check-Act-cyclus. Kwaliteits- en informatiebeveiligingsdoelstellingen stellen we jaarlijks vast en beoordelen we periodiek. + +Met ons kwaliteits- en informatieveiligheidssysteem borgen we dat: + +- processen en werkwijzen duidelijk zijn vastgelegd en worden nageleefd; +- werkzaamheden doeltreffend en beheerst worden uitgevoerd, met aandacht voor risico's; +- prestaties worden gemonitord, geëvalueerd en waar mogelijk verbeterd; +- medewerkers inzicht hebben in hun verantwoordelijkheden en werkwijzen; +- wordt voldaan aan toepasselijke wet- en regelgeving en contractuele verplichtingen. + +Het managementteam is verantwoordelijk voor en gecommit aan het vaststellen, uitvoeren en onderhouden van dit beleid en zorgt dat de organisatie beschikt over de benodigde mensen, middelen en informatie. We pakken kansen en mitigeren risico's. Dit delen we met onze medewerkers en laten hen hier zo veel mogelijk in meedenken. We maken tijd vrij voor onszelf, de interne auditors en medewerkers. + +Het toepassingsgebied van dit beleid en de certificering betreft de kwaliteit in informatieveiligheid gerelateerd aan advisering over, en het ontwikkelen en beheren van digitale oplossingen voor maatschappelijke vraagstukken. + +Kwaliteit, informatiebeveiliging, continue verbetering en klanttevredenheid staan centraal bij Conduction. De certificaten ISO 9001:2015 en ISO/IEC 27001:2022 zijn afgegeven op 24 juli 2025 en geldig tot en met 21 juli 2028. + +Voor meer informatie over ons kwaliteits- en informatieveiligheidssysteem kunt u contact met ons opnemen via [het contactformulier op onze website](/contact). + +
+ Vastgesteld door de directie van Conduction B.V. — Amsterdam. Tekst overgenomen uit de ondertekende beleidsverklaring van januari 2026. +
+ +
+ +
+ +
+ +, + }, + { + title: 'Way of Work', + desc: 'Hoe Conduction dagelijks werkt — onboarding, rollen, software bouwen en klantondersteuning. Het handboek dat elke (digitale) medewerker volgt.', + panel: , + }, + { + title: 'Hydra', + desc: 'De spec-gedreven CI/CD-pipeline van Conduction. Van OpenSpec-wijziging tot gereviewde PR — Builder, parallelle code- + security-review, met een mens in de loop.', + panel: , + }, + { + title: 'Claude-workflow', + desc: 'Spec-gedreven ontwikkeling met OpenSpec, GitHub Issues en Claude Code. Skills, commands, conventies, parallelle agents.', + panel: , + }, + { + title: 'ISO-compliance', + desc: 'Engineering-pipeline gemapt op ISO/IEC 9001:2015 en 27001:2022 clause-by-clause, met gaten als first-class output.', + panel: , + }, + { + title: 'Roadmap', + desc: 'Waar het Conduction-app-ecosysteem heen gaat — live op GitHub Projects. Wat volgt, wat loopt, wat is opgeleverd.', + panel: , + }, + ]} +/> + +
+ + + +
+ +
+
+ +## Pentest-tools + +Conduction draait **dagelijks** penetratietests tegen elke applicatie via het commerciële SaaS-scanplatform **[pentest-tools.com](https://app.pentest-tools.com/)**. De dekking beslaat de OWASP Top 10, bekende CVE's, TLS-hygiëne, netwerk-exposure en subdomain enumeration — tegen onze apps, de managed Common Ground-omgeving op commonground.nu en de marketingvlakken onder conduction.nl. + +Komt er een bevinding boven, dan triëren we hem en starten de opvolging **binnen 8 werkuren**. + +
+ +
+
+ +## Kwaliteitsworkflow op GitHub + +Elke Conduction-app staat publiek op GitHub onder de [ConductionNL](https://github.com/ConductionNL)-organisatie. De herbruikbare [`Quality`-workflow](https://github.com/ConductionNL/.github/blob/main/.github/workflows/quality.yml) — één keer gedefinieerd in de `.github`-repo en aangeroepen door elke app — draait dezelfde gate-stack op elke pull request: vier parallelle kwaliteitsmatrices, daarna een gated test-fase, daarna rapportage + een Software Bill of Materials op beschermde branches. + + + + + +### Om de pipeline heen + +- **Branch protection** — drie organisatie-brede rulesets dwingen overal dezelfde regels af: ten minste één goedkeurende review om naar `development` te mergen, ten minste twee reviews om naar `main` te mergen. Directe pushes naar beschermde branches zijn geblokkeerd. +- **Tweesporen-review** — pull requests krijgen de juiste reviewer via labels: `code-review:queued` start de code-review tegen PHPCS / PHPMD / Psalm / PHPStan (of `ruff` / `mypy` / ESLint per stack); `security-review:queued` start een informatiebeveiligingsreview tegen de relevante ISO 27001:2022 Annex A-controls. +- **Hydra-automatisering** — routinematig review-werk loopt via Hydra, die naar PR-labels kijkt, review-jobs naar gespecialiseerde agents stuurt en bevindingen terugschrijft als PR-comments. Een menselijke reviewer keurt goed voor de merge. +- **Spec-gedreven wijzigingen** — grotere wijzigingen dragen een OpenSpec change-folder en een ADR mee. Spec naast de code, ADR voor de architecturale beslissing, PR verwijst naar beide — de documentatiesporen die ISO 9001:2015 §7.5 vraagt, zonder een parallel kwaliteitshandboek bij te houden. + +
+ +
+ +## Aanverwante compliance-signalen + +Naast de twee ISO-certificeringen, het operationele handboek, de pentest-scans en de GitHub-workflow ziet het inkooprelevante compliance-beeld voor Conduction er zo uit: + +- **ISAE 3402.** De managed hosting op commonground.nu draait op infrastructuur van [Cyso](https://www.cyso.nl/) onder ISAE 3402 Type II. Hun verklaring sturen we op verzoek toe. +- **BIO.** De afstemming op de Baseline Informatiebeveiliging Overheid loopt. Statusupdates komen hier zodra die rond is. +- **DigiD.** Buiten de scope van onze huidige portfolio. We integreren met DigiD-systemen, maar hebben zelf geen DigiD-assessment. + +
+ +
+ + + ISO 9001:2015 dekt kwaliteitsmanagement: hoe we ons werk plannen, bouwen, leveren en verbeteren. ISO 27001:2022 dekt informatiebeveiliging: hoe we risico's voor de data die we onder ons hebben identificeren, mitigeren en auditen. De meeste inkoopdossiers vragen om beide. Wij zijn voor beide gecertificeerd door dezelfde externe auditor, op dezelfde jaarcyclus. + + + Nee. Zelf hosten betekent jouw Nextcloud-instance, jouw infrastructuur, jouw data. Ons ISO 27001-certificaat dekt de eigen systemen van Conduction en de apps die we ontwikkelen. De beveiliging van de data die je in OpenRegister op je eigen server zet, is jouw verantwoordelijkheid. Wil je dat onze certificering ook de hosting dekt, gebruik dan onze managed Common Ground-omgeving op commonground.nu. + + + Dagelijks, tegen elke applicatie. De pentest-tools.com-scans dekken de OWASP Top 10, bekende CVE's, TLS-hygiëne, netwerk-exposure en subdomain enumeration af. Komt er een bevinding boven, dan triëren we hem en starten de opvolging binnen 8 werkuren. + + + De afstemming op de Baseline Informatiebeveiliging Overheid loopt. We gebruiken de BIO-controleset als gap-analyse tegen onze bestaande ISO 27001-controls en publiceren hier een status­update zodra die rond is. ISO 27001:2022 dekt het grootste deel van de BIO-vereisten al af, daarom beschouwen de meeste overheidsklanten Conduction nu al als inkoop-klaar. + + + Nee, DigiD valt buiten de scope van onze huidige portfolio. We bouwen apps die met DigiD-systemen integreren (via Integriq), maar we zijn zelf geen DigiD-dienstverlener. Als je aanbesteding een DigiD-assessment vraagt, ligt dat assessment bij de hostende partij of bij het systeem dat de DigiD-login aanbiedt. + + + De managed Common Ground-omgeving draait op infrastructuur van Cyso onder ISAE 3402 Type II. Het ISO 27001-certificaat van Conduction dekt de applicatielaag (de Conduction-apps, de ontwikkelpipeline, de operatieworkflows), de ISAE van Cyso dekt de hostinglaag (datacenters, netwerk, hypervisor). Samen dekken ze de volledige stack die een overheidsklant op commonground.nu afneemt. + + + De ISO 9001:2015- en ISO 27001:2022-certificaten zelf staan op deze pagina — klik op de afbeeldingen voor de scans in volle resolutie. Voor de VvT, het meest recente interne auditrapport of de relevante pentest-samenvatting kun je mailen naar info@conduction.nl met je contract- of aanbestedingsreferentie. Die stukken sturen we direct toe, omdat we willen vastleggen wie ze opvraagt. + + diff --git a/i18n/nl/docusaurus-plugin-content-pages/sidecars.mdx b/i18n/nl/docusaurus-plugin-content-pages/sidecars.mdx new file mode 100644 index 000000000..9c53583ed --- /dev/null +++ b/i18n/nl/docusaurus-plugin-content-pages/sidecars.mdx @@ -0,0 +1,80 @@ +--- +title: Sidecars +description: Nextcloud-installeerbare wrappers rond bewezen open-source services. Dezelfde upstream, minder DevOps. +hide_table_of_contents: true +--- + +import React, {useState, useMemo} from 'react'; +import {Section, SectionHead, FacetedFilters, AppCard} from '@conduction/docusaurus-preset/components'; +import {getSidecars, SIDECAR_CATEGORIES} from '@site/src/data/sidecars-catalog'; + +export const sidecars = getSidecars(); + +export const STATUS_LABELS = {STABLE: 'Stable', BETA: 'Beta', 'COMING SOON': 'Binnenkort'}; + +export function FilterableSidecars() { + const [selected, setSelected] = useState({}); + + const facets = useMemo(() => { + const cats = SIDECAR_CATEGORIES.filter(c => c !== 'All'); + return [ + {key: 'category', label: 'Categorie', items: cats.map(c => ({ + value: c, label: c, + count: sidecars.filter(a => Array.isArray(a.categories) && a.categories.includes(c)).length, + })).filter(item => item.count > 0)}, + {key: 'status', label: 'Status', items: ['STABLE', 'BETA', 'COMING SOON'].map(s => ({ + value: s, label: STATUS_LABELS[s], + count: sidecars.filter(a => a.status === s).length, + })).filter(item => item.count > 0)}, + ]; + }, []); + + const filtered = useMemo(() => { + return sidecars.filter(a => { + for (const [key, values] of Object.entries(selected)) { + if (!values || values.length === 0) continue; + if (key === 'category') { + const cats = a.categories || []; + if (!values.some(v => cats.includes(v))) return false; + } else if (key === 'status') { + if (!values.includes(a.status)) return false; + } + } + return true; + }); + }, [selected]); + + const totalSelected = Object.values(selected).reduce( + (n, v) => n + (Array.isArray(v) ? v.length : 0), 0 + ); + + return ( +
+ +
+
+
+ {filtered.length} van {sidecars.length} sidecars + {totalSelected > 0 && · {sidecars.length - filtered.length} verborgen door filters} +
+
+
+ {filtered.map((app, i) => )} +
+ {filtered.length === 0 && ( +

Geen sidecars passen bij dit filter.

+ )} +
+
+ ); +} + +
+ Externe services, geïnstalleerd als een app.} + lede={<>Sidecars zijn Nextcloud-installeerbare wrappers rond bewezen open-source services. n8n, Keycloak, OpenZaak, Ollama en meer, verpakt zodat je ze uit de app store installeert in plaats van een apart cluster te draaien. We forken de upstream niet. De sidecar volgt de releases en regelt het koppelvlak met Nextcloud.} + /> + + +
diff --git a/i18n/nl/docusaurus-plugin-content-pages/sidecars/keycloak.mdx b/i18n/nl/docusaurus-plugin-content-pages/sidecars/keycloak.mdx new file mode 100644 index 000000000..11499e0d2 --- /dev/null +++ b/i18n/nl/docusaurus-plugin-content-pages/sidecars/keycloak.mdx @@ -0,0 +1,47 @@ +--- +title: Keycloak-sidecar, Conduction +description: Nextcloud-sidecar rond Keycloak voor self-hosted identity en SSO. +hide_table_of_contents: true +--- + +import {DetailHero, Section, SectionHead, CtaBanner} from '@conduction/docusaurus-preset/components'; + +Een Nextcloud-sidecar rond Keycloak. Draai de open-source identity broker in je workspace, geïnstalleerd uit de app store, met SSO over al je apps vanaf dag één.} + primaryCta={{label: 'Installeer uit de app store', href: '/install'}} + secondaryCta={{label: 'Lees de docs', href: 'https://docs.conduction.nl/keycloak'}} + tertiaryCta={{label: 'Bekijk op GitHub', href: 'https://github.com/ConductionNL/keycloak-nextcloud'}} + iconColor="var(--c-blue-cobalt)" + icon={} +/> + +
+ +
+ +
+ +
+ +Installeer uit de Nextcloud-app-store. Configureer SSO voor elke Conduction-app en elke externe sidecar op één plek.} + primaryCta={{label: 'Installeer uit de app store', href: '/install'}} + secondaryCta={{label: 'Praat met een partner', href: '/partners'}} +/> diff --git a/i18n/nl/docusaurus-plugin-content-pages/sidecars/n8n.mdx b/i18n/nl/docusaurus-plugin-content-pages/sidecars/n8n.mdx new file mode 100644 index 000000000..5b0a23a13 --- /dev/null +++ b/i18n/nl/docusaurus-plugin-content-pages/sidecars/n8n.mdx @@ -0,0 +1,47 @@ +--- +title: n8n-sidecar, Conduction +description: Nextcloud-sidecar rond n8n voor workflow-automatisering. +hide_table_of_contents: true +--- + +import {DetailHero, Section, SectionHead, CtaBanner} from '@conduction/docusaurus-preset/components'; + +Een Nextcloud-sidecar rond n8n. Draai de populaire visuele workflow-automatiserings-engine in je workspace, geïnstalleerd uit de Nextcloud-app-store, zonder aparte hosting.} + primaryCta={{label: 'Installeer uit de app store', href: '/install'}} + secondaryCta={{label: 'Lees de docs', href: 'https://docs.conduction.nl/n8n'}} + tertiaryCta={{label: 'Bekijk op GitHub', href: 'https://github.com/ConductionNL/n8n-nextcloud'}} + iconColor="var(--c-blue-cobalt)" + icon={} +/> + +
+ +
+ +
+ +
+ +Installeer uit de Nextcloud-app-store. Verbind met OpenRegister-rijen. Trigger workflows vanuit Integriq-events. Toon resultaten in LaunchPad.} + primaryCta={{label: 'Installeer uit de app store', href: '/install'}} + secondaryCta={{label: 'Praat met een partner', href: '/partners'}} +/> diff --git a/i18n/nl/docusaurus-plugin-content-pages/sidecars/ollama.mdx b/i18n/nl/docusaurus-plugin-content-pages/sidecars/ollama.mdx new file mode 100644 index 000000000..282acc181 --- /dev/null +++ b/i18n/nl/docusaurus-plugin-content-pages/sidecars/ollama.mdx @@ -0,0 +1,47 @@ +--- +title: Ollama-sidecar, Conduction +description: Nextcloud-sidecar rond Ollama voor lokale LLM-inferentie. +hide_table_of_contents: true +--- + +import {DetailHero, Section, SectionHead, CtaBanner} from '@conduction/docusaurus-preset/components'; + +Een Nextcloud-sidecar rond Ollama. Draai lokale large-language models in je workspace, installeer uit de app store, houd je prompts op je eigen hardware.} + primaryCta={{label: 'Installeer uit de app store', href: '/install'}} + secondaryCta={{label: 'Lees de docs', href: 'https://docs.conduction.nl/ollama'}} + tertiaryCta={{label: 'Bekijk op GitHub', href: 'https://github.com/ConductionNL/ollama-nextcloud'}} + iconColor="var(--c-blue-cobalt)" + icon={} +/> + +
+ +
+ +
+ +
+ +Installeer uit de Nextcloud-app-store. Koppel lokale modellen aan AI Companion. Drijf samenvattingsflows vanuit Integriq. Houd elke prompt op je eigen hardware.} + primaryCta={{label: 'Installeer uit de app store', href: '/install'}} + secondaryCta={{label: 'Praat met een partner', href: '/partners'}} +/> diff --git a/i18n/nl/docusaurus-plugin-content-pages/sidecars/open-webui.mdx b/i18n/nl/docusaurus-plugin-content-pages/sidecars/open-webui.mdx new file mode 100644 index 000000000..ee5f982d5 --- /dev/null +++ b/i18n/nl/docusaurus-plugin-content-pages/sidecars/open-webui.mdx @@ -0,0 +1,47 @@ +--- +title: Open WebUI-sidecar, Conduction +description: Nextcloud-sidecar rond Open WebUI voor een chat-interface op lokale LLM's. +hide_table_of_contents: true +--- + +import {DetailHero, Section, SectionHead, CtaBanner} from '@conduction/docusaurus-preset/components'; + +Een Nextcloud-sidecar rond Open WebUI. Een ChatGPT-achtige interface voor je lokale LLM's, geïnstalleerd uit de app store, alles op je eigen hardware.} + primaryCta={{label: 'Installeer uit de app store', href: '/install'}} + secondaryCta={{label: 'Lees de docs', href: 'https://docs.conduction.nl/open-webui'}} + tertiaryCta={{label: 'Bekijk op GitHub', href: 'https://github.com/ConductionNL/open-webui-nextcloud'}} + iconColor="var(--c-blue-cobalt)" + icon={} +/> + +
+ +
+ +
+ +
+ +Installeer uit de Nextcloud-app-store. Combineer met de Ollama-sidecar voor het model. SSO komt van de Keycloak-sidecar. Eén workspace, geen cloud-round-trip.} + primaryCta={{label: 'Installeer uit de app store', href: '/install'}} + secondaryCta={{label: 'Praat met een partner', href: '/partners'}} +/> diff --git a/i18n/nl/docusaurus-plugin-content-pages/sidecars/openklant.mdx b/i18n/nl/docusaurus-plugin-content-pages/sidecars/openklant.mdx new file mode 100644 index 000000000..9606cca04 --- /dev/null +++ b/i18n/nl/docusaurus-plugin-content-pages/sidecars/openklant.mdx @@ -0,0 +1,47 @@ +--- +title: OpenKlant sidecar, Conduction +description: Nextcloud-sidecar rond het OpenKlant klantcontact-register. ZGW-compatibel, installeer je uit de app store. +hide_table_of_contents: true +--- + +import {DetailHero, Section, SectionHead, CtaBanner} from '@conduction/docusaurus-preset/components'; + +Een Nextcloud-sidecar rond OpenKlant. Draai het officiële klantcontact-register naast je workspace. Installeer uit de Nextcloud-app-store, zonder apart cluster.} + primaryCta={{label: 'Installeer uit de app store', href: '/install'}} + secondaryCta={{label: 'Lees de docs', href: 'https://docs.conduction.nl/openklant'}} + tertiaryCta={{label: 'Bekijk op GitHub', href: 'https://github.com/ConductionNL/openklant'}} + iconColor="var(--c-blue-cobalt)" + icon={} +/> + +
+ +
+ +
+ +
+ +Installeer uit de Nextcloud-app-store. Combineer met Integriq om ZGW-calls te routeren. Combineer met Dossiq om workflows op klantcontact-events te draaien.} + primaryCta={{label: 'Installeer uit de app store', href: '/install'}} + secondaryCta={{label: 'Praat met een partner', href: '/partners'}} +/> diff --git a/i18n/nl/docusaurus-plugin-content-pages/sidecars/opentalk.mdx b/i18n/nl/docusaurus-plugin-content-pages/sidecars/opentalk.mdx new file mode 100644 index 000000000..29d759b88 --- /dev/null +++ b/i18n/nl/docusaurus-plugin-content-pages/sidecars/opentalk.mdx @@ -0,0 +1,47 @@ +--- +title: OpenTalk sidecar, Conduction +description: Nextcloud-sidecar rond de OpenTalk video-conferencing controller. AVG-conforme meetings, end-to-end encrypted, schermdelen. +hide_table_of_contents: true +--- + +import {DetailHero, Section, SectionHead, CtaBanner} from '@conduction/docusaurus-preset/components'; + +Een Nextcloud-sidecar rond OpenTalk. AVG-conforme videoconferencing, end-to-end encrypted, schermdelen ingebouwd. Installeer via de AppAPI en draai als ExApp naast de workspace.} + primaryCta={{label: 'Installeer uit de app store', href: '/install'}} + secondaryCta={{label: 'Lees de upstream-docs', href: 'https://opentalk.eu/'}} + tertiaryCta={{label: 'Bekijk op GitHub', href: 'https://github.com/ConductionNL/opentalk'}} + iconColor="var(--c-blue-cobalt)" + icon={} +/> + +
+ +
+ +
+ +
+ +Installeer uit de Nextcloud-app-store. AVG-conform, end-to-end encrypted, gehost naast de workspace die je team al gebruikt.} + primaryCta={{label: 'Installeer uit de app store', href: '/install'}} + secondaryCta={{label: 'Praat met een partner', href: '/partners'}} +/> diff --git a/i18n/nl/docusaurus-plugin-content-pages/sidecars/openzaak.mdx b/i18n/nl/docusaurus-plugin-content-pages/sidecars/openzaak.mdx new file mode 100644 index 000000000..f04a341c3 --- /dev/null +++ b/i18n/nl/docusaurus-plugin-content-pages/sidecars/openzaak.mdx @@ -0,0 +1,47 @@ +--- +title: OpenZaak sidecar, Conduction +description: Nextcloud-sidecar rond de OpenZaak zaakafhandeling-API. +hide_table_of_contents: true +--- + +import {DetailHero, Section, SectionHead, CtaBanner} from '@conduction/docusaurus-preset/components'; + +Een Nextcloud-sidecar rond OpenZaak. Draai de officiële Common Ground zaakafhandeling-API naast je workspace. ZGW-koppelvlakken, vanuit Nextcloud.} + primaryCta={{label: 'Installeer uit de app store', href: '/install'}} + secondaryCta={{label: 'Lees de docs', href: 'https://docs.conduction.nl/openzaak'}} + tertiaryCta={{label: 'Bekijk op GitHub', href: 'https://github.com/ConductionNL/openzaak'}} + iconColor="var(--c-blue-cobalt)" + icon={} +/> + +
+ +
+ +
+ +
+ +Installeer uit de Nextcloud-app-store. Route ZGW-calls via Integriq. Stuur zaken door Dossiq-workflows.} + primaryCta={{label: 'Installeer uit de app store', href: '/install'}} + secondaryCta={{label: 'Praat met een partner', href: '/partners'}} +/> diff --git a/i18n/nl/docusaurus-plugin-content-pages/sidecars/valtimo.mdx b/i18n/nl/docusaurus-plugin-content-pages/sidecars/valtimo.mdx new file mode 100644 index 000000000..0ae09143f --- /dev/null +++ b/i18n/nl/docusaurus-plugin-content-pages/sidecars/valtimo.mdx @@ -0,0 +1,47 @@ +--- +title: Valtimo sidecar, Conduction +description: Nextcloud-sidecar rond de Valtimo BPMN-workflow-engine. +hide_table_of_contents: true +--- + +import {DetailHero, Section, SectionHead, CtaBanner} from '@conduction/docusaurus-preset/components'; + +Een Nextcloud-sidecar rond Valtimo. Draai de open-source BPMN-workflow-engine binnen je workspace. Installeer uit de app store, geen apart cluster te beheren.} + primaryCta={{label: 'Installeer uit de app store', href: '/install'}} + secondaryCta={{label: 'Lees de docs', href: 'https://docs.conduction.nl/valtimo'}} + tertiaryCta={{label: 'Bekijk op GitHub', href: 'https://github.com/ConductionNL/valtimo'}} + iconColor="var(--c-blue-cobalt)" + icon={} +/> + +
+ +
+ +
+ +
+ +Installeer uit de Nextcloud-app-store. Trigger workflows vanuit Dossiq. Laat taken zien in LaunchPad. Route input via Integriq.} + primaryCta={{label: 'Installeer uit de app store', href: '/install'}} + secondaryCta={{label: 'Praat met een partner', href: '/partners'}} +/> diff --git a/i18n/nl/docusaurus-plugin-content-pages/solutions.mdx b/i18n/nl/docusaurus-plugin-content-pages/solutions.mdx index e0a605ae9..9edbc031f 100644 --- a/i18n/nl/docusaurus-plugin-content-pages/solutions.mdx +++ b/i18n/nl/docusaurus-plugin-content-pages/solutions.mdx @@ -9,47 +9,40 @@ import {Section, SectionHead, SolutionCard, SolutionGrid, FacetedFilters, Filter export const SOLUTIONS = [ { - href: '/solutions/woo', sector: 'public', sectorLabel: 'Publieke sector', - title: 'Woo-compliance, vóór vrijdag.', + href: '/solutions/openwoo', sector: 'public', sectorLabel: 'Publieke sector', + title: 'OpenWoo. Wet open overheid, vóór vrijdag.', outcome: <>Live Woo-portaal op je gehoste Nextcloud. Elke wetscategorie wordt een register, elk register publiceert via OpenCatalogi, elke connector haalt op uit je DMS., - builtOn: ['OpenCatalogi', 'OpenRegister', 'OpenConnector'], goals: ['compliance'], status: 'production', + builtOn: ['OpenCatalogi', 'OpenRegister', 'Integriq'], goals: ['compliance'], status: 'production', icon: , }, { href: '/solutions/zaakafhandeling', sector: 'public', sectorLabel: 'Publieke sector', title: <>ZaakAfhandelApp op Nextcloud., outcome: <>Vier apps, één zaakafhandeling. Burger-CRM, zaakworkflow en automatisering op de Nextcloud waar je team al inlogt., - builtOn: ['OpenRegister', 'PipelinQ', 'Procest', 'Windmill'], goals: ['workflow'], status: 'production', + builtOn: ['OpenRegister', 'Pipelinq', 'Dossiq', 'Windmill'], goals: ['workflow'], status: 'production', icon: , }, { href: '/solutions/archief', sector: 'public', sectorLabel: 'Publieke sector', title: 'Archief naar e-Depot, automatisch.', - outcome: 'OpenRegister bewaart je actuele data, OpenConnector zet records bewaartermijn-driven over naar je e-Depot. NEN-2082 gemapt, eIDAS-AdES verzegeld.', - builtOn: ['OpenRegister', 'OpenConnector'], goals: ['compliance'], status: 'production', + outcome: 'OpenRegister bewaart je actuele data, Integriq zet records bewaartermijn-driven over naar je e-Depot. NEN-2082 gemapt, eIDAS-AdES verzegeld.', + builtOn: ['OpenRegister', 'Integriq'], goals: ['compliance'], status: 'production', icon: , }, { - href: '/solutions/software-catalog', sector: 'public', sectorLabel: 'Publieke sector', - title: 'Software-catalogus voor de gemeente.', - outcome: 'Een publieke software-catalogus. Apps, datasets en APIs van je organisatie, zoekbaar op één plek, gefedereerd naar data.overheid.nl.', - builtOn: ['OpenCatalogi', 'OpenRegister'], goals: ['integration'], status: 'production', - icon: , + href: '/solutions/anonimiseren', sector: ['public', 'mkb'], sectorLabel: 'Publiek & MKB', + title: 'Anonimiseren, voor je het publiceert.', + outcome: <>Privacy-by-design pipeline op Nextcloud. Filinq en OpenRegister sporen PII op en redacteren records en documenten voordat ze de organisatie verlaten. Presidio onder de motorkap, AVG-uitlegbaar., + builtOn: ['Filinq', 'OpenRegister', 'Presidio'], goals: ['compliance'], status: 'pilot', + icon: , }, { href: '/solutions/mkb-workspace', sector: 'mkb', sectorLabel: 'MKB', title: 'MKB-workspace in twee minuten.', - outcome: <>Bestanden, agenda, talk, dashboards. Je volledige open-source workspace op Nextcloud, met MyDash erbovenop., - builtOn: ['MyDash', 'OpenRegister'], goals: ['reporting'], status: 'production', + outcome: <>Bestanden, agenda, talk, dashboards. Je volledige open-source workspace op Nextcloud, met LaunchPad erbovenop., + builtOn: ['LaunchPad', 'OpenRegister'], goals: ['reporting'], status: 'production', icon: , }, - { - href: '/solutions/legacy-erp', sector: 'mkb', sectorLabel: 'MKB', - title: 'Eén legacy-ERP, alle moderne tools.', - outcome: 'OpenConnector trekt data uit je ERP, vult typed registers, en biedt een API aan elke tool die je team vandaag gebruikt.', - builtOn: ['OpenConnector', 'OpenRegister'], goals: ['integration'], status: 'pilot', - icon: , - }, ]; export function FilterableSolutions() { @@ -105,13 +98,13 @@ export function FilterableSolutions() { ); return ( -
+
-
+
- {filteredFinal.length} van {SOLUTIONS.length} solutions - {totalSelected > 0 && · {SOLUTIONS.length - filteredFinal.length} verborgen door filters} + {filteredFinal.length} van {SOLUTIONS.length} solutions + {totalSelected > 0 && · {SOLUTIONS.length - filteredFinal.length} verborgen door filters}
diff --git a/i18n/nl/docusaurus-plugin-content-pages/solutions/anonimiseren.mdx b/i18n/nl/docusaurus-plugin-content-pages/solutions/anonimiseren.mdx new file mode 100644 index 000000000..49f2023fd --- /dev/null +++ b/i18n/nl/docusaurus-plugin-content-pages/solutions/anonimiseren.mdx @@ -0,0 +1,132 @@ +--- +title: Anonimiseren, AVG-proof publicatie en hergebruik +description: Privacy-by-design pipeline op Nextcloud. Filinq en OpenRegister detecteren en redacteren PII voordat documenten en records gepubliceerd, gedeeld of geëxporteerd worden. Presidio onder de motorkap. +hide_table_of_contents: true +--- + +import {DetailHero, Section, SectionHead, FeatureList, FeatureItem, Pipeline, PairRow, PairCard, CtaBanner} from '@conduction/docusaurus-preset/components'; + +Een privacy-by-design pipeline op Nextcloud. Filinq en OpenRegister sporen PII op en redacteren records en documenten voordat ze de organisatie verlaten. Werkt voor Woo-publicaties, dataset-export, klantcommunicatie en interne kennisdeling. Presidio onder de motorkap, AVG-uitlegbaar.} + primaryCta={{label: 'Praat met een partner', href: '/nl/partners'}} + secondaryCta={{label: 'Lees de recipe', href: 'https://docs.conduction.nl/anonimiseren'}} + iconColor="var(--c-blue-cobalt)" + icon={} +/> + +
+ + + + } + title="Detectie op tekst, tabel en document." + > + Presidio-gebaseerde detectie voor BSN, namen, adressen, IBAN, telefoonnummers, kentekens, e-mailadressen, geboortedata. Werkt op platte tekst, PDF, Word, Excel en op velden in een OpenRegister-record. + + } + title="Redactie met behoud van bewijs." + > + Filinq schrijft een geredigeerde kopie, bewaart het origineel achter rechten en houdt de mapping per match vast. Audit-trail toont wát is geredigeerd, op grond waarvan en door welk beleid. + + } + title="Beleid per register, per categorie." + > + Elk OpenRegister-register krijgt een eigen anonimisatieprofiel: welke entiteiten gedetecteerd worden, welke daarvan geredigeerd, welke geblokkeerd. Woo-publicatie, AVG-verzoek, dataset-export en intern delen krijgen elk hun eigen drempel. + + } + title="Mens in de lus, waar het moet." + > + Lage-zekerheidsmatches gaan naar een review-wachtrij in Nextcloud. De behandelaar bevestigt of verwerpt; de beslissing wordt onderdeel van het profiel en verbetert toekomstige detectie. + + } + title="Uitlegbaar voor de AVG." + > + Per geredigeerde match wordt vastgelegd: welk type PII, welk model, welke versie, welk profiel. Functionaris Gegevensbescherming kan elk besluit terugzien zonder het oorspronkelijke document te raadplegen. + + } + title="API en pipeline, niet alleen UI." + > + Aanroepbaar vanuit Integriq-flows, n8n, Windmill en directe REST-calls. Sluit aan op bestaande Woo-, export- en publicatieprocessen zonder de UI te raken. + + +
+ +
+ + +
+ +
+ + + } + name="Filinq" + why="Voert de redactie uit op tekst, PDF en Word, schrijft een geanonimiseerde kopie en houdt origineel + mapping veilig achter rechten." + /> + } + name="OpenRegister" + why="Levert de typed registers met een anonimisatieprofiel per categorie. Audit-trail toont welke match op grond van welk beleid is geredigeerd." + /> + } + name="Presidio" + why="Microsoft's open-source PII-detectiebibliotheek. Powert de detectielaag van Filinq; AVG-uitlegbaar, on-prem, geen externe API." + /> + +
+ +Twee apps en een PII-engine. Praat met een partner als je liever wilt dat we de uitrol verzorgen, of installeer zelf via de Nextcloud app store en volg de recipe.} + primaryCta={{label: 'Praat met een partner', href: '/nl/partners'}} + secondaryCta={{label: 'Lees de recipe', href: 'https://docs.conduction.nl/anonimiseren'}} +/> diff --git a/i18n/nl/docusaurus-plugin-content-pages/solutions/archief.mdx b/i18n/nl/docusaurus-plugin-content-pages/solutions/archief.mdx index 40a936eb7..a79ba105c 100644 --- a/i18n/nl/docusaurus-plugin-content-pages/solutions/archief.mdx +++ b/i18n/nl/docusaurus-plugin-content-pages/solutions/archief.mdx @@ -1,6 +1,6 @@ --- title: Archief, naar e-Depot zonder maatwerk -description: OpenRegister bewaart je actuele data, OpenConnector zet records over naar het centrale archief. NEN-2082, Wet hergebruik, eIDAS-AdES sealed exports. +description: OpenRegister bewaart je actuele data, Integriq zet records over naar het centrale archief. NEN-2082, Wet hergebruik, eIDAS-AdES sealed exports. hide_table_of_contents: true --- @@ -11,7 +11,7 @@ import {DetailHero, Section, SectionHead, FeatureList, FeatureItem, PairRow, Pai status={{label: 'Productie-ready', color: 'var(--c-mint-500)'}} version="Sector: Publieke sector + zorg" title={<>Archief op Nextcloud.} - tagline={<>Twee apps. Je actuele data blijft in OpenRegister. OpenConnector zet records over naar het e-Depot van je archiefdienst, op het moment dat de bewaartermijn dat voorschrijft. NEN-2082-mapping, eIDAS-AdES verzegeld, audit-trail volledig.} + tagline={<>Twee apps. Je actuele data blijft in OpenRegister. Integriq zet records over naar het e-Depot van je archiefdienst, op het moment dat de bewaartermijn dat voorschrijft. NEN-2082-mapping, eIDAS-AdES verzegeld, audit-trail volledig.} primaryCta={{label: 'Praat met een partner', href: '/partners'}} secondaryCta={{label: 'Lees de recipe', href: 'https://docs.conduction.nl/archief'}} iconColor="var(--c-blue-cobalt)" @@ -23,7 +23,7 @@ import {DetailHero, Section, SectionHead, FeatureList, FeatureItem, PairRow, Pai eyebrow="Het resultaat" title="Archiefoverdracht zonder een tweede systeem." align="stack" - lede="Geen aparte archiefapplicatie. Geen handmatige overzettingen. Een record in OpenRegister bereikt zijn bewaartermijn, OpenConnector zet hem geautomatiseerd over naar het e-Depot van je archiefdienst, en het audit-spoor blijft aanwezig in beide kanten." + lede="Geen aparte archiefapplicatie. Geen handmatige overzettingen. Een record in OpenRegister bereikt zijn bewaartermijn, Integriq zet hem geautomatiseerd over naar het e-Depot van je archiefdienst, en het audit-spoor blijft aanwezig in beide kanten." /> } - name="OpenConnector" + name="Integriq" why="De koppeling met je e-Depot. SOAP, REST of bestandsoverdracht, plus de eIDAS-AdES-sealing op de uitgaande records." /> @@ -92,13 +92,13 @@ import {DetailHero, Section, SectionHead, FeatureList, FeatureItem, PairRow, Pai
Bewaartermijn loopt af, OpenConnector levert het record naar je e-Depot, je archivaris krijgt een notificatie ter validatie. Geen losse archiefapplicatie, geen handwerk.} + lede={<>Bewaartermijn loopt af, Integriq levert het record naar je e-Depot, je archivaris krijgt een notificatie ter validatie. Geen losse archiefapplicatie, geen handwerk.} primaryCta={{label: 'Praat met een partner', href: '/partners'}} secondaryCta={{label: 'Lees de recipe', href: 'https://docs.conduction.nl/archief'}} /> diff --git a/i18n/nl/docusaurus-plugin-content-pages/solutions/legacy-erp.mdx b/i18n/nl/docusaurus-plugin-content-pages/solutions/legacy-erp.mdx deleted file mode 100644 index e637a3337..000000000 --- a/i18n/nl/docusaurus-plugin-content-pages/solutions/legacy-erp.mdx +++ /dev/null @@ -1,107 +0,0 @@ ---- -title: Eén legacy-ERP, alle moderne tools, Conduction -description: OpenConnector trekt data uit je ERP, vult typed registers, en biedt een API aan elke tool die je team vandaag gebruikt. Geen middleware, geen migratie. -hide_table_of_contents: true ---- - -import {DetailHero, Section, SectionHead, FeatureList, FeatureItem, Pipeline, PairRow, PairCard, CtaBanner} from '@conduction/docusaurus-preset/components'; - -Je hebt Exact, Twinfield, Visma of een ander ERP dat de laatste tien jaar braaf zijn werk doet. Vervang het niet. OpenConnector trekt de data eruit, OpenRegister maakt het typed, MyDash rapporteert erover. Geen migratie, geen middleware.} - primaryCta={{label: 'Installeer uit de app store', href: '/install'}} - secondaryCta={{label: 'Praat met een partner', href: '/partners'}} - iconColor="var(--c-cobalt-700)" - icon={} -/> - -
- - - } - title="Connector voor de Nederlandse ERP-stack." - > - Voorgebouwde adapters voor Exact Online, Twinfield, Visma.net, Yuki en AFAS. Standaard-velden gemapt naar standaard-registers. - - } - title="Realtime of geplande sync." - > - Webhook-based sync waar het ERP dat ondersteunt; cron-style schedule waar niet. Eerste sync vult het register, daarna alleen deltas. - - } - title="MyDash voor de cijfers die ertoe doen." - > - Bouw dashboards uit je registers: omzet per klant, urenproductiviteit, openstaande facturen. Geen aparte BI-tool, geen ETL. - - } - title="Open API voor andere tools." - > - OpenRegister biedt een schone REST-API plus GraphQL-endpoint. Notion, Slack, n8n, Zapier, je eigen scripts, alles consumeert direct. - - -
- -
- - -
- -
- - - } - name="OpenConnector" - why="De integratielaag. Trekt data uit je ERP zonder glue-code." - /> - } - name="OpenRegister" - why="Typed-data-foundation. REST en GraphQL-API rolt uit het schema." - /> - } - name="MyDash" - why="Dashboards en KPIs uit dezelfde registers. Geen ETL-team nodig." - /> - -
- - diff --git a/i18n/nl/docusaurus-plugin-content-pages/solutions/mkb-workspace.mdx b/i18n/nl/docusaurus-plugin-content-pages/solutions/mkb-workspace.mdx index 1554f0007..7c67113cf 100644 --- a/i18n/nl/docusaurus-plugin-content-pages/solutions/mkb-workspace.mdx +++ b/i18n/nl/docusaurus-plugin-content-pages/solutions/mkb-workspace.mdx @@ -1,6 +1,6 @@ --- title: MKB-workspace, Conduction -description: Bestanden, agenda, talk, dashboards. Je open-source workspace op Nextcloud, MyDash erbovenop, in twee minuten klaar. +description: Bestanden, agenda, talk, dashboards. Je open-source workspace op Nextcloud, LaunchPad erbovenop, in twee minuten klaar. hide_table_of_contents: true --- @@ -11,7 +11,7 @@ import {DetailHero, Section, SectionHead, FeatureList, FeatureItem, HowSteps, Ho status={{label: 'Productie-ready', color: 'var(--c-mint-500)'}} version="Sector: MKB" title="MKB-workspace in twee minuten." - tagline={<>Bestanden, agenda, talk, dashboards. Je volledige open-source workspace op Nextcloud, met MyDash erbovenop. Vanaf dag één ziet het team wat ertoe doet. Vervangt vier SaaS-abonnementen voor de prijs van één.} + tagline={<>Bestanden, agenda, talk, dashboards. Je volledige open-source workspace op Nextcloud, met LaunchPad erbovenop. Vanaf dag één ziet het team wat ertoe doet. Vervangt vier SaaS-abonnementen voor de prijs van één.} primaryCta={{label: 'Installeer uit de app store', href: '/install'}} secondaryCta={{label: 'Lees de recipe', href: 'https://docs.conduction.nl/mkb-workspace'}} iconColor="var(--c-cobalt-700)" @@ -34,7 +34,7 @@ import {DetailHero, Section, SectionHead, FeatureList, FeatureItem, HowSteps, Ho } - title="MyDash voor wat ertoe doet." + title="LaunchPad voor wat ertoe doet." > Bouw dashboards uit je registers, je facturatie en je urenregistratie. Geen aparte BI-tool, geen extra inlog. KPIs zichtbaar voor wie ze nodig heeft. @@ -42,7 +42,7 @@ import {DetailHero, Section, SectionHead, FeatureList, FeatureItem, HowSteps, Ho icon={} title="Connector haalt je legacy data binnen." > - Bestaande boekhouding (Exact, Twinfield), CRM (HubSpot, Salesforce) of urenregistratie (Toggl)? OpenConnector trekt het binnen, MyDash rapporteert erover. + Bestaande boekhouding (Exact, Twinfield), CRM (HubSpot, Salesforce) of urenregistratie (Toggl)? Integriq trekt het binnen, LaunchPad rapporteert erover. } @@ -54,7 +54,7 @@ import {DetailHero, Section, SectionHead, FeatureList, FeatureItem, HowSteps, Ho icon={} title="Twee minuten van install tot werkend." > - Drop OpenRegister, MyDash en OpenConnector in je Nextcloud. Configureer één schema, start de connector. Het team begint dezelfde middag te werken. + Drop OpenRegister, LaunchPad en Integriq in je Nextcloud. Configureer één schema, start de connector. Het team begint dezelfde middag te werken. @@ -69,11 +69,11 @@ import {DetailHero, Section, SectionHead, FeatureList, FeatureItem, HowSteps, Ho Je kunt zelf hosten, een Conduction-partner laten hosten, of je bestaande Nextcloud-omgeving gebruiken. Twee admins is genoeg om te starten. - + Beide apps zitten in de Nextcloud-app-store. Klik install, beide configureren zichzelf bij eerste request. De schema-bootstrap is idempotent. - - Optioneel: koppel je bestaande boekhouding, CRM of urenregistratie via OpenConnector. MyDash bouwt direct dashboards op de binnenkomende data. + + Optioneel: koppel je bestaande boekhouding, CRM of urenregistratie via Integriq. LaunchPad bouwt direct dashboards op de binnenkomende data. @@ -86,9 +86,9 @@ import {DetailHero, Section, SectionHead, FeatureList, FeatureItem, HowSteps, Ho /> } - name="MyDash" + name="LaunchPad" why="Persoonlijke en team-dashboards uit je registers. Rolgebaseerde toegang, KPIs op het startscherm." /> } title="Connector haalt op uit je DMS." > - OpenConnector leest Woo-records uit je bestaande zaaksysteem of DMS via REST, SOAP of bestandsoverdracht. Geen glue-code, geen nachtelijke batch. + Integriq leest Woo-records uit je bestaande zaaksysteem of DMS via REST, SOAP of bestandsoverdracht. Geen glue-code, geen nachtelijke batch. } @@ -71,7 +71,7 @@ import {DetailHero, Section, SectionHead, FeatureList, FeatureItem, Pipeline, Pa eyebrow="De pipeline" title="Van je DMS naar het burgerportaal." align="stack" - lede="Drie apps verplaatsen documenten van je bronsystemen naar een publiek Woo-portaal. OpenConnector leest uit XLNS, Joomla, OpenZaak, RX, of wat je verder draait. OpenRegister bewaart de records. OpenCatalogi maakt ze zoekbaar. De Open Tilburg-UI toont ze, open.overheid.nl federeert ze." + lede="Drie apps verplaatsen documenten van je bronsystemen naar een publiek Woo-portaal. Integriq leest uit XLNS, Joomla, OpenZaak, RX, of wat je verder draait. OpenRegister bewaart de records. OpenCatalogi maakt ze zoekbaar. De Open Tilburg-UI toont ze, open.overheid.nl federeert ze." /> } - name="OpenConnector" + name="Integriq" why="Haalt Woo-records uit je bestaande zaaksysteem of DMS via REST, SOAP of bestandsoverdracht." /> diff --git a/i18n/nl/docusaurus-plugin-content-pages/solutions/software-catalog.mdx b/i18n/nl/docusaurus-plugin-content-pages/solutions/software-catalog.mdx deleted file mode 100644 index 212a3d3c2..000000000 --- a/i18n/nl/docusaurus-plugin-content-pages/solutions/software-catalog.mdx +++ /dev/null @@ -1,83 +0,0 @@ ---- -title: Software-catalogus voor de gemeente, Conduction -description: Een publieke software-catalogus voor de gemeente. Apps, datasets en APIs op één plek, federatie naar data.overheid.nl direct uit de doos. -hide_table_of_contents: true ---- - -import {DetailHero, Section, SectionHead, FeatureList, FeatureItem, PairRow, PairCard, CtaBanner} from '@conduction/docusaurus-preset/components'; - -Een publieke software-catalogus. Apps, datasets en APIs die je organisatie gebruikt, doorzoekbaar op één plek, gefedereerd naar data.overheid.nl en Forum Standaardisatie direct uit de doos. Geen aparte tooling, geen jaarlijkse upload-marathon.} - primaryCta={{label: 'Praat met een partner', href: '/partners'}} - secondaryCta={{label: 'Lees de recipe', href: 'https://docs.conduction.nl/software-catalog'}} - iconColor="var(--c-blue-cobalt)" - icon={} -/> - -
- - - } - title="Zoeken dat werkt voor burgers." - > - Burgers landen op jouwgemeente.nl/catalogi en zoeken in één veld door alle gepubliceerde datasets, APIs en applicaties. NLDS-conforme UX uit de doos. - - } - title="DCAT-AP en NLDS-metadata." - > - Elke entry exporteert als DCAT-AP en Nederlandse profiel-metadata. data.overheid.nl en Forum Standaardisatie pikken het op zonder handmatige upload. - - } - title="Federatie met andere gemeenten." - > - Je catalogus federeert met die van andere gemeenten via signed manifests. Gezamenlijke standaarden, gezamenlijke implementatie-dossiers. - - } - title="Audit trail per entry." - > - Elke publicatie, edit of intrekking laat een tamper-evident trail. Klaar voor inspectie of verantwoording. - - -
- -
- - - } - name="OpenCatalogi" - why="De doorzoekbare publicatielaag. Federatie naar data.overheid.nl, NLDS-UI, zoeken voor burgers." - /> - } - name="OpenRegister" - why="Elke catalogus-entry is een typed register-record. Schema, audit-log, citation-stable IDs, direct uit de doos." - /> - -
- -Drop OpenCatalogi en OpenRegister in je Nextcloud, configureer de eerste registers, en burgers zoeken dezelfde middag.} - primaryCta={{label: 'Installeer uit de app store', href: '/install'}} - secondaryCta={{label: 'Praat met een partner', href: '/partners'}} -/> diff --git a/i18n/nl/docusaurus-plugin-content-pages/solutions/zaakafhandeling.mdx b/i18n/nl/docusaurus-plugin-content-pages/solutions/zaakafhandeling.mdx index 6695b65e8..ef7b71929 100644 --- a/i18n/nl/docusaurus-plugin-content-pages/solutions/zaakafhandeling.mdx +++ b/i18n/nl/docusaurus-plugin-content-pages/solutions/zaakafhandeling.mdx @@ -1,6 +1,6 @@ --- title: ZaakAfhandelApp, complete zaakafhandeling op Nextcloud -description: ZaakAfhandelApp combineert OpenRegister, PipelinQ, Procest en Windmill tot een complete zaakafhandeling op je eigen Nextcloud. ZGW-compliant, audit trail, burgerportaal. +description: ZaakAfhandelApp combineert OpenRegister, Pipelinq, Dossiq en Windmill tot een complete zaakafhandeling op je eigen Nextcloud. ZGW-compliant, audit trail, burgerportaal. hide_table_of_contents: true --- @@ -42,7 +42,7 @@ import {DetailHero, Section, SectionHead, FeatureList, FeatureItem, PairRow, Pai icon={} title="Burger-CRM ingebouwd." > - PipelinQ houdt elke burger-, organisatie- en relatie-historie bij. Een medewerker ziet alle voorgaande contactmomenten zodra een zaak opent. + Pipelinq houdt elke burger-, organisatie- en relatie-historie bij. Een medewerker ziet alle voorgaande contactmomenten zodra een zaak opent. } @@ -76,13 +76,13 @@ import {DetailHero, Section, SectionHead, FeatureList, FeatureItem, PairRow, Pai } - name="PipelinQ" + name="Pipelinq" why="Burger- en organisatie-CRM. Relaties, contactmomenten, communicatiehistorie. Eén ingang per burger, ongeacht welke zaak." /> } - name="Procest" + name="Dossiq" why="Case-management. Zaaktype-workflows uit de doos, aanpasbaar zonder code, configuratie blijft gehandhaafd na upgrades." /> SaaS via commonground.nu - Praat met een partner + Praat met een partner
diff --git a/i18n/nl/docusaurus-plugin-content-pages/support.mdx b/i18n/nl/docusaurus-plugin-content-pages/support.mdx index c3749a386..5ca9d61ac 100644 --- a/i18n/nl/docusaurus-plugin-content-pages/support.mdx +++ b/i18n/nl/docusaurus-plugin-content-pages/support.mdx @@ -5,16 +5,22 @@ hide_table_of_contents: true --- import {Section, SectionHead, HowSteps, HowStep, Card, Pill, HexBullet, HexNetwork, FeatureList, FeatureItem, PairRow, PairCard, Button, ManagedCommonGround} from '@conduction/docusaurus-preset/components'; +import EnquiryForm from '@site/src/components/EnquiryForm'; + +export const supportLeadTitle = (f) => + `Support enquiry: ${[f.firstName, f.lastName].filter(Boolean).join(' ') || 'Websitebezoeker'}${f.organisation ? ` (${f.organisation})` : ''}`; +export const partnerLeadTitle = (f) => + `Partner application: ${f.company || 'Onbekend bedrijf'}`;
-
+
-
+
Support
-

Hulp die past waar jij staat.

-

+

Hulp die past waar jij staat.

+

De apps zijn open source en gratis. De meeste teams installeren en draaien ze zelf. Heb je hulp nodig? Drie opties, op volgorde: lees de docs, open een GitHub-issue, of praat met een partner. Het partnerprogramma is het enige betaalde traject. Features zetten we nooit achter een betaalmuur.

@@ -88,7 +94,7 @@ import {Section, SectionHead, HowSteps, HowStep, Card, Pill, HexBullet, HexNetwo /> Beheerd onder ons gecertificeerde informatiebeveiligings-managementsysteem. Details op de ISO-pagina.} + desc={<>Beheerd onder ons gecertificeerde informatiebeveiligings-managementsysteem. Details op de kwaliteitspagina.} /> Een Nextcloud-framework alleen voor de overheid.
€250 per app per maand.} lede={<>Voor publieke organisaties draaien wij een managed Nextcloud-tenant als framework voor Common Ground-apps. Geen Files, geen Talk, geen Mail, geen Calendar, alleen de componenten waar je zaakbehandelaars echt mee werken. Wij richten de tenant in, installeren de componenten, monitoren, patchen en maken back-ups.} primaryCta={{label: 'Praat met ons', href: 'mailto:info@conduction.nl?subject=Managed%20Common%20Ground%20enquiry'}} - secondaryCta={{label: 'Compliance signals', href: '/iso'}} + secondaryCta={{label: 'Compliance signals', href: '/quality'}} components={[ {href: '/apps/openregister', name: 'OpenRegister', why: 'Typed register-store. €250 per maand, één registerset per component.', icon: }, {href: '/apps/opencatalogi', name: 'OpenCatalogi', why: 'Gefedereerde data-catalogus. €250 per maand, één catalogus per component.', icon: }, - {href: '/apps/openconnector', name: 'OpenConnector', why: 'API-gateway en integratie-runtime. €250 per maand, integraties inbegrepen.', icon: }, - {href: '/apps/docudesk', name: 'DocuDesk', why: 'Documentgeneratie, anonimisering, ondertekenen. €250 per maand.', icon: }, - {href: '/apps/mydash', name: 'MyDash', why: 'Dashboard-builder. €250 per maand, rolgebaseerde toegang inbegrepen.', icon: }, + {href: '/apps/integriq', name: 'Integriq', why: 'API-gateway en integratie-runtime. €250 per maand, integraties inbegrepen.', icon: }, + {href: '/apps/filinq', name: 'Filinq', why: 'Documentgeneratie, anonimisering, ondertekenen. €250 per maand.', icon: }, + {href: '/apps/launchpad', name: 'LaunchPad', why: 'Dashboard-builder. €250 per maand, rolgebaseerde toegang inbegrepen.', icon: }, {href: '/apps/openai-bridge', name: 'OpenAI Bridge', why: 'LLM-gateway met PII-redactie. €250 per maand, modelgebruik apart afgerekend.', icon: }, ]} footnote="Alleen beschikbaar voor publieke organisaties. We controleren de doelgroep (KvK-registratie, status als publiek lichaam) bij contact. Gehost bij Cyso onder ISAE 3402 Type II en ISO 27001:2022, Nederlandse jurisdictie, EU-data, geen Amerikaanse sub-processor." @@ -145,11 +151,11 @@ import {Section, SectionHead, HowSteps, HowStep, Card, Pill, HexBullet, HexNetwo align="stack" lede="Drie tiers laten zien hoe diep de samenwerking gaat. Elke tier installeert dezelfde apps. Het verschil zit in proces, reactietijd en gezamenlijke roadmap." /> -
+
PARTNER -

Implementatie, op aanvraag.

-
+

Implementatie, op aanvraag.

+
Zelf-gecertificeerd op de apps. Installeert en configureert voor één of twee klanten in een regio of sector.
    @@ -160,8 +166,8 @@ import {Section, SectionHead, HowSteps, HowStep, Card, Pill, HexBullet, HexNetwo GECERTIFICEERD -

    Gecertificeerd op de apps die je levert.

    -
    +

    Gecertificeerd op de apps die je levert.

    +
    Door Conduction gecertificeerd per app. Gezamenlijke go-to-market op aanbestedingen. Hogere SLA op gedeelde klanten.
      @@ -173,8 +179,8 @@ import {Section, SectionHead, HowSteps, HowStep, Card, Pill, HexBullet, HexNetwo STRATEGISCH -

      Gedeelde roadmap, gedeelde release.

      -
      +

      Gedeelde roadmap, gedeelde release.

      +
      De diepste partners van Conduction. Gedeelde release-cadence, hostingaanbod, gezamenlijke roadmap op de apps die ertoe doen.
        @@ -186,3 +192,138 @@ import {Section, SectionHead, HowSteps, HowStep, Card, Pill, HexBullet, HexNetwo
+ +
+ +
+ + +
+ + Ik zoek hulp +
+

Koppel me aan een partner.

+

Vertel ons wat je wilt bereiken. We sturen de vraag door naar een tot drie partners die passen bij je sector, je omvang en de apps die je op het oog hebt. Geen commissie, geen exclusiviteit.

+ +
+ + +
+ + + + + +
+ + +
+ + + +

Door te versturen ga je ermee akkoord dat Conduction en maximaal drie passende partners eenmalig contact opnemen. We delen alleen wat je hierboven hebt ingevuld. Privacyverklaring.

+ +
+ + +
+ + Ik wil partner worden +
+

Meld je aan voor het partnerprogramma.

+

If your team installs and operates Conduction apps for clients, the Partner tier is open. No fee, no exclusivity. The bar is one production deployment and a public reference.

+ +
+ + +
+ + + +
+ + +
+ + + + + +

We review applications within five business days. Approved partners get a directory listing, a Slack invite, and a slot in the next quarterly office hours.

+ +
+ +
+
diff --git a/i18n/nl/docusaurus-plugin-content-pages/terms.mdx b/i18n/nl/docusaurus-plugin-content-pages/terms.mdx index 33ed3925e..80783cdef 100644 --- a/i18n/nl/docusaurus-plugin-content-pages/terms.mdx +++ b/i18n/nl/docusaurus-plugin-content-pages/terms.mdx @@ -1,63 +1,356 @@ --- -title: Algemene voorwaarden -description: De algemene voorwaarden van Conduction. Apps, managed hosting, advieswerk, en hoe we met je contracteren. +title: Service Level Agreement +description: De SLA van Conduction voor SaaS en managed hosting. Beschikbaarheid, reactie- en oplostijden per prioriteit, ISO 27001-conforme beveiliging, capaciteit en software-updates. hide_table_of_contents: true --- -import {Section} from '@conduction/docusaurus-preset/components'; +import {DetailHero, Section, Showcase, FAQ, FAQItem} from '@conduction/docusaurus-preset/components'; -
+export const TermsIcon = ( + + + + + +); + +export const PROMISE_PANELS = { + uptime: ( +
+
Uptime-formule
+
+ Uptime % = (Overeengekomen Diensturen − Uren Downtime) / Overeengekomen Diensturen × 100% +
+
Telt niet als downtime
+
    + {['Gepland onderhoud', 'Verminderde prestaties', 'Overmacht', 'Internetstoringen', 'Handelingen of nalatigheid van klant/gebruiker', 'Handhaving van overheidsvoorschriften'].map((l, i) => ( +
  • + + {l} +
  • + ))} +
+
+ ), + response: ( +
+
Reactie en oplossing (kantooruren)
+
+
Prio
+
Reactie
+
Workaround / Oplossing
+
+
+ {[ + {p: 'P1', r: '2 uur', s: '4 uur / 8 uur', c: 'var(--c-orange-knvb)'}, + {p: 'P2', r: '4 uur', s: '16 uur / 48 uur', c: 'var(--c-blue-cobalt)'}, + {p: 'P3', r: 'een week', s: 'Planmatig', c: 'var(--c-cobalt-300)'}, + ].map((row, i) => ( +
+
+ + {row.p} +
+
{row.r}
+
{row.s}
+
+ ))} +
+
Kantooruren = ma–vr 09:00–17:00, officiële feestdagen niet inbegrepen.
+
+ ), + security: ( +
+ ISO 27001:2022-certificaat +
ISO/IEC 27001:2022
+
+ ), + capacity: ( +
+ {[ + {n: '5 GB', l: 'Opslag'}, + {n: '1 mln', l: 'Bevragingen / maand'}, + {n: '10 GB', l: 'Dataverkeer / maand'}, + ].map((b, i) => ( +
+
{b.n}
+
{b.l}
+
+ ))} +
+ ), + maintenance: ( +
+
Gepland onderhoud
+ {[ + {k: 'Aankondiging', v: '≥ 24 uur vooraf'}, + {k: 'Inplanning', v: 'Buiten kantooruren'}, + {k: 'Communicatie', v: 'Binnen redelijke termijn'}, + ].map((row, i) => ( +
0 ? '1px solid var(--c-cobalt-100)' : 'none'}}> + {row.k} + {row.v} +
+ ))} +
+ ), + updates: ( +
+
Inbegrepen in de SLA
+
    + {['Nieuwe versies en releases', 'Bugfixes', 'Beveiligingsupdates'].map((l, i) => ( +
  • + + {l} +
  • + ))} +
+
Buiten scope
+
Ontwikkeling van nieuwe functionaliteit naast bugfixes en beveiligingsupdates wordt per opdracht overeengekomen.
+
+ ), +}; + +Een belofte,
die we waarmaken.} + tagline={<>Voorwaarden zijn een belofte. Deze pagina is de Service Level Agreement waar Conduction zich aan committeert voor elk SaaS- en managed-hosting-abonnement: 95% uptime, vaste reactie- en oplostijden per prioriteit, ISO 27001-conforme beveiliging en een duidelijke capaciteit. De Nederlandse tekst hieronder is juridisch bindend; productspecifieke SLA's verwijzen naar deze basis.} + primaryCta={{label: 'Lees de beloftes', href: '#promises', tone: 'orange'}} + secondaryCta={{label: 'Productspecifieke SLA →', href: 'https://openwoo.app/Product/SLA/'}} + iconColor="var(--c-orange-knvb)" + icon={TermsIcon} +/> + +
+ + +
+
+ +## Service Level Agreement voor [product] + +
+ +
+
Service Level Agreement
+

[product] (productieomgevingen)

+
+
+
Standaard
+
ISO/IEC 27001-conform
+
+
+
Uptime
+
95% · 24/7
+
+
+
P1 reactie
+
2 u / 4 u / 8 u
+
+
+
Kantooruren
+
ma–vr 09:00–17:00
+
+
+
+ +### Intro + +Deze Service Level Agreement (SLA) omvat de afspraken tussen de organisatie die [product] gebruikt en Conduction die [product] als SaaS-oplossing levert. SaaS is de afkorting van Software as a Service. Ofwel: het aanbieden en gebruiken van software via internet in de vorm van een dienst. Klanten betalen de software in een abonnementsvorm naar gebruik in plaats van het eenmalig aankopen van software. Deze SLA beschrijft de niveaus van productbeschikbaarheid en -ondersteuning die verwacht kan worden van Conduction voor de duur van de overeenkomst. + +Als open-source softwareleverancier biedt Conduction B.V. ondersteuning op de hosting en beheer van de code. Let op: wij bieden geen derdelijns codeonderhoud en beheer, oftewel ondersteuning op de code van derden, aan. + +### Definities + +Binnen deze SLA worden de onderstaande termen gebruikt met de aangegeven betekenis. Enkelvoud en meervoud worden als gelijkwaardig beschouwd, en vice versa. De termen "maand", "kwartaal" en "jaar" verwijzen naar de kalendermaand, kalenderkwartaal en kalenderjaar, tenzij anders vermeld. Daarnaast duidt het woord "inclusief" op onbeperkte inclusie. + +| Term | Betekenis | +|---|---| +| **Reactietijd** | De tijdsduur tussen het ontvangen van een melding en het moment waarop Conduction de melding bevestigt. | +| **Oplostijd** | De periode die begint bij de Reactietijd en eindigt wanneer het Incident door Conduction als opgelost is gemeld. | +| **Downtime** | De periode waarin het product niet beschikbaar is voor de klant. Het begrip "downtime" omvat niet: gepland onderhoud; verminderde prestaties; factoren waarover Conduction geen controle heeft, waaronder overmachtsituaties; internetstoringen; handelingen of nalatigheid van de Klant en zijn Gebruikers; handhaving van overheidsvoorschriften. | +| **Beschikbaarheid** | Mate waarin de geboden dienst toegankelijk is voor geautoriseerde gebruikers. Een dienst is niet beschikbaar indien zich een prioriteit 1-storing op de dienst voordoet. | +| **Gepland Onderhoud** | Conduction zal gepland onderhoud van de service aankondigen met een minimum termijn van 24 uur voorafgaand aan het daadwerkelijke onderhoud. Conduction streeft ernaar de aankondiging binnen een redelijke termijn te doen en, indien mogelijk, zal de geplande uitval tijdens de Onderhoudsuren plaatsvinden. | +| **Onderhoudsuren** | Vinden plaats tijdens kantooruren. | +| **Incident** | Indien een P1-, P2- of P3-melding door de klant wordt gedaan. | +| **Kantooruren** | Maandag tot en met vrijdag van 09:00 tot 17:00 uur. Niet inbegrepen: officiële feestdagen. | +| **SLA Ingangsdatum** | De startdatum zoals vermeld in de overeenkomst en de datum waarop deze SLA in werking treedt. | +| **Ticket** | Een elektronisch verzoek dat door de klant naar Conduction is verzonden (bijvoorbeeld een verzoek om een oplossing van een Incident). | +| **Verminderde Prestaties** | Een verminderde servicekwaliteit zoals beschreven in deze SLA (bijvoorbeeld tijdelijk verbroken of tijdelijk niet beschikbare functionaliteit). | + +### Scope van de SLA -# Algemene voorwaarden +Deze SLA is uitsluitend van toepassing op het product en de professionele diensten zoals beschreven in de Overeenkomst. Deze SLA is niet van toepassing op software, apparatuur, diensten of andere componenten van een informatietechnologiesysteem die niet zijn aangeschaft of beheerd worden door Conduction. -Dit is de Nederlandse en juridisch bindende versie van onze voorwaarden. **Deze pagina is een werkversie en wacht op externe juridische toets.** +Conduction zal materiële problemen met het product oplossen, tenzij: -## 1. Partijen en toepassingsgebied +- het probleem is ontstaan doordat de klant het product heeft gebruikt op een manier die in strijd is met de instructies, training of kennisbank van Conduction; +- de klant ongeoorloofde wijzigingen heeft aangebracht in de configuratie of opzet van de betreffende omgeving; +- de klant Conduction heeft belemmerd bij het uitvoeren van onderhoud aan de omgeving; +- het probleem is veroorzaakt door derden; +- het probleem is veroorzaakt door gebruiker(s), bijvoorbeeld door het aanpassen van een deel van de software, configuratie of door het verwijderen of toekennen van onjuiste rechten aan gebruikers. -Deze voorwaarden gelden voor elke commerciële relatie tussen Conduction B.V. (KvK 76741850, BTW NL860784241B01, Lauriergracht 14h, 1016 RR Amsterdam) en de contractspartij. +### Ingangsdatum en termijn -## 2. Wat we leveren +Deze SLA treedt in werking vanaf de startdatum en eindigt zonder verdere kennisgeving, compensatie of restitutie bij het verstrijken of beëindigen van de overeenkomst. -Conduction levert drie soorten werk: +
-- **Apps**, de open-source software die we publiceren in de Nextcloud app store. Elke app valt onder EUPL-1.2, tenzij de README iets anders zegt. Iedereen mag ze installeren en gebruiken zonder contract met Conduction. -- **Managed hosting**, onze SaaS Common Ground componenten gehost op commonground.nu. Prijzen en SLA staan op [de Support-pagina](/support). -- **Advies- en ontwikkelwerk**, per opdracht afgebakend. De geldende opdrachtovereenkomst bevat de leveringen, mijlpalen en tarieven. +### Beschikbaarheid -## 3. Open-source apps +Conduction garandeert een uptime van 95% gedurende 24 uur per dag, 7 dagen per week. De Uptime wordt berekend op basis van het maandelijkse gemiddelde van beschikbaarheid, afgerond naar beneden op de dichtstbijzijnde minuut, volgens de volgende formule: -Apps blijven eigendom van hun auteurs en bijdragers. De bijdrage van Conduction valt onder EUPL-1.2. De apps worden zonder garantie geleverd. Wil je wel garantie, dan contracteer je een van onze [partners](/partners) of Conduction zelf via een opdrachtovereenkomst. +``` +Uptime % = (Overeengekomen Diensturen − Uren Downtime) / Overeengekomen Diensturen × 100% +``` -## 4. Managed hosting +
-Common Ground componenten factureren we maandelijks per app, vooraf, via SEPA-incasso of factuur. De SLA, supporturen en reactietijd bij incidenten staan op de Beheer-pagina. De opzegtermijn is één kalendermaand. +### Capaciteit -## 5. Advies en ontwikkeling +Conduction richt haar prijsstelling op kleine organisaties en hanteert dus weinig opslag in haar abonnementen. Dat betekent dat we bovenmatig gebruik doorbelasten aan de hand van nacalculatie. Hierbij gaat het echter wel om bovenmatig gebruik van servers of diensten; regulier gebruik zou niet tot een nacalculatie moeten leiden. De beschikbare capaciteit per organisatie bedraagt: -Voor advies- en ontwikkelopdrachten geldt een eigen opdrachtovereenkomst. Deze voorwaarden gelden aanvullend, voor wat de opdrachtovereenkomst niet regelt. +- 5 GB opslag +- 1 miljoen bevragingen per maand +- 10 GB dataverkeer per maand -## 6. Prijswijzigingen +### Stappen voor het melden van incidenten -Conduction mag de prijzen voor managed hosting eens per kalenderjaar aanpassen, met twee maanden opzegtermijn. Accepteer je de wijziging niet, dan kun je opzeggen voordat de nieuwe prijs ingaat. +Als er een probleem optreedt, neem dan tijdens kantoortijden contact op met onze helpdesk via het aangewezen Slack-channel, [support@conduction.nl](mailto:support@conduction.nl) of +31 85 303 6840. We streven ernaar binnen 2 uur te reageren op het probleem en er wordt een ticket toegewezen. Indien er buiten kantoortijden een melding plaatsvindt, wordt deze de eerstvolgende werkdag om 09:00 meegenomen. -## 7. Aansprakelijkheid +
-De aansprakelijkheid van Conduction per kalenderjaar is gemaximeerd op de vergoedingen die je in de afgelopen twaalf maanden aan ons hebt betaald. We zijn nooit aansprakelijk voor indirecte of gevolgschade. Deze cap geldt niet bij opzet of grove schuld. +### Reactie- en oplostijden -## 8. Privacy en informatiebeveiliging +We streven ernaar om zo snel mogelijk na ontvangst van het probleem te reageren en het probleem spoedig op te lossen, afhankelijk van de complexiteit van het probleem. Hieronder een overzicht van de prioriteitsniveaus die gebruikt worden voor de reactie- en oplostijden: -We verwerken persoonsgegevens volgens ons [privacybeleid](/privacy) en ons [ISO 27001:2022 informatiebeveiligingsmanagementsysteem](/iso). Op verzoek tekenen we een verwerkersovereenkomst. +- **Prioriteit 1 (P1)**: software is in zijn geheel niet beschikbaar. +- **Prioriteit 2 (P2)**: problemen die de servicekwaliteit verminderen, zoals intermitterende site- of productproblemen (bijvoorbeeld het niet doorkomen van publicaties). +- **Prioriteit 3 (P3)**: algemene problemen zoals productvragen, functieverzoeken en ontwikkelingsproblemen. -## 9. Beëindiging +Een klant kan bij het registreren van een melding een prioriteit meegeven. Deze wordt getoetst door een medewerker van Conduction en in redelijkheid en billijkheid behandeld: -Beide partijen kunnen een opdrachtovereenkomst beëindigen op de gronden die in die overeenkomst staan. Voor managed hosting geldt de opzegtermijn uit artikel 4. Bij beëindiging dragen we je gegevens over in een open, gedocumenteerd formaat. +| Incident-prioriteit | Reactietijd * | Workaround / Oplossing * | +|---|---|---| +| **P1** | 2 uur | 4 uur / 8 uur | +| **P2** | 4 uur | 16 uur / 48 uur | +| **P3** | een week | Planmatig | -## 10. Toepasselijk recht en forum +\* De genoemde uren tellen alleen binnen kantoortijd. Niet inbegrepen: officiële feestdagen. -Nederlands recht is van toepassing. De rechtbank Amsterdam is exclusief bevoegd. +#### Uitzonderingen -## Contact +**Updates aan het platform.** Updates zullen altijd buiten kantooruren worden uitgevoerd. Incidenten die zich voordoen tijdens deze updates vallen niet onder de definitie van incident zoals vermeld in deze SLA. -Vragen over deze voorwaarden kun je sturen naar [info@conduction.nl](mailto:info@conduction.nl). +**Niet-verwijtbare incidenten.** Incidenten bij de klant die veroorzaakt worden buiten de schuld van Conduction vallen niet onder de definitie van incident zoals vermeld in deze SLA. Deze incidenten worden op basis van kosten en tijdsbestek hersteld. + +**Veiligheidsupdates.** Updates die om veiligheidsredenen worden uitgevoerd, worden niet beschouwd als incident zoals vermeld in deze SLA. + +### Beveiliging + +Gegevensbeveiliging is van essentieel belang voor Conduction. Ons beveiligingsbeleid voldoet volledig aan ISO/IEC 27001, een internationaal erkende standaard voor informatiebeveiliging, en wordt onder hetzelfde managementsysteem geauditeerd als beschreven op onze [Quality-pagina](/quality). + +### Software-updates + +Binnen deze SLA zal Conduction de klant voorzien van nieuwe versies, releases en updates van het product. Aangezien [product] een open-source product is, valt ontwikkeling van nieuwe versies, releases en updates (naast bugfixing en beveiligingsupdates) buiten de SLA. + +### Periodieke evaluatie + +Tijdens de maandelijkse community-meeting van [product] zal er een vertegenwoordiger van Conduction aanwezig zijn. In deze meeting wordt het product geëvalueerd en de richting, roadmap en keuzes toegelicht. + +
+ Vastgesteld door de directie van Conduction B.V., Amsterdam, Nederland. Deze Nederlandse tekst is de juridisch bindende versie van de SLA. +
+ +
+ +
+ + + +
+ + + + Downtime is de periode waarin het product niet beschikbaar is voor de klant (een P1-incident). Het begrip omvat niet: gepland onderhoud, verminderde prestaties, overmacht, internetstoringen, handelingen of nalatigheid van klant of gebruiker, of handhaving van overheidsvoorschriften. + + + Je kunt bij het registreren van een melding een prioriteit meegeven. Een medewerker van Conduction toetst deze en behandelt hem in redelijkheid en billijkheid. De definitieve prioriteit ligt bij ons, zodat de reactie- en oplostijden zinvol blijven. + + + Incidenten die zich voordoen tijdens platform-updates vallen niet onder de definitie van incident in deze SLA. Updates worden specifiek buiten kantooruren ingepland zodat de werkdag niet wordt verstoord. + + + Elk [product] kent een eigen SLA-pagina die naar deze basis verwijst en productspecifieke aanvullingen bevat (afwijkende capaciteit, communicatiekanalen). De OpenWoo-uitvoering staat op openwoo.app/Product/SLA; andere producten verwijzen vanaf hun eigen productpagina. + + + Conduction richt haar prijsstelling op kleine organisaties en includeert 5 GB opslag, 1 miljoen bevragingen en 10 GB dataverkeer per organisatie per maand. Bovenmatig gebruik wordt achteraf doorbelast op basis van nacalculatie; regulier gebruik zou daar niet toe moeten leiden. + + + Nee, alleen bugfixes en beveiligingsupdates. Aangezien [product] open-source is, valt de ontwikkeling van nieuwe functionaliteit buiten de SLA en wordt deze per opdracht overeengekomen. + + diff --git a/i18n/nl/docusaurus-theme-classic/footer.json b/i18n/nl/docusaurus-theme-classic/footer.json index a4dfc5448..b2e4f3e20 100644 --- a/i18n/nl/docusaurus-theme-classic/footer.json +++ b/i18n/nl/docusaurus-theme-classic/footer.json @@ -4,7 +4,7 @@ "description": "The title of the footer links column with title=Apps in the footer" }, "link.title.Solutions": { - "message": "Solutions", + "message": "Oplossingen", "description": "The title of the footer links column with title=Solutions in the footer" }, "link.title.Resources": { @@ -24,24 +24,20 @@ "description": "The label of footer link with label=OpenRegister linking to https://openregister.conduction.nl/" }, "link.item.label.OpenConnector": { - "message": "OpenConnector", + "message": "Integriq", "description": "The label of footer link with label=OpenConnector linking to https://openconnector.conduction.nl/" }, "link.item.label.DocuDesk": { - "message": "DocuDesk", + "message": "Filinq", "description": "The label of footer link with label=DocuDesk linking to https://docudesk.conduction.nl/" }, - "link.item.label.MyDash": { - "message": "MyDash", - "description": "The label of footer link with label=MyDash linking to https://mydash.conduction.nl/" + "link.item.label.LaunchPad": { + "message": "LaunchPad", + "description": "The label of footer link with label=LaunchPad linking to https://launchpad.conduction.nl/" }, - "link.item.label.WOO compliance": { - "message": "Woo-compliance", - "description": "The label of footer link with label=WOO compliance linking to /solutions/woo" - }, - "link.item.label.Software catalog": { - "message": "Softwarecatalogus", - "description": "The label of footer link with label=Software catalog linking to /solutions/software-catalog" + "link.item.label.OpenWoo": { + "message": "OpenWoo", + "description": "The label of footer link with label=OpenWoo linking to /solutions/openwoo" }, "link.item.label.Support": { "message": "Support", @@ -71,9 +67,13 @@ "message": "Team", "description": "The label of footer link with label=Team linking to /about#team" }, - "link.item.label.ISO": { - "message": "ISO", - "description": "The label of footer link with label=ISO linking to /iso" + "link.item.label.Quality": { + "message": "Kwaliteit", + "description": "The label of footer link with label=Quality linking to /quality" + }, + "link.item.label.Way of Work": { + "message": "Werkwijze", + "description": "The label of footer link with label=Way of Work linking to https://docs.conduction.nl/WayOfWork/way-of-work/" }, "link.item.label.Identity": { "message": "Identiteit", @@ -82,5 +82,37 @@ "copyright": { "message": "Conduction B.V. · KvK 76741850 · BTW NL860784241B01 · IBAN NL51 ABNA 0868951550 · Lauriergracht 14h, 1016 RR Amsterdam · © 2026", "description": "The footer copyright" + }, + "link.item.label.Partners": { + "message": "Partners", + "description": "The label of footer link with label=Partners linking to /partners" + }, + "link.item.label.Blogs": { + "message": "Blogs", + "description": "The label of footer link with label=Blogs linking to /academy?type=blog" + }, + "link.item.label.Guides": { + "message": "Gidsen", + "description": "The label of footer link with label=Guides linking to /academy?type=guide" + }, + "link.item.label.Tutorials": { + "message": "Tutorials", + "description": "The label of footer link with label=Tutorials linking to /academy?type=tutorial" + }, + "link.item.label.Webinars": { + "message": "Webinars", + "description": "The label of footer link with label=Webinars linking to /academy?type=webinar" + }, + "link.item.label.Case studies": { + "message": "Klantcases", + "description": "The label of footer link with label=Case studies linking to /academy?type=case-study" + }, + "link.item.label.How we use AI": { + "message": "Hoe wij AI gebruiken", + "description": "The label of footer link with label=How we use AI linking to /ai" + }, + "link.item.label.Cookies": { + "message": "Cookies", + "description": "The label of footer link with label=Cookies linking to /privacy#cookies" } } diff --git a/i18n/nl/docusaurus-theme-classic/navbar.json b/i18n/nl/docusaurus-theme-classic/navbar.json index 54f3e5701..de0d22c6a 100644 --- a/i18n/nl/docusaurus-theme-classic/navbar.json +++ b/i18n/nl/docusaurus-theme-classic/navbar.json @@ -12,7 +12,7 @@ "description": "Navbar item with label Apps" }, "item.label.Solutions": { - "message": "Solutions", + "message": "Oplossingen", "description": "Navbar item with label Solutions" }, "item.label.Support": { @@ -30,5 +30,9 @@ "item.label.Install": { "message": "Installeer", "description": "Navbar item with label Install" + }, + "item.label.Academy": { + "message": "Academy", + "description": "Navbar item with label Academy" } } diff --git a/package-lock.json b/package-lock.json index a70924d26..1bf791fe6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,11 @@ "version": "0.0.0", "hasInstallScript": true, "dependencies": { - "@conduction/docusaurus-preset": "^3.3.0", - "@docusaurus/core": "^3.5.0", - "@docusaurus/preset-classic": "^3.5.0", + "@conduction/docusaurus-plugin-portaliq": "git+https://github.com/ConductionNL/docusaurus-plugin-portaliq.git#1bed5e053637024e1a86b2bc1515e25bc8840123", + "@conduction/docusaurus-preset": "^3.51.1", + "@docusaurus/core": "^3.10.2", + "@docusaurus/plugin-client-redirects": "^3.10.2", + "@docusaurus/preset-classic": "^3.10.2", "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", "prism-react-renderer": "^2.3.0", @@ -19,54 +21,70 @@ "react-dom": "^18.0.0" }, "devDependencies": { - "@docusaurus/module-type-aliases": "^3.5.0", - "@docusaurus/types": "^3.5.0" + "@docusaurus/module-type-aliases": "^3.10.2", + "@docusaurus/types": "^3.10.2", + "@playwright/test": "^1.62.1" }, "engines": { - "node": ">=18.0" + "node": ">=20.0" + } + }, + "node_modules/@11ty/gray-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@11ty/gray-matter/-/gray-matter-1.0.0.tgz", + "integrity": "sha512-7mJJl+wf1AByoT0PknQiQfOPnVNT4fevGrUBVWO4HXsnYn1aQPyRyrELYrNUFleUBM++KzMKN6QaxHPk0t/6/g==", + "license": "MIT", + "dependencies": { + "js-yaml": "^4.1.0", + "kind-of": "^6.0.3", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=11" } }, "node_modules/@algolia/abtesting": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.18.1.tgz", - "integrity": "sha512-aehCadlWOGvrT91KUIZpC0MbB8KBW9yUuvTJFd2xesR7le/IsT4nJUnjCCZ4ZqZCeTcPHPV5mo//fZ5oxcSVYw==", + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.22.0.tgz", + "integrity": "sha512-BFR6zNowNKcY7Ou7TaJc9QWexES4YKPbmf/OTFofpdsdhz4x6q0lbxp3duO0EHnyrN7rE4ba/TSXuY+BDGu4+g==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.52.1", - "@algolia/requester-browser-xhr": "5.52.1", - "@algolia/requester-fetch": "5.52.1", - "@algolia/requester-node-http": "5.52.1" + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/autocomplete-core": { - "version": "1.19.8", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.19.8.tgz", - "integrity": "sha512-3YEorYg44niXcm7gkft3nXYItHd44e8tmh4D33CTszPgP0QWkaLEaFywiNyJBo7UL/mqObA/G9RYuU7R8tN1IA==", + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.19.9.tgz", + "integrity": "sha512-4U2JKLMWlDu0CotYyUkWakDxr8AIav3QtIUXXRpfavYN29aVWfzlwJp9T0rPKEf/dO2QCPAUc0Kq1Tj1GJxo2A==", "license": "MIT", "dependencies": { - "@algolia/autocomplete-plugin-algolia-insights": "1.19.8", - "@algolia/autocomplete-shared": "1.19.8" + "@algolia/autocomplete-plugin-algolia-insights": "1.19.9", + "@algolia/autocomplete-shared": "1.19.9" } }, "node_modules/@algolia/autocomplete-plugin-algolia-insights": { - "version": "1.19.8", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.19.8.tgz", - "integrity": "sha512-ZvJWO8ZZJDpc1LNM2TTBdmQsZBLMR4rU5iNR2OYvEeFBiaf/0ESnRSSLQbryarJY4SVxtoz6A2ZtDMNM+iQEAA==", + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.19.9.tgz", + "integrity": "sha512-6mExC6X7762s2SV3eJy3QOkB8bdMmnUhQ2agvGVDuzwoGyr3PquGSY/0vPQXCfiAiCaXUz1rXn+lwghgSi0l0w==", "license": "MIT", "dependencies": { - "@algolia/autocomplete-shared": "1.19.8" + "@algolia/autocomplete-shared": "1.19.9" }, "peerDependencies": { "search-insights": ">= 1 < 3" } }, "node_modules/@algolia/autocomplete-shared": { - "version": "1.19.8", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.19.8.tgz", - "integrity": "sha512-h5hf2t8ejF6vlOgvLaZzQbWs5SyH2z4PAWygNAvvD/2RI29hdQ54ldUGwqVuj9Srs+n8XUKTPUqb7fvhBhQrnQ==", + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.19.9.tgz", + "integrity": "sha512-YosP9Uoek6y/Ur1r1qeogk4biMe/hzkyNcgMCciw0//3XpCM7VlYLSHnyt/vOnEOGhCCc0+3v+unEiH6zz+Z1A==", "license": "MIT", "peerDependencies": { "@algolia/client-search": ">= 4.9.1 < 6", @@ -74,99 +92,99 @@ } }, "node_modules/@algolia/client-abtesting": { - "version": "5.52.1", - "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.52.1.tgz", - "integrity": "sha512-HmXOGBOAOJPounpBzBpuY0zDYeiCpxgHnQmuA7JO6ScukcBdGp3/XM9zJk5pJx/xNGD68mbPGXWpDxGtl6BwDQ==", + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.56.0.tgz", + "integrity": "sha512-7r4Z3NC7yU1oAQVWJNA2HX7tX481F3pJvCGyLIXiTdBcthz4Q/o21jwcMYDFkuI92UWTNBQQmHYgwHo1zS5dzg==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.52.1", - "@algolia/requester-browser-xhr": "5.52.1", - "@algolia/requester-fetch": "5.52.1", - "@algolia/requester-node-http": "5.52.1" + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-analytics": { - "version": "5.52.1", - "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.52.1.tgz", - "integrity": "sha512-5oo4+I8iixie9vXhCyNFCzeIr8pqA3FQ//VsLHTDvZAV4ttYOPGvYHGQq5NSalrLx5Jc3dRro/5uDOlnUMcBJg==", + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.56.0.tgz", + "integrity": "sha512-avmjXQSq+jadFO8Xl2em05/uQdQnEmHsJyOAdVbZkmVgpMfxL12aJwVVfGNwYr9nulcpuJN1X0lTaQ5wxuNGcA==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.52.1", - "@algolia/requester-browser-xhr": "5.52.1", - "@algolia/requester-fetch": "5.52.1", - "@algolia/requester-node-http": "5.52.1" + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-common": { - "version": "5.52.1", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.52.1.tgz", - "integrity": "sha512-qCDoZfx5MpX7XQzvQ3bC4tSEMkQWQMaF/ABtLuoze03Y/flR563CCSws02qIJ23oX7lxl92LsilZjINVyTdtLw==", + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.56.0.tgz", + "integrity": "sha512-v2TPStUhY//ripPjIVclZ8AWc7DEGooXULZGFlFu37zNatgHjw34oZZ+OSbbc/YHO+xZwPl62I1k8xH1m4S2eg==", "license": "MIT", "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-insights": { - "version": "5.52.1", - "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.52.1.tgz", - "integrity": "sha512-hnGs0/lsFJ2PWDxNBz7pxreXo/Xz7gxYRcfePBUjsH26ad0kU/sgnVZd9LwWBpsQv65z2jlb5dkyaB9WE9M9FQ==", + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.56.0.tgz", + "integrity": "sha512-P0ehROpM4Sem3Sqo5x2cKPgj67D3G3jy0rh1Amwkcvsfr6tkvIcdCmerieanqTF7NxUMPNFLkpIFeMO8Rpa50w==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.52.1", - "@algolia/requester-browser-xhr": "5.52.1", - "@algolia/requester-fetch": "5.52.1", - "@algolia/requester-node-http": "5.52.1" + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-personalization": { - "version": "5.52.1", - "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.52.1.tgz", - "integrity": "sha512-2VxxNc/uBysyKvGeBdSM5n9eIDKH8kWD7wd9/yqbJAiVwU4Yv6tU1LSJusHKrXV/aCu1KW7t9Gug9QyeEmtn/Q==", + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.56.0.tgz", + "integrity": "sha512-SXK3Vn3WVxyzbm31oePZBJkp1wpOyuWdd4B/Pv7n0aXDxmeSWhC1R1FC1517mMrFAIaPH4Rt0x6RUe7ZNjz8FA==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.52.1", - "@algolia/requester-browser-xhr": "5.52.1", - "@algolia/requester-fetch": "5.52.1", - "@algolia/requester-node-http": "5.52.1" + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-query-suggestions": { - "version": "5.52.1", - "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.52.1.tgz", - "integrity": "sha512-O6mPtsw3xEfNOe6gWFpYLeAZAIljNa4Hgna3bq15PwyN7nbjTY0wXJFRbzs/0YVf75Br+SbOQUmjKxXYjDiSiQ==", + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.56.0.tgz", + "integrity": "sha512-5+ZdX8garFnmycnZgKhtXHePEaLj5zqDxI/0lkhhluzCcvTn0/PvvTirTg8hHYetQHvn7GDyeAiqTAieMvMW4A==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.52.1", - "@algolia/requester-browser-xhr": "5.52.1", - "@algolia/requester-fetch": "5.52.1", - "@algolia/requester-node-http": "5.52.1" + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-search": { - "version": "5.52.1", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.52.1.tgz", - "integrity": "sha512-gA8oJOV1LnQQkDf91iebNnFInHuW0gRPEgLSOQ7EfipCEjYTHm5swm1DlH9H5RaRw4RrHuzHBegnlzc0MAstcg==", + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.56.0.tgz", + "integrity": "sha512-+mKUdYvqOi0BcvpAEyCEw49vSBptufIcfibtHz2bdr1pI789M46Yt0uQEk/sxtK3teh71OQvVFHaTDzShUWewQ==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.52.1", - "@algolia/requester-browser-xhr": "5.52.1", - "@algolia/requester-fetch": "5.52.1", - "@algolia/requester-node-http": "5.52.1" + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" }, "engines": { "node": ">= 14.0.0" @@ -179,93 +197,93 @@ "license": "MIT" }, "node_modules/@algolia/ingestion": { - "version": "1.52.1", - "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.52.1.tgz", - "integrity": "sha512-U9zZfc5xIu9wRxZkt+HceJUAD4VKHKbAyLSloJdEyMRmphXeibfrY9cxqIXBcmPeZzGhn3Imb35Dq8l19PkJhw==", + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.56.0.tgz", + "integrity": "sha512-9g/zj+AZx5moFcdFIrYQoVrueXivjUcc3MQHtCYT8WhIuk1lUh1AyEhvJCS0XBZld09cLvd1AZ3BvDBpVpX2UA==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.52.1", - "@algolia/requester-browser-xhr": "5.52.1", - "@algolia/requester-fetch": "5.52.1", - "@algolia/requester-node-http": "5.52.1" + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/monitoring": { - "version": "1.52.1", - "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.52.1.tgz", - "integrity": "sha512-a3SGNceHmkQfq77iG8Ka+w1pvwfZa/0lzEIgse30fL0kD+yKnd/dg0dQvSfFPAEt2f21DMcGkDSSeJlO3KdQjQ==", + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.56.0.tgz", + "integrity": "sha512-Qf3Sr6f9A9uxCZUf3MXS0d2b877uYzEB5yxqpVGXAhcJnBCQjrRRon0KvefpGkxy+BshrIJs96OUoMtGqXTFDA==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.52.1", - "@algolia/requester-browser-xhr": "5.52.1", - "@algolia/requester-fetch": "5.52.1", - "@algolia/requester-node-http": "5.52.1" + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/recommend": { - "version": "5.52.1", - "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.52.1.tgz", - "integrity": "sha512-z98QEguCFDpxb4S/PyrUK1igqF8tPsdbqOUUO6ON91vJ58w+Gwa6ncrI0oNXSFcrkxA5EqPKPQ2A1PBCn08TYQ==", + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.56.0.tgz", + "integrity": "sha512-GXWG1rWc5wu8hY4N33Y3b6ernY6sAdAvmKWN/zHAiACOx40WnpG0TVX5YazCAr/9gOYGInSiM2A0y2jy2xbiDA==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.52.1", - "@algolia/requester-browser-xhr": "5.52.1", - "@algolia/requester-fetch": "5.52.1", - "@algolia/requester-node-http": "5.52.1" + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-browser-xhr": { - "version": "5.52.1", - "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.52.1.tgz", - "integrity": "sha512-CI7+/0I11QeZM59Uc8whd2or0kqzFVjpaPn9Qpwll/krHcBAxk24WkAQ6WX+IwDVMfpont4YGbKwAmCre3vE8Q==", + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.56.0.tgz", + "integrity": "sha512-7t24cBxaInS3mZb7ddEaZT/tp6q+/aR4YttsQVyP1/i+LmwPR34atO35KjaLFCcRVrlP7sYOAqkCfg6lIRB+ew==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.52.1" + "@algolia/client-common": "5.56.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-fetch": { - "version": "5.52.1", - "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.52.1.tgz", - "integrity": "sha512-S6bDuw9byfOvm3T71cgdoZgrgnZq6hpdMLkx52Louh57nUAmvGQESz2aojOynQHjbTiV55smvAFbgn0qT4tJrg==", + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.56.0.tgz", + "integrity": "sha512-R7ePHgVYmDFjZpvrsVAfbDz/d4RxKAYZ5/vgLfIsCVRZRryjWl/3INOxpOICzitehQ5FjNtNjcLQTrmHPTcHBQ==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.52.1" + "@algolia/client-common": "5.56.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-node-http": { - "version": "5.52.1", - "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.52.1.tgz", - "integrity": "sha512-tqZXM+54rWo4mk5jL5Z/flE11nPmNEdXwFBM5py9DkOmbjeCNemfVd45FyM97XdzfZ0dl9uOJC6PYn1FpkeyQg==", + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.56.0.tgz", + "integrity": "sha512-PIOUXlSnrqM0S+WOgDRb4RzotydJH7ZoT6tOyL7tAO7qJOfvX5wsEW8Pe+PMKMwvuI4/gIyK9cg2H7lJXqnc4Q==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.52.1" + "@algolia/client-common": "5.56.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -274,29 +292,29 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", - "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -322,13 +340,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -350,13 +368,13 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -447,9 +465,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -469,27 +487,27 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -511,9 +529,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -567,27 +585,27 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -608,25 +626,25 @@ } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -1451,12 +1469,12 @@ } }, "node_modules/@babel/plugin-transform-react-constant-elements": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.27.1.tgz", - "integrity": "sha512-edoidOjl/ZxvYo4lSBOQGDSyToYVkTAwyVoa2tkuYTSmjrB1+uAedoL5iROVLXkxH+vRgA7uP4tMg2pUJpZ3Ug==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.29.7.tgz", + "integrity": "sha512-J0wGhKan+rIiE2OhfhRptySLrJ6SjQYM6b6N1FMlhyhCcw1Mig8vQjWchyB+bgHGDvaWo6Diu6CLRMra2uMtmg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1577,13 +1595,13 @@ } }, "node_modules/@babel/plugin-transform-runtime": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.0.tgz", - "integrity": "sha512-jlaRT5dJtMaMCV6fAuLbsQMSwz/QkvaHOHOSXRitGGwSpR1blCY4KUKoyP2tYO8vJcqYe8cEj96cqSztv3uF9w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.7.tgz", + "integrity": "sha512-xmAscdE/AsqRW7vutbPNoUmu/nF5SrLKPs7aoJgEjo35lLKA/Bc0i2rMv/hr1+Y0o1bQCiVtith3u2vdgRL39Q==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", "babel-plugin-polyfill-corejs2": "^0.4.14", "babel-plugin-polyfill-corejs3": "^0.13.0", "babel-plugin-polyfill-regenerator": "^0.6.5", @@ -1933,31 +1951,31 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -1965,13 +1983,13 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1987,11 +2005,26 @@ "node": ">=0.1.90" } }, + "node_modules/@conduction/docusaurus-plugin-portaliq": { + "version": "0.2.0", + "resolved": "git+ssh://git@github.com/ConductionNL/docusaurus-plugin-portaliq.git#1bed5e053637024e1a86b2bc1515e25bc8840123", + "integrity": "sha512-IR1vVnoDkgJChiIwPIgdKYYxPyHR1KehwZLQCeHVzXwQxPnS+3U+PQMtvZ8xgwy18DUKsaWfeGIqwc/6p7n1VQ==", + "license": "EUPL-1.2", + "engines": { + "node": "^22.14 || ^24 || >=26" + }, + "peerDependencies": { + "@docusaurus/core": "^3.0.0" + } + }, "node_modules/@conduction/docusaurus-preset": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@conduction/docusaurus-preset/-/docusaurus-preset-3.3.0.tgz", - "integrity": "sha512-xpNp/Azxwp1TbvnGLN8egxpPk/ZlpUEzdEHfwUDxf7McfpsM4C16JdgXaQ33n6GZAMCHKWrbseKjfSwRSbrmtQ==", + "version": "3.51.1", + "resolved": "https://registry.npmjs.org/@conduction/docusaurus-preset/-/docusaurus-preset-3.51.1.tgz", + "integrity": "sha512-qbmslRlBtQf4UtJSfYosgmodO/fLt2osL0vQET97W/a/zhCsfgSnsOJ0pgdoCDiO+ALHlWSaVFgc2jn8R9YGLQ==", "license": "EUPL-1.2", + "bin": { + "validate-ai-baseline": "bin/validate-ai-baseline.mjs" + }, "peerDependencies": { "@docusaurus/core": "^3.0.0", "@docusaurus/preset-classic": "^3.0.0", @@ -2210,6 +2243,41 @@ "postcss": "^8.4" } }, + "node_modules/@csstools/postcss-cascade-layers/node_modules/@csstools/selector-specificity": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz", + "integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss-selector-parser": "^7.0.0" + } + }, + "node_modules/@csstools/postcss-cascade-layers/node_modules/postcss-selector-parser": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/@csstools/postcss-color-function": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/@csstools/postcss-color-function/-/postcss-color-function-4.0.12.tgz", @@ -2596,6 +2664,41 @@ "postcss": "^8.4" } }, + "node_modules/@csstools/postcss-is-pseudo-class/node_modules/@csstools/selector-specificity": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz", + "integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss-selector-parser": "^7.0.0" + } + }, + "node_modules/@csstools/postcss-is-pseudo-class/node_modules/postcss-selector-parser": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/@csstools/postcss-light-dark-function": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/@csstools/postcss-light-dark-function/-/postcss-light-dark-function-2.0.11.tgz", @@ -3030,6 +3133,19 @@ "postcss": "^8.4" } }, + "node_modules/@csstools/postcss-scope-pseudo-class/node_modules/postcss-selector-parser": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/@csstools/postcss-sign-functions": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@csstools/postcss-sign-functions/-/postcss-sign-functions-1.1.4.tgz", @@ -3210,50 +3326,6 @@ "postcss": "^8.4" } }, - "node_modules/@csstools/selector-resolve-nested": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-3.1.0.tgz", - "integrity": "sha512-mf1LEW0tJLKfWyvn5KdDrhpxHyuxpbNwTIwOYLIvsTffeyOf85j5oIzfG0yosxDgx/sswlqBnESYUcQH0vgZ0g==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss-selector-parser": "^7.0.0" - } - }, - "node_modules/@csstools/selector-specificity": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz", - "integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "postcss-selector-parser": "^7.0.0" - } - }, "node_modules/@csstools/utilities": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@csstools/utilities/-/utilities-2.0.0.tgz", @@ -3377,9 +3449,9 @@ } }, "node_modules/@docusaurus/babel": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/babel/-/babel-3.10.1.tgz", - "integrity": "sha512-DZzFO1K3v/GoEt1fx1DiYHF4en+PuhtQf1AkQJa5zu3CoeKSpr5cpQRUlz3jr0m44wyzmSXu9bVpfir+N4+8bg==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/babel/-/babel-3.10.2.tgz", + "integrity": "sha512-aJ1hpGyvfkte3dDAfNbWM4biW4yWZBVz7TIGLZP+v+tWOBgxX3e0N5ZIXHIvmfNNXTI77pcHUx3KmtOk05Ze3Q==", "license": "MIT", "dependencies": { "@babel/core": "^7.25.9", @@ -3391,8 +3463,8 @@ "@babel/preset-typescript": "^7.25.9", "@babel/runtime": "^7.25.9", "@babel/traverse": "^7.25.9", - "@docusaurus/logger": "3.10.1", - "@docusaurus/utils": "3.10.1", + "@docusaurus/logger": "3.10.2", + "@docusaurus/utils": "3.10.2", "babel-plugin-dynamic-import-node": "^2.3.3", "fs-extra": "^11.1.1", "tslib": "^2.6.0" @@ -3402,17 +3474,17 @@ } }, "node_modules/@docusaurus/bundler": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/bundler/-/bundler-3.10.1.tgz", - "integrity": "sha512-HIqQPvbqnnQRe4NsBd1774KRarjXqS6wHsWELtyuSs1gCfvixJO2jUGH/OEBtr1Gvzpw+ze5CjGMvSJ8UE1KUw==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/bundler/-/bundler-3.10.2.tgz", + "integrity": "sha512-i0ZNcy0f0WhaOlYVgzLsWhIoEXO9kS3HRoKPtgE6vQtZUq7arKZaYdNBudr3mqCmd+TyOkwtwfHgs1ENj07r5g==", "license": "MIT", "dependencies": { "@babel/core": "^7.25.9", - "@docusaurus/babel": "3.10.1", - "@docusaurus/cssnano-preset": "3.10.1", - "@docusaurus/logger": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils": "3.10.1", + "@docusaurus/babel": "3.10.2", + "@docusaurus/cssnano-preset": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", "babel-loader": "^9.2.1", "clean-css": "^5.3.3", "copy-webpack-plugin": "^11.0.0", @@ -3445,18 +3517,18 @@ } }, "node_modules/@docusaurus/core": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-3.10.1.tgz", - "integrity": "sha512-3pf2fXXw0eVk8WnC3T4LIigRDupcpvngpKo9Vy7mYyBhuddc0klDUuZAIfzMoK6z05pdlk6EFC/vBSX43+1O5w==", - "license": "MIT", - "dependencies": { - "@docusaurus/babel": "3.10.1", - "@docusaurus/bundler": "3.10.1", - "@docusaurus/logger": "3.10.1", - "@docusaurus/mdx-loader": "3.10.1", - "@docusaurus/utils": "3.10.1", - "@docusaurus/utils-common": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-3.10.2.tgz", + "integrity": "sha512-EYByj6nk+aD9KeVxV6Hmo2/nAAT79P21Y82ycTBOBtrmqilloIbIEhgL2/8Xpt2Jz/pgNqHAwyusOGwmbKeJmA==", + "license": "MIT", + "dependencies": { + "@docusaurus/babel": "3.10.2", + "@docusaurus/bundler": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/mdx-loader": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "boxen": "^6.2.1", "chalk": "^4.1.2", "chokidar": "^3.5.3", @@ -3464,7 +3536,7 @@ "combine-promises": "^1.1.0", "commander": "^5.1.0", "core-js": "^3.31.1", - "detect-port": "^1.5.1", + "detect-port": "^2.1.0", "escape-html": "^1.0.3", "eta": "^2.2.0", "eval": "^0.1.8", @@ -3512,9 +3584,9 @@ } }, "node_modules/@docusaurus/cssnano-preset": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-3.10.1.tgz", - "integrity": "sha512-eNfHGcTKCSq6xmcavAkX3RRclHaE2xRCMParlDXLdXVP01/a2e/jKXMj/0ULnLFQSNwwuI62L0Ge8J+nZsR7UQ==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-3.10.2.tgz", + "integrity": "sha512-4gCnHRbJLTloiwfvFAa92tgb2gI4KYhvjfQVYnEaiMO/EgvWfCo1LwytHXen+1oZAN0VAlS0JAPxp3MsvKDa3A==", "license": "MIT", "dependencies": { "cssnano-preset-advanced": "^6.1.2", @@ -3527,9 +3599,9 @@ } }, "node_modules/@docusaurus/logger": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/logger/-/logger-3.10.1.tgz", - "integrity": "sha512-oPjNFnfJsRCkePVjkGrxWGq4MvJKRQT0r9jOP0eRBTZ7Wr9FAbzdP/Gjs0I2Ss6YRkPoEgygKG112OkE6skvJw==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/logger/-/logger-3.10.2.tgz", + "integrity": "sha512-gSEwqtPfCAnC3ZSJY6xL7tcIfgg0vFD39jbv93eakuweyvO2864xR0K+kmKwBhkTCtWRNjuGGnb5rdmkD/ndqw==", "license": "MIT", "dependencies": { "chalk": "^4.1.2", @@ -3540,14 +3612,14 @@ } }, "node_modules/@docusaurus/mdx-loader": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/mdx-loader/-/mdx-loader-3.10.1.tgz", - "integrity": "sha512-GRmeb/wQ+iXRrFwcHBfgQhrJxGElgCsoTWZYDhccjsZVne1p8MK/EpQVIloXttz76TCe78kKD5AEG9n1xc1oxQ==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/mdx-loader/-/mdx-loader-3.10.2.tgz", + "integrity": "sha512-9Fd4V/SFjfrVQ0JH5EN0+iPWyFunvTeQE3gfyFeetqPaXMP0OylIjOw16dCuXG4NZJrYdBqwzjh18/h3gRi47w==", "license": "MIT", "dependencies": { - "@docusaurus/logger": "3.10.1", - "@docusaurus/utils": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", + "@docusaurus/logger": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "@mdx-js/mdx": "^3.0.0", "@slorber/remark-comment": "^1.0.0", "escape-html": "^1.0.3", @@ -3579,12 +3651,12 @@ } }, "node_modules/@docusaurus/module-type-aliases": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-3.10.1.tgz", - "integrity": "sha512-YoOZKUdGlp8xSYhuAkGdSo5Ydkbq4V4eK3sD8v0a2hloxCWdQbNBhkc+Ko9QyjpESc0BYcIGM5iHVAy5hdFV6w==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-3.10.2.tgz", + "integrity": "sha512-h/I5e4jaAhDHW4vaLENi1i2hnOEnXY1t9R+nnRTbgUl7ymVRzN/HF7dDfj8rKYGj8gfIge+Ef+iYRAMtbGvsrQ==", "license": "MIT", "dependencies": { - "@docusaurus/types": "3.10.1", + "@docusaurus/types": "3.10.2", "@types/history": "^4.7.11", "@types/react": "*", "@types/react-router-config": "*", @@ -3597,20 +3669,44 @@ "react-dom": "*" } }, + "node_modules/@docusaurus/plugin-client-redirects": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-client-redirects/-/plugin-client-redirects-3.10.2.tgz", + "integrity": "sha512-z5I5ttCXw+8y2gHVZvqAMmUw4Rb0ZzKA5eCPk87SfM/jCKOTvE24yDpPAwwipvug96ij7mOwEHXWw8G4LlWdGA==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", + "eta": "^2.2.0", + "fs-extra": "^11.1.1", + "lodash": "^4.17.21", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=20.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, "node_modules/@docusaurus/plugin-content-blog": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.10.1.tgz", - "integrity": "sha512-mmkgE6Q2+K74tnkou7tXlpDLvoCU/qkSa2GSQ3XUiHWvcebCoDQzS670RR3tO8PmaWlIyWWISYWzZLuMfxunRA==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.10.1", - "@docusaurus/logger": "3.10.1", - "@docusaurus/mdx-loader": "3.10.1", - "@docusaurus/theme-common": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils": "3.10.1", - "@docusaurus/utils-common": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.10.2.tgz", + "integrity": "sha512-0cbEnNKf0InmLkhj/+nVRmqEnWEoOE8Mh+2x1qOXI0qYpCnphq4RXknVJ8BvybKRXqYVvbmdMfiJSup+k4tm5w==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/mdx-loader": "3.10.2", + "@docusaurus/theme-common": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "cheerio": "1.0.0-rc.12", "combine-promises": "^1.1.0", "feed": "^4.2.2", @@ -3633,20 +3729,20 @@ } }, "node_modules/@docusaurus/plugin-content-docs": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.10.1.tgz", - "integrity": "sha512-2jRVrtzjf8LClGTHQlwlwuD3wQXRx3WEoF7XUarJ8Ou+0onV+SLtejsyfY9JLpfUh9hPhXM4pbBGkyAY4Bi3HQ==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.10.1", - "@docusaurus/logger": "3.10.1", - "@docusaurus/mdx-loader": "3.10.1", - "@docusaurus/module-type-aliases": "3.10.1", - "@docusaurus/theme-common": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils": "3.10.1", - "@docusaurus/utils-common": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.10.2.tgz", + "integrity": "sha512-Sqwl4FPoZBDrlY8I2VU2H8O0M91CHp9T8ToMSkTZmjvHCif+1laqfXi6sTk8IfyVS/trN5yNjcWd1bFsGB6W5Q==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/mdx-loader": "3.10.2", + "@docusaurus/module-type-aliases": "3.10.2", + "@docusaurus/theme-common": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "@types/react-router-config": "^5.0.7", "combine-promises": "^1.1.0", "fs-extra": "^11.1.1", @@ -3666,16 +3762,16 @@ } }, "node_modules/@docusaurus/plugin-content-pages": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.10.1.tgz", - "integrity": "sha512-huJpaRPMl42nsFwuCXvV8bVDj2MazuwRJIUylI/RSlmZeJssVoZXeCjVf1y+1Drtpa9SKcdGn8yoJ76IRJijtw==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.10.2.tgz", + "integrity": "sha512-h5R12sZ/vV9EPiVjvIl9YFCOwkpwXes7dQMYt3EvP6Pphu4amHxxTqWxf08Fl5DR8h+oZMbWpFTNw5vKEYfvzQ==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.10.1", - "@docusaurus/mdx-loader": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", + "@docusaurus/core": "3.10.2", + "@docusaurus/mdx-loader": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "fs-extra": "^11.1.1", "tslib": "^2.6.0", "webpack": "^5.88.1" @@ -3689,15 +3785,15 @@ } }, "node_modules/@docusaurus/plugin-css-cascade-layers": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-css-cascade-layers/-/plugin-css-cascade-layers-3.10.1.tgz", - "integrity": "sha512-r//fn+MNHkE1wCof8T29VAQezt1enGCpsFxoziBbvLgBM4JfXN2P3rxrBaavHmvLvm7lYkpJeitcDthwnmWCTw==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-css-cascade-layers/-/plugin-css-cascade-layers-3.10.2.tgz", + "integrity": "sha512-UkdvQby5OQUKWrw3lLnSTJXQ6VETaUVTuPQX9AABtmFm5h+ifEBx1OQ+LN726Q4byuwBf2ElHkf4qU4hTxdvRg==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", + "@docusaurus/core": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "tslib": "^2.6.0" }, "engines": { @@ -3705,14 +3801,14 @@ } }, "node_modules/@docusaurus/plugin-debug": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-debug/-/plugin-debug-3.10.1.tgz", - "integrity": "sha512-9KqOpKNfAyqGZykRb9LhIT/vyRF6sm/ykhjj/39JvaJahDS+jZJE0Z1Wfz9q3DUNDTMNN0Q7u/kk4rKKU+IJuA==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-debug/-/plugin-debug-3.10.2.tgz", + "integrity": "sha512-8vbZNOSCpnsT57EY6CgN7sgRVmx3KTYwO8Uvo2pbxOyb8tbqAwtT9SslqaQ41HbA1v1hpn5RP7u5s2KvRwAFpQ==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils": "3.10.1", + "@docusaurus/core": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", "fs-extra": "^11.1.1", "react-json-view-lite": "^2.3.0", "tslib": "^2.6.0" @@ -3726,14 +3822,14 @@ } }, "node_modules/@docusaurus/plugin-google-analytics": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.10.1.tgz", - "integrity": "sha512-8o0P1KtmgdYQHH+oInitPpRWI0Of5XednAX4+DMhQNSmGSRNrsEEHg1ebv35m9AgRClfAytCJ5jA9KvcASTyuA==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.10.2.tgz", + "integrity": "sha512-kMHMBK9j4VAtgd5owwrRLRIi0EjkrpXlX7ePj1+y68XfVZV9I1T4S+koPDm+Hfw2TtnyHvh0uNrDvjz+DjQGVA==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", + "@docusaurus/core": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "tslib": "^2.6.0" }, "engines": { @@ -3745,15 +3841,14 @@ } }, "node_modules/@docusaurus/plugin-google-gtag": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.10.1.tgz", - "integrity": "sha512-pu3xIUo5o/zCMLfUY9BO5KOwSH0zIsAGyFRPvXHayFSA5XIhCU/SFuB0g0ZNjFn9niZLCaNvoeAuOGFJZq0fdw==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.10.2.tgz", + "integrity": "sha512-Vt90nNFhtAChRe9+it1hcHFgFvETdSnOkL5Bma+p6E/yU2tAYrvvyk+gv+LJGM2ZUkyKuKXLRsZ2Lb0bO7+Vog==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", - "@types/gtag.js": "^0.0.20", + "@docusaurus/core": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "tslib": "^2.6.0" }, "engines": { @@ -3765,14 +3860,14 @@ } }, "node_modules/@docusaurus/plugin-google-tag-manager": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.10.1.tgz", - "integrity": "sha512-f6fyGHiCm7kJHBtAisGQS5oNBnpnMTYQZxDXeVrnw/3zWU+LMA22pr6UHGYkBKDbN+qPC5QHG3NuOfzQLq3+Lw==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.10.2.tgz", + "integrity": "sha512-MLCffCldysi/R0nzJQP7ZWd0xAoGNnSTiVOo6TTR6mKVGFhE+/XArGe67ZcaZv1uytgQXoXs92VJrgVDrz80rQ==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", + "@docusaurus/core": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "tslib": "^2.6.0" }, "engines": { @@ -3784,17 +3879,17 @@ } }, "node_modules/@docusaurus/plugin-sitemap": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.10.1.tgz", - "integrity": "sha512-C26MbmmqgdjkDq1htaZ3aD7LzEDKFWXfpyQpt0EOUThuq5nV77zDaedV20yHcVo9p+3ey9aZ4pbHA0D3QcZTzg==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.10.2.tgz", + "integrity": "sha512-PODkwg5XetLML3hU/3xpCKJUZ9cqExLaBnD/Fzzwj2VHogLeqnDisLIujae87zuze7T4mCm2A6KEqZkyiz07EQ==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.10.1", - "@docusaurus/logger": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils": "3.10.1", - "@docusaurus/utils-common": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", + "@docusaurus/core": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "fs-extra": "^11.1.1", "sitemap": "^7.1.1", "tslib": "^2.6.0" @@ -3808,15 +3903,15 @@ } }, "node_modules/@docusaurus/plugin-svgr": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-svgr/-/plugin-svgr-3.10.1.tgz", - "integrity": "sha512-6SFxsmjWFkVLDmBUvFK6i72QjUwqyQFe4Ovz+SUJophJjOyVG3ZZG5IQpBC/kX/Gfv1yWeU9nWauH6F6Q7QX/Q==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-svgr/-/plugin-svgr-3.10.2.tgz", + "integrity": "sha512-JgfT3jWM0TJ8Uw0cEcqxHpybngQY1vlBYpuuNO+gEh5iPh5Ar+vxq/u9CFrYsWeXy48BN7Db76Pzp2edNXUQ8A==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", + "@docusaurus/core": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "@svgr/core": "8.1.0", "@svgr/webpack": "^8.1.0", "tslib": "^2.6.0", @@ -3831,26 +3926,26 @@ } }, "node_modules/@docusaurus/preset-classic": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/preset-classic/-/preset-classic-3.10.1.tgz", - "integrity": "sha512-YO/FL8v1zmbxoTso6mjMz/RDjhaTJxb1UpFFTDdY5847LLDCeyYiYlrhyTbgN1RIN3xnkLKZ9Lj1x8hUzI4JOg==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.10.1", - "@docusaurus/plugin-content-blog": "3.10.1", - "@docusaurus/plugin-content-docs": "3.10.1", - "@docusaurus/plugin-content-pages": "3.10.1", - "@docusaurus/plugin-css-cascade-layers": "3.10.1", - "@docusaurus/plugin-debug": "3.10.1", - "@docusaurus/plugin-google-analytics": "3.10.1", - "@docusaurus/plugin-google-gtag": "3.10.1", - "@docusaurus/plugin-google-tag-manager": "3.10.1", - "@docusaurus/plugin-sitemap": "3.10.1", - "@docusaurus/plugin-svgr": "3.10.1", - "@docusaurus/theme-classic": "3.10.1", - "@docusaurus/theme-common": "3.10.1", - "@docusaurus/theme-search-algolia": "3.10.1", - "@docusaurus/types": "3.10.1" + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/preset-classic/-/preset-classic-3.10.2.tgz", + "integrity": "sha512-a4B3VczmDl99zK0EufDQYomdJ186WDingjmDXxhN2PNPS9Ty/Y2M5CLFX1KQMRKqRTLiRDKfutzG5IY1FC/ceg==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/plugin-content-blog": "3.10.2", + "@docusaurus/plugin-content-docs": "3.10.2", + "@docusaurus/plugin-content-pages": "3.10.2", + "@docusaurus/plugin-css-cascade-layers": "3.10.2", + "@docusaurus/plugin-debug": "3.10.2", + "@docusaurus/plugin-google-analytics": "3.10.2", + "@docusaurus/plugin-google-gtag": "3.10.2", + "@docusaurus/plugin-google-tag-manager": "3.10.2", + "@docusaurus/plugin-sitemap": "3.10.2", + "@docusaurus/plugin-svgr": "3.10.2", + "@docusaurus/theme-classic": "3.10.2", + "@docusaurus/theme-common": "3.10.2", + "@docusaurus/theme-search-algolia": "3.10.2", + "@docusaurus/types": "3.10.2" }, "engines": { "node": ">=20.0" @@ -3861,24 +3956,24 @@ } }, "node_modules/@docusaurus/theme-classic": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-classic/-/theme-classic-3.10.1.tgz", - "integrity": "sha512-VU1RK0qb2pab0si4r7HFK37cYco8VzqLj3u1PspVipSr/z/GPVKHO4/HXbnePqHoWDk8urjyGSeatH0NIMBM1A==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.10.1", - "@docusaurus/logger": "3.10.1", - "@docusaurus/mdx-loader": "3.10.1", - "@docusaurus/module-type-aliases": "3.10.1", - "@docusaurus/plugin-content-blog": "3.10.1", - "@docusaurus/plugin-content-docs": "3.10.1", - "@docusaurus/plugin-content-pages": "3.10.1", - "@docusaurus/theme-common": "3.10.1", - "@docusaurus/theme-translations": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils": "3.10.1", - "@docusaurus/utils-common": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-classic/-/theme-classic-3.10.2.tgz", + "integrity": "sha512-JqTSLQmqmA9uKWZsD5iwBGJ4JyKB4/yTw6PsSXVPRJG/6GAm/u+add9Iip+hvwP12/AnPNztrdxsI14NJW4KeA==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/mdx-loader": "3.10.2", + "@docusaurus/module-type-aliases": "3.10.2", + "@docusaurus/plugin-content-blog": "3.10.2", + "@docusaurus/plugin-content-docs": "3.10.2", + "@docusaurus/plugin-content-pages": "3.10.2", + "@docusaurus/theme-common": "3.10.2", + "@docusaurus/theme-translations": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", "copy-text-to-clipboard": "^3.2.0", @@ -3902,15 +3997,15 @@ } }, "node_modules/@docusaurus/theme-common": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-3.10.1.tgz", - "integrity": "sha512-0YtmIeoNo1fIw65LO8+/1dPgmDV86UmhMkow37gzjytuiCSQm9xob6PJy0L4kuQEMTLfUOGvkXvZr7GPrHquMA==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-3.10.2.tgz", + "integrity": "sha512-R9b/vMpK1yye6hNZTA6x/ivRv+at6GhxnXcxkpzCGzO1R1RwiquqiFg2wMFh6aqlJTpWRFKpFD2TzCDQcyOU0A==", "license": "MIT", "dependencies": { - "@docusaurus/mdx-loader": "3.10.1", - "@docusaurus/module-type-aliases": "3.10.1", - "@docusaurus/utils": "3.10.1", - "@docusaurus/utils-common": "3.10.1", + "@docusaurus/mdx-loader": "3.10.2", + "@docusaurus/module-type-aliases": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", "@types/history": "^4.7.11", "@types/react": "*", "@types/react-router-config": "*", @@ -3930,20 +4025,20 @@ } }, "node_modules/@docusaurus/theme-search-algolia": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.10.1.tgz", - "integrity": "sha512-OTaARARVZj2GvkJQjB+1jOIxntRaXea+G+fMsNqrZBAU1O1vJKDW22R7kECOHW27oJCLFN9HKaZeRrfAUyviug==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.10.2.tgz", + "integrity": "sha512-1msxllyhi/5m77JukXtp5UFnUAriwZIC1oJ7MTnpQpCwLTbclJi5BK5n28CTZuSXpQN2ewbbnqRgAhMM6c6ihg==", "license": "MIT", "dependencies": { "@algolia/autocomplete-core": "^1.19.2", "@docsearch/react": "^3.9.0 || ^4.3.2", - "@docusaurus/core": "3.10.1", - "@docusaurus/logger": "3.10.1", - "@docusaurus/plugin-content-docs": "3.10.1", - "@docusaurus/theme-common": "3.10.1", - "@docusaurus/theme-translations": "3.10.1", - "@docusaurus/utils": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", + "@docusaurus/core": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/plugin-content-docs": "3.10.2", + "@docusaurus/theme-common": "3.10.2", + "@docusaurus/theme-translations": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "algoliasearch": "^5.37.0", "algoliasearch-helper": "^3.26.0", "clsx": "^2.0.0", @@ -3962,9 +4057,9 @@ } }, "node_modules/@docusaurus/theme-translations": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-3.10.1.tgz", - "integrity": "sha512-cLMyaKivjBVWKMJuWqyFVVgtqe8DPJNPkog0bn8W1MDVAKcPdxRFycBfC1We1RaNp7Rdk513bmtW78RR6OBxBw==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-3.10.2.tgz", + "integrity": "sha512-iv20wrxnyXkY89LM3TzRlzGlt5fIGO5UnaR6UL1ZVfB9RRFjxQFQ6awDrwAc6Km8Y5gD8pInuwYPF+6/TiCxXA==", "license": "MIT", "dependencies": { "fs-extra": "^11.1.1", @@ -3975,9 +4070,9 @@ } }, "node_modules/@docusaurus/types": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-3.10.1.tgz", - "integrity": "sha512-XYMK8k1szDCFMw2V+Xyen0g7Kee1sP3dtFnl7vkGkZOkeAJ/oPDQPL8iz4HBKOo/cwU8QeV6onVjMqtP+tFzsw==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-3.10.2.tgz", + "integrity": "sha512-B6rvfwIFSapUqUJjMriZswX13K8l5Z7AcmVE6uTEJpYddQieSTR12DsGaFtcZAIDsQd4p+0WTl0Vc6jmZK0Trw==", "license": "MIT", "dependencies": { "@mdx-js/mdx": "^3.0.0", @@ -4011,21 +4106,21 @@ } }, "node_modules/@docusaurus/utils": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-3.10.1.tgz", - "integrity": "sha512-3ojeJry9xBYdJO6qoyyzqeJFSJBVx2mXhyDzSdjwL2+URFQMf+h25gG38iswGImicK0ELjTd1EL2xzk8hf3QPw==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-3.10.2.tgz", + "integrity": "sha512-xx0W3eav2uW1NRIpuHJWNwLTC15xPNjU4Uxi9NSnd3swYC96BE3vFiT93SD8s24kmAAWNwgZwfZ2fghGZ01Lcw==", "license": "MIT", "dependencies": { - "@docusaurus/logger": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils-common": "3.10.1", + "@11ty/gray-matter": "^1.0.0", + "@docusaurus/logger": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils-common": "3.10.2", "escape-string-regexp": "^4.0.0", "execa": "^5.1.1", "file-loader": "^6.2.0", "fs-extra": "^11.1.1", "github-slugger": "^1.5.0", "globby": "^11.1.0", - "gray-matter": "^4.0.3", "jiti": "^1.20.0", "js-yaml": "^4.1.0", "lodash": "^4.17.21", @@ -4043,12 +4138,12 @@ } }, "node_modules/@docusaurus/utils-common": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/utils-common/-/utils-common-3.10.1.tgz", - "integrity": "sha512-5mFSgEADtnFxFH7RLw02QA5MpU5JVUCj0MPeIvi/aF4Fi45tQRIuTwXoXDqJ+1VfQJuYJGz3SI63wmGz4HvXzA==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/utils-common/-/utils-common-3.10.2.tgz", + "integrity": "sha512-x3Dz6jv6iQKBNjBmVTu8p57abMp/VNTUgKBMgRVXJc5444orBTsArv0+cdfrXTiz/VMmHfDRVkPbL7GH2B7T7w==", "license": "MIT", "dependencies": { - "@docusaurus/types": "3.10.1", + "@docusaurus/types": "3.10.2", "tslib": "^2.6.0" }, "engines": { @@ -4056,14 +4151,14 @@ } }, "node_modules/@docusaurus/utils-validation": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-3.10.1.tgz", - "integrity": "sha512-cRv1X69jwaWv47waglllgZVWzeBFLhl53XT/XED/83BerVBTC5FTP8WTcVl8Z6sZOegDSwitu/wpCSPCDOT6lg==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-3.10.2.tgz", + "integrity": "sha512-sn8unbDfUL585NtR3cwHefPicOyaHvPaX7VD0aOg/siIxUBoKyKKaGEqzJZDS64mM43TnxurkYDtmB1wsJlZsw==", "license": "MIT", "dependencies": { - "@docusaurus/logger": "3.10.1", - "@docusaurus/utils": "3.10.1", - "@docusaurus/utils-common": "3.10.1", + "@docusaurus/logger": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", "fs-extra": "^11.2.0", "joi": "^17.9.2", "js-yaml": "^4.1.0", @@ -4846,6 +4941,22 @@ "node": ">=20.0.0" } }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@pnpm/config.env-replace": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", @@ -4915,9 +5026,9 @@ "license": "BSD-3-Clause" }, "node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "license": "MIT" }, "node_modules/@sindresorhus/is": { @@ -5318,12 +5429,6 @@ "@types/send": "*" } }, - "node_modules/@types/gtag.js": { - "version": "0.0.20", - "resolved": "https://registry.npmjs.org/@types/gtag.js/-/gtag.js-0.0.20.tgz", - "integrity": "sha512-wwAbk3SA2QeU67unN7zPxjEHmPmlXwZXZvQEpbEUQuMCRGgKyE1m6XDuTUA9b6pCGb/GqJmdfMOY5LuDjJSbbg==", - "license": "MIT" - }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -5837,12 +5942,12 @@ } }, "node_modules/address": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/address/-/address-1.2.2.tgz", - "integrity": "sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/address/-/address-2.0.3.tgz", + "integrity": "sha512-XNAb/a6TCqou+TufU8/u11HCu9x1gYvOoxLwtlXgIqmkrYQADVv6ljyW2zwiPhHz9R1gItAWpuDrdJMmrOBFEA==", "license": "MIT", "engines": { - "node": ">= 10.0.0" + "node": ">= 16.0.0" } }, "node_modules/aggregate-error": { @@ -5904,34 +6009,34 @@ } }, "node_modules/algoliasearch": { - "version": "5.52.1", - "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.52.1.tgz", - "integrity": "sha512-fHA8+kXTbjagw3jkLiaS7KKrH8qe2DyOsiUhGlN4cdT77PEsfqXZl7ewDk1hsg+pJnPlnE50XtLxjR91iJOpmg==", - "license": "MIT", - "dependencies": { - "@algolia/abtesting": "1.18.1", - "@algolia/client-abtesting": "5.52.1", - "@algolia/client-analytics": "5.52.1", - "@algolia/client-common": "5.52.1", - "@algolia/client-insights": "5.52.1", - "@algolia/client-personalization": "5.52.1", - "@algolia/client-query-suggestions": "5.52.1", - "@algolia/client-search": "5.52.1", - "@algolia/ingestion": "1.52.1", - "@algolia/monitoring": "1.52.1", - "@algolia/recommend": "5.52.1", - "@algolia/requester-browser-xhr": "5.52.1", - "@algolia/requester-fetch": "5.52.1", - "@algolia/requester-node-http": "5.52.1" + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.56.0.tgz", + "integrity": "sha512-PrqppUmhT4ENdas2pH9caE7efUcxy6EcSFhWzosiVuQBzu2tQ5yLTI6jwomT/1cuBnivzGfxiJCqDNN9FRRh+Q==", + "license": "MIT", + "dependencies": { + "@algolia/abtesting": "1.22.0", + "@algolia/client-abtesting": "5.56.0", + "@algolia/client-analytics": "5.56.0", + "@algolia/client-common": "5.56.0", + "@algolia/client-insights": "5.56.0", + "@algolia/client-personalization": "5.56.0", + "@algolia/client-query-suggestions": "5.56.0", + "@algolia/client-search": "5.56.0", + "@algolia/ingestion": "1.56.0", + "@algolia/monitoring": "1.56.0", + "@algolia/recommend": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/algoliasearch-helper": { - "version": "3.29.1", - "resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.29.1.tgz", - "integrity": "sha512-6ck2YFudF2Pje7szQoPBiRFTGfd+1I+0I/WfLPGn0bj1kvrFoOQmNyedNiDxTk3/r4IfSLDYk+RA4G7u8H6+yA==", + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.29.2.tgz", + "integrity": "sha512-SaV+rZM3drExb0punEYYjT+sNcH74YFwN8ocjya7IDOyQvKWeQpEaSMVG3+IGTVos+feuatj7ljQ4BXlXdUp3w==", "license": "MIT", "dependencies": { "@algolia/events": "^4.0.1" @@ -6078,9 +6183,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", - "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz", + "integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==", "funding": [ { "type": "opencollective", @@ -6097,8 +6202,8 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.28.2", - "caniuse-lite": "^1.0.30001787", + "browserslist": "^4.28.6", + "caniuse-lite": "^1.0.30001806", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" @@ -6204,9 +6309,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.27", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz", - "integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz", + "integrity": "sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -6243,9 +6348,9 @@ } }, "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -6290,21 +6395,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/body-parser/node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/bonjour-service": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.3.0.tgz", @@ -6344,9 +6434,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -6366,9 +6456,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", "funding": [ { "type": "opencollective", @@ -6385,10 +6475,10 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { @@ -6555,9 +6645,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001792", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", - "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", "funding": [ { "type": "opencollective", @@ -7271,6 +7361,19 @@ "postcss": "^8.4" } }, + "node_modules/css-blank-pseudo/node_modules/postcss-selector-parser": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/css-declaration-sorter": { "version": "7.4.0", "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-7.4.0.tgz", @@ -7310,6 +7413,41 @@ "postcss": "^8.4" } }, + "node_modules/css-has-pseudo/node_modules/@csstools/selector-specificity": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz", + "integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss-selector-parser": "^7.0.0" + } + }, + "node_modules/css-has-pseudo/node_modules/postcss-selector-parser": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/css-loader": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.11.0.tgz", @@ -7453,9 +7591,9 @@ } }, "node_modules/cssdb": { - "version": "8.8.0", - "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-8.8.0.tgz", - "integrity": "sha512-QbLeyz2Bgso1iRlh7IpWk6OKa3lLNGXsujVjDMPl9rOZpxKeiG69icLpbLCFxeURwmcdIfZqQyhlooKJYM4f8Q==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-8.9.0.tgz", + "integrity": "sha512-J8jOU/hLjaXcO1LldOLraJSQpfLXRKof0I7mtbRyOy2AAXgqst0x9rlgi2qXeD6d0ou3ZLqcPAMqYVbpCbrxEw==", "funding": [ { "type": "opencollective", @@ -7812,20 +7950,19 @@ "license": "MIT" }, "node_modules/detect-port": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/detect-port/-/detect-port-1.6.1.tgz", - "integrity": "sha512-CmnVc+Hek2egPx1PeTFVta2W78xy2K/9Rkf6cC4T59S50tVnzKj+tnx5mmx5lwvCkujZ4uRrpRSuV+IVs3f90Q==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-port/-/detect-port-2.1.0.tgz", + "integrity": "sha512-epZuWb/6Q62L+nDHJc/hQAqf8pylsqgk3BpZXVBx1CDnr3nkrVNn73Uu1rXcFzkNcc+hkP3whuOg7JZYaQB65Q==", "license": "MIT", "dependencies": { - "address": "^1.0.1", - "debug": "4" + "address": "^2.0.1" }, "bin": { - "detect": "bin/detect-port.js", - "detect-port": "bin/detect-port.js" + "detect": "dist/commonjs/bin/detect-port.js", + "detect-port": "dist/commonjs/bin/detect-port.js" }, "engines": { - "node": ">= 4.0.0" + "node": ">= 16.0.0" } }, "node_modules/devlop": { @@ -7996,9 +8133,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.352", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.352.tgz", - "integrity": "sha512-9wHk8x6dyuimoe18EdiDPWKExNdxYqo4fn4FwOVVper6RxT3cmpBwBkWWfSOCYJjQdIco/nPhJhNLmn4Ufg1Yg==", + "version": "1.5.395", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.395.tgz", + "integrity": "sha512-7zt9Aw+SrmxLWLN0zhaTWZQiCdryLVrYTq5R7iZakLvi2UQPYMMsROYV/2qVCzMeCiSXHwKOU+sZ4zOVVlrtKA==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -8195,19 +8332,6 @@ "node": ">=8.0.0" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/esrecurse": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", @@ -8422,14 +8546,14 @@ } }, "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "~1.20.3", + "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", @@ -8448,7 +8572,7 @@ "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "~6.14.0", + "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -8556,9 +8680,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -9054,60 +9178,23 @@ } }, "node_modules/got/node_modules/@sindresorhus/is": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-5.6.0.tgz", - "integrity": "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==", - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" - }, - "node_modules/gray-matter": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", - "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", - "license": "MIT", - "dependencies": { - "js-yaml": "^3.13.1", - "kind-of": "^6.0.2", - "section-matter": "^1.0.0", - "strip-bom-string": "^1.0.0" - }, - "engines": { - "node": ">=6.0" - } - }, - "node_modules/gray-matter/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/gray-matter/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-5.6.0.tgz", + "integrity": "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==", "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "engines": { + "node": ">=14.16" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, "node_modules/gzip-size": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", @@ -10234,9 +10321,19 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -10342,13 +10439,13 @@ } }, "node_modules/launch-editor": { - "version": "2.13.2", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.13.2.tgz", - "integrity": "sha512-4VVDnbOpLXy/s8rdRCSXb+zfMeFR0WlJWpET1iA9CQdlZDfwyLjUuGQzXU4VeOoey6AicSAluWan7Etga6Kcmg==", + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.1.tgz", + "integrity": "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==", "license": "MIT", "dependencies": { "picocolors": "^1.1.1", - "shell-quote": "^1.8.3" + "shell-quote": "^1.8.4" } }, "node_modules/leven": { @@ -12933,9 +13030,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", "funding": [ { "type": "github", @@ -12991,10 +13088,13 @@ } }, "node_modules/node-releases": { - "version": "2.0.38", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", - "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", - "license": "MIT" + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/normalize-path": { "version": "3.0.0", @@ -13585,10 +13685,57 @@ "node": ">=16.0.0" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "funding": [ { "type": "opencollective", @@ -13605,7 +13752,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -13638,6 +13785,19 @@ "postcss": "^8.4" } }, + "node_modules/postcss-attribute-case-insensitive/node_modules/postcss-selector-parser": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/postcss-calc": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-9.0.1.tgz", @@ -13654,19 +13814,6 @@ "postcss": "^8.2.2" } }, - "node_modules/postcss-calc/node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/postcss-clamp": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/postcss-clamp/-/postcss-clamp-4.1.0.tgz", @@ -13882,6 +14029,19 @@ "postcss": "^8.4" } }, + "node_modules/postcss-custom-selectors/node_modules/postcss-selector-parser": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/postcss-dir-pseudo-class": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/postcss-dir-pseudo-class/-/postcss-dir-pseudo-class-9.0.1.tgz", @@ -13907,6 +14067,19 @@ "postcss": "^8.4" } }, + "node_modules/postcss-dir-pseudo-class/node_modules/postcss-selector-parser": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/postcss-discard-comments": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-6.0.2.tgz", @@ -13970,19 +14143,6 @@ "postcss": "^8.4.31" } }, - "node_modules/postcss-discard-unused/node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/postcss-double-position-gradients": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/postcss-double-position-gradients/-/postcss-double-position-gradients-6.0.4.tgz", @@ -14035,6 +14195,19 @@ "postcss": "^8.4" } }, + "node_modules/postcss-focus-visible/node_modules/postcss-selector-parser": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/postcss-focus-within": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/postcss-focus-within/-/postcss-focus-within-9.0.1.tgz", @@ -14060,6 +14233,19 @@ "postcss": "^8.4" } }, + "node_modules/postcss-focus-within/node_modules/postcss-selector-parser": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/postcss-font-variant": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/postcss-font-variant/-/postcss-font-variant-5.0.0.tgz", @@ -14243,19 +14429,6 @@ "postcss": "^8.4.31" } }, - "node_modules/postcss-merge-rules/node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/postcss-minify-font-values": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-6.1.0.tgz", @@ -14320,19 +14493,6 @@ "postcss": "^8.4.31" } }, - "node_modules/postcss-minify-selectors/node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/postcss-modules-extract-imports": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", @@ -14362,6 +14522,19 @@ "postcss": "^8.1.0" } }, + "node_modules/postcss-modules-local-by-default/node_modules/postcss-selector-parser": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/postcss-modules-scope": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", @@ -14377,6 +14550,19 @@ "postcss": "^8.1.0" } }, + "node_modules/postcss-modules-scope/node_modules/postcss-selector-parser": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/postcss-modules-values": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", @@ -14419,6 +14605,63 @@ "postcss": "^8.4" } }, + "node_modules/postcss-nesting/node_modules/@csstools/selector-resolve-nested": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-3.1.0.tgz", + "integrity": "sha512-mf1LEW0tJLKfWyvn5KdDrhpxHyuxpbNwTIwOYLIvsTffeyOf85j5oIzfG0yosxDgx/sswlqBnESYUcQH0vgZ0g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss-selector-parser": "^7.0.0" + } + }, + "node_modules/postcss-nesting/node_modules/@csstools/selector-specificity": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz", + "integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss-selector-parser": "^7.0.0" + } + }, + "node_modules/postcss-nesting/node_modules/postcss-selector-parser": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/postcss-normalize-charset": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-6.0.2.tgz", @@ -14769,6 +15012,19 @@ "postcss": "^8.4" } }, + "node_modules/postcss-pseudo-class-any-link/node_modules/postcss-selector-parser": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/postcss-reduce-idents": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/postcss-reduce-idents/-/postcss-reduce-idents-6.0.3.tgz", @@ -14849,10 +15105,23 @@ "postcss": "^8.4" } }, + "node_modules/postcss-selector-not/node_modules/postcss-selector-parser": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -14908,19 +15177,6 @@ "postcss": "^8.4.31" } }, - "node_modules/postcss-unique-selectors/node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/postcss-value-parser": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", @@ -15091,9 +15347,9 @@ } }, "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -16063,9 +16319,9 @@ } }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -16337,9 +16593,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", + "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -16599,12 +16855,6 @@ "wbuf": "^1.7.3" } }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "license": "BSD-3-Clause" - }, "node_modules/srcset": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/srcset/-/srcset-4.0.0.tgz", @@ -16789,19 +17039,6 @@ "postcss": "^8.4.31" } }, - "node_modules/stylehacks/node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -16833,9 +17070,9 @@ "license": "MIT" }, "node_modules/svgo": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.3.tgz", - "integrity": "sha512-+wn7I4p7YgJhHs38k2TNjy1vCfPIfLIJWR5MnCStsN8WuuTcBnRKcMHQLMM2ijxGZmDoZwNv8ipl5aTTen62ng==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.4.tgz", + "integrity": "sha512-GsNRis4e8jxn2Y9ENz/8lbJ93CstG8svtMnuRaHbiF2LTJ5tK0/q3t/URPq9Zc7zVWBJnNnJMIp6bevK7bSmNg==", "license": "MIT", "dependencies": { "commander": "^7.2.0", @@ -17826,9 +18063,9 @@ } }, "node_modules/webpack-dev-server": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.3.tgz", - "integrity": "sha512-9Gyu2F7+bg4Vv+pjbovuYDhHX+mqdqITykfzdM9UyKqKHlsE5aAjRhR+oOEfXW5vBeu8tarzlJFIZva4ZjAdrQ==", + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.6.tgz", + "integrity": "sha512-HNLRmamRvVavZQ+avceZifmv8hmdUjg43t6MI4SqJDwFdW7RPQwH5vzGhDRZSX59SgfbeHhLnq3g+uooWo7pVw==", "license": "MIT", "dependencies": { "@types/bonjour": "^3.5.13", @@ -17849,7 +18086,7 @@ "graceful-fs": "^4.2.6", "http-proxy-middleware": "^2.0.9", "ipaddr.js": "^2.1.0", - "launch-editor": "^2.6.1", + "launch-editor": "^2.14.1", "open": "^10.0.3", "p-retry": "^6.2.0", "schema-utils": "^4.2.0", @@ -17913,9 +18150,9 @@ } }, "node_modules/webpack-dev-server/node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -17993,9 +18230,9 @@ } }, "node_modules/websocket-driver": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", - "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz", + "integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==", "license": "Apache-2.0", "dependencies": { "http-parser-js": ">=0.5.1", @@ -18120,9 +18357,9 @@ } }, "node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", "license": "MIT", "engines": { "node": ">=8.3.0" diff --git a/package.json b/package.json index 7ca89dfe8..8fe347453 100644 --- a/package.json +++ b/package.json @@ -7,21 +7,31 @@ "docusaurus": "docusaurus", "postinstall": "node scripts/sync-app-downloads.js", "prestart": "node scripts/sync-app-downloads.js", - "start": "docusaurus start", + "start": "docusaurus start --locale en", + "start:nl": "docusaurus start --locale nl", + "preview": "docusaurus build && docusaurus serve", "prebuild": "python3 .github/scripts/app_downloads.py && node scripts/sync-app-downloads.js", "build": "docusaurus build", + "postbuild": "node scripts/validate-ai-baseline.mjs && node scripts/lint-color-roles.mjs", + "validate:ai-baseline": "node scripts/validate-ai-baseline.mjs", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", "clear": "docusaurus clear", "serve": "docusaurus serve", "write-translations": "docusaurus write-translations", "write-heading-ids": "docusaurus write-heading-ids", - "ci": "npm ci --legacy-peer-deps && npm run build" + "ci": "npm ci --legacy-peer-deps && npm run build", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "lint:colors": "node scripts/lint-color-roles.mjs", + "lint": "node scripts/lint-color-roles.mjs" }, "dependencies": { - "@conduction/docusaurus-preset": "^3.3.0", - "@docusaurus/core": "^3.5.0", - "@docusaurus/preset-classic": "^3.5.0", + "@conduction/docusaurus-plugin-portaliq": "git+https://github.com/ConductionNL/docusaurus-plugin-portaliq.git#1bed5e053637024e1a86b2bc1515e25bc8840123", + "@conduction/docusaurus-preset": "^3.51.1", + "@docusaurus/core": "^3.10.2", + "@docusaurus/plugin-client-redirects": "^3.10.2", + "@docusaurus/preset-classic": "^3.10.2", "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", "prism-react-renderer": "^2.3.0", @@ -29,8 +39,9 @@ "react-dom": "^18.0.0" }, "devDependencies": { - "@docusaurus/module-type-aliases": "^3.5.0", - "@docusaurus/types": "^3.5.0" + "@docusaurus/module-type-aliases": "^3.10.2", + "@docusaurus/types": "^3.10.2", + "@playwright/test": "^1.62.1" }, "browserslist": { "production": [ @@ -45,6 +56,6 @@ ] }, "engines": { - "node": ">=18.0" + "node": ">=20.0" } } diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 000000000..0f00cbf7c --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,110 @@ +/** + * Playwright config for the conduction.nl site. + * + * These tests run against a **built** site, not the dev server. Docusaurus + * dev and production differ in the ways that have actually bitten this + * repo: hydration mismatches only surface when a server-rendered page is + * hydrated, and CSS-module class names are hashed differently. A suite that + * passed against `docusaurus start` would miss both. + * + * npm run build && npm run test:e2e + * + * `reuseExistingServer` means a `docusaurus serve` you already have running + * on this port is used as-is, so an edit-run loop does not pay the startup + * cost each time. + */ + +import {defineConfig, devices} from '@playwright/test'; + +/* 4173 by default, overridable because it does collide. `reuseExistingServer` + * takes whatever is already on the port, and another project's `docusaurus + * serve` sitting there is indistinguishable from our own: the suite then runs + * green or red against a different site entirely. Seen 2026-09-19, where it + * produced 20 confident failures against a site that was not this one. + * Set E2E_PORT to move out of the way. */ +const PORT = Number(process.env.E2E_PORT) || 4173; + +/* Point the same suite at a deployed site instead of a local build: + * + * E2E_BASE_URL=https://www.conduction.nl npm run test:e2e + * + * Useful as a post-deploy smoke test, and the only way to check what + * visitors actually get rather than what the build produced. No local + * server is started in that mode. Expect timeouts are looser because the + * assertions now include a network. */ +const REMOTE = process.env.E2E_BASE_URL; + +export default defineConfig({ + testDir: './e2e', + /* The cookie CLI tests type into a terminal and play a game that ticks on + a timer, so they are slower than a normal page assertion. */ + timeout: 90_000, + expect: {timeout: 10_000}, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + /* Against a deployed site, run serially. Pointed at www.conduction.nl + with the default worker count, 26 of 63 tests failed and every one + passed in isolation; at two workers, immediately after a deploy when + the edge cache is cold, four still failed. Cloudflare throttles a burst + of parallel requests from one address and a throttled response is + indistinguishable from a broken page, so the suite was reporting on the + CDN rather than on the site. One worker takes about three minutes and + tells the truth. */ + workers: REMOTE ? 1 : (process.env.CI ? 2 : undefined), + reporter: process.env.CI ? [['list'], ['html', {open: 'never'}]] : [['list']], + + use: { + baseURL: REMOTE || `http://localhost:${PORT}`, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'off', + }, + + projects: [ + /* The desktop suite skips the mobile spec: its assertions are about + a 390px viewport and would be meaningless at 1280px. */ + { + name: 'chromium', + use: {...devices['Desktop Chrome']}, + testIgnore: /mobile-layout\.spec\.js/, + }, + /* Mobile layout regressions. iPhone 13 is 390x844, the viewport the + September 2026 audit measured, so the numbers in that spec and + the ones in the report refer to the same thing. + + `browserName` is overridden because the iPhone descriptor brings + `defaultBrowserType: 'webkit'` with it, and nothing else in this + repo installs WebKit: the suite would fail on a missing binary + rather than on the page. The assertions here are about layout and + reachability, which are engine-agnostic, so Chromium at the same + viewport, device scale and touch settings answers the same + question and runs wherever the chromium project already runs. */ + { + name: 'mobile', + use: {...devices['iPhone 13'], browserName: 'chromium'}, + testMatch: /mobile-layout\.spec\.js/, + }, + /* The same assertions on the engine most phones actually use. + The comment above is right that layout and reachability are largely + engine-agnostic, and that argument was used to justify never running + WebKit at all. "Largely" is not "entirely": Safari is the dominant + mobile browser, and the September 2026 review covered zero percent of + it. WebKit is installed now, so the excuse is gone. + CI installs it alongside chromium in .github/workflows/deploy.yml. */ + { + name: 'mobile-safari', + use: {...devices['iPhone 13']}, + testMatch: /mobile-layout\.spec\.js|a11y\.spec\.js/, + }, + ], + + ...(REMOTE ? {} : { + webServer: { + command: `npx docusaurus serve --port ${PORT} --no-open`, + url: `http://localhost:${PORT}/`, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + }), +}); diff --git a/plugins/academy-modules/index.js b/plugins/academy-modules/index.js new file mode 100644 index 000000000..90257ced9 --- /dev/null +++ b/plugins/academy-modules/index.js @@ -0,0 +1,268 @@ +/** + * academy-modules — Docusaurus plugin that exposes module groupings. + * + * Scans every `academy//index.mdx` frontmatter at build time, + * groups posts by their `module` frontmatter, sorts members by + * `modulePosition`, and writes the result to `globalData` so the + * BlogListPage swizzle and per-module MDX index pages can render + * composite cards and ordered TOCs without re-parsing markdown. + * + * The blog plugin itself only exposes a thin posts array to global + * data (permalink, date, title). We need the full frontmatter — and + * we only need it once per build, not per-render — so a tiny custom + * plugin is the lowest-noise way to get it. + * + * Consumed by: + * - src/theme/BlogListPage (composite ModuleCards + module pill row) + * - on per-module MDX index pages + * + * Output shape exposed via `usePluginData('academy-modules')`: + * + * { + * modules: { + * 'build-an-app-tutorial': { + * slug: 'build-an-app-tutorial', + * title: 'Build a Nextcloud app on the Conduction stack', + * lede: 'A four-part path from blank Nextcloud to a published app …', + * parts: [ + * { + * slug: 'build-an-app-tutorial-1-scaffold', + * permalink: '/academy/build-an-app-tutorial-1-scaffold', + * title: 'Build a Nextcloud app on the Conduction stack — Part 1: Scaffold', + * summary: 'Clone the Conduction app template …', + * contentType: 'tutorial', + * durationMinutes: 20, + * audience: ['developer'], + * apps: ['openregister'], + * modulePosition: 1, + * }, + * ... + * ], + * totalMinutes: 95, + * audience: ['developer'], + * apps: ['openregister', 'openconnector'], + * contentTypes: ['tutorial'], + * }, + * ... + * }, + * postModuleIndex: { + * '/academy/build-an-app-tutorial-1-scaffold': 'build-an-app-tutorial', + * ... + * }, + * } + * + * Permalinks mirror the blog plugin's default: when frontmatter has an + * explicit `slug:`, the post is served at `${routeBasePath}/${slug}` + * (routeBasePath defaults to `/academy` in this site). When no slug is + * set, Docusaurus derives one from the filename; we replicate that + * fallback to keep things simple. If the site ever changes + * routeBasePath, pass it via plugin options. + */ + +const fs = require('fs'); +const path = require('path'); + +const SLUG_RE = /^\s*slug:\s*([^\s#]+)\s*$/m; +const TITLE_RE = /^\s*title:\s*(?:"([^"]+)"|'([^']+)'|(.+?))\s*$/m; +const SUMMARY_RE = /^\s*summary:\s*(?:"([^"]+)"|'([^']+)'|(.+?))\s*$/m; +const TYPE_RE = /^\s*contentType:\s*([a-z-]+)\s*$/m; +const DURATION_RE = /^\s*durationMinutes:\s*(\d+)\s*$/m; +const MODULE_RE = /^\s*module:\s*([a-z0-9-]+)\s*$/m; +const MOD_POS_RE = /^\s*modulePosition:\s*(\d+)\s*$/m; +const MOD_TITLE_RE = /^\s*moduleTitle:\s*(?:"([^"]+)"|'([^']+)'|(.+?))\s*$/m; +const MOD_LEDE_RE = /^\s*moduleLede:\s*(?:"([^"]+)"|'([^']+)'|(.+?))\s*$/m; +const DATE_RE = /^\s*date:\s*([0-9-]+)\s*$/m; + +/* `audience: [developer, mkb]` or `apps: [openregister, openconnector]`. + We only ever emit single-line flow-array YAML in the academy + frontmatter, so a regex covers all current and likely-future entries. */ +function arrayField(frontmatter, key) { + const re = new RegExp('^\\s*' + key + ':\\s*\\[([^\\]]*)\\]\\s*$', 'm'); + const m = frontmatter.match(re); + if (!m) return []; + return m[1] + .split(',') + .map((s) => s.trim().replace(/^["']|["']$/g, '')) + .filter(Boolean); +} + +function pickQuoted(match) { + if (!match) return undefined; + return match[1] || match[2] || match[3]; +} + +function extractFrontmatter(mdxPath) { + const raw = fs.readFileSync(mdxPath, 'utf8'); + const match = raw.match(/^---\n([\s\S]*?)\n---/); + if (!match) return null; + const fm = match[1]; + + return { + slug: pickQuoted(fm.match(SLUG_RE)), + title: pickQuoted(fm.match(TITLE_RE)), + summary: pickQuoted(fm.match(SUMMARY_RE)), + contentType: pickQuoted(fm.match(TYPE_RE)), + durationMinutes: fm.match(DURATION_RE) ? parseInt(fm.match(DURATION_RE)[1], 10) : undefined, + module: pickQuoted(fm.match(MODULE_RE)), + modulePosition: fm.match(MOD_POS_RE) ? parseInt(fm.match(MOD_POS_RE)[1], 10) : undefined, + moduleTitle: pickQuoted(fm.match(MOD_TITLE_RE)), + moduleLede: pickQuoted(fm.match(MOD_LEDE_RE)), + date: pickQuoted(fm.match(DATE_RE)), + audience: arrayField(fm, 'audience'), + apps: arrayField(fm, 'apps'), + authors: arrayField(fm, 'authors'), + }; +} + +/* Lightweight authors.yml parser. The academy authors file is a flat + `key: \n name: ... \n image_url: ...` shape; we don't need a full + YAML lib for it. Falls back to {} if the file is missing. */ +function loadAuthors(authorsPath) { + if (!fs.existsSync(authorsPath)) return {}; + const raw = fs.readFileSync(authorsPath, 'utf8'); + const out = {}; + let current = null; + for (const rawLine of raw.split(/\r?\n/)) { + const line = rawLine.replace(/\s+$/, ''); + if (!line || line.startsWith('#')) continue; + const top = line.match(/^([a-z0-9_-]+):\s*$/i); + if (top) { + current = top[1]; + out[current] = {}; + continue; + } + const kv = line.match(/^\s{2}([a-z_]+):\s*(.*)$/); + if (kv && current) { + out[current][kv[1]] = kv[2].replace(/^["']|["']$/g, ''); + } + } + return out; +} + +function kebabToSentence(slug) { + if (!slug) return ''; + return slug + .split('-') + .map((w, i) => (i === 0 ? w[0].toUpperCase() + w.slice(1) : w)) + .join(' '); +} + +function unique(arr) { + return Array.from(new Set(arr.filter(Boolean))); +} + +module.exports = function academyModulesPlugin(_context, options) { + const contentDir = options && options.contentDir ? options.contentDir : 'academy'; + const routeBasePath = options && options.routeBasePath ? options.routeBasePath : '/academy'; + + return { + name: 'academy-modules', + + async loadContent() { + const siteDir = _context.siteDir; + const absDir = path.join(siteDir, contentDir); + if (!fs.existsSync(absDir)) return {modules: {}, postModuleIndex: {}}; + + const entries = fs.readdirSync(absDir, {withFileTypes: true}) + .filter((e) => e.isDirectory()) + .map((e) => path.join(absDir, e.name, 'index.mdx')) + .filter((p) => fs.existsSync(p)); + + const authorsMap = loadAuthors(path.join(absDir, 'authors.yml')); + + const posts = entries + .map((p) => extractFrontmatter(p)) + .filter((fm) => fm && fm.module && fm.modulePosition); + + const byModule = {}; + for (const post of posts) { + const slug = post.module; + if (!byModule[slug]) { + byModule[slug] = { + slug, + title: null, + lede: null, + parts: [], + }; + } + const permalink = post.slug + ? `${routeBasePath}/${post.slug}` + : null; + + byModule[slug].parts.push({ + slug: post.slug, + permalink, + title: post.title, + summary: post.summary, + contentType: post.contentType, + durationMinutes: post.durationMinutes, + audience: post.audience || [], + apps: post.apps || [], + modulePosition: post.modulePosition, + date: post.date, + authors: post.authors || [], + }); + + /* moduleTitle / moduleLede may live on any part (we ask + editors to put them on position 1), so absorb the first + occurrence we encounter. */ + if (!byModule[slug].title && post.moduleTitle) { + byModule[slug].title = post.moduleTitle; + } + if (!byModule[slug].lede && post.moduleLede) { + byModule[slug].lede = post.moduleLede; + } + } + + for (const slug of Object.keys(byModule)) { + const mod = byModule[slug]; + mod.parts.sort((a, b) => a.modulePosition - b.modulePosition); + + if (!mod.title) mod.title = kebabToSentence(slug); + if (!mod.lede && mod.parts[0]) mod.lede = mod.parts[0].summary || ''; + + mod.totalMinutes = mod.parts.reduce((sum, p) => sum + (p.durationMinutes || 0), 0); + mod.audience = unique(mod.parts.flatMap((p) => p.audience)); + mod.apps = unique(mod.parts.flatMap((p) => p.apps)); + mod.contentTypes = unique(mod.parts.map((p) => p.contentType)); + mod.permalink = `${routeBasePath}/modules/${slug}`; + + /* Latest part's date drives the module card's date line — the + module's "most recent activity" reads more truthfully than its + kickoff date once parts trickle in over weeks. */ + mod.latestDate = mod.parts + .map((p) => p.date) + .filter(Boolean) + .sort() + .pop() || null; + + /* Curator = the author of part 1. If part 1 has multiple authors + we take the first; if part 1 has none, we leave the slot empty + and the card skips the avatar row. */ + const curatorKey = mod.parts[0] && mod.parts[0].authors && mod.parts[0].authors[0]; + const curatorYml = curatorKey && authorsMap[curatorKey]; + mod.curator = curatorYml + ? { + key: curatorKey, + name: curatorYml.name || curatorKey, + imageURL: curatorYml.image_url || null, + } + : null; + } + + const postModuleIndex = {}; + for (const slug of Object.keys(byModule)) { + for (const part of byModule[slug].parts) { + if (part.permalink) postModuleIndex[part.permalink] = slug; + } + } + + return {modules: byModule, postModuleIndex}; + }, + + async contentLoaded({content, actions}) { + const {setGlobalData} = actions; + setGlobalData(content); + }, + }; +}; diff --git a/plugins/apps-registry-interim.js b/plugins/apps-registry-interim.js new file mode 100644 index 000000000..472233f00 --- /dev/null +++ b/plugins/apps-registry-interim.js @@ -0,0 +1,43 @@ +/** + * INTERIM · apps-registry descriptions + AppCrossLinks description rendering. + * + * The preset's apps-registry has no `description` or `tags` fields, and its + * AppCrossLinks card renders only the app name, leaving the "About this app" + * box empty next to the glyph. Both fixes belong in the design-system preset; + * until a release ships them, this plugin (same mechanism as + * hermiq-glyph-interim.js) swaps in site copies: + * + * - src/data/apps-registry-interim.js registry + description/tags + * - src/components/AppCrossLinksInterim.jsx renders reg.description + * + * Every consumer that imports the preset registry (BlogListPage app chips, + * DetailHero JSON-LD, partner cards) transparently gets the interim data, + * so the site has ONE app config file while the override is active. + * + * REMOVE this plugin, its config entry and both interim files when bumping + * @conduction/docusaurus-preset to a version that ships registry + * descriptions and renders them in AppCrossLinks. + */ + +const path = require('path'); +const webpack = require('webpack'); + +module.exports = function appsRegistryInterim() { + return { + name: 'apps-registry-interim', + configureWebpack() { + return { + plugins: [ + new webpack.NormalModuleReplacementPlugin( + /data[\\/]apps-registry\.js$/, + path.resolve(__dirname, '..', 'src', 'data', 'apps-registry-interim.js'), + ), + new webpack.NormalModuleReplacementPlugin( + /AppCrossLinks[\\/]AppCrossLinks\.jsx$/, + path.resolve(__dirname, '..', 'src', 'components', 'AppCrossLinksInterim.jsx'), + ), + ], + }; + }, + }; +}; diff --git a/plugins/hermiq-glyph-interim.js b/plugins/hermiq-glyph-interim.js new file mode 100644 index 000000000..d2545d03f --- /dev/null +++ b/plugins/hermiq-glyph-interim.js @@ -0,0 +1,39 @@ +/** + * INTERIM · Hermiq app glyph override. + * + * The canonical Hermiq glyph (MDI "creation", the AI sparkles) landed in + * design-system commit e03a790 (docusaurus-preset/src/data/app-glyphs.json + * + the identity brand kit), but the preset cannot currently publish to + * npm: release-npm.yml fails with ENEEDAUTH because npm trusted publishing + * is not configured for ConductionNL/design-system (see the workflow's + * header comment). Until a release ≥3.30.0 is installable, the AppGlyph + * component in the installed preset has no "hermiq" entry and renders an + * empty hex on every AppCrossLinks card that names hermiq. + * + * This plugin swaps the preset's app-glyphs.json for a verbatim copy of + * the design-system file that already carries the hermiq entry + * (src/data/app-glyphs-interim.json). + * + * REMOVE this plugin, its config entry, and src/data/app-glyphs-interim.json + * when bumping @conduction/docusaurus-preset to a version that ships the + * hermiq glyph. + */ + +const path = require('path'); +const webpack = require('webpack'); + +module.exports = function hermiqGlyphInterim() { + return { + name: 'hermiq-glyph-interim', + configureWebpack() { + return { + plugins: [ + new webpack.NormalModuleReplacementPlugin( + /app-glyphs\.json$/, + path.resolve(__dirname, '..', 'src', 'data', 'app-glyphs-interim.json'), + ), + ], + }; + }, + }; +}; diff --git a/plugins/watch-preset-dev.js b/plugins/watch-preset-dev.js new file mode 100644 index 000000000..fe5588b31 --- /dev/null +++ b/plugins/watch-preset-dev.js @@ -0,0 +1,76 @@ +/** + * DEV ONLY · Watch the installed preset so edits to it hot-reload. + * + * Every mini-game, and most of the site's components, live in + * @conduction/docusaurus-preset — the design-system monorepo. While + * working on one we edit it there and copy the changed files into + * node_modules/@conduction/docusaurus-preset so it can be tried on the + * running site before a release. + * + * Webpack never noticed. `docusaurus start` sets + * + * watchOptions.ignored = /node_modules\/(?!@docusaurus)/ + * + * (@docusaurus/core/lib/webpack/client.js), and watchpack normalises + * backslashes to forward slashes before testing the regex, so the rule + * bites on Windows too. Every copy therefore needed a full server + * restart, which also reopens a browser tab. + * + * Plugin configureWebpack() hooks run after those watch options are + * built (getStartClientConfig in commands/start/webpack.js), so this + * puts @conduction back on the watch list. Docusaurus itself is kept + * un-ignored, exactly as before. + * + * Watching alone was not enough, and the way it failed is worth + * recording. A rebuild fired on every copy, and the output never + * changed. Two defaults conspire: + * + * - snapshot.managedPaths defaults to this project's node_modules + * (webpack/lib/config/defaults.js). Anything under it is taken to + * be owned by a package manager, so webpack validates it by the + * package's version rather than by its contents — and the preset's + * version does not move when we copy a file into it. + * - cache.type is 'filesystem' (@docusaurus/core/lib/webpack/base.js), + * so that verdict is written to node_modules/.cache/webpack and + * survives every restart. + * + * snapshot.unmanagedPaths is checked first and short-circuits the + * managed test (FileSystemInfo.checkManaged), so naming @conduction + * there puts it back on ordinary content hashing. + * + * Note for anyone editing this file: it is not one of webpack's + * buildDependencies (only docusaurus.config.js is), so a change here + * does not evict that cache. Clear node_modules/.cache/webpack by hand + * if this plugin's behaviour ever looks stuck. + * + * Scope: the dev server only. In a production build there is no + * watcher, and the hook returns nothing rather than leaving a stray + * watchOptions in the config. + * + * The cost is that webpack now walks @conduction on every rebuild. One + * package is cheap; if dev ever feels sluggish, this is a suspect. + * + * This plugin is a convenience, not a dependency: dropping it only + * brings the restart back. + */ + +module.exports = function watchPresetDev() { + return { + name: 'watch-preset-dev', + configureWebpack(config, isServer) { + if (isServer || process.env.NODE_ENV !== 'development') return {}; + return { + /* Replace rather than merge: two regexes cannot be combined, + and webpack-merge would otherwise keep Docusaurus's. */ + mergeStrategy: {'watchOptions.ignored': 'replace'}, + watchOptions: { + ignored: /node_modules\/(?!@docusaurus|@conduction)/, + }, + /* Appends to webpack's empty default, so nothing is lost. */ + snapshot: { + unmanagedPaths: [/[\\/]node_modules[\\/]@conduction[\\/]/], + }, + }; + }, + }; +}; diff --git a/scripts/a11y-sweep.mjs b/scripts/a11y-sweep.mjs new file mode 100644 index 000000000..efede5a1c --- /dev/null +++ b/scripts/a11y-sweep.mjs @@ -0,0 +1,252 @@ +#!/usr/bin/env node +/** + * The full mobile and dark-mode review: every route, both locales, four + * widths, both themes. + * + * npm run build + * npx docusaurus serve --port 4300 --no-open & + * node scripts/a11y-sweep.mjs # local build + * SWEEP_BASE=https://www.conduction.nl node scripts/a11y-sweep.mjs + * + * This is not part of CI. `e2e/a11y.spec.js` gates a sample on every run; + * this measures the whole site when something structural moves. + * + * Two things it does that the earlier sweeps did not, both of which changed + * the answer: + * + * - It builds the route list from the BUILD DIRECTORY, not from + * sitemap.xml. The sitemap does not list `/nl/`, so three separate + * "all 163 pages" sweeps measured exactly half the site and reported + * green while /nl/terms/ carried 88 invisible elements. + * - It refuses to start until the checks have been shown to fail. Run + * `npx playwright test a11y-selftest` first. + */ +import {chromium} from '@playwright/test'; +import fs from 'node:fs'; +import path from 'node:path'; + +const BASE = process.env.SWEEP_BASE || 'http://localhost:4300'; +const LANES = Number(process.env.SWEEP_LANES || 6); +const OUT = process.env.SWEEP_OUT || 'a11y-findings.json'; +const BUILD = process.env.SWEEP_BUILD || 'build'; + +const {RUN_CHECKS} = await import('../e2e/a11y-checks.js'); +const {REVEAL_ALL} = await import('../e2e/a11y-reveal.js'); +const {FOCUS_CHECK} = await import('../e2e/a11y-focus.js'); + +/* Measuring a page only as it loads says nothing about anything behind a + click. Set SWEEP_REVEAL=0 to skip it; it roughly doubles the time per + route. FOCUS runs on the revealed page so it also covers controls that + only exist once a panel is open. */ +const REVEAL = process.env.SWEEP_REVEAL !== '0'; + +/* Every directory in the build that holds an index.html is a route a + visitor can reach. Docusaurus's own machinery (assets, the search index) + has no index.html, so this needs no allow-list. */ +function routes(dir) { + const found = []; + const walk = (d) => { + for (const entry of fs.readdirSync(d, {withFileTypes: true})) { + const full = path.join(d, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.name === 'index.html') { + const rel = path.relative(BUILD, path.dirname(full)); + found.push(rel === '' ? '/' : `/${rel}/`); + } + } + }; + walk(dir); + return [...new Set(found)].sort(); +} + +/* 320 is the narrowest phone still in real use and the width that actually + breaks layouts; 390 is the audit baseline; 768 is the tablet hinge where + a desktop grid is still in force; 1280 catches desktop-only regressions. + Light runs alongside dark so a finding can be ATTRIBUTED to dark mode + rather than assumed: a contrast miss present in both themes is brand debt + that predates it. */ +const COMBOS = [ + {width: 320, theme: 'dark'}, + {width: 390, theme: 'light'}, + {width: 390, theme: 'dark'}, + {width: 768, theme: 'dark'}, + {width: 1280, theme: 'light'}, + {width: 1280, theme: 'dark'}, +]; + +/* The footer stylesheet is injected lazily; measured before it lands the + footer reports its unstyled metrics and invents target-size findings. */ +const footerReady = () => { + const f = document.querySelector('.canal-footer'); + if (!f) return true; + const link = [...document.querySelectorAll('link[rel="stylesheet"]')].find((l) => + l.href.includes('canal-footer'), + ); + if (!link) return false; + try { + if (!link.sheet || link.sheet.cssRules.length === 0) return false; + } catch { + if (!link.sheet) return false; + } + const go = f.querySelector('.game-over'); + return !(go && getComputedStyle(go).display !== 'none'); +}; + +const all = routes(BUILD); +const findings = []; +const errors = []; +const redirects = []; +let done = 0; + +async function lane(browser, slice) { + const pages = []; + for (const combo of COMBOS) { + const ctx = await browser.newContext({ + viewport: {width: combo.width, height: combo.width < 700 ? 844 : 900}, + deviceScaleFactor: combo.width < 700 ? 3 : 1, + isMobile: combo.width < 700, + hasTouch: combo.width < 700, + }); + pages.push({combo, ctx, page: await ctx.newPage()}); + } + for (const url of slice) { + for (const {combo, page} of pages) { + let ok = false; + for (let attempt = 1; attempt <= 3 && !ok; attempt++) { + try { + const resp = await page.goto(BASE + url, {waitUntil: 'networkidle', timeout: 45000}); + if (!resp || resp.status() >= 400) throw new Error(`HTTP ${resp && resp.status()}`); + /* 118 of these routes are redirect stubs: a meta-refresh plus a + location assignment. The browser follows them, so without this + check the sweep measures whatever it lands on and files the + findings under the stub's URL. Off site that means reporting + another domain's defects as ours (/openconnector/ redirects to + openconnector.conduction.nl, which this repo does not control); + on site it means measuring the target twice and attributing one + copy to the wrong page. Either way the stub itself has no + content to check. */ + const landed = page.url(); + if (!landed.startsWith(BASE) || new URL(landed).pathname !== url) { + redirects.push({from: url, to: landed}); + ok = true; + break; + } + await page.waitForFunction(footerReady, null, {timeout: 20000}); + for (const f of await page.evaluate(RUN_CHECKS, {theme: combo.theme})) { + findings.push({url, width: combo.width, theme: combo.theme, state: 'loaded', ...f}); + } + /* Reveal costs a second full measure per combo and focus costs 40 + focus calls, so neither runs on every combo. Layout behind a + disclosure matters most at the narrowest width and on the + desktop default; focus styling does not vary with width but + does with theme, so it runs once per theme. */ + const doReveal = REVEAL && (combo.width === 320 || combo.width === 1280); + const doFocus = REVEAL && combo.width === 1280; + if (doReveal) { + /* Open every disclosure, tab and panel, then measure again. A + finding carries the state it was found in, so "only visible + after opening something" stays distinguishable from "on the + page as it loads". */ + const opened = await page.evaluate(REVEAL_ALL); + if (opened.details + opened.expanded + opened.tabs + opened.listItems > 0) { + for (const f of await page.evaluate(RUN_CHECKS, {theme: combo.theme})) { + findings.push({url, width: combo.width, theme: combo.theme, state: 'revealed', ...f}); + } + } + } + if (doFocus) { + const focus = await page.evaluate(FOCUS_CHECK, {limit: 40}); + for (const f of focus.findings) { + findings.push({url, width: combo.width, theme: combo.theme, state: 'focus', ...f}); + } + } + ok = true; + } catch (e) { + if (attempt === 3) { + errors.push({url, combo, error: String(e).slice(0, 140)}); + console.log(`ERROR ${url} ${combo.width}/${combo.theme}: ${String(e).slice(0, 80)}`); + } else { + await new Promise((r) => setTimeout(r, 1500 * attempt)); + } + } + } + } + done++; + if (done % 25 === 0) console.log(`PROGRESS ${done}/${all.length} routes, ${findings.length} findings`); + } + for (const p of pages) await p.ctx.close(); +} + +console.log(`sweeping ${all.length} routes x ${COMBOS.length} combos = ${all.length * COMBOS.length} loads against ${BASE}`); +const browser = await chromium.launch(); +await Promise.all( + Array.from({length: LANES}, (_, i) => lane(browser, all.filter((_, j) => j % LANES === i))), +); +await browser.close(); + +/* A defect seen at six combos is one defect. Attribution: a contrast miss + that also occurs in light is not something dark mode broke; a layout + defect that also occurs at 1280 is not a mobile defect. */ +const uniq = new Map(); +for (const f of findings) { + const k = `${f.url}|${f.check}|${f.text}|${f.state}`; + if (!uniq.has(k)) uniq.set(k, {...f, themes: new Set(), widths: new Set()}); + uniq.get(k).themes.add(f.theme); + uniq.get(k).widths.add(f.width); +} +const defects = [...uniq.values()].map((f) => ({ + ...f, + themes: [...f.themes], + widths: [...f.widths], + bucket: f.check.includes('contrast') + ? (f.themes.has('dark') && !f.themes.has('light') ? 'dark-only' : 'both-themes') + : (f.widths.has(1280) ? 'all-widths' : 'narrow-only'), +})); + +fs.writeFileSync(OUT, JSON.stringify({defects, errors, redirects}, null, 1)); + +const pagesOf = (l) => new Set(l.map((f) => f.url)).size; +console.log(''); +const stubRoutes = new Set(redirects.map((r) => r.from)).size; +console.log(`SWEPT ${done - stubRoutes} routes with content, skipped ${stubRoutes} redirect stubs, ${errors.length} load failures`); +console.log(`raw ${findings.length} -> distinct defects ${defects.length}`); +console.log(''); +console.log('check defects pages en/nl attribution'); +const byCheck = {}; +for (const f of defects) (byCheck[f.check] = byCheck[f.check] || []).push(f); +for (const [check, sub] of Object.entries(byCheck).sort((a, b) => b[1].length - a[1].length)) { + const en = sub.filter((f) => !f.url.startsWith('/nl/')).length; + const b = {}; + for (const f of sub) b[f.bucket] = (b[f.bucket] || 0) + 1; + console.log( + `${check.padEnd(22)} ${String(sub.length).padStart(7)} ${String(pagesOf(sub)).padStart(6)} ${String(en).padStart(4)}/${String(sub.length - en).padEnd(4)} ` + + Object.entries(b).map(([k, v]) => `${k}:${v}`).join(' '), + ); +} +const byState = {}; +for (const f of defects) (byState[f.state || 'loaded'] = byState[f.state || 'loaded'] || []).push(f); +console.log(''); +console.log('by state:'); +for (const [st, list] of Object.entries(byState)) { + const bad = list.filter((f) => f.severity === 'critical' || f.severity === 'serious'); + console.log(` ${st.padEnd(10)} ${String(list.length).padStart(6)} defects, ${bad.length} critical or serious`); +} + +const focusFindings = defects.filter((f) => f.check && f.check.startsWith('focus-')); +if (focusFindings.length) { + const g = {}; + for (const f of focusFindings) (g[f.check] = g[f.check] || []).push(f); + console.log(''); + console.log('focus visibility:'); + for (const [k, list] of Object.entries(g)) { + console.log(` ${k}: ${list.length} on ${new Set(list.map((f) => f.url)).size} pages`); + for (const f of list.slice(0, 6)) console.log(` ${f.url} "${f.text}" ${f.detail}`); + } +} + +const crit = defects.filter((f) => f.severity === 'critical'); +console.log(''); +console.log(`=== critical: ${crit.length} on ${pagesOf(crit)} pages ===`); +for (const f of crit.slice(0, 40)) console.log(` ${f.url} ${f.check} "${f.text}" ${f.detail}`); +console.log(''); +console.log(`SWEEP EXIT=${crit.length || errors.length ? 1 : 0}`); diff --git a/scripts/lint-color-roles.mjs b/scripts/lint-color-roles.mjs new file mode 100644 index 000000000..54d722f6b --- /dev/null +++ b/scripts/lint-color-roles.mjs @@ -0,0 +1,309 @@ +#!/usr/bin/env node +/** + * scripts/lint-color-roles.mjs + * + * Catch, at authoring time, the colour mistake that a runtime sweep can only + * catch after it ships. + * + * THE INVARIANT + * + * Within one declaration block or inline style object, ink and surface must + * be either BOTH theme-aware or BOTH fixed. Never one of each. + * + * Why that is the right line. A semantic token (`--conduction-color-*`) has a + * different value per theme; a raw palette token (`--c-*`), a hex or a named + * colour has one value forever. Mixing them means one theme is always wrong, + * and which theme is wrong depends on which half you fixed: + * + * fixed surface + theme-aware ink the ink flips out from under a surface + * that cannot follow. Measured 2026-09-20: + * white on gold at 2.58:1 and white on + * mint at 3.62:1, on 23 pages. + * theme-aware surface + fixed ink the classic dark-mode failure: the + * surface goes dark under navy text. + * 1,362 elements under 1.5:1 in the first + * audit. + * + * A block with a fixed ink and NO surface of its own is also flagged, because + * it inherits whatever surface an ancestor provides and that surface is free + * to flip. That is how 32 declarations of + * `color: 'var(--c-cobalt-500, #6f7da3)'` came to render at 1.73:1 in dark. + * If the ancestor's surface really is fixed, say so with an opt-out. + * + * OPT-OUT + * + * Put `a11y-fixed-surface` in a comment on the line above, or anywhere in + * the same JSX style object. Use it where an ancestor paints a surface that + * cannot flip, e.g. text inside a permanently-cobalt hero. It is deliberately + * a phrase and not a bare eslint-style pragma so it reads as a claim someone + * made, and `git grep a11y-fixed-surface` lists every such claim. + * + * WHAT THIS DOES NOT DO + * + * It cannot know a token's contrast against a surface set three components + * up. That is what `scripts/a11y-sweep.mjs` and `e2e/a11y.spec.js` are for. + * This is the cheap half: the half a person can fix while writing the line. + * + * npm run lint:colors check + * npm run lint:colors -- --self-test prove the checks can fail + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +const ROOTS = ['src', 'i18n', 'academy']; +const EXTS = new Set(['.css', '.mdx', '.jsx', '.js']); +const OPT_OUT = 'a11y-fixed-surface'; + +/** + * Brand accent colours, whose whole job is to be a fixed ink. + * + * The advisory rule flags a fixed colour with no surface of its own, because + * it inherits a surface that is free to flip. That is the right default, and + * for these particular values it is noise: the design system defines them as + * accents with a named role ("coral = KNVB-orange accent for highlights", + * "gold = the Conduction Certified mark only"), and an accent that followed + * the theme would stop being the brand. + * + * 126 of the 163 advisory findings on 2026-09-22 were these, which is enough + * noise to make the other 37 invisible. The full 661-route runtime sweep + * reports zero contrast failures for any of them, so this is not hiding a + * known defect; if one of these ever does land on a surface it cannot read + * against, the sweep and the gate catch it as a contrast finding, which is + * the layer that measures rather than guesses. + * + * Listed by value so the exception stays narrow and reviewable. A greyscale + * or cobalt ink is deliberately NOT here: those are the ones picked to suit + * whatever surface the author happened to be looking at. + */ +const BRAND_ACCENT_INK = [ + '--c-orange-knvb', '--c-coral-500', '--c-coral-600', + '--c-mint-500', '--c-mint-300', + '--c-amber-500', '--c-gold-500', '--c-gold-300', + '--c-nextcloud-blue', '--c-nextcloud-cyan', '--c-commonground-yellow', + '--c-red-vermillion', '--c-terracotta-500', '--c-lavender-500', '--c-forest-500', +]; +const isBrandAccent = (v) => BRAND_ACCENT_INK.some((t) => v.includes(t)); + +/** A colour value is theme-aware only if it comes from the semantic layer. */ +const isThemeAware = (v) => /var\(\s*--conduction-color-/.test(v); +/** Fixed: a raw palette token, a hex, a named colour, or an rgb()/hsl() literal. */ +const isFixed = (v) => + /var\(\s*--c-/.test(v) || + /#[0-9a-fA-F]{3,8}\b/.test(v) || + /\b(?:rgba?|hsla?)\s*\(/.test(v) || + /\b(?:white|black)\b/.test(v); +/* `transparent`, `inherit`, `currentColor` and `none` are neither: they defer + to something else, which is exactly the safe thing to do. */ +const isDeferred = (v) => /\b(?:transparent|inherit|currentColor|unset|revert|initial|none)\b/i.test(v); + +/** + * Split a block body into declarations. + * + * CSS separates with `;`, a JS style object with `,`, and both allow commas + * INSIDE a value (`rgba(1,2,3)`, `linear-gradient(...)`). Splitting a JS + * object on a naive regex made the background's value swallow the rest of the + * object, including the colour, so a mixed block looked consistent and the + * MDX self-test case stayed silent. Walk the string instead, tracking paren + * depth and quotes. + */ +function declarations(body, sep) { + const out = []; + let buf = ''; + let depth = 0; + let quote = null; + for (const ch of body) { + if (quote) { + if (ch === quote) quote = null; + buf += ch; + continue; + } + if (ch === '"' || ch === "'" || ch === '`') { quote = ch; buf += ch; continue; } + if (ch === '(') depth++; + if (ch === ')') depth--; + if (ch === sep && depth === 0) { out.push(buf); buf = ''; continue; } + buf += ch; + } + out.push(buf); + return out; +} + +/* `color` but not `border-color`, `background-color`, `outline-color`, and in + a JS object also `backgroundColor`. The property boundary matters: `color:` + is a substring of `border-color:`, which silently rewrote 58 declarations + during an earlier migration. */ +const INK = /(?:^|[^-\w.])(?:color)\s*:\s*(.+)$/is; +const SURFACE = /(?:^|[^-\w.])(?:background|background-color|backgroundColor)\s*:\s*(.+)$/is; + +function roleValues(body, sep) { + let ink; + let surface; + for (const raw of declarations(body, sep)) { + const d = raw.trim(); + if (!d) continue; + const i = d.match(INK); + if (i && ink === undefined) ink = i[1].trim().replace(/^['"`]|['"`],?$/g, '').trim(); + const s2 = d.match(SURFACE); + if (s2 && surface === undefined) surface = s2[1].trim().replace(/^['"`]|['"`],?$/g, '').trim(); + } + return {ink, surface}; +} + +/** + * Split a file into candidate blocks: CSS rule bodies and JSX style objects. + * Crude on purpose. A missed block is a missed warning, never a wrong one, + * and the runtime sweep is the backstop. + */ +function blocks(text, file) { + const out = []; + if (file.endsWith('.css')) { + for (const m of text.matchAll(/\{([^{}]*)\}/g)) { + out.push({body: m[1], index: m.index}); + } + } else { + /* JSX inline styles: style={{ ... }} and bare object literals that carry + a colour role, e.g. `const pill = {background: ..., color: ...}`. */ + for (const m of text.matchAll(/\{\{([^{}]*)\}\}|=\s*\{([^{}]*)\}/g)) { + out.push({body: m[1] ?? m[2] ?? '', index: m.index}); + } + } + return out; +} + +const lineOf = (text, index) => text.slice(0, index).split('\n').length; + +function check(file) { + const text = fs.readFileSync(file, 'utf8'); + const lines = text.split('\n'); + /* A file-level opt-out, for a component that paints one fixed shell and + draws everything inside it: a terminal pane, an error page. Putting the + phrase in the file's header comment says "every ink in here sits on a + surface I control and that does not flip", which is a claim about the + file rather than about one block. It only counts in the header, so it + cannot be dropped in halfway down to silence something inconvenient. */ + if (lines.slice(0, 40).join('\n').includes(OPT_OUT)) return []; + const findings = []; + for (const {body, index} of blocks(text, file)) { + if (body.includes(OPT_OUT)) continue; + const line = lineOf(text, index); + /* An opt-out on the line above the block, or on the block's own line. */ + const near = [lines[line - 2], lines[line - 1]].filter(Boolean).join('\n'); + if (near.includes(OPT_OUT)) continue; + + const {ink, surface} = roleValues(body, file.endsWith('.css') ? ';' : ','); + if (!ink || isDeferred(ink)) continue; + + const inkAware = isThemeAware(ink); + const inkFixed = !inkAware && isFixed(ink); + if (!inkAware && !inkFixed) continue; + + if (surface && !isDeferred(surface)) { + const surfAware = isThemeAware(surface); + const surfFixed = !surfAware && isFixed(surface); + if (inkAware && surfFixed) { + findings.push({file, line, kind: 'ink-flips-surface-cannot', + detail: `color ${ink} follows the theme, background ${surface} cannot`}); + } else if (inkFixed && surfAware) { + findings.push({file, line, kind: 'surface-flips-ink-cannot', + detail: `background ${surface} follows the theme, color ${ink} cannot`}); + } + } else if (inkFixed && !isBrandAccent(ink)) { + findings.push({file, line, kind: 'fixed-ink-inherited-surface', + detail: `color ${ink} cannot follow the theme and this block sets no surface of its own`}); + } + } + return findings; +} + +function walk(dir, acc = []) { + if (!fs.existsSync(dir)) return acc; + for (const e of fs.readdirSync(dir, {withFileTypes: true})) { + const full = path.join(dir, e.name); + if (e.isDirectory()) walk(full, acc); + else if (EXTS.has(path.extname(e.name))) acc.push(full); + } + return acc; +} + +/* ---------------- self-test: every rule must be able to fire ------------- */ +if (process.argv.includes('--self-test')) { + const tmp = fs.mkdtempSync('/tmp/lint-color-roles-'); + const cases = [ + ['ink-flips-surface-cannot', 'a.css', '.x { background: var(--c-gold-500); color: var(--conduction-color-text-strong); }'], + ['surface-flips-ink-cannot', 'b.css', '.y { background: var(--conduction-color-surface-card); color: var(--c-cobalt-500); }'], + ['fixed-ink-inherited-surface', 'c.css', '.z { color: var(--c-cobalt-500, #6f7da3); }'], + ['ink-flips-surface-cannot', 'd.mdx', 'x'], + ]; + let bad = 0; + for (const [kind, name, src] of cases) { + const f = path.join(tmp, name); + fs.writeFileSync(f, src); + const hit = check(f).some((x) => x.kind === kind); + console.log(`${hit ? 'FIRED ' : 'SILENT'} ${kind.padEnd(30)} ${name}`); + if (!hit) bad++; + } + /* Negative controls: correct code must stay quiet, or the rule is noise. */ + const quiet = [ + ['both fixed', 'e.css', '.a { background: var(--c-gold-500); color: var(--c-cobalt-900); }'], + ['both theme-aware', 'f.css', '.b { background: var(--conduction-color-surface-card); color: var(--conduction-color-text-body); }'], + ['inherit defers', 'g.css', '.c { color: inherit; }'], + ['opted out', 'h.css', '/* a11y-fixed-surface: the hero is cobalt in both themes */\n.d { color: var(--c-cobalt-100); }'], + ]; + for (const [label, name, src] of quiet) { + const f = path.join(tmp, name); + fs.writeFileSync(f, src); + const n = check(f).length; + console.log(`${n === 0 ? 'quiet ' : 'NOISY '} ${label.padEnd(30)} ${name}${n ? ' -> ' + JSON.stringify(check(f)) : ''}`); + if (n !== 0) bad++; + } + fs.rmSync(tmp, {recursive: true, force: true}); + console.log(''); + console.log(`SELF-TEST ${bad === 0 ? 'PASS' : 'FAIL'}`); + process.exit(bad === 0 ? 0 : 1); +} + +const STRICT = process.argv.includes('--strict'); +/* Two tiers, because a check that reports 167 things on the day it lands gets + switched off. + + BLOCKING is the pair that is wrong by construction: one half of the block + follows the theme and the other cannot, so one theme is always broken. No + judgement needed, and no false positives in practice. + + ADVISORY is fixed ink with no surface of its own. Often fine (a terminal + pane, a permanently-cobalt hero), sometimes the exact bug that put 32 + declarations at 1.73:1 in dark. It needs a person to look at the ancestor, + so it prints and does not fail. Work the list down with `--strict`, and + mark the deliberate ones with an a11y-fixed-surface comment. */ +const BLOCKING = new Set(['ink-flips-surface-cannot', 'surface-flips-ink-cannot']); + +const files = ROOTS.flatMap((r) => walk(r)); +const findings = files.flatMap(check); +const blocking = findings.filter((f) => BLOCKING.has(f.kind)); +const advisory = findings.filter((f) => !BLOCKING.has(f.kind)); + +const report = (title, list, cap) => { + if (!list.length) return; + const byKind = {}; + for (const f of list) (byKind[f.kind] = byKind[f.kind] || []).push(f); + console.log(`\n${title}`); + for (const [kind, sub] of Object.entries(byKind)) { + console.log(` ${kind}: ${sub.length} on ${new Set(sub.map((f) => f.file)).size} file(s)`); + for (const f of sub.slice(0, cap)) console.log(` ${f.file}:${f.line} ${f.detail}`); + if (sub.length > cap) console.log(` ... and ${sub.length - cap} more`); + } +}; + +report('BLOCKING - one half follows the theme, the other cannot:', blocking, 40); +report(STRICT ? 'ALSO BLOCKING (--strict):' : 'ADVISORY - fixed ink, surface comes from an ancestor:', advisory, STRICT ? Number.MAX_SAFE_INTEGER : 5); + +console.log(''); +const fail = blocking.length + (STRICT ? advisory.length : 0); +if (fail) { + console.error(`${fail} colour-role problem(s) across ${new Set([...blocking, ...(STRICT ? advisory : [])].map((f) => f.file)).size} file(s).`); + console.error('Ink and surface must both follow the theme or both be fixed. Where an'); + console.error(`ancestor paints a surface that cannot flip, say so with an "${OPT_OUT}" comment.`); + process.exit(1); +} +console.log(`No blocking colour-role problems in ${files.length} files.` + + (advisory.length ? ` ${advisory.length} advisory, run with --strict to see them all.` : '')); diff --git a/scripts/validate-ai-baseline.mjs b/scripts/validate-ai-baseline.mjs new file mode 100755 index 000000000..048da6e2a --- /dev/null +++ b/scripts/validate-ai-baseline.mjs @@ -0,0 +1,261 @@ +#!/usr/bin/env node +/** + * scripts/validate-ai-baseline.mjs + * + * Hard-fail validation that the AI-crawler baseline is intact in the + * production build. Run after `npx docusaurus build` (CI invokes it via + * .github/workflows/documentation.yml, see also npm script + * `validate:ai-baseline`). + * + * Checks (any failure exits non-zero): + * + * robots.txt Exists, has Sitemap line, has User-agent: * Allow: /, + * names at least one AI training and one AI search bot. + * llms.txt Exists, body > 1 KB (placeholder content is shorter). + * sitemap.xml Exists, has >= 50 entries. + * nl/sitemap.xml Same, for the Dutch locale build. + * homepage Has >= 2 application/ld+json blocks, all valid JSON. + * Has og:image, og:type, twitter:site, twitter:card + * meta tags. og:image URL resolves to an actual file + * in the build output (so LinkedIn/Slack/AI previews + * don't render a 404). + * /apps/openregister Has SoftwareApplication JSON-LD with + * applicationCategory + operatingSystem (regression + * marker for the DetailHero schema emission shipped + * in @conduction/docusaurus-preset). + * + * Adding a check: append to the CHECKS array. Each check is a function + * (buildDir) -> {ok: boolean, msg: string}. Keep messages specific so + * a failing CI annotation tells the reader what to fix without + * re-running locally. + */ + +import {readFileSync, existsSync, statSync} from 'node:fs'; +import {join, resolve} from 'node:path'; + +const buildDir = resolve(process.argv[2] || 'build'); + +if (!existsSync(buildDir)) { + console.error(`✗ build directory not found: ${buildDir}`); + console.error(` Run \`npx docusaurus build\` first.`); + process.exit(2); +} + +const results = []; + +function check(name, fn) { + try { + const r = fn(); + results.push({name, ok: r.ok, msg: r.msg}); + } catch (e) { + results.push({name, ok: false, msg: `threw: ${e.message}`}); + } +} + +function readBuild(p) { + return readFileSync(join(buildDir, p), 'utf8'); +} + +/* robots.txt */ +check('robots.txt exists and is non-empty', () => { + const path = join(buildDir, 'robots.txt'); + if (!existsSync(path)) return {ok: false, msg: 'missing'}; + const size = statSync(path).size; + if (size < 50) return {ok: false, msg: `too small (${size} bytes)`}; + return {ok: true, msg: `${size} bytes`}; +}); + +check('robots.txt has Sitemap line', () => { + const body = readBuild('robots.txt'); + const matches = body.match(/^Sitemap:\s+https?:\/\//gm) || []; + if (matches.length === 0) return {ok: false, msg: 'no Sitemap: line'}; + return {ok: true, msg: `${matches.length} sitemap line(s)`}; +}); + +check('robots.txt allows AI search bots', () => { + const body = readBuild('robots.txt'); + const needed = ['OAI-SearchBot', 'Claude-SearchBot', 'PerplexityBot']; + const missing = needed.filter(ua => !body.includes(`User-agent: ${ua}`)); + if (missing.length) return {ok: false, msg: `missing User-agent entries for: ${missing.join(', ')}`}; + return {ok: true, msg: needed.join(', ')}; +}); + +check('robots.txt has at least User-agent: * Allow: /', () => { + const body = readBuild('robots.txt'); + if (!/^User-agent:\s*\*\s*$/m.test(body)) return {ok: false, msg: 'no User-agent: * block'}; + if (!/^Allow:\s*\/\s*$/m.test(body)) return {ok: false, msg: 'no Allow: / line'}; + return {ok: true, msg: 'default-allow present'}; +}); + +/* llms.txt */ +check('llms.txt exists and has content', () => { + const path = join(buildDir, 'llms.txt'); + if (!existsSync(path)) return {ok: false, msg: 'missing'}; + const size = statSync(path).size; + if (size < 1024) return {ok: false, msg: `under 1 KB (${size} bytes) - likely placeholder`}; + return {ok: true, msg: `${size} bytes`}; +}); + +/* Sitemaps */ +function sitemapLocCount(p) { + const body = readBuild(p); + return (body.match(//g) || []).length; +} + +check('sitemap.xml has at least 50 URLs', () => { + const path = join(buildDir, 'sitemap.xml'); + if (!existsSync(path)) return {ok: false, msg: 'missing'}; + const n = sitemapLocCount('sitemap.xml'); + if (n < 50) return {ok: false, msg: `only ${n} URLs`}; + return {ok: true, msg: `${n} URLs`}; +}); + +check('nl/sitemap.xml has at least 50 URLs', () => { + const path = join(buildDir, 'nl/sitemap.xml'); + if (!existsSync(path)) return {ok: false, msg: 'missing'}; + const n = sitemapLocCount('nl/sitemap.xml'); + if (n < 50) return {ok: false, msg: `only ${n} URLs`}; + return {ok: true, msg: `${n} URLs`}; +}); + +/* JSON-LD blocks */ +function extractJsonLdBlocks(html) { + /* Docusaurus emits JSON-LD via two paths with different attribute + ordering: top-level headTags renders + + diff --git a/static/academy/opinion/index.html b/static/academy/opinion/index.html new file mode 100644 index 000000000..681ff4fca --- /dev/null +++ b/static/academy/opinion/index.html @@ -0,0 +1,14 @@ + + + + +Moved to /academy/?type=blog + + + + + +

Opinion pieces live in the academy blog feed. You will be redirected to /academy/.

+ + + diff --git a/static/connext/connext-compose.yml b/static/connext/connext-compose.yml new file mode 100644 index 000000000..69254f03e --- /dev/null +++ b/static/connext/connext-compose.yml @@ -0,0 +1,281 @@ +# Connext — the whole ecosystem, locally, in one command +# +# docker compose -f connext-compose.yml up -d +# +# Then open http://localhost:8700 (admin / admin) +# +# Tear down, including all data: +# docker compose -f connext-compose.yml down -v +# +# Needs Docker Compose v2.23 or newer. Older versions ignore the `content:` +# field on a config without saying so, which yields an instance with no apps +# installed and no error to explain it. +# +# --------------------------------------------------------------------------- +# THIS IS A DEMO ENVIRONMENT, NOT A DEVELOPMENT ENVIRONMENT. +# +# Nothing is bind-mounted from a working copy, deliberately. Nextcloud installs +# and updates an app by DELETING its directory and extracting a fresh archive +# over it — point that at a checkout and an app-store update deletes the +# checkout. Measured 2026-08-27 on a development machine, where +# `\OC\Updater::upgradeAppStoreApp` fired on a container restart and removed +# every top-level file from a bind-mounted app, including its `.git` directory. +# +# To work ON these apps rather than WITH them, use a development environment. +# +# --------------------------------------------------------------------------- +# WHY RELEASE ARCHIVES RATHER THAN `git clone` +# +# A release archive is a COMPLETE app: it carries `vendor/` and the built `js/` +# bundle. A git clone carries neither, and a Nextcloud app whose `vendor/` is +# missing does NOT fail loudly — `include_once` warns once and the app keeps +# loading, so it looks installed while every service that needs a dependency is +# quietly absent. +# +# --------------------------------------------------------------------------- +# VERSIONS +# +# Most Connext apps do not yet publish a stable release, so by default each app +# resolves to its NEWEST release, pre-releases included. That is honest about +# what exists rather than pinning to tags that do not. +# +# Pin any app explicitly when you need a reproducible environment: +# +# OPENREGISTER_VERSION=1.1.6 OPENCATALOGI_VERSION=1.0.9 \ +# docker compose -f connext-compose.yml up -d +# +# --------------------------------------------------------------------------- +# THREE NAMES PER APP, AND THEY ARE NOT THE SAME NAME +# +# The fleet renamed many apps in 2026, and the rename reached the three names +# at different times. The installer therefore carries all three per app: +# +# repo the GitHub repository (versioniq) +# app id the DIRECTORY Nextcloud loads (app_versions) +# asset the archive filename (app_versions-1.4.1-….tar.gz) +# +# Nextcloud resolves an app by its DIRECTORY name, and REFUSES an archive whose +# appinfo/info.xml declares a different : "App X cannot be installed because +# appinfo file cannot be read." Measured while writing this file — thematiq was +# installed as `nldesign` on the assumption that the id had not moved with the +# repo, and it had. The in the RELEASED archive is the only authority; a +# local checkout can be on a branch where it has not moved yet. + +name: connext-demo + +volumes: + db: + nextcloud: + apps: + +services: + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: nextcloud + POSTGRES_PASSWORD: nextcloud + POSTGRES_DB: nextcloud + volumes: + - db:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U nextcloud -d nextcloud"] + interval: 5s + timeout: 3s + retries: 20 + + # Populates the shared `apps` volume, then exits. Nextcloud waits for that + # exit, so it never boots against a half-populated app directory. + # Idempotent: an app already present is skipped rather than re-downloaded. + app-installer: + image: alpine:3.20 + restart: "no" + environment: + OPENREGISTER_VERSION: ${OPENREGISTER_VERSION:-} + OPENCATALOGI_VERSION: ${OPENCATALOGI_VERSION:-} + PORTALIQ_VERSION: ${PORTALIQ_VERSION:-} + THEMATIQ_VERSION: ${THEMATIQ_VERSION:-} + INTEGRIQ_VERSION: ${INTEGRIQ_VERSION:-} + FILINQ_VERSION: ${FILINQ_VERSION:-} + LAUNCHPAD_VERSION: ${LAUNCHPAD_VERSION:-} + PIPELINQ_VERSION: ${PIPELINQ_VERSION:-} + SHILLINQ_VERSION: ${SHILLINQ_VERSION:-} + HERMIQ_VERSION: ${HERMIQ_VERSION:-} + ZAAKAFHANDELAPP_VERSION: ${ZAAKAFHANDELAPP_VERSION:-} + volumes: + - apps:/apps + configs: + - source: install-apps + target: /install-apps.sh + mode: "0755" + command: ["/bin/sh", "/install-apps.sh"] + + nextcloud: + image: nextcloud:34-apache + restart: unless-stopped + ports: + - "${CONNEXT_PORT:-8700}:80" + depends_on: + db: + condition: service_healthy + app-installer: + condition: service_completed_successfully + environment: + POSTGRES_HOST: db + POSTGRES_USER: nextcloud + POSTGRES_PASSWORD: nextcloud + POSTGRES_DB: nextcloud + NEXTCLOUD_ADMIN_USER: admin + NEXTCLOUD_ADMIN_PASSWORD: admin + NEXTCLOUD_TRUSTED_DOMAINS: "localhost localhost:${CONNEXT_PORT:-8700} 127.0.0.1 127.0.0.1:${CONNEXT_PORT:-8700}" + # Load-bearing. OpenCatalogi advertises this address to the federated + # directory network, and refuses to broadcast a LOCAL one — which is what + # stops a laptop demo from registering `http://localhost` as a peer in the + # national directory at directory.opencatalogi.nl. + OVERWRITECLIURL: "http://localhost:${CONNEXT_PORT:-8700}" + OVERWRITEPROTOCOL: http + # The ecosystem is large; the default 512M is not enough to enable it all. + PHP_MEMORY_LIMIT: 2G + PHP_UPLOAD_LIMIT: 512M + volumes: + - nextcloud:/var/www/html + - apps:/var/www/html/custom_apps + configs: + - source: enable-apps + target: /docker-entrypoint-hooks.d/post-installation/10-enable-connext-apps.sh + mode: "0755" + +configs: + # EVERY SHELL VARIABLE BELOW IS `$$name`, NOT `$name`. + # Compose interpolates `$name` inside `configs.content` before the file is + # written, so an un-escaped shell variable arrives as an EMPTY STRING and the + # script runs on regardless. `$$` survives interpolation and reaches /bin/sh + # as `$`. `${CONNEXT_PORT:-8700}` is deliberately NOT escaped — that one is + # Compose's to substitute. + install-apps: + content: | + #!/bin/sh + set -u + apk add --no-cache curl tar jq >/dev/null + + # RESOLVING "NEWEST" TAKES TWO CORRECTIONS, NOT ONE. + # + # 1. The GitHub "latest release" endpoint EXCLUDES pre-releases, and 404s + # for a repository that has only ever shipped them — which reads exactly + # like "no such app". So ask the releases LIST instead. + # + # 2. THE LIST IS NOT ORDERED BY CREATION DATE. Taking the first entry looks + # right and is not. Measured 2026-08-27 on openregister: + # + # v1.1.6 created 10:17:45 <- returned first + # v1.1.6-unstable.20260827110807 created 11:09:37 <- actually newest + # + # Picking the first entry installed an OLDER OpenRegister than the leaf + # apps needed, and the failure surfaced far away from the cause: integriq + # aborted with `Class "OCA\OpenRegister\AppHost\Repair\ + # GenericInitializeActions" not found`, and every OpenCatalogi API route + # 500'd on `Class "OCA\OpenRegister\AppHost\Routes" not found`. + # + # Sorting by created_at explicitly is the fix; jq is here for that. + resolve_latest() { + curl -fsSL "https://api.github.com/repos/ConductionNL/$$1/releases?per_page=50" \ + | jq -r '[.[] | select(.draft | not)] | sort_by(.created_at) | last | .tag_name // empty' \ + | sed 's/^v//' + } + + FAILED="" + + # install_app + install_app() { + repo="$$1"; appid="$$2"; version="$$3"; asset="$$4" + + if [ -f "/apps/$$appid/appinfo/info.xml" ]; then + echo "==> $$appid already present, skipping" + return 0 + fi + + if [ -z "$$version" ]; then + version="$$(resolve_latest "$$repo")" + if [ -z "$$version" ]; then + echo "!! $$appid: no release found in $$repo" + FAILED="$$FAILED $$appid" + return 0 + fi + fi + + url="https://github.com/ConductionNL/$$repo/releases/download/v$$version/$$asset-$$version.tar.gz" + echo "==> installing $$appid $$version" + + rm -rf /tmp/stage && mkdir -p /tmp/stage + if ! curl -fsSL "$$url" | tar -xz -C /tmp/stage 2>/dev/null; then + echo "!! $$appid: download failed ($$url)" + FAILED="$$FAILED $$appid" + return 0 + fi + + # The archive's top-level directory is not guaranteed to equal the + # Nextcloud app id — several repos still ship archives under their OLD + # name. Nextcloud resolves an app by DIRECTORY name, so installing it + # under the archive's name yields an app that is never loaded. + top="$$(ls /tmp/stage | head -n1)" + [ "$$top" = "$$appid" ] || echo " archive unpacked as '$$top', installing as '$$appid'" + rm -rf "/apps/$$appid" + mv "/tmp/stage/$$top" "/apps/$$appid" + rm -rf /tmp/stage + } + + # repo app-id version asset-prefix + install_app openregister openregister "$$OPENREGISTER_VERSION" openregister + install_app opencatalogi opencatalogi "$$OPENCATALOGI_VERSION" opencatalogi + install_app portaliq portaliq "$$PORTALIQ_VERSION" portaliq + # Repo AND app id both moved to `thematiq`. Verified against the released + # archive's own appinfo/info.xml (thematiq), not assumed from a + # local checkout — the id moves per app and at a different time from the + # repo, and installing under the wrong directory name yields an app + # Nextcloud refuses with "appinfo file cannot be read". + install_app thematiq thematiq "$$THEMATIQ_VERSION" thematiq + install_app integriq integriq "$$INTEGRIQ_VERSION" integriq + install_app filinq filinq "$$FILINQ_VERSION" filinq + install_app launchpad launchpad "$$LAUNCHPAD_VERSION" launchpad + install_app pipelinq pipelinq "$$PIPELINQ_VERSION" pipelinq + install_app shillinq shillinq "$$SHILLINQ_VERSION" shillinq + install_app hermiq hermiq "$$HERMIQ_VERSION" hermiq + install_app zaakafhandelapp zaakafhandelapp "$$ZAAKAFHANDELAPP_VERSION" zaakafhandelapp + + # 33 is www-data inside the Nextcloud image. + chown -R 33:33 /apps + + echo "==> apps present: $$(ls /apps | tr '\n' ' ')" + if [ -n "$$FAILED" ]; then + # A missing leaf app must not abort the whole ecosystem, but it must not + # be silent either: an app that failed to download is indistinguishable + # from one that was never meant to be here once the log scrolls past. + echo "!! NOT INSTALLED:$$FAILED" + echo "!! The rest of the stack still starts. Re-run 'up' to retry them." + fi + + # OpenRegister is not optional — every other app declares registers + # against it, and enabling a leaf without it produces an app with no + # register to attach to. + [ -f /apps/openregister/appinfo/info.xml ] || { echo "!! openregister missing; aborting"; exit 1; } + + enable-apps: + content: | + #!/bin/sh + set -u + # ORDER IS NOT ARBITRARY. OpenRegister owns the registers and schemas every + # other app declares against, and NLDesign supplies the themes the portal + # resolves, so both come before the leaves. A leaf enabled first finds no + # register to attach to. + for app in openregister thematiq opencatalogi portaliq integriq filinq \ + launchpad pipelinq shillinq hermiq zaakafhandelapp; do + if [ ! -f "/var/www/html/custom_apps/$$app/appinfo/info.xml" ]; then + echo "==> $$app not installed, skipping" + continue + fi + echo "==> enabling $$app" + php /var/www/html/occ app:enable "$$app" || echo "!! failed to enable $$app" + done + + echo "==> Connext is up: http://localhost:${CONNEXT_PORT:-8700}" + echo "==> OpenCatalogi demo portal: http://localhost:${CONNEXT_PORT:-8700}/apps/portaliq/site?portal=demo" diff --git a/static/img/brand/avatar-conduction-cobalt-on-white.svg b/static/img/brand/avatar-conduction-cobalt-on-white.svg new file mode 100644 index 000000000..a2867822b --- /dev/null +++ b/static/img/brand/avatar-conduction-cobalt-on-white.svg @@ -0,0 +1,16 @@ + + + Conduction avatar — cobalt gradient on white + + + + + + + + + + + + + diff --git a/static/img/og-conduction.png b/static/img/og-conduction.png new file mode 100644 index 000000000..af5c3ba65 Binary files /dev/null and b/static/img/og-conduction.png differ diff --git a/static/img/partners/notubiz.svg b/static/img/partners/notubiz.svg new file mode 100644 index 000000000..ca3e2df7e --- /dev/null +++ b/static/img/partners/notubiz.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/static/img/team/barry-orange.svg b/static/img/team/barry-orange.svg new file mode 100644 index 000000000..f8e2b6b90 --- /dev/null +++ b/static/img/team/barry-orange.svg @@ -0,0 +1,84 @@ + + diff --git a/static/img/team/barry.svg b/static/img/team/barry.svg new file mode 100644 index 000000000..172bdc820 --- /dev/null +++ b/static/img/team/barry.svg @@ -0,0 +1,105 @@ + + diff --git a/static/img/team/marleen-orange.svg b/static/img/team/marleen-orange.svg new file mode 100644 index 000000000..4e50f61d2 --- /dev/null +++ b/static/img/team/marleen-orange.svg @@ -0,0 +1,71 @@ + + diff --git a/static/img/team/marleen.svg b/static/img/team/marleen.svg new file mode 100644 index 000000000..6732e4c29 --- /dev/null +++ b/static/img/team/marleen.svg @@ -0,0 +1,92 @@ + + diff --git a/static/img/team/remco-orange.svg b/static/img/team/remco-orange.svg new file mode 100644 index 000000000..f2e0e115c --- /dev/null +++ b/static/img/team/remco-orange.svg @@ -0,0 +1,188 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/img/team/remco.svg b/static/img/team/remco.svg new file mode 100644 index 000000000..4ef5b266e --- /dev/null +++ b/static/img/team/remco.svg @@ -0,0 +1,187 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/img/team/remko-orange.svg b/static/img/team/remko-orange.svg new file mode 100644 index 000000000..f961daafd --- /dev/null +++ b/static/img/team/remko-orange.svg @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/img/team/remko.svg b/static/img/team/remko.svg new file mode 100644 index 000000000..f6af291ea --- /dev/null +++ b/static/img/team/remko.svg @@ -0,0 +1,158 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/img/team/robert-orange.svg b/static/img/team/robert-orange.svg new file mode 100644 index 000000000..21a0f0d7b --- /dev/null +++ b/static/img/team/robert-orange.svg @@ -0,0 +1,116 @@ + + diff --git a/static/img/team/robert.svg b/static/img/team/robert.svg new file mode 100644 index 000000000..e485302b0 --- /dev/null +++ b/static/img/team/robert.svg @@ -0,0 +1,137 @@ + + diff --git a/static/img/team/ruben-orange.svg b/static/img/team/ruben-orange.svg new file mode 100644 index 000000000..7b5e7ecb3 --- /dev/null +++ b/static/img/team/ruben-orange.svg @@ -0,0 +1,109 @@ + + diff --git a/static/img/team/ruben.svg b/static/img/team/ruben.svg new file mode 100644 index 000000000..4fb13fdae --- /dev/null +++ b/static/img/team/ruben.svg @@ -0,0 +1,129 @@ + + diff --git a/static/img/team/thijn-orange.svg b/static/img/team/thijn-orange.svg new file mode 100644 index 000000000..f3bd449b0 --- /dev/null +++ b/static/img/team/thijn-orange.svg @@ -0,0 +1,67 @@ + + diff --git a/static/img/team/thijn.svg b/static/img/team/thijn.svg new file mode 100644 index 000000000..1b05d1b1e --- /dev/null +++ b/static/img/team/thijn.svg @@ -0,0 +1,88 @@ + + diff --git a/static/img/team/wilco-orange.svg b/static/img/team/wilco-orange.svg new file mode 100644 index 000000000..f1e38445f --- /dev/null +++ b/static/img/team/wilco-orange.svg @@ -0,0 +1,82 @@ + + diff --git a/static/img/team/wilco.svg b/static/img/team/wilco.svg new file mode 100644 index 000000000..6a2b0d6f9 --- /dev/null +++ b/static/img/team/wilco.svg @@ -0,0 +1,103 @@ + + diff --git a/static/llms.txt b/static/llms.txt new file mode 100644 index 000000000..bcb5f93ac --- /dev/null +++ b/static/llms.txt @@ -0,0 +1,50 @@ +# Conduction + +> Conduction B.V. is a Dutch open-source software company founded in 2019 in Amsterdam. We build EUPL-1.2 apps for the Nextcloud workspace, used by Dutch municipalities, central government and SMBs. ISO 27001:2022 and ISO 9001:2015 certified. + +Conduction ships ~17 apps that plug into Nextcloud, organised around four product families: Data (registers and catalogs), Processes (case management, decisions, CRM), Connectors (integration platform) and Documents. Every app is open source on GitHub at https://github.com/ConductionNL, installable from the Nextcloud app store, and has a named product owner. + +## Company + +- [About Conduction](https://www.conduction.nl/about): who we are, the Digital Socials story, leadership team, named product owners. +- [Apps catalog](https://www.conduction.nl/apps): the full app catalog with status and install counts. +- [Solutions](https://www.conduction.nl/solutions): government use cases (WOO compliance, software catalog, zaakafhandeling, archief, legacy ERP migration, MKB workspace). +- [Partners](https://www.conduction.nl/partners): certified hosting and service partners (Procolix, Acato, Yard and others). +- [ISO certifications](https://www.conduction.nl/iso): ISO 27001:2022 and ISO 9001:2015 scope and audit details. +- [Academy](https://www.conduction.nl/academy): blogs, guides, case studies, webinars, tutorials. + +## Apps + +- [OpenCatalogi](https://www.conduction.nl/apps/opencatalogi): public software catalog. Every app, dataset, API in your organisation, searchable in one place. +- [OpenRegister](https://www.conduction.nl/apps/openregister): schemas, registers, structured data objects. The typed-data backbone for every Conduction app. +- [Integriq](https://www.conduction.nl/apps/integriq): connect Nextcloud to anything. REST, SOAP, GraphQL, file drops, message queues. +- [Filinq](https://www.conduction.nl/apps/filinq): auto-classify, anonymise, and route inbound documents. Drop them in a folder, get them filed. +- [LaunchPad](https://www.conduction.nl/apps/launchpad): personal and team dashboards built from your registers. No separate BI tool, no extra login. +- [ZaakAfhandelApp](https://www.conduction.nl/apps/zaakafhandelapp): citizen-facing case-status portal. ZGW APIs, archief koppelvlakken, audit trail. +- [Pipelinq](https://www.conduction.nl/apps/pipelinq): CRM with quotes, contacts and deal-flow. Built on registers, no separate sales database. +- [Dossiq](https://www.conduction.nl/apps/dossiq): case management for VTH and citizen processes. Workflow engine plus typed registers. +- [Decidiq](https://www.conduction.nl/apps/decidiq): decision-support and board management. Agenda, dossiers, motions, voting, audit. +- [Stackiq](https://www.conduction.nl/apps/stackiq): IT asset management. Software inventory, licenses, contracts, dependencies. +- [Larpinq](https://www.conduction.nl/apps/larpinq): workflow and process orchestration for live-action role-play events. Visual designer, audit-logged. +- [Learniq](https://www.conduction.nl/apps/learniq): learning record and LMS. Courses, enrolments, certificates, compliance training. +- [Thematiq](https://www.conduction.nl/apps/thematiq): drop-in NL Design System theme for Nextcloud, with Conduction component variants on top. +- [Shillinq](https://www.conduction.nl/apps/shillinq): business administration on Nextcloud. Invoices, contracts, procurement on shared registers. +- [Buildiq](https://www.conduction.nl/apps/buildiq): citizen-developer app builder. Compose Nextcloud apps from registers, connectors, workflows. No PHP required. + +## Optional + +- [Install guide](https://www.conduction.nl/install): self-host any Conduction app on your own Nextcloud. +- [Support](https://www.conduction.nl/support): managed-hosting and advisory offerings. +- [ConNext platform](https://www.conduction.nl/connext): the unified workspace marketing surface. +- [Common Ground](https://www.conduction.nl/commonground): the Dutch government Common Ground initiative we ship for. +- [Build an app](https://www.conduction.nl/build): how to ship your own Conduction-compatible app. +- [Privacy](https://www.conduction.nl/privacy) and [Terms](https://www.conduction.nl/terms). + +## Contact + +- Email: info@conduction.nl +- Phone: +31 85 303 6840 +- Address: Lauriergracht 14h, 1016 RR Amsterdam, Netherlands +- KvK: 76741850 +- BTW: NL860784241B01 +- GitHub: https://github.com/ConductionNL diff --git a/static/oas/dcat_register.json b/static/oas/dcat_register.json new file mode 100644 index 000000000..59e3bf0a0 --- /dev/null +++ b/static/oas/dcat_register.json @@ -0,0 +1,78 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "DCAT Register", + "version": "1.0.0", + "description": "DCAT-AP-NL open-data register for OpenRegister. Holds a single Dataset schema aligned to the DCAT-AP-NL standard (https://data.overheid.nl/dcat-ap-nl). A dataset's files become its distributions." + }, + "x-openregister": { + "app": "dcat", + "type": "url" + }, + "components": { + "registers": { + "dcat": { + "slug": "dcat", + "title": "DCAT", + "version": "1.0.0", + "description": "Open-data register. Datasets are published as DCAT-AP-NL and can be harvested by OpenCatalogi.", + "schemas": [ + "dataset" + ] + } + }, + "schemas": { + "dataset": { + "slug": "dataset", + "title": "Dataset", + "$id": "https://openregister.conduction.nl/schemas/dataset.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "version": "1.0.0", + "type": "object", + "description": "A DCAT-AP-NL dataset: a published, findable collection of open data. Files attached to the object become its distributions.", + "required": [ + "title" + ], + "properties": { + "title": { + "type": "string", + "description": "Title of the dataset (dct:title)." + }, + "description": { + "type": "string", + "description": "Free-text description of the dataset (dct:description)." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Keywords describing the dataset (dcat:keyword)." + }, + "category": { + "type": "string", + "description": "Theme/category of the dataset (dcat:theme), e.g. a value from the EU data theme vocabulary." + }, + "license": { + "type": "string", + "description": "License under which the dataset is published (dct:license), e.g. CC0-1.0." + }, + "publicatiedatum": { + "type": "string", + "format": "date-time", + "description": "Publication date (dct:issued). Datasets with a publicatiedatum at or before now are publicly visible." + }, + "landingPage": { + "type": "string", + "format": "uri", + "description": "URL of a human-readable landing page for the dataset (dcat:landingPage)." + }, + "publisher": { + "type": "string", + "description": "Name of the organisation publishing the dataset (dct:publisher)." + } + } + } + } + } +} diff --git a/static/oas/woo_register.json b/static/oas/woo_register.json new file mode 100644 index 000000000..f287340be --- /dev/null +++ b/static/oas/woo_register.json @@ -0,0 +1,697 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Woo Register", + "version": "1.0.1" + }, + "components": { + "registers": { + "woo": { + "slug": "woo", + "title": "Woo", + "version": "1.0.1", + "description": "Woo-register met TOOI-informatiecategorieën", + "schemas": [ + "vergaderstukken_decentrale_overheden", + "subsidieverplichtingen_anders_dan_met_beschikking", + "overige_besluiten_van_algemene_strekking", + "organisatie_en_werkwijze", + "ontwerpen_van_wet_en_regelgeving_met_adviesaanvraag", + "onderzoeksrapporten", + "klachtoordelen", + "wetten_en_algemeen_verbindende_voorschriften", + "woo_verzoeken_en_besluiten", + "vergaderstukken_staten_generaal" + ] + } + }, + "schemas": { + "vergaderstukken_decentrale_overheden": { + "slug": "vergaderstukken_decentrale_overheden", + "title": "Metadata TOOI vergaderstukken decentrale overheden ", + "$id": "https://openwoo.app/schemas/vergaderstukken_decentrale_overheden.schema.json", + "$schema": "https://docs.commongateway.nl/schemas/Entity.schema.json", + "version": "0.0.2", + "type": "object", + "description": "Vergaderstukken en verslagen van diverse decentrale overheden en hun commissies.", + "properties": { + "tooiCategorieNaam": { + "type": "string", + "const": "vergaderstukken decentrale overheden", + "description": "De naam van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)" + }, + "tooiCategorieId": { + "type": "string", + "const": "c_db4862c3", + "description": "De tooiCategorieId van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)" + }, + "tooiCategorieUri": { + "type": "string", + "const": "https://identifier.overheid.nl/tooi/def/thes/kern/c_db4862c3", + "description": "De id van de categorie voorafgegaan door `https://identifier.overheid.nl/tooi/def/thes/kern/[tooiCategorieId]`" + }, + "tooiThemaNaam": { + "type": "string", + "description": "De naam van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)" + }, + "tooiThemaId": { + "type": "string", + "description": "De id van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)" + }, + "tooiThemaUri": { + "type": "string", + "description": "De tooiThemaId van het thema voorafgegaan door `https://identifier.overheid.nl/tooi/def/thes/kern/[tooiThemaId]`" + }, + "values": { + "type": "array", + "description": "Binnen KOOP Woo Index kunnen waardes niet uitlijnen op TOOI wel worden meegegeven in key value pair's. Deze zijn vervolgens wel doorzoekbaar maar geen onderdeel van de open.overheid.nl user interface", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "tooiCategorieNaam" + ] + }, + "subsidieverplichtingen_anders_dan_met_beschikking": { + "slug": "subsidieverplichtingen_anders_dan_met_beschikking", + "title": "Metadata TOOI subsidieverplichtingen anders dan met beschikking", + "$id": "https://openwoo.app/schemas/subsidieverplichtingen_anders_dan_met_beschikking.schema.json", + "$schema": "https://docs.commongateway.nl/schemas/Entity.schema.json", + "version": "0.0.2", + "type": "object", + "description": "Subsidieverplichtingen die niet via een beschikking lopen, specifiek bij Ministerie van Binnenlandse Zaken. ", + "properties": { + "tooiCategorieNaam": { + "type": "string", + "const": "subsidieverplichtingen anders dan met beschikking", + "description": "De naam van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)" + }, + "tooiCategorieId": { + "type": "string", + "const": "c_cf268088", + "description": "De tooiCategorieId van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)" + }, + "tooiCategorieUri": { + "type": "string", + "const": "https://identifier.overheid.nl/tooi/def/thes/kern/c_cf268088", + "description": "De id van de categorie voorafgegaan door `https://identifier.overheid.nl/tooi/def/thes/kern/[tooiCategorieId]`" + }, + "tooiThemaNaam": { + "type": "string", + "description": "De naam van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)" + }, + "tooiThemaId": { + "type": "string", + "description": "De id van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)" + }, + "tooiThemaUri": { + "type": "string", + "description": "De tooiThemaId van het thema voorafgegaan door `https://identifier.overheid.nl/tooi/def/thes/kern/[tooiThemaId]`" + }, + "values": { + "type": "array", + "description": "Binnen KOOP Woo Index kunnen waardes niet uitlijnen op TOOI wel worden meegegeven in key value pair's. Deze zijn vervolgens wel doorzoekbaar maar geen onderdeel van de open.overheid.nl user interface", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "tooiCategorieNaam" + ] + }, + "overige_besluiten_van_algemene_strekking": { + "slug": "overige_besluiten_van_algemene_strekking", + "title": "Metadata TOOI overige besluiten van algemene strekking", + "$id": "https://openwoo.app/schemas/overige_besluiten_van_algemene_strekking.schema.json", + "$schema": "https://docs.commongateway.nl/schemas/Entity.schema.json", + "version": "0.0.2", + "type": "object", + "description": "Overige besluiten van algemene strekking. ", + "properties": { + "tooiCategorieNaam": { + "type": "string", + "const": "overige besluiten van algemene strekking", + "description": "De naam van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)" + }, + "tooiCategorieId": { + "type": "string", + "const": "c_aab6bfc7", + "description": "De tooiCategorieId van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)" + }, + "tooiCategorieUri": { + "type": "string", + "const": "https://identifier.overheid.nl/tooi/def/thes/kern/c_aab6bfc7", + "description": "De id van de categorie voorafgegaan door `https://identifier.overheid.nl/tooi/def/thes/kern/[tooiCategorieId]`" + }, + "tooiThemaNaam": { + "type": "string", + "description": "De naam van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)" + }, + "tooiThemaId": { + "type": "string", + "description": "De id van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)" + }, + "tooiThemaUri": { + "type": "string", + "description": "De tooiThemaId van het thema voorafgegaan door `https://identifier.overheid.nl/tooi/def/thes/kern/[tooiThemaId]`" + }, + "values": { + "type": "array", + "description": "Binnen KOOP Woo Index kunnen waardes niet uitlijnen op TOOI wel worden meegegeven in key value pair's. Deze zijn vervolgens wel doorzoekbaar maar geen onderdeel van de open.overheid.nl user interface", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "tooiCategorieNaam" + ] + }, + "organisatie_en_werkwijze": { + "slug": "organisatie_en_werkwijze", + "title": "Metadata TOOI organisatie en werkwijze", + "$id": "https://openwoo.app/schemas/organisatie_en_werkwijze.schema.json", + "$schema": "https://docs.commongateway.nl/schemas/Entity.schema.json", + "version": "0.0.3", + "type": "object", + "description": "Informatie over de organisatie en werkwijze van bestuursorganen.", + "properties": { + "tooiCategorieNaam": { + "type": "string", + "const": "organisatie en werkwijze", + "required": true, + "description": "De naam van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)" + }, + "tooiCategorieId": { + "type": "string", + "const": "c_40a05794", + "description": "De tooiCategorieId van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)" + }, + "tooiCategorieUri": { + "type": "string", + "const": "https://identifier.overheid.nl/tooi/def/thes/kern/c_40a05794", + "description": "De id van de categorie voorafgegaan door `https://identifier.overheid.nl/tooi/def/thes/kern/[tooiCategorieId]`" + }, + "tooiThemaNaam": { + "type": "string", + "description": "De naam van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)" + }, + "tooiThemaId": { + "type": "string", + "description": "De id van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)" + }, + "tooiThemaUri": { + "type": "string", + "description": "De tooiThemaId van het thema voorafgegaan door `https://identifier.overheid.nl/tooi/def/thes/kern/[tooiThemaId]`" + }, + "values": { + "type": "array", + "description": "Binnen KOOP Woo Index kunnen waardes niet uitlijnen op TOOI wel worden meegegeven in key value pair's. Deze zijn vervolgens wel doorzoekbaar maar geen onderdeel van de open.overheid.nl user interface", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "tooiCategorieNaam" + ] + }, + "ontwerpen_van_wet_en_regelgeving_met_adviesaanvraag": { + "slug": "ontwerpen_van_wet_en_regelgeving_met_adviesaanvraag", + "title": "Metadata TOOI ontwerpen van wet- en regelgeving met adviesaanvraag", + "$id": "https://openwoo.app/schemas/ontwerpen_van_wet_en_regelgeving_met_adviesaanvraag.schema.json", + "$schema": "https://docs.commongateway.nl/schemas/Entity.schema.json", + "version": "0.0.2", + "type": "object", + "description": "Ontwerpen van wetgeving waarover extern advies is gevraagd, inclusief de adviesaanvraag.", + "properties": { + "tooiCategorieNaam": { + "type": "string", + "const": "ontwerpen van wet- en regelgeving met adviesaanvraag", + "description": "De naam van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)" + }, + "tooiCategorieId": { + "type": "string", + "const": "c_759721e2", + "description": "De tooiCategorieId van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)" + }, + "tooiCategorieUri": { + "type": "string", + "const": "https://identifier.overheid.nl/tooi/def/thes/kern/c_759721e2", + "description": "De id van de categorie voorafgegaan door `https://identifier.overheid.nl/tooi/def/thes/kern/[tooiCategorieId]`" + }, + "tooiThemaNaam": { + "type": "string", + "description": "De naam van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)" + }, + "tooiThemaId": { + "type": "string", + "description": "De id van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)" + }, + "tooiThemaUri": { + "type": "string", + "description": "De tooiThemaId van het thema voorafgegaan door `https://identifier.overheid.nl/tooi/def/thes/kern/[tooiThemaId]`" + }, + "values": { + "type": "array", + "description": "Binnen KOOP Woo Index kunnen waardes niet uitlijnen op TOOI wel worden meegegeven in key value pair's. Deze zijn vervolgens wel doorzoekbaar maar geen onderdeel van de open.overheid.nl user interface", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "tooiCategorieNaam" + ] + }, + "onderzoeksrapporten": { + "slug": "onderzoeksrapporten", + "title": "Metadata TOOI onderzoeksrapporten", + "$id": "https://openwoo.app/schemas/onderzoeksrapporten.schema.json", + "$schema": "https://docs.commongateway.nl/schemas/Entity.schema.json", + "version": "0.0.2", + "type": "object", + "description": "Onderzoeksrapporten opgesteld op verzoek van bestuursorganen die geen taakuitvoering zijn, gericht op organisatie of beleidsevaluatie.", + "properties": { + "tooiCategorieNaam": { + "type": "string", + "const": "onderzoeksrapporten", + "description": "De naam van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)" + }, + "tooiCategorieId": { + "type": "string", + "const": "c_fdaee95e", + "description": "De tooiCategorieId van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)" + }, + "tooiCategorieUri": { + "type": "string", + "const": "https://identifier.overheid.nl/tooi/def/thes/kern/c_fdaee95e", + "description": "De id van de categorie voorafgegaan door `https://identifier.overheid.nl/tooi/def/thes/kern/[tooiCategorieId]`" + }, + "tooiThemaNaam": { + "type": "string", + "description": "De naam van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)" + }, + "tooiThemaId": { + "type": "string", + "description": "De id van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)" + }, + "tooiThemaUri": { + "type": "string", + "description": "De tooiThemaId van het thema voorafgegaan door `https://identifier.overheid.nl/tooi/def/thes/kern/[tooiThemaId]`" + }, + "values": { + "type": "array", + "description": "Binnen KOOP Woo Index kunnen waardes niet uitlijnen op TOOI wel worden meegegeven in key value pair's. Deze zijn vervolgens wel doorzoekbaar maar geen onderdeel van de open.overheid.nl user interface", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "tooiCategorieNaam" + ] + }, + "klachtoordelen": { + "slug": "klachtoordelen", + "title": "Metadata TOOI klachtoordelen", + "$id": "https://openwoo.app/schemas/klachtoordelen.schema.json", + "$schema": "https://docs.commongateway.nl/schemas/Entity.schema.json", + "version": "0.0.2", + "type": "object", + "description": "Schriftelijke oordelen in klachtprocedures.", + "properties": { + "tooiCategorieNaam": { + "type": "string", + "const": "klachtoordelen", + "description": "De naam van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)" + }, + "tooiCategorieId": { + "type": "string", + "const": "c_a870c43d", + "description": "De tooiCategorieId van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)" + }, + "tooiCategorieUri": { + "type": "string", + "const": "https://identifier.overheid.nl/tooi/def/thes/kern/c_a870c43d", + "description": "De id van de categorie voorafgegaan door `https://identifier.overheid.nl/tooi/def/thes/kern/[tooiCategorieId]`" + }, + "tooiThemaNaam": { + "type": "string", + "description": "De naam van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)" + }, + "tooiThemaId": { + "type": "string", + "description": "De id van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)" + }, + "tooiThemaUri": { + "type": "string", + "description": "De tooiThemaId van het thema voorafgegaan door `https://identifier.overheid.nl/tooi/def/thes/kern/[tooiThemaId]`" + }, + "datumBesluit": { + "type": "string", + "format": "date-time", + "description": "Wanneer is er over de klacht besloten", + "required": true + }, + "datumOntvangst": { + "type": "string", + "format": "date-time", + "description": "Wanneer is de klacht binnen gekomen", + "required": true + }, + "termijnoverschrijding": { + "type": "string", + "format": "date-time", + "description": "Een (evenuteel) overschrijding van het beantwoordings termijn termijn" + }, + "organisatieonderdeel": { + "type": "string", + "description": "", + "maxLength": 2555 + }, + "functiebenaming": { + "type": "string", + "description": "", + "maxLength": 2555 + }, + "gedraging": { + "type": "string", + "description": "", + "maxLength": 2555 + }, + "bevindingen": { + "type": "string", + "description": "", + "maxLength": 2555 + }, + "oordeel": { + "type": "string", + "description": "", + "maxLength": 2555 + }, + "conclusies": { + "type": "string", + "description": "", + "maxLength": 2555 + }, + "values": { + "type": "array", + "description": "Binnen KOOP Woo Index kunnen waardes niet uitlijnen op TOOI wel worden meegegeven in key value pair's. Deze zijn vervolgens wel doorzoekbaar maar geen onderdeel van de open.overheid.nl user interface", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "tooiCategorieNaam", + "datumBesluit", + "datumOntvangst" + ] + }, + "wetten_en_algemeen_verbindende_voorschriften": { + "slug": "wetten_en_algemeen_verbindende_voorschriften", + "title": "Metadata TOOI wetten en algemeen verbindende voorschriften", + "$id": "https://openwoo.app/schemas/wetten_en_algemeen_verbindende_voorschriften.schema.json", + "$schema": "https://docs.commongateway.nl/schemas/Entity.schema.json", + "version": "0.0.2", + "description": "Wetten en andere algemeen verbindende voorschriften. ", + "properties": { + "tooiCategorieNaam": { + "const": "wetten en algemeen verbindende voorschriften", + "description": "De naam van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)", + "type": "string" + }, + "tooiCategorieId": { + "const": "c_139c6280", + "description": "De tooiCategorieId van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)", + "type": "string" + }, + "tooiCategorieUri": { + "const": "https://identifier.overheid.nl/tooi/def/thes/kern/c_139c6280", + "description": "De id van de categorie voorafgegaan door `https://identifier.overheid.nl/tooi/def/thes/kern/[tooiCategorieId]`", + "type": "string" + }, + "tooiThemaNaam": { + "type": "string", + "description": "De naam van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)" + }, + "tooiThemaId": { + "type": "string", + "description": "De id van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)" + }, + "tooiThemaUri": { + "type": "string", + "description": "De tooiThemaId van het thema voorafgegaan door `https://identifier.overheid.nl/tooi/def/thes/kern/[tooiThemaId]`" + }, + "values": { + "type": "array", + "description": "Binnen KOOP Woo Index kunnen waardes niet uitlijnen op TOOI wel worden meegegeven in key value pair's. Deze zijn vervolgens wel doorzoekbaar maar geen onderdeel van de open.overheid.nl user interface", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "$id": "https://openwoo.app/schemas/wetten_en_algemeen_verbindende_voorschriften.schema.json" + }, + "woo_verzoeken_en_besluiten": { + "slug": "woo_verzoeken_en_besluiten", + "title": "Metadata TOOI Woo-verzoeken en -besluiten", + "$id": "https://openwoo.app/schemas/woo_verzoeken_en_besluiten.schema.json", + "$schema": "https://docs.commongateway.nl/schemas/Entity.schema.json", + "version": "0.0.2", + "type": "object", + "description": "Inhoud van schriftelijke Woo-verzoeken en besluiten daarop, inclusief verstrekte informatie.", + "properties": { + "tooiCategorieNaam": { + "type": "string", + "const": "Woo-verzoeken en -besluiten", + "description": "De naam van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)" + }, + "tooiCategorieId": { + "type": "string", + "const": "c_3baef532", + "description": "De tooiCategorieId van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)" + }, + "tooiCategorieUri": { + "type": "string", + "const": "https://identifier.overheid.nl/tooi/def/thes/kern/c_3baef532", + "description": "De id van de categorie voorafgegaan door `https://identifier.overheid.nl/tooi/def/thes/kern/[tooiCategorieId]`" + }, + "tooiThemaNaam": { + "type": "string", + "description": "De naam van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)" + }, + "tooiThemaId": { + "type": "string", + "description": "De id van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)" + }, + "tooiThemaUri": { + "type": "string", + "description": "De tooiThemaId van het thema voorafgegaan door `https://identifier.overheid.nl/tooi/def/thes/kern/[tooiThemaId]`" + }, + "datumBesluit": { + "type": "string", + "format": "date-time", + "description": "Wanneer is er over het verzoek besloten", + "required": true + }, + "datumOntvangst": { + "type": "string", + "format": "date-time", + "description": "Wanneer is het verzoek binnen gekomen", + "required": true + }, + "termijnoverschrijding": { + "type": "string", + "format": "date-time", + "description": "Een (evenuteel) overschrijding van het beantwoordings termijn termijn" + }, + "values": { + "type": "array", + "description": "Binnen KOOP Woo Index kunnen waardes niet uitlijnen op TOOI wel worden meegegeven in key value pair's. Deze zijn vervolgens wel doorzoekbaar maar geen onderdeel van de open.overheid.nl user interface", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "tooiCategorieNaam", + "datumBesluit", + "datumOntvangst" + ] + }, + "vergaderstukken_staten_generaal": { + "slug": "vergaderstukken_staten_generaal", + "title": "Metadata TOOI vergaderstukken Staten-Generaal", + "$id": "https://openwoo.app/schemas/vergaderstukken_staten_generaal.schema.json", + "$schema": "https://docs.commongateway.nl/schemas/Entity.schema.json", + "version": "0.0.2", + "type": "object", + "description": "Vergaderstukken en verslagen van de Staten-Generaal, uitgezonderd vertrouwelijke informatie verstrekt aan de Staten-Generaal. ", + "properties": { + "tooiCategorieNaam": { + "type": "string", + "const": "Vergaderstukken Staten-Generaal", + "description": "De naam van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)" + }, + "tooiCategorieId": { + "type": "string", + "const": "c_c76862ab", + "description": "De tooiCategorieId van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)" + }, + "tooiCategorieUri": { + "type": "string", + "const": "https://identifier.overheid.nl/tooi/def/thes/kern/c_c76862ab", + "description": "De id van de categorie voorafgegaan door `https://identifier.overheid.nl/tooi/def/thes/kern/[tooiCategorieId]`" + }, + "tooiThemaNaam": { + "type": "string", + "description": "De naam van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)" + }, + "tooiThemaId": { + "type": "string", + "description": "De id van de TOOI categorie conform [diwoo metadata lijsten](https://standaarden.overheid.nl/diwoo/metadata/doc/0.9.1/diwoo-metadata-lijsten_xsd_Simple_Type_diwoo_scw_woo_informatiecategorieen)" + }, + "tooiThemaUri": { + "type": "string", + "description": "De tooiThemaId van het thema voorafgegaan door `https://identifier.overheid.nl/tooi/def/thes/kern/[tooiThemaId]`" + }, + "values": { + "type": "array", + "description": "Binnen KOOP Woo Index kunnen waardes niet uitlijnen op TOOI wel worden meegegeven in key value pair's. Deze zijn vervolgens wel doorzoekbaar maar geen onderdeel van de open.overheid.nl user interface", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "tooiCategorieNaam" + ] + } + } + } +} \ No newline at end of file diff --git a/static/robots.txt b/static/robots.txt new file mode 100644 index 000000000..2c243ce6b --- /dev/null +++ b/static/robots.txt @@ -0,0 +1,85 @@ +# Conduction: robots.txt +# +# Posture: open. We allow both AI-search citation and AI-training crawls. +# Conduction is an open-source company; our content is intended to be read, +# quoted, and learned from. If that ever needs to change, swap "Allow: /" +# for "Disallow: /" on the relevant blocks below. +# +# Bots are listed explicitly even though "User-agent: *" already permits +# them. The explicit list documents intent and makes a future audit a +# one-line diff. Last reviewed: 2026-05-18. + +# --- Default: allow all classic crawlers --- +User-agent: * +Allow: / + +# --- AI search / citation crawlers (real-time + index) --- +User-agent: OAI-SearchBot +Allow: / + +User-agent: ChatGPT-User +Allow: / + +User-agent: Claude-SearchBot +Allow: / + +User-agent: Claude-User +Allow: / + +User-agent: PerplexityBot +Allow: / + +User-agent: Perplexity-User +Allow: / + +User-agent: DuckAssistBot +Allow: / + +User-agent: MistralAI-User +Allow: / + +User-agent: Amazonbot +Allow: / + +User-agent: YouBot +Allow: / + +# --- AI training crawlers --- +User-agent: GPTBot +Allow: / + +User-agent: ClaudeBot +Allow: / + +User-agent: anthropic-ai +Allow: / + +User-agent: Claude-Web +Allow: / + +User-agent: CCBot +Allow: / + +User-agent: cohere-training-data-crawler +Allow: / + +User-agent: cohere-ai +Allow: / + +User-agent: Meta-ExternalAgent +Allow: / + +User-agent: FacebookBot +Allow: / + +User-agent: Applebot-Extended +Allow: / + +User-agent: Google-Extended +Allow: / + +# --- Sitemaps --- +# Per-locale, both listed so crawlers without locale-suffix discovery +# pick up the Nederlandse pages too. +Sitemap: https://www.conduction.nl/sitemap.xml +Sitemap: https://www.conduction.nl/nl/sitemap.xml