discourse/app/assets/javascripts/discourse-common/addon/resolver.js
David Taylor 68c7c8e25d
DEV: Deprecate mobile-specific templates (#29514)
We are moving away from the mobile-specific template pattern in favor of logical `{{#if}}` statements. This brings us closer to a standard Ember app, makes testing easier, and reduces duplicate code.

This commit includes some minor refactoring in the resolver & component-templates initializer, so that the mobile lookups happen on desktop, without actually being used. This allows us to print the deprecation message consistently, to improve visibility to developers.
2024-11-01 14:51:12 +00:00

391 lines
12 KiB
JavaScript

import { dasherize, decamelize } from "@ember/string";
import Resolver from "ember-resolver";
import deprecated from "discourse-common/lib/deprecated";
import DiscourseTemplateMap from "discourse-common/lib/discourse-template-map";
import { findHelper } from "discourse-common/lib/helpers";
import SuffixTrie from "discourse-common/lib/suffix-trie";
let _options = {};
let moduleSuffixTrie = null;
const DEPRECATED_MODULES = new Map(
Object.entries({
"controller:discovery.categoryWithID": {
newName: "controller:discovery.category",
since: "2.6.0",
},
"controller:discovery.parentCategory": {
newName: "controller:discovery.category",
since: "2.6.0",
},
"controller:tags-show": { newName: "controller:tag-show", since: "2.6.0" },
"controller:tags.show": { newName: "controller:tag.show", since: "2.6.0" },
"controller:tagsShow": { newName: "controller:tagShow", since: "2.6.0" },
"route:discovery.categoryWithID": {
newName: "route:discovery.category",
since: "2.6.0",
},
"route:discovery.parentCategory": {
newName: "route:discovery.category",
since: "2.6.0",
},
"route:tags-show": { newName: "route:tag-show", since: "2.6.0" },
"route:tags.show": { newName: "route:tag.show", since: "2.6.0" },
"route:tagsShow": { newName: "route:tagShow", since: "2.6.0" },
"app-events:main": {
newName: "service:app-events",
since: "2.4.0",
dropFrom: "2.9.0.beta1",
},
// Deprecations below are silenced because they're in widespread use, and upgrading
// themes/plugins right now would break their compatibility with the stable branch.
// These should be unsilenced for the release of 2.9.0 stable.
"store:main": {
newName: "service:store",
since: "2.8.0.beta8",
dropFrom: "2.9.0.beta1",
silent: true,
},
"search-service:main": {
newName: "service:search",
since: "2.8.0.beta8",
dropFrom: "2.9.0.beta1",
silent: true,
},
"key-value-store:main": {
newName: "service:key-value-store",
since: "2.9.0.beta7",
dropFrom: "3.0.0",
silent: true,
},
"pm-topic-tracking-state:main": {
newName: "service:pm-topic-tracking-state",
since: "2.9.0.beta7",
dropFrom: "3.0.0",
silent: true,
},
"message-bus:main": {
newName: "service:message-bus",
since: "2.9.0.beta7",
dropFrom: "3.0.0",
silent: true,
},
"site-settings:main": {
newName: "service:site-settings",
since: "2.9.0.beta7",
dropFrom: "3.0.0",
silent: true,
},
"capabilities:main": {
newName: "service:capabilities",
since: "3.1.0.beta4",
dropFrom: "3.2.0.beta1",
silent: true,
},
"current-user:main": {
newName: "service:current-user",
since: "2.9.0.beta7",
dropFrom: "3.0.0",
silent: true,
},
"session:main": {
newName: "service:session",
since: "2.9.0.beta7",
dropFrom: "3.0.0",
silent: true,
},
"site:main": {
newName: "service:site",
since: "2.9.0.beta7",
dropFrom: "3.0.0",
silent: true,
},
"topic-tracking-state:main": {
newName: "service:topic-tracking-state",
since: "2.9.0.beta7",
dropFrom: "3.0.0",
silent: true,
},
"controller:composer": {
newName: "service:composer",
since: "3.1.0.beta3",
dropFrom: "3.2.0",
silent: true,
},
})
);
export function setResolverOption(name, value) {
_options[name] = value;
}
export function getResolverOption(name) {
return _options[name];
}
export function clearResolverOptions() {
_options = {};
}
function lookupModuleBySuffix(suffix) {
if (!moduleSuffixTrie) {
moduleSuffixTrie = new SuffixTrie("/");
const searchPaths = [
"discourse/", // Includes themes/plugins
"discourse-common/",
"select-kit/",
"admin/",
];
Object.keys(requirejs.entries).forEach((name) => {
if (
searchPaths.some((s) => name.startsWith(s)) &&
!name.includes("/templates/")
) {
moduleSuffixTrie.add(name);
}
});
}
return (
moduleSuffixTrie.withSuffix(suffix, 1)[0] ||
moduleSuffixTrie.withSuffix(`${suffix}/index`, 1)[0]
);
}
export function expireModuleTrieCache() {
moduleSuffixTrie = null;
}
export function buildResolver(baseName) {
return class extends Resolver {
resolveRouter(/* parsedName */) {
const routerPath = `${baseName}/router`;
if (requirejs.entries[routerPath]) {
const module = requirejs(routerPath, null, null, true);
return module.default;
}
}
// We overwrite this instead of `normalize` so we still get the benefits of the cache.
_normalize(fullName) {
const deprecationInfo = DEPRECATED_MODULES.get(fullName);
if (deprecationInfo) {
if (!deprecationInfo.silent) {
deprecated(
`"${fullName}" is deprecated, use "${deprecationInfo.newName}" instead`,
{
since: deprecationInfo.since,
dropFrom: deprecationInfo.dropFrom,
id: "discourse.resolver-resolutions",
}
);
}
fullName = deprecationInfo.newName;
}
const split = fullName.split(":");
const type = split[0];
let normalized;
if (type === "template" && split[1]?.includes("connectors/")) {
// The default normalize implementation will skip dasherizing component template names
// We need the same for our connector templates names
normalized = "template:" + split[1].replace(/_/g, "-");
} else {
normalized = super._normalize(fullName);
}
// This is code that we don't really want to keep long term. The main situation where we need it is for
// doing stuff like `controllerFor('adminWatchedWordsAction')` where the real route name
// is actually `adminWatchedWords.action`. The default behavior for the former is to
// normalize to `adminWatchedWordsAction` where the latter becomes `adminWatchedWords.action`.
// While these end up looking up the same file ultimately, they are treated as different
// items and so we can end up with two distinct version of the controller!
if (
split.length > 1 &&
(type === "controller" || type === "route" || type === "template")
) {
let corrected;
// This should only apply when there's a dot or slash in the name
if (split[1].includes(".") || split[1].includes("/")) {
// Check to see if the dasherized version exists. If it does we want to
// normalize to that eagerly so the normalized versions of the dotted/slashed and
// dotless/slashless match.
const dashed = dasherize(split[1].replace(/[\.\/]/g, "-"));
const adminBase = `admin/${type}s/`;
if (
lookupModuleBySuffix(`${type}s/${dashed}`) ||
requirejs.entries[adminBase + dashed] ||
requirejs.entries[adminBase + dashed.replace(/^admin[-]/, "")] ||
requirejs.entries[
adminBase + dashed.replace(/^admin[-]/, "").replace(/-/g, "_")
]
) {
corrected = type + ":" + dashed;
}
}
if (corrected && corrected !== normalized) {
normalized = corrected;
}
}
return normalized;
}
findModuleName(parsedName) {
let resolved = super.findModuleName(parsedName);
if (resolved) {
return resolved;
}
const standard = parsedName.fullNameWithoutType;
let variants = [standard];
if (standard.includes("/")) {
variants.push(standard.replace(/\//g, "-"));
}
for (let name of variants) {
// If we end with the name we want, use it. This allows us to define components within plugins.
const suffix = parsedName.type + "s/" + name;
resolved = lookupModuleBySuffix(dasherize(suffix));
if (resolved) {
return resolved;
}
}
}
resolveHelper(parsedName) {
return findHelper(parsedName.fullNameWithoutType);
}
// If no match is found here, the resolver falls back to `resolveOther`.
resolveRoute(parsedName) {
if (parsedName.fullNameWithoutType === "basic") {
return requirejs("discourse/routes/discourse", null, null, true)
.default;
}
}
resolveTemplate(parsedName) {
return (
this.findMobileTemplate(parsedName) ||
this.findTemplate(parsedName) ||
this.findAdminTemplate(parsedName) ||
this.findLoadingTemplate(parsedName) ||
this.findConnectorTemplate(parsedName) ||
this.discourseTemplateModule("not_found")
);
}
findLoadingTemplate(parsedName) {
if (parsedName.fullNameWithoutType.match(/loading$/)) {
return this.discourseTemplateModule("loading");
}
}
findConnectorTemplate(parsedName) {
if (parsedName.fullName.startsWith("template:connectors/")) {
const connectorParsedName = this.parseName(
parsedName.fullName
.replace("template:connectors/", "template:")
.replace("components/", "")
);
return this.findTemplate(connectorParsedName);
}
}
findMobileTemplate(parsedName) {
const result = this.findTemplate(parsedName, "mobile/");
if (result) {
deprecated(
`Mobile-specific hbs templates are deprecated. Use responsive CSS or {{#if this.site.mobileView}} instead. [${parsedName}]`,
{
id: "discourse.mobile-templates",
}
);
}
if (_options.mobileView) {
return result;
}
}
/**
* Given a template path, this function will return a template, taking into account
* priority rules for theme and plugin overrides. See `lib/discourse-template-map.js`
*/
discourseTemplateModule(name) {
const resolvedName = DiscourseTemplateMap.resolve(name);
if (resolvedName) {
return require(resolvedName).default;
}
}
findTemplate(parsedName, prefix) {
prefix = prefix || "";
const withoutType = parsedName.fullNameWithoutType,
underscored = decamelize(withoutType).replace(/-/g, "_"),
segments = withoutType.split("/");
return (
// Convert dots and dashes to slashes
this.discourseTemplateModule(
prefix + withoutType.replace(/[\.-]/g, "/")
) ||
// Default unmodified behavior of original resolveTemplate.
this.discourseTemplateModule(prefix + withoutType) ||
// Underscored without namespace
this.discourseTemplateModule(prefix + underscored) ||
// Underscored with first segment as directory
this.discourseTemplateModule(prefix + underscored.replace("_", "/")) ||
// Underscore only the last segment
this.discourseTemplateModule(
`${prefix}${segments.slice(0, -1).join("/")}/${segments[
segments.length - 1
].replace(/-/g, "_")}`
) ||
// All dasherized
this.discourseTemplateModule(prefix + withoutType.replace(/\//g, "-"))
);
}
// Try to find a template within a special admin namespace, e.g. adminEmail => admin/templates/email
// (similar to how discourse lays out templates)
findAdminTemplate(parsedName) {
if (parsedName.fullNameWithoutType === "admin") {
return this.discourseTemplateModule("admin/templates/admin");
}
let namespaced, match;
if (parsedName.fullNameWithoutType.startsWith("components/")) {
return (
this.findTemplate(parsedName, "admin/templates/") ||
this.findTemplate(parsedName, "admin/") // Nested under discourse/templates/admin (e.g. from plugins)
);
} else if (/^admin[_\.-]/.test(parsedName.fullNameWithoutType)) {
namespaced = parsedName.fullNameWithoutType.slice(6);
} else if (
(match = parsedName.fullNameWithoutType.match(/^admin([A-Z])(.+)$/))
) {
namespaced = `${match[1].toLowerCase()}${match[2]}`;
}
let resolved;
if (namespaced) {
let adminParsedName = this.parseName(`template:${namespaced}`);
resolved =
this.findTemplate(adminParsedName, "admin/templates/") ||
this.findTemplate(parsedName, "admin/templates/") ||
this.findTemplate(adminParsedName, "admin/"); // Nested under discourse/templates/admin (e.g. from plugin)
}
return resolved;
}
};
}