diff --git a/.kindel/last-sync.txt b/.kindel/last-sync.txt index 3166715..507ba84 100644 --- a/.kindel/last-sync.txt +++ b/.kindel/last-sync.txt @@ -1,16 +1,4 @@ -principles 669b1023d8b2789a940b3b7bea817878c9caaa27 +principles 961aa456a8435f01a1a669d63de8b7f415923cef -new companies: - usmc (empty questions; inherits the fixed bank via facets) -alias updates from upstream terms: - usmc/know-yourself-and-seek-self-improvement: +['know yourself', 'self-improvement', 'self-assessment'] - usmc/be-technically-and-tactically-proficient: +['technically proficient', 'know your job'] - usmc/know-your-marines-and-look-out-for-their-welfare: +['look out for their welfare', 'know your people'] - usmc/keep-your-marines-informed: +['keep them informed', 'give the why'] - usmc/set-the-example: +['lead by example', 'personal example'] - usmc/ensure-the-task-is-understood-supervised-and-accomplished: +['understood, supervised, and accomplished', 'supervise the work'] - usmc/train-your-marines-as-a-team: +['train as a team', 'teamwork'] - usmc/make-sound-and-timely-decisions: +['sound and timely', 'are right a lot', 'bias for action'] - usmc/develop-a-sense-of-responsibility-in-your-subordinates: +['develop subordinates', 'grow the team'] - usmc/employ-your-unit-in-accordance-with-its-capabilities: +['employ within capabilities', 'know the team limits'] - usmc/seek-responsibility-and-take-responsibility-for-your-actions: +['seek responsibility', 'take responsibility', 'acts like an owner'] +preamble updates: + usmc diff --git a/css/biq.css b/css/biq.css index e654f79..9c67f57 100644 --- a/css/biq.css +++ b/css/biq.css @@ -604,6 +604,43 @@ h1, h2, h3 { line-height: 1.55; } +.bhiq-further { + margin: 0.85rem 0 0; + max-width: 42rem; +} + +.bhiq-further-label { + margin: 0 0 0.35rem; + color: var(--biq-accent-dark); + font-size: 0.8rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.bhiq-further ul { + margin: 0; + padding-left: 1.15rem; +} + +.bhiq-further li { + margin: 0 0 0.55rem; +} + +.bhiq-further li:last-child { + margin-bottom: 0; +} + +.bhiq-further a { + font-weight: 700; +} + +.bhiq-further-note { + display: block; + color: var(--biq-muted); + font-size: 0.92rem; + line-height: 1.45; +} + .bhiq-intro { margin: 0 0 1.75rem; color: var(--biq-ink); diff --git a/data/questions.json b/data/questions.json index bd84789..3820f27 100644 --- a/data/questions.json +++ b/data/questions.json @@ -382,7 +382,49 @@ ] } ], - "preamble": "**Everyone is a leader.** These principles apply to every person, not just people managers. They guide judgment rather than replace it. They remain open to revision: *unless you know better.*" + "preamble": "**Everyone is a leader.** These principles apply to every person, not just people managers. They guide judgment rather than replace it. They remain open to revision: *unless you know better.*", + "blog": [ + { + "title": "Just Right Porridge and Leadership Principles", + "url": "https://kindel.com/essays/just-right-porridge-and-leadership-principles/", + "note": "The under, just right, over idea this manual is built on." + }, + { + "title": "The Tension is Intentional", + "url": "https://kindel.com/essays/the-tension-is-intentional/", + "note": "The principles pull against each other on purpose. That is the work." + }, + { + "title": "Amazon's Leadership Principles", + "url": "https://www.amazon.jobs/content/en/our-workplace/leadership-principles", + "note": "The published source this set is derived from. It lists 16 now. This manual covers the counterparts of the classic 14." + }, + { + "title": "Make Your Values Mean Something, Patrick M. Lencioni", + "url": "https://hbr.org/2002/07/make-your-values-mean-something", + "note": "Values only count when they are specific enough to hire, promote, and fire by." + }, + { + "title": "Aligning Action and Values, Jim Collins", + "url": "https://www.jimcollins.com/article_topics/articles/aligning-action.html", + "note": "A principle set is real only when daily decisions match the words on the wall." + }, + { + "title": "Netflix Culture Memo", + "url": "https://www.jobs.netflix.com/culture", + "note": "A public culture document that names the behaviors the company actually rewards and will not tolerate." + }, + { + "title": "Run This Diagnostic to Thoughtfully Build (and Evaluate) Your Startup's Culture", + "url": "https://review.firstround.com/run-this-diagnostic-to-thoughtfully-build-and-evaluate-your-startups-culture/", + "note": "A practical checklist for building and testing the culture you say you want." + }, + { + "title": "10 Things Your Corporate Culture Needs to Get Right, Donald Sull and Charles Sull", + "url": "https://sloanreview.mit.edu/article/10-things-your-corporate-culture-needs-to-get-right/", + "note": "Ten operating habits that separate a lived culture from a poster." + } + ] }, { "id": "amazon", @@ -1417,7 +1459,24 @@ } ], "examples": true, - "item": "Leadership principle" + "item": "Leadership principle", + "blog": [ + { + "title": "Just Right Porridge and Leadership Principles", + "url": "https://kindel.com/essays/just-right-porridge-and-leadership-principles/", + "note": "The under, just right, over idea this manual is built on." + }, + { + "title": "The Tension is Intentional", + "url": "https://kindel.com/essays/the-tension-is-intentional/", + "note": "The principles pull against each other on purpose. That is the work." + }, + { + "title": "Amazon's Leadership Principles", + "url": "https://www.amazon.jobs/content/en/our-workplace/leadership-principles", + "note": "The published source. It lists 16 now; this manual covers the classic 14." + } + ] }, { "id": "arm", @@ -1845,7 +1904,19 @@ } ], "examples": true, - "item": "10x factor" + "item": "10x factor", + "blog": [ + { + "title": "Life at Arm", + "url": "https://careers.arm.com/life-at-arm", + "note": "The published 10x Mindset. Two lenses, 10 principles." + }, + { + "title": "Arm Company", + "url": "https://www.arm.com/company", + "note": "The company page that states the mission those principles serve." + } + ] }, { "id": "coupang", @@ -2458,6 +2529,13 @@ "frugality" ] } + ], + "blog": [ + { + "title": "Coupang Leadership Principles", + "url": "https://www.coupang.jobs/en/coupang-leadership-principles/", + "note": "The published set. Fifteen principles, in the company's order." + } ] }, { @@ -2714,6 +2792,18 @@ "stay-humble" ] } + ], + "blog": [ + { + "title": "Launching Our Leadership Principles, Delivery Hero", + "url": "https://careers.deliveryhero.com/delivery-hero/2025-4/launching-our-leadership-principles", + "note": "The published announcement of the six principles." + }, + { + "title": "Delivery Hero Careers", + "url": "https://careers.deliveryhero.com/", + "note": "The careers home for the culture those principles describe." + } ] }, { @@ -2973,6 +3063,13 @@ "make-it-public" ] } + ], + "blog": [ + { + "title": "GitLab Values", + "url": "https://handbook.gitlab.com/handbook/values/", + "note": "The published CREDIT values. Six values, in the order that spells CREDIT." + } ] }, { @@ -3563,6 +3660,18 @@ "match-the-rigor" ] } + ], + "blog": [ + { + "title": "About Dawn Aerospace", + "url": "https://www.dawnaerospace.com/about", + "note": "The public mission statement. The tenets are first-party text published with permission." + }, + { + "title": "Careers at Dawn Aerospace", + "url": "https://www.dawnaerospace.com/careers", + "note": "How the company describes the work and the people it hires." + } ] }, { @@ -3674,6 +3783,23 @@ "grow-the-team" ] } + ], + "blog": [ + { + "title": "Toyota Way 2001", + "url": "https://www.toyota-global.com/company/history_of_toyota/75years/data/conditions/philosophy/toyotaway2001.html", + "note": "The official page for the two pillars and the five keywords." + }, + { + "title": "Toyota Annual Report 2018, the Toyota Way keywords", + "url": "https://www.toyota-global.com/pages/contents/investors/ir_library/annual/pdf/2018/ar18_3_en.pdf", + "note": "The first-party text that states each keyword. Page 29." + }, + { + "title": "Toyota Corporate Philosophy", + "url": "https://www.toyota-global.com/company/history_of_toyota/75years/data/conditions/philosophy/index.html", + "note": "The philosophy index that places that set next to the Guiding Principles." + } ] }, { @@ -3956,7 +4082,19 @@ ] } ], - "preamble": "Blue Origin's 14 Leadership Principles exemplify the company's culture and unyielding dedication to mission success." + "preamble": "Blue Origin's 14 Leadership Principles exemplify the company's culture and unyielding dedication to mission success.", + "blog": [ + { + "title": "Blue Origin Work Culture", + "url": "https://www.blueorigin.com/careers/work-culture", + "note": "The published 14 Leadership Principles." + }, + { + "title": "About Blue Origin", + "url": "https://www.blueorigin.com/about-blue", + "note": "The mission those principles are written to serve." + } + ] }, { "id": "usmc", @@ -4144,7 +4282,54 @@ ] } ], - "preamble": "Being of good character is not enough to lead. Marine leaders are guided by 11 leadership principles. The 14 leadership traits, remembered as JJ DID TIE BUCKLE, are the character those principles rest on. They are further reading, not a second set of principles: https://www.mcjrotc.marines.mil/About/Leadership-Traits/" + "preamble": "Being of good character is not enough to lead. Marine leaders are guided by 11 leadership principles. The 14 leadership traits, remembered as JJ DID TIE BUCKLE, are the character those principles rest on. They are further reading, not a second set of principles.", + "blog": [ + { + "title": "Leadership Traits", + "url": "https://www.mcjrotc.marines.mil/About/Leadership-Traits/", + "note": "The 14 traits, remembered as JJ DID TIE BUCKLE. Character, not a second set of principles." + }, + { + "title": "Officership Foundations, The Basic School", + "url": "https://www.trngcmd.marines.mil/Portals/207/Site%20Images/TBS/B1X0099XQ%20Officership%20Foundations.pdf", + "note": "The published source for the 11 principles, in numbered doctrine order." + }, + { + "title": "MCWP 6-10 Leading Marines", + "url": "https://www.marines.mil/News/Publications/MCPEL/Electronic-Library-Display/Article/899578/mcwp-6-10/", + "note": "The warfighting publication on ethos, traits, and the leadership principles." + }, + { + "title": "MCDP 6 Command and Control", + "url": "https://www.marines.mil/News/Publications/MCPEL/Electronic-Library-Display/Article/898678/mcdp-6/", + "note": "The doctrinal publication on deciding and acting faster than an adversary." + }, + { + "title": "Leading Marines, Sergeant Major of the Marine Corps", + "url": "https://www.smmc.marines.mil/Leading-Marines/", + "note": "The official page for the publication meant to be read cover to cover." + }, + { + "title": "Lejeune Leadership Institute, Marine Corps University", + "url": "https://www.usmcu.edu/LLI", + "note": "The institute that writes leadership education for the total force." + }, + { + "title": "Marine Leader Development, Marine Corps University", + "url": "https://www.usmcu.edu/Academic-Programs/Lejeune-Leadership-Institute/Marine-Leader-Development/", + "note": "The framework for teaching, coaching, counseling, and mentoring." + }, + { + "title": "Commandant's Professional Reading List", + "url": "https://grc-usmcu.libguides.com/cmc-reading-list", + "note": "The official reading program. Professional self-study from this list." + }, + { + "title": "Commandant's Professional Reading List, Lejeune Leadership Institute", + "url": "https://www.usmcu.edu/Academic-Programs/Lejeune-Leadership-Institute/LLI-Professional-Reading-List/", + "note": "The institute page that hosts the current list and the archive." + } + ] } ] } diff --git a/index.html b/index.html index 9634f6a..2ac2312 100644 --- a/index.html +++ b/index.html @@ -23,6 +23,7 @@

