Implemented phase 0 for the POC prototype in the Website service.
This commit is contained in:
1 parent
916df7e2f0
commit
717df5208c
31 files changed
+2351
-240
No files matched your search
@@ -51,6 +51,13 @@ HealthController (GET /health → liveness, GET /health
|
||||
|
||||
The router is created with `.autoGenerateHeadEndpoints`, so each of those `GET` routes gets a `HEAD` sibling for free.
|
||||
|
||||
### Design foundation
|
||||
The screens follow the **CCN marketplace** design canvas:
|
||||
- **Tokens and primitives** live in `Resources/Static/css/shared.css`, written phone-first with breakpoints at 600px and 1024px; the dark theme only repoints the tokens.
|
||||
- **No inline styles**: the `Content-Security-Policy` blocks them, so markup carries classes only.
|
||||
- **Shared frame**: `SiteHeader`, `SiteFooter`, and the `SiteLayout` a page wraps its `content` in, under `Sources/Library/Internal/Components`.
|
||||
- **Paths**: `String.Route` lists every screen's path; screens not built yet answer with the not-found page.
|
||||
|
||||
### Page metadata
|
||||
Each page conforms to `Infrastructure`'s `Page` protocol and supplies only its `title`, `content`, `stylesheets`, and `scripts`; the protocol assembles the document around them and renders the head in a fixed order: the viewport declaration, the `analytics` origin preconnect hint, the `summary`, the `canonicalURL` link, the `socialCard` tags, the `structuredData` script, the `analytics` tracker script, then the page `metadata` and the stylesheet links. The body is the content followed by the script tags.
|
||||
|
||||
@@ -68,7 +75,7 @@ What the pages *do* ship is in `Page+Defaults` (`Sources/Library/Internal/Extens
|
||||
|
||||
`ogLocale` renders Open Graph's `language_TERRITORY` form by looking the page's `lang` up in the `ogLocales` map, which ships one pairing: `en` → `en_US`. A site serving a language in a territory of its own repoints or extends the map (`en_NL`, `nl_NL`, …); a language with no entry stays a bare code, which scrapers also accept. Nothing reads it until a page supplies a `socialCard`.
|
||||
|
||||
`preloadedFonts` is empty until the site ships fonts. Listing one emits `<link rel="preload" as="font" crossorigin>` at `/font/<name>.woff2`, deliberately unversioned: a preload URL must match the stylesheet's `@font-face` source exactly, or the browser fetches the font twice. List only the faces the stylesheets actually render — a subset gated by a `unicode-range` no page reaches would add a download that never otherwise happens.
|
||||
`preloadedFonts` lists the three self-hosted faces under `Resources/Static/font`: Archivo, Oswald, and Anton. Listing one emits `<link rel="preload" as="font" crossorigin>` at `/font/<name>.woff2`, deliberately unversioned: a preload URL must match the stylesheet's `@font-face` source exactly, or the browser fetches the font twice. List only the faces the stylesheets actually render — a subset gated by a `unicode-range` no page reaches would add a download that never otherwise happens.
|
||||
|
||||
### Language editions
|
||||
The `WebsiteLibrary` String Catalog is the single source of truth for the languages the site serves: add a localization and it appears, with no code change. `Language` (`Sources/Library/Internal/Types`) reads that list and derives each language's URLs from it.
|
||||
@@ -373,7 +380,7 @@ All icons are renditions of the star mark in `icon.svg`, the canonical source
|
||||
| `icon-192.png`, `icon-512.png` | 192/512 | `site.webmanifest` install icons and splash screens | None (no platform mechanism); full artwork, light rendering, on a transparent background. |
|
||||
| `apple-touch-icon.png` | 180×180 | iOS home-screen bookmarks | None (fetched once, outside any page context); full artwork, deliberately opaque — iOS fills transparent regions with black. |
|
||||
|
||||
The icon and manifest links come from the shared `Page+Defaults` extension, so every page carries them, alongside two `theme-color` metas: `#fafafa` unqualified, then `#0c0710` qualified with `(prefers-color-scheme: dark)`.
|
||||
The icon and manifest links come from the shared `Page+Defaults` extension, so every page carries them, alongside two `theme-color` metas matching the site header, which stays dark in both themes: `#191713` unqualified, then `#0f0e0b` qualified with `(prefers-color-scheme: dark)`.
|
||||
|
||||
> **Note:** a browser applies the *first* `theme-color` whose media query matches, so the unqualified light value currently wins on both themes. Put the dark, media-qualified meta first in `Page+Defaults` if the dark value should take effect.
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/* CCN — landing page. */
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
max-width: 640px;
|
||||
padding: var(--space-1) 0 var(--space-2);
|
||||
}
|
||||
|
||||
.hero__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
padding-top: var(--space-3);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.hero {
|
||||
gap: var(--space-5);
|
||||
padding: var(--space-10) 0;
|
||||
}
|
||||
}
|
||||
@@ -1,43 +1,9 @@
|
||||
/* Styles for the not-found (404) error page. */
|
||||
/* CCN — not-found page. */
|
||||
|
||||
* {
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
color: #888;
|
||||
display: table;
|
||||
font-family: sans-serif;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
margin: 2em auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #555;
|
||||
font-size: 2em;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 auto;
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 280px) {
|
||||
body, p {
|
||||
width: 95%;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5em;
|
||||
margin: 0 0 0.3em;
|
||||
}
|
||||
.not-found {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-4);
|
||||
padding-top: var(--space-14);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,7 @@
|
||||
CCN — self-hosted fonts (latin subsets, from Google Fonts; SIL Open Font License 1.1)
|
||||
|
||||
anton-400.woff2 Anton 400 lettering
|
||||
archivo-variable.woff2 Archivo 400-700 body
|
||||
oswald-variable.woff2 Oswald 500-700 display
|
||||
|
||||
Served unversioned: the preload links must match the `@font-face` URLs in `css/shared.css`.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"short_name": "",
|
||||
"name": "",
|
||||
"short_name": "CCN",
|
||||
"name": "CCN — Comics marketplace",
|
||||
"icons": [{
|
||||
"src": "icon-192.png",
|
||||
"type": "image/png",
|
||||
@@ -11,6 +11,6 @@
|
||||
"sizes": "512x512"
|
||||
}],
|
||||
"start_url": "/?utm_source=homescreen",
|
||||
"background_color": "#fafafa",
|
||||
"theme_color": "#fafafa"
|
||||
"background_color": "#f5f3ee",
|
||||
"theme_color": "#191713"
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"sourceLanguage" : "en",
|
||||
"strings" : {
|
||||
"index.greeting" : {
|
||||
"index.heading" : {
|
||||
"comment" : "The landing page's greeting paragraph.",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Hello world! This is HTML5 Boilerplate."
|
||||
"value" : "Back issues, from shops you can vet first."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Index page"
|
||||
"value" : "CCN — Comics marketplace"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Page Not Found"
|
||||
"value" : "Page Not Found — CCN"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import Elementary
|
||||
|
||||
/// A decorative inline SVG stroke icon, drawn in the current text colour.
|
||||
struct Icon: HTML {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The glyph the icon draws.
|
||||
let glyph: Glyph
|
||||
|
||||
/// The icon's rendered width and height, in pixels.
|
||||
let size: Int
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates an icon.
|
||||
/// - Parameters:
|
||||
/// - glyph: the glyph the icon draws.
|
||||
/// - size: the icon's rendered width and height, in pixels; 20 by default.
|
||||
init(
|
||||
_ glyph: Glyph,
|
||||
size: Int = 20
|
||||
) {
|
||||
self.glyph = glyph
|
||||
self.size = size
|
||||
}
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// Rendered raw: the markup is static, never user input.
|
||||
var body: some HTML {
|
||||
HTMLRaw(
|
||||
"""
|
||||
<svg class="icon" width="\(size)" height="\(size)" viewBox="0 0 24 24" fill="none" stroke="currentColor" \
|
||||
stroke-width="\(glyph.strokeWidth)" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">\(glyph.shapes)</svg>
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Enumerations
|
||||
|
||||
extension Icon {
|
||||
/// A glyph an ``Icon`` can draw.
|
||||
enum Glyph {
|
||||
/// A bell.
|
||||
case bell
|
||||
/// A bookmark.
|
||||
case bookmark
|
||||
/// A check mark.
|
||||
case check
|
||||
/// Filter lines.
|
||||
case filter
|
||||
/// A key.
|
||||
case key
|
||||
/// A speech bubble.
|
||||
case message
|
||||
/// A magnifying glass.
|
||||
case search
|
||||
/// A shopfront.
|
||||
case shop
|
||||
/// Sort arrows.
|
||||
case sort
|
||||
}
|
||||
}
|
||||
|
||||
extension Icon.Glyph {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The shapes the glyph is drawn with.
|
||||
var shapes: String {
|
||||
switch self {
|
||||
case .bell: #"<path d="M6 16V11a6 6 0 0 1 12 0v5l2 2H4z"></path><path d="M10 21h4"></path>"#
|
||||
case .bookmark: #"<path d="M6 3h12v18l-6-4-6 4z"></path>"#
|
||||
case .check: #"<path d="m5 12 5 5 9-10"></path>"#
|
||||
case .filter: #"<path d="M4 6h16M7 12h10M10 18h4"></path>"#
|
||||
case .key: #"<circle cx="7.5" cy="15.5" r="4.5"></circle><path d="M10.7 12.3 21 2"></path><path d="m18 5 3 3"></path><path d="m15 8 3 3"></path>"#
|
||||
case .message: #"<path d="M4 5h16v11H8l-4 4z"></path>"#
|
||||
case .search: #"<circle cx="11" cy="11" r="7"></circle><path d="m20 20-3.5-3.5"></path>"#
|
||||
case .shop: #"<path d="M3 9.5 12 4l9 5.5"></path><path d="M5 10v9h14v-9"></path><path d="M10 19v-5h4v5"></path>"#
|
||||
case .sort: #"<path d="M7 4v16M7 20l-3-3M7 20l3-3M17 20V4M17 4l-3 3M17 4l3 3"></path>"#
|
||||
}
|
||||
}
|
||||
|
||||
/// The glyph's stroke width; heavier for glyphs drawn small.
|
||||
var strokeWidth: String {
|
||||
switch self {
|
||||
case .check: "3.5"
|
||||
case .filter, .key, .search, .sort: "2.2"
|
||||
default: "2"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import Elementary
|
||||
|
||||
/// The site footer: the about blurb, the link columns, the language toggle, and the legal line.
|
||||
struct SiteFooter: HTML {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
var body: some HTML {
|
||||
footer(.class("site-footer")) {
|
||||
div(.class("site-footer__inner")) {
|
||||
div(.class("site-footer__columns")) {
|
||||
div(.class("site-footer__about")) {
|
||||
span(.class("site-footer__wordmark")) {
|
||||
"CCN"
|
||||
}
|
||||
p {
|
||||
"A marketplace for single-issue comics, manga, trade paperbacks and hardcovers, sold by independent shops in the Netherlands and across the EU."
|
||||
}
|
||||
}
|
||||
for column in Self.columns {
|
||||
nav(
|
||||
.class("site-footer__column"),
|
||||
.custom(
|
||||
name: "aria-label",
|
||||
value: column.title
|
||||
)
|
||||
) {
|
||||
h2(.class("site-footer__heading")) {
|
||||
column.title
|
||||
}
|
||||
ul(.class("site-footer__links")) {
|
||||
for link in column.links {
|
||||
li {
|
||||
a(.href(link.path)) {
|
||||
link.label
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
language
|
||||
}
|
||||
div(.class("site-footer__legal")) {
|
||||
span {
|
||||
"CCN B.V. · Amsterdam · KvK [NUMBER] · BTW-id [NUMBER] · Business sellers show their KvK number on their shop page; consumers have a 14-day right of withdrawal on purchases from business sellers."
|
||||
}
|
||||
span(.class("site-footer__legal-links")) {
|
||||
a(.href(.Route.terms)) {
|
||||
"Terms"
|
||||
}
|
||||
a(.href(.Route.privacy)) {
|
||||
"Privacy"
|
||||
}
|
||||
a(.href(.Route.sellerRules)) {
|
||||
"Seller rules"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension SiteFooter {
|
||||
|
||||
// MARK: Type aliases
|
||||
|
||||
/// A titled column of links.
|
||||
typealias Column = (title: String, links: [(label: String, path: String)])
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
/// The link columns, in reading order.
|
||||
static let columns: [Column] = [
|
||||
(
|
||||
title: "Browse",
|
||||
links: Format.allCases.map { ($0.label, $0.path) } + [
|
||||
("By series", .Route.series)
|
||||
]
|
||||
),
|
||||
(
|
||||
title: "Sell",
|
||||
links: [
|
||||
("Open a shop", .Route.openShop),
|
||||
("Seller dashboard", .Route.sellerDashboard),
|
||||
("Grading guide", .Route.gradingGuide),
|
||||
("Trade at events", .Route.events),
|
||||
]
|
||||
),
|
||||
(
|
||||
title: "Account",
|
||||
links: [
|
||||
("Sign in or join", .Route.signIn),
|
||||
("Want list & alerts", .Route.wants),
|
||||
("My collection", .Route.collection),
|
||||
("Messages", .Route.messages),
|
||||
("Passkeys & settings", .Route.account),
|
||||
("Lost your passkey?", .Route.recover),
|
||||
]
|
||||
),
|
||||
]
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The language toggle; display-only while the prototype serves English alone.
|
||||
var language: some HTML {
|
||||
div(.class("site-footer__column site-footer__language")) {
|
||||
h2(.class("site-footer__heading")) {
|
||||
"Language"
|
||||
}
|
||||
div(
|
||||
.class("language-toggle"),
|
||||
.custom(
|
||||
name: "role",
|
||||
value: "group"
|
||||
),
|
||||
.custom(
|
||||
name: "aria-label",
|
||||
value: "Language"
|
||||
)
|
||||
) {
|
||||
button(
|
||||
.class("language-toggle__option"),
|
||||
.type(.button),
|
||||
.custom(
|
||||
name: "aria-pressed",
|
||||
value: "false"
|
||||
),
|
||||
.disabled
|
||||
) {
|
||||
"NL"
|
||||
}
|
||||
button(
|
||||
.class("language-toggle__option"),
|
||||
.type(.button),
|
||||
.custom(
|
||||
name: "aria-pressed",
|
||||
value: "true"
|
||||
)
|
||||
) {
|
||||
"EN"
|
||||
}
|
||||
}
|
||||
span(.class("site-footer__note")) {
|
||||
"Prices in € · ships to NL"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import Elementary
|
||||
|
||||
/// The site header: the wordmark, the format navigation, the search field, and the viewer's actions.
|
||||
///
|
||||
/// One markup serves all three layouts; the shared stylesheet rearranges it through grid areas.
|
||||
struct SiteHeader: HTML {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The format marked as current in the navigation, or `nil` for none.
|
||||
let active: Format?
|
||||
|
||||
/// The signed-in viewer, or `nil` to render the signed-out actions.
|
||||
let viewer: Viewer?
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a site header.
|
||||
/// - Parameters:
|
||||
/// - active: the format marked as current, or `nil` (the default) for none.
|
||||
/// - viewer: the signed-in viewer, or `nil` (the default) to render the signed-out actions.
|
||||
init(
|
||||
active: Format? = nil,
|
||||
viewer: Viewer? = nil
|
||||
) {
|
||||
self.active = active
|
||||
self.viewer = viewer
|
||||
}
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
var body: some HTML {
|
||||
header(.class("site-header")) {
|
||||
div(.class("site-header__inner")) {
|
||||
a(
|
||||
.class("site-header__brand"),
|
||||
.href(.Route.home)
|
||||
) {
|
||||
"CCN"
|
||||
}
|
||||
navigation
|
||||
search
|
||||
div(.class("site-header__actions")) {
|
||||
if let viewer {
|
||||
actions(for: viewer)
|
||||
} else {
|
||||
guestActions
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension SiteHeader {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The signed-out actions: *Open a shop* and the passkey sign-in.
|
||||
@HTMLBuilder
|
||||
var guestActions: some HTML {
|
||||
a(
|
||||
.class("site-header__icon-link phone-only"),
|
||||
.href(.Route.openShop),
|
||||
.custom(
|
||||
name: "aria-label",
|
||||
value: "Open a shop"
|
||||
)
|
||||
) {
|
||||
Icon(.shop, size: 22)
|
||||
}
|
||||
a(
|
||||
.class("site-header__text-link from-desktop"),
|
||||
.href(.Route.openShop)
|
||||
) {
|
||||
"Open a shop"
|
||||
}
|
||||
a(
|
||||
.class("site-header__sign-in"),
|
||||
.href(.Route.signIn)
|
||||
) {
|
||||
Icon(.key, size: 15)
|
||||
span {
|
||||
"Sign in"
|
||||
span(.class("from-desktop")) {
|
||||
" with passkey"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The format links, plus the series and events links shown on tablet only.
|
||||
var navigation: some HTML {
|
||||
nav(
|
||||
.class("site-nav"),
|
||||
.custom(
|
||||
name: "aria-label",
|
||||
value: "Formats"
|
||||
)
|
||||
) {
|
||||
for format in Format.allCases {
|
||||
if format == active {
|
||||
a(
|
||||
.class("site-nav__link"),
|
||||
.href(format.path),
|
||||
.custom(
|
||||
name: "aria-current",
|
||||
value: "page"
|
||||
)
|
||||
) {
|
||||
format.label
|
||||
}
|
||||
} else {
|
||||
a(
|
||||
.class("site-nav__link"),
|
||||
.href(format.path)
|
||||
) {
|
||||
format.label
|
||||
}
|
||||
}
|
||||
}
|
||||
a(
|
||||
.class("site-nav__link site-nav__link--extra"),
|
||||
.href(.Route.series)
|
||||
) {
|
||||
"Series"
|
||||
}
|
||||
a(
|
||||
.class("site-nav__link site-nav__link--extra"),
|
||||
.href(.Route.events)
|
||||
) {
|
||||
"Events"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The search field, a plain `GET` form.
|
||||
var search: some HTML {
|
||||
form(
|
||||
.class("site-search"),
|
||||
.action(.Route.search),
|
||||
.method(.get),
|
||||
.custom(
|
||||
name: "role",
|
||||
value: "search"
|
||||
)
|
||||
) {
|
||||
label(
|
||||
.class("visually-hidden"),
|
||||
.for("site-search-query")
|
||||
) {
|
||||
"Search listings"
|
||||
}
|
||||
input(
|
||||
.class("site-search__input"),
|
||||
.id("site-search-query"),
|
||||
.type(.search),
|
||||
.name("q"),
|
||||
.placeholder("Search title, issue, grade or shop")
|
||||
)
|
||||
button(
|
||||
.class("site-search__submit"),
|
||||
.type(.submit),
|
||||
.custom(
|
||||
name: "aria-label",
|
||||
value: "Search"
|
||||
)
|
||||
) {
|
||||
Icon(.search, size: 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// The signed-in actions: want list, messages, shop, and account.
|
||||
/// - Parameter viewer: the signed-in viewer.
|
||||
@HTMLBuilder
|
||||
func actions(
|
||||
for viewer: Viewer
|
||||
) -> some HTML {
|
||||
counter(
|
||||
.bell,
|
||||
label: "Want list",
|
||||
count: viewer.newWantMatches,
|
||||
unit: "new matches",
|
||||
path: .Route.wants
|
||||
)
|
||||
counter(
|
||||
.message,
|
||||
label: "Messages",
|
||||
count: viewer.unreadMessages,
|
||||
unit: "unread",
|
||||
path: .Route.messages
|
||||
)
|
||||
a(
|
||||
.class("site-header__text-link site-header__text-link--strong from-desktop"),
|
||||
.href(.Route.sellerDashboard)
|
||||
) {
|
||||
"My shop"
|
||||
}
|
||||
a(
|
||||
.class("site-header__account"),
|
||||
.href(.Route.account),
|
||||
.custom(
|
||||
name: "aria-label",
|
||||
value: "Account of \(viewer.displayName)"
|
||||
)
|
||||
) {
|
||||
span(.class("from-desktop")) {
|
||||
viewer.displayName
|
||||
}
|
||||
span(.class("site-header__avatar")) {
|
||||
viewer.initials
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An icon link with a count badge, omitted at zero.
|
||||
/// - Parameters:
|
||||
/// - glyph: the glyph the link shows.
|
||||
/// - label: the link's accessible name.
|
||||
/// - count: the number the badge shows.
|
||||
/// - unit: what the count measures.
|
||||
/// - path: the path the link leads to.
|
||||
func counter(
|
||||
_ glyph: Icon.Glyph,
|
||||
label: String,
|
||||
count: Int,
|
||||
unit: String,
|
||||
path: String
|
||||
) -> some HTML {
|
||||
a(
|
||||
.class("site-header__icon-link"),
|
||||
.href(path),
|
||||
.custom(
|
||||
name: "aria-label",
|
||||
value: count > 0 ? "\(label), \(count) \(unit)" : label
|
||||
)
|
||||
) {
|
||||
Icon(glyph)
|
||||
if count > 0 {
|
||||
span(.class("site-header__badge")) {
|
||||
"\(count)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import Elementary
|
||||
|
||||
/// The frame a page wraps its content in: a skip link, the ``SiteHeader``, the content inside `main`, and the ``SiteFooter``.
|
||||
struct SiteLayout<Content: HTML>: HTML {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The format marked as current in the header, or `nil` for none.
|
||||
let active: Format?
|
||||
|
||||
/// The screen's markup, rendered inside the `main` landmark.
|
||||
let content: Content
|
||||
|
||||
/// The signed-in viewer, or `nil` to render the signed-out header.
|
||||
let viewer: Viewer?
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a site layout around a screen's content.
|
||||
/// - Parameters:
|
||||
/// - active: the format marked as current, or `nil` (the default) for none.
|
||||
/// - viewer: the signed-in viewer, or `nil` (the default) to render the signed-out header.
|
||||
/// - content: the screen's markup.
|
||||
init(
|
||||
active: Format? = nil,
|
||||
viewer: Viewer? = nil,
|
||||
@HTMLBuilder content: () -> Content
|
||||
) {
|
||||
self.active = active
|
||||
self.content = content()
|
||||
self.viewer = viewer
|
||||
}
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
var body: some HTML {
|
||||
a(
|
||||
.class("skip-link"),
|
||||
.href("#main")
|
||||
) {
|
||||
"Skip to content"
|
||||
}
|
||||
SiteHeader(
|
||||
active: active,
|
||||
viewer: viewer
|
||||
)
|
||||
main(.id("main")) {
|
||||
content
|
||||
}
|
||||
SiteFooter()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/// A format listings are published in, declared in navigation order; the raw value is the search filter token.
|
||||
enum Format: String, CaseIterable, Sendable {
|
||||
/// Single-issue comics.
|
||||
case singleIssues = "single-issues"
|
||||
/// Manga volumes.
|
||||
case manga
|
||||
/// Trade paperbacks.
|
||||
case tradePaperbacks = "trade-paperbacks"
|
||||
/// Hardcovers.
|
||||
case hardcovers
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
extension Format {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The format's name as shown in the navigation.
|
||||
var label: String {
|
||||
switch self {
|
||||
case .singleIssues: "Single issues"
|
||||
case .manga: "Manga"
|
||||
case .tradePaperbacks: "Trade paperbacks"
|
||||
case .hardcovers: "Hardcovers"
|
||||
}
|
||||
}
|
||||
|
||||
/// The path of the search screen filtered to the format.
|
||||
var path: String {
|
||||
"\(String.Route.search)?format=\(rawValue)"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -11,9 +11,9 @@ extension Page {
|
||||
/// The fonts preloaded on every page: one per face the stylesheets actually render.
|
||||
///
|
||||
/// Listed rather than derived from every WOFF2 in ``StaticFile/all``: a subset gated by a `unicode-range` no page reaches would add a
|
||||
/// download that never otherwise happens. Empty until the site ships fonts of its own.
|
||||
/// download that never otherwise happens. All three faces render on every page.
|
||||
static var preloadedFonts: [StaticFile] {
|
||||
[]
|
||||
[.fontArchivo, .fontOswald, .fontAnton]
|
||||
}
|
||||
|
||||
/// The territories paired with the site's languages, in Open Graph's `language_TERRITORY` form.
|
||||
@@ -147,11 +147,11 @@ extension Page {
|
||||
)
|
||||
meta(
|
||||
.name("theme-color"),
|
||||
.content("#fafafa")
|
||||
.content("#191713")
|
||||
)
|
||||
meta(
|
||||
.name("theme-color"),
|
||||
.content("#0c0710"),
|
||||
.content("#0f0e0b"),
|
||||
.custom(
|
||||
name: "media",
|
||||
value: "(prefers-color-scheme: dark)"
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
extension String {
|
||||
/// A namespace for the paths of the site's screens.
|
||||
///
|
||||
/// Screens not built yet are listed too, and answer with the not-found page until they are.
|
||||
enum Route {
|
||||
/// The account screen.
|
||||
static let account = "/account"
|
||||
/// The collection tracker.
|
||||
static let collection = "/collection"
|
||||
/// The events screen.
|
||||
static let events = "/events"
|
||||
/// The public grading guide.
|
||||
static let gradingGuide = "/grading-guide"
|
||||
/// The browse / home screen.
|
||||
static let home = "/"
|
||||
/// The message threads.
|
||||
static let messages = "/messages"
|
||||
/// The open-a-shop onboarding.
|
||||
static let openShop = "/sell/open-a-shop"
|
||||
/// The privacy statement.
|
||||
static let privacy = "/privacy"
|
||||
/// The passkey recovery screen.
|
||||
static let recover = "/recover"
|
||||
/// The search results screen.
|
||||
static let search = "/search"
|
||||
/// The seller dashboard.
|
||||
static let sellerDashboard = "/sell"
|
||||
/// The rules sellers agree to.
|
||||
static let sellerRules = "/seller-rules"
|
||||
/// The series index.
|
||||
static let series = "/series"
|
||||
/// The passkey sign-in screen.
|
||||
static let signIn = "/sign-in"
|
||||
/// The terms of service.
|
||||
static let terms = "/terms"
|
||||
/// The want list.
|
||||
static let wants = "/wants"
|
||||
}
|
||||
}
|
||||
@@ -82,9 +82,36 @@ extension IndexPage: Page {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The hero alone, until the listings exist.
|
||||
var content: some HTML {
|
||||
p {
|
||||
localize("index.greeting", locale: locale)
|
||||
SiteLayout {
|
||||
div(.class("page")) {
|
||||
section(.class("hero")) {
|
||||
p(.class("type-eyebrow")) {
|
||||
"Single issues · Manga · Trades · Hardcovers"
|
||||
}
|
||||
h1(.class("type-hero")) {
|
||||
localize("index.heading", locale: locale)
|
||||
}
|
||||
p(.class("type-lead")) {
|
||||
"Search every listing by title, print and grade. Browse any seller's shop without an account — sign in with a passkey only when you're ready to buy, make an offer or message."
|
||||
}
|
||||
div(.class("hero__actions")) {
|
||||
a(
|
||||
.class("button button--primary button--lg"),
|
||||
.href(Format.singleIssues.path)
|
||||
) {
|
||||
"Browse single issues"
|
||||
}
|
||||
a(
|
||||
.class("button button--outline button--lg"),
|
||||
.href(.Route.series)
|
||||
) {
|
||||
"Browse by series"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,11 +47,21 @@ extension NotFoundPage: Page {
|
||||
// MARK: Properties
|
||||
|
||||
var content: some HTML {
|
||||
h1 {
|
||||
localize("notFound.heading", locale: locale)
|
||||
}
|
||||
p {
|
||||
localize("notFound.message", locale: locale)
|
||||
SiteLayout {
|
||||
div(.class("page not-found")) {
|
||||
h1(.class("type-h1")) {
|
||||
localize("notFound.heading", locale: locale)
|
||||
}
|
||||
p(.class("type-lead")) {
|
||||
localize("notFound.message", locale: locale)
|
||||
}
|
||||
a(
|
||||
.class("button button--dark"),
|
||||
.href(.Route.home)
|
||||
) {
|
||||
"Back to browsing"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,9 @@ extension StaticFile {
|
||||
static let all: [Self] = [
|
||||
.appleTouchIcon,
|
||||
.favicon,
|
||||
.fontAnton,
|
||||
.fontArchivo,
|
||||
.fontOswald,
|
||||
.icon,
|
||||
.icon192,
|
||||
.icon512,
|
||||
@@ -104,6 +107,15 @@ extension StaticFile {
|
||||
/// The `favicon.ico` icon.
|
||||
static let favicon = Self("favicon", as: .ico)
|
||||
|
||||
/// The `font/anton-400.woff2` lettering face.
|
||||
static let fontAnton = Self("anton-400", as: .woff2)
|
||||
|
||||
/// The `font/archivo-variable.woff2` body face (weights 400–700).
|
||||
static let fontArchivo = Self("archivo-variable", as: .woff2)
|
||||
|
||||
/// The `font/oswald-variable.woff2` display face (weights 500–700).
|
||||
static let fontOswald = Self("oswald-variable", as: .woff2)
|
||||
|
||||
/// The `icon.svg` icon.
|
||||
static let icon = Self("icon", as: .svg)
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/// The signed-in person a page is rendered for.
|
||||
struct Viewer: Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The viewer's name.
|
||||
let displayName: String
|
||||
|
||||
/// The number of new listings matching the viewer's want list.
|
||||
let newWantMatches: Int
|
||||
|
||||
/// The number of messages the viewer has not read yet.
|
||||
let unreadMessages: Int
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a viewer.
|
||||
/// - Parameters:
|
||||
/// - displayName: the viewer's name.
|
||||
/// - newWantMatches: the number of new listings matching the viewer's want list; none by default.
|
||||
/// - unreadMessages: the number of messages the viewer has not read yet; none by default.
|
||||
init(
|
||||
displayName: String,
|
||||
newWantMatches: Int = 0,
|
||||
unreadMessages: Int = 0
|
||||
) {
|
||||
self.displayName = displayName
|
||||
self.newWantMatches = newWantMatches
|
||||
self.unreadMessages = unreadMessages
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
extension Viewer {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The uppercased initials of the first two words of the display name.
|
||||
var initials: String {
|
||||
displayName
|
||||
.split(separator: " ")
|
||||
.prefix(2)
|
||||
.compactMap(\.first)
|
||||
.map { String($0).uppercased() }
|
||||
.joined()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -53,7 +53,7 @@ struct AppTests {
|
||||
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.contentType] == "text/html; charset=utf-8")
|
||||
#expect(body.contains("Hello world!"))
|
||||
#expect(body.contains("Back issues, from shops you can vet first."))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import Elementary
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite(
|
||||
"Icon component",
|
||||
.tags(.component)
|
||||
)
|
||||
struct IconTests {
|
||||
|
||||
// MARK: Functional tests
|
||||
|
||||
@Test
|
||||
func `renders a decorative icon at the given size`() {
|
||||
let html = Icon(.search, size: 16).render()
|
||||
|
||||
#expect(html.hasPrefix("<svg"))
|
||||
#expect(html.contains(#"width="16" height="16""#))
|
||||
#expect(html.contains(#"aria-hidden="true""#))
|
||||
#expect(html.contains(#"stroke="currentColor""#))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `renders no inline styles`() {
|
||||
// The Content-Security-Policy blocks `style` attributes, inside an SVG as anywhere else.
|
||||
#expect(!Icon(.key).render().contains("style="))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import Elementary
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite(
|
||||
"SiteFooter component",
|
||||
.tags(.component)
|
||||
)
|
||||
struct SiteFooterTests {
|
||||
|
||||
// MARK: Functional tests
|
||||
|
||||
@Test
|
||||
func `renders its link columns`() {
|
||||
let html = SiteFooter().render()
|
||||
|
||||
for title in ["Browse", "Sell", "Account", "Language"] {
|
||||
#expect(html.contains(">\(title)</h2>"))
|
||||
}
|
||||
|
||||
for path in [String.Route.series, .Route.openShop, .Route.gradingGuide, .Route.signIn, .Route.recover, .Route.terms] {
|
||||
#expect(html.contains(#"href="\#(path)""#))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `escapes the ampersands in its labels`() {
|
||||
#expect(SiteFooter().render().contains("Want list & alerts"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `presses the one language the prototype serves`() {
|
||||
let html = SiteFooter().render()
|
||||
|
||||
#expect(html.components(separatedBy: #"aria-pressed="true""#).count - 1 == 1)
|
||||
#expect(html.contains(#"aria-pressed="true">EN<"#))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import Elementary
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite(
|
||||
"SiteHeader component",
|
||||
.tags(.component)
|
||||
)
|
||||
struct SiteHeaderTests {
|
||||
|
||||
// MARK: Functional tests
|
||||
|
||||
@Test
|
||||
func `renders the signed-out actions without a viewer`() {
|
||||
let html = SiteHeader().render()
|
||||
|
||||
#expect(html.contains(#"href="/sign-in""#))
|
||||
#expect(html.contains("Open a shop"))
|
||||
#expect(!html.contains(#"href="/account""#))
|
||||
#expect(!html.contains(#"href="/messages""#))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `renders the signed-in actions for a viewer`() {
|
||||
let html = SiteHeader(viewer: .init(
|
||||
displayName: "Mara Visser",
|
||||
newWantMatches: 2,
|
||||
unreadMessages: 3
|
||||
)).render()
|
||||
|
||||
#expect(html.contains(#"aria-label="Want list, 2 new matches""#))
|
||||
#expect(html.contains(#"aria-label="Messages, 3 unread""#))
|
||||
#expect(html.contains(#"href="/sell""#))
|
||||
#expect(html.contains(">MV<"))
|
||||
#expect(!html.contains(#"href="/sign-in""#))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `omits a badge that counts nothing`() {
|
||||
let html = SiteHeader(viewer: .init(displayName: "Mara")).render()
|
||||
|
||||
#expect(html.contains(#"aria-label="Messages""#))
|
||||
#expect(!html.contains("site-header__badge"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `links every format and marks none current by default`() {
|
||||
let html = SiteHeader().render()
|
||||
|
||||
for format in Format.allCases {
|
||||
#expect(html.contains(#"href="\#(format.path)""#))
|
||||
}
|
||||
|
||||
#expect(!html.contains("aria-current"))
|
||||
}
|
||||
|
||||
@Test(arguments: Format.allCases)
|
||||
func `marks the active format alone as current`(
|
||||
_ format: Format
|
||||
) {
|
||||
let html = SiteHeader(active: format).render()
|
||||
|
||||
#expect(html.components(separatedBy: #"aria-current="page""#).count - 1 == 1)
|
||||
#expect(html.contains(#"href="\#(format.path)" aria-current="page""#))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `searches through a plain GET form`() {
|
||||
let html = SiteHeader().render()
|
||||
|
||||
#expect(html.contains(#"action="/search""#))
|
||||
#expect(html.contains(#"method="get""#))
|
||||
#expect(html.contains(#"name="q""#))
|
||||
#expect(html.contains(#"for="site-search-query""#))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite(
|
||||
"Format enumeration",
|
||||
.tags(.enumeration)
|
||||
)
|
||||
struct FormatTests {
|
||||
|
||||
// MARK: Computed tests
|
||||
|
||||
@Test(arguments: zip(
|
||||
Format.allCases,
|
||||
[
|
||||
"/search?format=single-issues",
|
||||
"/search?format=manga",
|
||||
"/search?format=trade-paperbacks",
|
||||
"/search?format=hardcovers",
|
||||
]
|
||||
))
|
||||
func `path`(
|
||||
of format: Format,
|
||||
expects path: String
|
||||
) {
|
||||
#expect(format.path == path)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -27,16 +27,47 @@ struct IndexPageTests {
|
||||
#expect(html.contains("/apple-touch-icon.png"))
|
||||
#expect(html.contains("/site.webmanifest"))
|
||||
#expect(html.contains(#"media="(prefers-color-scheme: dark)""#))
|
||||
#expect(html.contains("Hello world!"))
|
||||
#expect(html.contains("Back issues, from shops you can vet first."))
|
||||
#expect(html.contains("/js/shared.js"))
|
||||
#expect(html.contains("/js/index.js"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `renders no font preloads until fonts are declared`() {
|
||||
// The template ships no fonts, so a preload link here would point at a file the service does not serve.
|
||||
#expect(IndexPage.preloadedFonts.isEmpty)
|
||||
#expect(!IndexPage(locale: .init(identifier: "en")).render().contains(#"rel="preload""#))
|
||||
func `renders the site frame around its content`() {
|
||||
let html = IndexPage(
|
||||
locale: .init(identifier: "en")
|
||||
).render()
|
||||
|
||||
#expect(html.contains(#"class="skip-link""#))
|
||||
#expect(html.contains(#"class="site-header""#))
|
||||
#expect(html.contains(#"<main id="main">"#))
|
||||
#expect(html.contains(#"class="site-footer""#))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `renders no inline styles`() {
|
||||
// The Content-Security-Policy is `default-src 'self'`, which blocks `style` attributes and elements alike.
|
||||
let html = IndexPage(
|
||||
locale: .init(identifier: "en")
|
||||
).render()
|
||||
|
||||
#expect(!html.contains("style="))
|
||||
#expect(!html.contains("<style"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `preloads every font face unversioned`() {
|
||||
// A preload URL must match the stylesheet's `@font-face` source exactly, or the browser fetches the font twice.
|
||||
let html = IndexPage(
|
||||
locale: .init(identifier: "en"),
|
||||
assetVersion: "0123456789abcdef"
|
||||
).render()
|
||||
|
||||
#expect(IndexPage.preloadedFonts.count == 3)
|
||||
|
||||
for font in IndexPage.preloadedFonts {
|
||||
#expect(html.contains(#"<link rel="preload" href="\#(font.urlPath(for: .woff2))" as="font""#))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -95,6 +95,9 @@ private extension StaticFileTests {
|
||||
static let paths: [(File, [String])] = [
|
||||
(.appleTouchIcon, ["apple-touch-icon.png"]),
|
||||
(.favicon, ["favicon.ico"]),
|
||||
(.fontAnton, ["font/anton-400.woff2"]),
|
||||
(.fontArchivo, ["font/archivo-variable.woff2"]),
|
||||
(.fontOswald, ["font/oswald-variable.woff2"]),
|
||||
(.icon, ["icon.svg"]),
|
||||
(.icon192, ["icon-192.png"]),
|
||||
(.icon512, ["icon-512.png"]),
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite(
|
||||
"Viewer type",
|
||||
.tags(.type)
|
||||
)
|
||||
struct ViewerTests {
|
||||
|
||||
// MARK: Computed tests
|
||||
|
||||
@Test(arguments: zip(
|
||||
["Mara Visser", "Mara", "mara de visser", ""],
|
||||
["MV", "M", "MD", ""]
|
||||
))
|
||||
func `initials`(
|
||||
of displayName: String,
|
||||
expects initials: String
|
||||
) {
|
||||
#expect(Viewer(displayName: displayName).initials == initials)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -37,7 +37,7 @@ struct RootControllerTests {
|
||||
#expect(response.headers[.contentType] == "text/html; charset=utf-8")
|
||||
#expect(response.headers[.contentLanguage] == "en")
|
||||
#expect(response.headers[.vary] == "Accept-Language")
|
||||
#expect(body.contains("Hello world!"))
|
||||
#expect(body.contains("Back issues, from shops you can vet first."))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,7 +90,7 @@ struct RootControllerTests {
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(response.status == .ok)
|
||||
#expect(body.contains("Hello world!"))
|
||||
#expect(body.contains("Back issues, from shops you can vet first."))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Testing
|
||||
|
||||
extension Tag {
|
||||
/// Tests exercising a component of the Website library.
|
||||
@Tag static var component: Tag
|
||||
/// Tests exercising a controller of the Website library.
|
||||
@Tag static var controller: Tag
|
||||
/// Tests exercising an enumeration of the Website library.
|
||||
|
||||
Reference in new issue
Block a user