| 1 | import { downloadPaneFromURL, downloadURLForPane } from "./download-link.js"; |
| 2 | import { desktopDownloadVersion } from "../data/desktop-download.js"; |
| 3 | import { |
| 4 | cliReleaseModel, |
| 5 | cliUpgradeCommand, |
| 6 | fetchDesktopDownloadModel, |
| 7 | fetchFirstJSON, |
| 8 | releaseVersionLabel, |
| 9 | } from "./release-channels.js"; |
| 10 | import { initTheme } from "./theme.js"; |
| 11 | import { initMobileNav } from "./mobile-nav.js"; |
| 12 | |
| 13 | // Reasonix site — vanilla interactions |
| 14 | (function () { |
| 15 | initTheme(); |
| 16 | initMobileNav(); |
| 17 | const motionOK = () => |
| 18 | document.body.dataset.motion === "rich" && |
| 19 | !window.matchMedia("(prefers-reduced-motion: reduce)").matches; |
| 20 | |
| 21 | const nav = document.querySelector(".nav"); |
| 22 | if (nav) { |
| 23 | const onScroll = () => nav.classList.toggle("scrolled", window.scrollY > 12); |
| 24 | window.addEventListener("scroll", onScroll, { passive: true }); |
| 25 | onScroll(); |
| 26 | } |
| 27 | |
| 28 | const revealEls = Array.from(document.querySelectorAll(".reveal")); |
| 29 | const inView = (el, factor) => |
| 30 | el.getBoundingClientRect().top < window.innerHeight * (factor || 0.95); |
| 31 | |
| 32 | const term = document.querySelector(".term"); |
| 33 | const lines = Array.from(document.querySelectorAll(".term-body .tl")); |
| 34 | let played = false; |
| 35 | const playTerm = () => { |
| 36 | if (played) return; |
| 37 | played = true; |
| 38 | const fire = () => document.dispatchEvent(new CustomEvent("rx:term-played")); |
| 39 | if (!motionOK()) { |
| 40 | lines.forEach((l) => l.classList.add("on")); |
| 41 | fire(); |
| 42 | return; |
| 43 | } |
| 44 | lines.forEach((l, i) => setTimeout(() => l.classList.add("on"), 350 + i * 520)); |
| 45 | setTimeout(fire, 350 + Math.max(0, lines.length - 2) * 520); |
| 46 | }; |
| 47 | |
| 48 | let sweepQueued = false; |
| 49 | const sweep = () => { |
| 50 | sweepQueued = false; |
| 51 | revealEls.forEach((el) => { |
| 52 | if (!el.classList.contains("in") && inView(el, 0.95)) el.classList.add("in"); |
| 53 | }); |
| 54 | if (term && !played && inView(term, 0.85)) playTerm(); |
| 55 | }; |
| 56 | const queueSweep = () => { |
| 57 | if (sweepQueued) return; |
| 58 | sweepQueued = true; |
| 59 | requestAnimationFrame(sweep); |
| 60 | }; |
| 61 | window.addEventListener("scroll", queueSweep, { passive: true }); |
| 62 | window.addEventListener("resize", queueSweep, { passive: true }); |
| 63 | window.addEventListener("load", queueSweep); |
| 64 | sweep(); |
| 65 | setTimeout(sweep, 400); |
| 66 | |
| 67 | /* contributors marquee — duplicate the server-rendered set for a seamless loop */ |
| 68 | document.querySelectorAll(".crew-row").forEach((row) => { |
| 69 | const set = row.querySelector(".crew-set"); |
| 70 | if (set) row.appendChild(set.cloneNode(true)); |
| 71 | }); |
| 72 | |
| 73 | /* download / channel tabs */ |
| 74 | const tabs = Array.from(document.querySelectorAll(".dl-tab")); |
| 75 | const panes = Array.from(document.querySelectorAll(".dl-pane")); |
| 76 | const activatePane = (name) => { |
| 77 | tabs.forEach((b) => { |
| 78 | const active = b.dataset.pane === name; |
| 79 | b.classList.toggle("active", active); |
| 80 | b.setAttribute("aria-selected", active ? "true" : "false"); |
| 81 | b.tabIndex = active ? 0 : -1; |
| 82 | }); |
| 83 | panes.forEach((p) => { |
| 84 | const active = p.dataset.pane === name; |
| 85 | p.classList.toggle("active", active); |
| 86 | p.hidden = !active; |
| 87 | }); |
| 88 | }; |
| 89 | tabs.forEach((tab) => { |
| 90 | tab.addEventListener("click", () => { |
| 91 | activatePane(tab.dataset.pane); |
| 92 | reflectPaneURL(tab.dataset.pane); |
| 93 | }); |
| 94 | tab.addEventListener("keydown", (event) => { |
| 95 | const current = tabs.indexOf(tab); |
| 96 | let next = -1; |
| 97 | if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (current + 1) % tabs.length; |
| 98 | else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (current - 1 + tabs.length) % tabs.length; |
| 99 | else if (event.key === "Home") next = 0; |
| 100 | else if (event.key === "End") next = tabs.length - 1; |
| 101 | if (next < 0) return; |
| 102 | event.preventDefault(); |
| 103 | const nextTab = tabs[next]; |
| 104 | activatePane(nextTab.dataset.pane); |
| 105 | reflectPaneURL(nextTab.dataset.pane); |
| 106 | nextTab.focus(); |
| 107 | }); |
| 108 | }); |
| 109 | |
| 110 | /* OS detection — hero download button + card badge + highlight */ |
| 111 | const ua = navigator.userAgent; |
| 112 | const os = /Windows/i.test(ua) ? "win" : /Mac|iPhone|iPad/i.test(ua) ? "mac" : /Linux|X11/i.test(ua) ? "linux" : "mac"; |
| 113 | const osNames = { mac: "macOS", win: "Windows", linux: "Linux" }; |
| 114 | document.querySelectorAll("[data-os-dl] .os-name").forEach((s) => (s.textContent = osNames[os])); |
| 115 | const osCard = document.querySelector('.os-card[data-os="' + os + '"]'); |
| 116 | if (osCard) { |
| 117 | osCard.classList.add("detected"); |
| 118 | const chip = document.createElement("span"); |
| 119 | chip.className = "os-chip"; |
| 120 | chip.innerHTML = '<span class="l-en">your OS</span><span class="l-zh">当前系统</span>'; |
| 121 | osCard.appendChild(chip); |
| 122 | } |
| 123 | |
| 124 | const flashOSCard = () => { |
| 125 | if (!osCard) return; |
| 126 | osCard.classList.remove("flash"); |
| 127 | void osCard.offsetWidth; |
| 128 | setTimeout(() => osCard.classList.add("flash"), 450); |
| 129 | setTimeout(() => osCard.classList.remove("flash"), 2600); |
| 130 | }; |
| 131 | |
| 132 | const requestedPane = downloadPaneFromURL(window.location.href); |
| 133 | const legacyChannelURL = new URL(window.location.href); |
| 134 | if (legacyChannelURL.searchParams.has("channel")) { |
| 135 | legacyChannelURL.searchParams.delete("channel"); |
| 136 | window.history.replaceState(null, "", legacyChannelURL.href); |
| 137 | } |
| 138 | if (requestedPane) { |
| 139 | activatePane(requestedPane); |
| 140 | if (requestedPane === "desktop") flashOSCard(); |
| 141 | requestAnimationFrame(() => { |
| 142 | document.getElementById("start")?.scrollIntoView({ block: "start" }); |
| 143 | queueSweep(); |
| 144 | }); |
| 145 | } |
| 146 | |
| 147 | /* links that deep-link into a specific download tab */ |
| 148 | document.querySelectorAll("[data-goto]").forEach((a) => { |
| 149 | a.addEventListener("click", (event) => { |
| 150 | event.preventDefault(); |
| 151 | activatePane(a.dataset.goto); |
| 152 | reflectPaneURL(a.dataset.goto); |
| 153 | if (a.hasAttribute("data-os-dl")) flashOSCard(); |
| 154 | document.getElementById("start")?.scrollIntoView({ block: "start" }); |
| 155 | setTimeout(queueSweep, 500); |
| 156 | }); |
| 157 | }); |
| 158 | |
| 159 | /* language switch */ |
| 160 | const LANG_KEY = "reasonix-lang"; |
| 161 | const langBtns = Array.from(document.querySelectorAll(".lang-switch button")); |
| 162 | const setLang = (l, alignHash) => { |
| 163 | document.body.dataset.lang = l; |
| 164 | document.documentElement.lang = l === "zh" ? "zh-CN" : "en"; |
| 165 | const t = document.body.dataset[l === "zh" ? "titleZh" : "titleEn"]; |
| 166 | if (t) document.title = t; |
| 167 | langBtns.forEach((b) => b.classList.toggle("active", b.dataset.lang === l)); |
| 168 | try { localStorage.setItem(LANG_KEY, l); } catch (e) {} |
| 169 | if (alignHash && window.location.hash) { |
| 170 | const target = document.getElementById(window.location.hash.slice(1)); |
| 171 | if (target) requestAnimationFrame(() => target.scrollIntoView({ block: "start" })); |
| 172 | } |
| 173 | }; |
| 174 | langBtns.forEach((b) => b.addEventListener("click", () => setLang(b.dataset.lang))); |
| 175 | let savedLang = ""; |
| 176 | try { savedLang = localStorage.getItem(LANG_KEY) || ""; } catch (e) {} |
| 177 | const requestedLang = new URLSearchParams(window.location.search).get("lang"); |
| 178 | const initialLang = requestedLang === "zh" || requestedLang === "en" |
| 179 | ? requestedLang |
| 180 | : savedLang || ((navigator.language || "").toLowerCase().startsWith("zh") ? "zh" : "en"); |
| 181 | setLang(initialLang, true); |
| 182 | |
| 183 | /* docs scrollspy */ |
| 184 | const sideLinks = Array.from(document.querySelectorAll(".docs-side a[href^='#']")); |
| 185 | if (sideLinks.length) { |
| 186 | const targets = sideLinks |
| 187 | .map((a) => document.getElementById(a.getAttribute("href").slice(1))) |
| 188 | .filter(Boolean) |
| 189 | // Sidebar links are grouped editorially, so their order differs from the |
| 190 | // page order. The spy below picks the last section past the 140px line, |
| 191 | // which is only correct when targets are sorted in document order. |
| 192 | .sort((a, b) => |
| 193 | a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1); |
| 194 | const setActive = (id) => |
| 195 | sideLinks.forEach((a) => { |
| 196 | const on = a.getAttribute("href") === "#" + id; |
| 197 | a.classList.toggle("active", on); |
| 198 | if (on) a.setAttribute("aria-current", "true"); |
| 199 | else a.removeAttribute("aria-current"); |
| 200 | }); |
| 201 | // While a click smooth-scrolls to a section, pin the highlight to it so it |
| 202 | // doesn't sweep through every section scrolled past on the way. scrollend |
| 203 | // releases the pin when the scroll settles — on arrival or when the user |
| 204 | // takes over (wheel, touch, keyboard, scrollbar). Browsers without |
| 205 | // scrollend just skip pinning: correct destination, no sweep suppression. |
| 206 | let pinned = null; |
| 207 | const spy = () => { |
| 208 | if (pinned) return; |
| 209 | let current = targets[0]; |
| 210 | for (const t of targets) if (t.getBoundingClientRect().top < 140) current = t; |
| 211 | if (current) setActive(current.id); |
| 212 | }; |
| 213 | if ("onscrollend" in window) { |
| 214 | const ids = new Set(targets.map((t) => t.id)); |
| 215 | document.querySelectorAll("a[href^='#']").forEach((a) => { |
| 216 | const id = a.getAttribute("href").slice(1); |
| 217 | if (ids.has(id)) a.addEventListener("click", () => { pinned = id; setActive(id); }); |
| 218 | }); |
| 219 | window.addEventListener("scrollend", () => { pinned = null; spy(); }, { passive: true }); |
| 220 | } |
| 221 | window.addEventListener("scroll", spy, { passive: true }); |
| 222 | spy(); |
| 223 | } |
| 224 | |
| 225 | /* copy-to-clipboard */ |
| 226 | document.querySelectorAll("[data-copy]").forEach((btn) => { |
| 227 | btn.addEventListener("click", () => { |
| 228 | const text = btn.getAttribute("data-copy"); |
| 229 | const done = () => { |
| 230 | btn.classList.add("copied"); |
| 231 | const prev = btn.textContent; |
| 232 | btn.textContent = "Copied"; |
| 233 | setTimeout(() => { btn.classList.remove("copied"); btn.textContent = prev; }, 1600); |
| 234 | }; |
| 235 | if (navigator.clipboard && navigator.clipboard.writeText) { |
| 236 | navigator.clipboard.writeText(text).then(done).catch(done); |
| 237 | } else done(); |
| 238 | }); |
| 239 | }); |
| 240 | |
| 241 | /* public official releases */ |
| 242 | const releaseModels = { desktop: null, cli: null }; |
| 243 | const releasesPage = "https://github.com/esengine/DeepSeek-Reasonix/releases"; |
| 244 | const reflectPaneURL = (surface) => { |
| 245 | const nextURL = downloadURLForPane(window.location.href, surface, ""); |
| 246 | if (nextURL) window.history.replaceState(null, "", nextURL); |
| 247 | }; |
| 248 | |
| 249 | // npm / Homebrew / generic product chips track the CLI stable line only. |
| 250 | // Desktop and CLI download panes render their own versions via |
| 251 | // [data-release-version="<surface>"]; never let Desktop and CLI race on .rxv. |
| 252 | const updateCLIPackageVersion = (model) => { |
| 253 | if (!model) return; |
| 254 | document.querySelectorAll(".rxv").forEach((element) => { element.textContent = releaseVersionLabel(model); }); |
| 255 | document.querySelectorAll("a.rxnotes").forEach((link) => { |
| 256 | link.href = new URL("changelog/v" + model.displayVersion + "/", window.location.origin + "/").href; |
| 257 | }); |
| 258 | }; |
| 259 | |
| 260 | // Never synthesize public artifact URLs. If every required asset is not |
| 261 | // attested by live release data, fall back to the release list instead of a |
| 262 | // plausible-looking URL that may 404. |
| 263 | const fallbackReleaseURL = (surface) => surface === "desktop" && desktopDownloadVersion |
| 264 | ? releasesPage + "/tag/desktop-" + desktopDownloadVersion |
| 265 | : releasesPage; |
| 266 | |
| 267 | const renderReleaseSurface = (surface) => { |
| 268 | const model = releaseModels[surface]; |
| 269 | document.querySelectorAll('[data-release-version="' + surface + '"]').forEach((element) => { |
| 270 | element.textContent = releaseVersionLabel(model || (surface === "desktop" && desktopDownloadVersion |
| 271 | ? { version: desktopDownloadVersion } : null)); |
| 272 | }); |
| 273 | document.querySelectorAll('[data-release-notes="' + surface + '"]').forEach((link) => { |
| 274 | const path = model?.changelogURL ? new URL(model.changelogURL).pathname |
| 275 | : surface === "desktop" && desktopDownloadVersion ? "changelog/" + desktopDownloadVersion + "/" : "changelog/"; |
| 276 | link.href = new URL(path, window.location.origin + "/").href; |
| 277 | }); |
| 278 | |
| 279 | const assetAttribute = "data-" + surface + "-asset"; |
| 280 | document.querySelectorAll("[" + assetAttribute + "]").forEach((link) => { |
| 281 | const asset = link.getAttribute(assetAttribute); |
| 282 | const target = model?.assets?.[asset] || fallbackReleaseURL(surface); |
| 283 | link.href = target; |
| 284 | if (!model?.assets?.[asset]) link.removeAttribute("download"); |
| 285 | else link.setAttribute("download", ""); |
| 286 | }); |
| 287 | |
| 288 | if (surface === "cli") { |
| 289 | const command = cliUpgradeCommand(); |
| 290 | document.querySelectorAll('[data-release-command="cli"]').forEach((element) => { element.textContent = command; }); |
| 291 | document.querySelectorAll('.release-upgrade-command [data-copy]').forEach((button) => { button.dataset.copy = command; }); |
| 292 | } |
| 293 | }; |
| 294 | |
| 295 | renderReleaseSurface("desktop"); |
| 296 | renderReleaseSurface("cli"); |
| 297 | if (requestedPane) reflectPaneURL(requestedPane); |
| 298 | |
| 299 | fetchDesktopDownloadModel(fetch, desktopDownloadVersion) |
| 300 | .then((model) => { |
| 301 | if (!model) return; |
| 302 | releaseModels.desktop = model; |
| 303 | renderReleaseSurface("desktop"); |
| 304 | }) |
| 305 | .catch(() => {}); |
| 306 | |
| 307 | let githubCLIReleases; |
| 308 | const fallbackCLIReleases = () => { |
| 309 | githubCLIReleases ??= fetchFirstJSON([ |
| 310 | "https://api.github.com/repos/esengine/DeepSeek-Reasonix/releases?per_page=100", |
| 311 | ]).catch(() => null); |
| 312 | return githubCLIReleases; |
| 313 | }; |
| 314 | fetchFirstJSON( |
| 315 | ["https://crash.reasonix.io/v1/cli/releases/stable/latest.json"], |
| 316 | fetch, |
| 317 | (payload) => Boolean(cliReleaseModel(Array.isArray(payload) ? payload : [payload])), |
| 318 | ) |
| 319 | .catch(() => fallbackCLIReleases()) |
| 320 | .then((payload) => { |
| 321 | const releases = Array.isArray(payload) ? payload : payload ? [payload] : []; |
| 322 | const model = cliReleaseModel(releases); |
| 323 | if (!model) return; |
| 324 | releaseModels.cli = model; |
| 325 | updateCLIPackageVersion(model); |
| 326 | renderReleaseSurface("cli"); |
| 327 | }) |
| 328 | .catch(() => {}); |
| 329 | })(); |
| 330 |