Skip to content

Risograph-editorial redesign + security/a11y/perf/SEO hardening#19

Open
rahmanef63 wants to merge 1 commit into
IndopenSource:mainfrom
rahmanef63:feat/riso-editorial-redesign
Open

Risograph-editorial redesign + security/a11y/perf/SEO hardening#19
rahmanef63 wants to merge 1 commit into
IndopenSource:mainfrom
rahmanef63:feat/riso-editorial-redesign

Conversation

@rahmanef63

@rahmanef63 rahmanef63 commented Jun 15, 2026

Copy link
Copy Markdown

Ringkasan

PR ini membawa dua hal ke situs hub indopensource.org, di atas base main saat ini:

  1. Pengerasan engineering — keamanan, aksesibilitas, performa, SEO, kebenaran data. Perbaikan konkret, bisa langsung di-merge.
  2. Redesain UI/UX "risograph editorial" — arah visual baru yang khas (bukan template generik), self-hosted, aksesibel.

Semua perubahan menjaga astro check bersih, 16 unit test baru hijau, build 57 halaman, dan 0 inline style (CSP style-src 'self' tetap utuh). Dibuka untuk diskusi — silakan ambil sebagian atau seluruhnya.

Catatan: ini perubahan besar. Dengan senang hati saya pecah jadi beberapa PR yang lebih kecil kalau itu lebih enak direview — tinggal bilang.


1. Pengerasan engineering

Keamanan

  • Stored XSS di blog ditutup. marked.parse(post.content) sebelumnya masuk set:html tanpa sanitizer (marked v18 tidak punya sanitizer bawaan). Kini lewat allowlist sanitize-html terpusat (src/lib/content.tsrenderArticle()), dipakai juga untuk README project yang sekarang dirender saat build (bukan fetch klien).
  • CSP via <meta>: script-src 'self' + hash JSON-LD (tanpa unsafe-inline), style-src 'self', frame-ancestors 'none', object-src 'none'; rel="noopener noreferrer" dipaksa di semua link hasil sanitasi.

Konten & kebenaran data

  • Draft gating: isPublished() dipakai di getStaticPaths, pemilihan featured, dan sitemap — post status: draft tidak menghasilkan halaman dan tidak masuk sitemap.
  • Atribusi penulis diambil dari frontmatter authors[], bukan committer git.
  • parseFrontmatter diperkuat (CRLF, komentar inline, single/double quote, duplicate key, tanpa trailing newline), sort deterministik, resolusi default-branch repo via API (tidak hardcode main), total stars jujur (entry yang gagal di-sync ditandai & dikecualikan dari agregat).
  • projectSlug anti-collision (suffix FNV-1a) + hapus fungsi inverse yang mati.

SEO & aksesibilitas

  • og:image / twitter:image + og:image:alt per-halaman; JSON-LD (WebSite / BlogPosting / SoftwareSourceCode); sitemap /sitemap.xml dengan lastmod dari Astro.site; trailingSlash: 'always'.
  • Skip-to-content link, :focus-visible, satu <h1> per halaman, kontras WCAG AA diverifikasi numerik, prefers-reduced-motion.

Performa

  • Optimasi gambar via astro:assets <Picture> (AVIF/WebP): hero 60KB → ~8.5KB, logo 18KB → 737B; width/height di semua <img> (CLS ~0); preload LCP hero. README dirender saat build → tidak ada lagi fetch GitHub API per kunjungan.

Tooling

  • 16 unit test (node --test) untuk slug, isPublished, sanitizer/XSS, dan parser frontmatter; tambah .env.example.

2. Redesain "Risograph Editorial"

Arah visual: zine cetak risograph Indonesia — hangat, bertekstur, percaya diri. Bukan SaaS gradient.

  • Tipografi (self-hosted via @fontsource, tanpa CDN): Bricolage Grotesque (display), Newsreader (serif body), Spline Sans Mono (slug/stat/metadata).
  • Palet spot-ink riso terdisiplin (cream / teal / vermilion / sun); semua token teks AA-verified pada bidangnya.
  • Sistem: sticker, rubber-stamp, indeks seksi oversized, halftone, hard offset shadow, rule tinta tebal, panel tonal (ink / vermilion / sun), grain dua frekuensi.
  • Motion: reveal scroll-aware (IntersectionObserver sebagai module ter-bundle → CSP-safe), aman terhadap reduced-motion.
  • Hero: foto dijadikan lapisan tinta duotone (grayscale + mix-blend + halftone) sambil tetap memakai <Picture> AVIF teroptimasi.

Tangkapan layar

Home Projects
Home Projects
Blog Falsafah
Blog Falsafah
Contact Forum
Contact Forum

