960 lines
69 KiB
HTML
960 lines
69 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Homelab Academy</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&family=Literata:ital,opsz,wght@0,7..72,400;0,7..72,600;1,7..72,400&display=swap" rel="stylesheet">
|
||
<style>
|
||
:root{
|
||
--ground:#12141a; --side:#181b22; --raised:#232733; --line:#262a33; --line2:#343947;
|
||
--text:#d9dce3; --read:#d9d4c8; --head:#f2efe8; --muted:#a4abb8; --faint:#8c93a0;
|
||
--accent:#f1b44c; --accent-dim:#8c7440; --on-accent:#12141a;
|
||
--ok:#86c79f; --bad:#ee8f8f;
|
||
--sans:"IBM Plex Sans",ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
|
||
--serif:"Literata",Georgia,"Times New Roman",serif;
|
||
--mono:"IBM Plex Mono",ui-monospace,"SFMono-Regular",Menlo,Consolas,monospace;
|
||
--top:52px; --sidew:296px; --fast:140ms;
|
||
color-scheme:dark;
|
||
}
|
||
/* a course may set its own accent through its manifest; nothing else is themed */
|
||
body[data-course="docker"]{--accent:#6ea8fe; --accent-dim:#3f5f8e}
|
||
body[data-look="reading"]{--pf:var(--serif); --ps:19px; --pl:1.75; --pc:var(--read); --col:660px; --wide:900px; --tf:var(--serif); --h1:40px}
|
||
body[data-look="work"]{--pf:var(--sans); --ps:15.5px; --pl:1.6; --pc:var(--text); --col:760px; --wide:1180px; --tf:var(--sans); --h1:28px}
|
||
|
||
*{box-sizing:border-box}
|
||
html{-webkit-text-size-adjust:100%}
|
||
body{margin:0; background:var(--ground); color:var(--text); font:400 14px/1.5 var(--sans); -webkit-font-smoothing:antialiased}
|
||
a{color:inherit}
|
||
button,input,textarea{font:inherit; color:inherit}
|
||
button{cursor:pointer}
|
||
[hidden]{display:none !important}
|
||
.tnum{font-variant-numeric:tabular-nums}
|
||
:focus-visible{outline:2px solid var(--accent); outline-offset:2px}
|
||
::selection{background:color-mix(in srgb,var(--accent) 35%,transparent)}
|
||
kbd{font:500 11px/1 var(--mono); display:inline-grid; place-items:center; min-width:18px; height:18px; padding:0 4px; border:1px solid color-mix(in srgb,currentColor 38%,transparent); border-radius:4px}
|
||
code,pre{font-family:var(--mono)}
|
||
.sr{position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap}
|
||
|
||
/* reviewer strip: not product UI */
|
||
.review{display:flex; align-items:center; gap:4px; padding:0 16px; height:34px; background:#0d0f13; border-bottom:1px dashed var(--line2); font-size:12px; color:var(--faint); overflow-x:auto; white-space:nowrap}
|
||
.review b{font-weight:500; color:var(--muted); margin-right:8px}
|
||
.review a{text-decoration:none; padding:3px 10px; border-radius:14px; color:var(--muted)}
|
||
.review a:hover{color:var(--text)}
|
||
.review a[aria-current]{background:var(--raised); color:var(--head)}
|
||
.review .keys{margin-left:auto; padding-left:16px; display:flex; gap:6px; align-items:center}
|
||
|
||
/* header */
|
||
.top{position:sticky; top:0; z-index:30; height:var(--top); display:flex; align-items:center; gap:14px; padding:0 16px; background:var(--ground); border-bottom:1px solid var(--line)}
|
||
.org{flex:0 0 calc(var(--sidew) - 30px); font-weight:600; color:var(--head); text-decoration:none; font-size:14px; letter-spacing:.005em}
|
||
.icon-btn{display:none; width:34px; height:34px; border-radius:18px; border:1px solid var(--line2); background:none; place-items:center; color:var(--muted)}
|
||
.switch{position:relative}
|
||
.switch-btn{display:flex; align-items:center; gap:10px; height:34px; padding:0 12px 0 5px; border:1px solid var(--line2); border-radius:18px; background:none; font-weight:500; font-size:13px; max-width:300px}
|
||
.switch-btn:hover,.btn:hover,.icon-btn:hover{background:var(--raised)}
|
||
.switch-btn .t{overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
|
||
.switch-btn svg{color:var(--faint); flex:none}
|
||
.menu{position:absolute; top:42px; left:0; width:330px; margin:0; padding:6px; list-style:none; background:var(--side); border:1px solid var(--line2); border-radius:14px}
|
||
.menu a{display:flex; gap:12px; align-items:center; padding:8px 10px; border-radius:10px; text-decoration:none}
|
||
.menu a:hover,.menu a:focus-visible{background:var(--raised)}
|
||
.menu a[aria-current] .mt{color:var(--head)}
|
||
.menu .mt{font-weight:500; font-size:13.5px}
|
||
.menu .ms{display:block; font-size:12px; color:var(--faint)}
|
||
.search{flex:1; max-width:380px; display:flex; align-items:center; gap:8px; height:34px; padding:0 8px 0 12px; border:1px solid var(--line2); border-radius:18px; color:var(--faint); margin-left:auto}
|
||
.search:focus-within{border-color:var(--faint)}
|
||
.search input{flex:1; min-width:0; border:0; background:none; outline:0; color:var(--text); font-size:13px}
|
||
.search input::placeholder{color:var(--faint)}
|
||
.seg{display:inline-flex; padding:2px; border:1px solid var(--line2); border-radius:20px; flex:none}
|
||
.seg button{border:0; background:none; height:28px; padding:0 13px; border-radius:16px; font-size:12.5px; font-weight:500; color:var(--faint); transition:background var(--fast),color var(--fast)}
|
||
.seg button:hover{color:var(--text)}
|
||
.seg button[aria-pressed="true"]{background:var(--raised); color:var(--head)}
|
||
|
||
/* shell */
|
||
.shell{display:flex; align-items:flex-start}
|
||
.side{position:sticky; top:var(--top); flex:none; width:var(--sidew); height:calc(100vh - var(--top)); overflow-y:auto; background:var(--side); border-right:1px solid var(--line); overscroll-behavior:contain}
|
||
body.no-side .side{display:none}
|
||
.side-head{display:flex; gap:12px; padding:18px 16px 16px; border-bottom:1px solid var(--line)}
|
||
.side-head h2{margin:0; font-size:14.5px; font-weight:600; color:var(--head); line-height:1.3}
|
||
.side-head .src{font-size:12px; color:var(--faint); margin-top:2px}
|
||
.prog{display:flex; align-items:center; gap:10px; margin-top:10px; font-size:12px; color:var(--muted)}
|
||
.bar{display:block; flex:1; height:4px; border-radius:2px; background:var(--line); overflow:hidden}
|
||
.bar i{display:block; height:100%; background:var(--accent); border-radius:2px}
|
||
.bar.dim i{background:var(--accent-dim)}
|
||
.mod{border-bottom:1px solid var(--line)}
|
||
.mod-head{width:100%; display:grid; grid-template-columns:22px 1fr auto 12px; gap:6px; align-items:baseline; padding:11px 16px 7px; border:0; background:none; text-align:left; font-size:13.5px; color:var(--text)}
|
||
.mod-head:hover{color:var(--head)}
|
||
.mod-head .n{color:var(--faint); font-size:12px}
|
||
.mod-head .c{color:var(--faint); font-size:12px}
|
||
.mod-head svg{color:var(--faint); transition:transform var(--fast); align-self:center}
|
||
.mod-head[aria-expanded="true"] svg{transform:rotate(90deg)}
|
||
.mod.is-current .mod-head{color:var(--head); font-weight:500}
|
||
.mod .bar{margin:0 16px 11px 44px; flex:none}
|
||
.lessons{list-style:none; margin:-3px 0 0; padding:0 0 8px}
|
||
.les{display:grid; grid-template-columns:22px 1fr auto; gap:6px; align-items:center; padding:6px 16px; font-size:13px; line-height:1.35; color:var(--muted); text-decoration:none}
|
||
.les:hover{background:color-mix(in srgb,var(--raised) 60%,transparent); color:var(--text)}
|
||
.les .m{width:8px; height:8px; border-radius:50%; border:1.5px solid var(--line2); justify-self:center}
|
||
.les.done .m{border:0; width:12px; height:12px; color:var(--faint)}
|
||
.les .d{font-size:12px; color:var(--faint)}
|
||
.les[aria-current="page"]{background:var(--raised); color:var(--head); box-shadow:inset 2px 0 0 var(--accent)}
|
||
.les[aria-current="page"] .m{border:0; background:var(--accent)}
|
||
.nohit{padding:16px; color:var(--faint); font-size:13px}
|
||
|
||
/* main column */
|
||
main{flex:1; min-width:0; padding:44px 40px 0}
|
||
.col{max-width:var(--col); margin:0 auto}
|
||
.wide{max-width:var(--wide); margin:0 auto}
|
||
.chap{font-size:13px; font-weight:500; color:var(--muted); margin:0 0 10px}
|
||
h1.title{font:600 var(--h1)/1.12 var(--tf); color:var(--head); margin:0; letter-spacing:-.012em; text-wrap:balance}
|
||
.meta{display:flex; flex-wrap:wrap; gap:14px 30px; margin:22px 0 0; padding-bottom:22px; border-bottom:1px solid var(--line); align-items:end}
|
||
.meta div{min-width:0}
|
||
.meta dt{font-size:12px; color:var(--faint); margin-bottom:2px}
|
||
.meta dd{margin:0; font-size:13.5px; color:var(--text)}
|
||
.meta dd code{font-size:12.5px}
|
||
.source{font-size:12.5px; color:var(--faint); margin:14px 0 0}
|
||
.source strong{font-weight:500; color:var(--muted)}
|
||
|
||
.prose{font-family:var(--pf); font-size:var(--ps); line-height:var(--pl); color:var(--pc)}
|
||
.prose p,.prose ul,.prose ol{margin:0 0 1em}
|
||
.prose h2{font:600 1.32em/1.25 var(--tf); color:var(--head); margin:2em 0 .55em; letter-spacing:-.005em}
|
||
.prose h3{font:600 1.08em/1.3 var(--tf); color:var(--head); margin:1.7em 0 .45em}
|
||
.prose strong{font-weight:600; color:var(--head)}
|
||
.prose li{margin-bottom:.35em; padding-left:.2em}
|
||
.prose :not(pre)>code{font-size:.78em; padding:.08em .32em; background:color-mix(in srgb,var(--raised) 75%,transparent); border-radius:4px; color:var(--text)}
|
||
pre.code{font-size:13.5px; line-height:1.6; background:var(--side); border:1px solid var(--line); border-radius:10px; padding:14px 16px; margin:0 0 1.2em; overflow-x:auto; color:var(--text); tab-size:2}
|
||
.prose pre.code{font-size:13.5px}
|
||
.lede{color:var(--head)}
|
||
.aside{border-left:2px solid var(--line2); padding-left:18px; color:var(--muted); font-size:.92em; margin:0 0 1.2em}
|
||
.aside strong{color:var(--text)}
|
||
.c{color:var(--faint); font-style:italic}
|
||
.k{color:var(--head); font-weight:500}
|
||
.trk{display:none}
|
||
body[data-track="js"] .trk[data-t="js"],body[data-track="scala"] .trk[data-t="scala"],body[data-track="fs"] .trk[data-t="fs"]{display:block}
|
||
body[data-track="js"] span.trk[data-t="js"],body[data-track="scala"] span.trk[data-t="scala"],body[data-track="fs"] span.trk[data-t="fs"]{display:inline}
|
||
|
||
details.fold{border-top:1px solid var(--line); border-bottom:1px solid var(--line); margin:0 0 1.4em}
|
||
details.fold+details.fold{margin-top:-1.4em; border-top:0}
|
||
details.fold>summary{list-style:none; display:flex; align-items:center; gap:10px; padding:12px 2px; font:500 14px/1.4 var(--sans); color:var(--text); cursor:pointer}
|
||
details.fold>summary::-webkit-details-marker{display:none}
|
||
details.fold>summary svg{color:var(--faint); transition:transform var(--fast); flex:none}
|
||
details.fold[open]>summary svg{transform:rotate(90deg)}
|
||
details.fold>summary:hover{color:var(--head)}
|
||
details.fold>summary .tier{margin-left:auto; font-size:12px; color:var(--faint); font-weight:400}
|
||
details.fold>.in{padding:2px 0 14px 24px}
|
||
.qs{counter-reset:q; list-style:none; padding:0}
|
||
.qs li{counter-increment:q; position:relative; padding:.55em 0 .55em 2.2em; border-top:1px solid var(--line); margin:0}
|
||
.qs li::before{content:counter(q); position:absolute; left:0; top:.55em; font:500 13px/2.1 var(--sans); color:var(--faint); font-variant-numeric:tabular-nums}
|
||
|
||
/* pills */
|
||
.btn{display:inline-flex; align-items:center; gap:8px; height:34px; padding:0 15px; border:1px solid var(--line2); border-radius:18px; background:none; font-size:13px; font-weight:500; text-decoration:none; white-space:nowrap; transition:background var(--fast),color var(--fast),border-color var(--fast)}
|
||
.btn.sm{height:28px; padding:0 11px; font-size:12.5px}
|
||
.btn.primary{background:var(--accent); border-color:var(--accent); color:var(--on-accent)}
|
||
.btn.primary:hover{background:color-mix(in srgb,var(--accent) 86%,#fff)}
|
||
.btn.quiet{border-color:transparent; color:var(--muted)}
|
||
.btn.quiet:hover{color:var(--text)}
|
||
.btn:disabled{opacity:.45; cursor:default}
|
||
|
||
/* floating action pill */
|
||
.dock{position:sticky; bottom:18px; z-index:10; display:flex; width:max-content; max-width:100%; margin:56px auto 18px; gap:4px; padding:5px; background:var(--raised); border:1px solid var(--line2); border-radius:24px; box-shadow:0 0 0 10px var(--ground)}
|
||
.dock .btn{height:36px; border-color:transparent}
|
||
.dock .btn:hover{background:var(--line2)}
|
||
.dock .btn.primary:hover{background:color-mix(in srgb,var(--accent) 86%,#fff)}
|
||
.dock .btn kbd{border-color:color-mix(in srgb,currentColor 45%,transparent)}
|
||
.dock #done[aria-pressed="true"]{color:var(--accent)}
|
||
.dock .sep{width:1px; background:var(--line2); margin:7px 2px}
|
||
|
||
/* video */
|
||
.player{aspect-ratio:16/9; background:#000; border-radius:10px; overflow:hidden}
|
||
.player video{display:block; width:100%; height:100%}
|
||
.split{display:grid; grid-template-columns:minmax(0,1fr) 300px; gap:40px; margin-top:28px}
|
||
.blockhead{display:flex; align-items:baseline; justify-content:space-between; gap:12px; margin:0 0 10px}
|
||
.blockhead h2{margin:0; font:600 15px/1.3 var(--sans); color:var(--head)}
|
||
.blockhead span{font-size:12px; color:var(--faint)}
|
||
.notes{width:100%; min-height:140px; resize:vertical; background:var(--side); border:1px solid var(--line); border-radius:10px; padding:12px 14px; color:var(--text); font-size:14px; line-height:1.55}
|
||
.notes:focus{outline:0; border-color:var(--faint)}
|
||
.rows{list-style:none; margin:0; padding:0; border-top:1px solid var(--line)}
|
||
.rows li{display:grid; grid-template-columns:20px minmax(0,1fr) auto; gap:10px; align-items:center; padding:10px 2px; border-bottom:1px solid var(--line); font-size:13.5px}
|
||
.rows li svg{color:var(--faint)}
|
||
.rows a{overflow-wrap:anywhere}
|
||
.rows .sz{white-space:nowrap; font-size:12px; color:var(--faint)}
|
||
.rows a{text-decoration:none}
|
||
.rows a:hover{text-decoration:underline; text-underline-offset:3px}
|
||
|
||
/* slides */
|
||
.stage{position:relative; aspect-ratio:16/9; background:#fff; border-radius:8px; overflow:hidden}
|
||
.stage img{width:100%; height:100%; object-fit:cover; display:block}
|
||
.stage:fullscreen{border-radius:0; background:#000; display:grid; place-items:center}
|
||
.stage:fullscreen img{object-fit:contain}
|
||
.deckbar{display:flex; align-items:center; gap:8px; margin:12px 0 0}
|
||
.deckbar .count{font-size:13px; color:var(--muted); margin:0 6px; min-width:58px; text-align:center}
|
||
.deckbar .gap{flex:1}
|
||
.thumbs{display:flex; gap:8px; margin:16px 0 0; overflow-x:auto; padding:3px 3px 6px}
|
||
.thumbs button{flex:none; width:104px; aspect-ratio:16/9; padding:0; border:1px solid var(--line2); border-radius:5px; overflow:hidden; background:#fff; opacity:.6; transition:opacity var(--fast)}
|
||
.thumbs button:hover{opacity:.9}
|
||
.thumbs button[aria-current="true"]{opacity:1; outline:2px solid var(--accent); outline-offset:2px}
|
||
.thumbs img{width:100%; height:100%; object-fit:cover; display:block}
|
||
.thumbs .more{flex:none; width:128px; aspect-ratio:16/9; border:1px dashed var(--line2); border-radius:5px; display:grid; place-items:center; text-align:center; font-size:11.5px; line-height:1.35; color:var(--faint); padding:6px}
|
||
|
||
/* code files */
|
||
.files{display:grid; grid-template-columns:220px minmax(0,1fr); border:1px solid var(--line); border-radius:10px; overflow:hidden; margin:0 0 1.4em; font-family:var(--sans)}
|
||
.tree{background:var(--side); border-right:1px solid var(--line); padding:8px 0; font-size:13px}
|
||
.tree .dir{padding:5px 12px; color:var(--muted); display:flex; gap:7px; align-items:center}
|
||
.tree .dir.sub{padding-left:26px}
|
||
.tree button{display:flex; gap:7px; align-items:center; width:100%; border:0; background:none; text-align:left; padding:5px 12px 5px 26px; color:var(--muted); font-size:13px}
|
||
.tree button.l2{padding-left:40px}
|
||
.tree button:hover{color:var(--text)}
|
||
.tree button[aria-current="true"]{background:var(--raised); color:var(--head)}
|
||
.tree span.f{display:flex; gap:7px; align-items:center; padding:5px 12px 5px 26px; color:var(--faint)}
|
||
.tree svg{flex:none; color:var(--faint)}
|
||
.viewer{min-width:0; display:flex; flex-direction:column; background:var(--ground)}
|
||
.tabs{display:flex; align-items:center; border-bottom:1px solid var(--line); padding:0 6px 0 0; overflow-x:auto}
|
||
.tabs button{border:0; background:none; padding:10px 14px; font-size:12.5px; color:var(--faint); border-bottom:2px solid transparent; margin-bottom:-1px; white-space:nowrap; font-family:var(--mono)}
|
||
.tabs button[aria-selected="true"]{color:var(--head); border-bottom-color:var(--accent)}
|
||
.tabs .btn{margin-left:auto}
|
||
.path{font-size:12px; color:var(--faint); padding:8px 16px 0}
|
||
.viewer pre.code{border:0; border-radius:0; background:none; margin:0; counter-reset:ln}
|
||
.ln{display:block; counter-increment:ln}
|
||
.ln::before{content:counter(ln); display:inline-block; width:2.2em; margin-right:1em; text-align:right; color:#5b6170}
|
||
.cmd{position:relative}
|
||
.cmd .btn{position:absolute; top:8px; right:8px}
|
||
|
||
/* exercise */
|
||
.ex{display:grid; grid-template-columns:minmax(0,.9fr) minmax(0,1.1fr); gap:36px; align-items:start}
|
||
.ex .prose{font-size:15px}
|
||
.edit{border:1px solid var(--line); border-radius:10px; overflow:hidden; position:sticky; top:calc(var(--top) + 20px)}
|
||
.edit-bar{display:flex; align-items:center; gap:6px; padding:6px 6px 6px 14px; background:var(--side); border-bottom:1px solid var(--line)}
|
||
.edit-bar .f{font:500 12.5px var(--mono); color:var(--muted); margin-right:auto}
|
||
.editor{display:grid; grid-template-columns:44px 1fr; background:var(--ground); height:270px}
|
||
.gutter{margin:0; padding:12px 10px 12px 0; text-align:right; color:#5b6170; font:13.5px/1.6 var(--mono); overflow:hidden; user-select:none; background:var(--ground)}
|
||
.editor textarea{border:0; resize:none; outline:0; background:none; padding:12px 14px 12px 6px; font:13.5px/1.6 var(--mono); color:var(--text); white-space:pre; overflow:auto; tab-size:2}
|
||
.editor:focus-within{box-shadow:inset 0 0 0 1px var(--faint)}
|
||
.results{border-top:1px solid var(--line); background:var(--side)}
|
||
.res-sum{display:flex; align-items:center; justify-content:space-between; gap:10px; padding:10px 14px; font-size:13px; color:var(--muted); border-bottom:1px solid var(--line)}
|
||
.res-sum b{font-weight:600; color:var(--head)}
|
||
.res-list{list-style:none; margin:0; padding:0; max-height:300px; overflow:auto}
|
||
.res-list li{padding:8px 14px; border-bottom:1px solid var(--line); font-size:13px}
|
||
.res-list li:last-child{border-bottom:0}
|
||
.res-row{display:grid; grid-template-columns:40px 1fr; gap:8px}
|
||
.res-row .s{font-weight:600; font-size:12px}
|
||
.pass .s{color:var(--ok)}
|
||
.fail .s{color:var(--bad)}
|
||
.res-row code{font-size:12.5px; color:var(--text)}
|
||
.diff{display:grid; grid-template-columns:64px 1fr; gap:2px 8px; margin:6px 0 2px 48px; font-size:12.5px}
|
||
.diff dt{color:var(--faint)}
|
||
.diff dd{margin:0; font-family:var(--mono); color:var(--text)}
|
||
.local{margin:20px 0 0; padding:12px 0 0; border-top:1px solid var(--line); font-size:13px; color:var(--muted)}
|
||
.local strong{color:var(--text); font-weight:500}
|
||
.local code{display:block; margin-top:6px; font-size:12.5px; color:var(--text)}
|
||
|
||
/* library */
|
||
.lib-h{font:600 24px/1.2 var(--sans); color:var(--head); margin:0 0 4px}
|
||
.lib-sub{color:var(--faint); margin:0 0 32px; font-size:13.5px}
|
||
.sec-h{font:600 14px var(--sans); color:var(--muted); margin:0 0 10px}
|
||
.cover{flex:none; align-self:flex-start; position:relative; width:var(--cw,44px); aspect-ratio:3/4; border-radius:6px; background:var(--cbg); color:var(--cfg); border:1px solid color-mix(in srgb,var(--cfg) 18%,transparent); overflow:hidden; font:600 calc(var(--cw,44px)*.34)/1 var(--sans); letter-spacing:-.03em; display:flex; align-items:flex-end; padding:calc(var(--cw,44px)*.12)}
|
||
.cover.tiny{--cw:24px; border-radius:4px; font-size:9px}
|
||
.continue{display:grid; grid-template-columns:auto 1fr auto; gap:20px; align-items:center; padding:18px 0; border-top:1px solid var(--line); border-bottom:1px solid var(--line); margin-bottom:44px}
|
||
.continue .t{font-size:16px; font-weight:600; color:var(--head)}
|
||
.continue .s{font-size:13.5px; color:var(--muted); margin:2px 0 10px}
|
||
.continue .prog{max-width:360px; margin:0}
|
||
.lib{border-top:1px solid var(--line)}
|
||
.lib-row,.lib-cols{display:grid; grid-template-columns:minmax(0,1fr) 90px 210px 180px; gap:24px; align-items:center}
|
||
.lib-cols{padding:0 0 8px; font-size:12px; color:var(--faint)}
|
||
.lib-row{padding:16px 0; border-bottom:1px solid var(--line); text-decoration:none}
|
||
.lib-row:hover .lt{color:var(--head); text-decoration:underline; text-underline-offset:3px; text-decoration-color:var(--line2)}
|
||
.lib-main{display:flex; gap:16px; align-items:center; min-width:0}
|
||
.lt{font-size:15px; font-weight:600; color:var(--text)}
|
||
.ls{font-size:13px; color:var(--faint); margin-top:2px}
|
||
.lnum{font-size:13.5px; color:var(--muted)}
|
||
.tags{display:flex; flex-wrap:wrap; gap:5px}
|
||
.tag{font-size:11.5px; color:var(--muted); border:1px solid var(--line2); border-radius:10px; padding:1px 8px}
|
||
.lib-row .prog{margin:0}
|
||
|
||
.toast{position:fixed; left:50%; bottom:24px; transform:translateX(-50%); z-index:50; background:var(--raised); border:1px solid var(--line2); border-radius:18px; padding:9px 16px; font-size:13px; color:var(--text)}
|
||
.scrim{display:none}
|
||
|
||
@media (max-width:1100px){
|
||
.ex{grid-template-columns:minmax(0,1fr)}
|
||
.edit{position:static}
|
||
.split{grid-template-columns:1fr; gap:28px}
|
||
.lib-row,.lib-cols{grid-template-columns:minmax(0,1fr) 70px 150px}
|
||
.lib-row>.tags,.lib-cols>:nth-child(3){display:none}
|
||
}
|
||
@media (max-width:960px){
|
||
.org{flex:none}
|
||
.icon-btn{display:inline-grid}
|
||
.side{position:fixed; top:var(--top); left:0; bottom:0; height:auto; z-index:40; transform:translateX(-100%); transition:transform 160ms ease-out}
|
||
body.side-open .side{transform:none}
|
||
body.side-open .scrim{display:block; position:fixed; left:0; right:0; bottom:0; z-index:35; background:rgb(10 11 15 / .6)}
|
||
body.no-side .icon-btn{visibility:hidden; width:0; margin-right:-14px; border:0}
|
||
main{padding:32px 24px 0}
|
||
}
|
||
@media (max-width:680px){
|
||
:root{--top:52px}
|
||
.top{gap:8px; padding:0 10px}
|
||
.org{display:none}
|
||
.search{display:none}
|
||
.switch-btn{max-width:170px}
|
||
.switch-btn .t{font-size:12.5px}
|
||
.seg button{padding:0 10px}
|
||
.review .keys{display:none}
|
||
main{padding:26px 18px 0}
|
||
body[data-look="reading"]{--ps:18px; --h1:31px}
|
||
.meta{gap:12px 22px}
|
||
.files{grid-template-columns:1fr}
|
||
.tree{border-right:0; border-bottom:1px solid var(--line)}
|
||
.continue{grid-template-columns:auto 1fr}
|
||
.continue .btn{grid-column:1/-1; justify-self:start}
|
||
.lib-row,.lib-cols{grid-template-columns:minmax(0,1fr) 92px; gap:14px}
|
||
.lib-row>.lnum,.lib-cols>:nth-child(2){display:none}
|
||
.lib-cols>:last-child{text-align:left}
|
||
.menu{width:min(330px,calc(100vw - 20px)); left:-48px}
|
||
.dock{bottom:12px}
|
||
.dock .lbl-long{display:none}
|
||
.thumbs button{width:84px}
|
||
}
|
||
@media (prefers-reduced-motion:reduce){*,*::before,*::after{transition:none !important; animation:none !important; scroll-behavior:auto !important}}
|
||
</style>
|
||
</head>
|
||
<body data-look="reading" data-track="js" data-course="snake">
|
||
|
||
<nav class="review" aria-label="Mockup: lesson types">
|
||
<b>Mockup: lesson types</b>
|
||
<a href="#text">Text</a><a href="#video">Text + video</a><a href="#slides">Slides</a><a href="#code">Text + code files</a><a href="#exercise">Exercise</a><a href="#home">Library</a>
|
||
<span class="keys"><kbd>[</kbd><kbd>]</kbd> lessons <kbd>/</kbd> search</span>
|
||
</nav>
|
||
|
||
<header class="top">
|
||
<button class="icon-btn" id="outlineBtn" aria-label="Course outline" aria-expanded="false" aria-controls="side"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h12M2 12h8"/></svg></button>
|
||
<a class="org" href="#home">Homelab Academy</a>
|
||
<div class="switch">
|
||
<button class="switch-btn" id="courseBtn" aria-haspopup="true" aria-expanded="false" aria-controls="courseMenu"><span id="courseTile"></span><span class="t" id="courseName"></span><svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 3.5l3 3 3-3"/></svg></button>
|
||
<ul class="menu" id="courseMenu" hidden></ul>
|
||
</div>
|
||
<label class="search"><svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5L14 14"/></svg><span class="sr">Search</span><input id="search" type="search" placeholder="Search this course" autocomplete="off"><kbd>/</kbd></label>
|
||
<div class="seg" role="group" aria-label="Look"><button id="lookReading" aria-pressed="true">Reading</button><button id="lookWork" aria-pressed="false">Work</button></div>
|
||
</header>
|
||
|
||
<div class="shell">
|
||
<aside class="side" id="side" aria-label="Course outline"></aside>
|
||
<div class="scrim" id="scrim"></div>
|
||
<main id="main">
|
||
<div id="view"></div>
|
||
<div class="dock" id="dock" role="toolbar" aria-label="Lesson actions">
|
||
<button class="btn quiet" id="prev" title="Previous lesson"><span class="sr">Previous lesson</span><kbd>[</kbd></button>
|
||
<button class="btn quiet" id="done" aria-pressed="false"><svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M3 8.5l3.2 3L13 4.5"/></svg><span id="doneLbl">Mark done</span></button>
|
||
<span class="sep" aria-hidden="true"></span>
|
||
<button class="btn primary" id="next"><span>Next<span class="lbl-long"> lesson</span></span><kbd>]</kbd></button>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
<div class="toast" id="toast" role="status" hidden></div>
|
||
|
||
<!-- 1. Text only -->
|
||
<template id="tpl-text">
|
||
<article class="col">
|
||
<p class="chap">Module 3</p>
|
||
<h1 class="title">Pure game rules</h1>
|
||
<dl class="meta">
|
||
<div><dt>Time</dt><dd>About 3–4 hours</dd></div>
|
||
<div><dt>You edit</dt><dd><code class="trk" data-t="js">js/src/rules.js</code><code class="trk" data-t="scala">scala/src/Rules.scala</code><code class="trk" data-t="fs">fsharp/src/Rules.fs</code></dd></div>
|
||
<div><dt>Needs</dt><dd>Modules 1 and 2</dd></div>
|
||
<div><dt>Track</dt><dd><div class="seg" role="group" aria-label="Track" data-trackseg><button data-tr="js">JavaScript</button><button data-tr="scala">Scala 3</button><button data-tr="fs">F#</button></div></dd></div>
|
||
</dl>
|
||
<div class="prose" style="margin-top:30px">
|
||
<h2 style="margin-top:0">Why this module</h2>
|
||
<p>In a stateful Snake, the rules live in methods like <code>snake.move()</code>, <code>snake.changeDirection(d)</code> and <code>game.checkCollision()</code>. They are called in some order from a game loop, and each one reads and writes shared fields. The <em>order</em> of those calls is part of the rules, but it is written nowhere. Is the collision checked before or after the tail moves? Before or after the snake grows? You find out by reading the loop, or by watching bugs.</p>
|
||
<p>In this module, every rule is a <strong>pure function from a state to a new state</strong>. <code>turn(state, dir)</code> returns the state after the player asks to turn. <code>advance(state)</code> returns the state one step later. Each function says, in one place, everything that happens in that step and in which order. You test it by handing it a state and comparing the result with the expected state; no timers, no setup, no mocks.</p>
|
||
<p>Two of the rules hide real subtleties that many Snake implementations get wrong. You will meet them as puzzles, and reason them through before you read the explanations.</p>
|
||
|
||
<h2>Learning objectives</h2>
|
||
<p>After this module you can:</p>
|
||
<ul>
|
||
<li><strong>implement</strong> game rules as total functions <code>State → State</code> using copy-with-changes;</li>
|
||
<li><strong>explain</strong> why a rule returns the state unchanged instead of throwing when it does not apply;</li>
|
||
<li><strong>reason</strong> about the order of events inside one step (move, grow, collide) and predict the result for a given board;</li>
|
||
<li><strong>explain</strong> why <code>turn</code> must compare against <code>dir</code> (last moved) and not <code>nextDir</code>;</li>
|
||
<li><strong>read</strong> a fixture case, draw it, and use it to debug.</li>
|
||
</ul>
|
||
|
||
<h2>Rules as functions State → State</h2>
|
||
<p>Each rule has the same shape: it takes the whole game state (plus maybe some input) and returns a whole new game state.</p>
|
||
<pre class="code">turn : (GameState, Direction) -> GameState
|
||
advance : GameState -> GameState</pre>
|
||
<p>Because the result is a full state, rules can be chained: <code>advance(turn(s, 'Up'))</code> is the state after turning up and moving. Because they are pure, a rule called with a frozen state from a fixture gives exactly the fixture’s expected state, every time.</p>
|
||
|
||
<h3>Total functions: “unchanged” is a valid answer</h3>
|
||
<p>A function is <strong>total</strong> if it returns a result for every possible input. It never throws, never loops forever, never returns <code>undefined</code> by accident. Our rules follow one simple convention: <strong>when a rule does not apply, it returns the state unchanged.</strong> <code>turn</code> while paused returns the same state.</p>
|
||
<p class="aside"><strong>From everyday programming:</strong> the usual alternative is a method that throws an <code>IllegalStateException</code> when it is called at the wrong time. A total rule behaves more like a disabled button in a user interface: pressing it is allowed, and nothing happens.</p>
|
||
<p>A typical rule therefore starts with a <strong>guard</strong>:</p>
|
||
<pre class="code trk" data-t="js"><span class="c">// (illustration on a different record)</span>
|
||
const heal = (hero, amount) => {
|
||
if (hero.hp <= 0) return hero <span class="c">// dead heroes cannot be healed: unchanged</span>
|
||
return { ...hero, hp: Math.min(hero.maxHp, hero.hp + amount) }
|
||
}</pre>
|
||
<pre class="code trk" data-t="scala">def heal(hero: Hero, amount: Int): Hero =
|
||
if hero.hp <= 0 then hero
|
||
else hero.copy(hp = math.min(hero.maxHp, hero.hp + amount))</pre>
|
||
<pre class="code trk" data-t="fs">let heal (hero: Hero) (amount: int) : Hero =
|
||
if hero.Hp <= 0 then hero
|
||
else { hero with Hp = min hero.MaxHp (hero.Hp + amount) }</pre>
|
||
|
||
<h2>Puzzle 1: can the snake bite its own tail?</h2>
|
||
<p>Here is a 5 x 5 board. The snake has four segments, in a tight square. Head <code>H</code> at (1,1), then (1,2), (2,2), and the tail <code>T</code> at (2,1). It is about to move <code>Right</code>, onto the tail’s cell. There is no apple.</p>
|
||
<pre class="code"> x=0 x=1 x=2 x=3 x=4
|
||
y=0 . . . . .
|
||
y=1 . H T . . snake (head first): (1,1) (1,2) (2,2) (2,1)
|
||
y=2 . b b . . nextDir: Right
|
||
y=3 . . . . .
|
||
y=4 . . . . .</pre>
|
||
<p><strong>Before reading on, decide:</strong> is this move a collision (game over), or legal? Draw what the board looks like after the move in each case. Then consider a second board: identical, except that there is an apple on (2,1), the cell where the tail is.</p>
|
||
<details class="fold">
|
||
<summary><svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M3.5 2l3 3-3 3"/></svg>Think about it first, then open the explanation</summary>
|
||
<div class="in">
|
||
<p>All segments of the snake move <strong>at the same time</strong>. In one step, the head enters a new cell and, if the snake is not eating, the tail leaves its cell. So in the first board, by the time the head arrives at (2,1), the tail has moved on. The move is <strong>legal</strong>, and the snake chases its own tail around the square.</p>
|
||
<p>In the second board, the head lands on the apple, so the snake <strong>grows</strong>: the tail stays where it is. Now the head and the tail would occupy the same cell, which is a collision: game over.</p>
|
||
<pre class="code">eating? yes -> body = the whole old snake (tail stays, the snake grows)
|
||
no -> body = the old snake minus its last cell (tail leaves)
|
||
collision if the new head is off the board, or is contained in body</pre>
|
||
<p>This is why <code>advance</code> must first decide whether it eats, then compute the body, and only then check for collisions. The order is the rule.</p>
|
||
</div>
|
||
</details>
|
||
|
||
<h2>Check your understanding</h2>
|
||
<ol class="qs">
|
||
<li>Why does <code>turn</code> return the state unchanged when paused, instead of throwing? Who would have to deal with the exception, and how would that change the rest of the program?</li>
|
||
<li>Without looking: on a 5 x 5 board, the snake is (1,1) (1,2) (2,2) (2,1) heading Up with <code>nextDir = Right</code> and an apple at (2,1). What does <code>advance</code> return? Now the same with no apple.</li>
|
||
<li>Explain to a friend, with a drawing, why <code>turn</code> must compare against <code>dir</code> and not <code>nextDir</code>. What sequence of key presses kills the snake in the wrong version?</li>
|
||
<li>After <code>advance</code> crashes into a wall, the snake is unchanged but <code>dir</code> is updated. What invariant does that keep?</li>
|
||
<li><code>advance</code> does not place a new apple after eating. Why is it a good idea to keep that out of <code>advance</code>?</li>
|
||
<li>Why are the fixture boards 5 x 5 rather than the real 20 x 20? What does that tell you about storing <code>width</code> and <code>height</code> in the state?</li>
|
||
</ol>
|
||
</div>
|
||
</article>
|
||
</template>
|
||
|
||
<!-- 2. Text + video -->
|
||
<template id="tpl-video">
|
||
<article class="wide">
|
||
<p class="chap">Section 1: Introduction to Docker</p>
|
||
<h1 class="title">Getting Started with Containerization</h1>
|
||
<p class="source"><strong>Video 1.2</strong>, 0:48. Imported from a Packt video package on 28 October 2026; titles, order and files come from the package.</p>
|
||
<div class="player" style="margin-top:22px"><video id="vid" controls preload="metadata" poster="video1_2-poster.jpg" src="docker-1-2.mp4"></video></div>
|
||
<div class="split">
|
||
<section>
|
||
<div class="blockhead"><h2>Your notes</h2><button class="btn sm" id="stamp">Insert time <span class="tnum" id="stampT">0:00</span></button></div>
|
||
<textarea class="notes" id="notesTa" aria-label="Your notes for this clip" placeholder="Only you see these. Insert time adds a link back to this point in the clip."></textarea>
|
||
<div class="prose" style="margin-top:22px; font-size:calc(var(--ps) * .9)">
|
||
<p>This clip sets up the question the rest of the section answers: why run an application in a container at all, rather than straight on a server or in a virtual machine. Clip 1.3 then opens the box and shows the two kernel features that make a container possible.</p>
|
||
</div>
|
||
</section>
|
||
<section>
|
||
<div class="blockhead"><h2>Files for this section</h2><span>Section 1</span></div>
|
||
<ul class="rows" id="attach1"></ul>
|
||
<p class="source">Section 1 has no code in the companion repo.</p>
|
||
</section>
|
||
</div>
|
||
</article>
|
||
</template>
|
||
|
||
<!-- 3. Slides -->
|
||
<template id="tpl-slides">
|
||
<article class="wide">
|
||
<p class="chap">Module 2</p>
|
||
<h1 class="title">Best Practices for Writing Functions</h1>
|
||
<p class="source"><strong>Elle Krout</strong>, Pluralsight. Slide deck, 59 pages, imported from PDF.</p>
|
||
<figure class="stage" id="stage" style="margin:22px 0 0"><img id="slideImg" alt=""></figure>
|
||
<div class="deckbar">
|
||
<button class="btn sm" id="sPrev" aria-label="Previous slide"><svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M7.5 2.5L4 6l3.5 3.5"/></svg></button>
|
||
<span class="count tnum" id="sCount" aria-live="polite">1 / 59</span>
|
||
<button class="btn sm" id="sNext" aria-label="Next slide"><svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M4.5 2.5L8 6 4.5 9.5"/></svg></button>
|
||
<span class="gap"></span>
|
||
<span class="source" style="margin:0">Arrow keys change slides</span>
|
||
<button class="btn sm" id="sFull"><svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1.5 4.5v-3h3M7.5 1.5h3v3M10.5 7.5v3h-3M4.5 10.5h-3v-3"/></svg>Fullscreen</button>
|
||
</div>
|
||
<div class="thumbs" id="thumbs" role="group" aria-label="Slides"></div>
|
||
<div class="col" style="margin-top:40px">
|
||
<div class="prose">
|
||
<h2 style="margin-top:0">Notes for this deck</h2>
|
||
<p>The deck opens the module with one rule, <strong>keep code small</strong>, and then takes it apart three ways: a function should serve a single purpose, it should be idempotent, and it should depend on as little as possible.</p>
|
||
<p>Slides 5 to 8 return to the same three boxes and fill one at a time. The point on slide 5 is the easiest to miss in practice: each bit of pathing (one route, one event source) should be its own function, so that a change to one path can never break another.</p>
|
||
<p>Idempotent is the one to check in your own code. Serverless platforms retry: a function that runs twice for the same event must leave the world as if it had run once.</p>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
</template>
|
||
|
||
<!-- 4. Text + code files -->
|
||
<template id="tpl-code">
|
||
<article class="wide"><div class="col">
|
||
<p class="chap">Section 8: Containerizing Applications (Sample Project Using Python)</p>
|
||
<h1 class="title">Writing the Dockerfile</h1>
|
||
<p class="source"><strong>Text lesson</strong> between clips 8.2 and 8.4. Files come from the course’s companion repo.</p>
|
||
<div class="prose" style="margin-top:28px">
|
||
<p>NyxAcademy is the small Django site this section uses as its sample project. Started with <code>python manage.py runserver</code>, it works on your laptop because your laptop already has the right Python, the right Django and a database file next to the code. A container has none of that until the Dockerfile says so.</p>
|
||
<p>The Dockerfile in the sample repo does three things in order: start from a slim Python image, install Django, copy the project in. Its last line hands over to <code>startup.sh</code>, which prepares the database and the static files every time the container starts, and then runs the server.</p>
|
||
<p>Read both files side by side before you build. Notice that the server listens on <code>0.0.0.0</code>, not <code>127.0.0.1</code>: inside a container, localhost is the container itself, and the published port could never reach it.</p>
|
||
</div></div>
|
||
<div class="blockhead" style="margin-top:34px"><h2>Files for this section</h2><button class="btn sm" id="zip"><svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M6 1.5v6.5M3.2 5.5L6 8.2l2.8-2.7M1.5 10.5h9"/></svg>Download files for this section (zip)</button></div>
|
||
<div class="files">
|
||
<div class="tree" id="tree"></div>
|
||
<div class="viewer">
|
||
<div class="tabs" role="tablist" id="tabs"></div>
|
||
<div class="path" id="fpath"></div>
|
||
<pre class="code" id="fcode"></pre>
|
||
</div>
|
||
</div>
|
||
<div class="col"><div class="prose">
|
||
<h2>Run it on your machine</h2>
|
||
<p>From the folder you unzipped, build the image and start a container. Then open <code>localhost:8000</code>.</p>
|
||
</div>
|
||
<div class="cmd"><pre class="code" id="cmds">cd "S08 - Containerizing Applications/NyxAcademy-main"
|
||
docker build -f Dockerfile.example -t nyxacademy .
|
||
docker run --rm -p 8000:8000 nyxacademy</pre><button class="btn sm" data-copy="cmds">Copy</button></div>
|
||
<div class="blockhead" style="margin-top:30px"><h2>Attachments</h2><span>Whole course</span></div>
|
||
<ul class="rows" id="attach8"></ul>
|
||
</div></article>
|
||
</template>
|
||
|
||
<!-- 5. Exercise -->
|
||
<template id="tpl-exercise">
|
||
<article class="wide">
|
||
<div class="ex">
|
||
<div>
|
||
<p class="chap">Module 3: Pure game rules</p>
|
||
<h1 class="title">Exercise 3.1: <code style="font-size:.9em">move</code></h1>
|
||
<div class="prose" style="margin-top:20px">
|
||
<p>Return the cell one step from <code>p</code> in direction <code>dir</code>, where <code>dir</code> is <code>'Up'</code>, <code>'Down'</code>, <code>'Left'</code> or <code>'Right'</code>. The board’s <code>y</code> grows downward, so <code>Up</code> is <code>y - 1</code>.</p>
|
||
<pre class="code">move({x: 2, y: 2}, 'Up') -> {x: 2, y: 1}
|
||
move({x: 4, y: 2}, 'Right') -> {x: 5, y: 2}</pre>
|
||
<p>It does <strong>not</strong> wrap around the edges: deciding what “off the board” means is <code>inBounds</code>’s job. And it is a rule, so it builds a new point; the point you were given must come back untouched.</p>
|
||
<p><code>delta(dir)</code> and <code>point(x, y)</code> from Module 2 are already imported.</p>
|
||
</div>
|
||
<h2 class="sec-h" style="margin-top:26px">Hints</h2>
|
||
<details class="fold"><summary><svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M3.5 2l3 3-3 3"/></svg>Nudge<span class="tier">1 of 3</span></summary><div class="in prose"><p><code>delta(dir)</code> gives you a point to add.</p></div></details>
|
||
<details class="fold"><summary><svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M3.5 2l3 3-3 3"/></svg>Stronger hint<span class="tier">2 of 3</span></summary><div class="in prose"><p>Build a new point whose <code>x</code> is <code>p.x + d.x</code> and whose <code>y</code> is <code>p.y + d.y</code>, where <code>d = delta(dir)</code>.</p></div></details>
|
||
<details class="fold"><summary><svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M3.5 2l3 3-3 3"/></svg>Near-solution<span class="tier">try the others first</span></summary><div class="in prose"><pre class="code">export const move = (p, dir) => { const d = delta(dir); return point(___, ___) }</pre></div></details>
|
||
<p class="local"><strong>Runs in your browser — the full project runs on your machine.</strong> This page checks <code style="display:inline">move</code> against the five fixture cases plus a purity check. The whole module, in any track:<code>cd js && node --test "test/m03-*.test.js"</code></p>
|
||
</div>
|
||
<div class="edit">
|
||
<div class="edit-bar"><span class="f">rules.js</span><button class="btn sm quiet" id="reset">Reset</button><button class="btn sm primary" id="run">Run tests <kbd>Ctrl ↵</kbd></button></div>
|
||
<div class="editor"><pre class="gutter" id="gutter" aria-hidden="true"></pre><textarea id="code" spellcheck="false" aria-label="Code editor, rules.js"></textarea></div>
|
||
<div class="results" id="results"></div>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
</template>
|
||
|
||
<!-- 6. Library -->
|
||
<template id="tpl-home">
|
||
<article class="wide" style="max-width:1080px">
|
||
<h1 class="lib-h">Library</h1>
|
||
<p class="lib-sub">Four courses on this server.</p>
|
||
<h2 class="sec-h">Continue</h2>
|
||
<div class="continue">
|
||
<span class="cover" style="--cw:56px;--cbg:#272319;--cfg:#f1b44c">PS</span>
|
||
<div><div class="t">Pure Snake</div><div class="s" id="contS"></div>
|
||
<div class="prog"><span class="bar"><i id="contBar"></i></span><span class="tnum" id="contN"></span></div></div>
|
||
<a class="btn primary" id="contBtn">Continue <kbd>]</kbd></a>
|
||
</div>
|
||
<h2 class="sec-h">Courses</h2>
|
||
<div class="lib-cols"><span>Course</span><span>Lessons</span><span>Contains</span><span>Progress</span></div>
|
||
<div class="lib" id="lib"></div>
|
||
</article>
|
||
</template>
|
||
|
||
<template id="tpl-generic">
|
||
<article class="col">
|
||
<p class="chap" id="gChap"></p>
|
||
<h1 class="title" id="gTitle"></h1>
|
||
<div class="prose" style="margin-top:24px"><p style="color:var(--faint)">This lesson is not drawn in the mockup. The six lesson types are in the strip at the top; <kbd>[</kbd> and <kbd>]</kbd> keep moving through the course.</p></div>
|
||
</article>
|
||
</template>
|
||
|
||
<script>
|
||
const ICON = {
|
||
chev:'<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M3.5 2l3 3-3 3"/></svg>',
|
||
check:'<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M3 8.5l3.2 3L13 4.5"/></svg>',
|
||
file:'<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M4 1.5h5.5L13 5v9.5H4z"/><path d="M9.5 1.5V5H13"/></svg>',
|
||
dir:'<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M1.5 3.5h5l1.5 1.5h6.5v8.5h-13z"/></svg>',
|
||
pdf:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M3.5 1.5h6L13 5v9.5H3.5z"/><path d="M5.5 9h5M5.5 11.5h3.5"/></svg>'
|
||
};
|
||
const $ = (s, r = document) => r.querySelector(s);
|
||
const esc = s => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
|
||
/* ---------- course manifests (what an import or a repo would provide) ---------- */
|
||
const SNAKE_MODS = ['Setup and how to use this course','Purity and immutability','The game as data','Pure game rules','Randomness without side effects','Folding time','Rendering as data','The IO type','Event streams','Wiring it together','Extensions, all pure','Capstone: Pong, by the method'];
|
||
const SNAKE_EX = [0,4,4,4,3,3,3,4,3,4,3,1];
|
||
const M3_EX = ['move','inBounds','turn','advance'];
|
||
const snakeOutline = SNAKE_MODS.map((t, n) => {
|
||
const ls = [{id:`s${n}-r`, t, d:'Text', view: n === 3 ? 'text' : null}];
|
||
for (let k = 1; k <= SNAKE_EX[n]; k++) ls.push({id:`s${n}-e${k}`, t:`Exercise ${n}.${k}` + (n === 3 ? `: ${M3_EX[k-1]}` : ''), d:'Exercise', view: n === 3 && k === 1 ? 'exercise' : null});
|
||
if (n > 0) ls.push({id:`s${n}-q`, t:'Check your understanding', d:'Text'});
|
||
return {n, t, lessons: ls};
|
||
});
|
||
|
||
const clips = (s, list) => list.map(([t, d, extra], i) => Object.assign({id:`d${s}-${i+1}`, t:`${s}.${i+1} ${t}`, d}, extra || {}));
|
||
const STD = (s, mid) => clips(s, [['Topic Introduction','1:08'], ...mid, ['Summary','1:22']]);
|
||
const dockerOutline = [
|
||
{n:1, t:'Introduction to Docker', lessons: clips(1, [['Course Introduction','2:41'],['Getting Started with Containerization','0:48',{view:'video'}],['Containerization Core Concepts (Namespaces and Cgroups)','6:12'],['Available Container Toolsets','4:05'],['Docker at a Glance','3:37'],['Docker Architecture','5:20'],['Core Docker Objects (Images, Containers, Networks and Volumes)','7:46'],['Demonstration – Official Docker Documentation Walkthrough','6:58'],['Summary','1:52']])},
|
||
{n:2, t:'Getting Started with Docker', lessons: clips(2, [['Topic Introduction','1:10'],['Docker Installation Methods','4:22'],['Demonstration – Installing Docker Engine on Linux','8:15'],['Docker Playground','3:40'],['Introduction to Docker CLI','6:05'],['Summary','1:20']])},
|
||
{n:3, t:'Docker Images', lessons: STD(3, [['Image Layers','5:12'],['Demonstration – Pulling and Inspecting Images','7:30']])},
|
||
{n:4, t:'Docker Containers', lessons: STD(4, [['Container Lifecycle','4:48'],['Demonstration – Running and Managing Containers','9:02']])},
|
||
{n:5, t:'Building an Image with a Dockerfile', lessons: STD(5, [['Dockerfile Instructions','6:40'],['Demonstration – Building Your First Image','8:11']])},
|
||
{n:6, t:'Docker Networking', lessons: STD(6, [['Network Drivers','5:55'],['Demonstration – Connecting Containers','7:18']])},
|
||
{n:7, t:'Docker Volumes', lessons: STD(7, [['Volumes and Bind Mounts','5:03'],['Demonstration – Persisting Data','6:47']])},
|
||
{n:8, t:'Containerizing Applications (Sample Project Using Python)', lessons: clips(8, [['Topic Introduction','1:05'],['The Sample Project: NyxAcademy','3:30'],['Writing the Dockerfile','Text',{view:'code'}],['Demonstration – Building and Running NyxAcademy','7:45'],['Summary','1:15']])},
|
||
{n:9, t:'Docker Compose', lessons: STD(9, [['Compose File Basics','5:36'],['Demonstration – Multi-container NyxAcademy','9:20']])}
|
||
];
|
||
const serverlessOutline = [
|
||
{n:1, t:'Course Overview', lessons:[{id:'v1-1', t:'Course overview', d:'4 pages'}]},
|
||
{n:2, t:'Best Practices for Writing Functions', lessons:[{id:'v2-1', t:'Writing functions', d:'59 pages', view:'slides'}]},
|
||
{n:3, t:'Best Practices for Serverless Architecture', lessons:[{id:'v3-1', t:'Architecture', d:'42 pages'}]},
|
||
{n:4, t:'Best Practices for Security and Observability', lessons:[{id:'v4-1', t:'Security and observability', d:'37 pages'}]}
|
||
];
|
||
const systemicOutline = [
|
||
{n:0, t:'Before you start', lessons:[{id:'y0-1', t:'Choose your track: Rust or TypeScript', d:'Text'},{id:'y0-2', t:'The naive engine', d:'Exercise'}]},
|
||
{n:1, t:'Part I: Hearthstone', lessons:['Cards and zones','Triggers','Death phases','Auras','Text assembly'].map((t,i)=>({id:`y1-${i}`, t, d:'Text'}))},
|
||
{n:2, t:'Part II: Baba Is You', lessons:['Rules as pushable text','Parsing','Movement','Win and lose'].map((t,i)=>({id:`y2-${i}`, t, d:'Text'}))},
|
||
{n:3, t:'Part III: SpellGrid', lessons:['Combining both systems','Capstone: SpellGrid'].map((t,i)=>({id:`y3-${i}`, t, d:'Text'}))}
|
||
];
|
||
|
||
const COURSES = {
|
||
snake:{title:'Pure Snake', mono:'PS', cbg:'#272319', cfg:'#f1b44c', src:'Your course. Tracks: JavaScript, Scala 3, F#', types:['Text','Exercises'], outline:snakeOutline, start:'text'},
|
||
docker:{title:'Mastering Docker Essentials – Hands-on DevOps', mono:'MD', cbg:'#18202d', cfg:'#6ea8fe', src:'Packt, Thinknyx Technologies. Imported video package', types:['Video','Text','Files'], outline:dockerOutline, start:'video'},
|
||
serverless:{title:'Best Practices in Serverless', mono:'BS', cbg:'#2a1b24', cfg:'#f2709f', src:'Elle Krout, Pluralsight. Imported slides', types:['Slides'], outline:serverlessOutline, start:'slides'},
|
||
systemic:{title:'Systemic Design', mono:'SD', cbg:'#192521', cfg:'#86c7a6', src:'Your course. Tracks: Rust, TypeScript', types:['Text','Exercises'], outline:systemicOutline, start:'l/y1-0'}
|
||
};
|
||
const VIEWS = {text:['snake','s3-r'], exercise:['snake','s3-e1'], video:['docker','d1-2'], code:['docker','d8-3'], slides:['serverless','v2-1']};
|
||
|
||
const done = new Set();
|
||
snakeOutline.slice(0,3).forEach(m => m.lessons.forEach(l => done.add(l.id)));
|
||
['d1-1','v1-1','y0-1','y0-2'].forEach(id => done.add(id));
|
||
|
||
const flat = c => COURSES[c].outline.flatMap(m => m.lessons);
|
||
const findLesson = id => { for (const c in COURSES) { const l = flat(c).find(x => x.id === id); if (l) return [c, l]; } return [null, null]; };
|
||
const href = l => '#' + (l.view || 'l/' + l.id);
|
||
const pct = (ls) => ls.length ? Math.round(100 * ls.filter(l => done.has(l.id)).length / ls.length) : 0;
|
||
const cover = (c, cls = '', cw) => `<span class="cover ${cls}" style="--cbg:${COURSES[c].cbg};--cfg:${COURSES[c].cfg}${cw ? ';--cw:' + cw + 'px' : ''}" aria-hidden="true">${COURSES[c].mono}</span>`;
|
||
|
||
/* ---------- state ---------- */
|
||
const S = {course:'snake', lesson:'s3-r', view:'text', lookPref:'reading', open:new Set()};
|
||
|
||
/* ---------- sidebar ---------- */
|
||
function renderSide() {
|
||
const c = COURSES[S.course], q = $('#search').value.trim().toLowerCase();
|
||
const all = flat(S.course);
|
||
let h = `<div class="side-head">${cover(S.course, '', 36)}<div style="flex:1;min-width:0"><h2>${esc(c.title)}</h2><div class="src">${esc(c.src)}</div>
|
||
<div class="prog"><span class="bar"><i style="width:${pct(all)}%"></i></span><span class="tnum">${pct(all)}%</span></div></div></div>`;
|
||
let hits = 0;
|
||
for (const m of c.outline) {
|
||
const ls = q ? m.lessons.filter(l => l.t.toLowerCase().includes(q)) : m.lessons;
|
||
if (q && !ls.length && !m.t.toLowerCase().includes(q)) continue;
|
||
hits++;
|
||
const cur = m.lessons.some(l => l.id === S.lesson);
|
||
const open = q || S.open.has(S.course + m.n);
|
||
const d = m.lessons.filter(l => done.has(l.id)).length, p = pct(m.lessons);
|
||
h += `<div class="mod${cur ? ' is-current' : ''}"><button class="mod-head" aria-expanded="${!!open}" data-mod="${m.n}"><span class="n tnum">${m.n}</span><span>${esc(m.t)}</span><span class="c tnum">${d}/${m.lessons.length}</span>${ICON.chev}</button>
|
||
<span class="bar${p === 100 || !cur ? ' dim' : ''}" aria-hidden="true"><i style="width:${p}%"></i></span>`;
|
||
if (open) h += '<ol class="lessons">' + (q ? ls : m.lessons).map(l => `<li><a class="les${done.has(l.id) ? ' done' : ''}" href="${href(l)}"${l.id === S.lesson ? ' aria-current="page"' : ''}><span class="m">${done.has(l.id) ? ICON.check : ''}</span><span>${esc(l.t)}<span class="sr">${done.has(l.id) ? ', done' : l.id === S.lesson ? ', current' : ''}</span></span><span class="d tnum">${esc(l.d)}</span></a></li>`).join('') + '</ol>';
|
||
h += '</div>';
|
||
}
|
||
if (!hits) h += `<p class="nohit">No lessons match “${esc(q)}”.</p>`;
|
||
$('#side').innerHTML = h;
|
||
}
|
||
$('#side').addEventListener('click', e => {
|
||
const b = e.target.closest('.mod-head'); if (!b) { if (e.target.closest('.les')) closeSide(); return; }
|
||
const k = S.course + b.dataset.mod; S.open.has(k) ? S.open.delete(k) : S.open.add(k);
|
||
renderSide(); $(`.mod-head[data-mod="${b.dataset.mod}"]`).focus();
|
||
});
|
||
|
||
/* ---------- header ---------- */
|
||
function renderHeader() {
|
||
$('#courseTile').innerHTML = S.view === 'home' ? `<span style="width:24px;display:grid;place-items:center;color:var(--faint)">${ICON.dir}</span>` : cover(S.course, 'tiny');
|
||
$('#courseName').textContent = S.view === 'home' ? 'All courses' : COURSES[S.course].title;
|
||
$('#courseMenu').innerHTML = `<li><a href="#home"${S.view === 'home' ? ' aria-current="page"' : ''}><span style="width:24px;display:grid;place-items:center;color:var(--faint)">${ICON.dir}</span><span><span class="mt">Library</span><span class="ms">All four courses</span></span></a></li>` +
|
||
Object.entries(COURSES).map(([k, c]) => `<li><a href="#${c.start}"${k === S.course && S.view !== 'home' ? ' aria-current="page"' : ''}>${cover(k, 'tiny')}<span><span class="mt">${esc(c.title)}</span><span class="ms">${esc(c.src.split('.')[0])}</span></span></a></li>`).join('');
|
||
$('#search').placeholder = S.view === 'home' ? 'Search the library' : 'Search this course';
|
||
document.querySelectorAll('.review a').forEach(a => a.toggleAttribute('aria-current', a.getAttribute('href') === '#' + S.view));
|
||
}
|
||
const menuBtn = $('#courseBtn'), menu = $('#courseMenu');
|
||
function setMenu(open) { menu.hidden = !open; menuBtn.setAttribute('aria-expanded', open); if (open) menu.querySelector('a').focus(); }
|
||
menuBtn.addEventListener('click', () => setMenu(menu.hidden));
|
||
menu.addEventListener('click', e => { if (e.target.closest('a')) setMenu(false); });
|
||
document.addEventListener('click', e => { if (!menu.hidden && !e.target.closest('.switch')) setMenu(false); });
|
||
|
||
function applyLook() {
|
||
const look = S.view === 'exercise' ? 'work' : S.lookPref;
|
||
document.body.dataset.look = look;
|
||
$('#lookReading').setAttribute('aria-pressed', look === 'reading');
|
||
$('#lookWork').setAttribute('aria-pressed', look === 'work');
|
||
}
|
||
$('#lookReading').onclick = () => { S.lookPref = 'reading'; if (S.view === 'exercise') toast('Exercises always use Work. Reading applies to text lessons.'); applyLook(); };
|
||
$('#lookWork').onclick = () => { S.lookPref = 'work'; applyLook(); };
|
||
|
||
$('#search').addEventListener('input', () => S.view === 'home' ? renderLib() : renderSide());
|
||
$('#outlineBtn').onclick = () => { const y = $('.top').getBoundingClientRect().bottom + 'px'; $('#side').style.top = $('#scrim').style.top = y; const o = document.body.classList.toggle('side-open'); $('#outlineBtn').setAttribute('aria-expanded', o); };
|
||
function closeSide() { document.body.classList.remove('side-open'); $('#outlineBtn').setAttribute('aria-expanded', false); }
|
||
$('#scrim').onclick = closeSide;
|
||
matchMedia('(min-width: 961px)').addEventListener('change', e => { if (e.matches) $('#side').style.top = $('#scrim').style.top = ''; });
|
||
|
||
/* ---------- dock ---------- */
|
||
function neighbours() { const ls = flat(S.course), i = ls.findIndex(l => l.id === S.lesson); return [ls[i-1], ls[i+1]]; }
|
||
function renderDock() {
|
||
const home = S.view === 'home'; $('#dock').hidden = home; if (home) return;
|
||
const [p, n] = neighbours(), isDone = done.has(S.lesson);
|
||
$('#prev').disabled = !p; $('#prev').title = p ? 'Previous: ' + p.t + ' ([)' : 'First lesson';
|
||
$('#next').disabled = !n; $('#next').title = n ? 'Next: ' + n.t + ' (])' : 'Last lesson';
|
||
$('#done').setAttribute('aria-pressed', isDone); $('#doneLbl').textContent = isDone ? 'Done' : 'Mark done';
|
||
}
|
||
const go = d => { const n = neighbours()[d]; if (n) location.hash = href(n); };
|
||
$('#prev').onclick = () => go(0); $('#next').onclick = () => go(1);
|
||
$('#done').onclick = () => { done.has(S.lesson) ? done.delete(S.lesson) : done.add(S.lesson); renderDock(); renderSide(); };
|
||
|
||
let toastT; function toast(msg) { const t = $('#toast'); t.textContent = msg; t.hidden = false; clearTimeout(toastT); toastT = setTimeout(() => t.hidden = true, 2600); }
|
||
|
||
/* ---------- router ---------- */
|
||
function route() {
|
||
let h = location.hash.slice(1) || 'text';
|
||
if (document.fullscreenElement) document.exitFullscreen();
|
||
let course, lesson, view = h, tpl = h;
|
||
if (VIEWS[h]) [course, lesson] = VIEWS[h];
|
||
else if (h === 'home') { course = S.course; lesson = null; }
|
||
else if (h.startsWith('l/')) { [course] = findLesson(h.slice(2)); lesson = h.slice(2); view = 'lesson'; tpl = 'generic'; }
|
||
if (!course) { location.hash = '#text'; return; }
|
||
if (course !== S.course) $('#search').value = '';
|
||
Object.assign(S, {course, lesson, view});
|
||
document.body.dataset.course = course;
|
||
document.body.classList.toggle('no-side', view === 'home');
|
||
if (lesson) { const m = COURSES[course].outline.find(m => m.lessons.some(l => l.id === lesson)); S.open.add(course + m.n); }
|
||
const v = $('#view'); v.replaceChildren($('#tpl-' + tpl).content.cloneNode(true));
|
||
(INIT[tpl] || (() => {}))(v);
|
||
applyLook(); renderHeader(); renderSide(); renderDock(); closeSide();
|
||
const cur = $('.les[aria-current]'); if (cur) cur.scrollIntoView({block:'nearest'});
|
||
document.title = (view === 'home' ? 'Library' : findLesson(lesson)[1].t) + ' – Homelab Academy';
|
||
window.scrollTo(0, 0);
|
||
}
|
||
|
||
const INIT = {};
|
||
INIT.text = v => {
|
||
const seg = $('[data-trackseg]', v);
|
||
const sync = () => seg.querySelectorAll('button').forEach(b => b.setAttribute('aria-pressed', b.dataset.tr === document.body.dataset.track));
|
||
seg.onclick = e => { const b = e.target.closest('button'); if (b) { document.body.dataset.track = b.dataset.tr; sync(); } };
|
||
sync();
|
||
};
|
||
INIT.generic = () => {
|
||
const [c, l] = findLesson(S.lesson), m = COURSES[c].outline.find(m => m.lessons.includes(l));
|
||
$('#gChap').textContent = (c === 'docker' ? 'Section ' : c === 'systemic' ? '' : 'Module ') + (c === 'systemic' ? m.t : m.n + ': ' + m.t);
|
||
$('#gTitle').textContent = l.t;
|
||
};
|
||
|
||
const ATTACH = [['Thinknyx_Docker_CheatSheet.pdf','1.2 MB'],['Thinknyx_Mastering_Docker_Essentials_Handson_DevOps.pdf','8.6 MB']];
|
||
const attachRows = () => ATTACH.map(([n, s]) => `<li>${ICON.pdf}<a href="#" data-dl="${n}">${n}</a><span class="sz tnum">${s}</span></li>`).join('');
|
||
document.addEventListener('click', e => {
|
||
const a = e.target.closest('[data-dl]'); if (a) { e.preventDefault(); toast('Mockup: would download ' + a.dataset.dl); }
|
||
const c = e.target.closest('[data-copy]'); if (c) copy($('#' + c.dataset.copy).textContent, c);
|
||
});
|
||
function copy(text, btn) {
|
||
const ok = () => { const o = btn.textContent; btn.textContent = 'Copied'; setTimeout(() => btn.textContent = o, 1400); };
|
||
(navigator.clipboard ? navigator.clipboard.writeText(text) : Promise.reject()).then(ok, () => {
|
||
const t = document.createElement('textarea'); t.value = text; document.body.append(t); t.select();
|
||
try { document.execCommand('copy'); ok(); } catch (_) {} t.remove();
|
||
});
|
||
}
|
||
|
||
INIT.video = v => {
|
||
$('#attach1', v).innerHTML = attachRows();
|
||
const vid = $('#vid', v), ta = $('#notesTa', v);
|
||
const fmt = s => Math.floor(s / 60) + ':' + String(Math.floor(s % 60)).padStart(2, '0');
|
||
vid.addEventListener('timeupdate', () => $('#stampT').textContent = fmt(vid.currentTime));
|
||
$('#stamp', v).onclick = () => {
|
||
const ins = `[${fmt(vid.currentTime)}] `, p = ta.selectionStart;
|
||
ta.value = ta.value.slice(0, p) + (p && ta.value[p-1] !== '\n' ? '\n' : '') + ins + ta.value.slice(ta.selectionEnd);
|
||
ta.focus(); ta.selectionStart = ta.selectionEnd = ta.value.indexOf(ins, p) + ins.length;
|
||
};
|
||
};
|
||
|
||
const SLIDES = 8, PAGES = 59; let slide = 0;
|
||
INIT.slides = v => {
|
||
slide = 0;
|
||
$('#thumbs', v).innerHTML = Array.from({length:SLIDES}, (_, i) => `<button data-s="${i}" aria-label="Slide ${i+1}"><img src="deck-0${i+1}.png" alt="" loading="lazy"></button>`).join('') +
|
||
`<span class="more">Pages 9–${PAGES} not in this mockup</span>`;
|
||
$('#thumbs', v).onclick = e => { const b = e.target.closest('[data-s]'); if (b) showSlide(+b.dataset.s); };
|
||
$('#sPrev', v).onclick = () => showSlide(slide - 1);
|
||
$('#sNext', v).onclick = () => showSlide(slide + 1);
|
||
$('#sFull', v).onclick = () => document.fullscreenElement ? document.exitFullscreen() : $('#stage').requestFullscreen().catch(() => toast('Fullscreen is not available here'));
|
||
showSlide(0);
|
||
};
|
||
const SLIDE_ALT = ['Title: Best Practices in Serverless, Elle Krout','Section: Best Practices for Writing Functions','Keep Code Small: single purpose, idempotent, limit dependencies','Keep Code Small','Keep Code Small: each bit of pathing should be its own function','Keep Code Small','Keep Code Small','Keep Code Small: reuse functions'];
|
||
function showSlide(i) {
|
||
if (i < 0 || i >= SLIDES) { if (i >= SLIDES) toast(`Pages ${SLIDES + 1}–${PAGES} are not part of this mockup`); return; }
|
||
slide = i; const img = $('#slideImg');
|
||
img.src = `deck-0${i+1}.png`; img.alt = `Slide ${i+1}: ${SLIDE_ALT[i]}`;
|
||
$('#sCount').textContent = `${i+1} / ${PAGES}`;
|
||
$('#sPrev').disabled = i === 0;
|
||
document.querySelectorAll('#thumbs [data-s]').forEach(b => b.setAttribute('aria-current', +b.dataset.s === i));
|
||
const cur = $(`#thumbs [data-s="${i}"]`); if (cur) cur.scrollIntoView({block:'nearest', inline:'nearest'});
|
||
}
|
||
|
||
const REPO = 'S08 - Containerizing Applications/NyxAcademy-main/';
|
||
const FILES = {
|
||
'Dockerfile.example':{lang:'docker', src:`# NyxAcademy: the Django sample app from Section 8
|
||
FROM python:3.11-slim
|
||
|
||
ENV PYTHONDONTWRITEBYTECODE=1 \\
|
||
PYTHONUNBUFFERED=1
|
||
|
||
WORKDIR /app
|
||
|
||
RUN pip install --no-cache-dir "django>=4.2,<5"
|
||
|
||
COPY . .
|
||
RUN chmod +x startup.sh
|
||
|
||
EXPOSE 8000
|
||
CMD ["./startup.sh"]`},
|
||
'startup.sh':{lang:'sh', src:`#!/bin/sh
|
||
# Runs every time the container starts
|
||
set -e
|
||
|
||
python manage.py migrate --noinput
|
||
python manage.py collectstatic --noinput
|
||
|
||
# 0.0.0.0, not 127.0.0.1: the published port must reach it
|
||
exec python manage.py runserver 0.0.0.0:8000`},
|
||
'NyxAcademy/settings.py':{lang:'py', src:`# Excerpt: the lines the container depends on
|
||
import os
|
||
from pathlib import Path
|
||
|
||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||
|
||
DEBUG = os.environ.get("DJANGO_DEBUG", "1") == "1"
|
||
ALLOWED_HOSTS = ["localhost", "127.0.0.1", "0.0.0.0"]
|
||
|
||
STATIC_URL = "static/"
|
||
STATIC_ROOT = BASE_DIR / "staticfiles"`}
|
||
};
|
||
function hl(src, lang) {
|
||
return esc(src).split('\n').map(line => {
|
||
let l = line;
|
||
if (/^\s*#/.test(l)) l = `<span class="c">${l}</span>`;
|
||
else if (lang === 'docker') l = l.replace(/^(FROM|RUN|COPY|WORKDIR|ENV|EXPOSE|CMD)\b/, '<span class="k">$1</span>');
|
||
else if (lang === 'py') l = l.replace(/\s(#.*)$/, ' <span class="c">$1</span>');
|
||
return `<span class="ln">${l || ' '}</span>`;
|
||
}).join('');
|
||
}
|
||
INIT.code = v => {
|
||
$('#attach8', v).innerHTML = attachRows();
|
||
const f = (p, cls = '') => `<button class="${cls}" data-f="${p}">${ICON.file}${p.split('/').pop()}</button>`;
|
||
const s = n => `<span class="f">${ICON.file}${n}</span>`;
|
||
$('#tree', v).innerHTML = `<div class="dir">${ICON.dir}NyxAcademy-main</div>${f('Dockerfile.example')}${s('manage.py')}${f('startup.sh')}
|
||
<div class="dir sub">${ICON.dir}NyxAcademy</div>${f('NyxAcademy/settings.py', 'l2')}<span class="f" style="padding-left:40px">${ICON.file}urls.py</span>
|
||
<div class="dir sub">${ICON.dir}templates</div><div class="dir sub">${ICON.dir}static</div>`;
|
||
$('#tabs', v).innerHTML = Object.keys(FILES).map(p => `<button role="tab" data-f="${p}">${p.split('/').pop()}</button>`).join('') + '<button class="btn sm quiet" id="copyFile" style="font-family:var(--sans)">Copy</button>';
|
||
v.querySelector('.files').addEventListener('click', e => { const b = e.target.closest('[data-f]'); if (b) openFile(b.dataset.f); });
|
||
$('#copyFile', v).onclick = e => copy(FILES[openFile.cur].src, e.currentTarget);
|
||
$('#zip', v).onclick = () => toast('Mockup: would download “S08 - Containerizing Applications.zip” (7 files, 41 KB)');
|
||
openFile('Dockerfile.example');
|
||
};
|
||
function openFile(p) {
|
||
openFile.cur = p;
|
||
document.querySelectorAll('#tabs [data-f]').forEach(b => b.setAttribute('aria-selected', b.dataset.f === p));
|
||
document.querySelectorAll('#tree [data-f]').forEach(b => b.setAttribute('aria-current', b.dataset.f === p));
|
||
$('#fpath').textContent = 'From the sample repo: ' + REPO + p;
|
||
$('#fcode').innerHTML = hl(FILES[p].src, FILES[p].lang);
|
||
}
|
||
|
||
/* ---------- exercise: tests run in this page ---------- */
|
||
const STUB = `import { delta, point } from './model.js'
|
||
|
||
// Exercise 3.1: the cell one step from p in direction dir.
|
||
// y grows downward, so 'Up' is y - 1.
|
||
export const move = (p, dir) => {
|
||
const d = delta(dir)
|
||
p.x = p.x + d.x
|
||
p.y = p.y + d.y
|
||
return p
|
||
}
|
||
`;
|
||
let draft = STUB;
|
||
const fmtP = o => o && typeof o === 'object' ? `{x: ${o.x}, y: ${o.y}}` : String(o);
|
||
function runTests(src) {
|
||
const prelude = "const delta = d => ({Up:{x:0,y:-1}, Down:{x:0,y:1}, Left:{x:-1,y:0}, Right:{x:1,y:0}})[d];\nconst point = (x, y) => ({x, y});\n";
|
||
const body = src.replace(/^\s*import .*$/gm, '').replace(/\bexport\s+/g, '');
|
||
let move;
|
||
try { move = new Function(prelude + body + '\n;return typeof move === "function" ? move : undefined;')(); }
|
||
catch (e) { return {error: e.name + ': ' + e.message}; }
|
||
if (!move) return {error: 'No function named move. Keep the line “export const move = (p, dir) => …”.'};
|
||
const cases = [['move-up',{x:2,y:2},'Up',{x:2,y:1}],['move-down',{x:2,y:2},'Down',{x:2,y:3}],['move-left',{x:2,y:2},'Left',{x:1,y:2}],['move-right',{x:2,y:2},'Right',{x:3,y:2}],['move-does-not-wrap-at-the-edge',{x:4,y:2},'Right',{x:5,y:2}]];
|
||
const out = cases.map(([name, p, d, exp]) => {
|
||
try { const got = move({...p}, d); return {name, call:`move(${fmtP(p)}, '${d}')`, ok: !!got && got.x === exp.x && got.y === exp.y, exp: fmtP(exp), got: fmtP(got)}; }
|
||
catch (e) { return {name, call:`move(${fmtP(p)}, '${d}')`, ok:false, exp: fmtP(exp), got: 'threw ' + e.message}; }
|
||
});
|
||
const input = {x:2, y:2}; let r;
|
||
try { r = move(input, 'Up'); } catch (e) { r = e; }
|
||
const pure = input.x === 2 && input.y === 2 && r !== input;
|
||
out.push({name:'move-leaves-its-input-unchanged', call:"const p = {x: 2, y: 2}; move(p, 'Up')", ok: pure, exp:'p is still {x: 2, y: 2}', got: r === input ? `p became ${fmtP(input)}, and move returned p itself` : `p is ${fmtP(input)}`});
|
||
return {out};
|
||
}
|
||
function showResults(res) {
|
||
const el = $('#results');
|
||
if (!res) { el.innerHTML = `<div class="res-sum"><span>Not run yet. Six checks: five fixture cases and one purity check.</span></div>`; return; }
|
||
if (res.error) { el.innerHTML = `<div class="res-sum"><span><b>The code did not load.</b> ${esc(res.error)}</span></div>`; return; }
|
||
const n = res.out.filter(t => t.ok).length, all = n === res.out.length;
|
||
el.innerHTML = `<div class="res-sum" aria-live="polite"><span><b class="tnum">${n} of ${res.out.length}</b> passed${all ? '. Mark the exercise done when you are ready.' : ''}</span></div><ul class="res-list">` +
|
||
res.out.map(t => `<li class="${t.ok ? 'pass' : 'fail'}"><div class="res-row"><span class="s">${t.ok ? 'Pass' : 'Fail'}</span><code>${esc(t.name)}</code></div>` +
|
||
(t.ok ? '' : `<dl class="diff"><dt>Call</dt><dd>${esc(t.call)}</dd><dt>Expected</dt><dd>${esc(t.exp)}</dd><dt>Actual</dt><dd>${esc(t.got)}</dd></dl>`) + '</li>').join('') + '</ul>';
|
||
}
|
||
INIT.exercise = v => {
|
||
const ta = $('#code', v), gut = $('#gutter', v);
|
||
const lines = () => { gut.textContent = ta.value.split('\n').map((_, i) => i + 1).join('\n'); gut.scrollTop = ta.scrollTop; };
|
||
ta.value = draft; lines();
|
||
ta.addEventListener('input', () => { draft = ta.value; lines(); });
|
||
ta.addEventListener('scroll', () => gut.scrollTop = ta.scrollTop);
|
||
ta.addEventListener('keydown', e => {
|
||
if (e.key === 'Tab' && !e.shiftKey) { e.preventDefault(); ta.setRangeText(' ', ta.selectionStart, ta.selectionEnd, 'end'); draft = ta.value; lines(); }
|
||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); $('#run').click(); }
|
||
if (e.key === 'Escape') ta.blur();
|
||
});
|
||
$('#run', v).onclick = () => showResults(runTests(ta.value));
|
||
$('#reset', v).onclick = () => { ta.value = draft = STUB; lines(); showResults(null); toast('Code reset to the starting stub'); };
|
||
showResults(null);
|
||
};
|
||
|
||
/* ---------- library ---------- */
|
||
function renderLib() {
|
||
const q = $('#search').value.trim().toLowerCase();
|
||
const rows = Object.entries(COURSES).filter(([, c]) => !q || (c.title + c.src).toLowerCase().includes(q));
|
||
$('#lib').innerHTML = rows.map(([k, c]) => {
|
||
const ls = flat(k), p = pct(ls);
|
||
return `<a class="lib-row" href="#${c.start}"><span class="lib-main">${cover(k, '', 44)}<span style="min-width:0"><span class="lt">${esc(c.title)}</span><span class="ls" style="display:block">${esc(c.src)}</span></span></span>
|
||
<span class="lnum tnum">${ls.length}</span><span class="tags">${c.types.map(t => `<span class="tag">${t}</span>`).join('')}</span>
|
||
<span class="prog"><span class="bar${p === 0 ? '' : ''}"><i style="width:${p}%"></i></span><span class="tnum" style="min-width:3ch;text-align:right">${p}%</span></span></a>`;
|
||
}).join('') || `<p class="nohit">No courses match “${esc(q)}”.</p>`;
|
||
}
|
||
function nextUp() { const m = snakeOutline.find(m => m.lessons.some(l => !done.has(l.id))), l = m.lessons.find(l => !done.has(l.id)); return [m, l]; }
|
||
INIT.home = () => {
|
||
const [m, l] = nextUp(), d = m.lessons.filter(x => done.has(x.id)).length;
|
||
$('#contS').textContent = `Module ${m.n}, ${m.t}. Next up: ${l.t === m.t ? 'the reading' : l.t}`;
|
||
$('#contBar').style.width = pct(m.lessons) + '%';
|
||
$('#contN').textContent = `${d} of ${m.lessons.length} in this module`;
|
||
$('#contBtn').href = href(l);
|
||
renderLib();
|
||
};
|
||
|
||
/* ---------- keyboard ---------- */
|
||
document.addEventListener('keydown', e => {
|
||
const t = e.target, typing = t.closest && t.closest('input, textarea, [contenteditable]');
|
||
if (e.key === 'Escape') { if (!menu.hidden) { setMenu(false); menuBtn.focus(); } else if (t.id === 'search') { t.value = ''; t.dispatchEvent(new Event('input')); t.blur(); } closeSide(); return; }
|
||
if (typing || e.ctrlKey || e.metaKey || e.altKey) return;
|
||
if (e.key === ']') { e.preventDefault(); S.view === 'home' ? location.hash = href(nextUp()[1]) : go(1); }
|
||
else if (e.key === '[') { e.preventDefault(); go(0); }
|
||
else if (e.key === '/') { e.preventDefault(); const s = $('#search'); if (getComputedStyle(s.closest('.search')).display === 'none') return; s.focus(); }
|
||
else if (S.view === 'slides' && (e.key === 'ArrowRight' || e.key === 'ArrowLeft') && !(t.closest && t.closest('video'))) { e.preventDefault(); showSlide(slide + (e.key === 'ArrowRight' ? 1 : -1)); }
|
||
});
|
||
|
||
window.addEventListener('hashchange', route);
|
||
route();
|
||
</script>
|
||
</body>
|
||
</html>
|