diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27d050bd..92d2c839 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,11 @@ jobs: - run: pnpm format:check - - run: pnpm turbo run build typecheck test --cache-dir=.turbo/cache + # The site is built and deployed by site.yml: its build reads Strapi, Underlay and + # site-content. Here it's only type-checked. + - run: pnpm turbo run build typecheck test --cache-dir=.turbo/cache --filter='!site' + + - run: pnpm turbo run typecheck --cache-dir=.turbo/cache --filter=site # Non-blocking reminder: list packages that changed without a changeset. - name: Changeset status diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6aa79a4e..88cce677 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,7 @@ # -> build, pack and publish them via npm trusted publishing (OIDC) # # No npm token is used anywhere. Each package must have a trusted publisher configured on npmjs.com -# pointing at TrialAndErrorOrg/parsers + this file (release.yml). See docs/releasing.md. +# pointing at TrialAndErrorOrg/trialanderror + this file (release.yml). See docs/releasing.md. # # Layout follows https://changesets.dev/guide/automating (trusted publishing variant): only the # `publish` job gets `id-token: write`. diff --git a/.github/workflows/site.yml b/.github/workflows/site.yml new file mode 100644 index 00000000..8de4eab8 --- /dev/null +++ b/.github/workflows/site.yml @@ -0,0 +1,129 @@ +# Builds the website (apps/site) with the content from TrialAndErrorOrg/site-content, publishes it +# as ghcr.io/trialanderrororg/site and asks Coolify to deploy it. Fast because it keeps every cache +# between runs: pnpm's store, turbo's cache (the libraries the site uses) and the site's own caches +# of resized images, share images and journal article HTML. The image only packages the built +# site, see apps/site/Dockerfile. +# +# Secret: OP_TOKEN, a 1Password service account token. varlock reads everything else from the +# Website vault (apps/site/.env.schema). See apps/site/docs/deploying.md. +name: site + +on: + push: + branches: [main] + paths: + - apps/site/** + - libs/sveltia-gateway/** + # The converter at /admin/convert and the Typst template it puts in its downloads + - apps/converter/** + - templates/typst/** + - pnpm-lock.yaml + - .github/workflows/site.yml + # Sent by Strapi when something is published. site-content starts the workflow directly instead + repository_dispatch: + types: [content-updated] + # New journal articles reach Underlay without telling anyone + schedule: + - cron: '17 4 * * *' + workflow_dispatch: + +permissions: {} + +# Only the newest content matters: a save cancels the build of the one before +concurrency: + group: site + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + env: + IMAGE: ghcr.io/trialanderrororg/site + CONTENT_DIR: ${{ github.workspace }}/site-content + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + # What editors change in /admin, see apps/site/src/integrations/content.ts + - uses: actions/checkout@v7 + with: + repository: TrialAndErrorOrg/site-content + path: site-content + persist-credentials: false + + - uses: pnpm/action-setup@v6 + with: + version: 11.3.0 + + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Restore turbo cache + uses: actions/cache@v6 + with: + path: .turbo/cache + key: turbo-site-${{ runner.os }}-${{ github.sha }} + restore-keys: | + turbo-site-${{ runner.os }}- + turbo-${{ runner.os }}-main- + + # Content-addressed, so restoring the newest copy is always safe; each run saves a fresh one + - name: Restore the site's image and article caches + uses: actions/cache@v6 + with: + path: | + apps/site/node_modules/.astro + apps/site/node_modules/.cache + key: site-assets-${{ github.run_id }} + restore-keys: site-assets- + + # The site's build script runs through varlock, which reads the Strapi and Underlay tokens + # from 1Password and hides them in the log + - name: Build + run: pnpm turbo run build --filter=site... --cache-dir=.turbo/cache + env: + OP_TOKEN: ${{ secrets.OP_TOKEN }} + + # The built site plus only its production dependencies, with the musl builds of native + # packages for the Alpine image + - name: Package + run: pnpm --filter site deploy --prod --legacy --os=linux --cpu=x64 --libc=musl "$RUNNER_TEMP/site" + + - name: Build the image + run: | + docker build "$RUNNER_TEMP/site" -f apps/site/Dockerfile \ + --label org.opencontainers.image.source=https://github.com/${{ github.repository }} \ + --label org.opencontainers.image.revision=${{ github.sha }} \ + -t "$IMAGE:latest" -t "$IMAGE:${{ github.sha }}" + + # Every route the server renders itself, so a runtime package missing from the image's + # dependencies fails here instead of on the live site. scripts/release.sh runs it too + - name: Smoke test the image + run: apps/site/scripts/smoke-test.sh "$IMAGE:${{ github.sha }}" + + - name: Push the image + run: | + echo "$GITHUB_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin + docker push --all-tags "$IMAGE" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Skipped until COOLIFY_DEPLOY_URL is set in apps/site/.env.schema + - name: Deploy + working-directory: apps/site + run: | + # shellcheck disable=SC2016 # expanded by the inner shell, after varlock has filled them in + pnpm exec varlock run -- sh -c ' + if [ -z "$COOLIFY_DEPLOY_URL" ]; then echo "No COOLIFY_DEPLOY_URL, not deploying"; exit 0; fi + curl --fail --silent --show-error -X POST -H "Authorization: Bearer $COOLIFY_TOKEN" "$COOLIFY_DEPLOY_URL" + ' + env: + OP_TOKEN: ${{ secrets.OP_TOKEN }} diff --git a/.gitignore b/.gitignore index 1d9f1d2e..467be2c1 100644 --- a/.gitignore +++ b/.gitignore @@ -356,10 +356,14 @@ thebiggestfile.ts .env .env.* +# Examples and varlock schemas hold no values, so they belong in the repository +!.env.example +!.env.schema graph # typedoc scratch output (scripts/readme.ts); hand-written docs/*.md are tracked **/docs/* !/docs/*.md +!/apps/site/docs/*.md .sveltekit diff --git a/.oxfmtrc.json b/.oxfmtrc.json index f3788f87..e429a255 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -8,6 +8,7 @@ "sortPackageJson": false, "ignorePatterns": [ "dist", + "apps/site", "deprecated", "coverage", ".next", @@ -37,7 +38,7 @@ "files": ["apps/converter/**"], "options": { "sortTailwindcss": { - "stylesheet": "apps/converter/app/globals.css" + "stylesheet": "apps/converter/styles.css" } } } diff --git a/README.md b/README.md index a764e861..1b414a6d 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Additionally, there are a few other tools: - `unified-latex`: Utilities for working with [unified-latex](https://github.com/siefkenj/unified-latex) trees. - `book-converter`: a CLI to turn a `.docx` book into LaTeX. -`apps/converter` is the web converter (Next.js) that uses the parsers to turn `.docx` manuscripts into LaTeX. +`apps/converter` is the web converter, a React component that uses the parsers to turn `.docx` manuscripts into LaTeX and Typst in the browser. The website (`apps/site`) shows it at `/admin/convert`, where editors signed in with Microsoft can also search OJS. See below for more info. @@ -74,7 +74,7 @@ Packages marked _private_ are not published. | Package | Description | |---|---| -| [`crossref-json`](libs/citations/crossref-json) | Defined in: [index.ts:38](https://github.com/TrialAndErrorOrg/parsers/blob/main/libs/citations/crossref-json/src/index.ts | +| [`crossref-json`](libs/citations/crossref-json) | Defined in: [index.ts:38](https://github.com/TrialAndErrorOrg/trialanderror/blob/main/libs/citations/crossref-json/src/index.ts | | [`crossref-to-csl`](libs/citations/crossref-to-csl) | crossrefToCsl(item: CrossrefJSON): CSL; | | [`csl-consolidate`](libs/citations/csl-consolidate) | Try to resolve a list of CSL data with crossref metadata | | [`csl-to-biblatex`](libs/citations/csl-to-biblatex) | Somewhat jank CSL-JSON to biblatex converter | @@ -265,8 +265,8 @@ typst compile \ out/article/main.typ ``` -Or pass `--compile --font-path …` to either CLI to do it in one go. The converter app -(`apps/converter`) has a Typst tab that downloads the same output, template included, as a zip. +Or pass `--compile --font-path …` to either CLI to do it in one go. The converter +(`apps/converter`, on the site at `/admin/convert`) has a Typst tab that downloads the same output, template included, as a zip. + + + +
+ + +
+ +
+
+ + + diff --git a/apps/site/src/components/IssueCard.astro b/apps/site/src/components/IssueCard.astro new file mode 100644 index 00000000..d0f0f672 --- /dev/null +++ b/apps/site/src/components/IssueCard.astro @@ -0,0 +1,43 @@ +--- +import type { Issue } from "~/lib/sources/journal"; +import Picture from "./Picture.astro"; + +interface Props { + issue: Issue; + /** Where the issue's page is */ + href: string; +} + +/* + * An issue, drawn as its cover on a small stack of printed copies, with the + * title underneath like on a shelf. Rolling issues get a plain blue cover. + */ +const { issue, href } = Astro.props; +const count = `${issue.articles.length} ${issue.articles.length === 1 ? "article" : "articles"}`; +--- + +
+
+ { + issue.image ? ( + + ) : ( +

+ {issue.rolling ? "Rolling" : issue.title} + {issue.rolling && Articles as they're ready} +

+ ) + } + {issue.rolling && Ongoing} +
+
+

+ {issue.rolling ? "Ongoing" : issue.date.slice(0, 4)} · {count} +

+

+ + {issue.title} + +

+
+
diff --git a/apps/site/src/components/JournalNav.astro b/apps/site/src/components/JournalNav.astro new file mode 100644 index 00000000..1e7d2b71 --- /dev/null +++ b/apps/site/src/components/JournalNav.astro @@ -0,0 +1,81 @@ +--- +import { Icon } from "astro-icon/components"; +import { type Journal, journal as defaultJournal } from "~/lib/sources/journal"; + +/* + * The journal's own menu: a blue bar under the header on every /journal + * page. Its links are set in settings/navigation.yaml in site-content (or /admin → Menus). + * Other journals bring their own links, see src/lib/sources/journal.ts. + */ + +interface Props { + journal?: Journal; +} + +const source = Astro.props.journal ?? defaultJournal(); +const { config } = source; +// Links to the journal's PubPub pages go to this site's copy once the export has it +const localize = async (url?: string) => (url ? source.localUrl(url) : url); +const menu = await Promise.all( + config.menu.map(async (item) => ({ + ...item, + url: await localize(item.url), + children: item.children && (await Promise.all(item.children.map(async (child) => ({ ...child, url: await localize(child.url) })))), + })), +); +const base = config.path; +const list = config.listPath; +const path = Astro.url.pathname.replace(/\/$/, "") || "/"; +// "Articles" is current on the article list and its type filters, other links on their own pages +const isCurrent = (url?: string) => { + if (!url?.startsWith("/")) return false; + if (url === list) return path === list || path.startsWith(`${base}/kind/`); + return path === url || (url === `${base}/issues` && path.startsWith(`${base}/issue/`)); +}; +const external = (url?: string) => !!url && /^https?:/.test(url); +--- + + diff --git a/apps/site/src/components/Logo.astro b/apps/site/src/components/Logo.astro new file mode 100644 index 00000000..d8f045f5 --- /dev/null +++ b/apps/site/src/components/Logo.astro @@ -0,0 +1,12 @@ +--- +interface Props { + class?: string; +} +const { class: className } = Astro.props; +--- + + diff --git a/apps/site/src/components/Markdown.astro b/apps/site/src/components/Markdown.astro new file mode 100644 index 00000000..f1d2827f --- /dev/null +++ b/apps/site/src/components/Markdown.astro @@ -0,0 +1,13 @@ +--- +import { marked } from "marked"; + +interface Props { + text: string; + class?: string; +} + +const { text, class: className } = Astro.props; +const html = await marked.parse(text); +--- + +
diff --git a/apps/site/src/components/Pagination.astro b/apps/site/src/components/Pagination.astro new file mode 100644 index 00000000..b0d9e342 --- /dev/null +++ b/apps/site/src/components/Pagination.astro @@ -0,0 +1,36 @@ +--- +interface Props { + current: number; + total: number; + /** URL of page n */ + href: (page: number) => string; +} + +const { current, total, href } = Astro.props; +--- + +{ + total > 1 && ( + + ) +} diff --git a/apps/site/src/components/PersonCard.astro b/apps/site/src/components/PersonCard.astro new file mode 100644 index 00000000..149fd9d1 --- /dev/null +++ b/apps/site/src/components/PersonCard.astro @@ -0,0 +1,69 @@ +--- +import { personHref } from "~/lib/people"; +import type { Person } from "~/lib/types"; +import Picture from "./Picture.astro"; +import SocialIcon from "./SocialIcon.astro"; + +interface Props { + person: Person; + /** Show the full bio rather than a short one */ + full?: boolean; + link?: boolean; +} + +const { person, full = false, link = true } = Astro.props; +const href = link ? await personHref(person, Astro.isPrerendered) : undefined; +const initials = person.name + .split(" ") + .map((part) => part[0]) + .slice(0, 2) + .join(""); +--- + +
+
+ { + person.image ? ( + + ) : ( + {initials} + ) + } +
+
+

+ {href ? {person.name} : person.name} +

+ {person.position &&

{person.position}

} + {person.summary &&
} +
    + { + person.orcid && ( +
  • + + + +
  • + ) + } + { + person.links.map((l) => ( +
  • + + + +
  • + )) + } + { + person.email && ( +
  • + + + +
  • + ) + } +
+
+
diff --git a/apps/site/src/components/Picture.astro b/apps/site/src/components/Picture.astro new file mode 100644 index 00000000..9671bbde --- /dev/null +++ b/apps/site/src/components/Picture.astro @@ -0,0 +1,37 @@ +--- +import { Image, inferRemoteSize } from "astro:assets"; +import { Icon } from "astro-icon/components"; +import type { Picture } from "~/lib/types"; + +interface Props { + image?: Picture; + /** Width to render at; the file is resized to this at build time */ + width: number; + class?: string; + eager?: boolean; + alt?: string; +} + +const { image, width, class: className, eager = false, alt } = Astro.props; +// Measure the image when its source didn't say how big it is, so it keeps its proportions +const size = image && !(image.width && image.height) ? await inferRemoteSize(image.url).catch(() => undefined) : image; +const height = size?.width && size?.height ? Math.round((size.height / size.width) * width) : undefined; +const attributes = { alt: alt ?? image?.alt ?? "", width, class: className, loading: eager ? ("eager" as const) : ("lazy" as const) }; +--- + +{ + image ? ( + // A local image (site-content's uploads) is resized from its file, a remote one from its address + image.src ? ( + + ) : height ? ( + + ) : ( + + ) + ) : ( +
+
+ ) +} diff --git a/apps/site/src/components/PostCard.astro b/apps/site/src/components/PostCard.astro new file mode 100644 index 00000000..3a6c8865 --- /dev/null +++ b/apps/site/src/components/PostCard.astro @@ -0,0 +1,34 @@ +--- +import { Icon } from "astro-icon/components"; +import { formatDate, listNames } from "~/lib/format"; +import type { Post } from "~/lib/types"; +import Picture from "./Picture.astro"; + +interface Props { + post: Post; + /** Big horizontal card for the top post */ + wide?: boolean; + eager?: boolean; +} + +const { post, wide = false, eager = false } = Astro.props; +const href = `/blog/${post.slug}`; +--- + +
+
+ + {post.category && {post.category.title}} +
+
+ +