Catatan scope

  • Sengaja TIDAK disertakan (spesifik hosting/operasional, bukan untuk upstream): perubahan .github/workflows/*, vercel.json, dan tweak README.md / SECURITY.md.
  • astro.config.mjs site tetap https://indopensource.org.
  • Gambar di docs/redesign/ hanya ilustrasi untuk review — silakan dihapus sebelum merge bila lebih suka attachment.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Redesigned homepage with new hero imagery and editorial layout
    • Enhanced blog with curated featured posts and improved metadata
    • Project showcase now displays README previews
    • Restructured contact page with organized channel cards
    • New visual design refresh across the site
  • Improvements

    • Better search engine optimization with structured metadata
    • Faster image loading and performance optimization
    • Refined typography and updated color palette

Full breakdown in the PR description. Highlights:
- security: blog stored-XSS closed via a shared sanitize-html allowlist
  (src/lib/content.ts renderArticle, reused for build-time README render);
  CSP with hashed JSON-LD and no unsafe-inline; rel=noopener on sanitized links
- content: draft gating (isPublished) across pages/featured/sitemap; author from
  frontmatter authors[]; hardened parseFrontmatter; collision-safe projectSlug;
  default-branch resolution; honest sync-failure star totals
- seo/a11y: per-page og:image + JSON-LD (WebSite/BlogPosting/SoftwareSourceCode);
  /sitemap.xml with lastmod; trailingSlash always; skip-link, focus-visible,
  single h1, WCAG-AA contrast verified, prefers-reduced-motion
- perf: astro:assets <Picture> AVIF/WebP (hero 60KB->~8.5KB, logo 18KB->737B),
  width/height on all <img> (CLS ~0), hero LCP preload
- design: risograph-editorial system — self-hosted variable fonts (Bricolage /
  Newsreader / Spline Sans Mono), disciplined riso spot palette, sticker/stamp/
  halftone/grain/hard-shadow utilities, scroll-aware IntersectionObserver reveal
  (bundled module, CSP-safe), duotone riso hero keeping the optimized <Picture>
- tooling: 16 node --test unit tests (slug/isPublished/sanitizer-XSS/parser); .env.example

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

return `${base}-${shortHash(fullName)}`;

P2 Badge Preserve existing project detail slugs

Because this helper feeds both getStaticPaths and every project-card/detail URL, appending the hash to every project changes all existing public project URLs, e.g. /projects/opensid--opensid/ becomes /projects/opensid--opensid-db3yyo/. Without redirects, bookmarked and search-indexed detail pages for all non-colliding repos will 404 after deploy; reserve the suffix for actual collisions or add redirects from the previous slug shape.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread scripts/sync-projects.mjs
const resolve = (value, base) => {
if (!value || value.startsWith('#') || /^(https?:|mailto:)/i.test(value)) return value;
try {
const url = new URL(value, base);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve README root-relative paths within the repo

For README links or images that start with /, this now resolves against the host root, so /docs/setup.md becomes https://github.com/docs/setup.md (or https://raw.githubusercontent.com/docs/setup.md) instead of staying under the synced repository. The previous client-side sanitizer had repo-root handling for this case; after pre-rendering, any project README that uses root-relative docs/assets links will render broken links/images on the detail page.

Useful? React with 👍 / 👎.

with a JSON string is safe here: it is `JSON.stringify` output, not
user-controlled markup.
-->
<script type="application/ld+json" is:inline set:html={jsonLdString} />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Escape JSON-LD before injecting it

When synced data used in jsonLd (such as a GitHub repo description or blog frontmatter title/description) contains </script>, JSON.stringify does not make it safe for a raw script context, so the browser can terminate this JSON-LD block and parse attacker-controlled markup in the page head. CSP should block a follow-on inline script, but non-script markup such as a meta refresh can still be injected and the structured data breaks; escape < (for example to \u003c) before set:html.

Useful? React with 👍 / 👎.

@wauputr4

Copy link
Copy Markdown
Member

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 15, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR introduces a "riso/zine" visual design system across all pages and components, rewrites content sync scripts to resolve default branches and pre-render sanitized README HTML at build time, adds a typed content library (isPublished, renderArticle, sanitized HTML), moves JSON-LD generation and CSP hash computation into BaseLayout, replaces the sitemap endpoint, and adds unit tests for lib helpers.

Changes

Riso design system + content pipeline overhaul

Layer / File(s) Summary
Content lib contracts, renderArticle, and hero constants
package.json, src/lib/content.ts, src/lib/hero.ts, test/lib.test.mjs
Adds BlogPost/PostAuthor interfaces, isPublished, articleSanitizeOptions, forbiddenTags, and renderArticle (marked + sanitize-html). Adds heroImage/HERO_PRELOAD_WIDTH/HERO_QUALITY constants. Adds sanitize-html, @fontsource-variable/* deps and a Node test suite covering all lib helpers.
Sync scripts: frontmatter parser, default branch, README pre-rendering, date semantics
scripts/sync-blog-posts.mjs, scripts/sync-projects.mjs
sync-blog-posts gains unquoteScalar/parseFrontmatter, resolves and caches default_branch, tracks releasedAt/lastModifiedAt separately from editorial date, prefers frontmatter authors, and uses deterministic ISO-8601 sort. sync-projects resolves default branch dynamically, fetches and pre-renders each repo README into sanitized HTML, generates metaDescription via clamping, and marks failed syncs with syncFailed/partial.
BaseLayout: typed Props, JSON-LD, CSP, scroll-reveal; sitemap + robots
src/layouts/BaseLayout.astro, src/pages/sitemap.xml.ts, src/pages/sitemap-index.xml.ts, src/pages/robots.txt.ts, astro.config.mjs, .env.example
BaseLayout gains typed Props, builds JSON-LD graph, computes SHA-256 CSP hash for the JSON-LD script, adds CSP/referrer meta, preconnect warmups, optional LCP hero preload, og/twitter alt, skip link, and IntersectionObserver scroll-reveal. Replaces sitemap-index.xml.ts with a new sitemap.xml.ts using isPublished filtering and toLastmod. Updates robots.txt.ts to APIRoute with dynamic site origin. Adds trailingSlash: 'always', build.inlineStylesheets: 'never', and vite.build.assetsInlineLimit: 0.
Riso CSS design system: tokens, animations, reveal, prose
src/styles/global.css
Replaces @theme token set with self-hosted variable-font imports and riso palette. Adds grain/halftone/riso-shadow/riso-hover/sticker/stamp/rule/panel/section-number/display-* utilities, a js-reveal scroll-reveal system with stagger delays, and revised .article-body/.prose-readme prose styles. Introduces riso keyframes (riso-roll, riso-jitter, riso-misregister, riso-shimmer) and a strengthened prefers-reduced-motion catch-all.
Riso-restyled UI components
src/components/BaseButton.astro, src/components/InfoCard.astro, src/components/PageHeader.astro, src/components/SectionHeader.astro, src/components/SiteFooter.astro, src/components/SiteHeader.astro, src/components/LightSvgMotion.astro, src/components/HomeHero.astro, src/components/RoadmapTimeline.astro
All shared UI components are restyled: BaseButton → riso sticker-button; HomeHeroPicture-driven duotone/layered LCP stack; LightSvgMotion → halftone screens + misregistration per variant; SiteHeaderPicture brand icon + isCurrent class:list nav; SiteFooter → colophon dl/dt/dd + links.map sticker anchors; SectionHeader → uses BaseButton CTA; RoadmapTimelineol/reveal-stagger + status sticker; PageHeader/InfoCard receive riso class updates.
Blog pages: isPublished filtering, renderArticle, JSON-LD BlogPosting
src/pages/blog.astro, src/pages/blog/[slug].astro
blog.astro uses isPublished for featured post and drives steps/legend from local arrays. blog/[slug].astro filters getStaticPaths to published posts, generates HTML via renderArticle, builds heroAlt/authorByline/jsonLd BlogPosting, and passes type="article" + jsonLd to BaseLayout.
Projects pages: syncFailed filtering, build-time readmeHtml, JSON-LD
src/components/ProjectCard.astro, src/components/ProjectsDirectory.astro, src/pages/projects.astro, src/pages/projects/[slug].astro
ProjectCard imports shared Project type and adopts riso card styling. ProjectsDirectory adds dual pagination bars and reveal-stagger list. projects.astro filters to refreshedProjects (non-syncFailed) and shows unavailableCount. projects/[slug].astro replaces client-side README fetch with build-time set:html={readmeHtml}, adds metaDescription, builds SoftwareSourceCode jsonLd, and removes the client <script>.
Other pages: index, contact, falsafah, forum riso layout
src/pages/index.astro, src/pages/contact.astro, src/pages/falsafah.astro, src/pages/forum.astro
index.astro pre-generates AVIF hero via getImage and passes heroPreloadHref; adds numbered editorial sections, falsafah interstitial, vermilion CTA panel, and sun-ribbon aside. contact.astro replaces InfoCard grid with data-driven channels array. falsafah.astro replaces InfoCard layout with prinsip map and pull-quote/stamp sections. forum.astro restyls the dev notice as <aside role="note"> with riso stamp styling.

Sequence Diagram(s)

sequenceDiagram
  participant build as Astro Build
  participant sync as sync-projects.mjs
  participant github as GitHub API
  participant json as projects.json
  participant slugpage as projects/[slug].astro
  participant browser as Browser

  build->>sync: npm run sync:projects
  sync->>github: GET /repos/{awesome} → default_branch
  sync->>github: GET repos.json (raw via default_branch URL)
  loop per repo
    sync->>github: GET /repos/{slug} metadata
    sync->>github: GET README.md raw
    github-->>sync: markdown string
    sync->>sync: renderArticle(markdown) → sanitized readmeHtml
  end
  sync-->>json: write projects[] with readmeHtml + metaDescription + syncFailed
  build->>slugpage: getStaticPaths(projects as Project[])
  slugpage->>slugpage: set:html={project.readmeHtml}
  slugpage-->>browser: pre-rendered HTML (no client fetch needed)
Loading
sequenceDiagram
  participant page as Astro Page
  participant BaseLayout as BaseLayout.astro
  participant crypto as crypto.subtle
  participant head as HTML head output
  participant observer as IntersectionObserver (client)

  page->>BaseLayout: Props{jsonLd, heroPreloadHref, title, ...}
  BaseLayout->>crypto: digest("SHA-256", JSON.stringify(jsonLd))
  crypto-->>BaseLayout: base64 hash
  BaseLayout->>head: CSP meta (script-src sha256-{hash})
  BaseLayout->>head: preconnect / dns-prefetch hints
  BaseLayout->>head: <link rel="preload" as="image" href={heroPreloadHref}>
  BaseLayout->>head: <script type="application/ld+json"> (set:html)
  BaseLayout->>observer: IntersectionObserver toggles .in-view on .reveal elements
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Poem

🐰 Hop hop, the rabbit stamps in ink so fine,
Riso-printed pages, every pixel aligned.
The README pre-renders — no fetch at midnight!
SHA-256 hashes keep the scripts in sight.
With stickers and halftones and a zine-worthy glow,
The IndopenSource garden is ready to grow! 🌱

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and comprehensively summarizes the two major change categories (risograph-editorial redesign and security/a11y/perf/SEO hardening) that define this pull request.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
package.json (1)

6-14: ⚠️ Potential issue | 🟡 Minor

Declare the Node runtime contract in package.json.

The PR adds .mjs files that import .ts modules directly (e.g., scripts/sync-projects.mjs imports ../src/lib/content.ts, and test/lib.test.mjs imports multiple .ts files). While CI workflows already pin node-version: 22, package.json lacks an engines.node declaration. This leaves local developers without explicit version guidance and breaks the standard contract that tools and IDEs expect.

Add "engines": { "node": ">=22" } to package.json to match the enforced CI version.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@package.json` around lines 6 - 14, The package.json file is missing an
engines declaration to specify the Node.js version requirement. Add an "engines"
field to the root level of package.json (at the same level as "scripts") with
the value "node": ">=22" to explicitly document that the project requires
Node.js version 22 or higher, matching the CI workflow requirements and
providing clear guidance to local developers.
src/pages/projects.astro (1)

66-66: ⚠️ Potential issue | 🟡 Minor

Filter ProjectsDirectory to refreshedProjects or visually mark syncFailed entries.

The directory currently displays all projects including syncFailed entries, which show placeholder zero stars/forks without any visual indicator of failure. This contradicts the stated goal (CC-10) of showing "honest" totals—users will see projects with 0 stars and no explanation. The unavailability message (line 45) only explains the aggregate exclusion, not why individual cards display zeros. Either pass refreshedProjects to ProjectsDirectory on line 66, or add a "Sync Failed" status badge to ProjectCard (line 11 in ProjectCard.astro) to surface the issue explicitly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/pages/projects.astro` at line 66, The ProjectsDirectory component is
currently displaying all projects including those with syncFailed status, which
show placeholder zero stars/forks without any visual indicator of failure,
contradicting the goal of showing honest totals. To fix this, either pass
refreshedProjects instead of projects to the ProjectsDirectory component to
exclude failed syncs entirely, or add a "Sync Failed" status badge to the
ProjectCard component to visually mark entries that failed to sync, making it
clear to users why those cards display zero metrics.
src/components/ProjectsDirectory.astro (1)

96-182: ⚠️ Potential issue | 🔴 Critical

Move script to external bundled file or add nonce/hash support to CSP policy.

This inline <script> will be blocked by the BaseLayout CSP policy (script-src 'self'). Astro 6.x inlines plain <script> tags in the HTML output by default, which violates the strict script-source constraint. The filtering, sorting, and pagination will fail at runtime.

Options:

  • Move the script to an external module and import it, or
  • Update the CSP policy to include script-src nonce/hash directives if inline scripts are required.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/ProjectsDirectory.astro` around lines 96 - 182, The inline
script tag in ProjectsDirectory.astro containing the project filtering, sorting,
and pagination logic is blocked by the BaseLayout's CSP policy which restricts
scripts to 'self'. Extract the JavaScript code from the inline script block into
a separate external file (e.g., projectsDirectory.js or similar), then import
and execute it from the component instead of using an inline script tag. This
will allow the bundler to handle it properly and comply with the strict CSP
policy. Alternatively, if inline scripts must be kept, update the CSP policy in
BaseLayout to support nonce or hash directives for script-src.
🧹 Nitpick comments (3)
astro.config.mjs (1)

16-23: 💤 Low value

Misleading comment: inlineStylesheets does not control script inlining.

The comment mentions preventing inline <script> for CSP compliance, but build.inlineStylesheets only controls stylesheet emission, not scripts. Astro's script bundling is controlled separately—module scripts (like the scroll-reveal script) are already emitted as external /_astro/*.js files by default in Astro v5+.

The setting is still correct for preventing inline <style> blocks that would violate style-src 'self', but the comment should be updated to reflect this.

📝 Suggested comment fix
   build: {
-    // Never inline the scroll-reveal `<script>` (or any other) into the HTML.
-    // A strict `script-src 'self'` CSP (no nonce, no 'unsafe-inline') would block
-    // an inline code-bearing <script>; forcing Astro to emit it as an external
-    // /_astro/*.js entry referenced via `<script src>` keeps it covered by 'self'.
-    // (SEC-02 / MOTION)
+    // Never inline stylesheets into the HTML. A strict `style-src 'self'` CSP
+    // (no 'unsafe-inline') would block inline <style> blocks; forcing Astro to
+    // emit all CSS as external /_astro/*.css files keeps them covered by 'self'.
+    // (SEC-02)
     inlineStylesheets: 'never'
   },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@astro.config.mjs` around lines 16 - 23, The comment in the build
configuration block for astro.config.mjs is misleading because it describes
inlineStylesheets as controlling script inlining for CSP compliance, when this
setting actually only controls stylesheet emission (preventing inline <style>
blocks). Update the comment to accurately reflect that inlineStylesheets:
'never' prevents inline stylesheets that would violate style-src 'self', and
clarify that script bundling is handled separately by Astro and already emits
module scripts as external files by default in Astro v5+.
src/layouts/BaseLayout.astro (1)

118-121: 💤 Low value

Hardcoded type="image/avif" assumes all preloaded heroes are AVIF.

If heroPreloadHref ever points to a WebP, JPEG, or other format, the browser may ignore or mishandle the preload due to the mismatched MIME type. Currently the homepage generates AVIF via getImage, so this works, but the coupling is implicit.

Consider accepting the image type as an additional prop or omitting the type attribute entirely (browsers will sniff the format).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/layouts/BaseLayout.astro` around lines 118 - 121, The preload link
element in BaseLayout.astro hardcodes type="image/avif" regardless of the actual
format of heroPreloadHref, which will cause browsers to ignore or mishandle
preloads for non-AVIF images. Fix this by either accepting a heroPreloadType
prop and using it dynamically in the type attribute of the link element, or
removing the type attribute entirely from the preload link tag and allowing the
browser to sniff the image format automatically. The simpler approach is to omit
the type attribute since browsers can detect the format from the file extension
or content.
src/pages/blog/[slug].astro (1)

35-41: ⚡ Quick win

Use an absolute URL for BlogPosting.image in JSON-LD.

thumbnailUrl can be relative, which weakens structured-data portability across crawlers and validators. Prefer an absolute URL in the JSON-LD payload.

♻️ Proposed refinement
 const thumbnail = post.thumbnail || '/brand/indopensource-hero.jpg';
 const thumbnailUrl = /^https?:\/\//.test(thumbnail) ? thumbnail : withBase(thumbnail);
+const siteOrigin = Astro.site ?? new URL('https://indopensource.org');
+const thumbnailAbsoluteUrl = new URL(thumbnailUrl, siteOrigin).toString();
@@
-  image: thumbnailUrl,
+  image: thumbnailAbsoluteUrl,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/pages/blog/`[slug].astro around lines 35 - 41, The jsonLd object in the
BlogPosting schema uses thumbnailUrl which may be a relative URL, but structured
data requires absolute URLs for the image field to ensure proper portability
across crawlers and validators. Modify the jsonLd object to ensure the image
property receives an absolute URL by converting thumbnailUrl to a fully
qualified URL (including the domain) before assigning it to the image field.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/sync-blog-posts.mjs`:
- Around line 87-97: The YAML parsing logic in the array item handling is
incorrectly coercing empty scalar keys into arrays, which corrupts post fields.
When an array item contains a key-value pair (like `- key: value`), if the value
portion is empty (after the colon), the code should not treat it as a scalar
field but rather handle it appropriately to prevent field type corruption.
Modify the condition around the key-value pair parsing to check whether
`rest.join(':')` produces an actual value after trimming; if it results in an
empty string, either skip that item, treat it as a null scalar, or handle it in
a way that prevents initializing array fields that should remain scalars. This
same pattern correction should be applied at line 111-114 as well.
- Around line 250-263: The regex pattern in the date validation check accepts
timezone-less datetimes (e.g., "2023-06-14T14:30:00") which parse using local
machine timezone, breaking deterministic sorting. The timezone indicator at the
end of the regex pattern is currently optional due to the trailing question
mark. Modify the regex to require the timezone indicator (Z or UTC offset)
whenever a time component is present, or alternatively, restrict the validator
to only accept plain dates in YYYY-MM-DD format without time components. This
ensures Date.parse always interprets dates consistently across all machines
regardless of local timezone settings.

In `@src/components/SiteHeader.astro`:
- Around line 63-65: The isCurrent check at lines 63-65 in SiteHeader.astro uses
strict equality which fails to mark parent nav items as current when viewing
detail pages like /blog/my-post/ since they don't exactly match /blog/.
Additionally, the same issue exists at lines 98-100. Replace the strict equality
comparison with section-aware matching that checks if the current path starts
with the href value, ensuring parent navigation items are correctly marked as
current for both overview pages and their dynamic route detail pages. Apply this
fix to both locations where isCurrent is evaluated.

In `@src/pages/blog/`[slug].astro:
- Around line 6-7: The post.author.url is being inserted directly into anchor
href attributes without validation, which creates a security vulnerability where
malicious JavaScript URLs could be executed. Validate the post.author.url before
rendering it into any href attribute by implementing a URL validation function
that only allows safe protocols (http, https, mailto) and rejects potentially
dangerous ones like javascript. Apply this validation wherever post.author.url
is used in href attributes throughout the file, ensuring that unsafe URLs are
either sanitized or replaced with empty strings or fallback values.

In `@src/pages/index.astro`:
- Around line 14-15: The topProjects variable is being created by slicing
directly from the full projects array, which includes entries with syncFailed
status. These failed sync entries should be excluded from the featured rail on
the homepage. Filter the projects array to exclude any entries where syncFailed
is true before slicing to create topProjects, ensuring that only successfully
synced repositories appear in the featured cards.

In `@src/styles/global.css`:
- Around line 178-186: In the `.sr-only` class definition, the deprecated `clip`
property needs to be replaced. Remove or replace the line containing `clip:
rect(0, 0, 0, 0);` with the modern `clip-path: inset(50%);` property. If legacy
browser support is required, you may keep the deprecated `clip` property as a
fallback on a separate line before the `clip-path` property, but the modern
property should be the primary rule.
- Line 16: The stylelint configuration does not recognize Tailwind v4's `@theme`
directive, causing CI failures for valid CSS. Update your stylelint
configuration file (typically `.stylelintrc.js`, `.stylelintrc.json`, or
similar) to allow Tailwind's custom at-rules by either installing and extending
a Tailwind-specific stylelint config package like
`@dreamsicle.io/stylelint-config-tailwindcss`, or manually modify the
`at-rule-no-unknown` rule in your existing stylelint config to add an exceptions
list that includes Tailwind's custom at-rules (such as `theme`, `layer`,
`apply`, etc.).

---

Outside diff comments:
In `@package.json`:
- Around line 6-14: The package.json file is missing an engines declaration to
specify the Node.js version requirement. Add an "engines" field to the root
level of package.json (at the same level as "scripts") with the value "node":
">=22" to explicitly document that the project requires Node.js version 22 or
higher, matching the CI workflow requirements and providing clear guidance to
local developers.

In `@src/components/ProjectsDirectory.astro`:
- Around line 96-182: The inline script tag in ProjectsDirectory.astro
containing the project filtering, sorting, and pagination logic is blocked by
the BaseLayout's CSP policy which restricts scripts to 'self'. Extract the
JavaScript code from the inline script block into a separate external file
(e.g., projectsDirectory.js or similar), then import and execute it from the
component instead of using an inline script tag. This will allow the bundler to
handle it properly and comply with the strict CSP policy. Alternatively, if
inline scripts must be kept, update the CSP policy in BaseLayout to support
nonce or hash directives for script-src.

In `@src/pages/projects.astro`:
- Line 66: The ProjectsDirectory component is currently displaying all projects
including those with syncFailed status, which show placeholder zero stars/forks
without any visual indicator of failure, contradicting the goal of showing
honest totals. To fix this, either pass refreshedProjects instead of projects to
the ProjectsDirectory component to exclude failed syncs entirely, or add a "Sync
Failed" status badge to the ProjectCard component to visually mark entries that
failed to sync, making it clear to users why those cards display zero metrics.

---

Nitpick comments:
In `@astro.config.mjs`:
- Around line 16-23: The comment in the build configuration block for
astro.config.mjs is misleading because it describes inlineStylesheets as
controlling script inlining for CSP compliance, when this setting actually only
controls stylesheet emission (preventing inline <style> blocks). Update the
comment to accurately reflect that inlineStylesheets: 'never' prevents inline
stylesheets that would violate style-src 'self', and clarify that script
bundling is handled separately by Astro and already emits module scripts as
external files by default in Astro v5+.

In `@src/layouts/BaseLayout.astro`:
- Around line 118-121: The preload link element in BaseLayout.astro hardcodes
type="image/avif" regardless of the actual format of heroPreloadHref, which will
cause browsers to ignore or mishandle preloads for non-AVIF images. Fix this by
either accepting a heroPreloadType prop and using it dynamically in the type
attribute of the link element, or removing the type attribute entirely from the
preload link tag and allowing the browser to sniff the image format
automatically. The simpler approach is to omit the type attribute since browsers
can detect the format from the file extension or content.

In `@src/pages/blog/`[slug].astro:
- Around line 35-41: The jsonLd object in the BlogPosting schema uses
thumbnailUrl which may be a relative URL, but structured data requires absolute
URLs for the image field to ensure proper portability across crawlers and
validators. Modify the jsonLd object to ensure the image property receives an
absolute URL by converting thumbnailUrl to a fully qualified URL (including the
domain) before assigning it to the image field.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 875fa46f-6a17-4989-9b18-2d425c655e61

📥 Commits

Reviewing files that changed from the base of the PR and between cf6ef01 and 6044270.

⛔ Files ignored due to path filters (9)
  • docs/redesign/blog.png is excluded by !**/*.png
  • docs/redesign/contact.png is excluded by !**/*.png
  • docs/redesign/falsafah.png is excluded by !**/*.png
  • docs/redesign/forum.png is excluded by !**/*.png
  • docs/redesign/home.png is excluded by !**/*.png
  • docs/redesign/projects.png is excluded by !**/*.png
  • package-lock.json is excluded by !**/package-lock.json
  • src/assets/brand/icon-192.png is excluded by !**/*.png
  • src/assets/brand/indopensource-hero.jpg is excluded by !**/*.jpg
