diff --git a/.agents/skills/ofb/SKILL.md b/.agents/skills/ofb/SKILL.md
index 75e2cf3..26150a0 100644
--- a/.agents/skills/ofb/SKILL.md
+++ b/.agents/skills/ofb/SKILL.md
@@ -33,6 +33,7 @@ frontend/ → catalog-app, Mermaid map view, site chrome
```bash
bun install
bun run build:js # after frontend/ changes
+bun run generate:site-metadata # regenerate static/sitemap.xml + static/feed.xml from content + git log
bun run index:search # once per dev session (copies public/pagefind → static/pagefind)
zola serve # http://127.0.0.1:1111
bun run build # production: JS + Zola + pagefind → public/
@@ -59,10 +60,20 @@ bun run build # production: JS + Zola + pagefind → public/
| New subject (sidebar) | `data/catalog.json` → `subjects[]`; `data/{id}-curriculum.json` (catalog template auto-loads by `id`) |
| New chapter (planned) | `{subject}-curriculum.json` strand entry; optional `graph.edges` |
| New chapter (live) | Above + `content/{subject}/{slug}/_index.md` + HTML partial + `chapter.html` wiring + `main.tsx` widget mount if interactive |
+| Sitemap/feed coverage | `scripts/generate-site-metadata.ts`, `static/sitemap.xml`, `static/feed.xml`, `package.json` build script |
| Homepage subject card | `themes/.../templates/index.html` (not yet data-driven) |
| Browse / nav URL | `zola.toml` `[extra] browse_url` + `base.html` site-config + `main.tsx` fallback |
| Search UI / index | `base.html` (Pagefind assets + modal), `content/search.md`, `_search.scss`; `bun run index:search` for dev |
+## Sitemap / feed auto-generation
+
+- `bun run generate:site-metadata` generates:
+ - `static/sitemap.xml` (all user-facing built pages + live chapters)
+ - `static/feed.xml` (RSS 2.0 for pages + live chapters)
+- URL timestamps come from `git log -1` on content-source paths. If a page/chapter has no commit yet, generator falls back to filesystem mtime.
+- Build pipeline runs this automatically before `zola build` via `package.json`.
+- Include only resolvable pages; planned chapters without live content are excluded.
+
## Search (Pagefind)
| Piece | Path |
diff --git a/content/math/laws-of-indices/_index.md b/content/math/laws-of-indices/_index.md
new file mode 100644
index 0000000..8ac17da
--- /dev/null
+++ b/content/math/laws-of-indices/_index.md
@@ -0,0 +1,234 @@
++++
+title = "Laws of indices"
+description = "Apply index laws to simplify expressions with integer and rational exponents."
+template = "chapter.html"
+weight = 12
+[extra]
+subject = "math"
+chapter_id = "laws-of-indices"
+strand = "Number & Algebra"
++++
+
+
+
+ An index (or exponent) tells you how many times a base is used as a factor.
+ For example, \(2^4 = 2 \times 2 \times 2 \times 2\). The laws of indices let us simplify
+ expressions efficiently and work with very large or very small quantities.
+
+ Indices are central to scientific notation:
+ \[
+ N = k \times 10^n, \qquad 1 \leq k < 10.
+ \]
+ Positive \(n\) gives large numbers, negative \(n\) gives small decimals.
+
+
Example. \(0.00045 = 4.5 \times 10^{-4}\).
+
+
+
+
History
+
+ Exponent ideas developed over centuries as mathematicians searched for compact ways to write
+ repeated multiplication. Ancient methods used words or special marks, but a systematic notation
+ appeared gradually in early modern Europe.
+
+
+ In the 17th century, mathematicians such as Descartes helped popularize writing
+ powers with superscripts (like \(x^2\), \(x^3\)). Later work by
+ Wallis and Newton expanded these ideas to fractional and
+ negative exponents. This notation became a foundation for algebra, calculus, and scientific
+ notation.
+
+
+
Derivation
+
+
Why \(a^0 = 1\)
+
+ Using the quotient law with \(a \neq 0\):
+ \[
+ \frac{a^m}{a^m} = a^{m-m} = a^0.
+ \]
+ But \(\dfrac{a^m}{a^m} = 1\), so \(a^0 = 1\).
+
+
+
Why \(a^{-n} = \dfrac{1}{a^n}\)
+
+ Again from the quotient law:
+ \[
+ \frac{a^0}{a^n} = a^{0-n} = a^{-n}.
+ \]
+ Since \(a^0 = 1\), we get \(a^{-n} = \dfrac{1}{a^n}\).
+
+
+
Why \(a^{1/n} = \sqrt[n]{a}\)
+
+ Let \(a^{1/n} = t\). Raise both sides to power \(n\):
+ \[
+ (a^{1/n})^n = t^n \Rightarrow a = t^n.
+ \]
+ So \(t\) is an \(n\)-th root of \(a\), giving \(a^{1/n} = \sqrt[n]{a}\).
+
+
+
Checkpoints
+
+ Pause and reason before continuing. Discuss with a classmate or write your reasoning in a
+ notebook.
+
+
+
+
Checkpoint
+
+ Simplify \(x^7 \div x^3\). Which index law did you use?
+
+
+
+
+
Checkpoint
+
+ Rewrite \(5^{-2}\) without a negative index, then evaluate.
+
+
+
+
+
Checkpoint
+
+ Simplify \((a^2b^{-1})^3\) and express your final answer with positive indices.
+
+
+
+
+
Checkpoint
+
+ Evaluate \(81^{3/4}\) by rewriting it in root form first.
+
+
+
+
+
Checkpoint
+
+ Write \(0.000072\) in scientific notation and state the index on 10.
+
+
+
+
+
Checkpoint
+
+ True or false: \((x+y)^2 = x^2 + y^2\). Explain why index laws do or do not support this.
+
+
+
+
Applications
+
+
Large and small scales in science
+
+ Distances in astronomy and sizes of atoms are usually written with powers of 10 to keep numbers
+ readable. For example, \(1.5 \times 10^{11}\) m is much clearer than writing 150000000000 m.
+
+
+
Repeated growth and decay
+
+ Compound growth often uses exponent models such as
+ \(A = P(1+r)^t\). Exponents track repeated multiplication over equal time steps.
+
+
+
Computing and binary powers
+
+ Computer memory sizes are tied to powers of 2. For example, \(2^{10} = 1024\), so one kibibyte
+ is 1024 bytes and larger units build from further powers.
+
+
+
References
+
+
Descartes, R. (1637). La Geometrie (superscript power notation usage).
+
Wallis, J. (1685). A Treatise of Algebra (negative and fractional powers).
+
Secondary school algebra curricula on laws of indices and scientific notation.
+
+
diff --git a/content/math/laws-of-indices/core.html b/content/math/laws-of-indices/core.html
new file mode 100644
index 0000000..68a0d76
--- /dev/null
+++ b/content/math/laws-of-indices/core.html
@@ -0,0 +1,103 @@
+
+
+ An index (or exponent) tells you how many times a base is used as a factor.
+ For example, \(2^4 = 2 \times 2 \times 2 \times 2\). The laws of indices let us simplify
+ expressions efficiently and work with very large or very small quantities.
+
+ Indices are central to scientific notation:
+ \[
+ N = k \times 10^n, \qquad 1 \leq k < 10.
+ \]
+ Positive \(n\) gives large numbers, negative \(n\) gives small decimals.
+
+
Example. \(0.00045 = 4.5 \times 10^{-4}\).
+
diff --git a/content/math/laws-of-indices/supplement.html b/content/math/laws-of-indices/supplement.html
new file mode 100644
index 0000000..657e607
--- /dev/null
+++ b/content/math/laws-of-indices/supplement.html
@@ -0,0 +1,119 @@
+
+
History
+
+ Exponent ideas developed over centuries as mathematicians searched for compact ways to write
+ repeated multiplication. Ancient methods used words or special marks, but a systematic notation
+ appeared gradually in early modern Europe.
+
+
+ In the 17th century, mathematicians such as Descartes helped popularize writing
+ powers with superscripts (like \(x^2\), \(x^3\)). Later work by
+ Wallis and Newton expanded these ideas to fractional and
+ negative exponents. This notation became a foundation for algebra, calculus, and scientific
+ notation.
+
+
+
Derivation
+
+
Why \(a^0 = 1\)
+
+ Using the quotient law with \(a \neq 0\):
+ \[
+ \frac{a^m}{a^m} = a^{m-m} = a^0.
+ \]
+ But \(\dfrac{a^m}{a^m} = 1\), so \(a^0 = 1\).
+
+
+
Why \(a^{-n} = \dfrac{1}{a^n}\)
+
+ Again from the quotient law:
+ \[
+ \frac{a^0}{a^n} = a^{0-n} = a^{-n}.
+ \]
+ Since \(a^0 = 1\), we get \(a^{-n} = \dfrac{1}{a^n}\).
+
+
+
Why \(a^{1/n} = \sqrt[n]{a}\)
+
+ Let \(a^{1/n} = t\). Raise both sides to power \(n\):
+ \[
+ (a^{1/n})^n = t^n \Rightarrow a = t^n.
+ \]
+ So \(t\) is an \(n\)-th root of \(a\), giving \(a^{1/n} = \sqrt[n]{a}\).
+
+
+
Checkpoints
+
+ Pause and reason before continuing. Discuss with a classmate or write your reasoning in a
+ notebook.
+
+
+
+
Checkpoint
+
+ Simplify \(x^7 \div x^3\). Which index law did you use?
+
+
+
+
+
Checkpoint
+
+ Rewrite \(5^{-2}\) without a negative index, then evaluate.
+
+
+
+
+
Checkpoint
+
+ Simplify \((a^2b^{-1})^3\) and express your final answer with positive indices.
+
+
+
+
+
Checkpoint
+
+ Evaluate \(81^{3/4}\) by rewriting it in root form first.
+
+
+
+
+
Checkpoint
+
+ Write \(0.000072\) in scientific notation and state the index on 10.
+
+
+
+
+
Checkpoint
+
+ True or false: \((x+y)^2 = x^2 + y^2\). Explain why index laws do or do not support this.
+
+
+
+
Applications
+
+
Large and small scales in science
+
+ Distances in astronomy and sizes of atoms are usually written with powers of 10 to keep numbers
+ readable. For example, \(1.5 \times 10^{11}\) m is much clearer than writing 150000000000 m.
+
+
+
Repeated growth and decay
+
+ Compound growth often uses exponent models such as
+ \(A = P(1+r)^t\). Exponents track repeated multiplication over equal time steps.
+
+
+
Computing and binary powers
+
+ Computer memory sizes are tied to powers of 2. For example, \(2^{10} = 1024\), so one kibibyte
+ is 1024 bytes and larger units build from further powers.
+
+
+
References
+
+
Descartes, R. (1637). La Geometrie (superscript power notation usage).
+
Wallis, J. (1685). A Treatise of Algebra (negative and fractional powers).
+
Secondary school algebra curricula on laws of indices and scientific notation.
+ A linear inequality in one unknown compares a linear expression with a value or
+ another linear expression using symbols such as
+ \(<\), \(>\), \(\leq\), and \(\geq\). Instead of finding one exact value, we usually find a
+ set of values that make the statement true.
+
+
+
Inequality symbols
+
+
+
+
+
Symbol
+
Meaning
+
Example
+
+
+
+
+
\(<\)
+
Strictly less than
+
\(x < 4\)
+
+
+
\(>\)
+
Strictly greater than
+
\(x > -2\)
+
+
+
\(\leq\)
+
Less than or equal to
+
\(x \leq 7\)
+
+
+
\(\geq\)
+
Greater than or equal to
+
\(x \geq 0\)
+
+
+
+
+
+
Solving linear inequalities
+
+ Solve them much like linear equations: expand brackets when needed, collect like terms, then
+ isolate the variable.
+
+
Example. Solve \(3x - 5 \leq 16\).
+
+ Add \(5\) to both sides: \(3x \leq 21\). Divide by \(3\): \(x \leq 7\).
+
+
+
The sign-flip rule
+
+ When you multiply or divide both sides of an inequality by a negative number, the
+ inequality sign must reverse.
+
+
+
Key rule
+
+ \[
+ a < b \;\Rightarrow\; -a > -b
+ \]
+
+
+
Example. Solve \(-2x + 1 > 9\).
+
+ Subtract \(1\): \(-2x > 8\). Divide by \(-2\), so reverse \(>\) to \(<\):
+ \(x < -4\).
+
+
+
Number line and interval notation
+
+ The solution set can be written on a number line or in interval notation:
+
+
+
+
+
+
Inequality
+
Interval notation
+
Endpoint style
+
+
+
+
+
\(x < 3\)
+
\((-\infty, 3)\)
+
Open at \(3\)
+
+
+
\(x \leq 3\)
+
\((-\infty, 3]\)
+
Closed at \(3\)
+
+
+
\(x > -1\)
+
\((-1, \infty)\)
+
Open at \(-1\)
+
+
+
\(x \geq -1\)
+
\([-1, \infty)\)
+
Closed at \(-1\)
+
+
+
+
+
+
Compound inequalities
+
+ Compound inequalities combine two inequalities with and or or.
+
+
Example (and). Solve \(2 < x + 1 \leq 6\).
+
+ Subtract \(1\) from all three parts:
+ \(1 < x \leq 5\). Interval form: \((1, 5]\).
+
+
Example (or). Solve \(x - 4 < -7\) or \(3x + 1 \geq 10\).
+ Translate the condition into an inequality, solve, and interpret the answer in context.
+
+
Example. A bus ticket costs \$12. You have at most \$90. How many tickets \(n\)
+ can you buy?
+
+ \(12n \leq 90 \Rightarrow n \leq 7.5\). Since \(n\) is a whole number of tickets, the maximum is
+ \(7\).
+
+
+
+
+
History
+
+ Ideas behind inequalities are ancient. Greek mathematicians such as Euclid
+ compared magnitudes and proved strict order relationships long before modern algebraic symbols.
+ In India and the Islamic world, later algebra texts worked with bounds and constraints in
+ practical problems involving trade, measurement, and inheritance.
+
+
+ The modern symbols \(<\) and \(>\) were introduced in the 17th century by
+ Thomas Harriot. As algebra notation became standard, inequalities became a
+ compact way to describe ranges of possible values rather than one exact answer.
+ Today, the same language appears in budgeting, engineering safety margins, and optimization.
+
+
+
Derivation
+
+
Why does the sign flip with a negative?
+
+ Suppose \(a < b\). Subtract \(b\) from both sides to get \(a-b < 0\). Multiplying by \(-1\)
+ gives \(b-a > 0\), so \(-a > -b\). Geometrically, multiplying by \(-1\) reflects numbers
+ across zero on the number line, reversing left-right order. That is why dividing by a negative
+ must reverse \(<\) to \(>\) (and \(\leq\) to \(\geq\)).
+
+
+
Why intervals match inequality endpoints
+
+ A strict inequality such as \(x < c\) excludes \(c\), so interval notation uses an open bracket
+ \((\cdot)\). A non-strict inequality such as \(x \leq c\) includes \(c\), so interval notation
+ uses a closed bracket \([\cdot]\). This bracket choice encodes exactly the same endpoint logic
+ as open and closed dots on number lines.
+
+
+
Checkpoints
+
+ Pause and reason before moving on. Discuss with a classmate or write your reasoning in a
+ notebook.
+
+
+
+
Checkpoint
+
+ Solve \(4x - 3 < 9\). Write your answer as both an inequality and an interval.
+
+
+
+
+
Checkpoint
+
+ Solve \(-5x \geq 20\). Which step forces a sign flip, and what is the final solution?
+
+
+
+
+
Checkpoint
+
+ Is \(x = -2\) a solution of both \(x \leq -2\) and \(x < -2\)? Explain the difference.
+
+
+
+
+
Checkpoint
+
+ Solve \(1 \leq 2x + 3 < 9\). Then write the solution in interval notation.
+
+
+
+
+
Checkpoint
+
+ Write and solve an inequality for this condition: "A package mass \(m\) must be at least
+ 0.5 kg and less than 2 kg."
+
+
+
+
+
Checkpoint
+
+ Solve and sketch on a number line: \(x-6 < -10\) or \(2x+1 \geq 7\).
+
+
+
+
Applications
+
+
Budgeting and spending limits
+
+ If each notebook costs \$3 and you have at most \$24, the purchase count \(n\) satisfies
+ \(3n \leq 24\). Inequalities model spending caps in daily life, event planning, and inventory
+ decisions.
+
+
+
Safety and engineering limits
+
+ Many systems run within allowable ranges, such as pressure \(P \leq P_{\max}\) or temperature
+ \(T_{\min} \leq T \leq T_{\max}\). Inequalities represent these safe operating regions directly.
+
+
+
Grades and performance thresholds
+
+ A pass condition might be "score at least 50," written as \(s \geq 50\). Scholarship criteria
+ and qualification cutoffs are also inequality-based rules.
+
+
+
References
+
+
Harriot, T. (1631). Artis analyticae praxis (early use of \(<\) and \(>\)).
+
Euclid, Elements, Book V (order and comparison of magnitudes).
+
Secondary school algebra curricula on linear inequalities and number-line representation.
+
+
diff --git a/content/math/linear-inequalities-one-unknown/core.html b/content/math/linear-inequalities-one-unknown/core.html
new file mode 100644
index 0000000..4d69cc0
--- /dev/null
+++ b/content/math/linear-inequalities-one-unknown/core.html
@@ -0,0 +1,136 @@
+
+
+ A linear inequality in one unknown compares a linear expression with a value or
+ another linear expression using symbols such as
+ \(<\), \(>\), \(\leq\), and \(\geq\). Instead of finding one exact value, we usually find a
+ set of values that make the statement true.
+
+
+
Inequality symbols
+
+
+
+
+
Symbol
+
Meaning
+
Example
+
+
+
+
+
\(<\)
+
Strictly less than
+
\(x < 4\)
+
+
+
\(>\)
+
Strictly greater than
+
\(x > -2\)
+
+
+
\(\leq\)
+
Less than or equal to
+
\(x \leq 7\)
+
+
+
\(\geq\)
+
Greater than or equal to
+
\(x \geq 0\)
+
+
+
+
+
+
Solving linear inequalities
+
+ Solve them much like linear equations: expand brackets when needed, collect like terms, then
+ isolate the variable.
+
+
Example. Solve \(3x - 5 \leq 16\).
+
+ Add \(5\) to both sides: \(3x \leq 21\). Divide by \(3\): \(x \leq 7\).
+
+
+
The sign-flip rule
+
+ When you multiply or divide both sides of an inequality by a negative number, the
+ inequality sign must reverse.
+
+
+
Key rule
+
+ \[
+ a < b \;\Rightarrow\; -a > -b
+ \]
+
+
+
Example. Solve \(-2x + 1 > 9\).
+
+ Subtract \(1\): \(-2x > 8\). Divide by \(-2\), so reverse \(>\) to \(<\):
+ \(x < -4\).
+
+
+
Number line and interval notation
+
+ The solution set can be written on a number line or in interval notation:
+
+
+
+
+
+
Inequality
+
Interval notation
+
Endpoint style
+
+
+
+
+
\(x < 3\)
+
\((-\infty, 3)\)
+
Open at \(3\)
+
+
+
\(x \leq 3\)
+
\((-\infty, 3]\)
+
Closed at \(3\)
+
+
+
\(x > -1\)
+
\((-1, \infty)\)
+
Open at \(-1\)
+
+
+
\(x \geq -1\)
+
\([-1, \infty)\)
+
Closed at \(-1\)
+
+
+
+
+
+
Compound inequalities
+
+ Compound inequalities combine two inequalities with and or or.
+
+
Example (and). Solve \(2 < x + 1 \leq 6\).
+
+ Subtract \(1\) from all three parts:
+ \(1 < x \leq 5\). Interval form: \((1, 5]\).
+
+
Example (or). Solve \(x - 4 < -7\) or \(3x + 1 \geq 10\).
+ Translate the condition into an inequality, solve, and interpret the answer in context.
+
+
Example. A bus ticket costs \$12. You have at most \$90. How many tickets \(n\)
+ can you buy?
+
+ \(12n \leq 90 \Rightarrow n \leq 7.5\). Since \(n\) is a whole number of tickets, the maximum is
+ \(7\).
+
+
diff --git a/content/math/linear-inequalities-one-unknown/supplement.html b/content/math/linear-inequalities-one-unknown/supplement.html
new file mode 100644
index 0000000..7d943cc
--- /dev/null
+++ b/content/math/linear-inequalities-one-unknown/supplement.html
@@ -0,0 +1,110 @@
+
+
History
+
+ Ideas behind inequalities are ancient. Greek mathematicians such as Euclid
+ compared magnitudes and proved strict order relationships long before modern algebraic symbols.
+ In India and the Islamic world, later algebra texts worked with bounds and constraints in
+ practical problems involving trade, measurement, and inheritance.
+
+
+ The modern symbols \(<\) and \(>\) were introduced in the 17th century by
+ Thomas Harriot. As algebra notation became standard, inequalities became a
+ compact way to describe ranges of possible values rather than one exact answer.
+ Today, the same language appears in budgeting, engineering safety margins, and optimization.
+
+
+
Derivation
+
+
Why does the sign flip with a negative?
+
+ Suppose \(a < b\). Subtract \(b\) from both sides to get \(a-b < 0\). Multiplying by \(-1\)
+ gives \(b-a > 0\), so \(-a > -b\). Geometrically, multiplying by \(-1\) reflects numbers
+ across zero on the number line, reversing left-right order. That is why dividing by a negative
+ must reverse \(<\) to \(>\) (and \(\leq\) to \(\geq\)).
+
+
+
Why intervals match inequality endpoints
+
+ A strict inequality such as \(x < c\) excludes \(c\), so interval notation uses an open bracket
+ \((\cdot)\). A non-strict inequality such as \(x \leq c\) includes \(c\), so interval notation
+ uses a closed bracket \([\cdot]\). This bracket choice encodes exactly the same endpoint logic
+ as open and closed dots on number lines.
+
+
+
Checkpoints
+
+ Pause and reason before moving on. Discuss with a classmate or write your reasoning in a
+ notebook.
+
+
+
+
Checkpoint
+
+ Solve \(4x - 3 < 9\). Write your answer as both an inequality and an interval.
+
+
+
+
+
Checkpoint
+
+ Solve \(-5x \geq 20\). Which step forces a sign flip, and what is the final solution?
+
+
+
+
+
Checkpoint
+
+ Is \(x = -2\) a solution of both \(x \leq -2\) and \(x < -2\)? Explain the difference.
+
+
+
+
+
Checkpoint
+
+ Solve \(1 \leq 2x + 3 < 9\). Then write the solution in interval notation.
+
+
+
+
+
Checkpoint
+
+ Write and solve an inequality for this condition: "A package mass \(m\) must be at least
+ 0.5 kg and less than 2 kg."
+
+
+
+
+
Checkpoint
+
+ Solve and sketch on a number line: \(x-6 < -10\) or \(2x+1 \geq 7\).
+
+
+
+
Applications
+
+
Budgeting and spending limits
+
+ If each notebook costs \$3 and you have at most \$24, the purchase count \(n\) satisfies
+ \(3n \leq 24\). Inequalities model spending caps in daily life, event planning, and inventory
+ decisions.
+
+
+
Safety and engineering limits
+
+ Many systems run within allowable ranges, such as pressure \(P \leq P_{\max}\) or temperature
+ \(T_{\min} \leq T \leq T_{\max}\). Inequalities represent these safe operating regions directly.
+
+
+
Grades and performance thresholds
+
+ A pass condition might be "score at least 50," written as \(s \geq 50\). Scholarship criteria
+ and qualification cutoffs are also inequality-based rules.
+
+
+
References
+
+
Harriot, T. (1631). Artis analyticae praxis (early use of \(<\) and \(>\)).
+
Euclid, Elements, Book V (order and comparison of magnitudes).
+
Secondary school algebra curricula on linear inequalities and number-line representation.
+
+
diff --git a/data/contributors.json b/data/contributors.json
index 7c0eec1..7559673 100644
--- a/data/contributors.json
+++ b/data/contributors.json
@@ -7,10 +7,9 @@
"job_title": "EdTech specialist / software engineer",
"education": "BEng in Computer Science (HKUST) + BBA (HKUST)",
"contributions": [
- "Founder",
+ "Project Creator",
"Math textbook",
- "Physics textbook",
- "Chemistry textbook"
+ "Physics textbook"
],
"socials": [
{ "type": "email", "url": "manlung.yeung@outlook.com" },
diff --git a/data/math-curriculum.json b/data/math-curriculum.json
index b8ba26c..2756df1 100644
--- a/data/math-curriculum.json
+++ b/data/math-curriculum.json
@@ -466,7 +466,7 @@
"slug": "linear-inequalities-one-unknown",
"title": "Linear inequalities in one unknown",
"description": "Solve and represent linear inequalities on the number line and in word problems.",
- "status": "planned",
+ "status": "live",
"curriculumCoverage": {
"DSE": "core",
"IGCSE": "core",
@@ -483,7 +483,7 @@
"slug": "laws-of-indices",
"title": "Laws of indices",
"description": "Apply index laws to simplify expressions with integer and rational exponents.",
- "status": "planned",
+ "status": "live",
"curriculumCoverage": {
"DSE": "core",
"IGCSE": "core",
diff --git a/package.json b/package.json
index 964df5f..3f19335 100644
--- a/package.json
+++ b/package.json
@@ -5,8 +5,9 @@
"type": "module",
"scripts": {
"sync:chapters": "bun scripts/sync-chapters.ts",
+ "generate:site-metadata": "bun scripts/generate-site-metadata.ts",
"build:js": "bun run sync:chapters && vite build",
- "build": "bun run check:static-html && bun run validate:curriculum && rm -f themes/openfreebooks/static/main.css public/main.css && bun run build:js && zola build && bun run index:search",
+ "build": "bun run check:static-html && bun run validate:curriculum && rm -f themes/openfreebooks/static/main.css public/main.css && bun run build:js && bun run generate:site-metadata && zola build && bun run index:search",
"index:search": "pagefind --site public --glob \"**/index.html\" && rm -rf static/pagefind && cp -R public/pagefind static/pagefind",
"dev:js": "bun run sync:chapters && vite build --watch",
"dev": "bash scripts/dev.sh",
diff --git a/scripts/generate-site-metadata.ts b/scripts/generate-site-metadata.ts
new file mode 100644
index 0000000..e8700bb
--- /dev/null
+++ b/scripts/generate-site-metadata.ts
@@ -0,0 +1,322 @@
+import { existsSync } from "node:fs";
+import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
+import { dirname, join, relative, sep } from "node:path";
+import { $ } from "bun";
+
+type FrontMatter = {
+ title?: string;
+ description?: string;
+ template?: string;
+};
+
+type CurriculumChapter = {
+ slug?: unknown;
+ status?: unknown;
+ title?: unknown;
+ description?: unknown;
+};
+
+type CurriculumStrand = {
+ chapters?: unknown;
+};
+
+type CurriculumFile = {
+ strands?: unknown;
+};
+
+type UrlEntry = {
+ urlPath: string;
+ title: string;
+ description: string;
+ sourcePaths: string[];
+ lastmod: Date | null;
+};
+
+const ROOT = join(import.meta.dir, "..");
+const CONTENT_DIR = join(ROOT, "content");
+const DATA_DIR = join(ROOT, "data");
+const STATIC_DIR = join(ROOT, "static");
+const SITEMAP_PATH = join(STATIC_DIR, "sitemap.xml");
+const FEED_PATH = join(STATIC_DIR, "feed.xml");
+
+function xmlEscape(value: string): string {
+ return value
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+}
+
+function isNonEmptyString(value: unknown): value is string {
+ return typeof value === "string" && value.trim().length > 0;
+}
+
+function toPosixPath(path: string): string {
+ return path.split(sep).join("/");
+}
+
+function normalizeBaseUrl(rawBaseUrl: string): string {
+ return rawBaseUrl.replace(/\/+$/, "");
+}
+
+function ensureTrailingSlash(path: string): string {
+ return path.endsWith("/") ? path : `${path}/`;
+}
+
+function parseFrontMatter(markdown: string): FrontMatter {
+ const match = markdown.match(/^\+\+\+([\s\S]*?)\+\+\+/);
+ if (!match) return {};
+ const block = match[1];
+
+ const readKey = (key: string): string | undefined => {
+ const lineMatch = block.match(new RegExp(`^\\s*${key}\\s*=\\s*"([^"]*)"\\s*$`, "m"));
+ return lineMatch?.[1]?.trim();
+ };
+
+ return {
+ title: readKey("title"),
+ description: readKey("description"),
+ template: readKey("template"),
+ };
+}
+
+async function readBaseUrlFromConfig(): Promise {
+ const zolaPath = join(ROOT, "zola.toml");
+ const raw = await readFile(zolaPath, "utf8");
+ const match = raw.match(/^\s*base_url\s*=\s*"([^"]+)"\s*$/m);
+ if (!match) {
+ throw new Error("Could not read base_url from zola.toml");
+ }
+ return normalizeBaseUrl(match[1]);
+}
+
+async function discoverMarkdownFiles(dir: string): Promise {
+ const output: string[] = [];
+ const entries = await readdir(dir, { withFileTypes: true });
+ for (const entry of entries) {
+ if (entry.name.startsWith(".")) continue;
+ const abs = join(dir, entry.name);
+ if (entry.isDirectory()) {
+ output.push(...(await discoverMarkdownFiles(abs)));
+ continue;
+ }
+ if (entry.isFile() && entry.name.endsWith(".md")) {
+ output.push(abs);
+ }
+ }
+ return output;
+}
+
+async function readLiveChapterKeys(): Promise> {
+ const keys = new Set();
+ const entries = await readdir(DATA_DIR, { withFileTypes: true });
+ for (const entry of entries) {
+ if (!entry.isFile() || !entry.name.endsWith("-curriculum.json")) continue;
+ const subject = entry.name.replace(/-curriculum\.json$/, "");
+ const jsonPath = join(DATA_DIR, entry.name);
+ const raw = await readFile(jsonPath, "utf8");
+ const data = JSON.parse(raw) as CurriculumFile;
+ if (!Array.isArray(data.strands)) continue;
+ for (const strand of data.strands as CurriculumStrand[]) {
+ if (!Array.isArray(strand.chapters)) continue;
+ for (const chapter of strand.chapters as CurriculumChapter[]) {
+ if (!isNonEmptyString(chapter.slug)) continue;
+ if (chapter.status !== "live") continue;
+ keys.add(`${subject}/${chapter.slug}`);
+ }
+ }
+ }
+ return keys;
+}
+
+async function readGitLastmod(sourcePaths: string[]): Promise {
+ const relPaths = sourcePaths
+ .map((sourcePath) => toPosixPath(relative(ROOT, sourcePath)))
+ .filter((rel) => rel.length > 0);
+
+ if (relPaths.length === 0) return null;
+ const proc = await $`git --no-pager log -1 --format=%cI -- ${relPaths}`.quiet();
+ if (proc.exitCode !== 0) return null;
+
+ const raw = proc.stdout.toString().trim();
+ if (!raw) return null;
+
+ const parsed = new Date(raw);
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
+}
+
+async function readFilesystemLastmod(sourcePaths: string[]): Promise {
+ let latestMs = 0;
+
+ const visit = async (path: string): Promise => {
+ const info = await stat(path);
+ if (info.isDirectory()) {
+ const entries = await readdir(path, { withFileTypes: true });
+ for (const entry of entries) {
+ if (entry.name.startsWith(".")) continue;
+ await visit(join(path, entry.name));
+ }
+ return;
+ }
+ if (info.mtimeMs > latestMs) {
+ latestMs = info.mtimeMs;
+ }
+ };
+
+ for (const sourcePath of sourcePaths) {
+ if (!existsSync(sourcePath)) continue;
+ await visit(sourcePath);
+ }
+
+ if (!latestMs) return null;
+ return new Date(latestMs);
+}
+
+function toUrlPathFromMarkdown(contentRelativePath: string): string {
+ const rel = toPosixPath(contentRelativePath);
+ if (rel === "_index.md") return "/";
+ if (rel.endsWith("/_index.md")) {
+ return ensureTrailingSlash(`/${rel.replace(/\/_index\.md$/, "")}`);
+ }
+ return ensureTrailingSlash(`/${rel.replace(/\.md$/, "")}`);
+}
+
+function isChapterIndex(contentRelativePath: string, fm: FrontMatter): boolean {
+ return contentRelativePath.endsWith("/_index.md") && fm.template === "chapter.html";
+}
+
+function chapterKeyFromContentPath(contentRelativePath: string): string | null {
+ const parts = toPosixPath(contentRelativePath).split("/");
+ if (parts.length !== 3 || parts[2] !== "_index.md") return null;
+ return `${parts[0]}/${parts[1]}`;
+}
+
+async function buildEntries(): Promise {
+ const markdownFiles = await discoverMarkdownFiles(CONTENT_DIR);
+ const liveChapterKeys = await readLiveChapterKeys();
+ const entries: UrlEntry[] = [];
+
+ for (const absMarkdownPath of markdownFiles) {
+ const relPath = toPosixPath(relative(CONTENT_DIR, absMarkdownPath));
+ const raw = await readFile(absMarkdownPath, "utf8");
+ const fm = parseFrontMatter(raw);
+
+ if (isChapterIndex(relPath, fm)) {
+ const key = chapterKeyFromContentPath(relPath);
+ if (!key || !liveChapterKeys.has(key)) continue;
+ const chapterDir = join(CONTENT_DIR, key);
+ const sourcePaths = [chapterDir];
+ const lastmod = (await readGitLastmod(sourcePaths)) ?? (await readFilesystemLastmod(sourcePaths));
+ entries.push({
+ urlPath: toUrlPathFromMarkdown(relPath),
+ title: fm.title ?? key.split("/")[1],
+ description: fm.description ?? fm.title ?? key.split("/")[1],
+ sourcePaths,
+ lastmod,
+ });
+ continue;
+ }
+
+ const urlPath = toUrlPathFromMarkdown(relPath);
+ if (urlPath === "/404/") continue;
+
+ const sourcePaths = [absMarkdownPath];
+ const lastmod = (await readGitLastmod(sourcePaths)) ?? (await readFilesystemLastmod(sourcePaths));
+ const fallbackTitle =
+ urlPath === "/"
+ ? "Open Free Books"
+ : urlPath
+ .split("/")
+ .filter(Boolean)
+ .at(-1)
+ ?.replaceAll("-", " ") ?? "Page";
+
+ entries.push({
+ urlPath,
+ title: fm.title ?? fallbackTitle,
+ description: fm.description ?? fm.title ?? fallbackTitle,
+ sourcePaths,
+ lastmod,
+ });
+ }
+
+ const dedup = new Map();
+ for (const entry of entries) {
+ if (!dedup.has(entry.urlPath)) {
+ dedup.set(entry.urlPath, entry);
+ }
+ }
+
+ const result = [...dedup.values()];
+ result.sort((a, b) => a.urlPath.localeCompare(b.urlPath));
+ return result;
+}
+
+function renderSitemap(baseUrl: string, entries: UrlEntry[]): string {
+ const lines = ['', ''];
+
+ for (const entry of entries) {
+ const loc = `${baseUrl}${entry.urlPath}`;
+ lines.push(" ");
+ lines.push(` ${xmlEscape(loc)}`);
+ if (entry.lastmod) {
+ lines.push(` ${entry.lastmod.toISOString()}`);
+ }
+ lines.push(" ");
+ }
+
+ lines.push("", "");
+ return lines.join("\n");
+}
+
+function renderFeed(baseUrl: string, entries: UrlEntry[]): string {
+ const sorted = [...entries].sort((a, b) => {
+ const aMs = a.lastmod?.getTime() ?? 0;
+ const bMs = b.lastmod?.getTime() ?? 0;
+ return bMs - aMs;
+ });
+ const latest = sorted.find((entry) => entry.lastmod)?.lastmod ?? new Date();
+
+ const lines = [
+ '',
+ '',
+ " ",
+ " Open Free Books",
+ ` ${xmlEscape(`${baseUrl}/`)}`,
+ " Recent updates to Open Free Books pages and live chapters.",
+ ` ${latest.toUTCString()}`,
+ ];
+
+ for (const entry of sorted) {
+ const link = `${baseUrl}${entry.urlPath}`;
+ const pubDate = (entry.lastmod ?? latest).toUTCString();
+ lines.push(" ");
+ lines.push(` ${xmlEscape(entry.title)}`);
+ lines.push(` ${xmlEscape(link)}`);
+ lines.push(` ${xmlEscape(link)}`);
+ lines.push(` ${xmlEscape(pubDate)}`);
+ lines.push(` ${xmlEscape(entry.description)}`);
+ lines.push(" ");
+ }
+
+ lines.push(" ", "", "");
+ return lines.join("\n");
+}
+
+async function main() {
+ const baseUrl = await readBaseUrlFromConfig();
+ const entries = await buildEntries();
+ await mkdir(dirname(SITEMAP_PATH), { recursive: true });
+ await writeFile(SITEMAP_PATH, renderSitemap(baseUrl, entries));
+ await writeFile(FEED_PATH, renderFeed(baseUrl, entries));
+
+ if (!existsSync(SITEMAP_PATH) || !existsSync(FEED_PATH)) {
+ throw new Error("Failed to generate sitemap/feed outputs");
+ }
+
+ console.log(`Generated ${toPosixPath(relative(ROOT, SITEMAP_PATH))} with ${entries.length} URL(s).`);
+ console.log(`Generated ${toPosixPath(relative(ROOT, FEED_PATH))} with ${entries.length} item(s).`);
+}
+
+main();
diff --git a/static/feed.xml b/static/feed.xml
new file mode 100644
index 0000000..040accf
--- /dev/null
+++ b/static/feed.xml
@@ -0,0 +1,177 @@
+
+
+
+ Open Free Books
+ https://openfreebooks.org/
+ Recent updates to Open Free Books pages and live chapters.
+ Thu, 21 May 2026 06:54:29 GMT
+
+ Laws of indices
+ https://openfreebooks.org/math/laws-of-indices/
+ https://openfreebooks.org/math/laws-of-indices/
+ Thu, 21 May 2026 06:54:29 GMT
+ Apply index laws to simplify expressions with integer and rational exponents.
+
+
+ Linear inequalities in one unknown
+ https://openfreebooks.org/math/linear-inequalities-one-unknown/
+ https://openfreebooks.org/math/linear-inequalities-one-unknown/
+ Thu, 21 May 2026 06:33:43 GMT
+ Solve, represent, and apply linear inequalities in one unknown using number lines and interval notation.
+
+
+ Counting and numbers
+ https://openfreebooks.org/math/counting-and-numbers/
+ https://openfreebooks.org/math/counting-and-numbers/
+ Thu, 21 May 2026 05:05:38 GMT
+ Count objects, spot patterns, add, multiply, and divide; then meet place value and big numbers from hundreds to billions.
+
+
+ Linear equations in two unknowns
+ https://openfreebooks.org/math/linear-equations-two-unknowns/
+ https://openfreebooks.org/math/linear-equations-two-unknowns/
+ Thu, 21 May 2026 05:05:38 GMT
+ Solve pairs of linear equations by substitution, elimination, and graphs; classify unique, parallel, and coincident cases.
+
+
+ Written calculation
+ https://openfreebooks.org/math/written-calculation/
+ https://openfreebooks.org/math/written-calculation/
+ Thu, 21 May 2026 05:05:38 GMT
+ Add, subtract, multiply, and divide whole numbers using vertical written form: align columns, regroup, and read the result.
+
+
+ English
+ https://openfreebooks.org/english/
+ https://openfreebooks.org/english/
+ Thu, 21 May 2026 02:10:55 GMT
+ Browse English chapters in the curriculum catalog.
+
+
+ Reading comprehension foundations
+ https://openfreebooks.org/english/reading-comprehension-foundations/
+ https://openfreebooks.org/english/reading-comprehension-foundations/
+ Thu, 21 May 2026 02:10:55 GMT
+ Find the main idea, key details, and evidence in short texts.
+
+
+ FAQ
+ https://openfreebooks.org/faq/
+ https://openfreebooks.org/faq/
+ Thu, 21 May 2026 02:10:55 GMT
+ FAQ
+
+
+ Physics
+ https://openfreebooks.org/physics/
+ https://openfreebooks.org/physics/
+ Thu, 21 May 2026 02:10:55 GMT
+ Browse physics chapters in the curriculum catalog.
+
+
+ Force and Newton's laws
+ https://openfreebooks.org/physics/force-newtons-laws/
+ https://openfreebooks.org/physics/force-newtons-laws/
+ Thu, 21 May 2026 02:10:55 GMT
+ Use free-body diagrams, F = ma, and motion graphs to predict how objects move.
+
+
+ Basic math notation
+ https://openfreebooks.org/math/basic-math-notation/
+ https://openfreebooks.org/math/basic-math-notation/
+ Thu, 21 May 2026 01:07:39 GMT
+ Learn the names and meanings of plus, minus, times, divide, equals, brackets, and other signs used in number sentences.
+
+
+ Zero and one
+ https://openfreebooks.org/math/zero-and-one/
+ https://openfreebooks.org/math/zero-and-one/
+ Thu, 21 May 2026 01:07:39 GMT
+ 0 is nothing; 1 is one. Learn to tell them apart — the same skill as telling false from true — before counting larger amounts.
+
+
+ Algebraic fractions & formulas
+ https://openfreebooks.org/math/algebraic-fractions-formulas/
+ https://openfreebooks.org/math/algebraic-fractions-formulas/
+ Wed, 20 May 2026 20:17:25 GMT
+ Simplify and combine algebraic fractions; rearrange formulas in science contexts.
+
+
+ Directed numbers, rates & ratios
+ https://openfreebooks.org/math/directed-numbers-rates/
+ https://openfreebooks.org/math/directed-numbers-rates/
+ Wed, 20 May 2026 20:17:25 GMT
+ Operate with negative numbers and solve ratio, rate, and proportion problems in context.
+
+
+ Polynomials: expansion & factorisation
+ https://openfreebooks.org/math/polynomials-expansion-factorisation/
+ https://openfreebooks.org/math/polynomials-expansion-factorisation/
+ Wed, 20 May 2026 20:17:25 GMT
+ Expand products, collect like terms, and factorise expressions using common factors and identities.
+
+
+ Properties of operations
+ https://openfreebooks.org/math/properties-of-operations/
+ https://openfreebooks.org/math/properties-of-operations/
+ Wed, 20 May 2026 20:17:25 GMT
+ See why order, grouping, and distribution can change how you write a sum or product without changing the result — with interactive area and dot models.
+
+
+ Quadratic equations
+ https://openfreebooks.org/math/quadratic-equations/
+ https://openfreebooks.org/math/quadratic-equations/
+ Wed, 20 May 2026 20:17:25 GMT
+ The discriminant, nature of roots, and the graph of y = ax² + bx + c.
+
+
+ Search
+ https://openfreebooks.org/search/
+ https://openfreebooks.org/search/
+ Tue, 19 May 2026 19:29:49 GMT
+ Search
+
+
+ Contribute
+ https://openfreebooks.org/contributing/
+ https://openfreebooks.org/contributing/
+ Tue, 19 May 2026 12:34:25 GMT
+ Contribute
+
+
+ Mathematics
+ https://openfreebooks.org/math/
+ https://openfreebooks.org/math/
+ Tue, 19 May 2026 10:55:50 GMT
+ Browse mathematics chapters in the curriculum catalog.
+
+
+ Catalog
+ https://openfreebooks.org/catalog/
+ https://openfreebooks.org/catalog/
+ Tue, 19 May 2026 10:40:53 GMT
+ Catalog
+
+
+ Manifesto
+ https://openfreebooks.org/about/
+ https://openfreebooks.org/about/
+ Tue, 19 May 2026 06:35:42 GMT
+ Manifesto
+
+
+ Credits
+ https://openfreebooks.org/credits/
+ https://openfreebooks.org/credits/
+ Tue, 19 May 2026 05:33:20 GMT
+ Credits
+
+
+ Open Free Books
+ https://openfreebooks.org/
+ https://openfreebooks.org/
+ Tue, 19 May 2026 04:06:12 GMT
+ Free, open-source textbooks for every learner — elementary through university. Read the full book online at no cost, or contribute on GitHub.
+
+
+
diff --git a/static/sitemap.xml b/static/sitemap.xml
new file mode 100644
index 0000000..ed80bde
--- /dev/null
+++ b/static/sitemap.xml
@@ -0,0 +1,99 @@
+
+
+
+ https://openfreebooks.org/
+ 2026-05-19T04:06:12.000Z
+
+
+ https://openfreebooks.org/about/
+ 2026-05-19T06:35:42.000Z
+
+
+ https://openfreebooks.org/catalog/
+ 2026-05-19T10:40:53.000Z
+
+
+ https://openfreebooks.org/contributing/
+ 2026-05-19T12:34:25.000Z
+
+
+ https://openfreebooks.org/credits/
+ 2026-05-19T05:33:20.000Z
+
+
+ https://openfreebooks.org/english/
+ 2026-05-21T02:10:55.000Z
+
+
+ https://openfreebooks.org/english/reading-comprehension-foundations/
+ 2026-05-21T02:10:55.000Z
+
+
+ https://openfreebooks.org/faq/
+ 2026-05-21T02:10:55.000Z
+
+
+ https://openfreebooks.org/math/
+ 2026-05-19T10:55:50.000Z
+
+
+ https://openfreebooks.org/math/algebraic-fractions-formulas/
+ 2026-05-20T20:17:25.000Z
+
+
+ https://openfreebooks.org/math/basic-math-notation/
+ 2026-05-21T01:07:39.000Z
+
+
+ https://openfreebooks.org/math/counting-and-numbers/
+ 2026-05-21T05:05:38.000Z
+
+
+ https://openfreebooks.org/math/directed-numbers-rates/
+ 2026-05-20T20:17:25.000Z
+
+
+ https://openfreebooks.org/math/laws-of-indices/
+ 2026-05-21T06:54:29.553Z
+
+
+ https://openfreebooks.org/math/linear-equations-two-unknowns/
+ 2026-05-21T05:05:38.000Z
+
+
+ https://openfreebooks.org/math/linear-inequalities-one-unknown/
+ 2026-05-21T06:33:43.000Z
+
+
+ https://openfreebooks.org/math/polynomials-expansion-factorisation/
+ 2026-05-20T20:17:25.000Z
+
+
+ https://openfreebooks.org/math/properties-of-operations/
+ 2026-05-20T20:17:25.000Z
+
+
+ https://openfreebooks.org/math/quadratic-equations/
+ 2026-05-20T20:17:25.000Z
+
+
+ https://openfreebooks.org/math/written-calculation/
+ 2026-05-21T05:05:38.000Z
+
+
+ https://openfreebooks.org/math/zero-and-one/
+ 2026-05-21T01:07:39.000Z
+
+
+ https://openfreebooks.org/physics/
+ 2026-05-21T02:10:55.000Z
+
+
+ https://openfreebooks.org/physics/force-newtons-laws/
+ 2026-05-21T02:10:55.000Z
+
+
+ https://openfreebooks.org/search/
+ 2026-05-19T19:29:49.000Z
+
+