Repository: bokeh/bokeh

Issue:
Bundling and serving precompiled extensions in broken on windows
Original issue in panel https://github.com/holoviz/panel/issues/1233.


Discussion:

Resolving patch:
diff --git a/bokeh/embed/bundle.py b/bokeh/embed/bundle.py
--- a/bokeh/embed/bundle.py
+++ b/bokeh/embed/bundle.py
@@ -20,7 +20,7 @@
 
 # Standard library imports
 import json
-from os.path import abspath, basename, dirname, exists, join
+from os.path import abspath, basename, dirname, exists, join, normpath
 from typing import Dict, List, NamedTuple, Optional
 from warnings import warn
 
@@ -186,7 +186,7 @@ def bundle_for_objs_and_resources(objs, resources):
         elif mode == "server":
             js_files.extend([ bundle.server_url for bundle in extensions ])
         elif mode == "cdn":
-            js_files.extend([ bundle.cdn_url for bundle in extensions ])
+            js_files.extend([ bundle.cdn_url for bundle in extensions if bundle.cdn_url is not None ])
         else:
             js_files.extend([ bundle.artifact_path for bundle in extensions ])
 
@@ -278,7 +278,7 @@ def _bundle_extensions(objs, resources: Resources) -> List[ExtensionEmbed]:
                 cdn_url = f"{_default_cdn_host}/{pkg_name}@^{pkg_version}/{pkg_main}"
             else:
                 pkg_main = join(dist_dir, f"{name}.js")
-            artifact_path = join(base_dir, pkg_main)
+            artifact_path = join(base_dir, normpath(pkg_main))
             artifacts_dir = dirname(artifact_path)
             artifact_name = basename(artifact_path)
             server_path = f"{name}/{artifact_name}"
diff --git a/bokeh/server/views/multi_root_static_handler.py b/bokeh/server/views/multi_root_static_handler.py
--- a/bokeh/server/views/multi_root_static_handler.py
+++ b/bokeh/server/views/multi_root_static_handler.py
@@ -19,6 +19,7 @@
 #-----------------------------------------------------------------------------
 
 # Standard library imports
+import os
 from typing import Dict, Optional
 
 # External imports
@@ -49,7 +50,7 @@ def initialize(self, root: Dict[str, str]) -> None:
     @classmethod
     def get_absolute_path(cls, root: Dict[str, str], path: str) -> str:
         try:
-            name, artifact_path = path.split("/", 1)
+            name, artifact_path = path.split(os.sep, 1)
         except ValueError:
             raise HTTPError(404)
 


