Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
doc/html
.DS_Store
node_modules
.dev
593 changes: 422 additions & 171 deletions boostlook-v3.css

Large diffs are not rendered by default.

210 changes: 210 additions & 0 deletions boostlook-v3.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
# boostlook-v3.rb — Asciidoctor postprocessor for Boostlook 2.0 (boostlook-v3.css)
#
# Wraps rendered output in <div class="boostlook"> — the scope every v3 selector
# lives under — and injects the client-side behavior the CSS expects, so docs get
# it automatically without a per-document docinfo footer:
# * keeps the left TOC visible/pinned
# * turns the Asciidoctor :toc: into a collapsible nav-tree matching the Antora
# nav: caret toggles, collapsed by default, current path expanded,
# open sections persisted, and scroll-spy active-link highlighting.
#
# Caret/collapse styling lives in boostlook-v3.css (src/css/12-asciidoctor.css).
#
# Usage: asciidoctor -r ./boostlook-v3.rb ... doc.adoc

Asciidoctor::Extensions.register do
postprocessor do
process do |doc, output|
# Wrap the body content in the .boostlook scope (footer kept outside).
output = output.sub(/(<body[^>]*>)/, '\1<div class="boostlook">')
output = output.sub('</body>', '</div></body>')
output = output.sub(/(<body.*?<div[^>]*id="footer"[^>]*>)/m, '</div>\1')

