diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..5d0f124
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,2 @@
+*
+!Dockerfile
diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml
new file mode 100644
index 0000000..d294a16
--- /dev/null
+++ b/.github/workflows/deploy-web.yml
@@ -0,0 +1,62 @@
+name: Deploy Web to GitHub Pages
+
+on:
+ push:
+ branches: [main]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: true
+
+env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: '3.3'
+ bundler-cache: true
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '24'
+ cache: 'npm'
+
+ - name: Install npm dependencies
+ run: npm ci --ignore-scripts
+
+ - name: Build web pages (JP + EN)
+ run: npm run web
+
+ - name: Setup Pages
+ uses: actions/configure-pages@v4
+
+ - name: Upload artifact
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: articles/webroot
+
+ deploy:
+ needs: build
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.github/workflows/on_push.yml b/.github/workflows/on_push.yml
index 4b6c988..673fde9 100755
--- a/.github/workflows/on_push.yml
+++ b/.github/workflows/on_push.yml
@@ -2,9 +2,12 @@ name: Build Re:VIEW to make distribution file
# The workflow is triggered on pushes to the repository.
on: [push]
+env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+
jobs:
build:
- name:
+ name:
runs-on: ubuntu-latest
steps:
# uses v2 Stable version
diff --git a/.gitignore b/.gitignore
index 133519f..f040a9f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,5 +8,6 @@ articles/*.html
# articles/*.md
articles/*.xml
articles/*.txt
+articles/webroot/
.DS_Store
diff --git a/.ruby-version b/.ruby-version
new file mode 100644
index 0000000..5f6fc5e
--- /dev/null
+++ b/.ruby-version
@@ -0,0 +1 @@
+3.3.10
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..bb61aee
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,23 @@
+FROM ruby:3.3-bookworm
+
+# TeX Live + Japanese support for Re:VIEW PDF builds
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ texlive-luatex \
+ texlive-lang-japanese \
+ texlive-lang-cjk \
+ texlive-fonts-recommended \
+ texlive-fonts-extra \
+ texlive-latex-recommended \
+ texlive-latex-extra \
+ texlive-plain-generic \
+ texlive-extra-utils \
+ lmodern \
+ pandoc \
+ nodejs \
+ npm \
+ ghostscript \
+ && rm -rf /var/lib/apt/lists/*
+
+RUN gem install bundler:4.0.3
+
+WORKDIR /book
diff --git a/Gemfile b/Gemfile
index b350d83..5a31d0d 100644
--- a/Gemfile
+++ b/Gemfile
@@ -1,7 +1,7 @@
# A sample Gemfile
source "https://rubygems.org"
-gem 'review', '5.3.0'
+gem 'review', '5.11.0'
gem 'pandoc2review'
gem 'rake'
# gem 'review-peg', '0.2.2'
diff --git a/Gruntfile.js b/Gruntfile.js
index 03ff9f1..8b2297b 100644
--- a/Gruntfile.js
+++ b/Gruntfile.js
@@ -17,7 +17,7 @@ const reviewTextMaker = `${reviewPrefix}rake text ${reviewPostfix}`;
const reviewIDGXMLMaker = `${reviewPrefix}rake idgxml ${reviewPostfix}`;
const reviewVivliostyle = `${reviewPrefix}rake vivliostyle ${reviewPostfix}`;
-const bookConfig = yaml.safeLoad(fs.readFileSync(`${articles}/${reviewConfig}`, "utf8"));
+const bookConfig = yaml.load(fs.readFileSync(`${articles}/${reviewConfig}`, "utf8"));
module.exports = grunt => {
grunt.initConfig({
diff --git a/articles/layouts/layout-web.html.erb b/articles/layouts/layout-web.html.erb
new file mode 100644
index 0000000..24be826
--- /dev/null
+++ b/articles/layouts/layout-web.html.erb
@@ -0,0 +1,252 @@
+
+
+
+
+
+
+<% if @javascripts.present? %>
+<% @javascripts.each do |js| %>
+ <%= js %>
+
+<% end %>
+<% end %>
+<% if @stylesheets.present? %>
+<% @stylesheets.each do |style| %>
+
+<% end %>
+<% end%>
+<% if @next.present? %>" /><% end %>
+<% if @prev.present? %>" /><% end %>
+
+ <%= h(@title) %> | <%=h @book.config.name_of("booktitle")%>
+
+>
+
+
+
+
+
+ <%= @body %>
+
+
+
+
+
+
+
+
+
diff --git a/articles/lib/tasks/z01_pandoc2review.rake b/articles/lib/tasks/z01_pandoc2review.rake
index 5d7a0ab..b2244cf 100644
--- a/articles/lib/tasks/z01_pandoc2review.rake
+++ b/articles/lib/tasks/z01_pandoc2review.rake
@@ -20,6 +20,7 @@
require 'fileutils'
require 'yaml'
+require 'date'
def make_mdre(ch, p2r, path)
if File.exist?(ch) # re file
@@ -31,7 +32,7 @@ end
desc 'run pandoc2review'
task :pandoc2review do
- config = YAML.load_file(CONFIG_FILE)
+ config = YAML.safe_load_file(CONFIG_FILE, permitted_classes: [Date])
if config['contentdir'] == '_refiles'
path = '_refiles'
p2r = 'pandoc2review'
diff --git a/articles/sty/review-base.sty b/articles/sty/review-base.sty
index 687a085..dcc8472 100644
--- a/articles/sty/review-base.sty
+++ b/articles/sty/review-base.sty
@@ -1,8 +1,9 @@
\ProvidesClass{review-base}[2020/12/31]
\RequirePackage{ifthen}
\@ifundefined{Hy@Info}{% for jsbook.cls
+ \PassOptionsToPackage{dvipdfmx}{pxjahyper}
\RequirePackage[dvipdfmx,bookmarks=true,bookmarksnumbered=true]{hyperref}
- \RequirePackage[dvipdfmx]{pxjahyper}
+ \RequirePackage{pxjahyper}
\newif\if@reclscover \@reclscovertrue
\newcommand{\includefullpagegraphics}[2][]{%
\thispagestyle{empty}%
diff --git a/articles/sty/review-jsbook.cls b/articles/sty/review-jsbook.cls
index 2c90be9..d1efd0f 100644
--- a/articles/sty/review-jsbook.cls
+++ b/articles/sty/review-jsbook.cls
@@ -384,13 +384,14 @@
%% load hyperref package
\PassOptionsToPackage{hyphens}{url} % URLにハイフンが含まれる場合でも改行されるようにする
+\PassOptionsToPackage{dvipdfmx}{pxjahyper}
\RequirePackage[dvipdfmx, \if@pdfhyperlink\else draft,\fi
bookmarks=true,
bookmarksnumbered=true,
hidelinks,
setpagesize=false,
]{hyperref}
-\RequirePackage[dvipdfmx]{pxjahyper}
+\RequirePackage{pxjahyper}
%% more useful macros
%% ----------
diff --git a/articles/sty/techbooster-doujin-base.sty b/articles/sty/techbooster-doujin-base.sty
index cb30137..1cd4cc9 100644
--- a/articles/sty/techbooster-doujin-base.sty
+++ b/articles/sty/techbooster-doujin-base.sty
@@ -60,7 +60,7 @@
\renewcommand{\captionsize}{\fontsize{9}{9}\selectfont}
\newlength{\captionnumwidth}
\setlength{\captionnumwidth}{6zw}
-%\newlength{\captionwidth}
+\@ifundefined{captionwidth}{\newlength{\captionwidth}}{}
\setlength{\captionwidth}{\textwidth}
\addtolength{\captionwidth}{-\captionnumwidth}
\def\captionhead{\sffamily{\color{black!30!white}{▲}}}
diff --git a/articles/style-web.css b/articles/style-web.css
index 6b3dee6..27d2e22 100644
--- a/articles/style-web.css
+++ b/articles/style-web.css
@@ -1,45 +1,236 @@
-/* stylesheet for Re:VIEW web */
+/* stylesheet for Re:VIEW web — minimal modern */
+
+/* --- Sidebar --- */
nav.side-content {
- width: 200px;
- position: fixed; }
+ width: 260px;
+ position: fixed;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ overflow-y: auto;
+ padding: 24px 20px;
+ background: #f8f9fa;
+ border-right: 1px solid #e5e7eb; }
+ nav.side-content .side-header {
+ margin-bottom: 16px;
+ padding-bottom: 16px;
+ border-bottom: 1px solid #e5e7eb; }
+ nav.side-content .side-cover {
+ display: block;
+ width: 100%;
+ max-width: 180px;
+ height: auto;
+ margin: 0 auto 12px;
+ border-radius: 4px;
+ box-shadow: 0 1px 4px rgba(0,0,0,0.12); }
nav.side-content h1.side-title {
margin: 0;
padding: 0;
- font-size: 1em;
- border-top: none; }
+ font-size: 0.98em;
+ font-weight: 700;
+ line-height: 1.4;
+ color: #374151;
+ text-align: center;
+ border-top: none;
+ border-bottom: none; }
nav.side-content ul {
- list-style: none; }
+ list-style: none;
+ padding-left: 0;
+ margin: 0; }
+ nav.side-content ul li {
+ margin-bottom: 2px; }
+ nav.side-content ul li a {
+ display: block;
+ padding: 6px 10px;
+ font-size: 0.82em;
+ color: #374151;
+ text-decoration: none;
+ border-radius: 6px;
+ line-height: 1.4;
+ transition: background 0.15s; }
+ nav.side-content ul li a:hover {
+ background: #e5e7eb;
+ color: #111; }
+ nav.side-content .review-signature {
+ margin-top: 24px;
+ padding-top: 16px;
+ border-top: 1px solid #e5e7eb;
+ font-size: 0.75em;
+ color: #9ca3af; }
+ nav.side-content .review-signature a {
+ color: #6b7280; }
+
+/* --- Language Toggle --- */
+.lang-toggle {
+ display: flex;
+ gap: 0;
+ margin-bottom: 12px;
+ border: 1px solid #d1d5db;
+ border-radius: 6px;
+ overflow: hidden; }
+
+.lang-btn {
+ flex: 1;
+ display: block;
+ padding: 6px 0;
+ text-align: center;
+ font-size: 0.78em;
+ font-weight: 500;
+ color: #6b7280;
+ text-decoration: none;
+ background: #fff;
+ transition: background 0.15s, color 0.15s; }
+.lang-btn:hover {
+ background: #f3f4f6;
+ text-decoration: none; }
+.lang-btn.lang-active {
+ background: #1f2937;
+ color: #fff; }
+.lang-btn + .lang-btn {
+ border-left: 1px solid #d1d5db; }
+
+/* --- Search --- */
+.search-box {
+ position: relative;
+ margin-bottom: 16px; }
+
+#search-input {
+ width: 100%;
+ padding: 8px 12px;
+ font-size: 0.85em;
+ border: 1px solid #d1d5db;
+ border-radius: 6px;
+ background: #fff;
+ color: #1f2937;
+ outline: none;
+ box-sizing: border-box;
+ transition: border-color 0.15s; }
+#search-input:focus {
+ border-color: #9ca3af; }
+#search-input::placeholder {
+ color: #9ca3af; }
+
+.search-results {
+ display: none;
+ position: absolute;
+ top: 100%;
+ left: 0;
+ right: 0;
+ margin-top: 4px;
+ background: #fff;
+ border: 1px solid #e5e7eb;
+ border-radius: 8px;
+ box-shadow: 0 4px 16px rgba(0,0,0,0.10);
+ max-height: 400px;
+ overflow-y: auto;
+ z-index: 100; }
+
+.search-result {
+ display: block;
+ padding: 10px 14px;
+ text-decoration: none;
+ border-bottom: 1px solid #f3f4f6;
+ transition: background 0.1s; }
+.search-result:last-child {
+ border-bottom: none; }
+.search-result:hover {
+ background: #f8f9fa;
+ text-decoration: none; }
+
+.search-result-title {
+ font-size: 0.82em;
+ font-weight: 600;
+ color: #1f2937;
+ margin-bottom: 2px; }
+.search-result-snippet {
+ font-size: 0.75em;
+ color: #6b7280;
+ line-height: 1.5; }
+ .search-result-snippet mark {
+ background: #fef08a;
+ color: #1f2937;
+ padding: 0 1px;
+ border-radius: 2px; }
+
+.search-empty {
+ padding: 12px 14px;
+ font-size: 0.8em;
+ color: #9ca3af;
+ text-align: center; }
+
+/* --- TOC active & sub-sections --- */
+.book-toc > li.toc-active > a {
+ background: #e5e7eb;
+ color: #111;
+ font-weight: 600; }
+
+ul.toc-sub {
+ padding-left: 0;
+ margin: 2px 0 4px 0; }
+ ul.toc-sub li {
+ margin-bottom: 0; }
+ ul.toc-sub li a {
+ padding: 4px 10px 4px 20px;
+ font-size: 0.78em;
+ color: #6b7280; }
+ ul.toc-sub li a:hover {
+ background: #e5e7eb;
+ color: #374151; }
+ ul.toc-sub li.toc-sub-active a {
+ color: #111;
+ font-weight: 600; }
+
+/* --- Body --- */
.book-body {
- margin-left: 240px;
- margin-right: 40px;
+ margin-left: 300px;
+ margin-right: 0;
position: relative; }
.book-body .book-page {
- max-width: 800px;
+ max-width: 760px;
margin: 0 auto;
- padding: 32px 0 32px; }
+ padding: 48px 40px 64px; }
.book-body .book-navi {
position: fixed;
top: 0;
- min-width: 40px; }
+ min-width: 36px; }
.book-body .book-navi a {
text-decoration: none; }
.book-body .book-prev {
- left: 210px; }
+ left: 266px; }
.book-body .book-next {
- right: 10px; }
+ right: 4px; }
.book-body .book-cursor {
height: 100vh;
text-align: center;
- font-size: 32pt;
+ font-size: 24pt;
padding: 100px 0 0 0;
- color: #eee; }
+ color: #d1d5db; }
.book-body .book-cursor:hover {
- color: #333; }
+ color: #6b7280; }
+/* --- Footer --- */
footer p {
- margin-left: 240px;
- text-align: center; }
+ margin-left: 300px;
+ padding: 24px 40px;
+ text-align: center;
+ font-size: 0.8em;
+ color: #9ca3af; }
.cover-image img {
max-width: 100%; }
+
+/* --- Responsive --- */
+@media (max-width: 768px) {
+ nav.side-content {
+ position: relative;
+ width: 100%;
+ border-right: none;
+ border-bottom: 1px solid #e5e7eb; }
+ .book-body {
+ margin-left: 0; }
+ .book-body .book-prev {
+ left: 0; }
+ footer p {
+ margin-left: 0; }
+}
diff --git a/articles/style.css b/articles/style.css
index 50fcd44..4bcaad4 100644
--- a/articles/style.css
+++ b/articles/style.css
@@ -1,37 +1,60 @@
@charset "UTF-8";
+
body {
- font-family: "Helvetica Neue", Helvetica, Arial, Meiryo, "メイリオ", sans-serif;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, "Hiragino Sans", "Hiragino Kaku Gothic ProN", Meiryo, sans-serif;
font-size: 16px;
- line-height: 1.5; }
+ line-height: 1.8;
+ color: #1f2937;
+ background: #fff; }
+
+h1 {
+ font-size: 1.75em;
+ font-weight: 700;
+ letter-spacing: -0.01em; }
h2 {
- border-bottom: 1px solid #ccc; }
+ font-size: 1.4em;
+ font-weight: 700;
+ padding-bottom: 8px;
+ border-bottom: 2px solid #e5e7eb; }
+
+h3 {
+ font-size: 1.15em;
+ font-weight: 600; }
h1, h2, h3 {
- margin-top: 28px;
- margin-bottom: 14px; }
+ margin-top: 36px;
+ margin-bottom: 16px;
+ color: #111827; }
h4, h5, h6 {
- margin-top: 14px;
- margin-bottom: 14px; }
+ margin-top: 20px;
+ margin-bottom: 12px;
+ font-weight: 600; }
h4:before, h5:before, h6:before {
- content: "■"; }
+ content: "■";
+ margin-right: 0.3em;
+ color: #9ca3af; }
p {
- margin-bottom: 12px; }
+ margin-bottom: 16px; }
dd {
margin-left: 3em; }
+/* --- Column / Callout --- */
div.column {
- border: solid 3px;
- border-radius: 10px;
- padding-left: 1em;
- padding-right: 1em;
- margin-top: 12px;
- margin-bottom: 12px; }
+ border: 1px solid #e5e7eb;
+ border-left: 4px solid #6b7280;
+ border-radius: 6px;
+ padding: 16px 20px;
+ margin-top: 16px;
+ margin-bottom: 16px;
+ background: #f9fafb; }
div.column h1, div.column h2, div.column h3 {
- margin-top: 14px; }
+ margin-top: 8px;
+ border-bottom: none;
+ font-size: 1em; }
div.lead {
font-size: 16px;
@@ -40,12 +63,18 @@ div.lead {
p.flushright {
text-align: right; }
+/* --- Footnotes --- */
.noteref {
vertical-align: super;
- font-size: smaller; }
+ font-size: smaller;
+ color: #2563eb;
+ text-decoration: none; }
p.footnote {
- font-size: 0.8em; }
+ font-size: 0.85em;
+ color: #4b5563;
+ padding-top: 8px;
+ border-top: 1px solid #f3f4f6; }
.main-content {
display: flex;
@@ -54,23 +83,28 @@ p.footnote {
div.footnote {
order: 1; }
+/* --- Images / Tables / Code --- */
.image, .table, .caption-code, .cmd-code {
- margin-bottom: 12px; }
+ margin-bottom: 20px; }
.image .caption, .table .caption {
- text-align: center; }
+ text-align: center;
+ font-size: 0.9em;
+ color: #4b5563; }
.image .caption {
- margin-top: 0; }
+ margin-top: 4px; }
.image .caption:before {
content: "▲";
- color: lightgray; }
+ margin-right: 0.3em;
+ color: #d1d5db; }
.caption-code .caption, .table .caption {
- margin-bottom: 0; }
+ margin-bottom: 4px; }
.caption-code .caption:before, .table .caption:before {
content: "▼";
- color: lightgray; }
+ margin-right: 0.3em;
+ color: #d1d5db; }
.image img {
display: block;
@@ -79,6 +113,54 @@ div.footnote {
margin-left: auto;
margin-right: auto; }
+/* --- Code blocks --- */
+pre, code {
+ font-family: "SF Mono", "Menlo", "Consolas", monospace;
+ font-size: 0.9em; }
+
+pre {
+ background: #f8f9fa;
+ border: 1px solid #e5e7eb;
+ border-radius: 6px;
+ padding: 16px;
+ overflow-x: auto;
+ line-height: 1.6; }
+
+code {
+ background: #f3f4f6;
+ padding: 0.15em 0.4em;
+ border-radius: 4px; }
+
+pre code {
+ background: none;
+ padding: 0;
+ border-radius: 0; }
+
+/* --- Tables --- */
+table {
+ border-collapse: collapse;
+ width: 100%;
+ margin-bottom: 16px;
+ font-size: 0.95em; }
+
+th, td {
+ border: 1px solid #e5e7eb;
+ padding: 8px 12px;
+ text-align: left; }
+
+th {
+ background: #f8f9fa;
+ font-weight: 600; }
+
+/* --- Links --- */
+a {
+ color: #2563eb;
+ text-decoration: none; }
+
+a:hover {
+ text-decoration: underline; }
+
+/* --- Legacy header/footer (TechBooster) --- */
.tb-header {
background-image: url(images/html_header.jpg);
background-size: cover;
diff --git a/build-in-docker-epub.sh b/build-in-docker-epub.sh
index 565709d..62c80ed 100755
--- a/build-in-docker-epub.sh
+++ b/build-in-docker-epub.sh
@@ -3,7 +3,14 @@
[ ! -z $REVIEW_CONFIG_FILE ] || REVIEW_CONFIG_FILE="config-epub-$REVIEW_LANG.yml"
# コマンド手打ちで作業したい時は以下の通り /book に pwd がマウントされます
-# docker run -i -t -v $(pwd):/book vvakame/review:5.1 /bin/bash
+# docker run -i -t -v $(pwd):/book review-custom:5.11 /bin/bash
+
+DOCKER_IMAGE="review-custom:5.11"
+
+# イメージが存在しない場合はビルドする
+if ! docker image inspect "$DOCKER_IMAGE" > /dev/null 2>&1; then
+ docker build -t "$DOCKER_IMAGE" .
+fi
# reduce size of images
REVIEW_IMAGE_DIR="articles/images_$REVIEW_LANG"
@@ -12,7 +19,7 @@ IMAGEMAGICK_COMMAND='mogrify -format jpg -quality 75 -background white -alpha re
docker run -v $(pwd)/articles:/articles --entrypoint=sh dpokidov/imagemagick:7.1.0-47-buster -c "${FIND_COMMAND} | xargs ${IMAGEMAGICK_COMMAND}"
eval "${FIND_COMMAND}" | xargs rm -fr # PNGファイルを一時的に削除
-docker run -t --rm -v $(pwd):/book -v $(pwd)/articles/fonts/bizud:/usr/share/fonts/truetype/bizud vvakame/review:5.1 /bin/bash -ci "cd /book && ./setup.sh && REVIEW_CONFIG_FILE=$REVIEW_CONFIG_FILE npm run pdf"
+docker run -t --rm -v $(pwd):/book -v $(pwd)/articles/fonts/bizud:/usr/share/fonts/truetype/bizud "$DOCKER_IMAGE" /bin/bash -ci "cd /book && npm install --ignore-scripts && bundle install && gem pristine --all && REVIEW_CONFIG_FILE=$REVIEW_CONFIG_FILE npm run pdf"
# restore images
git restore "$REVIEW_IMAGE_DIR"
diff --git a/build-in-docker.sh b/build-in-docker.sh
index c7554c2..e3cecc3 100755
--- a/build-in-docker.sh
+++ b/build-in-docker.sh
@@ -3,6 +3,13 @@
[ ! -z $REVIEW_CONFIG_FILE ] || REVIEW_CONFIG_FILE="config-$REVIEW_LANG.yml"
# コマンド手打ちで作業したい時は以下の通り /book に pwd がマウントされます
-# docker run -i -t -v $(pwd):/book vvakame/review:5.1 /bin/bash
+# docker run -i -t -v $(pwd):/book review-custom:5.11 /bin/bash
-docker run -t --rm -v $(pwd):/book -v $(pwd)/articles/fonts/bizud:/usr/share/fonts/truetype/bizud vvakame/review:5.1 /bin/bash -ci "cd /book && ./setup.sh && REVIEW_CONFIG_FILE=$REVIEW_CONFIG_FILE npm run pdf"
\ No newline at end of file
+DOCKER_IMAGE="review-custom:5.11"
+
+# イメージが存在しない場合はビルドする
+if ! docker image inspect "$DOCKER_IMAGE" > /dev/null 2>&1; then
+ docker build -t "$DOCKER_IMAGE" .
+fi
+
+docker run -t --rm -v $(pwd):/book -v $(pwd)/articles/fonts/bizud:/usr/share/fonts/truetype/bizud "$DOCKER_IMAGE" /bin/bash -ci "cd /book && npm install --ignore-scripts && bundle install && gem pristine --all && REVIEW_CONFIG_FILE=$REVIEW_CONFIG_FILE npm run pdf"
diff --git a/build-search-index.js b/build-search-index.js
new file mode 100644
index 0000000..d81977b
--- /dev/null
+++ b/build-search-index.js
@@ -0,0 +1,70 @@
+"use strict";
+
+// Generates search-index.json from webroot HTML files.
+// Usage: node build-search-index.js [directory]
+
+const fs = require("fs");
+const path = require("path");
+
+const webroot = process.argv[2] || path.join(__dirname, "articles", "webroot");
+const outFile = path.join(webroot, "search-index.json");
+
+const htmlFiles = fs.readdirSync(webroot).filter(f => f.endsWith(".html") && f !== "index.html" && f !== "titlepage.html").sort();
+
+const index = [];
+
+htmlFiles.forEach(file => {
+ const html = fs.readFileSync(path.join(webroot, file), "utf8");
+
+ // Extract page title from
+ const titleMatch = html.match(/([^|]*)/);
+ const pageTitle = titleMatch ? titleMatch[1].trim() : file;
+
+ // Extract sections: split by h1/h2 headings
+ const sectionRegex = /]*id="([^"]*)"[^>]*>([\s\S]*?)<\/h[12]>/g;
+ const sections = [];
+ let match;
+ const headings = [];
+
+ while ((match = sectionRegex.exec(html)) !== null) {
+ headings.push({ id: match[1], pos: match.index, title: stripTags(match[2]) });
+ }
+
+ if (headings.length === 0) {
+ // No headings — index the whole page
+ const bodyMatch = html.match(/([\s\S]*?)<\/div>\s*