Skip to content
Merged
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
85 changes: 83 additions & 2 deletions assets/javascripts/app/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,17 +117,18 @@ class App extends Events {
delete this.DOC;
}

bootAll() {
async bootAll() {
const docs = this.settings.getDocs();
for (var doc of this.DOCS) {
(docs.includes(doc.slug) ? this.docs : this.disabledDocs).add(doc);
}
delete this.DOCS;
this.migrateDocs();
await this.migrateToLatestVersions();
this.docs.load(this.start.bind(this), this.onBootError.bind(this), {
readCache: true,
writeCache: true,
});
delete this.DOCS;
}

start() {
Expand Down Expand Up @@ -190,6 +191,86 @@ class App extends Events {
}
}

// With the "latest version" preference enabled, replace the enabled docs for
// which a newer version is available with that version.
async migrateToLatestVersions() {
if (!this.settings.get("autoLatestVersion")) {
return;
}

const allDocs = this.docs.all().concat(this.disabledDocs.all());
// The same version can supersede several enabled docs, so it's only loaded
// once, e.g. when both CMake 3.9 and CMake 3.10 are enabled.
const migrations = new Map();

for (const outdated of this.docs.all()) {
const latest = outdated.findLatestVersion(allDocs);
if (latest === outdated) {
continue;
}
if (!migrations.has(latest)) {
migrations.set(latest, []);
}
migrations.get(latest).push(outdated);
}

const loaded = await this.loadLatestVersions([...migrations.keys()]);
let needsSaving;

for (const [latest, outdatedDocs] of migrations) {
if (!loaded.has(latest)) {
continue;
}
for (const outdated of outdatedDocs) {
this.docs.remove(outdated);
this.disabledDocs.add(outdated);
}
if (!this.docs.contains(latest)) {
this.disabledDocs.remove(latest);
this.docs.add(latest);
}
needsSaving = true;
}

if (needsSaving) {
this.docs.sort();
this.saveDocs();
}
}

// Saving drops the offline data of the docs that are disabled, so the index
// of their latest version has to load before they are replaced. Loads no
// more docs at once than Docs#load does.
async loadLatestVersions(docs) {
const loaded = new Set();
let i = 0;

const next = async () => {
while (i < docs.length) {
const doc = docs[i++];
const success = await new Promise((resolve) =>
doc.load(
() => resolve(true),
() => resolve(false),
{ readCache: true, writeCache: true },
),
);
if (success) {
loaded.add(doc);
}
}
};

await Promise.all(
Array.from(
{ length: Math.min(docs.length, app.collections.Docs.CONCURRENCY) },
next,
),
);

return loaded;
}

enableDoc(doc, _onSuccess, onError) {
if (this.docs.contains(doc)) {
return;
Expand Down
2 changes: 2 additions & 0 deletions assets/javascripts/app/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ app.Settings = class Settings {
"tips",
"noAutofocus",
"autoInstall",
"autoLatestVersion",
"spaceScroll",
"spaceTimeout",
"noDocSpecificIcon",
Expand All @@ -40,6 +41,7 @@ app.Settings = class Settings {
spaceScroll: 1,
spaceTimeout: 0.5,
noDocSpecificIcon: false,
autoLatestVersion: false,
};

constructor() {
Expand Down
49 changes: 49 additions & 0 deletions assets/javascripts/models/doc.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
app.models.Doc = class Doc extends app.Model {
// Attributes: name, slug, type, version, release, db_size, mtime, links

static NUMBERED_VERSION_RGX = /^\d+(\.\d+)*$/;

constructor() {
super(...arguments);
this.reset(this);
Expand Down Expand Up @@ -192,6 +194,53 @@ app.models.Doc = class Doc extends app.Model {
);
}

// Whether the doc holds a numbered version of its documentation (e.g. "3.9"),
// as opposed to a variant (e.g. "10 LTS" or "Python"), which can't be
// ordered. An empty version means the doc holds the latest version
// (e.g. `angular`), whereas docs without a version aren't versioned at all.
hasNumberedVersion() {
return (
this.version === "" || Doc.NUMBERED_VERSION_RGX.test(this.version || "")
);
}

// Compares numbered versions (e.g. "3.9" is older than "3.12").
// An empty version means the latest version and is newer than any other.
isNewerVersionThan(other) {
if (this.version === "" || other.version === "") {
return this.version === "" && other.version !== "";
}
const version = this.version.split(".");
const otherVersion = other.version.split(".");
for (let i = 0; i < Math.max(version.length, otherVersion.length); i++) {
const diff =
(parseInt(version[i], 10) || 0) - (parseInt(otherVersion[i], 10) || 0);
if (diff !== 0) {
return diff > 0;
}
}
return false;
}

// Returns the doc holding the latest version of the same documentation among
// `docs`, or the doc itself when there is none.
findLatestVersion(docs) {
let latest = this;
if (!this.hasNumberedVersion()) {
return latest;
}
for (var doc of docs) {
if (
doc.name === this.name &&
doc.hasNumberedVersion() &&
doc.isNewerVersionThan(latest)
) {
latest = doc;
}
}
return latest;
}

isOutdated(status) {
if (!status) {
return false;
Expand Down
6 changes: 6 additions & 0 deletions assets/javascripts/templates/pages/settings_tmpl.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ app.templates.settingsPage = (settings) => `\
}>Automatically download documentation for offline use
<small>Only enable this when bandwidth isn't a concern to you.</small>
</label>
<label class="_settings-label">
<input type="checkbox" form="settings" name="autoLatestVersion" value="_auto-latest-version"${
settings.autoLatestVersion ? " checked" : ""
}>Automatically switch to the latest version of a documentation
<small>With this checked, enabling e.g. CMake 3.9 switches to CMake 3.10 once it becomes available.</small>
</label>
<label class="_settings-label _hide-in-development">
<input type="checkbox" form="settings" name="analyticsConsent"${
settings.analyticsConsent ? " checked" : ""
Expand Down
1 change: 1 addition & 0 deletions assets/javascripts/views/content/settings_page.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ app.views.SettingsPage = class SettingsPage extends app.View {
settings.arrowScroll = app.settings.get("arrowScroll");
settings.noAutofocus = app.settings.get("noAutofocus");
settings.autoInstall = app.settings.get("autoInstall");
settings.autoLatestVersion = app.settings.get("autoLatestVersion");
settings.analyticsConsent = app.settings.get("analyticsConsent");
settings.spaceScroll = app.settings.get("spaceScroll");
settings.spaceTimeout = app.settings.get("spaceTimeout");
Expand Down
172 changes: 172 additions & 0 deletions test/assets/doc_version_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const test = require("node:test");
const vm = require("node:vm");

const context = {
$: {},
$$: {},
page: {},
window: { matchMedia: () => ({ media: "not all" }) },
};

vm.createContext(context);

// The files are concatenated because top-level class declarations aren't
// shared between scripts run in the same context.
vm.runInContext(
[
"assets/javascripts/lib/events.js",
"assets/javascripts/app/app.js",
"assets/javascripts/models/model.js",
"assets/javascripts/models/doc.js",
"assets/javascripts/collections/collection.js",
"assets/javascripts/collections/docs.js",
]
.map((file) => fs.readFileSync(file, "utf8"))
.join("\n"),
context,
{ filename: "devdocs.js" },
);

const { app } = context;

app.collections.Entries = class Entries {
each() {}
};
app.collections.Types = class Types {
each() {}
};
app.models.Entry = class Entry {
addAlias() {}
};

const CMAKE = [
{ name: "CMake", slug: "cmake~3.12", version: "3.12" },
{ name: "CMake", slug: "cmake~3.10", version: "3.10" },
{ name: "CMake", slug: "cmake~3.9", version: "3.9" },
];
const NODE = [
{ name: "Node.js", slug: "node", version: "" },
{ name: "Node.js", slug: "node~10_lts", version: "10 LTS" },
{ name: "Node.js", slug: "node~8_lts", version: "8 LTS" },
];
const BASH = [{ name: "Bash", slug: "bash" }];

const newDoc = (attributes) => new app.models.Doc(attributes);
const findLatest = (slug, attributes) => {
const docs = attributes.map(newDoc);
return docs.find((doc) => doc.slug === slug).findLatestVersion(docs).slug;
};

test("the latest version of a documentation compares versions numerically", () => {
assert.equal(findLatest("cmake~3.9", CMAKE), "cmake~3.12");
assert.equal(findLatest("cmake~3.12", CMAKE), "cmake~3.12");
assert.equal(
findLatest("bazel~9", [
{ name: "Bazel", slug: "bazel~10", version: "10" },
{ name: "Bazel", slug: "bazel~9", version: "9" },
]),
"bazel~10",
);
});

test("a documentation without a version is the latest version", () => {
const docs = [
{ name: "Angular", slug: "angular", version: "" },
{ name: "Angular", slug: "angular~5", version: "5" },
];
assert.equal(findLatest("angular~5", docs), "angular");
assert.equal(findLatest("angular", docs), "angular");
});

test("documentations that aren't versioned have no latest version", () => {
assert.equal(findLatest("bash", BASH), "bash");
});

test("variants of a documentation aren't versions", () => {
// Node.js 10 LTS isn't superseded by the unversioned (latest) Node.js doc,
// and neither is a Haxe target by the base Haxe doc.
assert.equal(findLatest("node~10_lts", NODE), "node~10_lts");
assert.equal(
findLatest("haxe~python", [
{ name: "Haxe", slug: "haxe", version: "" },
{ name: "Haxe", slug: "haxe~python", version: "Python" },
]),
"haxe~python",
);
});

// The index of the latest version has to load for a doc to be replaced.
app.models.Doc.prototype.load = function (onSuccess, onError) {
app.loads.push(this.slug);
if (app.loadFails) {
onError();
} else {
onSuccess();
}
};

const migrate = async (enabled, allDocs, autoLatestVersion = true) => {
app.settings = {
get: (key) => (key === "autoLatestVersion" ? autoLatestVersion : undefined),
};
app.docs = new app.collections.Docs();
app.disabledDocs = new app.collections.Docs();
for (const attributes of allDocs) {
(enabled.includes(attributes.slug) ? app.docs : app.disabledDocs).add(
attributes,
);
}
app.saveDocs = () => {
app.saved = true;
};
app.saved = false;
app.loads = [];
await app.migrateToLatestVersions();
// Spread the array so that it's created in this realm, not the VM's.
return [...app.docs.all().map((doc) => doc.slug)];
};

test("enabled docs are migrated to their latest version at boot", async () => {
assert.deepEqual(await migrate(["cmake~3.9", "bash"], [...CMAKE, ...BASH]), [
"bash",
"cmake~3.12",
]);
assert.equal(app.saved, true);
assert.equal(app.disabledDocs.findBy("slug", "cmake~3.9").slug, "cmake~3.9");
});

test("outdated docs are disabled when their latest version is already enabled", async () => {
assert.deepEqual(await migrate(["cmake~3.9", "cmake~3.12"], CMAKE), [
"cmake~3.12",
]);
});

test("the version superseding several docs is only loaded once", async () => {
assert.deepEqual(await migrate(["cmake~3.9", "cmake~3.10"], CMAKE), [
"cmake~3.12",
]);
assert.deepEqual([...app.loads], ["cmake~3.12"]);
});

test("a doc whose latest version fails to load isn't replaced", async () => {
app.loadFails = true;
try {
assert.deepEqual(await migrate(["cmake~3.9"], CMAKE), ["cmake~3.9"]);
assert.equal(app.saved, false);
} finally {
app.loadFails = false;
}
});

test("docs are left alone without the preference or a newer version", async () => {
assert.deepEqual(await migrate(["cmake~3.9"], CMAKE, false), ["cmake~3.9"]);
assert.equal(app.saved, false);

assert.deepEqual(
await migrate(["cmake~3.12", "node~10_lts"], [...CMAKE, ...NODE]),
["cmake~3.12", "node~10_lts"],
);
assert.equal(app.saved, false);
});
Loading