diff --git a/docs/about.md b/docs/about.md
index 99a09d411..c56dad692 100644
--- a/docs/about.md
+++ b/docs/about.md
@@ -1,34 +1,26 @@
---
-title: About Us
+title: About us
layout: page
---
-## About us
+## The team
-### The team
+The team behind Pulsar is a community that came about organically after the announcement of [Atom’s sunset](https://github.blog/2022-06-08-sunsetting-atom/). We decided that we needed to do something to keep our favorite editor alive.
-The team behind Pulsar is a community that came about naturally after the
-announcement of [Atom's Sunset](https://github.blog/2022-06-08-sunsetting-atom/)
-and decided that they needed to do something about it to keep their favorite
-editor alive.
+Pulsar is a community-led project to modernize, update, and improve the original Atom project into a contemporary, hackable and fully open editor.
-This is a true community-led project to modernize, update and improve the
-original Atom project into a contemporary, hackable and fully open editor.
+Pulsar is all of us! Feel free to contribute, discuss, answer questions and suggest ideas in any of our [community areas](/community).
-Pulsar is all of us, feel free to contribute, discuss, answer questions and
-suggest ideas in any of our [community areas](./community.md).
+## The goals
-### The goals
+### Remain community-developed, -led, and -focused
-- Community developed, led and focused
- - Pulsar is being made by a community who came together from the stellar
- remnants of Atom. A community that wants to build upon the huge legacy that
- was left and make a uniquely hackable editor.
-- To continue and build upon the legacy of the Atom text editor which has been [sunset](https://github.blog/2022-06-08-sunsetting-atom/).
- - This means not only supporting the editor itself but also the package
- repository with its thousands of community contributions.
-- Update core technologies to bring the editor up to date.
- - Core technologies such as Node.js, Electron etc., keeping them up to date so
- new features and libraries can be used and added without hacky workarounds.
-- Emphasize the elements that make the editor great to really make Pulsar stand
- out from the crowd, not only for ex-Atom users but for everyone.
+The Pulsar community has the collective goal of building upon Atom’s sizable legacy. Decisions are made by those who participate and contribute; everyone helps decide how Pulsar evolves, not just a select few.
+
+### Keep the things that make it unique
+
+Atom’s design choices set it apart among editors. It is important to maintain those design choices as much as possible — especially since it helps us maintain compatibility with the thousands of community-contributed packages that already exist.
+
+### Bring the editor’s core technologies up to date
+
+Both Electron and Pulsar’s core modules need periodic updating to take advantage of new features in Node and Chromium.
diff --git a/docs/community.md b/docs/community.md
index 70c386687..d983f3cbd 100644
--- a/docs/community.md
+++ b/docs/community.md
@@ -1,12 +1,12 @@
---
-title: Community Areas
+title: Community areas
layout: page
---
-Here you will find a number of community links to discuss Pulsar, get help, suggest features and enhancements and otherwise interact with the team and community.
+Here you will find a number of community links to discuss Pulsar, get help, suggest features and enhancements, and otherwise interact with the team and community.
-- [ - GitHub Discussions](https://github.com/orgs/pulsar-edit/discussions)
-- [ - Discord](https://discord.gg/7aEbB9dGRT)
-- [ - Reddit](https://www.reddit.com/r/pulsaredit/)
-- [ - Mastodon](https://fosstodon.org/@pulsaredit)
-- [ - Lemmy](https://lemmy.ml/c/pulsaredit)
+- [ GitHub Discussions](https://github.com/orgs/pulsar-edit/discussions)
+- [ Discord](https://discord.gg/7aEbB9dGRT)
+- [ Reddit](https://www.reddit.com/r/pulsaredit/)
+- [ Mastodon](https://fosstodon.org/@pulsaredit)
+- [ Lemmy](https://lemmy.ml/c/pulsaredit)
diff --git a/docs/donate.md b/docs/donate.md
index 5d272c148..7f49839f5 100644
--- a/docs/donate.md
+++ b/docs/donate.md
@@ -3,17 +3,19 @@ title: Donate to Pulsar
layout: page
---
-Pulsar will always be free and fully open to the community but we do have some
+Pulsar will always be free and fully open to the community, but we do have some
running costs associated with hosting the package repository along with other
expenses.
-If you wish to contribute to the running costs of the project we have an
+If you wish to contribute to the running costs of the project, we have an
[OpenCollective](https://opencollective.com/pulsar-edit) where you can safely
donate and view how the money is being used.
-
-
-
+
+ `;
+ }
+
+ findParentHeadingBundle(level, index) {
+ let targetLevel = level - 1;
+ for (let current = index - 1; current >= 0; current--) {
+ let bundle = this.map.get(current);
+ if (bundle?.level !== targetLevel) continue;
+ return bundle;
+ }
+ }
+
+ bundleForTag (node, index) {
+ let name = node.dataset.name || node.innerText
+ return { node, name, id: node.id, index, level: this.levelForTag(node), children: [] };
+ }
+
+ levelForTag (node) {
+ return Number(node.tagName.substring(1));
+ }
+}
+
+// Highlight the sidebar navigation item that corresponds to the section that
+// the user is reading.
+class HeadingObserver {
+ constructor({ topMargin = 0, manageHistoryEntries = true, visibleOnly = false }) {
+ let container = document.querySelector('.sidebar__toc');
+ if (!container) return;
+
+ this.container = container;
+ this.sidebar = document.querySelector('.sidebar');
+ this.activeId = null;
+ this.topMargin = topMargin;
+ this.manageHistoryEntries = manageHistoryEntries;
+ this.visibleOnly = visibleOnly;
+
+ let threshold = [];
+ for (let i = 0; i <= 20; i++) {
+ threshold.push(i * 0.05);
+ }
+
+ let listener = (_entries, _observer) => recheck();
+ let recheck = (updateHash = false) => {
+ if (this.clickedOnAnchor) return;
+ let closest = this.getClosestHeadingToTopOfViewport();
+ this.setActive(closest?.id ?? null, { updateHash });
+ }
+
+ let debouncedRecheck = debounce(recheck, 200);
+
+ // The scroll listener comes in after the user has stopped scrolling and
+ // acts as a catch-all in cases where the `IntersectionObserver` fails.
+ window.addEventListener('scroll', () => debouncedRecheck(true));
+
+ this.observer = new IntersectionObserver(
+ listener,
+ {
+ // Draw an imaginary rectangle the size of the viewport, then decrease
+ // the height so that the rectangle covers only the top 10% of the
+ // viewport.
+ //
+ // When a heading passes into this imaginary rectangle, that's the
+ // active heading.
+ //
+ // If no heading is within this imaginary rectangle, the active heading
+ // is the closest heading _above_ the top edge of the viewport.
+ //
+ // If no such heading qualifies, the active heading is the closest
+ // heading to the top edge of the viewport.
+ rootMargin: `${topMargin}px 0px -90% 0px`,
+ threshold
+ }
+ );
+
+ this.ids = [];
+ this.headings = [];
+
+ this.container.addEventListener('click', (event) => {
+ let link = event.target.closest('a');
+ if (!link) return;
+ this.didClickLink(link);
+ })
+
+ this.scanSidebar();
+ }
+
+ rescanSidebar () {
+ this.ids = [];
+ this.headings = [];
+ this.scanSidebar();
+ }
+
+ scanSidebar() {
+ this.pathName = window.location.pathname;
+ this.observer.disconnect();
+
+ let links = this.container.querySelectorAll('a');
+
+ for (let link of links) {
+ let href = link.getAttribute('href');
+ if (!href?.startsWith('#')) {
+ if (`${href}/` === this.pathName) {
+ link.closest('li')?.classList.add('active');
+ }
+ }
+
+ let id = href.substring(1);
+ if (!id) continue;
+ let heading = document.getElementById(id);
+ if (!heading) continue;
+ if (this.visibleOnly && !this.isVisible(heading)) continue;
+
+ this.ids.push(id);
+ this.headings.push(heading);
+
+ this.observer.observe(heading);
+ }
+ }
+
+ isVisible (element) {
+ return element.offsetHeight !== 0;
+ }
+
+ setActive(id, { updateHash = false } = {}) {
+ if (id === null) return;
+ this.activeId = id;
+ let href = `#${id}`;
+
+ let previousActive = this.container.querySelector('a.active');
+ if (previousActive?.getAttribute('href').startsWith('#')) {
+ previousActive?.classList.remove('active');
+ }
- } else if (list.dataset.status === "closed") {
- btn.classList.remove("icon-chevron-down");
- btn.classList.add("icon-chevron-up");
+ let linkForId = this.container.querySelector(`a[href="${href}"]`);
+ if (!linkForId) return;
- list.classList.remove("hide");
- list.dataset.status = "open";
+ linkForId?.classList.add('active');
+ if (updateHash) {
+ this.scrollIntoView(linkForId)
+ this.setHistoryEntry(href);
+ }
}
+
+ setHistoryEntry(newHash) {
+ if (!this.manageHistoryEntries) return;
+ let hash = location.hash;
+ let newUrl = location.toString().replace(hash, '');
+ history.replaceState(null, '', `${newUrl}${newHash}`);
+ }
+
+ scrollIntoView (element) {
+ let { sidebar } = this;
+ // If there's no scrollbar on the container, there's nothing to scroll.
+ if (sidebar.scrollHeight === sidebar.offsetHeight) return;
+
+ let sidebarRect = sidebar.getBoundingClientRect();
+ let elementRect = element.getBoundingClientRect();
+
+ // The highest and lowest visible Y positions within the viewport.
+ let topEdge = sidebarRect.top;
+ let bottomEdge = sidebarRect.top + sidebarRect.height;
+ if (elementRect.top < topEdge) {
+ // The active link is above the scroll position. Nudge it in the negative
+ // direction just enough to bring the link on screen.
+ let diff = topEdge - elementRect.top;
+ sidebar.scrollTop -= diff;
+ } else if (elementRect.top + elementRect.height > bottomEdge) {
+ // The active link is below the scroll position. Nudge it in the positive
+ // direction just enough to bring the link on screen.
+ let diff = elementRect.bottom - bottomEdge;
+ sidebar.scrollTop += diff;
+ }
+ }
+
+ getClosestHeadingToTopOfViewport() {
+ let windowHeight = window.innerHeight;
+ let threshold = this.topMargin + (windowHeight * 0.1);
+ let previousHeading;
+ let lastIndex = this.headings.length - 1;
+ for (let [index, heading] of this.headings.entries()) {
+ let rect = heading.getBoundingClientRect();
+ if (rect.bottom >= threshold) {
+ if (previousHeading) {
+ return previousHeading;
+ } else if (rect.bottom <= windowHeight) {
+ // If this is the first heading on the page, we'll still consider it
+ // active if it's at least within the viewport.
+ return heading;
+ } else {
+ // Otherwise we have no active heading.
+ return null;
+ }
+ } else if (index === lastIndex) {
+ return heading;
+ }
+ previousHeading = heading;
+ }
+ }
+
+ didIntersect(entry, observer) {
+ let { target, isIntersecting } = entry;
+ if (!target.id) return;
+ if (!isIntersecting) {
+ if (target.id === this.activeId) {
+ this.setActive(null);
+ }
+ return
+ };
+ if (observer === this.observer) {
+ this.setActive(target.id);
+ return true;
+ }
+ if (observer === this.wideObserver && this.activeId === null) {
+ this.setActive(target.id);
+ }
+ return false;
+ }
+
+ // Clicking on a link with a heading anchor should always make that link the
+ // active link, even if it otherwise wouldn't qualify. (Like if it's a short
+ // section at the bottom of the page and cannot reach the top of the
+ // viewport.)
+ didClickLink(link) {
+ let href = link.getAttribute('href');
+ if (!href.startsWith('#')) return;
+
+ let id = href.substring(1);
+ if (!this.ids.includes(id)) return;
+
+ this.clickedOnAnchor = true;
+ requestAnimationFrame(() => this.setActive(id));
+ setTimeout(() => this.clickedOnAnchor = false, 300);
+ }
+}
+
+
+const Tabs = {
+ setup () {
+ let nodes = document.getElementsByClassName("tabs-tabs-wrapper");
+
+ for (let i = 0; i < nodes.length; i++) {
+ let node = nodes[i];
+
+ let buttons = node.getElementsByClassName("tabs-tab-button");
+ let isAnyBtnActive = false;
+
+ for (let y = 0; y < buttons.length; y++) {
+ let button = buttons[y];
+
+ if (button.classList.contains("active")) {
+ isAnyBtnActive = true;
+ }
+
+ button.addEventListener("click", this.onClick.bind(this));
+ }
+
+ if (!isAnyBtnActive) {
+ // This section has no active buttons. But since we want the first button
+ // to be active on page load, we will manually trigger one to be active
+ let defaultBtn = buttons[0];
+ defaultBtn.click();
+ }
+ }
+ },
+
+ onClick () {
+ let button = event.target;
+ let wrapper = button.closest('.tabs-tabs-wrapper');
+ let section = wrapper.querySelector(`[data-index="${button.dataset.tab}"]`);
+
+ let allButtons = wrapper.getElementsByClassName("tabs-tab-button");
+ let allSections = wrapper.getElementsByClassName("tabs-tab-content");
+
+ // Deactivate all buttons
+ for (let i = 0; i < allButtons.length; i++) {
+ allButtons[i].classList.remove("active");
+ }
+ // Deactivate all Sections
+ for (let i = 0; i < allSections.length; i++) {
+ allSections[i].classList.remove("active");
+ }
+
+ // Activate the button
+ button.classList.add("active");
+ // Activate the Section
+ section.classList.add("active");
+ }
+}
+
+
+ThemeSwitcher.setup();
+
+class SystemSwitcher {
+ constructor() {
+ this.BUTTON_TEXT_FOR_PLATFORM = {
+ mac: 'mac',
+ linux: 'Linux',
+ win: 'Windows'
+ };
+
+ this.list = document.querySelector('.platform-switcher');
+ if (!this.list) return;
+
+ this.list.addEventListener('click', (event) => {
+ let button = event.target.closest('button');
+ if (!button) return;
+ this.onClick(button);
+ })
+
+ this.observer = new MutationObserver(
+ (mutationList) => {
+ for (let mutation of mutationList) {
+ if (mutation.type !== 'attributes') continue;
+ if (mutation.attributeName !== 'data-platform') continue;
+ this.reactToPlatformChange(document.body.dataset.platform);
+ }
+ });
+ this.observer.observe(document.body, { attributes: true });
+
+ this.setPlatform(this.getPreferredPlatform() ?? this.detectPlatform());
+ this.reactToPlatformChange(document.body.dataset.platform);
+ }
+
+ detectPlatform () {
+ // Make a rough guess as to the platform of a given user.
+ const userAgent = window.navigator.userAgent;
+ const platform = window.navigator?.userAgentData?.platform || window.navigator.platform;
+ const macosPlatforms = ['macOS', 'Macintosh', 'MacIntel', 'MacPPC', 'Mac68K'];
+ const windowsPlatforms = ['Win32', 'Win64', 'Windows', 'WinCE'];
+ const iosPlatforms = ['iPhone', 'iPad', 'iPod'];
+
+ let os = null;
+
+ if (macosPlatforms.includes(platform)) {
+ os = 'mac';
+ } else if (iosPlatforms.includes(platform)) {
+ os = 'mac';
+ } else if (windowsPlatforms.includes(platform)) {
+ os = 'win';
+ } else if (/Android/.test(userAgent)) {
+ os = 'win';
+ } else if (/Linux/.test(platform)) {
+ os = 'linux';
+ }
+
+ return os;
+ }
+
+ setPlatform (platform) {
+ document.body.setAttribute(`data-platform`, platform);
+ localStorage.setItem("preferred-platform", platform);
+ }
+
+ getPreferredPlatform () {
+ return localStorage.getItem("preferred-platform") ?? null;
+ }
+
+ onClick (button) {
+ this.setPlatform(button.dataset.platform);
+ }
+
+ reactToPlatformChange (newPlatform) {
+ let active = this.list.querySelector('.active');
+ if (active?.dataset.platform === newPlatform) return;
+ active?.classList.remove('active');
+
+ let selector = `button[data-platform="${newPlatform}"]`;
+ let newActive = this.list.querySelector(selector);
+ newActive.classList.add('active');
+
+ // Are there any tabbed containers with different content based on
+ // platform? Those should be kept in sync with the platform selector. This
+ // also ensures that the correct tab is focused for the user's platform
+ // when the page is first shown.
+ let tabWrappers = document.querySelectorAll('.tabs-tabs-wrapper');
+ let targetButtonText = this.BUTTON_TEXT_FOR_PLATFORM[newPlatform];
+ for (let wrapper of tabWrappers) {
+ let buttons = Array.from(wrapper.querySelectorAll('button'));
+ // innerText can reflect CSS text-transform, amazingly. Normalize text
+ // before comparison.
+ let button = buttons.find(
+ b => b.innerText.toLowerCase().includes(targetButtonText.toLowerCase())
+ );
+ button?.click();
+ }
+ }
+}
+
+{
+ new SystemSwitcher();
+}
+
+// Must be declared before the heading observer, but after the system switcher
+// (so that it ignores invisible headings).
+let autoToc = new AutoTOC({ visibleOnly: true });
+{
+ // When `body[data-platform]` changes, rebuild the table of contents.
+ // Headings might have been hidden or shown.
+ let mutationObserver = new MutationObserver(() => autoToc.rebuildTOC());
+ mutationObserver.observe(document.body, { subtree: false, attributes: true, attributeFilter: ['data-platform'] })
+}
+
+{
+ let topMargin = document.querySelector('.page-header')?.offsetHeight ?? 0;
+ let manageHistoryEntries = !location.pathname.startsWith('/api/');
+ window.headingObserver = new HeadingObserver({ topMargin, manageHistoryEntries, visibleOnly: true });
}
diff --git a/docs/main.less b/docs/main.less
index 93a725a0e..183938730 100644
--- a/docs/main.less
+++ b/docs/main.less
@@ -1,36 +1,11 @@
-/*
- Override some styling from the main site to ensure download link data is readable
-*/
-ul ol, ul ul {
- font-size: 100% !important;
-}
-/*
- Additional styling for list items.
-*/
-
-.list, .collapsable {
- .list-item+.list-item {
- margin-top: 3rem;
- border-top: 5px solid var(--thematic-break-color);
- }
-
- .list-item__dropdown {
- display: flex;
- //justify-content: space-between;
- justify-content: normal;
- align-items: flex-start;
- padding-top: 1rem;
- }
-
- .list-item__alert {
- font: var(--font-color-subtle);
- }
-}
-
-.list-item__expandable.hide {
- display: none;
-}
-
-.list-item__inner {
- flex-grow: 1;
-}
+@import "less/variables.less";
+@import "less/mixins.less";
+@import "less/general.less";
+@import "less/overrides.less";
+
+@import "less/hero-section.less";
+@import "less/starfield.less";
+@import "less/download.less";
+@import "less/page-header.less";
+@import "less/page-footer.less";
+@import "less/home.less";
diff --git a/eleventy.config.js b/eleventy.config.js
index c09955844..7f7710f7b 100644
--- a/eleventy.config.js
+++ b/eleventy.config.js
@@ -1,12 +1,77 @@
const pulsarEleventyConfig = require("11ty-config");
+const { getMdLibrary } = pulsarEleventyConfig;
+const slugify = require('slugify');
+const get = require('just-safe-get');
module.exports = async (eleventyConfig) => {
pulsarEleventyConfig(eleventyConfig);
- const fontAwesomePlugin = await import("@11ty/font-awesome").then(fa => fa.default); // ESM export only
- eleventyConfig.addPlugin(fontAwesomePlugin);
+ const MD_LIBRARY = getMdLibrary();
+
+ MD_LIBRARY.use(
+ require("markdown-it-anchor")
+ );
+
+ // const fontAwesomePlugin = await import("@11ty/font-awesome").then(fa => fa.default); // ESM export only
+ // eleventyConfig.addPlugin(fontAwesomePlugin);
+
+ globalThis.helpers = {
+ // Auto-generates slugs for headings. (This is done automatically for
+ // Markdown content, so use this when you're writing headings manually in
+ // EJS.)
+ //
+ // Accepts an optional second parameter: a `Set` that will hold all the
+ // slugs that have already been used. Instantiate a `Set` at the page level
+ // and pass it into `helpers.slugify` so that it keeps track of duplicates
+ // automatically.
+ slugify (title, used = null) {
+ let value = slugify(title, { lower: true })
+ if (used) {
+ // Make sure we haven't used this slug already on this page. If we
+ // have, increment a number until we find an unused slug.
+ let i = 2;
+ let originalValue = value;
+ while (used.has(value)) {
+ value = `${originalValue}-${i}`;
+ i++;
+ }
+ used.add(value);
+ }
+ return value;
+ },
+
+ // Determines whether a section of download info is a dead-end (i.e., has
+ // no leaves with download links).
+ isLeaf (item) {
+ if (item.url) return false
+ if (!item.options || Object.keys(item.options).length === 0) return true
+ if (Object.values(item.options).every(opt => helpers.isLeaf(opt))) return true
+ return false
+ },
+
+ renderMarkdown (str) {
+ return MD_LIBRARY.render(str);
+ },
+
+ // Given a message and a list of "includes," detects if the message is
+ // plain text (in which case it is returned unmodified) or is the special
+ // "include" syntax — and, if the latter, returns the correct message for
+ // the key.
+ //
+ // Include syntax looks like %foo.bar% — and will attempt to look up a key
+ // at the `foo.bar` position in the `includes` object.
+ //
+ // If the key is not found, the message key itself (minus the percent signs
+ // on either side) will be returned.
+ dereference(message, includes) {
+ if (!message.startsWith('%') || !message.endsWith('%')) {
+ return message;
+ }
+ let messageKey = message.replace(/^%|%$/g, '');
+ return get(includes, messageKey, messageKey);
+ }
+ };
- // return config
return {
markdownTemplateEngine: false,
// ^^ We can't parse md in liquidjs or njk, because our docs seem to have
diff --git a/layouts/download.ejs b/layouts/download.ejs
index 1a64a99fa..a43888e9f 100644
--- a/layouts/download.ejs
+++ b/layouts/download.ejs
@@ -1,19 +1,38 @@
+<%
+ let used_slugs = new Set();
+%>
<%- include("header") %>
-
- <%- include("page_header") %>
+
+ <%- include("page_header", { platform_switcher: true }) %>
+
+ <%- include("page_footer") %>
<%- include("footer") %>
diff --git a/layouts/download_alerts.ejs b/layouts/download_alerts.ejs
new file mode 100644
index 000000000..7af0164e5
--- /dev/null
+++ b/layouts/download_alerts.ejs
@@ -0,0 +1,12 @@
+
diff --git a/layouts/download_list.ejs b/layouts/download_list.ejs
index bb57acbd5..be7f376b7 100644
--- a/layouts/download_list.ejs
+++ b/layouts/download_list.ejs
@@ -1,42 +1,36 @@
-<% for (const item in options) { %>
- <% if (options[item].hasOwnProperty("url")) { %>
-
-
-
- <<%=heading_lvl%> class="list-item__name">
- <%=options[item].title%>
- <%=heading_lvl%>>
- <% if (options[item].hasOwnProperty("alerts")) { %>
- <% for (const alert of options[item].alerts) { %>
-
- <%=alert%>
-
- <% } %>
+<%
+ let isDownloadList = Object.keys(options).every(opt => ('url' in options[opt]))
+%>
+
+<% if (isDownloadList) {%>
+
+<% } %>
+<% for (const item of Object.values(options)) { %>
+ <% let platformSlug = ('slug' in item) ? `platform-${item.slug}` : '' %>
+ <% if (isDownloadList) { %>
+ <%# Since each of these leaf nodes has a URL, we'll render this list. %>
+
+ <% } else if (!helpers.isLeaf(item)) { %>
+ <%# We're skipping leaf nodes here because there's nothing to do with them; we'll only render sections that have children. %>
+
+ id="<%= helpers.slugify(item.title, locals.used_slugs) %>"><%= item.title %>>
+ <% if (item.description) { %>
+