diff --git a/CLAUDE.md b/CLAUDE.md index a2c76529b2..f211f47e6d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,16 +21,6 @@ bundle exec rake serve bundle exec jekyll serve --watch --future --incremental ``` -### CSS (Tailwind) - -```bash -# Build CSS (production) -npm run build-css - -# Watch CSS for development -npm run watch-css -``` - ### Testing & Quality Assurance ```bash @@ -40,6 +30,7 @@ bundle exec rake test # Run individual test suites bundle exec rake test-news-plugin # News archive plugin tests bundle exec rake test-linter # Linter library tests +bundle exec rake test-postcss-incremental-fix-plugin # PostCSS incremental build plugin tests # Linting bundle exec rake lint # Markdown linter @@ -56,7 +47,7 @@ bundle exec rake check:links # Check for broken links (needs local serv bundle exec rake new_post:en # English bundle exec rake new_post:ja # Japanese bundle exec rake new_post:fr # French -# ... etc for: bg, de, es, id, it, ko, pl, pt, ru, tr, vi, zh_cn, zh_tw +# ... etc for: bg, de, es, id, it, ja, ko, pl, pt, ru, tr, vi, zh_cn, zh_tw ``` ## Architecture & Structure @@ -83,7 +74,7 @@ bundle exec rake new_post:fr # French - `_data/`: YAML data files (releases.yml, downloads.yml, branches.yml, locales/) - `lib/`: Ruby utilities (linter, markup checker, draft release) - `test/`: Test files for plugins and linter -- `stylesheets/`: CSS source and compiled output +- `stylesheets/`: CSS source (includes partials that Jekyll excludes by default: directories prefixed with `_` and files starting with `_`) - `_javascripts_src/`: TypeScript source files - `javascripts/`: Compiled JavaScript output @@ -91,16 +82,17 @@ bundle exec rake new_post:fr # French The site uses a custom Tailwind configuration with: -- **Semantic color tokens** via CSS variables (defined in `tailesheets/semantic-colors.css`) +- **Semantic color tokens** via CSS variables (defined in `stylesheets/semantic-colors.css`) - Accessible via `bg-semantic-*`, `text-semantic-*`, `border-semantic-*` classes - Automatically handles light/dark mode via `prefers-color-scheme` +- **Incremental Build**: Uses `_plugins/postcss_incremental_fix.rb` to trigger PostCSS rebuilds during `jekyll serve --incremental` when CSS partials are modified + - **CSS partials**: Files/directories that Jekyll normally excludes (underscore-prefixed like `_components/` or `_variables. css`) but are imported by main stylesheets + - The plugin watches these excluded partials and forces a rebuild when they change, ensuring Tailwind processes updated styles - **Brand colors**: Ruby (red) and Gold palettes - **Typography plugin** for prose styling - **Custom breakpoints**: Container max-widths configured for content layouts - **Dark mode**: Enabled via OS preference (`darkMode: 'media'`) -Input: `stylesheets/tailwind.css` → Output: `stylesheets/compiled.css` - ### News System The news system is powered by a custom Jekyll plugin (`_plugins/news.rb`): @@ -179,6 +171,11 @@ lang: en **Node (package.json):** - `tailwindcss` - CSS framework - `@tailwindcss/typography` - Prose styling plugin +- `postcss` - CSS transformation tool +- `postcss-cli` - PostCSS command-line interface +- `postcss-import` - PostCSS plugin for @import resolution +- `autoprefixer` - PostCSS plugin for vendor prefix automation +- `cssnano` - CSS minification tool (activated only in production builds to optimize CSS file size) ## Important Conventions diff --git a/Gemfile b/Gemfile index 9462c83575..17699a8c46 100644 --- a/Gemfile +++ b/Gemfile @@ -17,3 +17,7 @@ gem "base64" # Jekyll need this for Ruby 4.0.0+ gem "logger" + +group :jekyll_plugins do + gem "jekyll-postcss-v2" +end diff --git a/Gemfile.lock b/Gemfile.lock index 47d0e03070..7a29461a02 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -96,6 +96,7 @@ GEM safe_yaml (~> 1.0) terminal-table (>= 1.8, < 4.0) webrick (~> 1.7) + jekyll-postcss-v2 (1.0.2) jekyll-sass-converter (3.1.0) sass-embedded (~> 1.75) jekyll-watch (2.2.1) @@ -249,6 +250,7 @@ DEPENDENCIES csv html-proofer jekyll + jekyll-postcss-v2 logger minitest rake @@ -298,6 +300,7 @@ CHECKSUMS i18n (1.14.7) sha256=ceba573f8138ff2c0915427f1fc5bdf4aa3ab8ae88c8ce255eb3ecf0a11a5d0f io-event (1.10.0) sha256=e4e1f5bf01a1a8b8484db3c5d99b431eb3609dbc988b96622d14d77993e0e9dc jekyll (4.4.1) sha256=4c1144d857a5b2b80d45b8cf5138289579a9f8136aadfa6dd684b31fe2bc18c1 + jekyll-postcss-v2 (1.0.2) sha256=f179d3de83918ebb266ac8adc3191e422107fec5b088cb1c35fa9ff9c50b89c3 jekyll-sass-converter (3.1.0) sha256=83925d84f1d134410c11d0c6643b0093e82e3a3cf127e90757a85294a3862443 jekyll-watch (2.2.1) sha256=bc44ed43f5e0a552836245a54dbff3ea7421ecc2856707e8a1ee203a8387a7e1 json (2.10.2) sha256=34e0eada93022b2a0a3345bb0b5efddb6e9ff5be7c48e409cfb54ff8a36a8b06 diff --git a/README.md b/README.md index 21098fad6d..a1b49fd347 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ This is the [Jekyll](http://www.jekyllrb.com/) source code for the official [Rub ### Prerequisites - **Ruby** (latest stable version recommended) - [Install Ruby](https://www.ruby-lang.org/en/documentation/installation/) +- **Node.js** - [Install Node.js](https://nodejs.org/en/download/) - **Git** - [Install Git](https://git-scm.com/downloads) ### Get It Running @@ -29,6 +30,7 @@ This is the [Jekyll](http://www.jekyllrb.com/) source code for the official [Rub cd www.ruby-lang.org/ bundle config set --local without production bundle install + npm install ``` 3. **Start the development server**: @@ -125,16 +127,9 @@ If you can't build locally or want to test under production conditions: ## Styling with Tailwind CSS -This site uses [Tailwind CSS](https://tailwindcss.com/) for styling. -After making changes to HTML/Markdown files or Tailwind configuration: - -``` sh -npm run build-css # build CSS -npm run watch-css # watch and rebuild CSS automatically -``` - -**Note:** You need to have Node.js installed to run these commands. +This site uses [Tailwind CSS](https://tailwindcss.com/) for styling. +⏱️ **Note:** When you modify CSS files or add/modify CSS classes in HTML or Markdown files, it might take a moment for the changes to be reflected in the preview, as the CSS needs to be rebuilt. ## Testing diff --git a/Rakefile b/Rakefile index 296fbe6d6e..aa8e7d0430 100644 --- a/Rakefile +++ b/Rakefile @@ -19,7 +19,7 @@ task :"build-css" do end desc "Run tests (test-linter, lint, build)" -task test: %i[test-news-plugin test-html-lang-plugin test-linter lint build] +task test: %i[test-news-plugin test-html-lang-plugin test-postcss-incremental-fix-plugin test-linter lint build] desc "Build the Jekyll site" task build: :"build-css" do @@ -142,3 +142,11 @@ Rake::TestTask.new(:"test-html-lang-plugin") do |t| t.test_files = FileList['test/test_plugin_html_lang.rb'] t.verbose = true end + +require "rake/testtask" +Rake::TestTask.new(:"test-postcss-incremental-fix-plugin") do |t| + t.description = "Run tests for the PostCSS incremental fix plugin" + t.libs = ["test"] + t.test_files = FileList['test/test_plugin_postcss_incremental_fix.rb'] + t.verbose = true +end diff --git a/_config.yml b/_config.yml index 8c2c3738ba..ae39c05d90 100644 --- a/_config.yml +++ b/_config.yml @@ -15,12 +15,26 @@ exclude: - Gemfile.lock - Rakefile - README.md + - CLAUDE.md - lib - test - vendor - tsconfig.json + - package.json + - package-lock.json + - node_modules + - tailwind.config.js + - postcss.config.js + - .git + - .gitignore + - .github + - .idea + - .vscode - CLAUDE.md +plugins: + - jekyll-postcss-v2 + url: https://www.ruby-lang.org license: diff --git a/_layouts/default.html b/_layouts/default.html index f03fbd9e9c..8d7da73de6 100644 --- a/_layouts/default.html +++ b/_layouts/default.html @@ -61,10 +61,10 @@ - + @@ -122,4 +122,4 @@ {% include footer.html %} - \ No newline at end of file + diff --git a/_layouts/homepage.html b/_layouts/homepage.html index 3c664c4aaa..00083c34d0 100644 --- a/_layouts/homepage.html +++ b/_layouts/homepage.html @@ -38,10 +38,10 @@ - + diff --git a/_plugins/postcss_incremental_fix.rb b/_plugins/postcss_incremental_fix.rb new file mode 100644 index 0000000000..b277b99101 --- /dev/null +++ b/_plugins/postcss_incremental_fix.rb @@ -0,0 +1,76 @@ +# Regenerate CSS when HTML/Markdown files or imported CSS partials change +# Detects changes in content files and CSS partials (e.g., _components/*.css, _variables.css) +# to trigger PostCSS rebuild via jekyll-postcss-v2 plugin + +module Jekyll + module PostcssTrigger + # File patterns to watch for changes that should trigger CSS rebuild + WATCHED_FILE_PATTERNS = %w[ + _layouts/**/*.html + _includes/**/*.html + *.html + *.md + */**/*.html + */**/*.md + stylesheets/**/_*.css + stylesheets/_*/**/*.css + ].freeze + + class << self + attr_accessor :last_check_time, :css_touched + end + end +end + +Jekyll::Hooks.register :site, :post_read do |site| + # Skip if not in incremental mode + next unless site.config['incremental'] + + # On first build, only record the check time + if Jekyll::PostcssTrigger.last_check_time.nil? + Jekyll::PostcssTrigger.last_check_time = Time.now + Jekyll::PostcssTrigger.css_touched = false + next + end + + # Skip if CSS was already touched in this build + next if Jekyll::PostcssTrigger.css_touched + + # Check if any HTML/Markdown files or included CSS files have changed + content_patterns = Jekyll::PostcssTrigger::WATCHED_FILE_PATTERNS + + html_changed = false + last_check = Jekyll::PostcssTrigger.last_check_time + + content_patterns.each do |pattern| + Dir.glob(site.in_source_dir(pattern)).each do |file| + next if file.start_with?(site.dest) # Exclude _site directory + + # Check if a file was modified since the last check + if File.exist?(file) && File.mtime(file) > last_check + html_changed = true + Jekyll.logger.info "PostCSS Trigger:", "Detected change in #{File.basename(file)}" + break + end + end + break if html_changed + end + + # Touch CSS file if HTML has changed + if html_changed + css_file = site.in_source_dir("stylesheets/main.css") + if File.exist?(css_file) + Jekyll.logger.info "PostCSS Trigger:", "Marking CSS for rebuild" + FileUtils.touch(css_file) + Jekyll::PostcssTrigger.css_touched = true + end + end + + # Update check time + Jekyll::PostcssTrigger.last_check_time = Time.now +end + +# Reset flag after build completes +Jekyll::Hooks.register :site, :post_write do |site| + Jekyll::PostcssTrigger.css_touched = false +end diff --git a/package.json b/package.json index 8e2ddf3503..333c9587b7 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,14 @@ { "name": "www.ruby-lang.org", "version": "1.0.0", - "description": "Ruby Language Website with Tailwind CSS", - "scripts": { - "build-css": "tailwindcss -i ./stylesheets/tailwind.css -o ./stylesheets/compiled.css", - "watch-css": "tailwindcss -i ./stylesheets/tailwind.css -o ./stylesheets/compiled.css --watch" - }, + "description": "Ruby Language Website Renewal with Tailwind CSS", "devDependencies": { "@tailwindcss/typography": "^0.5.19", + "autoprefixer": "^10.4.23", + "cssnano": "^7.1.2", + "postcss": "^8.5.6", + "postcss-cli": "^11.0.1", + "postcss-import": "^16.1.1", "tailwindcss": "^3.4.14" } } diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000000..932d24bcb9 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,16 @@ +const path = require('node:path'); + +module.exports = { + plugins: { + "postcss-import": { + path: path.join(__dirname, 'stylesheets') + }, + tailwindcss: {}, + autoprefixer: {}, + ...(process.env.JEKYLL_ENV === 'production' && { + cssnano: { + preset: 'default' + } + }) + } +}; diff --git a/stylesheets/components/base.css b/stylesheets/_components/base.css similarity index 100% rename from stylesheets/components/base.css rename to stylesheets/_components/base.css diff --git a/stylesheets/components/icons.css b/stylesheets/_components/icons.css similarity index 100% rename from stylesheets/components/icons.css rename to stylesheets/_components/icons.css diff --git a/stylesheets/components/pagination.css b/stylesheets/_components/pagination.css similarity index 100% rename from stylesheets/components/pagination.css rename to stylesheets/_components/pagination.css diff --git a/stylesheets/components/print.css b/stylesheets/_components/print.css similarity index 100% rename from stylesheets/components/print.css rename to stylesheets/_components/print.css diff --git a/stylesheets/components/syntax-highlighting.css b/stylesheets/_components/syntax-highlighting.css similarity index 100% rename from stylesheets/components/syntax-highlighting.css rename to stylesheets/_components/syntax-highlighting.css diff --git a/stylesheets/components/tables.css b/stylesheets/_components/tables.css similarity index 100% rename from stylesheets/components/tables.css rename to stylesheets/_components/tables.css diff --git a/stylesheets/components/toc.css b/stylesheets/_components/toc.css similarity index 100% rename from stylesheets/components/toc.css rename to stylesheets/_components/toc.css diff --git a/stylesheets/variables.css b/stylesheets/_variables.css similarity index 100% rename from stylesheets/variables.css rename to stylesheets/_variables.css diff --git a/stylesheets/tailwind.css b/stylesheets/main.css similarity index 80% rename from stylesheets/tailwind.css rename to stylesheets/main.css index d2a19a7b87..3bb62163d6 100644 --- a/stylesheets/tailwind.css +++ b/stylesheets/main.css @@ -1,17 +1,20 @@ +--- +--- + /* Import CSS variables */ -@import './variables.css'; +@import '_variables.css'; /* Custom fonts if needed */ @import url('https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;700&display=swap'); /* Import component styles */ -@import './components/base.css'; -@import './components/pagination.css'; -@import './components/toc.css'; -@import './components/tables.css'; -@import './components/icons.css'; -@import './components/print.css'; -@import './components/syntax-highlighting.css'; +@import '_components/base.css'; +@import '_components/pagination.css'; +@import '_components/toc.css'; +@import '_components/tables.css'; +@import '_components/icons.css'; +@import '_components/print.css'; +@import '_components/syntax-highlighting.css'; /* Tailwind layers */ @tailwind base; diff --git a/test/test_plugin_postcss_incremental_fix.rb b/test/test_plugin_postcss_incremental_fix.rb new file mode 100644 index 0000000000..a61edd2c14 --- /dev/null +++ b/test/test_plugin_postcss_incremental_fix.rb @@ -0,0 +1,316 @@ +# frozen_string_literal: true + +require "helper" +require "jekyll" +require "fileutils" +require "time" +require_relative "../_plugins/postcss_incremental_fix" + +describe Jekyll::PostcssTrigger do + before do + # Reset module state before each test + Jekyll::PostcssTrigger.last_check_time = nil + Jekyll::PostcssTrigger.css_touched = false + end + + after do + # Clean up module state + Jekyll::PostcssTrigger.last_check_time = nil + Jekyll::PostcssTrigger.css_touched = false + end + + describe "state management" do + it "initializes with nil last_check_time and false css_touched" do + _(Jekyll::PostcssTrigger.last_check_time).must_be_nil + _(Jekyll::PostcssTrigger.css_touched).must_equal false + end + + it "can set and get last_check_time" do + time = Time. now + Jekyll::PostcssTrigger.last_check_time = time + _(Jekyll::PostcssTrigger.last_check_time).must_equal time + end + + it "can set and get css_touched" do + Jekyll::PostcssTrigger.css_touched = true + _(Jekyll::PostcssTrigger.css_touched).must_equal true + end + end + + describe "integration: HTML change triggers CSS rebuild" do + before do + chdir_tempdir + create_file("stylesheets/main.css", "/* css */") + + # Create a minimal Jekyll site + @site = Jekyll::Site.new( + Jekyll.configuration( + source: ".", + incremental: true, + quiet: true + ) + ) + end + + after do + teardown_tempdir + end + + it "records initial check time on first post_read" do + _(Jekyll::PostcssTrigger.last_check_time).must_be_nil + + Jekyll::Hooks.trigger :site, :post_read, @site + + _(Jekyll::PostcssTrigger.last_check_time).wont_be_nil + _(Jekyll::PostcssTrigger.css_touched).must_equal false + end + + it "touches CSS when HTML file changes after first build" do + # Create HTML file BEFORE first build + create_file("index.html", "original") + + # First build - establish baseline + Jekyll::Hooks.trigger :site, :post_read, @site + first_check_time = Jekyll::PostcssTrigger.last_check_time + + sleep 0.2 + + # NOW modify HTML file (after first build) + create_file("index.html", "modified") + css_original_mtime = File.mtime("stylesheets/main.css") + + sleep 0.1 + + # Second build - should detect change + Jekyll::Hooks.trigger :site, :post_read, @site + + _(Jekyll::PostcssTrigger.css_touched).must_equal true + _(File.mtime("stylesheets/main.css") > css_original_mtime).must_equal true + end + + it "touches CSS when markdown file changes" do + # Create markdown file first + create_file("about.md", "# About Page") + + # First build + Jekyll::Hooks.trigger :site, :post_read, @site + + sleep 0.2 + + # Modify markdown file + create_file("about.md", "# About Page Modified") + css_original_mtime = File.mtime("stylesheets/main.css") + + sleep 0.1 + + # Second build + Jekyll::Hooks.trigger :site, :post_read, @site + + _(Jekyll::PostcssTrigger.css_touched).must_equal true + _(File.mtime("stylesheets/main.css") > css_original_mtime).must_equal true + end + + it "touches CSS when CSS partial (e.g. _variables.css) changes" do + # Create CSS partial first + create_file("stylesheets/_variables.css", ":root { --color: red; }") + + # First build + Jekyll::Hooks.trigger :site, :post_read, @site + + sleep 0.2 + + # Modify CSS partial + create_file("stylesheets/_variables.css", ":root { --color: blue; }") + css_original_mtime = File.mtime("stylesheets/main.css") + + sleep 0.1 + + # Second build + Jekyll::Hooks.trigger :site, :post_read, @site + + _(Jekyll::PostcssTrigger.css_touched).must_equal true + _(File.mtime("stylesheets/main.css") > css_original_mtime).must_equal true + end + + it "touches CSS when CSS file in sub-directory (e.g. _components/*.css) changes" do + # Create CSS file in sub-directory first + create_file("stylesheets/_components/base.css", ".btn { color: red; }") + + # First build + Jekyll::Hooks.trigger :site, :post_read, @site + + sleep 0.2 + + # Modify CSS file in sub-directory + create_file("stylesheets/_components/base.css", ".btn { color: blue; }") + css_original_mtime = File.mtime("stylesheets/main.css") + + sleep 0.1 + + # Second build + Jekyll::Hooks.trigger :site, :post_read, @site + + _(Jekyll::PostcssTrigger.css_touched).must_equal true + _(File.mtime("stylesheets/main.css") > css_original_mtime).must_equal true + end + + it "touches CSS when layout file changes" do + # Create layout file first + create_file("_layouts/default.html", "{{ content }}") + + # First build + Jekyll::Hooks.trigger :site, :post_read, @site + + sleep 0.2 + + # Modify layout file + create_file("_layouts/default.html", "{{ content }}") + css_original_mtime = File.mtime("stylesheets/main.css") + + sleep 0.1 + + # Second build + Jekyll::Hooks.trigger :site, :post_read, @site + + _(Jekyll::PostcssTrigger. css_touched).must_equal true + _(File.mtime("stylesheets/main.css") > css_original_mtime).must_equal true + end + + it "touches CSS when include file changes" do + # Create include file first + create_file("_includes/header.html", "
Header
") + + # First build + Jekyll::Hooks.trigger :site, :post_read, @site + + sleep 0.2 + + # Modify include file + create_file("_includes/header.html", "
New Header
") + css_original_mtime = File.mtime("stylesheets/main.css") + + sleep 0.1 + + # Second build + Jekyll::Hooks.trigger :site, :post_read, @site + + _(Jekyll::PostcssTrigger.css_touched).must_equal true + _(File.mtime("stylesheets/main.css") > css_original_mtime).must_equal true + end + + it "does not touch CSS when no HTML/MD files changed" do + # First build + Jekyll::Hooks.trigger :site, :post_read, @site + + sleep 0.2 + + # Don't modify any files + css_original_mtime = File.mtime("stylesheets/main.css") + + sleep 0.1 + + # Second build + Jekyll::Hooks.trigger :site, :post_read, @site + + _(Jekyll::PostcssTrigger.css_touched).must_equal false + _(File.mtime("stylesheets/main.css")).must_equal css_original_mtime + end + + it "does not touch CSS multiple times in same build" do + # First build + Jekyll::Hooks.trigger :site, :post_read, @site + + sleep 0.2 + + # Modify multiple HTML files + create_file("index.html", "modified") + create_file("about.html", "also modified") + css_original_mtime = File.mtime("stylesheets/main.css") + + sleep 0.1 + + # Second build + Jekyll::Hooks.trigger :site, :post_read, @site + first_touch_mtime = File.mtime("stylesheets/main.css") + + # Try to trigger again in same "build" + Jekyll::Hooks.trigger :site, :post_read, @site + second_touch_mtime = File.mtime("stylesheets/main.css") + + _(first_touch_mtime).must_equal second_touch_mtime + end + + it "resets css_touched flag after post_write" do + # First build + Jekyll::Hooks.trigger :site, :post_read, @site + + sleep 0.2 + create_file("index.html", "modified") + sleep 0.1 + + # Second build - CSS should be touched + Jekyll::Hooks.trigger :site, :post_read, @site + _(Jekyll::PostcssTrigger.css_touched).must_equal true + + # Trigger post_write + Jekyll::Hooks.trigger :site, :post_write, @site + + # Flag should be reset + _(Jekyll::PostcssTrigger.css_touched).must_equal false + end + + it "does nothing when not in incremental mode" do + # Create non-incremental site + non_incremental_site = Jekyll::Site.new( + Jekyll.configuration( + source: ".", + incremental: false, + quiet: true + ) + ) + + Jekyll::PostcssTrigger.last_check_time = nil + + # Trigger hook + Jekyll::Hooks.trigger :site, :post_read, non_incremental_site + + # Should not set check time + _(Jekyll::PostcssTrigger.last_check_time).must_be_nil + end + + it "handles missing CSS file gracefully" do + # Remove CSS file + FileUtils.rm_f("stylesheets/main.css") + + # First build + Jekyll::Hooks.trigger :site, :post_read, @site + + sleep 0.2 + create_file("index.html", "modified") + sleep 0.1 + + # Should not crash when CSS file doesn't exist + _{ Jekyll::Hooks.trigger :site, :post_read, @site }.must_be_silent + end + + it "excludes _site directory from change detection" do + # First build + Jekyll::Hooks.trigger :site, :post_read, @site + + sleep 0.2 + + # Modify file in _site (generated output) + create_file("_site/index.html", "generated") + css_original_mtime = File.mtime("stylesheets/main.css") + + sleep 0.1 + + # Second build + Jekyll::Hooks.trigger :site, :post_read, @site + + # CSS should NOT be touched (changes in _site are ignored) + _(Jekyll::PostcssTrigger.css_touched).must_equal false + _(File.mtime("stylesheets/main.css")).must_equal css_original_mtime + end + end +end