+ {issue.rolling ? "Rolling" : issue.title} + {issue.rolling && Articles as they're ready} +
+ ) + } + {issue.rolling && Ongoing} ++ {issue.rolling ? "Ongoing" : issue.date.slice(0, 4)} · {count} +
+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. +
+ {issue.rolling ? "Rolling" : issue.title} + {issue.rolling && Articles as they're ready} +
+ ) + } + {issue.rolling && Ongoing} ++ {issue.rolling ? "Ongoing" : issue.date.slice(0, 4)} · {count} +
+{person.position}
} + {person.summary && } + +By {listNames(post.authors.map((a) => a.name))}
} + {post.excerpt &&{post.excerpt}
} + + Read + +{subtitle}
} + {text &&{site.newsletter.text}
+{kicker}
} +{description}
} +
+ {article.authors.map((author, i) => (
+ <>
+
+ {author.name}
+ {author.affiliation && {affiliations.indexOf(author.affiliation) + 1}}
+ {author.orcid && (
+
+
+ DOI:{" "} + + {doiUrl} + +
+ ) + } +This article is published as a PDF.
+ + Read the PDF + +{kicker}
} +{description}
} + {config.notice &&{config.notice}
} + { + filters.map((filter) => ( + + )) + } ++ {articles.length} {articles.length === 1 ? "article" : "articles"} +
+ {current !== config.listPath && ( + + All articles + + )} +
+ By{" "}
+ {post.authors.map((author, i) => (
+ <>
+
+ {author.name}
+
+ {author.orcid && (
+
+
+ DOI:{" "} + + https://doi.org/{post.doi} + +
+ ) + } + { + post.tags.length > 0 && ( +( |\s)*<\/p>/g, "")
+ .replace(/ /g, " ")
+ .replace(/(<\w{1,10}[^>]*?)\sstyle="[^"]*"/g, "$1")
+ .replace(/<(\/?)h1\b/g, "<$1h2")
+ .replace(/ \s*([A-Z])/, ' $1');
+ }
+
+ return { body, headings };
+}
diff --git a/apps/site/src/lib/journal-article.ts b/apps/site/src/lib/journal-article.ts
new file mode 100644
index 00000000..5aa1777a
--- /dev/null
+++ b/apps/site/src/lib/journal-article.ts
@@ -0,0 +1,230 @@
+import { getImage, inferRemoteSize } from "astro:assets";
+import { stripHtml } from "./format";
+import type { Heading } from "./html";
+import { decodeEntities, type Journal, journal, replaceElements, type Release, type Rich } from "./sources/journal";
+import type { UnderlayFile } from "./sources/underlay";
+import type { Article } from "./types";
+
+/*
+ * Everything an article page needs beyond the Article itself: the HTML export
+ * of its latest release, made to fit the site, its downloads and its links to
+ * reviews, commentaries and other pubs.
+ */
+
+export interface Download {
+ format: string;
+ label: string;
+ url: string;
+}
+
+export interface Connection {
+ /** How the other pub relates to this one, e.g. "Peer review" or "Commentary on" */
+ label: string;
+ title: string;
+ url: string;
+ article?: Article;
+}
+
+export interface ArticleDetails {
+ body: string;
+ headings: Heading[];
+ /** The export has no text, so the PDF is the article */
+ empty: boolean;
+ keywords: string[];
+ downloads: Download[];
+ connections: Connection[];
+ /** Date of the latest release, when it came well after the first */
+ updated?: string;
+}
+
+const DOWNLOADS: Record ]*>\s*(?:<[^>]+>\s*)*Key ?words?\s*:?([\s\S]*?)<\/p>/i)?.[1];
+ if (!line) return [];
+ return decodeEntities(stripHtml(line))
+ .replace(/^:\s*/, "")
+ .split(/[,;]/)
+ .map((k) => k.trim().replace(/\.$/, ""))
+ .filter(Boolean);
+}
+
+/**
+ * Fit PubPub's HTML export into the site: drop the parts the page renders
+ * itself, move headings one level down under the page's h1, serve images
+ * from this site and point attachments at their Underlay file.
+ */
+async function prepareArticle(html: string, assets: Map