Skip to content

Commit 6f8f80b

Browse files
authored
Merge pull request #2734 from freeCodeCamp/latest-version-preference
Add a preference to use the latest version of a documentation
2 parents f2b81c1 + 3978fc9 commit 6f8f80b

6 files changed

Lines changed: 313 additions & 2 deletions

File tree

‎assets/javascripts/app/app.js‎

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,17 +117,18 @@ class App extends Events {
117117
delete this.DOC;
118118
}
119119

120-
bootAll() {
120+
async bootAll() {
121121
const docs = this.settings.getDocs();
122122
for (var doc of this.DOCS) {
123123
(docs.includes(doc.slug) ? this.docs : this.disabledDocs).add(doc);
124124
}
125+
delete this.DOCS;
125126
this.migrateDocs();
127+
await this.migrateToLatestVersions();
126128
this.docs.load(this.start.bind(this), this.onBootError.bind(this), {
127129
readCache: true,
128130
writeCache: true,
129131
});
130-
delete this.DOCS;
131132
}
132133

133134
start() {
@@ -190,6 +191,86 @@ class App extends Events {
190191
}
191192
}
192193

194+
// With the "latest version" preference enabled, replace the enabled docs for
195+
// which a newer version is available with that version.
196+
async migrateToLatestVersions() {
197+
if (!this.settings.get("autoLatestVersion")) {
198+
return;
199+
}
200+
201+
const allDocs = this.docs.all().concat(this.disabledDocs.all());
202+
// The same version can supersede several enabled docs, so it's only loaded
203+
// once, e.g. when both CMake 3.9 and CMake 3.10 are enabled.
204+
const migrations = new Map();
205+
206+
for (const outdated of this.docs.all()) {
207+
const latest = outdated.findLatestVersion(allDocs);
208+
if (latest === outdated) {
209+
continue;
210+
}
211+
if (!migrations.has(latest)) {
212+
migrations.set(latest, []);
213+
}
214+
migrations.get(latest).push(outdated);
215+
}
216+
217+
const loaded = await this.loadLatestVersions([...migrations.keys()]);
218+
let needsSaving;
219+
220+
for (const [latest, outdatedDocs] of migrations) {
221+
if (!loaded.has(latest)) {
222+
continue;
223+
}
224+
for (const outdated of outdatedDocs) {
225+
this.docs.remove(outdated);
226+
this.disabledDocs.add(outdated);
227+
}
228+
if (!this.docs.contains(latest)) {
229+
this.disabledDocs.remove(latest);
230+
this.docs.add(latest);
231+
}
232+
needsSaving = true;
233+
}
234+
235+
if (needsSaving) {
236+
this.docs.sort();
237+
this.saveDocs();
238+
}
239+
}
240+
241+
// Saving drops the offline data of the docs that are disabled, so the index
242+
// of their latest version has to load before they are replaced. Loads no
243+
// more docs at once than Docs#load does.
244+
async loadLatestVersions(docs) {
245+
const loaded = new Set();
246+
let i = 0;
247+
248+
const next = async () => {
249+
while (i < docs.length) {
250+
const doc = docs[i++];
251+
const success = await new Promise((resolve) =>
252+
doc.load(
253+
() => resolve(true),
254+
() => resolve(false),
255+
{ readCache: true, writeCache: true },
256+
),
257+
);
258+
if (success) {
259+
loaded.add(doc);
260+
}
261+
}
262+
};
263+
264+
await Promise.all(
265+
Array.from(
266+
{ length: Math.min(docs.length, app.collections.Docs.CONCURRENCY) },
267+
next,
268+
),
269+
);
270+
271+
return loaded;
272+
}
273+
193274
enableDoc(doc, _onSuccess, onError) {
194275
if (this.docs.contains(doc)) {
195276
return;

‎assets/javascripts/app/settings.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ app.Settings = class Settings {
1414
"tips",
1515
"noAutofocus",
1616
"autoInstall",
17+
"autoLatestVersion",
1718
"spaceScroll",
1819
"spaceTimeout",
1920
"noDocSpecificIcon",
@@ -40,6 +41,7 @@ app.Settings = class Settings {
4041
spaceScroll: 1,
4142
spaceTimeout: 0.5,
4243
noDocSpecificIcon: false,
44+
autoLatestVersion: false,
4345
};
4446

4547
constructor() {

‎assets/javascripts/models/doc.js‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
app.models.Doc = class Doc extends app.Model {
22
// Attributes: name, slug, type, version, release, db_size, mtime, links
33

4+
static NUMBERED_VERSION_RGX = /^\d+(\.\d+)*$/;
5+
46
constructor() {
57
super(...arguments);
68
this.reset(this);
@@ -192,6 +194,53 @@ app.models.Doc = class Doc extends app.Model {
192194
);
193195
}
194196

197+
// Whether the doc holds a numbered version of its documentation (e.g. "3.9"),
198+
// as opposed to a variant (e.g. "10 LTS" or "Python"), which can't be
199+
// ordered. An empty version means the doc holds the latest version
200+
// (e.g. `angular`), whereas docs without a version aren't versioned at all.
201+
hasNumberedVersion() {
202+
return (
203+
this.version === "" || Doc.NUMBERED_VERSION_RGX.test(this.version || "")
204+
);
205+
}
206+
207+
// Compares numbered versions (e.g. "3.9" is older than "3.12").
208+
// An empty version means the latest version and is newer than any other.
209+
isNewerVersionThan(other) {
210+
if (this.version === "" || other.version === "") {
211+
return this.version === "" && other.version !== "";
212+
}
213+
const version = this.version.split(".");
214+
const otherVersion = other.version.split(".");
215+
for (let i = 0; i < Math.max(version.length, otherVersion.length); i++) {
216+
const diff =
217+
(parseInt(version[i], 10) || 0) - (parseInt(otherVersion[i], 10) || 0);
218+
if (diff !== 0) {
219+
return diff > 0;
220+
}
221+
}
222+
return false;
223+
}
224+
225+
// Returns the doc holding the latest version of the same documentation among
226+
// `docs`, or the doc itself when there is none.
227+
findLatestVersion(docs) {
228+
let latest = this;
229+
if (!this.hasNumberedVersion()) {
230+
return latest;
231+
}
232+
for (var doc of docs) {
233+
if (
234+
doc.name === this.name &&
235+
doc.hasNumberedVersion() &&
236+
doc.isNewerVersionThan(latest)
237+
) {
238+
latest = doc;
239+
}
240+
}
241+
return latest;
242+
}
243+
195244
isOutdated(status) {
196245
if (!status) {
197246
return false;

‎assets/javascripts/templates/pages/settings_tmpl.js‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ app.templates.settingsPage = (settings) => `\
6060
}>Automatically download documentation for offline use
6161
<small>Only enable this when bandwidth isn't a concern to you.</small>
6262
</label>
63+
<label class="_settings-label">
64+
<input type="checkbox" form="settings" name="autoLatestVersion" value="_auto-latest-version"${
65+
settings.autoLatestVersion ? " checked" : ""
66+
}>Automatically switch to the latest version of a documentation
67+
<small>With this checked, enabling e.g. CMake 3.9 switches to CMake 3.10 once it becomes available.</small>
68+
</label>
6369
<label class="_settings-label _hide-in-development">
6470
<input type="checkbox" form="settings" name="analyticsConsent"${
6571
settings.analyticsConsent ? " checked" : ""

‎assets/javascripts/views/content/settings_page.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ app.views.SettingsPage = class SettingsPage extends app.View {
1717
settings.arrowScroll = app.settings.get("arrowScroll");
1818
settings.noAutofocus = app.settings.get("noAutofocus");
1919
settings.autoInstall = app.settings.get("autoInstall");
20+
settings.autoLatestVersion = app.settings.get("autoLatestVersion");
2021
settings.analyticsConsent = app.settings.get("analyticsConsent");
2122
settings.spaceScroll = app.settings.get("spaceScroll");
2223
settings.spaceTimeout = app.settings.get("spaceTimeout");

‎test/assets/doc_version_test.js‎

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
const assert = require("node:assert/strict");
2+
const fs = require("node:fs");
3+
const test = require("node:test");
4+
const vm = require("node:vm");
5+
6+
const context = {
7+
$: {},
8+
$$: {},
9+
page: {},
10+
window: { matchMedia: () => ({ media: "not all" }) },
11+
};
12+
13+
vm.createContext(context);
14+
15+
// The files are concatenated because top-level class declarations aren't
16+
// shared between scripts run in the same context.
17+
vm.runInContext(
18+
[
19+
"assets/javascripts/lib/events.js",
20+
"assets/javascripts/app/app.js",
21+
"assets/javascripts/models/model.js",
22+
"assets/javascripts/models/doc.js",
23+
"assets/javascripts/collections/collection.js",
24+
"assets/javascripts/collections/docs.js",
25+
]
26+
.map((file) => fs.readFileSync(file, "utf8"))
27+
.join("\n"),
28+
context,
29+
{ filename: "devdocs.js" },
30+
);
31+
32+
const { app } = context;
33+
34+
app.collections.Entries = class Entries {
35+
each() {}
36+
};
37+
app.collections.Types = class Types {
38+
each() {}
39+
};
40+
app.models.Entry = class Entry {
41+
addAlias() {}
42+
};
43+
44+
const CMAKE = [
45+
{ name: "CMake", slug: "cmake~3.12", version: "3.12" },
46+
{ name: "CMake", slug: "cmake~3.10", version: "3.10" },
47+
{ name: "CMake", slug: "cmake~3.9", version: "3.9" },
48+
];
49+
const NODE = [
50+
{ name: "Node.js", slug: "node", version: "" },
51+
{ name: "Node.js", slug: "node~10_lts", version: "10 LTS" },
52+
{ name: "Node.js", slug: "node~8_lts", version: "8 LTS" },
53+
];
54+
const BASH = [{ name: "Bash", slug: "bash" }];
55+
56+
const newDoc = (attributes) => new app.models.Doc(attributes);
57+
const findLatest = (slug, attributes) => {
58+
const docs = attributes.map(newDoc);
59+
return docs.find((doc) => doc.slug === slug).findLatestVersion(docs).slug;
60+
};
61+
62+
test("the latest version of a documentation compares versions numerically", () => {
63+
assert.equal(findLatest("cmake~3.9", CMAKE), "cmake~3.12");
64+
assert.equal(findLatest("cmake~3.12", CMAKE), "cmake~3.12");
65+
assert.equal(
66+
findLatest("bazel~9", [
67+
{ name: "Bazel", slug: "bazel~10", version: "10" },
68+
{ name: "Bazel", slug: "bazel~9", version: "9" },
69+
]),
70+
"bazel~10",
71+
);
72+
});
73+
74+
test("a documentation without a version is the latest version", () => {
75+
const docs = [
76+
{ name: "Angular", slug: "angular", version: "" },
77+
{ name: "Angular", slug: "angular~5", version: "5" },
78+
];
79+
assert.equal(findLatest("angular~5", docs), "angular");
80+
assert.equal(findLatest("angular", docs), "angular");
81+
});
82+
83+
test("documentations that aren't versioned have no latest version", () => {
84+
assert.equal(findLatest("bash", BASH), "bash");
85+
});
86+
87+
test("variants of a documentation aren't versions", () => {
88+
// Node.js 10 LTS isn't superseded by the unversioned (latest) Node.js doc,
89+
// and neither is a Haxe target by the base Haxe doc.
90+
assert.equal(findLatest("node~10_lts", NODE), "node~10_lts");
91+
assert.equal(
92+
findLatest("haxe~python", [
93+
{ name: "Haxe", slug: "haxe", version: "" },
94+
{ name: "Haxe", slug: "haxe~python", version: "Python" },
95+
]),
96+
"haxe~python",
97+
);
98+
});
99+
100+
// The index of the latest version has to load for a doc to be replaced.
101+
app.models.Doc.prototype.load = function (onSuccess, onError) {
102+
app.loads.push(this.slug);
103+
if (app.loadFails) {
104+
onError();
105+
} else {
106+
onSuccess();
107+
}
108+
};
109+
110+
const migrate = async (enabled, allDocs, autoLatestVersion = true) => {
111+
app.settings = {
112+
get: (key) => (key === "autoLatestVersion" ? autoLatestVersion : undefined),
113+
};
114+
app.docs = new app.collections.Docs();
115+
app.disabledDocs = new app.collections.Docs();
116+
for (const attributes of allDocs) {
117+
(enabled.includes(attributes.slug) ? app.docs : app.disabledDocs).add(
118+
attributes,
119+
);
120+
}
121+
app.saveDocs = () => {
122+
app.saved = true;
123+
};
124+
app.saved = false;
125+
app.loads = [];
126+
await app.migrateToLatestVersions();
127+
// Spread the array so that it's created in this realm, not the VM's.
128+
return [...app.docs.all().map((doc) => doc.slug)];
129+
};
130+
131+
test("enabled docs are migrated to their latest version at boot", async () => {
132+
assert.deepEqual(await migrate(["cmake~3.9", "bash"], [...CMAKE, ...BASH]), [
133+
"bash",
134+
"cmake~3.12",
135+
]);
136+
assert.equal(app.saved, true);
137+
assert.equal(app.disabledDocs.findBy("slug", "cmake~3.9").slug, "cmake~3.9");
138+
});
139+
140+
test("outdated docs are disabled when their latest version is already enabled", async () => {
141+
assert.deepEqual(await migrate(["cmake~3.9", "cmake~3.12"], CMAKE), [
142+
"cmake~3.12",
143+
]);
144+
});
145+
146+
test("the version superseding several docs is only loaded once", async () => {
147+
assert.deepEqual(await migrate(["cmake~3.9", "cmake~3.10"], CMAKE), [
148+
"cmake~3.12",
149+
]);
150+
assert.deepEqual([...app.loads], ["cmake~3.12"]);
151+
});
152+
153+
test("a doc whose latest version fails to load isn't replaced", async () => {
154+
app.loadFails = true;
155+
try {
156+
assert.deepEqual(await migrate(["cmake~3.9"], CMAKE), ["cmake~3.9"]);
157+
assert.equal(app.saved, false);
158+
} finally {
159+
app.loadFails = false;
160+
}
161+
});
162+
163+
test("docs are left alone without the preference or a newer version", async () => {
164+
assert.deepEqual(await migrate(["cmake~3.9"], CMAKE, false), ["cmake~3.9"]);
165+
assert.equal(app.saved, false);
166+
167+
assert.deepEqual(
168+
await migrate(["cmake~3.12", "node~10_lts"], [...CMAKE, ...NODE]),
169+
["cmake~3.12", "node~10_lts"],
170+
);
171+
assert.equal(app.saved, false);
172+
});

0 commit comments

Comments
 (0)