|
| 1 | +/** |
| 2 | + * configuration utilities for jupyter-lite |
| 3 | + * |
| 4 | + * this file may not import anything else, and exposes no API |
| 5 | + */ |
| 6 | + |
| 7 | +/* |
| 8 | + * An `index.html` should `await import('../config-utils.js')` after specifying |
| 9 | + * the key `script` tags... |
| 10 | + * |
| 11 | + * ```html |
| 12 | + * <script id="jupyter-config-data" type="application/json" data-jupyter-lite-root=".."> |
| 13 | + * {} |
| 14 | + * </script> |
| 15 | + * ``` |
| 16 | + */ |
| 17 | +const JUPYTER_CONFIG_ID = 'jupyter-config-data'; |
| 18 | + |
| 19 | +/* |
| 20 | + * The JS-mangled name for `data-jupyter-lite-root` |
| 21 | + */ |
| 22 | +const LITE_ROOT_ATTR = 'jupyterLiteRoot'; |
| 23 | + |
| 24 | +/** |
| 25 | + * The well-known filename that contains `#jupyter-config-data` and other goodies |
| 26 | + */ |
| 27 | +const LITE_FILES = ['jupyter-lite.json', 'jupyter-lite.ipynb']; |
| 28 | + |
| 29 | +/** |
| 30 | + * And this link tag, used like so to load a bundle after configuration. |
| 31 | + * |
| 32 | + * ```html |
| 33 | + * <link |
| 34 | + * id="jupyter-lite-main" |
| 35 | + * rel="preload" |
| 36 | + * href="../build/bundle.js?_=bad4a54" |
| 37 | + * main="index" |
| 38 | + * as="script" |
| 39 | + * /> |
| 40 | + * ``` |
| 41 | + */ |
| 42 | +const LITE_MAIN = 'jupyter-lite-main'; |
| 43 | + |
| 44 | +/** |
| 45 | + * The current page, with trailing server junk stripped |
| 46 | + */ |
| 47 | +const HERE = `${window.location.origin}${window.location.pathname.replace( |
| 48 | + /(\/|\/index.html)?$/, |
| 49 | + '', |
| 50 | +)}/`; |
| 51 | + |
| 52 | +/** |
| 53 | + * The computed composite configuration |
| 54 | + */ |
| 55 | +let _JUPYTER_CONFIG; |
| 56 | + |
| 57 | +/** |
| 58 | + * A handle on the config script, must exist, and will be overridden |
| 59 | + */ |
| 60 | +const CONFIG_SCRIPT = document.getElementById(JUPYTER_CONFIG_ID); |
| 61 | + |
| 62 | +/** |
| 63 | + * The relative path to the root of this JupyterLite |
| 64 | + */ |
| 65 | +const RAW_LITE_ROOT = CONFIG_SCRIPT.dataset[LITE_ROOT_ATTR]; |
| 66 | + |
| 67 | +/** |
| 68 | + * The fully-resolved path to the root of this JupyterLite |
| 69 | + */ |
| 70 | +const FULL_LITE_ROOT = new URL(RAW_LITE_ROOT, HERE).toString(); |
| 71 | + |
| 72 | +/** |
| 73 | + * Paths that are joined with baseUrl to derive full URLs |
| 74 | + */ |
| 75 | +const UNPREFIXED_PATHS = ['licensesUrl', 'themesUrl']; |
| 76 | + |
| 77 | +/* a DOM parser for reading html files */ |
| 78 | +const parser = new DOMParser(); |
| 79 | + |
| 80 | +/** |
| 81 | + * Merge `jupyter-config-data` on the current page with: |
| 82 | + * - the contents of `.jupyter-lite#/jupyter-config-data` |
| 83 | + * - parent documents, and their `.jupyter-lite#/jupyter-config-data` |
| 84 | + * ...up to `jupyter-lite-root`. |
| 85 | + */ |
| 86 | +async function jupyterConfigData() { |
| 87 | + /** |
| 88 | + * Return the value if already cached for some reason |
| 89 | + */ |
| 90 | + if (_JUPYTER_CONFIG != null) { |
| 91 | + return _JUPYTER_CONFIG; |
| 92 | + } |
| 93 | + |
| 94 | + let parent = new URL(HERE).toString(); |
| 95 | + let promises = [getPathConfig(HERE)]; |
| 96 | + while (parent != FULL_LITE_ROOT) { |
| 97 | + parent = new URL('..', parent).toString(); |
| 98 | + promises.unshift(getPathConfig(parent)); |
| 99 | + } |
| 100 | + |
| 101 | + const configs = (await Promise.all(promises)).flat(); |
| 102 | + |
| 103 | + let finalConfig = configs.reduce(mergeOneConfig); |
| 104 | + |
| 105 | + // apply any final patches |
| 106 | + finalConfig = dedupFederatedExtensions(finalConfig); |
| 107 | + |
| 108 | + // hoist to cache |
| 109 | + _JUPYTER_CONFIG = finalConfig; |
| 110 | + |
| 111 | + return finalConfig; |
| 112 | +} |
| 113 | + |
| 114 | +/** |
| 115 | + * Merge a new configuration on top of the existing config |
| 116 | + */ |
| 117 | +function mergeOneConfig(memo, config) { |
| 118 | + for (const [k, v] of Object.entries(config)) { |
| 119 | + switch (k) { |
| 120 | + // this list of extension names is appended |
| 121 | + case 'disabledExtensions': |
| 122 | + case 'federated_extensions': |
| 123 | + memo[k] = [...(memo[k] || []), ...v]; |
| 124 | + break; |
| 125 | + // these `@org/pkg:plugin` are merged at the first level of values |
| 126 | + case 'litePluginSettings': |
| 127 | + case 'settingsOverrides': |
| 128 | + if (!memo[k]) { |
| 129 | + memo[k] = {}; |
| 130 | + } |
| 131 | + for (const [plugin, defaults] of Object.entries(v || {})) { |
| 132 | + memo[k][plugin] = { ...(memo[k][plugin] || {}), ...defaults }; |
| 133 | + } |
| 134 | + break; |
| 135 | + default: |
| 136 | + memo[k] = v; |
| 137 | + } |
| 138 | + } |
| 139 | + return memo; |
| 140 | +} |
| 141 | + |
| 142 | +function dedupFederatedExtensions(config) { |
| 143 | + const originalList = Object.keys(config || {})['federated_extensions'] || []; |
| 144 | + const named = {}; |
| 145 | + for (const ext of originalList) { |
| 146 | + named[ext.name] = ext; |
| 147 | + } |
| 148 | + let allExtensions = [...Object.values(named)]; |
| 149 | + allExtensions.sort((a, b) => a.name.localeCompare(b.name)); |
| 150 | + return config; |
| 151 | +} |
| 152 | + |
| 153 | +/** |
| 154 | + * Load jupyter config data from (this) page and merge with |
| 155 | + * `jupyter-lite.json#jupyter-config-data` |
| 156 | + */ |
| 157 | +async function getPathConfig(url) { |
| 158 | + let promises = [getPageConfig(url)]; |
| 159 | + for (const fileName of LITE_FILES) { |
| 160 | + promises.unshift(getLiteConfig(url, fileName)); |
| 161 | + } |
| 162 | + return Promise.all(promises); |
| 163 | +} |
| 164 | + |
| 165 | +/** |
| 166 | + * The current normalized location |
| 167 | + */ |
| 168 | +function here() { |
| 169 | + return window.location.href.replace(/(\/|\/index.html)?$/, '/'); |
| 170 | +} |
| 171 | + |
| 172 | +/** |
| 173 | + * Maybe fetch an `index.html` in this folder, which must contain the trailing slash. |
| 174 | + */ |
| 175 | +export async function getPageConfig(url = null) { |
| 176 | + let script = CONFIG_SCRIPT; |
| 177 | + |
| 178 | + if (url != null) { |
| 179 | + const text = await (await window.fetch(`${url}index.html`)).text(); |
| 180 | + const doc = parser.parseFromString(text, 'text/html'); |
| 181 | + script = doc.getElementById(JUPYTER_CONFIG_ID); |
| 182 | + } |
| 183 | + return fixRelativeUrls(url, JSON.parse(script.textContent)); |
| 184 | +} |
| 185 | + |
| 186 | +/** |
| 187 | + * Fetch a jupyter-lite JSON or Notebook in this folder, which must contain the trailing slash. |
| 188 | + */ |
| 189 | +export async function getLiteConfig(url, fileName) { |
| 190 | + let text = '{}'; |
| 191 | + let config = {}; |
| 192 | + const liteUrl = `${url || HERE}${fileName}`; |
| 193 | + try { |
| 194 | + text = await (await window.fetch(liteUrl)).text(); |
| 195 | + const json = JSON.parse(text); |
| 196 | + const liteConfig = fileName.endsWith('.ipynb') |
| 197 | + ? json['metadata']['jupyter-lite'] |
| 198 | + : json; |
| 199 | + config = liteConfig[JUPYTER_CONFIG_ID] || {}; |
| 200 | + } catch (err) { |
| 201 | + console.warn(`failed get ${JUPYTER_CONFIG_ID} from ${liteUrl}`); |
| 202 | + } |
| 203 | + return fixRelativeUrls(url, config); |
| 204 | +} |
| 205 | + |
| 206 | +export function fixRelativeUrls(url, config) { |
| 207 | + let urlBase = new URL(url || here()).pathname; |
| 208 | + for (const [k, v] of Object.entries(config)) { |
| 209 | + config[k] = fixOneRelativeUrl(k, v, url, urlBase); |
| 210 | + } |
| 211 | + return config; |
| 212 | +} |
| 213 | + |
| 214 | +export function fixOneRelativeUrl(key, value, url, urlBase) { |
| 215 | + if (key === 'litePluginSettings' || key === 'settingsOverrides') { |
| 216 | + // these are plugin id-keyed objects, fix each plugin |
| 217 | + return Object.entries(value || {}).reduce((m, [k, v]) => { |
| 218 | + m[k] = fixRelativeUrls(url, v); |
| 219 | + return m; |
| 220 | + }, {}); |
| 221 | + } else if ( |
| 222 | + !UNPREFIXED_PATHS.includes(key) && |
| 223 | + key.endsWith('Url') && |
| 224 | + value.startsWith('./') |
| 225 | + ) { |
| 226 | + // themesUrls, etc. are joined in code with baseUrl, leave as-is: otherwise, clean |
| 227 | + return `${urlBase}${value.slice(2)}`; |
| 228 | + } else if (key.endsWith('Urls') && Array.isArray(value)) { |
| 229 | + return value.map((v) => (v.startsWith('./') ? `${urlBase}${v.slice(2)}` : v)); |
| 230 | + } |
| 231 | + return value; |
| 232 | +} |
| 233 | + |
| 234 | +/** |
| 235 | + * Update with the as-configured favicon |
| 236 | + */ |
| 237 | +function addFavicon(config) { |
| 238 | + const favicon = document.createElement('link'); |
| 239 | + favicon.rel = 'icon'; |
| 240 | + favicon.type = 'image/x-icon'; |
| 241 | + favicon.href = config.faviconUrl; |
| 242 | + document.head.appendChild(favicon); |
| 243 | +} |
| 244 | + |
| 245 | +/** |
| 246 | + * The main entry point. |
| 247 | + */ |
| 248 | +async function main() { |
| 249 | + const config = await jupyterConfigData(); |
| 250 | + if (config.baseUrl === new URL(here()).pathname) { |
| 251 | + window.location.href = config.appUrl.replace(/\/?$/, '/index.html'); |
| 252 | + return; |
| 253 | + } |
| 254 | + // rewrite the config |
| 255 | + CONFIG_SCRIPT.textContent = JSON.stringify(config, null, 2); |
| 256 | + addFavicon(config); |
| 257 | + const preloader = document.getElementById(LITE_MAIN); |
| 258 | + const bundle = document.createElement('script'); |
| 259 | + bundle.src = preloader.href; |
| 260 | + bundle.main = preloader.attributes.main; |
| 261 | + document.head.appendChild(bundle); |
| 262 | +} |
| 263 | + |
| 264 | +/** |
| 265 | + * TODO: consider better pattern for invocation. |
| 266 | + */ |
| 267 | +await main(); |
0 commit comments