Behavioral interview questions

Or, you can use BIQ focused on how any of these companies define their principles.

+ diff --git a/js/biq.js b/js/biq.js index 976b2ce..bbb7183 100644 --- a/js/biq.js +++ b/js/biq.js @@ -207,6 +207,72 @@ return kindLabel(); } + // Further reading is the company catalog (`blog` on the shell), synced from + // principles data/teaching//index.json. Only https links render. + function httpsUrl(url) { + return /^https:\/\/[^\s<>"'\\]+$/.test(url); + } + + function readingItems(company) { + var raw = (company && company.blog) || []; + var out = []; + for (var i = 0; i < raw.length; i++) { + var item = raw[i] || {}; + var title = String(item.title || "").trim(); + var url = String(item.url || "").trim(); + var note = String(item.note || "").trim(); + if (!title || !note || !httpsUrl(url)) continue; + out.push({ title: title, url: url, note: note }); + } + return out; + } + + function renderReading(host, items) { + host.innerHTML = ""; + if (!items.length) { + host.hidden = true; + return; + } + host.hidden = false; + var label = document.createElement("p"); + label.className = "bhiq-further-label"; + label.id = "bhiq-further-label"; + label.textContent = "Further reading"; + host.appendChild(label); + host.setAttribute("aria-labelledby", "bhiq-further-label"); + var ul = document.createElement("ul"); + items.forEach(function (item) { + var li = document.createElement("li"); + var a = document.createElement("a"); + a.href = item.url; + a.rel = "noopener noreferrer"; + a.textContent = item.title; + li.appendChild(a); + var note = document.createElement("span"); + note.className = "bhiq-further-note"; + note.textContent = item.note; + li.appendChild(note); + ul.appendChild(li); + }); + host.appendChild(ul); + } + + // An older host shell has no reading container. Place it after the back + // link, or after the preamble when that shell has no back link. + function ensureReadingHost(pre) { + var host = document.getElementById("bhiq-further-reading"); + if (host) return host; + host = document.createElement("section"); + host.id = "bhiq-further-reading"; + host.className = "bhiq-further"; + host.hidden = true; + var back = document.getElementById("bhiq-back"); + var anchor = back && back.parentNode ? back : (pre && pre.parentNode ? pre : document.getElementById("bhiq-company")); + if (!anchor || !anchor.parentNode) return null; + anchor.parentNode.insertBefore(host, anchor.nextSibling); + return host; + } + // Emphasis and bare http(s) URLs. Escape first so a preamble cannot inject markup. function renderPreamble(md) { var s = String(md || "") @@ -346,6 +412,8 @@ pre.textContent = ""; } } + var reading = ensureReadingHost(pre); + if (reading) renderReading(reading, readingItems(currentCompany)); var chipsEl = document.getElementById("bhiq-chips"); if (chipsEl) chipsEl.setAttribute("aria-label", currentCompany && currentCompany.set ? currentCompany.set : "Principles"); var inputLabel = document.getElementById("bhiq-input-label"); diff --git a/scripts/check_reading.js b/scripts/check_reading.js new file mode 100644 index 0000000..24de1b3 --- /dev/null +++ b/scripts/check_reading.js @@ -0,0 +1,381 @@ +#!/usr/bin/env node +// Renders the real page script against the bank and checks the company view +// the way a visitor sees it. USMC must link the traits page from Further +// reading. The preamble no longer carries that URL. +// +// node scripts/check_reading.js +// +// Exits non-zero on any failure. +const fs = require("fs"); +const path = require("path"); + +const ROOT = path.dirname(__dirname); +const TRAITS = "https://www.mcjrotc.marines.mil/About/Leadership-Traits/"; +const fail = []; + +function check(cond, message) { + if (!cond) fail.push(message); +} + +function createElement(tag) { + const el = { + tagName: String(tag || "").toUpperCase(), + id: "", + className: "", + hidden: false, + childNodes: [], + parentNode: null, + attributes: {}, + listeners: {}, + value: "", + checked: false, + type: "", + src: "", + alt: "", + href: "", + rel: "", + tabIndex: 0, + _html: "", + _text: "", + }; + el.classList = { + add(c) { + const parts = el.className.split(/\s+/).filter(Boolean); + if (parts.indexOf(c) === -1) parts.push(c); + el.className = parts.join(" "); + }, + remove(c) { + el.className = el.className.split(/\s+/).filter((x) => x && x !== c).join(" "); + }, + }; + el.setAttribute = (k, v) => { + el.attributes[k] = String(v); + if (k === "id") el.id = String(v); + if (k === "class") el.className = String(v); + }; + el.getAttribute = (k) => (Object.prototype.hasOwnProperty.call(el.attributes, k) ? el.attributes[k] : null); + el.removeAttribute = (k) => { + delete el.attributes[k]; + }; + el.appendChild = (child) => { + detach(child); + child.parentNode = el; + el.childNodes.push(child); + return child; + }; + el.insertBefore = (child, ref) => { + detach(child); + child.parentNode = el; + const i = ref ? el.childNodes.indexOf(ref) : -1; + if (i < 0) el.childNodes.push(child); + else el.childNodes.splice(i, 0, child); + return child; + }; + el.replaceChild = (child, old) => { + const i = el.childNodes.indexOf(old); + detach(child); + child.parentNode = el; + if (i < 0) el.childNodes.push(child); + else el.childNodes.splice(i, 1, child); + old.parentNode = null; + return old; + }; + el.addEventListener = (type, fn) => { + (el.listeners[type] || (el.listeners[type] = [])).push(fn); + }; + el.click = () => { + const ev = { target: el, preventDefault() {}, key: "" }; + (el.listeners.click || []).forEach((fn) => fn.call(el, ev)); + }; + el.focus = () => {}; + Object.defineProperty(el, "nextSibling", { + get() { + const p = el.parentNode; + if (!p) return null; + const i = p.childNodes.indexOf(el); + return i >= 0 ? p.childNodes[i + 1] || null : null; + }, + }); + Object.defineProperty(el, "parentElement", { get() { return el.parentNode; } }); + Object.defineProperty(el, "innerHTML", { + get() { return el._html; }, + set(html) { + el._html = String(html); + el.childNodes = []; + }, + }); + Object.defineProperty(el, "textContent", { + get() { + if (el.childNodes.length) { + return el.childNodes.map((n) => n.textContent || "").join(""); + } + return el._text; + }, + set(v) { + el._text = String(v); + el.childNodes = []; + }, + }); + el.querySelectorAll = (sel) => collect(el, sel, false); + el.querySelector = (sel) => collect(el, sel, false)[0] || null; + el.closest = () => null; + return el; +} + +function detach(child) { + if (!child || !child.parentNode) return; + const p = child.parentNode; + const i = p.childNodes.indexOf(child); + if (i >= 0) p.childNodes.splice(i, 1); + child.parentNode = null; +} + +function matches(el, sel) { + if (!el || !el.tagName) return false; + let rest = sel.trim(); + let tag = ""; + if (rest[0] === ".") { + return el.className.split(/\s+/).indexOf(rest.slice(1)) !== -1; + } + if (rest[0] !== "[") { + const tm = /^([a-zA-Z0-9]+)/.exec(rest); + if (!tm) return false; + tag = tm[1].toUpperCase(); + rest = rest.slice(tm[1].length); + if (el.tagName !== tag) return false; + if (!rest) return true; + } + const attrs = rest.match(/\[[^\]]+\]/g) || []; + if (!attrs.length) return false; + return attrs.every((raw) => { + const body = raw.slice(1, -1); + const eq = body.indexOf("="); + if (eq < 0) return el.getAttribute(body) !== null; + const k = body.slice(0, eq); + let v = body.slice(eq + 1); + if (v[0] === '"' && v[v.length - 1] === '"') v = v.slice(1, -1); + return el.getAttribute(k) === v; + }); +} + +function collect(root, sel, includeSelf) { + const out = []; + function walk(node, self) { + if (!node) return; + if (self && matches(node, sel)) out.push(node); + (node.childNodes || []).forEach((child) => walk(child, true)); + } + walk(root, includeSelf); + return out; +} + +function findId(root, id) { + if (!root) return null; + if (root.id === id) return root; + for (const child of root.childNodes || []) { + const hit = findId(child, id); + if (hit) return hit; + } + return null; +} + +function makeDocument(withHost) { + const body = createElement("body"); + const main = createElement("main"); + body.appendChild(main); + const pre = createElement("p"); + pre.id = "bhiq-set-preamble"; + pre.hidden = true; + main.appendChild(pre); + const view = createElement("div"); + view.className = "bhiq-company-view"; + const company = createElement("div"); + company.id = "bhiq-company"; + company.setAttribute("data-universal-label", "Universal principles"); + view.appendChild(company); + const back = createElement("p"); + back.id = "bhiq-back"; + back.hidden = true; + view.appendChild(back); + if (withHost) { + const host = createElement("section"); + host.id = "bhiq-further-reading"; + host.className = "bhiq-further"; + host.hidden = true; + view.appendChild(host); + } + main.appendChild(view); + const input = createElement("input"); + input.id = "bhiq-input"; + main.appendChild(input); + const label = createElement("label"); + label.id = "bhiq-input-label"; + main.appendChild(label); + const chips = createElement("div"); + chips.id = "bhiq-chips"; + main.appendChild(chips); + const level = createElement("div"); + level.id = "bhiq-level-block"; + main.appendChild(level); + const results = createElement("div"); + results.id = "bhiq-results"; + main.appendChild(results); + return { + baseURI: "", + body, + createElement, + createTextNode(text) { + const n = createElement("#text"); + n.textContent = text; + return n; + }, + getElementById(id) { return findId(body, id); }, + querySelector(sel) { return collect(body, sel, true)[0] || null; }, + querySelectorAll(sel) { return collect(body, sel, true); }, + }; +} + +function anchors(root) { + return collect(root, "a", true); +} + +function boot(bank, search, withHost) { + const href = "http://127.0.0.1:8765/index.html" + (search || ""); + const location = { + href, + origin: "http://127.0.0.1:8765", + pathname: "/index.html", + search: search || "", + hash: "", + }; + const document = makeDocument(withHost); + document.baseURI = href; + const window = { + location, + history: { + replaceState(_state, _title, next) { + const u = new URL(next, location.href); + location.href = u.href; + location.pathname = u.pathname; + location.search = u.search; + location.hash = u.hash; + document.baseURI = location.href; + }, + }, + }; + const sessionStorage = { + store: {}, + getItem(k) { return Object.prototype.hasOwnProperty.call(this.store, k) ? this.store[k] : null; }, + setItem(k, v) { this.store[k] = String(v); }, + }; + const fetchImpl = () => Promise.resolve({ + ok: true, + json: () => Promise.resolve(bank), + }); + const code = fs.readFileSync(path.join(ROOT, "js", "biq.js"), "utf8"); + const run = new Function("fetch", "sessionStorage", "window", "document", code); + run(fetchImpl, sessionStorage, window, document); + return document; +} + +function flush() { + return new Promise((resolve) => setTimeout(resolve, 40)); +} + +function hrefs(document) { + const host = document.getElementById("bhiq-further-reading"); + if (!host || host.hidden) return []; + return anchors(host).map((a) => a.href); +} + +async function main() { + const page = fs.readFileSync(path.join(ROOT, "index.html"), "utf8"); + check(page.includes('id="bhiq-further-reading"'), "index.html is missing the further reading section"); + check(page.indexOf('id="bhiq-back"') < page.indexOf('id="bhiq-further-reading"'), + "further reading should follow the company picker"); + + const bank = JSON.parse(fs.readFileSync(path.join(ROOT, "data", "questions.json"), "utf8")); + const usmc = bank.companies.find((c) => c.id === "usmc"); + const gitlab = bank.companies.find((c) => c.id === "gitlab"); + check(usmc && (usmc.blog || []).some((item) => item.url === TRAITS), "bank is missing the USMC traits URL"); + + const doc = boot(bank, "?c=usmc", true); + await flush(); + const tiles = doc.getElementById("bhiq-company").querySelectorAll("[data-company]"); + check(tiles.length === bank.companies.length, "boot did not render a tile per company, got " + tiles.length); + const host = doc.getElementById("bhiq-further-reading"); + check(host && host.hidden === false, "USMC further reading is hidden"); + check((host.textContent || "").indexOf("Further reading") !== -1, "USMC view is missing the Further reading label"); + const rendered = hrefs(doc); + const want = (usmc.blog || []).map((item) => item.url); + check(rendered.join("\n") === want.join("\n"), "USMC rendered links:\n" + rendered.join("\n") + "\nwant:\n" + want.join("\n")); + const traits = anchors(host).find((a) => a.href === TRAITS); + check(traits && traits.textContent === "Leadership Traits", "USMC view does not link Leadership Traits to the traits page"); + check(traits && traits.rel === "noopener noreferrer", "traits link is missing rel=noopener noreferrer"); + const note = traits && traits.parentNode && anchors(traits.parentNode).length + ? (traits.parentNode.textContent || "") + : ""; + check(note.indexOf("JJ DID TIE BUCKLE") !== -1, "traits note is not in the USMC view"); + const pre = doc.getElementById("bhiq-set-preamble"); + check(pre && pre.hidden === false, "USMC preamble is hidden"); + check((pre.innerHTML || "").indexOf(TRAITS) === -1, "traits URL is still inside the preamble"); + check((pre.innerHTML || "").indexOf(" { + check(/^https:\/\//.test(url), "non-https link rendered: " + url); + }); + + const gitlabTile = tiles.find((t) => t.getAttribute("data-company") === "gitlab"); + check(gitlabTile, "gitlab tile is missing"); + if (gitlabTile) gitlabTile.click(); + const afterGitlab = hrefs(doc); + check(afterGitlab.indexOf(TRAITS) === -1, "USMC traits link stayed on the GitLab view"); + check(afterGitlab.join("\n") === (gitlab.blog || []).map((item) => item.url).join("\n"), + "GitLab rendered links do not match its catalog"); + + const genericTile = tiles.find((t) => t.getAttribute("data-company") === "generic"); + if (genericTile) genericTile.click(); + const generic = bank.companies.find((c) => c.id === "generic"); + check(hrefs(doc).join("\n") === (generic.blog || []).map((item) => item.url).join("\n"), + "universal view did not render its catalog"); + + // Older host shell: no reading node in the markup. The script has to add one. + const older = boot(bank, "?c=usmc", false); + await flush(); + const created = older.getElementById("bhiq-further-reading"); + check(created && hrefs(older).indexOf(TRAITS) !== -1, "older host shell did not render the traits link"); + + const evil = { + defaultCompany: "generic", + companies: [{ + id: "generic", + name: "Universal Leadership Principles", + set: "Leadership Principles", + item: "Leadership principle", + principles: [{ id: 1, slug: "ownership", name: "Ownership", aliases: [], questions: [] }], + blog: [ + { title: "Script", url: "javascript:alert(1)", note: "Drop this." }, + { title: "Insecure", url: "http://example.com/plain", note: "Drop this." }, + { title: "Official", url: "https://www.mcjrotc.marines.mil/About/Leadership-Traits/", note: "Keep the real traits page." }, + { title: " ", url: "https://example.com/blank-title", note: "Drop this." }, + ], + }], + }; + const filtered = boot(evil, "", true); + await flush(); + const kept = hrefs(filtered); + check(kept.length === 1 && kept[0] === TRAITS, "unsafe catalog entries were rendered: " + kept.join(", ")); + + if (fail.length) { + console.error(fail.join("\n")); + process.exit(1); + } + console.log("usmc traits link rendered"); + console.log("gitlab catalog rendered"); + console.log("generic catalog rendered"); + console.log("unsafe urls dropped"); +} + +main().catch((err) => { + console.error(err && err.stack ? err.stack : err); + process.exit(1); +}); diff --git a/scripts/sync_from_principles.py b/scripts/sync_from_principles.py index ef222d9..ccea3fc 100755 --- a/scripts/sync_from_principles.py +++ b/scripts/sync_from_principles.py @@ -7,6 +7,9 @@ Also syncs aliases from upstream term labels (kind: alias, equivalent, or facet) and facet labels from facets.json as a union with existing aliases so search cannot get worse. + +Also copies each company's Further reading catalog from +data/teaching//index.json. The field name stays `blog`. """ from __future__ import annotations @@ -35,6 +38,10 @@ "PRINCIPLES_SHA_URL", "https://api.github.com/repos/kindel/principles/commits/main", ) +TEACHING_INDEX_URL = os.environ.get( + "PRINCIPLES_TEACHING_INDEX_URL", + "https://raw.githubusercontent.com/kindel/principles/main/data/teaching/{company}/index.json", +) HOWTO = [ "Pick two or three questions per assigned {item}.", @@ -301,6 +308,76 @@ def company_shell(c: dict) -> dict: return shell +def https_url(url: object) -> bool: + """True for an https URL that can be used as an href.""" + if not isinstance(url, str) or not url.startswith("https://"): + return False + if any(ch.isspace() for ch in url): + return False + return not any(ch in url for ch in "<>\"'\\") + + +def reading_list(blog: object) -> list[dict]: + """Keep Further reading entries a visitor can open. + + Each catalog entry is title, url, and note. Anything else is dropped, + including a non-https URL, so the shell cannot grow a link the catalog + does not already contain. + """ + if not isinstance(blog, list): + return [] + out = [] + for item in blog: + if not isinstance(item, dict): + continue + title = item.get("title") + url = item.get("url") + note = item.get("note") + if not isinstance(title, str) or not title.strip(): + continue + if not isinstance(note, str) or not note.strip(): + continue + if not https_url(url): + continue + out.append({"title": title, "url": url, "note": note}) + return out + + +def sync_reading(existing: dict, blog: object) -> bool: + """Copy the company Further reading catalog onto a shell. + + `blog` is the list from the teaching index, or None when that file could + not be fetched. None leaves the shell alone so a network miss does not + wipe links the last sync stored. + """ + if blog is None: + return False + want = reading_list(blog) + if want: + if existing.get("blog") != want: + existing["blog"] = want + return True + return False + if "blog" in existing: + del existing["blog"] + return True + return False + + +def fetch_teaching_blog(company: str): + """Return the catalog blog list, or None if the file could not be read.""" + url = TEACHING_INDEX_URL.format(company=company) + try: + with urllib.request.urlopen(url, timeout=30) as r: + data = json.load(r) + except Exception: + return None + if not isinstance(data, dict): + return None + blog = data.get("blog") + return [] if blog is None else blog + + def sync_preamble(existing: dict, upstream: dict) -> bool: """Copy or remove the opening paragraph on a company that already exists. @@ -340,6 +417,7 @@ def main() -> int: added_companies = [] added_principles = [] preamble_changes = [] + reading_changes = [] for c in idx.get("companies", []): cid = c["id"] @@ -348,31 +426,35 @@ def main() -> int: bank["companies"].insert(insert_at(bank, idx, cid), shell) by_id[cid] = shell added_companies.append(cid) - continue - existing = by_id[cid] - if sync_preamble(existing, c): - preamble_changes.append(cid) - item = existing.get("item") or item_for(c["set"]) - - have_by_id = {} - have_by_slug = {} - for ep in existing.get("principles", []): - if isinstance(ep.get("id"), int): - have_by_id[ep["id"]] = ep - if ep.get("slug"): - have_by_slug[ep["slug"]] = ep - elif isinstance(ep.get("id"), str): - have_by_slug[ep["id"]] = ep - - for p in c.get("principles", []): - pid = p["id"] - slug = p.get("slug", "") - if pid in have_by_id: - continue - if slug in have_by_slug: - continue - existing.setdefault("principles", []).append(principle_shell(p, item)) - added_principles.append(f"{cid}/{slug or pid}") + existing = shell + else: + existing = by_id[cid] + if sync_preamble(existing, c): + preamble_changes.append(cid) + item = existing.get("item") or item_for(c["set"]) + + have_by_id = {} + have_by_slug = {} + for ep in existing.get("principles", []): + if isinstance(ep.get("id"), int): + have_by_id[ep["id"]] = ep + if ep.get("slug"): + have_by_slug[ep["slug"]] = ep + elif isinstance(ep.get("id"), str): + have_by_slug[ep["id"]] = ep + + for p in c.get("principles", []): + pid = p["id"] + slug = p.get("slug", "") + if pid in have_by_id: + continue + if slug in have_by_slug: + continue + existing.setdefault("principles", []).append(principle_shell(p, item)) + added_principles.append(f"{cid}/{slug or pid}") + + if sync_reading(existing, fetch_teaching_blog(cid)): + reading_changes.append(cid) # Sync aliases from upstream term labels (alias + equivalent kinds) alias_changes = sync_aliases(bank, idx) @@ -400,11 +482,14 @@ def main() -> int: if preamble_changes: lines.append("preamble updates:") lines.extend(f" {x}" for x in preamble_changes) + if reading_changes: + lines.append("further reading updates:") + lines.extend(f" {x}" for x in reading_changes) if default_change: lines.append(f"default company: {default_change}") if not any((added_companies, added_principles, alias_changes, - facet_stamps, preamble_changes, default_change)): - lines.append("no missing companies or principles, aliases and facets up to date") + facet_stamps, preamble_changes, reading_changes, default_change)): + lines.append("no missing companies or principles; aliases, facets, and further reading are up to date") print("\n".join(lines)) return 0 diff --git a/tests/test_framing.py b/tests/test_framing.py index 9426e66..47aec8f 100644 --- a/tests/test_framing.py +++ b/tests/test_framing.py @@ -3,6 +3,7 @@ import json import os +import subprocess import unittest ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -58,17 +59,45 @@ def test_the_display_name_is_not_a_company(self): self.assertEqual("Universal Leadership Principles", generic["name"]) self.assertEqual("generic", bank["defaultCompany"]) - def test_usmc_preamble_keeps_the_pinned_traits_url(self): + def test_usmc_preamble_points_to_further_reading(self): + # The traits URL lives on the company Further reading catalog. bank = json.loads(read("data", "questions.json")) usmc = [c for c in bank["companies"] if c["id"] == "usmc"][0] - self.assertIn( - "https://www.mcjrotc.marines.mil/About/Leadership-Traits/", - usmc["preamble"], - ) + traits = "https://www.mcjrotc.marines.mil/About/Leadership-Traits/" + self.assertIn("JJ DID TIE BUCKLE", usmc["preamble"]) + self.assertIn("further reading", usmc["preamble"]) + self.assertNotIn("http", usmc["preamble"]) + self.assertIn(traits, [item["url"] for item in usmc.get("blog") or []]) + # A new company can land before its teaching catalog exists. The page + # hides Further reading when blog is absent, so only a present list + # has to be a catalog of https links. + for company in bank["companies"]: + blog = company.get("blog") + if not blog: + continue + for item in blog: + self.assertEqual(["title", "url", "note"], list(item.keys())) + self.assertTrue(item["title"].strip(), company["id"]) + self.assertTrue(item["note"].strip(), company["id"]) + self.assertTrue(item["url"].startswith("https://"), item["url"]) + self.assertNotIn("\u2014", json.dumps(item, ensure_ascii=False)) + self.assertNotIn("\u2013", json.dumps(item, ensure_ascii=False)) js = read("js", "biq.js") self.assertIn("function renderPreamble(md)", js) + self.assertIn("function renderReading(host, items)", js) self.assertIn("/https?:\\/\\/[^\\s<]+/g", js) self.assertIn('rel="noopener noreferrer"', js) + self.assertIn('id="bhiq-further-reading"', read("index.html")) report = read(".kindel", "last-sync.txt").splitlines()[0] # The sync writes a SHA, or unknown when the commits API is down. self.assertRegex(report, r"^principles ([0-9a-f]{40}|unknown)$") + + def test_the_selected_company_renders_its_further_reading(self): + proc = subprocess.run( + ["node", os.path.join(ROOT, "scripts", "check_reading.js")], + cwd=ROOT, + capture_output=True, + text=True, + ) + self.assertEqual(0, proc.returncode, proc.stdout + proc.stderr) + self.assertIn("usmc traits link rendered", proc.stdout) diff --git a/tests/test_scripts.py b/tests/test_scripts.py index b2e23dd..b389129 100644 --- a/tests/test_scripts.py +++ b/tests/test_scripts.py @@ -252,5 +252,76 @@ def test_a_faceted_company_is_reported_as_inheriting_the_bank(self): self.assertEqual("empty questions; inherits the fixed bank via facets", note) +TRAITS_URL = "https://www.mcjrotc.marines.mil/About/Leadership-Traits/" + + +class ReadingSyncTest(unittest.TestCase): + """Further reading is the teaching catalog's blog list, copied as-is.""" + + def test_reading_list_keeps_only_https_catalog_entries(self): + blog = [ + { + "title": "Leadership Traits", + "url": TRAITS_URL, + "note": "The 14 traits.", + "extra": "drop", + }, + {"title": "Script", "url": "javascript:alert(1)", "note": "no"}, + {"title": "Plain", "url": "http://example.com/a", "note": "no"}, + {"title": " ", "url": "https://example.com/b", "note": "no"}, + "nope", + ] + self.assertEqual( + [{ + "title": "Leadership Traits", + "url": TRAITS_URL, + "note": "The 14 traits.", + }], + sync.reading_list(blog)) + + def test_sync_reading_copies_the_catalog_onto_the_shell(self): + existing = {"id": "usmc"} + blog = [{ + "title": "Leadership Traits", + "url": TRAITS_URL, + "note": "The 14 traits.", + }] + self.assertTrue(sync.sync_reading(existing, blog)) + self.assertEqual(blog, existing["blog"]) + self.assertFalse(sync.sync_reading(existing, blog)) + + def test_sync_reading_replaces_a_stale_list(self): + existing = {"blog": [{ + "title": "Old", + "url": "https://example.com/old", + "note": "Stale.", + }]} + blog = [{ + "title": "Leadership Traits", + "url": TRAITS_URL, + "note": "The 14 traits.", + }] + self.assertTrue(sync.sync_reading(existing, blog)) + self.assertEqual(blog, existing["blog"]) + + def test_an_empty_catalog_removes_stored_reading(self): + existing = {"blog": [{ + "title": "Old", + "url": "https://example.com/old", + "note": "Stale.", + }]} + self.assertTrue(sync.sync_reading(existing, [])) + self.assertNotIn("blog", existing) + + def test_a_failed_fetch_does_not_drop_stored_reading(self): + existing = {"blog": [{ + "title": "Leadership Traits", + "url": TRAITS_URL, + "note": "The 14 traits.", + }]} + self.assertFalse(sync.sync_reading(existing, None)) + self.assertEqual(TRAITS_URL, existing["blog"][0]["url"]) + + if __name__ == "__main__": unittest.main()