📒 Files selected for processing (33)
  • .env.example
  • astro.config.mjs
  • package.json
  • scripts/sync-blog-posts.mjs
  • scripts/sync-projects.mjs
  • src/components/BaseButton.astro
  • src/components/HomeHero.astro
  • src/components/InfoCard.astro
  • src/components/LightSvgMotion.astro
  • src/components/PageHeader.astro
  • src/components/ProjectCard.astro
  • src/components/ProjectsDirectory.astro
  • src/components/RoadmapTimeline.astro
  • src/components/SectionHeader.astro
  • src/components/SiteFooter.astro
  • src/components/SiteHeader.astro
  • src/layouts/BaseLayout.astro
  • src/lib/content.ts
  • src/lib/hero.ts
  • src/lib/projects.ts
  • src/pages/blog.astro
  • src/pages/blog/[slug].astro
  • src/pages/contact.astro
  • src/pages/falsafah.astro
  • src/pages/forum.astro
  • src/pages/index.astro
  • src/pages/projects.astro
  • src/pages/projects/[slug].astro
  • src/pages/robots.txt.ts
  • src/pages/sitemap-index.xml.ts
  • src/pages/sitemap.xml.ts
  • src/styles/global.css
  • test/lib.test.mjs
💤 Files with no reviewable changes (1)
  • src/pages/sitemap-index.xml.ts

