diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 831406539..41fe83806 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,7 +94,6 @@ jobs: with: path: ~/.npm key: ${{ runner.os }}-node-${{ hashFiles('asssets/package-lock.json') }} - - run: mkdir -p tmp/handlebars - run: npm ci --prefix assets - run: npm run build --prefix assets - name: Push updated assets diff --git a/assets/.eslintrc.js b/assets/.eslintrc.js index 7436d4593..b36d1cfd2 100644 --- a/assets/.eslintrc.js +++ b/assets/.eslintrc.js @@ -16,8 +16,5 @@ module.exports = { 'no-throw-literal': 0, 'no-useless-escape': 0, 'object-curly-spacing': 0 - }, - globals: { - 'Handlebars': 'readonly' } } diff --git a/assets/build/build.js b/assets/build/build.js index b490cd034..c46619056 100644 --- a/assets/build/build.js +++ b/assets/build/build.js @@ -1,155 +1,89 @@ const path = require('node:path') const process = require('node:process') -const child_process = require('node:child_process') +const cp = require('node:child_process') const esbuild = require('esbuild') -const util = require('./utilities') - -const watchMode = Boolean(process.env.npm_config_watch) - - -/** - * Configuration variables - */ - -// Basic build configuration and values -const commonOptions = { - entryNames: '[name]-[hash]', - bundle: true, - minify: true, - logLevel: watchMode ? 'warning' : 'info', -} -const epubOutDir = path.resolve('../formatters/epub/dist') -const htmlOutDir = path.resolve('../formatters/html/dist') - -// Handlebars template paths -const templates = { - sourceDir: path.resolve('js/handlebars/templates'), - compiledDir: path.resolve('../tmp/handlebars'), - filename: 'handlebars.templates.js', -} -templates.compiledPath = path.join(templates.compiledDir, templates.filename) - - -/** - * Build: Plugins - */ - -// Empty outdir directories before both normal and watch-mode builds -const epubOnStartPlugin = { - name: 'epubOnStart', - setup(build) { build.onStart(() => util.ensureEmptyDirsExistSync([epubOutDir])) }, -} -const htmlOnStartPlugin = { - name: 'htmlOnStart', - setup(build) { build.onStart(() => util.ensureEmptyDirsExistSync([htmlOutDir])) }, -} +const fsExtra = require('fs-extra') +const fs = require('node:fs/promises') +const handlebars = require('handlebars') +const util = require('node:util') +const exec = util.promisify(cp.exec) -/** - * Build - */ - -// ePub: esbuild options -const epubBuildOptions = { - ...commonOptions, - outdir: epubOutDir, - plugins: [epubOnStartPlugin], - entryPoints: [ - 'js/entry/epub.js', - 'css/entry/epub-elixir.css', - 'css/entry/epub-erlang.css', - ], -} - -// ePub: esbuild (conditionally configuring watch mode and rebuilding of docs) -if (!watchMode) { - esbuild.build(epubBuildOptions).catch(() => process.exit(1)) -} else { - esbuild.build({ - ...epubBuildOptions, - watch: { - onRebuild(error, result) { - if (error) { - console.error('[watch] epub build failed:', error) - } else { - console.log('[watch] epub assets rebuilt') - if (result.errors.length > 0) console.log('[watch] epub build errors:', result.errors) - if (result.warnings.length > 0) console.log('[watch] epub build warnings:', result.warnings) - generateDocs("epub") - } - }, - }, - }).then(() => generateDocs("epub")).catch(() => process.exit(1)) -} - -// HTML: Precompile Handlebars templates -util.runShellCmdSync(`npx handlebars ${templates.sourceDir} --output ${templates.compiledPath}`) +const watchMode = Boolean(process.env.npm_config_watch) -// HTML: esbuild options -const htmlBuildOptions = { - ...commonOptions, - outdir: htmlOutDir, - plugins: [htmlOnStartPlugin], - entryPoints: [ - templates.compiledPath, - 'js/entry/html.js', - 'css/entry/html-elixir.css', - 'css/entry/html-erlang.css', - ], - loader: { - '.woff2': 'file', - // TODO: Remove when @fontsource/* removes legacy .woff - '.woff': 'file', +/** @type {import('esbuild').BuildOptions[]} */ +const formatters = [ + { + formatter: 'epub', + outdir: path.resolve('../formatters/epub/dist'), + entryPoints: [ + 'js/entry/epub.js', + 'css/entry/epub-elixir.css', + 'css/entry/epub-erlang.css' + ] }, -} - -// HTML: esbuild (conditionally configuring watch mode and rebuilding of docs) -if (!watchMode) { - esbuild.build(htmlBuildOptions).then(() => buildTemplatesRuntime()).catch(() => process.exit(1)) -} else { - esbuild.build({ - ...htmlBuildOptions, - watch: { - onRebuild(error, result) { - if (error) { - console.error('[watch] html build failed:', error) - } else { - console.log('[watch] html assets rebuilt') - if (result.errors.length > 0) console.log('[watch] html build errors:', result.errors) - if (result.warnings.length > 0) console.log('[watch] html build warnings:', result.warnings) - buildTemplatesRuntime() - generateDocs("html") + { + formatter: 'html', + outdir: path.resolve('../formatters/html/dist'), + entryPoints: [ + 'js/entry/html.js', + 'css/entry/html-elixir.css', + 'css/entry/html-erlang.css' + ], + loader: { + '.woff2': 'file', + // TODO: Remove when @fontsource/* removes legacy .woff + '.woff': 'file' + } + } +] + +Promise.all(formatters.map(async ({formatter, ...options}) => { + // Clean outdir. + await fsExtra.emptyDir(options.outdir) + + await esbuild.build({ + entryNames: watchMode ? '[name]-dev' : '[name]-[hash]', + bundle: true, + minify: !watchMode, + logLevel: watchMode ? 'warning' : 'info', + watch: watchMode, + ...options, + plugins: [{ + name: 'ex_doc', + setup (build) { + // Pre-compile handlebars templates. + build.onLoad({ + filter: /\.handlebars$/ + }, async ({ path: filename }) => { + try { + const source = await fs.readFile(filename, 'utf-8') + const template = handlebars.precompile(source) + const contents = [ + "import * as Handlebars from 'handlebars/runtime'", + "import '../helpers'", + `export default Handlebars.template(${template})` + ].join('\n') + return { contents } + } catch (error) { + return { errors: [{ text: error.message }] } + } + }) + + // Generate docs with new assets (watch mode only). + if (watchMode) { + build.onEnd(async result => { + if (result.errors.length) return + console.log(`${formatter} assets built`) + await exec('mix compile --force', {cwd: '../'}) + await exec(`mix docs --formatter ${formatter}`, {cwd: '../'}) + console.log(`${formatter} docs built`) + }) } - }, - }, - }).then(() => { - buildTemplatesRuntime() - generateDocs("html") - }).catch(() => process.exit(1)) -} - -/** - * Functions - */ - -// HTML: Handlebars runtime -// The Handlebars runtime from the local module dist directory is used to ensure -// the version matches that which was used to compile the templates. -// 'bundle' must be false in order for 'Handlebar' to be available at runtime. -function buildTemplatesRuntime() { - esbuild.build({ - ...commonOptions, - outdir: htmlOutDir, - entryPoints: ['node_modules/handlebars/dist/handlebars.runtime.js'], - bundle: false, - }).catch(() => process.exit(1)) -} - -// Docs generation (used in watch mode only) -function generateDocs(formatter) { - console.log(`Building ${formatter} docs`) - process.chdir('../') - child_process.execSync('mix compile --force') - child_process.execSync(`mix docs --formatter ${formatter}`) - process.chdir('./assets/') -} + } + }] + }) +})).catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/assets/build/utilities.js b/assets/build/utilities.js deleted file mode 100644 index 9ac0d11a7..000000000 --- a/assets/build/utilities.js +++ /dev/null @@ -1,21 +0,0 @@ -const child_process = require('node:child_process') -const fs = require('fs-extra') - -module.exports.runShellCmdSync = (command) => { - child_process.execSync(command, (err, stdout, stderr) => { - if (err) { - console.error(err) - process.exit(1) - } else { - if (stdout) { console.log('\n' + stdout) } - if (stderr) { console.log('\n' + stderr) } - return true - } - }) -} - -module.exports.ensureEmptyDirsExistSync = (dirs) => { - dirs.forEach(dir => { - fs.emptyDirSync(dir) - }) -} diff --git a/assets/js/autocomplete/autocomplete-list.js b/assets/js/autocomplete/autocomplete-list.js index 8d9f5cf05..693c94715 100644 --- a/assets/js/autocomplete/autocomplete-list.js +++ b/assets/js/autocomplete/autocomplete-list.js @@ -1,6 +1,7 @@ import { getSuggestions } from './suggestions' import { isBlank, qs } from '../helpers' import { currentTheme } from '../theme' +import autocompleteSuggestionsTemplate from '../handlebars/templates/autocomplete-suggestions.handlebars' export const AUTOCOMPLETE_CONTAINER_SELECTOR = '.autocomplete' export const AUTOCOMPLETE_SUGGESTION_LIST_SELECTOR = '.autocomplete-suggestions' @@ -56,7 +57,7 @@ export function updateAutocompleteList (searchTerm) { // Updates list of suggestions inside the autocomplete. function renderSuggestions ({ term, suggestions }) { - const autocompleteContainerHtml = Handlebars.templates['autocomplete-suggestions']({ suggestions, term }) + const autocompleteContainerHtml = autocompleteSuggestionsTemplate({ suggestions, term }) const autocompleteContainer = qs(AUTOCOMPLETE_CONTAINER_SELECTOR) autocompleteContainer.innerHTML = autocompleteContainerHtml diff --git a/assets/js/handlebars/helpers.js b/assets/js/handlebars/helpers.js index 6452cb49b..17c355e4b 100644 --- a/assets/js/handlebars/helpers.js +++ b/assets/js/handlebars/helpers.js @@ -1,3 +1,5 @@ +import * as Handlebars from 'handlebars/runtime' + Handlebars.registerHelper('groupChanged', function (context, nodeGroup, options) { const group = nodeGroup || '' if (context.group !== group) { diff --git a/assets/js/modal.js b/assets/js/modal.js index 22b314d26..1bc62dc29 100644 --- a/assets/js/modal.js +++ b/assets/js/modal.js @@ -1,4 +1,5 @@ import { qs } from './helpers' +import modalLayoutTemplate from './handlebars/templates/modal-layout.handlebars' const MODAL_SELECTOR = '.modal' const MODAL_CLOSE_BUTTON_SELECTOR = '.modal .modal-close' @@ -22,7 +23,7 @@ export function initialize () { * Adds the modal to DOM, initially it's hidden. */ function renderModal () { - const modalLayoutHtml = Handlebars.templates['modal-layout']() + const modalLayoutHtml = modalLayoutTemplate() document.body.insertAdjacentHTML('beforeend', modalLayoutHtml) qs(MODAL_SELECTOR).addEventListener('keydown', event => { diff --git a/assets/js/quick-switch.js b/assets/js/quick-switch.js index a59ff028f..8871c8e14 100644 --- a/assets/js/quick-switch.js +++ b/assets/js/quick-switch.js @@ -1,5 +1,7 @@ import { debounce, qs, qsAll } from './helpers' import { openModal } from './modal' +import quickSwitchModalBodyTemplate from './handlebars/templates/quick-switch-modal-body.handlebars' +import quickSwitchResultsTemplate from './handlebars/templates/quick-switch-results.handlebars' const HEX_DOCS_ENDPOINT = 'https://hexdocs.pm/%%' const OTP_DOCS_ENDPOINT = 'https://www.erlang.org/doc/apps/%%' @@ -115,7 +117,7 @@ function handleInput (event) { export function openQuickSwitchModal () { openModal({ title: 'Go to package docs', - body: Handlebars.templates['quick-switch-modal-body']() + body: quickSwitchModalBodyTemplate() }) qs(QUICK_SWITCH_INPUT_SELECTOR).focus() @@ -185,7 +187,7 @@ function queryForAutocomplete (packageSlug) { function renderResults ({ results }) { const resultsContainer = qs(QUICK_SWITCH_RESULTS_SELECTOR) - const resultsHtml = Handlebars.templates['quick-switch-results']({ results }) + const resultsHtml = quickSwitchResultsTemplate({ results }) resultsContainer.innerHTML = resultsHtml qsAll(QUICK_SWITCH_RESULT_SELECTOR).forEach(result => { diff --git a/assets/js/search-page.js b/assets/js/search-page.js index aa91066b8..17ee6d7ee 100644 --- a/assets/js/search-page.js +++ b/assets/js/search-page.js @@ -3,6 +3,7 @@ import lunr from 'lunr' import { qs, escapeHtmlEntities, isBlank, getQueryParamByName, getProjectNameAndVersion } from './helpers' import { setSearchInputValue } from './search-bar' +import searchResultsTemplate from './handlebars/templates/search-results.handlebars' const EXCERPT_RADIUS = 80 const SEARCH_CONTAINER_SELECTOR = '#search' @@ -48,7 +49,7 @@ async function search (value) { function renderResults ({ value, results, errorMessage }) { const searchContainer = qs(SEARCH_CONTAINER_SELECTOR) - const resultsHtml = Handlebars.templates['search-results']({ value, results, errorMessage }) + const resultsHtml = searchResultsTemplate({ value, results, errorMessage }) searchContainer.innerHTML = resultsHtml } diff --git a/assets/js/settings.js b/assets/js/settings.js index 365e3c12e..ad383134c 100644 --- a/assets/js/settings.js +++ b/assets/js/settings.js @@ -2,6 +2,7 @@ import { qs, qsAll } from './helpers' import { openModal } from './modal' import { settingsStore } from './settings-store' import { keyboardShortcuts } from './keyboard-shortcuts' +import settingsModalBodyTemplate from './handlebars/templates/settings-modal-body.handlebars' const SETTINGS_LINK_SELECTOR = '.display-settings' const SETTINGS_MODAL_BODY_SELECTOR = '#settings-modal-content' @@ -53,7 +54,7 @@ function showKeyboardShortcutsTab () { export function openSettingsModal () { openModal({ title: modalTabs.map(({id, title}) => ``).join(''), - body: Handlebars.templates['settings-modal-body']({ shortcuts: keyboardShortcuts }) + body: settingsModalBodyTemplate({ shortcuts: keyboardShortcuts }) }) const modal = qs(SETTINGS_MODAL_BODY_SELECTOR) diff --git a/assets/js/sidebar/sidebar-list.js b/assets/js/sidebar/sidebar-list.js index 86720a5d8..397cf5a57 100644 --- a/assets/js/sidebar/sidebar-list.js +++ b/assets/js/sidebar/sidebar-list.js @@ -1,5 +1,6 @@ import { qs, getCurrentPageSidebarType, getLocationHash, findSidebarCategory } from '../helpers' import { getSidebarNodes } from '../globals' +import sidebarItemsTemplate from '../handlebars/templates/sidebar-items.handlebars' const SIDEBAR_TYPE = { search: 'search', @@ -42,7 +43,7 @@ function renderSidebarNodeList (nodesByType, type) { // Render the list const nodeList = qs(sidebarNodeListSelector(type)) if (!nodeList) { return } - const listContentHtml = Handlebars.templates['sidebar-items']({ nodes, group: '' }) + const listContentHtml = sidebarItemsTemplate({ nodes, group: '' }) nodeList.innerHTML = listContentHtml // Removes the "expand" class from links belonging to single-level sections diff --git a/assets/js/sidebar/sidebar-version-select.js b/assets/js/sidebar/sidebar-version-select.js index 9b3d48e88..f10ade61e 100644 --- a/assets/js/sidebar/sidebar-version-select.js +++ b/assets/js/sidebar/sidebar-version-select.js @@ -1,5 +1,6 @@ import { qs, checkUrlExists } from '../helpers' import { getVersionNodes } from '../globals' +import versionsDropdownTemplate from '../handlebars/templates/versions-dropdown.handlebars' const VERSIONS_CONTAINER_SELECTOR = '.sidebar-projectVersion' const VERSIONS_DROPDOWN_SELECTOR = '.sidebar-projectVersionsDropdown' @@ -22,7 +23,7 @@ export function initialize () { function renderVersionsDropdown ({ nodes }) { const versionsContainer = qs(VERSIONS_CONTAINER_SELECTOR) - const versionsDropdownHtml = Handlebars.templates['versions-dropdown']({ nodes }) + const versionsDropdownHtml = versionsDropdownTemplate({ nodes }) versionsContainer.innerHTML = versionsDropdownHtml qs(VERSIONS_DROPDOWN_SELECTOR).addEventListener('change', handleVersionSelected) diff --git a/assets/js/tabsets.js b/assets/js/tabsets.js index 8c33c51a3..251ed2023 100644 --- a/assets/js/tabsets.js +++ b/assets/js/tabsets.js @@ -1,3 +1,5 @@ +import tabsetTemplate from './handlebars/templates/tabset.handlebars' + const CONTENT_CONTAINER_ID = 'content' const TABSET_OPEN_COMMENT = 'tabs-open' const TABSET_CLOSE_COMMENT = 'tabs-close' @@ -78,7 +80,7 @@ function processTabset (element, tabSetIndex, _array) { wrapElements(allSetNodes, container) // Apply template to tabset container element. - container.innerHTML = Handlebars.templates.tabset({tabs: tabSet}) + container.innerHTML = tabsetTemplate({tabs: tabSet}) // Return tabset container element. return container diff --git a/assets/js/tooltips/tooltips.js b/assets/js/tooltips/tooltips.js index f7a38d82f..a220c62bc 100644 --- a/assets/js/tooltips/tooltips.js +++ b/assets/js/tooltips/tooltips.js @@ -1,6 +1,8 @@ import { qs, qsAll } from '../helpers' import { settingsStore } from '../settings-store' import { cancelHintFetchingIfAny, getHint, HINT_KIND, isValidHintHref } from './hints' +import tooltipLayoutTemplate from '../handlebars/templates/tooltip-layout.handlebars' +import tooltipBodyTemplate from '../handlebars/templates/tooltip-body.handlebars' // Elements that can activate the tooltip. const TOOLTIP_ACTIVATORS_SELECTOR = '.content a' @@ -41,7 +43,7 @@ export function initialize () { } function renderTooltipLayout () { - const tooltipLayoutHtml = Handlebars.templates['tooltip-layout']() + const tooltipLayoutHtml = tooltipLayoutTemplate() qs(CONTENT_INNER_SELECTOR).insertAdjacentHTML('beforeend', tooltipLayoutHtml) } @@ -108,7 +110,7 @@ function shouldShowTooltips () { } function renderTooltip (hint) { - const tooltipBodyHtml = Handlebars.templates['tooltip-body']({ + const tooltipBodyHtml = tooltipBodyTemplate({ isPlain: hint.kind === HINT_KIND.plain, hint }) diff --git a/assets/package.json b/assets/package.json index a41c67550..82b64e553 100644 --- a/assets/package.json +++ b/assets/package.json @@ -9,9 +9,9 @@ "npm": ">= 9.0.0" }, "scripts": { - "lint": "npx eslint './js/**/*.js'", - "lint:fix": "npx eslint --fix './js/**/*.js'", - "test": "npx karma start ./karma.conf.js --single-run", + "lint": "eslint './js/**/*.js'", + "lint:fix": "eslint --fix './js/**/*.js'", + "test": "karma start ./karma.conf.js --single-run", "build:watch": "npm run build --watch", "build": "node build/build.js" }, diff --git a/lib/ex_doc/formatter/html/templates/head_template.eex b/lib/ex_doc/formatter/html/templates/head_template.eex index 48e8e9be0..b8aa6414e 100644 --- a/lib/ex_doc/formatter/html/templates/head_template.eex +++ b/lib/ex_doc/formatter/html/templates/head_template.eex @@ -17,8 +17,6 @@ <%= if config.canonical do %> <% end %> - - diff --git a/mix.exs b/mix.exs index 2c3fcf449..7112a3b9a 100644 --- a/mix.exs +++ b/mix.exs @@ -56,7 +56,7 @@ defmodule ExDoc.Mixfile do clean: [&clean_test_fixtures/1, "clean"], fix: ["format", "cmd --cd assets npm run lint:fix"], lint: ["format --check-formatted", "cmd --cd assets npm run lint"], - setup: ["deps.get", "cmd mkdir -p tmp/handlebars", "cmd --cd assets npm install"] + setup: ["deps.get", "cmd --cd assets npm install"] ] end