diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 2cb7d2a..0000000 --- a/.eslintignore +++ /dev/null @@ -1 +0,0 @@ -**/*.js diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 8ed3589..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "root": true, - "env": { - "browser": true, - "es2020": true - }, - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/eslint-recommended", - "plugin:@typescript-eslint/recommended", - "prettier" - ], - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaVersion": 11, - "sourceType": "module" - }, - "plugins": ["unused-imports", "@typescript-eslint", "prettier"], - "rules": { - "prettier/prettier": "error", - "no-unused-vars": "off", - "unused-imports/no-unused-imports-ts": "error", - "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }], - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/explicit-module-boundary-types": "off", - "@typescript-eslint/no-non-null-assertion": "off", - "@typescript-eslint/ban-ts-comment": "off" - } -} diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml new file mode 100644 index 0000000..a83df4a --- /dev/null +++ b/.forgejo/workflows/deploy.yml @@ -0,0 +1,58 @@ +name: Deploy to Pages + +on: + push: + branches: ['main'] + pull_request: + branches: ['main'] + workflow_dispatch: + +concurrency: + group: 'pages' + cancel-in-progress: false + +jobs: + build: + runs-on: docker + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Lint + run: | + npm run lint + git diff + if [[ `git status --porcelain` ]]; then + exit 1 + fi + + - name: Typecheck + run: npm run typecheck + + - name: Build, Astro Audit & QA + run: | + npx playwright install chromium + npm run qa + + - name: Copy build to host pages mount + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: | + apt update && apt install -y rsync + mkdir -p /pages + rsync -a --delete dist/ /pages/schmelczer-dev + + - name: Copy build to staging pages mount + if: github.event_name == 'pull_request' + run: | + apt update && apt install -y rsync + mkdir -p /pages + rsync -a --delete dist/ /pages/schmelczer-dev-staging diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 14943fc..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,11 +0,0 @@ -version: 2 -updates: - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "daily" - - - package-ecosystem: "npm" - directory: "/" - schedule: - interval: "daily" diff --git a/.github/workflows/lint-and-deploy.yaml b/.github/workflows/lint-and-deploy.yaml deleted file mode 100644 index 6160131..0000000 --- a/.github/workflows/lint-and-deploy.yaml +++ /dev/null @@ -1,35 +0,0 @@ -name: Check, build and deploy to GitHub Pages -on: - push: - branches: - - main - -jobs: - check: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Lint - run: | - npm ci - npm run lint && git diff - if [[ `git status --porcelain` ]]; then - exit 1 - fi - build-and-deploy: - concurrency: ci-${{ github.ref }} - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v3 - - - name: Install and Build - run: | - npm ci - npm run build - - - name: Deploy - uses: JamesIves/github-pages-deploy-action@v4.4.1 - with: - branch: gh-pages - folder: dist diff --git a/.gitignore b/.gitignore index 317395e..38366ae 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ node_modules dist -target +.astro .DS_Store diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/.prettierrc b/.prettierrc index afe8ac1..70d7dc0 100644 --- a/.prettierrc +++ b/.prettierrc @@ -4,7 +4,13 @@ "tabWidth": 2, "singleQuote": true, "endOfLine": "lf", - "importOrder": ["^[./]", ".*", ".scss$"], - "importOrderSeparation": true, - "importOrderSortSpecifiers": true + "plugins": ["prettier-plugin-astro"], + "overrides": [ + { + "files": "*.astro", + "options": { + "parser": "astro" + } + } + ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index a08669b..80a5ba5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -37,6 +37,6 @@ "files.exclude": { "node_modules": true }, - "editor.rulers": [120], + "editor.rulers": [90], "editor.wordWrap": "on" } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 49d45f9..37915af 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -2,7 +2,7 @@ "version": "2.0.0", "tasks": [ { - "label": "Format and lint", + "label": "Lint", "type": "shell", "command": "npm run lint", "group": "test", diff --git a/README.md b/README.md index f368051..64893ae 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,33 @@ -# Portfolio - -> An easy-to-configure timeline for your projects. - -[![Check, build and deploy to GitHub Pages](https://github.com/schmelczer/schmelczer.github.io/actions/workflows/lint-and-deploy.yaml/badge.svg)](https://github.com/schmelczer/schmelczer.github.io/actions/workflows/lint-and-deploy.yaml) - - -[Check out the live version.](https://schmelczer.dev) - -## Configuration - -- The actual content is in the [data](src/data) folder, starting with [portfolio.ts](src/data/portfolio.ts) -- The assets referenced should be located in [data/media](src/data/media) - -## Build - -1. `npm install` -2. `npm run build` -3. You can find the results in the [dist](dist) folder - -## Info - -- All images are converted to `WebP` after being imported into any file. - > Except for the og-image, and SVGs. \ No newline at end of file +# schmelczer.dev + +Engineering writeups by Andras Schmelczer: finished projects with the design constraints left in. Built with Astro, no required client JavaScript. + +Articles live in `src/content/posts`, project index entries in `src/content/projects`, and normal pages are rendered as static HTML. + +## Setup + +```sh +npm ci +npx playwright install --with-deps chromium # required before Playwright QA checks +``` + +## Commands + +```sh +npm run dev +npm run lint +npm run build +npm run preview +npm run qa +``` + +## Structure + +- `src/content/posts`: Markdown articles +- `src/content/projects`: project index entries +- `src/pages`: static routes +- `src/layouts`: page and post layouts +- `src/components`: reusable UI pieces +- `src/styles/global.css`: the visual system +- `public/media/downloads`: CV and thesis PDFs +- `public/media/video`: project videos diff --git a/astro.config.mjs b/astro.config.mjs new file mode 100644 index 0000000..93a9357 --- /dev/null +++ b/astro.config.mjs @@ -0,0 +1,148 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import sitemap from '@astrojs/sitemap'; +import { defineConfig } from 'astro/config'; +import rehypeAutolinkHeadings from 'rehype-autolink-headings'; +import rehypeSlug from 'rehype-slug'; +import { visit } from 'unist-util-visit'; + +// Build a lookup of post slugs to their last modification dates so the sitemap +// can advertise accurate values to crawlers. astro:content isn't +// available inside the config, so we read post frontmatter directly. Our posts +// always use single-line scalar `date:` / `updated:` keys, so a small regex +// extraction is sufficient and intentional. +const postsDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + 'src/content/posts' +); + +function extractScalar(frontmatter, key) { + const match = frontmatter.match(new RegExp(`^${key}:\\s*(.+?)\\s*$`, 'm')); + return match?.[1]?.replace(/^['"]|['"]$/g, ''); +} + +const postLastmodLookup = new Map( + readdirSync(postsDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.md')) + .map((entry) => { + const raw = readFileSync(path.join(postsDir, entry.name), 'utf8'); + const frontmatter = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? ''; + const rawDate = + extractScalar(frontmatter, 'updated') ?? extractScalar(frontmatter, 'date'); + const parsed = rawDate ? new Date(rawDate) : null; + const valid = parsed && !Number.isNaN(parsed.valueOf()) ? parsed : null; + return [entry.name.replace(/\.md$/, ''), valid]; + }) + .filter(([, date]) => date !== null) +); + +export default defineConfig({ + site: 'https://schmelczer.dev', + trailingSlash: 'ignore', + integrations: [ + sitemap({ + filter: (page) => { + const path = new URL(page).pathname; + return !/^\/tags\/[^/]+\/?$/.test(path) && path !== '/404/'; + }, + serialize(item) { + const url = new URL(item.url); + const match = url.pathname.match(/^\/articles\/([^/]+)\/?$/); + let lastmod = item.lastmod; + if (match) { + const date = postLastmodLookup.get(match[1]); + if (date instanceof Date && !Number.isNaN(date.valueOf())) { + lastmod = date.toISOString(); + } + } + return { ...item, changefreq: 'monthly', ...(lastmod ? { lastmod } : {}) }; + }, + }), + ], + image: { + service: { entrypoint: 'astro/assets/services/sharp' }, + // SVG sources in src/content/**/_assets are author-controlled. + dangerouslyProcessSVG: true, + }, + vite: { + // Pre-bundle the analytics tracker during dev server startup. It is only + // referenced from a client ` diff --git a/src/components/ArticleList.astro b/src/components/ArticleList.astro new file mode 100644 index 0000000..d2d3f9e --- /dev/null +++ b/src/components/ArticleList.astro @@ -0,0 +1,62 @@ +--- +import type { CollectionEntry } from 'astro:content'; +import EntryThumbnail from './EntryThumbnail.astro'; +import TagList from './TagList.astro'; +import { ARTICLE_THUMBNAIL, articlePath, formatDate, formatDateShort } from '../lib/site'; + +interface Props { + posts: CollectionEntry<'posts'>[]; + showYear?: boolean; + tagLimit?: number; + timeline?: boolean; + // Opt-in: eagerly load thumbnails that are reliably above the fold. Lists + // below substantial content (related, about, 404) should leave this at zero. + eagerFirstThumbnail?: boolean; + eagerThumbnailCount?: number; +} + +const { + posts, + showYear = true, + tagLimit = 3, + timeline = false, + eagerFirstThumbnail = false, + eagerThumbnailCount = eagerFirstThumbnail ? 1 : 0, +} = Astro.props; +--- + +
    + { + posts.map((post, index) => { + const href = articlePath(post); + const eager = index < eagerThumbnailCount; + return ( +
  1. + + + +
  2. + ); + }) + } +
diff --git a/src/components/AtAGlance.astro b/src/components/AtAGlance.astro new file mode 100644 index 0000000..c5417ea --- /dev/null +++ b/src/components/AtAGlance.astro @@ -0,0 +1,50 @@ +--- +import type { CollectionEntry } from 'astro:content'; +import ProjectLinks from './ProjectLinks.astro'; + +type Link = CollectionEntry<'projects'>['data']['links'][number]; + +interface Props { + role?: string; + projectPeriod?: string; + stack?: string[]; + scale?: string; + outcome?: string; + links?: Link[]; + headingId: string; +} + +const { + role, + projectPeriod, + stack = [], + scale, + outcome, + links = [], + headingId, +} = Astro.props; + +const rows: Array<[string, string]> = []; +if (role) rows.push(['Role', role]); +if (projectPeriod) rows.push(['Period', projectPeriod]); +if (stack.length > 0) rows.push(['Stack', stack.join(', ')]); +if (scale) rows.push(['Scale', scale]); +if (outcome) rows.push(['Outcome', outcome]); +--- + +{ + rows.length > 0 && ( + + ) +} diff --git a/src/components/Breadcrumbs.astro b/src/components/Breadcrumbs.astro new file mode 100644 index 0000000..ba039af --- /dev/null +++ b/src/components/Breadcrumbs.astro @@ -0,0 +1,33 @@ +--- +interface Crumb { + href?: string; + label: string; +} + +interface Props { + items: Crumb[]; +} + +const { items } = Astro.props; +const lastIndex = items.length - 1; +--- + + diff --git a/src/components/EntryThumbnail.astro b/src/components/EntryThumbnail.astro new file mode 100644 index 0000000..750de48 --- /dev/null +++ b/src/components/EntryThumbnail.astro @@ -0,0 +1,57 @@ +--- +import type { ImageMetadata } from 'astro'; +import { Picture } from 'astro:assets'; + +interface Props { + src: ImageMetadata; + alt: string; + href?: string; + class?: string; + widths: number[]; + sizes: string; + loading?: 'lazy' | 'eager'; + fetchpriority?: 'high' | 'low' | 'auto'; + ariaLabel?: string; + // When the listing already has a focusable, screen-reader-visible title + // link, the thumbnail link is visually duplicative. We keep it clickable + // for pointer users but drop it from the tab order. The link still needs + // a name because some assistive tech exposes non-tabbable links. + decorative?: boolean; +} + +const { + src, + alt, + href, + class: extraClass, + widths, + sizes, + loading = 'lazy', + fetchpriority, + ariaLabel, + decorative = true, +} = Astro.props; + +const Tag = href ? 'a' : 'div'; +const isDecorativeLink = Boolean(href) && decorative; +--- + + + + diff --git a/src/components/Footer.astro b/src/components/Footer.astro new file mode 100644 index 0000000..8834892 --- /dev/null +++ b/src/components/Footer.astro @@ -0,0 +1,22 @@ +--- +import { site } from '../lib/site'; + +const year = new Date().getFullYear(); +--- + + diff --git a/src/components/Header.astro b/src/components/Header.astro new file mode 100644 index 0000000..43dac2f --- /dev/null +++ b/src/components/Header.astro @@ -0,0 +1,158 @@ +--- +import { navItems, site } from '../lib/site'; + +const currentPath = Astro.url.pathname; +const current = + currentPath === '/' || currentPath.endsWith('/') || /\.[^/]+$/.test(currentPath) + ? currentPath + : `${currentPath}/`; + +// Exact match for the current page; section match (descendant URLs) for +// ancestor links. `aria-current="page"` is reserved for the exact page, +// `"true"` indicates an ancestor section. +function currentState(href: string): 'page' | 'true' | undefined { + if (current === href) return 'page'; + if (href !== '/' && current.startsWith(href)) return 'true'; + return undefined; +} + +// Header shows nav items except Home and footer-only entries. RSS lives as a +// dedicated icon link to the right of the nav. +const headerNavItems = navItems.filter((item) => item.href !== '/' && !item.footerOnly); +--- + + + + + + + diff --git a/src/components/PostMedia.astro b/src/components/PostMedia.astro new file mode 100644 index 0000000..2a5f425 --- /dev/null +++ b/src/components/PostMedia.astro @@ -0,0 +1,30 @@ +--- +import type { CollectionEntry } from 'astro:content'; +import PostMediaFigure from './PostMediaFigure.astro'; + +type MediaItem = CollectionEntry<'posts'>['data']['media'][number]; + +interface Props { + items: MediaItem[]; +} + +const { items } = Astro.props; + +// Wrap in a gallery `