From bba857355ae81573adb19ca93d9f287cb9ddbbdc Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Mon, 2 Nov 2026 15:06:23 +0100 Subject: [PATCH 1/2] feat(site): journal pages from site-content's pages/journal, and chooser, cards, steps and contents sections for them --- apps/site/README.md | 3 +- apps/site/docs/editing.md | 22 ++- apps/site/src/admin/config.yml | 179 +++++++++++++++++- apps/site/src/admin/preview.ts | 9 +- apps/site/src/components/Sections.astro | 20 +- apps/site/src/components/blocks/Blocks.astro | 41 ++++ apps/site/src/components/blocks/Cards.astro | 66 +++++++ apps/site/src/components/blocks/More.astro | 25 +++ apps/site/src/components/blocks/Steps.astro | 26 +++ apps/site/src/components/blocks/status.ts | 10 + apps/site/src/components/blocks/switch.ts | 51 +++++ .../components/sections/CardsSection.astro | 26 +++ .../src/components/sections/Chooser.astro | 94 +++++++++ .../src/components/sections/Contents.astro | 22 +++ .../components/sections/SectionHeading.astro | 23 +++ .../components/sections/StepsSection.astro | 53 ++++++ apps/site/src/components/sections/Text.astro | 13 +- apps/site/src/content.config.ts | 119 +++++++++++- apps/site/src/layouts/SectionsPage.astro | 30 ++- apps/site/src/pages/[...slug].astro | 3 +- apps/site/src/pages/admin/preview.astro | 6 +- apps/site/src/pages/journal/[slug].astro | 29 ++- apps/site/src/styles/global.css | 20 ++ 23 files changed, 863 insertions(+), 27 deletions(-) create mode 100644 apps/site/src/components/blocks/Blocks.astro create mode 100644 apps/site/src/components/blocks/Cards.astro create mode 100644 apps/site/src/components/blocks/More.astro create mode 100644 apps/site/src/components/blocks/Steps.astro create mode 100644 apps/site/src/components/blocks/status.ts create mode 100644 apps/site/src/components/blocks/switch.ts create mode 100644 apps/site/src/components/sections/CardsSection.astro create mode 100644 apps/site/src/components/sections/Chooser.astro create mode 100644 apps/site/src/components/sections/Contents.astro create mode 100644 apps/site/src/components/sections/SectionHeading.astro create mode 100644 apps/site/src/components/sections/StepsSection.astro diff --git a/apps/site/README.md b/apps/site/README.md index aecfc2a2..a562213c 100644 --- a/apps/site/README.md +++ b/apps/site/README.md @@ -17,6 +17,7 @@ can also search OJS with their own OJS API key. | What | Where it lives | How to change it | | --- | --- | --- | | Homepage, About and other section-built pages | site-content: `pages/*.yaml` | `/admin`, or edit the file | +| The journal's own section-built pages (For Authors) | site-content: `pages/journal/*.yaml` | `/admin` → Journal pages | | Menus, footer, social links | site-content: `settings/navigation.yaml` | `/admin` → Settings → Menus | | Site name, newsletter, analytics | site-content: `settings/site.yaml` | `/admin` → Settings → Site | | Images and files uploaded in `/admin` | site-content: `uploads/` | `/admin` | @@ -63,7 +64,7 @@ Dockerfile packages the built site, see docs/deploying.md ``` site-content holds `pages/` (section-built pages, `index.yaml` is the -homepage), `blog/` (posts, Markdown), `people/`, `categories/`, `settings/` +homepage, `journal/` the journal's own pages), `blog/` (posts, Markdown), `people/`, `categories/`, `settings/` (`site.yaml`, `navigation.yaml`) and `uploads/`. ## What's where diff --git a/apps/site/docs/editing.md b/apps/site/docs/editing.md index d46dcf7f..291546a9 100644 --- a/apps/site/docs/editing.md +++ b/apps/site/docs/editing.md @@ -44,13 +44,33 @@ bottom. You can add, remove, reorder and edit sections: - **Newsletter sign-up** - **Buttons** +For pages that guide someone through steps, like the journal's For Authors +page, there are also: + +- **On this page**: links to the sections below it that have an anchor and a + title. "Short name" gives a section a shorter label there. +- **Chooser**: cards to pick from, each opening its own panel beside them + ("What are you submitting?"). A panel holds text, numbered parts, cards, + steps and "read more" boxes. Each card has an anchor, so + `/journal/for-authors#registered-report` opens that card's panel. +- **Cards**: boxes in a grid, optionally marked Required, Recommended or Not + needed, with a key above them if you tick it. Each card can be framed, + tinted, dashed, titled in a dark bar or have only a line on top, and can + have small tags and extra text that opens on click. +- **Steps**: a numbered process. Several routes (Standard, Perspective, + Registered Report) become tabs. + +Text sections can have buttons too. + Text fields take Markdown: `**bold**`, `*italic*`, `[a link](https://...)`. To make a new page, click **New Page** in `/admin`. Its address is its title in lowercase with dashes (`Open Positions` becomes `/open-positions`). The page called `journal` is the journal's front page at `/journal`, with the -journal's menu above it. All articles are at `/journal/articles`, as cards or +journal's menu above it. **Journal pages** in `/admin` are the journal's own +pages: `for-authors` is `/journal/for-authors`, under the journal's menu and in +its colours. A journal page replaces the PubPub page at the same address. All articles are at `/journal/articles`, as cards or a compact list depending on Settings → Site → Journal. The preview next to the fields is the page as the site draws it, updated a diff --git a/apps/site/src/admin/config.yml b/apps/site/src/admin/config.yml index 15ba999a..d16f6756 100644 --- a/apps/site/src/admin/config.yml +++ b/apps/site/src/admin/config.yml @@ -60,7 +60,8 @@ collections: - { name: title, label: Title, widget: string } - { name: description, label: Description (for search engines and link previews), widget: text, required: false } - { name: showTitle, label: Show the title as a big heading, widget: boolean, default: false } - - name: sections + - §ions + name: sections label: Sections label_singular: Section widget: list @@ -93,6 +94,7 @@ collections: - { name: id, label: Anchor (for links like /about#mission), widget: string, required: false } - { name: body, label: Text, widget: markdown } - { name: background, label: Background, widget: select, options: [paper, accent, accent-soft, journal, journal-soft], default: paper } + - *buttons - name: features label: Columns widget: object @@ -151,6 +153,181 @@ collections: widget: object fields: - *buttons + # Building blocks for structured pages like the journal's For Authors page + - name: contents + label: On this page (links to the sections below) + widget: object + summary: "{{title}}" + fields: + - { name: title, label: Title, widget: string, default: On this page } + - name: chooser + label: Chooser (cards that each open a panel) + widget: object + summary: "{{title}}" + fields: + - &eyebrow { name: eyebrow, label: Small text above the title (“Step 1”), widget: string, required: false } + - &title { name: title, label: Title, widget: string, required: false } + - &id { name: id, label: "Anchor (for links like #types)", widget: string, required: false } + - &short { name: short, label: Short name in “On this page”, widget: string, required: false } + - &intro { name: intro, label: Intro, widget: markdown, required: false } + - name: groups + label: Groups of cards + label_singular: Group + widget: list + summary: "{{title}}" + fields: + - { name: title, label: Title (“What are you submitting?”), widget: string, required: false } + - { name: note, label: Note beside the title, widget: string, required: false } + - name: options + label: Cards + label_singular: Card + widget: list + summary: "{{title}}" + fields: + - { name: title, label: Title, widget: string } + - { name: id, label: "Anchor (link straight to it with #anchor; made from the title if empty)", widget: string, required: false } + - { name: badge, label: Badge (“New”, “Pilot”), widget: string, required: false } + - { name: summary, label: One-line summary, widget: text, required: false } + - name: facts + label: Facts on the card + label_singular: Fact + widget: list + required: false + summary: "{{label}}: {{value}}" + fields: + - { name: label, label: Label (“Review”), widget: string } + - { name: value, label: Value (“standard open review”), widget: string } + - { name: tone, label: Colour when chosen, widget: select, options: [{ label: Black, value: ink }, { label: Journal blue, value: journal }], default: ink } + - { name: heading, label: Panel title (the card’s title if empty), widget: string, required: false } + - { name: note, label: Small text beside the panel title, widget: string, required: false } + - name: blocks + label: Panel content + label_singular: Block + widget: list + typeKey: type + types: + - name: text + label: Text + widget: object + fields: + - { name: body, label: Text, widget: markdown } + - name: parts + label: Numbered parts + widget: object + fields: + - name: items + label: Parts + label_singular: Part + widget: list + summary: "{{title}}" + fields: + - { name: title, label: Title, widget: string } + - { name: text, label: Text, widget: markdown, required: false } + - name: cards + label: Cards + widget: object + fields: + - { name: columns, label: Most cards side by side, widget: number, value_type: int, min: 1, max: 4, required: false } + - &cards + name: items + label: Cards + label_singular: Card + widget: list + summary: "{{title}}" + fields: + - { name: title, label: Title, widget: string } + - { name: body, label: Text, widget: markdown, required: false } + - { name: status, label: Status, widget: select, required: false, options: [{ label: Required, value: required }, { label: Recommended, value: recommended }, { label: Not needed, value: not-needed }] } + - { name: badge, label: Badge text (instead of the status’s own), widget: string, required: false } + - name: look + label: Look + widget: select + default: box + options: + - { label: Framed, value: box } + - { label: Title in a dark bar, value: headed } + - { label: Tinted, value: soft } + - { label: Dashed frame, value: dashed } + - { label: Line on top, value: rule } + - { label: No frame, value: plain } + - { name: tags, label: Small boxed words under the text, widget: list, required: false } + - name: more + label: Extra text that opens on click + widget: object + required: false + fields: + - { name: label, label: Button label, widget: string } + - { name: body, label: Text, widget: markdown } + - name: steps + label: Steps + widget: object + fields: + - &steps + name: steps + label: Steps + label_singular: Step + widget: list + summary: "{{title}}" + fields: + - { name: title, label: Title, widget: string } + - { name: kicker, label: Small text above (“Stage 1 · You”; numbered if empty), widget: string, required: false } + - { name: text, label: Text, widget: markdown, required: false } + - { name: highlight, label: Highlight (a milestone), widget: boolean, default: false } + - name: more + label: Read more (opens on click) + widget: object + summary: "{{label}}" + fields: + - { name: label, label: Button label, widget: string } + - { name: body, label: Text, widget: markdown } + - name: cards + label: Cards + widget: object + summary: "{{title}}" + fields: + - *eyebrow + - *title + - *id + - *short + - *intro + - { name: legend, label: Explain the Required / Recommended / Not needed badges, widget: boolean, default: false } + - { name: columns, label: Most cards side by side, widget: number, value_type: int, min: 1, max: 4, required: false } + - *cards + - name: steps + label: Steps (a numbered process) + widget: object + summary: "{{title}}" + fields: + - *eyebrow + - *title + - *id + - *short + - *intro + - name: routes + label: Routes (more than one become tabs) + label_singular: Route + widget: list + summary: "{{label}}" + fields: + - { name: label, label: Tab label, widget: string, required: false } + - *steps + - { name: note, label: Note under the steps, widget: markdown, required: false } + + - name: journal-pages + label: Journal pages + label_singular: Journal page + description: Pages under /journal, in the journal's colours and under its menu (for-authors is /journal/for-authors). They replace the PubPub page at the same address. + folder: pages/journal + extension: yaml + format: yaml + create: true + slug: "{{slug}}" + identifier_field: title + fields: + - { name: title, label: Title, widget: string } + - { name: description, label: Text under the title (also for search engines and link previews), widget: text, required: false } + - { name: showTitle, label: Show the title as a big heading, widget: boolean, default: true } + - *sections - name: blog label: Blog diff --git a/apps/site/src/admin/preview.ts b/apps/site/src/admin/preview.ts index e033cf28..03944b9b 100644 --- a/apps/site/src/admin/preview.ts +++ b/apps/site/src/admin/preview.ts @@ -118,9 +118,9 @@ const session = () => JSON.parse(localStorage.getItem("sveltia-cms.user") ?? "nu * as the editor has it goes to /admin/preview (src/pages/admin/preview.astro), * which answers with the page as it will look, shown in a frame. */ -function PagePreview({ entry }: PreviewProps) { +function PagePreview({ entry, collection }: PreviewProps & { collection: string }) { const [html, setHtml] = React.useState("

