DEV: Add Block API for declarative, validated UI extension points (#36810)

This commit introduces the Blocks API, a declarative system for composing
validated, conditionally rendered UI layouts.

Blocks are Glimmer components with typed argument schemas, conditional
rendering rules, and outlet restrictions — all validated at registration
time. The system provides centralized registries for blocks, outlets,
and condition types, with a two-phase lifecycle that freezes registries
after initialization. Container blocks enable hierarchical layouts, and
a built-in condition system supports route matching, user state, site
settings, viewport breakpoints, and outlet arg inspection — all
composable with AND/OR/NOT combinators.

## Plugin API

Four methods are added to the plugin API.

`api.registerBlock(BlockClass)` registers a block component for use in
layouts, supporting both direct class registration and lazy loading via
factory functions (`api.registerBlock("name", () => import(...))`).

`api.renderBlocks(outletName, layout)` defines the block layout for an
outlet, where each entry is a `LayoutEntry` describing the block, its
args, class names, children, conditions, and container args.

`api.registerBlockOutlet(name, options)` allows plugins and themes to
define their own named outlets.
`api.registerBlockConditionType(ConditionClass)` registers a custom
condition type.

Registration methods must be called in pre-initializers (before the
registry freeze), while `renderBlocks` must be called in
api-initializers (after the freeze).

## The `@block` Decorator

The `@block(name, options)` decorator transforms a Glimmer component
into a block. It supports typed argument schemas with validation
constraints (`min`, `max`, `minLength`, `maxLength`, `pattern`, `enum`,
`itemEnum`, `integer`) for types `string`, `number`, `boolean`, `array`,
`object`, and `any`. Cross-argument constraints like `atLeastOne`,
`exactlyOne`, `allOrNone`, `atMostOne`, and `requires` enforce
relationships between arguments. A `validate(args)` function enables
custom validation logic.

Blocks can restrict which outlets they render in using `allowedOutlets`
and `deniedOutlets` with glob patterns.

Container blocks (`container: true`) can nest children and define a
`childArgs` schema for args passed from children to the parent.

Namespacing is enforced: core blocks use `block-name`, plugins use
`plugin-name:block-name`, and themes use `theme:theme-name:block-name`.
Blocks can only render inside `<BlockOutlet>` components or as
authorized children of container blocks — direct template usage throws
an error.

## `<BlockOutlet>` Component

`<BlockOutlet>` is the root rendering component and is itself a
container block. It accepts `@name` (the outlet identifier),
`@outletArgs` (arguments passed to all blocks in the outlet), and
`@deprecatedArgs` (args that trigger deprecation warnings on access). It
provides `<:before>`, `<:after>`, and `<:error>` named block slots.

Four core outlets are defined: `hero-blocks`, `homepage-blocks`,
`main-outlet-blocks`, and `sidebar-blocks`. The application template
places `<BlockOutlet>` components for `hero-blocks` and
`main-outlet-blocks`, both hidden on admin routes.

## Conditions

Five built-in condition types handle the most common rendering
scenarios.

The `route` condition matches URL patterns using glob syntax, semantic
page types (`CATEGORY_PAGES`, `TAG_PAGES`, `DISCOVERY_PAGES`,
`HOMEPAGE`, `TOPIC_PAGES`, `USER_PAGES`, `ADMIN_PAGES`, `GROUP_PAGES`,
`TOP_MENU`), route params, and query params.

The `user` condition checks login status, trust level,
admin/moderator/staff status, and group membership.

The `setting` condition evaluates site settings with operators like
`enabled`, `equals`, `includes`, `contains`, and `containsAny`.

The `viewport` condition matches breakpoints (`sm`, `md`, `lg`, `xl`,
`2xl`) and touch capability.

The `outlet-arg` condition matches outlet argument values using
dot-notation paths with value matchers supporting primitives, arrays,
regex, `not`, and `any`.

Conditions are composable through combinators: arrays represent AND
logic, `{ any: [...] }` represents OR logic, and `{ not: {...} }`
represents NOT logic. Parameters within conditions (`params`,
`queryParams`) support the same combinator syntax.

## The `@blockCondition` Decorator

Custom conditions extend `BlockCondition` and use the
`@blockCondition(config)` decorator. The config accepts a `type` name, a
`sourceType` for resolving external data (`"none"`, `"outletArgs"`, or
`"object"` for sources like theme settings), an argument schema with the
same validation as block args, cross-arg constraints, and a custom
validation function.

## Built-in Container Blocks

Two container blocks are provided. `group` renders all its children in
sequence, acting as a simple grouping mechanism. `head` renders only the
first child whose conditions pass, implementing an if/else-if/else
fallback pattern.

## Two-Phase Initialization

Registration happens in pre-initializers. The `freeze-block-registry`
initializer then registers built-in blocks and core condition types, and
freezes all three registries (blocks, outlets, conditions) to prevent
further modifications. Layout rendering via `api.renderBlocks()` happens
in api-initializers after the freeze, ensuring all blocks and conditions
are available when layouts are validated.

## Developer Tooling

The dev tools integration provides a visual overlay that renders block
boundaries with hover tooltips showing the block name, conditions, and
arguments. Ghost blocks appear as dashed placeholders for hidden blocks,
showing why they're hidden (failed conditions, no visible children, or
hidden by `head`). Console logging outputs hierarchical condition
evaluation with pass/fail icons, color coding, resolved values, and type
mismatch hints. Outlet info badges display the outlet name, block count,
and arguments.

## Testing Utilities

`withTestBlockRegistration(callback)` and
`withTestConditionRegistration(callback)` temporarily unfreeze their
respective registries for test registration. Block query helpers
(`hasBlock`, `getBlockEntry`, `resolveBlock`, `tryResolveBlock`,
`isBlockResolved`) enable assertions on registry state.
`validateConditions(spec)` validates condition specs in tests.
`setupGhostCapture()` captures ghost block data for assertions on which
blocks were skipped and why.

## Test Coverage

Unit tests cover all five condition types, argument validation,
constraint validation, layout validation, the block/outlet/condition
registries, string similarity, outlet args, value matching, URL
matching, page definitions, debug logging, and console formatting.
Integration tests cover `<BlockOutlet>` rendering, container behavior
(`group`, `head`), condition evaluation, and layout wrappers. System
tests validate conditional rendering across SPA navigation and the dev
tools overlay.

---------

Co-authored-by: David Taylor <david@taylorhq.com>
This commit is contained in:
Sérgio Saquetim
2026-02-25 17:36:43 -03:00
committed by GitHub
co-authored by David Taylor
parent 53668b23a1
commit 9dcc851c9b
135 changed files with 35769 additions and 340 deletions
+9
View File
@@ -1,3 +1,12 @@
# Dev Experience (Block API, Dev Tools, Registry, Plugin API)
/frontend/discourse/app/blocks/ @discourse/dev-xp
/frontend/discourse/app/lib/blocks/ @discourse/dev-xp
/frontend/discourse/app/services/blocks.js @discourse/dev-xp
/frontend/discourse/app/static/dev-tools/ @discourse/dev-xp
/frontend/discourse/app/lib/registry/ @discourse/dev-xp
/frontend/discourse/app/lib/plugin-api.gjs @discourse/dev-xp
# Migrations tooling
/migrations/ @discourse/migrations-tooling
/script/bulk_import/ @discourse/migrations-tooling
/script/import_scripts/ @discourse/migrations-tooling
+35
View File
@@ -1,5 +1,24 @@
en:
js:
blocks:
ghost:
# Section titles
status: "Status"
conditions: "Conditions"
arguments: "Arguments"
container_args: "Container Args"
in_location: "in"
# Status text (used in badge, header, and status section)
hidden: "hidden"
not_registered: "not registered"
no_visible_children: "no visible children"
failed: "failed"
ghost_reasons:
# Longer hint messages shown at bottom of ghost tooltip
optional_missing_hint: "This optional block is not rendered because it's not registered."
no_visible_children_hint: "This container block is not rendered because none of its children are visible."
condition_failed_hint: "This block is not rendered because its conditions failed."
head_hidden_tail_hint: "This block is not rendered because another block was rendered first in the `head` container."
carousel:
go_to_slide: "Go to slide %{index}"
previous: "Previous slide"
@@ -355,6 +374,8 @@ en:
broken_plugin_alert: "Caused by plugin '%{name}'"
broken_transformer_alert: "There was an error. Your site may not work properly."
broken_block_alert: "A block configuration error was detected. Check browser console for details."
broken_block_factory_alert: "A block failed to load. Some UI elements may not display correctly."
critical_deprecation:
notice: "<b>[Admin Notice]</b> %{source} contains code which needs updating. (id:%{id})"
@@ -363,6 +384,20 @@ en:
plugin_source: "Plugin '%{name}'"
unknown_source: "One of your themes or plugins"
dev_tools:
drag_to_move: "Drag to move"
toggle_plugin_outlet_debug: "Toggle plugin outlet debug"
toggle_block_debug: "Toggle block debug"
block_debug:
outlet_boundaries: "Outlet boundaries"
visual_overlay: "Visual overlay"
ghost_blocks: "Ghost blocks"
condition_debugging: "Condition debugging"
toggle_safe_mode: "Toggle safe mode"
toggle_verbose_localization: "Toggle verbose localization"
toggle_mobile_view: "Toggle mobile view"
disable_dev_tools: "Disable dev tools"
s3:
regions:
ap_northeast_1: "Asia Pacific (Tokyo)"
@@ -0,0 +1,638 @@
// @ts-check
/**
* BlockOutlet System
*
* This module provides the BlockOutlet component and outlet layout management.
* BlockOutlet is the root entry point for rendering blocks in designated areas.
*
* This file handles:
* - BlockOutlet component
* - Outlet layout registration and management
* - Child block creation and rendering
*/
import Component from "@glimmer/component";
import { DEBUG } from "@glimmer/env";
import { cached } from "@glimmer/tracking";
import curryComponent from "ember-curry-component";
/** @type {import("discourse/components/async-content.gjs")} */
import AsyncContent from "discourse/components/async-content";
/** @type {import("discourse/lib/blocks/-internals/components/block-layout-wrapper.gjs")} */
import { wrapBlockLayout } from "discourse/lib/blocks/-internals/components/block-layout-wrapper";
/** @type {import("discourse/lib/blocks/-internals/components/block-outlet-inline-error.gjs")} */
import BlockOutletInlineError from "discourse/lib/blocks/-internals/components/block-outlet-inline-error";
/** @type {import("discourse/lib/blocks/-internals/components/block-outlet-root-container.gjs")} */
import BlockOutletRootContainer from "discourse/lib/blocks/-internals/components/block-outlet-root-container";
import {
createDebugGhost,
DEBUG_CALLBACK,
debugHooks,
} from "discourse/lib/blocks/-internals/debug-hooks";
import {
block,
createBlockArgsWithReactiveGetters,
getBlockMetadata,
registerRootBlock,
} from "discourse/lib/blocks/-internals/decorator";
import {
captureCallSite,
raiseBlockError,
} from "discourse/lib/blocks/-internals/error";
import { isBlockRegistryFrozen } from "discourse/lib/blocks/-internals/registry/block";
import { applyArgDefaults } from "discourse/lib/blocks/-internals/utils";
import { validateLayout } from "discourse/lib/blocks/-internals/validation/layout";
import { isRailsTesting, isTesting } from "discourse/lib/environment";
import { buildArgsWithDeprecations } from "discourse/lib/outlet-args";
import { BLOCK_OUTLETS } from "discourse/lib/registry/block-outlets";
/**
* A block entry in a layout configuration.
*
* @typedef {Object} LayoutEntry
* @property {typeof Component | string} block - The block component class (must use @block decorator) or a registered block name string.
* @property {Object} [args] - Args to pass to the block component.
* @property {string|string[]} [classNames] - Additional CSS classes for the block wrapper.
* @property {Array<LayoutEntry>} [children] - Nested block entries (only for container blocks).
* @property {Array<Object>|Object} [conditions] - Conditions that must pass for block to render.
* @property {Object} [containerArgs] - Args passed from parent container's childArgs.
*/
/**
* Maps outlet names to their registered outlet layouts.
* Each outlet can have exactly one layout registered.
*
* DO NOT EXPORT THIS MAP to prevent layouts bypassing the validation steps
*
* @type {Map<string, {validatedLayout: Promise<Array<Object>>}>}
*/
const outletLayouts = new Map();
/**
* Counter for generating stable entry keys.
* Incremented for each block entry when a layout is registered via `_renderBlocks()`.
*
* @type {number}
*/
let nextEntryKey = 0;
/**
* Recursively assigns stable keys to all block entries in a layout.
*
* Each entry receives a `__stableKey` property that remains constant across
* renders. This is critical for Ember's `{{#each key=}}` to maintain DOM
* identity when blocks are hidden/shown by conditions.
*
* Keys are assigned at registration time (in `_renderBlocks()`) rather than
* render time, ensuring they survive the shallow cloning in `BlockOutletRootContainer#preprocessEntries`.
*
* @param {Array<Object>} entries - The block entries to process.
*/
function assignStableKeys(entries) {
for (const entry of entries) {
entry.__stableKey = nextEntryKey++;
// Recursively assign keys to children
if (entry.children?.length) {
assignStableKeys(entry.children);
}
}
}
/**
* Clears all registered outlet layouts.
*
* USE ONLY FOR TESTING PURPOSES.
*/
export function _resetOutletLayoutsForTesting() {
if (DEBUG) {
outletLayouts.clear();
nextEntryKey = 0;
}
}
/**
* Returns the internal outlet layouts map for testing.
* Allows tests to access validation promises to verify error handling.
*
* USE ONLY FOR TESTING PURPOSES.
*
* @returns {Map<string, {validatedLayout: Promise<Array<Object>>}>} The outlet layouts map.
*/
export function _getOutletLayouts() {
if (DEBUG) {
return outletLayouts;
}
return new Map();
}
/**
* Resolves the decoratorClassNames value from block metadata.
* Handles string, array, and function forms.
*
* @param {Object} metadata - The block metadata object.
* @param {Object} args - The block's args (passed to function form).
* @returns {string|null} The resolved class names string, or null if none.
*/
function resolveDecoratorClassNames(metadata, args) {
const value = metadata.decoratorClassNames;
if (value == null) {
return null;
}
if (typeof value === "function") {
return value(args);
}
if (Array.isArray(value)) {
return value.join(" ");
}
return value;
}
/**
* Creates a renderable child block from a block entry.
* Curries the component with all necessary args and wraps all blocks
* in a layout wrapper for consistent styling.
*
* @param {Object} entry - The block entry
* @param {import("discourse/lib/blocks/-internals/registry/block").BlockClass} entry.block - The block component class
* @param {Object} [entry.args] - Args to pass to the block
* @param {Object} [entry.containerArgs] - Container args for parent's childArgs schema
* @param {string} [entry.classNames] - Additional CSS classes
* @param {string} [entry.id] - Unique identifier for BEM styling and targeting
* @param {import("@ember/owner").default} owner - The application owner
* @param {Object} [debugContext] - Debug context for visual overlay
* @param {string} [debugContext.displayHierarchy] - Where the block is rendered (for tooltip display)
* @param {string} [debugContext.containerPath] - Container's full path (for children's __hierarchy)
* @param {Object} [debugContext.conditions] - The block's conditions
* @param {Object} [debugContext.outletArgs] - Outlet args for debug display
* @param {string} [debugContext.key] - Stable unique key for this block
* @param {string} [debugContext.outletName] - The outlet name for wrapper class generation
* @param {Array<import("discourse/lib/blocks/-internals/entry-processing").ChildBlockResult>} [debugContext.processedChildren] - Pre-processed children
* @returns {import("discourse/lib/blocks/-internals/entry-processing").ChildBlockResult}
* An object containing the curried block component, any containerArgs
* provided in the block entry, and a stable unique key for list rendering.
* The containerArgs are values required by the parent container's childArgs
* schema, accessible to the parent but not to the child block itself.
*/
function createChildBlock(entry, owner, debugContext = {}) {
const {
block: ComponentClass,
args = {},
containerArgs,
classNames,
id,
} = entry;
const blockMeta = getBlockMetadata(ComponentClass);
const isContainer = blockMeta?.isContainer ?? false;
// Apply default values from metadata before building args
const argsWithDefaults = applyArgDefaults(ComponentClass, args);
// Create block args with authorization token embedded.
// classNames are handled by wrappers, containerPath provides full path for debug logging.
const blockArgs = createBlockArgsWithReactiveGetters(argsWithDefaults, {
children: debugContext.processedChildren,
outletArgs: debugContext.outletArgs,
outletName: debugContext.outletName,
__hierarchy: isContainer
? debugContext.containerPath
: debugContext.displayHierarchy,
});
// Curry the component with pre-bound args so it can be rendered
// without knowing its configuration details
const curried = curryComponent(ComponentClass, blockArgs, owner);
// All blocks are wrapped for consistent styling
let wrappedComponent = wrapBlockLayout(
{
name: blockMeta?.blockName,
namespace: blockMeta?.namespace,
outletName: debugContext.outletName,
isContainer,
id,
decoratorClassNames: resolveDecoratorClassNames(
blockMeta,
argsWithDefaults
),
classNames,
Component: curried,
},
owner
);
// Apply debug callback if present (for visual overlay)
const debugCallback = debugHooks.getCallback(DEBUG_CALLBACK.BLOCK_DEBUG);
if (debugCallback) {
const debugResult = debugCallback(
{
name: blockMeta?.blockName,
id,
Component: wrappedComponent,
args: argsWithDefaults,
containerArgs,
conditions: debugContext.conditions,
conditionsPassed: true,
},
{
outletName: debugContext.displayHierarchy,
outletArgs: debugContext.outletArgs,
}
);
if (debugResult?.Component) {
wrappedComponent = debugResult.Component;
}
}
/** @type {import("discourse/lib/blocks/-internals/entry-processing").ChildBlockResult} */
const result = {
Component: wrappedComponent,
containerArgs,
key: debugContext.key,
/**
* Returns a ghost version of this child with a custom failure reason.
*
* Used by container blocks (like head) that choose not to render some children
* but want to show them as ghosts in debug mode with an explanation.
*
* @param {string} reason - The failure reason to display in the ghost overlay.
* @returns {import("discourse/lib/blocks/-internals/entry-processing").ChildBlockResult|null}
* A ghost child block result, or null if debug mode is disabled.
*/
asGhost(reason) {
const ghostResult = createDebugGhost(
{
name: blockMeta?.blockName,
id,
args: argsWithDefaults,
containerArgs,
conditions: debugContext.conditions,
failureReason: reason,
},
{
outletName: debugContext.displayHierarchy,
outletArgs: debugContext.outletArgs,
}
);
if (ghostResult) {
/** @type {import("discourse/lib/blocks/-internals/entry-processing").ChildBlockResult} */
const ghostChild = {
Component: ghostResult.Component,
containerArgs,
key: `${debugContext.key}:ghost`,
isGhost: true,
asGhost: () => ghostChild,
};
return ghostChild;
}
return null;
},
};
return result;
}
/**
* Registers an outlet layout (array of block entries) for a named outlet.
*
* This is the main entry point for plugins to render blocks in designated areas.
* Each outlet can only have one layout registered. Attempting to register a
* second layout throws an error.
*
* @experimental This API is under active development and may change or be removed
* in future releases without prior notice. Use with caution in production environments.
*
* @param {string} outletName - The outlet identifier (must be in BLOCK_OUTLETS).
* @param {Array<LayoutEntry>} layout - Array of block entries.
* @param {Object} [owner] - The application owner for service lookup (passed from plugin API).
* @param {Error|null} [callSiteError] - Pre-captured error for source-mapped stack traces.
* When called via api.renderBlocks(), this is captured there to exclude the PluginApi wrapper.
* @returns {Promise<Array<Object>>} Promise resolving to the validated layout array.
* @throws {Error} If validation fails or outlet already has a layout.
*
* @example
* ```js
*
* api.renderBlocks("homepage-blocks", [
* { block: HeroBanner, args: { title: "Welcome" } },
* {
* block: BlockGroup,
* children: [
* { block: FeatureCard, args: { icon: "star" } },
* { block: FeatureCard, args: { icon: "heart" } },
* ]
* },
* {
* block: AdminBanner,
* args: { title: "Admin Only" },
* conditions: [
* { type: "user", admin: true }
* ]
* }
* ]);
* ```
*/
export function _renderBlocks(outletName, layout, owner, callSiteError = null) {
if (!callSiteError) {
callSiteError = captureCallSite(_renderBlocks);
}
// Check for duplicate registration
if (outletLayouts.has(outletName)) {
raiseBlockError(
`Block outlet "${outletName}" already has a layout registered.`
);
}
// Validate outlet name is known
if (!BLOCK_OUTLETS.includes(outletName)) {
raiseBlockError(`Unknown block outlet: ${outletName}`);
}
// Verify registries are frozen
if (!isBlockRegistryFrozen()) {
raiseBlockError(
`api.renderBlocks() was called before the block registry was frozen. ` +
`Move your code to an initializer that runs after "freeze-block-registry". ` +
`Outlet: "${outletName}"`
);
}
const blocksService = owner?.lookup("service:blocks");
// Assign stable keys to all entries
assignStableKeys(layout);
// Validate layout asynchronously
const validatedLayout = validateLayout(
layout,
outletName,
blocksService,
"", // parentPath - empty so paths start with array index like [0]
callSiteError // Error object for source-mapped call site
).then(() => layout);
// Store layout with validation promise for potential future use
outletLayouts.set(outletName, { validatedLayout });
return validatedLayout;
}
/**
* Checks if a layout has been registered for a given outlet.
* Used in templates to conditionally render content based on block presence.
*
* @param {string} outletName - The outlet identifier to check.
* @returns {boolean} True if a layout is registered for this outlet.
*/
function hasLayout(outletName) {
return outletLayouts.has(outletName);
}
/**
* Component signature for BlockOutlet.
*
* @typedef {Object} BlockOutletSignature
* @property {Object} Args
* @property {string} Args.name - The outlet name (must be in BLOCK_OUTLETS registry).
* @property {Object} [Args.outletArgs] - Arguments to pass to blocks rendered in this outlet.
* @property {Object} [Args.deprecatedArgs] - Deprecated args with deprecation warnings.
* @property {Object} Blocks
* @property {[hasLayout: boolean]} Blocks.before - Yields hasLayout flag before content.
* @property {[hasLayout: boolean]} Blocks.after - Yields hasLayout flag after content.
* @property {[error: Error]} Blocks.error - Yields error when validation fails.
*/
/**
* Root component for rendering registered blocks in a designated outlet.
*
* BlockOutlet serves as the entry point for the block rendering system. It:
* - Looks up registered layouts by outlet name
* - Renders blocks in a consistent wrapper structure
* - Provides named blocks (`<:before>`, `<:after>`) for conditional content
*
* Named blocks:
* - `<:before>` - Yields `hasLayout` boolean before block content.
* - `<:after>` - Yields `hasLayout` boolean after block content.
*
* @experimental This API is under active development and may change or be removed
* in future releases without prior notice. Use with caution in production environments.
*
* @extends {Component<BlockOutletSignature>}
*
* @example
* ```hbs
* <BlockOutlet @name="homepage-blocks">
* <:after as |hasBlocks|>
* {{#unless hasBlocks}}
* <p>No blocks configured</p>
* {{/unless}}
* </:after>
* </BlockOutlet>
* ```
*/
@block("block-outlet", { container: true })
export default class BlockOutlet extends Component {
/**
* The outlet name, locked at construction time.
* This prevents dynamic name changes which could cause inconsistent rendering.
*
* @type {string}
*/
#name;
constructor(owner, args) {
super(owner, args);
// Lock the name at construction to prevent dynamic changes
this.#name = this.args.name;
if (!BLOCK_OUTLETS.includes(this.#name)) {
raiseBlockError(
`Block outlet ${this.#name} is not registered in the blocks registry`
);
}
}
get validatedLayout() {
return outletLayouts.get(this.#name)?.validatedLayout;
}
/**
* Processes block entries and returns renderable components.
*
* @returns {Promise<{rawChildren: Array<Object>, showGhosts: boolean, showVisualOverlay: boolean, isLoggingEnabled: boolean}>|undefined}
*/
@cached
get children() {
// We need to track the state outside the promise contexts to force the children to be rendered when
// the user enables the debugging
const showGhosts = debugHooks.isGhostBlocksEnabled;
const showVisualOverlay = debugHooks.isVisualOverlayEnabled;
const isLoggingEnabled = debugHooks.isBlockLoggingEnabled;
if (!this.validatedLayout) {
return;
}
/* Block entries are validated asynchronously. TrackedAsyncData lets us wait
for validation to complete before rendering blocks, while also exposing
any validation errors to the debug overlay.
Note: We intentionally do NOT evaluate conditions here. Condition evaluation
happens in BlockOutletRootContainer.processedChildren so that service reads
(router.currentURL, discovery.category, etc.) are tracked by Ember's
autotracking system. If we evaluated conditions inside this promise, route
changes would not trigger re-evaluation. */
const promiseWithLogging = this.validatedLayout
.then((rawChildren) => {
if (!rawChildren.length) {
return;
}
return { rawChildren, showGhosts, showVisualOverlay, isLoggingEnabled };
})
.catch((error) => {
if (isTesting() || isRailsTesting()) {
setTimeout(() => {
throw error;
}, 0);
}
// Notify admins via the client error handler
// This also logs the error in the console automatically
document.dispatchEvent(
new CustomEvent("discourse-error", {
detail: { messageKey: "broken_block_alert", error },
})
);
throw error;
});
return promiseWithLogging;
}
/**
* The locked outlet name, used for CSS class generation and config lookup.
*
* @returns {string}
*/
get outletName() {
return this.#name;
}
/**
* The component to render for outlet boundary debug info.
* Returns the OutletInfo component when debug mode is enabled, null otherwise.
*
* @returns {typeof Component|null}
*/
get OutletInfoComponent() {
return debugHooks.outletInfoComponent;
}
/**
* Combines `@outletArgs` with `@deprecatedArgs` for lazy evaluation.
*
* Outlet args are values passed from the parent template to blocks rendered
* in this outlet. They are separate from layout entry args and accessed via
* `@outletArgs` in block components.
*
* Deprecated args trigger a deprecation warning when accessed, helping
* migrate consumers away from renamed or removed outlet args.
*
* @returns {Object} Combined args object with lazy property getters
*/
@cached
get outletArgsWithDeprecations() {
if (!this.args.deprecatedArgs) {
return this.args.outletArgs || {};
}
return buildArgsWithDeprecations(
this.args.outletArgs || {},
this.args.deprecatedArgs,
{ outletName: this.#name }
);
}
<template>
{{! yield to :before block with hasLayout boolean for conditional rendering
This allows block outlets to wrap other elements and conditionally render them based on
the presence of a registered layout if necessary }}
{{yield (hasLayout this.outletName) to="before"}}
{{#let
(if
this.OutletInfoComponent
(component
this.OutletInfoComponent
outletName=this.outletName
outletArgs=this.outletArgsWithDeprecations
blockCount=0
error=null
)
)
as |OutletInfo|
}}
<AsyncContent @asyncData={{this.children}}>
<:loading>
{{! Resolving async blocks should not display a loading UI }}
</:loading>
<:content as |layout|>
{{#let
(component
BlockOutletRootContainer
outletName=this.outletName
outletArgs=this.outletArgsWithDeprecations
rawChildren=layout.rawChildren
showGhosts=layout.showGhosts
showVisualOverlay=layout.showVisualOverlay
isLoggingEnabled=layout.isLoggingEnabled
createChildBlockFn=createChildBlock
)
as |ChildrenContainer|
}}
{{#if OutletInfo}}
<OutletInfo @blockCount={{layout.rawChildren.length}}>
<ChildrenContainer />
</OutletInfo>
{{else}}
<ChildrenContainer />
{{/if}}
{{/let}}
</:content>
<:error as |error|>
{{#if OutletInfo}}
<OutletInfo @error={{error}}>
{{#if (has-block "error")}}
{{yield error to="error"}}
{{else}}
<BlockOutletInlineError @error={{error}} />
{{/if}}
</OutletInfo>
{{else if (has-block "error")}}
{{yield error to="error"}}
{{else}}
<BlockOutletInlineError @error={{error}} />
{{/if}}
</:error>
<:empty>
{{#if OutletInfo}}
<OutletInfo />
{{/if}}
</:empty>
</AsyncContent>
{{/let}}
{{! yield to :after block with hasLayout boolean for conditional rendering
This allows block outlets to wrap other elements and conditionally render them based on
the presence of a registered layout if necessary }}
{{yield (hasLayout this.outletName) to="after"}}
</template>
}
registerRootBlock(BlockOutlet);
@@ -0,0 +1,27 @@
// @ts-check
import Component from "@glimmer/component";
import { block } from "discourse/blocks";
/**
* A container block that groups multiple children blocks together.
* Rendered children are pre-processed by the block outlet system and passed via the @children arg.
*
* The system wrapper provides standard BEM classes:
* - `{outletName}__block-container` - Standard container class
* - `{outletName}__block-container--{id}` - BEM modifier when entry has an `id`
*
* System args (curried at creation time, not passed from parent):
* - `@outletName` - The outlet identifier this group belongs to
* - `@outletArgs` - Outlet args available for condition evaluation and access
*/
@block("group", {
container: true,
description: "Groups multiple children blocks together",
})
export default class GroupedBlocks extends Component {
<template>
{{#each @children key="key" as |child|}}
<child.Component />
{{/each}}
</template>
}
@@ -0,0 +1,118 @@
// @ts-check
import Component from "@glimmer/component";
import { service } from "@ember/service";
import { block } from "discourse/blocks";
import { eq } from "discourse/truth-helpers";
import { i18n } from "discourse-i18n";
/**
* A container block that renders only its first visible child.
*
* Use this for prioritized conditional rendering where you want fallback logic:
* show the first child whose conditions pass, ignore the rest. This is useful
* for "if X, else if Y, else Z" patterns.
*
* ## Debug Visual Overlay Handling
*
* Unlike most containers that render all their children, the head block
* intentionally only renders one child (the first visible one). This means
* the block must handle the debug visual overlay ghosts itself.
*
* The preprocessing phase passes ALL children that passed their conditions,
* plus ghost blocks for children that failed conditions. Since we choose to
* not display children 2+, we are responsible for:
*
* 1. Rendering ghosts for children that failed their own conditions
* (already ghost blocks in @children)
* 2. Converting children that passed conditions but aren't rendered
* (because another sibling was first) into ghosts with an explanation
*
* This ensures the debug overlay accurately shows why each child is or
* isn't visible.
*
* The system wrapper provides standard BEM classes:
* - `{outletName}__head` - Standard block class
*
* System args (curried at creation time, not passed from parent):
* - `@outletName` - The outlet identifier this block belongs to
* - `@outletArgs` - Outlet args available for condition evaluation and access
*
* @example
* ```javascript
* api.renderBlocks("category-sidebar-blocks", [
* {
* block: "head",
* children: [
* // Show support panel for support category
* { block: InfoPanel, args: {...}, conditions: [{ type: "route", params: { categorySlug: "support" } }] },
* // Show dev panel for dev categories
* { block: InfoPanel, args: {...}, conditions: [{ type: "route", params: { categorySlug: "dev" } }] },
* // Default fallback (no conditions = always matches)
* { block: InfoPanel, args: {...} },
* ],
* },
* ]);
* ```
*/
@block("head", {
container: true,
description: "Renders only the first child whose conditions pass",
})
export default class HeadBlock extends Component {
@service blocks;
/**
* Children that passed their conditions and could be rendered.
*
* @returns {Array<import("discourse/lib/blocks/-internals/entry-processing").ChildBlockResult>}
*/
get renderableChildren() {
return this.args.children?.filter((c) => !c.isGhost) ?? [];
}
/**
* The one child we actually render (first that passed conditions).
*
* @returns {import("discourse/lib/blocks/-internals/entry-processing").ChildBlockResult|undefined}
*/
get firstChild() {
return this.renderableChildren[0];
}
<template>
{{#if this.blocks.showGhosts}}
{{!
When debug mode is enabled, render children in their original order:
- Ghosts for children that failed conditions
- The first visible child (actually rendered)
- Ghosts for children hidden by priority
}}
{{#each @children as |child|}}
{{#if child.isGhost}}
{{! Child failed its own conditions - already a ghost }}
<child.Component />
{{else if (eq child this.firstChild)}}
{{! First child that passed conditions - render it }}
<child.Component />
{{else}}
{{! Passed conditions but hidden by priority - convert to ghost }}
{{#let
(child.asGhost
(i18n "js.blocks.ghost_reasons.head_hidden_tail_hint")
)
as |ghostChild|
}}
{{#if ghostChild}}
<ghostChild.Component />
{{/if}}
{{/let}}
{{/if}}
{{/each}}
{{else}}
{{! Normal mode: just render the first visible child }}
{{#if this.firstChild}}
<this.firstChild.Component />
{{/if}}
{{/if}}
</template>
}
@@ -0,0 +1,32 @@
/**
* Built-in blocks registry.
*
* This module lists all built-in block components provided by Discourse.
* These blocks are registered at runtime by the `freeze-block-registry`
* initializer, which imports from this file and calls `api.registerBlock()`
* for each exported block.
*
* ## Adding a New Built-in Block
*
* 1. Create the block component in `app/blocks/builtin/` with the `@block` decorator:
* ```javascript
* import Component from "@glimmer/component";
* import { block } from "discourse/blocks";
*
* @block("my-block")
* export default class MyBlock extends Component {
* // ...
* }
* ```
*
* 2. Add an export to this file:
* ```javascript
* export { default as MyBlock } from "discourse/blocks/builtin/my-block";
* ```
*
* The initializer automatically picks up any new exports and registers them.
*
* @module discourse/blocks/builtin
*/
export { default as BlockHead } from "./block-head";
export { default as BlockGroup } from "./block-group";
@@ -0,0 +1,241 @@
// @ts-check
import { getByPath } from "discourse/lib/blocks";
/**
* Base class for all block conditions.
*
* Subclasses must:
* - Use the `@blockCondition` decorator with `type` and `args` schema config
* - Implement the `evaluate(args, context)` method
* - Optionally provide a `validate` function in the decorator config for custom validation
* - Optionally pass `sourceType` to the decorator to enable `source` parameter support
*
* Condition classes can inject services using `@service` decorator.
* The Blocks service sets the owner on condition instances, enabling dependency injection.
*
* ## Validation Flow
*
* Validation happens at block registration time in this order:
* 1. Unknown args are detected (typo detection with suggestions)
* 2. Arg values are validated against the `args` schema (type, min, max, pattern, etc.)
* 3. Constraints are validated (atLeastOne, exactlyOne, allOrNone, atMostOne)
* 4. Source parameter is validated (based on sourceType)
* 5. Custom `validate` function from decorator config is called (if provided)
*
* ## Source Parameter Support
*
* Conditions can declare support for the `source` parameter via `static sourceType`:
*
* - `"none"` (default): `source` parameter is disallowed
* - `"outletArgs"`: `source` must be `@outletArgs.propertyPath`; base class resolves it
* - `"object"`: `source` is passed directly as an object (e.g., for settings)
*
* When `sourceType` is `"outletArgs"`, use `resolveSource(args, context)` to get the
* resolved value from outlet args.
*
* @experimental This API is under active development and may change or be removed
* in future releases without prior notice. Use with caution in production environments.
*
* @class BlockCondition
*
* @example
* ```javascript
* import { blockCondition, BlockCondition } from "discourse/blocks/conditions";
*
* @blockCondition({
* type: "my-condition",
* sourceType: "outletArgs",
* args: {
* requiredArg: { type: "string", required: true },
* optionalCount: { type: "number", min: 0, max: 10 },
* },
* validate(args) {
* // Custom validation that can't be expressed in schema
* if (args.requiredArg === "forbidden") {
* return "requiredArg cannot be 'forbidden'";
* }
* return null;
* }
* })
* export default class BlockMyCondition extends BlockCondition {
* @service myService;
*
* evaluate(args, context) {
* // Get value from source (outlet args) or fall back to service
* const value = this.resolveSource(args, context) ?? this.myService.defaultValue;
* return this.myService.someCheck(value, args.requiredArg);
* }
* }
* ```
*/
export class BlockCondition {
/**
* Unique identifier for this condition type.
* Used in condition specs: `{ type: "route", ... }`
*
* This property is defined by the `@blockCondition` decorator and should not
* be set directly. Pass the `type` option to the decorator instead.
*
* @type {string}
*/
static type;
/**
* Declares how this condition handles the `source` parameter.
*
* - `"none"` (default): `source` parameter is disallowed
* - `"outletArgs"`: `source` must be `@outletArgs.propertyPath`; base class resolves it
* - `"object"`: `source` is passed directly as an object (e.g., settings object)
*
* This property is defined by the `@blockCondition` decorator and should not
* be set directly. Pass the `sourceType` option to the decorator instead.
*
* @type {"none" | "outletArgs" | "object"}
*/
static sourceType = "none";
/**
* Arg schema definitions for this condition.
*
* This property is defined by the `@blockCondition` decorator and should not
* be overridden directly. The decorator creates a non-configurable getter
* that returns a frozen object.
*
* @type {Object}
*/
static argsSchema;
/**
* Cross-arg constraint definitions for this condition.
*
* This property is defined by the `@blockCondition` decorator and should not
* be overridden directly. The decorator creates a non-configurable getter
* that returns a frozen object or undefined.
*
* @type {Object|undefined}
*/
static constraints;
/**
* Custom validation function for this condition.
*
* This property is defined by the `@blockCondition` decorator and should not
* be overridden directly. The decorator creates a non-configurable getter
* that returns the validate function or undefined.
*
* @type {Function|undefined}
*/
static validateFn;
/**
* Valid argument keys for this condition.
*
* This property is derived from the `args` schema by the `@blockCondition`
* decorator and should not be overridden directly.
*
* The `source` key is automatically added by the decorator when
* `sourceType !== "none"`.
*
* @type {readonly string[]}
*/
static validArgKeys;
/**
* Resolves the `source` parameter value based on the condition's `sourceType`.
*
* - `sourceType: "outletArgs"`: Extracts the property path from `@outletArgs.path.to.value`
* and retrieves the corresponding value from `context.outletArgs`.
* - `sourceType: "object"`: Returns the `source` value directly.
*
* @param {Object} args - The condition arguments containing `source`.
* @param {Object} context - Evaluation context containing `outletArgs`.
* @param {Object} [context.outletArgs] - The outlet args passed to the block.
* @returns {*} The resolved value from outlet args, or undefined if not found.
*/
resolveSource(args, context) {
const { source } = args;
if (!source) {
return undefined;
}
// @ts-ignore - Static property defined on subclasses
const sourceType = this.constructor.sourceType;
if (sourceType === "object") {
return source;
}
if (sourceType === "outletArgs") {
// Extract path after "@outletArgs."
const path = source.replace(/^@outletArgs\./, "");
return getByPath(context?.outletArgs, path);
}
return undefined;
}
/**
* Default source value when `source` parameter is not provided.
* Override in subclasses to provide a fallback (e.g., currentUser, siteSettings).
*
* @type {*}
*/
get defaultSource() {
return undefined;
}
/**
* Resolves the source value, falling back to defaultSource when not provided.
*
* @param {Object} args - The condition arguments.
* @param {Object} [context] - Evaluation context.
* @returns {*} The resolved source or defaultSource.
*/
getSourceValue(args, context) {
return args.source !== undefined
? this.resolveSource(args, context)
: this.defaultSource;
}
/**
* Evaluates whether the condition passes.
* Called at render time to determine if a block should be shown.
*
* **Note: This method MUST be pure and idempotent.** It may be called
* multiple times during a single render cycle (e.g., when debug logging
* is enabled), and should not have side effects.
*
* @param {Object} args - The condition arguments from the layout entry.
* @param {Object} [context] - Evaluation context from the blocks service.
* @param {boolean} [context.debug] - Whether debug logging is enabled.
* @param {Object} [context.outletArgs] - Outlet args for source resolution.
* @param {number} [context._depth] - Current nesting depth for logging.
* @returns {boolean} True if condition passes, false otherwise.
*/
// eslint-disable-next-line no-unused-vars
evaluate(args, context) {
throw new Error(`${this.constructor.name} must implement evaluate()`);
}
/**
* Returns the resolved value for debug logging purposes.
*
* Override this method in subclasses to provide custom resolved values
* for conditions that don't use the standard `source` parameter.
* For example, the `outletArg` condition uses a `path` parameter
* to resolve values from outlet args.
*
* @param {Object} args - The condition arguments from the layout entry.
* @param {Object} [context] - Evaluation context containing outletArgs.
* @returns {{ value: *, hasValue: true }|undefined} Object with resolved value,
* or undefined if this condition doesn't resolve values.
*/
getResolvedValueForLogging(args, context) {
// Default implementation returns resolved source if present
if (args.source !== undefined) {
return { value: this.resolveSource(args, context), hasValue: true };
}
return undefined;
}
}
@@ -0,0 +1,219 @@
// @ts-check
import {
MAX_BLOCK_NAME_LENGTH,
parseBlockName,
VALID_NAMESPACED_BLOCK_PATTERN,
} from "discourse/lib/blocks/-internals/patterns";
import { validateConditionArgsSchema } from "discourse/lib/blocks/-internals/validation/condition-args";
import { validateConstraintsSchema } from "discourse/lib/blocks/-internals/validation/constraints";
import { formatWithSuggestion } from "discourse/lib/string-similarity";
import { BlockCondition } from "./condition";
/**
* Valid sourceType values for the decorator config.
* @constant {ReadonlyArray<string>}
*/
const VALID_SOURCE_TYPES = Object.freeze(["none", "outletArgs", "object"]);
/**
* Valid config keys for the decorator.
* @constant {ReadonlyArray<string>}
*/
const VALID_CONFIG_KEYS = Object.freeze([
"type",
"sourceType",
"args",
"constraints",
"validate",
]);
/**
* WeakSet tracking decorated classes.
* Private - only accessible via isDecoratedCondition().
*/
const decoratedConditions = new WeakSet();
/**
* Checks if a class was decorated with @blockCondition.
* Used by the block registration system to reject non-decorated classes.
*
* @experimental This API is under active development and may change or be removed
* in future releases without prior notice. Use with caution in production environments.
*
* @param {Function} ConditionClass - The class to check.
* @returns {boolean} True if the class was decorated.
*/
export function isDecoratedCondition(ConditionClass) {
return decoratedConditions.has(ConditionClass);
}
/**
* Decorator to define a block condition with declarative arg validation.
*
* The class must extend BlockCondition. The decorator adds static getters
* for type, sourceType, argsSchema, constraints, validateFn, and validArgKeys
* based on the provided config. Unknown config keys are rejected with helpful suggestions.
*
* @experimental This API is under active development and may change or be removed
* in future releases without prior notice. Use with caution in production environments.
*
* @param {Object} config - Condition configuration.
* @param {string} config.type - Unique condition type identifier.
* @param {"none"|"outletArgs"|"object"} [config.sourceType="none"] - How source parameter is handled.
* @param {Object} [config.args={}] - Arg schema definitions (type, required, min, max, pattern, etc.).
* Use `{ type: "any" }` to allow any type.
* @param {Object} [config.constraints] - Cross-arg constraints (atLeastOne, exactlyOne, allOrNone, atMostOne).
* @param {Function} [config.validate] - Custom validation function called at registration time.
* Receives args object, returns error string/array or null.
* @throws {Error} If config is invalid or class doesn't extend BlockCondition.
*
* @example
* import { blockCondition, BlockCondition } from "discourse/blocks/conditions";
*
* @blockCondition({
* type: "user",
* sourceType: "outletArgs",
* args: {
* loggedIn: { type: "boolean" },
* admin: { type: "boolean" },
* minTrustLevel: { type: "number", min: 0, max: 4, integer: true },
* groups: { type: "array", itemType: "string" },
* },
* validate(args) {
* if (args.loggedIn === false && args.admin) {
* return "Cannot use loggedIn: false with admin condition.";
* }
* return null;
* }
* })
* export default class BlockUserCondition extends BlockCondition {
* @service currentUser;
* evaluate(args, context) { ... }
* }
*
* @example
* // With constraints
* @blockCondition({
* type: "setting",
* sourceType: "object",
* args: {
* name: { type: "string", required: true },
* enabled: { type: "boolean" },
* equals: { type: "any" },
* includes: { type: "array" },
* },
* constraints: {
* atMostOne: ["enabled", "equals", "includes"],
* },
* })
*/
export function blockCondition(config) {
const {
type,
sourceType = "none",
args: argsSchema = {},
constraints,
validate: validateFn,
} = config;
// Validate config at decoration time
if (!type || typeof type !== "string") {
throw new Error("blockCondition: `type` is required and must be a string.");
}
// Validate type length
if (type.length > MAX_BLOCK_NAME_LENGTH) {
throw new Error(
`blockCondition: type "${type}" exceeds maximum length of ${MAX_BLOCK_NAME_LENGTH} characters.`
);
}
// Validate type follows the namespaced pattern
if (!VALID_NAMESPACED_BLOCK_PATTERN.test(type)) {
throw new Error(
`blockCondition: type "${type}" is invalid. ` +
`Valid formats: "condition-name" (core), "plugin:condition-name" (plugin), ` +
`"theme:namespace:condition-name" (theme).`
);
}
// Parse the type to extract namespace components
const parsed = parseBlockName(type);
// Validate sourceType is one of the allowed values
if (!VALID_SOURCE_TYPES.includes(sourceType)) {
const suggestion = formatWithSuggestion(sourceType, VALID_SOURCE_TYPES);
throw new Error(
`blockCondition: Invalid \`sourceType\` ${suggestion}. ` +
`Valid values are: ${VALID_SOURCE_TYPES.join(", ")}.`
);
}
// Validate no unknown config keys (catches typos)
const unknownKeys = Object.keys(config).filter(
(key) => !VALID_CONFIG_KEYS.includes(key)
);
if (unknownKeys.length > 0) {
const suggestions = unknownKeys
.map((key) => formatWithSuggestion(key, VALID_CONFIG_KEYS))
.join(", ");
throw new Error(
`blockCondition: unknown config key(s): ${suggestions}. ` +
`Valid keys are: ${VALID_CONFIG_KEYS.join(", ")}.`
);
}
// Validate args schema at decoration time (catches schema definition errors)
if (argsSchema && typeof argsSchema === "object") {
validateConditionArgsSchema(argsSchema, type);
}
// Validate constraints schema at decoration time
if (constraints) {
// Constraints reference args schema - validate they're compatible
validateConstraintsSchema(constraints, argsSchema, `Condition "${type}"`);
}
// Validate that validate is a function if provided
if (validateFn !== undefined && typeof validateFn !== "function") {
throw new Error(
`blockCondition: "validate" must be a function, got ${typeof validateFn}.`
);
}
// Freeze schema and compute derived validArgKeys
const frozenSchema = Object.freeze({ ...argsSchema });
const frozenConstraints = constraints
? Object.freeze({ ...constraints })
: undefined;
const argKeys = Object.freeze(Object.keys(argsSchema));
const allKeys =
sourceType !== "none" ? Object.freeze([...argKeys, "source"]) : argKeys;
return function decorator(TargetClass) {
// Validate that the class extends BlockCondition
if (!(TargetClass.prototype instanceof BlockCondition)) {
throw new Error(
`blockCondition: ${TargetClass.name} must extend BlockCondition.`
);
}
// Define static getters (non-configurable to prevent reassignment).
Object.defineProperties(TargetClass, {
type: { get: () => type, configurable: false },
namespace: { get: () => parsed.namespace, configurable: false },
namespaceType: { get: () => parsed.type, configurable: false },
sourceType: { get: () => sourceType, configurable: false },
argsSchema: { get: () => frozenSchema, configurable: false },
constraints: { get: () => frozenConstraints, configurable: false },
validateFn: { get: () => validateFn, configurable: false },
// validArgKeys combines argsSchema keys with "source" when sourceType !== "none".
validArgKeys: { get: () => allKeys, configurable: false },
});
// Track as decorated so Blocks service can verify
decoratedConditions.add(TargetClass);
return TargetClass;
};
}
@@ -0,0 +1,12 @@
// @ts-check
// BlockCondition class and decorator
export { BlockCondition } from "./condition";
export { blockCondition } from "./decorator";
// Built-in condition classes
// Registered by the freeze-block-registry initializer
export { default as BlockOutletArgCondition } from "./outlet-arg";
export { default as BlockRouteCondition } from "./route";
export { default as BlockUserCondition } from "./user";
export { default as BlockSettingCondition } from "./setting";
export { default as BlockViewportCondition } from "./viewport";
@@ -0,0 +1,148 @@
// @ts-check
import { getByPath, matchValue } from "discourse/lib/blocks";
import { BlockCondition } from "./condition";
import { blockCondition } from "./decorator";
/**
* Maximum allowed length for outlet arg paths.
* Prevents potential memory and performance issues from extremely long paths.
*/
const MAX_PATH_LENGTH = 255;
/**
* A condition that evaluates based on outlet arg values.
*
* Checks properties passed via `@outletArgs` on the BlockOutlet. Supports
* dot-notation paths for nested properties and flexible value matching.
*
* @class BlockOutletArgCondition
* @extends BlockCondition
*
* @param {string} path - Dot-notation path to the property (required).
* E.g., `"topic.closed"`, `"user.trust_level"`, `"category.id"`.
* @param {*} [value] - Value to match against (see matching rules below).
* @param {boolean} [exists] - If true, passes when property exists (not undefined);
* if false, passes when property is undefined.
*
* ## Value Matching Rules
*
* Uses the shared `matchValue` utility:
*
* - **Primitive**: Passes if `actual === value` (strict equality)
* - **RegExp**: Passes if `actual` (coerced to string) matches the pattern
* - **[...values]**: Passes if `actual` matches ANY element (OR logic)
* - **`{ not: value }`**: Passes if `actual` does NOT match `value`
* - **`{ any: [...] }`**: Passes if `actual` matches ANY spec in array (OR logic)
*
* @example
* // Check if topic is closed
* { type: "outlet-arg", path: "topic.closed", value: true }
*
* @example
* // Check user trust level is 2 or higher
* { type: "outlet-arg", path: "user.trust_level", value: [2, 3, 4] }
*
* @example
* // Check category is one of several IDs
* { type: "outlet-arg", path: "category.id", value: [1, 2, 3] }
*
* @example
* // Check if topic property exists
* { type: "outlet-arg", path: "topic", exists: true }
*
* @example
* // Check topic is NOT closed
* { type: "outlet-arg", path: "topic.closed", value: { not: true } }
*
* @example
* // Check category slug matches pattern
* { type: "outlet-arg", path: "category.slug", value: /^support/ }
*
* @example
* // Check topic is closed OR archived (using any)
* { type: "outlet-arg", path: "topic.closed", value: { any: [true, { not: false }] } }
*/
@blockCondition({
type: "outlet-arg",
args: {
path: { type: "string", required: true },
value: { type: "any" },
exists: { type: "boolean" },
},
constraints: {
exactlyOne: ["value", "exists"],
},
validate(args) {
const { path: argPath } = args;
// Check length first to prevent regex DoS with extremely long strings.
if (argPath.length > MAX_PATH_LENGTH) {
return (
`\`path\` exceeds maximum length of ${MAX_PATH_LENGTH} characters. ` +
`Path length: ${argPath.length}.`
);
}
// Validate path format (alphanumeric, underscores, dots)
if (!/^[\w.]+$/.test(argPath)) {
return (
`\`path\` "${argPath}" is invalid. ` +
`Use dot-notation with alphanumeric characters (e.g., "user.trust_level").`
);
}
return null;
},
})
export default class BlockOutletArgCondition extends BlockCondition {
/**
* Evaluates whether the outlet arg condition passes.
*
* @param {Object} args - The condition arguments.
* @param {Object} [context] - Evaluation context.
* @returns {boolean} True if the condition passes.
*/
evaluate(args, context) {
const { path, value, exists } = args;
const outletArgs = context?.outletArgs;
// Get the value at the path
const targetValue = getByPath(outletArgs, path);
// Check existence if specified
if (exists !== undefined) {
const doesExist = targetValue !== undefined;
return exists ? doesExist : !doesExist;
}
// The exactlyOne constraint ensures value or exists is always specified
return matchValue({ actual: targetValue, expected: value });
}
/**
* Returns the resolved value at the path for debug logging.
*
* @param {Object} args - The condition arguments containing `path`, `value`, `exists`.
* @param {Object} [context] - Evaluation context containing outletArgs.
* @returns {{
* hasValue: true,
* formatted: {
* path: string,
* actual: *,
* configured: * | { exists: boolean }
* }
* }} Object with formatted log data showing path, actual value, and configured expectation.
*/
// @ts-ignore - TS2416: Override returns formatted object instead of base class value structure
getResolvedValueForLogging(args, context) {
const { path, value, exists } = args;
return {
hasValue: true,
formatted: {
path,
actual: getByPath(context?.outletArgs, path),
configured: exists !== undefined ? { exists } : value,
},
};
}
}
@@ -0,0 +1,511 @@
// @ts-check
import { service } from "@ember/service";
import {
getCurrentPageType,
getPageContext,
getParamsForPageType,
isValidPageType,
VALID_PAGE_TYPES,
validateParamsAgainstPages,
validateParamType,
} from "discourse/lib/blocks/-internals/matching/page-definitions";
import {
matchesAnyPattern,
normalizePath,
} from "discourse/lib/blocks/-internals/matching/url-matcher";
import {
matchParams,
validateParamSpec,
} from "discourse/lib/blocks/-internals/matching/value-matcher";
import { isValidGlobPattern } from "discourse/lib/glob-utils";
import { BlockCondition } from "./condition";
import { blockCondition } from "./decorator";
/**
* A condition that evaluates based on the current URL path, semantic page types,
* route parameters, and query parameters.
*
* URL patterns use picomatch glob syntax:
* - `*` matches a single path segment (no slashes)
* - `**` matches zero or more path segments
* - `?` matches a single character
* - `[abc]` matches any character in the brackets
* - `{a,b}` matches any of the comma-separated patterns
*
* Page types match semantic page contexts without requiring knowledge of URL structure:
* - `CATEGORY_PAGES` - any category page (when `discovery.category` is set)
* - `TAG_PAGES` - any tag page (when `discovery.tag` is set)
* - `DISCOVERY_PAGES` - discovery routes (latest, top, etc.) excluding custom homepage
* - `HOMEPAGE` - custom homepage only
* - `TOP_MENU` - discovery routes that appear in the top navigation menu
* - `TOPIC_PAGES` - individual topic pages
* - `USER_PAGES` - user profile pages
* - `ADMIN_PAGES` - admin section pages
* - `GROUP_PAGES` - group pages
*
* URL matching automatically handles Discourse subfolder installations by
* normalizing URLs before matching. Theme authors don't need to know if
* Discourse runs on `/forum` or root - patterns like `/c/**` work everywhere.
*
* @class BlockRouteCondition
* @extends BlockCondition
*
* @param {string[]} [urls] - URL patterns to match (passes if ANY match).
* @param {string[]} [pages] - Page types to match (passes if ANY match).
* @param {Object} [params] - Page parameters to match (only valid with `pages`).
* @param {Object} [queryParams] - Query parameters to match. Note: queryParams alone
* is not sufficient - the condition requires `urls` or `pages` to match first.
* This creates implicit AND behavior between route and query params.
*
* @example
* // Match category pages using page type
* { type: "route", pages: ["CATEGORY_PAGES"] }
*
* @example
* // Match category pages using URL pattern
* { type: "route", urls: ["/c/**"] }
*
* @example
* // Match multiple page types (OR logic)
* { type: "route", pages: ["CATEGORY_PAGES", "TAG_PAGES"] }
*
* @example
* // Match specific category by ID
* { type: "route", pages: ["CATEGORY_PAGES"], params: { categoryId: 5 } }
*
* @example
* // Match with query params
* { type: "route", urls: ["/latest"], queryParams: { filter: "solved" } }
*
* @example
* // Exclude pages using NOT combinator
* { not: { type: "route", pages: ["ADMIN_PAGES"] } }
*/
@blockCondition({
type: "route",
args: {
urls: { type: "array", itemType: "string" },
pages: { type: "array", itemType: "string", itemEnum: VALID_PAGE_TYPES },
params: { type: "any" },
queryParams: { type: "any" },
},
constraints: {
atLeastOne: ["urls", "pages"],
requires: { params: "pages" },
atMostOne: ["params", "urls"],
},
validate(args) {
const { urls, pages, params, queryParams } = args;
// Validate urls
if (urls?.length) {
for (let i = 0; i < urls.length; i++) {
const pattern = urls[i];
// Check for page type names mistakenly used in urls
if (isValidPageType(pattern)) {
return (
`Page shortcuts like '${pattern}' are not supported in \`urls\`.\n` +
`Use the \`pages\` option instead:\n` +
` { type: "route", pages: ["${pattern}"] }`
);
}
// Validate glob pattern syntax
if (!isValidGlobPattern(pattern)) {
return (
`Invalid glob pattern "${pattern}". ` +
`Check for unbalanced brackets or braces.`
);
}
}
}
// Validate params
if (params) {
// Handle any/not operators in params
const paramsError = validateParamsWithOperators(params, pages, "params");
if (paramsError) {
return paramsError;
}
}
// Validate queryParams for operator typos (e.g., "an" vs "any")
if (queryParams) {
let queryParamsError = null;
validateParamSpec(queryParams, "queryParams", (msg) => {
queryParamsError = msg;
});
if (queryParamsError) {
return queryParamsError;
}
}
return null;
},
})
export default class BlockRouteCondition extends BlockCondition {
@service router;
@service discovery;
/**
* Returns the current URL path, normalized for matching.
*
* Normalization strips the subfolder prefix, query strings, hash fragments,
* and trailing slashes. This allows patterns to work consistently regardless
* of Discourse's installation configuration.
*
* @returns {string} The normalized URL path.
*/
get currentPath() {
return normalizePath(this.router.currentURL);
}
/**
* Evaluates whether the current route matches the condition.
*
* Evaluation logic:
* - With `urls`: passes if ANY URL pattern matches
* - With `pages`: passes if ANY page type matches
* - With `params`: additionally requires all params to match (only with `pages`)
* - With `queryParams`: additionally requires all query params to match
*
* **Important:** `queryParams` alone is not sufficient to match. The condition
* will only pass if BOTH the route (via `urls` or `pages`) AND the queryParams
* match. This is implicit AND behavior. To match only based on query parameters,
* use `urls: ["**"]` to match all routes first.
*
* @param {Object} args - The condition arguments.
* @param {Object} [context={}] - Evaluation context (for debugging).
* @returns {boolean} True if the condition passes.
*/
evaluate(args, context = {}) {
const { urls, pages, params, queryParams } = args;
const currentPath = this.currentPath;
// Debug context for nested logging
const isDebugging = context.debug ?? false;
const logger = context.logger;
const childDepth = (context._depth ?? 0) + 1;
const debugContext = { debug: isDebugging, _depth: childDepth, logger };
// Get actual query params for matching
const actualQueryParams = this.router.currentRoute?.queryParams;
let routeMatched = false;
let matchedPageType = null;
let actualPageContext = null;
// Check urls (passes if ANY match)
if (urls?.length) {
if (matchesAnyPattern(currentPath, urls)) {
routeMatched = true;
}
}
// Check pages (passes if ANY match)
const services = { router: this.router, discovery: this.discovery };
if (pages?.length && !routeMatched) {
for (const pageType of pages) {
const pageContext = getPageContext(pageType, services);
if (pageContext !== null) {
// Track page context for debugging (shows what values were checked)
if (isDebugging) {
actualPageContext = { pageType, ...pageContext };
}
// Page type matches, now check params if provided
if (params) {
// Pass debug=false here - logging happens in dedicated block below
if (this.#matchPageParams(params, pageContext, { debug: false })) {
routeMatched = true;
matchedPageType = pageType;
break;
}
} else {
routeMatched = true;
matchedPageType = pageType;
break;
}
}
}
}
// Log route state for conditions using pages or queryParams with urls
if (isDebugging && (pages?.length || (urls?.length && queryParams))) {
logger?.logRouteState?.({
currentPath,
expectedUrls: urls,
pages,
matchedPageType,
actualPageType: actualPageContext ? null : getCurrentPageType(services),
actualPageContext,
depth: childDepth,
result: routeMatched,
});
}
// Log params with nesting (like queryParams)
if (isDebugging && params && actualPageContext) {
// Log params summary before nested checks (result updated after)
const paramsSpec = { _isParams: true };
logger?.logCondition?.({
type: "params",
args: {
actual: this.#extractParamValues(params, actualPageContext),
expected: params,
},
result: null,
depth: childDepth,
conditionSpec: paramsSpec,
});
// Call #matchPageParams with debug=true to log nested OR/NOT/param checks
const paramsContext = { debug: true, _depth: childDepth + 1, logger };
const paramsMatched = this.#matchPageParams(
params,
actualPageContext,
paramsContext
);
// Update params summary result
logger?.updateConditionResult?.(paramsSpec, paramsMatched);
}
// Return early if URL/page didn't match (unless debugging, where we show queryParams)
if (!routeMatched && !isDebugging) {
return false;
}
// Check query params (uses shared matcher with AND/OR/NOT support)
if (queryParams) {
// Log queryParams summary before matchParams (result updated after)
// Use an object as conditionSpec so we can update the result
const queryParamsSpec = isDebugging ? { _isQueryParams: true } : null;
if (isDebugging) {
logger?.logCondition?.({
type: "queryParams",
args: { actual: actualQueryParams, expected: queryParams },
result: null, // Will be updated after matchParams
depth: childDepth,
conditionSpec: queryParamsSpec,
});
}
// Pass deeper context so OR/AND/NOT logs nest under the queryParams summary
const queryParamsContext = {
...debugContext,
_depth: childDepth + 1,
};
const queryParamsMatched = matchParams({
actualParams: actualQueryParams,
expectedParams: queryParams,
context: queryParamsContext,
label: "queryParams",
});
// Update the queryParams summary result
if (isDebugging) {
logger?.updateConditionResult?.(queryParamsSpec, queryParamsMatched);
}
// Both URL/page AND queryParams must match
if (!routeMatched || !queryParamsMatched) {
return false;
}
}
return routeMatched;
}
/**
* Matches page parameters against the current context.
* Supports any/not operators for complex matching.
*
* @param {Object} params - The expected params from the condition.
* @param {Object} pageContext - The actual context values from the current page.
* @param {Object} debugContext - Debug context for logging.
* @returns {boolean} True if params match.
*/
#matchPageParams(params, pageContext, debugContext) {
// Recursive matching with operators:
// - { any: [...] } - OR logic, passes if any spec matches
// - { not: {...} } - Negation, passes if inner spec does NOT match
// - { key: value } - Simple match, all keys must match (AND logic)
const isLoggingEnabled = debugContext.debug ?? false;
const depth = debugContext._depth ?? 0;
const logger = debugContext.logger;
// Handle any operator: { any: [{ categoryId: 1 }, { categoryId: 2 }] }
if (params.any !== undefined) {
const specs = params.any;
// Log combinator BEFORE children so it appears first in tree
if (isLoggingEnabled) {
logger?.logCondition?.({
type: "OR",
args: `${specs.length} params specs`,
result: null,
depth,
conditionSpec: params,
});
}
const results = specs.map((spec) =>
this.#matchPageParams(spec, pageContext, {
debug: isLoggingEnabled,
_depth: depth + 1,
logger,
})
);
const anyPassed = results.some(Boolean);
// Update combinator result after children evaluated
if (isLoggingEnabled) {
logger?.updateCombinatorResult?.(params, anyPassed);
}
return anyPassed;
}
// Handle not operator: { not: { categoryId: 3 } }
if (params.not !== undefined) {
// Log combinator BEFORE children so it appears first in tree
if (isLoggingEnabled) {
logger?.logCondition?.({
type: "NOT",
args: null,
result: null,
depth,
conditionSpec: params,
});
}
const innerResult = this.#matchPageParams(params.not, pageContext, {
debug: isLoggingEnabled,
_depth: depth + 1,
logger,
});
const result = !innerResult;
// Update combinator result after children evaluated
if (isLoggingEnabled) {
logger?.updateCombinatorResult?.(params, result);
}
return result;
}
// Simple params object - all must match
// Log individual param checks
const matches = [];
for (const [paramName, expectedValue] of Object.entries(params)) {
const actualValue = pageContext[paramName];
const result = actualValue === expectedValue;
matches.push({
key: paramName,
expected: expectedValue,
actual: actualValue,
result,
});
}
const allPassed = matches.every((m) => m.result);
// Log as a nested group with all param matches
if (isLoggingEnabled) {
logger?.logParamGroup?.({
label: "params",
matches,
result: allPassed,
depth,
});
}
return allPassed;
}
/**
* Extracts param values from page context for the specified params.
* Handles any/not operators by extracting from nested param objects.
*
* @param {Object} params - The expected params (keys to extract).
* @param {Object} pageContext - The page context containing actual values.
* @returns {Object} Object with only the requested param keys and their actual values.
*/
#extractParamValues(params, pageContext) {
// Handle any/not operators - extract from first nested object for display
if (params.any !== undefined && params.any.length > 0) {
return this.#extractParamValues(params.any[0], pageContext);
}
if (params.not !== undefined) {
return this.#extractParamValues(params.not, pageContext);
}
const result = {};
for (const key of Object.keys(params)) {
result[key] = pageContext[key];
}
return result;
}
}
/**
* Validates params with support for any/not operators.
* This is a standalone function to keep decorator config clean.
*
* @param {Object} params - The params to validate.
* @param {Array<string>} pages - The page types to validate against.
* @param {string} [path="params"] - Path for error messages.
* @returns {string|null} Error or null if valid.
*/
function validateParamsWithOperators(params, pages, path = "params") {
// Handle any operator: { any: [{ categoryId: 1 }, { categoryId: 2 }] }
if (params.any !== undefined) {
if (!Array.isArray(params.any)) {
return `\`any\` in params must be an array of param objects.`;
}
for (let i = 0; i < params.any.length; i++) {
const nestedError = validateParamsWithOperators(
params.any[i],
pages,
`${path}.any[${i}]`
);
if (nestedError) {
return nestedError;
}
}
return null;
}
// Handle not operator: { not: { categoryId: 3 } }
if (params.not !== undefined) {
return validateParamsWithOperators(params.not, pages, `${path}.not`);
}
// Simple params object - validate against page types
const { valid, errors } = validateParamsAgainstPages(params, pages);
if (!valid) {
return errors.join("\n");
}
// Validate param types
for (const [paramName, value] of Object.entries(params)) {
const pageType = pages.find((p) => {
const pageParams = getParamsForPageType(p);
return pageParams && paramName in pageParams;
});
if (pageType) {
const { valid: typeValid, error } = validateParamType(
paramName,
value,
pageType
);
if (!typeValid) {
return error;
}
}
}
return null;
}
@@ -0,0 +1,220 @@
// @ts-check
import { service } from "@ember/service";
import { findClosestMatch } from "discourse/lib/string-similarity";
import { BlockCondition } from "./condition";
import { blockCondition } from "./decorator";
/**
* A condition that evaluates based on site setting or custom settings object values.
*
* Supports multiple condition types for different setting formats:
* - `enabled` - For boolean settings (truthy/falsy check)
* - `equals` - For exact value matching (strings, numbers, enums)
* - `includes` - When setting is a single value: check if it matches one of YOUR provided options
* - `contains` - When setting is a list: check if it contains YOUR single value
* - `containsAny` - When setting is a list: check if it contains ANY of YOUR provided values
*
* **`includes` vs `contains` - Key Difference:**
*
* | Condition | Setting type | Question answered |
* |--------------|----------------------------|------------------------------------------|
* | `includes` | Single value (enum/string) | Is the setting value IN my list? |
* | `contains` | List (pipe-separated) | Does the setting list CONTAIN my value? |
*
* **Theme Settings Support:**
* Pass a custom settings object via `source` (e.g., from `import { settings } from "virtual:theme"`)
* to check theme-specific settings instead of site settings.
*
* **Note:** Setting name validation is deferred to evaluate time since it requires
* access to the siteSettings service. Unknown settings will cause the condition to
* return false rather than throw an error at registration time.
*
* @class BlockSettingCondition
* @extends BlockCondition
*
* @param {string} name - The setting key to check (required).
* @param {Object} [source] - Custom settings object (e.g., theme settings). If not provided, uses siteSettings.
* @param {boolean} [enabled] - If true, passes when setting is truthy; if false, passes when falsy.
* @param {*} [equals] - Passes when setting exactly equals this value.
* @param {Array<*>} [includes] - For single-value settings: passes when setting value is in this array.
* @param {string} [contains] - For list settings: passes when the setting list contains this value.
* @param {Array<string>} [containsAny] - For list settings: passes when setting list contains ANY of these.
*
* @example
* // Boolean setting check
* { type: "setting", name: "enable_badges", enabled: true }
*
* @example
* // Exact value match
* { type: "setting", name: "desktop_category_page_style", equals: "categories_and_latest_topics" }
*
* @example
* // Setting is one of several values
* { type: "setting", name: "desktop_category_page_style", includes: ["categories_and_latest_topics", "categories_and_top_topics"] }
*
* @example
* // List setting contains value
* { type: "setting", name: "top_menu", contains: "hot" }
*
* @example
* // List setting contains any of values
* { type: "setting", name: "share_links", containsAny: ["twitter", "facebook"] }
*
* @example
* // Theme setting check (pass settings object from "virtual:theme")
* import { settings } from "virtual:theme";
* { type: "setting", source: settings, name: "show_sidebar", enabled: true }
*/
@blockCondition({
type: "setting",
sourceType: "object",
args: {
name: { type: "string", required: true },
enabled: { type: "boolean" },
equals: { type: "any" },
includes: { type: "array" },
contains: { type: "string" },
containsAny: { type: "array" },
},
constraints: {
// Exactly one condition type required
exactlyOne: ["enabled", "equals", "includes", "contains", "containsAny"],
},
// No custom validate - setting name existence checked at evaluate time
// (since it requires service access to siteSettings)
})
export default class BlockSettingCondition extends BlockCondition {
@service siteSettings;
/**
* Returns the siteSettings service as the default source.
*
* @returns {Object} The siteSettings service.
*/
get defaultSource() {
return this.siteSettings;
}
/**
* Evaluates whether the setting condition passes.
*
* @param {Object} args - The condition arguments.
* @param {Object} [context] - Evaluation context.
* @returns {boolean} True if the condition passes.
*/
evaluate(args, context) {
const { name, enabled, equals, includes, contains, containsAny } = args;
const settingsSource = this.getSourceValue(args, context);
// Handle null/undefined settings source gracefully
if (settingsSource == null) {
return false;
}
// Return false for unknown settings (deferred validation)
if (!(name in settingsSource)) {
return false;
}
const value = settingsSource?.[name];
// Check enabled/disabled (boolean check)
if (enabled !== undefined) {
return enabled ? !!value : !value;
}
// Check exact equality
if (equals !== undefined) {
return value === equals;
}
// Check if value is in the includes array (for enum settings)
if (includes?.length) {
return includes.includes(value);
}
// Check if list setting contains a specific value
if (contains !== undefined) {
return this.#settingContains(value, contains);
}
// Check if list setting contains any of the provided values
if (containsAny?.length) {
return containsAny.some((item) => this.#settingContains(value, item));
}
return false;
}
/**
* Checks if a list setting contains a specific value.
* Handles both array and pipe-separated string formats.
*
* Values are converted to strings before comparison to handle cases where
* `searchValue` might be a number but the setting stores string values
* (e.g., checking for `123` in `"123|456"`).
*
* @param {string|Array} settingValue - The setting value (may be "a|b|c" or ["a", "b", "c"]).
* @param {string|number} searchValue - The value to search for.
* @returns {boolean} True if the setting contains the search value.
*/
#settingContains(settingValue, searchValue) {
if (Array.isArray(settingValue)) {
// Convert all values to strings for consistent matching
return settingValue.map(String).includes(String(searchValue));
}
if (typeof settingValue === "string") {
// List settings are often pipe-separated strings like "latest|new|unread"
const items = settingValue.split("|").map((s) => s.trim());
return items.includes(String(searchValue));
}
return false;
}
/**
* Returns the resolved setting value for debug logging.
* Includes a warning note with "did you mean" suggestion if the setting doesn't exist.
*
* @param {Object} args - The condition arguments.
* @param {Object} [context] - Evaluation context.
* @returns {{ value: *, hasValue: true, note?: string }}
*/
getResolvedValueForLogging(args, context) {
const { name } = args;
const settingsSource = this.getSourceValue(args, context);
// Handle null/undefined settings source
if (settingsSource == null) {
return {
value: undefined,
hasValue: true,
note: "settings source is null/undefined",
};
}
// Check if setting exists
if (!(name in settingsSource)) {
const availableSettings = Object.keys(settingsSource);
const suggestion = findClosestMatch(name, availableSettings);
const noteText = suggestion
? `"${name}" does not exist (did you mean "${suggestion}"?)`
: `"${name}" does not exist`;
return {
value: undefined,
hasValue: true,
note: noteText,
};
}
// Return the actual value
return {
value: settingsSource[name],
hasValue: true,
};
}
}
@@ -0,0 +1,281 @@
// @ts-check
import { service } from "@ember/service";
import { BlockCondition } from "./condition";
import { blockCondition } from "./decorator";
/**
* A condition that evaluates based on user state.
*
* Supports checking login status, trust level, admin/moderator status, and group membership.
* By default, checks the current logged-in user. Use `source` to check a different user
* object from outlet args.
*
* **Important: Multiple conditions use AND logic.** All specified conditions must be satisfied
* for the condition to pass. For example, `{ minTrustLevel: 2, groups: ["beta-testers"] }` requires
* the user to have trust level 2+ AND be a member of the beta-testers group.
*
* The only exception is the `groups` array itself, which uses OR logic internally
* (user must be in at least ONE of the specified groups).
*
* @class BlockUserCondition
* @extends BlockCondition
*
* ## Condition Configuration Properties
*
* These properties are passed as an args object to `evaluate()`:
*
* | Property | Type | Description |
* |-----------------|------------|-----------------------------------------------------------------------|
* | `source` | `string` | Optional. Path to user object in outlet args (e.g., `@outletArgs.user`)|
* | `loggedIn` | `boolean` | If true, passes for logged-in users (or if source matches currentUser)|
* | `admin` | `boolean` | If true, passes only for admin users |
* | `moderator` | `boolean` | If true, passes only for moderators (includes admins) |
* | `staff` | `boolean` | If true, passes only for staff members |
* | `minTrustLevel` | `number` | Minimum trust level required (0-4) |
* | `maxTrustLevel` | `number` | Maximum trust level allowed (0-4) |
* | `groups` | `string[]` | User must be in at least one of these groups (OR logic) |
*
* @example
* // Logged-in users only
* { type: "user", loggedIn: true }
*
* @example
* // Anonymous users only
* { type: "user", loggedIn: false }
*
* @example
* // Admin users only
* { type: "user", admin: true }
*
* @example
* // Trust level 2+ AND in specific groups
* { type: "user", minTrustLevel: 2, groups: ["beta-testers", "power-users"] }
*
* @example
* // Check a user from outlet args instead of currentUser
* { type: "user", source: "@outletArgs.topicAuthor", admin: true }
*
* @example
* // Check if source user IS the current logged-in user
* { type: "user", source: "@outletArgs.post.user", loggedIn: true }
*
* @example
* // Check if source user is NOT the current user (e.g., for "follow" button)
* { type: "user", source: "@outletArgs.user", loggedIn: false }
*/
@blockCondition({
type: "user",
sourceType: "outletArgs",
args: {
loggedIn: { type: "boolean" },
admin: { type: "boolean" },
moderator: { type: "boolean" },
staff: { type: "boolean" },
minTrustLevel: { type: "number", min: 0, max: 4, integer: true },
maxTrustLevel: { type: "number", min: 0, max: 4, integer: true },
groups: { type: "array", itemType: "string" },
},
constraints: {
atLeastOne: [
"loggedIn",
"admin",
"moderator",
"staff",
"minTrustLevel",
"maxTrustLevel",
"groups",
],
},
validate(args) {
const {
loggedIn,
admin,
moderator,
staff,
minTrustLevel,
maxTrustLevel,
groups,
} = args;
// Check for loggedIn: false with user-specific conditions
if (loggedIn === false) {
const hasUserConditions =
admin !== undefined ||
moderator !== undefined ||
staff !== undefined ||
minTrustLevel !== undefined ||
maxTrustLevel !== undefined ||
groups?.length;
if (hasUserConditions) {
return (
"Cannot use `loggedIn: false` with user-specific conditions " +
"(admin, moderator, staff, minTrustLevel, maxTrustLevel, groups). " +
"Anonymous users cannot have these properties."
);
}
}
// Check for minTrustLevel > maxTrustLevel
if (
minTrustLevel !== undefined &&
maxTrustLevel !== undefined &&
minTrustLevel > maxTrustLevel
) {
return (
`\`minTrustLevel\` (${minTrustLevel}) cannot be greater than ` +
`\`maxTrustLevel\` (${maxTrustLevel}). No user can satisfy this condition.`
);
}
return null;
},
})
export default class BlockUserCondition extends BlockCondition {
@service currentUser;
/**
* Returns the currentUser service as the default source.
*
* @returns {Object|null} The currentUser service.
*/
get defaultSource() {
return this.currentUser;
}
/**
* Evaluates whether the user condition passes.
*
* @param {Object} args - The condition arguments.
* @param {Object} [context] - Evaluation context.
* @param {Object} [context.outletArgs] - Outlet args for source resolution.
* @returns {boolean} True if the condition passes.
*/
evaluate(args, context) {
const {
loggedIn,
admin,
moderator,
staff,
minTrustLevel,
maxTrustLevel,
groups,
} = args;
const user = this.getSourceValue(args, context);
// Check login state or current user match (when source is provided)
if (loggedIn !== undefined) {
if (args.source !== undefined) {
// When source is provided, check if source user IS the current user
const isCurrentUser = this.#isSameUser(user, this.currentUser);
if (loggedIn === true && !isCurrentUser) {
return false;
}
if (loggedIn === false && isCurrentUser) {
return false;
}
} else {
// Check if there is a logged-in user
if (loggedIn === true && !user) {
return false;
}
if (loggedIn === false && user) {
return false;
}
}
}
// All other checks require a user
if (!user) {
// If loggedIn: false was specified, no user passes
if (loggedIn === false) {
return true;
}
// If user-specific conditions are specified, no user cannot satisfy them
const hasUserSpecificConditions =
admin !== undefined ||
moderator !== undefined ||
staff !== undefined ||
minTrustLevel !== undefined ||
maxTrustLevel !== undefined ||
groups?.length;
if (hasUserSpecificConditions) {
return false;
}
// This line is effectively unreachable: the `atLeastOne` constraint requires
// at least one arg, and all possible args are handled above (loggedIn, user-specific
// conditions). It's added just for defensive safety in case any of the constraints are
// altered in the future.
return loggedIn === undefined;
}
// Check admin status
if (admin === true && !user.admin) {
return false;
}
// Check moderator status (admins are also moderators)
if (moderator === true && !user.moderator && !user.admin) {
return false;
}
// Check staff status
if (staff === true && !user.staff) {
return false;
}
// Check trust level range
if (minTrustLevel !== undefined && user.trust_level < minTrustLevel) {
return false;
}
if (maxTrustLevel !== undefined && user.trust_level > maxTrustLevel) {
return false;
}
// Check group membership
if (groups?.length && !this.#isInAnyGroup(user, groups)) {
return false;
}
return true;
}
/**
* Checks if two user objects represent the same user by comparing their ids.
* Used when `source` is provided with `loggedIn` to check if the source user
* is the current logged-in user.
*
* @param {Object} user1 - First user object.
* @param {Object} user2 - Second user object.
* @returns {boolean} True if both users exist and have the same id.
*/
#isSameUser(user1, user2) {
if (!user1 || !user2) {
return false;
}
if (user1.id != null && user2.id != null) {
return user1.id === user2.id;
}
return user1 === user2;
}
/**
* Checks if a user is a member of at least one of the specified groups.
* This implements OR logic for group membership: if the user belongs to any of
* the provided groups, the check passes.
*
* @param {Object} user - The user object to check.
* @param {Array<string>} groupNames - Array of group names to check membership against.
* @returns {boolean} True if the user is in at least one of the specified groups.
*/
#isInAnyGroup(user, groupNames) {
// Extract the names of all groups the user belongs to
const userGroups = user.groups?.map((g) => g.name) || [];
// Check if any of the required group names appear in the user's groups
return groupNames.some((name) => userGroups.includes(name));
}
}
@@ -0,0 +1,119 @@
// @ts-check
import { service } from "@ember/service";
import { BlockCondition } from "./condition";
import { blockCondition } from "./decorator";
/**
* Available viewport breakpoint names.
* Values match the breakpoints defined in capabilities.viewport.
*
* @constant {ReadonlyArray<string>}
*/
const BREAKPOINTS = Object.freeze(["sm", "md", "lg", "xl", "2xl"]);
/**
* A condition that evaluates based on viewport size and device capabilities.
*
* Uses the standard Discourse breakpoints from the capabilities service:
* - sm: >= 40rem (640px)
* - md: >= 48rem (768px)
* - lg: >= 64rem (1024px)
* - xl: >= 80rem (1280px)
* - 2xl: >= 96rem (1536px)
*
* **Note:** For simple show/hide based on viewport, CSS media queries are often
* more performant. Use this condition when you need to completely remove components
* from the DOM on certain viewports, or when the block content differs significantly
* between viewports.
*
* @class BlockViewportCondition
* @extends BlockCondition
*
* @param {string} [min] - Minimum breakpoint required (passes at this size and larger)
* @param {string} [max] - Maximum breakpoint allowed (passes at this size and smaller)
* @param {boolean} [touch] - If true, passes only on touch devices; if false, only on non-touch
*
* @example
* // Large screens only (lg and up)
* { type: "viewport", min: "lg" }
*
* @example
* // Small screens only (below md)
* { type: "viewport", max: "sm" }
*
* @example
* // Medium to large screens only
* { type: "viewport", min: "md", max: "xl" }
*
* @example
* // Touch devices only
* { type: "viewport", touch: true }
*/
@blockCondition({
type: "viewport",
args: {
min: { type: "string", enum: BREAKPOINTS },
max: { type: "string", enum: BREAKPOINTS },
touch: { type: "boolean" },
},
constraints: {
atLeastOne: ["min", "max", "touch"],
},
validate(args) {
const { min, max } = args;
// Check that min <= max when both are specified
if (min && max) {
const minIndex = BREAKPOINTS.indexOf(min);
const maxIndex = BREAKPOINTS.indexOf(max);
if (minIndex > maxIndex) {
return (
`\`min\` breakpoint "${min}" is larger than ` +
`\`max\` breakpoint "${max}". No viewport can satisfy this condition.`
);
}
}
return null;
},
})
export default class BlockViewportCondition extends BlockCondition {
@service capabilities;
/**
* Evaluates whether the viewport condition passes.
*
* @param {Object} args - The condition arguments.
* @returns {boolean} True if the condition passes.
*/
evaluate(args) {
const { min, max, touch } = args;
// Check touch capability
if (touch !== undefined && touch !== this.capabilities.touch) {
return false;
}
// Check minimum breakpoint (viewport must be at least this size)
if (min && !this.capabilities.viewport[min]) {
return false;
}
// Check maximum breakpoint (viewport must be at most this size).
// For max, we check that the NEXT breakpoint is NOT matched. This works
// because BREAKPOINTS is ordered from smallest to largest (sm < md < lg...),
// and capabilities.viewport[breakpoint] returns true if the viewport is AT
// LEAST that size. So if the next larger breakpoint matches, we're too big.
if (max) {
const maxIndex = BREAKPOINTS.indexOf(max);
const nextBreakpoint = BREAKPOINTS[maxIndex + 1];
if (nextBreakpoint && this.capabilities.viewport[nextBreakpoint]) {
return false;
}
}
return true;
}
}
+14
View File
@@ -0,0 +1,14 @@
// @ts-check
/**
* Public API block exports.
*
* @experimental This API is under active development and may change or be removed
* in future releases without prior notice. Use with caution in production environments.
*
* @module discourse/blocks
*/
// Public API for plugin developers
export { block } from "discourse/lib/blocks/-internals/decorator";
export { BlockCondition } from "discourse/blocks/conditions";
@@ -52,7 +52,7 @@ export default class AsyncContent extends Component {
: this.#resolveAsyncData(asyncData, context);
}
if (!this.#isPromise(value)) {
if (value && !this.#isPromise(value)) {
throw new Error(
`\`<AsyncContent />\` expects @asyncData to be an async function or a promise`
);
@@ -1,9 +1,12 @@
import Component from "@glimmer/component";
import { cached } from "@glimmer/tracking";
import { service } from "@ember/service";
import htmlClass from "discourse/helpers/html-class";
import { outletContainerRule } from "discourse/lib/blocks/-internals/css";
import { getURLWithCDN } from "discourse/lib/get-url";
export default class DStyles extends Component {
@service blocks;
@service session;
@service site;
@service interfaceColor;
@@ -81,6 +84,20 @@ export default class DStyles extends Component {
return css.join("\n");
}
/**
* Generates the CSS container query rules for all registered block outlets.
*
* Each outlet gets a rule that sets the `container` property on its container
* element, enabling `@container` queries in child blocks. The outlet registry
* is frozen after boot, so this value is computed once.
*
* @returns {string} The concatenated CSS rules for all outlets.
*/
@cached
get blockOutletStyles() {
return this.blocks.listOutlets().map(outletContainerRule).join("\n");
}
<template>
{{#if this.siteSettings.viewport_based_mobile_mode}}
{{htmlClass (if this.site.mobileView "mobile-view" "desktop-view")}}
@@ -96,5 +113,8 @@ export default class DStyles extends Component {
{{this.categoryBadges}}
{{/if}}
</style>
<style id="d-styles-block-outlets">
{{this.blockOutletStyles}}
</style>
</template>
}
@@ -5,7 +5,7 @@ import { afterRender } from "discourse/lib/decorators";
import {
buildArgsWithDeprecations,
deprecatedArgumentValue,
} from "discourse/lib/plugin-connectors";
} from "discourse/lib/outlet-args";
let _decorators = {};
@@ -12,8 +12,8 @@ import PluginOutlet from "discourse/components/plugin-outlet";
import { bind } from "discourse/lib/decorators";
import deprecated from "discourse/lib/deprecated";
import { helperContext } from "discourse/lib/helpers";
import { buildArgsWithDeprecations } from "discourse/lib/outlet-args";
import {
buildArgsWithDeprecations,
connectorsExist,
renderedConnectorsFor,
} from "discourse/lib/plugin-connectors";
@@ -1,5 +1,6 @@
import Component from "@glimmer/component";
import { service } from "@ember/service";
import BlockOutlet from "discourse/blocks/block-outlet";
import ApiSections from "../api-sections";
import CategoriesSection from "./categories-section";
import CustomSections from "./custom-sections";
@@ -10,6 +11,7 @@ export default class SidebarUserSections extends Component {
<template>
<div class="sidebar-sections">
<BlockOutlet @name="sidebar-blocks" />
<CustomSections
@collapsable={{@collapsableSections}}
@toggleNavigationMenu={{@toggleNavigationMenu}}
@@ -14,8 +14,7 @@ const HIDE_SIDEBAR_KEY = "sidebar-hidden";
export default class ApplicationController extends Controller {
@service footer;
// eslint-disable-next-line discourse/no-unused-services
@service router; // used in the route template
@service router;
@service scrollState;
@service sidebarState;
@service siteSettings;
@@ -28,6 +27,10 @@ export default class ApplicationController extends Controller {
_showSiteHeader = true;
@tracked _showSidebar;
get isCurrentAdminRoute() {
return this.router.currentRouteName?.startsWith("admin");
}
get upcomingChangeBodyClasses() {
if (!this.siteSettings.currentUserUpcomingChanges) {
return "";
@@ -0,0 +1,12 @@
import dasherize from "discourse/helpers/dasherize";
/**
* Converts a string to a valid CSS identifier (class name, ID, etc.).
* Replaces colons and dots with hyphens and converts camelCase to kebab-case.
*
* @param {string} name - The string to convert.
* @returns {string} A CSS-safe kebab-case identifier.
*/
export default function cssIdentifier(name = "") {
return dasherize(name.replace(/:/g, "-"));
}
@@ -0,0 +1,58 @@
import * as BuiltinBlocks from "discourse/blocks/builtin";
import * as conditions from "discourse/blocks/conditions";
import { getBlockMetadata } from "discourse/lib/blocks/-internals/decorator";
import { _freezeBlockRegistry } from "discourse/lib/blocks/-internals/registry/block";
import {
_freezeConditionTypeRegistry,
_registerConditionType,
} from "discourse/lib/blocks/-internals/registry/condition";
import { _freezeOutletRegistry } from "discourse/lib/blocks/-internals/registry/outlet";
import { withPluginApi } from "discourse/lib/plugin-api";
/**
* Initializes the blocks system by registering built-in blocks and conditions,
* then freezing all registries.
*
* This initializer runs after "discourse-bootstrap" but before "inject-discourse-objects"
* to ensure:
* - Built-in blocks are registered before the registry is frozen
* - Core condition types are registered before the registry is frozen
* - All registries are frozen before plugins/themes configure layouts
*
* Execution order within this initializer:
* 1. Register built-in blocks via plugin API
* 2. Register core condition types
* 3. Freeze block, outlet, and condition type registries
*/
export default {
name: "freeze-block-registry",
after: "discourse-bootstrap",
before: "inject-discourse-objects",
initialize() {
// Register built-in blocks
withPluginApi((api) => {
for (const BlockClass of Object.values(BuiltinBlocks)) {
if (typeof BlockClass === "function" && getBlockMetadata(BlockClass)) {
api.registerBlock(BlockClass);
}
}
});
// Register core condition types
for (const exported of Object.values(conditions)) {
if (
typeof exported === "function" &&
exported.prototype instanceof conditions.BlockCondition &&
exported !== conditions.BlockCondition
) {
_registerConditionType(exported);
}
}
// Freeze all registries to prevent further registrations
_freezeBlockRegistry();
_freezeOutletRegistry();
_freezeConditionTypeRegistry();
},
};
+15 -5
View File
@@ -4,15 +4,25 @@ import $ from "jquery";
import { getOwnerWithFallback } from "discourse/lib/get-owner";
import { i18n } from "discourse-i18n";
export function extractErrorInfo(error, defaultMessage) {
export function extractErrorInfo(
error,
defaultMessage,
opts = { skipConsoleError: false }
) {
const skipConsoleError = opts.skipConsoleError ?? false;
if (error instanceof Error) {
// eslint-disable-next-line no-console
console.error(error.stack);
if (!skipConsoleError) {
// eslint-disable-next-line no-console
console.error(error.stack);
}
}
if (typeof error === "string") {
// eslint-disable-next-line no-console
console.error(error);
if (!skipConsoleError) {
// eslint-disable-next-line no-console
console.error(error);
}
}
if (error.jqXHR) {
@@ -0,0 +1,107 @@
// @ts-check
/**
* Block layout wrapper for blocks.
*
* This module provides standard wrapper components for both leaf blocks
* (non-container) and container blocks. All blocks rendered through
* `BlockOutlet` use these wrappers to ensure consistent BEM-style class
* naming and layout structure.
*
* @module discourse/lib/blocks/-internals/components/block-layout-wrapper
*/
import Component from "@glimmer/component";
import curryComponent from "ember-curry-component";
import concatClass from "discourse/helpers/concat-class";
import cssIdentifier from "discourse/helpers/css-identifier";
/**
* @typedef {import("ember-curry-component").CurriedComponent} CurriedComponent
*/
/**
* @typedef {Object} WrappedBlockLayoutArgs
* @property {string} outletName - The outlet name for class generation.
* @property {string} name - The block's full registered name.
* @property {string|null} namespace - The block's namespace prefix.
* @property {boolean} isContainer - Whether this is a container block.
* @property {string|null} [id] - Optional block ID for BEM modifiers and targeting.
* @property {CurriedComponent} Component - The curried block component to render.
* @property {string} [classNames] - Additional CSS classes from layout entry.
* @property {string} [decoratorClassNames] - Extra CSS classes from the @block decorator.
*/
/**
* @typedef {Object} WrappedBlockLayoutSignature
* @property {WrappedBlockLayoutArgs} Args
*/
/**
* Wraps a block in a standard layout wrapper with BEM-style classes.
*
* @param {Object} blockData - Block rendering data.
* @param {string} blockData.outletName - The outlet name for class generation.
* @param {string} blockData.name - The block's full registered name.
* @param {string} blockData.namespace - The block's namespace prefix.
* @param {boolean} blockData.isContainer - Whether this is a container block.
* @param {string|null} [blockData.id] - Optional block ID for BEM modifiers.
* @param {CurriedComponent} blockData.Component - The curried block component.
* @param {string} [blockData.classNames] - Additional CSS classes from layout entry.
* @param {string} [blockData.decoratorClassNames] - Extra CSS classes from the @block decorator.
* @param {import("@ember/owner").default} owner - The application owner for currying.
* @returns {CurriedComponent} The wrapped component.
*/
export function wrapBlockLayout(blockData, owner) {
return curryComponent(WrappedBlockLayout, blockData, owner);
}
/**
* Component that wraps all blocks with a standard class structure.
*
* All blocks (both containers and non-containers) receive:
* - `{outletName}__block` or `{outletName}__block-container` - Outlet-scoped class for styling
* - `{outletName}__block--{id}` or `{outletName}__block-container--{id}` - BEM modifier when `id` is provided
* - Custom classes from `@decoratorClassNames` (from the @block decorator)
* - Custom classes from `@classNames` (from the layout entry)
*
* Block identity is available via data attributes:
* - `data-block-name` - The block's full registered name
* - `data-block-namespace` - The block's namespace (if present)
* - `data-block-id` - The block's entry ID (if provided)
*
* @extends {Component<WrappedBlockLayoutSignature>}
*/
class WrappedBlockLayout extends Component {
/**
* Generates the appropriate CSS class based on block type and optional ID.
* When an ID is provided, adds a BEM modifier class (e.g., `outlet__block--my-id`).
*
* @returns {string[]} An array of CSS class names.
*/
get blockClassNames() {
const safeOutlet = cssIdentifier(this.args.outletName);
const baseClass = this.args.isContainer
? `${safeOutlet}__block-container`
: `${safeOutlet}__block`;
if (this.args.id) {
return [baseClass, `${baseClass}--${this.args.id}`];
}
return [baseClass];
}
<template>
<div
class={{concatClass
this.blockClassNames
@decoratorClassNames
@classNames
}}
data-block-id={{@id}}
data-block-name={{@name}}
data-block-namespace={{@namespace}}
>
<@Component />
</div>
</template>
}
@@ -0,0 +1,24 @@
import Component from "@glimmer/component";
import { htmlSafe } from "@ember/template";
import FlashMessage from "discourse/components/flash-message";
import { extractErrorInfo } from "discourse/lib/ajax-error";
/**
* Displays an error inline within a block outlet.
* Uses FlashMessage to render the error in a consistent format.
*
* @param {Error|string|Object} error - The error to display. Can be an Error object,
* a string, an HTTP response object with responseJSON/responseText, or a jqXHR object.
*/
export default class BlockOutletInlineError extends Component {
get errorMessage() {
const errorInfo = extractErrorInfo(this.args.error, undefined, {
skipConsoleError: true,
});
return errorInfo.html ? htmlSafe(errorInfo.message) : errorInfo.message;
}
<template>
<FlashMessage role="alert" @flash={{this.errorMessage}} @type="error" />
</template>
}
@@ -0,0 +1,276 @@
// @ts-check
import Component from "@glimmer/component";
import { cached } from "@glimmer/tracking";
import { getOwner } from "@ember/owner";
import { service } from "@ember/service";
import {
outletClassName,
outletContainerClassName,
outletLayoutClassName,
} from "discourse/lib/blocks/-internals/css";
import { withDebugGroup } from "discourse/lib/blocks/-internals/debug-hooks";
import { getBlockMetadata } from "discourse/lib/blocks/-internals/decorator";
import { processBlockEntries } from "discourse/lib/blocks/-internals/entry-processing";
import {
FAILURE_TYPE,
isOptionalMissing,
} from "discourse/lib/blocks/-internals/patterns";
import { tryResolveBlock } from "discourse/lib/blocks/-internals/registry/block";
/**
* Internal container component that processes and renders block children.
*
* This component handles condition evaluation and component creation in a
* tracked getter context. By evaluating conditions synchronously in
* `processedChildren`, Ember's autotracking establishes dependencies on
* services like `router` and `discovery`. Route changes trigger re-evaluation.
*
* If condition evaluation happened in the async promise chain (as it did
* previously), service reads would not be tracked and route navigation
* would not trigger re-evaluation.
*
* The component receives the authorization-dependent function `createChildBlockFn`
* as a prop to maintain the authorization model in the main block-outlet module.
*
* @private
*/
export default class BlockOutletRootContainer extends Component {
@service blocks;
/**
* Cache for curried components, keyed by their stable block key.
*
* This cache prevents unnecessary component recreation during navigation.
* Components are reused when their class and args haven't changed.
*
* Memory note: This cache is bounded, not unbounded:
* - Keys use stable `__stableKey` values assigned once at registration time
* - The same keys are reused on every render/navigation
* - This cache instance is garbage collected when the owning component is destroyed
*
* @type {Map<string, {ComponentClass: typeof Component, args: Object, result: Object}>}
*/
#componentCache = new Map();
/**
* The CSS-safe version of the outlet name, used as the class for the
* outermost wrapper `<div>`.
*
* @see {@link outletClassName} for the naming convention.
* @returns {string} The CSS-safe outlet name (e.g., "hero-blocks").
*/
get safeOutletName() {
return outletClassName(this.args.outletName);
}
/**
* The CSS class name for the container element that establishes the
* CSS container query context for child blocks.
*
* @see {@link outletContainerClassName} for the naming convention.
* @returns {string} The container class name (e.g., "hero-blocks__container").
*/
get containerClassName() {
return outletContainerClassName(this.args.outletName);
}
/**
* The CSS class name for the layout element that wraps the rendered
* block children.
*
* @see {@link outletLayoutClassName} for the naming convention.
* @returns {string} The layout class name (e.g., "hero-blocks__layout").
*/
get layoutClassName() {
return outletLayoutClassName(this.args.outletName);
}
/**
* Processes raw block entries and creates renderable child components.
*
* This getter is the key to reactive condition evaluation. By accessing
* services like `router` and `discovery` during condition evaluation here
* (synchronously in a tracked getter), Ember establishes tracking
* dependencies. Route changes trigger this getter to re-run.
*
* @returns {Array<import("discourse/lib/blocks/-internals/entry-processing").ChildBlockResult>}
*/
@cached
get processedChildren() {
const {
rawChildren,
showGhosts,
isLoggingEnabled,
outletName,
outletArgs,
createChildBlockFn,
} = this.args;
// force tracking the value
this.args.showVisualOverlay;
if (!rawChildren?.length) {
return [];
}
const owner = getOwner(this);
const baseHierarchy = outletName;
// Step 1: Evaluate conditions - THIS IS NOW TRACKED!
// When blocksService.evaluate() reads router.currentURL or discovery.category,
// Ember establishes a dependency. Route changes trigger re-evaluation.
const processedEntries = this.#preprocessEntries(
rawChildren,
outletArgs,
this.blocks,
showGhosts,
isLoggingEnabled,
baseHierarchy
);
// Step 2: Create components from processed entries
// @ts-ignore - TS2322: ChildBlockResult type compatible with return type
return processBlockEntries({
entries: processedEntries,
cache: this.#componentCache,
owner,
baseHierarchy,
outletName,
outletArgs,
showGhosts,
isLoggingEnabled,
createChildBlockFn,
});
}
/**
* Pre-processes block entries to compute visibility for all blocks.
*
* This method evaluates conditions for all blocks in the tree and adds
* visibility metadata to each entry:
* - `__visible`: Whether the block should be rendered
* - `__failureType`: The failure type constant (debug mode only)
*
* Container blocks have an implicit condition: they must have at least
* one visible child. This is evaluated bottom-up (children first).
*
* @param {Array<Object>} entries - Array of block entries to process.
* @param {Object} outletArgs - Outlet arguments for condition evaluation.
* @param {Object} blocksService - Blocks service for condition evaluation.
* @param {boolean} showGhosts - If true, keep all blocks for ghost rendering.
* @param {boolean} isLoggingEnabled - If true, log condition evaluation.
* @param {string} baseHierarchy - Base hierarchy path for logging.
* @returns {Array<Object>} Processed entries with visibility metadata.
*/
#preprocessEntries(
entries,
outletArgs,
blocksService,
showGhosts,
isLoggingEnabled,
baseHierarchy
) {
const result = [];
for (const entry of entries) {
// Shallow clone to add visibility metadata without mutating the original
// layout entry. The layout is immutable after registration, so we create
// a copy to attach __visible and __failureReason properties.
const entryClone = { ...entry };
// Resolve block reference
const resolvedBlock = tryResolveBlock(entryClone.block);
// Skip unresolved blocks (optional missing or pending factory resolution)
if (!resolvedBlock || isOptionalMissing(resolvedBlock)) {
// Keep the entry for ghost handling in the main loop
if (showGhosts || isOptionalMissing(resolvedBlock)) {
result.push(entryClone);
}
continue;
}
const blockClass =
/** @type {import("discourse/lib/blocks/-internals/registry/block").BlockClass} */ (
resolvedBlock
);
const blockMeta = getBlockMetadata(blockClass);
const blockName = blockMeta?.blockName || "unknown";
const isContainer = blockMeta?.isContainer ?? false;
// Evaluate this block's own conditions.
// The withDebugGroup wrapper ensures START_GROUP/END_GROUP are always paired.
// This is the key reactive line - blocksService.evaluate() reads from router/discovery
// services, and since we're in a tracked getter, Ember tracks these reads.
const conditionsPassed = entryClone.conditions
? withDebugGroup(
blockName,
entryClone.id,
baseHierarchy,
isLoggingEnabled,
() =>
blocksService.evaluate(entryClone.conditions, {
debug: isLoggingEnabled,
outletArgs,
})
)
: true;
// For containers: recursively process children first (bottom-up evaluation)
// This determines which children are visible before we check if container has any
let hasVisibleChildren = true; // Non-containers always "have" visible children
if (isContainer && entryClone.children?.length) {
// Recursively preprocess children - this computes their visibility.
// Include the container's ID in the hierarchy for better debug identification.
const containerSuffix = entryClone.id ? `(#${entryClone.id})` : "";
const processedChildren = this.#preprocessEntries(
entryClone.children,
outletArgs,
blocksService,
showGhosts,
isLoggingEnabled,
`${baseHierarchy}/${blockName}${containerSuffix}`
);
hasVisibleChildren = processedChildren.some((child) => child.__visible);
// Update the cloned entry's children with the processed result
entryClone.children = processedChildren;
}
// Final visibility: own conditions must pass AND (not container OR has visible children)
// This implements the implicit "container must have visible children" condition
const visible = conditionsPassed && hasVisibleChildren;
entryClone.__visible = visible;
// In debug mode, record why the block is hidden for the ghost tooltip.
// We store the failure type (a constant) for internal logic comparisons.
// Display messages are generated at render time in ghost-block.gjs.
if (showGhosts && !visible) {
entryClone.__failureType = !conditionsPassed
? FAILURE_TYPE.CONDITION_FAILED
: FAILURE_TYPE.NO_VISIBLE_CHILDREN;
}
// In production mode, filter out invisible blocks
// In debug mode, keep all blocks for ghost rendering
if (visible || showGhosts) {
result.push(entryClone);
}
}
return result;
}
<template>
<div class={{this.safeOutletName}}>
<div class={{this.containerClassName}}>
<div class={{this.layoutClassName}}>
{{#each this.processedChildren key="key" as |child|}}
<child.Component />
{{/each}}
</div>
</div>
</div>
</template>
}
@@ -0,0 +1,54 @@
// @ts-check
import cssIdentifier from "discourse/helpers/css-identifier";
/**
* Returns the CSS-safe version of an outlet name.
*
* Converts namespaced outlet names (e.g., "plugin:outlet") to valid CSS
* identifiers by replacing colons with hyphens and dasherizing.
*
* @param {string} outletName - The outlet name (e.g., "hero-blocks", "plugin:sidebar").
* @returns {string} The CSS-safe name (e.g., "hero-blocks", "plugin-sidebar").
*/
export function outletClassName(outletName) {
return cssIdentifier(outletName);
}
/**
* Returns the container CSS class name for a block outlet.
*
* Follows the BEM-like pattern: `{safe-outlet-name}__container`.
* For example, outlet "hero-blocks" produces "hero-blocks__container".
*
* @param {string} outletName - The outlet name (e.g., "hero-blocks", "plugin:sidebar").
* @returns {string} The CSS class name (e.g., "hero-blocks__container").
*/
export function outletContainerClassName(outletName) {
return `${outletClassName(outletName)}__container`;
}
/**
* Returns the layout CSS class name for a block outlet.
*
* Follows the BEM-like pattern: `{safe-outlet-name}__layout`.
* For example, outlet "hero-blocks" produces "hero-blocks__layout".
*
* @param {string} outletName - The outlet name (e.g., "hero-blocks", "plugin:sidebar").
* @returns {string} The CSS class name (e.g., "hero-blocks__layout").
*/
export function outletLayoutClassName(outletName) {
return `${outletClassName(outletName)}__layout`;
}
/**
* Returns the CSS container query rule for a block outlet.
*
* Generates the CSS that enables `@container` queries inside block outlets
* by setting the `container` property on the outlet's container element.
*
* @param {string} outletName - The outlet name (e.g., "hero-blocks").
* @returns {string} The CSS rule (e.g., `.hero-blocks__container { container: hero-blocks / inline-size; }`).
*/
export function outletContainerRule(outletName) {
return `.${outletContainerClassName(outletName)} { container: ${outletName} / inline-size; }`;
}
@@ -0,0 +1,371 @@
// @ts-check
/**
* Debug hooks for block dev-tools integration.
*
* This module provides:
* - Debug callback hooks for dev-tools integration (visual overlays, logging, outlet boundaries)
* - Ghost component creation for visualizing hidden blocks
* - Debug console grouping utilities
*
* The debug hooks use TrackedMap for reactivity, enabling Ember's reactivity system
* to trigger re-renders when callbacks are set/cleared.
*
* @module discourse/lib/blocks/-internals/debug-hooks
*/
import { TrackedMap } from "@ember-compat/tracked-built-ins";
import { FAILURE_TYPE } from "discourse/lib/blocks/-internals/patterns";
/**
* Callback key constants for the debug hooks registry.
* Use these instead of magic strings when calling debugHooks.getCallback/setCallback.
*/
export const DEBUG_CALLBACK = Object.freeze({
BLOCK_DEBUG: "blockDebug",
BLOCK_LOGGING: "blockLogging",
VISUAL_OVERLAY: "visualOverlay",
GHOST_BLOCKS: "ghostBlocks",
OUTLET_INFO_COMPONENT: "outletInfoComponent",
CONDITION_LOG: "conditionLog",
COMBINATOR_LOG: "combinatorLog",
CONDITION_RESULT: "conditionResult",
PARAM_GROUP_LOG: "paramGroupLog",
ROUTE_STATE_LOG: "routeStateLog",
OPTIONAL_MISSING_LOG: "optionalMissingLog",
START_GROUP: "startGroup",
END_GROUP: "endGroup",
LOGGER_INTERFACE: "loggerInterface",
GHOST_CHILDREN_CREATOR: "ghostChildrenCreator",
});
/**
* Singleton class that manages debug callback hooks for the block rendering system.
* Uses TrackedMap for reactivity, so components accessing these values will re-render
* when callbacks are set or cleared.
*/
class DebugHooks {
/**
* Tracked callback registry for debug hooks.
* Using TrackedMap enables reactivity when callbacks are set/cleared.
*
* @type {TrackedMap<string, Function|null>}
*/
#callbacks = new TrackedMap(
Object.values(DEBUG_CALLBACK).map((key) => [key, null])
);
/**
* Gets a debug callback from the registry.
*
* @param {string} key - The callback key (use DEBUG_CALLBACK constants).
* @returns {Function|null} The callback function, or null if not set.
*/
getCallback(key) {
return this.#callbacks.get(key);
}
/**
* Sets a debug callback in the registry.
* Used by dev-tools to register debug hooks.
*
* @param {string} key - The callback key (use DEBUG_CALLBACK constants).
* @param {Function|null} value - The callback function, or null to clear.
* @throws {Error} If the key is not a valid callback key.
*/
setCallback(key, value) {
if (!this.#callbacks.has(key)) {
const validKeys = Object.values(DEBUG_CALLBACK).join(", ");
throw new Error(
`[Blocks] Unknown debug callback key: "${key}". Valid keys are: ${validKeys}.`
);
}
this.#callbacks.set(key, value);
}
/**
* Returns whether console logging is enabled.
* Convenience getter that invokes the blockLogging callback.
*
* @returns {boolean} True if logging is enabled.
*/
get isBlockLoggingEnabled() {
return this.#callbacks.get(DEBUG_CALLBACK.BLOCK_LOGGING)?.() ?? false;
}
/**
* Returns the outlet info component if outlet boundaries are enabled.
* Invokes the OUTLET_INFO_COMPONENT callback which returns the component
* when enabled, or null when disabled.
*
* @returns {typeof import("@glimmer/component").default|null} The outlet info component, or null.
*/
get outletInfoComponent() {
return this.#callbacks.get(DEBUG_CALLBACK.OUTLET_INFO_COMPONENT)?.();
}
/**
* Returns whether outlet boundaries should be shown.
* Derived from whether the outlet info component is available.
*
* @returns {boolean} True if boundaries should be shown.
*/
get isOutletBoundaryEnabled() {
return !!this.outletInfoComponent;
}
/**
* Returns whether visual overlay is enabled.
*
* @returns {boolean} True if visual overlay is enabled.
*/
get isVisualOverlayEnabled() {
return this.#callbacks.get(DEBUG_CALLBACK.VISUAL_OVERLAY)?.() ?? false;
}
/**
* Returns whether ghost blocks are enabled.
*
* @returns {boolean} True if ghost blocks are enabled.
*/
get isGhostBlocksEnabled() {
return this.#callbacks.get(DEBUG_CALLBACK.GHOST_BLOCKS)?.() ?? false;
}
/**
* Returns the logger interface for conditions to use.
* Convenience getter that invokes the loggerInterface callback.
*
* The interface has methods: logCondition, updateCombinatorResult,
* updateConditionResult, logParamGroup, logRouteState.
*
* @returns {Object|null} The logger interface, or null if not available.
*/
get loggerInterface() {
return this.#callbacks.get(DEBUG_CALLBACK.LOGGER_INTERFACE)?.() ?? null;
}
}
/**
* Singleton instance of DebugHooks.
* Import this to access debug callbacks with tracked reactivity.
*/
export const debugHooks = new DebugHooks();
/**
* Handles an optional missing block by logging and optionally creating a ghost.
*
* When a block reference ends with `?` but the block is not registered, this
* function handles the logging and ghost component creation.
*
* @param {Object} options - Options for handling the missing block.
* @param {string} options.blockName - The name of the missing block.
* @param {Object} options.entry - The block entry.
* @param {string} options.hierarchy - The hierarchy path for logging.
* @param {boolean} options.isLoggingEnabled - Whether debug logging is enabled.
* @param {boolean} options.showGhosts - Whether to show ghost components.
* @param {string} options.key - Stable unique key for this block.
* @returns {import("discourse/lib/blocks/-internals/entry-processing").ChildBlockResult|null}
* Ghost component data with key if showGhosts is true, null otherwise.
*/
export function handleOptionalMissingBlock({
blockName,
entry,
hierarchy,
isLoggingEnabled,
showGhosts,
key,
}) {
// Log if debug logging is enabled
if (isLoggingEnabled) {
debugHooks.getCallback(DEBUG_CALLBACK.OPTIONAL_MISSING_LOG)?.(
blockName,
entry.id,
hierarchy
);
}
// Show ghost if ghost blocks are enabled
if (showGhosts) {
const ghostData = createDebugGhost(
{
name: blockName,
id: entry.id,
args: entry.args,
conditions: entry.conditions,
failureType: FAILURE_TYPE.OPTIONAL_MISSING,
},
{ outletName: hierarchy }
);
return ghostData ? { ...ghostData, key } : null;
}
return null;
}
/**
* Builds a container path for nested containers.
*
* Maintains a count map to ensure unique indices for containers of the same type.
* For example, if there are two "group" containers without ids, they get paths like:
* - `baseHierarchy/group[0]`
* - `baseHierarchy/group[1]`
*
* If a block has an id, it replaces the index (since the id is unique):
* - `baseHierarchy/group(#my-id)`
*
* @param {string} blockName - The block name.
* @param {string|null} blockId - The block's unique id (if set).
* @param {string} baseHierarchy - The base hierarchy path.
* @param {Map<string, number>} containerCounts - Map tracking container counts.
* @returns {string} The full container path.
*/
export function buildContainerPath(
blockName,
blockId,
baseHierarchy,
containerCounts
) {
// Always increment the counter for consistent indexing of blocks without ids.
const count = containerCounts.get(blockName) ?? 0;
containerCounts.set(blockName, count + 1);
// Use id if available (unique), otherwise fall back to index.
const suffix = blockId ? `(#${blockId})` : `[${count}]`;
return `${baseHierarchy}/${blockName}${suffix}`;
}
/**
* Invokes the BLOCK_DEBUG callback to create a ghost component.
*
* This is the low-level helper that calls the debug callback with block data.
* Used by both `createGhostBlock` (entry-processing time) and `asGhost`
* (render time) to avoid duplicating the callback invocation logic.
*
* @param {Object} blockData - Data describing the block to ghost.
* @param {string} blockData.name - The block name.
* @param {string} [blockData.id] - The block's unique ID (if set).
* @param {Object} [blockData.args] - Block arguments.
* @param {Object} [blockData.containerArgs] - Container arguments.
* @param {Array} [blockData.conditions] - Block conditions.
* @param {string} [blockData.failureType] - Type of failure (from FAILURE_TYPE).
* @param {string} [blockData.failureReason] - Custom failure reason message.
* @param {Array} [blockData.children] - Ghost children for containers.
* @param {Object} context - Context for the ghost.
* @param {string} context.outletName - The outlet/hierarchy name for display.
* @param {Object} [context.outletArgs] - Outlet arguments.
* @returns {Object|null} Ghost data with Component property, or null if callback
* not set or didn't return a component.
*/
export function createDebugGhost(blockData, context) {
const ghostData = debugHooks.getCallback(DEBUG_CALLBACK.BLOCK_DEBUG)?.(
{
...blockData,
Component: null,
conditionsPassed: false,
},
context
);
return ghostData?.Component ? ghostData : null;
}
/**
* Creates a ghost component for an invisible block.
*
* Ghost components are shown in debug mode to visualize blocks that failed
* their conditions or have no visible children.
*
* @param {Object} options - Options for creating the ghost.
* @param {string} options.blockName - The block name.
* @param {Object} options.entry - The block entry.
* @param {string} options.hierarchy - The hierarchy path for display.
* @param {string|undefined} options.containerPath - Container path for child hierarchies.
* @param {boolean} options.isContainer - Whether this block is a container.
* @param {import("@ember/owner").default} options.owner - The application owner.
* @param {Object} options.outletArgs - Outlet arguments.
* @param {boolean} options.isLoggingEnabled - Whether debug logging is enabled.
* @param {Function} options.resolveBlockFn - Function to resolve block references.
* @param {string} options.key - Stable unique key for this block.
* @returns {import("discourse/lib/blocks/-internals/entry-processing").ChildBlockResult|null}
* Ghost component data with key if successful, null otherwise.
*/
export function createGhostBlock({
blockName,
entry,
hierarchy,
containerPath,
isContainer,
owner,
outletArgs,
isLoggingEnabled,
resolveBlockFn,
key,
}) {
// For container blocks with children that failed due to no visible children,
// recursively create ghost children so they appear nested in the debug overlay.
let ghostChildren = null;
if (
isContainer &&
entry.children?.length &&
entry.__failureType === FAILURE_TYPE.NO_VISIBLE_CHILDREN
) {
ghostChildren = debugHooks.getCallback(
DEBUG_CALLBACK.GHOST_CHILDREN_CREATOR
)?.(
entry.children,
owner,
containerPath,
outletArgs,
isLoggingEnabled,
resolveBlockFn
);
}
const ghostData = createDebugGhost(
{
name: blockName,
id: entry.id,
args: entry.args,
containerArgs: entry.containerArgs,
conditions: entry.conditions,
failureType: entry.__failureType,
failureReason: entry.__failureReason,
children: ghostChildren,
},
{ outletName: hierarchy }
);
return ghostData ? { ...ghostData, key } : null;
}
/**
* Executes a function within a debug console group.
* Ensures START_GROUP and END_GROUP callbacks are always paired.
*
* @param {string} blockName - The block name for the group label.
* @param {string|null} blockId - The block's unique ID (if set).
* @param {string} hierarchy - The hierarchy path for context.
* @param {boolean} isLoggingEnabled - Whether debug logging is active.
* @param {() => boolean} fn - Function to execute that returns the condition result.
* @returns {boolean} The result of the function execution.
*/
export function withDebugGroup(
blockName,
blockId,
hierarchy,
isLoggingEnabled,
fn
) {
if (!isLoggingEnabled) {
return fn();
}
debugHooks.getCallback(DEBUG_CALLBACK.START_GROUP)?.(
blockName,
blockId,
hierarchy
);
const result = fn();
debugHooks.getCallback(DEBUG_CALLBACK.END_GROUP)?.(result);
return result;
}
@@ -0,0 +1,376 @@
// @ts-check
/**
* Block Decorator Module
*
* This module provides the @block decorator and related authorization utilities.
* The authorization model uses private symbols and WeakMaps to prevent external
* code from spoofing block authorization or bypassing validation.
*
* Key concepts:
* - AUTH_TOKEN: A private symbol used to verify authorized block rendering contexts
* - blockMetadataMap: WeakMap tracking which classes are blocks and their metadata
* - rootBlockClass: Single variable holding the root block class (set via registerRootBlock)
*
* @module discourse/lib/blocks/-internals/decorator
*/
import Component from "@glimmer/component";
import {
getInternalComponentManager,
setInternalComponentManager,
// @ts-ignore - @glimmer/manager types not provided by ember-source
} from "@glimmer/manager";
import { raiseBlockError } from "discourse/lib/blocks/-internals/error";
import {
validateArgsSchema,
validateChildArgsSchema,
} from "discourse/lib/blocks/-internals/validation/block-args";
import {
validateAndParseBlockName,
validateBlockOptions,
validateOutletRestrictions,
} from "discourse/lib/blocks/-internals/validation/block-decorator";
import { validateConstraintsSchema } from "discourse/lib/blocks/-internals/validation/constraints";
/*
* Authorization System
*
* IMPORTANT: These values MUST NOT be exported.
*
* The authorization model works as follows:
* 1. AUTH_TOKEN is a secret symbol known only to this module
* 2. blockMetadataMap tracks which classes are decorated with @block
* 3. rootBlockClass holds the single block that can be rendered directly (set via registerRootBlock)
* 4. Child blocks receive AUTH_TOKEN via the __block$ arg from their parent
* 5. BlockComponentManager verifies authorization before instantiation
*
* This prevents:
* - Using blocks directly in templates (bypassing BlockOutlet)
* - Spoofing block authorization by setting properties on classes
* - Accessing authorization state from external code
*/
/**
* Secret token used to authorize block rendering.
* Passed via __block$ arg from parent containers to child blocks.
*/
const AUTH_TOKEN = Symbol("block-auth-token");
/**
* @typedef {Object} BlockMetadataEntry
* @property {boolean} isContainer - Whether the block is a container.
* @property {string} blockName - The block's full name identifier (e.g., "theme:tactile:hero-banner").
* @property {string} shortName - The block's plain name without namespace (e.g., "hero-banner").
* @property {string|null} namespace - The parsed namespace for CSS (e.g., "my-plugin" or "theme-tactile").
* @property {"core"|"plugin"|"theme"} namespaceType - The type of namespace.
* @property {string} description - Human-readable description of the block.
* @property {string|string[]|Function|null} decoratorClassNames - CSS classNames from decorator.
* @property {Object|null} args - Args schema for the block.
* @property {Object|null} childArgs - Child args schema (containers only).
* @property {Object|null} constraints - Cross-arg validation constraints.
* @property {Function|null} validate - Custom validation function.
* @property {readonly string[]|null} allowedOutlets - Allowed outlet patterns.
* @property {readonly string[]|null} deniedOutlets - Denied outlet patterns.
*/
/**
* Maps block classes to their metadata.
* Using WeakMap ensures:
* - Classes are garbage collected when no longer referenced
* - Authorization state cannot be discovered via Object.getOwnPropertySymbols()
*
* @type {WeakMap<Function, BlockMetadataEntry>}
*/
const blockMetadataMap = new WeakMap();
/**
* The single root block class (BlockOutlet).
* Only one block can be the root - once set, it cannot be changed.
* This prevents any other block from claiming root status.
*
* @type {Function|null}
*/
let rootBlockClass = null;
/**
* Registers a block class as the root block that can be rendered directly
* without authorization. Only one root block can be registered.
*
* @param {Function} klass - The block class to register as root.
*/
export function registerRootBlock(klass) {
if (rootBlockClass !== null) {
raiseBlockError(
`Only one root block is allowed. ` +
`"${rootBlockClass.name}" is already registered as the root block.`
);
}
rootBlockClass = klass;
}
/**
* Custom component manager proxy that enforces block authorization.
*
* Blocks can only be instantiated in two authorized scenarios:
* 1. As a root block - The class is the rootBlockClass (set via registerRootBlock)
* 2. As a child of a container - The parent passes __block$ arg with AUTH_TOKEN
*
* This prevents blocks from being used directly in templates, ensuring they
* can only be rendered through the BlockOutlet system.
*/
const BlockComponentManager = new Proxy(
getInternalComponentManager(Component),
{
get(target, prop) {
if (prop === "create") {
return function (owner, klass, args) {
// Check if this is the root block (BlockOutlet)
const isRootBlock = klass === rootBlockClass;
// Check if this is an authorized child (parent passes __block$ secret token)
let isAuthorizedChild = false;
const named = args?.named;
if (named?.names?.includes("__block$")) {
const ref = named.get("__block$");
isAuthorizedChild = ref.compute() === AUTH_TOKEN;
}
if (!isRootBlock && !isAuthorizedChild) {
const blockName =
blockMetadataMap.get(klass)?.blockName || klass.name;
throw new Error(
`Block "${blockName}" cannot be used directly in templates. ` +
`Blocks can only be rendered inside BlockOutlets or container blocks.`
);
}
return target.create(...arguments);
};
}
return Reflect.get(target, prop);
},
}
);
/**
* Schema for block argument validation.
*
* @typedef {Object} ArgSchema
* @property {"string"|"number"|"boolean"|"array"|"any"} type - The argument type (required)
* @property {boolean} [required=false] - Whether the argument is required
* @property {*} [default] - Default value for the argument
* @property {"string"|"number"|"boolean"} [itemType] - Item type for array arguments
* @property {RegExp} [pattern] - Regex pattern for string validation
* @property {number} [minLength] - Minimum length for string or array
* @property {number} [maxLength] - Maximum length for string or array
* @property {number} [min] - Minimum value for number
* @property {number} [max] - Maximum value for number
* @property {boolean} [integer] - Whether number must be an integer
* @property {Array} [enum] - Allowed values for the argument
* @property {Array} [itemEnum] - Allowed values for array items
*/
/**
* Decorator that transforms a Glimmer component into a block component.
*
* Block components have special authorization constraints:
* - They can only be rendered inside BlockOutlets or container blocks
* - They cannot be used directly in templates
* - They receive special args for authorization and hierarchy management
*
* @experimental This API is under active development and may change or be removed
* in future releases without prior notice. Use with caution in production environments.
*
* @param {string} name - Unique identifier for the block. Supports three namespacing formats:
* - Core blocks: `"block-name"` (e.g., "hero-banner", "sidebar-panel")
* - Plugin blocks: `"plugin-name:block-name"` (e.g., "my-plugin:custom-card")
* - Theme blocks: `"theme:theme-name:block-name"` (e.g., "theme:tactile:hero-section")
* Names must use lowercase letters, numbers, and hyphens only.
*
* @param {Object} [options] - Configuration options for the block.
*
* @param {boolean} [options.container=false] - If true, this block can contain nested child blocks.
*
* @param {string} [options.description] - Human-readable description of the block.
*
* @param {Object.<string, ArgSchema>} [options.args] - Schema for block arguments.
*
* @param {Object.<string, ArgSchema>} [options.childArgs] - Schema for args passed to children
* of this container block. Only valid when container: true.
*
* @param {Object} [options.constraints] - Cross-arg validation constraints.
*
* @param {Function} [options.validate] - Custom validation function.
*
* @param {string|string[]|((args: Object) => string)} [options.classNames] - Additional CSS classes.
*
* @param {string[]} [options.allowedOutlets] - Glob patterns for allowed outlets.
*
* @param {string[]} [options.deniedOutlets] - Glob patterns for denied outlets.
*
* @returns {Function} Decorator function that returns the decorated class
*
* @example
* // Simple block
* @block("my-card")
* class MyCard extends Component { ... }
*
* @example
* // Container block
* @block("my-section", { container: true })
* class MySection extends Component { ... }
*
*/
export function block(name, options = {}) {
// === Decoration-time validation ===
validateBlockOptions(name, options);
const parsed = validateAndParseBlockName(name);
// Extract all options with defaults
const {
container: isContainer = false,
classNames: decoratorClassNames = null,
description = "",
args: argsSchema = null,
childArgs: childArgsSchema = null,
constraints = null,
validate: validateFn = null,
allowedOutlets = null,
deniedOutlets = null,
} = options;
// Validate arg schema structure and types
validateArgsSchema(argsSchema, name);
// Validate childArgs is only allowed on container blocks
if (childArgsSchema && !isContainer) {
raiseBlockError(
`Block "${name}": "childArgs" is only valid for container blocks (container: true).`
);
}
// Validate classNames type (string, array, or function)
if (
decoratorClassNames != null &&
typeof decoratorClassNames !== "string" &&
typeof decoratorClassNames !== "function" &&
!Array.isArray(decoratorClassNames)
) {
raiseBlockError(
`Block "${name}": "classNames" must be a string, array, or function.`
);
}
// Validate childArgs schema structure and types
validateChildArgsSchema(childArgsSchema, name);
// Validate constraints schema
validateConstraintsSchema(constraints, argsSchema, name);
// Validate that validate is a function if provided
if (validateFn !== null && typeof validateFn !== "function") {
raiseBlockError(
`Block "${name}": "validate" must be a function, got ${typeof validateFn}.`
);
}
// Validate outlet restriction patterns
validateOutletRestrictions(name, allowedOutlets, deniedOutlets);
return function (target) {
setInternalComponentManager(BlockComponentManager, target);
if (!(target.prototype instanceof Component)) {
raiseBlockError("@block target must be a Glimmer component class");
return target;
}
// Create and register metadata object with all block information
const metadata = Object.freeze({
allowedOutlets: allowedOutlets
? Object.freeze([...allowedOutlets])
: null,
args: argsSchema ? Object.freeze(argsSchema) : null,
blockName: name,
childArgs: childArgsSchema ? Object.freeze(childArgsSchema) : null,
constraints: constraints ? Object.freeze(constraints) : null,
decoratorClassNames,
deniedOutlets: deniedOutlets ? Object.freeze([...deniedOutlets]) : null,
description,
isContainer,
namespace: parsed.namespace,
namespaceType: parsed.type,
shortName: parsed.name,
validate: validateFn,
});
blockMetadataMap.set(target, metadata);
return target;
};
}
/**
* Creates the args object for a child block with reactive getters for context args.
*
* This function embeds the AUTH_TOKEN in the __block$ property, which is how
* child blocks are authorized to render. The token is not exposed - it's
* embedded in the returned object.
*
* Context args are defined as getters rather than direct properties. This allows
* `curryComponent` to maintain a stable component identity while enabling reactive
* updates when the getter values change. Without getters, changing any arg would
* require creating a new curried component, breaking Ember's identity-based rendering.
*
* @param {Object} entryArgs - User-provided args from the layout entry.
* @param {Object} contextArgs - Rendering context to define as reactive getters.
* @returns {Object} The merged args object ready for `curryComponent`.
*/
export function createBlockArgsWithReactiveGetters(entryArgs, contextArgs) {
const blockArgs = {
...entryArgs,
__block$: AUTH_TOKEN,
};
// Dynamically define reactive getters for each context arg
/** @type {PropertyDescriptorMap} */
const propertyDescriptors = {};
for (const [key, value] of Object.entries(contextArgs)) {
propertyDescriptors[key] = {
get() {
return value;
},
enumerable: true,
};
}
Object.defineProperties(blockArgs, propertyDescriptors);
return blockArgs;
}
/**
* Gets all metadata for a component registered with @block.
*
* Returns a flat object containing all block information:
* - `blockName` - Full block name (e.g., "theme:my-theme:heading")
* - `shortName` - Plain name without namespace (e.g., "heading")
* - `namespace` - Full namespace for CSS ("my-plugin" or "theme-tactile") or null for core
* - `namespaceType` - "core" | "plugin" | "theme"
* - `isContainer` - Whether block is a container
* - `description` - Human-readable description
* - `decoratorClassNames` - CSS classNames from decorator
* - `args` - Args schema
* - `childArgs` - Child args schema (containers only)
* - `constraints` - Cross-arg validation constraints
* - `validate` - Custom validation function
* - `allowedOutlets` - Allowed outlet patterns
* - `deniedOutlets` - Denied outlet patterns
*
* @experimental This API is under active development and may change or be removed
* in future releases without prior notice. Use with caution in production environments.
*
* @param {Function} component - The component to get metadata for.
* @returns {Object|null} The block metadata object, or null if not a block.
*/
export function getBlockMetadata(component) {
return blockMetadataMap.get(component) ?? null;
}
@@ -0,0 +1,254 @@
// @ts-check
/**
* Block Entry Processing
*
* This module contains utilities for processing block entries and creating
* renderable components. These functions iterate through pre-processed block
* entries and transform them into curried Glimmer components.
*
* The functions use dependency injection for the authorization-dependent operation
* `createChildBlockFn` to maintain the authorization model in the main block-outlet
* module.
*
* @module discourse/lib/blocks/-internals/entry-processing
*/
import {
buildContainerPath,
createGhostBlock,
handleOptionalMissingBlock,
} from "discourse/lib/blocks/-internals/debug-hooks";
import { getBlockMetadata } from "discourse/lib/blocks/-internals/decorator";
import { isOptionalMissing } from "discourse/lib/blocks/-internals/patterns";
import { tryResolveBlock } from "discourse/lib/blocks/-internals/registry/block";
import { shallowArgsEqual } from "discourse/lib/blocks/-internals/utils";
/**
* Gets or creates a curried component for a leaf block, using cache when possible.
*
* Only leaf blocks (blocks without children) are cached. Container blocks are
* always recreated because their children's visibility may change between
* renders, and caching would result in stale children being displayed.
*
* Cache hit conditions:
* 1. The component class must be the same reference
* 2. The args object must be shallowly equal
*
* @param {Map<string, {ComponentClass: typeof import("@glimmer/component").default, args: Object, result: Object}>} cache - The component cache keyed by stable block keys.
* @param {Object} entry - The block entry with __stableKey and optional children.
* @param {typeof import("@glimmer/component").default} resolvedBlock - The resolved block component class.
* @param {Object} debugContext - Debug context for visual overlay and hierarchy tracking.
* @param {string} debugContext.key - Stable unique key for this block.
* @param {string} debugContext.displayHierarchy - Where the block is rendered (for tooltip display).
* @param {string} debugContext.outletName - The outlet name for wrapper class generation.
* @param {string} [debugContext.containerPath] - Container's full path (for children's __hierarchy).
* @param {Object} [debugContext.conditions] - The block's conditions.
* @param {Object} debugContext.outletArgs - Outlet arguments passed from the parent.
* @param {Array<Object>} [debugContext.processedChildren] - Pre-processed children for container blocks.
* @param {import("@ember/owner").default} owner - The application owner for service lookup.
* @param {Function} createChildBlockFn - Function to create child block components (injected from block-outlet.gjs).
* @returns {ChildBlockResult} The cached or newly created component data with stable key for list rendering.
*/
function getOrCreateLeafBlockComponent(
cache,
entry,
resolvedBlock,
debugContext,
owner,
createChildBlockFn
) {
const { key } = debugContext;
const cachedEntry = cache.get(key);
const hasChildren = entry.children?.length > 0;
// Only cache leaf blocks (no children). Container blocks are always recreated
// to ensure their children reflect current visibility state.
if (
!hasChildren &&
cachedEntry &&
cachedEntry.ComponentClass === resolvedBlock &&
shallowArgsEqual(cachedEntry.args, entry.args)
) {
return cachedEntry.result;
}
// Create new curried component
const result = createChildBlockFn(
{ ...entry, block: resolvedBlock },
owner,
debugContext
);
// Cache leaf blocks for future reuse
if (!hasChildren) {
cache.set(key, {
ComponentClass: resolvedBlock,
args: entry.args,
result,
});
}
return result;
}
/**
* Processes block entries and creates renderable child components.
*
* This function iterates through a list of pre-processed block entries and
* transforms them into renderable components, handling ghost blocks for
* debug mode and optional missing blocks.
*
* @typedef {Object} BlockEntry
* @property {string|typeof import("@glimmer/component").default} block - Block reference (string name or class).
* @property {Object} [args] - Arguments to pass to the block.
* @property {Object} [containerArgs] - Values for parent container's childArgs schema.
* @property {Array<BlockEntry>} [children] - Nested block entries for containers.
* @property {Object|Array<Object>} [conditions] - Conditions that must pass for block to render.
* @property {string} [classNames] - Additional CSS classes for the block wrapper.
* @property {string} [id] - Unique identifier for BEM styling and targeting.
* @property {boolean} __visible - Whether the block passed condition evaluation.
* @property {number} __stableKey - Stable key assigned at registration time.
* @property {string} [__failureType] - The failure type constant (debug mode only).
* @property {string} [__failureReason] - Custom failure reason message (debug mode only).
*
* @typedef {Object} ChildBlockResult
* @property {import("ember-curry-component").CurriedComponent} Component - Curried component ready to render.
* @property {Object} [containerArgs] - Values for parent container's childArgs schema.
* @property {string} key - Stable unique key for list rendering.
* @property {boolean} [isGhost] - True if this is a ghost block (debug mode only).
* @property {(reason: string) => ChildBlockResult|null} [asGhost] - Returns a ghost version of this child with the given reason.
* For regular children, creates a new ghost component (or null if debug mode is disabled).
* For ghost children, returns self (no-op).
*
* @param {Object} options - Rendering options.
* @param {Array<BlockEntry>} options.entries - Pre-processed block entries with visibility metadata.
* @param {Map<string, {ComponentClass: typeof import("@glimmer/component").default, args: Object, result: ChildBlockResult}>} options.cache - Component cache keyed by stable block keys.
* @param {import("@ember/owner").default} options.owner - Application owner for service lookup.
* @param {string} options.baseHierarchy - Current hierarchy path (e.g., "homepage-blocks/section-1").
* @param {string} options.outletName - The outlet name for CSS class generation.
* @param {Object} options.outletArgs - Arguments passed from the outlet to blocks.
* @param {boolean} options.showGhosts - Whether to render ghost blocks for invisible entries.
* @param {boolean} options.isLoggingEnabled - Whether debug logging is active.
* @param {Function} options.createChildBlockFn - Function to create child block components (injected from block-outlet.gjs).
* @returns {Array<ChildBlockResult>} Array of renderable child objects with Component and containerArgs.
*/
export function processBlockEntries({
entries,
cache,
owner,
baseHierarchy,
outletName,
outletArgs,
showGhosts,
isLoggingEnabled,
createChildBlockFn,
}) {
const result = [];
const containerCounts = new Map();
for (const entry of entries) {
// @ts-ignore - entry.block can be string or BlockClass
const resolvedBlock = tryResolveBlock(entry.block);
// Handle optional missing block (block ref ended with `?` but not registered)
if (isOptionalMissing(resolvedBlock)) {
const key = `optional-missing:${resolvedBlock.name}:${entry.__stableKey}`;
const ghostData = handleOptionalMissingBlock({
blockName: resolvedBlock.name,
entry,
hierarchy: baseHierarchy,
isLoggingEnabled,
showGhosts,
key,
});
if (ghostData) {
result.push(ghostData);
}
continue;
}
// Skip blocks that haven't resolved yet. Block factories may be resolving
// asynchronously (e.g., lazy-loaded plugins). The component will automatically
// re-render when the factory resolves (via TrackedMap reactivity).
if (!resolvedBlock) {
continue;
}
const blockClass =
/** @type {import("discourse/lib/blocks/-internals/registry/block").BlockClass} */ (
resolvedBlock
);
const blockMeta = getBlockMetadata(blockClass);
const blockName = blockMeta?.blockName || "unknown";
const isContainer = blockMeta?.isContainer ?? false;
// Use the stable key assigned at registration time. This key survives
// shallow cloning and ensures DOM identity is maintained when blocks
// are hidden/shown by conditions.
const key = `${blockName}:${entry.__stableKey}`;
// For containers, build their full path for children's hierarchy.
// The id is included in the path for easier identification in debug tools.
const containerPath = isContainer
? buildContainerPath(blockName, entry.id, baseHierarchy, containerCounts)
: undefined;
// For containers with children, recursively process children FIRST
// This creates the child components at the root level, so containers
// receive pre-processed children via @children arg instead of raw entries.
let processedChildren;
if (isContainer && entry.children?.length) {
processedChildren = processBlockEntries({
entries: entry.children,
cache, // Same root cache for all levels
owner,
baseHierarchy: containerPath,
outletName,
outletArgs,
showGhosts,
isLoggingEnabled,
createChildBlockFn,
});
}
// Render visible blocks
if (entry.__visible) {
result.push(
getOrCreateLeafBlockComponent(
cache,
entry,
blockClass,
{
displayHierarchy: baseHierarchy,
outletName,
containerPath,
conditions: entry.conditions,
outletArgs,
key,
processedChildren, // Pass pre-processed children for containers
},
owner,
createChildBlockFn
)
);
} else if (showGhosts) {
// Show ghost for invisible blocks in debug mode
const ghostData = createGhostBlock({
blockName,
entry,
hierarchy: baseHierarchy,
containerPath,
isContainer,
owner,
outletArgs,
isLoggingEnabled,
resolveBlockFn: tryResolveBlock,
key,
});
if (ghostData) {
result.push(ghostData);
}
}
}
return result;
}
@@ -0,0 +1,845 @@
// @ts-check
/**
* Block error handling and entry formatting utilities.
*
* This module provides error classes, formatting utilities for error display,
* and helpers for human-readable console output during block validation.
*
* @module discourse/lib/blocks/-internals/error
*/
import { DEBUG } from "@glimmer/env";
import { getBlockMetadata } from "discourse/lib/blocks/-internals/decorator";
/* Value Display Helpers */
/**
* Formats a simple value (primitive, function, or block reference) for display.
* Returns null if the value is a complex type (array/object) that needs special handling.
*
* @param {*} obj - The value to format.
* @returns {string|null} String representation, or null if complex type.
*/
function formatSimpleValue(obj) {
if (obj === undefined) {
return "undefined";
}
if (obj === null) {
return "null";
}
if (typeof obj === "function") {
const blockName = getBlockMetadata(obj)?.blockName;
return `<${blockName || obj.name || "Function"}>`;
}
if (typeof obj !== "object") {
return JSON.stringify(obj);
}
const blockName = getBlockMetadata(obj)?.blockName;
if (blockName) {
return `<${blockName}>`;
}
return null;
}
/**
* Formats a value as a truncated string for console display.
* Returns shallow representations like "{ ... }" or "[ 3 items ]".
*
* Note: This returns STRINGS for console output. For JSON-serializable
* truncation, use truncateForDisplay() instead.
*
* @param {*} obj - The value to format.
* @returns {string} Truncated string representation.
*/
function formatTruncatedValue(obj) {
const simple = formatSimpleValue(obj);
if (simple !== null) {
return simple;
}
if (Array.isArray(obj)) {
return obj.length === 0 ? "[]" : `[ ${obj.length} items ]`;
}
const keys = Object.keys(obj);
return keys.length === 0 ? "{}" : "{ ... }";
}
/* Path Parsing */
/**
* Parses a condition path string into path segments.
* Handles both dot notation (`.key`) and bracket notation (`[0]`).
*
* @param {string} path - The path string (e.g., "conditions.any[0][1].queryParams").
* @returns {Array<string|number>} Array of path segments.
*
* @example
* parseConditionPath("conditions.any[0][1].type")
* // Returns: ["conditions", "any", 0, 1, "type"]
*/
export function parseConditionPath(path) {
const segments = [];
let current = "";
for (let i = 0; i < path.length; i++) {
const char = path[i];
if (char === ".") {
if (current) {
segments.push(current);
current = "";
}
} else if (char === "[") {
if (current) {
segments.push(current);
current = "";
}
// Find closing bracket
let j = i + 1;
while (j < path.length && path[j] !== "]") {
j++;
}
const index = path.slice(i + 1, j);
segments.push(parseInt(index, 10));
i = j; // Skip to after the closing bracket
} else {
current += char;
}
}
if (current) {
segments.push(current);
}
return segments;
}
/* Path-Aware Entry Rendering */
/**
* Renders block entries with error path highlighting.
* Displays the structure with proper indentation and marks where errors occurred.
*/
class PathHighlightRenderer {
/** @type {Array<string|number>} */
#errorSegments;
/** @type {string|number} */
#errorKey;
/** @type {string|undefined} */
#label;
/**
* @param {string} errorPath - Path to the error (e.g., "conditions.any[0].type").
* @param {Object} [options] - Formatting options.
* @param {string} [options.prefix] - Prefix to skip in the path.
* @param {string} [options.label] - Label to show before the entry.
*/
constructor(errorPath, options = {}) {
const { prefix, label } = options;
const pathSegments = parseConditionPath(errorPath);
const startIndex = prefix && pathSegments[0] === prefix ? 1 : 0;
this.#errorSegments = pathSegments.slice(startIndex);
this.#errorKey = this.#errorSegments.at(-1);
this.#label = label;
}
/**
* Renders the entry with the error path highlighted.
*
* @param {Object|Array} entry - The block entry object to render.
* @returns {string} Formatted string with error location highlighted.
*/
render(entry) {
const rendered = this.#renderValue(entry, 0, []);
return this.#label ? `${this.#label} ${rendered}` : rendered;
}
/**
* Checks if the current path is on the error path.
*
* @param {Array<string|number>} currentPath - Current path segments.
* @returns {boolean} True if on error path.
*/
#isOnErrorPath(currentPath) {
return currentPath.every(
(seg, i) =>
i >= this.#errorSegments.length || seg === this.#errorSegments[i]
);
}
/**
* Checks if we've passed the error location in the tree.
*
* @param {Array<string|number>} currentPath - Current path segments.
* @returns {boolean} True if past error location.
*/
#isPastErrorLocation(currentPath) {
return currentPath.length > this.#errorSegments.length;
}
/**
* Renders any value, dispatching to the appropriate handler.
*
* @param {*} obj - The value to render.
* @param {number} depth - Current indentation depth.
* @param {Array<string|number>} currentPath - Path segments to current location.
* @returns {string} Rendered string.
*/
#renderValue(obj, depth, currentPath) {
if (this.#isPastErrorLocation(currentPath)) {
return formatTruncatedValue(obj);
}
const simple = formatSimpleValue(obj);
if (simple !== null) {
return simple;
}
if (Array.isArray(obj)) {
return this.#renderArray(obj, depth, currentPath);
}
return this.#renderObject(obj, depth, currentPath);
}
/**
* Renders an array with path highlighting.
*
* @param {Array} arr - The array to render.
* @param {number} depth - Current indentation depth.
* @param {Array<string|number>} currentPath - Path segments to current location.
* @returns {string} Rendered string.
*/
#renderArray(arr, depth, currentPath) {
if (arr.length === 0) {
return "[]";
}
const indent = " ".repeat(depth);
const itemOnPathIndex = this.#findItemOnPath(arr, currentPath);
const lines = ["["];
if (itemOnPathIndex >= 0) {
this.#renderArrayWithHighlightedItem(
arr,
itemOnPathIndex,
depth,
currentPath,
indent,
lines
);
} else {
this.#renderArrayAllTruncated(arr, indent, lines);
}
lines.push(`${indent}]`);
return lines.join("\n");
}
/**
* Finds which array item (if any) is on the error path.
*
* @param {Array} arr - The array to search.
* @param {Array<string|number>} currentPath - Current path segments.
* @returns {number} Index of item on path, or -1 if none.
*/
#findItemOnPath(arr, currentPath) {
for (let i = 0; i < arr.length; i++) {
const itemPath = [...currentPath, i];
if (this.#isOnErrorPath(itemPath)) {
return i;
}
}
return -1;
}
/**
* Renders an array with a highlighted item on the error path.
*
* @param {Array} arr - The array to render.
* @param {number} itemIndex - Index of the highlighted item.
* @param {number} depth - Current indentation depth.
* @param {Array<string|number>} currentPath - Current path segments.
* @param {string} indent - Current indentation string.
* @param {Array<string>} lines - Output lines array.
*/
#renderArrayWithHighlightedItem(
arr,
itemIndex,
depth,
currentPath,
indent,
lines
) {
if (itemIndex > 0) {
lines.push(`${indent} ...`);
}
const itemPath = [...currentPath, itemIndex];
const value = this.#renderValue(arr[itemIndex], depth + 1, itemPath);
const comma = itemIndex < arr.length - 1 ? "," : "";
const errorMarker = this.#isExactErrorLocation(itemPath)
? " // <-- error here"
: "";
lines.push(`${indent} ${value}${comma}${errorMarker}`);
if (itemIndex < arr.length - 1) {
lines.push(`${indent} ...`);
}
}
/**
* Renders all array items as truncated values.
*
* @param {Array} arr - The array to render.
* @param {string} indent - Current indentation string.
* @param {Array<string>} lines - Output lines array.
*/
#renderArrayAllTruncated(arr, indent, lines) {
for (let i = 0; i < arr.length; i++) {
const value = formatTruncatedValue(arr[i]);
const comma = i < arr.length - 1 ? "," : "";
lines.push(`${indent} ${value}${comma}`);
}
}
/**
* Checks if a path is the exact error location.
*
* @param {Array<string|number>} path - Path to check.
* @returns {boolean} True if this is the exact error location.
*/
#isExactErrorLocation(path) {
return (
path.length === this.#errorSegments.length &&
path.every((seg, j) => seg === this.#errorSegments[j])
);
}
/**
* Renders an object with path highlighting.
*
* @param {Object} obj - The object to render.
* @param {number} depth - Current indentation depth.
* @param {Array<string|number>} currentPath - Path segments to current location.
* @returns {string} Rendered string.
*/
#renderObject(obj, depth, currentPath) {
const indent = " ".repeat(depth);
const keys = Object.keys(obj).filter((k) => !k.startsWith("_"));
const isOnPath = this.#isOnErrorPath(currentPath);
const nextErrorSegment = this.#errorSegments[currentPath.length];
const needsSyntheticEntry =
isOnPath &&
typeof nextErrorSegment === "string" &&
!keys.includes(nextErrorSegment);
if (keys.length === 0 && !needsSyntheticEntry) {
return "{}";
}
const lines = ["{"];
this.#renderObjectKeys(
obj,
keys,
depth,
currentPath,
isOnPath,
indent,
lines
);
if (needsSyntheticEntry) {
this.#renderSyntheticEntry(currentPath, indent, lines);
}
lines.push(`${indent}}`);
return lines.join("\n");
}
/**
* Renders all keys of an object.
*
* @param {Object} obj - The object being rendered.
* @param {Array<string>} keys - Keys to render.
* @param {number} depth - Current indentation depth.
* @param {Array<string|number>} currentPath - Current path segments.
* @param {boolean} isOnPath - Whether current location is on error path.
* @param {string} indent - Current indentation string.
* @param {Array<string>} lines - Output lines array.
*/
#renderObjectKeys(obj, keys, depth, currentPath, isOnPath, indent, lines) {
for (const key of keys) {
const keyPath = [...currentPath, key];
const isKeyOnPath = this.#isOnErrorPath(keyPath);
const isKeyTheError =
isOnPath &&
currentPath.length === this.#errorSegments.length - 1 &&
key === this.#errorKey;
const value =
isKeyOnPath || isKeyTheError
? this.#renderValue(obj[key], depth + 1, keyPath)
: formatTruncatedValue(obj[key]);
const errorMarker = isKeyTheError ? " // <-- error here" : "";
this.#appendKeyValueLine(key, value, errorMarker, indent, lines);
}
}
/**
* Appends a key-value line to the output, handling multiline values.
*
* @param {string} key - The object key.
* @param {string} value - The rendered value.
* @param {string} errorMarker - Error marker string (or empty).
* @param {string} indent - Current indentation string.
* @param {Array<string>} lines - Output lines array.
*/
#appendKeyValueLine(key, value, errorMarker, indent, lines) {
if (value.includes("\n")) {
const [firstLine, ...restLines] = value.split("\n");
lines.push(`${indent} ${key}: ${firstLine}${errorMarker}`);
lines.push(`${restLines.join("\n")},`);
} else {
lines.push(`${indent} ${key}: ${value},${errorMarker}`);
}
}
/**
* Renders a synthetic entry for missing keys on the error path.
*
* @param {Array<string|number>} currentPath - Current path segments.
* @param {string} indent - Current indentation string.
* @param {Array<string>} lines - Output lines array.
*/
#renderSyntheticEntry(currentPath, indent, lines) {
const remainingPath = this.#errorSegments.slice(currentPath.length);
const nextSegment = remainingPath[0];
const isAtFinalKey = remainingPath.length === 1;
if (isAtFinalKey) {
lines.push(`${indent} ${nextSegment}: <missing>, // <-- error here`);
} else {
lines.push(`${indent} ${nextSegment}: { // <-- missing`);
lines.push(
...this.#renderMissingPath(
remainingPath.slice(1),
currentPath.length + 1
)
);
lines.push(`${indent} },`);
}
}
/**
* Renders the remaining path segments for a missing key.
*
* @param {Array<string|number>} segments - Remaining path segments.
* @param {number} depth - Current indentation depth.
* @returns {Array<string>} Array of formatted lines.
*/
#renderMissingPath(segments, depth) {
const indent = " ".repeat(depth);
const lines = [];
if (segments.length === 0) {
return lines;
}
const [seg, ...rest] = segments;
if (rest.length === 0) {
lines.push(`${indent} ${seg}: <missing>, // <-- error here`);
} else {
lines.push(`${indent} ${seg}: { // <-- missing`);
lines.push(...this.#renderMissingPath(rest, depth + 1));
lines.push(`${indent} },`);
}
return lines;
}
}
/**
* Renders a block entry object with the error path highlighted.
* Shows the structure with proper indentation and adds a comment marker
* to indicate where the error occurred.
*
* @param {Object|Array} entry - The block entry object to render.
* @param {string} errorPath - The path to the error (e.g., "conditions.any[0][0].queryParams").
* @param {Object} [options] - Formatting options.
* @param {string} [options.prefix] - Optional prefix to skip in the path (e.g., "conditions").
* @param {string} [options.label] - Label to show before the entry (e.g., "conditions:").
* @returns {string} Formatted string with the error location highlighted.
*/
export function formatEntryWithErrorPath(entry, errorPath, options = {}) {
return new PathHighlightRenderer(errorPath, options).render(entry);
}
/**
* Truncates an object for JSON serialization in error messages.
* Returns actual objects/arrays with truncated content, not strings.
*
* Note: This returns OBJECTS for JSON.stringify(). For string representations
* in console output, use formatTruncatedValue() instead.
*
* Handles special cases like block classes, children arrays, and circular references.
*
* @param {*} obj - The object to truncate.
* @param {number} [maxDepth=2] - Maximum nesting depth before truncating.
* @param {number} [maxKeys=5] - Maximum number of keys to show per object.
* @param {WeakSet} [_seen=null] - Internal parameter for tracking circular references.
* @returns {*} Truncated representation of the object.
*/
export function truncateForDisplay(
obj,
maxDepth = 2,
maxKeys = 5,
_seen = null
) {
if (obj === null || typeof obj !== "object") {
return obj;
}
// Initialize seen set on first call to track circular references
const seen = _seen ?? new WeakSet();
// Handle circular references
if (seen.has(obj)) {
return "[Circular]";
}
seen.add(obj);
if (maxDepth <= 0) {
return Array.isArray(obj) ? "[...]" : "{...}";
}
if (Array.isArray(obj)) {
if (obj.length > maxKeys) {
return [
...obj
.slice(0, maxKeys)
.map((v) => truncateForDisplay(v, maxDepth - 1, maxKeys, seen)),
"...",
];
}
return obj.map((v) => truncateForDisplay(v, maxDepth - 1, maxKeys, seen));
}
// Filter out private keys (starting with _)
const keys = Object.keys(obj).filter((k) => !k.startsWith("_"));
const result = {};
const displayKeys = keys.slice(0, maxKeys);
for (const key of displayKeys) {
// Handle special keys that don't serialize well or are verbose
if (key === "block") {
result[key] = `<${getBlockMetadata(obj[key])?.blockName || "Component"}>`;
} else if (key === "children") {
result[key] = `[${obj[key]?.length || 0} children]`;
} else {
result[key] = truncateForDisplay(obj[key], maxDepth - 1, maxKeys, seen);
}
}
if (keys.length > maxKeys) {
result["..."] = `(${keys.length - maxKeys} more)`;
}
return result;
}
/* Error Handling */
/**
* Captures the current call site as an Error object, excluding internal frames.
* Call this at the entry point (e.g., renderBlocks) to capture where
* the user's code called into the block system.
*
* Uses `Error.captureStackTrace` (V8-specific) to exclude the calling function
* and everything above it from the stack trace. This means the stack will
* point directly to the user's code, not to internal block system functions.
*
* @param {Function} callerFn - The function to exclude from the stack trace.
* Pass the function that calls `captureCallSite` (e.g., `_renderBlocks`).
* @returns {Error} An Error object with stack trace starting from callerFn's caller.
*/
export function captureCallSite(callerFn) {
const error = new Error();
// V8-specific: exclude callerFn and everything above from the stack trace.
// In non-V8 browsers, this is a no-op and the full stack is preserved.
// @ts-ignore - V8-specific API
if (Error.captureStackTrace) {
// @ts-ignore - V8-specific API
Error.captureStackTrace(error, callerFn);
}
return error;
}
/**
* Builds a tree-style breadcrumb showing the path from root to error.
* Uses Unicode box-drawing characters for visual hierarchy.
*
* @param {Array|Object} rootLayout - The root layout (usually an array of block entries).
* @param {string} errorPath - The path to the error (e.g., "[4].children[2].args.nme").
* @returns {string} Tree-style breadcrumb string.
*
* @example
* // Returns:
* // └─ [4] BlockGroup (name: "callouts")
* // └─ [2] ChildBlockName
* // └─ args.nme ← error here
*/
function buildBreadcrumb(rootLayout, errorPath) {
const segments = parseConditionPath(errorPath);
const lines = [];
let current = rootLayout;
let indent = "";
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
if (typeof seg === "number" && Array.isArray(current)) {
// Array index - show block info
const block = current[seg];
const blockClass = block?.block;
const blockName =
getBlockMetadata(blockClass)?.blockName || blockClass?.name || "Block";
const nameArg = block?.args?.name ? ` (name: "${block.args.name}")` : "";
lines.push(`${indent}└─ [${seg}] ${blockName}${nameArg}`);
current = block;
indent += " ";
} else if (
typeof seg === "string" &&
current &&
typeof current === "object"
) {
// Object key - check if this is a terminal segment or intermediate
const isLastSegment = i === segments.length - 1;
const nextSeg = segments[i + 1];
const value = current[seg];
if (seg === "children" && typeof nextSeg === "number") {
// "children" followed by index - continue traversal
current = value;
} else if (isLastSegment) {
// Final segment - this is the error location
lines.push(`${indent}└─ ${seg} ← error here`);
} else if (seg === "args" || seg === "conditions") {
// Show remaining path as the error location
const remaining = segments.slice(i).join(".");
lines.push(`${indent}└─ ${remaining} ← error here`);
break;
} else {
current = value;
}
}
}
return lines.join("\n");
}
/**
* Formats the error context into a human-readable string for console output.
*
* When an `errorPath` is provided, uses the path-aware formatter to show
* the error location within the entry with a comment marker.
*
* @param {Object} context - The error context.
* @returns {string} Formatted context string, or empty string if no context.
*/
function formatErrorContext(context) {
if (!context) {
return "";
}
const parts = [];
// Use errorPath if available, otherwise fall back to path
// Many validation calls use "path" for block location in the tree
const effectivePath = context.errorPath || context.path;
// Display the error path location - use tree-style breadcrumb when rootLayout is available
if (effectivePath) {
if (context.rootLayout) {
try {
const breadcrumb = buildBreadcrumb(context.rootLayout, effectivePath);
parts.push(`Location:\n${breadcrumb}`);
} catch {
// Fallback to plain path if breadcrumb fails
parts.push(`Location: ${effectivePath}`);
}
} else {
parts.push(`Location: ${effectivePath}`);
}
}
// Priority: rootLayout tree > conditions > individual entry
// Always prefer showing the full tree when rootLayout is available
if (context.rootLayout && effectivePath) {
// Root layout available - show full nesting path from root to error
try {
const layoutStr = formatEntryWithErrorPath(
context.rootLayout,
effectivePath
);
parts.push(`\nContext:\n${layoutStr}`);
} catch {
parts.push("Context: [unable to format]");
}
} else if (context.conditions && context.conditionsPath) {
// Fallback: conditions with path-aware formatter
// conditionsPath is relative to conditions object (e.g., "params.categoryId")
try {
const conditionsStr = formatEntryWithErrorPath(
context.conditions,
context.conditionsPath,
{ label: "conditions:" }
);
parts.push(`\nContext:\n${conditionsStr}`);
} catch {
parts.push("Context: [unable to format]");
}
} else if (context.entry && effectivePath) {
// Fallback: individual block entry - strip path prefix for relative path
try {
let relativePath = effectivePath;
if (context.path && effectivePath.startsWith(context.path)) {
relativePath = effectivePath.slice(context.path.length);
if (relativePath.startsWith(".")) {
relativePath = relativePath.slice(1);
}
}
const entryStr = formatEntryWithErrorPath(context.entry, relativePath);
parts.push(`\nContext:\n${entryStr}`);
} catch {
parts.push("Context: [unable to format]");
}
} else if (context.conditions) {
// Fallback to simple display without path highlighting
try {
const conditionsStr = JSON.stringify(
truncateForDisplay(context.conditions),
null,
2
);
parts.push(`Conditions:\n${conditionsStr}`);
} catch {
parts.push("Conditions: [unable to serialize]");
}
} else if (context.entry) {
try {
const entryStr = JSON.stringify(
truncateForDisplay(context.entry),
null,
2
);
parts.push(`Block entry:\n${entryStr}`);
} catch {
parts.push("Block entry: [unable to serialize]");
}
}
return parts.length > 0 ? `\n\n${parts.join("\n")}` : "";
}
/**
* Error thrown when block validation fails.
* Used by block entry and condition validation to report
* errors at registration time.
*
* Supports the standard `cause` option from ES2022 to chain errors together.
* Note: `raiseBlockError()` uses a different approach - it reuses the
* `callSiteError` directly (mutating its message and name) rather than
* passing it as `cause`. This preserves the original stack trace pointing
* to where `renderBlocks()` was called.
*
* @class BlockError
* @extends Error
*/
export class BlockError extends Error {
/**
* Creates a new BlockError.
*
* @param {string} message - The error message.
* @param {Object} [options] - Error options.
* @param {Error} [options.cause] - The underlying cause of this error.
* @param {string} [options.path] - Path to the error within the layout
* (e.g., "[0].conditions.any[0].type" or "[0].args.showIcon").
*/
constructor(message, options) {
super(message, options);
this.name = "BlockError";
this.path = options?.path;
}
}
/**
* Raises a block error by throwing a `BlockError`.
*
* If context is provided, the error message will include the block
* entry for debugging.
*
* If a `callSiteError` is present in the context, it is reused with the
* new message. This preserves the original stack trace pointing to where
* `renderBlocks()` was called, which is more useful than pointing to this
* function. Source maps are applied automatically by the browser.
*
* @param {string} message - The error message.
* @param {Object} [context] - Optional error context for better error messages.
* @param {string} [context.outletName] - The outlet name where the block is registered.
* @param {string} [context.blockName] - The name of the block being validated.
* @param {string} [context.path] - Path within the layout to the error (e.g., "[2].conditions.params.categoryId").
* Used by validation code to indicate where errors occurred. Combined with `errorPath` for display.
* @param {Object} [context.entry] - The block entry being validated.
* @param {Object} [context.conditions] - The conditions being validated.
* @param {string} [context.errorPath] - Full path to the error for display (e.g., "[2].conditions.params.categoryId").
* @param {Array<Object>} [context.rootLayout] - The root outlet layout for tree display in errors.
* @param {Error | null} [context.callSiteError] - Error object capturing where renderBlocks() was called.
* @throws {BlockError} Always throws.
*/
export function raiseBlockError(message, context = null) {
// Warn in DEBUG mode when entry-related errors are missing rootLayout
// This helps catch future validation code that forgets to pass rootLayout
if (DEBUG) {
const hasPath = context?.path || context?.errorPath;
const isEntryError =
context?.entry || context?.conditions || context?.outletName;
if (hasPath && isEntryError && !context?.rootLayout) {
// eslint-disable-next-line no-console
console.warn(
`[Blocks] raiseBlockError called with path but no rootLayout. ` +
`Add rootLayout to context for better error display. ` +
`Path: ${context?.path || context?.errorPath}`
);
}
}
const contextInfo = formatErrorContext(context);
const fullMessage = `[Blocks] ${message}${contextInfo}`;
let error;
// If we have a call site error, reuse it with updated message.
// This preserves the stack trace pointing to where renderBlocks() was called,
// which is more useful than pointing to raiseBlockError().
if (context?.callSiteError) {
error = context.callSiteError;
error.name = "BlockError";
error.message = fullMessage;
// @ts-ignore - Adding path property to Error for BlockError compatibility
error.path = context.path;
} else {
error = new BlockError(fullMessage, { path: context?.path });
}
throw error;
}
@@ -0,0 +1,314 @@
// @ts-check
import {
DEBUG_CALLBACK,
debugHooks,
} from "discourse/lib/blocks/-internals/debug-hooks";
/**
* Evaluates condition specs at render time.
* Recursively evaluates nested conditions with AND/OR/NOT logic.
*
* @param {Object|Array<Object>} conditionSpec - Condition spec(s) to evaluate.
* @param {Map<string, import("discourse/blocks/conditions").BlockCondition>} conditionTypes - Map of registered condition types.
* @param {Object} [context] - Evaluation context.
* @param {boolean} [context.debug] - Enable debug logging for this evaluation.
* @param {number} [context._depth] - Internal: nesting depth for logging.
* @param {Object} [context.outletArgs] - Outlet arguments passed to conditions.
* @returns {boolean} True if conditions pass, false otherwise.
*/
export function evaluateConditions(
conditionSpec,
conditionTypes,
context = {}
) {
const isLoggingEnabled = context.debug ?? false;
const depth = context._depth ?? 0;
// Get logging callbacks (null if dev tools not loaded or logging disabled)
const conditionLog = isLoggingEnabled
? debugHooks.getCallback(DEBUG_CALLBACK.CONDITION_LOG)
: null;
const combinatorLog = isLoggingEnabled
? debugHooks.getCallback(DEBUG_CALLBACK.COMBINATOR_LOG)
: null;
const conditionResultLog = isLoggingEnabled
? debugHooks.getCallback(DEBUG_CALLBACK.CONDITION_RESULT)
: null;
// Get logger interface for conditions (e.g., route condition needs to log params)
const logger = isLoggingEnabled ? debugHooks.loggerInterface : null;
if (!conditionSpec) {
return true;
}
// Array of conditions (AND logic - all must pass)
if (Array.isArray(conditionSpec)) {
return evaluateAndCombinator(
conditionSpec,
conditionTypes,
context,
isLoggingEnabled,
depth,
conditionLog,
combinatorLog
);
}
// OR combinator (at least one must pass)
if (conditionSpec.any !== undefined) {
return evaluateOrCombinator(
conditionSpec,
conditionTypes,
context,
isLoggingEnabled,
depth,
conditionLog,
combinatorLog
);
}
// NOT combinator (must fail)
if (conditionSpec.not !== undefined) {
return evaluateNotCombinator(
conditionSpec,
conditionTypes,
context,
isLoggingEnabled,
depth,
conditionLog,
combinatorLog
);
}
// Single condition with type
return evaluateSingleCondition(
conditionSpec,
conditionTypes,
context,
isLoggingEnabled,
depth,
conditionLog,
conditionResultLog,
logger
);
}
/**
* Evaluates an array of conditions with AND logic (all must pass).
*
* @param {Array<Object>} conditionSpec - Array of condition specs.
* @param {Map} conditionTypes - Map of registered condition types.
* @param {Object} context - Evaluation context.
* @param {boolean} isLoggingEnabled - Whether debug logging is enabled.
* @param {number} depth - Current nesting depth.
* @param {Function|null} conditionLog - Callback for logging conditions.
* @param {Function|null} combinatorLog - Callback for logging combinator results.
* @returns {boolean} True if all conditions pass.
*/
function evaluateAndCombinator(
conditionSpec,
conditionTypes,
context,
isLoggingEnabled,
depth,
conditionLog,
combinatorLog
) {
// Empty array is vacuous truth - no conditions to fail
if (conditionSpec.length === 0) {
return true;
}
// Log combinator BEFORE children (result=null as placeholder)
conditionLog?.({
type: "AND",
args: `${conditionSpec.length} conditions`,
result: null,
depth,
conditionSpec,
});
let andResult = true;
for (const condition of conditionSpec) {
const result = evaluateConditions(condition, conditionTypes, {
debug: isLoggingEnabled,
_depth: depth + 1,
outletArgs: context.outletArgs,
});
if (!result) {
andResult = false;
// Short-circuit only when not debugging - evaluate all for debug visibility
if (!isLoggingEnabled) {
break;
}
}
}
// Update combinator with actual result
combinatorLog?.({ conditionSpec, result: andResult });
return andResult;
}
/**
* Evaluates an OR combinator (at least one must pass).
*
* @param {Object} conditionSpec - Condition spec containing "any" array.
* @param {Map} conditionTypes - Map of registered condition types.
* @param {Object} context - Evaluation context.
* @param {boolean} isLoggingEnabled - Whether debug logging is enabled.
* @param {number} depth - Current nesting depth.
* @param {Function|null} conditionLog - Callback for logging conditions.
* @param {Function|null} combinatorLog - Callback for logging combinator results.
* @returns {boolean} True if at least one condition passes.
*/
function evaluateOrCombinator(
conditionSpec,
conditionTypes,
context,
isLoggingEnabled,
depth,
conditionLog,
combinatorLog
) {
// Empty OR array means no conditions can pass
if (conditionSpec.any.length === 0) {
return false;
}
// Log combinator BEFORE children (result=null as placeholder)
conditionLog?.({
type: "OR",
args: `${conditionSpec.any.length} conditions`,
result: null,
depth,
conditionSpec,
});
let orResult = false;
for (const condition of conditionSpec.any) {
const result = evaluateConditions(condition, conditionTypes, {
debug: isLoggingEnabled,
_depth: depth + 1,
outletArgs: context.outletArgs,
});
if (result) {
orResult = true;
// Short-circuit only when not debugging - evaluate all for debug visibility
if (!isLoggingEnabled) {
break;
}
}
}
// Update combinator with actual result
combinatorLog?.({ conditionSpec, result: orResult });
return orResult;
}
/**
* Evaluates a NOT combinator (inner condition must fail).
*
* @param {Object} conditionSpec - Condition spec containing "not" condition.
* @param {Map} conditionTypes - Map of registered condition types.
* @param {Object} context - Evaluation context.
* @param {boolean} isLoggingEnabled - Whether debug logging is enabled.
* @param {number} depth - Current nesting depth.
* @param {Function|null} conditionLog - Callback for logging conditions.
* @param {Function|null} combinatorLog - Callback for logging combinator results.
* @returns {boolean} True if inner condition fails.
*/
function evaluateNotCombinator(
conditionSpec,
conditionTypes,
context,
isLoggingEnabled,
depth,
conditionLog,
combinatorLog
) {
// Log combinator BEFORE children (result=null as placeholder)
conditionLog?.({
type: "NOT",
args: null,
result: null,
depth,
conditionSpec,
});
const innerResult = evaluateConditions(conditionSpec.not, conditionTypes, {
debug: isLoggingEnabled,
_depth: depth + 1,
outletArgs: context.outletArgs,
});
const notResult = !innerResult;
// Update combinator with actual result
combinatorLog?.({ conditionSpec, result: notResult });
return notResult;
}
/**
* Evaluates a single condition with a type property.
*
* @param {Object} conditionSpec - Single condition spec with type.
* @param {Map} conditionTypes - Map of registered condition types.
* @param {Object} context - Evaluation context.
* @param {boolean} isLoggingEnabled - Whether debug logging is enabled.
* @param {number} depth - Current nesting depth.
* @param {Function|null} conditionLog - Callback for logging conditions.
* @param {Function|null} conditionResultLog - Callback for logging condition results.
* @param {Object|null} logger - Logger interface for conditions.
* @returns {boolean} True if condition passes.
*/
function evaluateSingleCondition(
conditionSpec,
conditionTypes,
context,
isLoggingEnabled,
depth,
conditionLog,
conditionResultLog,
logger
) {
const { type, ...args } = conditionSpec;
const conditionInstance = conditionTypes.get(type);
if (!conditionInstance) {
conditionLog?.({
type: `unknown "${type}"`,
args,
result: false,
depth,
});
return false;
}
// Resolve value for logging (handles source, path, and other condition-specific values)
let resolvedValue;
if (isLoggingEnabled) {
resolvedValue = conditionInstance.getResolvedValueForLogging(args, context);
}
// Log condition BEFORE evaluate so nested logs appear underneath
conditionLog?.({
type,
args,
result: null,
depth,
resolvedValue,
conditionSpec,
});
// Pass context to evaluate so conditions can access outletArgs and log nested items
const evalContext = {
debug: isLoggingEnabled,
_depth: depth,
outletArgs: context.outletArgs,
logger,
};
const result = conditionInstance.evaluate(args, evalContext);
// Update the condition's result after evaluate
conditionResultLog?.({ conditionSpec, result });
return result;
}
@@ -0,0 +1,340 @@
// @ts-check
import { DEBUG } from "@glimmer/env";
import picomatch from "picomatch";
import { raiseBlockError } from "discourse/lib/blocks/-internals/error";
import { getAllOutlets } from "discourse/lib/blocks/-internals/registry/outlet";
import { isValidGlobPattern } from "discourse/lib/glob-utils";
/**
* Checks if a pattern targets a namespaced outlet.
*
* Namespaced outlets use a colon separator to identify outlets defined by
* plugins or themes (e.g., `plugin-name:outlet-name`, `theme-name:outlet-name`).
* These patterns bypass known-outlet validation since they reference outlets
* that may not be in the core `BLOCK_OUTLETS` registry.
*
* @param {string} pattern - The pattern to check.
* @returns {boolean} True if the pattern contains a namespace separator.
*
* @example
* isNamespacedPattern("my-plugin:dashboard"); // true
* isNamespacedPattern("my-theme:hero-section"); // true
* isNamespacedPattern("sidebar-blocks"); // false
* isNamespacedPattern("sidebar-*"); // false
*/
export function isNamespacedPattern(pattern) {
return pattern.includes(":");
}
/**
* Matches an outlet name against a glob pattern using picomatch.
*
* Outlet names follow kebab-case (lowercase letters, numbers, hyphens).
* Supported glob syntax:
* - `*` matches any characters
* - `?` matches a single character
* - `[abc]` matches any character in the brackets
* - `{a,b}` matches any of the comma-separated patterns
* - `!(pattern)` negative match (matches anything except pattern)
*
* @param {string} outlet - The outlet name to test.
* @param {string} pattern - The glob pattern to match against.
* @returns {boolean} True if the outlet matches the pattern.
*
* @example
* // Exact match
* matchOutletPattern("sidebar-blocks", "sidebar-blocks"); // true
*
* @example
* // Wildcard matching
* matchOutletPattern("sidebar-left", "sidebar-*"); // true
* matchOutletPattern("sidebar-left-top", "sidebar-*"); // true
*
* @example
* // Brace expansion
* matchOutletPattern("sidebar-blocks", "{sidebar,footer}-*"); // true
*
* @example
* // Character class
* matchOutletPattern("modal-1", "modal-[0-9]"); // true
*
* @example
* // Negation
* matchOutletPattern("sidebar-blocks", "!(*-debug)"); // true
*/
export function matchOutletPattern(outlet, pattern) {
// Use dot: true to match dots in outlet names (e.g., namespaced outlets)
const isMatch = picomatch(pattern, { dot: true });
return isMatch(outlet);
}
/**
* Validates that outlet patterns are a valid array of strings with valid picomatch syntax.
*
* This function is called at decoration time to catch configuration errors early.
* It validates:
* 1. The patterns parameter is an array (or null/undefined for no restrictions)
* 2. Each pattern in the array is a string
* 3. Each pattern can be compiled by picomatch
*
* @param {*} patterns - The patterns to validate.
* @param {string} blockName - Block name for error messages.
* @param {string} propertyName - Property name ("allowedOutlets" or "deniedOutlets").
*
* @example
* validateOutletPatterns(["sidebar-*", "homepage-blocks"], "my-block", "allowedOutlets");
* validateOutletPatterns(null, "my-block", "allowedOutlets"); // null means no restrictions
*/
export function validateOutletPatterns(patterns, blockName, propertyName) {
// null/undefined means "no restrictions" - this is valid
if (patterns == null) {
return;
}
// Must be an array
if (!Array.isArray(patterns)) {
raiseBlockError(
`Block "${blockName}": ${propertyName} must be an array of strings, got ${typeof patterns}.`
);
return;
}
// Validate each pattern in the array
for (let i = 0; i < patterns.length; i++) {
const pattern = patterns[i];
// Each pattern must be a string
if (typeof pattern !== "string") {
raiseBlockError(
`Block "${blockName}": ${propertyName}[${i}] must be a string, got ${typeof pattern}.`
);
continue;
}
// Each pattern must be valid picomatch syntax
if (!isValidGlobPattern(pattern)) {
raiseBlockError(
`Block "${blockName}": ${propertyName}[${i}] "${pattern}" is not valid glob syntax.`
);
}
}
}
/**
* Detects if allowed and denied patterns could match the same outlet name.
*
* This function uses two strategies to detect conflicts:
*
* 1. **Known outlets check**: Tests each registered outlet (core and custom)
* against both pattern lists. This catches conflicts for outlets that
* actually exist.
*
* 2. **Synthetic test strings**: Generates test strings by replacing wildcards
* in patterns with concrete characters. This catches conflicts for outlets
* that don't exist yet (e.g., plugin-defined outlets).
*
* @param {string[]|null} allowedPatterns - Patterns for allowed outlets.
* @param {string[]|null} deniedPatterns - Patterns for denied outlets.
* @returns {{ conflict: boolean, details?: { outlet: string, allowed: string, denied: string } }}
* Returns conflict: true with details if a conflict is detected.
*
* @example
* // No conflict
* detectPatternConflicts(["sidebar-*"], ["homepage-*"]);
* // { conflict: false }
*
* @example
* // Conflict detected
* detectPatternConflicts(["*-blocks"], ["sidebar-*"]);
* // { conflict: true, details: { outlet: "sidebar-blocks", allowed: "*-blocks", denied: "sidebar-*" } }
*/
export function detectPatternConflicts(allowedPatterns, deniedPatterns) {
// Early exit: No conflict possible if either list is empty/null
if (!allowedPatterns?.length || !deniedPatterns?.length) {
return { conflict: false };
}
// Strategy 1: Check against all registered outlets (core and custom)
// This is fast and catches conflicts for outlets that actually exist.
const allOutlets = getAllOutlets();
for (const outlet of allOutlets) {
const allowedMatch = allowedPatterns.find((p) =>
matchOutletPattern(outlet, p)
);
const deniedMatch = deniedPatterns.find((p) =>
matchOutletPattern(outlet, p)
);
if (allowedMatch && deniedMatch) {
return {
conflict: true,
details: { outlet, allowed: allowedMatch, denied: deniedMatch },
};
}
}
// Strategy 2: Generate synthetic test strings from patterns
// This catches conflicts for outlets that don't exist yet (e.g., plugin outlets).
// We derive test strings by replacing glob wildcards with concrete characters.
const testStrings = new Set();
[...allowedPatterns, ...deniedPatterns].forEach((pattern) => {
// Replace all glob special chars with 'x' to get a literal string
// e.g., "sidebar-*" -> "sidebar-x"
const literal = pattern.replace(/[*?[\]{}!()]/g, "x");
testStrings.add(literal);
// Replace single wildcards with a test word
// e.g., "sidebar-*" -> "sidebar-test"
testStrings.add(pattern.replace(/\*/g, "test"));
// Replace double wildcards with a hyphenated string
// e.g., "admin-**" -> "admin-a-b-c"
testStrings.add(pattern.replace(/\*\*/g, "a-b-c"));
});
// Test each synthetic string against both pattern lists
for (const test of testStrings) {
const allowedMatch = allowedPatterns.find((p) =>
matchOutletPattern(test, p)
);
const deniedMatch = deniedPatterns.find((p) => matchOutletPattern(test, p));
if (allowedMatch && deniedMatch) {
return {
conflict: true,
details: { outlet: test, allowed: allowedMatch, denied: deniedMatch },
};
}
}
return { conflict: false };
}
/**
* Checks if a block is permitted to render in a specific outlet.
*
* The permission check follows these rules:
* 1. If `deniedPatterns` is specified and the outlet matches any pattern, deny.
* 2. If `allowedPatterns` is specified and the outlet doesn't match any pattern, deny.
* 3. Otherwise, permit.
*
* Denial takes precedence over allowance. An empty `allowedOutlets` array means
* "no outlets allowed" (strict whitelist).
*
* @param {string} outlet - The outlet name to check.
* @param {string[]|null} allowedPatterns - Patterns for allowed outlets.
* @param {string[]|null} deniedPatterns - Patterns for denied outlets.
* @returns {{ permitted: boolean, reason?: string }}
* Returns permitted: true if allowed, or permitted: false with a reason if denied.
*
* @example
* // No restrictions
* isBlockPermittedInOutlet("sidebar-blocks", null, null);
* // { permitted: true }
*
* @example
* // Allowed by pattern
* isBlockPermittedInOutlet("sidebar-blocks", ["sidebar-*"], null);
* // { permitted: true }
*
* @example
* // Denied by pattern
* isBlockPermittedInOutlet("sidebar-blocks", null, ["sidebar-*"]);
* // { permitted: false, reason: 'outlet "sidebar-blocks" matches deniedOutlets pattern "sidebar-*"' }
*
* @example
* // Not in allowed list
* isBlockPermittedInOutlet("homepage-blocks", ["sidebar-*"], null);
* // { permitted: false, reason: 'outlet "homepage-blocks" does not match any allowedOutlets pattern' }
*/
export function isBlockPermittedInOutlet(
outlet,
allowedPatterns,
deniedPatterns
) {
// Check denied list first (explicit deny always wins)
// This ensures that even if a pattern appears in both lists (shouldn't happen
// due to conflict detection), denial takes precedence for safety.
if (deniedPatterns?.length > 0) {
const deniedMatch = deniedPatterns.find((p) =>
matchOutletPattern(outlet, p)
);
if (deniedMatch) {
return {
permitted: false,
reason: `outlet "${outlet}" matches deniedOutlets pattern "${deniedMatch}"`,
};
}
}
// If allowed list is specified, outlet must match at least one pattern.
// An empty allowedOutlets array means "no outlets allowed" (strict whitelist).
if (allowedPatterns !== null && allowedPatterns !== undefined) {
// Empty array = strict whitelist with no allowed outlets
if (allowedPatterns.length === 0) {
return {
permitted: false,
reason: `outlet "${outlet}" does not match any allowedOutlets pattern (allowedOutlets is empty)`,
};
}
const allowedMatch = allowedPatterns.find((p) =>
matchOutletPattern(outlet, p)
);
if (!allowedMatch) {
return {
permitted: false,
reason: `outlet "${outlet}" does not match any allowedOutlets pattern`,
};
}
}
// No restrictions, or passed all checks
return { permitted: true };
}
/**
* Warns if patterns don't match any known outlet (helps catch typos).
*
* This function is called at decoration time to help developers catch
* configuration mistakes. It checks patterns against all registered outlets
* (both core and custom).
*
* Warnings are only printed in development builds (when DEBUG is true).
* This prevents noise in production.
*
* @param {string[]|null} patterns - The patterns to check.
* @param {string} blockName - Block name for warning messages.
* @param {string} propertyName - Property name ("allowedOutlets" or "deniedOutlets").
*
* @example
* // Warns about typo
* warnUnknownOutletPatterns(["sidbar-*"], "my-block", "allowedOutlets");
* // Console: [Blocks] Block "my-block": allowedOutlets pattern "sidbar-*" does not match any known outlet...
*/
export function warnUnknownOutletPatterns(patterns, blockName, propertyName) {
if (!patterns?.length) {
return;
}
// Do not show the warnings in production builds
if (!DEBUG) {
return;
}
const allOutlets = getAllOutlets();
for (const pattern of patterns) {
// Check if pattern matches at least one registered outlet
const matchesKnown = allOutlets.some((outlet) =>
matchOutletPattern(outlet, pattern)
);
if (!matchesKnown) {
// eslint-disable-next-line no-console
console.warn(
`[Blocks] Block "${blockName}": ${propertyName} pattern "${pattern}" ` +
`does not match any registered outlet. This may be a typo. ` +
`Registered outlets: ${allOutlets.join(", ")}`
);
}
}
}
@@ -0,0 +1,441 @@
// @ts-check
import { findClosestMatch } from "discourse/lib/string-similarity";
/**
* Page type definitions for the route condition.
*
* This is a pure data structure that defines the available page types
* and their parameters. The evaluation logic is in route.js.
*
* @typedef {Object} ParamDefinition
* @property {"string"|"number"} type - The expected type of the parameter.
* @property {string} description - A description of the parameter.
*
* @typedef {Object} PageDefinition
* @property {string} description - A description of the page type.
* @property {Object<string, ParamDefinition>} params - The parameters for this page type.
*/
/**
* Definitions for all supported page types.
*
* Each page type has:
* - `description`: A human-readable description of what pages this matches.
* - `params`: An object mapping parameter names to their definitions.
*
* @type {Object<string, PageDefinition>}
*/
export const PAGE_DEFINITIONS = {
/**
* Category listing pages (/c/slug, /c/parent/child).
* Matches when discovery.category is set.
*/
CATEGORY_PAGES: {
description: "Category listing pages",
params: {
categoryId: {
type: "number",
description: "Category ID",
},
categorySlug: {
type: "string",
description: "Category slug (URL-safe name)",
},
parentCategoryId: {
type: "number",
description: "Parent category ID (for subcategories)",
},
},
},
/**
* Tag listing pages (/tag/name, /tags/intersection/tag1/tag2).
* Matches when discovery.tag is set.
*/
TAG_PAGES: {
description: "Tag listing pages",
params: {
tagId: {
type: "string",
description: "Tag name/ID",
},
categoryId: {
type: "number",
description: "Category ID (when tag is filtered by category)",
},
categorySlug: {
type: "string",
description: "Category slug (when tag is filtered by category)",
},
parentCategoryId: {
type: "number",
description: "Parent category ID (when tag is filtered by subcategory)",
},
},
},
/**
* Discovery routes (latest, top, new, unread, hot, etc.).
* Excludes custom homepage.
*/
DISCOVERY_PAGES: {
description:
"Discovery routes (latest, top, new, etc.) excluding custom homepage",
params: {
filter: {
type: "string",
description: "The discovery filter type (e.g., 'latest', 'top', 'new')",
},
},
},
/**
* Custom homepage only (discovery.custom route).
*/
HOMEPAGE: {
description: "Custom homepage only",
params: {},
},
/**
* Top navigation discovery routes.
* Excludes category pages, tag pages, and custom homepage.
*/
TOP_MENU: {
description:
"Top navigation discovery routes (excludes category, tag, homepage)",
params: {
filter: {
type: "string",
description: "The filter type (e.g., 'latest', 'top', 'new')",
},
},
},
/**
* Individual topic pages (/t/slug/id).
*/
TOPIC_PAGES: {
description: "Individual topic pages",
params: {
id: {
type: "number",
description: "Topic ID",
},
slug: {
type: "string",
description: "Topic slug",
},
},
},
/**
* User profile pages (/u/username).
*/
USER_PAGES: {
description: "User profile pages",
params: {
username: {
type: "string",
description: "Username being viewed",
},
},
},
/**
* Admin section pages (/admin/**).
*/
ADMIN_PAGES: {
description: "Admin section pages",
params: {},
},
/**
* Group pages (/g/groupname).
*/
GROUP_PAGES: {
description: "Group pages",
params: {
name: {
type: "string",
description: "Group name",
},
},
},
};
/**
* Array of all valid page type names.
*
* @type {string[]}
*/
export const VALID_PAGE_TYPES = Object.keys(PAGE_DEFINITIONS);
/**
* Checks if a page type is valid.
*
* @param {string} pageType - The page type to check.
* @returns {boolean} True if the page type is valid.
*/
export function isValidPageType(pageType) {
return pageType in PAGE_DEFINITIONS;
}
/**
* Gets the parameter definitions for a page type.
*
* @param {string} pageType - The page type.
* @returns {Object<string, ParamDefinition>|null} The parameter definitions, or null if invalid.
*/
export function getParamsForPageType(pageType) {
const definition = PAGE_DEFINITIONS[pageType];
return definition ? definition.params : null;
}
/**
* Gets all valid parameter names for a page type.
*
* @param {string} pageType - The page type.
* @returns {string[]} Array of valid parameter names.
*/
export function getValidParamNames(pageType) {
const params = getParamsForPageType(pageType);
return params ? Object.keys(params) : [];
}
/**
* Suggests a page type for a potential typo using fuzzy matching.
*
* @param {string} typo - The potentially misspelled page type.
* @returns {string|null} The suggested page type, or null if no good match found.
*/
export function suggestPageType(typo) {
return findClosestMatch(typo, VALID_PAGE_TYPES);
}
/**
* Validates that all provided params are valid for ALL listed page types.
*
* @param {Object} params - The params object to validate.
* @param {string[]} pages - The array of page types.
* @returns {{valid: boolean, errors: string[]}} Validation result with any errors.
*/
export function validateParamsAgainstPages(params, pages) {
const errors = [];
if (!params || typeof params !== "object") {
return { valid: true, errors: [] };
}
const paramNames = Object.keys(params);
if (paramNames.length === 0) {
return { valid: true, errors: [] };
}
for (const paramName of paramNames) {
const validFor = [];
const invalidFor = [];
for (const pageType of pages) {
const pageParams = getParamsForPageType(pageType);
if (pageParams && paramName in pageParams) {
validFor.push(pageType);
} else {
invalidFor.push(pageType);
}
}
if (invalidFor.length > 0) {
if (validFor.length > 0) {
// Param is valid for some but not all page types
errors.push(
`Parameter '${paramName}' is not valid for all listed page types.\n` +
`'${paramName}' is valid for: ${validFor.join(", ")}\n` +
`'${paramName}' is NOT valid for: ${invalidFor.join(", ")}\n` +
`All params must be valid for ALL page types when multiple pages are listed.`
);
} else {
// Param is not valid for any page type
const validParams = pages
.flatMap((p) => getValidParamNames(p))
.filter((v, i, a) => a.indexOf(v) === i); // unique
errors.push(
`Parameter '${paramName}' is not valid for any of the listed page types.\n` +
`Valid parameters across listed pages: ${validParams.join(", ") || "(none)"}`
);
}
}
}
return { valid: errors.length === 0, errors };
}
/**
* Validates the type of a parameter value against its definition.
*
* @param {string} paramName - The parameter name.
* @param {*} value - The value to validate.
* @param {string} pageType - The page type (for error messages).
* @returns {{valid: boolean, error: string|null}} Validation result.
*/
export function validateParamType(paramName, value, pageType) {
const params = getParamsForPageType(pageType);
if (!params || !(paramName in params)) {
return { valid: false, error: `Unknown parameter '${paramName}'` };
}
const definition = params[paramName];
const actualType = typeof value;
if (definition.type === "number") {
if (actualType !== "number") {
return {
valid: false,
error:
`Parameter '${paramName}' must be a number, got ${actualType} '${value}'.\n` +
`Hint: Use numeric value: { params: { ${paramName}: ${parseInt(value, 10) || 123} } }`,
};
}
} else if (definition.type === "string") {
if (actualType !== "string") {
return {
valid: false,
error: `Parameter '${paramName}' must be a string, got ${actualType} '${value}'.`,
};
}
}
return { valid: true, error: null };
}
/**
* Gets the current context values for a page type.
*
* Returns an object with the current values for all parameters defined for
* this page type, or null if the page type doesn't match the current route.
*
* @param {string} pageType - The page type (e.g., "CATEGORY_PAGES").
* @param {Object} services - Injected services for context extraction.
* @param {Object} services.router - The Ember router service.
* @param {Object} services.discovery - The Discourse discovery service.
* @returns {Object|null} The context object with param values, or null if page type doesn't match.
*
* @example
* const context = getPageContext("CATEGORY_PAGES", { router, discovery });
* // Returns { categoryId: 5, categorySlug: "general", parentCategoryId: null }
* // or null if not on a category page
*/
export function getPageContext(pageType, { router, discovery }) {
switch (pageType) {
case "CATEGORY_PAGES": {
const category = discovery.category;
if (!category) {
return null;
}
return {
categoryId: category.id,
categorySlug: category.slug,
parentCategoryId: category.parent_category_id,
};
}
case "TAG_PAGES": {
const tag = discovery.tag;
if (!tag) {
return null;
}
const category = discovery.category;
return {
tagId: tag.name,
categoryId: category?.id,
categorySlug: category?.slug,
parentCategoryId: category?.parent_category_id,
};
}
case "DISCOVERY_PAGES": {
if (!discovery.onDiscoveryRoute || discovery.custom) {
return null;
}
const filter = router.currentRouteName
?.replace(/^discovery\./, "")
.split(".")[0];
return { filter };
}
case "HOMEPAGE":
return discovery.custom ? {} : null;
case "TOP_MENU": {
if (
!discovery.onDiscoveryRoute ||
discovery.category ||
discovery.tag ||
discovery.custom
) {
return null;
}
const filter = router.currentRouteName
?.replace(/^discovery\./, "")
.split(".")[0];
return { filter };
}
case "TOPIC_PAGES": {
if (!router.currentRouteName?.startsWith("topic.")) {
return null;
}
const routeParams = router.currentRoute?.params || {};
return {
id: routeParams.id ? parseInt(routeParams.id, 10) : undefined,
slug: routeParams.slug,
};
}
case "USER_PAGES": {
if (!router.currentRouteName?.startsWith("user.")) {
return null;
}
const routeParams = router.currentRoute?.params || {};
return { username: routeParams.username };
}
case "ADMIN_PAGES":
return router.currentRouteName?.startsWith("admin") ? {} : null;
case "GROUP_PAGES": {
if (!router.currentRouteName?.startsWith("group.")) {
return null;
}
const routeParams = router.currentRoute?.params || {};
return { name: routeParams.name };
}
default:
return null;
}
}
/**
* Determines the current page type by checking all known page types.
*
* Iterates through all valid page types and returns the first one that matches
* the current route. Useful for debugging to show what page the user is on.
*
* @param {Object} services - Injected services for context extraction.
* @param {Object} services.router - The Ember router service.
* @param {Object} services.discovery - The Discourse discovery service.
* @returns {string|null} The current page type, or null if no match.
*
* @example
* const pageType = getCurrentPageType({ router, discovery });
* // Returns "CATEGORY_PAGES", "TOPIC_PAGES", etc., or null
*/
export function getCurrentPageType({ router, discovery }) {
for (const pageType of VALID_PAGE_TYPES) {
if (getPageContext(pageType, { router, discovery }) !== null) {
return pageType;
}
}
return null;
}
@@ -0,0 +1,103 @@
// @ts-check
import picomatch from "picomatch";
import { withoutPrefix } from "discourse/lib/get-url";
/**
* Normalizes a URL path for matching.
*
* This function prepares a URL for glob pattern matching by:
* 1. Stripping the Discourse subfolder prefix (if Discourse runs on `/forum`, etc.)
* 2. Removing query strings and hash fragments
* 3. Removing trailing slashes (except for root `/`)
*
* This ensures theme authors don't need to know about subfolder configurations
* and can write patterns like `/c/**` that work universally.
*
* @param {string} url - The URL to normalize (typically `router.currentURL`).
* @returns {string} The normalized path, ready for pattern matching.
*
* @example
* // Subfolder stripping
* normalizePath("/forum/c/general"); // "/c/general"
*
* // Query string removal
* normalizePath("/c/general?foo=bar"); // "/c/general"
*
* // Hash fragment removal
* normalizePath("/c/general#section"); // "/c/general"
*
* // Trailing slash removal
* normalizePath("/c/general/"); // "/c/general"
*
* // Root path preserved
* normalizePath("/"); // "/"
*
* // Empty/null handling
* normalizePath(""); // "/"
* normalizePath(null); // "/"
*/
export function normalizePath(url) {
if (!url) {
return "/";
}
// Strip subfolder prefix first (e.g., /forum -> "")
let path = withoutPrefix(url);
// Strip query string and hash fragment
path = path.split("?")[0].split("#")[0];
// Remove trailing slash (except for root)
if (path.length > 1 && path.endsWith("/")) {
path = path.slice(0, -1);
}
return path || "/";
}
/**
* Matches a URL path against a glob pattern using picomatch.
*
* Supports full picomatch glob syntax:
* - `*` matches a single path segment (no slashes)
* - `**` matches zero or more path segments
* - `?` matches a single character
* - `[abc]` matches any character in the brackets
* - `{a,b}` matches any of the comma-separated patterns
*
* @param {string} path - The normalized URL path to test.
* @param {string} pattern - The glob pattern to match against.
* @returns {boolean} True if the path matches the pattern.
*
* @example
* // Single wildcard
* matchUrlPattern("/c/general", "/c/*"); // true
* matchUrlPattern("/c/general/sub", "/c/*"); // false
*
* @example
* // Double wildcard
* matchUrlPattern("/c/general/sub", "/c/**"); // true
*
* @example
* // Brace expansion
* matchUrlPattern("/latest", "/{latest,top}"); // true
*/
export function matchUrlPattern(path, pattern) {
const isMatch = picomatch(pattern, { dot: true });
return isMatch(path);
}
/**
* Checks if any URL pattern in the array matches the given path.
*
* @param {string} path - The normalized URL path to test.
* @param {string[]} patterns - Array of URL patterns.
* @returns {boolean} True if any pattern matches the path.
*
* @example
* matchesAnyPattern("/c/general", ["/c/**", "/t/**"]); // true (matches /c/**)
* matchesAnyPattern("/latest", ["/c/**", "/t/**"]); // false
*/
export function matchesAnyPattern(path, patterns) {
return patterns.some((pattern) => matchUrlPattern(path, pattern));
}
@@ -0,0 +1,323 @@
// @ts-check
import { findClosestMatch } from "discourse/lib/string-similarity";
/**
* Evaluates a value matcher spec against an actual value.
* Supports the same AND/OR/NOT logic as condition evaluation.
*
* Supports:
* - Exact match: `123`, `"foo"`
* - Array of simple values (OR): `[123, 456]` matches if actual is any of these
* - Array of complex specs (AND): `[{ not: "a" }, { not: "b" }]` all specs must match
* - RegExp: `/^foo/` matches if actual matches the pattern
* - NOT: `{ not: value }` matches if actual does NOT match value
* - ANY (OR): `{ any: [...] }` matches if actual matches any spec in array
*
* @param {Object} options - Options object.
* @param {*} options.actual - The actual value to test.
* @param {*} options.expected - The expected value spec (exact, array, regex, or AND/OR/NOT spec).
* @returns {boolean} True if the actual value matches the expected spec.
*/
export function matchValue({ actual, expected }) {
// Handle arrays first (before checking for `any`/`not` properties)
// because Ember prototype extensions add `any()` method to arrays
if (Array.isArray(expected)) {
if (expected.length > 0 && !isSimpleValueArray(expected)) {
// AND logic: Array of non-primitive conditions, all must pass
return expected.every((exp) => matchValue({ actual, expected: exp }));
}
// Simple array of values (OR - match any)
return matchSimpleValue(actual, expected);
}
// OR logic: { any: [...] }
if (expected?.any !== undefined) {
return expected.any.some((exp) => matchValue({ actual, expected: exp }));
}
// NOT logic: { not: ... }
if (expected?.not !== undefined) {
return !matchValue({ actual, expected: expected.not });
}
// Simple value matching (leaf node)
return matchSimpleValue(actual, expected);
}
/**
* Checks if an array is a simple value array (for OR matching) vs a condition
* array (for AND matching). Simple value arrays contain only primitives (strings,
* numbers, booleans, null, undefined) or RegExp objects.
*
* @param {Array} arr - The array to check.
* @returns {boolean} True if all items are primitives or RegExp.
*/
function isSimpleValueArray(arr) {
return arr.every(
(item) =>
typeof item !== "object" || item === null || item instanceof RegExp
);
}
/**
* Matches a simple value (exact, array of primitives, or regex).
*
* Note: When `expected` is a RegExp, `actual` is converted to a string before
* testing. This means numeric values like `123` will match patterns like `/1/`
* (because 123 is converted to "123").
*
* @param {*} actual - The actual value.
* @param {*} expected - The expected value (primitive, array of primitives/RegExp, or RegExp).
* @returns {boolean} True if matches.
*/
function matchSimpleValue(actual, expected) {
// RegExp pattern - coerce actual to string for testing
if (expected instanceof RegExp) {
return expected.test(String(actual));
}
// Array of values (OR - match any)
if (Array.isArray(expected)) {
return expected.some((exp) => matchSimpleValue(actual, exp));
}
// Exact match (strict equality)
return actual === expected;
}
/**
* Checks if a failed match is due to a string/number type mismatch.
* Used to provide helpful debug hints.
*
* @param {*} actual - The actual value.
* @param {*} expected - The expected value.
* @returns {boolean} True if the values would match with type coercion.
*/
export function isTypeMismatch(actual, expected) {
// Already matches - not a mismatch
if (actual === expected) {
return false;
}
// Check if string/number coercion would make them equal
if (
(typeof actual === "string" && typeof expected === "number") ||
(typeof actual === "number" && typeof expected === "string")
) {
return String(actual) === String(expected);
}
// Check arrays for type mismatches
if (Array.isArray(expected)) {
return expected.some((exp) => isTypeMismatch(actual, exp));
}
// Check { any: [...] } for type mismatches
if (expected?.any !== undefined) {
return expected.any.some((exp) => isTypeMismatch(actual, exp));
}
return false;
}
/**
* Evaluates params/queryParams object matching with full AND/OR/NOT support.
*
* Supports:
* - Object with keys: AND logic (all keys must match)
* - Array of objects: AND logic (all must match)
* - `{ any: [...] }`: OR logic (any must match)
* - `{ not: {...} }`: NOT logic (must NOT match)
*
* Keys starting with backslash are escaped (e.g., `"\\any"` matches literal param `"any"`).
*
* @param {Object} options - Options object.
* @param {Object} options.actualParams - Current params from router.
* @param {Object|Array} options.expectedParams - Expected params spec.
* @param {Object} [options.context] - Debug context.
* @param {boolean} [options.context.debug] - Enable debug logging.
* @param {number} [options.context._depth] - Nesting depth for logging.
* @param {Object} [options.context.logger] - Logger interface from dev-tools (optional).
* @param {string} [options.label] - Label for debug output (e.g., "params", "queryParams").
* @returns {boolean} True if params match.
*/
export function matchParams({
actualParams,
expectedParams,
context = {},
label = "params",
}) {
const isLoggingEnabled = context.debug ?? false;
const depth = context._depth ?? 0;
const logger = context.logger;
if (!expectedParams) {
return true; // No expected params, always pass
}
// Array of param specs = AND logic (all must match)
if (Array.isArray(expectedParams)) {
// Log combinator BEFORE children so it appears first in tree
logger?.logCondition?.({
type: "AND",
args: `${expectedParams.length} ${label} specs`,
result: null,
depth,
conditionSpec: expectedParams,
});
const results = expectedParams.map((spec, i) =>
matchParams({
actualParams,
expectedParams: spec,
context: { debug: isLoggingEnabled, _depth: depth + 1, logger },
label: `${label}[${i}]`,
})
);
const allPassed = results.every(Boolean);
// Update combinator result after children evaluated
logger?.updateCombinatorResult?.(expectedParams, allPassed);
return allPassed;
}
// OR logic: { any: [...] }
if (expectedParams.any !== undefined) {
const specs = expectedParams.any;
// Log combinator BEFORE children so it appears first in tree
logger?.logCondition?.({
type: "OR",
args: `${specs.length} ${label} specs`,
result: null,
depth,
conditionSpec: expectedParams,
});
const results = specs.map((spec, i) =>
matchParams({
actualParams,
expectedParams: spec,
context: { debug: isLoggingEnabled, _depth: depth + 1, logger },
label: `${label}[${i}]`,
})
);
const anyPassed = results.some(Boolean);
// Update combinator result after children evaluated
logger?.updateCombinatorResult?.(expectedParams, anyPassed);
return anyPassed;
}
// NOT logic: { not: {...} }
if (expectedParams.not !== undefined) {
// Log combinator BEFORE children so it appears first in tree
logger?.logCondition?.({
type: "NOT",
args: null,
result: null,
depth,
conditionSpec: expectedParams,
});
const innerResult = matchParams({
actualParams,
expectedParams: expectedParams.not,
context: { debug: isLoggingEnabled, _depth: depth + 1, logger },
label,
});
const result = !innerResult;
// Update combinator result after children evaluated
logger?.updateCombinatorResult?.(expectedParams, result);
return result;
}
// Plain object with keys = AND logic across all keys
// Note: keys starting with \ are escaped (e.g., "\\any" matches literal param "any")
const keys = Object.keys(expectedParams);
if (keys.length === 0) {
return true;
}
// Collect match results for debug logging
const matches = [];
for (const key of keys) {
// Strip leading backslash for escaped keys (e.g., "\\any" -> "any")
const actualKey = key.startsWith("\\") ? key.slice(1) : key;
const expected = expectedParams[key];
const actual = actualParams?.[actualKey];
const result = matchValue({ actual, expected });
matches.push({ key: actualKey, expected, actual, result });
}
const allPassed = matches.every((m) => m.result);
// Log as a nested group with all param matches
logger?.logParamGroup?.({
label,
matches,
result: allPassed,
depth,
});
return allPassed;
}
/**
* Valid operator keys for param/queryParam specs.
* These are the only keys with special meaning in param matching.
*/
const VALID_OPERATOR_KEYS = Object.freeze(["any", "not"]);
/**
* Validates a param spec for typos in operator keys.
*
* Recursively checks that any object key that looks like an operator typo
* (e.g., "an" instead of "any", "nto" instead of "not") is flagged.
*
* @param {*} spec - The param spec to validate.
* @param {string} path - Current path for error messages.
* @param {Function} raiseError - Function to call with error message.
*/
export function validateParamSpec(spec, path, raiseError) {
if (spec === null || spec === undefined) {
return;
}
// Skip primitives and RegExp - they're just values
if (typeof spec !== "object" || spec instanceof RegExp) {
return;
}
// Arrays: validate each item
if (Array.isArray(spec)) {
spec.forEach((item, i) => {
validateParamSpec(item, `${path}[${i}]`, raiseError);
});
return;
}
// Objects: check keys for operator typos
const keys = Object.keys(spec);
for (const key of keys) {
// Check if this key looks like a typo of a valid operator
// Skip if it's a valid operator or an escaped key (starts with \)
if (!VALID_OPERATOR_KEYS.includes(key) && !key.startsWith("\\")) {
const suggestion = findClosestMatch(key, VALID_OPERATOR_KEYS, {
minSimilarity: 0.7,
});
if (suggestion) {
raiseError(
`Unknown key "${key}" at ${path}. Did you mean "${suggestion}"?`
);
}
}
// Recursively validate the value
validateParamSpec(spec[key], `${path}.${key}`, raiseError);
}
}
@@ -0,0 +1,206 @@
// @ts-check
/**
* Pattern constants for block name validation.
*
* @module discourse/lib/blocks/-internals/patterns
*/
/**
* Maximum allowed nesting depth for block layouts.
*
* Prevents stack overflow from deeply nested configurations. This limit is
* enforced at layout validation time with a clear error message, and as a
* defense-in-depth measure during ghost component rendering.
*
* A depth of 20 is generous for real-world use cases while protecting against
* malicious or buggy configurations that could cause infinite recursion.
*
* @type {number}
*/
export const MAX_LAYOUT_DEPTH = 20;
/**
* Maximum allowed length for block names.
*
* Prevents potential memory and performance issues from extremely long names.
* A limit of 100 characters is generous for real-world use cases while
* protecting against malicious or buggy configurations.
*
* This applies to the full namespaced name (e.g., "theme:my-theme:my-block").
*
* @type {number}
*/
export const MAX_BLOCK_NAME_LENGTH = 100;
/**
* Symbol used to mark a block reference as optional and missing from the registry.
* When this marker is returned from resolution functions, it signals that the block
* should be silently skipped rather than throwing an error.
*
* Used in layout validation and block registration to identify optional
* blocks that aren't registered and should be skipped during validation and rendering.
*
* @type {symbol}
*/
export const OPTIONAL_MISSING = Symbol("optional-missing");
/**
* Constants for failure type codes used by ghost blocks.
*
* These are internal codes for logic comparisons (e.g., determining which
* section to show in the ghost tooltip). Display messages are generated
* at render time in ghost-block.gjs based on these types.
*
* Custom failure reasons (like "hidden by priority" from the head block)
* don't need a type constant - they're passed as display strings directly
* via the `failureReason` field.
*/
export const FAILURE_TYPE = Object.freeze({
/** Block's conditions evaluated to false. */
CONDITION_FAILED: "condition-failed",
/** Container block has no children that passed their conditions. */
NO_VISIBLE_CHILDREN: "no-visible-children",
/** Block reference uses `?` suffix but isn't registered. */
OPTIONAL_MISSING: "optional-missing",
});
/**
* Checks if a resolved block is an optional missing block marker.
*
* @param {*} resolvedBlock - The result from tryResolveBlock.
* @returns {boolean} True if the block is an optional missing marker.
*/
export function isOptionalMissing(resolvedBlock) {
return resolvedBlock?.optionalMissing === OPTIONAL_MISSING;
}
/**
* Valid block name pattern: lowercase letters, numbers, and hyphens.
* Must start with a letter. Examples: "hero-banner", "sidebar-blocks", "my-block-1"
*
* Used for both block names and outlet names since they follow the same format.
*/
export const VALID_BLOCK_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
/**
* Valid block ID pattern for the `id` entry property.
* Same format as block names: lowercase letters, numbers, and hyphens,
* must start with a letter.
*
* Examples: "featured-banner", "sidebar-1", "main-content"
*/
export const VALID_BLOCK_ID_PATTERN = VALID_BLOCK_NAME_PATTERN;
/**
* Valid namespaced block name pattern. Supports three formats:
*
* - **Core blocks**: `block-name` (no prefix)
* - **Plugin blocks**: `plugin-name:block-name` (where plugin-name is not "theme")
* - **Theme blocks**: `theme:theme-name:block-name`
*
* Each segment must start with a letter and contain only lowercase letters,
* numbers, and hyphens.
*
* @example
* // Valid patterns:
* "group" // Core block
* "chat:message-widget" // Plugin block
* "theme:tactile:hero-banner" // Theme block
*
* // Invalid patterns:
* "Theme:Name:block" // Uppercase not allowed
* "theme:block" // Theme requires namespace segment
* "my_block" // Underscores not allowed
*/
export const VALID_NAMESPACED_BLOCK_PATTERN =
/^(?:theme:[a-z][a-z0-9-]*:[a-z][a-z0-9-]*|(?!theme:)[a-z][a-z0-9-]*:[a-z][a-z0-9-]*|[a-z][a-z0-9-]*)$/;
/**
* The parsed components of a block name.
*
* @typedef {{
* type: "core"|"plugin"|"theme",
* namespace: string|null,
* name: string
* }} ParsedBlockName
*/
/**
* Parses a full block name into its components.
*
* @param {string} fullName - The full block name.
* @returns {ParsedBlockName|null}
* An object with the parsed components, or `null` if the name is invalid.
*
* @example
* parseBlockName("group")
* // => { type: "core", namespace: null, name: "group" }
*
* parseBlockName("chat:message-widget")
* // => { type: "plugin", namespace: "chat", name: "message-widget" }
*
* parseBlockName("theme:tactile:hero-banner")
* // => { type: "theme", namespace: "theme:tactile", name: "hero-banner" }
*
* parseBlockName("Invalid_Name")
* // => null
*/
export function parseBlockName(fullName) {
// Theme: theme:namespace:name
const themeMatch = fullName.match(
/^theme:([a-z][a-z0-9-]*):([a-z][a-z0-9-]*)$/
);
if (themeMatch) {
return {
type: "theme",
namespace: `theme:${themeMatch[1]}`,
name: themeMatch[2],
};
}
// Plugin: namespace:name (where namespace is NOT "theme")
const pluginMatch = fullName.match(/^([a-z][a-z0-9-]*):([a-z][a-z0-9-]*)$/);
if (pluginMatch && pluginMatch[1] !== "theme") {
return { type: "plugin", namespace: pluginMatch[1], name: pluginMatch[2] };
}
// Core: just name
const coreMatch = fullName.match(/^[a-z][a-z0-9-]*$/);
if (coreMatch) {
return { type: "core", namespace: null, name: fullName };
}
return null;
}
/**
* Parses a block reference string to extract the block name and optional flag.
*
* Block references can be marked as optional by appending a `?` suffix to the
* name. Optional blocks that are not registered will be silently skipped
* instead of throwing an error.
*
* Supports all namespaced formats:
* - Core: `"block-name"` or `"block-name?"`
* - Plugin: `"plugin:block"` or `"plugin:block?"`
* - Theme: `"theme:namespace:block"` or `"theme:namespace:block?"`
*
* @param {string|Object} blockRef - The block reference. If a string, may have an
* optional `?` suffix. Non-string references (e.g., component classes) are
* returned as-is in the `name` property with `optional: false`.
* @returns {{ name: string|Object, optional: boolean }} Parsed result with the clean
* block name (or original reference) and whether it's optional.
*
* @example
* parseBlockReference("chat:widget?")
* // => { name: "chat:widget", optional: true }
*
* parseBlockReference("hero-banner")
* // => { name: "hero-banner", optional: false }
*/
export function parseBlockReference(blockRef) {
if (typeof blockRef === "string" && blockRef.endsWith("?")) {
return { name: blockRef.slice(0, -1), optional: true };
}
return { name: blockRef, optional: false };
}
@@ -0,0 +1,527 @@
// @ts-check
import { DEBUG } from "@glimmer/env";
import { TrackedMap } from "@ember-compat/tracked-built-ins";
import { getBlockMetadata } from "discourse/lib/blocks/-internals/decorator";
import { raiseBlockError } from "discourse/lib/blocks/-internals/error";
import {
OPTIONAL_MISSING,
parseBlockReference,
} from "discourse/lib/blocks/-internals/patterns";
import { isTesting } from "discourse/lib/environment";
import {
assertNotDuplicate,
assertRegistryNotFrozen,
createTestRegistrationWrapper,
validateNamePattern,
validateSourceNamespace,
} from "./helpers";
/**
* A block class decorated with `@block`.
*
* Block metadata is stored in an internal WeakMap and accessed via the
* `getBlockMetadata()` function from the decorator module.
*
* @typedef {typeof import("@glimmer/component").default} BlockClass
*/
/**
* Metadata object containing block configuration set by the `@block` decorator.
* Includes args schema, container settings, validation, and outlet restrictions.
*
* @typedef {{
* blockName: string,
* shortName: string,
* namespace: string|null,
* namespaceType: "core"|"plugin"|"theme",
* description: string,
* isContainer: boolean,
* decoratorClassNames: string|Array<string>|Function|null,
* args: Object|null,
* childArgs: Object|null,
* constraints: Object|null,
* validate: Function|null,
* allowedOutlets: ReadonlyArray<string>|null,
* deniedOutlets: ReadonlyArray<string>|null
* }} BlockMetadata
*/
/**
* A factory function that returns a Promise resolving to a BlockClass or module with default export.
*
* @typedef {() => Promise<BlockClass | { default: BlockClass }>} BlockFactory
*/
/**
* Registry entry: either a resolved BlockClass or a factory function for lazy loading.
*
* @typedef {BlockClass | BlockFactory} BlockRegistryEntry
*/
/*
* Registry State
*/
/**
* Registry of block components registered via `api.registerBlock()`.
* Maps block names to their component classes or factory functions.
*
* @type {Map<string, BlockRegistryEntry>}
*/
const blockRegistry = new Map();
/**
* Cache for resolved factory functions.
* Once a factory is resolved, the result is stored here to avoid re-resolving.
*
* This is a TrackedMap so that components calling `tryResolveBlock()` will
* automatically re-render when a factory they depend on finishes resolving.
* TrackedMap tracks per-key, so only components waiting on specific blocks
* are invalidated.
*
* @type {TrackedMap<string, BlockClass>}
*/
const resolvedFactoryCache = new TrackedMap();
/**
* Tracks in-flight resolution promises to prevent duplicate concurrent attempts.
*
* @type {Map<string, Promise<BlockClass|undefined>>}
*/
const pendingResolutions = new Map();
/**
* Caches failed resolution attempts to prevent infinite retry loops.
*
* @type {Set<string>}
*/
const failedResolutions = new Set();
/**
* Whether the block registry is frozen (no new registrations allowed).
*/
let registryFrozen = false;
/**
* Stores the initial frozen state to allow correct reset after tests.
* @type {boolean | null}
*/
let testRegistryFrozenState = null;
/*
* Public Functions
*/
/**
* Returns whether the block registry is frozen.
*
* @returns {boolean}
*/
export function isBlockRegistryFrozen() {
return registryFrozen;
}
/**
* Checks if a block is registered (by name or class reference).
*
* This is a synchronous check that does not resolve factory functions.
* Use this to verify a block exists before attempting resolution.
*
* @param {string | BlockClass} nameOrClass - Block name string or BlockClass.
* @returns {boolean} True if the block is registered.
*/
export function hasBlock(nameOrClass) {
if (typeof nameOrClass === "string") {
return blockRegistry.has(nameOrClass);
}
const blockName = getBlockMetadata(nameOrClass)?.blockName;
return blockName && blockRegistry.has(blockName);
}
/**
* Returns the registry entry for a block (class or factory).
*
* @param {string} name - The block name.
* @returns {BlockRegistryEntry | undefined} The registry entry, or undefined if not found.
*/
export function getBlockEntry(name) {
return blockRegistry.get(name);
}
/**
* Returns all block entries with their names as [name, entry] pairs.
* Used by Blocks service for listing and introspection.
*
* @returns {Array<[string, BlockRegistryEntry]>} Array of [name, entry] pairs.
*/
export function getAllBlockEntries() {
return Array.from(blockRegistry.entries());
}
/**
* Checks if a block is registered and fully resolved (not a pending factory).
*
* @param {string} name - The block name to check.
* @returns {boolean} True if registered and resolved.
*/
export function isBlockResolved(name) {
if (!blockRegistry.has(name)) {
return false;
}
const entry = blockRegistry.get(name);
return !isBlockFactory(entry);
}
/**
* Checks if a registry entry is a factory function (not a resolved class).
*
* Factory functions are plain functions not registered in the block metadata WeakMap.
* BlockClasses are tracked by the `@block` decorator.
*
* @param {BlockRegistryEntry} entry - The registry entry to check.
* @returns {entry is BlockFactory} True if the entry is a factory function.
*/
export function isBlockFactory(entry) {
return typeof entry === "function" && !getBlockMetadata(entry);
}
/**
* Resolves a block reference (string name or class) to a BlockClass.
*
* - If given a BlockClass, returns it directly.
* - If given a string, looks up in registry and resolves factory if needed.
* - Caches resolved factories to avoid re-resolving.
*
* @param {string | BlockClass} nameOrClass - Block name string or BlockClass.
* @returns {Promise<BlockClass|undefined>} The resolved block class, or undefined if resolution previously failed.
* @throws {Error} If block not registered or factory resolution fails on first attempt.
*
* @example
* ```javascript
* const BlockClass = await resolveBlock("hero-banner");
* const BlockClass = await resolveBlock(HeroBanner); // Returns directly
* ```
*/
export async function resolveBlock(nameOrClass) {
if (typeof nameOrClass !== "string") {
if (!getBlockMetadata(nameOrClass)) {
raiseBlockError(
`Invalid block reference: expected string name or @block-decorated class, ` +
`got ${typeof nameOrClass}.`
);
}
return nameOrClass;
}
const name = nameOrClass;
if (resolvedFactoryCache.has(name)) {
return resolvedFactoryCache.get(name);
}
if (failedResolutions.has(name)) {
return undefined;
}
if (pendingResolutions.has(name)) {
return pendingResolutions.get(name);
}
if (!blockRegistry.has(name)) {
raiseBlockError(
`Block "${name}" is not registered. ` +
`Use api.registerBlock() in a pre-initializer before any renderBlocks() configuration.`
);
}
const entry = blockRegistry.get(name);
if (!isBlockFactory(entry)) {
return entry;
}
const resolutionPromise = resolveFactory(name, entry);
pendingResolutions.set(name, resolutionPromise);
return resolutionPromise;
}
/**
* Attempts to resolve a block reference to a BlockClass synchronously.
*
* If the block is already resolved, returns the BlockClass immediately.
* If the block is a factory that hasn't resolved yet, triggers async resolution
* and returns null. The calling component will automatically re-render when the
* factory resolves.
*
* @param {string | BlockClass} blockRef - Block reference (string name or class).
* String names may include a trailing "?" to mark the block as optional.
* @returns {BlockClass | { optionalMissing: symbol, name: string } | null}
* - The BlockClass if found and resolved
* - An object with `optionalMissing` marker if the block is optional and not registered
* - null if the block is not registered (non-optional) or is a factory awaiting resolution
*/
export function tryResolveBlock(blockRef) {
if (typeof blockRef !== "string") {
return blockRef;
}
const { name: blockName, optional } = parseBlockReference(blockRef);
// Check cache first - ALWAYS call .get() to establish tracking dependency.
// TrackedMap establishes tracking even when key doesn't exist (returns undefined).
// This ensures component re-renders when factory resolves and calls .set().
const cachedClass = resolvedFactoryCache.get(blockName);
if (cachedClass) {
return cachedClass;
}
if (!blockRegistry.has(blockName)) {
if (optional) {
return { optionalMissing: OPTIONAL_MISSING, name: blockName };
}
// eslint-disable-next-line no-console
console.error(`[Blocks] Block "${blockName}" is not registered.`);
return null;
}
const entry = blockRegistry.get(blockName);
if (!isBlockFactory(entry)) {
return entry;
}
// Trigger async resolution. Returns null for this render cycle - the component
// will re-render automatically when the factory resolves (tracked via TrackedMap).
resolveBlock(blockName).catch((error) => {
// TODO (blocks-api) Consider returning an error marker and rendering an error
// placeholder component for admin visibility, rather than silently returning null.
document.dispatchEvent(
new CustomEvent("discourse-error", {
detail: { messageKey: "broken_block_factory_alert", error },
})
);
});
return null;
}
/*
* Internal Functions
*/
/**
* Freezes the registry, preventing further registrations.
* Called by the "freeze-block-registry" initializer during app boot.
*
* @internal
*/
export function _freezeBlockRegistry() {
registryFrozen = true;
}
/**
* Registers a block component in the registry.
* Must be called before any renderBlocks() configuration is registered.
*
* The block component must be decorated with `@block`. Block metadata
* (including `blockName`) is stored in an internal WeakMap and accessed
* via `getBlockMetadata()`.
*
* @param {BlockClass} BlockClass - The block component class
* @throws {Error} If called after registry is locked, or if block is invalid
*
* @example
* ```javascript
* // In a plugin's pre-initializer (plugins/my-plugin/assets/javascripts/pre-initializers/...)
* import { withPluginApi } from "discourse/lib/plugin-api";
* import MyBlock from "../blocks/my-block";
*
* export default {
* initialize() {
* withPluginApi("1.0", (api) => {
* api.registerBlock(MyBlock);
* });
* },
* };
* ```
*/
export function _registerBlock(BlockClass) {
const blockName = getBlockMetadata(BlockClass)?.blockName;
if (
!assertRegistryNotFrozen({
frozen: registryFrozen,
apiMethod: "api.registerBlock()",
entityType: "Block",
entityName: blockName || BlockClass?.name,
})
) {
return;
}
if (!blockName) {
raiseBlockError(
`Block class "${BlockClass?.name}" must be decorated with @block to be registered.`
);
return;
}
if (!validateNamePattern(blockName, "Block")) {
return;
}
if (!validateSourceNamespace({ name: blockName, entityType: "block" })) {
return;
}
if (!assertNotDuplicate(blockRegistry, blockName, "Block")) {
return;
}
blockRegistry.set(blockName, BlockClass);
}
/**
* Registers a factory function for lazy loading a block.
*
* The factory will be called when the block is first needed. It must return
* a Promise that resolves to a BlockClass (or a module with a default export).
*
* @param {string} name - The name to register the block under.
* @param {BlockFactory} factory - Factory function returning Promise<BlockClass>.
* @throws {Error} If registry is locked, name is invalid, or factory is not a function.
*
* @example
* ```javascript
* api.registerBlock("hero-banner", () => import("../blocks/hero-banner"));
* ```
*
* @internal
*/
export function _registerBlockFactory(name, factory) {
if (
!assertRegistryNotFrozen({
frozen: registryFrozen,
apiMethod: "api.registerBlock()",
entityType: "Block",
entityName: name,
})
) {
return;
}
if (!validateNamePattern(name, "Block")) {
return;
}
if (typeof factory !== "function") {
raiseBlockError(
`Block factory for "${name}" must be a function that returns a Promise<BlockClass>.`
);
return;
}
if (!validateSourceNamespace({ name, entityType: "block" })) {
return;
}
if (!assertNotDuplicate(blockRegistry, name, "Block")) {
return;
}
blockRegistry.set(name, factory);
}
/**
* Resolves a factory function and caches the result.
*
* @param {string} name - The block name.
* @param {BlockFactory} factory - The factory function to resolve.
* @returns {Promise<BlockClass>} The resolved block class.
* @throws {BlockError} If the factory returns an invalid class or the resolved name doesn't match.
*/
async function resolveFactory(name, factory) {
try {
const result = await factory();
// @ts-ignore - result may be a module with .default or direct BlockClass
const BlockClass = result?.default ?? result;
const resolvedBlockName = getBlockMetadata(BlockClass)?.blockName;
if (!resolvedBlockName) {
raiseBlockError(
`Block factory for "${name}" did not return a valid @block-decorated class.`
);
}
if (resolvedBlockName !== name) {
raiseBlockError(
`Block factory registered as "${name}" resolved to a block with ` +
`blockName "${resolvedBlockName}". The registered name must match ` +
`the block's @block decorator name.`
);
}
resolvedFactoryCache.set(name, BlockClass);
blockRegistry.set(name, BlockClass);
return BlockClass;
} catch (error) {
failedResolutions.add(name);
if (error.name === "BlockError") {
throw error;
}
raiseBlockError(
`Failed to resolve block factory for "${name}": ${error.message}`
);
return undefined;
} finally {
pendingResolutions.delete(name);
}
}
/*
* Test Utilities
*/
/**
* Temporarily unfreezes the block registry for testing.
*
* USE ONLY FOR TESTING PURPOSES.
*
* @param {Function} callback - Function to execute with unfrozen registry.
*/
export const withTestBlockRegistration = createTestRegistrationWrapper({
getFrozen: () => registryFrozen,
setFrozen: (value) => {
registryFrozen = value;
},
getSavedState: () => testRegistryFrozenState,
setSavedState: (value) => {
testRegistryFrozenState = value;
},
name: "withTestBlockRegistration",
});
/**
* Resets the block registry state for testing.
*
* USE ONLY FOR TESTING PURPOSES.
*
* @internal Called by `resetBlockRegistryForTesting`, not meant for direct use.
*/
export function _resetBlockRegistryState() {
// allows tree-shaking in production builds
if (!DEBUG) {
return;
}
if (!isTesting()) {
throw new Error("_resetBlockRegistryState can only be used in tests.");
}
blockRegistry.clear();
resolvedFactoryCache.clear();
pendingResolutions.clear();
failedResolutions.clear();
registryFrozen = false;
testRegistryFrozenState = null;
}
@@ -0,0 +1,201 @@
// @ts-check
import { DEBUG } from "@glimmer/env";
import { isDecoratedCondition } from "discourse/blocks/conditions/decorator";
import { raiseBlockError } from "discourse/lib/blocks/-internals/error";
import { isTesting } from "discourse/lib/environment";
import {
assertRegistryNotFrozen,
createTestRegistrationWrapper,
validateNamePattern,
validateSourceNamespace,
} from "./helpers";
/*
* Registry State
*/
/**
* Registry of condition type classes registered by core, plugins, and themes.
* Maps condition type names to their class constructors.
*
* Unlike blocks which store component classes, conditions are stored as classes
* and instantiated by the Blocks service when first needed. This allows the
* service to set the owner for dependency injection.
*
* @type {Map<string, typeof import("discourse/blocks/conditions").BlockCondition>}
*/
const conditionTypeRegistry = new Map();
/**
* Whether the condition type registry is frozen (no new registrations allowed).
*/
let conditionTypeRegistryFrozen = false;
/**
* Stores the initial frozen state for condition registry to allow correct reset after tests.
* @type {boolean | null}
*/
let testConditionRegistryFrozenState = null;
/*
* Public Functions
*/
/**
* Returns whether the condition type registry is frozen.
*
* @returns {boolean}
*/
export function isConditionTypeRegistryFrozen() {
return conditionTypeRegistryFrozen;
}
/**
* Checks if a condition type is registered.
*
* @param {string} type - The condition type name.
* @returns {boolean}
*/
export function hasConditionType(type) {
return conditionTypeRegistry.has(type);
}
/**
* Returns all condition type entries as [type, ConditionClass] pairs.
* Used by Blocks service for lazy initialization.
*
* @returns {Array<[string, typeof import("discourse/blocks/conditions").BlockCondition]>}
*/
export function getAllConditionTypeEntries() {
return Array.from(conditionTypeRegistry.entries());
}
/*
* Internal Functions
*/
/**
* Freezes the condition type registry, preventing further registrations.
* Called by the "freeze-block-registry" initializer during app boot.
*
* @internal
*/
export function _freezeConditionTypeRegistry() {
conditionTypeRegistryFrozen = true;
}
/**
* Registers a condition type class in the registry.
* Must be called before the registry is frozen by the "freeze-block-registry" initializer.
*
* The condition class must be decorated with `@blockCondition`
*
* @param {typeof import("discourse/blocks/conditions").BlockCondition} ConditionClass - The condition class to register.
*
* @example
* ```javascript
* import { withPluginApi } from "discourse/lib/plugin-api";
* import MyCondition from "../conditions/my-condition";
*
* export default {
* initialize() {
* withPluginApi((api) => {
* api.registerBlockConditionType(MyCondition);
* });
* },
* };
* ```
*
* @internal
*/
export function _registerConditionType(ConditionClass) {
if (
!assertRegistryNotFrozen({
frozen: conditionTypeRegistryFrozen,
apiMethod: "api.registerBlockConditionType()",
entityType: "Condition",
entityName: ConditionClass?.type || ConditionClass?.name,
})
) {
return;
}
// Ensure the class was created by the @blockCondition decorator
if (!isDecoratedCondition(ConditionClass)) {
raiseBlockError(
`${ConditionClass.name} must use the @blockCondition decorator. ` +
`Manual inheritance from BlockCondition is not allowed.`
);
return;
}
const type = ConditionClass.type;
// Validate condition type follows the namespaced pattern
if (!validateNamePattern(type, "Condition")) {
return;
}
// Validate namespace requirements for plugins/themes and enforce consistency
if (!validateSourceNamespace({ name: type, entityType: "condition" })) {
return;
}
if (conditionTypeRegistry.has(type)) {
raiseBlockError(`Condition type "${type}" is already registered`);
return;
}
conditionTypeRegistry.set(type, ConditionClass);
}
/*
* Test Utilities
*/
/**
* Temporarily unfreezes the condition type registry for testing purposes.
* Call this before registering condition types in tests.
*
* USE ONLY FOR TESTING PURPOSES.
*
* @param {Function} callback - Function to execute with unfrozen registry.
*
* @example
* ```javascript
* withTestConditionRegistration(() => {
* _registerConditionType(MyTestCondition);
* });
* ```
*/
export const withTestConditionRegistration = createTestRegistrationWrapper({
getFrozen: () => conditionTypeRegistryFrozen,
setFrozen: (value) => {
conditionTypeRegistryFrozen = value;
},
getSavedState: () => testConditionRegistryFrozenState,
setSavedState: (value) => {
testConditionRegistryFrozenState = value;
},
name: "withTestConditionRegistration",
});
/**
* Resets the condition registry state for testing.
*
* USE ONLY FOR TESTING PURPOSES.
*
* @internal Called by `resetBlockRegistryForTesting`, not meant for direct use.
*/
export function _resetConditionRegistryState() {
// allows tree-shaking in production builds
if (!DEBUG) {
return;
}
if (!isTesting()) {
throw new Error("_resetConditionRegistryState can only be used in tests.");
}
conditionTypeRegistry.clear();
conditionTypeRegistryFrozen = false;
testConditionRegistryFrozenState = null;
}
@@ -0,0 +1,311 @@
// @ts-check
import { DEBUG } from "@glimmer/env";
import { raiseBlockError } from "discourse/lib/blocks/-internals/error";
import {
MAX_BLOCK_NAME_LENGTH,
parseBlockName,
VALID_NAMESPACED_BLOCK_PATTERN,
} from "discourse/lib/blocks/-internals/patterns";
import { isTesting } from "discourse/lib/environment";
import identifySource from "discourse/lib/source-identifier";
/**
* Tracks which namespace each source (theme/plugin) has used.
* Enforces that each source can only register blocks with a single namespace.
*
* Key: source identifier (e.g., "theme:Tactile Theme" or "plugin:chat")
* Value: the namespace prefix used (e.g., "theme:tactile" or "chat")
*
* @type {Map<string, string|null>}
*/
const sourceNamespaceMap = new Map();
/**
* Override for source identifier in tests.
* @type {string|null|undefined}
*/
let testSourceIdentifier;
/*
* Public Functions
*/
/**
* Asserts that a registry is not frozen before registration.
*
* @param {Object} options - Validation options.
* @param {boolean} options.frozen - Whether the registry is frozen.
* @param {string} options.apiMethod - The API method name for the error message.
* @param {string} options.entityType - Type of entity (e.g., "Block", "Outlet", "Condition").
* @param {string} options.entityName - Name of the entity being registered.
* @returns {boolean} True if not frozen, false if frozen (error was raised).
*/
export function assertRegistryNotFrozen({
frozen,
apiMethod,
entityType,
entityName,
}) {
if (frozen) {
raiseBlockError(
`${apiMethod} was called after the ${entityType.toLowerCase()} registry was frozen. ` +
`Move your code to a pre-initializer that runs before "freeze-block-registry". ` +
`${entityType}: "${entityName}"`
);
return false;
}
return true;
}
/**
* Validates that a name follows the namespaced block/outlet name pattern.
*
* Checks both the pattern format and maximum length to prevent memory and
* performance issues from extremely long names.
*
* @param {string} name - The name to validate.
* @param {string} entityType - Type of entity for error messages (e.g., "Block", "Outlet").
* @returns {boolean} True if valid, false if invalid (error was raised).
*/
export function validateNamePattern(name, entityType) {
// Check length first to avoid regex issues with extremely long strings.
if (name.length > MAX_BLOCK_NAME_LENGTH) {
raiseBlockError(
`${entityType} name exceeds maximum length of ${MAX_BLOCK_NAME_LENGTH} characters. ` +
`Name length: ${name.length}.`
);
return false;
}
if (!VALID_NAMESPACED_BLOCK_PATTERN.test(name)) {
const entityLower = entityType.toLowerCase();
raiseBlockError(
`${entityType} name "${name}" is invalid. ` +
`Valid formats: "${entityLower}-name" (core), "plugin:${entityLower}-name" (plugin), ` +
`"theme:namespace:${entityLower}-name" (theme).`
);
return false;
}
return true;
}
/**
* Asserts that an entry is not already registered.
*
* @param {Map} registry - The registry to check.
* @param {string} name - The name to check.
* @param {string} entityType - Type of entity for error messages.
* @returns {boolean} True if not duplicate, false if duplicate (error was raised).
*/
export function assertNotDuplicate(registry, name, entityType) {
if (registry.has(name)) {
raiseBlockError(`${entityType} "${name}" is already registered.`);
return false;
}
return true;
}
/**
* Validates that a block or outlet name follows namespace requirements for themes and plugins.
*
* This helper enforces the following rules:
* - Themes must use `theme:namespace:name` format
* - Plugins must use `namespace:name` format
* - Optionally enforces that each source uses a consistent namespace across all registrations
*
* @param {Object} options - Validation options.
* @param {string} options.name - The name being registered.
* @param {"block"|"outlet"|"condition"} options.entityType - Type of entity for error messages.
* @param {boolean} [options.enforceConsistency=true] - Whether to enforce single namespace per source.
* @returns {boolean} True if validation passes, false if it failed (error was raised).
*/
export function validateSourceNamespace({
name,
entityType,
enforceConsistency = true,
}) {
const sourceId = getSourceIdentifier();
if (!sourceId) {
return true;
}
const namespacePrefix = getNamespacePrefix(name);
const ENTITY_PLURALS = {
block: "blocks",
outlet: "outlets",
condition: "conditions",
};
const entityPlural = ENTITY_PLURALS[entityType] ?? "conditions";
const entityCapitalized =
entityType.charAt(0).toUpperCase() + entityType.slice(1);
// Themes must use theme:namespace:name format
if (sourceId.startsWith("theme:") && !namespacePrefix?.startsWith("theme:")) {
raiseBlockError(
`Theme ${entityPlural} must use the "theme:namespace:${entityType}-name" format. ` +
`${entityCapitalized} "${name}" should be renamed to "theme:<your-theme>:${name}".`
);
return false;
}
// Plugins must use namespace:name format
if (sourceId.startsWith("plugin:") && !namespacePrefix) {
const pluginName = sourceId.replace("plugin:", "");
raiseBlockError(
`Plugin ${entityPlural} must use the "namespace:${entityType}-name" format. ` +
`${entityCapitalized} "${name}" should be renamed to "${pluginName}:${name}".`
);
return false;
}
// Enforce single namespace per source (shared across blocks, outlets, and conditions)
if (enforceConsistency) {
const existingNamespace = sourceNamespaceMap.get(sourceId);
if (
existingNamespace !== undefined &&
existingNamespace !== namespacePrefix
) {
raiseBlockError(
`${entityCapitalized} "${name}" uses namespace "${namespacePrefix ?? "(core)"}" but ` +
`${sourceId} already used namespace "${existingNamespace ?? "(core)"}". ` +
`Each theme/plugin must use a single consistent namespace for all blocks, outlets, and conditions.`
);
return false;
}
sourceNamespaceMap.set(sourceId, namespacePrefix);
}
return true;
}
/**
* Creates a test registration wrapper function for temporarily unfreezing a registry.
*
* USE ONLY FOR TESTING PURPOSES.
*
* @param {Object} options - Options object.
* @param {() => boolean} options.getFrozen - Function to get frozen state.
* @param {(value: boolean) => void} options.setFrozen - Function to set frozen state.
* @param {() => boolean|null} options.getSavedState - Function to get saved test state.
* @param {(value: boolean|null) => void} options.setSavedState - Function to set saved test state.
* @param {string} options.name - Name for error message.
* @returns {((callback: Function) => void)|undefined} The wrapper function.
*/
export function createTestRegistrationWrapper({
getFrozen,
setFrozen,
getSavedState,
setSavedState,
name,
}) {
// allows tree-shaking in production builds
if (!DEBUG) {
return; // this won't be called in production builds
}
return function (callback) {
if (!isTesting()) {
throw new Error(`Use \`${name}\` only in tests.`);
}
if (getSavedState() === null) {
setSavedState(getFrozen());
}
setFrozen(false);
try {
callback();
} finally {
setFrozen(getSavedState());
}
};
}
/*
* Internal Functions
*/
/**
* Gets a unique identifier for the current source from the call stack.
* Returns null for core code (no theme or plugin detected).
*
* @returns {string|null} Source identifier like "theme:Tactile" or "plugin:chat"
*/
function getSourceIdentifier() {
if (DEBUG && testSourceIdentifier !== undefined) {
return testSourceIdentifier;
}
const source = identifySource();
if (!source) {
return null;
}
if (source.type === "theme") {
return `theme:${source.name}`;
}
if (source.type === "plugin") {
return `plugin:${source.name}`;
}
return null;
}
/**
* Extracts the namespace prefix from a block name.
*
* @param {string} blockName - The full block name.
* @returns {string|null} The namespace prefix, or null for core blocks.
*
* @example
* getNamespacePrefix("theme:tactile:banner") // => "theme:tactile"
* getNamespacePrefix("chat:widget") // => "chat"
* getNamespacePrefix("group") // => null (core)
*/
function getNamespacePrefix(blockName) {
const parsed = parseBlockName(blockName);
if (!parsed) {
return null;
}
if (parsed.type === "theme") {
return `theme:${parsed.namespace}`;
}
if (parsed.type === "plugin") {
return parsed.namespace;
}
return null;
}
/**
* Internal implementation for setting the test source identifier.
*
* USE ONLY FOR TESTING PURPOSES.
*
* @param {string|null} sourceId - Source identifier to use, or null to clear.
* @internal Called by `setTestSourceIdentifier` in block-testing.js.
*/
export function _setTestSourceIdentifierInternal(sourceId) {
// allows tree-shaking in production builds
if (!DEBUG) {
return;
}
testSourceIdentifier = sourceId;
}
/**
* Resets the source namespace map and test source identifier.
*
* USE ONLY FOR TESTING PURPOSES.
*
* @internal Called by `resetBlockRegistryForTesting`, not meant for direct use.
*/
export function _resetSourceNamespaceState() {
// allows tree-shaking in production builds
if (!DEBUG) {
return;
}
if (!isTesting()) {
throw new Error("_resetSourceNamespaceState can only be used in tests.");
}
sourceNamespaceMap.clear();
testSourceIdentifier = undefined;
}
@@ -0,0 +1,166 @@
// @ts-check
import { DEBUG } from "@glimmer/env";
import { raiseBlockError } from "discourse/lib/blocks/-internals/error";
import { isTesting } from "discourse/lib/environment";
import { BLOCK_OUTLETS } from "discourse/lib/registry/block-outlets";
import {
assertRegistryNotFrozen,
validateNamePattern,
validateSourceNamespace,
} from "./helpers";
/*
* Registry State
*/
/**
* Registry of custom block outlets registered by plugins and themes.
* Maps outlet names to their metadata.
*
* @type {Map<string, { name: string, description?: string }>}
*/
const customOutletRegistry = new Map();
/**
* Whether the outlet registry is frozen (no new registrations allowed).
*/
let outletRegistryFrozen = false;
/*
* Public Functions
*/
/**
* Returns whether the outlet registry is frozen.
*
* @returns {boolean}
*/
export function isOutletRegistryFrozen() {
return outletRegistryFrozen;
}
/**
* Returns all valid outlet names (both core and custom).
*
* @returns {string[]} Array of all outlet names.
*/
export function getAllOutlets() {
return [...BLOCK_OUTLETS, ...customOutletRegistry.keys()];
}
/**
* Checks if an outlet name is valid (registered as core or custom).
*
* @param {string} name - The outlet name to check.
* @returns {boolean} True if the outlet is registered.
*/
export function isValidOutlet(name) {
return BLOCK_OUTLETS.includes(name) || customOutletRegistry.has(name);
}
/**
* Gets metadata for a custom outlet.
*
* @param {string} name - The outlet name.
* @returns {{ name: string, description?: string } | undefined} Outlet metadata or undefined.
*/
export function getCustomOutlet(name) {
return customOutletRegistry.get(name);
}
/*
* Internal Functions
*/
/**
* Freezes the outlet registry, preventing further registrations.
* Called by the "freeze-block-registry" initializer during app boot.
*
* @internal
*/
export function _freezeOutletRegistry() {
outletRegistryFrozen = true;
}
/**
* Registers a custom block outlet.
*
* Custom outlets follow the same naming conventions as blocks:
* - Core outlets: `outlet-name` (kebab-case)
* - Plugin outlets: `namespace:outlet-name`
* - Theme outlets: `theme:namespace:outlet-name`
*
* @param {string} outletName - The outlet name (must follow naming conventions).
* @param {Object} [options] - Outlet options.
* @param {string} [options.description] - Human-readable description.
*
* @internal
*/
export function _registerOutlet(outletName, options = {}) {
if (
!assertRegistryNotFrozen({
frozen: outletRegistryFrozen,
apiMethod: "api.registerBlockOutlet()",
entityType: "Outlet",
entityName: outletName,
})
) {
return;
}
if (!validateNamePattern(outletName, "Outlet")) {
return;
}
// Check for duplicates against core outlets
if (BLOCK_OUTLETS.includes(outletName)) {
raiseBlockError(
`Outlet "${outletName}" is already registered as a core outlet.`
);
return;
}
// Check for duplicates against custom outlets
if (customOutletRegistry.has(outletName)) {
raiseBlockError(`Outlet "${outletName}" is already registered.`);
return;
}
// Validate namespace requirements (shared consistency check with blocks and conditions)
if (
!validateSourceNamespace({
name: outletName,
entityType: "outlet",
})
) {
return;
}
customOutletRegistry.set(outletName, {
name: outletName,
description: options.description,
});
}
/*
* Test Utilities
*/
/**
* Resets the outlet registry state for testing.
*
* USE ONLY FOR TESTING PURPOSES.
*
* @internal Called by `resetBlockRegistryForTesting`, not meant for direct use.
*/
export function _resetOutletRegistryState() {
// allows tree-shaking in production builds
if (!DEBUG) {
return;
}
if (!isTesting()) {
throw new Error("_resetOutletRegistryState can only be used in tests.");
}
customOutletRegistry.clear();
outletRegistryFrozen = false;
}
@@ -0,0 +1,170 @@
// @ts-check
/**
* Utility functions for the block system.
*
* @module discourse/lib/blocks/-internals/utils
*/
import { getBlockMetadata } from "discourse/lib/blocks/-internals/decorator";
/**
* @typedef {Object} ValidationContext
* @property {string} outletName - The name of the outlet being validated.
* @property {string|null} [blockName=null] - The name of the block, if resolved.
* @property {string} path - The hierarchical path to this entry (e.g., "layout[0].children[1]").
* @property {Object|null} [entry=null] - The block entry object being validated.
* @property {Error|null} [callSiteError=null] - Error captured at the call site for stack traces.
* @property {Array|null} [rootLayout=null] - The root layout array for error display.
*
* The following properties are added by validation code after initial context creation:
* @property {string} [errorPath] - Full path to the error (e.g., "layout[0].conditions.params.categoryId").
* @property {string} [conditionsPath] - Path within conditions (e.g., "params.categoryId").
* @property {Object} [conditions] - The conditions object for error display.
*/
/**
* Creates a validation context object for error reporting.
* Centralizes context creation to ensure consistent structure across
* all validation functions.
*
* @param {Object} params - Context parameters.
* @param {string} params.outletName - The name of the outlet being validated.
* @param {string|null} [params.blockName=null] - The name of the block, if resolved.
* @param {string} params.path - The hierarchical path to this entry.
* @param {Object|null} [params.entry=null] - The block entry object being validated.
* @param {Error|null} [params.callSiteError=null] - Error captured at the call site.
* @param {Array|null} [params.rootLayout=null] - The root layout array for error display.
* @returns {ValidationContext} A validation context object.
*/
export function createValidationContext({
outletName,
blockName = null,
path,
entry = null,
callSiteError = null,
rootLayout = null,
}) {
return { outletName, blockName, path, entry, callSiteError, rootLayout };
}
/**
* Builds a hierarchical error path by joining path segments.
* Used to construct full paths for error messages (e.g., "layout[0].args.title").
*
* @param {string} basePath - The base path (e.g., "layout[0]").
* @param {string} segment - The segment to append (e.g., "args.title").
* @returns {string} Combined path with dot separator, or the non-empty path if one is missing.
*
* @example
* buildErrorPath("layout[0]", "args.title")
* // => "layout[0].args.title"
*
* buildErrorPath("", "args")
* // => "args"
*/
export function buildErrorPath(basePath, segment) {
if (!basePath) {
return segment;
}
if (!segment) {
return basePath;
}
return `${basePath}.${segment}`;
}
/**
* Applies default values from block metadata to provided args.
*
* When a block is configured with args, this function merges the provided
* args with default values from the block's metadata schema. Default values
* are only applied when the arg is undefined in the provided args.
*
* @param {import("discourse/lib/blocks/-internals/registry/block").BlockClass} ComponentClass - The block component class.
* @param {Object} providedArgs - The args provided in the layout entry.
* @returns {Readonly<Object>} A new object with defaults applied for missing args.
*
* @example
* ```javascript
* // Block metadata: { args: { title: { default: "Hello" }, count: { default: 0 } } }
* applyArgDefaults(MyBlock, { title: "Custom" });
* // => { title: "Custom", count: 0 }
* ```
*/
export function applyArgDefaults(ComponentClass, providedArgs) {
const schema = getBlockMetadata(ComponentClass)?.args;
const result = { ...providedArgs };
// apply default values
if (schema) {
for (const [argName, argDef] of Object.entries(schema)) {
if (result[argName] === undefined && argDef.default !== undefined) {
result[argName] = argDef.default;
}
}
}
return Object.freeze(result);
}
/**
* Performs a shallow comparison of two args objects.
*
* Compares top-level values using strict equality (===). Does not perform
* deep comparison of nested objects. Used to determine if cached curried
* components can be reused.
*
* @param {Object|null|undefined} a - First args object.
* @param {Object|null|undefined} b - Second args object.
* @returns {boolean} True if the args are shallowly equal, false otherwise.
*/
export function shallowArgsEqual(a, b) {
if (a === b) {
return true;
}
if (a == null || b == null) {
return false;
}
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) {
return false;
}
return keysA.every((key) => a[key] === b[key]);
}
/**
* Retrieves a value from a nested object using dot-notation path.
*
* This utility safely navigates through nested object properties using a
* dot-separated path string. It handles null/undefined values gracefully
* at any level of the path.
*
* @param {Object} obj - The object to get the value from.
* @param {string} path - Dot-notation path (e.g., "user.trust_level").
* @returns {*} The value at the path, or undefined if not found or if any
* intermediate value is null/undefined.
*
* @example
* const user = { profile: { name: "Alice", settings: { theme: "dark" } } };
* getByPath(user, "profile.name"); // "Alice"
* getByPath(user, "profile.settings.theme"); // "dark"
* getByPath(user, "profile.missing"); // undefined
* getByPath(user, "profile.settings.missing.deep"); // undefined (safe)
*/
export function getByPath(obj, path) {
if (!obj || !path) {
return undefined;
}
const parts = path.split(".");
let current = obj;
for (const part of parts) {
if (current === null || current === undefined) {
return undefined;
}
current = current[part];
}
return current;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,195 @@
// @ts-check
/**
* Block-specific arg validation.
*
* This module adapts the shared arg validation utilities from args.js
* for use with blocks. Key differences from condition arg validation:
* - Supports "default" values (conditions don't use defaults)
* - Validates "required + default" contradiction
* - Supports childArgs with "unique" property
*
* @module discourse/lib/blocks/-internals/validation/block-args
*/
import { getBlockMetadata } from "discourse/lib/blocks/-internals/decorator";
import {
BlockError,
raiseBlockError,
} from "discourse/lib/blocks/-internals/error";
import {
VALID_ARG_SCHEMA_PROPERTIES,
validateArgName,
validateArgsAgainstSchema,
validateArgSchemaEntry,
validateArgValue,
} from "discourse/lib/blocks/-internals/validation/args";
/**
* Valid properties for childArgs schema definitions.
* Includes all standard arg properties plus "unique" for sibling uniqueness validation.
*/
export const VALID_CHILD_ARG_SCHEMA_PROPERTIES = Object.freeze([
...VALID_ARG_SCHEMA_PROPERTIES,
"unique",
]);
/**
* Validates block-specific default value rules:
* - "required + default" is contradictory (an arg with a default is never missing)
* - Default value must match the arg's type schema
*
* @param {Object} argDef - The argument definition.
* @param {string} argName - The argument name.
* @param {string} blockName - Block name for error messages.
* @param {string} [argLabel="arg"] - Label for error messages (e.g., "childArgs arg").
*/
function validateBlockDefaultValue(
argDef,
argName,
blockName,
argLabel = "arg"
) {
// Check for required + default contradiction (value-based, not presence-based)
// An arg with required: false + default is valid, so we check required === true
if (argDef.required === true && argDef.default !== undefined) {
raiseBlockError(
`Block "${blockName}": ${argLabel} "${argName}" has both "required: true" and "default". ` +
`These options are contradictory - an arg with a default value is never missing.`
);
}
if (argDef.default !== undefined) {
const defaultError = validateArgValue(argDef.default, argDef, argName, {
contextName: blockName,
contextType: "Block",
});
if (defaultError) {
raiseBlockError(
`Block "${blockName}": ${argLabel} "${argName}" has invalid default value. ${defaultError.message}`
);
}
}
}
/**
* Validates the arg schema definition passed to the @block decorator.
* Enforces strict schema format - unknown properties are not allowed.
*
* @param {Object} argsSchema - The args schema object from decorator options.
* @param {string} blockName - Block name for error messages.
* @throws {Error} If schema is invalid.
*/
export function validateArgsSchema(argsSchema, blockName) {
if (!argsSchema || typeof argsSchema !== "object") {
return;
}
for (const [argName, argDef] of Object.entries(argsSchema)) {
if (
!validateArgName(argName, { entityName: blockName, entityType: "Block" })
) {
continue;
}
const shouldContinue = validateArgSchemaEntry(argDef, argName, {
entityName: blockName,
entityType: "Block",
validProperties: VALID_ARG_SCHEMA_PROPERTIES,
});
if (!shouldContinue) {
continue;
}
validateBlockDefaultValue(argDef, argName, blockName);
}
}
/**
* Validates block arguments against the block's metadata arg schema.
* Checks for required args and validates types.
*
* @param {Object} entry - The block entry.
* @param {Object} blockClass - The resolved block class (must be a class, not a string reference).
* @param {Object} [options={}] - Optional configuration.
* @param {Object} [options.owner] - Ember owner for registry lookups (used for "model:*" instanceOf).
* @throws {BlockError} If args are invalid.
*/
export function validateBlockArgs(entry, blockClass, options = {}) {
const metadata = getBlockMetadata(blockClass);
const providedArgs = entry.args || {};
const hasProvidedArgs = Object.keys(providedArgs).length > 0;
const argsSchema = metadata?.args;
// If args are provided but no schema exists, reject them
if (hasProvidedArgs && !argsSchema) {
const argNames = Object.keys(providedArgs).join(", ");
throw new BlockError(
`args were provided (${argNames}) but this block does not declare an args schema. ` +
`Add an args schema to the @block decorator or remove the args.`,
{ path: "args" }
);
}
// No schema and no args - nothing to validate
if (!argsSchema) {
return;
}
validateArgsAgainstSchema(providedArgs, argsSchema, "args", options);
}
/**
* Validates the childArgs schema definition passed to the @block decorator.
* Similar to validateArgsSchema but supports the additional "unique" property
* for enforcing uniqueness across sibling children.
*
* @param {Object} childArgsSchema - The childArgs schema object from decorator options.
* @param {string} blockName - Block name for error messages.
* @throws {Error} If schema is invalid.
*/
export function validateChildArgsSchema(childArgsSchema, blockName) {
if (!childArgsSchema || typeof childArgsSchema !== "object") {
return;
}
for (const [argName, argDef] of Object.entries(childArgsSchema)) {
if (
!validateArgName(argName, {
entityName: blockName,
entityType: "Block",
argLabel: "childArgs arg",
})
) {
continue;
}
const shouldContinue = validateArgSchemaEntry(argDef, argName, {
entityName: blockName,
entityType: "Block",
validProperties: VALID_CHILD_ARG_SCHEMA_PROPERTIES,
argLabel: "childArgs arg",
});
if (!shouldContinue) {
continue;
}
// childArgs-specific: validate "unique" is a boolean if provided
if (argDef.unique !== undefined && typeof argDef.unique !== "boolean") {
raiseBlockError(
`Block "${blockName}": childArgs arg "${argName}" has invalid "unique" value. Must be a boolean.`
);
}
// childArgs-specific: validate "unique" is only used with primitive types
if (argDef.unique === true && argDef.type === "array") {
raiseBlockError(
`Block "${blockName}": childArgs arg "${argName}" has "unique: true" but type is "array". ` +
`Uniqueness validation is only supported for primitive types (string, number, boolean).`
);
}
validateBlockDefaultValue(argDef, argName, blockName, "childArgs arg");
}
}
@@ -0,0 +1,119 @@
/**
* Block Decorator Validation
*
* This module contains validation functions used by the @block decorator.
* These validations run at decoration time (not render time) for fail-fast behavior.
*/
import { raiseBlockError } from "discourse/lib/blocks/-internals/error";
import {
detectPatternConflicts,
validateOutletPatterns,
warnUnknownOutletPatterns,
} from "discourse/lib/blocks/-internals/matching/outlet-matcher";
import {
parseBlockName,
VALID_NAMESPACED_BLOCK_PATTERN,
} from "discourse/lib/blocks/-internals/patterns";
import { formatWithSuggestion } from "discourse/lib/string-similarity";
/**
* Valid keys for the @block decorator options (block schema).
*
* @constant {ReadonlyArray<string>}
*/
export const VALID_BLOCK_OPTIONS = Object.freeze([
"container",
"classNames",
"description",
"args",
"childArgs",
"constraints",
"validate",
"allowedOutlets",
"deniedOutlets",
]);
/**
* Validates the options object passed to the @block decorator.
* Checks for unknown keys and provides suggestions for typos.
*
* @param {string} name - The block name (for error messages).
* @param {Object} options - The options object to validate.
*/
export function validateBlockOptions(name, options) {
if (options && typeof options === "object") {
const unknownKeys = Object.keys(options).filter(
(key) => !VALID_BLOCK_OPTIONS.includes(key)
);
if (unknownKeys.length > 0) {
const suggestions = unknownKeys
.map((key) => formatWithSuggestion(key, VALID_BLOCK_OPTIONS))
.join(", ");
raiseBlockError(
`@block("${name}"): unknown option(s): ${suggestions}. ` +
`Valid options are: ${VALID_BLOCK_OPTIONS.join(", ")}.`
);
}
}
}
/**
* Validates and parses the block name.
* Ensures the name follows the required format for core, plugin, or theme blocks.
*
* @param {string} name - The block name to validate.
* @returns {import("discourse/lib/blocks/-internals/patterns").ParsedBlockName} Parsed name components.
*/
export function validateAndParseBlockName(name) {
if (!VALID_NAMESPACED_BLOCK_PATTERN.test(name)) {
raiseBlockError(
`Block name "${name}" is invalid. ` +
`Valid formats: "block-name" (core), "plugin:block-name" (plugin), ` +
`"theme:namespace:block-name" (theme).`
);
}
const parsed = parseBlockName(name);
if (!parsed) {
// This shouldn't happen if VALID_NAMESPACED_BLOCK_PATTERN passed, but be defensive
raiseBlockError(`Block name "${name}" could not be parsed.`);
}
return parsed;
}
/**
* Validates outlet restriction patterns (allowedOutlets and deniedOutlets).
* Checks for valid picomatch syntax and detects conflicts between patterns.
*
* @param {string} name - The block name (for error messages).
* @param {string[]|null} allowedOutlets - Allowed outlet patterns.
* @param {string[]|null} deniedOutlets - Denied outlet patterns.
*/
export function validateOutletRestrictions(
name,
allowedOutlets,
deniedOutlets
) {
// Validate outlet patterns are valid picomatch syntax (arrays of strings)
validateOutletPatterns(allowedOutlets, name, "allowedOutlets");
validateOutletPatterns(deniedOutlets, name, "deniedOutlets");
// Detect conflicts between allowed and denied patterns.
// This prevents configurations where a block is both allowed AND denied
// in the same outlet, which would be confusing and likely a mistake.
const conflict = detectPatternConflicts(allowedOutlets, deniedOutlets);
if (conflict.conflict) {
raiseBlockError(
`Block "${name}": outlet "${conflict.details.outlet}" matches both ` +
`allowedOutlets pattern "${conflict.details.allowed}" and ` +
`deniedOutlets pattern "${conflict.details.denied}".`
);
}
// Warn if patterns don't match any known outlet (possible typos).
// This checks against both core outlets and custom outlets registered
// by plugins/themes.
warnUnknownOutletPatterns(allowedOutlets, name, "allowedOutlets");
warnUnknownOutletPatterns(deniedOutlets, name, "deniedOutlets");
}
@@ -0,0 +1,134 @@
// @ts-check
/**
* Condition-specific arg validation.
*
* This module adapts the shared arg validation utilities from args.js
* for use with block conditions. Key differences from block arg validation:
* - Error messages use "Condition" instead of "Block"
* - The "default" property is not allowed (conditions don't use defaults)
* - Type validation happens at registration time
*
* @module discourse/lib/blocks/-internals/validation/condition-args
*/
import { BlockError } from "discourse/lib/blocks/-internals/error";
import {
VALID_ARG_SCHEMA_PROPERTIES,
validateArgName,
validateArgSchemaEntry,
validateArgValue,
} from "discourse/lib/blocks/-internals/validation/args";
/**
* Disallowed properties for condition arg schemas.
* Maps property names to their specific error messages.
* The "default" property is disallowed because conditions don't apply defaults -
* they check explicitly for undefined values to determine what was provided.
*/
const DISALLOWED_CONDITION_PROPERTIES = Object.freeze({
default: "Conditions do not support default values.",
});
/**
* Valid properties for condition arg schemas.
* Includes all standard arg properties except those in DISALLOWED_CONDITION_PROPERTIES.
*/
export const VALID_CONDITION_ARG_PROPERTIES = Object.freeze(
VALID_ARG_SCHEMA_PROPERTIES.filter(
(p) => !Object.hasOwn(DISALLOWED_CONDITION_PROPERTIES, p)
)
);
/**
* Validates the arg schema definition passed to the @blockCondition decorator.
* Enforces strict schema format - unknown properties are not allowed.
* Called at decoration time to catch schema errors early.
*
* @param {Object} argsSchema - The args schema object from decorator options.
* @param {string} conditionType - Condition type name for error messages.
* @throws {Error} If schema is invalid.
*/
export function validateConditionArgsSchema(argsSchema, conditionType) {
if (!argsSchema || typeof argsSchema !== "object") {
return;
}
for (const [argName, argDef] of Object.entries(argsSchema)) {
if (
!validateArgName(argName, {
entityName: conditionType,
entityType: "Condition",
})
) {
continue;
}
// Conditions have no additional validation after the shared entry validation
validateArgSchemaEntry(argDef, argName, {
entityName: conditionType,
entityType: "Condition",
validProperties: VALID_CONDITION_ARG_PROPERTIES,
disallowedProperties: DISALLOWED_CONDITION_PROPERTIES,
allowAnyType: true,
});
}
}
/**
* Formats an error message for condition arg validation.
*
* @param {string} argName - The argument name.
* @param {string} message - The error message.
* @param {string} conditionType - The condition type name.
* @returns {string} Formatted error message.
*/
function formatConditionArgError(argName, message, conditionType) {
return `Condition "${conditionType}": arg "${argName}" ${message}`;
}
/**
* Validates provided arg values against the condition's schema.
* Called at block registration time to catch invalid values early.
*
* @param {Object} args - The arguments provided to the condition.
* @param {Object} argsSchema - The condition's args schema.
* @param {string} conditionType - The condition type for error messages.
* @param {string} path - The path to this condition in the block tree.
* @throws {BlockError} If validation fails.
*/
export function validateConditionArgValues(
args,
argsSchema,
conditionType,
path
) {
for (const [argName, argDef] of Object.entries(argsSchema)) {
const value = args[argName];
// Check required args
if (argDef.required && value === undefined) {
throw new BlockError(
`Condition "${conditionType}": missing required arg "${argName}".`,
{ path: path ? `${path}.${argName}` : argName }
);
}
// Skip validation for undefined values or "any" type
if (value === undefined || argDef.type === "any") {
continue;
}
// Validate type if value is provided
const typeError = validateArgValue(value, argDef, argName);
if (typeError) {
throw new BlockError(
formatConditionArgError(
typeError.path,
typeError.message.replace(/^Arg "[^"]+" /, ""),
conditionType
),
{ path: path ? `${path}.${typeError.path}` : typeError.path }
);
}
}
}
@@ -0,0 +1,299 @@
// @ts-check
import { BlockError } from "discourse/lib/blocks/-internals/error";
import { validateConditionArgValues } from "discourse/lib/blocks/-internals/validation/condition-args";
import {
runCustomValidation,
validateConstraints,
} from "discourse/lib/blocks/-internals/validation/constraints";
import { formatWithSuggestion } from "discourse/lib/string-similarity";
/**
* Regex for validating source path format: `@outletArgs.propertyName` or
* `@outletArgs.nested.path`.
*/
const OUTLET_ARGS_SOURCE_PATTERN = /^@outletArgs\.[\w.]+$/;
/**
* Validates the `source` parameter based on the condition's `sourceType`.
*
* - `sourceType: "none"`: Returns error if `source` is provided
* - `sourceType: "outletArgs"`: Validates format is `@outletArgs.propertyPath`
* - `sourceType: "object"`: Validates `source` is an object if provided
*
* @param {"none"|"outletArgs"|"object"} sourceType - The condition's source type.
* @param {Object} args - The condition arguments from the layout entry.
* @returns {{ message: string, path?: string } | null} Error info or null if valid.
*/
export function validateConditionSource(sourceType, args) {
const { source } = args;
if (source === undefined) {
return null; // source is always optional
}
switch (sourceType) {
case "none":
return {
message: `\`source\` parameter is not supported for this condition type.`,
path: "source",
};
case "outletArgs":
if (typeof source !== "string") {
return {
message: `\`source\` must be a string in format "@outletArgs.propertyName".`,
path: "source",
};
}
if (!OUTLET_ARGS_SOURCE_PATTERN.test(source)) {
return {
message: `\`source\` must be in format "@outletArgs.propertyName", got "${source}".`,
path: "source",
};
}
break;
case "object":
if (source !== null && typeof source !== "object") {
return {
message: `\`source\` must be an object.`,
path: "source",
};
}
break;
}
return null;
}
/**
* Validates that all provided args are recognized by a condition.
* Suggests corrections for typos using fuzzy string matching.
*
* @param {import("discourse/blocks/conditions").BlockCondition} instance - The condition instance.
* @param {string} type - The condition type name.
* @param {Object} args - The args provided to the condition.
* @param {string} path - The path to this condition in the block tree.
* @throws {BlockError} If an unrecognized arg key is found.
*/
export function validateConditionArgKeys(instance, type, args, path) {
// validArgKeys already includes "source" when sourceType !== "none"
// (computed by the @blockCondition decorator)
// @ts-ignore - Static property defined on condition classes
const validKeys = instance.constructor.validArgKeys;
for (const key of Object.keys(args)) {
if (!validKeys.includes(key)) {
const suggestion = formatWithSuggestion(key, validKeys);
throw new BlockError(
`Condition type "${type}": unknown arg ${suggestion}. ` +
`Valid args: ${validKeys.join(", ")}`,
{ path: path ? `${path}.${key}` : key }
);
}
}
}
/**
* Validates condition specs at block registration time.
* Recursively validates nested conditions in `any` and `not` combinators.
*
* Throws BlockError objects with a `path` property indicating where in the
* conditions the error occurred. Callers can use this path combined with
* their context path to build full error location.
*
* Note: Paths are constructed via simple string concatenation (e.g.,
* `${path}.${key}`, `${path}[${i}]`). Keys are not escaped, so special
* characters in user-provided keys may cause confusing path displays.
* This is acceptable since condition keys come from theme/plugin layouts
* where unusual characters are rare.
*
* @param {Object|Array<Object>} conditionSpec - Condition spec(s) to validate.
* @param {Map<string, import("discourse/blocks/conditions").BlockCondition>} conditionTypes - Map of registered condition types.
* @param {string} [path=""] - The path to this condition relative to conditions root
* (e.g., "", "[0]", "any[1]", "params.categoryId").
* @throws {BlockError} If validation fails.
*/
export function validateConditions(conditionSpec, conditionTypes, path = "") {
if (!conditionSpec) {
return;
}
// Array of conditions (AND logic)
if (Array.isArray(conditionSpec)) {
for (let i = 0; i < conditionSpec.length; i++) {
validateConditions(conditionSpec[i], conditionTypes, `${path}[${i}]`);
}
return;
}
// OR combinator
if (conditionSpec.any !== undefined) {
validateAnyCombinator(conditionSpec, conditionTypes, path);
return;
}
// NOT combinator
if (conditionSpec.not !== undefined) {
validateNotCombinator(conditionSpec, conditionTypes, path);
return;
}
// Single condition with type
validateSingleCondition(conditionSpec, conditionTypes, path);
}
/**
* Validates an "any" (OR) combinator.
*
* @param {Object} conditionSpec - The condition spec containing "any".
* @param {Map<string, import("discourse/blocks/conditions").BlockCondition>} conditionTypes - Map of registered condition types.
* @param {string} path - The path to this condition in the block tree.
* @throws {BlockError} If validation fails.
*/
function validateAnyCombinator(conditionSpec, conditionTypes, path) {
// Validate no extra keys alongside "any"
const extraKeys = Object.keys(conditionSpec).filter((k) => k !== "any");
if (extraKeys.length > 0) {
throw new BlockError(
`"any" combinator has extra keys: ${extraKeys.join(", ")}. ` +
`Only "any" is allowed.`,
{ path }
);
}
if (!Array.isArray(conditionSpec.any)) {
throw new BlockError('"any" must be an array of conditions', {
path: `${path}.any`,
});
}
for (let i = 0; i < conditionSpec.any.length; i++) {
validateConditions(
conditionSpec.any[i],
conditionTypes,
`${path}.any[${i}]`
);
}
}
/**
* Validates a "not" (NOT) combinator.
*
* @param {Object} conditionSpec - The condition spec containing "not".
* @param {Map<string, import("discourse/blocks/conditions").BlockCondition>} conditionTypes - Map of registered condition types.
* @param {string} path - The path to this condition in the block tree.
* @throws {BlockError} If validation fails.
*/
function validateNotCombinator(conditionSpec, conditionTypes, path) {
// Validate no extra keys alongside "not"
const extraKeys = Object.keys(conditionSpec).filter((k) => k !== "not");
if (extraKeys.length > 0) {
throw new BlockError(
`"not" combinator has extra keys: ${extraKeys.join(", ")}. ` +
`Only "not" is allowed.`,
{ path }
);
}
if (
typeof conditionSpec.not !== "object" ||
Array.isArray(conditionSpec.not)
) {
throw new BlockError('"not" must be a single condition object', {
path: `${path}.not`,
});
}
validateConditions(conditionSpec.not, conditionTypes, `${path}.not`);
}
/**
* Validates a single condition with a type property.
*
* Validation order:
* 1. Validate unknown args (typo detection) - checked FIRST so typos like "nam"
* produce "unknown arg 'nam' (did you mean 'name'?)" instead of "missing required arg 'name'"
* 2. Validate arg values against schema (type, min/max, pattern, etc.)
* 3. Validate constraints (atLeastOne, exactlyOne, allOrNone, atMostOne)
* 4. Validate source parameter (based on sourceType)
* 5. Run custom validate function from decorator config
*
* @param {Object} conditionSpec - The condition spec with a type property.
* @param {Map<string, import("discourse/blocks/conditions").BlockCondition>} conditionTypes - Map of registered condition types.
* @param {string} path - The path to this condition in the block tree.
* @throws {BlockError} If validation fails.
*/
function validateSingleCondition(conditionSpec, conditionTypes, path) {
const { type, ...args } = conditionSpec;
if (!type) {
throw new BlockError(
`Condition is missing "type" property: ${JSON.stringify(conditionSpec)}`,
{ path: path || undefined }
);
}
const conditionInstance = conditionTypes.get(type);
if (!conditionInstance) {
const availableTypes = [...conditionTypes.keys()];
const suggestion = formatWithSuggestion(type, availableTypes);
throw new BlockError(
`Unknown condition type: ${suggestion}. Available types: ${availableTypes.join(", ")}`,
{ path: path ? `${path}.type` : "type" }
);
}
// @ts-ignore - Static properties defined on condition classes
const argsSchema = conditionInstance.constructor.argsSchema;
// @ts-ignore - Static properties defined on condition classes
const constraints = conditionInstance.constructor.constraints;
// @ts-ignore - Static properties defined on condition classes
const validateFn = conditionInstance.constructor.validateFn;
// 1. Validate unknown args (catches typos like "nam" instead of "name")
// This is checked FIRST so typos produce helpful suggestions rather than
// confusing "missing required arg" errors
validateConditionArgKeys(conditionInstance, type, args, path);
// 2. Validate arg values against schema (type, min/max, pattern, etc.)
if (argsSchema && Object.keys(argsSchema).length > 0) {
validateConditionArgValues(args, argsSchema, type, path);
}
// 3. Validate constraints (atLeastOne, exactlyOne, allOrNone, atMostOne)
if (constraints) {
const constraintError = validateConstraints(
constraints,
args,
`Condition "${type}"`
);
if (constraintError) {
// Point to the condition's `type` property so the error location isn't empty.
// This tells users which condition has the constraint violation.
const typePath = path ? `${path}.type` : "type";
throw new BlockError(constraintError, { path: typePath });
}
}
// 4. Validate source parameter (based on sourceType)
// @ts-ignore - Static property defined on condition classes
const sourceType = conditionInstance.constructor.sourceType;
const sourceError = validateConditionSource(sourceType, args);
if (sourceError) {
throw new BlockError(sourceError.message, {
path: sourceError.path ? `${path}.${sourceError.path}` : path,
});
}
// 5. Run custom validate function from decorator config
if (validateFn) {
const customErrors = runCustomValidation(validateFn, args);
if (customErrors?.length > 0) {
throw new BlockError(`Condition "${type}": ${customErrors.join("; ")}`, {
path,
});
}
}
}
@@ -0,0 +1,488 @@
// @ts-check
/**
* Cross-arg constraint validation for blocks.
*
* This module handles validation rules that span multiple arguments, such as
* "at least one of these args must be provided" or "these args must be provided together."
*
* Supported constraint types:
* - atLeastOne: At least one of the specified args must be provided
* - exactlyOne: Exactly one of the specified args must be provided
* - allOrNone: Either all or none of the specified args must be provided
* - atMostOne: At most one of the specified args may be provided (0 or 1)
* - requires: If a dependent arg is provided, its required arg must also be provided
*
* @module discourse/lib/blocks/-internals/validation/constraints
*/
import { raiseBlockError } from "discourse/lib/blocks/-internals/error";
import { formatWithSuggestion } from "discourse/lib/string-similarity";
/**
* Valid constraint types for cross-arg validation.
*/
export const VALID_CONSTRAINT_TYPES = Object.freeze([
"atLeastOne",
"exactlyOne",
"allOrNone",
"atMostOne",
"requires",
]);
/**
* Formats an array of arg names as a quoted, comma-separated list.
*
* @param {string[]} argNames - Array of argument names.
* @returns {string} Formatted string like `"a", "b", "c"`.
*/
function formatArgList(argNames) {
return argNames.map((n) => `"${n}"`).join(", ");
}
/**
* Validates the constraints schema at decoration time.
* Checks for:
* - Valid constraint types
* - Arg references exist in the args schema
* - Constraint arrays have at least 2 elements
* - Incompatible constraints (exactlyOne + allOrNone, exactlyOne + atLeastOne)
* - Vacuous constraints (constraints rendered always true/false by defaults)
*
* @param {Object} constraints - The constraints object from decorator options.
* @param {Object} argsSchema - The args schema object from decorator options.
* @param {string} blockName - Block name for error messages.
*/
export function validateConstraintsSchema(constraints, argsSchema, blockName) {
if (!constraints || typeof constraints !== "object") {
return;
}
const declaredArgs = argsSchema ? Object.keys(argsSchema) : [];
const constraintsByArgs = new Map();
for (const [constraintType, argNames] of Object.entries(constraints)) {
// Check for unknown constraint types with fuzzy matching
if (!VALID_CONSTRAINT_TYPES.includes(constraintType)) {
const suggestion = formatWithSuggestion(
constraintType,
VALID_CONSTRAINT_TYPES
);
raiseBlockError(
`Block "${blockName}": unknown constraint type ${suggestion}. ` +
`Valid constraint types are: ${VALID_CONSTRAINT_TYPES.join(", ")}.`
);
continue;
}
// Handle requires constraint (object format instead of array)
if (constraintType === "requires") {
if (
typeof argNames !== "object" ||
Array.isArray(argNames) ||
argNames == null
) {
raiseBlockError(
`Block "${blockName}": constraint "requires" must be an object mapping dependent args to required args.`
);
continue;
}
for (const [dependentArg, requiredArg] of Object.entries(argNames)) {
// Validate dependent arg exists
if (!declaredArgs.includes(dependentArg)) {
const suggestion = formatWithSuggestion(dependentArg, declaredArgs);
raiseBlockError(
`Block "${blockName}": constraint "requires" references unknown arg ${suggestion}.`
);
}
// Validate required arg is a string
if (typeof requiredArg !== "string") {
raiseBlockError(
`Block "${blockName}": constraint "requires" value for "${dependentArg}" must be a string arg name.`
);
continue;
}
// Validate required arg exists
if (!declaredArgs.includes(requiredArg)) {
const suggestion = formatWithSuggestion(requiredArg, declaredArgs);
raiseBlockError(
`Block "${blockName}": constraint "requires" references unknown arg ${suggestion}.`
);
}
}
continue; // Skip array-based validation for requires
}
// Constraint value must be an array
if (!Array.isArray(argNames)) {
raiseBlockError(
`Block "${blockName}": constraint "${constraintType}" must be an array of arg names.`
);
continue;
}
// Constraint array must have at least 2 elements
if (argNames.length < 2) {
raiseBlockError(
`Block "${blockName}": constraint "${constraintType}" must reference at least 2 args.`
);
continue;
}
// Check that all referenced args exist in the schema
for (const argName of argNames) {
if (typeof argName !== "string") {
raiseBlockError(
`Block "${blockName}": constraint "${constraintType}" contains non-string value "${argName}".`
);
continue;
}
if (!declaredArgs.includes(argName)) {
const suggestion = formatWithSuggestion(argName, declaredArgs);
raiseBlockError(
`Block "${blockName}": constraint "${constraintType}" references unknown arg ${suggestion}. ` +
`Declared args are: ${declaredArgs.join(", ") || "none"}.`
);
}
}
// Track constraints by their arg sets for incompatibility detection
const sortedArgs = [...argNames].sort().join(",");
if (!constraintsByArgs.has(sortedArgs)) {
constraintsByArgs.set(sortedArgs, []);
}
constraintsByArgs.get(sortedArgs).push(constraintType);
// Check for vacuous constraints (always true or always false due to defaults)
if (argsSchema) {
checkVacuousConstraint(constraintType, argNames, argsSchema, blockName);
}
}
// Check for incompatible constraints on the same args
for (const [argSet, constraintTypes] of constraintsByArgs) {
if (constraintTypes.length > 1) {
checkIncompatibleConstraints(constraintTypes, argSet, blockName);
}
}
}
/**
* Checks if a constraint is vacuous (always true or always false) due to default values.
*
* @param {string} constraintType - The constraint type.
* @param {string[]} argNames - The arg names in the constraint.
* @param {Object} argsSchema - The args schema.
* @param {string} blockName - Block name for error messages.
*/
function checkVacuousConstraint(
constraintType,
argNames,
argsSchema,
blockName
) {
const argsWithDefaults = argNames.filter(
(name) => argsSchema[name]?.default !== undefined
);
const argsWithoutDefaults = argNames.filter(
(name) => argsSchema[name]?.default === undefined
);
switch (constraintType) {
case "atLeastOne":
// Always true if any arg has a default
if (argsWithDefaults.length > 0) {
raiseBlockError(
`Block "${blockName}": constraint atLeastOne([${formatArgList(argNames)}]) ` +
`is always true because "${argsWithDefaults[0]}" has a default value.`
);
}
break;
case "exactlyOne":
// Always false if 2+ args have defaults (both will always be provided)
if (argsWithDefaults.length >= 2) {
raiseBlockError(
`Block "${blockName}": constraint exactlyOne([${formatArgList(argNames)}]) ` +
`is always false because multiple args have default values: ${formatArgList(argsWithDefaults)}.`
);
}
// Always true if exactly one arg has a default and all others have no default
// (the one with default is always provided, others never are unless explicitly set)
// This is NOT vacuous - it's a valid constraint that forces users to not provide
// any of the other args, or to provide exactly one of the non-default args
break;
case "allOrNone":
// Always false if some but not all args have defaults
if (argsWithDefaults.length > 0 && argsWithoutDefaults.length > 0) {
raiseBlockError(
`Block "${blockName}": constraint allOrNone([${formatArgList(argNames)}]) ` +
`is always false because only some args have defaults: ${formatArgList(argsWithDefaults)} ` +
`have defaults but ${formatArgList(argsWithoutDefaults)} do not.`
);
}
// If all have defaults or none have defaults, constraint is not vacuous
break;
case "atMostOne":
// Always false if 2+ args have defaults (both will always be provided)
if (argsWithDefaults.length >= 2) {
raiseBlockError(
`Block "${blockName}": constraint atMostOne([${formatArgList(argNames)}]) ` +
`is always false because multiple args have default values: ${formatArgList(argsWithDefaults)}.`
);
}
break;
}
}
/**
* Checks for incompatible constraint types on the same arg set.
*
* @param {string[]} constraintTypes - The constraint types applied to the same args.
* @param {string} argSet - The sorted arg names (for error message).
* @param {string} blockName - Block name for error messages.
*/
function checkIncompatibleConstraints(constraintTypes, argSet, blockName) {
const argList = argSet
.split(",")
.map((n) => `"${n}"`)
.join(", ");
// exactlyOne + allOrNone = contradiction (XOR vs all-or-nothing)
if (
constraintTypes.includes("exactlyOne") &&
constraintTypes.includes("allOrNone")
) {
raiseBlockError(
`Block "${blockName}": constraints "exactlyOne" and "allOrNone" conflict for args [${argList}]. ` +
`"exactlyOne" requires exactly one arg, but "allOrNone" requires all or none.`
);
}
// exactlyOne + atLeastOne = redundant (exactlyOne implies atLeastOne)
if (
constraintTypes.includes("exactlyOne") &&
constraintTypes.includes("atLeastOne")
) {
raiseBlockError(
`Block "${blockName}": constraint "atLeastOne" is redundant with "exactlyOne" for args [${argList}]. ` +
`"exactlyOne" already implies at least one must be provided.`
);
}
// atMostOne + atLeastOne = redundant (equivalent to exactlyOne)
if (
constraintTypes.includes("atMostOne") &&
constraintTypes.includes("atLeastOne")
) {
raiseBlockError(
`Block "${blockName}": constraints "atMostOne" and "atLeastOne" together for args [${argList}] ` +
`are equivalent to "exactlyOne". Use "exactlyOne" instead.`
);
}
// atMostOne + exactlyOne = redundant (exactlyOne implies atMostOne)
if (
constraintTypes.includes("atMostOne") &&
constraintTypes.includes("exactlyOne")
) {
raiseBlockError(
`Block "${blockName}": constraint "atMostOne" is redundant with "exactlyOne" for args [${argList}]. ` +
`"exactlyOne" already implies at most one may be provided.`
);
}
}
/**
* Validates constraints against the provided args at runtime.
* Called after defaults are applied.
*
* @param {Object} constraints - The constraints from block metadata.
* @param {Object} args - The resolved args (with defaults applied).
* @param {string} blockName - Block name for error messages.
* @returns {string|null} Error message if validation fails, null otherwise.
*/
export function validateConstraints(constraints, args, blockName) {
if (!constraints || typeof constraints !== "object") {
return null;
}
for (const [constraintType, argNames] of Object.entries(constraints)) {
let error = null;
// Handle requires constraint (object format)
if (constraintType === "requires") {
if (typeof argNames === "object" && !Array.isArray(argNames)) {
error = validateRequires(argNames, args, blockName);
}
} else if (Array.isArray(argNames)) {
// Handle array-based constraints
switch (constraintType) {
case "atLeastOne":
error = validateAtLeastOne(argNames, args, blockName);
break;
case "exactlyOne":
error = validateExactlyOne(argNames, args, blockName);
break;
case "allOrNone":
error = validateAllOrNone(argNames, args, blockName);
break;
case "atMostOne":
error = validateAtMostOne(argNames, args, blockName);
break;
}
}
if (error) {
return error;
}
}
return null;
}
/**
* Validates that at least one of the specified args is provided.
*
* @param {string[]} argNames - The arg names to check.
* @param {Object} args - The resolved args.
* @param {string} blockName - The block name for error messages.
* @returns {string|null} Error message if validation fails, null otherwise.
*/
function validateAtLeastOne(argNames, args, blockName) {
const providedCount = argNames.filter(
(name) => args[name] !== undefined
).length;
if (providedCount === 0) {
const argList = formatArgList(argNames);
return `Block "${blockName}": at least one of ${argList} must be provided.`;
}
return null;
}
/**
* Validates that exactly one of the specified args is provided.
*
* @param {string[]} argNames - The arg names to check.
* @param {Object} args - The resolved args.
* @param {string} blockName - The block name for error messages.
* @returns {string|null} Error message if validation fails, null otherwise.
*/
function validateExactlyOne(argNames, args, blockName) {
const providedArgs = argNames.filter((name) => args[name] !== undefined);
const argList = formatArgList(argNames);
if (providedArgs.length === 0) {
return `Block "${blockName}": exactly one of ${argList} must be provided, but got none.`;
}
if (providedArgs.length > 1) {
const providedList = formatArgList(providedArgs);
return `Block "${blockName}": exactly one of ${argList} must be provided, but got ${providedArgs.length}: ${providedList}.`;
}
return null;
}
/**
* Validates that either all or none of the specified args are provided.
*
* @param {string[]} argNames - The arg names to check.
* @param {Object} args - The resolved args.
* @param {string} blockName - The block name for error messages.
* @returns {string|null} Error message if validation fails, null otherwise.
*/
function validateAllOrNone(argNames, args, blockName) {
const providedCount = argNames.filter(
(name) => args[name] !== undefined
).length;
// Valid: all provided or none provided
if (providedCount === 0 || providedCount === argNames.length) {
return null;
}
// Invalid: some but not all
const providedArgs = argNames.filter((name) => args[name] !== undefined);
const missingArgs = argNames.filter((name) => args[name] === undefined);
const argList = formatArgList(argNames);
return (
`Block "${blockName}": args ${argList} must be provided together or not at all. ` +
`Got ${formatArgList(providedArgs)} but missing ${formatArgList(missingArgs)}.`
);
}
/**
* Validates that at most one of the specified args is provided (0 or 1).
*
* @param {string[]} argNames - The arg names to check.
* @param {Object} args - The resolved args.
* @param {string} blockName - The block name for error messages.
* @returns {string|null} Error message if validation fails, null otherwise.
*/
function validateAtMostOne(argNames, args, blockName) {
const providedArgs = argNames.filter((name) => args[name] !== undefined);
if (providedArgs.length > 1) {
const providedList = formatArgList(providedArgs);
const argList = formatArgList(argNames);
return `Block "${blockName}": at most one of ${argList} may be provided, but got ${providedArgs.length}: ${providedList}.`;
}
return null;
}
/**
* Validates that if a dependent arg is provided, its required arg must also be provided.
*
* @param {Object} requiresMap - Object mapping dependent args to required args.
* @param {Object} args - The resolved args.
* @param {string} blockName - The block name for error messages.
* @returns {string|null} Error message if validation fails, null otherwise.
*/
function validateRequires(requiresMap, args, blockName) {
for (const [dependentArg, requiredArg] of Object.entries(requiresMap)) {
if (args[dependentArg] !== undefined && args[requiredArg] === undefined) {
return `Block "${blockName}": "${dependentArg}" requires "${requiredArg}" to be specified.`;
}
}
return null;
}
/**
* Runs a custom validation function if provided.
*
* @param {Function} validateFn - The custom validate function.
* @param {Object} args - The resolved args (with defaults applied).
* @returns {string[]|null} Array of error messages if validation fails, null otherwise.
*/
export function runCustomValidation(validateFn, args) {
if (typeof validateFn !== "function") {
return null;
}
const result = validateFn(args);
if (result == null) {
return null;
}
// Normalize to array
if (typeof result === "string") {
return [result];
}
if (Array.isArray(result)) {
// Filter out non-string values and empty strings
const errors = result.filter((e) => typeof e === "string" && e.length > 0);
return errors.length > 0 ? errors : null;
}
// Invalid return type - ignore
return null;
}
@@ -0,0 +1,948 @@
// @ts-check
/**
* Outlet layout validation utilities.
*
* This module provides validation for outlet layouts passed to renderBlocks().
* It validates block entries, container/children relationships, args against
* block schemas, and conditions.
*
* Terminology:
* - **Block Entry**: An object in a layout that specifies how to use a block.
* - **Outlet Layout**: An array of block entries defining which blocks appear in an outlet.
*
* @module discourse/lib/blocks/-internals/validation/layout
*/
import { DEBUG } from "@glimmer/env";
import { getOwner } from "@ember/owner";
import { getBlockMetadata } from "discourse/lib/blocks/-internals/decorator";
import {
BlockError,
raiseBlockError,
} from "discourse/lib/blocks/-internals/error";
import { isBlockPermittedInOutlet } from "discourse/lib/blocks/-internals/matching/outlet-matcher";
import {
MAX_LAYOUT_DEPTH,
OPTIONAL_MISSING,
parseBlockReference,
VALID_BLOCK_ID_PATTERN,
} from "discourse/lib/blocks/-internals/patterns";
import {
hasBlock,
isBlockResolved,
resolveBlock,
} from "discourse/lib/blocks/-internals/registry/block";
import {
getAllOutlets,
isValidOutlet,
} from "discourse/lib/blocks/-internals/registry/outlet";
import {
applyArgDefaults,
buildErrorPath,
createValidationContext,
} from "discourse/lib/blocks/-internals/utils";
import { validateArgsAgainstSchema } from "discourse/lib/blocks/-internals/validation/args";
import { validateBlockArgs } from "discourse/lib/blocks/-internals/validation/block-args";
import {
runCustomValidation,
validateConstraints,
} from "discourse/lib/blocks/-internals/validation/constraints";
import { formatWithSuggestion } from "discourse/lib/string-similarity";
/**
* Wraps a validation function call with BlockError handling.
* Catches errors with a `path` property and re-raises with full context.
*
* @param {Function} validationFn - The validation function to call.
* @param {string} errorPrefix - Prefix for the error message.
* @param {Object} context - Error context including outletName, blockName, path, etc.
*/
function wrapValidationError(validationFn, errorPrefix, context) {
try {
validationFn();
} catch (error) {
// Errors with path property need context enrichment
if (error.path) {
raiseBlockError(`${errorPrefix}: ${error.message}`, {
...context,
errorPath: buildErrorPath(context.path, error.path),
});
}
throw error;
}
}
/**
* Validates that a block is permitted in the specified outlet.
* Checks allowedOutlets and deniedOutlets metadata if present.
*
* @param {Object} metadata - Block metadata with outlet restrictions.
* @param {string} outletName - The outlet being validated.
* @param {string} blockName - The block name for error messages.
* @param {Object} context - Error context for raiseBlockError.
* @returns {boolean} True if validation passed, false if error was raised.
*/
function validateOutletPermission(metadata, outletName, blockName, context) {
if (!metadata?.allowedOutlets && !metadata?.deniedOutlets) {
return true;
}
const permission = isBlockPermittedInOutlet(
outletName,
metadata.allowedOutlets,
metadata.deniedOutlets
);
if (!permission.permitted) {
raiseBlockError(
`Block "${blockName}" at ${context.path} cannot be rendered in outlet "${outletName}": ${permission.reason}.`,
context
);
return false;
}
return true;
}
/**
* Validates container/children relationship.
* Containers must have children, non-containers cannot have children.
*
* @param {Object} entry - The block entry.
* @param {boolean} isContainer - Whether the block is a container.
* @param {string} blockName - The block name for error messages.
* @param {string} outletName - The outlet name for error messages.
* @param {Object} context - Error context for raiseBlockError.
* @returns {boolean} True if validation passed, false if error was raised.
*/
function validateContainerChildren(
entry,
isContainer,
blockName,
outletName,
context
) {
const hasChildren = entry.children?.length > 0;
if (hasChildren && !isContainer) {
raiseBlockError(
`Block component ${blockName} in layout ${outletName} cannot have children`,
context
);
return false;
}
if (isContainer && !hasChildren) {
raiseBlockError(
`Block component ${blockName} in layout ${outletName} must have children`,
context
);
return false;
}
return true;
}
/**
* Validates block constraints and custom validation functions.
* Applies arg defaults before validation.
*
* @param {Object} metadata - Block metadata with constraints/validate.
* @param {Object} resolvedBlock - The resolved block class.
* @param {Object} entry - The block entry.
* @param {string} blockName - The block name for error messages.
* @param {Object} context - Error context for raiseBlockError.
*/
function validateBlockConstraints(
metadata,
resolvedBlock,
entry,
blockName,
context
) {
if (!metadata?.constraints && !metadata?.validate) {
return;
}
const argsWithDefaults = applyArgDefaults(resolvedBlock, entry.args || {});
// Validate declarative constraints
if (metadata.constraints) {
const constraintError = validateConstraints(
metadata.constraints,
argsWithDefaults,
blockName
);
if (constraintError) {
raiseBlockError(
`Invalid block "${blockName}" at ${context.path} for outlet "${context.outletName}": ${constraintError}`,
{ ...context, errorPath: "constraints" }
);
}
}
// Run custom validation function
if (metadata.validate) {
const customErrors = runCustomValidation(
metadata.validate,
argsWithDefaults
);
if (customErrors?.length > 0) {
const errorMessage =
customErrors.length === 1
? customErrors[0]
: customErrors.map((e) => ` - ${e}`).join("\n");
raiseBlockError(
`Invalid block "${blockName}" at ${context.path} for outlet "${context.outletName}": ${errorMessage}`,
{ ...context, errorPath: "validate" }
);
}
}
}
/**
* Validates a child block's containerArgs against the parent container's childArgs schema.
* Reuses the shared validateArgsAgainstSchema function for core validation logic.
*
* @param {Object} childEntry - The child block entry.
* @param {Object} parentChildArgsSchema - The parent's childArgs schema.
* @param {string} parentName - Parent block name for error messages.
* @param {Object} context - Error context.
*/
function validateContainerArgs(
childEntry,
parentChildArgsSchema,
parentName,
context
) {
const providedArgs = childEntry.containerArgs || {};
try {
validateArgsAgainstSchema(
providedArgs,
parentChildArgsSchema,
"containerArgs"
);
} catch (error) {
// Enhance error message with parent context
raiseBlockError(
`Child block at ${context.path} ${error.message} (required by parent "${parentName}").`,
{
...context,
errorPath: buildErrorPath(context.path, error.path),
}
);
}
}
/**
* Validates uniqueness constraints for containerArgs across all sibling children.
*
* @param {Array<Object>} childEntries - Array of child block entries.
* @param {Object} childArgsSchema - The parent's childArgs schema.
* @param {string} parentName - Parent block name for error messages.
* @param {string} parentPath - Path to parent for error context.
* @param {Object} context - Error context.
*/
function validateContainerArgsUniqueness(
childEntries,
childArgsSchema,
parentName,
parentPath,
context
) {
// Find args with unique: true
const uniqueArgs = Object.entries(childArgsSchema)
.filter(([, schema]) => schema.unique)
.map(([name]) => name);
for (const argName of uniqueArgs) {
const seenValues = new Map(); // value -> index of first occurrence
for (let i = 0; i < childEntries.length; i++) {
const childEntry = childEntries[i];
const value = childEntry.containerArgs?.[argName];
// Skip undefined values (uniqueness only applies to provided values)
if (value === undefined) {
continue;
}
if (seenValues.has(value)) {
const firstIndex = seenValues.get(value);
raiseBlockError(
`Duplicate value "${value}" for containerArgs.${argName} in children of "${parentName}". ` +
`Found at children[${firstIndex}] and children[${i}]. ` +
`The "${argName}" arg must be unique among siblings.`,
{
...context,
path: `${parentPath}.children[${i}]`,
errorPath: `${parentPath}.children[${i}].containerArgs.${argName}`,
}
);
}
seenValues.set(value, i);
}
}
}
/**
* Validates that a block entry's `id` matches the required pattern.
* IDs must start with a lowercase letter and contain only lowercase letters,
* numbers, and hyphens (same format as block names).
*
* @param {Object} entry - The block entry.
* @throws {BlockError} If the id format is invalid.
*/
export function validateEntryIdFormat(entry) {
if (!entry.id) {
return;
}
if (!VALID_BLOCK_ID_PATTERN.test(entry.id)) {
throw new BlockError(
`"id" value "${entry.id}" is invalid. ` +
`IDs must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens.`,
{ path: "id" }
);
}
}
/**
* Validates that containerArgs is not provided when parent has no childArgs.
* Follows the pattern: error in dev/test, warn in production.
*
* @param {Object} entry - The block entry.
* @param {Object} parentChildArgsSchema - The parent's childArgs schema (null if none).
* @param {Object} context - Error context.
*/
function validateOrphanContainerArgs(entry, parentChildArgsSchema, context) {
if (entry.containerArgs && !parentChildArgsSchema) {
const message =
`Block at ${context.path} has "containerArgs" but parent container does not declare "childArgs". ` +
`Remove the containerArgs or add a childArgs schema to the parent.`;
if (DEBUG) {
raiseBlockError(message, context);
} else {
// eslint-disable-next-line no-console
console.warn(`[Blocks] ${message}`);
}
}
}
/**
* Validates block conditions and raises errors with proper context.
*
* @param {Object} blocksService - The blocks service with validate method.
* @param {Object} entry - The block entry containing conditions.
* @param {string} outletName - The outlet name for error messages.
* @param {string} blockName - The block name for error messages.
* @param {string} path - The path in the layout tree for error messages.
* @param {Error | null} [callSiteError] - Error object capturing where renderBlocks() was called.
* @param {Array<Object>} [rootLayout] - The root blocks array for error context display.
*/
function validateBlockConditions(
blocksService,
entry,
outletName,
blockName,
path,
callSiteError = null,
rootLayout = null
) {
if (!entry.conditions || !blocksService) {
return;
}
try {
blocksService.validate(entry.conditions);
} catch (error) {
// Build context for error message - include rootLayout for tree display
const context = {
...createValidationContext({
outletName,
blockName,
path,
entry,
callSiteError,
rootLayout,
}),
conditions: entry.conditions,
};
// If error has a path property, build the full errorPath and conditionsPath
// error.path is relative to conditions (e.g., "params.categoryId")
if (error.path) {
context.errorPath = buildErrorPath(
path,
buildErrorPath("conditions", error.path)
);
// conditionsPath is relative to the conditions object (for formatter)
context.conditionsPath = error.path;
}
raiseBlockError(
`Invalid conditions for block "${blockName}" in outlet "${outletName}": ${error.message}`,
context
);
}
}
/**
* Resolves a block reference (string or class) to a BlockClass for validation.
*
* This function handles the dual-mode resolution strategy:
*
* - **Development/Test mode**: Eagerly resolves all block references including
* factory functions. This ensures errors surface early at boot time with clear
* stack traces.
*
* - **Production mode**: Only resolves if the block is already resolved (not a
* pending factory). Factories are left unresolved, with validation deferred to
* render time. This enables true lazy loading.
*
* **Optional blocks**: Block references ending with `?` are treated as optional.
* If an optional block is not registered, an object with `OPTIONAL_MISSING`
* is returned instead of throwing an error. The calling code should check for this
* marker and skip validation/rendering for the block.
*
* @param {string | Object} blockRef - Block name string (possibly with `?` suffix) or BlockClass.
* @param {string} outletName - Outlet name for error messages.
* @param {Object} [context] - Context for error messages.
* @param {string} [context.path] - Path to this entry in the block tree.
* @param {Object} [context.entry] - The block entry object.
* @param {Error} [context.callSiteError] - Error capturing call site location.
* @param {Array} [context.rootLayout] - Root layout array for error display.
* @returns {Promise<Object | string | { [OPTIONAL_MISSING]: true, name: string }>}
* Resolved BlockClass, string name if deferred, or optional missing marker object.
* @throws {Error} If required block is not registered.
*/
export async function resolveBlockForValidation(
blockRef,
outletName,
context = {}
) {
// Class reference - return as-is (classes always exist)
if (typeof blockRef !== "string") {
return blockRef;
}
// Parse optional suffix from block reference
const { name, optional } = parseBlockReference(blockRef);
// String reference - check registration
if (!hasBlock(name)) {
if (optional) {
// Optional block not registered - return marker to skip validation
return { [OPTIONAL_MISSING]: true, name };
}
raiseBlockError(
`Block "${name}" at ${context.path || "unknown"} for outlet "${outletName}" is not registered. ` +
`Use api.registerBlock() in a pre-initializer before any renderBlocks() configuration.`,
createValidationContext({
outletName,
blockName: name,
path: context.path,
entry: context.entry,
callSiteError: context.callSiteError,
rootLayout: context.rootLayout,
})
);
return null;
}
if (DEBUG) {
// In dev/test, eagerly resolve to catch factory errors early
return await resolveBlock(name);
}
// In production, only resolve if already resolved (avoid triggering lazy load)
if (isBlockResolved(name)) {
return await resolveBlock(name);
}
// Return the string name - full validation deferred to render time
return name;
}
/**
* Valid top-level keys in block entry objects.
* Any key not in this list will trigger a validation error, helping catch
* common typos like `condition` instead of `conditions`.
*/
export const VALID_ENTRY_KEYS = Object.freeze([
"block", // Block class or name (required)
"conditions", // Conditions for rendering
"args", // Arguments to pass to the block
"containerArgs", // Arguments required by parent container's childArgs schema
"classNames", // CSS classes to add to wrapper
"children", // Nested block entries
"id", // Unique identifier for targeting and BEM styling
]);
/**
* Declarative type validation rules for block entry fields.
* Each rule specifies how to validate a field's type and generate error messages.
*
* @type {Object<string, {
* validate: (value: any) => boolean,
* expected: string,
* actual?: (value: any) => string
* }>}
*/
const ENTRY_TYPE_RULES = {
args: {
validate: (v) => typeof v === "object" && !Array.isArray(v),
expected: "an object",
actual: (v) => (Array.isArray(v) ? "array" : typeof v),
},
containerArgs: {
validate: (v) => typeof v === "object" && !Array.isArray(v),
expected: "an object",
actual: (v) => (Array.isArray(v) ? "array" : typeof v),
},
children: {
validate: (v) => Array.isArray(v),
expected: "an array",
actual: (v) => typeof v,
},
classNames: {
validate: (v) =>
typeof v === "string" ||
(Array.isArray(v) && v.every((item) => typeof item === "string")),
expected: "a string or array of strings",
actual: (v) =>
Array.isArray(v) ? "array with non-string items" : typeof v,
},
conditions: {
validate: (v) => typeof v === "object",
expected: "an object or array",
actual: (v) => typeof v,
},
id: {
validate: (v) => typeof v === "string",
expected: "a string",
actual: (v) => typeof v,
},
};
/**
* Validates that a block entry only uses known keys.
* Uses fuzzy matching to suggest corrections for typos like "condition",
* "codition", or "conditons" instead of "conditions".
*
* Internal keys (starting with `__`) are skipped as they are added by the
* system during preprocessing (e.g., `__visible`, `__failureReason`).
*
* @param {Object} entry - The block entry object.
* @throws {BlockError} If unknown keys are found.
*/
export function validateEntryKeys(entry) {
const unknownKeys = Object.keys(entry).filter(
(key) => !key.startsWith("__") && !VALID_ENTRY_KEYS.includes(key)
);
if (unknownKeys.length > 0) {
// Build helpful suggestions using fuzzy matching from shared lib
const suggestions = unknownKeys.map((key) =>
formatWithSuggestion(key, VALID_ENTRY_KEYS)
);
const keyWord = unknownKeys.length > 1 ? "keys" : "key";
// Throw BlockError directly - wrapValidationError will add context
throw new BlockError(
`Unknown entry ${keyWord}: ${suggestions.join(", ")}. ` +
`Valid keys are: ${VALID_ENTRY_KEYS.join(", ")}.`,
{ path: unknownKeys[0] }
);
}
}
/**
* Validates the types of optional entry fields.
* Iterates over ENTRY_TYPE_RULES to check each field's type.
*
* @param {Object} entry - The block entry object.
* @throws {BlockError} If any field has an invalid type.
*/
export function validateEntryTypes(entry) {
for (const [field, rule] of Object.entries(ENTRY_TYPE_RULES)) {
const value = entry[field];
if (value != null && !rule.validate(value)) {
const actualType = rule.actual?.(value) ?? typeof value;
// Throw BlockError directly - wrapValidationError will add context
throw new BlockError(
`"${field}" must be ${rule.expected}, got ${actualType}.`,
{ path: field }
);
}
}
}
/**
* Validation context passed through layout validation recursion.
* Created at the root level and shared across all entries to enable
* cross-cutting validation (e.g., ID uniqueness across the entire tree).
*
* @typedef {Object} LayoutValidationContext
* @property {Map<string, {path: string}>} seenIds - Map of entry IDs to their paths for uniqueness validation.
*/
/**
* Recursively validates an outlet layout (array of block entries).
* Validates each block entry and traverses nested children.
*
* This function is async to support lazy-loaded blocks:
* - In dev/test: Eagerly resolves all factories for early error detection.
* - In production: Defers factory resolution to render time.
*
* @param {Array<Object>} layout - The outlet layout (array of block entries) to validate.
* @param {string} outletName - The outlet these blocks belong to.
* @param {import("discourse/services/blocks").default} blocksService - Service for validating conditions.
* @param {string} [parentPath=""] - JSON-path style parent location for error context.
* @param {Error | null} [callSiteError] - Where renderBlocks() was called from.
* @param {Array<Object>} [rootLayout] - The root layout array for error context display.
* @param {Object|null} [parentChildArgsSchema=null] - The parent container's childArgs schema, if any.
* @param {string|null} [parentBlockName=null] - The parent container's block name for error messages.
* @param {number} [depth=0] - Current nesting depth for recursion limit checking.
* @param {LayoutValidationContext} [context] - Validation context for cross-cutting concerns like ID uniqueness.
* @returns {Promise<void>} Resolves when validation completes.
* @throws {Error} If any block entry is invalid or nesting depth exceeds MAX_LAYOUT_DEPTH.
*/
export async function validateLayout(
layout,
outletName,
blocksService,
parentPath = "",
callSiteError = null,
rootLayout = null,
parentChildArgsSchema = null,
parentBlockName = null,
depth = 0,
context = { seenIds: new Map() }
) {
// On first call, capture the root layout for error display
const effectiveRootLayout = rootLayout ?? layout;
// Check recursion depth limit to prevent stack overflow from deeply nested layouts
if (depth >= MAX_LAYOUT_DEPTH) {
raiseBlockError(
`Layout exceeds maximum nesting depth of ${MAX_LAYOUT_DEPTH}. ` +
`Deeply nested layouts may indicate a configuration issue.`,
createValidationContext({
outletName,
path: parentPath,
callSiteError,
rootLayout: effectiveRootLayout,
})
);
}
// Validate containerArgs uniqueness across siblings if parent has childArgs with unique constraints
if (parentChildArgsSchema) {
validateContainerArgsUniqueness(
layout,
parentChildArgsSchema,
parentBlockName,
parentPath.replace(/\.children$/, ""),
createValidationContext({
outletName,
path: parentPath,
callSiteError,
rootLayout: effectiveRootLayout,
})
);
}
// Use Promise.all for parallel validation (faster in dev when resolving factories)
const validationPromises = layout.map(async (entry, index) => {
const currentPath = `${parentPath}[${index}]`;
// Check ID uniqueness across the entire layout using shared context
if (entry.id) {
if (context.seenIds.has(entry.id)) {
const first = context.seenIds.get(entry.id);
raiseBlockError(
`Duplicate block id "${entry.id}" in outlet "${outletName}". ` +
`Found at ${first.path} and ${currentPath}. Block IDs must be unique per layout.`,
{
...createValidationContext({
outletName,
path: currentPath,
entry,
callSiteError,
rootLayout: effectiveRootLayout,
}),
errorPath: `${currentPath}.id`,
}
);
}
context.seenIds.set(entry.id, { path: currentPath });
}
// Validate the block entry itself (whether it has children or not)
// Returns the block's childArgsSchema if it's a container with childArgs
const childArgsSchema = await validateEntry(
entry,
outletName,
blocksService,
currentPath,
callSiteError,
effectiveRootLayout,
parentChildArgsSchema,
parentBlockName
);
// Recursively validate nested children
if (entry.children) {
// Get the block name for error messages when passing childArgs to children
let blockName = null;
if (childArgsSchema) {
// We need the block name for error messages - resolve it
const resolved = await resolveBlockForValidation(
entry.block,
outletName,
createValidationContext({
outletName,
path: currentPath,
entry,
callSiteError,
rootLayout: effectiveRootLayout,
})
);
if (
resolved &&
typeof resolved !== "string" &&
!resolved[OPTIONAL_MISSING]
) {
blockName = getBlockMetadata(resolved)?.blockName;
}
}
await validateLayout(
entry.children,
outletName,
blocksService,
`${currentPath}.children`,
callSiteError,
effectiveRootLayout,
childArgsSchema,
blockName,
depth + 1,
context
);
}
});
await Promise.all(validationPromises);
}
/**
* Validates a single block entry object.
*
* Performs comprehensive validation including:
* - Outlet name is a valid registered outlet (core or custom)
* - Block reference is valid (string name or @block-decorated class)
* - Block is registered in the registry
* - Container/children relationship is valid
* - No reserved arg names are used
* - containerArgs match parent's childArgs schema (if applicable)
* - Conditions are valid (if blocksService is provided)
*
* This function is async to support lazy-loaded blocks. In production mode,
* if a block reference is a string pointing to an unresolved factory, full
* validation is deferred to render time.
*
* @param {Object} entry - The block entry object.
* @param {typeof import("@glimmer/component").default | string} entry.block - Block class or name string.
* @param {Object} [entry.args] - Args to pass to the block.
* @param {Object} [entry.containerArgs] - Args required by parent container's childArgs schema.
* @param {Array<Object>} [entry.children] - Nested block entries.
* @param {Array<Object>|Object} [entry.conditions] - Conditions for rendering.
* @param {string} outletName - The outlet this block belongs to.
* @param {import("discourse/services/blocks").default} blocksService - Service for validating conditions.
* @param {string} [path] - JSON-path style location in layout (e.g., "[3].children[0]").
* @param {Error | null} [callSiteError] - Where renderBlocks() was called from.
* @param {Array<Object>} [rootLayout] - The root layout array for error context display.
* @param {Object|null} [parentChildArgsSchema=null] - The parent container's childArgs schema, if any.
* @param {string|null} [parentBlockName=null] - The parent container's block name for error messages.
* @returns {Promise<Object|null>} The block's childArgsSchema if it's a container with childArgs, otherwise null.
* @throws {Error} If validation fails.
*/
export async function validateEntry(
entry,
outletName,
blocksService,
path,
callSiteError = null,
rootLayout = null,
parentChildArgsSchema = null,
parentBlockName = null
) {
// Create context without blockName for early validation errors
const earlyContext = createValidationContext({
outletName,
path,
entry,
callSiteError,
rootLayout,
});
if (!isValidOutlet(outletName)) {
const allOutlets = getAllOutlets();
const suggestion = formatWithSuggestion(outletName, allOutlets);
raiseBlockError(
`Unknown block outlet: ${suggestion}. ` +
`Register custom outlets with api.registerBlockOutlet() in a pre-initializer. ` +
`Available outlets: ${allOutlets.join(", ")}`,
earlyContext
);
return null;
}
// Validate entry structure (keys, types, and id format) with error tracing
wrapValidationError(
() => {
validateEntryKeys(entry);
validateEntryTypes(entry);
validateEntryIdFormat(entry);
},
`Invalid block entry at ${path} for outlet "${outletName}"`,
earlyContext
);
if (!entry.block) {
raiseBlockError(
`Block entry at ${path} for outlet "${outletName}" is missing required "block" property.`,
earlyContext
);
return null;
}
// Resolve block reference (string name or class)
// In dev: eagerly resolves factories
// In prod: returns string if factory is unresolved (defers to render time)
const resolvedBlock = await resolveBlockForValidation(
entry.block,
outletName,
earlyContext
);
// If resolution returned null (error was raised), exit early
if (resolvedBlock === null) {
return null;
}
// Optional block not registered - skip validation entirely
if (resolvedBlock?.[OPTIONAL_MISSING]) {
return null;
}
// In production with unresolved factory, defer full validation to render time
// We've already verified the block name is registered in resolveBlockForValidation
if (typeof resolvedBlock === "string") {
const blockName = resolvedBlock;
// Validate conditions since they don't depend on the block class
validateBlockConditions(
blocksService,
entry,
outletName,
blockName,
path,
callSiteError,
rootLayout
);
// Skip class-specific validation (will happen at render time)
return null;
}
// Full validation with resolved class
const blockMeta = getBlockMetadata(resolvedBlock);
if (!blockMeta) {
raiseBlockError(
`Block "${resolvedBlock?.name || "unknown"}" at ${path} for outlet "${outletName}" is not a valid @block-decorated component.`,
earlyContext
);
return null;
}
const blockName = blockMeta.blockName;
// Build base context for all validation errors in this block
const baseContext = createValidationContext({
outletName,
blockName,
path,
entry,
callSiteError,
rootLayout,
});
// Validate outlet permission (allowedOutlets/deniedOutlets)
if (
!validateOutletPermission(blockMeta, outletName, blockName, baseContext)
) {
return null;
}
// Validate container/children relationship
const isContainer = blockMeta.isContainer;
if (
!validateContainerChildren(
entry,
isContainer,
blockName,
outletName,
baseContext
)
) {
return null;
}
// Validate block args against schema
const errorPrefix = `Invalid block "${blockName}" at ${path} for outlet "${outletName}"`;
const owner = blocksService ? getOwner(blocksService) : null;
wrapValidationError(
() => validateBlockArgs(entry, resolvedBlock, { owner }),
errorPrefix,
baseContext
);
// Validate constraints and custom validation (after applying defaults)
validateBlockConstraints(
blockMeta,
resolvedBlock,
entry,
blockName,
baseContext
);
// Validate conditions if service is available
validateBlockConditions(
blocksService,
entry,
outletName,
blockName,
path,
callSiteError,
rootLayout
);
// Validate containerArgs against parent's childArgs schema
if (parentChildArgsSchema) {
validateContainerArgs(
entry,
parentChildArgsSchema,
parentBlockName,
baseContext
);
}
// Validate orphan containerArgs (containerArgs without parent's childArgs)
validateOrphanContainerArgs(entry, parentChildArgsSchema, baseContext);
// Return the block's childArgsSchema for validating its children
return isContainer ? blockMeta.childArgs : null;
}
+142
View File
@@ -0,0 +1,142 @@
// @ts-check
/**
* Public API for the Discourse Block system.
*
* This module exposes constants and utilities that plugin and theme developers
* can use when working with blocks. Internal implementation details are kept
* in the `-internals/` directory and should not be imported directly.
*
* @module discourse/lib/blocks
*
* @example
* import {
* VALID_BLOCK_NAME_PATTERN,
* parseBlockName,
* VALID_ARG_TYPES,
* matchValue,
* } from "discourse/lib/blocks";
*/
/* Pattern Validation */
/**
* Valid block name pattern: lowercase letters, numbers, and hyphens.
* Must start with a letter. Examples: "hero-banner", "sidebar-blocks", "my-block-1"
*
* Used for both block names and outlet names since they follow the same format.
*/
export { VALID_BLOCK_NAME_PATTERN } from "discourse/lib/blocks/-internals/patterns";
/**
* Valid namespaced block name pattern. Supports three formats:
*
* - **Core blocks**: `block-name` (no prefix)
* - **Plugin blocks**: `plugin-name:block-name` (where plugin-name is not "theme")
* - **Theme blocks**: `theme:theme-name:block-name`
*
* @example
* // Valid patterns:
* "group" // Core block
* "chat:message-widget" // Plugin block
* "theme:tactile:hero-banner" // Theme block
*/
export { VALID_NAMESPACED_BLOCK_PATTERN } from "discourse/lib/blocks/-internals/patterns";
/**
* Parses a full block name into its components.
*
* @example
* parseBlockName("group")
* // => { type: "core", namespace: null, name: "group" }
*
* parseBlockName("chat:message-widget")
* // => { type: "plugin", namespace: "chat", name: "message-widget" }
*
* parseBlockName("theme:tactile:hero-banner")
* // => { type: "theme", namespace: "tactile", name: "hero-banner" }
*/
export { parseBlockName } from "discourse/lib/blocks/-internals/patterns";
/**
* Parses a block reference string to extract the block name and optional flag.
*
* Block references can be marked as optional by appending a `?` suffix to the
* name. Optional blocks that are not registered will be silently skipped
* instead of throwing an error.
*
* @example
* parseBlockReference("chat:widget?")
* // => { name: "chat:widget", optional: true }
*
* parseBlockReference("hero-banner")
* // => { name: "hero-banner", optional: false }
*/
export { parseBlockReference } from "discourse/lib/blocks/-internals/patterns";
/* Arg Schema Constants */
/**
* Valid arg types for schema definitions.
* Types: "string", "number", "boolean", "array", "any"
*/
export { VALID_ARG_TYPES } from "discourse/lib/blocks/-internals/validation/args";
/**
* Valid item types for array args.
* Types: "string", "number", "boolean"
*/
export { VALID_ITEM_TYPES } from "discourse/lib/blocks/-internals/validation/args";
/* Constraint Types */
/**
* Valid constraint types for cross-arg validation.
* Types: "atLeastOne", "exactlyOne", "allOrNone", "atMostOne", "requires"
*/
export { VALID_CONSTRAINT_TYPES } from "discourse/lib/blocks/-internals/validation/constraints";
/* Page Types (for Route Condition) */
/**
* Array of all valid page type names for the route condition.
* Types: "CATEGORY_PAGES", "TAG_PAGES", "DISCOVERY_PAGES", "HOMEPAGE",
* "TOP_MENU", "TOPIC_PAGES", "USER_PAGES", "ADMIN_PAGES", "GROUP_PAGES"
*/
export { VALID_PAGE_TYPES } from "discourse/lib/blocks/-internals/matching/page-definitions";
/* Utilities for Custom Conditions */
/**
* Retrieves a value from a nested object using dot-notation path.
*
* This utility safely navigates through nested object properties using a
* dot-separated path string. It handles null/undefined values gracefully
* at any level of the path.
*
* @example
* const user = { profile: { name: "Alice", settings: { theme: "dark" } } };
* getByPath(user, "profile.name"); // "Alice"
* getByPath(user, "profile.settings.theme"); // "dark"
* getByPath(user, "profile.missing"); // undefined
*/
export { getByPath } from "discourse/lib/blocks/-internals/utils";
/**
* Evaluates a value matcher spec against an actual value.
* Supports the same AND/OR/NOT logic as condition evaluation.
*
* Supports:
* - Exact match: `123`, `"foo"`
* - Array of simple values (OR): `[123, 456]` matches if actual is any of these
* - Array of complex specs (AND): `[{ not: "a" }, { not: "b" }]` all specs must match
* - RegExp: `/^foo/` matches if actual matches the pattern
* - NOT: `{ not: value }` matches if actual does NOT match value
* - ANY (OR): `{ any: [...] }` matches if actual matches any spec in array
*
* @example
* matchValue({ actual: 5, expected: 5 }) // true
* matchValue({ actual: 5, expected: [3, 5, 7] }) // true (OR)
* matchValue({ actual: "hello", expected: /^hel/ }) // true
* matchValue({ actual: 5, expected: { not: 3 } }) // true
*/
export { matchValue } from "discourse/lib/blocks/-internals/matching/value-matcher";
+29
View File
@@ -0,0 +1,29 @@
// @ts-check
import picomatch from "picomatch";
/**
* Validates that a glob pattern can be compiled by picomatch.
*
* Instead of using a restrictive regex, this function attempts to compile
* the pattern with picomatch using strict mode. This allows full picomatch
* syntax including advanced features like brace expansion, character classes,
* and negation, while catching syntax errors like unbalanced brackets.
*
* @param {string} pattern - The pattern to validate.
* @returns {boolean} True if the pattern is valid picomatch syntax.
*
* @example
* isValidGlobPattern("sidebar-*"); // true
* isValidGlobPattern("{a,b}-blocks"); // true
* isValidGlobPattern("[unclosed"); // false (unbalanced bracket)
*/
export function isValidGlobPattern(pattern) {
try {
// Compile with strictBrackets to throw on imbalanced brackets/braces/parens.
// Without this option, picomatch treats malformed patterns as literals.
picomatch(pattern, { strictBrackets: true });
return true;
} catch {
return false;
}
}
+155
View File
@@ -0,0 +1,155 @@
/**
* Shared utilities for outlet args with deprecation support.
*
* These utilities are used by both PluginOutlet and BlockOutlet to handle
* deprecated args with lazy evaluation and warning messages.
*
* @module discourse/lib/outlet-args
*/
import { isDeprecatedOutletArgument } from "discourse/helpers/deprecated-outlet-argument";
import deprecated, { withSilencedDeprecations } from "discourse/lib/deprecated";
/**
* Key used to store the raw deprecatedArgs object as a non-enumerable
* property on the combined args object. This is used by dev-tools to display
* deprecation info without triggering the deprecation warnings.
*/
export const DEPRECATED_ARGS_KEY = "__deprecatedArgs__";
/**
* Flag to control whether buildArgsWithDeprecations includes the raw
* deprecatedArgs object as a non-enumerable property. This is enabled
* by dev-tools when outlet debugging is active.
*/
let _includeDeprecatedArgsProperty = false;
/**
* Enables or disables including the raw deprecatedArgs object as a
* non-enumerable property in buildArgsWithDeprecations output.
*
* @param {boolean} value - Whether to include the property.
*/
export function _setIncludeDeprecatedArgsProperty(value) {
_includeDeprecatedArgsProperty = value;
}
/**
* Builds an args object that combines current args with deprecated args.
*
* Both current and deprecated args are accessed via property getters for lazy
* evaluation. Deprecated args trigger a deprecation warning when accessed.
*
* @param {Object} args - Current outlet args.
* @param {Object} deprecatedArgs - Deprecated args created with `deprecatedOutletArgument` helper.
* @param {Object} [opts={}] - Options passed to deprecation warnings.
* @param {string} [opts.outletName] - The outlet name for warning messages.
* @returns {Object} Combined args object with lazy property getters.
*
* @example
* const argsWithDeprecations = buildArgsWithDeprecations(
* { topic: this.topic },
* { oldTopic: deprecatedOutletArgument({ value: this.topic, message: "Use 'topic'" }) },
* { outletName: "topic-sidebar" }
* );
*/
export function buildArgsWithDeprecations(args, deprecatedArgs, opts = {}) {
const output = {};
if (args) {
Object.keys(args).forEach((key) => {
Object.defineProperty(output, key, {
enumerable: true,
get() {
return args[key];
},
});
});
}
if (deprecatedArgs) {
Object.keys(deprecatedArgs).forEach((argumentName) => {
// Skip if this key already exists in args (e.g., from a parent outlet's
// outletArgsWithDeprecations that already includes deprecatedArgs)
if (args && argumentName in args) {
return;
}
Object.defineProperty(output, argumentName, {
enumerable: true,
get() {
const deprecatedArg = deprecatedArgs[argumentName];
return deprecatedArgumentValue(deprecatedArg, {
...opts,
argumentName,
});
},
});
});
// When dev-tools outlet debugging is enabled, include the raw deprecatedArgs
// as a non-enumerable property so ArgsTable can display deprecation info.
if (_includeDeprecatedArgsProperty) {
Object.defineProperty(output, DEPRECATED_ARGS_KEY, {
enumerable: false,
value: deprecatedArgs,
});
}
}
return output;
}
/**
* Evaluates a deprecated argument, triggering a deprecation warning.
*
* The warning is triggered each time the value is accessed. If the deprecated
* arg has a `silence` option, the warning is silenced under that deprecation ID.
*
* @param {Object} deprecatedArg - A deprecated arg created with `deprecatedOutletArgument` helper.
* @param {Object} options - Options for the deprecation warning.
* @param {string} options.argumentName - The name of the deprecated arg.
* @param {string} [options.outletName] - The outlet name for the warning message.
* @param {string} [options.classModuleName] - Module name for connector class.
* @param {string} [options.templateModule] - Module name for connector template.
* @param {string} [options.connectorName] - Connector name.
* @param {string} [options.layoutName] - Layout name.
* @returns {*} The value of the deprecated arg.
* @throws {Error} If the deprecated arg was not created with `deprecatedOutletArgument`.
*/
export function deprecatedArgumentValue(deprecatedArg, options) {
if (!isDeprecatedOutletArgument(deprecatedArg)) {
throw new Error(
"deprecated argument is not defined properly, use helper `deprecatedOutletArgument` from discourse/helpers/deprecated-outlet-argument"
);
}
let message = deprecatedArg.message;
if (!message) {
if (options.outletName) {
message = `outlet arg \`${options.argumentName}\` is deprecated on the outlet \`${options.outletName}\``;
} else {
message = `${options.argumentName} is deprecated`;
}
}
const connectorModule =
options.classModuleName || options.templateModule || options.connectorName;
if (connectorModule) {
message += ` [used on connector ${connectorModule}]`;
} else if (options.layoutName) {
message += ` [used on ${options.layoutName}]`;
}
if (!deprecatedArg.silence) {
deprecated(message, deprecatedArg.options);
return deprecatedArg.value;
}
return withSilencedDeprecations(deprecatedArg.silence, () => {
deprecated(message, deprecatedArg.options);
return deprecatedArg.value;
});
}
+203
View File
@@ -1,5 +1,6 @@
/* eslint-disable ember/no-jquery */
import $ from "jquery";
import { _renderBlocks } from "discourse/blocks/block-outlet";
import { addAboutPageActivity } from "discourse/components/about-page";
import { addBulkDropdownButton } from "discourse/components/bulk-select-topics-dropdown";
import { addCardClickListenerSelector } from "discourse/components/card-contents-base";
@@ -55,6 +56,13 @@ import { addBeforeAuthCompleteCallback } from "discourse/instance-initializers/a
import { registerAdminPluginConfigNav } from "discourse/lib/admin-plugin-config-nav";
import { registerPluginHeaderActionComponent } from "discourse/lib/admin-plugin-header-actions";
import { registerReportModeComponent } from "discourse/lib/admin-report-additional-modes";
import { captureCallSite } from "discourse/lib/blocks/-internals/error";
import {
_registerBlock,
_registerBlockFactory,
} from "discourse/lib/blocks/-internals/registry/block";
import { _registerConditionType } from "discourse/lib/blocks/-internals/registry/condition";
import { _registerOutlet } from "discourse/lib/blocks/-internals/registry/outlet";
import classPrepend, {
withPrependsRolledBack,
} from "discourse/lib/class-prepend";
@@ -3372,6 +3380,201 @@ class _PluginApi {
registeredEditCategoryTabs.push(tab);
}
/**
* Registers block components to render in a designated outlet.
*
* **IMPORTANT:** Must be called in an initializer that runs after "freeze-block-registry".
* All blocks must be registered via `registerBlock()` before this is called.
*
* Block outlets are extension points where themes and plugins can render custom
* content layouts. Each block must be decorated with `@block` from "discourse/blocks".
*
* Blocks can have conditions that determine when they render. Conditions support
* AND logic (array), OR logic (`any`), and NOT logic (`not`).
*
* @experimental This API is under active development and may change or be removed
* in future releases without prior notice. Use with caution in production environments.
*
* @param {string} outletName - The block outlet identifier
* @param {Array<import("discourse/blocks/block-outlet").LayoutEntry>} blocks - Array of layout entries
*
* @example
* ```javascript
* import { block } from "discourse/blocks";
*
* @block("my-banner")
* class MyBanner extends Component {
* <template>
* <h1>{{@title}}</h1>
* </template>
* }
*
* api.renderBlocks("homepage-blocks", [
* // Simple block without conditions
* {
* block: MyBanner,
* args: { title: "Welcome!" },
* },
* // Block with conditions (AND logic - all must pass)
* {
* block: MyBanner,
* args: { title: "Admin Banner" },
* conditions: [
* { type: "route", pages: ["DISCOVERY_PAGES"] },
* { type: "user", admin: true }
* ],
* },
* // Block with OR conditions
* {
* block: MyBanner,
* args: { title: "Staff Banner" },
* conditions: [
* { any: [
* { type: "user", admin: true },
* { type: "user", moderator: true }
* ]}
* ],
* },
* ]);
* ```
*/
renderBlocks(outletName, blocks) {
// Capture call site here, excluding this method, so the stack trace
// points directly to the user's code that called api.renderBlocks().
const callSiteError = captureCallSite(this.renderBlocks);
_renderBlocks(outletName, blocks, this.container, callSiteError);
}
/**
* Registers a block component for use with `renderBlocks()`.
*
* **IMPORTANT:** Must be called in a pre-initializer that runs before "freeze-block-registry".
* The block registry is frozen by the "freeze-block-registry" initializer, preventing
* late registrations.
*
* Supports two registration patterns:
*
* 1. **Direct class registration**: `registerBlock(BlockClass)`
* Registers using the block's own `blockName` from its `@block` decorator.
*
* 2. **Lazy loading with factory**: `registerBlock("name", () => import(...))`
* Registers a factory function for lazy loading. The block module won't be
* loaded until actually needed. The resolved block's `blockName` must match
* the registered name.
*
* @experimental This API is under active development and may change or be removed
* in future releases without prior notice. Use with caution in production environments.
*
* @param {typeof import("@glimmer/component").default | string} blockOrName - Block class or name string for lazy loading.
* @param {Function} [factory] - Factory function returning Promise<BlockClass> (required when first arg is name).
*
* @example Direct class registration
* ```javascript
* import HeroBanner from "../blocks/hero-banner";
* api.registerBlock(HeroBanner);
* ```
*
* @example Lazy loading with factory
* ```javascript
* api.registerBlock("sidebar-widget", () => import("../blocks/sidebar-widget"));
* ```
*/
registerBlock(blockOrName, factory) {
if (typeof blockOrName === "string") {
// Lazy loading: registerBlock("name", () => import(...))
if (typeof factory !== "function") {
throw new Error(
`registerBlock("${blockOrName}", ...) requires a factory function as second argument.`
);
}
_registerBlockFactory(blockOrName, factory);
} else {
// Direct class: registerBlock(BlockClass)
_registerBlock(blockOrName);
}
}
/**
* Registers a custom block outlet where blocks can be rendered.
*
* This allows plugins and themes to define their own block outlets that can be
* used with `renderBlocks()`. Custom outlets must follow naming conventions:
* - Core outlets: `outlet-name` (kebab-case)
* - Plugin outlets: `namespace:outlet-name` (e.g., `chat:message-actions`)
* - Theme outlets: `theme:namespace:outlet-name` (e.g., `theme:my-theme:hero`)
*
* **IMPORTANT:** Must be called in a pre-initializer before "freeze-block-registry".
*
* @experimental This API is under active development and may change or be removed
* in future releases without prior notice. Use with caution in production environments.
*
* @param {string} outletName - The outlet name (must follow naming conventions).
* @param {Object} [options] - Outlet configuration options.
* @param {string} [options.description] - Human-readable description of the outlet.
*
* @example
* ```javascript
* // In a pre-initializer
* api.registerBlockOutlet("chat:message-actions", {
* description: "Actions displayed below chat messages",
* });
*
* // Later, in an api-initializer
* api.renderBlocks("chat:message-actions", [...]);
* ```
*/
registerBlockOutlet(outletName, options) {
_registerOutlet(outletName, options);
}
/**
* Registers a custom block condition type.
*
* Custom conditions must use the `@blockCondition` decorator from "discourse/blocks/conditions"
* and extend `BlockCondition`. The class must implement the `evaluate(args)` method.
*
* **Note: The `evaluate()` method MUST be pure and idempotent.** It may be called
* multiple times during a single render cycle, especially when debug logging
* is enabled, and should not perform any side effects or state mutations.
*
* @experimental This API is under active development and may change or be removed
* in future releases without prior notice. Use with caution in production environments.
*
* @param {typeof import("discourse/blocks/conditions").BlockCondition} ConditionClass - The condition class decorated with `@blockCondition`.
*
* @example
* ```javascript
* import { blockCondition, BlockCondition } from "discourse/blocks/conditions";
*
* @blockCondition({
* type: "feature-flag",
* args: {
* flag: { type: "string", required: true },
* },
* })
* class BlockFeatureFlagCondition extends BlockCondition {
* @service currentUser;
*
* evaluate(args) {
* return this.currentUser?.feature_flags?.[args.flag] === true;
* }
* }
*
* api.registerBlockConditionType(BlockFeatureFlagCondition);
*
* // Then use it in renderBlocks:
* api.renderBlocks("homepage-blocks", [
* {
* block: MyBlock,
* conditions: [{ type: "feature-flag", flag: "new_feature" }]
* }
* ]);
* ```
*/
registerBlockConditionType(ConditionClass) {
_registerConditionType(ConditionClass);
}
// eslint-disable-next-line no-unused-vars
#deprecateModifyClass(className) {
// display notification messages for deprecated classes
@@ -4,8 +4,7 @@ import {
setComponentTemplate,
} from "@glimmer/manager";
import templateOnly from "@ember/component/template-only";
import { isDeprecatedOutletArgument } from "discourse/helpers/deprecated-outlet-argument";
import deprecated, { withSilencedDeprecations } from "discourse/lib/deprecated";
import deprecated from "discourse/lib/deprecated";
let _connectorCache;
let _extraConnectorClasses = {};
@@ -213,18 +212,20 @@ export function connectorsExist(outletName) {
return Boolean(_connectorCache[outletName] || debugOutletCallback);
}
export function connectorsFor(outletName) {
export function connectorsFor(outletName, outletArgs) {
if (!_connectorCache) {
buildConnectorCache();
}
if (debugOutletCallback) {
return debugOutletCallback(outletName, _connectorCache[outletName]);
return debugOutletCallback(outletName, _connectorCache[outletName], {
outletArgs,
});
}
return _connectorCache[outletName] || [];
}
export function renderedConnectorsFor(outletName, args, context, owner) {
return connectorsFor(outletName).filter((con) => {
return connectorsFor(outletName, args).filter((con) => {
return (
!con.connectorClass?.shouldRender ||
con.connectorClass?.shouldRender(args, context, owner)
@@ -232,73 +233,6 @@ export function renderedConnectorsFor(outletName, args, context, owner) {
});
}
export function buildArgsWithDeprecations(args, deprecatedArgs, opts = {}) {
const output = {};
if (args) {
Object.keys(args).forEach((key) => {
Object.defineProperty(output, key, {
get() {
return args[key];
},
});
});
}
if (deprecatedArgs) {
Object.keys(deprecatedArgs).forEach((argumentName) => {
Object.defineProperty(output, argumentName, {
get() {
const deprecatedArg = deprecatedArgs[argumentName];
return deprecatedArgumentValue(deprecatedArg, {
...opts,
argumentName,
});
},
});
});
}
return output;
}
export function deprecatedArgumentValue(deprecatedArg, options) {
if (!isDeprecatedOutletArgument(deprecatedArg)) {
throw new Error(
"deprecated argument is not defined properly, use helper `deprecatedOutletArgument` from discourse/helpers/deprecated-outlet-argument"
);
}
let message = deprecatedArg.message;
if (!message) {
if (options.outletName) {
message = `outlet arg \`${options.argumentName}\` is deprecated on the outlet \`${options.outletName}\``;
} else {
message = `${options.argumentName} is deprecated`;
}
}
const connectorModule =
options.classModuleName || options.templateModule || options.connectorName;
if (connectorModule) {
message += ` [used on connector ${connectorModule}]`;
} else if (options.layoutName) {
message += ` [used on ${options.layoutName}]`;
}
if (!deprecatedArg.silence) {
deprecated(message, deprecatedArg.options);
return deprecatedArg.value;
}
return withSilencedDeprecations(deprecatedArg.silence, () => {
deprecated(message, deprecatedArg.options);
return deprecatedArg.value;
});
}
export function _setOutletDebugCallback(callback) {
debugOutletCallback = callback;
}
@@ -0,0 +1,56 @@
import { DEBUG } from "@glimmer/env";
import { VALID_BLOCK_NAME_PATTERN } from "discourse/lib/blocks";
/**
* Registry of CORE block outlet names in the application.
*
* Block outlets provide extension points where plugins and themes can render
* custom block layouts. Each outlet represents a specific location in the UI
* where blocks can be rendered.
*
* Outlet names must follow kebab-case: lowercase letters, numbers, and hyphens,
* starting with a letter. Examples: "sidebar-blocks", "hero-blocks", "main-outlet-1"
*
* ## IMPORTANT: Plugin Outlets DO NOT Belong Here
*
* **This file is ONLY for core Discourse outlets that are always available.**
*
* If you are adding an outlet for a plugin (including core plugins like chat,
* AI, polls, etc.), you MUST use the Plugin API instead:
*
* ```javascript
* // In a pre-initializer
* api.registerBlockOutlet("chat:message-actions", {
* description: "Actions below chat messages",
* });
* ```
*
* **Why?** Plugin outlets added here will cause issues because:
* 1. The outlet will be "registered" even when the plugin is disabled
* 2. Themes/plugins may try to render blocks to outlets that don't exist
* 3. The outlet validation will pass but the actual rendering location won't exist
*
* **Rule of thumb:** If the outlet depends on ANY plugin being enabled, it MUST
* be registered via `api.registerBlockOutlet()` in that plugin's code, not here.
*
* @constant {ReadonlyArray<string>} BLOCK_OUTLETS - An immutable array of core block outlet identifiers
*/
// eslint-discourse keep-array-sorted
export const BLOCK_OUTLETS = Object.freeze([
"hero-blocks",
"homepage-blocks",
"main-outlet-blocks",
"sidebar-blocks",
]);
// Validate outlet names follow the kebab-case pattern.
if (DEBUG) {
BLOCK_OUTLETS.forEach((name) => {
if (!VALID_BLOCK_NAME_PATTERN.test(name)) {
throw new Error(
`Block outlet name "${name}" is invalid. ` +
`Names must be kebab-case: lowercase letters, numbers, and hyphens, starting with a letter.`
);
}
});
}
@@ -1,5 +1,14 @@
/**
* Registry of available behavior transformers in the application.
* Behavior transformers allow plugins and themes to modify or enhance the specific behaviors and interactions.
* These transformers are invoked at key points to allow customization of the application behavior.
*
* USE ONLY lowercase names
*
* @constant {ReadonlyArray<string>} BEHAVIOR_TRANSFORMERS - An immutable array of behavior transformer identifiers
*/
// eslint-discourse keep-array-sorted
export const BEHAVIOR_TRANSFORMERS = Object.freeze([
// use only lowercase names
"composer-position:correct-scroll-position",
"composer-position:editor-touch-move",
"discovery-topic-list-load-more",
@@ -10,11 +19,20 @@ export const BEHAVIOR_TRANSFORMERS = Object.freeze([
"topic-list-item-click",
]);
/**
* Registry of available value transformers in the application.
* Value transformers allow plugins and themes to modify or replace specific values before they are used by the application.
* Each transformer represents a specific value or computation that can be customized.
*
* USE ONLY lowercase names
*
* @constant {ReadonlyArray<string>} VALUE_TRANSFORMERS - An immutable array of value transformer identifiers
*/
// eslint-discourse keep-array-sorted
export const VALUE_TRANSFORMERS = Object.freeze([
// use only lowercase names
"admin-onboarding-start-posting-options",
"admin-plugin-icon",
"admin-reports-show-query-params",
"admin-onboarding-start-posting-options",
"bulk-select-in-nav-controls",
"category-available-views",
"category-default-colors",
@@ -26,13 +44,13 @@ export const VALUE_TRANSFORMERS = Object.freeze([
"composer-editor-reply-placeholder",
"composer-force-editor-mode",
"composer-message-components",
"composer-reply-options-user-link-name",
"composer-reply-options-user-avatar-template",
"composer-reply-options-user-link-name",
"composer-save-button-label",
"composer-service-cannot-submit-post",
"composer-toggles-class",
"create-topic-label",
"create-topic-button-class",
"create-topic-label",
"flag-button-disabled-state",
"flag-button-dynamic-class",
"flag-button-render-decision",
@@ -0,0 +1,166 @@
/**
* String similarity utilities for fuzzy matching.
*
* This module provides functions for computing string similarity using
* Jaro-Winkler similarity. It is used for typo suggestions in validation
* error messages throughout Discourse.
*
* @module discourse/lib/string-similarity
*/
/**
* Calculates Jaro-Winkler similarity between two strings.
*
* Returns a score from 0 to 1, where 1 is an exact match. This algorithm
* gives bonus weight to strings that share a common prefix, making it
* ideal for detecting typos where someone forgets a suffix (e.g., "DISCOVERY"
* instead of "DISCOVERY_PAGES").
*
* The algorithm works in two steps:
* 1. Jaro similarity: Measures matching characters and transpositions
* 2. Winkler modification: Adds bonus for matching prefix (up to 4 chars)
*
* @param {string} a - First string.
* @param {string} b - Second string.
* @returns {number} Similarity score between 0 and 1.
*
* @example
* jaroWinklerSimilarity("DISCOVERY", "DISCOVERY_PAGES") // ~0.88 (prefix match)
* jaroWinklerSimilarity("condition", "conditions") // ~0.97 (1 char diff)
* jaroWinklerSimilarity("foobar", "HOMEPAGE") // ~0.40 (unrelated)
*/
export function jaroWinklerSimilarity(a, b) {
if (a === b) {
return 1;
}
if (a.length === 0 || b.length === 0) {
return 0;
}
// Calculate the match window - characters can match if within this distance
const matchWindow = Math.floor(Math.max(a.length, b.length) / 2) - 1;
const aMatches = new Array(a.length).fill(false);
const bMatches = new Array(b.length).fill(false);
let matches = 0;
let transpositions = 0;
// Find matching characters within the match window
for (let i = 0; i < a.length; i++) {
const start = Math.max(0, i - matchWindow);
const end = Math.min(i + matchWindow + 1, b.length);
for (let j = start; j < end; j++) {
if (bMatches[j] || a[i] !== b[j]) {
continue;
}
aMatches[i] = true;
bMatches[j] = true;
matches++;
break;
}
}
if (matches === 0) {
return 0;
}
// Count transpositions (matched chars that appear in different order)
let k = 0;
for (let i = 0; i < a.length; i++) {
if (!aMatches[i]) {
continue;
}
while (!bMatches[k]) {
k++;
}
if (a[i] !== b[k]) {
transpositions++;
}
k++;
}
// Calculate Jaro similarity
const jaro =
(matches / a.length +
matches / b.length +
(matches - transpositions / 2) / matches) /
3;
// Winkler modification: add bonus for common prefix (up to 4 characters)
let prefix = 0;
for (let i = 0; i < Math.min(4, a.length, b.length); i++) {
if (a[i] === b[i]) {
prefix++;
} else {
break;
}
}
return jaro + prefix * 0.1 * (1 - jaro);
}
/**
* Finds the closest match for a string from a list of candidates.
*
* Uses Jaro-Winkler similarity with a threshold to avoid suggesting
* completely unrelated strings. The Jaro-Winkler algorithm gives bonus
* weight to strings with matching prefixes, making it ideal for detecting
* typos like "DISCOVERY" instead of "DISCOVERY_PAGES".
*
* @param {string} input - The string to find a match for.
* @param {readonly string[]} candidates - List of valid strings to match against.
* @param {Object} [options] - Options.
* @param {number} [options.minSimilarity=0.8] - Minimum similarity (0-1) to consider a match.
* @param {boolean} [options.caseSensitive=false] - Whether to compare case-sensitively.
* @returns {string|null} The closest matching string, or null if none is similar enough.
*
* @example
* findClosestMatch("conditon", ["block", "args", "conditions"]) // => "conditions"
* findClosestMatch("DISCOVERY", ["DISCOVERY_PAGES", "HOMEPAGE"]) // => "DISCOVERY_PAGES"
* findClosestMatch("foo", ["block", "args", "conditions"]) // => null
*/
export function findClosestMatch(input, candidates, options = {}) {
const { minSimilarity = 0.8, caseSensitive = false } = options;
let closestMatch = null;
let highestSimilarity = 0;
const normalizedInput = caseSensitive ? input : input.toLowerCase();
for (const candidate of candidates) {
const normalizedCandidate = caseSensitive
? candidate
: candidate.toLowerCase();
const similarity = jaroWinklerSimilarity(
normalizedInput,
normalizedCandidate
);
if (similarity > highestSimilarity && similarity >= minSimilarity) {
highestSimilarity = similarity;
closestMatch = candidate;
}
}
return closestMatch;
}
/**
* Formats an unknown value with a "did you mean?" suggestion if a close match exists.
*
* @param {string} value - The unknown/invalid value.
* @param {readonly string[]} validValues - List of valid values to match against.
* @param {Object} [options] - Options passed to findClosestMatch.
* @returns {string} Formatted string like '"foo" (did you mean "bar"?)' or just '"foo"'.
*
* @example
* formatWithSuggestion("conditon", ["conditions"]) // => '"conditon" (did you mean "conditions"?)'
* formatWithSuggestion("xyz", ["conditions"]) // => '"xyz"'
*/
export function formatWithSuggestion(value, validValues, options = {}) {
const suggestion = findClosestMatch(value, validValues, options);
if (suggestion) {
return `"${value}" (did you mean "${suggestion}"?)`;
}
return `"${value}"`;
}
+3 -3
View File
@@ -1,11 +1,11 @@
import { DEBUG } from "@glimmer/env";
import { capitalize } from "@ember/string";
import { isTesting } from "discourse/lib/environment";
import { consolePrefix } from "discourse/lib/source-identifier";
import {
BEHAVIOR_TRANSFORMERS,
VALUE_TRANSFORMERS,
} from "discourse/lib/transformer/registry";
} from "discourse/lib/registry/transformers";
import { consolePrefix } from "discourse/lib/source-identifier";
const CORE_TRANSFORMER = "CORE";
const PLUGIN_TRANSFORMER = "PLUGIN";
@@ -28,7 +28,7 @@ let skipApplyExceptionOnTests = false;
*
* Some checks are performed to ensure there are no repeated names between the multiple transformer types.
*
* The list can be edited in `discourse/lib/transformer/registry`
* The list can be edited in `discourse/lib/registry/transformers`
*/
let validTransformerNames = new Map();
+3
View File
@@ -116,3 +116,6 @@ loaderShim("truth-helpers/helpers/or", () =>
);
loaderShim("virtual-dom", () => importSync("discourse/widgets/virtual-dom"));
loaderShim("xss", () => importSync("xss"));
loaderShim("discourse/lib/transformer/registry", () =>
importSync("discourse/lib/registry/transformers")
);
+353
View File
@@ -0,0 +1,353 @@
// @ts-check
import { getOwner, setOwner } from "@ember/owner";
import Service from "@ember/service";
import { debugHooks } from "discourse/lib/blocks/-internals/debug-hooks";
import { getBlockMetadata } from "discourse/lib/blocks/-internals/decorator";
import { evaluateConditions } from "discourse/lib/blocks/-internals/matching/condition-evaluator";
import {
getAllBlockEntries,
getBlockEntry,
hasBlock,
isBlockFactory,
resolveBlock,
} from "discourse/lib/blocks/-internals/registry/block";
import { getAllConditionTypeEntries } from "discourse/lib/blocks/-internals/registry/condition";
import { getAllOutlets } from "discourse/lib/blocks/-internals/registry/outlet";
import { validateConditions } from "discourse/lib/blocks/-internals/validation/conditions";
/**
* Unified service for block registry and condition evaluation.
*
* ## Block Registry
*
* Provides introspection for registered block components:
* - `getBlock(name)` - Get a block by name
* - `hasBlock(name)` - Check if a block is registered
* - `listBlocks()` - Get all registered blocks
* - `listBlocksWithMetadata()` - Get all blocks with their metadata
*
* Core blocks are auto-discovered from `discourse/blocks/builtin`.
* Theme/plugin blocks are registered via `api.registerBlock()` in pre-initializers.
*
* ## Condition Evaluation
*
* Evaluates block render conditions at runtime:
* - `evaluate(conditionSpec)` - Evaluate condition(s)
* - `validate(conditionSpec)` - Validate condition(s) at registration time
*
* Custom condition types are registered via `api.registerBlockConditionType()` in pre-initializers.
* Built-in condition types are auto-discovered from `discourse/blocks/conditions`.
*
* Supports boolean combinators:
* - Array of conditions: AND logic (all must pass)
* - `{ any: [...] }`: OR logic (at least one must pass)
* - `{ not: {...} }`: NOT logic (must fail)
*
* ## Debug Support
*
* - `showGhosts` - Check if visual overlay is enabled (for rendering ghost blocks)
*
* @experimental This API is under active development and may change or be removed
* in future releases without prior notice. Use with caution in production environments.
*
* @class Blocks
* @extends Service
*/
export default class Blocks extends Service {
/**
* Map of condition type names to their instances.
* Built lazily from the condition type registry when first accessed.
*
* @type {Map<string, import("discourse/blocks/conditions").BlockCondition>}
*/
#conditionInstances = new Map();
/**
* Tracks the registry size at last initialization to detect new registrations.
*
* We use size-based detection (rather than tracking individual type names) because:
* 1. Condition types are only ever added, never removed
* 2. Size comparison is O(1) vs O(n) for set difference
* 3. Avoids allocating a Set<string> for tracking
*
* When registry.size > #lastKnownRegistrySize, we know new types were registered
* and need to create instances for them.
*
* @type {number}
*/
#lastKnownRegistrySize = 0;
/*
* Block Outlet Methods
*/
/**
* Returns all registered block outlet names (both core and custom).
*
* Core outlets are defined in `lib/registry/block-outlets.js`. Custom outlets
* are registered by plugins and themes via `api.registerBlockOutlet()`.
*
* @returns {string[]} Array of outlet names (e.g., ["hero-blocks", "homepage-blocks", ...]).
*
* @example
* ```javascript
* const outlets = this.blocks.listOutlets();
* ```
*/
listOutlets() {
return getAllOutlets();
}
/*
* Block Registry Methods
*/
/**
* Gets a registered block by name.
*
* @param {string} name - The block name (e.g., "hero-banner")
* @returns {import("discourse/lib/blocks/-internals/registry/block").BlockRegistryEntry|undefined} The block entry, or undefined if not found
*
* @example
* ```javascript
* const HeroBanner = this.blocks.getBlock("hero-banner");
* ```
*/
getBlock(name) {
return getBlockEntry(name);
}
/**
* Checks if a block is registered.
*
* @param {string} name - The block name
* @returns {boolean}
*
* @example
* ```javascript
* if (this.blocks.hasBlock("hero-banner")) {
* // Block is available
* }
* ```
*/
hasBlock(name) {
return hasBlock(name);
}
/**
* Returns all registered block entries.
*
* @returns {Array<import("discourse/lib/blocks/-internals/registry/block").BlockRegistryEntry>}
*
* @example
* ```javascript
* const allBlocks = this.blocks.listBlocks();
* ```
*/
listBlocks() {
return getAllBlockEntries().map(([, entry]) => entry);
}
/**
* Returns all registered blocks with their metadata.
* Useful for admin UIs and documentation generation.
*
* @returns {Array<{name: string, component: import("discourse/lib/blocks/-internals/registry/block").BlockRegistryEntry, metadata: Object}>}
*
* @example
* ```javascript
* const blocksInfo = this.blocks.listBlocksWithMetadata();
* blocksInfo.forEach(({ name, metadata }) => {
* console.log(name, metadata.description, metadata.args);
* });
* ```
*/
listBlocksWithMetadata() {
return getAllBlockEntries().map(([name, component]) => ({
name,
component,
metadata: getBlockMetadata(component),
}));
}
/**
* Asynchronously gets a registered block by name, resolving factories if needed.
*
* Unlike `getBlock()` which returns the raw registry entry (which may be a factory),
* this method ensures the returned value is always a resolved BlockClass.
*
* @param {string} name - The block name (e.g., "hero-banner").
* @returns {Promise<import("discourse/lib/blocks/-internals/registry/block").BlockClass|undefined>}
* The resolved block class, or undefined if not found.
*
* @example
* ```javascript
* const HeroBanner = await this.blocks.getBlockAsync("hero-banner");
* if (HeroBanner) {
* // Block is ready to use
* }
* ```
*/
async getBlockAsync(name) {
if (!hasBlock(name)) {
return undefined;
}
try {
return await resolveBlock(name);
} catch {
return undefined;
}
}
/**
* Checks if a block is registered and fully resolved (not a pending factory).
*
* Use this to check if a block is immediately available without needing async resolution.
* Returns false for unregistered blocks or blocks that are registered as factory functions
* but haven't been resolved yet.
*
* @param {string} name - The block name.
* @returns {boolean} True if registered and immediately available.
*
* @example
* ```javascript
* if (this.blocks.isBlockReady("hero-banner")) {
* // Block is available synchronously
* const HeroBanner = this.blocks.getBlock("hero-banner");
* } else {
* // Block needs async resolution
* const HeroBanner = await this.blocks.getBlockAsync("hero-banner");
* }
* ```
*/
isBlockReady(name) {
if (!hasBlock(name)) {
return false;
}
const entry = getBlockEntry(name);
return !isBlockFactory(entry);
}
/*
* Condition Evaluation Methods
*/
/**
* Lazily initializes condition instances from the registry.
*
* This deferred initialization pattern handles the timing issue where:
* 1. Service is instantiated early (e.g., during plugin API usage)
* 2. Core conditions are registered later by the pre-initializer
* 3. Service needs to pick up the newly registered conditions
*
* Called at the start of validate(), evaluate(), and other condition methods.
*/
#lazilyInitializeConditionInstances() {
const entries = getAllConditionTypeEntries();
// Only rebuild if registry has grown since last check
if (entries.length === this.#lastKnownRegistrySize) {
return;
}
// Create instances for any new condition types
for (const [type, ConditionClass] of entries) {
if (!this.#conditionInstances.has(type)) {
this.#createConditionInstance(type, ConditionClass);
}
}
this.#lastKnownRegistrySize = entries.length;
}
/**
* Creates an instance of a condition class and stores it in the instances map.
* Sets the owner on the instance to enable service injection.
*
* @param {string} type - The condition type name.
* @param {typeof import("discourse/blocks/conditions").BlockCondition} ConditionClass - The condition class.
*/
#createConditionInstance(type, ConditionClass) {
const instance = new ConditionClass();
setOwner(instance, getOwner(this));
this.#conditionInstances.set(type, instance);
}
/**
* Validates condition specs at block registration time.
* Recursively validates nested conditions in `any` and `not` combinators.
*
* Throws BlockError objects so callers can decide how to format
* the final error with appropriate context. The error object includes a
* `path` property indicating where in the conditions the error occurred
* (relative to the conditions root, e.g., "params.categoryId").
*
* @param {Object|Array<Object>} conditionSpec - Condition spec(s) to validate.
* @throws {BlockError} If validation fails.
*/
validate(conditionSpec) {
this.#lazilyInitializeConditionInstances();
validateConditions(conditionSpec, this.#conditionInstances);
}
/**
* Evaluates condition specs at render time.
* Recursively evaluates nested conditions with AND/OR/NOT logic.
*
* @param {Object|Array<Object>} conditionSpec - Condition spec(s) to evaluate.
* @param {Object} [context] - Evaluation context.
* @param {boolean} [context.debug] - Enable debug logging for this evaluation.
* @param {number} [context._depth] - Internal: nesting depth for logging.
* @param {Object} [context.outletArgs] - Outlet arguments passed to conditions.
* @returns {boolean} True if conditions pass, false otherwise.
*/
evaluate(conditionSpec, context = {}) {
this.#lazilyInitializeConditionInstances();
return evaluateConditions(conditionSpec, this.#conditionInstances, context);
}
/**
* Checks if a condition type is registered.
*
* @param {string} type - The condition type name
* @returns {boolean}
*/
hasConditionType(type) {
this.#lazilyInitializeConditionInstances();
return this.#conditionInstances.has(type);
}
/**
* Returns all registered condition type names.
* Useful for debugging and error messages.
*
* @returns {string[]}
*/
getRegisteredConditionTypes() {
this.#lazilyInitializeConditionInstances();
return [...this.#conditionInstances.keys()];
}
/*
* Debug Methods
*/
/**
* Returns whether the debug visual overlay is enabled.
*
* Container blocks can use this to conditionally render ghost blocks
* for children they choose not to display.
*
* @returns {boolean} True if the visual overlay (ghost blocks) is enabled.
*
* @example
* ```javascript
* if (this.blocks.showGhosts) {
* // Render ghost blocks for hidden children
* }
* ```
*/
get showGhosts() {
return debugHooks.isGhostBlocksEnabled;
}
}
@@ -37,6 +37,12 @@ export default class DiscoveryService extends Service {
}
}
get custom() {
if (this.onDiscoveryRoute) {
return this.router.currentRouteName === "discovery.custom";
}
}
get #routeAttrs() {
return this.router.currentRoute.attributes;
}
@@ -0,0 +1,177 @@
// @ts-check
import Component from "@glimmer/component";
import { array, hash } from "@ember/helper";
/** @type {import("discourse/float-kit/components/d-tooltip.gjs").default} */
import DTooltip from "discourse/float-kit/components/d-tooltip";
import icon from "discourse/helpers/d-icon";
/** @type {import("../shared/args-table.gjs").default} */
import ArgsTable from "../shared/args-table";
/** @type {import("./conditions-tree.gjs").default} */
import ConditionsTree from "./conditions-tree";
/**
* Visual overlay component for rendered blocks.
* Wraps a block with debug information including name badge and tooltip.
*
* @param {string} blockName - The name of the block.
* @param {string} [blockId] - The block's unique ID (if set).
* @param {string} debugLocation - The hierarchy path where the block is rendered.
* @param {Object} [blockArgs] - Arguments passed to the block.
* @param {Object} [containerArgs] - Container arguments passed from parent container's childArgs.
* @param {Object} [conditions] - Conditions that were evaluated.
* @param {Object} [outletArgs] - Outlet arguments available to the block.
* @param {Component} WrappedComponent - The actual block component to render.
*/
export default class BlockInfo extends Component {
/**
* Checks whether this block has any conditions configured.
* Used to conditionally render the conditions section in the tooltip.
*
* @returns {boolean} True if the block has conditions defined.
*/
get hasConditions() {
return this.args.conditions != null;
}
/**
* Checks whether this block has any arguments passed to it.
* Used to conditionally render the arguments section in the tooltip.
*
* @returns {boolean} True if the block has at least one argument.
*/
get hasArgs() {
return (
this.args.blockArgs != null && Object.keys(this.args.blockArgs).length > 0
);
}
/**
* Checks whether this block has container args from a parent container.
* Used to conditionally render the container args section in the tooltip.
*
* @returns {boolean} True if the block has container args.
*/
get hasContainerArgs() {
return (
this.args.containerArgs != null &&
Object.keys(this.args.containerArgs).length > 0
);
}
/**
* Checks whether this block has outlet args available.
* Used to conditionally render the outlet args section in the tooltip.
*
* @returns {boolean} True if outlet args are available.
*/
get hasOutletArgs() {
return (
this.args.outletArgs != null &&
Object.keys(this.args.outletArgs).length > 0
);
}
/**
* Checks whether the tooltip has no content to display.
* Used to show an "empty" message when there are no conditions, args,
* container args, or outlet args.
*
* @returns {boolean} True if there is nothing to display in the tooltip.
*/
get isEmpty() {
return (
!this.hasConditions &&
!this.hasArgs &&
!this.hasContainerArgs &&
!this.hasOutletArgs
);
}
/**
* Returns the display name for the block, including ID if set.
* Format: "blockName" or "blockName(#id)".
*
* @returns {string} The display name.
*/
get displayName() {
if (this.args.blockId) {
return `${this.args.blockName}(#${this.args.blockId})`;
}
return this.args.blockName;
}
<template>
<div class="block-debug-info --rendered" data-block-name={{@blockName}}>
<DTooltip
@identifier="block-debug-info"
@interactive={{true}}
@placement="bottom-start"
@maxWidth={{500}}
@triggers={{hash
mobile=(array "click")
desktop=(array "hover" "click")
}}
@untriggers={{hash mobile=(array "click") desktop=(array "mouseleave")}}
>
<:trigger>
<span class="block-debug-badge">
{{icon "cube"}}
<span class="block-debug-badge__name">{{this.displayName}}</span>
</span>
</:trigger>
<:content>
<div class="block-debug-tooltip">
<div class="block-debug-tooltip__header">
<div class="block-debug-tooltip__row">
{{icon "cube"}}
<span class="block-debug-tooltip__title">
{{this.displayName}}
</span>
</div>
<div class="block-debug-tooltip__location">
in
{{@debugLocation}}
</div>
</div>
{{#if this.hasConditions}}
<div class="block-debug-tooltip__section">
<div class="block-debug-tooltip__section-title">Conditions
<span class="--passed">(passed)</span></div>
<ConditionsTree @conditions={{@conditions}} @passed={{true}} />
</div>
{{/if}}
{{#if this.hasArgs}}
<div class="block-debug-tooltip__section">
<div class="block-debug-tooltip__section-title">Arguments</div>
<ArgsTable @args={{@blockArgs}} />
</div>
{{/if}}
{{#if this.hasContainerArgs}}
<div class="block-debug-tooltip__section">
<div class="block-debug-tooltip__section-title">Container Args</div>
<ArgsTable @args={{@containerArgs}} />
</div>
{{/if}}
{{#if this.hasOutletArgs}}
<div class="block-debug-tooltip__section">
<div class="block-debug-tooltip__section-title">Outlet Args</div>
<ArgsTable @args={{@outletArgs}} />
</div>
{{/if}}
{{#if this.isEmpty}}
<div class="block-debug-tooltip__empty">
No conditions or arguments
</div>
{{/if}}
</div>
</:content>
</DTooltip>
<@WrappedComponent />
</div>
</template>
}
@@ -0,0 +1,140 @@
// @ts-check
import Component from "@glimmer/component";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
/** @type {import("discourse/float-kit/components/d-menu.gjs").default} */
import DMenu from "discourse/float-kit/components/d-menu";
import concatClass from "discourse/helpers/concat-class";
import icon from "discourse/helpers/d-icon";
/** @type {import("discourse/helpers/element.gjs").default} */
import element from "discourse/helpers/element";
import { i18n } from "discourse-i18n";
import devToolsState from "../state";
/**
* Block debug button with dropdown menu.
* Provides separate toggles for outlet boundaries, visual overlay, ghost blocks,
* and condition debugging.
*/
export default class BlockDebugButton extends Component {
/**
* Determines if any block debug feature is currently enabled.
* Used to highlight the toolbar button when debugging is active.
*
* @returns {boolean} True if any block debug mode is enabled.
*/
get isActive() {
return (
devToolsState.blockDebug ||
devToolsState.blockVisualOverlay ||
devToolsState.blockGhostBlocks ||
devToolsState.blockOutletBoundaries
);
}
/**
* Toggles outlet boundary indicators around block outlets.
* When enabled, shows visual borders around each block outlet area.
*
* @param {Event} event - The checkbox change event.
*/
@action
toggleOutletBoundaries(event) {
devToolsState.blockOutletBoundaries = /** @type {HTMLInputElement} */ (
event.target
).checked;
}
/**
* Toggles visual overlay that displays block information on the page.
* When enabled, shows badges and tooltips on rendered blocks.
*
* @param {Event} event - The checkbox change event.
*/
@action
toggleVisualOverlay(event) {
devToolsState.blockVisualOverlay = /** @type {HTMLInputElement} */ (
event.target
).checked;
}
/**
* Toggles ghost blocks that show hidden blocks with dashed outlines.
* When enabled, shows placeholder outlines for blocks that weren't rendered
* (e.g., failed conditions, optional missing, no visible children).
*
* @param {Event} event - The checkbox change event.
*/
@action
toggleGhostBlocks(event) {
devToolsState.blockGhostBlocks = /** @type {HTMLInputElement} */ (
event.target
).checked;
}
/**
* Toggles condition debugging for block condition evaluation.
* When enabled, logs detailed information about each block's condition checks.
*
* @param {Event} event - The checkbox change event.
*/
@action
toggleConditionDebugging(event) {
devToolsState.blockDebug = /** @type {HTMLInputElement} */ (
event.target
).checked;
}
<template>
<DMenu
@identifier="block-debug-menu"
@triggerClass={{concatClass
"toggle-blocks"
(if this.isActive "--active")
}}
@triggerComponent={{element "button"}}
@modalForMobile={{false}}
@title={{i18n "dev_tools.toggle_block_debug"}}
>
<:trigger>
{{icon "cubes"}}
</:trigger>
<:content>
<div class="block-debug-menu">
<label>
<input
type="checkbox"
checked={{devToolsState.blockOutletBoundaries}}
{{on "change" this.toggleOutletBoundaries}}
/>
{{i18n "dev_tools.block_debug.outlet_boundaries"}}
</label>
<label>
<input
type="checkbox"
checked={{devToolsState.blockVisualOverlay}}
{{on "change" this.toggleVisualOverlay}}
/>
{{i18n "dev_tools.block_debug.visual_overlay"}}
</label>
<label>
<input
type="checkbox"
checked={{devToolsState.blockGhostBlocks}}
{{on "change" this.toggleGhostBlocks}}
/>
{{i18n "dev_tools.block_debug.ghost_blocks"}}
</label>
<label>
<input
type="checkbox"
checked={{devToolsState.blockDebug}}
{{on "change" this.toggleConditionDebugging}}
/>
{{i18n "dev_tools.block_debug.condition_debugging"}}
</label>
</div>
</:content>
</DMenu>
</template>
}
@@ -0,0 +1,186 @@
// @ts-check
import Component from "@glimmer/component";
import { htmlSafe } from "@ember/template";
import concatClass from "discourse/helpers/concat-class";
import { formatValue } from "../lib/value-formatter";
/**
* Displays condition hierarchy with pass/fail indicators.
*
* @param {Object|Array} conditions - The conditions to display
* @param {boolean} passed - Whether the conditions passed overall
*/
export default class ConditionsTree extends Component {
/**
* Transforms the raw conditions object/array into a hierarchical array of
* formatted nodes for rendering. This is the entry point for the recursive
* formatting. Each node may contain a `children` array for nested conditions.
*
* @returns {Array<Object>} An array of condition nodes ready for rendering.
*/
get formattedConditions() {
return this.#formatCondition(this.args.conditions, 0);
}
/**
* Recursively transforms a condition structure into a hierarchical array of nodes.
* Each combinator node (AND/OR/NOT) contains a `children` array with nested nodes.
* Handles four types of input:
* - Array: Treated as AND logic, wraps children in an AND combinator node.
* - Object with `any`: OR combinator, children are the `any` array items.
* - Object with `not`: NOT combinator, child is the negated condition.
* - Object with `type`: Leaf condition node with optional arguments.
*
* @param {Object|Array|null} condition - The condition to format.
* @param {number} depth - The nesting depth for indentation calculation.
* @returns {Array<Object>} An array of formatted condition nodes.
*/
#formatCondition(condition, depth) {
if (!condition) {
return [];
}
const items = [];
// Array of conditions (AND logic) - all conditions must pass
if (Array.isArray(condition)) {
items.push({
type: "AND",
depth,
children: condition.flatMap((c) => this.#formatCondition(c, depth + 1)),
});
return items;
}
// OR combinator - at least one condition must pass
if (condition.any !== undefined) {
items.push({
type: "OR",
depth,
children: condition.any.flatMap((c) =>
this.#formatCondition(c, depth + 1)
),
});
return items;
}
// NOT combinator - inverts the result of the nested condition
if (condition.not !== undefined) {
items.push({
type: "NOT",
depth,
children: this.#formatCondition(condition.not, depth + 1),
});
return items;
}
// Single condition with type (leaf node) - extract type and remaining args
const { type, ...args } = condition;
items.push({
type,
args: Object.keys(args).length > 0 ? args : null,
depth,
isLeaf: true,
});
return items;
}
<template>
<div
class={{concatClass
"block-debug-conditions"
(if @passed "--passed" "--failed")
}}
>
{{#each this.formattedConditions as |item|}}
<ConditionNode @item={{item}} @passed={{@passed}} />
{{/each}}
</div>
</template>
}
/**
* Renders a single condition node in the tree.
* Handles both combinator nodes (AND, OR, NOT) and leaf condition nodes.
*
* @param {Object} item - The condition node data from #formatCondition.
* @param {boolean} passed - Whether the overall conditions passed.
*/
class ConditionNode extends Component {
/**
* Formatting options for condition argument values.
* Enables expanded arrays, symbols, and RegExp handling for readable output.
*
* @constant {Object}
*/
static FORMAT_OPTIONS = {
expandArrays: true,
handleSymbols: true,
handleRegExp: true,
};
/**
* Calculates the CSS padding for indentation based on the node's depth.
* Each level adds 12px of left padding.
*
* @returns {ReturnType<typeof htmlSafe>} CSS style string for padding, marked as safe for binding.
*/
get indentStyle() {
return htmlSafe(`padding-left: ${this.args.item.depth * 12}px`);
}
/**
* Checks if this node is a boolean combinator (AND, OR, NOT) rather than
* a leaf condition. Combinators are styled differently in the UI.
*
* @returns {boolean} True if this is a combinator node.
*/
get isCombinator() {
return ["AND", "OR", "NOT"].includes(this.args.item.type);
}
/**
* Formats the condition's arguments as a comma-separated string for display.
* Returns null if there are no arguments to display.
*
* @returns {string|null} Formatted arguments string, or null if no arguments.
*/
get argsDisplay() {
const args = this.args.item.args;
if (!args) {
return null;
}
return Object.entries(args)
.map(([k, v]) => `${k}: ${formatValue(v, ConditionNode.FORMAT_OPTIONS)}`)
.join(", ");
}
<template>
<div
class={{concatClass
"block-debug-condition"
(if this.isCombinator "--combinator" "--leaf")
}}
style={{this.indentStyle}}
>
{{#if this.isCombinator}}
<span class="block-debug-condition__type --combinator">
{{@item.type}}
</span>
{{else}}
<span class="block-debug-condition__type">{{@item.type}}</span>
{{#if this.argsDisplay}}
<span
class="block-debug-condition__args"
>({{this.argsDisplay}})</span>
{{/if}}
{{/if}}
</div>
{{#if @item.children}}
{{#each @item.children as |child|}}
<ConditionNode @item={{child}} @passed={{@passed}} />
{{/each}}
{{/if}}
</template>
}
@@ -0,0 +1,541 @@
// @ts-check
/**
* Block debug logger with styled console output.
*
* Provides grouped, hierarchical logging for condition evaluations.
* Logs are grouped by block, showing condition trees with pass/fail status.
*
* This module lives in the dev-tools bundle and is only loaded when dev tools
* are enabled, reducing the main application bundle size.
*
* @module discourse/static/dev-tools/block-debug/debug-logger
*/
import { isTypeMismatch } from "discourse/lib/blocks/-internals/matching/value-matcher";
// Console output styles
const STYLES = {
blockName: "font-weight: bold", // bold only, no color
passed: "color: #50c050; font-weight: bold", // bright green for RENDERED
failed: "color: #e05050; font-weight: bold", // vivid red for SKIPPED
combinator: "color: #3070c0; font-weight: bold", // bold blue for operators
hint: "color: #d4a000; font-style: italic", // yellow/orange for hints
};
const ICONS = {
passed: "\u2713", // checkmark
failed: "\u2717", // X (failed)
};
/**
* Block debug logger class.
* Provides grouped console output for block condition evaluations.
*/
class BlockDebugLogger {
/**
* Current evaluation group context.
*
* @type {{blockName: string, blockId: string|null, hierarchy: string, logs: Array}|null}
*/
#currentGroup = null;
/**
* WeakMap to track pending log entries by condition spec object.
* This allows updating log entries by reference rather than by depth/type lookup,
* which is more robust for complex nested conditions.
*
* @type {WeakMap<Object, Object>}
*/
#pendingLogs = new WeakMap();
/**
* Start a new evaluation group for a block render.
* All subsequent logCondition calls will be collected in this group
* until endGroup is called.
*
* @param {string} blockName - The block being evaluated.
* @param {string|null} blockId - The block's unique ID, or null if not set.
* @param {string} hierarchy - The outlet/parent hierarchy path (e.g., "outlet-name/parent-block").
*/
startGroup(blockName, blockId, hierarchy) {
this.#currentGroup = { blockName, blockId, hierarchy, logs: [] };
}
/**
* Log a condition evaluation within the current group.
* If no group is active, logs immediately to console.
*
* @param {Object} options - Log options
* @param {string} options.type - Condition type or combinator (AND/OR/NOT)
* @param {Object} [options.args] - Condition arguments
* @param {boolean|null} options.result - Whether condition passed, or null for pending combinators
* @param {number} [options.depth=0] - Nesting depth for indentation
* @param {{ value: *, hasValue: true, formatted?: Object, note?: string }|undefined} [options.resolvedValue] - Resolved value object
* @param {Object} [options.conditionSpec] - The condition spec object, used to track
* pending results for combinators/conditions that log before evaluation completes.
*/
logCondition({
type,
args,
result,
depth = 0,
resolvedValue,
conditionSpec,
}) {
if (!this.#currentGroup) {
this.#logStandalone(type, args, result);
return;
}
const logEntry = { type, args, result, depth, resolvedValue };
this.#currentGroup.logs.push(logEntry);
// Track pending logs by conditionSpec for later result updates.
// This is used for combinators (AND/OR/NOT) and conditions that need to
// log nested items before knowing their final result.
if (conditionSpec && result === null) {
this.#pendingLogs.set(conditionSpec, logEntry);
}
}
/**
* Log a param group with all matches as a nested expandable group.
* Used for params/queryParams matching in route conditions.
*
* @param {Object} options - Log options.
* @param {string} options.label - Group label (e.g., "params", "queryParams", "params[0]").
* @param {Array<{key: string, expected: *, actual: *, result: boolean}>} options.matches - Match results.
* @param {boolean} options.result - Overall result (all passed).
* @param {number} options.depth - Nesting depth for indentation.
*/
logParamGroup({ label, matches, result, depth }) {
if (!this.#currentGroup) {
return;
}
this.#currentGroup.logs.push({
type: "param-group",
label,
matches,
result,
depth,
});
}
/**
* Log current URL/page state for debugging route conditions.
* Shows URL/page matching status. Params and queryParams are logged separately
* with proper nesting via logCondition.
*
* @param {Object} options - Log options.
* @param {string} options.currentPath - The current URL path (normalized).
* @param {Array} [options.expectedUrls] - URL patterns to match (if using urls).
* @param {Array} [options.excludeUrls] - URL patterns to exclude (if using excludeUrls).
* @param {Array} [options.pages] - Page types to match (e.g., ["CATEGORY_PAGES"]).
* @param {string} [options.actualPageType] - The actual page type (when expected doesn't match).
* @param {Object} [options.actualPageContext] - Actual page context (for determining page type match).
* @param {number} options.depth - Nesting depth for indentation.
* @param {boolean} options.result - Whether the URL/page matched (true) or not (false).
*/
logRouteState({
currentPath,
expectedUrls,
excludeUrls,
pages,
actualPageType,
actualPageContext,
depth,
result,
}) {
if (!this.#currentGroup) {
return;
}
this.#currentGroup.logs.push({
type: "route-state",
currentPath,
expectedUrls,
excludeUrls,
pages,
actualPageType,
actualPageContext,
depth,
result,
});
}
/**
* Update the result of a combinator (AND/OR/NOT) by its condition spec.
* Used to set the actual result after children have been evaluated.
*
* @param {Object} conditionSpec - The condition spec object used when logging.
* @param {boolean} result - The actual result.
*/
updateCombinatorResult(conditionSpec, result) {
if (!this.#currentGroup || !conditionSpec) {
return;
}
const logEntry = this.#pendingLogs.get(conditionSpec);
if (logEntry) {
logEntry.result = result;
this.#pendingLogs.delete(conditionSpec);
}
}
/**
* Update the result of a condition by its condition spec.
* Used when the condition needs to log nested items before knowing its final result.
*
* @param {Object} conditionSpec - The condition spec object used when logging.
* @param {boolean} result - The actual result.
*/
updateConditionResult(conditionSpec, result) {
if (!this.#currentGroup || !conditionSpec) {
return;
}
const logEntry = this.#pendingLogs.get(conditionSpec);
if (logEntry) {
logEntry.result = result;
this.#pendingLogs.delete(conditionSpec);
}
}
/**
* End the current group and flush logs to console.
* Uses console.groupCollapsed for a clean, expandable view.
* Conditions with nested children are rendered as collapsible groups.
*
* @param {boolean} finalResult - Whether the block will render
*/
endGroup(finalResult) {
if (!this.#currentGroup) {
return;
}
const { blockName, hierarchy, blockId, logs } = this.#currentGroup;
if (logs.length === 0) {
this.#currentGroup = null;
return;
}
const status = finalResult ? "RENDERED" : "SKIPPED";
const statusStyle = finalResult ? STYLES.passed : STYLES.failed;
const icon = finalResult ? ICONS.passed : ICONS.failed;
// Format display name with ID if available: "blockName" or "blockName(#id)"
const displayName = blockId ? `${blockName}(#${blockId})` : blockName;
// Format: [Blocks] {icon} {STATUS} {displayName} in {hierarchy}
// eslint-disable-next-line no-console
console.groupCollapsed(
`[Blocks] %c${icon} ${status}%c %c${displayName}%c in ${hierarchy}`,
statusStyle, // icon + status - same color
"", // reset
STYLES.blockName, // block name - bold
"font-weight: normal" // "in {hierarchy}" - explicitly reset bold
);
// Track open groups by their depth so we can close them when needed
const openGroupDepths = [];
for (let i = 0; i < logs.length; i++) {
const log = logs[i];
const nextLog = logs[i + 1];
// Close any groups at same or deeper depth before rendering this log
while (
openGroupDepths.length > 0 &&
openGroupDepths[openGroupDepths.length - 1] >= log.depth
) {
// eslint-disable-next-line no-console
console.groupEnd();
openGroupDepths.pop();
}
// Check if this log has children (next log is deeper)
const hasChildren = nextLog && nextLog.depth > log.depth;
this.#logTreeNode(log, hasChildren);
// If we opened a group for this log, track it
if (hasChildren && this.#isGroupableLog(log)) {
openGroupDepths.push(log.depth);
}
}
// Close any remaining open groups
while (openGroupDepths.length > 0) {
// eslint-disable-next-line no-console
console.groupEnd();
openGroupDepths.pop();
}
// eslint-disable-next-line no-console
console.groupEnd();
this.#currentGroup = null;
}
/**
* Check if a log entry should be rendered as a collapsible group when it has children.
* Param groups and route state handle their own grouping internally.
*
* @param {Object} log - The log entry
* @returns {boolean} True if this log should be a group when it has children
*/
#isGroupableLog(log) {
const isSpecialType = ["param-group", "route-state"].includes(log.type);
return !isSpecialType;
}
/**
* Log a single node in the condition tree.
*
* @param {Object} log - The log entry.
* @param {string} log.type - Condition type.
* @param {Object} [log.args] - Condition arguments.
* @param {boolean} log.result - Pass/fail.
* @param {number} log.depth - Indentation depth.
* @param {string} [log.label] - Label for param groups.
* @param {Array} [log.matches] - Match results for param groups.
* @param {{ value: *, hasValue: true, formatted?: Object, note?: string }|undefined} [log.resolvedValue] - Resolved value object.
* @param {string} [log.currentPath] - Current URL path (for route-state type).
* @param {Array<string>} [log.expectedUrls] - Expected URL patterns (for route-state type).
* @param {Array<string>} [log.excludeUrls] - Excluded URL patterns (for route-state type).
* @param {Object} [log.pages] - Page configuration (for route-state type).
* @param {string} [log.actualPageType] - Actual page type (for route-state type).
* @param {Object} [log.actualPageContext] - Actual page context (for route-state type).
* @param {boolean} [hasChildren=false] - Whether this node has nested children.
*/
#logTreeNode(log, hasChildren = false) {
const { type, args, result, resolvedValue } = log;
// Handle route state (shows current URL/page status)
// Uses checkmark/X to show whether the route matched
if (type === "route-state") {
const {
currentPath,
expectedUrls,
excludeUrls: excludedUrls,
pages,
actualPageType,
actualPageContext,
result: routeResult,
} = log;
const routeIcon = routeResult ? ICONS.passed : ICONS.failed;
const routeStyle = routeResult ? STYLES.passed : STYLES.failed;
// When using pages option, show page type and params as siblings
if (pages) {
// Page type matches if actualPageContext exists (regardless of params)
const pageTypeMatched = actualPageContext !== null;
const pageIcon = pageTypeMatched ? ICONS.passed : ICONS.failed;
const pageStyle = pageTypeMatched ? STYLES.passed : STYLES.failed;
let pageStatus;
if (pageTypeMatched) {
pageStatus = `on ${actualPageContext?.pageType || pages[0]}`;
} else {
// Show what page type they're actually on (if any)
const actualInfo = actualPageType
? ` (actual: ${actualPageType})`
: "";
pageStatus = `not on ${pages.join(", ")}${actualInfo}`;
}
// eslint-disable-next-line no-console
console.log(`%c${pageIcon}%c ${pageStatus}`, pageStyle, "");
// Note: params are logged separately with nesting (like queryParams)
return;
}
// For urls option, show URL matching info
const expected = excludedUrls
? { excludeUrls: excludedUrls }
: { urls: expectedUrls };
// eslint-disable-next-line no-console
console.log(`%c${routeIcon}%c current URL:`, routeStyle, "", {
actual: currentPath,
expected,
});
return;
}
const icon = result ? ICONS.passed : ICONS.failed;
const iconStyle = result ? STYLES.passed : STYLES.failed;
// Handle param group (params/queryParams)
if (type === "param-group") {
const { label, matches } = log;
// Single key: print directly without group wrapper
if (matches.length === 1) {
const match = matches[0];
this.#logParamMatch(match, `${label}: ${match.key}`, icon, iconStyle);
return;
}
// Multiple keys: use collapsible group
// eslint-disable-next-line no-console
console.groupCollapsed(
`%c${icon}%c ${label} (${matches.length} keys)`,
iconStyle,
""
);
for (const match of matches) {
const matchIcon = match.result ? ICONS.passed : ICONS.failed;
const matchStyle = match.result ? STYLES.passed : STYLES.failed;
this.#logParamMatch(match, match.key, matchIcon, matchStyle);
}
// eslint-disable-next-line no-console
console.groupEnd();
return;
}
const isCombinator = ["AND", "OR", "NOT"].includes(type);
// Use groupCollapsed for conditions with children, log for others
// eslint-disable-next-line no-console
const logFn = hasChildren ? console.groupCollapsed : console.log;
if (isCombinator) {
logFn.call(
console,
`%c${icon}%c %c${type}`,
iconStyle,
"",
STYLES.combinator,
args ? `(${args})` : ""
);
} else {
// Condition type has no special formatting
// Use formatted object if provided, otherwise add actual to args
const hasResolved = resolvedValue?.hasValue;
let loggedArgs;
if (hasResolved && resolvedValue.formatted) {
loggedArgs = resolvedValue.formatted;
} else if (hasResolved) {
loggedArgs =
args && Object.keys(args).length > 0
? { ...args, actual: resolvedValue.value }
: { actual: resolvedValue.value };
} else {
loggedArgs = args && Object.keys(args).length > 0 ? args : "";
}
// Display warning note if present (e.g., unknown setting names)
if (resolvedValue?.note) {
logFn.call(
console,
`%c${icon}%c ${type} %c⚠ ${resolvedValue.note}`,
iconStyle,
"",
STYLES.hint,
loggedArgs
);
} else {
logFn.call(console, `%c${icon}%c ${type}`, iconStyle, "", loggedArgs);
}
}
}
/**
* Log a single param match with optional type mismatch hint.
*
* @param {Object} match - The match object with configured (expected), actual, result.
* @param {string} label - Display label for the param.
* @param {string} icon - Pass/fail icon.
* @param {string} iconStyle - CSS style for the icon.
*/
#logParamMatch(match, label, icon, iconStyle) {
const { expected: configured, actual, result } = match;
// Check for type mismatch on failed matches
if (!result && isTypeMismatch(actual, configured)) {
const configuredType = this.#getExpectedValueType(configured);
// eslint-disable-next-line no-console
console.log(
`%c${icon}%c ${label} %c⚠ type mismatch: actual is ${typeof actual}, condition specifies ${configuredType}`,
iconStyle,
"",
STYLES.hint,
{ actual, configured }
);
return;
}
// eslint-disable-next-line no-console
console.log(`%c${icon}%c ${label}`, iconStyle, "", { actual, configured });
}
/**
* Get the type of value configured in the condition, unwrapping `{ any: [...] }` and arrays.
* Shows all unique types if mixed (e.g., "number/string").
*
* @param {*} expected - The configured value spec from the condition.
* @returns {string} The type description.
*/
#getExpectedValueType(expected) {
// Unwrap { any: [...] } or arrays
const values = expected?.any ?? (Array.isArray(expected) ? expected : null);
if (values && values.length > 0) {
const types = [...new Set(values.map((v) => typeof v))];
return types.join("/");
}
return typeof expected;
}
/**
* Log a standalone condition (when no group is active).
*
* @param {string} type - Condition type
* @param {Object} args - Condition arguments
* @param {boolean} result - Pass/fail
*/
#logStandalone(type, args, result) {
const icon = result ? ICONS.passed : ICONS.failed;
// eslint-disable-next-line no-console
console.debug(`[Blocks] ${icon} ${type}:`, args);
}
/**
* Log that an optional block was skipped because it's not registered.
* Uses standalone log (no group needed since there are no conditions to show).
* Format matches endGroup header: `[Blocks] ✗ SKIPPED {displayName} in {hierarchy}`
*
* @param {string} blockName - The name of the missing optional block.
* @param {string|null} blockId - The block's unique ID, or null if not set.
* @param {string} hierarchy - The outlet/container hierarchy path.
*/
logOptionalMissing(blockName, blockId, hierarchy) {
// Format display name with ID if available
const displayName = blockId ? `${blockName}(#${blockId})` : blockName;
// eslint-disable-next-line no-console
console.log(
`[Blocks] %c${ICONS.failed} SKIPPED%c %c${displayName}%c in ${hierarchy} %c(optional, not registered)`,
STYLES.failed,
"",
STYLES.blockName,
"font-weight: normal",
STYLES.hint
);
}
/**
* Check if a group is currently active.
*
* @returns {boolean}
*/
hasActiveGroup() {
return this.#currentGroup !== null;
}
}
export const blockDebugLogger = new BlockDebugLogger();
@@ -0,0 +1,225 @@
// @ts-check
import Component from "@glimmer/component";
import { array, hash } from "@ember/helper";
/** @type {import("discourse/float-kit/components/d-tooltip.gjs").default} */
import DTooltip from "discourse/float-kit/components/d-tooltip";
import icon from "discourse/helpers/d-icon";
import { FAILURE_TYPE } from "discourse/lib/blocks/-internals/patterns";
import { i18n } from "discourse-i18n";
/** @type {import("../shared/args-table.gjs").default} */
import ArgsTable from "../shared/args-table";
/** @type {import("./conditions-tree.gjs").default} */
import ConditionsTree from "./conditions-tree";
/**
* Component signature for GhostBlock.
*
* @typedef {Object} GhostBlockSignature
* @property {Object} Args
* @property {string} Args.blockName - The name of the hidden block.
* @property {string} [Args.blockId] - The block's unique ID (if set).
* @property {string} Args.debugLocation - The hierarchy path where the block would render.
* @property {Object} [Args.blockArgs] - Arguments that would have been passed to the block.
* @property {Object} [Args.containerArgs] - Container arguments from parent container's childArgs.
* @property {Object} [Args.conditions] - Conditions that failed evaluation.
* @property {string} [Args.failureType] - The failure type constant (FAILURE_TYPE value).
* @property {string} [Args.failureReason] - Optional custom display message (overrides type-based default).
* @property {Array<{Component: import("ember-curry-component").CurriedComponent}>} [Args.children] - Nested ghost children for containers.
*/
/**
* Ghost placeholder for blocks that are hidden.
* Shows a dashed outline indicating where the block would render.
*
* Blocks can be hidden for several reasons:
* - **Optional missing**: Block reference uses `?` suffix but isn't registered
* - **Conditions failed**: Block is registered but conditions evaluated to false
* - **No visible children**: Container block has no children that pass their conditions
* - **Custom reason**: Container block chose not to render this child (e.g., head block's "hidden by priority")
*
* For container blocks hidden due to no visible children, nested ghost children
* are rendered inside to show the full block tree structure.
*
* @extends {Component<GhostBlockSignature>}
*/
export default class GhostBlock extends Component {
/**
* Returns the appropriate hint message based on why the block is hidden.
* If a custom `failureReason` is provided, it is displayed directly.
* Otherwise, a default message is generated based on the `failureType`.
*
* @returns {string} The hint message to display.
*/
get hintMessage() {
if (this.args.failureReason) {
return this.args.failureReason;
}
if (this.args.failureType === FAILURE_TYPE.OPTIONAL_MISSING) {
return i18n("js.blocks.ghost_reasons.optional_missing_hint");
}
if (this.args.failureType === FAILURE_TYPE.NO_VISIBLE_CHILDREN) {
return i18n("js.blocks.ghost_reasons.no_visible_children_hint");
}
return i18n("js.blocks.ghost_reasons.condition_failed_hint");
}
/**
* Returns the section title for the status/conditions section.
*
* @returns {string} Either "Status" or "Conditions".
*/
get sectionTitle() {
if (
this.args.failureType === FAILURE_TYPE.OPTIONAL_MISSING ||
this.args.failureType === FAILURE_TYPE.NO_VISIBLE_CHILDREN ||
this.args.failureReason
) {
return i18n("js.blocks.ghost.status");
}
return i18n("js.blocks.ghost.conditions");
}
/**
* Returns the status text shown in parentheses.
*
* @returns {string} The status text (e.g., "not registered", "failed").
*/
get statusText() {
if (this.args.failureType === FAILURE_TYPE.OPTIONAL_MISSING) {
return i18n("js.blocks.ghost.not_registered");
}
if (this.args.failureType === FAILURE_TYPE.NO_VISIBLE_CHILDREN) {
return i18n("js.blocks.ghost.no_visible_children");
}
if (this.args.failureReason) {
return i18n("js.blocks.ghost.hidden");
}
return i18n("js.blocks.ghost.failed");
}
/**
* Determines if the conditions tree should be shown.
* Only shown when conditions actually failed (not for optional missing,
* no visible children, or custom reason).
*
* @returns {boolean} True if conditions tree should be rendered.
*/
get showConditionsTree() {
return (
this.args.failureType !== FAILURE_TYPE.OPTIONAL_MISSING &&
this.args.failureType !== FAILURE_TYPE.NO_VISIBLE_CHILDREN &&
!this.args.failureReason
);
}
/**
* Checks if an args object has any entries.
*
* @param {Object} args - The arguments object to check.
* @returns {boolean} True if args is non-null and has at least one key.
*/
hasArgs(args) {
return args != null && Object.keys(args).length > 0;
}
/**
* Returns the display name for the block, including ID if set.
* Format: "blockName" or "blockName(#id)".
*
* @returns {string} The display name.
*/
get displayName() {
if (this.args.blockId) {
return `${this.args.blockName}(#${this.args.blockId})`;
}
return this.args.blockName;
}
<template>
<div class="block-debug-ghost" data-block-name={{@blockName}}>
<DTooltip
@identifier="block-debug-ghost"
@interactive={{true}}
@placement="bottom-start"
@maxWidth={{500}}
@triggers={{hash
mobile=(array "click")
desktop=(array "hover" "click")
}}
@untriggers={{hash mobile=(array "click") desktop=(array "mouseleave")}}
>
<:trigger>
<span class="block-debug-ghost__badge">
{{icon "cube"}}
<span class="block-debug-ghost__name">
{{this.displayName}}
</span>
<span class="block-debug-ghost__status">
({{i18n "js.blocks.ghost.hidden"}})
</span>
</span>
</:trigger>
<:content>
<div class="block-debug-tooltip --ghost">
<div class="block-debug-tooltip__header --failed">
<div class="block-debug-tooltip__row">
{{icon "cube"}}
<span class="block-debug-tooltip__title">
{{this.displayName}}
</span>
<span class="block-debug-tooltip__status">
{{i18n "js.blocks.ghost.hidden"}}
</span>
</div>
<div class="block-debug-tooltip__location">
{{i18n "js.blocks.ghost.in_location"}}
{{@debugLocation}}
</div>
</div>
<div class="block-debug-tooltip__section">
<div class="block-debug-tooltip__section-title">
{{this.sectionTitle}}
<span class="--failed">({{this.statusText}})</span>
</div>
{{#if this.showConditionsTree}}
<ConditionsTree @conditions={{@conditions}} @passed={{false}} />
{{/if}}
</div>
{{#if (this.hasArgs @blockArgs)}}
<div class="block-debug-tooltip__section">
<div class="block-debug-tooltip__section-title">
{{i18n "js.blocks.ghost.arguments"}}
</div>
<ArgsTable @args={{@blockArgs}} />
</div>
{{/if}}
{{#if (this.hasArgs @containerArgs)}}
<div class="block-debug-tooltip__section">
<div class="block-debug-tooltip__section-title">
{{i18n "js.blocks.ghost.container_args"}}
</div>
<ArgsTable @args={{@containerArgs}} />
</div>
{{/if}}
<div class="block-debug-tooltip__hint">
{{this.hintMessage}}
</div>
</div>
</:content>
</DTooltip>
{{! Render nested ghost children for container blocks with no visible children }}
{{#if @children.length}}
<div class="block-debug-ghost__children">
{{#each @children as |child|}}
<child.Component />
{{/each}}
</div>
{{/if}}
</div>
</template>
}
@@ -0,0 +1,153 @@
// @ts-check
import Component from "@glimmer/component";
import { array, hash } from "@ember/helper";
/** @type {import("discourse/float-kit/components/d-tooltip.gjs").default} */
import DTooltip from "discourse/float-kit/components/d-tooltip";
import concatClass from "discourse/helpers/concat-class";
import icon from "discourse/helpers/d-icon";
import { DEPRECATED_ARGS_KEY } from "discourse/lib/outlet-args";
/** @type {import("../shared/args-table.gjs").default} */
import ArgsTable from "../shared/args-table";
/**
* Component signature for OutletInfo.
*
* @typedef {Object} OutletInfoSignature
* @property {Object} Args
* @property {string} Args.outletName - The name of the block outlet.
* @property {number} Args.blockCount - Number of blocks registered.
* @property {Object} [Args.outletArgs] - Arguments passed to the outlet.
* @property {Error} [Args.error] - Validation error if config failed.
* @property {Object} Blocks
* @property {[]} Blocks.default - Default block for rendering children.
*/
/**
* Debug overlay for BlockOutlet components.
* Shows outlet name badge with a tooltip containing outlet info and GitHub search link.
*
* @extends {Component<OutletInfoSignature>}
*/
export default class OutletInfo extends Component {
/**
* Returns a human-readable label for the block count.
*
* @returns {string} "1 block" for singular, "{n} blocks" for plural.
*/
get blockLabel() {
const count = this.args.blockCount;
return count === 1 ? "1 block" : `${count} blocks`;
}
/**
* Cleans up the error message for display in the popup.
* Removes the "[Blocks]" prefix while preserving formatted structure.
*
* @returns {string} The cleaned error message.
*/
get errorMessage() {
let message = this.args.error?.message ?? "Unknown validation error";
// Remove "[Blocks]" prefix that's added for console logging
message = message.replace(/^\[Blocks\]\s*/i, "");
return message.trim();
}
/**
* Checks whether this outlet has any args passed to it.
*
* @returns {boolean} True if outlet has at least one arg.
*/
get hasOutletArgs() {
const outletArgs = this.args.outletArgs;
const deprecatedArgs = outletArgs?.[DEPRECATED_ARGS_KEY];
return (
(outletArgs != null && Object.keys(outletArgs).length > 0) ||
(deprecatedArgs != null && Object.keys(deprecatedArgs).length > 0)
);
}
<template>
<div
class={{concatClass
"block-outlet-debug"
(if @error "--validation-failed")
}}
data-outlet-name={{@outletName}}
>
<DTooltip
@identifier="block-outlet-info"
@interactive={{true}}
@placement="bottom-start"
@maxWidth={{400}}
@triggers={{hash
mobile=(array "click")
desktop=(array "click" "hover")
}}
@untriggers={{hash mobile=(array "click") desktop=(array "click")}}
>
<:trigger>
<span class="block-outlet-debug__badge {{if @error '--error'}}">
{{icon "cubes"}}
{{@outletName}}
</span>
</:trigger>
<:content>
<div class="outlet-info__wrapper">
<div
class="outlet-info__heading
{{if @error '--error' '--block-outlet'}}"
>
<span class="title">
{{icon "cubes"}}
{{@outletName}}
</span>
{{#if @error}}
<span class="outlet-info__status">ERROR</span>
{{/if}}
<a
class="github-link"
href="https://github.com/search?q=repo%3Adiscourse%2Fdiscourse%20BlockOutlet%20@name=%22{{@outletName}}%22&type=code"
target="_blank"
rel="noopener noreferrer"
title="Find on GitHub"
>{{icon "fab-github"}}</a>
</div>
<div class="outlet-info__content">
{{#if @error}}
<div class="outlet-info__error">
<div class="outlet-info__section-title">Validation failed</div>
<pre
class="outlet-info__error-message"
>{{this.errorMessage}}</pre>
</div>
{{else if @blockCount}}
<div class="outlet-info__section">
<div class="outlet-info__section-title">Blocks Registered</div>
<div class="outlet-info__stat">
{{icon "cube"}}
<span>{{this.blockLabel}}</span>
</div>
</div>
{{else}}
<div class="outlet-info__empty">
No blocks registered for this outlet
</div>
{{/if}}
{{#if this.hasOutletArgs}}
<div class="outlet-info__section">
<div class="outlet-info__section-title">Outlet Args</div>
<ArgsTable @args={{@outletArgs}} @prefix="block outlet" />
</div>
{{/if}}
</div>
</div>
</:content>
</DTooltip>
{{yield}}
</div>
</template>
}
@@ -0,0 +1,362 @@
// @ts-check
import curryComponent from "ember-curry-component";
import {
DEBUG_CALLBACK,
debugHooks,
} from "discourse/lib/blocks/-internals/debug-hooks";
import { getBlockMetadata } from "discourse/lib/blocks/-internals/decorator";
import {
FAILURE_TYPE,
MAX_LAYOUT_DEPTH,
OPTIONAL_MISSING,
} from "discourse/lib/blocks/-internals/patterns";
import { getOwnerWithFallback } from "discourse/lib/get-owner";
import devToolsState from "../state";
/** @type {import("./block-info.gjs").default} */
import BlockInfo from "./block-info";
import { blockDebugLogger } from "./debug-logger";
/** @type {import("./ghost-block.gjs").default} */
import GhostBlock from "./ghost-block";
/** @type {import("./outlet-info.gjs").default} */
import OutletInfo from "./outlet-info";
/**
* Creates a wrapper callback that only executes when block debug is enabled.
*
* This factory eliminates the repeated `if (devToolsState.blockDebug)` checks
* across all logging callbacks.
*
* @param {Function} fn - The callback function to wrap.
* @returns {Function} A wrapper that calls `fn` only when debug is enabled.
*/
function makeDebugCallback(fn) {
return (...args) => {
if (devToolsState.blockDebug) {
fn(...args);
}
};
}
/**
* Creates ghost components for children of a container ghost block.
*
* When a container block is rendered as a ghost (due to no visible children),
* this function recursively processes its children to create ghost components
* so they appear nested inside the container ghost in the debug overlay.
*
* Includes a depth limit as defense-in-depth against stack overflow. The primary
* protection is validation-time depth checking in `validateLayout`, but this
* provides additional safety during ghost rendering.
*
* @param {Array<Object>} childEntries - Child layout entries (already preprocessed with __visible and __failureType)
* @param {import("@ember/owner").default} owner - The application owner
* @param {string} containerPath - The container's hierarchy path (e.g., "outlet/group[0]")
* @param {Object} outletArgs - Outlet arguments for context
* @param {boolean} isLoggingEnabled - Whether logging is enabled (unused, kept for API compatibility)
* @param {Function} resolveBlockFn - Function to resolve block references to classes
* @param {number} [depth=0] - Current nesting depth for recursion limit checking.
* @returns {Array<{Component: import("ember-curry-component").CurriedComponent, isGhost?: boolean, asGhost?: Function}>} Array of ghost component data
*/
function createGhostChildren(
childEntries,
owner,
containerPath,
outletArgs,
isLoggingEnabled,
resolveBlockFn,
depth = 0
) {
// Defense-in-depth: silently stop recursion if depth exceeds limit.
// Primary validation happens at layout validation time in validateLayout().
if (depth >= MAX_LAYOUT_DEPTH) {
return [];
}
const result = [];
const containerCounts = new Map();
for (const childEntry of childEntries) {
const resolvedBlock = resolveBlockFn(childEntry.block);
// Handle optional missing block
if (resolvedBlock?.optionalMissing === OPTIONAL_MISSING) {
const ghostData = debugHooks.getCallback(DEBUG_CALLBACK.BLOCK_DEBUG)?.(
{
name: resolvedBlock.name,
id: childEntry.id,
Component: null,
args: childEntry.args,
containerArgs: childEntry.containerArgs,
conditions: childEntry.conditions,
conditionsPassed: false,
failureType: FAILURE_TYPE.OPTIONAL_MISSING,
},
{ outletName: containerPath }
);
if (ghostData?.Component) {
result.push(ghostData);
}
continue;
}
// Skip unresolved blocks
if (!resolvedBlock) {
continue;
}
const blockMeta = getBlockMetadata(resolvedBlock);
const blockName = blockMeta?.blockName || "unknown";
const isChildContainer = blockMeta?.isContainer ?? false;
// Build container path for nested containers.
// Use id if available (unique), otherwise fall back to index.
let nestedContainerPath;
if (isChildContainer) {
const count = containerCounts.get(blockName) ?? 0;
containerCounts.set(blockName, count + 1);
const suffix = childEntry.id ? `(#${childEntry.id})` : `[${count}]`;
nestedContainerPath = `${containerPath}/${blockName}${suffix}`;
}
// Recursively create ghost children for nested containers
let nestedGhostChildren = null;
if (
isChildContainer &&
childEntry.children?.length &&
childEntry.__failureType === FAILURE_TYPE.NO_VISIBLE_CHILDREN
) {
nestedGhostChildren = createGhostChildren(
childEntry.children,
owner,
nestedContainerPath,
outletArgs,
isLoggingEnabled,
resolveBlockFn,
depth + 1
);
}
const ghostData = debugHooks.getCallback(DEBUG_CALLBACK.BLOCK_DEBUG)?.(
{
name: blockName,
id: childEntry.id,
Component: null,
args: childEntry.args,
containerArgs: childEntry.containerArgs,
conditions: childEntry.conditions,
conditionsPassed: false,
failureType: childEntry.__failureType,
failureReason: childEntry.__failureReason, // Optional custom message
children: nestedGhostChildren,
},
{ outletName: containerPath }
);
if (ghostData?.Component) {
result.push(ghostData);
}
}
return result;
}
/**
* Patches the block system to inject debug overlay components.
*
* When visual overlay is enabled, this callback wraps rendered blocks
* with BlockInfo components and adds GhostBlock placeholders for
* blocks that fail their conditions.
*
* Uses devToolsState via closure to check state at invocation time,
* following the same pattern as plugin-outlet-debug.
*/
export function patchBlockRendering() {
// Callback for visual overlay and ghost blocks - wraps blocks with debug info
debugHooks.setCallback(DEBUG_CALLBACK.BLOCK_DEBUG, (blockData, context) => {
const showVisualOverlay = devToolsState.blockVisualOverlay;
const showGhostBlocks = devToolsState.blockGhostBlocks;
// Check state at invocation time (devToolsState is captured in closure)
if (!showVisualOverlay && !showGhostBlocks) {
return blockData;
}
const {
name,
id,
Component,
args,
containerArgs,
conditions,
conditionsPassed,
failureType,
failureReason,
children,
} = blockData;
const { outletName } = context;
const owner = getOwnerWithFallback();
// If conditions failed, return a ghost block (if ghost blocks enabled)
if (conditionsPassed === false) {
if (!showGhostBlocks) {
return blockData;
}
const ghostResult = {
Component: curryComponent(
GhostBlock,
{
blockName: name,
blockId: id,
// Use debugLocation to avoid being overwritten by template's @outletName
debugLocation: outletName,
blockArgs: args,
containerArgs,
conditions,
failureType,
failureReason,
// Children are ghost components for container blocks with no visible children
children,
},
owner
),
isGhost: true,
// No-op: calling asGhost on a ghost returns itself
asGhost: () => ghostResult,
};
return ghostResult;
}
// Wrap the rendered block with debug info (if visual overlay enabled)
if (!showVisualOverlay) {
return blockData;
}
return {
Component: curryComponent(
BlockInfo,
{
blockName: name,
blockId: id,
// Use debugLocation to avoid being overwritten by template's @outletName
debugLocation: outletName,
blockArgs: args,
containerArgs,
conditions,
outletArgs: context.outletArgs,
WrappedComponent: Component,
},
owner
),
};
});
// Callback for console logging
debugHooks.setCallback(
DEBUG_CALLBACK.BLOCK_LOGGING,
() => devToolsState.blockDebug
);
// Callback for visual overlay
debugHooks.setCallback(
DEBUG_CALLBACK.VISUAL_OVERLAY,
() => devToolsState.blockVisualOverlay
);
// Callback for ghost blocks
debugHooks.setCallback(
DEBUG_CALLBACK.GHOST_BLOCKS,
() => devToolsState.blockGhostBlocks
);
// Callback for outlet info component - returns the component when enabled, null otherwise.
debugHooks.setCallback(DEBUG_CALLBACK.OUTLET_INFO_COMPONENT, () =>
devToolsState.blockOutletBoundaries ? OutletInfo : null
);
// === Logging Callbacks ===
// These bridge the main bundle to the debug logger in dev-tools.
// All use makeDebugCallback to centralize the devToolsState.blockDebug check.
// Callback for logging condition evaluations
debugHooks.setCallback(
DEBUG_CALLBACK.CONDITION_LOG,
makeDebugCallback((opts) => {
blockDebugLogger.logCondition(opts);
})
);
// Callback for updating combinator (AND/OR/NOT) results
debugHooks.setCallback(
DEBUG_CALLBACK.COMBINATOR_LOG,
makeDebugCallback((opts) => {
blockDebugLogger.updateCombinatorResult(opts.conditionSpec, opts.result);
})
);
// Callback for updating single condition results
debugHooks.setCallback(
DEBUG_CALLBACK.CONDITION_RESULT,
makeDebugCallback((opts) => {
blockDebugLogger.updateConditionResult(opts.conditionSpec, opts.result);
})
);
// Callback for logging param group matches
debugHooks.setCallback(
DEBUG_CALLBACK.PARAM_GROUP_LOG,
makeDebugCallback((opts) => {
blockDebugLogger.logParamGroup(opts);
})
);
// Callback for logging route state
debugHooks.setCallback(
DEBUG_CALLBACK.ROUTE_STATE_LOG,
makeDebugCallback((opts) => {
blockDebugLogger.logRouteState(opts);
})
);
// Callback for logging optional missing blocks
debugHooks.setCallback(
DEBUG_CALLBACK.OPTIONAL_MISSING_LOG,
makeDebugCallback((blockName, blockId, hierarchy) => {
blockDebugLogger.logOptionalMissing(blockName, blockId, hierarchy);
})
);
// Callback for starting a logging group
debugHooks.setCallback(
DEBUG_CALLBACK.START_GROUP,
makeDebugCallback((blockName, blockId, hierarchy) => {
blockDebugLogger.startGroup(blockName, blockId, hierarchy);
})
);
// Callback for ending a logging group
debugHooks.setCallback(
DEBUG_CALLBACK.END_GROUP,
makeDebugCallback((finalResult) => {
blockDebugLogger.endGroup(finalResult);
})
);
// Callback that returns the logger interface for conditions
debugHooks.setCallback(DEBUG_CALLBACK.LOGGER_INTERFACE, () => {
if (!devToolsState.blockDebug) {
return null;
}
return {
logCondition: (opts) => blockDebugLogger.logCondition(opts),
updateCombinatorResult: (conditionSpec, result) =>
blockDebugLogger.updateCombinatorResult(conditionSpec, result),
updateConditionResult: (conditionSpec, result) =>
blockDebugLogger.updateConditionResult(conditionSpec, result),
logParamGroup: (opts) => blockDebugLogger.logParamGroup(opts),
logRouteState: (opts) => blockDebugLogger.logRouteState(opts),
};
});
// Register the ghost children creator function
debugHooks.setCallback(
DEBUG_CALLBACK.GHOST_CHILDREN_CREATOR,
createGhostChildren
);
}
@@ -1,10 +1,12 @@
import "./styles.css";
import { withPluginApi } from "discourse/lib/plugin-api";
import { patchBlockRendering } from "./block-debug/patch";
import { patchConnectors } from "./plugin-outlet-debug/patch";
import Toolbar from "./toolbar";
export function init() {
patchConnectors();
patchBlockRendering();
withPluginApi((api) => {
api.renderInOutlet("above-site-header", Toolbar);
@@ -0,0 +1,85 @@
/**
* Shared console logging utility for dev tools.
*
* Provides consistent argument logging to console with a persistent counter
* that increments across all dev tools components during the session.
*
* @module discourse/static/dev-tools/lib/console-logger
*/
/**
* Counter for generating unique global variable names.
* Persists across all dev-tools components for the session.
*
* Note: This counter never automatically resets, so global variables (`arg1`,
* `arg2`, etc.) accumulate in `window` during long debugging sessions. This is
* intentional - it prevents variable name collisions and allows developers to
* reference previously logged values. Use `resetArgCounter()` to manually reset
* if namespace pollution becomes a concern.
*
* @type {number}
*/
let globalArgCounter = 1;
/**
* Console output styles for dev tools logging.
*/
const STYLES = {
varName: "color: #ce6edf; font-weight: bold",
keyName: "color: #46a7f5",
reset: "",
};
/**
* Logs a value to the console and saves it to a global variable.
* The variable is named `arg1`, `arg2`, etc., incrementing for each call.
*
* @param {Object} options - Log options.
* @param {string} options.key - The argument key/name being logged.
* @param {any} options.value - The value to log and store globally.
* @param {string} [options.prefix] - Optional prefix for context (e.g., "plugin outlet").
* @returns {string} The variable name assigned (e.g., "arg1").
*/
export function logArgToConsole({ key, value, prefix }) {
const varName = `arg${globalArgCounter++}`;
// Warn if overwriting an existing global variable
if (varName in window && window[varName] !== undefined) {
// eslint-disable-next-line no-console
console.warn(`DevTools: Overwriting existing global "${varName}"`);
}
window[varName] = value;
const prefixStr = prefix ? `[${prefix}] ` : "";
// eslint-disable-next-line no-console
console.log(
`${prefixStr}%c${key}%c saved to %c${varName}%c`,
STYLES.keyName,
STYLES.reset,
STYLES.varName,
STYLES.reset,
value
);
return varName;
}
/**
* Resets the global argument counter.
* Primarily for testing purposes.
*/
export function resetArgCounter() {
globalArgCounter = 1;
}
/**
* Gets the current counter value without incrementing.
* Useful for displaying what the next variable name will be.
*
* @returns {number} The current counter value.
*/
export function getNextArgNumber() {
return globalArgCounter;
}
@@ -0,0 +1,129 @@
/**
* Shared value formatting utilities for dev tools.
*
* Provides consistent value display across block-debug and plugin-outlet-debug
* components, ensuring uniform representation of different data types.
*
* @module dev-tools/lib/value-formatter
*/
/**
* Maximum length for string values before truncation.
* Strings longer than this will be truncated with "..." appended.
*
* @constant {number}
*/
const MAX_STRING_LENGTH = 50;
/**
* Formats a value for display in debug tables. Each type is handled differently
* to provide a concise yet informative representation that fits in the UI.
*
* @param {any} value - The value to format.
* @param {Object} [options] - Formatting options.
* @param {boolean} [options.expandArrays=false] - If true, shows array contents
* instead of just "Array(n)". Useful for condition trees where values matter.
* @param {boolean} [options.handleSymbols=false] - If true, formats Symbols with
* their description. Useful for condition trees where Symbol values may appear.
* @param {boolean} [options.handleRegExp=false] - If true, formats RegExp instances
* as their string pattern. Useful for condition trees with regex value matchers.
* @returns {string} A human-readable string representation of the value.
*
* @example
* formatValue(null); // "null"
* formatValue("hello world"); // '"hello world"'
* formatValue([1, 2, 3]); // "Array(3)"
* formatValue([1, 2], { expandArrays: true }); // "[1, 2]"
* formatValue(Symbol("test"), { handleSymbols: true }); // "Symbol(test)"
*/
export function formatValue(value, options = {}) {
const {
expandArrays = false,
handleSymbols = false,
handleRegExp = false,
} = options;
// Null and undefined are displayed as literal keywords
if (value === null) {
return "null";
}
if (value === undefined) {
return "undefined";
}
// Symbols show their description (optional - for condition trees)
if (handleSymbols && typeof value === "symbol") {
return `Symbol(${value.description || ""})`;
}
// Strings are quoted and truncated to prevent UI overflow
if (typeof value === "string") {
const truncated =
value.length > MAX_STRING_LENGTH
? value.slice(0, MAX_STRING_LENGTH) + "..."
: value;
return `"${truncated}"`;
}
// Numbers and booleans can be displayed directly as their string representation
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
// Arrays: either show contents or just length depending on context
if (Array.isArray(value)) {
if (expandArrays) {
return `[${value.map((v) => formatValue(v, options)).join(", ")}]`;
}
return `Array(${value.length})`;
}
// RegExp instances show their pattern (optional - for condition trees)
if (handleRegExp && value instanceof RegExp) {
return value.toString();
}
// Functions show their name to help identify callbacks
if (typeof value === "function") {
return `fn ${value.name || "anonymous"}()`;
}
// Objects show their constructor name (e.g., "User {...}") or just "{...}"
if (typeof value === "object") {
const name = value.constructor?.name;
if (name && name !== "Object") {
return `${name} {...}`;
}
return "{...}";
}
// Fallback for any other types (bigints, etc.)
return String(value);
}
/**
* Determines the type label to display for a value. This provides a quick
* visual indicator of what kind of data is in each argument.
*
* @param {any} value - The value to get the type info for.
* @returns {string} A type label (e.g., "string", "number", "array", "object").
*
* @example
* getTypeInfo(null); // "null"
* getTypeInfo([1, 2, 3]); // "array"
* getTypeInfo({ foo: 1 }); // "object"
* getTypeInfo("hello"); // "string"
*/
export function getTypeInfo(value) {
if (value === null) {
return "null";
}
if (value === undefined) {
return "undefined";
}
// Arrays are identified separately since typeof returns "object" for arrays
if (Array.isArray(value)) {
return "array";
}
return typeof value;
}
@@ -4,6 +4,7 @@ import { action } from "@ember/object";
import concatClass from "discourse/helpers/concat-class";
import icon from "discourse/helpers/d-icon";
import mobile from "discourse/lib/mobile";
import { i18n } from "discourse-i18n";
export default class MobileViewButton extends Component {
get mobileViewActive() {
@@ -17,7 +18,7 @@ export default class MobileViewButton extends Component {
<template>
<button
title="Toggle mobile view"
title={{i18n "dev_tools.toggle_mobile_view"}}
class={{concatClass
"toggle-mobile-view"
(if this.mobileViewActive "--active")
@@ -1,75 +0,0 @@
import Component from "@glimmer/component";
import { fn } from "@ember/helper";
import { on } from "@ember/modifier";
import icon from "discourse/helpers/d-icon";
let globalI = 1;
function stringifyValue(value) {
try {
if (value === undefined) {
return "undefined";
} else if (value === null) {
return "null";
} else if (["string", "number"].includes(typeof value)) {
return JSON.stringify(value);
} else if (typeof value === "boolean") {
return String(value);
} else if (Array.isArray(value)) {
return `Array (${value.length} items)`;
} else if (String(value).startsWith("class ")) {
return `class ${value.name} {}`;
} else if (value.constructor?.name === "function") {
return `ƒ ${value.name || "function"}(...)`;
} else if (value.id) {
return `${value.constructor?.name} { id: ${value.id} }`;
} else {
return `${value.constructor?.name} {}`;
}
} catch (e) {
// eslint-disable-next-line no-console
console.error("Unable to stringify value:", value, e);
return "(unable to stringify)";
}
}
export default class ArgsTable extends Component {
get renderArgs() {
return Object.entries(this.args.outletArgs).map(([key, value]) => {
return {
key,
value: stringifyValue(value),
originalValue: value,
};
});
}
writeToConsole(key, value, event) {
event.preventDefault();
window[`arg${globalI}`] = value;
/* eslint-disable no-console */
console.log(
`[plugin outlet debug] \`@${key}\` saved to global \`arg${globalI}\`, and logged below:`
);
console.log(value);
/* eslint-enable no-console */
globalI++;
}
<template>
{{#each this.renderArgs as |arg|}}
<div class="key"><span class="fw">@{{arg.key}}</span>:</div>
<div class="value">
<span class="fw">{{arg.value}}</span>
<a
title="Write to console"
href=""
{{on "click" (fn this.writeToConsole arg.key arg.originalValue)}}
>{{icon "code"}}</a>
</div>
{{else}}
<div class="no-arguments">(no arguments)</div>
{{/each}}
</template>
}
@@ -3,8 +3,13 @@ import { on } from "@ember/modifier";
import { action } from "@ember/object";
import concatClass from "discourse/helpers/concat-class";
import icon from "discourse/helpers/d-icon";
import { i18n } from "discourse-i18n";
import devToolsState from "../state";
/**
* Toggle button for the plugin outlet debug mode in the dev-tools toolbar.
* Shows plugin outlet boundaries and arg information when active.
*/
export default class PluginOutletDebugButton extends Component {
@action
togglePluginOutlets() {
@@ -13,7 +18,7 @@ export default class PluginOutletDebugButton extends Component {
<template>
<button
title="Toggle plugin outlet debug"
title={{i18n "dev_tools.toggle_plugin_outlet_debug"}}
class={{concatClass
"toggle-plugin-outlets"
(if devToolsState.pluginOutletDebug "--active")
@@ -6,8 +6,9 @@ import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import DTooltip from "discourse/float-kit/components/d-tooltip";
import concatClass from "discourse/helpers/concat-class";
import icon from "discourse/helpers/d-icon";
import { DEPRECATED_ARGS_KEY } from "discourse/lib/outlet-args";
import ArgsTable from "../shared/args-table";
import devToolsState from "../state";
import ArgsTable from "./args-table";
// Outlets matching these patterns will be displayed with an icon only.
// Feel free to add more if it improves the layout.
@@ -23,6 +24,14 @@ const SMALL_OUTLETS = [
"after-breadcrumbs",
];
/**
* Debug overlay for PluginOutlet components.
* Shows outlet name badge with a tooltip containing outlet info, args, and GitHub search link.
*
* @param {string} outletName - The name of the plugin outlet.
* @param {Object} [outletArgs] - Arguments passed to the outlet. May contain a non-enumerable
* `__deprecatedArgs__` property with the raw deprecated args for display in the debug tooltip.
*/
export default class OutletInfoComponent extends Component {
static shouldRender() {
return devToolsState.pluginOutletDebug;
@@ -76,25 +85,50 @@ export default class OutletInfoComponent extends Component {
);
}
/**
* Checks whether this outlet has any args passed to it.
*
* @returns {boolean} True if outlet has at least one arg.
*/
get hasOutletArgs() {
const outletArgs = this.args.outletArgs;
const deprecatedArgs = outletArgs?.[DEPRECATED_ARGS_KEY];
return (
(outletArgs != null && Object.keys(outletArgs).length > 0) ||
(deprecatedArgs != null && Object.keys(deprecatedArgs).length > 0)
);
}
/**
* Returns the heading modifier class based on outlet type.
*
* @returns {string} The CSS modifier class for the heading.
*/
get headingModifier() {
return this.partOfWrapper ? "--wrapper-outlet" : "--plugin-outlet";
}
<template>
<div
class={{concatClass
"plugin-outlet-info"
"plugin-outlet-debug"
(if this.partOfWrapper "--wrapper")
(if this.isHidden "hidden")
}}
{{didInsert this.checkIsWrapper}}
data-outlet-name={{@outletName}}
title={{@outletName}}
>
<DTooltip
@identifier="plugin-outlet-info"
@interactive={{true}}
@placement="bottom-start"
@maxWidth={{600}}
@triggers={{hash mobile=(array "click") desktop=(array "hover")}}
@untriggers={{hash mobile=(array "click") desktop=(array "click")}}
@identifier="plugin-outlet-info"
>
<:trigger>
<span class="name">
<span class="plugin-outlet-debug__badge">
{{#if this.partOfWrapper}}
&lt;{{if this.isAfter "/"}}{{if
this.showName
@@ -107,8 +141,10 @@ export default class OutletInfoComponent extends Component {
</span>
</:trigger>
<:content>
<div class="plugin-outlet-info__wrapper">
<div class="plugin-outlet-info__heading">
<div class="outlet-info__wrapper">
<div
class={{concatClass "outlet-info__heading" this.headingModifier}}
>
<span class="title">
{{icon "plug"}}
{{this.displayName}}
@@ -124,8 +160,17 @@ export default class OutletInfoComponent extends Component {
title="Find on GitHub"
>{{icon "fab-github"}}</a>
</div>
<div class="plugin-outlet-info__content">
<ArgsTable @outletArgs={{@outletArgs}} />
<div class="outlet-info__content">
{{#if this.hasOutletArgs}}
<div class="outlet-info__section">
<div class="outlet-info__section-title">Outlet Args</div>
<ArgsTable @args={{@outletArgs}} @prefix="plugin outlet" />
</div>
{{else}}
<div class="outlet-info__empty">
No outlet args passed to this outlet
</div>
{{/if}}
</div>
</div>
</:content>
@@ -1,5 +1,6 @@
import curryComponent from "ember-curry-component";
import { getOwnerWithFallback } from "discourse/lib/get-owner";
import { _setIncludeDeprecatedArgsProperty } from "discourse/lib/outlet-args";
import { _setOutletDebugCallback } from "discourse/lib/plugin-connectors";
import devToolsState from "../state";
import OutletInfoComponent from "./outlet-info";
@@ -9,7 +10,11 @@ const SKIP_EXISTING_FOR_OUTLETS = [
];
export function patchConnectors() {
_setOutletDebugCallback((outletName, existing) => {
// Enable including raw deprecatedArgs in outletArgsWithDeprecations
// so ArgsTable can display deprecation info without separate prop passing
_setIncludeDeprecatedArgsProperty(true);
_setOutletDebugCallback((outletName, existing, { outletArgs } = {}) => {
existing ||= [];
if (!devToolsState.pluginOutletDebug) {
@@ -22,7 +27,7 @@ export function patchConnectors() {
const componentClass = curryComponent(
OutletInfoComponent,
{ outletName },
{ outletName, outletArgs },
getOwnerWithFallback()
);
@@ -3,8 +3,9 @@ import { on } from "@ember/modifier";
import { action } from "@ember/object";
import concatClass from "discourse/helpers/concat-class";
import icon from "discourse/helpers/d-icon";
import { i18n } from "discourse-i18n";
export default class PluginOutletDebugButton extends Component {
export default class SafeModeButton extends Component {
get safeModeActive() {
return new URLSearchParams(window.location.search).has("safe_mode");
}
@@ -22,7 +23,7 @@ export default class PluginOutletDebugButton extends Component {
<template>
<button
title="Toggle safe mode"
title={{i18n "dev_tools.toggle_safe_mode"}}
class={{concatClass
"toggle-safe-mode"
(if this.safeModeActive "--active")
@@ -0,0 +1,179 @@
import Component from "@glimmer/component";
import { fn } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import concatClass from "discourse/helpers/concat-class";
import icon from "discourse/helpers/d-icon";
import { isDeprecatedOutletArgument } from "discourse/helpers/deprecated-outlet-argument";
import { DEPRECATED_ARGS_KEY } from "discourse/lib/outlet-args";
import { logArgToConsole } from "../lib/console-logger";
import { formatValue, getTypeInfo } from "../lib/value-formatter";
/**
* Shared component for displaying outlet arguments in a formatted table.
* Used by both PluginOutlet and BlockOutlet debug tooltips.
*
* Supports deprecated arguments marked with the `deprecatedOutletArgument` helper,
* showing a visual indicator and deprecation info. Deprecated args are read from
* `args.__deprecatedArgs__` (set by `buildArgsWithDeprecations` when dev-tools outlet
* debugging is enabled).
*
* @param {Object} args - The arguments to display. May contain a non-enumerable
* `__deprecatedArgs__` property with the raw deprecated args.
* @param {string} [prefix] - Prefix for console logging context (e.g., "plugin outlet").
*/
export default class ArgsTable extends Component {
/**
* Transforms the raw args object into an array of entry objects for display.
* Each entry contains the original key/value plus formatted display representations.
*
* @returns {Array<{key: string, value: any, displayValue: string, typeInfo: string, isDeprecated: boolean, deprecationInfo: Object|null}>}
*/
get entries() {
const entries = [];
const args = this.args.args;
// Read deprecatedArgs from the non-enumerable property on args (set by
// buildArgsWithDeprecations when dev-tools outlet debugging is enabled).
const deprecatedArgs = args?.[DEPRECATED_ARGS_KEY];
const deprecatedKeys = new Set(
deprecatedArgs && typeof deprecatedArgs === "object"
? Object.keys(deprecatedArgs)
: []
);
// Process regular args first, but skip keys that are in deprecatedArgs
// (those will be handled in the second loop with proper deprecation info).
// Use Object.keys() instead of Object.entries() to avoid triggering the
// deprecation warning getters for deprecated args.
if (args && typeof args === "object") {
for (const key of Object.keys(args)) {
// Skip deprecated keys - accessing them would trigger deprecation warnings.
// They'll be processed below from the raw deprecatedArgs object.
if (deprecatedKeys.has(key)) {
continue;
}
const rawValue = args[key];
// Check if this is a deprecated arg that was merged into args
const isDeprecated = isDeprecatedOutletArgument(rawValue);
const value = isDeprecated ? rawValue.value : rawValue;
entries.push({
key,
value,
displayValue: formatValue(value),
typeInfo: getTypeInfo(value),
isDeprecated,
deprecationInfo: isDeprecated
? this.#getDeprecationInfo(rawValue)
: null,
});
}
}
// Process deprecated args (if passed separately)
if (deprecatedArgs && typeof deprecatedArgs === "object") {
for (const [key, deprecatedArg] of Object.entries(deprecatedArgs)) {
if (isDeprecatedOutletArgument(deprecatedArg)) {
const value = deprecatedArg.value;
entries.push({
key,
value,
displayValue: formatValue(value),
typeInfo: getTypeInfo(value),
isDeprecated: true,
deprecationInfo: this.#getDeprecationInfo(deprecatedArg),
});
}
}
}
return entries;
}
/**
* Extracts deprecation info from a deprecated argument for display.
*
* @param {DeprecatedOutletArgument} deprecatedArg - The deprecated argument.
* @returns {{message: string, since: string|undefined, dropFrom: string|undefined}}
*/
#getDeprecationInfo(deprecatedArg) {
return {
message: deprecatedArg.message,
since: deprecatedArg.options?.since,
dropFrom: deprecatedArg.options?.dropFrom,
};
}
/**
* Logs the argument value to the console and stores it in a global variable
* for easy inspection. The variable is named `arg1`, `arg2`, etc.
*
* @param {{key: string, value: any}} entry - The entry to log.
*/
@action
logValue(entry) {
logArgToConsole({
key: entry.key,
value: entry.value,
prefix: this.args.prefix,
});
}
<template>
{{#if this.entries.length}}
<div class="outlet-args-table">
{{#each this.entries as |entry|}}
<button
type="button"
class={{concatClass
"outlet-args-table__row"
(if entry.isDeprecated "--deprecated")
}}
title={{if
entry.isDeprecated
entry.deprecationInfo.message
"Save to global variable"
}}
{{on "click" (fn this.logValue entry)}}
>
<span class="outlet-args-table__key">
@{{entry.key}}
{{#if entry.isDeprecated}}
<span class="outlet-args-table__deprecated-badge">
{{icon "triangle-exclamation"}}
</span>
{{/if}}
</span>
<span class="outlet-args-table__value">
<span class="outlet-args-table__type">{{entry.typeInfo}}</span>
{{entry.displayValue}}
</span>
</button>
{{#if entry.isDeprecated}}
<div class="outlet-args-table__deprecation-info">
{{#if entry.deprecationInfo.message}}
{{entry.deprecationInfo.message}}
{{else}}
Deprecated
{{/if}}
{{#if entry.deprecationInfo.since}}
<span class="outlet-args-table__deprecation-version">
(since
{{entry.deprecationInfo.since}}{{#if
entry.deprecationInfo.dropFrom
}}, removal in {{entry.deprecationInfo.dropFrom}}{{/if}})
</span>
{{/if}}
</div>
{{/if}}
{{/each}}
</div>
{{else}}
<div class="outlet-args-table --empty">No arguments</div>
{{/if}}
</template>
}
@@ -1,7 +1,149 @@
import { tracked } from "@glimmer/tracking";
/**
* Singleton class that manages the state of developer tools.
* State is persisted to sessionStorage so it survives page refreshes
* but not browser restarts. Each property is tracked for reactivity.
*
* @class DevToolsState
*/
class DevToolsState {
@tracked pluginOutletDebug = false;
static #SESSION_STORAGE_KEY = "discourse__dev_tools_state";
// Private backing fields for tracked properties.
// These are @tracked so that Glimmer re-renders when values change.
@tracked _pluginOutletDebug;
@tracked _blockDebug;
@tracked _blockVisualOverlay;
@tracked _blockGhostBlocks;
@tracked _blockOutletBoundaries;
/**
* Initializes the state by loading persisted values from sessionStorage.
* Falls back to false for any missing values.
*/
constructor() {
const persisted = this.#loadPersistedState();
this._pluginOutletDebug = persisted.pluginOutletDebug ?? false;
this._blockDebug = persisted.blockDebug ?? false;
this._blockVisualOverlay = persisted.blockVisualOverlay ?? false;
this._blockGhostBlocks = persisted.blockGhostBlocks ?? false;
this._blockOutletBoundaries = persisted.blockOutletBoundaries ?? false;
}
/**
* Load persisted state from sessionStorage.
*
* @returns {Object} Parsed state object or empty object if not found
*/
#loadPersistedState() {
try {
const stored = window.sessionStorage?.getItem(
DevToolsState.#SESSION_STORAGE_KEY
);
return stored ? JSON.parse(stored) : {};
} catch (e) {
// eslint-disable-next-line no-console
console.warn(
"[DevTools] Failed to parse persisted state from sessionStorage. " +
"Using defaults.",
e
);
return {};
}
}
/**
* Save current state to sessionStorage.
*/
#persistState() {
try {
window.sessionStorage?.setItem(
DevToolsState.#SESSION_STORAGE_KEY,
JSON.stringify({
pluginOutletDebug: this._pluginOutletDebug,
blockDebug: this._blockDebug,
blockVisualOverlay: this._blockVisualOverlay,
blockGhostBlocks: this._blockGhostBlocks,
blockOutletBoundaries: this._blockOutletBoundaries,
})
);
} catch {
// Ignore storage errors
}
}
/**
* Enable visual overlay showing plugin outlet debug information.
* When enabled, plugin outlets display badges and tooltips with outlet details.
*
* @type {boolean}
*/
get pluginOutletDebug() {
return this._pluginOutletDebug;
}
set pluginOutletDebug(value) {
this._pluginOutletDebug = value;
this.#persistState();
}
/**
* Enable console logging of block condition evaluations.
*
* @type {boolean}
*/
get blockDebug() {
return this._blockDebug;
}
set blockDebug(value) {
this._blockDebug = value;
this.#persistState();
}
/**
* Enable visual overlay showing block boundaries and info.
*
* @type {boolean}
*/
get blockVisualOverlay() {
return this._blockVisualOverlay;
}
set blockVisualOverlay(value) {
this._blockVisualOverlay = value;
this.#persistState();
}
/**
* Enable ghost blocks showing hidden blocks with dashed outlines.
*
* @type {boolean}
*/
get blockGhostBlocks() {
return this._blockGhostBlocks;
}
set blockGhostBlocks(value) {
this._blockGhostBlocks = value;
this.#persistState();
}
/**
* Show block outlet debug boundaries around outlets, even when no blocks
* are rendered. This helps visualize outlet locations during development.
*
* @type {boolean}
*/
get blockOutletBoundaries() {
return this._blockOutletBoundaries;
}
set blockOutletBoundaries(value) {
this._blockOutletBoundaries = value;
this.#persistState();
}
}
const state = new DevToolsState();
@@ -1,126 +1,616 @@
/**
/*
This CSS file is loaded dynamically when loadDevTools() is run in the console.
It is not part of our normal CSS build process, so SCSS variables are not available.
Native CSS nesting can be used safely, because developers who use this tool are expected to have modern browsers.
*/
.plugin-outlet-info {
--plugin-outlet-info-border-color: #080;
--plugin-outlet-info-background-color: #0c0;
margin: 1px;
border: 1px solid var(--plugin-outlet-info-border-color);
display: inline-block;
:root {
/* Colors by usage */
--dev-tools-plugin-outlet-color: #16a34a;
--dev-tools-plugin-outlet-hover-color: #15803d;
--dev-tools-wrapper-outlet-color: #2563eb;
--dev-tools-wrapper-outlet-hover-color: #1d4ed8;
--dev-tools-block-color: #d97706;
--dev-tools-block-hover-color: #b45309;
--dev-tools-ghost-color: #dc2626;
--dev-tools-ghost-hover-color: #b91c1c;
--dev-tools-block-outlet-color: #f59e0b;
background-color: var(--plugin-outlet-info-background-color);
color: white;
text-align: center;
font-size: 14px !important;
font-weight: normal;
padding: 1px 5px;
display: inline-flex;
align-items: center;
.d-icon {
color: white !important;
font-size: 14px !important;
width: 14px !important;
}
&.--wrapper {
--plugin-outlet-info-border-color: #00c;
--plugin-outlet-info-background-color: #88f;
}
}
.plugin-outlet-info__wrapper {
display: flex;
flex-direction: column;
}
.plugin-outlet-info__heading {
font-size: 14px;
font-weight: bold;
margin-bottom: 10px;
display: flex;
gap: 5px;
.title {
flex-grow: 1;
}
.github-link {
color: var(--primary-medium);
}
}
.plugin-outlet-info__content {
display: grid;
grid-template-columns: min-content 1fr;
font-size: 14px;
grid-gap: 10px;
min-width: 300px;
max-width: 100%;
width: 100%;
overflow: hidden;
& > div {
min-width: 0;
}
.fw {
font-family:
"Courier New", "Courier", "Lucida Console", "Monaco", monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
background: var(--primary-very-low);
}
.value {
display: flex;
gap: 5px;
}
.no-arguments {
grid-column: span 2;
}
a {
color: var(--primary-medium);
}
/* Shared values */
--dev-tools-badge-font-size: 0.75rem;
--dev-tools-badge-padding: 2px 5px;
--dev-tools-badge-radius: 0 0 4px 0;
--dev-tools-shadow: 1px 1px 2px rgb(0 0 0 / 0.5);
--dev-tools-bg-opacity: 0.05;
--dev-tools-container-margin: 2px;
--dev-tools-mono-font:
"Courier New", courier, "Lucida Console", monaco, monospace;
}
/* Dev Tools Toolbar */
.dev-tools-toolbar {
position: fixed;
z-index: 999999;
display: flex;
flex-direction: column;
background-color: var(--primary-200);
border-radius: 0 5px 5px 0;
box-shadow: var(--dev-tools-shadow);
opacity: 0.8;
transition: opacity 0.2s ease-in-out;
background-color: var(--primary-low);
border-radius: 0px 5px 5px 0px;
&.--dragging {
opacity: 0.5;
}
button {
background: none;
border: none;
padding: 5px;
color: var(--primary-medium);
color: var(--primary-700);
.d-icon {
color: inherit !important;
}
&:hover:not(.gripper) {
background-color: var(--primary-very-low);
background-color: var(--primary-50);
}
&.gripper {
cursor: grab;
padding-bottom: 0;
padding-top: 0;
color: var(--primary-400);
padding-block: 0;
border-bottom: 1px dotted var(--primary-low-mid);
color: var(--primary-500);
}
&.--active {
color: var(--success);
background-color: var(--primary-100);
color: var(--tertiary);
.d-icon {
font-weight: bold;
}
}
}
}
/* Shared Base Styles */
/* Base styles for all debug container boundaries */
.plugin-outlet-debug,
.block-debug-info,
.block-debug-ghost,
.block-outlet-debug {
position: relative;
margin: var(--dev-tools-container-margin);
}
/* Base styles for all debug badges */
.plugin-outlet-debug__badge,
.block-debug-badge,
.block-debug-ghost__badge,
.block-outlet-debug__badge {
position: absolute;
top: -1px;
left: -1px;
z-index: 1000;
display: inline-flex;
align-items: center;
gap: 4px;
color: white !important;
padding: var(--dev-tools-badge-padding) !important;
font-size: var(--dev-tools-badge-font-size) !important;
font-weight: bold;
border-radius: var(--dev-tools-badge-radius);
cursor: pointer;
text-shadow: var(--dev-tools-shadow);
.d-icon {
color: white !important;
font-size: var(--dev-tools-badge-font-size) !important;
font-weight: bold;
width: var(--dev-tools-badge-font-size) !important;
filter: drop-shadow(var(--dev-tools-shadow));
}
}
/* Plugin Outlet Debug */
.plugin-outlet-debug {
border: 1px dashed var(--dev-tools-plugin-outlet-color);
background: rgb(22 163 74 / var(--dev-tools-bg-opacity));
/* Wrapper outlets are compact inline markers */
&.--wrapper {
border: none;
background: none;
.plugin-outlet-debug__badge {
position: static;
background: var(--dev-tools-wrapper-outlet-color);
border-radius: 4px;
&:hover {
background: var(--dev-tools-wrapper-outlet-hover-color);
}
}
}
}
.plugin-outlet-debug__badge {
background: var(--dev-tools-plugin-outlet-color);
&:hover {
background: var(--dev-tools-plugin-outlet-hover-color);
}
}
/* Block Debug Overlay */
.block-debug-info {
border: 1px dotted var(--dev-tools-block-color);
background: rgb(217 119 6 / var(--dev-tools-bg-opacity));
padding: 2px;
&.--rendered {
border-color: var(--dev-tools-block-color);
}
}
.block-debug-badge {
background: var(--dev-tools-block-color);
&:hover {
background: var(--dev-tools-block-hover-color);
}
}
/* Ghost blocks (hidden/failed conditions) */
.block-debug-ghost {
border: 1px dashed var(--dev-tools-ghost-color);
background: repeating-linear-gradient(
45deg,
transparent,
transparent 10px,
rgb(220 38 38 / var(--dev-tools-bg-opacity)) 10px,
rgb(220 38 38 / var(--dev-tools-bg-opacity)) 20px
);
padding: 2px;
min-height: 30px;
}
.block-debug-ghost__badge {
background: var(--dev-tools-ghost-color);
&:hover {
background: var(--dev-tools-ghost-hover-color);
}
}
.block-debug-ghost__children {
margin-top: 4px;
}
/* Nested ghost blocks: reduced padding */
.block-debug-ghost__children .block-debug-ghost {
padding: 2px;
}
/* Odd depth levels: flip stripe direction */
.block-debug-ghost__children > .block-debug-ghost {
background: repeating-linear-gradient(
-45deg,
transparent,
transparent 10px,
rgb(220 38 38 / var(--dev-tools-bg-opacity)) 10px,
rgb(220 38 38 / var(--dev-tools-bg-opacity)) 20px
);
}
/* Even depth levels: original stripe direction */
.block-debug-ghost__children
> .block-debug-ghost
> .block-debug-ghost__children
> .block-debug-ghost {
background: repeating-linear-gradient(
45deg,
transparent,
transparent 10px,
rgb(220 38 38 / var(--dev-tools-bg-opacity)) 10px,
rgb(220 38 38 / var(--dev-tools-bg-opacity)) 20px
);
}
/* Block Outlet boundaries */
.block-outlet-debug {
border: 1px dashed var(--dev-tools-block-outlet-color);
padding: 2px;
min-height: 20px;
background: rgb(245 158 11 / var(--dev-tools-bg-opacity));
&.--validation-failed {
border: 1px dashed var(--dev-tools-ghost-color);
background: repeating-linear-gradient(
45deg,
transparent,
transparent 10px,
rgb(220 38 38 / var(--dev-tools-bg-opacity)) 10px,
rgb(220 38 38 / var(--dev-tools-bg-opacity)) 20px
);
}
}
.block-outlet-debug__badge {
background: var(--dev-tools-block-outlet-color);
&.--error {
background: var(--dev-tools-ghost-color);
&:hover {
background: var(--dev-tools-ghost-hover-color);
}
}
}
/* Block Debug Tooltip */
.block-debug-tooltip {
display: flex;
flex-direction: column;
gap: 12px;
font-size: 13px;
min-width: 280px;
max-width: 450px;
&.--ghost {
.block-debug-tooltip__header {
color: #f44336;
}
}
}
.block-debug-tooltip__header {
display: flex;
flex-direction: column;
gap: 2px;
font-weight: bold;
color: var(--dev-tools-block-color);
&.--failed {
color: var(--dev-tools-ghost-color);
}
.d-icon {
font-size: var(--dev-tools-badge-font-size) !important;
}
}
.block-debug-tooltip__row {
display: flex;
align-items: center;
gap: 6px;
}
.block-debug-tooltip__title {
font-size: 14px;
}
.block-debug-tooltip__location {
font-weight: normal;
font-size: 12px;
opacity: 0.7;
margin-left: 20px;
}
.block-debug-tooltip__status {
font-size: 10px;
padding: 1px 4px;
background: var(--dev-tools-ghost-color);
color: white;
border-radius: 2px;
text-transform: uppercase;
}
.block-debug-tooltip__section {
display: flex;
flex-direction: column;
gap: 6px;
}
.block-debug-tooltip__section-title {
font-size: 11px;
font-weight: bold;
text-transform: uppercase;
opacity: 0.6;
.--passed {
color: var(--success);
}
.--failed {
color: var(--danger);
}
}
.block-debug-tooltip__empty {
font-style: italic;
opacity: 0.6;
}
.block-debug-tooltip__hint {
font-size: 11px;
font-style: italic;
opacity: 0.7;
border-top: 1px solid var(--primary-low);
padding-top: 8px;
}
/* Block Debug Conditions Tree */
.block-debug-conditions {
display: flex;
flex-direction: column;
gap: 2px;
font-family: var(--dev-tools-mono-font);
font-size: 12px;
&.--passed {
.block-debug-condition__type {
color: var(--success);
}
}
&.--failed {
.block-debug-condition__type {
color: var(--danger);
}
}
}
.block-debug-condition {
display: flex;
align-items: center;
gap: 4px;
&.--combinator {
.block-debug-condition__type {
font-weight: bold;
}
}
}
.block-debug-condition__type {
&.--combinator {
color: #607d8b;
}
}
.block-debug-condition__args {
opacity: 0.7;
font-size: 11px;
}
.block-debug-menu {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
min-width: 160px;
label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 13px;
&:hover {
color: var(--primary);
}
input[type="checkbox"] {
margin: 0;
}
}
}
.fk-d-menu[data-identifier="block-debug-menu"] {
z-index: 1000001;
}
/*
* Shared Outlet Components
* Used by both PluginOutlet and BlockOutlet debug overlays.
*/
/* Shared Outlet Info Tooltip Structure */
.outlet-info__wrapper {
display: flex;
flex-direction: column;
min-width: 250px;
}
.outlet-info__heading {
font-size: 14px;
font-weight: bold;
display: flex;
gap: 5px;
padding-bottom: 8px;
border-bottom: 1px solid var(--primary-low);
margin-bottom: 8px;
.title {
flex-grow: 1;
display: flex;
align-items: center;
gap: 5px;
}
.github-link {
color: var(--primary-medium);
}
/* Type-specific heading colors */
&.--plugin-outlet {
color: var(--dev-tools-plugin-outlet-color);
}
&.--wrapper-outlet {
color: var(--dev-tools-wrapper-outlet-color);
}
&.--block-outlet {
color: var(--dev-tools-block-outlet-color);
}
&.--error {
color: var(--dev-tools-ghost-color);
}
}
.outlet-info__content {
display: flex;
flex-direction: column;
gap: 12px;
font-size: 13px;
max-width: 100%;
overflow: hidden;
}
.outlet-info__section {
display: flex;
flex-direction: column;
gap: 6px;
}
.outlet-info__section-title {
font-size: 11px;
font-weight: bold;
text-transform: uppercase;
color: var(--primary-medium);
letter-spacing: 0.5px;
}
.outlet-info__stat {
display: flex;
align-items: center;
gap: 6px;
color: var(--primary-high);
}
.outlet-info__empty {
font-style: italic;
color: var(--primary-medium);
}
.outlet-info__status {
font-size: 10px;
padding: 2px 6px;
background: var(--dev-tools-ghost-color);
color: white;
border-radius: 2px;
text-transform: uppercase;
font-weight: bold;
margin-left: auto;
}
.outlet-info__error {
display: flex;
flex-direction: column;
gap: 6px;
}
pre.outlet-info__error-message {
margin: 0;
padding: 8px;
background: none;
border: none;
font-size: 11px;
line-height: 1.4;
font-family: var(--dev-tools-mono-font);
white-space: pre-wrap;
word-break: break-word;
max-height: 300px;
overflow-y: auto;
}
/* Shared Outlet Args Table */
.outlet-args-table {
display: flex;
flex-direction: column;
gap: 4px;
&.--empty {
font-style: italic;
color: var(--primary-medium);
}
}
.outlet-args-table__row {
display: flex;
gap: 8px;
padding: 2px 4px;
border-radius: 2px;
cursor: pointer;
background: none;
border: none;
width: 100%;
text-align: left;
font: inherit;
color: inherit;
&:hover {
background: var(--primary-very-low);
}
&.--deprecated {
.outlet-args-table__key {
color: var(--tertiary);
text-decoration: line-through;
}
}
}
.outlet-args-table__key {
display: flex;
align-items: center;
gap: 4px;
font-weight: bold;
color: #2196f3;
white-space: nowrap;
}
.outlet-args-table__deprecated-badge {
color: var(--tertiary);
font-size: 10px;
.d-icon {
width: 10px !important;
height: 10px !important;
}
}
.outlet-args-table__value {
font-family: var(--dev-tools-mono-font);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.outlet-args-table__type {
font-size: 10px;
opacity: 0.5;
margin-right: 4px;
}
.outlet-args-table__deprecation-info {
font-size: 11px;
font-style: italic;
color: var(--tertiary);
padding-left: 8px;
margin-top: -2px;
margin-bottom: 4px;
}
.outlet-args-table__deprecation-version {
opacity: 0.7;
}
@@ -4,10 +4,12 @@ import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import concatClass from "discourse/helpers/concat-class";
import icon from "discourse/helpers/d-icon";
import draggable from "discourse/modifiers/draggable";
import onResize from "discourse/modifiers/on-resize";
import I18n from "discourse-i18n";
import I18n, { i18n } from "discourse-i18n";
import BlockDebugButton from "./block-debug/button";
import MobileViewButton from "./mobile-view/button";
import PluginOutletDebugButton from "./plugin-outlet-debug/button";
import SafeModeButton from "./safe-mode/button";
@@ -16,10 +18,9 @@ import VerboseLocalizationButton from "./verbose-localization/button";
export default class Toolbar extends Component {
@service siteSettings;
@tracked top = 250;
@tracked activeDragOffset;
@tracked ownSize = 0;
activeDragOffset;
@tracked top = 250;
get style() {
const clampedTop = Math.max(this.top, 0);
@@ -59,26 +60,17 @@ export default class Toolbar extends Component {
<template>
<div
class="dev-tools-toolbar"
class={{concatClass
"dev-tools-toolbar"
(if this.activeDragOffset "--dragging")
}}
style={{this.style}}
{{onResize this.onResize}}
>
<PluginOutletDebugButton />
<SafeModeButton />
<VerboseLocalizationButton />
{{#unless this.siteSettings.viewport_based_mobile_mode}}
<MobileViewButton />
{{/unless}}
<button
title="Disable dev tools"
class="disable-dev-tools"
{{on "click" this.disableDevTools}}
>
{{icon "xmark"}}
</button>
<button
type="button"
title={{i18n "dev_tools.drag_to_move"}}
class="gripper"
title="Drag to move"
{{draggable
didStartDrag=this.didStartDrag
didEndDrag=this.didEndDrag
@@ -87,6 +79,21 @@ export default class Toolbar extends Component {
>
{{icon "grip-lines"}}
</button>
<PluginOutletDebugButton />
<BlockDebugButton />
<SafeModeButton />
<VerboseLocalizationButton />
{{#unless this.siteSettings.viewport_based_mobile_mode}}
<MobileViewButton />
{{/unless}}
<button
type="button"
title={{i18n "dev_tools.disable_dev_tools"}}
class="disable-dev-tools"
{{on "click" this.disableDevTools}}
>
{{icon "xmark"}}
</button>
</div>
</template>
}
@@ -3,7 +3,7 @@ import { on } from "@ember/modifier";
import { action } from "@ember/object";
import concatClass from "discourse/helpers/concat-class";
import icon from "discourse/helpers/d-icon";
import I18n from "discourse-i18n";
import I18n, { i18n } from "discourse-i18n";
export default class VerboseLocalizationButton extends Component {
@action
@@ -18,7 +18,7 @@ export default class VerboseLocalizationButton extends Component {
<template>
<button
title="Toggle verbose localization"
title={{i18n "dev_tools.toggle_verbose_localization"}}
class={{concatClass
"toggle-verbose-localization"
(if I18n.verbose "--active")
@@ -1,4 +1,5 @@
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import BlockOutlet from "discourse/blocks/block-outlet";
import A11yLiveRegions from "discourse/components/a11y/live-regions";
import A11ySkipLinks from "discourse/components/a11y/skip-links";
import AdminOnboardingBanner from "discourse/components/admin-onboarding/banner";
@@ -84,6 +85,10 @@ export default <template>
}}
/>
{{#unless @controller.isCurrentAdminRoute}}
<BlockOutlet @name="hero-blocks" />
{{/unless}}
<div id="main-outlet-wrapper" class="wrap" role="main">
{{#if @controller.sidebarEnabled}}
<SidebarWrapper
@@ -99,6 +104,9 @@ export default <template>
<div id="main-outlet">
{{#unless @controller.shouldHideScrollableContentAbove}}
<PluginOutlet @name="above-main-container" @connectorTagName="div" />
{{#unless @controller.isCurrentAdminRoute}}
<BlockOutlet @name="main-outlet-blocks" />
{{/unless}}
{{#if @controller.siteSettings.enable_site_owner_onboarding}}
<AdminOnboardingBanner />
@@ -1,12 +1,19 @@
import BlockOutlet from "discourse/blocks/block-outlet";
import PluginOutlet from "discourse/components/plugin-outlet";
import { i18n } from "discourse-i18n";
export default <template>
<PluginOutlet @name="custom-homepage">
{{#if @controller.currentUser.admin}}
<p class="alert alert-info">
{{i18n "custom_homepage.admin_message"}}
</p>
{{/if}}
</PluginOutlet>
<BlockOutlet @name="homepage-blocks">
<:after as |hasBlocks|>
<PluginOutlet @name="custom-homepage">
{{#if @controller.currentUser.admin}}
{{#unless hasBlocks}}
<p class="alert alert-info">
{{i18n "custom_homepage.admin_message"}}
</p>
{{/unless}}
{{/if}}
</PluginOutlet>
</:after>
</BlockOutlet>
</template>
+1
View File
@@ -59,6 +59,7 @@
"orderedmap": "^2.1.1",
"photoswipe": "5.4.4",
"pikaday": "^1.8.2",
"picomatch": "^4.0.3",
"pretty-text": "workspace:1.0.0",
"prosemirror-codemark": "^0.4.2",
"prosemirror-commands": "^1.7.1",
@@ -0,0 +1,379 @@
// @ts-check
/**
* Testing utilities for the Discourse Block system.
*
* This module provides helpers for plugin and theme developers to test
* their custom blocks and conditions. These utilities temporarily unfreeze
* registries to allow registration during tests.
*
* @module discourse/tests/helpers/block-testing
*
* @example
* import {
* withTestBlockRegistration,
* registerBlock,
* withTestConditionRegistration,
* registerConditionType,
* resetBlockRegistryForTesting,
* hasBlock,
* isValidOutlet,
* } from "discourse/tests/helpers/block-testing";
*
* // Register a block for testing
* withTestBlockRegistration(() => registerBlock(MyCustomBlock));
*
* // Register a condition for testing
* withTestConditionRegistration(() => registerConditionType(MyCondition));
*
* // Assert block was registered
* assert.true(hasBlock("my-block"));
*/
import {
DEBUG_CALLBACK,
debugHooks,
} from "discourse/lib/blocks/-internals/debug-hooks";
import { FAILURE_TYPE } from "discourse/lib/blocks/-internals/patterns";
import {
_freezeBlockRegistry,
_registerBlock,
_registerBlockFactory,
_resetBlockRegistryState,
getBlockEntry,
hasBlock,
isBlockFactory,
isBlockRegistryFrozen,
isBlockResolved,
resolveBlock,
tryResolveBlock,
withTestBlockRegistration,
} from "discourse/lib/blocks/-internals/registry/block";
import {
_freezeConditionTypeRegistry,
_registerConditionType,
_resetConditionRegistryState,
hasConditionType,
isConditionTypeRegistryFrozen,
withTestConditionRegistration,
} from "discourse/lib/blocks/-internals/registry/condition";
import {
_resetSourceNamespaceState,
_setTestSourceIdentifierInternal,
} from "discourse/lib/blocks/-internals/registry/helpers";
import {
_freezeOutletRegistry,
_registerOutlet,
_resetOutletRegistryState,
getAllOutlets,
getCustomOutlet,
isOutletRegistryFrozen,
isValidOutlet,
} from "discourse/lib/blocks/-internals/registry/outlet";
import { validateConditions } from "discourse/lib/blocks/-internals/validation/conditions";
import { isTesting } from "discourse/lib/environment";
/*
* Block Registration
**/
/**
* Freezes the block registry, preventing further registrations.
* Useful for testing frozen state behavior.
*/
export { _freezeBlockRegistry as freezeBlockRegistry };
/**
* Registers a block class with the block registry.
* Use inside withTestBlockRegistration callback.
*
* @example
* withTestBlockRegistration(() => registerBlock(MyBlock));
*/
export { _registerBlock as registerBlock };
/**
* Registers a factory function for lazy loading a block.
* Use inside withTestBlockRegistration callback.
*
* @example
* withTestBlockRegistration(() => {
* registerBlockFactory("lazy-block", async () => LazyBlock);
* });
*/
export { _registerBlockFactory as registerBlockFactory };
/**
* Temporarily unfreezes the block registry to allow registration during tests.
* Takes a callback that performs registration, then re-freezes the registry.
*
* @example
* withTestBlockRegistration(() => registerBlock(MyBlock));
*/
export { withTestBlockRegistration };
/*
* Block Registry Queries
**/
/**
* Returns the registry entry for a block (class or factory).
*/
export { getBlockEntry };
/**
* Checks if a block is registered (by name or class reference).
*
* @example
* assert.true(hasBlock("my-block"));
*/
export { hasBlock };
/**
* Checks if a registry entry is a factory function (not a resolved class).
*/
export { isBlockFactory };
/**
* Returns whether the block registry is frozen.
*/
export { isBlockRegistryFrozen };
/**
* Checks if a block is registered and fully resolved (not a pending factory).
*/
export { isBlockResolved };
/**
* Resolves a block reference (string name or class) to a BlockClass.
* Async - use for testing factory resolution.
*/
export { resolveBlock };
/**
* Attempts to resolve a block reference synchronously.
* Returns the BlockClass if found and resolved, null if pending or not found.
*/
export { tryResolveBlock };
/*
* Outlet Registration
**/
/**
* Freezes the outlet registry, preventing further registrations.
* Useful for testing frozen state behavior.
*/
export { _freezeOutletRegistry as freezeOutletRegistry };
/**
* Registers a custom outlet for testing.
* Use inside withTestBlockRegistration callback.
*
* @example
* withTestBlockRegistration(() => {
* registerOutlet("test-outlet", { description: "For testing" });
* });
*/
export { _registerOutlet as registerOutlet };
/*
* Outlet Registry Queries
**/
/**
* Returns all registered outlets (core + custom).
*/
export { getAllOutlets };
/**
* Returns custom outlet data for a registered custom outlet.
*/
export { getCustomOutlet };
/**
* Returns whether the outlet registry is frozen.
*/
export { isOutletRegistryFrozen };
/**
* Checks if an outlet name is valid (registered as core or custom outlet).
*
* @example
* assert.true(isValidOutlet("sidebar-blocks"));
*/
export { isValidOutlet };
/*
* Condition Registration
**/
/**
* Freezes the condition type registry, preventing further registrations.
* Useful for testing frozen state behavior.
*/
export { _freezeConditionTypeRegistry as freezeConditionTypeRegistry };
/**
* Registers a condition class with the condition registry.
* Use inside withTestConditionRegistration callback.
*
* @example
* withTestConditionRegistration(() => registerConditionType(MyCondition));
*/
export { _registerConditionType as registerConditionType };
/**
* Temporarily unfreezes the condition registry to allow registration during tests.
* Takes a callback that performs registration, then re-freezes the registry.
*
* @example
* withTestConditionRegistration(() => registerConditionType(MyCondition));
*/
export { withTestConditionRegistration };
/*
* Condition Registry Queries
**/
/**
* Checks if a condition type is registered.
*
* @example
* assert.true(hasConditionType("user"));
*/
export { hasConditionType };
/**
* Returns whether the condition type registry is frozen.
*/
export { isConditionTypeRegistryFrozen };
/**
* Validates a condition specification against the registered condition types.
* Throws detailed errors if the specification is invalid.
*/
export { validateConditions };
/**
* Constants for block failure types used in debug mode.
* Used to identify why a block didn't render (conditions failed, optional missing, etc.).
*/
export { FAILURE_TYPE };
/*
* Debug Utilities
**/
/**
* Debug callback type constants.
* Used with debugHooks.setCallback() for testing debug behavior.
*/
export { DEBUG_CALLBACK };
/**
* Debug hook interface for testing debug mode behavior.
* Provides reactive getters and callback management.
*/
export { debugHooks };
/**
* Sets up debug callbacks to capture ghost blocks and render them with standard markup.
* Returns an array that will be populated with ghost data as blocks are processed.
*
* The rendered ghost blocks have:
* - class="ghost-block"
* - data-name={blockName}
* - data-type={failureType}
* - data-reason={failureReason}
*
* @param {Object} [options] - Configuration options.
* @param {boolean} [options.enabled=true] - Whether ghost blocks are enabled.
* Set to false to test that ghosts aren't rendered when debug mode is disabled.
* @returns {Array<{name: string, failureType: string, failureReason: string|undefined}>}
*
* @example
* const capturedGhosts = setupGhostCapture();
* // ... register blocks and render ...
* assert.dom('.ghost-block[data-name="my-block"]').exists();
* assert.strictEqual(capturedGhosts[0].name, "my-block");
*/
export function setupGhostCapture({ enabled = true } = {}) {
const capturedGhosts = [];
debugHooks.setCallback(DEBUG_CALLBACK.GHOST_BLOCKS, () => enabled);
debugHooks.setCallback(DEBUG_CALLBACK.BLOCK_DEBUG, (blockData) => {
if (blockData.conditionsPassed === false) {
capturedGhosts.push({
name: blockData.name,
failureType: blockData.failureType,
failureReason: blockData.failureReason,
});
return {
Component: <template>
<div
class="ghost-block"
data-name={{blockData.name}}
data-type={{blockData.failureType}}
data-reason={{blockData.failureReason}}
>Ghost: {{blockData.name}}</div>
</template>,
isGhost: true,
asGhost: () => null,
};
}
return { Component: blockData.Component };
});
return capturedGhosts;
}
/**
* Resets all debug callbacks to null.
* Use in afterEach hooks to clean up debug state between tests.
*
* @example
* hooks.afterEach(function () {
* resetDebugCallbacks();
* });
*/
export function resetDebugCallbacks() {
for (const key of Object.values(DEBUG_CALLBACK)) {
debugHooks.setCallback(key, null);
}
}
/*
* Reset Utilities
**/
/**
* Resets all registries (blocks, outlets, conditions) for testing.
*
* USE ONLY FOR TESTING PURPOSES.
*
* Clears all registered entities and restores the original frozen state.
*/
export function resetBlockRegistryForTesting() {
if (!isTesting()) {
throw new Error("resetBlockRegistryForTesting can only be used in tests.");
}
_resetBlockRegistryState();
_resetOutletRegistryState();
_resetConditionRegistryState();
_resetSourceNamespaceState();
}
/**
* Sets a test override for the source identifier.
*
* USE ONLY FOR TESTING PURPOSES.
*
* @param {string|null} sourceId - Source identifier to use, or null to clear.
*/
export function setTestSourceIdentifier(sourceId) {
if (!isTesting()) {
throw new Error("setTestSourceIdentifier can only be used in tests.");
}
_setTestSourceIdentifierInternal(sourceId);
}
@@ -14,6 +14,7 @@ import MessageBus from "message-bus-client";
import { resetCache as resetOneboxCache } from "pretty-text/oneboxer";
import QUnit, { module, test } from "qunit";
import sinon from "sinon";
import { _resetOutletLayoutsForTesting } from "discourse/blocks/block-outlet";
import { clearAboutPageActivities } from "discourse/components/about-page";
import { resetCardClickListenerSelector } from "discourse/components/card-contents-base";
import {
@@ -102,6 +103,10 @@ import {
} from "discourse/services/keyboard-shortcuts";
import sessionFixtures from "discourse/tests/fixtures/session-fixtures";
import siteFixtures from "discourse/tests/fixtures/site-fixtures";
import {
resetBlockRegistryForTesting,
resetDebugCallbacks,
} from "discourse/tests/helpers/block-testing";
import {
currentSettings,
mergeSettings,
@@ -274,6 +279,9 @@ export function testCleanup(container, app) {
enableClearA11yAnnouncementsInTests();
resetHtmlDecorators();
resetCustomUserNavMessagesDropdownRows();
_resetOutletLayoutsForTesting();
resetBlockRegistryForTesting();
resetDebugCallbacks();
}
function cleanupCssGeneratorTags() {
@@ -0,0 +1,478 @@
import Component from "@glimmer/component";
import { render } from "@ember/test-helpers";
import { module, test } from "qunit";
import { block } from "discourse/blocks";
import BlockOutlet from "discourse/blocks/block-outlet";
import BlockGroup from "discourse/blocks/builtin/block-group";
import { withPluginApi } from "discourse/lib/plugin-api";
import {
DEBUG_CALLBACK,
debugHooks,
} from "discourse/tests/helpers/block-testing";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
module("Integration | Blocks | BlockGroup", function (hooks) {
setupRenderingTest(hooks);
test("renders with BEM classes", async function (assert) {
@block("group-child-1")
class GroupChild1 extends Component {
<template>
<div class="child-1">Child 1</div>
</template>
}
@block("group-child-2")
class GroupChild2 extends Component {
<template>
<div class="child-2">Child 2</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("hero-blocks", [
{
block: BlockGroup,
id: "features",
classNames: "custom-group-class",
children: [{ block: GroupChild1 }, { block: GroupChild2 }],
},
])
);
await render(<template><BlockOutlet @name="hero-blocks" /></template>);
assert
.dom(".hero-blocks__block-container--features")
.exists("container has BEM modifier class from id");
assert.dom(".custom-group-class").exists("custom classNames applied");
});
test("renders all children blocks", async function (assert) {
@block("multi-child-a")
class MultiChildA extends Component {
<template>
<div class="multi-a">A</div>
</template>
}
@block("multi-child-b")
class MultiChildB extends Component {
<template>
<div class="multi-b">B</div>
</template>
}
@block("multi-child-c")
class MultiChildC extends Component {
<template>
<div class="multi-c">C</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("homepage-blocks", [
{
block: BlockGroup,
children: [
{ block: MultiChildA },
{ block: MultiChildB },
{ block: MultiChildC },
],
},
])
);
await render(<template><BlockOutlet @name="homepage-blocks" /></template>);
assert.dom(".multi-a").exists();
assert.dom(".multi-b").exists();
assert.dom(".multi-c").exists();
});
test("passes args to children blocks", async function (assert) {
@block("args-child", { args: { title: { type: "string" } } })
class ArgsChild extends Component {
<template>
<div class="args-child-content">{{@title}}</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("sidebar-blocks", [
{
block: BlockGroup,
children: [
{ block: ArgsChild, args: { title: "First" } },
{ block: ArgsChild, args: { title: "Second" } },
],
},
])
);
await render(<template><BlockOutlet @name="sidebar-blocks" /></template>);
const contents = document.querySelectorAll(".args-child-content");
assert.strictEqual(contents.length, 2);
assert.strictEqual(contents[0].textContent.trim(), "First");
assert.strictEqual(contents[1].textContent.trim(), "Second");
});
test("supports nested BlockGroups", async function (assert) {
@block("nested-leaf")
class NestedLeaf extends Component {
<template>
<div class="nested-leaf">Leaf</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("main-outlet-blocks", [
{
block: BlockGroup,
id: "outer",
children: [
{
block: BlockGroup,
id: "inner",
children: [{ block: NestedLeaf }],
},
],
},
])
);
await render(
<template><BlockOutlet @name="main-outlet-blocks" /></template>
);
assert
.dom(".main-outlet-blocks__block-container--outer")
.exists("outer container has BEM modifier");
assert
.dom(".main-outlet-blocks__block-container--inner")
.exists("inner container has BEM modifier");
assert.dom(".nested-leaf").exists();
});
test("children blocks have outlet-prefixed wrapper classes", async function (assert) {
@block("wrapper-test-child")
class WrapperTestChild extends Component {
<template>
<span class="child-content">Child</span>
</template>
}
withPluginApi((api) =>
api.renderBlocks("sidebar-blocks", [
{
block: BlockGroup,
id: "wrapper-test",
children: [{ block: WrapperTestChild }],
},
])
);
await render(<template><BlockOutlet @name="sidebar-blocks" /></template>);
assert
.dom(".sidebar-blocks__block-container--wrapper-test")
.exists("container has BEM modifier from id");
// Child block wrapper should have outlet-prefixed class
assert
.dom(".sidebar-blocks__block")
.exists("child has outlet-prefixed __block class");
assert
.dom('[data-block-name="wrapper-test-child"]')
.exists("child has data-block-name attribute");
});
test("deeply nested blocks have correct outlet-prefixed wrapper classes", async function (assert) {
@block("deep-leaf")
class DeepLeaf extends Component {
<template>
<span class="deep-leaf-content">Leaf</span>
</template>
}
withPluginApi((api) =>
api.renderBlocks("hero-blocks", [
{
block: BlockGroup,
id: "level-1",
children: [
{
block: BlockGroup,
id: "level-2",
children: [
{
block: BlockGroup,
id: "level-3",
children: [{ block: DeepLeaf }],
},
],
},
],
},
])
);
await render(<template><BlockOutlet @name="hero-blocks" /></template>);
// All nested containers should have BEM modifier classes from id
assert
.dom(".hero-blocks__block-container--level-1")
.exists("level-1 container has BEM modifier");
assert
.dom(".hero-blocks__block-container--level-2")
.exists("level-2 container has BEM modifier");
assert
.dom(".hero-blocks__block-container--level-3")
.exists("level-3 container has BEM modifier");
// The leaf block should also have the outlet prefix
assert
.dom(".hero-blocks__block")
.exists("deeply nested leaf has outlet-prefixed class");
assert
.dom('[data-block-name="deep-leaf"]')
.exists("deeply nested leaf has data-block-name attribute");
});
test("@outletName is curried and accessible in blocks", async function (assert) {
@block("outlet-name-test")
class OutletNameTest extends Component {
<template>
<div class="outlet-name-display" data-outlet={{@outletName}}>
{{@outletName}}
</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("homepage-blocks", [
{
block: BlockGroup,
children: [{ block: OutletNameTest }],
},
])
);
await render(<template><BlockOutlet @name="homepage-blocks" /></template>);
const display = document.querySelector(".outlet-name-display");
assert.strictEqual(
display.getAttribute("data-outlet"),
"homepage-blocks",
"@outletName is curried into nested blocks"
);
assert.strictEqual(
display.textContent.trim(),
"homepage-blocks",
"@outletName value is accessible in template"
);
});
test("wrapper classes are correct when debug overlay is enabled", async function (assert) {
@block("overlay-test-child")
class OverlayTestChild extends Component {
<template>
<span class="overlay-child-content" data-outlet={{@outletName}}>
Child
</span>
</template>
}
// Enable debug overlay - this wraps blocks with BlockInfo component
debugHooks.setCallback(DEBUG_CALLBACK.BLOCK_DEBUG, (blockData) => {
return { Component: blockData.Component };
});
withPluginApi((api) =>
api.renderBlocks("sidebar-blocks", [
{
block: BlockGroup,
id: "overlay-test",
children: [{ block: OverlayTestChild }],
},
])
);
await render(<template><BlockOutlet @name="sidebar-blocks" /></template>);
// Verify container has BEM modifier class with overlay enabled
assert
.dom(".sidebar-blocks__block-container--overlay-test")
.exists("container has BEM modifier with overlay enabled");
// Verify child has correct outlet-prefixed class even with overlay
assert
.dom(".sidebar-blocks__block")
.exists("child has outlet-prefixed class with overlay enabled");
// Verify @outletName is still accessible to the block component
const childContent = document.querySelector(".overlay-child-content");
assert.strictEqual(
childContent.getAttribute("data-outlet"),
"sidebar-blocks",
"@outletName is accessible even when overlay wraps the component"
);
});
test("deeply nested wrapper classes are correct with debug overlay enabled", async function (assert) {
@block("deep-overlay-leaf")
class DeepOverlayLeaf extends Component {
<template>
<span class="deep-overlay-content" data-outlet={{@outletName}}>
Leaf
</span>
</template>
}
// Enable debug overlay
debugHooks.setCallback(DEBUG_CALLBACK.BLOCK_DEBUG, (blockData) => {
return { Component: blockData.Component };
});
withPluginApi((api) =>
api.renderBlocks("hero-blocks", [
{
block: BlockGroup,
id: "level-1",
children: [
{
block: BlockGroup,
id: "level-2",
children: [{ block: DeepOverlayLeaf }],
},
],
},
])
);
await render(<template><BlockOutlet @name="hero-blocks" /></template>);
// All nested containers should have BEM modifier with overlay enabled
assert
.dom(".hero-blocks__block-container--level-1")
.exists("level-1 has BEM modifier with overlay");
assert
.dom(".hero-blocks__block-container--level-2")
.exists("level-2 has BEM modifier with overlay");
// The deeply nested leaf should have outlet prefix
assert
.dom(".hero-blocks__block")
.exists("deeply nested leaf has outlet-prefixed class with overlay");
// @outletName should be accessible in deeply nested blocks
const leafContent = document.querySelector(".deep-overlay-content");
assert.strictEqual(
leafContent.getAttribute("data-outlet"),
"hero-blocks",
"@outletName is accessible in deeply nested blocks with overlay"
);
});
test("containerArgs are accessible to parent container", async function (assert) {
// A tabs-like container that requires each child to provide a name via containerArgs.
// The parent can access containerArgs to render tab headers.
@block("tabs-container", {
container: true,
childArgs: {
tabName: { type: "string", required: true, unique: true },
},
classNames: "tabs-container",
})
class TabsContainer extends Component {
<template>
<div class="tabs-header">
{{#each @children as |child|}}
<button class="tab-button" data-tab={{child.containerArgs.tabName}}>
{{child.containerArgs.tabName}}
</button>
{{/each}}
</div>
<div class="tabs-content">
{{#each @children as |child|}}
<child.Component />
{{/each}}
</div>
</template>
}
@block("tab-content")
class TabContent extends Component {
<template>
<div class="tab-panel">Tab Panel Content</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("hero-blocks", [
{
block: TabsContainer,
children: [
{ block: TabContent, containerArgs: { tabName: "settings" } },
{ block: TabContent, containerArgs: { tabName: "profile" } },
{ block: TabContent, containerArgs: { tabName: "security" } },
],
},
])
);
await render(<template><BlockOutlet @name="hero-blocks" /></template>);
// Verify tab headers are rendered from containerArgs
const tabButtons = document.querySelectorAll(".tab-button");
assert.strictEqual(tabButtons.length, 3, "three tab buttons rendered");
assert.strictEqual(
tabButtons[0].getAttribute("data-tab"),
"settings",
"first tab has correct name from containerArgs"
);
assert.strictEqual(
tabButtons[1].getAttribute("data-tab"),
"profile",
"second tab has correct name from containerArgs"
);
assert.strictEqual(
tabButtons[2].getAttribute("data-tab"),
"security",
"third tab has correct name from containerArgs"
);
// Verify tab content panels are rendered
const tabPanels = document.querySelectorAll(".tab-panel");
assert.strictEqual(tabPanels.length, 3, "three tab panels rendered");
});
test("data-block-id attribute is set when id is provided", async function (assert) {
@block("id-test-child")
class IdTestChild extends Component {
<template>
<span class="id-test-content">Child</span>
</template>
}
withPluginApi((api) =>
api.renderBlocks("sidebar-blocks", [
{
block: BlockGroup,
id: "main-group",
children: [{ block: IdTestChild, id: "first-child" }],
},
])
);
await render(<template><BlockOutlet @name="sidebar-blocks" /></template>);
assert
.dom('[data-block-id="main-group"]')
.exists("container has data-block-id attribute");
assert
.dom('[data-block-id="first-child"]')
.exists("child has data-block-id attribute");
});
});
@@ -0,0 +1,451 @@
import Component from "@glimmer/component";
import { render } from "@ember/test-helpers";
import { module, test } from "qunit";
import { block } from "discourse/blocks";
import BlockOutlet from "discourse/blocks/block-outlet";
import BlockGroup from "discourse/blocks/builtin/block-group";
import BlockHead from "discourse/blocks/builtin/block-head";
import { withPluginApi } from "discourse/lib/plugin-api";
import {
FAILURE_TYPE,
setupGhostCapture,
} from "discourse/tests/helpers/block-testing";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { i18n } from "discourse-i18n";
module("Integration | Blocks | BlockHead", function (hooks) {
setupRenderingTest(hooks);
test("renders only the first child when all children are visible", async function (assert) {
@block("head-child-a")
class ChildA extends Component {
<template>
<div class="child-a">A</div>
</template>
}
@block("head-child-b")
class ChildB extends Component {
<template>
<div class="child-b">B</div>
</template>
}
@block("head-child-c")
class ChildC extends Component {
<template>
<div class="child-c">C</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("homepage-blocks", [
{
block: BlockHead,
children: [{ block: ChildA }, { block: ChildB }, { block: ChildC }],
},
])
);
await render(<template><BlockOutlet @name="homepage-blocks" /></template>);
assert.dom(".child-a").exists("first child renders");
assert.dom(".child-b").doesNotExist("second child does not render");
assert.dom(".child-c").doesNotExist("third child does not render");
});
test("renders first child whose conditions pass", async function (assert) {
@block("conditional-child-1")
class ConditionalChild1 extends Component {
<template>
<div class="cond-1">First</div>
</template>
}
@block("conditional-child-2")
class ConditionalChild2 extends Component {
<template>
<div class="cond-2">Second</div>
</template>
}
@block("conditional-child-3")
class ConditionalChild3 extends Component {
<template>
<div class="cond-3">Third</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("homepage-blocks", [
{
block: BlockHead,
children: [
// First child fails condition (user is not admin in test)
{
block: ConditionalChild1,
conditions: { type: "user", admin: true },
},
// Second child passes (no conditions)
{ block: ConditionalChild2 },
// Third child also passes but shouldn't render
{ block: ConditionalChild3 },
],
},
])
);
await render(<template><BlockOutlet @name="homepage-blocks" /></template>);
assert
.dom(".cond-1")
.doesNotExist("first child with failing condition does not render");
assert.dom(".cond-2").exists("second child (first passing) renders");
assert.dom(".cond-3").doesNotExist("third child does not render");
});
test("renders nothing when no children pass conditions", async function (assert) {
@block("all-fail-child")
class AllFailChild extends Component {
<template>
<div class="fail-child">Should not render</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("homepage-blocks", [
{
block: BlockHead,
children: [
{
block: AllFailChild,
conditions: { type: "user", admin: true },
},
{
block: AllFailChild,
conditions: { type: "user", staff: true },
},
],
},
])
);
await render(<template><BlockOutlet @name="homepage-blocks" /></template>);
assert
.dom(".fail-child")
.doesNotExist("no children render when all conditions fail");
});
test("renders with correct BEM classes", async function (assert) {
@block("bem-test-child")
class BemTestChild extends Component {
<template>
<div class="bem-child">Child</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("hero-blocks", [
{
block: BlockHead,
classNames: "custom-head",
children: [{ block: BemTestChild }],
},
])
);
await render(<template><BlockOutlet @name="hero-blocks" /></template>);
assert
.dom('[data-block-name="head"]')
.exists("has data-block-name attribute");
assert.dom(".custom-head").exists("has custom class");
});
test("works nested inside a group", async function (assert) {
@block("nested-head-a")
class NestedHeadA extends Component {
<template>
<div class="nested-a">A</div>
</template>
}
@block("nested-head-b")
class NestedHeadB extends Component {
<template>
<div class="nested-b">B</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("homepage-blocks", [
{
block: BlockGroup,
id: "outer",
children: [
{
block: BlockHead,
children: [{ block: NestedHeadA }, { block: NestedHeadB }],
},
],
},
])
);
await render(<template><BlockOutlet @name="homepage-blocks" /></template>);
assert
.dom(".homepage-blocks__block-container--outer")
.exists("group renders with BEM modifier");
assert.dom(".nested-a").exists("head renders first child");
assert.dom(".nested-b").doesNotExist("head does not render second child");
});
test("passes args to the rendered child", async function (assert) {
@block("args-head-child", { args: { message: { type: "string" } } })
class ArgsHeadChild extends Component {
<template>
<div class="args-child">{{@message}}</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("homepage-blocks", [
{
block: BlockHead,
children: [
{
block: ArgsHeadChild,
args: { message: "Hello from head" },
},
],
},
])
);
await render(<template><BlockOutlet @name="homepage-blocks" /></template>);
assert.dom(".args-child").hasText("Hello from head");
});
test("@outletName is accessible in the rendered child", async function (assert) {
@block("outlet-name-head-child")
class OutletNameHeadChild extends Component {
<template>
<div
class="outlet-display"
data-outlet={{@outletName}}
>{{@outletName}}</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("sidebar-blocks", [
{
block: BlockHead,
children: [{ block: OutletNameHeadChild }],
},
])
);
await render(<template><BlockOutlet @name="sidebar-blocks" /></template>);
const display = document.querySelector(".outlet-display");
assert.strictEqual(display.getAttribute("data-outlet"), "sidebar-blocks");
assert.strictEqual(display.textContent.trim(), "sidebar-blocks");
});
test("shows ghosts for children that failed conditions in debug mode", async function (assert) {
const capturedGhosts = setupGhostCapture();
@block("ghost-fail-child")
class GhostFailChild extends Component {
<template>
<div class="fail-child">Should be ghost</div>
</template>
}
@block("ghost-pass-child")
class GhostPassChild extends Component {
<template>
<div class="pass-child">Rendered</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("homepage-blocks", [
{
block: BlockHead,
children: [
{
block: GhostFailChild,
conditions: { type: "user", admin: true },
},
{ block: GhostPassChild },
],
},
])
);
await render(<template><BlockOutlet @name="homepage-blocks" /></template>);
assert.dom(".pass-child").exists("passing child renders");
assert
.dom('.ghost-block[data-name="ghost-fail-child"]')
.exists("failed child shows as ghost");
assert
.dom(`[data-type="${FAILURE_TYPE.CONDITION_FAILED}"]`)
.exists("ghost has correct failure type");
const failedGhost = capturedGhosts.find(
(g) => g.name === "ghost-fail-child"
);
assert.strictEqual(
failedGhost?.failureType,
FAILURE_TYPE.CONDITION_FAILED,
"captured ghost has CONDITION_FAILED type"
);
// Verify order: ghost should appear before the rendered child
const headContainer = document.querySelector('[data-block-name="head"]');
const children = [...headContainer.children];
const ghostIndex = children.findIndex((el) =>
el.matches('.ghost-block[data-name="ghost-fail-child"]')
);
const passIndex = children.findIndex((el) =>
el.querySelector(".pass-child")
);
assert.true(
ghostIndex < passIndex,
"ghost appears before rendered child in DOM order"
);
});
test("shows ghosts for children hidden by priority in debug mode", async function (assert) {
const capturedGhosts = setupGhostCapture();
@block("priority-first")
class PriorityFirst extends Component {
<template>
<div class="priority-first">First</div>
</template>
}
@block("priority-second")
class PrioritySecond extends Component {
<template>
<div class="priority-second">Second</div>
</template>
}
@block("priority-third")
class PriorityThird extends Component {
<template>
<div class="priority-third">Third</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("hero-blocks", [
{
block: BlockHead,
children: [
{ block: PriorityFirst },
{ block: PrioritySecond },
{ block: PriorityThird },
],
},
])
);
await render(<template><BlockOutlet @name="hero-blocks" /></template>);
assert.dom(".priority-first").exists("first child renders");
assert.dom(".priority-second").doesNotExist("second child does not render");
assert.dom(".priority-third").doesNotExist("third child does not render");
assert
.dom('.ghost-block[data-name="priority-second"]')
.exists("second child shows as ghost");
assert
.dom('.ghost-block[data-name="priority-third"]')
.exists("third child shows as ghost");
const hiddenGhosts = capturedGhosts.filter(
(g) => g.name === "priority-second" || g.name === "priority-third"
);
assert.strictEqual(
hiddenGhosts.length,
2,
"two children hidden by priority"
);
assert.true(
hiddenGhosts.every(
(g) =>
g.failureReason ===
i18n("js.blocks.ghost_reasons.head_hidden_tail_hint")
),
"ghosts have hidden-by-priority reason"
);
// Verify order: first < second ghost < third ghost
const headContainer = document.querySelector('[data-block-name="head"]');
const children = [...headContainer.children];
const firstIndex = children.findIndex((el) =>
el.querySelector(".priority-first")
);
const secondGhostIndex = children.findIndex((el) =>
el.matches('.ghost-block[data-name="priority-second"]')
);
const thirdGhostIndex = children.findIndex((el) =>
el.matches('.ghost-block[data-name="priority-third"]')
);
assert.true(
firstIndex < secondGhostIndex,
"rendered child appears before second ghost"
);
assert.true(
secondGhostIndex < thirdGhostIndex,
"second ghost appears before third ghost"
);
});
test("no ghosts rendered when debug mode is disabled", async function (assert) {
// When visual overlay is disabled, ghosts should not be rendered even if
// BLOCK_DEBUG callback is set. The blocks service's showGhosts getter
// controls whether head block renders ghosts for hidden children.
setupGhostCapture({ enabled: false });
@block("no-ghost-first")
class NoGhostFirst extends Component {
<template>
<div class="no-ghost-first">First</div>
</template>
}
@block("no-ghost-second")
class NoGhostSecond extends Component {
<template>
<div class="no-ghost-second">Second</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("sidebar-blocks", [
{
block: BlockHead,
children: [{ block: NoGhostFirst }, { block: NoGhostSecond }],
},
])
);
await render(<template><BlockOutlet @name="sidebar-blocks" /></template>);
assert.dom(".no-ghost-first").exists("first child renders");
assert.dom(".no-ghost-second").doesNotExist("second child does not render");
assert
.dom(".ghost-block")
.doesNotExist("no ghost blocks rendered when overlay disabled");
});
});
@@ -0,0 +1,386 @@
import Component from "@glimmer/component";
import { render } from "@ember/test-helpers";
import { module, test } from "qunit";
import { block } from "discourse/blocks";
import BlockOutlet from "discourse/blocks/block-outlet";
import BlockGroup from "discourse/blocks/builtin/block-group";
import { withPluginApi } from "discourse/lib/plugin-api";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
module("Integration | Blocks | BlockLayoutWrapper", function (hooks) {
setupRenderingTest(hooks);
module("data attributes", function () {
test("non-container block has correct data-block-name", async function (assert) {
@block("data-attr-test-block")
class DataAttrTestBlock extends Component {
<template>
<div class="test-content">Content</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("hero-blocks", [{ block: DataAttrTestBlock }])
);
await render(<template><BlockOutlet @name="hero-blocks" /></template>);
assert
.dom('[data-block-name="data-attr-test-block"]')
.exists("data-block-name attribute is set");
assert
.dom(".hero-blocks__block")
.hasAttribute(
"data-block-name",
"data-attr-test-block",
"data-block-name has correct value"
);
});
test("namespaced block has correct data-block-namespace", async function (assert) {
@block("test-plugin:namespaced-block")
class NamespacedBlock extends Component {
<template>
<div class="namespaced-content">Namespaced</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("sidebar-blocks", [{ block: NamespacedBlock }])
);
await render(<template><BlockOutlet @name="sidebar-blocks" /></template>);
assert
.dom('[data-block-namespace="test-plugin"]')
.exists("data-block-namespace attribute is set for namespaced block");
assert
.dom(".sidebar-blocks__block")
.hasAttribute(
"data-block-namespace",
"test-plugin",
"data-block-namespace has correct value"
);
});
test("core block has no data-block-namespace", async function (assert) {
@block("core-style-block")
class CoreStyleBlock extends Component {
<template>
<div class="core-content">Core</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("hero-blocks", [{ block: CoreStyleBlock }])
);
await render(<template><BlockOutlet @name="hero-blocks" /></template>);
const wrapper = document.querySelector(".hero-blocks__block");
assert.strictEqual(
wrapper.getAttribute("data-block-namespace"),
null,
"core blocks have no namespace attribute"
);
});
test("data-block-id is set when id is provided", async function (assert) {
@block("id-attr-test-block")
class IdAttrTestBlock extends Component {
<template>
<div class="id-content">Content</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("hero-blocks", [
{ block: IdAttrTestBlock, id: "my-block-id" },
])
);
await render(<template><BlockOutlet @name="hero-blocks" /></template>);
assert
.dom('[data-block-id="my-block-id"]')
.exists("data-block-id attribute is set");
});
test("data-block-id is not set when id is not provided", async function (assert) {
@block("no-id-attr-block")
class NoIdAttrBlock extends Component {
<template>
<div class="no-id-content">Content</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("hero-blocks", [{ block: NoIdAttrBlock }])
);
await render(<template><BlockOutlet @name="hero-blocks" /></template>);
const wrapper = document.querySelector(".hero-blocks__block");
assert.strictEqual(
wrapper.getAttribute("data-block-id"),
null,
"data-block-id is not set when id is not provided"
);
});
test("all data attributes are present on container block", async function (assert) {
@block("full-attr-child")
class FullAttrChild extends Component {
<template>
<div class="full-child">Child</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("main-outlet-blocks", [
{
block: BlockGroup,
id: "full-test",
children: [{ block: FullAttrChild }],
},
])
);
await render(
<template><BlockOutlet @name="main-outlet-blocks" /></template>
);
const containerWrapper = document.querySelector(
'[data-block-name="group"]'
);
assert.strictEqual(
containerWrapper.getAttribute("data-block-name"),
"group",
"container has correct data-block-name"
);
assert.strictEqual(
containerWrapper.getAttribute("data-block-namespace"),
null,
"built-in container has no namespace"
);
assert.strictEqual(
containerWrapper.getAttribute("data-block-id"),
"full-test",
"container has correct data-block-id"
);
});
test("nested blocks have correct data attributes", async function (assert) {
@block("nested-attr-leaf")
class NestedAttrLeaf extends Component {
<template>
<div class="nested-leaf">Leaf</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("hero-blocks", [
{
block: BlockGroup,
id: "outer",
children: [
{
block: BlockGroup,
id: "inner",
children: [{ block: NestedAttrLeaf }],
},
],
},
])
);
await render(<template><BlockOutlet @name="hero-blocks" /></template>);
assert
.dom('[data-block-id="outer"]')
.exists("outer container has data-block-id");
assert
.dom('[data-block-id="inner"]')
.exists("inner container has data-block-id");
const leaf = document.querySelector(
'[data-block-name="nested-attr-leaf"]'
);
assert.strictEqual(
leaf.getAttribute("data-block-id"),
null,
"nested leaf without id does not have data-block-id"
);
});
});
module("CSS classes", function () {
test("leaf blocks have {outlet}__block class", async function (assert) {
@block("css-class-block")
class CssClassBlock extends Component {
<template>
<div class="css-content">Content</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("sidebar-blocks", [{ block: CssClassBlock }])
);
await render(<template><BlockOutlet @name="sidebar-blocks" /></template>);
assert
.dom(".sidebar-blocks__block")
.exists("leaf block has outlet__block class");
});
test("container blocks have {outlet}__block-container class", async function (assert) {
@block("container-css-child")
class ContainerCssChild extends Component {
<template>
<div class="container-css-content">Child</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("hero-blocks", [
{
block: BlockGroup,
children: [{ block: ContainerCssChild }],
},
])
);
await render(<template><BlockOutlet @name="hero-blocks" /></template>);
const containerWrapper = document.querySelector(
'[data-block-name="group"]'
);
assert.true(
containerWrapper.classList.contains("hero-blocks__block-container"),
"container has outlet__block-container class"
);
});
test("id generates BEM modifier class on leaf blocks", async function (assert) {
@block("bem-modifier-block")
class BemModifierBlock extends Component {
<template>
<div class="bem-content">Content</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("sidebar-blocks", [
{ block: BemModifierBlock, id: "featured" },
])
);
await render(<template><BlockOutlet @name="sidebar-blocks" /></template>);
assert
.dom(".sidebar-blocks__block--featured")
.exists("leaf block has BEM modifier class from id");
});
test("id generates BEM modifier class on container blocks", async function (assert) {
@block("bem-container-child")
class BemContainerChild extends Component {
<template>
<div class="bem-child">Child</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("hero-blocks", [
{
block: BlockGroup,
id: "main-group",
children: [{ block: BemContainerChild }],
},
])
);
await render(<template><BlockOutlet @name="hero-blocks" /></template>);
assert
.dom(".hero-blocks__block-container--main-group")
.exists("container has BEM modifier class from id");
});
test("decorator classNames are applied", async function (assert) {
@block("decorator-class-block", {
classNames: "custom-decorator-class",
})
class DecoratorClassBlock extends Component {
<template>
<div class="decorator-content">Content</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("homepage-blocks", [{ block: DecoratorClassBlock }])
);
await render(
<template><BlockOutlet @name="homepage-blocks" /></template>
);
assert
.dom(".custom-decorator-class")
.exists("decorator classNames are applied to wrapper");
});
test("layout entry classNames are applied", async function (assert) {
@block("entry-class-block")
class EntryClassBlock extends Component {
<template>
<div class="entry-content">Content</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("sidebar-blocks", [
{ block: EntryClassBlock, classNames: "custom-entry-class" },
])
);
await render(<template><BlockOutlet @name="sidebar-blocks" /></template>);
assert
.dom(".custom-entry-class")
.exists("layout entry classNames are applied to wrapper");
});
test("both decorator and entry classNames are applied", async function (assert) {
@block("both-classes-block", {
classNames: "from-decorator",
})
class BothClassesBlock extends Component {
<template>
<div class="both-content">Content</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("main-outlet-blocks", [
{ block: BothClassesBlock, classNames: "from-entry" },
])
);
await render(
<template><BlockOutlet @name="main-outlet-blocks" /></template>
);
const wrapper = document.querySelector(".main-outlet-blocks__block");
assert.true(
wrapper.classList.contains("from-decorator"),
"decorator class is applied"
);
assert.true(
wrapper.classList.contains("from-entry"),
"entry class is applied"
);
});
});
});
@@ -0,0 +1,623 @@
import Component from "@glimmer/component";
import { render } from "@ember/test-helpers";
import { module, test } from "qunit";
import { block } from "discourse/blocks";
import BlockOutlet from "discourse/blocks/block-outlet";
import BlockGroup from "discourse/blocks/builtin/block-group";
import { BlockCondition, blockCondition } from "discourse/blocks/conditions";
import { withPluginApi } from "discourse/lib/plugin-api";
import {
registerConditionType,
withTestConditionRegistration,
} from "discourse/tests/helpers/block-testing";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
/* Test condition classes - defined at module scope to use with decorator */
@blockCondition({ type: "always-true", args: {} })
class BlockAlwaysTrueCondition extends BlockCondition {
evaluate() {
return true;
}
}
@blockCondition({ type: "always-false", args: {} })
class BlockAlwaysFalseCondition extends BlockCondition {
evaluate() {
return false;
}
}
module("Integration | Blocks | BlockOutlet | Conditions", function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
// Register test conditions for each test (registries are reset between tests)
withTestConditionRegistration(() => {
registerConditionType(BlockAlwaysTrueCondition);
registerConditionType(BlockAlwaysFalseCondition);
});
});
test("renders block when no conditions specified", async function (assert) {
@block("no-condition-block")
class NoConditionBlock extends Component {
<template>
<div class="no-condition">No Condition</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("hero-blocks", [{ block: NoConditionBlock }])
);
await render(<template><BlockOutlet @name="hero-blocks" /></template>);
assert.dom(".no-condition").exists();
});
test("renders block when condition passes", async function (assert) {
@block("passing-condition-block")
class PassingConditionBlock extends Component {
<template>
<div class="passing-condition">Passes</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("homepage-blocks", [
{
block: PassingConditionBlock,
conditions: { type: "always-true" },
},
])
);
await render(<template><BlockOutlet @name="homepage-blocks" /></template>);
assert.dom(".passing-condition").exists();
});
test("hides block when condition fails", async function (assert) {
@block("failing-condition-block")
class FailingConditionBlock extends Component {
<template>
<div class="failing-condition">Fails</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("sidebar-blocks", [
{
block: FailingConditionBlock,
conditions: { type: "always-false" },
},
])
);
await render(<template><BlockOutlet @name="sidebar-blocks" /></template>);
assert.dom(".failing-condition").doesNotExist();
});
test("AND logic: hides if any condition fails", async function (assert) {
@block("and-logic-block")
class AndLogicBlock extends Component {
<template>
<div class="and-logic">AND Logic</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("main-outlet-blocks", [
{
block: AndLogicBlock,
conditions: [{ type: "always-true" }, { type: "always-false" }],
},
])
);
await render(
<template><BlockOutlet @name="main-outlet-blocks" /></template>
);
assert.dom(".and-logic").doesNotExist();
});
test("AND logic: renders when all conditions pass", async function (assert) {
@block("all-pass-block")
class AllPassBlock extends Component {
<template>
<div class="all-pass">All Pass</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("hero-blocks", [
{
block: AllPassBlock,
conditions: [{ type: "always-true" }, { type: "always-true" }],
},
])
);
await render(<template><BlockOutlet @name="hero-blocks" /></template>);
assert.dom(".all-pass").exists();
});
test("OR logic: renders when any condition passes", async function (assert) {
@block("or-logic-pass-block")
class OrLogicPassBlock extends Component {
<template>
<div class="or-logic-pass">OR Logic Pass</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("hero-blocks", [
{
block: OrLogicPassBlock,
conditions: {
any: [{ type: "always-false" }, { type: "always-true" }],
},
},
])
);
await render(<template><BlockOutlet @name="hero-blocks" /></template>);
assert.dom(".or-logic-pass").exists();
});
test("OR logic: hides when all conditions fail", async function (assert) {
@block("or-logic-fail-block")
class OrLogicFailBlock extends Component {
<template>
<div class="or-logic-fail">OR Logic Fail</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("homepage-blocks", [
{
block: OrLogicFailBlock,
conditions: {
any: [{ type: "always-false" }, { type: "always-false" }],
},
},
])
);
await render(<template><BlockOutlet @name="homepage-blocks" /></template>);
assert.dom(".or-logic-fail").doesNotExist();
});
test("NOT logic: inverts true to false", async function (assert) {
@block("not-true-block")
class NotTrueBlock extends Component {
<template>
<div class="not-true">NOT True</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("sidebar-blocks", [
{
block: NotTrueBlock,
conditions: { not: { type: "always-true" } },
},
])
);
await render(<template><BlockOutlet @name="sidebar-blocks" /></template>);
assert.dom(".not-true").doesNotExist();
});
test("NOT logic: inverts false to true", async function (assert) {
@block("not-false-block")
class NotFalseBlock extends Component {
<template>
<div class="not-false">NOT False</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("main-outlet-blocks", [
{
block: NotFalseBlock,
conditions: { not: { type: "always-false" } },
},
])
);
await render(
<template><BlockOutlet @name="main-outlet-blocks" /></template>
);
assert.dom(".not-false").exists();
});
test("filters nested children based on conditions", async function (assert) {
@block("nested-visible")
class NestedVisibleBlock extends Component {
<template>
<div class="nested-visible">Visible</div>
</template>
}
@block("nested-hidden")
class NestedHiddenBlock extends Component {
<template>
<div class="nested-hidden">Hidden</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("hero-blocks", [
{
block: BlockGroup,
children: [
{
block: NestedVisibleBlock,
conditions: { type: "always-true" },
},
{
block: NestedHiddenBlock,
conditions: { type: "always-false" },
},
],
},
])
);
await render(<template><BlockOutlet @name="hero-blocks" /></template>);
assert.dom(".nested-visible").exists();
assert.dom(".nested-hidden").doesNotExist();
});
test("multiple blocks with mixed conditions", async function (assert) {
@block("mixed-visible-1")
class MixedVisible1 extends Component {
<template>
<div class="mixed-visible-1">Visible 1</div>
</template>
}
@block("mixed-visible-2")
class MixedVisible2 extends Component {
<template>
<div class="mixed-visible-2">Visible 2</div>
</template>
}
@block("mixed-hidden")
class MixedHidden extends Component {
<template>
<div class="mixed-hidden">Hidden</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("hero-blocks", [
{ block: MixedVisible1, conditions: { type: "always-true" } },
{ block: MixedHidden, conditions: { type: "always-false" } },
{ block: MixedVisible2 },
])
);
await render(<template><BlockOutlet @name="hero-blocks" /></template>);
assert.dom(".mixed-visible-1").exists();
assert.dom(".mixed-visible-2").exists();
assert.dom(".mixed-hidden").doesNotExist();
});
test("complex nested conditions: NOT within OR renders when inner condition is false", async function (assert) {
@block("not-within-or-block")
class NotWithinOrBlock extends Component {
<template>
<div class="not-within-or">NOT within OR</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("sidebar-blocks", [
{
block: NotWithinOrBlock,
conditions: {
any: [{ type: "always-false" }, { not: { type: "always-false" } }],
},
},
])
);
await render(<template><BlockOutlet @name="sidebar-blocks" /></template>);
assert.dom(".not-within-or").exists();
});
test("complex nested conditions: OR within AND hides when AND fails", async function (assert) {
@block("or-within-and-block")
class OrWithinAndBlock extends Component {
<template>
<div class="or-within-and">OR within AND</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("main-outlet-blocks", [
{
block: OrWithinAndBlock,
conditions: [
{ any: [{ type: "always-true" }, { type: "always-false" }] },
{ type: "always-false" },
],
},
])
);
await render(
<template><BlockOutlet @name="main-outlet-blocks" /></template>
);
assert.dom(".or-within-and").doesNotExist();
});
test("complex nested conditions: OR within AND renders when all pass", async function (assert) {
@block("or-within-and-pass-block")
class OrWithinAndPassBlock extends Component {
<template>
<div class="or-within-and-pass">OR within AND Pass</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("hero-blocks", [
{
block: OrWithinAndPassBlock,
conditions: [
{ any: [{ type: "always-false" }, { type: "always-true" }] },
{ type: "always-true" },
],
},
])
);
await render(<template><BlockOutlet @name="hero-blocks" /></template>);
assert.dom(".or-within-and-pass").exists();
});
test("complex nested conditions: deeply nested NOT within OR within AND", async function (assert) {
@block("deep-nested-block")
class DeepNestedBlock extends Component {
<template>
<div class="deep-nested">Deep Nested</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("homepage-blocks", [
{
block: DeepNestedBlock,
conditions: [
{
any: [
{ not: { type: "always-true" } },
{ not: { type: "always-false" } },
],
},
{ type: "always-true" },
],
},
])
);
await render(<template><BlockOutlet @name="homepage-blocks" /></template>);
assert.dom(".deep-nested").exists();
});
test("container with all children failing conditions does not render", async function (assert) {
@block("child-hidden-1")
class ChildHidden1 extends Component {
<template>
<div class="child-hidden-1">Hidden 1</div>
</template>
}
@block("child-hidden-2")
class ChildHidden2 extends Component {
<template>
<div class="child-hidden-2">Hidden 2</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("hero-blocks", [
{
block: BlockGroup,
classNames: "admin-only-group",
children: [
{
block: ChildHidden1,
conditions: { type: "always-false" },
},
{
block: ChildHidden2,
conditions: { type: "always-false" },
},
],
},
])
);
await render(<template><BlockOutlet @name="hero-blocks" /></template>);
// Container should not render because no children are visible
assert.dom(".admin-only-group").doesNotExist();
assert.dom(".child-hidden-1").doesNotExist();
assert.dom(".child-hidden-2").doesNotExist();
});
test("container with at least one visible child renders", async function (assert) {
@block("child-visible-container")
class ChildVisibleContainer extends Component {
<template>
<div class="child-visible-container">Visible</div>
</template>
}
@block("child-hidden-container")
class ChildHiddenContainer extends Component {
<template>
<div class="child-hidden-container">Hidden</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("sidebar-blocks", [
{
block: BlockGroup,
classNames: "mixed-group",
children: [
{
block: ChildVisibleContainer,
conditions: { type: "always-true" },
},
{
block: ChildHiddenContainer,
conditions: { type: "always-false" },
},
],
},
])
);
await render(<template><BlockOutlet @name="sidebar-blocks" /></template>);
// Container should render because at least one child is visible
assert.dom(".mixed-group").exists();
assert.dom(".child-visible-container").exists();
assert.dom(".child-hidden-container").doesNotExist();
});
test("nested containers: inner container without visible children hides outer container", async function (assert) {
@block("deeply-hidden-child")
class DeeplyHiddenChild extends Component {
<template>
<div class="deeply-hidden-child">Hidden</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("main-outlet-blocks", [
{
block: BlockGroup,
classNames: "outer-group",
children: [
{
block: BlockGroup,
classNames: "inner-group",
children: [
{
block: DeeplyHiddenChild,
conditions: { type: "always-false" },
},
],
},
],
},
])
);
await render(
<template><BlockOutlet @name="main-outlet-blocks" /></template>
);
// Both containers should not render because the deepest child fails
assert.dom(".outer-group").doesNotExist();
assert.dom(".inner-group").doesNotExist();
assert.dom(".deeply-hidden-child").doesNotExist();
});
test("nested containers: outer renders when inner has visible children", async function (assert) {
@block("deeply-visible-child")
class DeeplyVisibleChild extends Component {
<template>
<div class="deeply-visible-child">Visible</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("hero-blocks", [
{
block: BlockGroup,
classNames: "outer-visible-group",
children: [
{
block: BlockGroup,
classNames: "inner-visible-group",
children: [
{
block: DeeplyVisibleChild,
conditions: { type: "always-true" },
},
],
},
],
},
])
);
await render(<template><BlockOutlet @name="hero-blocks" /></template>);
// Both containers should render because the deepest child is visible
assert.dom(".outer-visible-group").exists();
assert.dom(".inner-visible-group").exists();
assert.dom(".deeply-visible-child").exists();
});
test("container with own failing condition does not render even with visible children", async function (assert) {
@block("child-would-be-visible")
class ChildWouldBeVisible extends Component {
<template>
<div class="child-would-be-visible">Would be visible</div>
</template>
}
withPluginApi((api) =>
api.renderBlocks("homepage-blocks", [
{
block: BlockGroup,
classNames: "failing-container",
conditions: { type: "always-false" },
children: [
{
block: ChildWouldBeVisible,
conditions: { type: "always-true" },
},
],
},
])
);
await render(<template><BlockOutlet @name="homepage-blocks" /></template>);
// Container should not render because its own condition fails
assert.dom(".failing-container").doesNotExist();
assert.dom(".child-would-be-visible").doesNotExist();
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,203 @@
import { setupTest } from "ember-qunit";
import { module, test } from "qunit";
import {
VALID_CHILD_ARG_SCHEMA_PROPERTIES,
validateChildArgsSchema,
} from "discourse/lib/blocks/-internals/validation/block-args";
module("Unit | Blocks | arg-validation", function (hooks) {
setupTest(hooks);
module("validateChildArgsSchema", function () {
test("accepts valid childArgs schema with basic types", function (assert) {
const schema = {
name: { type: "string", required: true },
count: { type: "number" },
active: { type: "boolean" },
};
// Should not throw
validateChildArgsSchema(schema, "test-container");
assert.true(true, "valid schema accepted");
});
test("accepts childArgs schema with unique property", function (assert) {
const schema = {
name: { type: "string", required: true, unique: true },
itemId: { type: "number", unique: true },
};
// Should not throw
validateChildArgsSchema(schema, "test-container");
assert.true(true, "schema with unique property accepted");
});
test("throws for invalid unique value type", function (assert) {
const schema = {
name: { type: "string", unique: "yes" },
};
assert.throws(
() => validateChildArgsSchema(schema, "test-container"),
/invalid "unique" value\. Must be a boolean/,
"rejects non-boolean unique value"
);
});
test("throws for unique: true with array type", function (assert) {
const schema = {
items: { type: "array", unique: true },
};
assert.throws(
() => validateChildArgsSchema(schema, "test-container"),
/has "unique: true" but type is "array"/,
"rejects unique on array type"
);
});
test("allows unique: true with string type", function (assert) {
const schema = {
name: { type: "string", unique: true },
};
validateChildArgsSchema(schema, "test-container");
assert.true(true, "unique with string type accepted");
});
test("allows unique: true with number type", function (assert) {
const schema = {
itemId: { type: "number", unique: true },
};
validateChildArgsSchema(schema, "test-container");
assert.true(true, "unique with number type accepted");
});
test("allows unique: true with boolean type", function (assert) {
const schema = {
flag: { type: "boolean", unique: true },
};
validateChildArgsSchema(schema, "test-container");
assert.true(true, "unique with boolean type accepted");
});
test("throws for missing type property", function (assert) {
const schema = {
name: { required: true },
};
assert.throws(
() => validateChildArgsSchema(schema, "test-container"),
/missing required "type" property/,
"rejects schema without type"
);
});
test("throws for invalid type value", function (assert) {
const schema = {
name: { type: "invalid" },
};
assert.throws(
() => validateChildArgsSchema(schema, "test-container"),
/has invalid type/,
"rejects invalid type"
);
});
test("throws for invalid arg name format", function (assert) {
const schema = {
"123invalid": { type: "string" },
};
assert.throws(
() => validateChildArgsSchema(schema, "test-container"),
/arg name "123invalid" is invalid/,
"rejects invalid arg name"
);
});
test("throws for unknown properties", function (assert) {
const schema = {
name: { type: "string", unknownProp: true },
};
assert.throws(
() => validateChildArgsSchema(schema, "test-container"),
/has unknown properties/,
"rejects unknown properties"
);
});
test("throws for required + default combination", function (assert) {
const schema = {
name: { type: "string", required: true, default: "test" },
};
assert.throws(
() => validateChildArgsSchema(schema, "test-container"),
/has both "required: true" and "default"/,
"rejects required + default"
);
});
test("accepts schema with default value", function (assert) {
const schema = {
name: { type: "string", default: "default-name" },
};
validateChildArgsSchema(schema, "test-container");
assert.true(true, "schema with default accepted");
});
test("accepts schema with pattern constraint", function (assert) {
const schema = {
name: { type: "string", pattern: /^[a-z]+$/ },
};
validateChildArgsSchema(schema, "test-container");
assert.true(true, "schema with pattern accepted");
});
test("accepts schema with min/max constraints", function (assert) {
const schema = {
count: { type: "number", min: 0, max: 100 },
};
validateChildArgsSchema(schema, "test-container");
assert.true(true, "schema with min/max accepted");
});
test("accepts schema with enum constraint", function (assert) {
const schema = {
color: { type: "string", enum: ["red", "green", "blue"] },
};
validateChildArgsSchema(schema, "test-container");
assert.true(true, "schema with enum accepted");
});
test("accepts null or undefined schema", function (assert) {
validateChildArgsSchema(null, "test-container");
validateChildArgsSchema(undefined, "test-container");
assert.true(true, "null/undefined schema accepted");
});
test("VALID_CHILD_ARG_SCHEMA_PROPERTIES includes unique", function (assert) {
assert.true(
VALID_CHILD_ARG_SCHEMA_PROPERTIES.includes("unique"),
"unique is a valid child arg property"
);
assert.true(
VALID_CHILD_ARG_SCHEMA_PROPERTIES.includes("type"),
"type is a valid child arg property"
);
assert.true(
VALID_CHILD_ARG_SCHEMA_PROPERTIES.includes("required"),
"required is a valid child arg property"
);
});
});
});
@@ -0,0 +1,293 @@
import { getOwner } from "@ember/owner";
import { setupTest } from "ember-qunit";
import { module, test } from "qunit";
import { BlockCondition } from "discourse/blocks/conditions";
import { validateConditionSource } from "discourse/lib/blocks/-internals/validation/conditions";
module("Unit | Blocks | Conditions | condition", function (hooks) {
setupTest(hooks);
hooks.beforeEach(function () {
this.blocks = getOwner(this).lookup("service:blocks");
});
module("validateConditionSource", function () {
test("allows source to be undefined for all sourceTypes", function (assert) {
assert.strictEqual(validateConditionSource("none", {}), null);
assert.strictEqual(validateConditionSource("outletArgs", {}), null);
assert.strictEqual(validateConditionSource("object", {}), null);
});
test("returns error when source is provided for sourceType 'none'", function (assert) {
const error = validateConditionSource("none", {
source: "@outletArgs.foo",
});
assert.true(error?.message.includes("source"));
assert.true(error?.message.includes("not supported"));
assert.strictEqual(error.path, "source");
});
test("validates source format for sourceType 'outletArgs'", function (assert) {
// Valid formats should return null
assert.strictEqual(
validateConditionSource("outletArgs", { source: "@outletArgs.foo" }),
null
);
assert.strictEqual(
validateConditionSource("outletArgs", {
source: "@outletArgs.nested.path",
}),
null
);
assert.strictEqual(
validateConditionSource("outletArgs", {
source: "@outletArgs.deep.nested.value",
}),
null
);
// Invalid formats should return errors with path
let error = validateConditionSource("outletArgs", { source: "foo" });
assert.true(
error?.message.includes('must be in format "@outletArgs.propertyName"')
);
assert.strictEqual(error.path, "source");
error = validateConditionSource("outletArgs", {
source: "outletArgs.foo",
});
assert.true(
error?.message.includes('must be in format "@outletArgs.propertyName"')
);
error = validateConditionSource("outletArgs", { source: "@outletArgs" });
assert.true(
error?.message.includes('must be in format "@outletArgs.propertyName"')
);
error = validateConditionSource("outletArgs", { source: 123 });
assert.true(error?.message.includes("must be a string"));
});
test("validates source is object for sourceType 'object'", function (assert) {
// Valid objects should return null
assert.strictEqual(
validateConditionSource("object", { source: { key: "value" } }),
null
);
assert.strictEqual(
validateConditionSource("object", { source: {} }),
null
);
assert.strictEqual(
validateConditionSource("object", { source: null }),
null
);
// Invalid types should return errors
let error = validateConditionSource("object", { source: "string" });
assert.true(error?.message.includes("must be an object"));
assert.strictEqual(error.path, "source");
error = validateConditionSource("object", { source: 123 });
assert.true(error?.message.includes("must be an object"));
error = validateConditionSource("object", { source: true });
assert.true(error?.message.includes("must be an object"));
});
});
module("resolveSource", function () {
test("returns undefined when source is not provided", function (assert) {
class TestCondition extends BlockCondition {
static type = "resolve-no-source-test";
static sourceType = "outletArgs";
evaluate() {
return true;
}
}
const condition = new TestCondition();
assert.strictEqual(condition.resolveSource({}, {}), undefined);
});
test("returns source directly for sourceType 'object'", function (assert) {
class ObjectCondition extends BlockCondition {
static type = "resolve-object-test";
static sourceType = "object";
evaluate() {
return true;
}
}
const condition = new ObjectCondition();
const sourceObj = { key: "value", nested: { deep: true } };
assert.strictEqual(
condition.resolveSource({ source: sourceObj }, {}),
sourceObj
);
});
test("resolves value from outlet args for sourceType 'outletArgs'", function (assert) {
class OutletArgsCondition extends BlockCondition {
static type = "resolve-outlet-args-test";
static sourceType = "outletArgs";
evaluate() {
return true;
}
}
const condition = new OutletArgsCondition();
const context = {
outletArgs: {
topic: {
id: 123,
title: "Test Topic",
},
user: {
admin: true,
},
},
};
// Simple path
assert.deepEqual(
condition.resolveSource({ source: "@outletArgs.topic" }, context),
{ id: 123, title: "Test Topic" }
);
// Nested path
assert.strictEqual(
condition.resolveSource({ source: "@outletArgs.topic.id" }, context),
123
);
assert.true(
condition.resolveSource({ source: "@outletArgs.user.admin" }, context)
);
});
test("returns undefined for missing paths in outlet args", function (assert) {
class OutletArgsCondition extends BlockCondition {
static type = "resolve-missing-path-test";
static sourceType = "outletArgs";
evaluate() {
return true;
}
}
const condition = new OutletArgsCondition();
const context = {
outletArgs: {
topic: { id: 123 },
},
};
// Non-existent property
assert.strictEqual(
condition.resolveSource({ source: "@outletArgs.nonexistent" }, context),
undefined
);
// Non-existent nested path
assert.strictEqual(
condition.resolveSource(
{ source: "@outletArgs.topic.missing.deep" },
context
),
undefined
);
// Missing outlet args entirely
assert.strictEqual(
condition.resolveSource({ source: "@outletArgs.topic" }, {}),
undefined
);
});
test("returns undefined for sourceType 'none'", function (assert) {
class NoSourceCondition extends BlockCondition {
static type = "resolve-none-test";
static sourceType = "none";
evaluate() {
return true;
}
}
const condition = new NoSourceCondition();
assert.strictEqual(
condition.resolveSource(
{ source: "@outletArgs.foo" },
{ outletArgs: { foo: "bar" } }
),
undefined
);
});
});
module("evaluate", function () {
test("throws when not implemented in subclass", function (assert) {
class UnimplementedCondition extends BlockCondition {
static type = "unimplemented-test";
}
const condition = new UnimplementedCondition();
assert.throws(
() => condition.evaluate({}, {}),
/must implement evaluate/
);
});
});
module("getResolvedValueForLogging", function () {
test("returns resolved source value when source is provided", function (assert) {
class SourceCondition extends BlockCondition {
static type = "logging-source-test";
static sourceType = "outletArgs";
evaluate() {
return true;
}
}
const condition = new SourceCondition();
const context = {
outletArgs: {
topic: { id: 123 },
},
};
const result = condition.getResolvedValueForLogging(
{ source: "@outletArgs.topic.id" },
context
);
assert.deepEqual(result, { value: 123, hasValue: true });
});
test("returns undefined when source is not provided", function (assert) {
class NoSourceLoggingCondition extends BlockCondition {
static type = "logging-no-source-test";
static sourceType = "outletArgs";
evaluate() {
return true;
}
}
const condition = new NoSourceLoggingCondition();
const result = condition.getResolvedValueForLogging({}, {});
assert.strictEqual(result, undefined);
});
});
});
@@ -0,0 +1,627 @@
import { module, test } from "qunit";
import { BlockCondition, blockCondition } from "discourse/blocks/conditions";
module("Unit | Blocks | Conditions | decorator", function () {
module("config validation", function () {
test("throws for missing type", function (assert) {
assert.throws(
() =>
blockCondition({
args: {},
}),
/`type` is required and must be a string/
);
});
test("throws for non-string type", function (assert) {
assert.throws(
() =>
blockCondition({
type: 123,
args: {},
}),
/`type` is required and must be a string/
);
});
test("defaults to empty args object when not provided", function (assert) {
@blockCondition({
type: "test-no-args",
})
class TestCondition extends BlockCondition {
evaluate() {
return true;
}
}
assert.deepEqual(TestCondition.argsSchema, {});
assert.deepEqual(TestCondition.validArgKeys, []);
});
});
module("sourceType validation", function () {
test("accepts valid sourceType values", function (assert) {
const validSourceTypes = ["none", "outletArgs", "object"];
validSourceTypes.forEach((sourceType, index) => {
const decorator = blockCondition({
type: `test-source-type-${index}`,
sourceType,
args: {},
});
@decorator
class TestCondition extends BlockCondition {
evaluate() {
return true;
}
}
assert.strictEqual(
TestCondition.sourceType,
sourceType,
`sourceType "${sourceType}" should be accepted`
);
});
});
test("throws for invalid sourceType with suggestion", function (assert) {
assert.throws(
() =>
blockCondition({
type: "test",
sourceType: "outletarg",
args: {},
}),
/Invalid `sourceType`.*"outletarg".*did you mean.*"outletArgs"/
);
});
test("throws for completely invalid sourceType", function (assert) {
assert.throws(
() =>
blockCondition({
type: "test",
sourceType: "invalid",
args: {},
}),
/Invalid `sourceType`.*Valid values are: none, outletArgs, object/
);
});
test("defaults to 'none' when sourceType is not provided", function (assert) {
@blockCondition({
type: "test-default-source",
args: {},
})
class TestCondition extends BlockCondition {
evaluate() {
return true;
}
}
assert.strictEqual(TestCondition.sourceType, "none");
});
});
module("unknown config keys validation", function () {
test("throws for unknown config key with suggestion", function (assert) {
assert.throws(
() =>
blockCondition({
type: "test",
args: {},
sourceType: "none",
arg: {},
}),
/unknown config key.*"arg".*did you mean.*"args"/
);
});
test("throws for multiple unknown config keys", function (assert) {
assert.throws(
() =>
blockCondition({
type: "test",
args: {},
sourceTyp: "none",
typo: true,
}),
/unknown config key.*"sourceTyp".*"typo"/
);
});
test("throws for typo in sourceType key", function (assert) {
assert.throws(
() =>
blockCondition({
type: "test",
args: {},
sourcetype: "outletArgs",
}),
/unknown config key.*"sourcetype".*did you mean.*"sourceType"/
);
});
test("accepts only valid config keys", function (assert) {
@blockCondition({
type: "test-valid-keys",
sourceType: "outletArgs",
args: {
foo: { type: "string" },
bar: { type: "number" },
},
})
class TestCondition extends BlockCondition {
evaluate() {
return true;
}
}
assert.strictEqual(TestCondition.type, "test-valid-keys");
assert.strictEqual(TestCondition.sourceType, "outletArgs");
assert.deepEqual(TestCondition.validArgKeys, ["foo", "bar", "source"]);
});
});
module("args schema validation", function () {
test("validates arg type is valid", function (assert) {
assert.throws(
() =>
blockCondition({
type: "test",
args: {
myArg: { type: "invalid" },
},
}),
/arg "myArg" has invalid type/
);
});
test("validates arg name format", function (assert) {
assert.throws(
() =>
blockCondition({
type: "test",
args: {
"invalid-name": { type: "string" },
},
}),
/arg name "invalid-name" is invalid/
);
});
test("rejects default property for conditions", function (assert) {
assert.throws(
() =>
blockCondition({
type: "test",
args: {
myArg: { type: "string", default: "value" },
},
}),
/disallowed property "default"/
);
});
test("allows type: 'any' for accepting any value type", function (assert) {
@blockCondition({
type: "test-any-type",
args: {
anyValue: { type: "any" },
},
})
class TestCondition extends BlockCondition {
evaluate() {
return true;
}
}
assert.deepEqual(TestCondition.argsSchema, { anyValue: { type: "any" } });
});
test("validates enum values match declared type", function (assert) {
assert.throws(
() =>
blockCondition({
type: "test",
args: {
myArg: { type: "string", enum: [1, 2, 3] },
},
}),
/enum contains invalid value/
);
});
test("validates min/max/integer only for number type", function (assert) {
assert.throws(
() =>
blockCondition({
type: "test",
args: {
myArg: { type: "string", min: 0 },
},
}),
/"min" is only valid for number type/
);
});
test("validates minLength/maxLength for string and array types", function (assert) {
assert.throws(
() =>
blockCondition({
type: "test",
args: {
myArg: { type: "number", minLength: 0 },
},
}),
/"minLength" is only valid for string or array/
);
});
});
module("constraints validation", function () {
test("validates constraint types are known", function (assert) {
assert.throws(
() =>
blockCondition({
type: "test",
args: {
a: { type: "string" },
b: { type: "string" },
},
constraints: {
unknownConstraint: ["a", "b"],
},
}),
/unknown constraint type.*"unknownConstraint"/i
);
});
test("validates constraint args exist in schema", function (assert) {
assert.throws(
() =>
blockCondition({
type: "test",
args: {
a: { type: "string" },
},
constraints: {
atLeastOne: ["a", "nonexistent"],
},
}),
/references unknown arg.*"nonexistent"/
);
});
test("accepts valid constraints", function (assert) {
@blockCondition({
type: "test-constraints",
args: {
a: { type: "string" },
b: { type: "string" },
},
constraints: {
atLeastOne: ["a", "b"],
},
})
class TestCondition extends BlockCondition {
evaluate() {
return true;
}
}
assert.deepEqual(TestCondition.constraints, { atLeastOne: ["a", "b"] });
});
test("accepts atMostOne constraint", function (assert) {
@blockCondition({
type: "test-at-most-one",
args: {
optionA: { type: "string" },
optionB: { type: "string" },
optionC: { type: "string" },
},
constraints: {
atMostOne: ["optionA", "optionB", "optionC"],
},
})
class TestCondition extends BlockCondition {
evaluate() {
return true;
}
}
assert.deepEqual(TestCondition.constraints, {
atMostOne: ["optionA", "optionB", "optionC"],
});
});
});
module("validate function", function () {
test("throws when validate is not a function", function (assert) {
assert.throws(
() =>
blockCondition({
type: "test",
args: {},
validate: "not a function",
}),
/"validate" must be a function/
);
});
test("accepts validate function", function (assert) {
const validateFn = () => null;
@blockCondition({
type: "test-validate-fn",
args: {
foo: { type: "string" },
},
validate: validateFn,
})
class TestCondition extends BlockCondition {
evaluate() {
return true;
}
}
assert.strictEqual(TestCondition.validateFn, validateFn);
});
});
module("class validation", function () {
test("throws when class does not extend BlockCondition", function (assert) {
class NotACondition {}
assert.throws(() => {
blockCondition({
type: "invalid-class",
args: {},
})(NotACondition);
}, /NotACondition must extend BlockCondition/);
});
test("accepts class that extends BlockCondition", function (assert) {
@blockCondition({
type: "valid-class",
args: {},
})
class ValidCondition extends BlockCondition {
evaluate() {
return true;
}
}
assert.strictEqual(ValidCondition.type, "valid-class");
});
});
module("static property assignment", function () {
test("assigns type as static getter", function (assert) {
@blockCondition({
type: "static-type-test",
args: {},
})
class TestCondition extends BlockCondition {
evaluate() {
return true;
}
}
assert.strictEqual(TestCondition.type, "static-type-test");
});
test("assigns sourceType as static getter", function (assert) {
@blockCondition({
type: "static-source-test",
sourceType: "object",
args: {},
})
class TestCondition extends BlockCondition {
evaluate() {
return true;
}
}
assert.strictEqual(TestCondition.sourceType, "object");
});
test("assigns argsSchema as static getter", function (assert) {
const schema = {
foo: { type: "string", required: true },
bar: { type: "number", min: 0 },
};
@blockCondition({
type: "static-schema-test",
args: schema,
})
class TestCondition extends BlockCondition {
evaluate() {
return true;
}
}
assert.deepEqual(TestCondition.argsSchema, schema);
});
test("derives validArgKeys from args schema", function (assert) {
@blockCondition({
type: "derived-keys-test",
sourceType: "outletArgs",
args: {
foo: { type: "string" },
bar: { type: "number" },
},
})
class TestCondition extends BlockCondition {
evaluate() {
return true;
}
}
assert.deepEqual(TestCondition.validArgKeys, ["foo", "bar", "source"]);
});
test("does not add source to validArgKeys when sourceType is none", function (assert) {
@blockCondition({
type: "no-source-key-test",
sourceType: "none",
args: {
foo: { type: "string" },
},
})
class TestCondition extends BlockCondition {
evaluate() {
return true;
}
}
assert.deepEqual(TestCondition.validArgKeys, ["foo"]);
});
test("freezes validArgKeys array", function (assert) {
@blockCondition({
type: "frozen-keys-test",
args: {
foo: { type: "string" },
},
})
class TestCondition extends BlockCondition {
evaluate() {
return true;
}
}
assert.true(Object.isFrozen(TestCondition.validArgKeys));
});
test("freezes argsSchema object", function (assert) {
@blockCondition({
type: "frozen-schema-test",
args: {
foo: { type: "string" },
},
})
class TestCondition extends BlockCondition {
evaluate() {
return true;
}
}
assert.true(Object.isFrozen(TestCondition.argsSchema));
});
});
module("type namespace validation", function () {
test("accepts valid core type (simple name)", function (assert) {
@blockCondition({
type: "simple-type",
args: {},
})
class TestCondition extends BlockCondition {
evaluate() {
return true;
}
}
assert.strictEqual(TestCondition.type, "simple-type");
assert.strictEqual(TestCondition.namespace, null);
assert.strictEqual(TestCondition.namespaceType, "core");
});
test("accepts valid plugin type (namespace:name)", function (assert) {
@blockCondition({
type: "chat:unread-messages",
args: {},
})
class TestCondition extends BlockCondition {
evaluate() {
return true;
}
}
assert.strictEqual(TestCondition.type, "chat:unread-messages");
assert.strictEqual(TestCondition.namespace, "chat");
assert.strictEqual(TestCondition.namespaceType, "plugin");
});
test("accepts valid theme type (theme:namespace:name)", function (assert) {
@blockCondition({
type: "theme:tactile:dark-mode",
args: {},
})
class TestCondition extends BlockCondition {
evaluate() {
return true;
}
}
assert.strictEqual(TestCondition.type, "theme:tactile:dark-mode");
assert.strictEqual(TestCondition.namespace, "theme:tactile");
assert.strictEqual(TestCondition.namespaceType, "theme");
});
test("throws for uppercase in type", function (assert) {
assert.throws(
() =>
blockCondition({
type: "InvalidType",
args: {},
}),
/type "InvalidType" is invalid/
);
});
test("throws for underscores in type", function (assert) {
assert.throws(
() =>
blockCondition({
type: "invalid_type",
args: {},
}),
/type "invalid_type" is invalid/
);
});
test("throws for type exceeding max length", function (assert) {
const longType = "a".repeat(101);
assert.throws(
() =>
blockCondition({
type: longType,
args: {},
}),
/exceeds maximum length/
);
});
test("throws for invalid theme format (theme:name without namespace)", function (assert) {
assert.throws(
() =>
blockCondition({
type: "theme:my-type",
args: {},
}),
/type "theme:my-type" is invalid/
);
});
test("allows type with numbers", function (assert) {
@blockCondition({
type: "type-123",
args: {},
})
class TestCondition extends BlockCondition {
evaluate() {
return true;
}
}
assert.strictEqual(TestCondition.type, "type-123");
});
});
});
@@ -0,0 +1,301 @@
import { getOwner, setOwner } from "@ember/owner";
import { setupTest } from "ember-qunit";
import { module, test } from "qunit";
import BlockOutletArgCondition from "discourse/blocks/conditions/outlet-arg";
import { validateConditions } from "discourse/tests/helpers/block-testing";
module("Unit | Blocks | Condition | outlet-arg", function (hooks) {
setupTest(hooks);
hooks.beforeEach(function () {
this.condition = new BlockOutletArgCondition();
setOwner(this.condition, getOwner(this));
// Helper to validate via infrastructure
this.validateCondition = (args) => {
const conditionTypes = new Map([["outlet-arg", this.condition]]);
try {
validateConditions({ type: "outlet-arg", ...args }, conditionTypes);
return null;
} catch (error) {
return error;
}
};
});
module("validate (through infrastructure)", function () {
test("returns error when path is missing", function (assert) {
const error = this.validateCondition({});
assert.true(error?.message.includes("missing required arg"));
});
test("returns error when path is not a string (schema type validation)", function (assert) {
const error = this.validateCondition({ path: 123 });
assert.true(error?.message.includes("must be a string"));
});
test("returns error when path contains invalid characters (custom validation)", function (assert) {
const error = this.validateCondition({ path: "user-name", value: true });
assert.true(error?.message.includes("is invalid"));
});
test("returns error when both value and exists are specified (exactlyOne constraint)", function (assert) {
const error = this.validateCondition({
path: "user",
value: true,
exists: true,
});
assert.true(error?.message.includes("exactly one of"));
});
test("returns error when neither value nor exists is specified (exactlyOne constraint)", function (assert) {
const error = this.validateCondition({
path: "user",
});
assert.true(error?.message.includes("exactly one of"));
});
test("accepts valid path with value", function (assert) {
assert.strictEqual(
this.validateCondition({ path: "user.admin", value: true }),
null
);
});
test("accepts valid path with exists", function (assert) {
assert.strictEqual(
this.validateCondition({ path: "topic", exists: true }),
null
);
});
test("accepts dot-notation paths", function (assert) {
assert.strictEqual(
this.validateCondition({ path: "user.trust_level", value: 2 }),
null
);
});
test("returns error when exists is not a boolean (schema type validation)", function (assert) {
const error = this.validateCondition({ path: "user", exists: "true" });
assert.true(error?.message.includes("must be a boolean"));
});
});
module("evaluate", function () {
test("returns true when property matches value", function (assert) {
const context = { outletArgs: { user: { admin: true } } };
const result = this.condition.evaluate(
{ path: "user.admin", value: true },
context
);
assert.true(result);
});
test("returns false when property does not match value", function (assert) {
const context = { outletArgs: { user: { admin: false } } };
const result = this.condition.evaluate(
{ path: "user.admin", value: true },
context
);
assert.false(result);
});
test("returns true when value matches exactly", function (assert) {
const context = { outletArgs: { topic: { closed: true } } };
const result = this.condition.evaluate(
{ path: "topic.closed", value: true },
context
);
assert.true(result);
});
test("returns false when value does not match exactly", function (assert) {
const context = { outletArgs: { topic: { closed: false } } };
const result = this.condition.evaluate(
{ path: "topic.closed", value: true },
context
);
assert.false(result);
});
test("supports array value matching (OR logic)", function (assert) {
const context = { outletArgs: { user: { trust_level: 2 } } };
const result = this.condition.evaluate(
{ path: "user.trust_level", value: [2, 3, 4] },
context
);
assert.true(result);
});
test("returns false when value not in array", function (assert) {
const context = { outletArgs: { user: { trust_level: 1 } } };
const result = this.condition.evaluate(
{ path: "user.trust_level", value: [2, 3, 4] },
context
);
assert.false(result);
});
test("supports negation with { not: value }", function (assert) {
const context = { outletArgs: { topic: { closed: false } } };
const result = this.condition.evaluate(
{ path: "topic.closed", value: { not: true } },
context
);
assert.true(result);
});
test("exists: true passes when property exists", function (assert) {
const context = { outletArgs: { topic: { title: "Hello" } } };
const result = this.condition.evaluate(
{ path: "topic.title", exists: true },
context
);
assert.true(result);
});
test("exists: true fails when property is undefined", function (assert) {
const context = { outletArgs: { topic: {} } };
const result = this.condition.evaluate(
{ path: "topic.title", exists: true },
context
);
assert.false(result);
});
test("exists: false passes when property is undefined", function (assert) {
const context = { outletArgs: { topic: {} } };
const result = this.condition.evaluate(
{ path: "topic.title", exists: false },
context
);
assert.true(result);
});
test("exists: false fails when property exists", function (assert) {
const context = { outletArgs: { topic: { title: "Hello" } } };
const result = this.condition.evaluate(
{ path: "topic.title", exists: false },
context
);
assert.false(result);
});
test("handles missing outletArgs gracefully", function (assert) {
const result = this.condition.evaluate(
{ path: "user.admin", value: true },
{}
);
assert.false(result);
});
test("handles null outletArgs gracefully", function (assert) {
const result = this.condition.evaluate(
{ path: "user.admin", value: true },
{ outletArgs: null }
);
assert.false(result);
});
test("handles deeply nested paths", function (assert) {
const context = {
outletArgs: { topic: { category: { parent: { id: 5 } } } },
};
const result = this.condition.evaluate(
{ path: "topic.category.parent.id", value: 5 },
context
);
assert.true(result);
});
test("returns false for non-existent path", function (assert) {
const context = { outletArgs: { topic: {} } };
const result = this.condition.evaluate(
{ path: "topic.category.id", value: 5 },
context
);
assert.false(result);
});
module("nested path error handling", function () {
test("handles null intermediate value in nested path", function (assert) {
const context = { outletArgs: { topic: { category: null } } };
const result = this.condition.evaluate(
{ path: "topic.category.id", value: 5 },
context
);
assert.false(result);
});
test("handles undefined intermediate value in nested path", function (assert) {
const context = { outletArgs: { topic: { category: undefined } } };
const result = this.condition.evaluate(
{ path: "topic.category.parent.id", value: 5 },
context
);
assert.false(result);
});
test("handles missing root property in nested path", function (assert) {
const context = { outletArgs: {} };
const result = this.condition.evaluate(
{ path: "topic.category.parent.id", value: 5 },
context
);
assert.false(result);
});
test("handles deeply nested path with null at various levels", function (assert) {
// null at first level
let context = { outletArgs: { a: null } };
assert.false(
this.condition.evaluate({ path: "a.b.c.d", value: 1 }, context),
"null at first level"
);
// null at second level
context = { outletArgs: { a: { b: null } } };
assert.false(
this.condition.evaluate({ path: "a.b.c.d", value: 1 }, context),
"null at second level"
);
// null at third level
context = { outletArgs: { a: { b: { c: null } } } };
assert.false(
this.condition.evaluate({ path: "a.b.c.d", value: 1 }, context),
"null at third level"
);
});
test("handles exists: true with null intermediate value", function (assert) {
const context = { outletArgs: { topic: { category: null } } };
const result = this.condition.evaluate(
{ path: "topic.category.id", exists: true },
context
);
assert.false(result);
});
test("handles exists: false with null intermediate value", function (assert) {
const context = { outletArgs: { topic: { category: null } } };
const result = this.condition.evaluate(
{ path: "topic.category.id", exists: false },
context
);
assert.true(result);
});
test("handles value check with missing nested path", function (assert) {
const context = { outletArgs: { topic: null } };
const result = this.condition.evaluate(
{ path: "topic.closed", value: true },
context
);
assert.false(result);
});
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,660 @@
import { getOwner, setOwner } from "@ember/owner";
import Service from "@ember/service";
import { setupTest } from "ember-qunit";
import { module, test } from "qunit";
import BlockSettingCondition from "discourse/blocks/conditions/setting";
import { validateConditions } from "discourse/tests/helpers/block-testing";
module("Unit | Blocks | Conditions | setting", function (hooks) {
setupTest(hooks);
hooks.beforeEach(function () {
const testOwner = getOwner(this);
// Mock site settings
this.mockSiteSettings = {
enable_badges: true,
enable_whispers: false,
desktop_category_page_style: "categories_and_latest_topics",
top_menu: "latest|new|unread|categories",
share_links: "twitter|facebook|email",
};
// Create mock site settings service
const mockSiteSettings = this.mockSiteSettings;
class MockSiteSettings extends Service {
get enable_badges() {
return mockSiteSettings.enable_badges;
}
get enable_whispers() {
return mockSiteSettings.enable_whispers;
}
get desktop_category_page_style() {
return mockSiteSettings.desktop_category_page_style;
}
get top_menu() {
return mockSiteSettings.top_menu;
}
get share_links() {
return mockSiteSettings.share_links;
}
}
testOwner.unregister("service:site-settings");
testOwner.register("service:site-settings", MockSiteSettings);
// Store owner for creating condition instances
this.testOwner = testOwner;
// Helper to evaluate setting condition directly
this.evaluateCondition = (args) => {
const condition = new BlockSettingCondition();
setOwner(condition, testOwner);
return condition.evaluate(args);
};
// Helper to validate via infrastructure
this.validateCondition = (args) => {
const condition = new BlockSettingCondition();
setOwner(condition, testOwner);
// Create a map with the condition instance
const conditionTypes = new Map([["setting", condition]]);
try {
validateConditions({ type: "setting", ...args }, conditionTypes);
return null;
} catch (error) {
return error;
}
};
});
module("with site settings (existing behavior)", function () {
test("enabled: true passes when setting is truthy", function (assert) {
assert.true(
this.evaluateCondition({
name: "enable_badges",
enabled: true,
})
);
});
test("enabled: true fails when setting is falsy", function (assert) {
assert.false(
this.evaluateCondition({
name: "enable_whispers",
enabled: true,
})
);
});
test("enabled: false passes when setting is falsy", function (assert) {
assert.true(
this.evaluateCondition({
name: "enable_whispers",
enabled: false,
})
);
});
test("equals matches exact value", function (assert) {
assert.true(
this.evaluateCondition({
name: "desktop_category_page_style",
equals: "categories_and_latest_topics",
})
);
assert.false(
this.evaluateCondition({
name: "desktop_category_page_style",
equals: "categories_only",
})
);
});
test("includes matches if setting is in array", function (assert) {
assert.true(
this.evaluateCondition({
name: "desktop_category_page_style",
includes: [
"categories_and_latest_topics",
"categories_and_top_topics",
],
})
);
assert.false(
this.evaluateCondition({
name: "desktop_category_page_style",
includes: ["categories_only", "categories_boxes"],
})
);
});
test("contains matches if list setting contains value", function (assert) {
assert.true(
this.evaluateCondition({
name: "top_menu",
contains: "latest",
})
);
assert.false(
this.evaluateCondition({
name: "top_menu",
contains: "hot",
})
);
});
test("containsAny matches if list setting contains any value", function (assert) {
assert.true(
this.evaluateCondition({
name: "share_links",
containsAny: ["twitter", "linkedin"],
})
);
assert.false(
this.evaluateCondition({
name: "share_links",
containsAny: ["linkedin", "reddit"],
})
);
});
});
module("with explicit source object (theme settings)", function () {
test("enabled: true passes when custom setting is truthy", function (assert) {
const themeSettings = {
show_sidebar: true,
enable_animations: false,
};
assert.true(
this.evaluateCondition({
source: themeSettings,
name: "show_sidebar",
enabled: true,
})
);
assert.false(
this.evaluateCondition({
source: themeSettings,
name: "enable_animations",
enabled: true,
})
);
});
test("enabled: false passes when custom setting is falsy", function (assert) {
const themeSettings = {
show_sidebar: true,
enable_animations: false,
};
assert.true(
this.evaluateCondition({
source: themeSettings,
name: "enable_animations",
enabled: false,
})
);
assert.false(
this.evaluateCondition({
source: themeSettings,
name: "show_sidebar",
enabled: false,
})
);
});
test("equals matches exact value in custom settings", function (assert) {
const themeSettings = {
theme_color: "dark",
layout_style: "compact",
};
assert.true(
this.evaluateCondition({
source: themeSettings,
name: "theme_color",
equals: "dark",
})
);
assert.false(
this.evaluateCondition({
source: themeSettings,
name: "theme_color",
equals: "light",
})
);
});
test("includes matches if custom setting is in array", function (assert) {
const themeSettings = {
icon_style: "outline",
};
assert.true(
this.evaluateCondition({
source: themeSettings,
name: "icon_style",
includes: ["outline", "filled"],
})
);
assert.false(
this.evaluateCondition({
source: themeSettings,
name: "icon_style",
includes: ["filled", "duotone"],
})
);
});
test("contains matches if custom list setting contains value", function (assert) {
const themeSettings = {
enabled_features: "sidebar|dark-mode|animations",
};
assert.true(
this.evaluateCondition({
source: themeSettings,
name: "enabled_features",
contains: "dark-mode",
})
);
assert.false(
this.evaluateCondition({
source: themeSettings,
name: "enabled_features",
contains: "tooltips",
})
);
});
test("containsAny matches if custom list setting contains any value", function (assert) {
const themeSettings = {
enabled_modules: "header|footer|sidebar",
};
assert.true(
this.evaluateCondition({
source: themeSettings,
name: "enabled_modules",
containsAny: ["header", "navigation"],
})
);
assert.false(
this.evaluateCondition({
source: themeSettings,
name: "enabled_modules",
containsAny: ["navigation", "search"],
})
);
});
test("handles missing setting key in custom settings", function (assert) {
const themeSettings = {
existing_key: true,
};
assert.false(
this.evaluateCondition({
source: themeSettings,
name: "missing_key",
enabled: true,
})
);
});
test("handles null source object gracefully", function (assert) {
assert.false(
this.evaluateCondition({
source: null,
name: "any_setting",
enabled: true,
}),
"enabled: true returns false with null source"
);
assert.false(
this.evaluateCondition({
source: null,
name: "any_setting",
enabled: false,
}),
"enabled: false returns false with null source (source is invalid)"
);
});
test("handles undefined source value gracefully", function (assert) {
const themeSettings = {};
assert.false(
this.evaluateCondition({
source: themeSettings,
name: "undefined_setting",
enabled: true,
}),
"undefined setting returns false (setting doesn't exist)"
);
assert.false(
this.evaluateCondition({
source: themeSettings,
name: "undefined_setting",
enabled: false,
}),
"enabled: false returns false for non-existent setting"
);
});
test("evaluates truthy using enabled: true", function (assert) {
const themeSettings = {
some_setting: "has-value",
empty_setting: "",
};
assert.true(
this.evaluateCondition({
source: themeSettings,
name: "some_setting",
enabled: true,
})
);
assert.false(
this.evaluateCondition({
source: themeSettings,
name: "empty_setting",
enabled: true,
})
);
});
});
module("validate (through infrastructure)", function () {
test("returns error when name argument is missing", function (assert) {
const error = this.validateCondition({});
assert.true(error?.message.includes("missing required arg"));
});
test("typo in required arg produces unknown arg error with suggestion, not missing required error", function (assert) {
// Typo: "nam" instead of "name"
const error = this.validateCondition({ nam: "enable_badges" });
// Should say "unknown" not "missing required"
assert.true(
error?.message.includes("unknown arg"),
"error should mention unknown arg"
);
assert.false(
error?.message.includes("missing required"),
"error should NOT mention missing required"
);
assert.true(
error?.message.includes('did you mean "name"'),
"error should suggest the correct arg name"
);
});
test("returns error when multiple condition types are provided (exactlyOne constraint)", function (assert) {
const error = this.validateCondition({
name: "enable_badges",
enabled: true,
equals: "some-value",
});
assert.true(error?.message.includes("exactly one of"));
});
test("returns error when enabled and includes are both provided", function (assert) {
const error = this.validateCondition({
name: "enable_badges",
enabled: true,
includes: ["value1", "value2"],
});
assert.true(error?.message.includes("exactly one of"));
});
test("returns error when no condition type is provided (exactlyOne constraint)", function (assert) {
const error = this.validateCondition({
name: "enable_badges",
});
assert.true(error?.message.includes("exactly one of"));
});
test("constraint error path points to condition type for better error location", function (assert) {
const error = this.validateCondition({
name: "enable_badges",
// Missing required arg: enabled, equals, includes, contains, or containsAny
});
// The error path should include "type" so the error location indicator
// points to the condition (identified by its type) rather than the block
assert.strictEqual(
error?.path,
"type",
"constraint error path should point to the condition's type property"
);
});
test("constraint error path includes array index when condition is in an array", function (assert) {
const condition = new BlockSettingCondition();
setOwner(condition, this.testOwner);
const conditionTypes = new Map([["setting", condition]]);
let error;
try {
// Array of conditions - the second one has a constraint error
validateConditions(
[
{ type: "setting", name: "enable_badges", enabled: true },
{ type: "setting", name: "enable_whispers" }, // Missing condition type arg
],
conditionTypes
);
} catch (e) {
error = e;
}
// Path should include the array index and point to the type
assert.strictEqual(
error?.path,
"[1].type",
"constraint error path should include array index and type"
);
});
test("accepts valid site setting", function (assert) {
assert.strictEqual(
this.validateCondition({ name: "enable_badges", enabled: true }),
null
);
});
test("returns error when enabled is not a boolean", function (assert) {
const error = this.validateCondition({
name: "enable_badges",
enabled: "true",
});
assert.true(error?.message.includes("must be a boolean"));
});
test("returns error when includes is not an array", function (assert) {
const error = this.validateCondition({
name: "desktop_category_page_style",
includes: "categories_only",
});
assert.true(error?.message.includes("must be an array"));
});
test("returns error when containsAny is not an array", function (assert) {
const error = this.validateCondition({
name: "top_menu",
containsAny: "latest",
});
assert.true(error?.message.includes("must be an array"));
});
});
module("getResolvedValueForLogging", function () {
test("returns setting value when setting exists", function (assert) {
const condition = new BlockSettingCondition();
setOwner(condition, this.testOwner);
const result = condition.getResolvedValueForLogging({
name: "enable_badges",
});
assert.deepEqual(result, { value: true, hasValue: true });
});
test("returns note when setting does not exist", function (assert) {
const condition = new BlockSettingCondition();
setOwner(condition, this.testOwner);
// Use a custom source with enumerable properties
const settings = { existing_setting: true };
const result = condition.getResolvedValueForLogging({
source: settings,
name: "nonexistent_setting",
});
assert.strictEqual(result.value, undefined);
assert.true(result.hasValue);
assert.true(result.note.includes('"nonexistent_setting" does not exist'));
});
test("suggests similar setting name in note", function (assert) {
const condition = new BlockSettingCondition();
setOwner(condition, this.testOwner);
// Use a custom source with enumerable properties
const settings = { enable_badges: true, enable_whispers: false };
const result = condition.getResolvedValueForLogging({
source: settings,
name: "enable_badgez", // typo - similar to "enable_badges"
});
assert.strictEqual(result.value, undefined);
assert.true(result.hasValue);
assert.true(result.note.includes('did you mean "enable_badges"'));
});
test("returns note when settings source is null", function (assert) {
const condition = new BlockSettingCondition();
setOwner(condition, this.testOwner);
const result = condition.getResolvedValueForLogging({
source: null,
name: "any_setting",
});
assert.deepEqual(result, {
value: undefined,
hasValue: true,
note: "settings source is null/undefined",
});
});
test("returns value from custom source when setting exists", function (assert) {
const condition = new BlockSettingCondition();
setOwner(condition, this.testOwner);
const themeSettings = { my_setting: "custom_value" };
const result = condition.getResolvedValueForLogging({
source: themeSettings,
name: "my_setting",
});
assert.deepEqual(result, { value: "custom_value", hasValue: true });
});
test("returns note with suggestion from custom source", function (assert) {
const condition = new BlockSettingCondition();
setOwner(condition, this.testOwner);
const themeSettings = { my_setting: "value" };
const result = condition.getResolvedValueForLogging({
source: themeSettings,
name: "my_settin", // typo
});
assert.strictEqual(result.value, undefined);
assert.true(result.hasValue);
assert.true(result.note.includes('did you mean "my_setting"'));
});
});
module("type coercion", function () {
test("contains matches number searchValue against string list", function (assert) {
const themeSettings = {
allowed_ids: "123|456|789",
};
assert.true(
this.evaluateCondition({
source: themeSettings,
name: "allowed_ids",
contains: 123,
})
);
assert.false(
this.evaluateCondition({
source: themeSettings,
name: "allowed_ids",
contains: 999,
})
);
});
test("containsAny matches number searchValues against string list", function (assert) {
const themeSettings = {
allowed_ids: "123|456|789",
};
assert.true(
this.evaluateCondition({
source: themeSettings,
name: "allowed_ids",
containsAny: [123, 999],
})
);
assert.false(
this.evaluateCondition({
source: themeSettings,
name: "allowed_ids",
containsAny: [111, 222],
})
);
});
test("contains matches number searchValue against array setting", function (assert) {
const themeSettings = {
allowed_ids: [123, 456, 789],
};
assert.true(
this.evaluateCondition({
source: themeSettings,
name: "allowed_ids",
contains: "123",
})
);
});
});
});
@@ -0,0 +1,725 @@
import { getOwner, setOwner } from "@ember/owner";
import { setupTest } from "ember-qunit";
import { module, test } from "qunit";
import BlockUserCondition from "discourse/blocks/conditions/user";
import { validateConditions } from "discourse/tests/helpers/block-testing";
module("Unit | Blocks | Condition | user", function (hooks) {
setupTest(hooks);
hooks.beforeEach(function () {
this.condition = new BlockUserCondition();
setOwner(this.condition, getOwner(this));
// Helper to validate via infrastructure
this.validateCondition = (args) => {
const conditionTypes = new Map([["user", this.condition]]);
try {
validateConditions({ type: "user", ...args }, conditionTypes);
return null;
} catch (error) {
return error;
}
};
});
module("validate (through infrastructure)", function () {
test("returns error when loggedIn: false combined with admin", function (assert) {
const error = this.validateCondition({ loggedIn: false, admin: true });
assert.true(error?.message.includes("loggedIn: false"));
});
test("returns error when loggedIn: false combined with moderator", function (assert) {
const error = this.validateCondition({
loggedIn: false,
moderator: true,
});
assert.true(error?.message.includes("loggedIn: false"));
});
test("returns error when loggedIn: false combined with staff", function (assert) {
const error = this.validateCondition({ loggedIn: false, staff: true });
assert.true(error?.message.includes("loggedIn: false"));
});
test("returns error when loggedIn: false combined with minTrustLevel", function (assert) {
const error = this.validateCondition({
loggedIn: false,
minTrustLevel: 2,
});
assert.true(error?.message.includes("loggedIn: false"));
});
test("returns error when loggedIn: false combined with maxTrustLevel", function (assert) {
const error = this.validateCondition({
loggedIn: false,
maxTrustLevel: 2,
});
assert.true(error?.message.includes("loggedIn: false"));
});
test("returns error when loggedIn: false combined with groups", function (assert) {
const error = this.validateCondition({
loggedIn: false,
groups: ["some-group"],
});
assert.true(error?.message.includes("loggedIn: false"));
});
test("returns error when minTrustLevel > maxTrustLevel", function (assert) {
const error = this.validateCondition({
minTrustLevel: 3,
maxTrustLevel: 1,
});
assert.true(error?.message.includes("cannot be greater than"));
});
test("returns error when minTrustLevel is negative", function (assert) {
const error = this.validateCondition({ minTrustLevel: -1 });
assert.true(error?.message.includes("must be at least 0"));
});
test("returns error when maxTrustLevel is negative", function (assert) {
const error = this.validateCondition({ maxTrustLevel: -1 });
assert.true(error?.message.includes("must be at least 0"));
});
test("returns error when minTrustLevel exceeds 4", function (assert) {
const error = this.validateCondition({ minTrustLevel: 5 });
assert.true(error?.message.includes("must be at most 4"));
});
test("returns error when maxTrustLevel exceeds 4", function (assert) {
const error = this.validateCondition({ maxTrustLevel: 5 });
assert.true(error?.message.includes("must be at most 4"));
});
test("returns error when minTrustLevel is not a number", function (assert) {
const error = this.validateCondition({ minTrustLevel: "2" });
assert.true(error?.message.includes("must be a number"));
});
test("returns error when maxTrustLevel is not a number", function (assert) {
const error = this.validateCondition({ maxTrustLevel: "3" });
assert.true(error?.message.includes("must be a number"));
});
test("accepts boundary trust levels 0 and 4", function (assert) {
assert.strictEqual(this.validateCondition({ minTrustLevel: 0 }), null);
assert.strictEqual(this.validateCondition({ maxTrustLevel: 4 }), null);
assert.strictEqual(
this.validateCondition({ minTrustLevel: 0, maxTrustLevel: 4 }),
null
);
});
test("returns error when loggedIn is not a boolean", function (assert) {
const error = this.validateCondition({ loggedIn: "true" });
assert.true(error?.message.includes("must be a boolean"));
});
test("returns error when admin is not a boolean", function (assert) {
const error = this.validateCondition({ admin: 1 });
assert.true(error?.message.includes("must be a boolean"));
});
test("returns error when moderator is not a boolean", function (assert) {
const error = this.validateCondition({ moderator: "yes" });
assert.true(error?.message.includes("must be a boolean"));
});
test("returns error when staff is not a boolean", function (assert) {
const error = this.validateCondition({ staff: 0 });
assert.true(error?.message.includes("must be a boolean"));
});
test("returns error when groups is not an array", function (assert) {
const error = this.validateCondition({ groups: "beta-testers" });
assert.true(error?.message.includes("must be an array"));
});
test("returns error when groups contains non-string values", function (assert) {
const error = this.validateCondition({ groups: ["valid", 123] });
assert.true(error?.message.includes("must be a string"));
});
test("passes valid configurations", function (assert) {
assert.strictEqual(this.validateCondition({ loggedIn: true }), null);
assert.strictEqual(this.validateCondition({ loggedIn: false }), null);
assert.strictEqual(this.validateCondition({ admin: true }), null);
assert.strictEqual(this.validateCondition({ moderator: true }), null);
assert.strictEqual(this.validateCondition({ staff: true }), null);
assert.strictEqual(this.validateCondition({ minTrustLevel: 2 }), null);
assert.strictEqual(this.validateCondition({ maxTrustLevel: 3 }), null);
assert.strictEqual(
this.validateCondition({ minTrustLevel: 1, maxTrustLevel: 3 }),
null
);
assert.strictEqual(
this.validateCondition({ minTrustLevel: 2, maxTrustLevel: 2 }),
null
);
assert.strictEqual(
this.validateCondition({ groups: ["test-group"] }),
null
);
assert.strictEqual(
this.validateCondition({ loggedIn: true, admin: true }),
null
);
assert.strictEqual(
this.validateCondition({
minTrustLevel: 2,
groups: ["beta"],
staff: true,
}),
null
);
});
});
module("evaluate", function () {
module("anonymous users", function () {
test("fails with loggedIn: true", function (assert) {
assert.false(this.condition.evaluate({ loggedIn: true }));
});
test("passes with loggedIn: false", function (assert) {
assert.true(this.condition.evaluate({ loggedIn: false }));
});
test("fails with admin: true", function (assert) {
assert.false(this.condition.evaluate({ admin: true }));
});
test("fails with moderator: true", function (assert) {
assert.false(this.condition.evaluate({ moderator: true }));
});
test("fails with staff: true", function (assert) {
assert.false(this.condition.evaluate({ staff: true }));
});
test("fails with minTrustLevel", function (assert) {
assert.false(this.condition.evaluate({ minTrustLevel: 1 }));
});
test("fails with maxTrustLevel", function (assert) {
assert.false(this.condition.evaluate({ maxTrustLevel: 4 }));
});
test("fails with groups", function (assert) {
assert.false(this.condition.evaluate({ groups: ["some-group"] }));
});
});
module("logged-in users", function (nestedHooks) {
nestedHooks.beforeEach(function () {
this.condition.currentUser = {
admin: false,
moderator: false,
staff: false,
trust_level: 2,
groups: [{ name: "trust_level_2" }, { name: "beta-testers" }],
};
});
test("passes with loggedIn: true", function (assert) {
assert.true(this.condition.evaluate({ loggedIn: true }));
});
test("fails with loggedIn: false", function (assert) {
assert.false(this.condition.evaluate({ loggedIn: false }));
});
module("admin condition", function () {
test("fails when user is not admin", function (assert) {
assert.false(this.condition.evaluate({ admin: true }));
});
test("passes when user is admin", function (assert) {
this.condition.currentUser.admin = true;
assert.true(this.condition.evaluate({ admin: true }));
});
test("admin: false is a no-op (passes for any user)", function (assert) {
assert.true(
this.condition.evaluate({ admin: false }),
"non-admin user passes"
);
this.condition.currentUser.admin = true;
assert.true(
this.condition.evaluate({ admin: false }),
"admin user also passes"
);
});
});
module("moderator condition", function () {
test("fails when user is not moderator", function (assert) {
assert.false(this.condition.evaluate({ moderator: true }));
});
test("passes when user is moderator", function (assert) {
this.condition.currentUser.moderator = true;
assert.true(this.condition.evaluate({ moderator: true }));
});
test("passes when user is admin (admins are moderators)", function (assert) {
this.condition.currentUser.admin = true;
assert.true(this.condition.evaluate({ moderator: true }));
});
test("moderator: false is a no-op (passes for any user)", function (assert) {
assert.true(
this.condition.evaluate({ moderator: false }),
"non-moderator user passes"
);
this.condition.currentUser.moderator = true;
assert.true(
this.condition.evaluate({ moderator: false }),
"moderator user also passes"
);
});
});
module("staff condition", function () {
test("fails when user is not staff", function (assert) {
assert.false(this.condition.evaluate({ staff: true }));
});
test("passes when user is staff", function (assert) {
this.condition.currentUser.staff = true;
assert.true(this.condition.evaluate({ staff: true }));
});
test("staff: false is a no-op (passes for any user)", function (assert) {
assert.true(
this.condition.evaluate({ staff: false }),
"non-staff user passes"
);
this.condition.currentUser.staff = true;
assert.true(
this.condition.evaluate({ staff: false }),
"staff user also passes"
);
});
});
module("trust level conditions", function () {
test("passes when trust level meets minimum", function (assert) {
assert.true(this.condition.evaluate({ minTrustLevel: 2 }));
});
test("passes when trust level exceeds minimum", function (assert) {
assert.true(this.condition.evaluate({ minTrustLevel: 1 }));
});
test("fails when trust level is below minimum", function (assert) {
assert.false(this.condition.evaluate({ minTrustLevel: 3 }));
});
test("passes when trust level meets maximum", function (assert) {
assert.true(this.condition.evaluate({ maxTrustLevel: 2 }));
});
test("passes when trust level is below maximum", function (assert) {
assert.true(this.condition.evaluate({ maxTrustLevel: 4 }));
});
test("fails when trust level exceeds maximum", function (assert) {
assert.false(this.condition.evaluate({ maxTrustLevel: 1 }));
});
test("passes when trust level is within range", function (assert) {
assert.true(
this.condition.evaluate({ minTrustLevel: 1, maxTrustLevel: 3 })
);
});
test("passes when trust level equals both min and max", function (assert) {
assert.true(
this.condition.evaluate({ minTrustLevel: 2, maxTrustLevel: 2 })
);
});
test("fails when trust level is outside range", function (assert) {
assert.false(
this.condition.evaluate({ minTrustLevel: 3, maxTrustLevel: 4 })
);
});
});
module("group conditions", function () {
test("passes when user is in specified group", function (assert) {
assert.true(this.condition.evaluate({ groups: ["beta-testers"] }));
});
test("passes when user is in one of multiple groups (OR logic)", function (assert) {
assert.true(
this.condition.evaluate({
groups: ["alpha-testers", "beta-testers"],
})
);
});
test("fails when user is not in any specified group", function (assert) {
assert.false(
this.condition.evaluate({ groups: ["alpha-testers", "vip"] })
);
});
test("handles empty groups array", function (assert) {
this.condition.currentUser.groups = [];
assert.false(this.condition.evaluate({ groups: ["any-group"] }));
});
test("handles undefined groups", function (assert) {
this.condition.currentUser.groups = undefined;
assert.false(this.condition.evaluate({ groups: ["any-group"] }));
});
});
module("combined conditions", function () {
test("passes when all conditions are met", function (assert) {
this.condition.currentUser.staff = true;
assert.true(
this.condition.evaluate({
loggedIn: true,
staff: true,
minTrustLevel: 2,
groups: ["beta-testers"],
})
);
});
test("fails when one condition is not met", function (assert) {
assert.false(
this.condition.evaluate({
loggedIn: true,
admin: true,
minTrustLevel: 2,
})
);
});
});
});
});
module("source parameter", function () {
test("has sourceType of outletArgs", function (assert) {
assert.strictEqual(BlockUserCondition.sourceType, "outletArgs");
});
test("validate passes with valid source format", function (assert) {
assert.strictEqual(
this.validateCondition({ source: "@outletArgs.user", admin: true }),
null
);
});
test("validate returns error with invalid source format", function (assert) {
const error = this.validateCondition({ source: "user", admin: true });
assert.notStrictEqual(error, null, "returns an error");
assert.true(
error.message.includes("must be in format"),
"error message mentions format"
);
});
test("validate returns error when no args specified (atLeastOne constraint)", function (assert) {
const error = this.validateCondition({});
assert.notStrictEqual(error, null, "returns an error");
assert.true(
error.message.includes("at least one of"),
"error message mentions atLeastOne"
);
});
test("uses user from source when provided", function (assert) {
const outletUser = {
admin: true,
moderator: true,
staff: true,
trust_level: 4,
};
const context = { outletArgs: { customUser: outletUser } };
const result = this.condition.evaluate(
{ source: "@outletArgs.customUser", admin: true },
context
);
assert.true(result);
});
test("does NOT fall back to currentUser when source resolves to undefined", function (assert) {
this.condition.currentUser = {
admin: true,
trust_level: 2,
};
const context = { outletArgs: {} };
// Source is provided but resolves to undefined - should use undefined, not currentUser
const result = this.condition.evaluate(
{ source: "@outletArgs.user", admin: true },
context
);
// No user found at source path, so admin check fails
assert.false(result);
});
test("checks source user properties correctly", function (assert) {
const outletUser = {
admin: false,
moderator: true,
staff: true,
trust_level: 3,
};
const context = { outletArgs: { topicAuthor: outletUser } };
// Should fail admin check on source user
assert.false(
this.condition.evaluate(
{ source: "@outletArgs.topicAuthor", admin: true },
context
)
);
// Should pass moderator check on source user
assert.true(
this.condition.evaluate(
{ source: "@outletArgs.topicAuthor", moderator: true },
context
)
);
// Should pass trust level check on source user
assert.true(
this.condition.evaluate(
{ source: "@outletArgs.topicAuthor", minTrustLevel: 2 },
context
)
);
});
test("handles nested source paths", function (assert) {
const topicCreator = {
admin: true,
trust_level: 4,
};
const context = { outletArgs: { topic: { creator: topicCreator } } };
const result = this.condition.evaluate(
{ source: "@outletArgs.topic.creator", admin: true },
context
);
assert.true(result);
});
module("nested source path error handling", function () {
test("handles null intermediate value in source path", function (assert) {
const context = { outletArgs: { topic: null } };
const result = this.condition.evaluate(
{ source: "@outletArgs.topic.creator", admin: true },
context
);
assert.false(result);
});
test("handles undefined intermediate value in source path", function (assert) {
const context = { outletArgs: { topic: { creator: undefined } } };
const result = this.condition.evaluate(
{ source: "@outletArgs.topic.creator", admin: true },
context
);
assert.false(result);
});
test("handles missing root property in source path", function (assert) {
const context = { outletArgs: {} };
const result = this.condition.evaluate(
{ source: "@outletArgs.topic.creator", admin: true },
context
);
assert.false(result);
});
test("handles deeply nested source path with null at various levels", function (assert) {
// null at first level
let context = { outletArgs: { a: null } };
assert.false(
this.condition.evaluate(
{ source: "@outletArgs.a.b.c", admin: true },
context
),
"null at first level"
);
// null at second level
context = { outletArgs: { a: { b: null } } };
assert.false(
this.condition.evaluate(
{ source: "@outletArgs.a.b.c", admin: true },
context
),
"null at second level"
);
});
test("handles source resolving to non-user object gracefully", function (assert) {
const context = { outletArgs: { topic: { creator: "not-a-user" } } };
const result = this.condition.evaluate(
{ source: "@outletArgs.topic.creator", admin: true },
context
);
assert.false(result);
});
test("handles missing outletArgs in context", function (assert) {
const result = this.condition.evaluate(
{ source: "@outletArgs.user", admin: true },
{}
);
assert.false(result);
});
test("handles null outletArgs in context", function (assert) {
const result = this.condition.evaluate(
{ source: "@outletArgs.user", admin: true },
{ outletArgs: null }
);
assert.false(result);
});
});
module("loggedIn comparison with currentUser", function (nestedHooks) {
nestedHooks.beforeEach(function () {
this.condition.currentUser = {
id: 42,
admin: false,
trust_level: 2,
};
});
test("loggedIn: true passes when source user IS currentUser", function (assert) {
const context = { outletArgs: { postUser: { id: 42 } } };
assert.true(
this.condition.evaluate(
{ source: "@outletArgs.postUser", loggedIn: true },
context
)
);
});
test("loggedIn: true fails when source user is NOT currentUser", function (assert) {
const context = { outletArgs: { postUser: { id: 99 } } };
assert.false(
this.condition.evaluate(
{ source: "@outletArgs.postUser", loggedIn: true },
context
)
);
});
test("loggedIn: true fails when source user is undefined", function (assert) {
const context = { outletArgs: {} };
assert.false(
this.condition.evaluate(
{ source: "@outletArgs.postUser", loggedIn: true },
context
)
);
});
test("loggedIn: false fails when source user IS currentUser", function (assert) {
const context = { outletArgs: { postUser: { id: 42 } } };
assert.false(
this.condition.evaluate(
{ source: "@outletArgs.postUser", loggedIn: false },
context
)
);
});
test("loggedIn: false passes when source user is NOT currentUser", function (assert) {
const context = { outletArgs: { postUser: { id: 99 } } };
assert.true(
this.condition.evaluate(
{ source: "@outletArgs.postUser", loggedIn: false },
context
)
);
});
test("loggedIn: false passes when source user is undefined", function (assert) {
const context = { outletArgs: {} };
assert.true(
this.condition.evaluate(
{ source: "@outletArgs.postUser", loggedIn: false },
context
)
);
});
test("loggedIn: true fails when currentUser is null (anon)", function (assert) {
this.condition.currentUser = null;
const context = { outletArgs: { postUser: { id: 99 } } };
assert.false(
this.condition.evaluate(
{ source: "@outletArgs.postUser", loggedIn: true },
context
)
);
});
test("loggedIn: false passes when currentUser is null (anon)", function (assert) {
this.condition.currentUser = null;
const context = { outletArgs: { postUser: { id: 99 } } };
assert.true(
this.condition.evaluate(
{ source: "@outletArgs.postUser", loggedIn: false },
context
)
);
});
test("compares by reference when users have no id", function (assert) {
const sharedUser = { username: "test" };
this.condition.currentUser = sharedUser;
const context = { outletArgs: { postUser: sharedUser } };
assert.true(
this.condition.evaluate(
{ source: "@outletArgs.postUser", loggedIn: true },
context
),
"same reference passes"
);
const differentUser = { username: "test" };
const context2 = { outletArgs: { postUser: differentUser } };
assert.false(
this.condition.evaluate(
{ source: "@outletArgs.postUser", loggedIn: true },
context2
),
"different reference fails even with same properties"
);
});
});
});
module("static type", function () {
test("has correct type", function (assert) {
assert.strictEqual(BlockUserCondition.type, "user");
});
});
});

Some files were not shown because too many files have changed in this diff Show More