defmodule Algebrica.MixProject do
use Mix.Project
@version "0.1.0"
@source_url "https://github.com/beaver-lodge/algebrica"
@homepage_url "https://algebrica.org"
@content_groups [
{"Sets and Numbers", "sets-and-numbers"},
{"Powers, Radicals, and Logarithms", "powers-radicals-logarithms"},
{"Equations", "equations"},
{"Polynomials", "polynomials"},
{"Complex Numbers", "complex-numbers"},
{"Trigonometry", "trigonometry"},
{"Vectors and Matrices", "vectors-and-matrices"},
{"Linear Systems", "linear-systems"},
{"Integrals", "integrals"},
{"Limits", "limits"},
{"Algebraic Structures", "algebraic-structures"},
{"Algebrica.org Pages", "algebrica-org-pages"}
]
def project do
[
app: :algebrica,
version: @version,
elixir: "~> 1.19",
start_permanent: Mix.env() == :prod,
name: "Algebrica",
description: description(),
homepage_url: @homepage_url,
package: package(),
docs: docs(),
deps: deps()
]
end
def application do
[
extra_applications: [:logger]
]
end
defp description do
"A Mix/ExDoc wrapper for the Algebrica mathematics knowledge base."
end
defp package do
[
name: "algebrica",
licenses: ["CC-BY-NC-4.0"],
links: %{
"GitHub" => @source_url,
"Website" => @homepage_url
},
files: ~w(.formatter.exs lib mix.exs README.md LICENSE.md) ++ content_dirs(),
exclude_patterns: ["**/.DS_Store", "**/.obsidian/**"]
]
end
defp docs do
[
main: "algebrica-readme",
source_url: @source_url,
source_ref: "v#{@version}",
extras: extras(),
groups_for_extras: groups_for_extras(),
markdown_processor: Algebrica.Markdown,
before_closing_head_tag: &before_closing_head_tag/1,
before_closing_body_tag: &before_closing_body_tag/1
]
end
defp extras do
[
project_extras(),
content_extras()
]
|> List.flatten()
|> existing_extras()
|> Enum.map(&extra_entry/1)
end
defp groups_for_extras do
project_group = {"Project", existing_extras(project_extras())}
content_groups =
Enum.map(@content_groups, fn {group, dir} ->
{group, existing_extras(markdown_files(dir))}
end)
[project_group | content_groups]
|> Enum.reject(fn {_group, entries} -> entries == [] end)
end
defp project_extras do
[
"README.md",
"LICENSE.md"
]
end
defp content_extras do
@content_groups
|> Enum.flat_map(fn {_group, dir} -> markdown_files(dir) end)
|> Enum.uniq()
end
defp content_dirs do
Enum.map(@content_groups, fn {_group, dir} -> dir end)
end
defp markdown_files(dir) do
dir
|> Path.join("*.md")
|> Path.wildcard()
|> Enum.sort()
end
defp existing_extras(entries) do
Enum.filter(entries, &File.exists?/1)
end
defp extra_entry("README.md") do
{"README.md", [filename: "algebrica-readme", title: "Algebrica"]}
end
defp extra_entry("LICENSE.md") do
{"LICENSE.md", [filename: "license", title: "License"]}
end
defp extra_entry(path), do: path
defp before_closing_head_tag(:html) do
~S"""
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.4/dist/katex.min.css">
<style>
.katex-display {
overflow-x: auto;
overflow-y: hidden;
padding: 0.15rem 0;
}
</style>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.4/dist/katex.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.4/dist/contrib/auto-render.min.js"></script>
"""
end
defp before_closing_head_tag(:epub), do: ""
defp before_closing_body_tag(:html) do
~S"""
<script>
(() => {
const ignoredTextSelector = "script, noscript, style, textarea, pre, code";
function normalizeMathText(text) {
return text
.replace(/\\\\\(/g, "\\(")
.replace(/\\\\\)/g, "\\)")
.replace(/\\\\\[/g, "\\[")
.replace(/\\\\\]/g, "\\]")
.replace(/\\begin\{align\*?\}/g, "\\begin{aligned}")
.replace(/\\end\{align\*?\}/g, "\\end{aligned}");
}
function normalizeTex(text) {
let normalized = text.trim();
const replacements = [
["\\\\{", "\\{"],
["\\\\}", "\\}"],
["\\\\;", "\\;"],
["\\\\,", "\\,"],
["\\(", "("],
["\\)", ")"],
["\\_", "_"],
["\\=", "="],
["\\+", "+"],
["\\-", "-"],
["\\'", "'"],
["\\*", "*"],
["\\\\%", "\\%"]
];
for (const [from, to] of replacements) {
normalized = normalized.split(from).join(to);
}
normalized = normalized
.replace(/\\{3,}\[/g, "\\\\[")
.replace(/(^|[^\\])\\([\[\]])/g, "$1$2");
normalized = normalized.replace(/\\\\([A-Za-z]+)/g, (_whole, command) => `\\${command}`);
return normalized
.replace(/\\begin\{align\\\*\}/g, "\\begin{aligned}")
.replace(/\\end\{align\\\*\}/g, "\\end{aligned}")
.replace(/\\begin\{align\*?\}/g, "\\begin{aligned}")
.replace(/\\end\{align\*?\}/g, "\\end{aligned}");
}
function renderExplicitMath(root) {
if (typeof katex === "undefined") {
return false;
}
for (const mathEl of root.querySelectorAll(".math-display, .math-inline")) {
if (mathEl.dataset.mathRendered === "true") {
continue;
}
const displayMode = mathEl.classList.contains("math-display");
const tex = normalizeTex(mathEl.textContent || "");
katex.render(tex, mathEl, {
displayMode,
throwOnError: false,
strict: "ignore"
});
mathEl.dataset.mathRendered = "true";
}
return true;
}
function isLikelyMathCode(text) {
const trimmed = normalizeMathText(text).trim();
const isDelimitedMath =
/^\\$\\$[\s\S]+\\$\\$$/.test(trimmed) ||
/^\\$[^$][\s\S]*\\$$/.test(trimmed) ||
/^\\\([\s\S]+\\\)$/.test(trimmed) ||
/^\\\[[\s\S]+\\\]$/.test(trimmed);
if (!isDelimitedMath) {
return false;
}
return /\\[a-zA-Z]+/.test(trimmed) || /[\^_{}=]/.test(trimmed);
}
function unwrapInlineMathCode(root) {
for (const codeEl of root.querySelectorAll("code")) {
if (codeEl.closest("pre")) {
continue;
}
const text = codeEl.textContent || "";
if (!isLikelyMathCode(text)) {
continue;
}
codeEl.replaceWith(document.createTextNode(normalizeMathText(text)));
}
}
function normalizeMathTextNodes(root) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const nodes = [];
let node;
while ((node = walker.nextNode())) {
if (node.parentElement?.closest(ignoredTextSelector)) {
continue;
}
const normalized = normalizeMathText(node.nodeValue || "");
if (normalized !== node.nodeValue) {
nodes.push([node, normalized]);
}
}
for (const [textNode, normalized] of nodes) {
textNode.nodeValue = normalized;
}
}
function renderMath() {
if (typeof renderMathInElement !== "function" || typeof katex === "undefined") {
return false;
}
renderExplicitMath(document.body);
unwrapInlineMathCode(document.body);
normalizeMathTextNodes(document.body);
renderMathInElement(document.body, {
delimiters: [
{left: "$$", right: "$$", display: true},
{left: "$", right: "$", display: false},
{left: "\\(", right: "\\)", display: false},
{left: "\\[", right: "\\]", display: true}
],
ignoredTags: ["script", "noscript", "style", "textarea", "pre", "code"],
preProcess: normalizeTex,
throwOnError: false,
strict: "ignore"
});
return true;
}
function renderMathWhenReady(attempt = 0) {
if (renderMath() || attempt > 60) {
return;
}
window.setTimeout(() => renderMathWhenReady(attempt + 1), 50);
}
window.addEventListener("exdoc:loaded", () => renderMathWhenReady());
})();
</script>
"""
end
defp before_closing_body_tag(:epub), do: ""
defp deps do
[
{:ex_doc, "~> 0.34", only: :dev, runtime: false, warn_if_outdated: true}
]
end
end