Loading the preview…

"); - const body = JSON.stringify({ slug: entry.get("slug"), data: entry.get("data")?.toJS?.() ?? {} }); + const body = JSON.stringify({ slug: entry.get("slug"), collection, data: entry.get("data")?.toJS?.() ?? {} }); React.useEffect(() => { const controller = new AbortController(); @@ -147,4 +147,7 @@ function PagePreview({ entry }: PreviewProps) { return h("iframe", { srcDoc: html, title: "Preview", style: { width: "100%", height: "100vh", border: 0, display: "block" } }); } -CMS.registerPreviewTemplate("pages", PagePreview); +// Journal pages are drawn under the journal's menu +for (const collection of ["pages", "journal-pages"]) { + CMS.registerPreviewTemplate(collection, (props: PreviewProps) => h(PagePreview, { ...props, collection })); +} diff --git a/apps/site/src/components/Sections.astro b/apps/site/src/components/Sections.astro index 08f0344f..4089e5e0 100644 --- a/apps/site/src/components/Sections.astro +++ b/apps/site/src/components/Sections.astro @@ -1,12 +1,16 @@ --- import type { Section } from "~/content.config"; import Buttons from "./sections/Buttons.astro"; +import CardsSection from "./sections/CardsSection.astro"; +import Chooser from "./sections/Chooser.astro"; +import Contents from "./sections/Contents.astro"; import Features from "./sections/Features.astro"; import Hero from "./sections/Hero.astro"; import Issues from "./sections/Issues.astro"; import LatestArticles from "./sections/LatestArticles.astro"; import LatestPosts from "./sections/LatestPosts.astro"; import Newsletter from "./sections/Newsletter.astro"; +import StepsSection from "./sections/StepsSection.astro"; import Team from "./sections/Team.astro"; import Text from "./sections/Text.astro"; @@ -26,14 +30,26 @@ const components = { team: Team, newsletter: Newsletter, buttons: Buttons, + contents: Contents, + chooser: Chooser, + cards: CardsSection, + steps: StepsSection, } satisfies Record; const { sections } = Astro.props; + +// "On this page" lists the sections after it that have an anchor and a title +const linksAfter = (index: number) => + sections.slice(index + 1).flatMap((s) => { + if (!("id" in s) || !s.id || !("title" in s) || !s.title) return []; + const label = ("short" in s && s.short) || s.title; + return [{ id: s.id, label }]; + }); --- { - sections.map((section) => { + sections.map((section, i) => { const Component = components[section.type] as any; - return ; + return section.type === "contents" ? : ; }) } diff --git a/apps/site/src/components/blocks/Blocks.astro b/apps/site/src/components/blocks/Blocks.astro new file mode 100644 index 00000000..53c6db13 --- /dev/null +++ b/apps/site/src/components/blocks/Blocks.astro @@ -0,0 +1,41 @@ +--- +import type { Block } from "~/content.config"; +import Markdown from "../Markdown.astro"; +import Cards from "./Cards.astro"; +import More from "./More.astro"; +import Steps from "./Steps.astro"; + +interface Props { + blocks: Block[]; +} + +const { blocks } = Astro.props; +--- + +{ + blocks.map((block) => { + switch (block.type) { + case "text": + return ; + case "parts": + return ( +
    + {block.items.map((part, i) => ( +
  1. + + {i + 1} · {part.title} + + {part.text && } +
  2. + ))} +