Comment on lines 87 to 97
const arrayItem = line.match(/^\s+-\s+(.*)$/);
if (arrayItem && activeArray) {
const value = arrayItem[1].trim();
if (value.includes(':')) {
const item = {};
const [key, ...rest] = value.split(':');
item[key.trim()] = rest.join(':').trim().replace(/^"|"$/g, '');
item[key.trim()] = unquoteScalar(rest.join(':'));
data[activeArray].push(item);
} else {
data[activeArray].push(value.replace(/^"|"$/g, ''));
data[activeArray].push(unquoteScalar(value));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Empty scalar keys are coerced into arrays, which can corrupt emitted post fields.

With the current logic, key: is initialized as [] before knowing whether list items follow. That can turn scalar fields into arrays (for example status:), breaking downstream contracts and publish gating behavior.

Proposed fix
-    if (arrayItem && activeArray) {
+    if (arrayItem && activeArray) {
+      if (!Array.isArray(data[activeArray])) data[activeArray] = [];
       const value = arrayItem[1].trim();
       if (value.includes(':')) {
         const item = {};
         const [key, ...rest] = value.split(':');
         item[key.trim()] = unquoteScalar(rest.join(':'));
         data[activeArray].push(item);
       } else {
         data[activeArray].push(unquoteScalar(value));
       }
       continue;
     }

     if (!rawValue.trim()) {
-      data[key] = [];
+      data[key] = '';
       activeArray = key;
       continue;
     }

Also applies to: 111-114

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/sync-blog-posts.mjs` around lines 87 - 97, The YAML parsing logic in
the array item handling is incorrectly coercing empty scalar keys into arrays,
which corrupts post fields. When an array item contains a key-value pair (like
`- key: value`), if the value portion is empty (after the colon), the code
should not treat it as a scalar field but rather handle it appropriately to
prevent field type corruption. Modify the condition around the key-value pair
parsing to check whether `rest.join(':')` produces an actual value after
trimming; if it results in an empty string, either skip that item, treat it as a
null scalar, or handle it in a way that prevents initializing array fields that
should remain scalars. This same pattern correction should be applied at line
111-114 as well.

Comment on lines +250 to +263
// Only trust strict ISO 8601 dates (`YYYY-MM-DD`, optionally with a time) so
// ordering is deterministic across machines/locales. `Date.parse` is lenient
// and locale-dependent (e.g. it silently accepts "14 Juni 2026"), which would
// reintroduce the nondeterministic ordering CC-4 is about. Anything that is
// not strict ISO is treated as "no usable date" and pushed to the end.
if (!/^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?$/.test(trimmed)) {
if (trimmed) console.warn(`Non-ISO blog date "${value}" — sorting this post last (use YYYY-MM-DD).`);
return null;
}
const time = Date.parse(trimmed);
if (Number.isNaN(time)) {
console.warn(`Invalid blog date "${value}" — sorting this post last.`);
return null;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Timezone-less datetimes still parse in local time, so ordering can differ across machines.

The validator allows datetime values without Z/offset. Date.parse() then uses local timezone semantics, which undermines the deterministic sort guarantee.

Proposed fix
   if (!/^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?$/.test(trimmed)) {
     if (trimmed) console.warn(`Non-ISO blog date "${value}" — sorting this post last (use YYYY-MM-DD).`);
     return null;
   }
+  const hasTime = /[T ]\d{2}:\d{2}/.test(trimmed);
+  const hasTimezone = /(Z|[+-]\d{2}:?\d{2})$/.test(trimmed);
+  if (hasTime && !hasTimezone) {
+    console.warn(`Timezone-less blog date "${value}" — sorting this post last (add Z or ±hh:mm).`);
+    return null;
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Only trust strict ISO 8601 dates (`YYYY-MM-DD`, optionally with a time) so
// ordering is deterministic across machines/locales. `Date.parse` is lenient
// and locale-dependent (e.g. it silently accepts "14 Juni 2026"), which would
// reintroduce the nondeterministic ordering CC-4 is about. Anything that is
// not strict ISO is treated as "no usable date" and pushed to the end.
if (!/^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?$/.test(trimmed)) {
if (trimmed) console.warn(`Non-ISO blog date "${value}" — sorting this post last (use YYYY-MM-DD).`);
return null;
}
const time = Date.parse(trimmed);
if (Number.isNaN(time)) {
console.warn(`Invalid blog date "${value}" — sorting this post last.`);
return null;
}
// Only trust strict ISO 8601 dates (`YYYY-MM-DD`, optionally with a time) so
// ordering is deterministic across machines/locales. `Date.parse` is lenient
// and locale-dependent (e.g. it silently accepts "14 Juni 2026"), which would
// reintroduce the nondeterministic ordering CC-4 is about. Anything that is
// not strict ISO is treated as "no usable date" and pushed to the end.
if (!/^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?$/.test(trimmed)) {
if (trimmed) console.warn(`Non-ISO blog date "${value}" — sorting this post last (use YYYY-MM-DD).`);
return null;
}
const hasTime = /[T ]\d{2}:\d{2}/.test(trimmed);
const hasTimezone = /(Z|[+-]\d{2}:?\d{2})$/.test(trimmed);
if (hasTime && !hasTimezone) {
console.warn(`Timezone-less blog date "${value}" — sorting this post last (add Z or ±hh:mm).`);
return null;
}
const time = Date.parse(trimmed);
if (Number.isNaN(time)) {
console.warn(`Invalid blog date "${value}" — sorting this post last.`);
return null;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/sync-blog-posts.mjs` around lines 250 - 263, The regex pattern in the
date validation check accepts timezone-less datetimes (e.g.,
"2023-06-14T14:30:00") which parse using local machine timezone, breaking
deterministic sorting. The timezone indicator at the end of the regex pattern is
currently optional due to the trailing question mark. Modify the regex to
require the timezone indicator (Z or UTC offset) whenever a time component is
present, or alternatively, restrict the validator to only accept plain dates in
YYYY-MM-DD format without time components. This ensures Date.parse always
interprets dates consistently across all machines regardless of local timezone
settings.

Comment on lines +63 to +65
navItems.map(([label, href]) => {
const isCurrent = currentPathWithoutBase === href;
return isCurrent ? (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use section-aware matching for active nav state.

At Line 64 and Line 99, strict equality means /blog/[slug]/ and /projects/[slug]/ won’t mark their parent nav item as current, so visual active state and aria-current are incorrect on detail pages.

Suggested patch
 const currentPathWithoutBase = currentPath.replace(new RegExp(`^${baseUrl.replace(/\/$/, '')}`), '') || '/';
+const isCurrentPath = (href: string) =>
+  href === '/' ? currentPathWithoutBase === '/' : currentPathWithoutBase.startsWith(href);
@@
-          const isCurrent = currentPathWithoutBase === href;
+          const isCurrent = isCurrentPath(href);
@@
-      const isCurrent = currentPathWithoutBase === href;
+      const isCurrent = isCurrentPath(href);

Also applies to: 98-100

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/SiteHeader.astro` around lines 63 - 65, The isCurrent check at
lines 63-65 in SiteHeader.astro uses strict equality which fails to mark parent
nav items as current when viewing detail pages like /blog/my-post/ since they
don't exactly match /blog/. Additionally, the same issue exists at lines 98-100.
Replace the strict equality comparison with section-aware matching that checks
if the current path starts with the href value, ensuring parent navigation items
are correctly marked as current for both overview pages and their dynamic route
detail pages. Apply this fix to both locations where isCurrent is evaluated.

Comment on lines +6 to 7
import { isPublished, renderArticle, type BlogPost } from '../../lib/content';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate author URLs before rendering them into href.

post.author.url is inserted directly into an anchor. If content contains a javascript: URL, it becomes an executable link.

🔒 Proposed fix
-import { withBase } from '../../lib/urls';
+import { normalizeHref, withBase } from '../../lib/urls';
@@
 const thumbnail = post.thumbnail || '/brand/indopensource-hero.jpg';
 const thumbnailUrl = /^https?:\/\//.test(thumbnail) ? thumbnail : withBase(thumbnail);
+const authorUrl = post.author?.url ? normalizeHref(post.author.url) : '';
@@
-          {post.author?.url
-            ? <a class="display-sm text-brand underline decoration-2 underline-offset-2 hover:text-accent" href={post.author.url}>{post.author?.name}</a>
+          {authorUrl
+            ? <a class="display-sm text-brand underline decoration-2 underline-offset-2 hover:text-accent" href={authorUrl}>{post.author?.name}</a>
             : <span class="display-sm text-brand">{post.author?.name}</span>}

Also applies to: 87-89

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/pages/blog/`[slug].astro around lines 6 - 7, The post.author.url is being
inserted directly into anchor href attributes without validation, which creates
a security vulnerability where malicious JavaScript URLs could be executed.
Validate the post.author.url before rendering it into any href attribute by
implementing a URL validation function that only allows safe protocols (http,
https, mailto) and rejects potentially dangerous ones like javascript. Apply
this validation wherever post.author.url is used in href attributes throughout
the file, ensuring that unsafe URLs are either sanitized or replaced with empty
strings or fallback values.

Comment thread src/pages/index.astro
Comment on lines 14 to 15
const totalProjects = projects.length;
const topProjects = projects.slice(0, 5);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Filter out failed sync entries before computing topProjects.

Line 15 slices from all projects, so homepage cards can still include syncFailed entries that are excluded elsewhere. This can surface partial/unavailable repos in the featured rail.

Suggested fix
 const totalProjects = projects.length;
-const topProjects = projects.slice(0, 5);
+const refreshedProjects = projects.filter((project) => !project.syncFailed);
+const topProjects = refreshedProjects.slice(0, 5);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const totalProjects = projects.length;
const topProjects = projects.slice(0, 5);
const totalProjects = projects.length;
const refreshedProjects = projects.filter((project) => !project.syncFailed);
const topProjects = refreshedProjects.slice(0, 5);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/pages/index.astro` around lines 14 - 15, The topProjects variable is
being created by slicing directly from the full projects array, which includes
entries with syncFailed status. These failed sync entries should be excluded
from the featured rail on the homepage. Filter the projects array to exclude any
entries where syncFailed is true before slicing to create topProjects, ensuring
that only successfully synced repositories appear in the featured cards.

Comment thread src/styles/global.css

@import "tailwindcss";

@theme {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether stylelint config still rejects Tailwind v4 directives.
fd -HI 'stylelint\.config|\.stylelintrc' | while read -r f; do
  echo "===== $f ====="
  sed -n '1,220p' "$f"
done

echo "===== stylelint rule references ====="
rg -n "at-rule-no-unknown|scss/at-rule-no-unknown|tailwind|value-keyword-case|declaration-empty-line-before|property-no-deprecated"

Repository: IndopenSource/indopensource.org

Length of output: 7017


🏁 Script executed:

# Check the global.css file content around line 16
wc -l src/styles/global.css
echo "===== global.css content ====="
cat -n src/styles/global.css | head -30

Repository: IndopenSource/indopensource.org

Length of output: 1807


🏁 Script executed:

# Check for at-rule-no-unknown in the stylelint config and related documentation
rg -A 5 -B 5 "at-rule-no-unknown" .stylelintrc.json

Repository: IndopenSource/indopensource.org

Length of output: 57


🌐 Web query:

Tailwind CSS v4 @theme directive stylelint configuration

💡 Result:

To use Stylelint with Tailwind CSS v4 and the @theme directive, you must configure Stylelint to recognize Tailwind’s custom CSS at-rules and syntax, as standard CSS parsers will otherwise flag them as errors [1][2][3]. Since Tailwind CSS v4 utilizes the @theme directive to define design tokens [4], standard Stylelint configurations often require supplemental settings to prevent validation failures. The most effective approach is to use a dedicated Stylelint configuration package specifically built for Tailwind CSS v4+ [1][2][5]. Recommended Configuration Approach Several community-maintained packages, such as @dreamsicle.io/stylelint-config-tailwindcss, are designed to implement Tailwind's directives (including @theme) into Stylelint's languageOptions [1][5]. 1. Install the package: npm install --save-dev @dreamsicle.io/stylelint-config-tailwindcss 2. Add it to your stylelint.config.mjs (or equivalent configuration file): / * @type {import("stylelint").Config} */ export default { extends: [ "stylelint-config-standard", "@dreamsicle.io/stylelint-config-tailwindcss", ], }; Why this is necessary: Tailwind CSS v4 introduces custom syntax that extends beyond standard CSS specifications [4]. By using a specialized configuration, you ensure that Stylelint understands the @theme directive and associated custom properties as valid syntax rather than unknown at-rules [1][6]. These configurations typically inject Tailwind's custom at-rules, properties, and types into Stylelint’s syntax validation engine, allowing for proper linting without suppressing critical CSS errors [1][3]. If you are not using a pre-built configuration, you would otherwise need to manually configure Stylelint’s customSyntax or ignore the specific at-rule violations using the rules configuration (e.g., at-rule-no-unknown), which is generally discouraged as it sacrifices linting quality [1][2].

Citations:


Update stylelint config to accept Tailwind v4 @theme directive.

Line 16 uses a valid Tailwind v4 directive, but standard stylelint configurations will report it as an unknown at-rule. This will block CI merges despite the CSS being valid. Add a Tailwind-specific stylelint configuration (e.g., @dreamsicle.io/stylelint-config-tailwindcss) or manually configure the at-rule-no-unknown rule to allow Tailwind's custom at-rules.

🧰 Tools
🪛 Stylelint (17.12.0)

[error] 16-16: Unexpected unknown at-rule "@theme" (scss/at-rule-no-unknown)

(scss/at-rule-no-unknown)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/styles/global.css` at line 16, The stylelint configuration does not
recognize Tailwind v4's `@theme` directive, causing CI failures for valid CSS.
Update your stylelint configuration file (typically `.stylelintrc.js`,
`.stylelintrc.json`, or similar) to allow Tailwind's custom at-rules by either
installing and extending a Tailwind-specific stylelint config package like
`@dreamsicle.io/stylelint-config-tailwindcss`, or manually modify the
`at-rule-no-unknown` rule in your existing stylelint config to add an exceptions
list that includes Tailwind's custom at-rules (such as `theme`, `layer`,
`apply`, etc.).

Source: Linters/SAST tools

Comment thread src/styles/global.css
Comment on lines +178 to +186
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Replace deprecated .sr-only clipping rule.

Line 185 uses clip, which is deprecated and already flagged by stylelint. Prefer clip-path: inset(50%) (optionally keep clip as fallback only if legacy support is required).

Suggested patch
 .sr-only {
   position: absolute;
   width: 1px;
   height: 1px;
   padding: 0;
   margin: -1px;
   overflow: hidden;
-  clip: rect(0, 0, 0, 0);
+  clip-path: inset(50%);
+  clip: rect(0, 0, 0, 0); /* optional fallback */
   white-space: nowrap;
   border: 0;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
clip: rect(0, 0, 0, 0); /* optional fallback */
white-space: nowrap;
🧰 Tools
🪛 Stylelint (17.12.0)

[error] 185-185: Deprecated property "clip" (property-no-deprecated)

(property-no-deprecated)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/styles/global.css` around lines 178 - 186, In the `.sr-only` class
definition, the deprecated `clip` property needs to be replaced. Remove or
replace the line containing `clip: rect(0, 0, 0, 0);` with the modern
`clip-path: inset(50%);` property. If legacy browser support is required, you
may keep the deprecated `clip` property as a fallback on a separate line before
the `clip-path` property, but the modern property should be the primary rule.

Source: Linters/SAST tools

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants