Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions addon/components/docs-header/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,9 @@ export default class DocsHeader extends Component {
@action
didVisitPage() {
this.query = null;
let search = document.querySelector('[data-search-box-input]');
search.blur();
if (typeof document !== 'undefined') {
let search = document.querySelector('[data-search-box-input]');
search?.blur();
}
}
}
2 changes: 2 additions & 0 deletions addon/components/docs-header/search-box/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export default class DocsHeaderSearchBox extends Component {

@action
focusSearch() {
if (typeof document === 'undefined') return;

if (!formElementHasFocus()) {
this.element.querySelector('input').focus();
}
Expand Down
6 changes: 5 additions & 1 deletion addon/components/docs-modal-dialog.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ export default class DocsModalDialog extends ModalDialog {
super.init(...arguments);

const config = getOwner(this).resolveRegistration('config:environment');
this.set('renderInPlace', config.environment === 'test');
let fastboot = getOwner(this).lookup('service:fastboot');
this.set(
'renderInPlace',
config.environment === 'test' || fastboot?.isFastBoot,
);
}
}
2 changes: 2 additions & 0 deletions addon/components/docs-viewer/x-main/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export default class XMain extends Component {

@action
setupElement(element) {
if (typeof MutationObserver === 'undefined') return;

let target = element.querySelector('[data-current-page-index-target]');

this._mutationObserver = new MutationObserver(
Expand Down
2 changes: 2 additions & 0 deletions addon/keyboard-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ const TAGNAMES_THAT_WHEN_FOCUSED_PREVENT_KEYBOARD_SHORTCUTS = [
@hide
*/
export function formElementHasFocus() {
if (typeof document === 'undefined') return false;

return TAGNAMES_THAT_WHEN_FOCUSED_PREVENT_KEYBOARD_SHORTCUTS.includes(
document.activeElement.tagName,
);
Expand Down
8 changes: 8 additions & 0 deletions addon/services/docs-search.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Service from '@ember/service';
import { getOwner } from '@ember/application';
import lunr from 'lunr';
import { task } from 'ember-concurrency';
import {
Expand All @@ -12,6 +13,7 @@ export default class DocsSearch extends Service {
async search(phrase) {
const { searchTokenSeparator } = getAddonDocsConfig(this);
let { index, documents } = await this.loadSearchIndex();
if (!index) return [];
let words = phrase.toLowerCase().split(new RegExp(searchTokenSeparator));
let results = index.query((query) => {
// In the future we could boost results based on the field they come from
Expand Down Expand Up @@ -91,6 +93,12 @@ export default class DocsSearch extends Service {

_loadSearchIndex = task({ enqueue: true }, async () => {
if (!this._searchIndex) {
let fastboot = getOwner(this).lookup('service:fastboot');
if (fastboot?.isFastBoot) {
this._searchIndex = { index: null, documents: {} };
return this._searchIndex;
}

let response = await fetch(this._indexURL);
let json = await response.json();

Expand Down
42 changes: 36 additions & 6 deletions addon/services/docs-store.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
/* global FastBoot */
import Service from '@ember/service';
import { getOwner } from '@ember/application';
import { tracked } from '@glimmer/tracking';
import { getRootURL } from 'ember-cli-addon-docs/-private/config';

Expand Down Expand Up @@ -52,11 +54,35 @@ export default class DocsStoreService extends Service {
}

async _fetchProject(id) {
let namespace = `${getRootURL(this).replace(/\/$/, '')}/docs`;
let url = `${namespace}/${id}.json`;

let response = await fetch(url);
let payload = await response.json();
let payload;

let fastboot = getOwner(this).lookup('service:fastboot');
if (fastboot?.isFastBoot) {

Copilot AI Mar 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The FastBoot-specific _fetchProject path (Node http request + header-derived origin) isn’t covered by the current unit tests. Adding a test that stubs service:fastboot and FastBoot.require('http') would help prevent regressions in the static prerendering path.

Suggested change
if (fastboot?.isFastBoot) {
if (
fastboot?.isFastBoot &&
typeof FastBoot !== 'undefined' &&
typeof FastBoot.require === 'function'
) {

Copilot uses AI. Check for mistakes.
// In FastBoot, read the docs JSON directly from the dist directory
// using FastBoot.distPath. This works during both prember builds and
// ember serve with fastboot, without needing an HTTP request.
let fs = FastBoot.require('fs');
let path = FastBoot.require('path');
let filePath = path.join(FastBoot.distPath, 'docs', `${id}.json`);
payload = JSON.parse(fs.readFileSync(filePath, 'utf8'));
} else {
let namespace = `${getRootURL(this).replace(/\/$/, '')}/docs`;

Copilot AI Mar 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSON.parse(data) can throw with an unhelpful error if the response body isn’t valid JSON (e.g. an HTML error page from the FastBoot server). Consider wrapping the parse in a try/catch and throwing an error that includes the URL and a short snippet of the body to aid debugging.

Copilot uses AI. Check for mistakes.
let url = `${namespace}/${id}.json`;
let response;
try {
response = await fetch(url);
} catch (e) {
throw new Error(
`Network error while fetching ${url}: ${e && e.message}`,
);
}
if (!response.ok) {
throw new Error(
`Request to ${url} failed with status ${response.status}`,
);
}
payload = await response.json();
}

this._loadPayload(payload);

Expand All @@ -67,7 +93,11 @@ export default class DocsStoreService extends Service {
let allRecords = [];

// Collect data (can be single or array)
let dataItems = Array.isArray(payload.data) ? payload.data : [payload.data];
let dataItems = Array.isArray(payload.data)
? payload.data
: payload.data
? [payload.data]
: [];
allRecords.push(...dataItems);

// Collect included
Expand Down
14 changes: 14 additions & 0 deletions addon/services/project-version.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Service from '@ember/service';
import { getOwner } from '@ember/application';
import { task } from 'ember-concurrency';
import { tracked } from '@glimmer/tracking';
import {
Expand All @@ -12,6 +13,18 @@ export default class ProjectVersionService extends Service {
@addonDocsConfig config;

_loadAvailableVersions = task(async () => {
let fastboot = getOwner(this).lookup('service:fastboot');
if (fastboot?.isFastBoot) {
this.versions = [
{
...this.currentVersion,
truncatedSha: this.currentVersion.sha?.substr(0, 5) || '',
key: this.config.latestVersionName,
},
Comment on lines +21 to +23

Copilot AI Mar 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

substr is deprecated in modern JS runtimes. Prefer slice(0, 5) here (and elsewhere in this service) to avoid deprecation warnings and keep string handling consistent.

Copilot uses AI. Check for mistakes.
];
return;
}

let response = await fetch(`${this.root}versions.json`);
let json;
if (response.ok) {
Expand All @@ -32,6 +45,7 @@ export default class ProjectVersionService extends Service {
});

redirectTo(version) {
if (typeof window === 'undefined') return;
window.location.href = `${this.root}${version.path}`;
}

Expand Down
27 changes: 3 additions & 24 deletions blueprints/ember-cli-addon-docs/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,34 +19,13 @@ module.exports = {
'ember-cli-deploy-build',
'ember-cli-deploy-git',
'ember-cli-deploy-git-ci',
'ember-cli-fastboot',
'prember',
],
});
},

afterInstall(options) {
let configPath = require.resolve(this.project.configPath());
let configContents = fs.readFileSync(configPath, 'utf-8');

if (configContents.indexOf('ADDON_DOCS_ROOT_URL') === -1) {
configContents = configContents.replace(
/([ \t]+)if \(environment === 'production'\) {/,
[
'$&',
'$1 // Allow ember-cli-addon-docs to update the rootURL in compiled assets',
"$1 ENV.rootURL = '/ADDON_DOCS_ROOT_URL/';",
].join('\n'),
);

if (configContents.indexOf('ADDON_DOCS_ROOT_URL') === -1) {
this.ui.writeWarnLine(
`Unable to update rootURL configuration. You should update ${configPath} so that your rootURL is ` +
`the string '/ADDON_DOCS_ROOT_URL/' in production.`,
);
}
}

fs.writeFileSync(configPath, configContents, 'utf-8');

afterInstall() {
if (fs.existsSync('.npmignore')) {
this.insertIntoFile('.npmignore', '/config/addon-docs.js');
}
Expand Down
7 changes: 7 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,13 @@ module.exports = {
}
}

// Set up prember for static site generation if not already configured
if (!includer.options.prember) {
includer.options.prember = {
urls: require('./lib/prember-urls'),
};
}

includer.options.includeFileExtensionInSnippetNames =
includer.options.includeFileExtensionInSnippetNames || false;
if (!includer.options.snippetSearchPaths) {
Expand Down
94 changes: 94 additions & 0 deletions lib/prember-urls.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
'use strict';

const fs = require('fs');
const path = require('path');

/**
* Prember URL enumeration for ember-cli-addon-docs.
*
* Automatically discovers all documentation pages by reading
* the generated docs JSON and search index from the build output.
*
* Usage in ember-cli-build.js:
*
* const app = new EmberAddon(defaults, {
* prember: {
* urls: require('ember-cli-addon-docs/lib/prember-urls'),
* },
* });
*
* @param {Object} options
* @param {string} options.distDir - Path to the built dist directory
* @returns {string[]} Array of URLs to pre-render
*/
module.exports = function premberUrls({ distDir }) {
let urls = new Set(['/']);

// Discover guide/template pages and API pages from the search index.
// The search index contains all indexed documents with their route info.
let searchIndexPath = path.join(
distDir,
'ember-cli-addon-docs',
'search-index.json',
);

if (fs.existsSync(searchIndexPath)) {
let searchIndex = JSON.parse(fs.readFileSync(searchIndexPath, 'utf8'));

for (let doc of Object.values(searchIndex.documents || {})) {
if (doc.type === 'template' && doc.route) {
// Skip internal/non-page routes
if (
doc.route === 'application' ||
doc.route === 'not-found' ||
doc.route.startsWith('templates.') ||
doc.route.startsWith('pods.')
) {
continue;
}

let routePath = doc.route.replace(/\./g, '/').replace(/\/index$/, '');
if (routePath === 'index') routePath = '';
urls.add('/' + routePath);
}
Comment thread
RobbieTheWagner marked this conversation as resolved.
}
}

// Discover API pages from the main project's docs JSON navigationIndex.
// We read all JSON files in docs/ and use the project ID to build URLs.
// The main project maps to /docs/api/, additional projects would need
// custom URL mapping from the consuming app.
let docsDir = path.join(distDir, 'docs');

if (fs.existsSync(docsDir)) {
let files = fs
.readdirSync(docsDir)
.filter((f) => f.endsWith('.json'))
.sort();

for (let i = 0; i < files.length; i++) {
let docsJson = JSON.parse(
fs.readFileSync(path.join(docsDir, files[i]), 'utf8'),
);
let projectData = Array.isArray(docsJson.data)
? docsJson.data[0]
: docsJson.data;
let navIndex = projectData?.attributes?.navigationIndex || [];

// Only generate /docs/api/ URLs for the first (alphabetically)
// project. Additional projects have their own route prefixes
// that we can't determine automatically.
if (files.length === 1 || i === 0) {
urls.add('/docs');
Comment thread
RobbieTheWagner marked this conversation as resolved.

for (let section of navIndex) {
for (let item of section.items) {
urls.add(`/docs/api/${item.path}`);
}
}
}
}
}

return [...urls];
};
53 changes: 40 additions & 13 deletions lib/utils/find-and-replace-in-directory.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,50 @@
const fs = require('fs-extra');
const path = require('path');

function replaceAddonDocsRootURL(contents, addonDocsRootURL, encodedVersion) {
return contents
.replace('%2FADDON_DOCS_ROOT_URL%2F', encodeURIComponent(addonDocsRootURL))
.replace(/\/?ADDON_DOCS_ROOT_URL\/?/g, addonDocsRootURL)
.replace(/%22ADDON_DOCS_DEPLOY_VERSION%22/g, encodedVersion);
/**
* Replaces rootURL and deploy version tokens in file contents.
*
* The app is built with rootURL = '/' and deployVersion = 'ADDON_DOCS_DEPLOY_VERSION'.
* At deploy time, these are rewritten to the real values.
*/
function replaceDeployTokens(contents, addonDocsRootURL, encodedVersion) {
return (
contents
// Replace rootURL in the URI-encoded config meta tag: "rootURL":"/" → "rootURL":"/my-addon/versions/main/"
.replace(
/%22rootURL%22%3A%22%2F%22/g,
`%22rootURL%22%3A%22${encodeURIComponent(addonDocsRootURL)}%22`,
)
// Replace asset paths: src="/assets/ → src="/my-addon/versions/main/assets/
// and href="/assets/ → href="/my-addon/versions/main/assets/
.replace(/((?:src|href)=")\/assets\//g, `$1${addonDocsRootURL}assets/`)
// Replace webpack public path: ="\/assets\/" or ="/assets/"
.replace(/="\/assets\/"/g, `="${addonDocsRootURL}assets/"`)
// Replace bare /assets/ references in JS (webpack chunk loading etc.)
.replace(/(["`])\/assets\//g, `$1${addonDocsRootURL}assets/`)
// Handle the legacy ADDON_DOCS_ROOT_URL token for backward compatibility
// with consuming apps that haven't updated their config yet
.replace(
'%2FADDON_DOCS_ROOT_URL%2F',
encodeURIComponent(addonDocsRootURL),
)
.replace(/\/?ADDON_DOCS_ROOT_URL\/?/g, addonDocsRootURL)
// Replace deploy version token
.replace(/%22ADDON_DOCS_DEPLOY_VERSION%22/g, encodedVersion)
);
}

function processFile(filePath, addonDocsRootURL, encodedVersion) {
const contents = fs.readFileSync(filePath, 'utf-8');

// Write the updated content to the file
fs.writeFileSync(
filePath,
replaceAddonDocsRootURL(contents, addonDocsRootURL, encodedVersion),
const updated = replaceDeployTokens(
contents,
addonDocsRootURL,
encodedVersion,
);

if (updated !== contents) {
fs.writeFileSync(filePath, updated);
}
}

function findAndReplaceInDirectory(
Expand All @@ -37,10 +66,8 @@ function findAndReplaceInDirectory(
const fullPath = path.join(directory, entry.name);

if (entry.isDirectory()) {
// Recursively process subdirectories
findAndReplaceInDirectory(fullPath, addonDocsRootURL, encodedVersion);
} else if (entry.isFile()) {
// Process files
processFile(fullPath, addonDocsRootURL, encodedVersion);
}
});
Expand All @@ -49,5 +76,5 @@ function findAndReplaceInDirectory(

module.exports = {
findAndReplaceInDirectory,
replaceAddonDocsRootURL,
replaceDeployTokens,
};
Loading
Loading