+ ); + case "cards": + return ; + case "steps": + return ; + case "more": + return ; + } + }) +} diff --git a/apps/site/src/components/blocks/Cards.astro b/apps/site/src/components/blocks/Cards.astro new file mode 100644 index 00000000..aa530343 --- /dev/null +++ b/apps/site/src/components/blocks/Cards.astro @@ -0,0 +1,66 @@ +--- +import type { Card } from "~/content.config"; +import Markdown from "../Markdown.astro"; +import More from "./More.astro"; +import { badge, badgeClass, statusLabels } from "./status"; + +interface Props { + items: Card[]; + /** Narrowest a card gets before the grid wraps */ + min?: string; + /** Most cards side by side */ + columns?: number; +} + +const { items, min = "16rem", columns } = Astro.props; +// Cards wrap below `min`, and never more than `columns` sit in a row +const narrowest = columns ? `max(min(${min}, 100%), calc((100% - ${columns - 1} * 1.25rem) / ${columns}))` : `min(${min}, 100%)`; + +// A card with a status takes its frame from it unless it sets a look of its own +const frame = (card: Card) => { + if (card.look !== "box") return looks[card.look]; + if (card.status === "recommended") return "border-frame border-journal"; + if (card.status === "not-needed") return "border-frame border-dashed border-muted"; + return looks.box; +}; +const looks = { + box: "border-frame border-ink", + headed: "border-frame border-ink shadow-hard", + soft: "bg-journal-soft", + dashed: "border-frame border-dashed border-ink", + rule: "border-t-frame border-ink !px-0 !pb-0", + plain: "!p-0", +}; +--- + +
+ { + items.map((card) => ( +
+ {card.look === "headed" ? ( +

{card.title}

+ ) : ( + <> + {(card.status || card.badge) && ( + + {card.badge ?? statusLabels[card.status!]} + + )} +

{card.title}

+ + )} + {card.body && } + {card.tags.length > 0 && ( +
    + {card.tags.map((tag) => ( +
  • {tag}
  • + ))} +
+ )} + {card.more && } +
+ )) + } +
diff --git a/apps/site/src/components/blocks/More.astro b/apps/site/src/components/blocks/More.astro new file mode 100644 index 00000000..2ea66a13 --- /dev/null +++ b/apps/site/src/components/blocks/More.astro @@ -0,0 +1,25 @@ +--- +import { Icon } from "astro-icon/components"; +import Markdown from "../Markdown.astro"; + +interface Props { + label: string; + body: string; + small?: boolean; +} + +const { label, body, small = false } = Astro.props; +--- + +
+ + {label} + + +
diff --git a/apps/site/src/components/blocks/Steps.astro b/apps/site/src/components/blocks/Steps.astro new file mode 100644 index 00000000..e17a8945 --- /dev/null +++ b/apps/site/src/components/blocks/Steps.astro @@ -0,0 +1,26 @@ +--- +import type { Step } from "~/content.config"; +import Markdown from "../Markdown.astro"; + +interface Props { + steps: Step[]; +} + +const { steps } = Astro.props; +--- + +
    + { + steps.map((step, i) => ( +
  1. + {step.kicker ? ( + {step.kicker} + ) : ( + {i + 1} + )} + {step.title} + {step.text && } +
  2. + )) + } +