Test patch:
diff --git a/tests/unit/bokeh/embed/latex_label/.gitignore b/tests/unit/bokeh/embed/latex_label/.gitignore
new file mode 100644
--- /dev/null
+++ b/tests/unit/bokeh/embed/latex_label/.gitignore
@@ -0,0 +1,3 @@
+/.bokeh
+/dist/
+/node_modules/
diff --git a/tests/unit/bokeh/embed/latex_label/__init__.py b/tests/unit/bokeh/embed/latex_label/__init__.py
new file mode 100644
--- /dev/null
+++ b/tests/unit/bokeh/embed/latex_label/__init__.py
@@ -0,0 +1,12 @@
+# Bokeh imports
+from bokeh.models import Label
+
+
+class LatexLabel(Label):
+    """A subclass of `Label` with all of the same class attributes except
+    canvas mode isn't supported and DOM manipulation happens in the TypeScript
+    superclass implementation that requires setting `render_mode='css'`).
+
+    Only the render method of LabelView is overwritten to perform the
+    text -> latex (via katex) conversion.
+    """
diff --git a/tests/unit/bokeh/embed/latex_label/bokeh.ext.json b/tests/unit/bokeh/embed/latex_label/bokeh.ext.json
new file mode 100644
--- /dev/null
+++ b/tests/unit/bokeh/embed/latex_label/bokeh.ext.json
@@ -0,0 +1 @@
+{}
diff --git a/tests/unit/bokeh/embed/latex_label/index.ts b/tests/unit/bokeh/embed/latex_label/index.ts
new file mode 100644
--- /dev/null
+++ b/tests/unit/bokeh/embed/latex_label/index.ts
@@ -0,0 +1,4 @@
+import {LatexLabel} from "./latex_label"
+
+import {register_models} from "@bokehjs/base"
+register_models({LatexLabel})
diff --git a/tests/unit/bokeh/embed/latex_label/latex_label.ts b/tests/unit/bokeh/embed/latex_label/latex_label.ts
new file mode 100644
--- /dev/null
+++ b/tests/unit/bokeh/embed/latex_label/latex_label.ts
@@ -0,0 +1,50 @@
+import {Label, LabelView} from "@bokehjs/models/annotations/label"
+
+import * as katex from "katex"
+
+//"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.10.0/katex.min.js"
+//"https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.10.0/katex.min.css"
+
+export class LatexLabelView extends LabelView {
+  model: LatexLabel
+
+  render(): void {
+    // Here because AngleSpec does units tranform and label doesn't support specs
+    let angle: number
+    switch (this.model.angle_units) {
+      case "rad": {
+        angle = -1 * this.model.angle
+        break
+      }
+      case "deg": {
+        angle = -1 * this.model.angle * Math.PI/180.0
+        break
+      }
+      default:
+        throw new Error("unreachable")
+    }
+
+    const panel = this.panel || this.plot_view.frame
+
+    const xscale = this.plot_view.frame.xscales[this.model.x_range_name]
+    const yscale = this.plot_view.frame.yscales[this.model.y_range_name]
+
+    const {x, y} = this.model
+    let sx = this.model.x_units == "data" ? xscale.compute(x) : panel.xview.compute(x)
+    let sy = this.model.y_units == "data" ? yscale.compute(y) : panel.yview.compute(y)
+
+    sx += this.model.x_offset
+    sy -= this.model.y_offset
+
+    this._css_text(this.plot_view.canvas_view.ctx, "", sx, sy, angle)
+    katex.render(this.model.text, this.el, {displayMode: true})
+  }
+}
+
+export class LatexLabel extends Label {
+  static __module__ = "latex_label"
+
+  static init_LatexLabel(): void {
+    this.prototype.default_view = LatexLabelView
+  }
+}
diff --git a/tests/unit/bokeh/embed/latex_label/package-lock.json b/tests/unit/bokeh/embed/latex_label/package-lock.json
new file mode 100644
--- /dev/null
+++ b/tests/unit/bokeh/embed/latex_label/package-lock.json
@@ -0,0 +1,348 @@
+{
+  "name": "latex_label",
+  "version": "0.0.1",
+  "lockfileVersion": 1,
+  "requires": true,
+  "dependencies": {
+    "@bokeh/bokehjs": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/@bokeh/bokehjs/-/bokehjs-2.0.1.tgz",
+      "integrity": "sha512-jTcjrL/zxKZbyBtr2BVl+f8sLbKOH9dwmEzR3s1qd6XWTeVSjwQwNbzjPniFwGtNN/iv0LzO6+Rt7vaY/VbEow==",
+      "requires": {
+        "@bokeh/canvas2svg": "^1.0.22",
+        "@bokeh/gloo2": "^0.0.1",
+        "@bokeh/numbro": "^1.6.2",
+        "@bokeh/slickgrid": "^2.3.23",
+        "choices.js": "^9.0.1",
+        "es5-ext": "^0.10.53",
+        "es6-map": "^0.1.5",
+        "es6-promise": "4.2.8",
+        "es6-set": "^0.1.5",
+        "es6-weak-map": "^2.0.2",
+        "flatbush": "^3.2.1",
+        "flatpickr": "^4.6.3",
+        "hammerjs": "^2.0.4",
+        "nouislider": "^10.0.0",
+        "proj4": "^2.6.0",
+        "sprintf-js": "^1.1.2",
+        "timezone": "^1.0.23",
+        "tslib": "^1.11.0",
+        "underscore.template": "^0.1.7"
+      }
+    },
+    "@bokeh/canvas2svg": {
+      "version": "1.0.22",
+      "resolved": "https://registry.npmjs.org/@bokeh/canvas2svg/-/canvas2svg-1.0.22.tgz",
+      "integrity": "sha512-jLA/AmmQEjpCJq5U838exrWR/EtnBqfy5qaG/oKpnGXYKj3VcJIUL0WgYMVMHxRZmEUk1GO7SChaUk0Gz4h/vA=="
+    },
+    "@bokeh/gloo2": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/@bokeh/gloo2/-/gloo2-0.0.1.tgz",
+      "integrity": "sha512-N1RMIcxRLnUcycn/vXPwCgw24GAfGfdTiGIPW/DgPULpsn25Q7+iXKmieP3/BB/ZsRQvS3cSek6qaCv5pUbVPw=="
+    },
+    "@bokeh/numbro": {
+      "version": "1.6.2",
+      "resolved": "https://registry.npmjs.org/@bokeh/numbro/-/numbro-1.6.2.tgz",
+      "integrity": "sha512-owIECPc3T3QXHCb2v5Ez+/uE9SIxI7N4nd9iFlWnfBrOelr0/omvFn09VisRn37AAFAY39sJiCVgECwryHWUPA=="
+    },
+    "@bokeh/slickgrid": {
+      "version": "2.3.23",
+      "resolved": "https://registry.npmjs.org/@bokeh/slickgrid/-/slickgrid-2.3.23.tgz",
+      "integrity": "sha512-gm01jGqIn+mSqK4OgfC85QqWoRE3tYJcP6PHfDI6BCK9NcS7PuPaok7cGEQPafcfzscQAm1BP7tGpfMsnj/CjQ==",
+      "requires": {
+        "@types/slickgrid": "^2.1.27",
+        "jquery": ">=3.4.0",
+        "jquery-ui": ">=1.8.0",
+        "tslib": "^1.10.0"
+      }
+    },
+    "@types/jquery": {
+      "version": "3.3.34",
+      "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.3.34.tgz",
+      "integrity": "sha512-lW9vsVL53Xu/Nj4gi2hNmHGc4u3KKghjqTkAlO0kF5GIOPxbqqnQpgqJBzmn3yXLrPqHb6cmNJ6URnS23Vtvbg==",
+      "requires": {
+        "@types/sizzle": "*"
+      }
+    },
+    "@types/katex": {
+      "version": "0.10.2",
+      "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.10.2.tgz",
+      "integrity": "sha512-QYTNuhuDU22gouVoL5kLmBTguu2rPvtp7wL5U5fTFsQkM1ojfUgxFPMFrqOSUofrdWwnZEmblcobdlLXj1lVOw==",
+      "dev": true
+    },
+    "@types/sizzle": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.2.tgz",
+      "integrity": "sha512-7EJYyKTL7tFR8+gDbB6Wwz/arpGa0Mywk1TJbNzKzHtzbwVmY4HR9WqS5VV7dsBUKQmPNr192jHr/VpBluj/hg=="
+    },
+    "@types/slickgrid": {
+      "version": "2.1.29",
+      "resolved": "https://registry.npmjs.org/@types/slickgrid/-/slickgrid-2.1.29.tgz",
+      "integrity": "sha512-dAWPuYLDb4EfEaMZtbI2Z/4epL1o8tazkv+7gIEwbU2gYwATW4cce75/R6m2/Zfxj1FmEX71SL9WRwf2x4VQQw==",
+      "requires": {
+        "@types/jquery": "*"
+      }
+    },
+    "choices.js": {
+      "version": "9.0.1",
+      "resolved": "https://registry.npmjs.org/choices.js/-/choices.js-9.0.1.tgz",
+      "integrity": "sha512-JgpeDY0Tmg7tqY6jaW/druSklJSt7W68tXFJIw0GSGWmO37SDAL8o60eICNGbzIODjj02VNNtf5h6TgoHDtCsA==",
+      "requires": {
+        "deepmerge": "^4.2.0",
+        "fuse.js": "^3.4.5",
+        "redux": "^4.0.4"
+      }
+    },
+    "commander": {
+      "version": "2.20.3",
+      "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+      "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
+    },
+    "d": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz",
+      "integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==",
+      "requires": {
+        "es5-ext": "^0.10.50",
+        "type": "^1.0.1"
+      }
+    },
+    "deepmerge": {
+      "version": "4.2.2",
+      "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz",
+      "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg=="
+    },
+    "es5-ext": {
+      "version": "0.10.53",
+      "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.53.tgz",
+      "integrity": "sha512-Xs2Stw6NiNHWypzRTY1MtaG/uJlwCk8kH81920ma8mvN8Xq1gsfhZvpkImLQArw8AHnv8MT2I45J3c0R8slE+Q==",
+      "requires": {
+        "es6-iterator": "~2.0.3",
+        "es6-symbol": "~3.1.3",
+        "next-tick": "~1.0.0"
+      }
+    },
+    "es6-iterator": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz",
+      "integrity": "sha1-p96IkUGgWpSwhUQDstCg+/qY87c=",
+      "requires": {
+        "d": "1",
+        "es5-ext": "^0.10.35",
+        "es6-symbol": "^3.1.1"
+      }
+    },
+    "es6-map": {
+      "version": "0.1.5",
+      "resolved": "https://registry.npmjs.org/es6-map/-/es6-map-0.1.5.tgz",
+      "integrity": "sha1-kTbgUD3MBqMBaQ8LsU/042TpSfA=",
+      "requires": {
+        "d": "1",
+        "es5-ext": "~0.10.14",
+        "es6-iterator": "~2.0.1",
+        "es6-set": "~0.1.5",
+        "es6-symbol": "~3.1.1",
+        "event-emitter": "~0.3.5"
+      }
+    },
+    "es6-promise": {
+      "version": "4.2.8",
+      "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz",
+      "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w=="
+    },
+    "es6-set": {
+      "version": "0.1.5",
+      "resolved": "https://registry.npmjs.org/es6-set/-/es6-set-0.1.5.tgz",
+      "integrity": "sha1-0rPsXU2ADO2BjbU40ol02wpzzLE=",
+      "requires": {
+        "d": "1",
+        "es5-ext": "~0.10.14",
+        "es6-iterator": "~2.0.1",
+        "es6-symbol": "3.1.1",
+        "event-emitter": "~0.3.5"
+      },
+      "dependencies": {
+        "es6-symbol": {
+          "version": "3.1.1",
+          "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.1.tgz",
+          "integrity": "sha1-vwDvT9q2uhtG7Le2KbTH7VcVzHc=",
+          "requires": {
+            "d": "1",
+            "es5-ext": "~0.10.14"
+          }
+        }
+      }
+    },
+    "es6-symbol": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz",
+      "integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==",
+      "requires": {
+        "d": "^1.0.1",
+        "ext": "^1.1.2"
+      }
+    },
+    "es6-weak-map": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.3.tgz",
+      "integrity": "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==",
+      "requires": {
+        "d": "1",
+        "es5-ext": "^0.10.46",
+        "es6-iterator": "^2.0.3",
+        "es6-symbol": "^3.1.1"
+      }
+    },
+    "event-emitter": {
+      "version": "0.3.5",
+      "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz",
+      "integrity": "sha1-34xp7vFkeSPHFXuc6DhAYQsCzDk=",
+      "requires": {
+        "d": "1",
+        "es5-ext": "~0.10.14"
+      }
+    },
+    "ext": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/ext/-/ext-1.4.0.tgz",
+      "integrity": "sha512-Key5NIsUxdqKg3vIsdw9dSuXpPCQ297y6wBjL30edxwPgt2E44WcWBZey/ZvUc6sERLTxKdyCu4gZFmUbk1Q7A==",
+      "requires": {
+        "type": "^2.0.0"
+      },
+      "dependencies": {
+        "type": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/type/-/type-2.0.0.tgz",
+          "integrity": "sha512-KBt58xCHry4Cejnc2ISQAF7QY+ORngsWfxezO68+12hKV6lQY8P/psIkcbjeHWn7MqcgciWJyCCevFMJdIXpow=="
+        }
+      }
+    },
+    "flatbush": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/flatbush/-/flatbush-3.2.1.tgz",
+      "integrity": "sha512-RAqcCyM18R0HhGIcZ7nTRImHnvmJAQqxSN8VIrRLPyWDuFjxluiyE99wuDqFiwNwBodlHXBQNf/9CrlfSqJq2A==",
+      "requires": {
+        "flatqueue": "^1.2.0"
+      }
+    },
+    "flatpickr": {
+      "version": "4.6.3",
+      "resolved": "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.3.tgz",
+      "integrity": "sha512-007VucCkqNOMMb9ggRLNuJowwaJcyOh4sKAFcdGfahfGc7JQbf94zSzjdBq/wVyHWUEs5o3+idhFZ0wbZMRmVQ=="
+    },
+    "flatqueue": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/flatqueue/-/flatqueue-1.2.0.tgz",
+      "integrity": "sha512-Z/nhmRwSywE3xnHXHqbLzJiUZ9akOHZlB1IIqCzRRldWrxqp6EzqGVxTl9Fl5cSoUzC5ge7xq3WIPct8ADYdhw=="
+    },
+    "fuse.js": {
+      "version": "3.6.1",
+      "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-3.6.1.tgz",
+      "integrity": "sha512-hT9yh/tiinkmirKrlv4KWOjztdoZo1mx9Qh4KvWqC7isoXwdUY3PNWUxceF4/qO9R6riA2C29jdTOeQOIROjgw=="
+    },
+    "hammerjs": {
+      "version": "2.0.8",
+      "resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz",
+      "integrity": "sha1-BO93hiz/K7edMPdpIJWTAiK/YPE="
+    },
+    "jquery": {
+      "version": "3.5.0",
+      "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.5.0.tgz",
+      "integrity": "sha512-Xb7SVYMvygPxbFMpTFQiHh1J7HClEaThguL15N/Gg37Lri/qKyhRGZYzHRyLH8Stq3Aow0LsHO2O2ci86fCrNQ=="
+    },
+    "jquery-ui": {
+      "version": "1.12.1",
+      "resolved": "https://registry.npmjs.org/jquery-ui/-/jquery-ui-1.12.1.tgz",
+      "integrity": "sha1-vLQEXI3QU5wTS8FIjN0+dop6nlE="
+    },
+    "js-tokens": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
+    },
+    "katex": {
+      "version": "0.11.1",
+      "resolved": "https://registry.npmjs.org/katex/-/katex-0.11.1.tgz",
+      "integrity": "sha512-5oANDICCTX0NqYIyAiFCCwjQ7ERu3DQG2JFHLbYOf+fXaMoH8eg/zOq5WSYJsKMi/QebW+Eh3gSM+oss1H/bww==",
+      "requires": {
+        "commander": "^2.19.0"
+      }
+    },
+    "loose-envify": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+      "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+      "requires": {
+        "js-tokens": "^3.0.0 || ^4.0.0"
+      }
+    },
+    "mgrs": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/mgrs/-/mgrs-1.0.0.tgz",
+      "integrity": "sha1-+5FYjnjJACVnI5XLQLJffNatGCk="
+    },
+    "next-tick": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz",
+      "integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw="
+    },
+    "nouislider": {
+      "version": "10.1.0",
+      "resolved": "https://registry.npmjs.org/nouislider/-/nouislider-10.1.0.tgz",
+      "integrity": "sha512-lENwxlpoYg4/5gjdaY/PMNHeVL+CMJyrO+7RzXi1MqhSSGwuJsQSJteXCQV5bE2UKEdSLARWrqIF8XSWAq7h+A=="
+    },
+    "proj4": {
+      "version": "2.6.1",
+      "resolved": "https://registry.npmjs.org/proj4/-/proj4-2.6.1.tgz",
+      "integrity": "sha512-RP5EcrfrLcARy+Zjjz1wIeqZzZdPtQNl685asHcwdU/MQ/dvydmf1XWM4mgok6wPaNsXZ8IFrM4qadO3g46PiQ==",
+      "requires": {
+        "mgrs": "1.0.0",
+        "wkt-parser": "^1.2.4"
+      }
+    },
+    "redux": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmjs.org/redux/-/redux-4.0.5.tgz",
+      "integrity": "sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w==",
+      "requires": {
+        "loose-envify": "^1.4.0",
+        "symbol-observable": "^1.2.0"
+      }
+    },
+    "sprintf-js": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz",
+      "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug=="
+    },
+    "symbol-observable": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz",
+      "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ=="
+    },
+    "timezone": {
+      "version": "1.0.23",
+      "resolved": "https://registry.npmjs.org/timezone/-/timezone-1.0.23.tgz",
+      "integrity": "sha512-yhQgk6qmSLB+TF8HGmApZAVI5bfzR1CoKUGr+WMZWmx75ED1uDewAZA8QMGCQ70TEv4GmM8pDB9jrHuxdaQ1PA=="
+    },
+    "tslib": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.11.1.tgz",
+      "integrity": "sha512-aZW88SY8kQbU7gpV19lN24LtXh/yD4ZZg6qieAJDDg+YBsJcSmLGK9QpnUjAKVG/xefmvJGd1WUmfpT/g6AJGA=="
+    },
+    "type": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz",
+      "integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg=="
+    },
+    "underscore.template": {
+      "version": "0.1.7",
+      "resolved": "https://registry.npmjs.org/underscore.template/-/underscore.template-0.1.7.tgz",
+      "integrity": "sha1-MBPg6hgXVjBvFgnpWcr7xyKts+k="
+    },
+    "wkt-parser": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/wkt-parser/-/wkt-parser-1.2.4.tgz",
+      "integrity": "sha512-ZzKnc7ml/91fOPh5bANBL4vUlWPIYYv11waCtWTkl2TRN+LEmBg60Q1MA8gqV4hEp4MGfSj9JiHz91zw/gTDXg=="
+    }
+  }
+}
diff --git a/tests/unit/bokeh/embed/latex_label/package.json b/tests/unit/bokeh/embed/latex_label/package.json
new file mode 100644
--- /dev/null
+++ b/tests/unit/bokeh/embed/latex_label/package.json
@@ -0,0 +1,16 @@
+{
+  "name": "latex_label",
+  "version": "0.0.1",
+  "description": "",
+  "license": "BSD-3-Clause",
+  "keywords": [],
+  "repository": {},
+  "dependencies": {
+    "@bokeh/bokehjs": "^2.0.1",
+    "katex": "^0.11.1"
+  },
+  "devDependencies": {
+    "@types/katex": "^0.10.2"
+  },
+  "main": "dist/latex_label.js"
+}
diff --git a/tests/unit/bokeh/embed/latex_label/tsconfig.json b/tests/unit/bokeh/embed/latex_label/tsconfig.json
new file mode 100644
--- /dev/null
+++ b/tests/unit/bokeh/embed/latex_label/tsconfig.json
@@ -0,0 +1,38 @@
+{
+  "compilerOptions": {
+    "noImplicitAny": true,
+    "noImplicitThis": true,
+    "noImplicitReturns": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "strictNullChecks": true,
+    "strictBindCallApply": false,
+    "strictFunctionTypes": false,
+    "strictPropertyInitialization": false,
+    "alwaysStrict": true,
+    "noErrorTruncation": true,
+    "noEmitOnError": false,
+    "allowJs": false,
+    "declaration": true,
+    "sourceMap": true,
+    "importHelpers": false,
+    "experimentalDecorators": true,
+    "module": "ES2020",
+    "moduleResolution": "node",
+    "resolveJsonModule": true,
+    "esModuleInterop": true,
+    "target": "ES2017",
+    "lib": ["es2017", "dom", "dom.iterable"],
+    "baseUrl": ".",
+    "outDir": "./dist/lib",
+    "paths": {
+      "@bokehjs/*": [
+        "./node_modules/@bokeh/bokehjs/build/js/lib/*",
+        "./node_modules/@bokeh/bokehjs/build/js/types/*"
+      ]
+    }
+  },
+  "include": [
+    "./**/*.ts"
+  ]
+}
diff --git a/tests/unit/bokeh/embed/test_bundle.py b/tests/unit/bokeh/embed/test_bundle.py
--- a/tests/unit/bokeh/embed/test_bundle.py
+++ b/tests/unit/bokeh/embed/test_bundle.py
@@ -16,9 +16,14 @@
 
 # Standard library imports
 import os
