Skip to content
Open
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
13 changes: 13 additions & 0 deletions ghost/core/frontend/services/data/fetch-data.js
Original file line number Diff line number Diff line change
Expand Up @@ -167,4 +167,17 @@ async function fetchData(pathOptions, routerOptions, locals) {
return response;
}

/**
* Read the post or page a route's data names, with the relations an entry needs.
*
* @param {Object} query resolved `read` spec for posts or pages
* @param {Object} locals
* @returns {Promise<Object|undefined>}
*/
async function fetchEntry(query, locals) {
const result = await processQuery(_.merge({}, query, defaultQueryOptions), null, locals);
return result[query.resource][0];
}

module.exports = fetchData;
module.exports.fetchEntry = fetchEntry;
1 change: 1 addition & 0 deletions ghost/core/frontend/services/data/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ module.exports = {
checks: require('./checks'),
entryLookup: require('./entry-lookup'),
fetchData: require('./fetch-data'),
fetchEntry: require('./fetch-data').fetchEntry,
};
12 changes: 12 additions & 0 deletions ghost/core/frontend/services/routing/api-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,18 @@ export function resolveRouteData(routeData?: RouteData): Record<string, ApiCallS
return _.mapValues(routeData, (entry) => resolveApiCall(entry));
}

/**
* The post or page read a route's `data` block stands in for, or `null` when it
* reads none or more than one.
*/
export function resolveRouteEntry(routeData?: RouteData): ApiCallSpec | null {
const reads = Object.values(resolveRouteData(routeData)).filter(
({ type, resource }) => type === 'read' && (resource === 'posts' || resource === 'pages'),
);

return reads.length === 1 ? reads[0] : null;
}