+ {post.title} +

+ {post.authors.length > 0 &&

By {listNames(post.authors.map((a) => a.name))}

} + {post.excerpt &&

{post.excerpt}

} + + Read +
+
diff --git a/apps/site/src/components/PostList.astro b/apps/site/src/components/PostList.astro new file mode 100644 index 00000000..512f8e7c --- /dev/null +++ b/apps/site/src/components/PostList.astro @@ -0,0 +1,18 @@ +--- +import type { Post } from "~/lib/types"; +import PostCard from "./PostCard.astro"; + +interface Props { + posts: Post[]; + /** Show the first post as a wide card */ + featureFirst?: boolean; +} + +const { posts, featureFirst = false } = Astro.props; +const [first, ...rest] = posts; +--- + +{featureFirst && first && } +
+ {(featureFirst ? rest : posts).map((post) => )} +
diff --git a/apps/site/src/components/Sections.astro b/apps/site/src/components/Sections.astro new file mode 100644 index 00000000..08f0344f --- /dev/null +++ b/apps/site/src/components/Sections.astro @@ -0,0 +1,39 @@ +--- +import type { Section } from "~/content.config"; +import Buttons from "./sections/Buttons.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 Team from "./sections/Team.astro"; +import Text from "./sections/Text.astro"; + +interface Props { + sections: Section[]; +} + +// To add a section type: make a component in ./sections, add it here, +// and describe its fields in src/content.config.ts and src/admin/config.yml +const components = { + hero: Hero, + text: Text, + features: Features, + "latest-posts": LatestPosts, + "latest-articles": LatestArticles, + issues: Issues, + team: Team, + newsletter: Newsletter, + buttons: Buttons, +} satisfies Record; + +const { sections } = Astro.props; +--- + +{ + sections.map((section) => { + const Component = components[section.type] as any; + return ; + }) +} diff --git a/apps/site/src/components/SocialIcon.astro b/apps/site/src/components/SocialIcon.astro new file mode 100644 index 00000000..f6e223bc --- /dev/null +++ b/apps/site/src/components/SocialIcon.astro @@ -0,0 +1,28 @@ +--- +import { Icon } from "astro-icon/components"; + +interface Props { + icon: string; + class?: string; +} + +// Maps the names used in navigation.yaml and on people to icon sets +const names: Record = { + bluesky: "simple-icons:bluesky", + github: "simple-icons:github", + mastodon: "simple-icons:mastodon", + linkedin: "simple-icons:linkedin", + instagram: "simple-icons:instagram", + facebook: "simple-icons:facebook", + twitter: "simple-icons:x", + orcid: "simple-icons:orcid", + substack: "simple-icons:substack", + email: "lucide:mail", + rss: "lucide:rss", + website: "lucide:globe", +}; + +const { icon, class: className = "size-5" } = Astro.props; +--- + +