scripts = <<~'HTML'
<script>
(function () {
var html = document.documentElement;
html.classList.add('toc-visible', 'toc-pinned');
html.classList.remove('toc-hidden');
})();
</script>
<script>
/*
* Collapsible TOC — mirrors the Antora nav-tree (01-nav.js): parent items
* get .nav-item + a .nav-item-toggle caret, branches are collapsed by
* default (.is-active expands them), the current section's path is
* expanded, open sections persist, and the section in view is highlighted
* via .is-active-link. Caret/collapse styling lives in boostlook-v3.css.
*/
(function () {
function init() {
var toc = document.querySelector('#toc');
if (!toc) return;

var labelOf = function (li) {
var a = li.querySelector(':scope > a');
return a ? a.textContent.trim() : null;
};
var setOpen = function (li, open) {
li.classList.toggle('is-active', open);
var btn = li.querySelector(':scope > .nav-item-toggle');
if (btn) btn.setAttribute('aria-expanded', open ? 'true' : 'false');
};
var expandPath = function (li) {
for (var n = li; n && n !== toc; n = n.parentNode) {
if (n.classList && n.classList.contains('nav-item')) setOpen(n, true);
}
};

// Decorate every parent item with .nav-item + a caret toggle.
Array.prototype.forEach.call(toc.querySelectorAll('li'), function (li) {
if (!li.querySelector(':scope > ul')) return;
li.classList.add('nav-item');
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'nav-item-toggle';
btn.setAttribute('aria-label', 'Toggle section');
btn.setAttribute('aria-expanded', 'false');
li.insertBefore(btn, li.firstChild);
});

var navItems = Array.prototype.slice.call(toc.querySelectorAll('.nav-item'));

// Persist open sections (like Antora's nav-open-sections).
var KEY = 'boostlook-toc-open:' + (document.title || 'doc');
var save = function () {
try {
localStorage.setItem(KEY, JSON.stringify(
navItems.filter(function (li) { return li.classList.contains('is-active'); })
.map(labelOf).filter(Boolean)
));
} catch (e) {}
};
var hadSaved = false;
try {
var saved = JSON.parse(localStorage.getItem(KEY));
if (saved && saved.length) {
navItems.forEach(function (li) {
if (saved.indexOf(labelOf(li)) !== -1) setOpen(li, true);
});
hadSaved = true;
}
} catch (e) {}

// Click the row (not the link or a nested list) to toggle.
navItems.forEach(function (li) {
li.addEventListener('click', function (e) {
if (e.target.closest('a')) return;
var sub = li.querySelector(':scope > ul');
if (sub && sub.contains(e.target)) return;
setOpen(li, !li.classList.contains('is-active'));
save();
});
});

// Scroll-spy: highlight the section in view (.is-active-link). The
// .boostlook wrapper is the scroll container (html has overflow:hidden),
// and on mobile it's the window — so listen on every plausible scroller
// and measure with viewport-relative rects.
var links = Array.prototype.slice.call(toc.querySelectorAll('a[href^="#"]'));
var byId = {};
links.forEach(function (a) {
var id = decodeURIComponent(a.getAttribute('href').slice(1));
if (id && document.getElementById(id)) byId[id] = a;
});
var headings = Object.keys(byId).map(function (id) { return document.getElementById(id); });
var activeLi = null;
var spyLock = false, spyTimer; // ignore scroll-spy during click-driven scroll
var relock = function () {
spyLock = true;
clearTimeout(spyTimer);
spyTimer = setTimeout(function () { spyLock = false; }, 150);
};
var setActive = function () {
if (spyLock) return;
var line = 130, current = null;
headings.forEach(function (h) {
if (h.getBoundingClientRect().top - line <= 0) current = h;
});
if (!current && headings.length) current = headings[0];
links.forEach(function (a) { a.classList.remove('is-active-link'); });
if (current && byId[current.id]) {
byId[current.id].classList.add('is-active-link');
activeLi = byId[current.id].closest('li');
}
};
var ticking = false;
var onScroll = function () {
if (spyLock) { relock(); return; } // keep the lock through the click-scroll
if (!ticking) { ticking = true; requestAnimationFrame(function () { ticking = false; setActive(); }); }
};
[document.querySelector('.boostlook'),
document.querySelector('.article.toc2.toc-left'),
window].forEach(function (el) {
if (el) el.addEventListener('scroll', onScroll, { passive: true });
});
// A click highlights exactly the clicked link; lock the spy through the
// scroll that follows so it can't bump the highlight to the next heading.
links.forEach(function (a) {
a.addEventListener('click', function () {
relock();
links.forEach(function (l) { l.classList.remove('is-active-link'); });
a.classList.add('is-active-link');
var li = a.closest('li');
if (li) { activeLi = li; expandPath(li); }
});
});
setActive();

// On first load (no saved state) expand the current section's path so
// the tree isn't fully collapsed; otherwise honor what was open.
if (!hadSaved) {
var hashLink = location.hash && toc.querySelector('a[href="' + location.hash + '"]');
var startLi = (hashLink && hashLink.closest('li')) || activeLi || (navItems[0] || null);
if (startLi) expandPath(startLi);
}
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script>
HTML

# Theme init — mirror the Antora UI (head-scripts.hbs + 00-theme-toggle.js)
# so standalone AsciiDoctor docs follow the user's OS theme. Injected at the
# top of <head> so html.dark is set before first paint (no flash). Honors a
# saved 'antora-theme' choice if present, otherwise prefers-color-scheme, and
# live-updates on OS changes unless the user has pinned a theme. Skipped when
# embedded in an iframe (the host page controls the theme there).
theme = <<~'HTML'
<script>
(function () {
if (window.self !== window.top) return;
var html = document.documentElement;
function prefersDark() {
return !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
}
try {
var saved = localStorage.getItem('antora-theme');
html.classList.toggle('dark', saved ? saved === 'dark' : prefersDark());
} catch (e) {
if (prefersDark()) html.classList.add('dark');
}
if (window.matchMedia) {
var mq = window.matchMedia('(prefers-color-scheme: dark)');
var onChange = function (e) {
try { if (localStorage.getItem('antora-theme')) return; } catch (_) {}
html.classList.toggle('dark', e.matches);
};
if (mq.addEventListener) mq.addEventListener('change', onChange);
else if (mq.addListener) mq.addListener(onChange);
}
})();
</script>
HTML

output = output.sub(/<head[^>]*>/) { |m| m + theme }
output.sub('</body>', "#{scripts}</body>")
end
end
end
1 change: 1 addition & 0 deletions build-css.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,5 @@ cat \
src/css/14-quickbook.css \
src/css/15-readme.css \
src/css/16-responsive-toc.css \
src/css/17-site-components.css \
> boostlook-v3.css
107 changes: 107 additions & 0 deletions build-preview.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
#!/bin/sh
#
# Build the Boostlook 2.0 multi-doc preview and (optionally) deploy to Netlify.
#
# Pipeline:
# 1. Build boostlook-v3.css from the src/css modules
# 2. Inject it into website-v2-docs/antora-ui and rebuild the UI bundle
# 3. Rebuild the site guides (local content) with the 2.0 UI bundle
# 4. Bake 2.0 CSS into the reused (prebuilt) library docs
# 5. Render the charconv AsciiDoctor specimen
# 6. Generate the preview landing (index.html)
# 7. Optionally deploy: --draft (preview URL) or --prod (publish)
#
# Usage:
# ./build-preview.sh # build only -> <docs>/build ready to upload
# ./build-preview.sh --draft # build + draft deploy (preview URL)
# ./build-preview.sh --prod # build + production deploy
#
# Env overrides:
# BOOSTLOOK_DOCS path to website-v2-docs (default: ../website-v2-docs)
# NETLIFY_SITE_ID Netlify site id (default: the boostlook-v3 site)
#
set -eu

DEPLOY=""
case "${1:-}" in
--draft) DEPLOY=draft ;;
--prod) DEPLOY=prod ;;
"") ;;
*) echo "unknown argument: $1 (use --draft or --prod)"; exit 2 ;;
esac

