Path: blob/main/src/format/dashboard/format-dashboard.ts
6451 views
/*1* format-dashboard.ts2*3* Copyright (C) 2020-2022 Posit Software, PBC4*/56import {7HtmlPostProcessResult,8RenderServices,9} from "../../command/render/types.ts";10import {11kEcho,12kFilterParams,13kIncludeAfterBody,14kIpynbShellInteractivity,15kLogo,16kPlotlyConnected,17kTemplate,18kTheme,19kWarning,20} from "../../config/constants.ts";21import {22DependencyHtmlFile,23Format,24FormatExtras,25kDependencies,26kHtmlPostprocessors,27kSassBundles,28Metadata,29} from "../../config/types.ts";30import { LogoLightDarkSpecifier } from "../../resources/types/zod/schema-types.ts";31import { PandocFlags } from "../../config/types.ts";32import { mergeConfigs } from "../../core/config.ts";33import { Document, Element } from "../../core/deno-dom.ts";34import { InternalError } from "../../core/lib/error.ts";35import { formatResourcePath } from "../../core/resources.ts";36import { kLogoAlt, ProjectContext } from "../../project/types.ts";37import { registerWriterFormatHandler } from "../format-handlers.ts";38import { kPageLayout, kPageLayoutCustom } from "../html/format-html-shared.ts";39import { htmlFormat } from "../html/format-html.ts";40import { kDTTableSentinel } from "./format-dashboard-shared.ts";4142import { join } from "../../deno_ral/path.ts";43import {44DashboardMeta,45dashboardMeta,46dashboardScssLayer,47kDashboard,48kDontMutateTags,49} from "./format-dashboard-shared.ts";50import { processCards } from "./format-dashboard-card.ts";51import { processValueBoxes } from "./format-dashboard-valuebox.ts";52import {53applyFillItemClasses,54processColumns,55processRows,56} from "./format-dashboard-layout.ts";57import { processSidebars } from "./format-dashboard-sidebar.ts";58import { kTemplatePartials } from "../../command/render/template.ts";59import { processPages } from "./format-dashboard-page.ts";60import { processNavButtons } from "./format-dashboard-navbutton.ts";61import { processNavigation } from "./format-dashboard-website.ts";62import { projectIsWebsite } from "../../project/project-shared.ts";63import { processShinyComponents } from "./format-dashboard-shiny.ts";64import { processToolbars } from "./format-dashboard-toolbar.ts";65import { processDatatables } from "./format-dashboard-tables.ts";66import { assert } from "testing/asserts";67import { brandBootstrapSassBundles } from "../../core/sass/brand.ts";68import { logoAddLeadingSlashes, resolveLogo } from "../../core/brand/brand.ts";6970const kDashboardClz = "quarto-dashboard";7172export function dashboardFormat() {73// use ~ the golden ratio74const baseHtmlFormat = htmlFormat(8, 5);75const dashboardFormat = mergeConfigs(76baseHtmlFormat,77{78execute: {79[kEcho]: false,80[kWarning]: false,81[kIpynbShellInteractivity]: "all",82[kPlotlyConnected]: false,83},84metadata: {85[kPageLayout]: kPageLayoutCustom,86},87},88);8990if (baseHtmlFormat.formatExtras) {91const dashboardExtras = async (92input: string,93markdown: string,94flags: PandocFlags,95format: Format,96libDir: string,97services: RenderServices,98offset?: string,99project?: ProjectContext,100quiet?: boolean,101) => {102assert(project);103if (baseHtmlFormat.formatExtras) {104// Read the dashboard metadata105const dashboard = await dashboardMeta(format);106107const isWebsiteProject = projectIsWebsite(project);108109// Forward the theme along (from either the html format110// or from the dashboard format)111// TODO: There must be a beter way to do this112if (isWebsiteProject) {113const formats: Record<string, Metadata> = format.metadata114.format as Record<string, Metadata>;115const htmlFormat = formats["html"];116const dashboardFormat = formats["dashboard"];117if (dashboardFormat && dashboardFormat[kTheme]) {118format.metadata[kTheme] = dashboardFormat[kTheme];119} else if (htmlFormat && htmlFormat[kTheme]) {120format.metadata[kTheme] = htmlFormat[kTheme];121}122}123124const brand = format.render.brand;125let logoSpec = format.metadata[kLogo] as LogoLightDarkSpecifier;126if (typeof logoSpec === "string" && format.metadata[kLogoAlt]) {127logoSpec = {128path: logoSpec,129alt: format.metadata[kLogoAlt] as string,130};131}132let logo = resolveLogo(brand, logoSpec, [133"small",134"medium",135"large",136]);137logo = logoAddLeadingSlashes(logo, brand, input);138139format.metadata[kLogo] = logo;140const extras: FormatExtras = await baseHtmlFormat.formatExtras(141input,142markdown,143flags,144format,145libDir,146services,147offset,148project,149quiet,150);151152extras.html = extras.html || {};153extras.html[kHtmlPostprocessors] = extras.html[kHtmlPostprocessors] ||154[];155extras.html[kHtmlPostprocessors].push(156dashboardHtmlPostProcessor(dashboard),157);158159extras.metadata = extras.metadata || {};160extras.metadata[kTemplatePartials] = [161"title-block.html",162"_nav-container.html",163].map(164(file) => {165return formatResourcePath(166"dashboard",167file,168);169},170);171172extras.pandoc = extras.pandoc || {};173extras.pandoc[kTemplate] = formatResourcePath(174"dashboard",175"template.html",176);177178extras[kFilterParams] = extras[kFilterParams] || {};179extras[kFilterParams][kDashboard] = {180orientation: dashboard.orientation,181scrolling: dashboard.scrolling,182};183184extras.html[kSassBundles] = extras.html[kSassBundles] || [];185if (!isWebsiteProject) {186// If this is a website project, it will inject the scss for dashboards187extras.html[kSassBundles].unshift(dashboardScssLayer());188}189190// add _brand.yml sass bundle191extras.html[kSassBundles].push(192...await brandBootstrapSassBundles(input, project, "bootstrap"),193);194195const scripts: DependencyHtmlFile[] = [];196const stylesheets: DependencyHtmlFile[] = [];197198// Add the js script which we can use in dashboard to make client side199// adjustments200scripts.push({201name: "quarto-dashboard.js",202path: formatResourcePath("dashboard", "quarto-dashboard.js"),203});204205// Add the sticky headers script206scripts.push({207name: "stickythead.js",208path: formatResourcePath("dashboard", join("js", "stickythead.js")),209});210211// Add the DT scripts and CSS212// Note that the `tables` processing may remove this if no connected / matching DT tables213// are detected214scripts.push({215name: "datatables.min.js",216path: formatResourcePath(217"dashboard",218join("js", "dt", "datatables.min.js"),219),220attribs: {221[kDTTableSentinel]: "true",222},223});224stylesheets.push({225name: "datatables.min.css",226path: formatResourcePath(227"dashboard",228join("js", "dt", "datatables.min.css"),229),230attribs: {231[kDTTableSentinel]: "true",232},233});234scripts.push({235name: "pdfmake.min.js",236path: formatResourcePath(237"dashboard",238join("js", "dt", "pdfmake.min.js"),239),240attribs: {241[kDTTableSentinel]: "true",242},243});244scripts.push({245name: "vfs_fonts.js",246path: formatResourcePath(247"dashboard",248join("js", "dt", "vfs_fonts.js"),249),250attribs: {251[kDTTableSentinel]: "true",252},253});254255const componentDir = join(256"bslib",257"components",258"dist",259);260261[{262name: "web-components",263module: true,264}, { name: "components", module: false }].forEach(265(dependency) => {266const attribs: Record<string, string> = {};267if (dependency.module) {268attribs["type"] = "module";269}270271scripts.push({272name: `${dependency.name}.js`,273path: formatResourcePath(274"html",275join(componentDir, `${dependency.name}.js`),276),277attribs,278});279},280);281282extras.html[kDependencies] = extras.html[kDependencies] || [];283extras.html[kDependencies].push({284name: "quarto-dashboard",285scripts,286stylesheets,287});288289extras[kIncludeAfterBody] = extras[kIncludeAfterBody] || [];290291return extras;292} else {293throw new InternalError(294"Dashboard superclass must provide a format extras",295);296}297};298299if (dashboardExtras) {300dashboardFormat.formatExtras = dashboardExtras;301}302}303304return dashboardFormat;305}306307registerWriterFormatHandler((format) => {308switch (format) {309case "dashboard":310return {311format: dashboardFormat(),312pandocTo: "html",313};314}315});316317function dashboardHtmlPostProcessor(318dashboardMeta: DashboardMeta,319) {320return (doc: Document): Promise<HtmlPostProcessResult> => {321const result: HtmlPostProcessResult = {322resources: [],323supporting: [],324};325326// Mark the body as a quarto dashboard327doc.body.classList.add(kDashboardClz);328329// Note the orientation as fill if needed330if (!dashboardMeta.scrolling) {331doc.body.classList.add("dashboard-fill");332}333334// Mark the page container with layout instructions335const containerEl = doc.querySelector("div.page-layout-custom");336if (containerEl) {337const containerClz = [338"quarto-dashboard-content",339"bslib-gap-spacing",340"html-fill-container",341];342343// The scrolling behavior344if (!dashboardMeta.scrolling) {345containerClz.push("bslib-page-fill"); // only apply this if we aren't scrolling346} else {347containerClz.push("dashboard-scrolling"); // only apply this if we are scrolling348}349350containerClz.forEach(351(clz) => {352containerEl.classList.add(clz);353},354);355}356357// Mark the children with layout instructions358const children = containerEl?.children;359if (children) {360for (const childEl of children) {361// All the children of the dashboard container at the root level become362// fill children363if (364!childEl.classList.contains("quarto-title-block") &&365!kDontMutateTags.includes(childEl.tagName.toUpperCase())366) {367childEl.classList.add("bslib-grid-item");368applyFillItemClasses(childEl);369}370}371}372373// Helper for forwarding supporting and resources374const addResults = (375res: {376resources: string[];377supporting: string[];378} | undefined,379) => {380if (res) {381result.resources.push(...res.resources);382result.supporting.push(...res.supporting);383}384};385386// Process Data Tables387addResults(processDatatables(doc));388389// Process navigation390processNavigation(doc);391392// Process pages that may be present in the document393processPages(doc, dashboardMeta);394395// Process Navbar buttons396processNavButtons(doc, dashboardMeta);397398// Adjust the appearance of row elements399processRows(doc);400401// Adjust the appearance of column element402processColumns(doc);403404// Process card405processCards(doc, dashboardMeta);406407// Process valueboxes408processValueBoxes(doc);409410// Process sidedars411processSidebars(doc);412413// Process toolbars414processToolbars(doc);415416// Process tables417processTables(doc);418419// Process Shiny Specific Components420processShinyComponents(doc);421422// Process fill images to include proper fill behavior423const imgFillSelectors = [424"div.cell-output-display > div.quarto-figure > .quarto-float img",425"div.cell-output-display > img",426];427imgFillSelectors.forEach((selector) => {428const fillImgNodes = doc.body.querySelectorAll(selector);429for (const fillImgNode of fillImgNodes) {430const fillImgEl = fillImgNode as Element;431fillImgEl.classList.add("quarto-dashboard-img-contain");432fillImgEl.removeAttribute("height");433fillImgEl.removeAttribute("width");434}435});436437return Promise.resolve(result);438};439}440441function processTables(doc: Document) {442doc.querySelectorAll(".itables table").forEach((tableEl) => {443(tableEl as Element).setAttribute("style", "width:100%;");444});445}446447448