From 958ab14504e9ca9bf6e3ca0ac8d2c9d137ef07d9 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 5 Oct 2026 14:06:10 +0200 Subject: [PATCH 01/31] feat(news): staff can put a news item on one portal's public website, read through /api/content/news newsItem 0.4.0 (register 0.59.0) gains public, portal and audienceLabel. PublicNewsReader serves only published items staff put on this portal, never one about single children, and only photos from the portal's own published media library. --- appinfo/routes.php | 3 + lib/Controller/ContentNewsController.php | 211 +++++++++++++ lib/Controller/NewsController.php | 45 ++- lib/Service/PublicNewsReader.php | 276 ++++++++++++++++++ lib/Settings/portaliq_register.json | 26 +- .../Controller/ContentNewsControllerTest.php | 111 +++++++ tests/Unit/Controller/NewsControllerTest.php | 74 +++++ tests/Unit/Service/PublicNewsReaderTest.php | 196 +++++++++++++ .../Settings/PortaliqRegisterConfigTest.php | 12 +- tests/news-item-translation.spec.mjs | 2 +- 10 files changed, 945 insertions(+), 11 deletions(-) create mode 100644 lib/Controller/ContentNewsController.php create mode 100644 lib/Service/PublicNewsReader.php create mode 100644 tests/Unit/Controller/ContentNewsControllerTest.php create mode 100644 tests/Unit/Service/PublicNewsReaderTest.php diff --git a/appinfo/routes.php b/appinfo/routes.php index 44de9de9d..2313beee6 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -117,6 +117,9 @@ // A published media library item of the serving portal // (site-page-seo-history-and-media T07); pages refer to it as media:. ['name' => 'contentMedia#show', 'url' => '/api/content/media/{id}', 'verb' => 'GET'], + // The news staff put on this portal's public website (site-school-blocks). + ['name' => 'contentNews#index', 'url' => '/api/content/news', 'verb' => 'GET'], + ['name' => 'contentNews#show', 'url' => '/api/content/news/{id}', 'verb' => 'GET'], [ 'name' => 'content#page', 'url' => '/api/content/page/{route}', diff --git a/lib/Controller/ContentNewsController.php b/lib/Controller/ContentNewsController.php new file mode 100644 index 000000000..beb177aef --- /dev/null +++ b/lib/Controller/ContentNewsController.php @@ -0,0 +1,211 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @link https://conduction.nl + * + * @spec openspec/changes/site-school-blocks/specs/portaliq-cms/spec.md#requirement-a-news-item-shows-on-a-portals-public-website-only-when-staff-put-it-there + */ + +declare(strict_types=1); + +namespace OCA\Portaliq\Controller; + +use OCA\Portaliq\Service\PortalResolver; +use OCA\Portaliq\Service\PortalSessionService; +use OCA\Portaliq\Service\PublicNewsReader; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AnonRateLimit; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\PublicPage; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; + +/** + * Serves a portal's public news. + * + * @SuppressWarnings(PHPMD.StaticAccess) -- PortalSessionService::trustSatisfies is the single + * trust comparator, called statically everywhere so the ordering cannot fork (as in ContentController). + * + * @spec openspec/changes/site-school-blocks/specs/portaliq-cms/spec.md#requirement-a-news-item-shows-on-a-portals-public-website-only-when-staff-put-it-there + */ +class ContentNewsController extends Controller { + /** + * Constructor. + * + * @param string $appName The app id. + * @param IRequest $request The request. + * @param PortalResolver $resolver Resolves the serving portal. + * @param PortalSessionService $session Resolves the caller's portal session for the content gate. + * @param PublicNewsReader $news Reads the portal's public news. + * + * @return void + */ + public function __construct( + string $appName, + IRequest $request, + private readonly PortalResolver $resolver, + private readonly PortalSessionService $session, + private readonly PublicNewsReader $news, + ) { + parent::__construct(appName: $appName, request: $request); + }//end __construct() + + /** + * The newest public items of the resolved portal. + * + * @param string|null $portal Explicit portal slug, for a consumer not using the host. + * @param int|null $limit How many, 1 to 12; 4 when absent. + * + * @return JSONResponse `{items: []}`, a 401/403 refusal, or 404. + * + * @spec openspec/changes/site-school-blocks/specs/portaliq-cms/spec.md#requirement-a-news-item-shows-on-a-portals-public-website-only-when-staff-put-it-there + */ + #[PublicPage] + #[NoCSRFRequired] + #[AnonRateLimit(limit: 240, period: 60)] + public function index(?string $portal = null, ?int $limit = null): JSONResponse { + $resolved = $this->resolver->resolve(request: $this->request, portalSlug: $portal); + if ($resolved === null) { + return $this->notFound(); + } + + $refusal = $this->refuseUnlessPermitted(portal: $resolved); + if ($refusal !== null) { + return $refusal; + } + + return $this->publicJson( + payload: ['items' => $this->news->listFor(portal: (string)$resolved['slug'], limit: ($limit ?? 4))] + ); + }//end index() + + /** + * One public item of the resolved portal. + * + * @param string $id The item id. + * @param string|null $portal Explicit portal slug, for a consumer not using the host. + * + * @return JSONResponse `{item}`, a 401/403 refusal, or 404 for anything that is not a public item of this portal. + * + * @spec openspec/changes/site-school-blocks/specs/portaliq-cms/spec.md#requirement-a-news-item-shows-on-a-portals-public-website-only-when-staff-put-it-there + */ + #[PublicPage] + #[NoCSRFRequired] + #[AnonRateLimit(limit: 240, period: 60)] + public function show(string $id, ?string $portal = null): JSONResponse { + $resolved = $this->resolver->resolve(request: $this->request, portalSlug: $portal); + if ($resolved === null) { + return $this->notFound(); + } + + $refusal = $this->refuseUnlessPermitted(portal: $resolved); + if ($refusal !== null) { + return $refusal; + } + + $item = $this->news->itemFor(portal: (string)$resolved['slug'], id: $id); + if ($item === null) { + return $this->notFound(); + } + + return $this->publicJson(payload: ['item' => $item]); + }//end show() + + /** + * Refuse a read the portal does not allow this caller: the same rule as + * the rest of the content API (ContentController::refuseUnlessPermitted). + * A portal without modes, or with `public`, is readable by anyone. + * + * @param array $portal The resolved portal. + * + * @return JSONResponse|null A refusal, or null when the read may proceed. + */ + private function refuseUnlessPermitted(array $portal): ?JSONResponse { + $auth = (array)($portal['authentication'] ?? []); + $modes = array_values(array_filter((array)($auth['modes'] ?? []), 'is_string')); + if ($modes === [] || in_array('public', $modes, true) === true) { + return null; + } + + $subject = $this->session->resolveFromBearer($this->request->getHeader('Authorization')); + if ($subject === null) { + return $this->refusal(error: 'authentication_required', status: Http::STATUS_UNAUTHORIZED, modes: $modes); + } + + if (PortalSessionService::trustSatisfies($subject['trust'] ?? null, ($auth['minTrust'] ?? null)) === false) { + return $this->refusal(error: 'insufficient_trust', status: Http::STATUS_FORBIDDEN, modes: $modes); + } + + return null; + }//end refuseUnlessPermitted() + + /** + * A refusal that names the portal's modes, so a renderer can offer the way in. + * + * @param string $error The error code. + * @param int $status The HTTP status. + * @param array $modes The portal's modes. + * + * @return JSONResponse + */ + private function refusal(string $error, int $status, array $modes): JSONResponse { + $response = new JSONResponse(['error' => $error, 'authentication' => ['modes' => $modes]], $status); + $response->addHeader('Cache-Control', 'private, no-store'); + + return $response; + }//end refusal() + + /** + * A content response: cacheable for a visitor without a bearer, private otherwise. + * + * @param array $payload The body. + * + * @return JSONResponse + */ + private function publicJson(array $payload): JSONResponse { + $response = new JSONResponse($payload); + $cache = 'private, no-store'; + if ((string)$this->request->getHeader('Authorization') === '') { + $cache = 'public, max-age=300, must-revalidate'; + } + + $response->addHeader('Cache-Control', $cache); + + return $response; + }//end publicJson() + + /** + * The not-found every miss shares. + * + * @return JSONResponse A 404. + */ + private function notFound(): JSONResponse { + $response = new JSONResponse(['error' => 'not_found'], Http::STATUS_NOT_FOUND); + $response->addHeader('Cache-Control', 'private, no-store'); + + return $response; + }//end notFound() +}//end class diff --git a/lib/Controller/NewsController.php b/lib/Controller/NewsController.php index f8ff70d38..bb0039b27 100644 --- a/lib/Controller/NewsController.php +++ b/lib/Controller/NewsController.php @@ -109,13 +109,17 @@ private function requireAuthenticatedStaff(): string { * @param string $body The body. * @param array $target The target (schoolRef/groupRefs/childRefs). * @param array $photoRefs Attached photo references. + * @param bool $public Whether the item also shows on a portal's public website (site-school-blocks). + * @param string $portal The portal whose website shows it. + * @param string $audienceLabel The words that website shows for who it is for. * * @return JSONResponse The created object, or a 400/500. * * @spec openspec/changes/news-and-newsletter-authoring/specs/portaliq-cms/spec.md#requirement-a-newsitem-is-authored-per-school-group-or-child-and-tracks-read-receipts + * @spec openspec/changes/site-school-blocks/specs/portaliq-cms/spec.md#requirement-a-news-item-shows-on-a-portals-public-website-only-when-staff-put-it-there */ #[NoAdminRequired] - public function create(string $title, string $body, array $target, array $photoRefs = []): JSONResponse { + public function create(string $title, string $body, array $target, array $photoRefs=[], bool $public=false, string $portal='', string $audienceLabel=''): JSONResponse { $authorRef = $this->requireAuthenticatedStaff(); if ($title === '' || $body === '' || $this->hasAnyTarget(target: $target) === false) { @@ -137,7 +141,7 @@ public function create(string $title, string $body, array $target, array $photoR 'status' => 'draft', 'photoRefs' => $photoRefs, 'readReceipts' => [], - ], + ] + $this->website(public: $public, portal: $portal, audienceLabel: $audienceLabel), register: self::REGISTER, schema: self::SCHEMA, _rbac: false, @@ -160,20 +164,30 @@ public function create(string $title, string $body, array $target, array $photoR * @param string $title The title. * @param string $body The body. * @param array $target The target (schoolRef/groupRefs/childRefs). + * @param bool|null $public Whether the item also shows on a portal's public website; null leaves it as it is. + * @param string $portal The portal whose website shows it. + * @param string $audienceLabel The words that website shows for who it is for. * * @return JSONResponse The updated object, a 400 or a 404. * * @spec openspec/changes/staff-news-screen/tasks.md#T1 + * @spec openspec/changes/site-school-blocks/specs/portaliq-cms/spec.md#requirement-a-news-item-shows-on-a-portals-public-website-only-when-staff-put-it-there */ #[NoAdminRequired] - public function update(string $id, string $title, string $body, array $target): JSONResponse { + public function update(string $id, string $title, string $body, array $target, ?bool $public=null, string $portal='', string $audienceLabel=''): JSONResponse { $this->requireAuthenticatedStaff(); if ($title === '' || $body === '' || $this->hasAnyTarget(target: $target) === false) { return new JSONResponse(['error' => 'invalid_target'], Http::STATUS_BAD_REQUEST); } - return $this->write(id: $id, data: ['title' => $title, 'body' => $body, 'target' => $target]); + $data = ['title' => $title, 'body' => $body, 'target' => $target]; + // A screen that does not send `public` leaves the website choice as it is. + if ($public !== null) { + $data += $this->website(public: $public, portal: $portal, audienceLabel: $audienceLabel); + } + + return $this->write(id: $id, data: $data); }//end update() /** @@ -271,6 +285,29 @@ private function write(string $id, array $data): JSONResponse { return new JSONResponse($updated); }//end write() + /** + * Whether the item also shows on a portal's public website, and with which + * words for its audience (site-school-blocks). Turned on only with a portal + * slug to show it on; turned off, the portal and the words are cleared, so + * a later "on" never revives an old choice by accident. + * + * @param bool $public Whether staff put the item on the website. + * @param string $portal The portal slug. + * @param string $audienceLabel The words the website shows for who it is for. + * + * @return array{public: bool, portal: string, audienceLabel: string} + * + * @spec openspec/changes/site-school-blocks/specs/portaliq-cms/spec.md#requirement-a-news-item-shows-on-a-portals-public-website-only-when-staff-put-it-there + */ + private function website(bool $public, string $portal, string $audienceLabel): array { + $portal = trim($portal); + if ($public === false || preg_match('/^[a-z0-9][a-z0-9-]{0,62}$/', $portal) !== 1) { + return ['public' => false, 'portal' => '', 'audienceLabel' => '']; + } + + return ['public' => true, 'portal' => $portal, 'audienceLabel' => mb_substr(trim($audienceLabel), 0, 60)]; + }//end website() + /** * Whether a target names at least one dimension. * diff --git a/lib/Service/PublicNewsReader.php b/lib/Service/PublicNewsReader.php new file mode 100644 index 000000000..e48bc7710 --- /dev/null +++ b/lib/Service/PublicNewsReader.php @@ -0,0 +1,276 @@ +`): a photo reference to anywhere else could carry a child + * whose photo consent was never asked for the website; + * - the target, the author, the read receipts or the translations. + * + * @category Service + * @package OCA\Portaliq\Service + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @link https://conduction.nl + * + * @spec openspec/changes/site-school-blocks/specs/portaliq-cms/spec.md#requirement-a-news-item-shows-on-a-portals-public-website-only-when-staff-put-it-there + */ + +declare(strict_types=1); + +namespace OCA\Portaliq\Service; + +use OCA\Portaliq\Service\Cms\MediaReferences; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Reads the public news of one portal. + * + * @spec openspec/changes/site-school-blocks/specs/portaliq-cms/spec.md#requirement-a-news-item-shows-on-a-portals-public-website-only-when-staff-put-it-there + */ +class PublicNewsReader { + /** + * The most items one list returns. + */ + public const MAX_LIMIT = 12; + + /** + * The longest intro, in characters. + */ + private const INTRO_LENGTH = 280; + + /** + * The OpenRegister read of news rows. + * + * @var NewsRowSource + */ + private readonly NewsRowSource $rows; + + /** + * Constructor. + * + * @param ContainerInterface $container For resolving OpenRegister services. + * @param MediaReferences $media Resolves `media:` against the portal's published library. + * @param LoggerInterface $logger The logger. + */ + public function __construct( + ContainerInterface $container, + private readonly MediaReferences $media, + LoggerInterface $logger, + ) { + $this->rows = new NewsRowSource(container: $container, logger: $logger); + }//end __construct() + + /** + * The newest public items of a portal, newest first. + * + * @param string $portal The portal slug. + * @param int $limit How many, clamped to 1..MAX_LIMIT. + * + * @return array> Each `{id, title, intro, publishedAt, audienceLabel, image}`. + * + * @spec openspec/changes/site-school-blocks/specs/portaliq-cms/spec.md#requirement-a-news-item-shows-on-a-portals-public-website-only-when-staff-put-it-there + */ + public function listFor(string $portal, int $limit): array { + $limit = max(1, min(self::MAX_LIMIT, $limit)); + $items = array_slice($this->publicRows(portal: $portal), 0, $limit); + + return array_map(fn (array $row): array => $this->summary(portal: $portal, row: $row), $items); + }//end listFor() + + /** + * One public item of a portal, with its body, or null when there is no + * such public item. + * + * @param string $portal The portal slug. + * @param string $id The item id. + * + * @return array|null The summary plus `body` (markdown), or null. + * + * @spec openspec/changes/site-school-blocks/specs/portaliq-cms/spec.md#requirement-a-news-item-shows-on-a-portals-public-website-only-when-staff-put-it-there + */ + public function itemFor(string $portal, string $id): ?array { + if ($id === '') { + return null; + } + + foreach ($this->publicRows(portal: $portal) as $row) { + if ($this->rows->rowId(row: $row) === $id) { + $body = $this->media->markdown(portal: $portal, markdown: (string)($row['body'] ?? '')); + + return $this->summary(portal: $portal, row: $row) + ['body' => $body]; + } + } + + return null; + }//end itemFor() + + /** + * Whether a stored row may show on a portal's website. + * + * @param array $row The stored row. + * @param string $portal The portal slug. + * + * @return bool + * + * @spec openspec/changes/site-school-blocks/specs/portaliq-cms/spec.md#requirement-a-news-item-shows-on-a-portals-public-website-only-when-staff-put-it-there + */ + public static function isPublicOn(array $row, string $portal): bool { + if ($portal === '' || ($row['status'] ?? '') !== 'published' || ($row['public'] ?? false) !== true) { + return false; + } + + if ((string)($row['portal'] ?? '') !== $portal) { + return false; + } + + $children = ($row['target']['childRefs'] ?? []); + + return is_array($children) === false || count($children) === 0; + }//end isPublicOn() + + /** + * The public rows of a portal, newest first. + * + * @param string $portal The portal slug. + * + * @return array> + */ + private function publicRows(string $portal): array { + $rows = array_values( + array_filter( + $this->rows->findAll(schema: 'newsItem'), + static fn (array $row): bool => self::isPublicOn(row: $row, portal: $portal) + ) + ); + usort($rows, fn (array $left, array $right): int => $this->momentOf(row: $right) <=> $this->momentOf(row: $left)); + + return $rows; + }//end publicRows() + + /** + * What a visitor may see of one row. + * + * @param string $portal The portal slug. + * @param array $row The stored row. + * + * @return array + */ + private function summary(string $portal, array $row): array { + $moment = $this->momentOf(row: $row); + + return [ + 'id' => $this->rows->rowId(row: $row), + 'title' => (string)($row['title'] ?? ''), + 'intro' => self::introOf(body: (string)($row['body'] ?? '')), + 'publishedAt' => ($moment === PHP_INT_MIN) ? '' : gmdate('c', $moment), + 'audienceLabel' => trim((string)($row['audienceLabel'] ?? '')), + 'image' => $this->imageOf(portal: $portal, row: $row), + ]; + }//end summary() + + /** + * The first photo that is a published item of this portal's library. + * + * @param string $portal The portal slug. + * @param array $row The stored row. + * + * @return array{url: string, alt: string}|null + */ + private function imageOf(string $portal, array $row): ?array { + foreach ((array)($row['photoRefs'] ?? []) as $ref) { + if (MediaReferences::isReference(value: $ref) === false) { + continue; + } + + $image = $this->media->hero(portal: $portal, value: $ref); + if ($image !== null) { + return $image; + } + } + + return null; + }//end imageOf() + + /** + * The first paragraph of a markdown body as plain text, cut on a word. + * + * @param string $body The markdown body. + * + * @return string + * + * @spec openspec/changes/site-school-blocks/specs/portaliq-cms/spec.md#requirement-a-news-item-shows-on-a-portals-public-website-only-when-staff-put-it-there + */ + public static function introOf(string $body): string { + $paragraphs = preg_split('/\R\s*\R/', trim($body)); + $first = (string)($paragraphs[0] ?? ''); + // Links keep their text, images go, and the marks an author types go. + $first = (string)preg_replace('/!\[[^\]]*\]\([^)]*\)/', '', $first); + $first = (string)preg_replace('/\[([^\]]*)\]\([^)]*\)/', '$1', $first); + $first = (string)preg_replace('/^#+\s*/m', '', $first); + $first = str_replace(['**', '__', '`'], '', $first); + $first = trim((string)preg_replace('/\s+/', ' ', $first)); + + if (mb_strlen($first) <= self::INTRO_LENGTH) { + return $first; + } + + $cut = mb_substr($first, 0, self::INTRO_LENGTH); + $space = mb_strrpos($cut, ' '); + if ($space !== false) { + $cut = mb_substr($cut, 0, $space); + } + + return rtrim($cut, ' ,.;:').'…'; + }//end introOf() + + /** + * When an item was published, as a Unix timestamp; PHP_INT_MIN when undated. + * The same order as the guardian feed: `publishedAt`, else OpenRegister's + * `@self.published`, else `@self.created`. + * + * @param array $row The row. + * + * @return int + */ + private function momentOf(array $row): int { + $self = []; + if (is_array($row['@self'] ?? null) === true) { + $self = $row['@self']; + } + + foreach ([($row['publishedAt'] ?? null), ($self['published'] ?? null), ($self['created'] ?? null)] as $value) { + if (is_string($value) === false || $value === '') { + continue; + } + + $time = strtotime($value); + if ($time !== false) { + return $time; + } + } + + return PHP_INT_MIN; + }//end momentOf() +}//end class diff --git a/lib/Settings/portaliq_register.json b/lib/Settings/portaliq_register.json index e91e8492b..241c72e1f 100644 --- a/lib/Settings/portaliq_register.json +++ b/lib/Settings/portaliq_register.json @@ -3,7 +3,7 @@ "info": { "title": "Portaliq Register", "description": "Portaliq — the fleet's shared external portal for clients and suppliers (ADR-046). Holds every portal account, page, session, submission, message, notification, audit entry, traffic record, form and landing-page submission the app reads and writes.", - "version": "0.58.0" + "version": "0.59.0" }, "x-openregister": { "type": "application", @@ -20,7 +20,7 @@ "slug": "portaliq", "title": "Portaliq Register", "description": "Portaliq — the fleet's shared external portal for clients and suppliers (ADR-046). Holds every portal account, page, session, submission, message, notification, audit entry and traffic record the app reads and writes.", - "version": "0.58.0", + "version": "0.59.0", "schemas": [ "exampleDocument", "glossaryTerm", @@ -7424,7 +7424,7 @@ "newsItem": { "slug": "newsItem", "icon": "FileDocument", - "version": "0.3.0", + "version": "0.4.0", "title": "News item", "authorization": { "read": [ @@ -7514,6 +7514,26 @@ "description": "Server-managed. When staff last published the item, in ISO 8601; empty while it is a draft. The news feed sorts on it, newest first. Set by NewsController::publish, cleared by taking the item back; never client-writable.", "example": "2026-10-03T09:00:00+00:00" }, + "public": { + "title": "On the website", + "type": "boolean", + "default": false, + "description": "Whether the item also shows on the public website of `portal`, for visitors who are not signed in. Off by default: a news item is for its audience only until staff turn this on. An item that targets single children never shows publicly, whatever this says (PublicNewsReader).", + "example": false + }, + "portal": { + "title": "Website", + "type": "string", + "description": "The slug of the portal whose public website shows this item when `public` is on. Empty means no website shows it.", + "example": "wilgenboom" + }, + "audienceLabel": { + "title": "Who it is for, on the website", + "type": "string", + "maxLength": 60, + "description": "The words the public website shows beside the date to say who the item is for, such as 'groep 7' or 'hele school'. Written by staff, because the site never reads the school app's groups for a visitor who is not signed in. Empty shows nothing.", + "example": "hele school" + }, "photoRefs": { "title": "Photos", "type": "array", diff --git a/tests/Unit/Controller/ContentNewsControllerTest.php b/tests/Unit/Controller/ContentNewsControllerTest.php new file mode 100644 index 000000000..4f0dc11ff --- /dev/null +++ b/tests/Unit/Controller/ContentNewsControllerTest.php @@ -0,0 +1,111 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/site-school-blocks/specs/portaliq-cms/spec.md#requirement-a-news-item-shows-on-a-portals-public-website-only-when-staff-put-it-there + */ + +declare(strict_types=1); + +namespace OCA\Portaliq\Tests\Unit\Controller; + +use OCA\Portaliq\Controller\ContentNewsController; +use OCA\Portaliq\Service\PortalResolver; +use OCA\Portaliq\Service\PortalSessionService; +use OCA\Portaliq\Service\PublicNewsReader; +use OCP\IRequest; +use PHPUnit\Framework\TestCase; + +/** + * @spec openspec/changes/site-school-blocks/specs/portaliq-cms/spec.md#requirement-a-news-item-shows-on-a-portals-public-website-only-when-staff-put-it-there + */ +class ContentNewsControllerTest extends TestCase { + + /** + * A controller for a portal with these modes and this bearer. + * + * @param array|null $modes The portal's modes; null for no portal. + * @param string $bearer The Authorization header. + * @param array|null $subject What the bearer resolves to. + * @param PublicNewsReader|null $news The reader. + * + * @return ContentNewsController + */ + private function controller(?array $modes, string $bearer = '', ?array $subject = null, ?PublicNewsReader $news = null): ContentNewsController { + $request = $this->createMock(IRequest::class); + $request->method('getHeader')->willReturnCallback(static fn (string $name): string => ($name === 'Authorization') ? $bearer : ''); + + $resolver = $this->createMock(PortalResolver::class); + $resolver->method('resolve')->willReturn( + ($modes === null) ? null : ['slug' => 'wilgenboom', 'authentication' => ['modes' => $modes]] + ); + + $session = $this->createMock(PortalSessionService::class); + $session->method('resolveFromBearer')->willReturn($subject); + + if ($news === null) { + $news = $this->createMock(PublicNewsReader::class); + $news->method('listFor')->willReturn([['id' => 'n1', 'title' => 'Nieuws']]); + $news->method('itemFor')->willReturnCallback( + static fn (string $portal, string $id): ?array => ($id === 'n1') ? ['id' => 'n1', 'body' => 'Tekst'] : null + ); + } + + return new ContentNewsController('portaliq', $request, $resolver, $session, $news); + }//end controller() + + /** + * The Cache-Control header the controller set. Read from the response's own + * headers, because `getHeaders()` merges in defaults through the server + * container, which a unit test has none of. + * + * @param \OCP\AppFramework\Http\Response $response The response. + * + * @return string + */ + private function cacheControl(\OCP\AppFramework\Http\Response $response): string { + $headers = \Closure::bind(fn (): ?array => $this->headers, $response, \OCP\AppFramework\Http\Response::class)(); + + return (string)($headers['Cache-Control'] ?? ''); + }//end cacheControl() + + public function testAPublicPortalServesItsNewsCacheably(): void { + $response = $this->controller(['public', 'digid'])->index(); + + $this->assertSame(200, $response->getStatus()); + $this->assertSame([['id' => 'n1', 'title' => 'Nieuws']], $response->getData()['items']); + $this->assertSame('public, max-age=300, must-revalidate', $this->cacheControl($response)); + }//end testAPublicPortalServesItsNewsCacheably() + + public function testTheLimitReachesTheReader(): void { + $news = $this->createMock(PublicNewsReader::class); + $news->expects($this->once())->method('listFor')->with('wilgenboom', 3)->willReturn([]); + + $this->controller([], '', null, $news)->index(null, 3); + }//end testTheLimitReachesTheReader() + + public function testASignInOnlyPortalRefusesAVisitorWithoutASession(): void { + $response = $this->controller(['digid'])->index(); + + $this->assertSame(401, $response->getStatus()); + $this->assertSame(['digid'], $response->getData()['authentication']['modes']); + $this->assertSame('private, no-store', $this->cacheControl($response)); + }//end testASignInOnlyPortalRefusesAVisitorWithoutASession() + + public function testASignedInVisitorGetsAPrivateAnswer(): void { + $response = $this->controller(['digid'], 'Bearer x', ['trust' => 'substantial'])->show('n1'); + + $this->assertSame(200, $response->getStatus()); + $this->assertSame('private, no-store', $this->cacheControl($response)); + }//end testASignedInVisitorGetsAPrivateAnswer() + + public function testAnUnknownPortalOrItemIsTheShared404(): void { + $this->assertSame(404, $this->controller(null)->index()->getStatus()); + $this->assertSame(404, $this->controller([])->show('missing')->getStatus()); + }//end testAnUnknownPortalOrItemIsTheShared404() +}//end class diff --git a/tests/Unit/Controller/NewsControllerTest.php b/tests/Unit/Controller/NewsControllerTest.php index 2bccbc906..811eb73b5 100644 --- a/tests/Unit/Controller/NewsControllerTest.php +++ b/tests/Unit/Controller/NewsControllerTest.php @@ -281,6 +281,80 @@ public function saveObject(array $object, mixed $register = null, mixed $schema $this->assertSame('po-leerkracht-09', $objectService->saved['authorRef']); }//end testUpdateChangesTheTextAndAudienceOnly() + /** + * Staff may put an item on one portal's public website; without a valid + * portal slug the item stays off it (site-school-blocks). + * + * @spec openspec/changes/site-school-blocks/specs/portaliq-cms/spec.md#requirement-a-news-item-shows-on-a-portals-public-website-only-when-staff-put-it-there + */ + public function testCreatePutsAnItemOnOnePortalsWebsiteOnlyWithAPortal(): void { + $objectService = new class { + /** + * @var array + */ + public array $saved = []; + + /** + * @param array $object + */ + public function saveObject(array $object, mixed $register = null, mixed $schema = null, ?string $uuid = null, bool $_rbac = true, bool $_multitenancy = true): array { + $this->saved = $object; + return $object; + }//end saveObject() + }; + + $controller = new NewsController($this->createMock(IRequest::class), $this->authenticatedUserSession(), $this->container($objectService), $this->createMock(LoggerInterface::class)); + + $controller->create('T', 'B', ['schoolRef' => 's'], [], true, 'wilgenboom', ' hele school '); + $this->assertTrue($objectService->saved['public']); + $this->assertSame('wilgenboom', $objectService->saved['portal']); + $this->assertSame('hele school', $objectService->saved['audienceLabel']); + + $controller->create('T', 'B', ['schoolRef' => 's'], [], true, '../other'); + $this->assertFalse($objectService->saved['public']); + $this->assertSame('', $objectService->saved['portal']); + + $controller->create('T', 'B', ['schoolRef' => 's']); + $this->assertFalse($objectService->saved['public'], 'an item is not public unless staff say so'); + }//end testCreatePutsAnItemOnOnePortalsWebsiteOnlyWithAPortal() + + /** + * A screen that does not send the website choice leaves it as it was, so + * editing the text never takes an item off the website by accident. + * + * @spec openspec/changes/site-school-blocks/specs/portaliq-cms/spec.md#requirement-a-news-item-shows-on-a-portals-public-website-only-when-staff-put-it-there + */ + public function testUpdateWithoutTheWebsiteChoiceKeepsIt(): void { + $objectService = new class { + /** + * @var array + */ + public array $saved = []; + + public function find(string $id, mixed $register = null, mixed $schema = null, bool $_rbac = true, bool $_multitenancy = true): array { + return ['id' => $id, 'title' => 'Old', 'body' => 'B', 'target' => ['schoolRef' => 's'], 'status' => 'published', 'public' => true, 'portal' => 'wilgenboom']; + }//end find() + + /** + * @param array $object + */ + public function saveObject(array $object, mixed $register = null, mixed $schema = null, ?string $uuid = null, bool $_rbac = true, bool $_multitenancy = true): array { + $this->saved = $object; + return $object; + }//end saveObject() + }; + + $controller = new NewsController($this->createMock(IRequest::class), $this->authenticatedUserSession(), $this->container($objectService), $this->createMock(LoggerInterface::class)); + + $controller->update('n1', 'New', 'B', ['schoolRef' => 's']); + $this->assertTrue($objectService->saved['public']); + $this->assertSame('wilgenboom', $objectService->saved['portal']); + + $controller->update('n1', 'New', 'B', ['schoolRef' => 's'], false); + $this->assertFalse($objectService->saved['public']); + $this->assertSame('', $objectService->saved['portal']); + }//end testUpdateWithoutTheWebsiteChoiceKeepsIt() + /** * Editing and the audience choices need a signed-in Nextcloud user, like * every other authoring endpoint. diff --git a/tests/Unit/Service/PublicNewsReaderTest.php b/tests/Unit/Service/PublicNewsReaderTest.php new file mode 100644 index 000000000..9f6c363de --- /dev/null +++ b/tests/Unit/Service/PublicNewsReaderTest.php @@ -0,0 +1,196 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/site-school-blocks/specs/portaliq-cms/spec.md#requirement-a-news-item-shows-on-a-portals-public-website-only-when-staff-put-it-there + */ + +declare(strict_types=1); + +namespace OCA\Portaliq\Tests\Unit\Service; + +use OCA\Portaliq\Service\Cms\MediaLibraryReader; +use OCA\Portaliq\Service\Cms\MediaReferences; +use OCA\Portaliq\Service\PublicNewsReader; +use OCP\IURLGenerator; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; +use RuntimeException; + +/** + * @spec openspec/changes/site-school-blocks/specs/portaliq-cms/spec.md#requirement-a-news-item-shows-on-a-portals-public-website-only-when-staff-put-it-there + */ +class PublicNewsReaderTest extends TestCase { + + private const OS = 'OCA\\OpenRegister\\Service\\ObjectService'; + + /** + * A reader over these stored rows, with one published library item `m1` + * on portal `wilgenboom`. + * + * @param array> $rows The newsItem rows. + * + * @return PublicNewsReader + */ + private function reader(array $rows): PublicNewsReader { + $objectService = new class($rows) { + /** + * @param array> $rows The rows. + */ + public function __construct(private array $rows) { + }//end __construct() + + public function setRegister(string $register): self { + return $this; + }//end setRegister() + + public function setSchema(string $schema): self { + return $this; + }//end setSchema() + + /** + * @param array $config The query. + * + * @return array> + */ + public function findAll(array $config, bool $_rbac = true, bool $_multitenancy = true): array { + return $this->rows; + }//end findAll() + }; + + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturnCallback( + static function (string $id) use ($objectService) { + if ($id === self::OS) { + return $objectService; + } + + throw new RuntimeException('no service: '.$id); + } + ); + + $library = $this->createMock(MediaLibraryReader::class); + $library->method('item')->willReturnCallback( + static fn (string $portal, string $id): ?array => ($portal === 'wilgenboom' && $id === 'm1') ? ['id' => 'm1', 'alt' => 'Kinderen lezen voor'] : null + ); + $urls = $this->createMock(IURLGenerator::class); + $urls->method('linkToRouteAbsolute')->willReturnCallback( + static fn (string $route, array $args): string => 'https://school.example/media/'.$args['id'].'?portal='.$args['portal'] + ); + + return new PublicNewsReader($container, new MediaReferences($urls, $library), $this->createMock(LoggerInterface::class)); + }//end reader() + + /** + * A published public item of portal `wilgenboom`, with overrides. + * + * @param array $overrides The changes. + * + * @return array + */ + private function row(array $overrides = []): array { + return $overrides + [ + 'id' => 'n1', + 'title' => 'De Kinderboekenweek is begonnen', + 'body' => "Groep 8 las voor aan de kleuters.\n\nTweede alinea.", + 'status' => 'published', + 'public' => true, + 'portal' => 'wilgenboom', + 'audienceLabel' => 'hele school', + 'target' => ['schoolRef' => 'school-1'], + 'publishedAt' => '2026-10-02T09:00:00+00:00', + 'authorRef' => 'staff-1', + 'readReceipts' => [['subjectRef' => 'guardian-1']], + ]; + }//end row() + + public function testOnlyPublicPublishedItemsOfThisPortalAreListed(): void { + $reader = $this->reader([ + $this->row(), + $this->row(['id' => 'draft', 'status' => 'draft']), + $this->row(['id' => 'private', 'public' => false]), + $this->row(['id' => 'other', 'portal' => 'vaartveld']), + $this->row(['id' => 'string-true', 'public' => 'true']), + ]); + $items = $reader->listFor('wilgenboom', 12); + + $ids = array_column($items, 'id'); + $this->assertContains('n1', $ids); + $this->assertNotContains('draft', $ids); + $this->assertNotContains('private', $ids); + $this->assertNotContains('other', $ids); + $this->assertNotContains('string-true', $ids, 'only a real boolean true puts an item on the website'); + }//end testOnlyPublicPublishedItemsOfThisPortalAreListed() + + public function testAnItemFromBeforeTheFlagIsNotPublic(): void { + $old = $this->row(); + unset($old['public'], $old['portal']); + + $this->assertSame([], $this->reader([$old])->listFor('wilgenboom', 12)); + $this->assertFalse(PublicNewsReader::isPublicOn($old, 'wilgenboom')); + }//end testAnItemFromBeforeTheFlagIsNotPublic() + + public function testAnItemAboutSingleChildrenNeverShowsPublicly(): void { + $reader = $this->reader([$this->row(['target' => ['groupRefs' => ['g7'], 'childRefs' => ['child-1']]])]); + + $this->assertSame([], $reader->listFor('wilgenboom', 12)); + $this->assertNull($reader->itemFor('wilgenboom', 'n1')); + }//end testAnItemAboutSingleChildrenNeverShowsPublicly() + + public function testAVisitorGetsNoTargetAuthorOrReceipts(): void { + $item = $this->reader([$this->row()])->listFor('wilgenboom', 4)[0]; + + $this->assertSame(['id', 'title', 'intro', 'publishedAt', 'audienceLabel', 'image'], array_keys($item)); + $this->assertSame('Groep 8 las voor aan de kleuters.', $item['intro']); + $this->assertSame('hele school', $item['audienceLabel']); + }//end testAVisitorGetsNoTargetAuthorOrReceipts() + + public function testOnlyAPhotoFromThisPortalsPublishedLibraryIsShown(): void { + $outside = $this->reader([$this->row(['photoRefs' => ['https://elsewhere.example/child.jpg', 'media:unknown']])]); + $this->assertNull($outside->listFor('wilgenboom', 1)[0]['image']); + + $own = $this->reader([$this->row(['photoRefs' => ['https://elsewhere.example/child.jpg', 'media:m1']])]); + $this->assertSame( + ['url' => 'https://school.example/media/m1?portal=wilgenboom', 'alt' => 'Kinderen lezen voor'], + $own->listFor('wilgenboom', 1)[0]['image'] + ); + }//end testOnlyAPhotoFromThisPortalsPublishedLibraryIsShown() + + public function testNewestFirstAndTheLimitIsClamped(): void { + $rows = []; + for ($day = 1; $day <= 15; $day++) { + $rows[] = $this->row(['id' => 'n'.$day, 'publishedAt' => sprintf('2026-09-%02dT09:00:00+00:00', $day)]); + } + + $reader = $this->reader($rows); + $this->assertSame(['n15', 'n14'], array_column($reader->listFor('wilgenboom', 2), 'id')); + $this->assertCount(PublicNewsReader::MAX_LIMIT, $reader->listFor('wilgenboom', 99)); + $this->assertCount(1, $reader->listFor('wilgenboom', 0)); + }//end testNewestFirstAndTheLimitIsClamped() + + public function testOneItemCarriesItsBodyAndAnotherPortalGetsNothing(): void { + $reader = $this->reader([$this->row()]); + + $item = $reader->itemFor('wilgenboom', 'n1'); + $this->assertNotNull($item); + $this->assertStringContainsString('Tweede alinea.', $item['body']); + $this->assertNull($reader->itemFor('vaartveld', 'n1')); + $this->assertNull($reader->itemFor('wilgenboom', '')); + }//end testOneItemCarriesItsBodyAndAnotherPortalGetsNothing() + + public function testTheIntroIsPlainTextCutOnAWord(): void { + $this->assertSame('Lees de link en dit', PublicNewsReader::introOf("## Lees **de** [link](https://x.example) en dit\n\nmeer")); + + $long = str_repeat('woord ', 80); + $intro = PublicNewsReader::introOf($long); + $this->assertLessThanOrEqual(281, mb_strlen($intro)); + $this->assertStringEndsWith('woord…', $intro); + }//end testTheIntroIsPlainTextCutOnAWord() +}//end class diff --git a/tests/Unit/Settings/PortaliqRegisterConfigTest.php b/tests/Unit/Settings/PortaliqRegisterConfigTest.php index a183638e3..995740f10 100644 --- a/tests/Unit/Settings/PortaliqRegisterConfigTest.php +++ b/tests/Unit/Settings/PortaliqRegisterConfigTest.php @@ -326,9 +326,15 @@ public function testRegisterJsonParsesAndVersionsAreBumped(): void { // 0.58.0 (newsItem 0.3.0): `publishedAt`, the moment staff published // the item, stamped by NewsController::publish and back-filled by the // repair step BackfillNewsPublishedAt (news-publish-date). Additive. - $this->assertSame('0.58.0', self::$register['info']['version']); - $this->assertSame('0.58.0', self::$register['components']['registers']['portaliq']['version']); - $this->assertSame('0.3.0', self::$register['components']['schemas']['newsItem']['version']); + // 0.59.0 (newsItem 0.4.0): `public` and `portal`, so staff can put an + // item on one portal's public website (site-school-blocks). Additive; + // an item from before is not public. + $this->assertSame('0.59.0', self::$register['info']['version']); + $this->assertSame('0.59.0', self::$register['components']['registers']['portaliq']['version']); + $this->assertSame('0.4.0', self::$register['components']['schemas']['newsItem']['version']); + $this->assertSame('boolean', self::$register['components']['schemas']['newsItem']['properties']['public']['type']); + $this->assertFalse(self::$register['components']['schemas']['newsItem']['properties']['public']['default']); + $this->assertSame('string', self::$register['components']['schemas']['newsItem']['properties']['portal']['type']); $this->assertSame('date-time', self::$register['components']['schemas']['newsItem']['properties']['publishedAt']['format']); $this->assertSame('0.14.0', self::$register['components']['schemas']['portalAccount']['version']); $this->assertSame('date-time', self::$register['components']['schemas']['portalAccount']['properties']['activationExpiresAt']['format']); diff --git a/tests/news-item-translation.spec.mjs b/tests/news-item-translation.spec.mjs index 789d2585d..bba596dcf 100644 --- a/tests/news-item-translation.spec.mjs +++ b/tests/news-item-translation.spec.mjs @@ -139,7 +139,7 @@ test('newsItem declares translations and the register moved', () => { // turned `validate` and `check:specs` red on development itself and so on // every open pull request. assert.ok( - ['0.2.0', '0.2.1', '0.3.0'].includes(newsItem.version), + ['0.2.0', '0.2.1', '0.3.0', '0.4.0'].includes(newsItem.version), `newsItem ${newsItem.version} is not a version this test knows; widen the list` + ' in the same change that moves the schema', ) From 423627ce18af9f68c1e1880ecef411241bfa6f41 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 5 Oct 2026 15:44:41 +0200 Subject: [PATCH 02/31] feat(site): task tiles, public news, a dated list and the sign-in card for the school home pages nlQuickTasks, nlNewsList, nlNewsArticle and nlEventList load on demand. nlSignIn gains a card display and is handed the portal's own ways in; nlList draws numbered steps. OpenSpec change site-school-blocks. --- .../changes/site-school-blocks/proposal.md | 35 ++ .../specs/portaliq-cms/spec.md | 106 +++++ openspec/changes/site-school-blocks/tasks.md | 19 + package.json | 3 +- src/site/components/WidgetGrid.vue | 35 ++ src/site/components/mijn/DateTile.vue | 90 ++++ src/site/components/mijn/dates.js | 117 ++++++ src/site/components/mijn/links.js | 45 ++ src/site/lib/publicNews.js | 70 ++++ src/site/widgets/coverage.js | 32 ++ src/site/widgets/index.js | 8 + src/site/widgets/loaders.js | 4 + src/site/widgets/nlEventList/NlEventList.vue | 280 +++++++++++++ src/site/widgets/nlEventList/events.js | 47 +++ src/site/widgets/nlEventList/meta.js | 44 ++ src/site/widgets/nlList/NlList.vue | 100 ++++- src/site/widgets/nlList/lines.js | 25 ++ src/site/widgets/nlList/meta.js | 1 + .../widgets/nlNewsArticle/NlNewsArticle.vue | 244 +++++++++++ src/site/widgets/nlNewsArticle/article.js | 27 ++ src/site/widgets/nlNewsArticle/meta.js | 26 ++ src/site/widgets/nlNewsList/NlNewsList.vue | 386 ++++++++++++++++++ src/site/widgets/nlNewsList/meta.js | 47 +++ src/site/widgets/nlNewsList/strings.js | 22 + .../widgets/nlQuickTasks/NlQuickTasks.vue | 297 ++++++++++++++ src/site/widgets/nlQuickTasks/icons.js | 33 ++ src/site/widgets/nlQuickTasks/meta.js | 29 ++ src/site/widgets/nlSignIn/NlSignIn.vue | 254 ++++++++++-- src/site/widgets/nlSignIn/meta.js | 13 +- src/site/widgets/nlSignIn/signIn.js | 84 ++++ src/site/widgets/nlSignIn/strings.js | 19 + tests/site-school-blocks.spec.mjs | 236 +++++++++++ tests/widget-registry.spec.mjs | 32 +- tests/widget-tokens.spec.mjs | 9 + 34 files changed, 2773 insertions(+), 46 deletions(-) create mode 100644 openspec/changes/site-school-blocks/proposal.md create mode 100644 openspec/changes/site-school-blocks/specs/portaliq-cms/spec.md create mode 100644 openspec/changes/site-school-blocks/tasks.md create mode 100644 src/site/components/mijn/DateTile.vue create mode 100644 src/site/components/mijn/dates.js create mode 100644 src/site/components/mijn/links.js create mode 100644 src/site/lib/publicNews.js create mode 100644 src/site/widgets/nlEventList/NlEventList.vue create mode 100644 src/site/widgets/nlEventList/events.js create mode 100644 src/site/widgets/nlEventList/meta.js create mode 100644 src/site/widgets/nlList/lines.js create mode 100644 src/site/widgets/nlNewsArticle/NlNewsArticle.vue create mode 100644 src/site/widgets/nlNewsArticle/article.js create mode 100644 src/site/widgets/nlNewsArticle/meta.js create mode 100644 src/site/widgets/nlNewsList/NlNewsList.vue create mode 100644 src/site/widgets/nlNewsList/meta.js create mode 100644 src/site/widgets/nlNewsList/strings.js create mode 100644 src/site/widgets/nlQuickTasks/NlQuickTasks.vue create mode 100644 src/site/widgets/nlQuickTasks/icons.js create mode 100644 src/site/widgets/nlQuickTasks/meta.js create mode 100644 src/site/widgets/nlSignIn/signIn.js create mode 100644 src/site/widgets/nlSignIn/strings.js create mode 100644 tests/site-school-blocks.spec.mjs diff --git a/openspec/changes/site-school-blocks/proposal.md b/openspec/changes/site-school-blocks/proposal.md new file mode 100644 index 000000000..e82bf91c1 --- /dev/null +++ b/openspec/changes/site-school-blocks/proposal.md @@ -0,0 +1,35 @@ +# Proposal: site-school-blocks + +## Why + +Four school portals were designed on 5 October 2026 (De Wilgenboom, Vaartveld College, Esdoornveen, Warmtepompacademie; boards in the school-design canvases, plan `PORTAL-PLAN.md` gaps G-09, G-10, G-11, G-13, G-16). Their home pages are built from the same handful of blocks, and the page palette cannot draw any of them today: + +- **"Direct regelen"**: one card of 8 or 9 task tiles (icon, label, arrow), pulled up over the hero. The palette has `nlLinkList` (a plain list) and the library's `cardGrid` (cards, not tiles). Quick tiles exist only on contributed pages (`QuickTiles.vue`), fed by `cta` blocks. +- **News**: the newest item as a card with photo and intro, then rows of date and title, "Al het nieuws". portaliq's `newsItem` is for an audience only: it is read authenticated, has no portal, and no endpoint serves it to a visitor who is not signed in. There is no news widget for a CMS page. +- **A dated list**: "Deze maand op school" with date tiles, "Agenda" with date labels ("13 en 15 okt"), "Eerstvolgende cursusdagen" with a places note. Nothing on a CMS page draws a date tile. +- **The "Mijn ..." card**: heading, intro, a check list, one button, a note. `nlSignIn` draws a row of buttons and is handed no ways in at all: `WidgetGrid.propsFor()` has no branch for it, so on every portal it says "Dit portaal heeft nog geen manier om in te loggen." +- **Numbered steps**: "Kies de cursus", "Schrijf in", "Het certificaat staat klaar". `nlList` takes strings only. +- **The news article** (Artikel board) and its "related news" aside. + +Everything must stay generic: no school names, no colours, no education words in portaliq. The look comes from the thematiq token sets. + +## What changes + +- **`newsItem` 0.4.0** (register 0.59.0): `public` (boolean, default false), `portal` (slug) and `audienceLabel` (the words the website shows, such as "hele school"). The staff `create` and `update` endpoints accept them; `update` without `public` leaves the choice as it was. +- **`GET /api/content/news`** and **`GET /api/content/news/{id}`** (`ContentNewsController`, `PublicNewsReader`): published items staff put on THIS portal, newest first. Never an item that targets single children, never a photo that is not a published item of the portal's own media library, never the target, author, receipts or translations. Gated by the portal's sign-in modes like the rest of `/api/content`. +- **Four new widgets**, each loaded on demand: `nlQuickTasks`, `nlNewsList` (with a `compact` display for an aside), `nlNewsArticle` (item from the route), `nlEventList` (tiles or labels; items authored on the page in this change). +- **`nlSignIn`**: a `card` display, and the host now hands it the portal's own sign-in routes and the signed-in state, after the authored props. +- **`nlList`**: lines may be `{title, text}`, and `display: steps` draws large numbers. +- A shared `DateTile` part and date helpers under `src/site/components/mijn/`, for this change and the Mijn omgeving displays that follow. +- The coverage record gains `SITE_COMPOSITIONS`: widgets made of several NL Design System components, kept out of the 101-row record that mirrors design D1. + +## Not in this change + +- An app filling `nlEventList` (an anonymous contribution of events, plan G-12). The item shape is fixed here so that change only adds a `source`. +- Search over news and documents (G-14). +- The staff News screen's own checkbox for `public`; the endpoint accepts it, the example sets write it. +- The course card inside the warmtepompacademie hero: the hero belongs to the site chrome. + +## Depends on + +- The shell passing `signInRoutes` into `WidgetGrid` (one line in `App.vue`, owned by the chrome lane). Until it lands, the card's button goes to the sign-in page instead of straight to the one way in. diff --git a/openspec/changes/site-school-blocks/specs/portaliq-cms/spec.md b/openspec/changes/site-school-blocks/specs/portaliq-cms/spec.md new file mode 100644 index 000000000..bb5e871ca --- /dev/null +++ b/openspec/changes/site-school-blocks/specs/portaliq-cms/spec.md @@ -0,0 +1,106 @@ +## ADDED Requirements + +### Requirement: A news item shows on a portal's public website only when staff put it there + +A `newsItem` MUST be served to the public website of a portal only when its status is `published`, its `public` flag is the boolean `true` and its `portal` equals that portal's slug. An item whose target names single children MUST NOT be served publicly, whatever its flag says. A public read MUST return only the id, title, a plain-text intro, the publication moment, the staff-written `audienceLabel` and an image; it MUST NOT return the target, the author, the read receipts or the translations. An image MUST be served only when it is a published item of the same portal's media library. The read MUST apply the portal's declared sign-in modes like every other content read, and MUST be publicly cacheable only for a caller without a bearer. Updating an item without naming `public` MUST leave its website choice unchanged. + +#### Scenario: A whole-school item on the website +- GIVEN a published `newsItem` with `public: true`, `portal: "wilgenboom"` and `audienceLabel: "hele school"` +- WHEN a visitor who is not signed in asks `/api/content/news?portal=wilgenboom` +- THEN the item is in the list with its title, intro, date and "hele school" +- AND the response carries no target, author or read receipts + +#### Scenario: An item about one child stays off the website +- GIVEN a published, public item for `wilgenboom` whose target names a child +- WHEN the website's news is read +- THEN the item is not in the list and its own address answers 404 + +#### Scenario: Another portal's item is not this portal's news +- GIVEN a public item for `vaartveld` +- WHEN `wilgenboom`'s news is read +- THEN the item is not in it + +#### Scenario: A photo from outside the library is not shown +- GIVEN a public item whose only photo is an address on another site +- WHEN it is read publicly +- THEN its image is empty + +#### Scenario: Editing the text keeps the item on the website +- GIVEN a public item +- WHEN staff save new text through a screen that does not send `public` +- THEN the item is still public on the same portal + +### Requirement: A news list shows the news staff put on the website + +The `nlNewsList` widget MUST show the newest public items of the serving portal, at most its `limit` (1 to 12). With `featured` on and the `list` display, the newest item MUST lead with its photo, its date and audience, its title as a link and its intro; the others MUST be rows of date (and audience when `showAudience` is on) and title. The `compact` display MUST show rows only. An item MUST link to `/`. On a page whose route names an item, that item MUST be left out. Which portal is read MUST come from the host, not from the placement. + +#### Scenario: The home page's news +- GIVEN three public items for the portal and a news list with `limit: 4` +- WHEN the home page renders +- THEN the newest item leads and the other two are rows +- AND each links to `/nieuws/` + +#### Scenario: Related news beside an article +- GIVEN the article page for item `n1` with a compact news list beside it +- WHEN it renders +- THEN `n1` is not in the list + +### Requirement: A news article page shows one public item chosen by the route + +The `nlNewsArticle` widget MUST show the public item whose id is the trailing route segment handed over by the host, with its date and audience, its title as the page heading, its first paragraph as the lead, its photo and the rest of its body through the site's sanitising markdown renderer. An id that is not a public item of the serving portal MUST read as not found, with a link back, never as an error. A placement MUST NOT be able to pin the widget to one item. + +#### Scenario: An item that is not public +- GIVEN an id of a draft item +- WHEN `/nieuws/` is opened +- THEN the page says the item does not exist or is no longer on the website, and links back to the news + +### Requirement: A task list shows a portal's most asked tasks as tiles + +The `nlQuickTasks` widget MUST render its authored items as at most twelve tiles in one card, each tile ONE link holding a decorative icon, the label and an arrow. A tile without a label, or whose address is not a path inside the site or an `http(s)`, `mailto` or `tel` address, MUST be left out. A path inside the site MUST become the site's own address for that route, and a plain click on it MUST stay in the site. `columns` MUST be 2, 3 or 4 on a wide screen; a phone MUST show one column. With `overlap` the card MUST be pulled up over the band above it. + +#### Scenario: Eight tasks in three columns +- GIVEN eight items and `columns: 3` +- WHEN the home page renders at 1440px +- THEN the card shows the eight tiles in three columns, each one link + +#### Scenario: A script address is refused +- GIVEN an item whose address is `javascript:alert(1)` +- WHEN the card renders +- THEN that item is not a tile + +### Requirement: A dated list shows each date as a tile or a label + +The `nlEventList` widget MUST render its items sorted by date, each with a date tile (`display: tiles`, the day and the short month in the page language, the full date for assistive technology) or a date label (`display: labels`, the authored `dateLabel` or the day or run of days), the title (a link when the item has a usable address), a meta line and a note whose tone (`neutral`, `positive`, `warning`) only adds weight to words. With `upcomingOnly` on, an item whose last day is before today MUST be left out. An item without a title or a readable date MUST be left out. + +#### Scenario: This month at school +- GIVEN items on 1, 7, 9 and 29 October and today is 5 October +- WHEN the list renders with tiles +- THEN it shows 7, 9 and 29 October in that order, each as a tile + +#### Scenario: A run of days as a label +- GIVEN an item from 17 to 25 October without a `dateLabel` +- WHEN the list renders with labels +- THEN its label reads "17 - 25 okt" + +### Requirement: The sign-in card offers the portal's own ways in + +The host MUST hand `nlSignIn` the portal's own sign-in routes and whether a session is held, after the authored props, so a placement cannot add or change a way in. A way whose address is not a path inside the site MUST be refused. With `display: card` the widget MUST show the heading, intro, the authored points as a check list, one button and a note. Signed out with one way in, the button MUST go straight to it; with several or none known, it MUST go to the sign-in page. Signed in, it MUST lead to the visitor's own area. + +#### Scenario: One way in +- GIVEN a portal whose only mode is DigiD and a sign-in card +- WHEN a visitor who is not signed in sees the card +- THEN its button starts the DigiD sign-in + +#### Scenario: A placement cannot add a way in +- GIVEN a placement that declares its own `ways` with an address on another site +- WHEN the card renders +- THEN only the portal's own ways are offered + +### Requirement: A list may show numbered steps with a title and a line + +The `nlList` widget MUST accept lines as text or as `{title, text}`. With `display: steps` it MUST render an ordered list whose numbers are drawn large and whose items show the title and the line. + +#### Scenario: Three steps +- GIVEN three lines with a title and a text and `display: steps` +- WHEN the list renders +- THEN it is an ordered list of three, each with its number, title and line diff --git a/openspec/changes/site-school-blocks/tasks.md b/openspec/changes/site-school-blocks/tasks.md new file mode 100644 index 000000000..86f3a6153 --- /dev/null +++ b/openspec/changes/site-school-blocks/tasks.md @@ -0,0 +1,19 @@ +# Tasks: site-school-blocks + +## Wave 1: the website blocks (plan L2-1) + +- [x] **T1**: `newsItem` 0.4.0 (`public`, `portal`, `audienceLabel`), register 0.59.0; `NewsController::create/update` accept them, `update` without `public` keeps the choice. + - PHPUnit `NewsControllerTest::testCreatePutsAnItemOnOnePortalsWebsiteOnlyWithAPortal`, `::testUpdateWithoutTheWebsiteChoiceKeepsIt`; `PortaliqRegisterConfigTest` names 0.59.0. +- [x] **T2**: `PublicNewsReader`, `ContentNewsController`, routes `/api/content/news` and `/api/content/news/{id}`. + - PHPUnit `PublicNewsReaderTest` (8: portal, flag, draft, single children, fields, photos, order and limit, intro) and `ContentNewsControllerTest` (5: cacheable for a visitor, limit, 401 on a sign-in only portal, private when signed in, 404). +- [x] **T3**: `nlQuickTasks`, `nlNewsList`, `nlNewsArticle`, `nlEventList`; `DateTile` and `dates.js`, `links.js`; `publicNews.js`. +- [x] **T4**: `nlSignIn` card display; `WidgetGrid` hands it `signInRoutes` and `signedIn`, and hands the news widgets `portal` and `routeParam`. +- [x] **T5**: `nlList` steps display. +- [x] **T6**: registry, metas (the editor's fields), `SITE_COMPOSITIONS` in the coverage record, the token allow-list. + - node `tests/site-school-blocks.spec.mjs` (`check:site-school-blocks`, in `check:specs`); `check:widget-registry` gains the compositions test. +- [ ] **T7**: chrome lane: pass `signInRoutes: this.signInRoutes` in `App.vue`'s `gridContext()`. +- [ ] **T8**: live check on a portal with the primary-school example set: each block next to its board. + +## Validation + +- [x] **T9**: `openspec validate site-school-blocks --strict` diff --git a/package.json b/package.json index eb1f40300..00a6b6161 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "check:register": "node tests/validate-register.js", "check:registry": "node tests/registry.spec.js", "check:json-strict": "node tests/validate-json-strict.js", - "check:specs": "npm run check:json-strict && npm run check:manifest-v2 && npm run check:register && npm run check:registry && npm run check:site-auth && npm run check:federated-search && npm run check:campaign-tracking && npm run check:traffic-client && npm run check:traffic-summary && npm run check:traffic-paths && npm run check:page-traffic && npm run check:open-portal-site && npm run check:shared-instance && npm run check:connection-registry && npm run check:leaf-integrations && npm run check:page-layout-entry && npm run check:collection-table && npm run check:case-timeline && npm run check:schema-form-file-field && npm run check:timed-task && npm run check:row-action && npm run check:translated-message-notice && npm run check:access-requests && npm run check:news-item-translation && npm run check:news-title-and-newsletter-translation && npm run check:newsletter-title-translation && npm run check:intake-entry && npm run check:access-request-asker && npm run check:registered-details && npm run check:branch-choice && npm run check:account-page && npm run check:signing-dialog && npm run check:form-binding-preview && npm run check:open-record && npm run check:message-box-channel && npm run check:portal-live-regions && npm run check:case-withdraw-screen && npm run check:case-documents-screen && npm run check:my-cases-page && npm run check:my-cases-acting-for && npm run check:proposal-queue-leaf && npm run check:portal-theme-choice && npm run check:site-head && npm run check:page-history && npm run check:media-library && npm run check:broker-login && npm run check:portal-signin-settings && npm run check:site-shell-blocks && npm run check:site-grid && npm run check:site-regions && npm run check:page-editor && npm run check:page-editor-regions && npm run check:notices && npm run check:site-edit-mode && npm run check:site-page-tree && npm run check:branch-in-effect && npm run check:idle-session && npm run check:publication-documents && npm run check:woo-entry-points && npm run check:attached-actions && npm run check:my-dossiers && npm run check:staff-account-screens && npm run check:site-signed-in-shell && npm run check:site-resident-menu && npm run check:create-names-its-action && npm run check:site-shared-dossier && npm run check:guest-action-page && npm run check:ways-in-screens && npm run check:visible-when-local && npm run check:schema-form && npm run check:propose-change && npm run check:site-collections && npm run check:collection-loader && npm run check:rich-text && npm run check:tasks-page && npm run check:site-inbox-pages && npm run check:embed-copy && npm run check:embed-height && npm run check:service-worker && npm run check:install-banner && npm run check:embed-frame && npm run check:array-cells && npm run check:collection-groups && npm run check:record-page && npm run check:case-type-portal-header && npm run check:inbox-unread && npm run check:inbox-attachments && npm run check:inbox-open-link && npm run check:inbox-delete && npm run check:value-labels && npm run check:admin-menu-access && npm run check:news-list-refresh && npm run check:site-navigation && npm run check:site-theme-bridge && npm run check:mijn-components && npm run check:mijn-cases && npm run check:mijn-home && npm run check:mijn-lists && npm run check:mijn-wave6 && npm run check:mijn-documents && npm run check:site-form-fields && npm run check:widget-registry && npm run check:widget-palette && npm run check:editor-text-toolbar && npm run check:widget-tokens && npm run check:portal-home-page", + "check:specs": "npm run check:json-strict && npm run check:manifest-v2 && npm run check:register && npm run check:registry && npm run check:site-auth && npm run check:federated-search && npm run check:campaign-tracking && npm run check:traffic-client && npm run check:traffic-summary && npm run check:traffic-paths && npm run check:page-traffic && npm run check:open-portal-site && npm run check:shared-instance && npm run check:connection-registry && npm run check:leaf-integrations && npm run check:page-layout-entry && npm run check:collection-table && npm run check:case-timeline && npm run check:schema-form-file-field && npm run check:timed-task && npm run check:row-action && npm run check:translated-message-notice && npm run check:access-requests && npm run check:news-item-translation && npm run check:news-title-and-newsletter-translation && npm run check:newsletter-title-translation && npm run check:intake-entry && npm run check:access-request-asker && npm run check:registered-details && npm run check:branch-choice && npm run check:account-page && npm run check:signing-dialog && npm run check:form-binding-preview && npm run check:open-record && npm run check:message-box-channel && npm run check:portal-live-regions && npm run check:case-withdraw-screen && npm run check:case-documents-screen && npm run check:my-cases-page && npm run check:my-cases-acting-for && npm run check:proposal-queue-leaf && npm run check:portal-theme-choice && npm run check:site-head && npm run check:page-history && npm run check:media-library && npm run check:broker-login && npm run check:portal-signin-settings && npm run check:site-shell-blocks && npm run check:site-grid && npm run check:site-regions && npm run check:page-editor && npm run check:page-editor-regions && npm run check:notices && npm run check:site-edit-mode && npm run check:site-page-tree && npm run check:branch-in-effect && npm run check:idle-session && npm run check:publication-documents && npm run check:woo-entry-points && npm run check:attached-actions && npm run check:my-dossiers && npm run check:staff-account-screens && npm run check:site-signed-in-shell && npm run check:site-resident-menu && npm run check:create-names-its-action && npm run check:site-shared-dossier && npm run check:guest-action-page && npm run check:ways-in-screens && npm run check:visible-when-local && npm run check:schema-form && npm run check:propose-change && npm run check:site-collections && npm run check:collection-loader && npm run check:rich-text && npm run check:tasks-page && npm run check:site-inbox-pages && npm run check:embed-copy && npm run check:embed-height && npm run check:service-worker && npm run check:install-banner && npm run check:embed-frame && npm run check:array-cells && npm run check:collection-groups && npm run check:record-page && npm run check:case-type-portal-header && npm run check:inbox-unread && npm run check:inbox-attachments && npm run check:inbox-open-link && npm run check:inbox-delete && npm run check:value-labels && npm run check:admin-menu-access && npm run check:news-list-refresh && npm run check:site-navigation && npm run check:site-theme-bridge && npm run check:mijn-components && npm run check:mijn-cases && npm run check:mijn-home && npm run check:mijn-lists && npm run check:mijn-wave6 && npm run check:mijn-documents && npm run check:site-form-fields && npm run check:widget-registry && npm run check:site-school-blocks && npm run check:widget-palette && npm run check:editor-text-toolbar && npm run check:widget-tokens && npm run check:portal-home-page", "prepare": "git config core.hooksPath .githooks || true", "build:site": "NODE_ENV=production webpack --config webpack.site.js --progress", "postbuild:site": "node scripts/check-single-dexie.js && node scripts/check-site-chunks.js", @@ -95,6 +95,7 @@ "check:site-shell-blocks": "node --test tests/site-shell-blocks.spec.mjs", "check:site-grid": "node --test tests/site-grid.spec.mjs", "check:widget-registry": "node --test tests/widget-registry.spec.mjs", + "check:site-school-blocks": "node --test tests/site-school-blocks.spec.mjs", "check:widget-palette": "node --test tests/widget-palette.spec.mjs", "check:site-regions": "node --test tests/site-regions.spec.mjs", "check:page-layout-entry": "node --test tests/page-layout-entry.spec.mjs", diff --git a/src/site/components/WidgetGrid.vue b/src/site/components/WidgetGrid.vue index 2c0134a41..373f023aa 100644 --- a/src/site/components/WidgetGrid.vue +++ b/src/site/components/WidgetGrid.vue @@ -345,6 +345,18 @@ export default { type: Object, default: () => ({}), }, + + /** + * The portal's sign-in routes, `{mode, label, href}` as the shell + * derives them from `authentication.modes` (authApi.js + * signInRoutes). Handed to `nlSignIn` AFTER its authored props, so a + * page words the card but cannot add or change a way in + * (site-school-blocks). + */ + signInRoutes: { + type: Array, + default: () => [], + }, }, // `search` comes from the shared hero block, which renders a search box and @@ -419,6 +431,7 @@ export default { * @return {object} The component props. * * @spec openspec/changes/portal-theme-blocks-and-contributed-pages/specs/portaliq-cms/spec.md#requirement-blocks-must-take-their-data-as-props-and-nothing-else-req-ptb-007 + * @spec openspec/changes/site-school-blocks/specs/portaliq-cms/spec.md#requirement-the-sign-in-card-offers-the-portals-own-ways-in */ propsFor(widget) { // `style` and `class` never reach a block (REQ-PTB-007). @@ -514,6 +527,28 @@ export default { return { ...props, portal: this.portal } } + // site-school-blocks: the news widgets read this portal's public + // news, and the article takes its item from the route; the sign-in + // card gets the portal's own ways in. All after the authored props. + if ( + widget.widgetKey === 'nlNewsList' + || widget.widgetKey === 'nlNewsArticle' + ) { + return { ...props, portal: this.portal, routeParam: this.routeParam } + } + + if (widget.widgetKey === 'nlSignIn') { + return { + ...props, + signedIn: this.signedIn === true, + ways: this.signInRoutes.map((r) => ({ + id: r.mode, + label: r.label, + href: r.href, + })), + } + } + return props }, diff --git a/src/site/components/mijn/DateTile.vue b/src/site/components/mijn/DateTile.vue new file mode 100644 index 000000000..e7e6b1d58 --- /dev/null +++ b/src/site/components/mijn/DateTile.vue @@ -0,0 +1,90 @@ + + + + + + + + diff --git a/src/site/components/mijn/dates.js b/src/site/components/mijn/dates.js new file mode 100644 index 000000000..0aa57af80 --- /dev/null +++ b/src/site/components/mijn/dates.js @@ -0,0 +1,117 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 +// +// Dates as the school blocks print them (site-school-blocks): a day and a +// short month for a date tile, a long date for a news row, and whether a +// date has passed. A date-only value (`2026-10-07`) is a calendar day, not a +// moment, so it is read in local time and never shifts a day at midnight UTC. +// +// Imports nothing, so node tests it (tests/site-school-blocks.spec.mjs). +// +// @spec openspec/changes/site-school-blocks/specs/portaliq-cms/spec.md#requirement-a-dated-list-shows-each-date-as-a-tile-or-a-label + +/** + * A stored value as a Date, or null when it is not a date. + * + * @param {string} value An ISO date (`YYYY-MM-DD`) or date-time. + * @return {Date|null} The date. + */ +export function toDate(value) { + const text = String(value ?? '').trim() + const day = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text) + const date = day + ? new Date(Number(day[1]), Number(day[2]) - 1, Number(day[3])) + : new Date(text) + return text === '' || Number.isNaN(date.getTime()) ? null : date +} + +/** + * The BCP 47 language a date is printed in. + * + * @param {string} [locale] The page language. + * @return {string} `nl-NL` or `en-GB`. + */ +export function dateLocale(locale) { + const raw = + locale + || (typeof document !== 'undefined' && document.documentElement?.lang) + || 'nl' + return String(raw).toLowerCase().startsWith('en') ? 'en-GB' : 'nl-NL' +} + +/** + * A date tile's two lines: the day and the short month without a dot. + * + * @param {string} value The date. + * @param {string} [locale] The page language. + * @return {{day: string, month: string}|null} The lines, or null. + */ +export function dayAndMonth(value, locale) { + const date = toDate(value) + if (!date) { + return null + } + const month = new Intl.DateTimeFormat(dateLocale(locale), { month: 'short' }) + .format(date) + .replace(/\.$/, '') + return { day: String(date.getDate()), month } +} + +/** + * A long date: "2 oktober 2026". + * + * @param {string} value The date. + * @param {string} [locale] The page language. + * @return {string} The date, or '' when it is not one. + */ +export function longDate(value, locale) { + const date = toDate(value) + return date + ? new Intl.DateTimeFormat(dateLocale(locale), { + day: 'numeric', + month: 'long', + year: 'numeric', + }).format(date) + : '' +} + +/** + * A short label for one day or a run of days: "7 okt", "17 - 25 okt", + * "30 sep - 2 okt". + * + * @param {string} start The first day. + * @param {string} [end] The last day. + * @param {string} [locale] The page language. + * @return {string} The label, or '' when the start is not a date. + */ +export function dayLabel(start, end, locale) { + const first = dayAndMonth(start, locale) + if (!first) { + return '' + } + const last = end ? dayAndMonth(end, locale) : null + if (!last || (last.day === first.day && last.month === first.month)) { + return `${first.day} ${first.month}` + } + return last.month === first.month + ? `${first.day} - ${last.day} ${last.month}` + : `${first.day} ${first.month} - ${last.day} ${last.month}` +} + +/** + * Whether a day lies before today (the end day, for a run of days). + * + * @param {string} start The first day. + * @param {string} [end] The last day. + * @param {Date} [now] Today, for a test. + * @return {boolean} True when the whole run is over. + */ +export function isPast(start, end, now = new Date()) { + const last = toDate(end) || toDate(start) + if (!last) { + return false + } + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const day = new Date(last.getFullYear(), last.getMonth(), last.getDate()) + return day < today +} diff --git a/src/site/components/mijn/links.js b/src/site/components/mijn/links.js new file mode 100644 index 000000000..575147cc6 --- /dev/null +++ b/src/site/components/mijn/links.js @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 +// +// An authored link, made safe and made to work wherever the site is served +// (site-school-blocks). A path inside the site (`/praktisch`) becomes the +// site's own address for that route, so it works on a portal at its own +// address and on one served through Nextcloud (`?route=/praktisch`); a click +// on it stays in the site. `http(s):`, `mailto:` and `tel:` pass as they are. +// Anything else is no link at all. +// +// @spec openspec/changes/site-school-blocks/specs/portaliq-cms/spec.md#requirement-a-task-list-shows-a-portals-most-asked-tasks-as-tiles + +import { siteHref } from './rows.js' + +/** + * @param {string} value The authored address. + * @return {{href: string, route: string}|null} The address and, for a path in the site, its route; null when it may not be a link. + */ +export function authoredLink(value) { + const raw = String(value ?? '').trim() + if (/^\/(?!\/)/.test(raw)) { + return { href: siteHref(raw), route: raw } + } + if (/^(https?:\/\/|mailto:|tel:)/i.test(raw)) { + return { href: raw, route: '' } + } + return null +} + +/** + * Whether a click should stay in the site: a plain left click on a link to a + * route. A click for a new tab or window is the browser's. + * + * @param {MouseEvent} event The click. + * @param {{route: string}|null} link The link. + * @return {boolean} True when the caller should navigate in place. + */ +export function staysInSite(event, link) { + return Boolean( + link + && link.route + && !(event?.ctrlKey || event?.metaKey || event?.shiftKey || event?.altKey) + && (event?.button ?? 0) === 0, + ) +} diff --git a/src/site/lib/publicNews.js b/src/site/lib/publicNews.js new file mode 100644 index 000000000..5eb4a7b64 --- /dev/null +++ b/src/site/lib/publicNews.js @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 +// +// The portal's public news, over the headless content contract +// (`/api/content/news`, site-school-blocks). Loaded only by the news widgets, +// so the site entry carries none of it. +// +// @spec openspec/changes/site-school-blocks/specs/portaliq-cms/spec.md#requirement-a-news-list-shows-the-news-staff-put-on-the-website + +import { adoptSessionToken } from './authApi.js' +import { resolveApiBase } from './contentApi.js' + +/** + * GET a content path as JSON; a non-2xx is an error carrying its status. + * + * @param {string} path The path under the content API. + * @param {object} query The query. + * @return {Promise} The body. + */ +async function get(path, query) { + const url = new URL(resolveApiBase() + path, window.location.origin) + for (const [key, value] of Object.entries(query)) { + if (value !== undefined && value !== null && value !== '') { + url.searchParams.set(key, String(value)) + } + } + const headers = { Accept: 'application/json' } + const token = adoptSessionToken() + if (token) { + headers.Authorization = `Bearer ${token}` + } + const response = await fetch(url.toString(), { headers }) + if (!response.ok) { + const error = new Error(`content api ${response.status} for ${path}`) + error.status = response.status + throw error + } + return response.json() +} + +/** + * The newest public items of a portal. + * + * @param {string} portal The portal slug. + * @param {number} limit How many. + * @return {Promise>} `{id, title, intro, publishedAt, audienceLabel, image}` each. + */ +export async function fetchPublicNews(portal, limit) { + const body = await get('/news', { portal, limit }) + return Array.isArray(body?.items) ? body.items : [] +} + +/** + * One public item, with its body; null when there is no such item. + * + * @param {string} portal The portal slug. + * @param {string} id The item id. + * @return {Promise} The item. + */ +export async function fetchPublicNewsItem(portal, id) { + try { + const body = await get(`/news/${encodeURIComponent(id)}`, { portal }) + return body?.item || null + } catch (error) { + if (error.status === 404) { + return null + } + throw error + } +} diff --git a/src/site/widgets/coverage.js b/src/site/widgets/coverage.js index 1566a242e..2e4b1c855 100644 --- a/src/site/widgets/coverage.js +++ b/src/site/widgets/coverage.js @@ -975,3 +975,35 @@ export function coverageByPlacement() { return counts } + +/** + * Widgets that are not one NL Design System component but a composition of + * several, built for the school portals (site-school-blocks). They are kept + * OUT of the 101-row record above, which mirrors design D1 row by row, and + * listed here with the components they are made of, so a widget can never + * exist without being accounted for in one of the two lists. + * + * @type {Array<{key: string, composes: Array, why: string}>} + */ +export const SITE_COMPOSITIONS = [ + { + key: 'nlQuickTasks', + composes: ['Link List', 'Card as Link'], + why: 'The tasks a portal is visited for, as one card of tiles ("Direct regelen").', + }, + { + key: 'nlNewsList', + composes: ['Link List', 'Card as Link', 'Image'], + why: 'The news staff put on the website, the newest as a card, the rest as rows.', + }, + { + key: 'nlNewsArticle', + composes: ['Heading', 'Paragraph', 'Image'], + why: 'One public news item, chosen by the route.', + }, + { + key: 'nlEventList', + composes: ['Link List', 'Card as Link'], + why: 'A dated list with date tiles or date labels ("Agenda", "Deze maand").', + }, +] diff --git a/src/site/widgets/index.js b/src/site/widgets/index.js index b1d3655cc..6d8078109 100644 --- a/src/site/widgets/index.js +++ b/src/site/widgets/index.js @@ -46,16 +46,20 @@ import { metaOf as codeBlockMeta } from './nlCodeBlock/meta.js' import { metaOf as descriptionListMeta } from './nlDescriptionList/meta.js' import { metaOf as dialogMeta } from './nlDialog/meta.js' import { metaOf as drawerMeta } from './nlDrawer/meta.js' +import { metaOf as eventListMeta } from './nlEventList/meta.js' import { metaOf as headingMeta } from './nlHeading/meta.js' import { metaOf as imageMeta } from './nlImage/meta.js' import { metaOf as languageNavMeta } from './nlLanguageNav/meta.js' import { metaOf as linkMeta } from './nlLink/meta.js' import { metaOf as linkListMeta } from './nlLinkList/meta.js' import { metaOf as listMeta } from './nlList/meta.js' +import { metaOf as newsArticleMeta } from './nlNewsArticle/meta.js' +import { metaOf as newsListMeta } from './nlNewsList/meta.js' import { metaOf as noteMeta } from './nlNote/meta.js' import { metaOf as paragraphMeta } from './nlParagraph/meta.js' import { metaOf as progressBarMeta } from './nlProgressBar/meta.js' import { metaOf as progressCircleMeta } from './nlProgressCircle/meta.js' +import { metaOf as quickTasksMeta } from './nlQuickTasks/meta.js' import { metaOf as quoteMeta } from './nlQuote/meta.js' import { metaOf as separatorMeta } from './nlSeparator/meta.js' import { metaOf as signInMeta } from './nlSignIn/meta.js' @@ -116,6 +120,10 @@ export const metas = { nlSignIn: signInMeta, nlTaskNav: taskNavMeta, nlTabs: tabsMeta, + nlQuickTasks: quickTasksMeta, + nlNewsList: newsListMeta, + nlNewsArticle: newsArticleMeta, + nlEventList: eventListMeta, } /** diff --git a/src/site/widgets/loaders.js b/src/site/widgets/loaders.js index fa840f5ea..7c9493f57 100644 --- a/src/site/widgets/loaders.js +++ b/src/site/widgets/loaders.js @@ -53,4 +53,8 @@ export const loaders = { nlSignIn: () => import('./nlSignIn/NlSignIn.vue'), nlTaskNav: () => import('./nlTaskNav/NlTaskNav.vue'), nlTabs: () => import('./nlTabs/NlTabs.vue'), + nlQuickTasks: () => import('./nlQuickTasks/NlQuickTasks.vue'), + nlNewsList: () => import('./nlNewsList/NlNewsList.vue'), + nlNewsArticle: () => import('./nlNewsArticle/NlNewsArticle.vue'), + nlEventList: () => import('./nlEventList/NlEventList.vue'), } diff --git a/src/site/widgets/nlEventList/NlEventList.vue b/src/site/widgets/nlEventList/NlEventList.vue new file mode 100644 index 000000000..35108f5d7 --- /dev/null +++ b/src/site/widgets/nlEventList/NlEventList.vue @@ -0,0 +1,280 @@ + + + + + + + + diff --git a/src/site/widgets/nlEventList/events.js b/src/site/widgets/nlEventList/events.js new file mode 100644 index 000000000..c6a786753 --- /dev/null +++ b/src/site/widgets/nlEventList/events.js @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 +// +// The dated list's rows from its authored items (site-school-blocks): +// sorted by day, the past left out when asked, cut to the limit. Plain +// functions, so node tests them. +// +// @spec openspec/changes/site-school-blocks/specs/portaliq-cms/spec.md#requirement-a-dated-list-shows-each-date-as-a-tile-or-a-label + +import { dayLabel, isPast, toDate } from '../../components/mijn/dates.js' +import { authoredLink } from '../../components/mijn/links.js' + +/** The tones a note may have. */ +export const NOTE_TONES = ['neutral', 'positive', 'warning'] + +/** + * The rows to draw. + * + * @param {Array} items The authored items. + * @param {object} options How to read them. + * @param {boolean} options.upcomingOnly Leave out what is over. + * @param {number} options.limit The most rows. + * @param {string} [options.locale] The page language. + * @param {Date} [options.now] Today, for a test. + * @return {Array} `{key, date, label, title, link, meta, note, tone}` each. + */ +export function eventRows(items, { upcomingOnly, limit, locale, now = new Date() }) { + const max = Math.min(20, Math.max(1, Math.trunc(Number(limit)) || 6)) + return (Array.isArray(items) ? items : []) + .map((item, index) => ({ + key: index, + date: String(item?.date ?? '').trim(), + endDate: String(item?.endDate ?? '').trim(), + label: + String(item?.dateLabel ?? '').trim() + || dayLabel(item?.date, item?.endDate, locale), + title: String(item?.title ?? '').trim(), + link: item?.href ? authoredLink(item.href) : null, + meta: String(item?.meta ?? '').trim(), + note: String(item?.note ?? '').trim(), + tone: NOTE_TONES.includes(item?.noteTone) ? item.noteTone : 'neutral', + })) + .filter((row) => row.title !== '' && toDate(row.date) !== null) + .filter((row) => !upcomingOnly || !isPast(row.date, row.endDate, now)) + .sort((a, b) => toDate(a.date) - toDate(b.date)) + .slice(0, max) +} diff --git a/src/site/widgets/nlEventList/meta.js b/src/site/widgets/nlEventList/meta.js new file mode 100644 index 000000000..0366de608 --- /dev/null +++ b/src/site/widgets/nlEventList/meta.js @@ -0,0 +1,44 @@ +/** + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * The dated list's description for the editor (site-school-blocks). + * + * NO IMPORTS: the editor reads every meta to build the palette. + */ + +/** + * @type {import('../index.js').SiteWidgetMeta} + */ +export const metaOf = { + key: 'nlEventList', + group: 'content', + label: 'Agenda', + nlds: 'Link List, Card', + synonyms: [ + 'agenda', + 'kalender', + 'activiteiten', + 'data', + 'binnenkort', + 'evenementen', + 'cursusdagen', + ], + fields: [ + { name: 'heading', kind: 'string', label: 'Kop' }, + { name: 'subtitle', kind: 'string', label: 'Regel onder de kop' }, + { + name: 'items', + kind: 'json', + label: 'Data (date, endDate, dateLabel, title, href, meta, note, noteTone)', + }, + { name: 'display', kind: 'string', label: 'Weergave: tiles of labels' }, + { name: 'upcomingOnly', kind: 'boolean', label: 'Alleen wat nog komt' }, + { name: 'limit', kind: 'number', label: 'Aantal (1 tot 20)' }, + { name: 'moreLabel', kind: 'string', label: 'Tekst van de link eronder' }, + { name: 'moreHref', kind: 'string', label: 'Adres van de link eronder' }, + { name: 'framed', kind: 'boolean', label: 'Als kaart' }, + ], + defaultSize: { gridWidth: 4, gridHeight: 4 }, + scope: 'public', +} diff --git a/src/site/widgets/nlList/NlList.vue b/src/site/widgets/nlList/NlList.vue index 1168a6aa7..28eb0b9ce 100644 --- a/src/site/widgets/nlList/NlList.vue +++ b/src/site/widgets/nlList/NlList.vue @@ -4,27 +4,58 @@ --> + + diff --git a/src/site/widgets/nlList/lines.js b/src/site/widgets/nlList/lines.js new file mode 100644 index 000000000..79c1630c7 --- /dev/null +++ b/src/site/widgets/nlList/lines.js @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 +// +// The list widget's lines (site-school-blocks). Imports nothing, so node +// tests it. + +/** + * The lines that have text, as `{title, text}`: a string is a title alone. + * + * @param {Array} items The authored lines. + * @return {Array<{title: string, text: string}>} The lines. + * @spec openspec/changes/site-school-blocks/specs/portaliq-cms/spec.md#requirement-a-list-may-show-numbered-steps-with-a-title-and-a-line + */ +export function listLines(items) { + return (Array.isArray(items) ? items : []) + .map((item) => + item !== null && typeof item === 'object' + ? { + title: String(item.title ?? '').trim(), + text: String(item.text ?? '').trim(), + } + : { title: String(item ?? '').trim(), text: '' }, + ) + .filter((item) => item.title !== '') +} diff --git a/src/site/widgets/nlList/meta.js b/src/site/widgets/nlList/meta.js index 14b7b72c1..d3bff6178 100644 --- a/src/site/widgets/nlList/meta.js +++ b/src/site/widgets/nlList/meta.js @@ -19,6 +19,7 @@ export const metaOf = { fields: [ { name: 'items', kind: 'json', label: 'Regels' }, { name: 'ordered', kind: 'boolean', label: 'Genummerd' }, + { name: 'display', kind: 'string', label: 'Weergave: list of steps' }, ], defaultSize: { gridWidth: 6, gridHeight: 2 }, scope: 'public', diff --git a/src/site/widgets/nlNewsArticle/NlNewsArticle.vue b/src/site/widgets/nlNewsArticle/NlNewsArticle.vue new file mode 100644 index 000000000..a09436f87 --- /dev/null +++ b/src/site/widgets/nlNewsArticle/NlNewsArticle.vue @@ -0,0 +1,244 @@ + + + + + + + + diff --git a/src/site/widgets/nlNewsArticle/article.js b/src/site/widgets/nlNewsArticle/article.js new file mode 100644 index 000000000..bb73d8d12 --- /dev/null +++ b/src/site/widgets/nlNewsArticle/article.js @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 +// +// The news article's lead and body (site-school-blocks). Imports nothing, so +// node tests it. + +/** + * The first paragraph as the lead, the rest as the body. A first block that + * is a heading, a list or an image is not a lead, so the body keeps it. + * + * @param {string} body The markdown. + * @return {{lead: string, rest: string}} The two parts. + * @spec openspec/changes/site-school-blocks/specs/portaliq-cms/spec.md#requirement-a-news-article-page-shows-one-public-item-chosen-by-the-route + */ +export function splitLead(body) { + const blocks = String(body ?? '') + .trim() + .split(/\n\s*\n/) + const first = (blocks[0] || '').trim() + if (first === '' || /^(#|[-*+]\s|\d+\.\s|!\[|>|\||```)/.test(first)) { + return { lead: '', rest: blocks.join('\n\n').trim() } + } + return { + lead: first.replace(/\s+/g, ' '), + rest: blocks.slice(1).join('\n\n').trim(), + } +} diff --git a/src/site/widgets/nlNewsArticle/meta.js b/src/site/widgets/nlNewsArticle/meta.js new file mode 100644 index 000000000..c3adf02e8 --- /dev/null +++ b/src/site/widgets/nlNewsArticle/meta.js @@ -0,0 +1,26 @@ +/** + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * The news article's description for the editor (site-school-blocks). + * + * NO IMPORTS: the editor reads every meta to build the palette. + */ + +/** + * @type {import('../index.js').SiteWidgetMeta} + */ +export const metaOf = { + key: 'nlNewsArticle', + group: 'content', + label: 'Nieuwsbericht', + nlds: 'Heading, Paragraph, Image', + synonyms: ['nieuwsbericht', 'artikel', 'bericht', 'nieuws lezen'], + fields: [ + { name: 'kindLabel', kind: 'string', label: 'Label boven de titel' }, + { name: 'backLabel', kind: 'string', label: 'Tekst van de link terug' }, + { name: 'backHref', kind: 'string', label: 'Adres van de link terug' }, + ], + defaultSize: { gridWidth: 8, gridHeight: 6 }, + scope: 'public', +} diff --git a/src/site/widgets/nlNewsList/NlNewsList.vue b/src/site/widgets/nlNewsList/NlNewsList.vue new file mode 100644 index 000000000..6123221e2 --- /dev/null +++ b/src/site/widgets/nlNewsList/NlNewsList.vue @@ -0,0 +1,386 @@ + + + + + + + + diff --git a/src/site/widgets/nlNewsList/meta.js b/src/site/widgets/nlNewsList/meta.js new file mode 100644 index 000000000..1e165ebf8 --- /dev/null +++ b/src/site/widgets/nlNewsList/meta.js @@ -0,0 +1,47 @@ +/** + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * The public news list's description for the editor (site-school-blocks). + * + * NO IMPORTS: the editor reads every meta to build the palette. + */ + +/** + * @type {import('../index.js').SiteWidgetMeta} + */ +export const metaOf = { + key: 'nlNewsList', + group: 'content', + label: 'Nieuws', + nlds: 'Link List, Card, Image', + synonyms: ['nieuws', 'berichten', 'actueel', 'laatste nieuws', 'gerelateerd'], + fields: [ + { name: 'heading', kind: 'string', label: 'Kop' }, + { name: 'limit', kind: 'number', label: 'Aantal berichten (1 tot 12)' }, + { + name: 'featured', + kind: 'boolean', + label: 'Nieuwste bericht groot, met foto', + }, + { name: 'display', kind: 'string', label: 'Weergave: list of compact' }, + { + name: 'showAudience', + kind: 'boolean', + label: 'Laat zien voor wie het is', + }, + { + name: 'moreLabel', + kind: 'string', + label: 'Tekst van de link naar al het nieuws', + }, + { name: 'moreHref', kind: 'string', label: 'Adres van die link' }, + { + name: 'articleRoute', + kind: 'string', + label: 'Pagina waar een bericht opent', + }, + ], + defaultSize: { gridWidth: 8, gridHeight: 4 }, + scope: 'public', +} diff --git a/src/site/widgets/nlNewsList/strings.js b/src/site/widgets/nlNewsList/strings.js new file mode 100644 index 000000000..6c5a702b3 --- /dev/null +++ b/src/site/widgets/nlNewsList/strings.js @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 +// +// The few words the news widgets say themselves; everything else is +// authored. Dutch and English, by the page language. + +export default { + nl: { + loading: 'Het nieuws wordt geladen.', + empty: 'Er is nog geen nieuws.', + failed: 'Het nieuws kan nu niet worden geladen. Probeer het later opnieuw.', + notFound: 'Dit nieuwsbericht bestaat niet of staat niet meer op de website.', + back: 'Terug naar het nieuws', + }, + en: { + loading: 'Loading the news.', + empty: 'There is no news yet.', + failed: 'The news cannot be loaded right now. Please try again later.', + notFound: 'This news item does not exist or is no longer on the website.', + back: 'Back to the news', + }, +} diff --git a/src/site/widgets/nlQuickTasks/NlQuickTasks.vue b/src/site/widgets/nlQuickTasks/NlQuickTasks.vue new file mode 100644 index 000000000..9f4c92533 --- /dev/null +++ b/src/site/widgets/nlQuickTasks/NlQuickTasks.vue @@ -0,0 +1,297 @@ + + + + + + + + diff --git a/src/site/widgets/nlQuickTasks/icons.js b/src/site/widgets/nlQuickTasks/icons.js new file mode 100644 index 000000000..2c8acddd3 --- /dev/null +++ b/src/site/widgets/nlQuickTasks/icons.js @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 +// +// Line icons for task tiles (site-school-blocks), drawn on a 24 by 24 grid +// with a round 1.9 stroke, so a tile list reads as one family. A name not in +// this list draws no icon: an icon is decorative, the label carries the +// meaning. Names say what the icon shows, not what a portal uses it for. +// +// @spec openspec/changes/site-school-blocks/specs/portaliq-cms/spec.md#requirement-a-task-list-shows-a-portals-most-asked-tasks-as-tiles + +export default { + alert: 'M12 3v10M12 17v.5M5 21h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2z', + chat: 'M4 5h16v11H8l-4 4z', + calendar: 'M4 6h16v14H4zM4 10h16M8 3v4M16 3v4', + calendarLines: 'M4 6h16v14H4zM4 10h16M8 3v4M16 3v4M8 14h3M13 14h3', + clock: 'M12 3a9 9 0 1 1 0 18 9 9 0 0 1 0-18zM12 7v5l3 2', + document: 'M6 3h9l4 4v14H6zM14 3v5h5M9 13h7M9 17h5', + documentGrade: 'M6 3h9l4 4v14H6zM14 3v5h5M9 17l2-5 2 5M9.7 15.5h2.6M15 12v5', + home: 'M3 11l9-7 9 7M5 10v10h14V10M10 20v-5h4v5', + book: 'M4 5a2 2 0 0 1 2-2h14v16H6a2 2 0 0 0-2 2zM4 21V5M9 7h7', + bookLines: 'M4 5a2 2 0 0 1 2-2h14v16H6a2 2 0 0 0-2 2zM4 21V5M9 7h7M9 11h5', + bookStack: + 'M5 4h11a3 3 0 0 1 3 3v13H8a3 3 0 0 1-3-3zM5 17a3 3 0 0 1 3-3h11M9 8h6', + personPlus: + 'M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM2 21c1-4 4-6 7-6s6 2 7 6M19 8v6M16 11h6', + heartPlus: + 'M12 20s-7-4.5-7-10a4 4 0 0 1 7-2.5A4 4 0 0 1 19 10c0 5.5-7 10-7 10zM12 9v4M10 11h4', + sun: 'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8zM12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.5 1.5M17.5 17.5L19 19M5 19l1.5-1.5M17.5 6.5L19 5', + pencil: 'M4 20h4l10-10-4-4L4 16zM13 7l4 4', + card: 'M4 6h16v12H4zM4 10h16M8 15h3', + plusBox: 'M12 5v14M5 12h14M4 4h16v16H4z', + building: 'M4 21V7l8-4 8 4v14M9 21v-6h6v6M8 10h.01M12 10h.01M16 10h.01', +} diff --git a/src/site/widgets/nlQuickTasks/meta.js b/src/site/widgets/nlQuickTasks/meta.js new file mode 100644 index 000000000..1a1cad3c0 --- /dev/null +++ b/src/site/widgets/nlQuickTasks/meta.js @@ -0,0 +1,29 @@ +/** + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * The task tiles' description for the editor (site-school-blocks). + * + * NO IMPORTS: the editor reads every meta to build the palette. + */ + +/** + * @type {import('../index.js').SiteWidgetMeta} + */ +export const metaOf = { + key: 'nlQuickTasks', + group: 'nav', + label: 'Direct regelen', + nlds: 'Link List, Card', + synonyms: ['taken', 'snel naar', 'regelen', 'tegels', 'veelgevraagd', 'tiles'], + fields: [ + { name: 'heading', kind: 'string', label: 'Kop' }, + { name: 'items', kind: 'json', label: 'Taken (label, href, icon)' }, + { name: 'columns', kind: 'number', label: 'Kolommen (2, 3 of 4)' }, + { name: 'moreLabel', kind: 'string', label: 'Tekst van de link eronder' }, + { name: 'moreHref', kind: 'string', label: 'Adres van de link eronder' }, + { name: 'overlap', kind: 'boolean', label: 'Over de band erboven schuiven' }, + ], + defaultSize: { gridWidth: 12, gridHeight: 3 }, + scope: 'public', +} diff --git a/src/site/widgets/nlSignIn/NlSignIn.vue b/src/site/widgets/nlSignIn/NlSignIn.vue index 8de036c76..b98482893 100644 --- a/src/site/widgets/nlSignIn/NlSignIn.vue +++ b/src/site/widgets/nlSignIn/NlSignIn.vue @@ -4,19 +4,58 @@ --> diff --git a/src/site/widgets/nlSignIn/meta.js b/src/site/widgets/nlSignIn/meta.js index 32a4bad9c..382380b94 100644 --- a/src/site/widgets/nlSignIn/meta.js +++ b/src/site/widgets/nlSignIn/meta.js @@ -2,7 +2,8 @@ * SPDX-FileCopyrightText: 2026 Conduction B.V. * SPDX-License-Identifier: EUPL-1.2 * - * The Inloggen widget's description for the editor (design D1 row 55). + * The Inloggen widget's description for the editor (design D1 row 55, + * site-school-blocks for the card). * * NO IMPORTS: the editor reads every meta to build the palette. */ @@ -16,7 +17,15 @@ export const metaOf = { label: 'Inloggen', nlds: 'Login Link', synonyms: ['inloggen', 'aanmelden', 'digid', 'eherkenning', 'mijn omgeving'], - fields: [{ name: 'heading', kind: 'string', label: 'Kop boven de knoppen' }], + fields: [ + { name: 'heading', kind: 'string', label: 'Kop boven de knoppen' }, + { name: 'display', kind: 'string', label: 'Weergave: buttons of card' }, + { name: 'intro', kind: 'text', label: 'Tekst op de kaart' }, + { name: 'points', kind: 'json', label: 'Punten op de kaart' }, + { name: 'buttonLabel', kind: 'string', label: 'Tekst op de knop' }, + { name: 'note', kind: 'string', label: 'Regel onder de knop' }, + { name: 'tone', kind: 'string', label: 'Kleur: inverse of light' }, + ], defaultSize: { gridWidth: 4, gridHeight: 2 }, scope: 'public', } diff --git a/src/site/widgets/nlSignIn/signIn.js b/src/site/widgets/nlSignIn/signIn.js new file mode 100644 index 000000000..a4c9cbf0e --- /dev/null +++ b/src/site/widgets/nlSignIn/signIn.js @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 +// +// What the sign-in widget offers, decided without Vue so node tests it +// (site-nlds-widget-palette design D1 row 55, site-school-blocks). +// +// THE WAYS IN ARE THE PORTAL'S, NOT THE PLACEMENT'S. The host hands down the +// sign-in routes the portal declares; a placement only words the card. A way +// whose address is not a path inside this site is refused: a sign-in link to +// another origin is the one link on a government page that must never be +// authorable. +// +// @spec openspec/changes/site-school-blocks/specs/portaliq-cms/spec.md#requirement-the-sign-in-card-offers-the-portals-own-ways-in + +/** + * The ways in that name a route inside this site. + * + * @param {Array} ways `{id, label, href}` from the host. + * @return {Array<{id: string, label: string, href: string}>} The safe ways. + */ +export function safeWays(ways) { + return (Array.isArray(ways) ? ways : []) + .map((way) => ({ + id: String(way?.id ?? '').trim(), + label: String(way?.label ?? '').trim(), + href: String(way?.href ?? '').trim(), + })) + .filter( + (way) => + way.id !== '' + && way.label !== '' + && way.href.startsWith('/') + && !way.href.startsWith('//'), + ) +} + +/** + * The card's one button. + * + * Signed in: to the resident's own area. One way in: straight to it, in its + * own words unless the page words it. Several, or none known: to the sign-in + * page, where the visitor chooses. + * + * @param {object} options What the card knows. + * @param {Array} options.ways The safe ways. + * @param {boolean} options.signedIn Whether a session is held. + * @param {string} options.heading The card's heading. + * @param {string} options.buttonLabel The authored label. + * @param {string} options.signInHref The sign-in page route. + * @param {(key: string, vars?: object) => string} options.say The widget's words. + * @return {{label: string, href: string, route: string, id: string}} The button. + */ +export function cardButton({ + ways, + signedIn, + heading, + buttonLabel, + signInHref, + say, +}) { + const page = /^\/(?!\/)/.test(signInHref || '') ? signInHref : '/mijn' + if (signedIn) { + return { + id: 'own-area', + label: heading ? say('goTo', { name: heading }) : say('ownArea'), + href: '/mijn', + route: '/mijn', + } + } + if (ways.length === 1) { + return { + id: ways[0].id, + label: buttonLabel || ways[0].label, + href: ways[0].href, + route: '', + } + } + return { + id: 'choose', + label: buttonLabel || say('signIn'), + href: page, + route: page, + } +} diff --git a/src/site/widgets/nlSignIn/strings.js b/src/site/widgets/nlSignIn/strings.js new file mode 100644 index 000000000..fd64924e0 --- /dev/null +++ b/src/site/widgets/nlSignIn/strings.js @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 +// +// The words the sign-in widget says itself, by the page language. + +export default { + nl: { + signIn: 'Inloggen', + goTo: 'Naar {name}', + ownArea: 'Naar uw eigen omgeving', + none: 'Dit portaal heeft nog geen manier om in te loggen.', + }, + en: { + signIn: 'Log in', + goTo: 'Go to {name}', + ownArea: 'Go to your own area', + none: 'This portal has no way to log in yet.', + }, +} diff --git a/tests/site-school-blocks.spec.mjs b/tests/site-school-blocks.spec.mjs new file mode 100644 index 000000000..efbe7276d --- /dev/null +++ b/tests/site-school-blocks.spec.mjs @@ -0,0 +1,236 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: EUPL-1.2 +// Copyright (C) 2026 Conduction B.V. +// +// site-school-blocks.spec.mjs: the website blocks the school portals need +// (site-school-blocks), tested as the plain functions they decide with. +// +// Usage: +// node --test tests/site-school-blocks.spec.mjs +// +// @spec openspec/changes/site-school-blocks/specs/portaliq-cms/spec.md + +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' +import { + dayAndMonth, + dayLabel, + isPast, + longDate, + toDate, +} from '../src/site/components/mijn/dates.js' +import { authoredLink, staysInSite } from '../src/site/components/mijn/links.js' +import { eventRows } from '../src/site/widgets/nlEventList/events.js' +import { listLines } from '../src/site/widgets/nlList/lines.js' +import { splitLead } from '../src/site/widgets/nlNewsArticle/article.js' +import icons from '../src/site/widgets/nlQuickTasks/icons.js' +import { cardButton, safeWays } from '../src/site/widgets/nlSignIn/signIn.js' + +const MONDAY = new Date(2026, 9, 5, 9, 30) + +test('a date-only value is a calendar day and never shifts', () => { + assert.equal(toDate('2026-10-07').getDate(), 7) + assert.equal(toDate(''), null) + assert.equal(toDate('soon'), null) + assert.deepEqual(dayAndMonth('2026-10-07', 'nl'), { day: '7', month: 'okt' }) + assert.deepEqual(dayAndMonth('2026-10-07', 'en'), { day: '7', month: 'Oct' }) + assert.equal(longDate('2026-10-02', 'nl'), '2 oktober 2026') +}) + +test('a run of days reads as one label', () => { + assert.equal(dayLabel('2026-11-03', '', 'nl'), '3 nov') + assert.equal(dayLabel('2026-10-17', '2026-10-25', 'nl'), '17 - 25 okt') + assert.equal(dayLabel('2026-09-30', '2026-10-02', 'nl'), '30 sep - 2 okt') + assert.equal(dayLabel('x', '', 'nl'), '') +}) + +test('a day is over only after its last day', () => { + assert.equal(isPast('2026-10-04', '', MONDAY), true) + assert.equal(isPast('2026-10-05', '', MONDAY), false, 'today is not over') + assert.equal( + isPast('2026-10-01', '2026-10-09', MONDAY), + false, + 'a run that is still going', + ) +}) + +test('an authored link is a route in the site, an outside address, or nothing', () => { + assert.deepEqual(authoredLink('/praktisch'), { + href: '/praktisch', + route: '/praktisch', + }) + assert.deepEqual(authoredLink('https://example.org/a'), { + href: 'https://example.org/a', + route: '', + }) + assert.equal(authoredLink('mailto:info@example.org').route, '') + assert.equal(authoredLink('//evil.example'), null) + assert.equal(authoredLink('javascript:alert(1)'), null) + assert.equal(authoredLink(''), null) + + assert.equal(staysInSite({ button: 0 }, { route: '/a' }), true) + assert.equal(staysInSite({ button: 0, ctrlKey: true }, { route: '/a' }), false) + assert.equal(staysInSite({ button: 0 }, { route: '' }), false) +}) + +test('the dated list sorts, leaves out what is over, and cuts to the limit', () => { + const rows = eventRows( + [ + { date: '2026-10-29', title: 'Ouderavond', meta: 'Donderdag' }, + { date: '2026-10-07', title: 'Schoolfotograaf' }, + { date: '2026-10-01', title: 'Voorbij' }, + { + date: '2026-10-09', + title: 'Studiedag', + note: 'Nog 1 plek', + noteTone: 'warning', + }, + { date: 'nooit', title: 'Geen datum' }, + { date: '2026-10-08', title: '' }, + { + date: '2026-10-13', + endDate: '2026-10-15', + dateLabel: '13 en 15 okt', + title: 'Mentorgesprekken', + href: '/agenda', + }, + ], + { upcomingOnly: true, limit: 4, locale: 'nl', now: MONDAY }, + ) + assert.deepEqual( + rows.map((row) => row.title), + ['Schoolfotograaf', 'Studiedag', 'Mentorgesprekken', 'Ouderavond'], + ) + assert.equal(rows[1].tone, 'warning') + assert.equal(rows[2].label, '13 en 15 okt') + assert.equal(rows[2].link.route, '/agenda') + assert.equal(rows[0].label, '7 okt') + + const all = eventRows( + [{ date: '2026-10-01', title: 'Voorbij', noteTone: 'loud' }], + { upcomingOnly: false, limit: 99, now: MONDAY }, + ) + assert.equal(all.length, 1, 'the past stays when asked') + assert.equal(all[0].tone, 'neutral', 'an unknown tone is neutral') +}) + +test('list lines take a string or a title with a line', () => { + assert.deepEqual( + listLines([ + 'Een', + ' ', + { title: 'Twee', text: 'Uitleg' }, + { text: 'zonder titel' }, + null, + ]), + [ + { title: 'Een', text: '' }, + { title: 'Twee', text: 'Uitleg' }, + ], + ) + assert.deepEqual(listLines('niet een lijst'), []) +}) + +test('an article leads with its first paragraph unless that is not a paragraph', () => { + assert.deepEqual(splitLead('Eerste alinea\nloopt door.\n\nTweede.\n\n## Kop'), { + lead: 'Eerste alinea loopt door.', + rest: 'Tweede.\n\n## Kop', + }) + assert.deepEqual(splitLead('## Kop\n\nTekst'), { + lead: '', + rest: '## Kop\n\nTekst', + }) + assert.deepEqual(splitLead(''), { lead: '', rest: '' }) +}) + +test('every task icon is a path on the 24 grid with a name that says what it shows', () => { + for (const [name, path] of Object.entries(icons)) { + assert.match(name, /^[a-z][A-Za-z]+$/, name) + assert.match( + path, + /^M[\d.\s,MLHVCSQTAZmlhvcsqtaz-]+$/, + `${name} is not a path`, + ) + } + assert.ok(Object.keys(icons).length >= 18) +}) + +test('the sign-in card never offers a way the portal did not declare', () => { + const say = (key, vars) => + ({ + signIn: 'Inloggen', + goTo: `Naar ${vars?.name}`, + ownArea: 'Naar uw eigen omgeving', + })[key] + const digid = { + id: 'digid', + label: 'Inloggen met DigiD', + href: '/portal/api/session/oidc/start?provider=digid', + } + + assert.deepEqual( + safeWays([ + digid, + { id: 'x', label: 'Elders', href: 'https://evil.example/login' }, + { id: 'y', label: 'Ook', href: '//evil.example' }, + { id: '', label: 'Leeg', href: '/a' }, + ]), + [digid], + ) + + const one = cardButton({ + ways: [digid], + signedIn: false, + heading: 'Mijn Wilgenboom', + buttonLabel: '', + signInHref: '/mijn', + say, + }) + assert.equal(one.href, digid.href, 'one way in: straight to it') + assert.equal(one.label, 'Inloggen met DigiD') + + const several = cardButton({ + ways: [digid, { ...digid, id: 'nextcloud' }], + signedIn: false, + heading: '', + buttonLabel: '', + signInHref: 'https://evil.example', + say, + }) + assert.equal( + several.route, + '/mijn', + 'several: to the sign-in page, and an outside page is refused', + ) + assert.equal(several.label, 'Inloggen') + + const signedIn = cardButton({ + ways: [digid], + signedIn: true, + heading: 'Mijn Wilgenboom', + buttonLabel: 'Inloggen', + signInHref: '/mijn', + say, + }) + assert.deepEqual( + [signedIn.label, signedIn.route], + ['Naar Mijn Wilgenboom', '/mijn'], + ) +}) + +test('the grid hands the host data to the new widgets after the authored props', () => { + const source = readFileSync( + new URL('../src/site/components/WidgetGrid.vue', import.meta.url), + 'utf8', + ) + assert.match( + source, + /'nlNewsArticle'\s*\)\s*\{\s*return \{ \.\.\.props, portal: this\.portal, routeParam: this\.routeParam \}/, + ) + + assert.match( + source, + /widgetKey === 'nlSignIn'\) \{\s*return \{\s*\.\.\.props,\s*signedIn:[^}]*ways: this\.signInRoutes/, + ) +}) diff --git a/tests/widget-registry.spec.mjs b/tests/widget-registry.spec.mjs index fa5335dc6..128dac17e 100644 --- a/tests/widget-registry.spec.mjs +++ b/tests/widget-registry.spec.mjs @@ -19,6 +19,7 @@ import { coverageByPlacement, NLDS_COVERAGE, NLDS_PLACEMENTS, + SITE_COMPOSITIONS, } from '../src/site/widgets/coverage.js' import { loaders, @@ -287,7 +288,8 @@ test('every built widget is in the record as a widget, under its own key', () => // A widget that exists but is recorded as a part, or under another key, // would make the count read as covered while the palette offered // something else. - for (const key of Object.keys(loaders)) { + const composed = SITE_COMPOSITIONS.map((entry) => entry.key) + for (const key of Object.keys(loaders).filter((k) => !composed.includes(k))) { const recorded = NLDS_COVERAGE.filter((entry) => entry.key === key) assert.equal( recorded.length, @@ -306,3 +308,31 @@ test('every built widget is in the record as a widget, under its own key', () => ) } }) + +test('a composed widget is listed once, made of recorded components, and kept out of the record', () => { + // site-school-blocks: the school portals' blocks are compositions, not NL + // Design System components. They must be accounted for somewhere, and + // only in one place, or the 101-row record would drift from design D1. + const components = new Set(NLDS_COVERAGE.map((entry) => entry.component)) + const keys = SITE_COMPOSITIONS.map((entry) => entry.key) + assert.equal(new Set(keys).size, keys.length, 'a composition is listed twice') + for (const entry of SITE_COMPOSITIONS) { + assert.ok(Object.hasOwn(loaders, entry.key), `${entry.key} has no loader`) + assert.ok(Object.hasOwn(metas, entry.key), `${entry.key} has no meta`) + assert.equal( + NLDS_COVERAGE.some((row) => row.key === entry.key), + false, + `${entry.key} is in the NL Design System record as well`, + ) + assert.ok(entry.composes.length > 0 && entry.why.trim() !== '', `${entry.key} says nothing about what it is`) + for (const name of entry.composes) { + assert.ok(components.has(name), `${entry.key} names "${name}", which is not in the record`) + } + } + + // And every built widget is in exactly one of the two lists. + for (const key of Object.keys(loaders)) { + const inRecord = NLDS_COVERAGE.some((row) => row.key === key) + assert.ok(inRecord !== keys.includes(key), `${key} must be in exactly one of the record and the compositions`) + } +}) diff --git a/tests/widget-tokens.spec.mjs b/tests/widget-tokens.spec.mjs index 6ad6010e7..8326047eb 100644 --- a/tests/widget-tokens.spec.mjs +++ b/tests/widget-tokens.spec.mjs @@ -206,6 +206,15 @@ test('every widget with its own stylesheet is one design D5 names', () => { 'nlTabs', 'nlTaskNav', 'nlSignIn', + // site-school-blocks. Compositions with no single upstream component + // (SITE_COMPOSITIONS in coverage.js), and the numbered steps of + // `nlList`, which Utrecht's ordered list does not draw: layout and + // theme tokens only, which the two tests above hold them to. + 'nlList', + 'nlQuickTasks', + 'nlNewsList', + 'nlNewsArticle', + 'nlEventList', ] for (const { key, source } of components()) { From 808456a737eede8f8e7423267c402f2336fb951d Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 5 Oct 2026 15:54:53 +0200 Subject: [PATCH 03/31] fix(news): phpcs and phpmd on the public news reader and the staff news endpoints --- lib/Controller/NewsController.php | 23 +++++++++++++++++++++-- lib/Service/PublicNewsReader.php | 11 +++++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/lib/Controller/NewsController.php b/lib/Controller/NewsController.php index bb0039b27..52790b4cf 100644 --- a/lib/Controller/NewsController.php +++ b/lib/Controller/NewsController.php @@ -117,9 +117,20 @@ private function requireAuthenticatedStaff(): string { * * @spec openspec/changes/news-and-newsletter-authoring/specs/portaliq-cms/spec.md#requirement-a-newsitem-is-authored-per-school-group-or-child-and-tracks-read-receipts * @spec openspec/changes/site-school-blocks/specs/portaliq-cms/spec.md#requirement-a-news-item-shows-on-a-portals-public-website-only-when-staff-put-it-there + * + * @SuppressWarnings(PHPMD.BooleanArgumentFlag) -- `public` is a request parameter the framework + * binds by name; it is data the item stores, not a switch between two behaviours here. */ #[NoAdminRequired] - public function create(string $title, string $body, array $target, array $photoRefs=[], bool $public=false, string $portal='', string $audienceLabel=''): JSONResponse { + public function create( + string $title, + string $body, + array $target, + array $photoRefs=[], + bool $public=false, + string $portal='', + string $audienceLabel='' + ): JSONResponse { $authorRef = $this->requireAuthenticatedStaff(); if ($title === '' || $body === '' || $this->hasAnyTarget(target: $target) === false) { @@ -174,7 +185,15 @@ public function create(string $title, string $body, array $target, array $photoR * @spec openspec/changes/site-school-blocks/specs/portaliq-cms/spec.md#requirement-a-news-item-shows-on-a-portals-public-website-only-when-staff-put-it-there */ #[NoAdminRequired] - public function update(string $id, string $title, string $body, array $target, ?bool $public=null, string $portal='', string $audienceLabel=''): JSONResponse { + public function update( + string $id, + string $title, + string $body, + array $target, + ?bool $public=null, + string $portal='', + string $audienceLabel='' + ): JSONResponse { $this->requireAuthenticatedStaff(); if ($title === '' || $body === '' || $this->hasAnyTarget(target: $target) === false) { diff --git a/lib/Service/PublicNewsReader.php b/lib/Service/PublicNewsReader.php index e48bc7710..4bb4f430b 100644 --- a/lib/Service/PublicNewsReader.php +++ b/lib/Service/PublicNewsReader.php @@ -47,6 +47,9 @@ /** * Reads the public news of one portal. * + * @SuppressWarnings(PHPMD.StaticAccess) -- MediaReferences::isReference is the one test of a media + * reference the CMS uses; asking it statically keeps the rule in one place. + * * @spec openspec/changes/site-school-blocks/specs/portaliq-cms/spec.md#requirement-a-news-item-shows-on-a-portals-public-website-only-when-staff-put-it-there */ class PublicNewsReader { @@ -178,13 +181,17 @@ private function publicRows(string $portal): array { * @return array */ private function summary(string $portal, array $row): array { - $moment = $this->momentOf(row: $row); + $moment = $this->momentOf(row: $row); + $published = ''; + if ($moment !== PHP_INT_MIN) { + $published = gmdate('c', $moment); + } return [ 'id' => $this->rows->rowId(row: $row), 'title' => (string)($row['title'] ?? ''), 'intro' => self::introOf(body: (string)($row['body'] ?? '')), - 'publishedAt' => ($moment === PHP_INT_MIN) ? '' : gmdate('c', $moment), + 'publishedAt' => $published, 'audienceLabel' => trim((string)($row['audienceLabel'] ?? '')), 'image' => $this->imageOf(portal: $portal, row: $row), ]; From 735f62a12137246c1e3ad956af536650f2547344 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 5 Oct 2026 15:58:11 +0200 Subject: [PATCH 04/31] fix(site): the news widgets' mounted hooks name their requirement --- src/site/widgets/nlNewsArticle/NlNewsArticle.vue | 5 +++++ src/site/widgets/nlNewsList/NlNewsList.vue | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/src/site/widgets/nlNewsArticle/NlNewsArticle.vue b/src/site/widgets/nlNewsArticle/NlNewsArticle.vue index a09436f87..acb86e1e6 100644 --- a/src/site/widgets/nlNewsArticle/NlNewsArticle.vue +++ b/src/site/widgets/nlNewsArticle/NlNewsArticle.vue @@ -134,6 +134,11 @@ export default { routeParam: 'load', }, + /** + * Read the news once the widget is on the page. + * + * @spec openspec/changes/site-school-blocks/specs/portaliq-cms/spec.md#requirement-a-news-article-page-shows-one-public-item-chosen-by-the-route + */ mounted() { this.load() }, diff --git a/src/site/widgets/nlNewsList/NlNewsList.vue b/src/site/widgets/nlNewsList/NlNewsList.vue index 6123221e2..f49c6a739 100644 --- a/src/site/widgets/nlNewsList/NlNewsList.vue +++ b/src/site/widgets/nlNewsList/NlNewsList.vue @@ -204,6 +204,11 @@ export default { }, }, + /** + * Read the news once the widget is on the page. + * + * @spec openspec/changes/site-school-blocks/specs/portaliq-cms/spec.md#requirement-a-news-list-shows-the-news-staff-put-on-the-website + */ async mounted() { try { // One more than shown, so leaving out the item on screen still fills the list. From 89f6c571d6b2bc21175c5844f1eaa54182528f44 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 5 Oct 2026 16:02:11 +0200 Subject: [PATCH 05/31] wip(site-chrome): portal schema and content API serve the header search, the account button, the footer button and contact column, and the sign-in cards --- l10n/en.js | 55 +++++- l10n/en.json | 55 +++++- l10n/nl.js | 55 +++++- l10n/nl.json | 55 +++++- lib/Controller/PortalPageController.php | 15 +- lib/Service/Cms/PortalShell.php | 196 ++++++++++++++++++- lib/Service/PortalThemeResolver.php | 32 ++++ lib/Settings/portaliq_register.json | 209 +++++++++++++++++++++ node_modules | 1 + templates/site.php | 6 + tests/Unit/Service/Cms/PortalShellTest.php | 105 ++++++++++- vendor | 1 + 12 files changed, 777 insertions(+), 8 deletions(-) create mode 120000 node_modules create mode 120000 vendor diff --git a/l10n/en.js b/l10n/en.js index ceb283612..be33d0834 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -2077,7 +2077,60 @@ OC.L10N.register( "Add a page with route /, then publish it.": "Add a page with route /, then publish it.", "Open this page": "Open this page", "Open the pages of this portal": "Open the pages of this portal", - "The home page of this portal could not be checked.": "The home page of this portal could not be checked." + "The home page of this portal could not be checked.": "The home page of this portal could not be checked.", + "A box under the cards, for example what to do the first time.": "A box under the cards, for example what to do the first time.", + "A column of contact details beside the footer text: an address, a phone number, an e-mail address. A line with a link is shown as a link.": "A column of contact details beside the footer text: an address, a phone number, an e-mail address. A line with a link is shown as a link.", + "A line under the cards for staff, with a link to where they sign in.": "A line under the cards for staff, with a link to where they sign in.", + "A panel beside the cards with what the visitor finds after signing in.": "A panel beside the cards with what the visitor finds after signing in.", + "A search box in the header, on every page. It opens the portal's search page with what the visitor typed. Off unless switched on.": "A search box in the header, on every page. It opens the portal's search page with what the visitor typed. Off unless switched on.", + "A short line under the button, such as what you need to sign in.": "A short line under the button, such as what you need to sign in.", + "Button text": "Button text", + "Button to the own area": "Button to the own area", + "Card heading": "Card heading", + "Card text": "Card text", + "Column heading": "Column heading", + "Contact column": "Contact column", + "Footer button": "Footer button", + "Hint": "Hint", + "Hint in the box": "Hint in the box", + "Introduction": "Introduction", + "Lines": "Lines", + "Link for staff": "Link for staff", + "One button under the footer text, such as a link to the contact page. Left out when it has no label or no destination.": "One button under the footer text, such as a link to the contact page. Left out when it has no label or no destination.", + "One line more about it.": "One line more about it.", + "One or two sentences under the heading: who signs in how.": "One or two sentences under the heading: who signs in how.", + "Points": "Points", + "Search in the header": "Search in the header", + "Search page": "Search page", + "Show the search box": "Show the search box", + "Side panel": "Side panel", + "Sign-in cards": "Sign-in cards", + "Sign-in page": "Sign-in page", + "The heading above the lines, such as Contact.": "The heading above the lines, such as Contact.", + "The heading of the box.": "The heading of the box.", + "The heading of the panel.": "The heading of the panel.", + "The lines of the column, in order.": "The lines of the column, in order.", + "The name of an icon for the card. DigiD and eHerkenning show their own mark.": "The name of an icon for the card. DigiD and eHerkenning show their own mark.", + "The name of an icon for the point.": "The name of an icon for the point.", + "The page heading, such as Inloggen bij Mijn Wilgenboom. Empty shows Welkom.": "The page heading, such as Inloggen bij Mijn Wilgenboom. Empty shows Welkom.", + "The page of this portal that shows the results, such as /zoeken. Empty or not a page of this portal means /zoeken.": "The page of this portal that shows the results, such as /zoeken. Empty or not a page of this portal means /zoeken.", + "The point in a few words.": "The point in a few words.", + "The points in the panel.": "The points in the panel.", + "The sentence before the link, such as Werkt u bij De Wilgenboom?": "The sentence before the link, such as Werkt u bij De Wilgenboom?", + "The short text in the empty box, which is also its name for screen readers. Empty shows \"Zoeken\".": "The short text in the empty box, which is also its name for screen readers. Empty shows \"Zoeken\".", + "The text around the sign-in cards. Every part is optional.": "The text around the sign-in cards. Every part is optional.", + "The text of one button in the header that opens the visitor's own area, where they choose how to sign in. Empty shows a link for each way to sign in instead.": "The text of one button in the header that opens the visitor's own area, where they choose how to sign in. Empty shows a link for each way to sign in instead.", + "The text of the box.": "The text of the box.", + "The text of the card for each way to sign in on the sign-in page, keyed by mode (digid, nextcloud, eherkenning, oidc, local, eidas). A mode without a card shows one plain button.": "The text of the card for each way to sign in on the sign-in page, keyed by mode (digid, nextcloud, eherkenning, oidc, local, eidas). A mode without a card shows one plain button.", + "The text of the link.": "The text of the link.", + "The text of the sign-in button. Empty shows the standard text for this way in.": "The text of the sign-in button. Empty shows the standard text for this way in.", + "What the line says.": "What the line says.", + "What the visitor can do after signing in this way.": "What the visitor can do after signing in this way.", + "Where the line links to: a page of this portal, or a web, mail or phone address. Empty shows plain text.": "Where the line links to: a page of this portal, or a web, mail or phone address. Empty shows plain text.", + "Where the link goes.": "Where the link goes.", + "Where the link goes: a page of this portal such as /contact, or a full web, mail or phone address.": "Where the link goes: a page of this portal such as /contact, or a full web, mail or phone address.", + "Whether the header shows the search box.": "Whether the header shows the search box.", + "Who this way in is for, such as Ik ben leerling.": "Who this way in is for, such as Ik ben leerling." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/en.json b/l10n/en.json index 70a85ff6f..6b0a4011d 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -2088,7 +2088,60 @@ "Add a page with route /, then publish it.": "Add a page with route /, then publish it.", "Open this page": "Open this page", "Open the pages of this portal": "Open the pages of this portal", - "The home page of this portal could not be checked.": "The home page of this portal could not be checked." + "The home page of this portal could not be checked.": "The home page of this portal could not be checked.", + "A box under the cards, for example what to do the first time.": "A box under the cards, for example what to do the first time.", + "A column of contact details beside the footer text: an address, a phone number, an e-mail address. A line with a link is shown as a link.": "A column of contact details beside the footer text: an address, a phone number, an e-mail address. A line with a link is shown as a link.", + "A line under the cards for staff, with a link to where they sign in.": "A line under the cards for staff, with a link to where they sign in.", + "A panel beside the cards with what the visitor finds after signing in.": "A panel beside the cards with what the visitor finds after signing in.", + "A search box in the header, on every page. It opens the portal's search page with what the visitor typed. Off unless switched on.": "A search box in the header, on every page. It opens the portal's search page with what the visitor typed. Off unless switched on.", + "A short line under the button, such as what you need to sign in.": "A short line under the button, such as what you need to sign in.", + "Button text": "Button text", + "Button to the own area": "Button to the own area", + "Card heading": "Card heading", + "Card text": "Card text", + "Column heading": "Column heading", + "Contact column": "Contact column", + "Footer button": "Footer button", + "Hint": "Hint", + "Hint in the box": "Hint in the box", + "Introduction": "Introduction", + "Lines": "Lines", + "Link for staff": "Link for staff", + "One button under the footer text, such as a link to the contact page. Left out when it has no label or no destination.": "One button under the footer text, such as a link to the contact page. Left out when it has no label or no destination.", + "One line more about it.": "One line more about it.", + "One or two sentences under the heading: who signs in how.": "One or two sentences under the heading: who signs in how.", + "Points": "Points", + "Search in the header": "Search in the header", + "Search page": "Search page", + "Show the search box": "Show the search box", + "Side panel": "Side panel", + "Sign-in cards": "Sign-in cards", + "Sign-in page": "Sign-in page", + "The heading above the lines, such as Contact.": "The heading above the lines, such as Contact.", + "The heading of the box.": "The heading of the box.", + "The heading of the panel.": "The heading of the panel.", + "The lines of the column, in order.": "The lines of the column, in order.", + "The name of an icon for the card. DigiD and eHerkenning show their own mark.": "The name of an icon for the card. DigiD and eHerkenning show their own mark.", + "The name of an icon for the point.": "The name of an icon for the point.", + "The page heading, such as Inloggen bij Mijn Wilgenboom. Empty shows Welkom.": "The page heading, such as Inloggen bij Mijn Wilgenboom. Empty shows Welkom.", + "The page of this portal that shows the results, such as /zoeken. Empty or not a page of this portal means /zoeken.": "The page of this portal that shows the results, such as /zoeken. Empty or not a page of this portal means /zoeken.", + "The point in a few words.": "The point in a few words.", + "The points in the panel.": "The points in the panel.", + "The sentence before the link, such as Werkt u bij De Wilgenboom?": "The sentence before the link, such as Werkt u bij De Wilgenboom?", + "The short text in the empty box, which is also its name for screen readers. Empty shows \"Zoeken\".": "The short text in the empty box, which is also its name for screen readers. Empty shows \"Zoeken\".", + "The text around the sign-in cards. Every part is optional.": "The text around the sign-in cards. Every part is optional.", + "The text of one button in the header that opens the visitor's own area, where they choose how to sign in. Empty shows a link for each way to sign in instead.": "The text of one button in the header that opens the visitor's own area, where they choose how to sign in. Empty shows a link for each way to sign in instead.", + "The text of the box.": "The text of the box.", + "The text of the card for each way to sign in on the sign-in page, keyed by mode (digid, nextcloud, eherkenning, oidc, local, eidas). A mode without a card shows one plain button.": "The text of the card for each way to sign in on the sign-in page, keyed by mode (digid, nextcloud, eherkenning, oidc, local, eidas). A mode without a card shows one plain button.", + "The text of the link.": "The text of the link.", + "The text of the sign-in button. Empty shows the standard text for this way in.": "The text of the sign-in button. Empty shows the standard text for this way in.", + "What the line says.": "What the line says.", + "What the visitor can do after signing in this way.": "What the visitor can do after signing in this way.", + "Where the line links to: a page of this portal, or a web, mail or phone address. Empty shows plain text.": "Where the line links to: a page of this portal, or a web, mail or phone address. Empty shows plain text.", + "Where the link goes.": "Where the link goes.", + "Where the link goes: a page of this portal such as /contact, or a full web, mail or phone address.": "Where the link goes: a page of this portal such as /contact, or a full web, mail or phone address.", + "Whether the header shows the search box.": "Whether the header shows the search box.", + "Who this way in is for, such as Ik ben leerling.": "Who this way in is for, such as Ik ben leerling." }, "plurals": "", "pluralForm": "nplurals=2; plural=(n != 1);" diff --git a/l10n/nl.js b/l10n/nl.js index e38fc85bc..7f94a213a 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -2142,7 +2142,60 @@ OC.L10N.register( "Add a page with route /, then publish it.": "Voeg een pagina toe met route /, en publiceer die.", "Open this page": "Deze pagina openen", "Open the pages of this portal": "De pagina's van dit portaal openen", - "The home page of this portal could not be checked.": "De startpagina van dit portaal kon niet worden gecontroleerd." + "The home page of this portal could not be checked.": "De startpagina van dit portaal kon niet worden gecontroleerd.", + "A box under the cards, for example what to do the first time.": "Een vak onder de kaarten, bijvoorbeeld wat u de eerste keer doet.", + "A column of contact details beside the footer text: an address, a phone number, an e-mail address. A line with a link is shown as a link.": "Een kolom met contactgegevens naast de tekst in de voettekst: een adres, een telefoonnummer, een e-mailadres. Een regel met een link wordt als link getoond.", + "A line under the cards for staff, with a link to where they sign in.": "Een regel onder de kaarten voor medewerkers, met een link naar de plek waar zij inloggen.", + "A panel beside the cards with what the visitor finds after signing in.": "Een vak naast de kaarten met wat de bezoeker vindt na het inloggen.", + "A search box in the header, on every page. It opens the portal's search page with what the visitor typed. Off unless switched on.": "Een zoekvak in de kop, op elke pagina. Het opent de zoekpagina van het portaal met wat de bezoeker typte. Uit tot u het aanzet.", + "A short line under the button, such as what you need to sign in.": "Een korte regel onder de knop, bijvoorbeeld wat u nodig heeft om in te loggen.", + "Button text": "Tekst op de knop", + "Button to the own area": "Knop naar de eigen omgeving", + "Card heading": "Kop van de kaart", + "Card text": "Tekst van de kaart", + "Column heading": "Kop van de kolom", + "Contact column": "Kolom met contactgegevens", + "Footer button": "Knop in de voettekst", + "Hint": "Hint", + "Hint in the box": "Hint in het vak", + "Introduction": "Inleiding", + "Lines": "Regels", + "Link for staff": "Link voor medewerkers", + "One button under the footer text, such as a link to the contact page. Left out when it has no label or no destination.": "Eén knop onder de tekst in de voettekst, bijvoorbeeld naar de contactpagina. Weggelaten als de tekst of de bestemming ontbreekt.", + "One line more about it.": "Eén regel meer erover.", + "One or two sentences under the heading: who signs in how.": "Een of twee zinnen onder de kop: wie logt hoe in.", + "Points": "Punten", + "Search in the header": "Zoeken in de kop", + "Search page": "Zoekpagina", + "Show the search box": "Zoekvak tonen", + "Side panel": "Zijvak", + "Sign-in cards": "Inlogkaarten", + "Sign-in page": "Inlogpagina", + "The heading above the lines, such as Contact.": "De kop boven de regels, bijvoorbeeld Contact.", + "The heading of the box.": "De kop van het vak.", + "The heading of the panel.": "De kop van het vak.", + "The lines of the column, in order.": "De regels van de kolom, op volgorde.", + "The name of an icon for the card. DigiD and eHerkenning show their own mark.": "De naam van een pictogram voor de kaart. DigiD en eHerkenning tonen hun eigen logo.", + "The name of an icon for the point.": "De naam van een pictogram voor het punt.", + "The page heading, such as Inloggen bij Mijn Wilgenboom. Empty shows Welkom.": "De kop van de pagina, bijvoorbeeld Inloggen bij Mijn Wilgenboom. Leeg toont Welkom.", + "The page of this portal that shows the results, such as /zoeken. Empty or not a page of this portal means /zoeken.": "De pagina van dit portaal die de resultaten toont, bijvoorbeeld /zoeken. Leeg of geen pagina van dit portaal betekent /zoeken.", + "The point in a few words.": "Het punt in een paar woorden.", + "The points in the panel.": "De punten in het vak.", + "The sentence before the link, such as Werkt u bij De Wilgenboom?": "De zin voor de link, bijvoorbeeld Werkt u bij De Wilgenboom?", + "The short text in the empty box, which is also its name for screen readers. Empty shows \"Zoeken\".": "De korte tekst in het lege vak, die ook de naam voor schermlezers is. Leeg toont \"Zoeken\".", + "The text around the sign-in cards. Every part is optional.": "De tekst rond de inlogkaarten. Elk deel mag leeg blijven.", + "The text of one button in the header that opens the visitor's own area, where they choose how to sign in. Empty shows a link for each way to sign in instead.": "De tekst van één knop in de kop die de eigen omgeving van de bezoeker opent, waar die kiest hoe hij inlogt. Leeg toont in plaats daarvan een link voor elke manier van inloggen.", + "The text of the box.": "De tekst van het vak.", + "The text of the card for each way to sign in on the sign-in page, keyed by mode (digid, nextcloud, eherkenning, oidc, local, eidas). A mode without a card shows one plain button.": "De tekst van de kaart voor elke manier van inloggen op de inlogpagina, per manier (digid, nextcloud, eherkenning, oidc, local, eidas). Een manier zonder kaart toont één gewone knop.", + "The text of the link.": "De tekst van de link.", + "The text of the sign-in button. Empty shows the standard text for this way in.": "De tekst van de inlogknop. Leeg toont de gewone tekst voor deze manier van inloggen.", + "What the line says.": "Wat de regel zegt.", + "What the visitor can do after signing in this way.": "Wat de bezoeker kan doen na het inloggen op deze manier.", + "Where the line links to: a page of this portal, or a web, mail or phone address. Empty shows plain text.": "Waar de regel naar linkt: een pagina van dit portaal, of een web-, mail- of telefoonadres. Leeg toont gewone tekst.", + "Where the link goes.": "Waar de link naartoe gaat.", + "Where the link goes: a page of this portal such as /contact, or a full web, mail or phone address.": "Waar de link naartoe gaat: een pagina van dit portaal zoals /contact, of een volledig web-, mail- of telefoonadres.", + "Whether the header shows the search box.": "Of de kop het zoekvak toont.", + "Who this way in is for, such as Ik ben leerling.": "Voor wie deze manier van inloggen is, bijvoorbeeld Ik ben leerling." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nl.json b/l10n/nl.json index 0535a47c4..de6b7ce21 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -2153,7 +2153,60 @@ "Add a page with route /, then publish it.": "Voeg een pagina toe met route /, en publiceer die.", "Open this page": "Deze pagina openen", "Open the pages of this portal": "De pagina's van dit portaal openen", - "The home page of this portal could not be checked.": "De startpagina van dit portaal kon niet worden gecontroleerd." + "The home page of this portal could not be checked.": "De startpagina van dit portaal kon niet worden gecontroleerd.", + "A box under the cards, for example what to do the first time.": "Een vak onder de kaarten, bijvoorbeeld wat u de eerste keer doet.", + "A column of contact details beside the footer text: an address, a phone number, an e-mail address. A line with a link is shown as a link.": "Een kolom met contactgegevens naast de tekst in de voettekst: een adres, een telefoonnummer, een e-mailadres. Een regel met een link wordt als link getoond.", + "A line under the cards for staff, with a link to where they sign in.": "Een regel onder de kaarten voor medewerkers, met een link naar de plek waar zij inloggen.", + "A panel beside the cards with what the visitor finds after signing in.": "Een vak naast de kaarten met wat de bezoeker vindt na het inloggen.", + "A search box in the header, on every page. It opens the portal's search page with what the visitor typed. Off unless switched on.": "Een zoekvak in de kop, op elke pagina. Het opent de zoekpagina van het portaal met wat de bezoeker typte. Uit tot u het aanzet.", + "A short line under the button, such as what you need to sign in.": "Een korte regel onder de knop, bijvoorbeeld wat u nodig heeft om in te loggen.", + "Button text": "Tekst op de knop", + "Button to the own area": "Knop naar de eigen omgeving", + "Card heading": "Kop van de kaart", + "Card text": "Tekst van de kaart", + "Column heading": "Kop van de kolom", + "Contact column": "Kolom met contactgegevens", + "Footer button": "Knop in de voettekst", + "Hint": "Hint", + "Hint in the box": "Hint in het vak", + "Introduction": "Inleiding", + "Lines": "Regels", + "Link for staff": "Link voor medewerkers", + "One button under the footer text, such as a link to the contact page. Left out when it has no label or no destination.": "Eén knop onder de tekst in de voettekst, bijvoorbeeld naar de contactpagina. Weggelaten als de tekst of de bestemming ontbreekt.", + "One line more about it.": "Eén regel meer erover.", + "One or two sentences under the heading: who signs in how.": "Een of twee zinnen onder de kop: wie logt hoe in.", + "Points": "Punten", + "Search in the header": "Zoeken in de kop", + "Search page": "Zoekpagina", + "Show the search box": "Zoekvak tonen", + "Side panel": "Zijvak", + "Sign-in cards": "Inlogkaarten", + "Sign-in page": "Inlogpagina", + "The heading above the lines, such as Contact.": "De kop boven de regels, bijvoorbeeld Contact.", + "The heading of the box.": "De kop van het vak.", + "The heading of the panel.": "De kop van het vak.", + "The lines of the column, in order.": "De regels van de kolom, op volgorde.", + "The name of an icon for the card. DigiD and eHerkenning show their own mark.": "De naam van een pictogram voor de kaart. DigiD en eHerkenning tonen hun eigen logo.", + "The name of an icon for the point.": "De naam van een pictogram voor het punt.", + "The page heading, such as Inloggen bij Mijn Wilgenboom. Empty shows Welkom.": "De kop van de pagina, bijvoorbeeld Inloggen bij Mijn Wilgenboom. Leeg toont Welkom.", + "The page of this portal that shows the results, such as /zoeken. Empty or not a page of this portal means /zoeken.": "De pagina van dit portaal die de resultaten toont, bijvoorbeeld /zoeken. Leeg of geen pagina van dit portaal betekent /zoeken.", + "The point in a few words.": "Het punt in een paar woorden.", + "The points in the panel.": "De punten in het vak.", + "The sentence before the link, such as Werkt u bij De Wilgenboom?": "De zin voor de link, bijvoorbeeld Werkt u bij De Wilgenboom?", + "The short text in the empty box, which is also its name for screen readers. Empty shows \"Zoeken\".": "De korte tekst in het lege vak, die ook de naam voor schermlezers is. Leeg toont \"Zoeken\".", + "The text around the sign-in cards. Every part is optional.": "De tekst rond de inlogkaarten. Elk deel mag leeg blijven.", + "The text of one button in the header that opens the visitor's own area, where they choose how to sign in. Empty shows a link for each way to sign in instead.": "De tekst van één knop in de kop die de eigen omgeving van de bezoeker opent, waar die kiest hoe hij inlogt. Leeg toont in plaats daarvan een link voor elke manier van inloggen.", + "The text of the box.": "De tekst van het vak.", + "The text of the card for each way to sign in on the sign-in page, keyed by mode (digid, nextcloud, eherkenning, oidc, local, eidas). A mode without a card shows one plain button.": "De tekst van de kaart voor elke manier van inloggen op de inlogpagina, per manier (digid, nextcloud, eherkenning, oidc, local, eidas). Een manier zonder kaart toont één gewone knop.", + "The text of the link.": "De tekst van de link.", + "The text of the sign-in button. Empty shows the standard text for this way in.": "De tekst van de inlogknop. Leeg toont de gewone tekst voor deze manier van inloggen.", + "What the line says.": "Wat de regel zegt.", + "What the visitor can do after signing in this way.": "Wat de bezoeker kan doen na het inloggen op deze manier.", + "Where the line links to: a page of this portal, or a web, mail or phone address. Empty shows plain text.": "Waar de regel naar linkt: een pagina van dit portaal, of een web-, mail- of telefoonadres. Leeg toont gewone tekst.", + "Where the link goes.": "Waar de link naartoe gaat.", + "Where the link goes: a page of this portal such as /contact, or a full web, mail or phone address.": "Waar de link naartoe gaat: een pagina van dit portaal zoals /contact, of een volledig web-, mail- of telefoonadres.", + "Whether the header shows the search box.": "Of de kop het zoekvak toont.", + "Who this way in is for, such as Ik ben leerling.": "Voor wie deze manier van inloggen is, bijvoorbeeld Ik ben leerling." }, "plurals": "", "pluralForm": "nplurals=2; plural=(n != 1);" diff --git a/lib/Controller/PortalPageController.php b/lib/Controller/PortalPageController.php index 3f6bf794d..8c9dd2652 100644 --- a/lib/Controller/PortalPageController.php +++ b/lib/Controller/PortalPageController.php @@ -265,6 +265,9 @@ public function site(): TemplateResponse { // contract withholds; it only decides which stylesheet tag to emit. 'themeStylesheet' => $this->siteThemeStylesheet(), 'themeLogoUrl' => $this->siteThemeLogoUrl(), + // The set's light logo for the dark footer band, or '' + // (site-chrome-follows-the-design). + 'themeLogoInverseUrl' => $this->siteThemeLogoUrl(inverse: true), 'themeAppSheets' => $this->siteThemeAppSheets(), // The NLDS token set this app ships for the serving portal's // theme, when it has one. Separate from the line above because @@ -576,11 +579,17 @@ private function siteThemeAppSheets(): array { * So the resolution happens here, where the theme app's real path is * known, and the template emits the result after the token stylesheets. * + * With `$inverse` it is the set's light logo, for the dark footer band + * (site-chrome-follows-the-design), or '' when the set ships none. + * + * @param bool $inverse Whether the light logo is asked for. + * * @return string An absolute URL, or '' when there is no logo to serve. * * @spec openspec/specs/portaliq-cms/spec.md#requirement-a-portals-theme-must-change-what-a-visitor-sees + * @spec openspec/changes/site-chrome-follows-the-design/specs/site-chrome/spec.md#requirement-the-footer-must-carry-the-motif-the-light-logo-and-the-brand-column-first */ - private function siteThemeLogoUrl(): string { + private function siteThemeLogoUrl(bool $inverse=false): string { $stylesheet = $this->siteThemeStylesheet(); if ($stylesheet === '') { return ''; @@ -594,6 +603,10 @@ private function siteThemeLogoUrl(): string { try { $relative = $this->themeResolver->logoFileFor(theme: $theme); + if ($inverse === true) { + $relative = $this->themeResolver->inverseLogoFileFor(theme: $theme); + } + if ($relative === null) { return ''; } diff --git a/lib/Service/Cms/PortalShell.php b/lib/Service/Cms/PortalShell.php index b54e86014..44d6adb8f 100644 --- a/lib/Service/Cms/PortalShell.php +++ b/lib/Service/Cms/PortalShell.php @@ -39,6 +39,12 @@ class PortalShell { */ public const HEADER_VARIANTS = ['double', 'single']; + /** + * The ways in a sign-in card may be written for (the portal's modes + * besides `public`). + */ + public const SIGN_IN_MODES = ['nextcloud', 'local', 'oidc', 'digid', 'eherkenning', 'eidas']; + /** * Constructor. * @@ -54,19 +60,51 @@ public function __construct( * * @param array $portal The portal record. * - * @return array `authentication`, `headerVariant`, `footer` and `regions`. + * @return array `authentication`, `headerVariant`, `headerSearch`, `accountLabel`, `footer` and `regions`. * * @spec openspec/changes/portal-theme-blocks-and-contributed-pages/specs/portaliq-cms/spec.md#requirement-the-header-must-be-a-block-whose-shape-the-portal-chooses-req-ptb-004 + * @spec openspec/changes/site-chrome-follows-the-design/specs/site-chrome/spec.md#requirement-the-header-must-carry-the-search-box-and-one-way-to-the-own-area */ public function project(array $portal): array { return [ 'authentication' => $this->authentication(portal: $portal), 'headerVariant' => $this->headerVariant(portal: $portal), + 'headerSearch' => $this->headerSearch(portal: $portal), + 'accountLabel' => $this->text(value: ($portal['accountLabel'] ?? '')), 'footer' => $this->footer(portal: $portal), 'regions' => $this->publicRegions(portal: $portal), ]; }//end project() + /** + * The search box in the header: whether it shows, its hint and the + * portal's search page. A route that is not an in-site path falls back to + * `/zoeken`, the page the hero search has always opened. + * + * @param array $portal The portal record. + * + * @return array{enabled: bool, placeholder: string, route: string} The box. + * + * @spec openspec/changes/site-chrome-follows-the-design/specs/site-chrome/spec.md#requirement-the-header-must-carry-the-search-box-and-one-way-to-the-own-area + */ + public function headerSearch(array $portal): array { + $search = $portal['headerSearch'] ?? []; + if (is_array($search) === false) { + $search = []; + } + + $route = $this->text(value: ($search['route'] ?? '')); + if (preg_match('#^/(?!/)#', $route) !== 1) { + $route = '/zoeken'; + } + + return [ + 'enabled' => ($search['enabled'] ?? false) === true, + 'placeholder' => $this->text(value: ($search['placeholder'] ?? '')), + 'route' => $route, + ]; + }//end headerSearch() + /** * The header variant: the portal's choice when it is known, else `double`. * @@ -96,6 +134,7 @@ public function headerVariant(array $portal): string { * @return array `{modes, register?, registerLabel?}`. * * @spec openspec/changes/portal-theme-blocks-and-contributed-pages/specs/portaliq-cms/spec.md#requirement-the-header-must-be-a-block-whose-shape-the-portal-chooses-req-ptb-004 + * @spec openspec/changes/site-chrome-follows-the-design/specs/site-chrome/spec.md#requirement-the-sign-in-page-must-offer-each-way-in-as-a-card-for-its-role */ public function authentication(array $portal): array { $auth = (array)($portal['authentication'] ?? []); @@ -108,9 +147,120 @@ public function authentication(array $portal): array { } } + $labels = $this->modeLabels(labels: ($auth['modeLabels'] ?? [])); + if ($labels !== []) { + $public['modeLabels'] = $labels; + } + + $page = $this->signInPage(page: ($auth['signInPage'] ?? [])); + if ($page !== []) { + $public['signInPage'] = $page; + } + return $public; }//end authentication() + /** + * The sign-in card per way in: title, text, button, hint and icon, each + * plain text. A mode the portal could not offer is dropped; so is a card + * that says nothing. + * + * @param mixed $labels The authored map, mode to card. + * + * @return array> Mode to `{title?, text?, button?, hint?, icon?}`. + * + * @spec openspec/changes/site-chrome-follows-the-design/specs/site-chrome/spec.md#requirement-the-sign-in-page-must-offer-each-way-in-as-a-card-for-its-role + */ + public function modeLabels(mixed $labels): array { + if (is_array($labels) === false) { + return []; + } + + $kept = []; + foreach (self::SIGN_IN_MODES as $mode) { + $card = $labels[$mode] ?? null; + if (is_array($card) === false) { + continue; + } + + $texts = $this->texts(source: $card, keys: ['title', 'text', 'button', 'hint', 'icon']); + if ($texts !== []) { + $kept[$mode] = $texts; + } + } + + return $kept; + }//end modeLabels() + + /** + * The text around the sign-in cards: the page's title and intro, a + * notice under the cards, a line for staff with its link, and a side + * panel of points. Plain text; a link only when it can be followed. + * + * @param mixed $page The authored block. + * + * @return array The parts that say something. + * + * @spec openspec/changes/site-chrome-follows-the-design/specs/site-chrome/spec.md#requirement-the-sign-in-page-must-offer-each-way-in-as-a-card-for-its-role + */ + public function signInPage(mixed $page): array { + if (is_array($page) === false) { + return []; + } + + $kept = $this->texts(source: $page, keys: ['title', 'intro']); + + $notice = (is_array($page['notice'] ?? null) === true) ? $this->texts(source: $page['notice'], keys: ['title', 'text']) : []; + if (($notice['text'] ?? '') !== '') { + $kept['notice'] = $notice; + } + + $staff = (is_array($page['staffLink'] ?? null) === true) ? $this->texts(source: $page['staffLink'], keys: ['text', 'label', 'href']) : []; + if (($staff['label'] ?? '') !== '' && $this->followable(href: ($staff['href'] ?? '')) === true) { + $kept['staffLink'] = $staff; + } + + $panel = $page['panel'] ?? null; + if (is_array($panel) === true) { + $items = []; + foreach ((array)($panel['items'] ?? []) as $item) { + $texts = (is_array($item) === true) ? $this->texts(source: $item, keys: ['title', 'text', 'icon']) : []; + if (($texts['title'] ?? '') !== '') { + $items[] = $texts; + } + } + + $title = $this->text(value: ($panel['title'] ?? '')); + if ($title !== '' || $items !== []) { + $kept['panel'] = ['title' => $title, 'items' => $items]; + } + } + + return $kept; + }//end signInPage() + + /** + * The named keys of an authored block that hold text, trimmed. + * + * @param array $source The block. + * @param list $keys The keys to keep. + * + * @return array The keys that say something. + * + * @spec openspec/changes/site-chrome-follows-the-design/specs/site-chrome/spec.md#requirement-the-sign-in-page-must-offer-each-way-in-as-a-card-for-its-role + */ + private function texts(array $source, array $keys): array { + $kept = []; + foreach ($keys as $key) { + $value = $this->text(value: ($source[$key] ?? '')); + if ($value !== '') { + $kept[$key] = $value; + } + } + + return $kept; + }//end texts() + /** * The portal's footer content, on named keys only. * @@ -120,9 +270,10 @@ public function authentication(array $portal): array { * * @param array $portal The portal record. * - * @return array `{description, colophon, socials, legalLinks, badges}`; each list holds `{label, href}` entries. + * @return array `{description, colophon, socials, legalLinks, badges, cta, contact}`; each list holds `{label, href}` entries. * * @spec openspec/changes/portal-theme-blocks-and-contributed-pages/specs/portaliq-cms/spec.md#requirement-the-footer-must-be-a-block-whose-bands-are-styled-by-role-req-ptb-005 + * @spec openspec/changes/site-chrome-follows-the-design/specs/site-chrome/spec.md#requirement-the-footer-must-carry-the-motif-the-light-logo-and-the-brand-column-first */ public function footer(array $portal): array { $footer = $portal['footer'] ?? []; @@ -130,15 +281,56 @@ public function footer(array $portal): array { $footer = []; } + $cta = $this->links(entries: [($footer['cta'] ?? null)], extra: null); + return [ 'description' => $this->text(value: ($footer['description'] ?? '')), 'colophon' => $this->text(value: ($footer['colophon'] ?? '')), 'socials' => $this->links(entries: ($footer['socials'] ?? []), extra: 'icon'), 'legalLinks' => $this->links(entries: ($footer['legalLinks'] ?? []), extra: null), 'badges' => $this->links(entries: ($footer['badges'] ?? []), extra: null), + 'cta' => ($cta[0] ?? null), + 'contact' => $this->contact(contact: ($footer['contact'] ?? null)), ]; }//end footer() + /** + * The footer's contact column: a title and plain lines, a line with a + * followable `href` rendered as a link. Null when it has no line. + * + * @param mixed $contact The authored column. + * + * @return array{title: string, lines: list>}|null The column. + * + * @spec openspec/changes/site-chrome-follows-the-design/specs/site-chrome/spec.md#requirement-the-footer-must-carry-the-motif-the-light-logo-and-the-brand-column-first + */ + public function contact(mixed $contact): ?array { + if (is_array($contact) === false) { + return null; + } + + $lines = []; + foreach ((array)($contact['lines'] ?? []) as $line) { + if (is_array($line) === false) { + continue; + } + + $text = $this->text(value: ($line['text'] ?? '')); + if ($text === '') { + continue; + } + + $href = $this->text(value: ($line['href'] ?? '')); + $lines[] = ($this->followable(href: $href) === true) ? ['text' => $text, 'href' => $href] : ['text' => $text]; + } + + if ($lines === []) { + return null; + } + + return ['title' => $this->text(value: ($contact['title'] ?? '')), 'lines' => $lines]; + }//end contact() + /** * The portal's own region contents, the middle step of resolution. * diff --git a/lib/Service/PortalThemeResolver.php b/lib/Service/PortalThemeResolver.php index 95e2400be..94bafcb17 100644 --- a/lib/Service/PortalThemeResolver.php +++ b/lib/Service/PortalThemeResolver.php @@ -221,6 +221,38 @@ public function logoFileFor(string $theme): ?string { }//end logoFileFor() + /** + * The theme app path of a set's light logo, for a dark band, or null. + * + * The footer of a school portal is a dark band, and the set's own logo + * (dark ink) disappears on it. Every school set ships a light variant as + * `img/logos/-dark.svg` (named after the dark scheme it was made + * for). The bridge shows it on the footer through + * `--nldesign-logo-inverse-url` (thematiq brand-motif-on-portals); a set + * without one keeps its own logo there, as before. + * + * @param string $theme The portal's theme reference. + * + * @return string|null The path relative to the theme app, or null. + * + * @spec openspec/changes/site-chrome-follows-the-design/specs/site-chrome/spec.md#requirement-the-footer-must-carry-the-motif-the-light-logo-and-the-brand-column-first + */ + public function inverseLogoFileFor(string $theme): ?string { + $own = $this->logoFileFor(theme: $theme); + $root = $this->themeAppPath(); + if ($own === null || $root === null) { + return null; + } + + $relative = 'img/logos/' . $theme . '-dark.svg'; + if (is_file($root . '/' . $relative) === false) { + return null; + } + + return $relative; + }//end inverseLogoFileFor() + + /** * Whether the theme app's catalogue offers this set. * diff --git a/lib/Settings/portaliq_register.json b/lib/Settings/portaliq_register.json index e91e8492b..56ff50dd0 100644 --- a/lib/Settings/portaliq_register.json +++ b/lib/Settings/portaliq_register.json @@ -2356,6 +2356,56 @@ } } } + }, + "cta": { + "title": "Footer button", + "type": "object", + "description": "One button under the footer text, such as a link to the contact page. Left out when it has no label or no destination.", + "properties": { + "label": { + "title": "Label", + "type": "string", + "description": "The text a visitor reads or a screen reader announces." + }, + "href": { + "title": "Link", + "type": "string", + "description": "Where the link goes: a page of this portal such as /contact, or a full web, mail or phone address." + } + } + }, + "contact": { + "title": "Contact column", + "type": "object", + "description": "A column of contact details beside the footer text: an address, a phone number, an e-mail address. A line with a link is shown as a link.", + "properties": { + "title": { + "title": "Column heading", + "type": "string", + "description": "The heading above the lines, such as Contact.", + "example": "Contact" + }, + "lines": { + "title": "Lines", + "type": "array", + "description": "The lines of the column, in order.", + "items": { + "type": "object", + "properties": { + "text": { + "title": "Text", + "type": "string", + "description": "What the line says." + }, + "href": { + "title": "Link", + "type": "string", + "description": "Where the line links to: a page of this portal, or a web, mail or phone address. Empty shows plain text." + } + } + } + } + } } } }, @@ -2728,6 +2778,37 @@ ], "example": "double" }, + "headerSearch": { + "title": "Search in the header", + "type": "object", + "description": "A search box in the header, on every page. It opens the portal's search page with what the visitor typed. Off unless switched on.", + "properties": { + "enabled": { + "title": "Show the search box", + "type": "boolean", + "description": "Whether the header shows the search box.", + "example": true + }, + "placeholder": { + "title": "Hint in the box", + "type": "string", + "description": "The short text in the empty box, which is also its name for screen readers. Empty shows \"Zoeken\".", + "example": "Zoek een cursus" + }, + "route": { + "title": "Search page", + "type": "string", + "description": "The page of this portal that shows the results, such as /zoeken. Empty or not a page of this portal means /zoeken.", + "example": "/zoeken" + } + } + }, + "accountLabel": { + "title": "Button to the own area", + "type": "string", + "description": "The text of one button in the header that opens the visitor's own area, where they choose how to sign in. Empty shows a link for each way to sign in instead.", + "example": "Mijn Wilgenboom" + }, "locales": { "title": "Locales", "type": "array", @@ -2863,6 +2944,134 @@ "type": "string", "description": "The text on the register button. Empty shows Registreren.", "example": "Account aanmaken" + }, + "modeLabels": { + "title": "Sign-in cards", + "type": "object", + "description": "The text of the card for each way to sign in on the sign-in page, keyed by mode (digid, nextcloud, eherkenning, oidc, local, eidas). A mode without a card shows one plain button.", + "additionalProperties": { + "type": "object", + "properties": { + "title": { + "title": "Card heading", + "type": "string", + "description": "Who this way in is for, such as Ik ben leerling." + }, + "text": { + "title": "Card text", + "type": "string", + "description": "What the visitor can do after signing in this way." + }, + "button": { + "title": "Button text", + "type": "string", + "description": "The text of the sign-in button. Empty shows the standard text for this way in." + }, + "hint": { + "title": "Hint", + "type": "string", + "description": "A short line under the button, such as what you need to sign in." + }, + "icon": { + "title": "Icon", + "type": "string", + "description": "The name of an icon for the card. DigiD and eHerkenning show their own mark." + } + } + } + }, + "signInPage": { + "title": "Sign-in page", + "type": "object", + "description": "The text around the sign-in cards. Every part is optional.", + "properties": { + "title": { + "title": "Heading", + "type": "string", + "description": "The page heading, such as Inloggen bij Mijn Wilgenboom. Empty shows Welkom." + }, + "intro": { + "title": "Introduction", + "type": "string", + "description": "One or two sentences under the heading: who signs in how." + }, + "notice": { + "title": "Notice", + "type": "object", + "description": "A box under the cards, for example what to do the first time.", + "properties": { + "title": { + "title": "Heading", + "type": "string", + "description": "The heading of the box." + }, + "text": { + "title": "Text", + "type": "string", + "description": "The text of the box." + } + } + }, + "staffLink": { + "title": "Link for staff", + "type": "object", + "description": "A line under the cards for staff, with a link to where they sign in.", + "properties": { + "text": { + "title": "Text", + "type": "string", + "description": "The sentence before the link, such as Werkt u bij De Wilgenboom?" + }, + "label": { + "title": "Link text", + "type": "string", + "description": "The text of the link." + }, + "href": { + "title": "Link", + "type": "string", + "description": "Where the link goes." + } + } + }, + "panel": { + "title": "Side panel", + "type": "object", + "description": "A panel beside the cards with what the visitor finds after signing in.", + "properties": { + "title": { + "title": "Heading", + "type": "string", + "description": "The heading of the panel." + }, + "items": { + "title": "Points", + "type": "array", + "description": "The points in the panel.", + "items": { + "type": "object", + "properties": { + "title": { + "title": "Heading", + "type": "string", + "description": "The point in a few words." + }, + "text": { + "title": "Text", + "type": "string", + "description": "One line more about it." + }, + "icon": { + "title": "Icon", + "type": "string", + "description": "The name of an icon for the point." + } + } + } + } + } + } + } } } }, diff --git a/node_modules b/node_modules new file mode 120000 index 000000000..32eb98175 --- /dev/null +++ b/node_modules @@ -0,0 +1 @@ +/home/rubenlinde/memcap-work/ui-open/portaliq/node_modules \ No newline at end of file diff --git a/templates/site.php b/templates/site.php index b35fe8521..5d1672568 100644 --- a/templates/site.php +++ b/templates/site.php @@ -149,6 +149,9 @@ // // So the app that knows where the theme app lives resolves it, once, here. $themeLogoUrl = (string)($_['themeLogoUrl'] ?? ''); +// The set's light logo for the dark footer band, absolute for the same reason +// (site-chrome-follows-the-design); '' when the set ships none. +$themeLogoInverseUrl = (string)($_['themeLogoInverseUrl'] ?? ''); // NO DARK LAYER IS LINKED HERE, AND THAT IS A MEASURED DECISION. // @@ -343,6 +346,9 @@ --> + + + +
+ + + + + +
+