ROOT="$(cd "$(dirname "$0")" && pwd)"
DOCS_RAW="${BOOSTLOOK_DOCS:-$ROOT/../website-v2-docs}"
[ -d "$DOCS_RAW" ] || { echo "ERROR: website-v2-docs not found at $DOCS_RAW (set BOOSTLOOK_DOCS)"; exit 1; }
DOCS="$(cd "$DOCS_RAW" && pwd)"
[ -f "$DOCS/site.playbook.yml" ] || { echo "ERROR: $DOCS does not look like website-v2-docs (no site.playbook.yml)"; exit 1; }
BUILD="$DOCS/build"
SITE_ID="${NETLIFY_SITE_ID:-0874ef8e-22a3-4a78-ad4b-b11a9c056a12}"

echo "boostlook : $ROOT"
echo "docs : $DOCS"
echo "build out : $BUILD"
echo

# 1. Build the CSS bundle from src/css modules
echo "==> [1/6] build boostlook-v3.css"
sh "$ROOT/build-css.sh"

# 2. Inject our working CSS into antora-ui and rebuild the UI bundle.
# --skip-boostlook makes gulp use the injected file instead of downloading.
echo "==> [2/6] inject 2.0 CSS + rebuild antora-ui bundle"
cp "$ROOT/boostlook-v3.css" "$DOCS/antora-ui/src/css/boostlook.css"
( cd "$DOCS/antora-ui" && npx gulp build --skip-boostlook && npx gulp bundle:pack )

# 3. Rebuild the site guides from local content with the fresh UI bundle.
echo "==> [3/6] rebuild site guides (antora)"
CID="$(cd "$DOCS" && git rev-parse --short HEAD 2>/dev/null || echo 0000000)"
( cd "$DOCS" && npx antora --fetch \
--attribute page-boost-branch=develop \
--attribute page-commit-id="$CID" \
--stacktrace site.playbook.yml )

# 4. Bake the same 2.0 bundle into the reused (prebuilt) library docs, whose
# own UI assets live under lib/doc/_/ and aren't rebuilt here.
echo "==> [4/6] bake 2.0 CSS into library docs"
if [ -d "$BUILD/lib/doc/_/css" ]; then
cp "$BUILD/_/css/boostlook.css" "$BUILD/lib/doc/_/css/boostlook.css"
[ -f "$BUILD/lib/doc/_/css/boostlook-v3.css" ] && \
cp "$BUILD/_/css/boostlook.css" "$BUILD/lib/doc/_/css/boostlook-v3.css" || true
echo " baked into lib/doc/_/css/boostlook.css"
else
echo " WARN: $BUILD/lib/doc not found — library samples will be omitted."
echo " Run 'cd $DOCS && ./dev.sh lib' once to generate them."
fi

# 5. Render the charconv AsciiDoctor specimen (boostlook.rb adds the .boostlook
# wrapper; highlight.js avoids needing the rouge gem).
echo "==> [5/6] render charconv specimen"
if command -v asciidoctor >/dev/null 2>&1; then
mkdir -p "$BUILD/specimen"
asciidoctor -r "$ROOT/boostlook-v3.rb" \
-a linkcss -a copycss! -a stylesdir=/_/css -a stylesheet=boostlook.css \
-a source-highlighter=highlightjs -a docinfodir="$ROOT/doc" \
"$ROOT/doc/specimen.adoc" -o "$BUILD/specimen/index.html"
echo " wrote specimen/index.html"
else
echo " WARN: asciidoctor not found — specimen omitted (gem install asciidoctor)."
fi

# 6. Generate the preview landing page.
echo "==> [6/6] generate preview landing (index.html)"
node "$ROOT/scripts/gen-landing.js" "$BUILD" "$ROOT"

echo
echo "Preview folder ready: $BUILD"

# 7. Optional deploy via netlify-cli (requires `netlify login` once).
if [ "$DEPLOY" = draft ]; then
echo "==> deploying DRAFT to Netlify (site $SITE_ID)"
npx netlify-cli deploy --site="$SITE_ID" --dir="$BUILD" --no-build
elif [ "$DEPLOY" = prod ]; then
echo "==> deploying PRODUCTION to Netlify (site $SITE_ID)"
npx netlify-cli deploy --site="$SITE_ID" --dir="$BUILD" --no-build --prod
else
echo "Next: re-run with --draft (preview URL) or --prod (publish), or drag-drop the folder into Netlify."
fi
Loading