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/", "wizard/", "truth-helpers/", ]; 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/`; const wizardBase = `wizard/${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, "_") ] || requirejs.entries[wizardBase + dashed] || requirejs.entries[wizardBase + dashed.replace(/^wizard[-]/, "")] || requirejs.entries[ wizardBase + dashed.replace(/^wizard[-]/, "").replace(/-/g, "_") ] ) { corrected = type + ":" + dashed; } } if (corrected && corrected !== normalized) { normalized = corrected; } } return normalized; } chooseModuleName(moduleName, parsedName) { let resolved = super.chooseModuleName(moduleName, parsedName); if (resolved) { return resolved; } const standard = parsedName.fullNameWithoutType; let variants = [standard]; if (standard.includes("/")) { variants.push(parsedName.fullNameWithoutType.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.findWizardTemplate(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) { if (_options.mobileView) { return this.findTemplate(parsedName, "mobile/"); } } /** * 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; } findWizardTemplate(parsedName) { if (parsedName.fullNameWithoutType === "wizard") { return this.discourseTemplateModule("wizard/templates/wizard"); } let namespaced; if (parsedName.fullNameWithoutType.startsWith("components/")) { // Look up components as-is namespaced = parsedName.fullNameWithoutType; } else if (/^wizard[_\.-]/.test(parsedName.fullNameWithoutType)) { // This may only get hit for the loading routes and may be removable. namespaced = parsedName.fullNameWithoutType.slice(7); } if (namespaced) { let wizardParsedName = this.parseName( `template:wizard/templates/${namespaced}` ); return this.findTemplate(wizardParsedName); } } }; }