/**
* The resource read a data entry claims, or `null` if it claims none.
*
Expand Down
2 changes: 2 additions & 0 deletions ghost/core/frontend/services/routing/collection-router.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ class CollectionRouter extends ParentRouter {
// REGISTER: collection route e.g. /, /podcast/
this.mountRoute(this.route.value, controllers.collection);

this.mountMarkdownRoute();

// REGISTER: enable pagination by default
this.router().param('page', middleware.pageParam);
this.mountRoute(
Expand Down
35 changes: 35 additions & 0 deletions ghost/core/frontend/services/routing/controllers/entry.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { NextFunction, Request, Response } from 'express';
import type { RouteData } from '@tryghost/adapter-base-route-settings';
import { resolveRouteEntry } from '../api-adapter';
import * as markdown from './entry/markdown';
import * as giftLinks from './entry/gift-links';
import buildCanonicalUrl from './entry/canonical-url';
Expand All @@ -13,6 +15,10 @@ const renderer = require('../../rendering');
export interface RouterOptions {
isMarkdownRequest?: boolean;
context?: string[];
data?: RouteData;
// Set when a routes.yaml route stands in for the entry.
canonicalPath?: string;
markdownPath?: string;
[key: string]: unknown;
}

Expand Down Expand Up @@ -118,3 +124,32 @@ export async function entryController(
return renderer.handleError(next)(err);
}
}

/**
* `<route>.md` for a routes.yaml route whose data reads a single post or page.
*/
export async function routeMarkdownController(
req: Request,
res: EntryResponse,
next: NextFunction,
): Promise<void | Response> {
debug('routeMarkdownController', res.routerOptions);

try {
if (giftLinks.isGiftRequest(req)) {
return giftLinks.stripGiftAndRedirect(req, res);
}

const query = resolveRouteEntry(res.routerOptions.data);
const entry = query ? await dataService.fetchEntry(query, res.locals) : null;

if (!query || !entry) {
return next();
}

res.routerOptions.resourceType = query.resource;
return await markdown.serveMdRequest(req, res, entry);
} catch (err) {
return renderer.handleError(next)(err);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,17 @@ import type { Request } from 'express';
import type { Entry } from '../entry';

/**
* Build the entry's canonical URL (its own pathname) carrying over the current
* request's query string. Shared by the permalink and markdown-url redirects.
* Build the entry's canonical URL (its own pathname, unless a route stands in
* for it) carrying over the current request's query string. Shared by the
* permalink and markdown-url redirects.
*/
export default function buildCanonicalUrl(req: Request, entry: Entry): string {
export default function buildCanonicalUrl(
req: Request,
entry: Entry,
pathname = parse(entry.url).pathname,
): string {
return format({
pathname: parse(entry.url).pathname,
pathname,
search: parse(req.originalUrl).search,
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,13 @@ function isPublic(entry: Entry): boolean {
return entry.visibility === 'public';
}

function serveMarkdown(res: Response, entry: Entry) {
function getContentLocation(res: EntryResponse, entry: Entry): string {
return res.routerOptions.markdownPath ?? getMarkdownPath(new URL(entry.url).pathname);
}

function serveMarkdown(res: EntryResponse, entry: Entry) {
const llmsIndexUrl = urlUtils.urlFor({ relativeUrl: '/llms.txt' }, true);
res.set('Content-Location', getMarkdownPath(new URL(entry.url).pathname));
res.set('Content-Location', getContentLocation(res, entry));
res.type('text/markdown');
return res.send(renderEntryMarkdown(entry, { llmsIndexUrl }));
}
Expand All @@ -78,7 +82,7 @@ function servePreviewMarkdown(res: EntryResponse, entry: Entry) {
const llmsIndexUrl = urlUtils.urlFor({ relativeUrl: '/llms.txt' }, true);
const subscribeUrl = urlUtils.urlFor({ relativeUrl: '/#/portal/signup' }, true);

res.set('Content-Location', getMarkdownPath(new URL(entry.url).pathname));
res.set('Content-Location', getContentLocation(res, entry));
res.type('text/markdown');
return res.send(
renderEntryMarkdown(entry, {
Expand Down Expand Up @@ -124,7 +128,7 @@ async function servePaidMarkdown(req: Request, res: EntryResponse, entry: Entry)
const resourceKind = getResourceKind(res);
const resourceType = resourceKind === 'page' ? 'pages' : 'posts';
const llmsIndexUrl = urlUtils.urlFor({ relativeUrl: '/llms.txt' }, true);
const contentLocation = getMarkdownPath(new URL(entry.url).pathname);
const contentLocation = getContentLocation(res, entry);
const fetchRequest = toFetchRequest(req);

const response = await machinePaymentsService.challengeOrFulfill(fetchRequest, {
Expand Down Expand Up @@ -169,7 +173,7 @@ export function isMdRequest(res: EntryResponse): boolean {
*/
export async function serveMdRequest(req: Request, res: EntryResponse, entry: Entry) {
if (!llmsEnabled(req)) {
return res.redirect(302, buildCanonicalUrl(req, entry));
return res.redirect(302, buildCanonicalUrl(req, entry, res.routerOptions.canonicalPath));
}

if (!isPublic(entry)) {
Expand Down
4 changes: 4 additions & 0 deletions ghost/core/frontend/services/routing/controllers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ module.exports = {
return require('./static');
},

get routeMarkdown() {
return require('./entry').routeMarkdownController;
},

get unsubscribe() {
return require('./unsubscribe');
},
Expand Down
65 changes: 55 additions & 10 deletions ghost/core/frontend/services/routing/parent-router.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ const debug = require('@tryghost/debug')('routing:parent-router');
const express = require('../../../shared/express');
const url = require('url');
const security = require('@tryghost/security');
const { resolveResourceRead } = require('./api-adapter');
const { resolveResourceRead, resolveRouteEntry } = require('./api-adapter');
const urlUtils = require('../../../shared/url-utils').default;
const { getMarkdownPath } = require('../llms/markdown');
const controllers = require('./controllers');
const registry = require('./registry');

/**
Expand Down Expand Up @@ -84,7 +86,7 @@ class ParentRouter {
*/
_respectDominantRouter(req, res, next, slug) {
const siteRouter = this._getSiteRouter(req);
let targetRoute = null;
let targetRouter = null;

// CASE: iterate over routers and check whether a router has a redirect for the target slug enabled.
siteRouter.handle.stack.every((router) => {
Expand All @@ -93,26 +95,38 @@ class ParentRouter {
router.handle.parent.isRedirectEnabled &&
router.handle.parent.isRedirectEnabled(this.getResourceType(), slug)
) {
targetRoute = router.handle.parent.getRoute();
targetRouter = router.handle.parent;
return false;
}

return true;
});

if (targetRoute) {
if (targetRouter) {
debug('_respectDominantRouter');

// CASE: transform /tag/:slug/ -> /tag/[a-zA-Z0-9-_]+/ to able to find url pieces to append
// e.g. /tag/bacon/page/2/ -> 'page/2' (to append)
// e.g. /bacon/welcome/ -> '' (nothing to append)
const matchPath = this.permalinks.getValue().replace(/:\w+/g, '[a-zA-Z0-9-_]+');
const toAppend = req.url.replace(new RegExp(matchPath), '');
let pathname = url.parse(req.url).pathname.endsWith('.md')
? targetRouter.getMarkdownRoute()
: null;

if (!pathname) {
// CASE: transform /tag/:slug/ -> /tag/[a-zA-Z0-9-_]+/ to able to find url pieces to append
// e.g. /tag/bacon/page/2/ -> 'page/2' (to append)
// e.g. /bacon/welcome/ -> '' (nothing to append)
const matchPath = this.permalinks.getValue().replace(/:\w+/g, '[a-zA-Z0-9-_]+');
const toAppend = req.url.replace(new RegExp(matchPath), '');
pathname = urlUtils.createUrl(
urlUtils.urlJoin(targetRouter.getRoute(), toAppend),
false,
false,
true,
);
}

return urlUtils.redirect301(
res,
url.format({
pathname: urlUtils.createUrl(urlUtils.urlJoin(targetRoute, toAppend), false, false, true),
pathname,
search: url.parse(req.originalUrl).search,
}),
);
Expand Down Expand Up @@ -181,6 +195,37 @@ class ParentRouter {
return urlUtils.createUrl(this.route.value, options.absolute);
}

/**
* @description Get the `.md` route of this router, including subdirectory. Only routes whose
* data reads a single post or page have one.
* @returns {string|null}
*/
getMarkdownRoute() {
if (!this.route || !resolveRouteEntry(this.data)) {
return null;
}

return urlUtils.createUrl(getMarkdownPath(this.route.value));
}

/**
* @description Serve the post or page this route's data reads as markdown at `<route>.md`.
*/
mountMarkdownRoute() {
const markdownRoute = this.getMarkdownRoute();

if (!markdownRoute) {
return;
}

this.mountRoute(getMarkdownPath(this.route.value), (req, res, next) => {
res.routerOptions.isMarkdownRequest = true;
res.routerOptions.canonicalPath = this.getRoute();
res.routerOptions.markdownPath = markdownRoute;
return controllers.routeMarkdown(req, res, next);
});
}

/**
* @description Figure out if the router has a redirect enabled.
*
Expand Down
4 changes: 4 additions & 0 deletions ghost/core/frontend/services/routing/static-routes-router.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ class StaticRoutesRouter extends ParentRouter {
// REGISTER: channel route
this.mountRoute(this.route.value, controllers[this.type]);

this.mountMarkdownRoute();

// REGISTER: pagination
this.router().param('page', middleware.pageParam);
this.mountRoute(
Expand Down Expand Up @@ -101,6 +103,8 @@ class StaticRoutesRouter extends ParentRouter {
// REGISTER: static route
this.mountRoute(this.route.value, controllers.static);

this.mountMarkdownRoute();

this.routerCreated(this);
}

Expand Down
Loading
Loading