{{ t('Welcome') }} @@ -186,6 +219,8 @@ export default { components: { // Loaded only while the resident acts for someone else. ActingForBar: defineAsyncComponent(() => import('./mijn/ActingForBar.vue')), + // Loaded only on a portal that writes its sign-in cards. + SignInPage: defineAsyncComponent(() => import('./chrome/SignInPage.vue')), ResidentMenu, WaysIn, }, @@ -256,6 +291,32 @@ export default { }, computed: { + /** + * The portal's sign-in page text, or an empty object. + * + * @return {object} `authentication.signInPage`. + * + * @spec openspec/changes/site-chrome-follows-the-design/specs/site-chrome/spec.md#requirement-the-sign-in-page-must-offer-each-way-in-as-a-card-for-its-role + */ + signInPageText() { + return this.portal?.authentication?.signInPage || {} + }, + + /** + * Whether the portal wrote its sign-in page: page text or a card for + * one of its ways in. Otherwise the plain list of buttons stays. + * + * @return {boolean} + * + * @spec openspec/changes/site-chrome-follows-the-design/specs/site-chrome/spec.md#requirement-the-sign-in-page-must-offer-each-way-in-as-a-card-for-its-role + */ + signInDesigned() { + return ( + Object.keys(this.signInPageText).length > 0 + || this.signInRoutes.some((way) => way.card) + ) + }, + /** * Whether the resident menu shows: signed in, with groups to show. * Signed out this area is the way in and keeps its full width. diff --git a/src/site/components/BrandHeader.vue b/src/site/components/BrandHeader.vue index 8e6f8760a..78d40c903 100644 --- a/src/site/components/BrandHeader.vue +++ b/src/site/components/BrandHeader.vue @@ -13,7 +13,10 @@ masthead. `single` puts it on the masthead's row and renders no second bar, so each link is in the accessibility tree once. --> -
+