diff --git a/datasette/static/datasette-manager.js b/datasette/static/datasette-manager.js
new file mode 100644
index 0000000000..10716cc51a
--- /dev/null
+++ b/datasette/static/datasette-manager.js
@@ -0,0 +1,210 @@
+// Custom events for use with the native CustomEvent API
+const DATASETTE_EVENTS = {
+ INIT: "datasette_init", // returns datasette manager instance in evt.detail
+};
+
+// Datasette "core" -> Methods/APIs that are foundational
+// Plugins will have greater stability if they use the functional hooks- but if they do decide to hook into
+// literal DOM selectors, they'll have an easier time using these addresses.
+const DOM_SELECTORS = {
+ /** Should have one match */
+ jsonExportLink: ".export-links a[href*=json]",
+
+ /** Event listeners that go outside of the main table, e.g. existing scroll listener */
+ tableWrapper: ".table-wrapper",
+ table: "table.rows-and-columns",
+ aboveTablePanel: ".above-table-panel",
+
+ // These could have multiple matches
+ /** Used for selecting table headers. Use makeColumnActions if you want to add menu items. */
+ tableHeaders: `table.rows-and-columns th`,
+
+ /** Used to add "where" clauses to query using direct manipulation */
+ filterRows: ".filter-row",
+ /** Used to show top available enum values for a column ("facets") */
+ facetResults: ".facet-results [data-column]",
+};
+
+/**
+ * Monolith class for interacting with Datasette JS API
+ * Imported with DEFER, runs after main document parsed
+ * For now, manually synced with datasette/version.py
+ */
+const datasetteManager = {
+ VERSION: window.datasetteVersion,
+
+ // TODO: Should order of registration matter more?
+
+ // Should plugins be allowed to clobber others or is it last-in takes priority?
+ // Does pluginMetadata need to be serializable, or can we let it be stateful / have functions?
+ plugins: new Map(),
+
+ registerPlugin: (name, pluginMetadata) => {
+ if (datasetteManager.plugins.has(name)) {
+ console.warn(`Warning -> plugin ${name} was redefined`);
+ }
+ datasetteManager.plugins.set(name, pluginMetadata);
+
+ // If the plugin participates in the panel... update the panel.
+ if (pluginMetadata.makeAboveTablePanelConfigs) {
+ datasetteManager.renderAboveTablePanel();
+ }
+ },
+
+ /**
+ * New DOM elements are created on each click, so the data is not stale.
+ *
+ * Items
+ * - must provide label (text)
+ * - might provide href (string) or an onclick ((evt) => void)
+ *
+ * columnMeta is metadata stored on the column header (TH) as a DOMStringMap
+ * - column: string
+ * - columnNotNull: boolean
+ * - columnType: sqlite datatype enum (text, number, etc)
+ * - isPk: boolean
+ */
+ makeColumnActions: (columnMeta) => {
+ let columnActions = [];
+
+ // Accept function that returns list of columnActions with keys
+ // Required: label (text)
+ // Optional: onClick or href
+ datasetteManager.plugins.forEach((plugin) => {
+ if (plugin.makeColumnActions) {
+ // Plugins can provide multiple columnActions if they want
+ // If multiple try to create entry with same label, the last one deletes the others
+ columnActions.push(...plugin.makeColumnActions(columnMeta));
+ }
+ });
+
+ // TODO: Validate columnAction configs and give informative error message if missing keys.
+ return columnActions;
+ },
+
+ /**
+ * In MVP, each plugin can only have 1 instance.
+ * In future, panels could be repeated. We omit that for now since so many plugins depend on
+ * shared URL state, so having multiple instances of plugin at same time is problematic.
+ * Currently, we never destroy any panels, we just hide them.
+ *
+ * TODO: nicer panel css, show panel selection state.
+ * TODO: does this hook need to take any arguments?
+ */
+ renderAboveTablePanel: () => {
+ const aboveTablePanel = document.querySelector(
+ DOM_SELECTORS.aboveTablePanel
+ );
+
+ if (!aboveTablePanel) {
+ console.warn(
+ "This page does not have a table, the renderAboveTablePanel cannot be used."
+ );
+ return;
+ }
+
+ let aboveTablePanelWrapper = aboveTablePanel.querySelector(".panels");
+
+ // First render: create wrappers. Otherwise, reuse previous.
+ if (!aboveTablePanelWrapper) {
+ aboveTablePanelWrapper = document.createElement("div");
+ aboveTablePanelWrapper.classList.add("tab-contents");
+ const panelNav = document.createElement("div");
+ panelNav.classList.add("tab-controls");
+
+ // Temporary: css for minimal amount of breathing room.
+ panelNav.style.display = "flex";
+ panelNav.style.gap = "8px";
+ panelNav.style.marginTop = "4px";
+ panelNav.style.marginBottom = "20px";
+
+ aboveTablePanel.appendChild(panelNav);
+ aboveTablePanel.appendChild(aboveTablePanelWrapper);
+ }
+
+ datasetteManager.plugins.forEach((plugin, pluginName) => {
+ const { makeAboveTablePanelConfigs } = plugin;
+
+ if (makeAboveTablePanelConfigs) {
+ const controls = aboveTablePanel.querySelector(".tab-controls");
+ const contents = aboveTablePanel.querySelector(".tab-contents");
+
+ // Each plugin can make multiple panels
+ const configs = makeAboveTablePanelConfigs();
+
+ configs.forEach((config, i) => {
+ const nodeContentId = `${pluginName}_${config.id}_panel-content`;
+
+ // quit if we've already registered this plugin
+ // TODO: look into whether plugins should be allowed to ask
+ // parent to re-render, or if they should manage that internally.
+ if (document.getElementById(nodeContentId)) {
+ return;
+ }
+
+ // Add tab control button
+ const pluginControl = document.createElement("button");
+ pluginControl.textContent = config.label;
+ pluginControl.onclick = () => {
+ contents.childNodes.forEach((node) => {
+ if (node.id === nodeContentId) {
+ node.style.display = "block";
+ } else {
+ node.style.display = "none";
+ }
+ });
+ };
+ controls.appendChild(pluginControl);
+
+ // Add plugin content area
+ const pluginNode = document.createElement("div");
+ pluginNode.id = nodeContentId;
+ config.render(pluginNode);
+ pluginNode.style.display = "none"; // Default to hidden unless you're ifrst
+
+ contents.appendChild(pluginNode);
+ });
+
+ // Let first node be selected by default
+ if (contents.childNodes.length) {
+ contents.childNodes[0].style.display = "block";
+ }
+ }
+ });
+ },
+
+ /** Selectors for document (DOM) elements. Store identifier instead of immediate references in case they haven't loaded when Manager starts. */
+ selectors: DOM_SELECTORS,
+
+ // Future API ideas
+ // Fetch page's data in array, and cache so plugins could reuse it
+ // Provide knowledge of what datasette JS or server-side via traditional console autocomplete
+ // State helpers: URL params https://github.com/simonw/datasette/issues/1144 and localstorage
+ // UI Hooks: command + k, tab manager hook
+ // Should we notify plugins that have dependencies
+ // when all dependencies were fulfilled? (leaflet, codemirror, etc)
+ // https://github.com/simonw/datasette-leaflet -> this way
+ // multiple plugins can all request the same copy of leaflet.
+};
+
+const initializeDatasette = () => {
+ // Hide the global behind __ prefix. Ideally they should be listening for the
+ // DATASETTE_EVENTS.INIT event to avoid the habit of reading from the window.
+
+ window.__DATASETTE__ = datasetteManager;
+ console.debug("Datasette Manager Created!");
+
+ const initDatasetteEvent = new CustomEvent(DATASETTE_EVENTS.INIT, {
+ detail: datasetteManager,
+ });
+
+ document.dispatchEvent(initDatasetteEvent);
+};
+
+/**
+ * Main function
+ * Fires AFTER the document has been parsed
+ */
+document.addEventListener("DOMContentLoaded", function () {
+ initializeDatasette();
+});
diff --git a/datasette/static/table.js b/datasette/static/table.js
index 51e901a5da..778457c532 100644
--- a/datasette/static/table.js
+++ b/datasette/static/table.js
@@ -17,7 +17,8 @@ var DROPDOWN_ICON_SVG = ``;
-(function () {
+/** Main initialization function for Datasette Table interactions */
+const initDatasetteTable = function (manager) {
// Feature detection
if (!window.URLSearchParams) {
return;
@@ -68,13 +69,11 @@ var DROPDOWN_ICON_SVG = `