Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 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
77 changes: 72 additions & 5 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,76 @@ 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, use Node's http module to fetch from the local server
// that prember/fastboot is running
let http = FastBoot.require('http');
let request = fastboot.request;
// Derive host and protocol from the FastBoot/Node request in a standards-based way
let host =
(request &&
request.headers &&
(request.headers.host || request.headers.Host)) ||
request.host;
let protocol =
(request && request.protocol) ||
(request &&
request.headers &&
(request.headers['x-forwarded-proto'] ||
request.headers['X-Forwarded-Proto'])) ||
'http';
let url = `${protocol}://${host}/docs/${id}.json`;

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.

In FastBoot, host/protocol are derived from request headers (e.g. Host, x-forwarded-proto). If those headers are missing or user-controlled, this can produce an invalid URL (e.g. http://undefined/...) and can enable server-side request forgery by redirecting the fetch off-box. Prefer deriving the origin from a trusted FastBoot/Express request property (or a configured base URL) and validate/guard before building the request URL.

Suggested change
// Derive host and protocol from the FastBoot/Node request in a standards-based way
let host =
(request &&
request.headers &&
(request.headers.host || request.headers.Host)) ||
request.host;
let protocol =
(request && request.protocol) ||
(request &&
request.headers &&
(request.headers['x-forwarded-proto'] ||
request.headers['X-Forwarded-Proto'])) ||
'http';
let url = `${protocol}://${host}/docs/${id}.json`;
// Build a URL that targets the local FastBoot server using only trusted
// server-side properties, not user-controlled headers, to avoid SSRF.
let port =
(request &&
request.socket &&
request.socket.localPort) ||
process.env.PORT ||
80;
// Only trust a known-safe protocol value; default to http otherwise.
let protocol = request && request.protocol === 'https' ? 'https' : 'http';
let host = 'localhost';
let url =
port === 80 || port === 443
? `${protocol}://${host}/docs/${id}.json`
: `${protocol}://${host}:${port}/docs/${id}.json`;

Copilot uses AI. Check for mistakes.

let data = await new Promise((resolve, reject) => {
let req = http.get(url, (res) => {
res.setEncoding('utf8');
let body = '';
res.on('data', (chunk) => {
body += chunk;
});
res.on('end', () => {
let statusCode = res.statusCode || 0;
if (statusCode >= 200 && statusCode < 300) {
resolve(body);
} else {
reject(
new Error(`Request to ${url} failed with status ${statusCode}`),
);
}
});
res.on('error', reject);
});

req.on('error', reject);
// Basic timeout so prember/fastboot failures don't hang indefinitely
req.setTimeout(10000, () => {
req.abort();
reject(new Error(`Request to ${url} timed out`));
});
});

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.

req.abort() is deprecated on Node’s http client request. Use req.destroy() (and ideally clear the timeout) to avoid relying on deprecated APIs and to ensure the socket is properly torn down on timeouts.

Copilot uses AI. Check for mistakes.
payload = JSON.parse(data);
} 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 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];
};
Loading
Loading