diff --git a/apps/site/src/components/blocks/status.ts b/apps/site/src/components/blocks/status.ts new file mode 100644 index 00000000..9fe03abd --- /dev/null +++ b/apps/site/src/components/blocks/status.ts @@ -0,0 +1,10 @@ +/** What a card's status badge says, also used for the legend above a cards section */ +export const statusLabels = { required: "Required", recommended: "Recommended", "not-needed": "Not needed" } as const; + +export const badgeClass = { + required: "bg-ink text-paper border-ink", + recommended: "border-journal text-journal", + "not-needed": "border-dashed border-muted text-muted", +} as const; + +export const badge = "self-start border-[1.5px] px-2 py-0.5 text-xs font-bold tracking-wide uppercase"; diff --git a/apps/site/src/components/blocks/switch.ts b/apps/site/src/components/blocks/switch.ts new file mode 100644 index 00000000..8fb87e0b --- /dev/null +++ b/apps/site/src/components/blocks/switch.ts @@ -0,0 +1,51 @@ +/* + * Show one panel of a group at a time: the chooser's options and the routes + * of a steps section. In the HTML, a group is + * + *
+ * + *
…
+ * + * Without JavaScript every panel shows. A link to a panel's id + * (/journal/for-authors#registered-report) opens that panel. + */ + +function select(group: Element, id: string, scroll = false) { + for (const button of group.querySelectorAll("[data-switch-to]")) { + if (button.closest("[data-switch]") !== group) continue; + button.setAttribute("aria-pressed", String(button.dataset.switchTo === id)); + } + let shown: HTMLElement | undefined; + for (const panel of group.querySelectorAll("[data-switch-panel]")) { + if (panel.closest("[data-switch]") !== group) continue; + panel.hidden = panel.id !== id; + if (!panel.hidden) shown = panel; + } + // On narrow screens the panel sits below the cards, out of sight + if (scroll && shown && shown.getBoundingClientRect().top > window.innerHeight * 0.6) { + const smooth = !window.matchMedia("(prefers-reduced-motion: reduce)").matches; + shown.scrollIntoView({ behavior: smooth ? "smooth" : "auto", block: "start" }); + } +} + +function openFromHash() { + const id = decodeURIComponent(location.hash.slice(1)); + const panel = id && document.getElementById(id); + const group = panel instanceof HTMLElement && panel.matches("[data-switch-panel]") ? panel.closest("[data-switch]") : null; + if (!group || !panel) return; + select(group, id); + panel.scrollIntoView({ block: "start" }); +} + +document.addEventListener("click", (event) => { + const button = (event.target as Element).closest("[data-switch-to]"); + const group = button?.closest("[data-switch]"); + if (!button || !group) return; + const id = button.dataset.switchTo!; + select(group, id, true); + // So the address can be shared, without jumping + history.replaceState(null, "", `#${id}`); +}); + +window.addEventListener("hashchange", openFromHash); +openFromHash(); diff --git a/apps/site/src/components/sections/CardsSection.astro b/apps/site/src/components/sections/CardsSection.astro new file mode 100644 index 00000000..87ec5cf3 --- /dev/null +++ b/apps/site/src/components/sections/CardsSection.astro @@ -0,0 +1,26 @@ +--- +import type { Section } from "~/content.config"; +import Cards from "../blocks/Cards.astro"; +import { badge, badgeClass, statusLabels } from "../blocks/status"; +import SectionHeading from "./SectionHeading.astro"; + +type Props = Extract; +const { id, eyebrow, title, intro, legend, columns, items } = Astro.props; +const used = (Object.keys(statusLabels) as (keyof typeof statusLabels)[]).filter((s) => items.some((card) => card.status === s)); +--- + +
+ + { + legend && used.length > 0 && ( +

+ Key: + {used.map((s) => ( + {statusLabels[s]} + ))} +

+ ) + } +
+ +
diff --git a/apps/site/src/components/sections/Chooser.astro b/apps/site/src/components/sections/Chooser.astro new file mode 100644 index 00000000..2c504588 --- /dev/null +++ b/apps/site/src/components/sections/Chooser.astro @@ -0,0 +1,94 @@ +--- +import GithubSlugger from "github-slugger"; +import type { Section } from "~/content.config"; +import Blocks from "../blocks/Blocks.astro"; +import SectionHeading from "./SectionHeading.astro"; + +/* + * Cards to pick from, on the left; the chosen one's panel on the right. + * Without JavaScript all panels show, one under the other. + */ + +type Props = Extract; +const { id, eyebrow, title, intro, groups: rawGroups } = Astro.props; + +const slugger = new GithubSlugger(); +const groups = rawGroups.map((group) => ({ + ...group, + options: group.options.map((option) => ({ ...option, id: option.id || slugger.slug(option.title) })), +})); +const options = groups.flatMap((g) => g.options); +const first = options[0]?.id; +--- + +
+ +
+
+ { + groups.map((group) => ( +
+ {(group.title || group.note) && ( +
+ {group.title &&

{group.title}

} + {group.note && {group.note}} +
+ )} + {group.options.map((option) => ( + + ))} +
+ )) + } +
+ +
+ { + options.map((option) => ( + + )) + } +
+
+
+ + + + diff --git a/apps/site/src/components/sections/Contents.astro b/apps/site/src/components/sections/Contents.astro new file mode 100644 index 00000000..8b620194 --- /dev/null +++ b/apps/site/src/components/sections/Contents.astro @@ -0,0 +1,22 @@ +--- +interface Props { + title: string; + /** The sections after this one that have an anchor and a title */ + links: { id: string; label: string }[]; +} + +const { title, links } = Astro.props; +--- + + diff --git a/apps/site/src/components/sections/SectionHeading.astro b/apps/site/src/components/sections/SectionHeading.astro new file mode 100644 index 00000000..fe290391 --- /dev/null +++ b/apps/site/src/components/sections/SectionHeading.astro @@ -0,0 +1,23 @@ +--- +import Markdown from "../Markdown.astro"; + +interface Props { + eyebrow?: string; + title?: string; + intro?: string; +} + +/* The top of a structured section: "Step 1", the title, a line or two of text */ +const { eyebrow, title, intro } = Astro.props; +--- + +{ + (eyebrow || title || intro) && ( +
+ {eyebrow && {eyebrow}} + {title &&

{title}

} + {intro && } + +
+ ) +} diff --git a/apps/site/src/components/sections/StepsSection.astro b/apps/site/src/components/sections/StepsSection.astro new file mode 100644 index 00000000..1fb4d97c --- /dev/null +++ b/apps/site/src/components/sections/StepsSection.astro @@ -0,0 +1,53 @@ +--- +import GithubSlugger from "github-slugger"; +import type { Section } from "~/content.config"; +import Markdown from "../Markdown.astro"; +import Steps from "../blocks/Steps.astro"; +import SectionHeading from "./SectionHeading.astro"; + +type Props = Extract; +const { id, eyebrow, title, intro, routes, note } = Astro.props; + +// Several routes become tabs; each needs an id so a link can open it +const slugger = new GithubSlugger(); +const tabs = routes.map((route, i) => ({ ...route, id: slugger.slug(`${id ?? "steps"}-${route.label || i + 1}`) })); +--- + +
+ + { + tabs.length > 1 ? ( +
+
+ {tabs.map((tab, i) => ( + + ))} +
+ {tabs.map((tab, i) => ( + + ))} +
+ ) : ( + tabs[0] && + ) + } + {note && } +
+ + + + diff --git a/apps/site/src/components/sections/Text.astro b/apps/site/src/components/sections/Text.astro index c002af87..4fb761d1 100644 --- a/apps/site/src/components/sections/Text.astro +++ b/apps/site/src/components/sections/Text.astro @@ -3,7 +3,7 @@ import type { Section } from "~/content.config"; import Markdown from "../Markdown.astro"; type Props = Extract; -const { id, title, body, background } = Astro.props; +const { id, title, body, background, buttons } = Astro.props; const backgrounds = { paper: "bg-paper", @@ -18,5 +18,16 @@ const backgrounds = {
{title &&

{title}

} + { + buttons.length > 0 && ( +
+ {buttons.map((b) => ( + + {b.label} + + ))} +
+ ) + }
diff --git a/apps/site/src/content.config.ts b/apps/site/src/content.config.ts index c5eb6a13..1c1e3b3c 100644 --- a/apps/site/src/content.config.ts +++ b/apps/site/src/content.config.ts @@ -5,7 +5,9 @@ import { contentDir } from "./integrations/content"; /* * Pages built from sections. Each file in pages/ of site-content is one page: - * index.yaml is the homepage, about.yaml is /about, and so on. A page is a + * index.yaml is the homepage, about.yaml is /about, and so on. Files in + * pages/journal/ are the journal's own pages: pages/journal/for-authors.yaml + * is /journal/for-authors, under the journal's menu. A page is a * list of sections, drawn top to bottom. Each section type is a component in * src/components/sections. Text fields accept Markdown. */ @@ -15,6 +17,87 @@ const background = z.enum(["paper", "accent", "accent-soft", "journal", "journal /** How a list of journal articles looks: cards with the abstract, or a compact list like a table of contents */ export const articleStyle = z.enum(["cards", "list"]); +/* + * Building blocks for structured pages, like the journal's For Authors page. + * Cards and steps are sections of their own, and also go inside the panel + * of a chooser option, with text, numbered parts and "read more" boxes. + */ + +/** Required / recommended / not needed: sets a card's badge and frame */ +const status = z.enum(["required", "recommended", "not-needed"]); + +const card = z.object({ + title: z.string(), + body: z.string().optional(), + status: status.optional(), + /** Badge text instead of the status's own ("Required if 2+ authors") */ + badge: z.string().optional(), + /** box: framed; headed: title in a dark bar; soft: tinted; dashed; rule: a line on top; plain: nothing */ + look: z.enum(["box", "headed", "soft", "dashed", "rule", "plain"]).default("box"), + /** Small boxed words under the text (CRediT roles) */ + tags: z.array(z.string()).default([]), + /** Extra text that opens on click */ + more: z.object({ label: z.string(), body: z.string() }).optional(), +}); + +const step = z.object({ + /** Small text above the title ("Stage 1 · You"). Steps without one are numbered */ + kicker: z.string().optional(), + title: z.string(), + text: z.string().optional(), + /** Draw this step in the journal colour, for a milestone */ + highlight: z.boolean().default(false), +}); + +const block = z.discriminatedUnion("type", [ + z.object({ type: z.literal("text"), body: z.string() }), + /** Numbered rows, such as the parts of a manuscript in order */ + z.object({ + type: z.literal("parts"), + items: z.array(z.object({ title: z.string(), text: z.string().default("") })), + }), + z.object({ + type: z.literal("cards"), + /** Most cards side by side */ + columns: z.number().int().min(1).max(4).optional(), + items: z.array(card), + }), + z.object({ type: z.literal("steps"), steps: z.array(step) }), + z.object({ type: z.literal("more"), label: z.string(), body: z.string() }), +]); + +export type Card = z.infer; +export type Step = z.infer; +export type Block = z.infer; + +/** The top of every structured section: "Step 1", the title, a line of text */ +const heading = { + id: z.string().optional(), + eyebrow: z.string().optional(), + title: z.string().optional(), + intro: z.string().optional(), + /** Shorter name for the "On this page" list */ + short: z.string().optional(), +}; + +const option = z.object({ + /** For links straight to this option (/journal/for-authors#registered-report). Made from the title if empty */ + id: z.string().optional(), + title: z.string(), + badge: z.string().optional(), + summary: z.string().optional(), + facts: z.array(z.object({ label: z.string(), value: z.string() })).default([]), + /** The panel's title ("Empirical article: what to include"). The option's title if empty */ + heading: z.string().optional(), + /** Small text beside the panel's title */ + note: z.string().optional(), + /** journal: the chosen card and the panel's title bar are in the journal colour instead of black */ + tone: z.enum(["ink", "journal"]).default("ink"), + blocks: z.array(block).default([]), +}); + +export type Option = z.infer; + const section = z.discriminatedUnion("type", [ z.object({ type: z.literal("hero"), @@ -30,6 +113,7 @@ const section = z.discriminatedUnion("type", [ title: z.string().optional(), body: z.string(), background, + buttons: z.array(button).default([]), }), z.object({ type: z.literal("features"), @@ -71,6 +155,39 @@ const section = z.discriminatedUnion("type", [ type: z.literal("buttons"), buttons: z.array(button), }), + /** "On this page": links to the sections below it that have an anchor and a title */ + z.object({ + type: z.literal("contents"), + title: z.string().default("On this page"), + }), + /** Cards to pick from, each opening its own panel ("What are you submitting?") */ + z.object({ + type: z.literal("chooser"), + ...heading, + groups: z.array( + z.object({ + title: z.string().optional(), + note: z.string().optional(), + options: z.array(option), + }), + ), + }), + z.object({ + type: z.literal("cards"), + ...heading, + /** Explain the Required / Recommended / Not needed badges above the cards */ + legend: z.boolean().default(false), + /** Most cards side by side */ + columns: z.number().int().min(1).max(4).optional(), + items: z.array(card), + }), + /** A numbered process. With more than one route, the routes become tabs */ + z.object({ + type: z.literal("steps"), + ...heading, + routes: z.array(z.object({ label: z.string().default(""), steps: z.array(step) })), + note: z.string().optional(), + }), ]); export type Section = z.infer; diff --git a/apps/site/src/layouts/SectionsPage.astro b/apps/site/src/layouts/SectionsPage.astro index e7bab05f..36a0e514 100644 --- a/apps/site/src/layouts/SectionsPage.astro +++ b/apps/site/src/layouts/SectionsPage.astro @@ -13,20 +13,34 @@ interface Props { searchSection?: string; } -/* A page built from sections in site-content: pages/.yaml, also drawn by /admin's preview */ +/* A page built from sections in site-content: pages/.yaml, or pages/journal/.yaml for the journal. Also drawn by /admin's preview */ const { page, journal, searchSection } = Astro.props; +// A journal page's tab names the journal too, as its PubPub pages do +const title = journal && page.title !== journal.config.title ? `${page.title} | ${journal.config.title}` : page.title; --- - + {journal && } { - page.showTitle && ( -
-
-

{page.title}

+ page.showTitle && + (journal ? ( + // Like the journal's PubPub pages (JournalPage.astro): the journal's name, the title and the description +
+
+ + {journal.config.title} + +

{page.title}

+ {page.description &&

{page.description}

} +
-
- ) + ) : ( +
+
+

{page.title}

+
+
+ )) } { searchSection ? ( diff --git a/apps/site/src/pages/[...slug].astro b/apps/site/src/pages/[...slug].astro index 7310ad21..26417d37 100644 --- a/apps/site/src/pages/[...slug].astro +++ b/apps/site/src/pages/[...slug].astro @@ -13,7 +13,8 @@ type Props = { local: CollectionEntry<"pages"> } | { cms: CmsPage }; export const getStaticPaths = (async () => { // The homepage and the journal's front page have pages of their own (index.astro, journal/index.astro) const OWN_PAGES = ["index", "journal"]; - const local = (await getCollection("pages")).filter((p) => !OWN_PAGES.includes(p.id)); + // pages/journal/* are the journal's (journal/[slug].astro) + const local = (await getCollection("pages")).filter((p) => !OWN_PAGES.includes(p.id) && !p.id.startsWith("journal/")); const localSlugs = new Set([...local.map((p) => p.id), ...OWN_PAGES]); const cms = (await getCmsPages()).filter((p) => !localSlugs.has(p.slug)); return [ diff --git a/apps/site/src/pages/admin/preview.astro b/apps/site/src/pages/admin/preview.astro index eaf34b08..2fe44e41 100644 --- a/apps/site/src/pages/admin/preview.astro +++ b/apps/site/src/pages/admin/preview.astro @@ -32,14 +32,16 @@ const clean = (value: unknown): unknown => { return value; }; -const { slug, data } = (await Astro.request.json().catch(() => ({}))) as { slug?: string; data?: unknown }; +const { slug, data, collection } = (await Astro.request.json().catch(() => ({}))) as { slug?: string; data?: unknown; collection?: string }; +// The journal's front page (pages/journal.yaml) and its own pages (pages/journal/) are drawn under the journal's menu +const inJournal = slug === "journal" || collection === "journal-pages"; const parsed = pageSchema.safeParse(clean(data ?? {})); const problems = parsed.success ? [] : parsed.error.issues.map((issue) => `${issue.path.join(" → ") || "page"}: ${issue.message}`); --- { parsed.success ? ( - + ) : ( diff --git a/apps/site/src/pages/journal/[slug].astro b/apps/site/src/pages/journal/[slug].astro index 4429d321..9cb91d37 100644 --- a/apps/site/src/pages/journal/[slug].astro +++ b/apps/site/src/pages/journal/[slug].astro @@ -1,19 +1,28 @@ --- import type { GetStaticPaths } from "astro"; +import { getCollection } from "astro:content"; import JournalArticle from "~/layouts/JournalArticle.astro"; import JournalPage from "~/layouts/JournalPage.astro"; +import SectionsPage from "~/layouts/SectionsPage.astro"; import { getArticleDetails, prefetchArticles } from "~/lib/journal-article"; -import { ARTICLE_PAGES, getArticles, getPages } from "~/lib/sources/journal"; +import { ARTICLE_PAGES, getArticles, getPages, journal } from "~/lib/sources/journal"; export const getStaticPaths = (async () => { + // Pages built from sections in site-content (pages/journal/.yaml) win over everything else at their address + const local = (await getCollection("pages")) + .filter((p) => p.id.startsWith("journal/")) + .map((p) => ({ slug: p.id.slice("journal/".length), data: p.data })); + const taken = new Set(local.map((p) => p.slug)); + const sectionPages = local.map((p) => ({ params: { slug: p.slug }, props: { sections: p.data } })); // The journal's own pages from PubPub (For Authors, Editorial Board...) share the address space - const pages = (await getPages()).map((page) => ({ params: { slug: page.slug }, props: { page } })); - if (!ARTICLE_PAGES) return pages; + const pages = (await getPages()).filter((p) => !taken.has(p.slug)).map((page) => ({ params: { slug: page.slug }, props: { page } })); + if (!ARTICLE_PAGES) return [...sectionPages, ...pages]; const articles = await getArticles(); await prefetchArticles(articles); return [ + ...sectionPages, ...pages, - ...articles.map((article) => { + ...articles.filter((article) => !taken.has(article.slug)).map((article) => { const issue = article.issues[0]?.slug; const related = issue ? articles.filter((a) => a.id !== article.id && a.issues.some((i) => i.slug === issue)).slice(0, 3) @@ -23,8 +32,16 @@ export const getStaticPaths = (async () => { ]; }) satisfies GetStaticPaths; -const { page, article, related } = Astro.props; +const { sections, page, article, related } = Astro.props; const details = article && (await getArticleDetails(article)); --- -{page ? : article && details && } +{ + sections ? ( + + ) : page ? ( + + ) : ( + article && details && + ) +} diff --git a/apps/site/src/styles/global.css b/apps/site/src/styles/global.css index bd427968..4bacbbbc 100644 --- a/apps/site/src/styles/global.css +++ b/apps/site/src/styles/global.css @@ -152,6 +152,26 @@ @apply w-full border-frame border-ink; } + /* Short Markdown inside cards, steps and panels: tighter than .prose-site */ + .card-text { + @apply flex flex-col gap-3; + } + .card-text :where(ul) { + @apply list-disc pl-5; + } + .card-text :where(ol) { + @apply list-decimal pl-5; + } + .card-text :where(li + li) { + @apply mt-1; + } + .card-text :where(a) { + @apply font-semibold underline decoration-2 underline-offset-4 hover:decoration-accent; + } + .card-text :where(h3, h4) { + @apply font-heading text-lg font-extrabold; + } + /* Drop cap on the first paragraph of a blog post */ .lettrine { @apply float-left mt-2 mr-2 border-frame border-ink bg-paper px-2 font-heading text-6xl leading-none font-bold md:text-8xl; From ab78ad70a9da89ba22bf070331d4a6f91becacd1 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Fri, 6 Nov 2026 14:58:17 +0100 Subject: [PATCH 2/2] feat(site): chooser cards without a panel are plain cards, card tags take Markdown links --- apps/site/docs/editing.md | 6 ++- apps/site/src/admin/config.yml | 3 +- apps/site/src/components/blocks/Cards.astro | 3 +- .../src/components/sections/Chooser.astro | 37 ++++++++++++------- apps/site/src/content.config.ts | 1 + 5 files changed, 33 insertions(+), 17 deletions(-) diff --git a/apps/site/docs/editing.md b/apps/site/docs/editing.md index 291546a9..61ed8b90 100644 --- a/apps/site/docs/editing.md +++ b/apps/site/docs/editing.md @@ -52,11 +52,13 @@ page, there are also: - **Chooser**: cards to pick from, each opening its own panel beside them ("What are you submitting?"). A panel holds text, numbered parts, cards, steps and "read more" boxes. Each card has an anchor, so - `/journal/for-authors#registered-report` opens that card's panel. + `/journal/for-authors#registered-report` opens that card's panel. A card + with no panel content is a plain card that opens nothing. - **Cards**: boxes in a grid, optionally marked Required, Recommended or Not needed, with a key above them if you tick it. Each card can be framed, tinted, dashed, titled in a dark bar or have only a line on top, and can - have small tags and extra text that opens on click. + have small tags and extra text that opens on click. A tag can be a link: + `[Methodology](https://credit.niso.org/contributor-roles/methodology/)`. - **Steps**: a numbered process. Several routes (Standard, Perspective, Registered Report) become tabs. diff --git a/apps/site/src/admin/config.yml b/apps/site/src/admin/config.yml index d16f6756..78fe233f 100644 --- a/apps/site/src/admin/config.yml +++ b/apps/site/src/admin/config.yml @@ -203,6 +203,7 @@ collections: - name: blocks label: Panel content label_singular: Block + hint: Leave empty for a card that only informs, with nothing to open widget: list typeKey: type types: @@ -250,7 +251,7 @@ collections: - { label: Dashed frame, value: dashed } - { label: Line on top, value: rule } - { label: No frame, value: plain } - - { name: tags, label: Small boxed words under the text, widget: list, required: false } + - { name: tags, label: Small boxed words under the text, widget: list, required: false, hint: "Separate with commas. For a link, write [Word](https://…)" } - name: more label: Extra text that opens on click widget: object diff --git a/apps/site/src/components/blocks/Cards.astro b/apps/site/src/components/blocks/Cards.astro index aa530343..b38e78e7 100644 --- a/apps/site/src/components/blocks/Cards.astro +++ b/apps/site/src/components/blocks/Cards.astro @@ -1,4 +1,5 @@ --- +import { marked } from "marked"; import type { Card } from "~/content.config"; import Markdown from "../Markdown.astro"; import More from "./More.astro"; @@ -55,7 +56,7 @@ const looks = { {card.tags.length > 0 && (
    {card.tags.map((tag) => ( -
  • {tag}
  • +
  • ))}
)} diff --git a/apps/site/src/components/sections/Chooser.astro b/apps/site/src/components/sections/Chooser.astro index 2c504588..3d527224 100644 --- a/apps/site/src/components/sections/Chooser.astro +++ b/apps/site/src/components/sections/Chooser.astro @@ -6,7 +6,8 @@ import SectionHeading from "./SectionHeading.astro"; /* * Cards to pick from, on the left; the chosen one's panel on the right. - * Without JavaScript all panels show, one under the other. + * An option with no blocks has no panel, so it is a plain card instead of + * a button. Without JavaScript all panels show, one under the other. */ type Props = Extract; @@ -17,8 +18,9 @@ const groups = rawGroups.map((group) => ({ ...group, options: group.options.map((option) => ({ ...option, id: option.id || slugger.slug(option.title) })), })); -const options = groups.flatMap((g) => g.options); +const options = groups.flatMap((g) => g.options).filter((option) => option.blocks.length > 0); const first = options[0]?.id; +const card = "flex flex-col gap-2 border-frame border-ink bg-paper p-4 text-left"; ---
@@ -34,15 +36,9 @@ const first = options[0]?.id; {group.note && {group.note}}
)} - {group.options.map((option) => ( - - ))} + + ); + return option.blocks.length > 0 ? ( + + ) : ( +
{body}
+ ); + })}
)) } diff --git a/apps/site/src/content.config.ts b/apps/site/src/content.config.ts index 1c1e3b3c..791d3b5f 100644 --- a/apps/site/src/content.config.ts +++ b/apps/site/src/content.config.ts @@ -35,6 +35,7 @@ const card = z.object({ /** box: framed; headed: title in a dark bar; soft: tinted; dashed; rule: a line on top; plain: nothing */ look: z.enum(["box", "headed", "soft", "dashed", "rule", "plain"]).default("box"), /** Small boxed words under the text (CRediT roles) */ + /** Markdown, so a tag can be a link: [Methodology](https://…) */ tags: z.array(z.string()).default([]), /** Extra text that opens on click */ more: z.object({ label: z.string(), body: z.string() }).optional(),