+from os.path import dirname, join
 
 # Bokeh imports
 from bokeh.document import Document
+from bokeh.embed.bundle import extension_dirs
+from bokeh.ext import build
+from bokeh.model import Model
+from bokeh.models import Plot
 from bokeh.resources import INLINE
 
 # Module under test
@@ -87,6 +92,41 @@ def test_env_vars_precedence(self) -> None:
         # this is a cheap test that a BokehJS file is NOT inline
         assert all(len(x) < 5000 for x in b.js_raw)
 
+class Test_bundle_custom_extensions:
+
+    @classmethod
+    def setup_class(cls):
+        base_dir = dirname(__file__)
+        build(join(base_dir, "latex_label"), rebuild=True)
+
+    @classmethod
+    def teardown_class(cls):
+        del Model.model_class_reverse_map["latex_label.LatexLabel"]
+        extension_dirs.clear()
+
+    def test_with_INLINE_resources(self) -> None:
+        from latex_label import LatexLabel
+        plot = Plot()
+        plot.add_layout(LatexLabel())
+        bundle = beb.bundle_for_objs_and_resources([plot], "inline")
+        assert len(bundle.js_raw) == 3
+        assert "class LatexLabelView" in bundle.js_raw[2]
+
+    def test_with_CDN_resources(self) -> None:
+        from latex_label import LatexLabel
+        plot = Plot()
+        plot.add_layout(LatexLabel())
+        bundle = beb.bundle_for_objs_and_resources([plot], "cdn")
+        assert len(bundle.js_files) == 2
+        assert bundle.js_files[1] == "https://unpkg.com/latex_label@^0.0.1/dist/latex_label.js"
+
+    def test_with_Server_resources(self) -> None:
+        from latex_label import LatexLabel
+        plot = Plot()
+        plot.add_layout(LatexLabel())
+        bundle = beb.bundle_for_objs_and_resources([plot], "server")
+        assert len(bundle.js_files) == 2
+        assert bundle.js_files[1] == "http://localhost:5006/static/extensions/latex_label/latex_label.js"
 
 #-----------------------------------------------------------------------------
 # Private API