Embed link patterns let a plugin support URI-like tokens such as youtube:video:jGwO_UgTS7I alongside normal web URLs. When a user types or pastes a registered token, the editor linkifies it and sends it to POST /api/v2/media/scrape. A PHP embed factory then resolves it to a rich embed.
This guide is for third-party developers who can only add or change files inside one plugin directory. It covers both supported approaches:
Vanilla includes the built-in discussion token vanilla:discussion:<discussionID>. The YouTube and Acme tokens in this guide are illustrative plugin examples, not built-in token integrations. Other token patterns require both a frontend registration and a matching backend embed factory.
The global Enable link embeds in posts and comments setting controls token linkification. When link embeds are disabled, typed and pasted tokens are not linkified. Patterns are registered in plugin code; there is no separate admin setting for each pattern.
- Extend an existing Vanilla factory and add a token to an existing embed type.
- Create a new factory that supports both your provider's URLs and tokens.
How the integration works
A token integration has two independent parts:
- Frontend:
registerEmbedLinkPattern() tells the editor which text to linkify. It does not create the embed. - Backend: an
AbstractEmbedFactory declares the same pattern and creates the embed returned by /media/scrape.
The PHP factory also keeps its normal domain/path matching, so a single factory accepts both inputs:
https://www.youtube.com/watch?v=jGwO_UgTS7I
youtube:video:jGwO_UgTS7I
The frontend and PHP patterns must describe the same token. If only one side is registered, the feature is incomplete.
Plugin layout
The examples use this plugin structure:
plugins/my-embeds/
├── addon.json
├── Addon/
│ └── MyEmbedsContainerRules.php
├── EmbeddedContent/
│ ├── AcmeVideoEmbed.php
│ └── Factories/
│ ├── AcmeVideoEmbedFactory.php
│ └── YouTubeTokenEmbedFactory.php
└── src/scripts/
├── embeds/
│ └── AcmeVideoEmbed.tsx
└── entries/
└── common.tsx
The exact plugin and namespace names are yours. All files remain inside your plugin.
Approach 1: Extend an existing factory
Use this approach when Vanilla already supports the provider and you only need to add a token. You can reuse the provider's existing backend embed type and frontend renderer.
This example adds youtube:video:{videoID} while preserving the existing YouTube URL behavior.
Step 1: Create a plugin-owned subclass
Do not edit YouTubeEmbedFactory in Vanilla. Create this file in your plugin:
plugins/my-embeds/EmbeddedContent/Factories/YouTubeTokenEmbedFactory.php
<?php
namespace MyCompany\MyEmbeds\EmbeddedContent\Factories;
use Vanilla\EmbeddedContent\AbstractEmbed;
use Vanilla\EmbeddedContent\Factories\YouTubeEmbedFactory;
/**
* Adds youtube:video:{videoID} tokens to Vanilla's existing YouTube support.
*/
class YouTubeTokenEmbedFactory extends YouTubeEmbedFactory
{
protected function getSupportedLinkPattern(): ?string
{
// YouTube video IDs are 11 characters.
return "/^youtube:video:(?<videoID>[A-Za-z0-9_-]{11})$/";
}
public function createEmbedForUrl(string $url): AbstractEmbed
{
$matches = $this->matchLinkPattern($url);
if ($matches !== null) {
// Convert the token to a URL understood by the parent factory.
$videoID = $matches["videoID"];
$url = "https://www.youtube.com/watch?v={$videoID}";
}
return parent::createEmbedForUrl($url);
}
}
The subclass inherits YouTube's domain/path matching, oEmbed request, YouTubeEmbed payload, and constructor dependencies. It does not access private parent implementation: it converts the token to a canonical URL and delegates to the public parent method.
Step 2: Register the subclass
Create your plugin's container rules:
plugins/my-embeds/Addon/MyEmbedsContainerRules.php
<?php
namespace MyCompany\MyEmbeds\Addon;
use Garden\Container\ContainerConfigurationInterface;
use Garden\Container\Reference;
use Vanilla\AddonContainerRules;
use Vanilla\EmbeddedContent\EmbedService;
use MyCompany\MyEmbeds\EmbeddedContent\Factories\YouTubeTokenEmbedFactory;
class MyEmbedsContainerRules extends AddonContainerRules
{
public function configureContainer(ContainerConfigurationInterface $container): void
{
$container->rule(EmbedService::class)->addCall("registerFactory", [
"embedFactory" => new Reference(YouTubeTokenEmbedFactory::class),
"priority" => EmbedService::PRIORITY_HIGH,
]);
}
}
Use PRIORITY_HIGH because Vanilla's original YouTube factory handles the same URLs. The higher priority makes the plugin subclass win for normal YouTube URLs. Tokens only match the subclass.
Do not call registerEmbed() here. Core already registers YouTubeEmbed and its React renderer.
Step 3: Register the frontend token
Register the same token shape in an entry loaded everywhere:
plugins/my-embeds/src/scripts/entries/common.tsx
import { registerEmbedLinkPattern } from "@library/embeddedContent/embedLinkPatterns";
registerEmbedLinkPattern({
name: "youtube:video",
scheme: "youtube",
pattern: /youtube:video:[A-Za-z0-9_-]{11}/,
});
No new React embed is needed. The scrape response uses the existing "youtube" embed type, so Vanilla renders it with the existing YouTube component.
Step 4: Verify both inputs
- Enable the plugin and clear caches.
- Paste
https://www.youtube.com/watch?v=jGwO_UgTS7I; confirm the existing YouTube embed still renders. - Type
youtube:video:jGwO_UgTS7I followed by a space; confirm it linkifies and embeds. - Confirm
/api/v2/media/scrape receives the raw token and returns embedType: "youtube".
Limitations of extending a factory
- The core factory must not be
final, and the methods you need must be public or protected. YouTubeEmbedFactory currently supports this extension. - Private parent methods and properties cannot be used by your subclass. Prefer transforming the token and delegating to
parent::createEmbedForUrl(). - Your plugin depends on the core factory's API. Re-test the integration when upgrading Vanilla.
- Register the subclass at a higher priority when it overlaps the core factory's URL domains.
- Do not replace the container rule for the core factory. Register your subclass normally on
EmbedService.
Approach 2: Create a new factory
Use this approach when Vanilla does not already support your provider. Your plugin supplies:
- A PHP embed data class.
- One PHP factory for both provider URLs and tokens.
- A React renderer.
- Frontend token and embed registrations.
- Container registrations for the PHP factory and embed class.
The example provider accepts:
https://video.acme.example/watch?v=abc123
acmevideo:video:abc123
Replace the example host, payload, and iframe URL with your provider's real values.
Step 1: Create the PHP embed class
plugins/my-embeds/EmbeddedContent/AcmeVideoEmbed.php
<?php
namespace MyCompany\MyEmbeds\EmbeddedContent;
use Garden\Schema\Schema;
use Vanilla\EmbeddedContent\AbstractEmbed;
use Vanilla\EmbeddedContent\EmbedUtils;
class AcmeVideoEmbed extends AbstractEmbed
{
public const TYPE = "acmeVideo";
protected function getAllowedTypes(): array
{
return [self::TYPE];
}
public function normalizeData(array $data): array
{
return EmbedUtils::ensureDimensions($data);
}
public function jsonSerialize(): array
{
$data = parent::jsonSerialize();
$videoID = $data["videoID"] ?? null;
if (is_string($videoID) && $videoID !== "") {
$data["frameSrc"] = "https://video.acme.example/embed/{$videoID}";
}
return $data;
}
protected function schema(): Schema
{
return Schema::parse(["videoID:s", "height:i", "width:i", "name:s?"]);
}
}
Use a dedicated embed type for partner content. Do not use IFrameEmbed: generic iframe embeds are extended content and can be filtered out for normal users.
Step 2: Create one factory for URLs and tokens
plugins/my-embeds/EmbeddedContent/Factories/AcmeVideoEmbedFactory.php
<?php
namespace MyCompany\MyEmbeds\EmbeddedContent\Factories;
use Garden\Web\Exception\NotFoundException;
use Vanilla\EmbeddedContent\AbstractEmbed;
use Vanilla\EmbeddedContent\AbstractEmbedFactory;
use MyCompany\MyEmbeds\EmbeddedContent\AcmeVideoEmbed;
class AcmeVideoEmbedFactory extends AbstractEmbedFactory
{
protected function getSupportedDomains(): array
{
return ["video.acme.example"];
}
protected function getSupportedPathRegex(string $domain): string
{
return "#^/watch$#";
}
protected function getSupportedLinkPattern(): ?string
{
return "/^acmevideo:video:(?<videoID>[A-Za-z0-9]+)$/";
}
public function createEmbedForUrl(string $url): AbstractEmbed
{
$matches = $this->matchLinkPattern($url);
if ($matches !== null) {
$videoID = $matches["videoID"];
} else {
parse_str(parse_url($url, PHP_URL_QUERY) ?? "", $query);
$videoID = $query["v"] ?? null;
}
if (!is_string($videoID) || $videoID === "") {
throw new NotFoundException("Embed");
}
return new AcmeVideoEmbed([
"embedType" => AcmeVideoEmbed::TYPE,
"url" => "https://video.acme.example/watch?v={$videoID}",
"videoID" => $videoID,
"name" => "Acme video",
"height" => 270,
"width" => 480,
]);
}
}
canHandleUrl() uses getSupportedDomains() and getSupportedPathRegex(). EmbedService separately calls canHandleLinkPattern() before URL normalization. Both routes end at the same createEmbedForUrl() method.
If your provider requires an API or oEmbed request, inject an HTTP client through the factory constructor and perform that request in createEmbedForUrl().
Step 3: Register the backend factory and embed
Add these calls to the same plugin container rules shown earlier:
use MyCompany\MyEmbeds\EmbeddedContent\AcmeVideoEmbed;
use MyCompany\MyEmbeds\EmbeddedContent\Factories\AcmeVideoEmbedFactory;
$container
->rule(EmbedService::class)
->addCall("registerEmbed", [AcmeVideoEmbed::class, AcmeVideoEmbed::TYPE])
->addCall("registerFactory", [
"embedFactory" => new Reference(AcmeVideoEmbedFactory::class),
"priority" => EmbedService::PRIORITY_NORMAL,
]);
This is one factory registration. A separate token factory is not required.
Step 4: Create the React renderer
plugins/my-embeds/src/scripts/embeds/AcmeVideoEmbed.tsx
import React from "react";
import { EmbedContainer } from "@library/embeddedContent/components/EmbedContainer";
import { EmbedContent } from "@library/embeddedContent/components/EmbedContent";
import { IBaseEmbedProps } from "@library/embeddedContent/embedService.register";
interface IProps extends IBaseEmbedProps {
videoID: string;
frameSrc?: string;
height?: number;
}
export function AcmeVideoEmbed(props: IProps) {
const src = props.frameSrc ?? `https://video.acme.example/embed/${props.videoID}`;
return (
<EmbedContainer>
<EmbedContent type={props.embedType}>
<iframe
title={props.name ?? "Acme video"}
src={src}
width="100%"
height={props.height ?? 270}
allowFullScreen
/>
</EmbedContent>
</EmbedContainer>
);
}
Step 5: Register the frontend embed and token
Add both registrations to common.tsx:
import { registerEmbedLinkPattern } from "@library/embeddedContent/embedLinkPatterns";
import { registerEmbed } from "@library/embeddedContent/embedService.register";
import { AcmeVideoEmbed } from "../embeds/AcmeVideoEmbed";
registerEmbedLinkPattern({
name: "acmevideo:video",
scheme: "acmevideo",
pattern: /acmevideo:video:[A-Za-z0-9]+/,
});
registerEmbed("acmeVideo", AcmeVideoEmbed);
The string passed to registerEmbed() must match AcmeVideoEmbed::TYPE.
Step 6: Allow the iframe domain
If your renderer uses an iframe, the provider's host must be allowed by the site's trusted-domain and content-security configuration. Register it through the extension point available to your plugin, or ask the site owner to add it.
This is separate from factory matching: a successful scrape can still produce an iframe that the browser or Vanilla blocks.
Step 7: Verify both inputs
- Enable the plugin and clear caches.
- Paste
https://video.acme.example/watch?v=abc123; confirm the new embed renders. - Type
acmevideo:video:abc123 followed by a space; confirm it linkifies and renders the same embed. - Confirm both scrape responses use
embedType: "acmeVideo". - Publish and view the post to verify the iframe policy outside the editor.
Frontend registration
registerEmbedLinkPattern({
name: "provider:resource",
scheme: "provider",
pattern: /provider:resource:[A-Za-z0-9]+/,
});
Field | Requirement |
|---|
name
| Unique registry key. A later registration with the same name replaces it. |
scheme
| Lowercase URI scheme matching /^[a-z][a-z0-9+.-]*$/. |
pattern
| Must match the full token shape and begin with the declared scheme. |
Rules and limits
Schemes
- Blocked on the frontend (registration rejected):
javascript, data, vbscript, file, about, blob, http, https, mailto, tel. - A frontend match only counts if the matched text starts with
${scheme}:. - Invalid
registerEmbedLinkPattern calls are logged and ignored. - On the backend, unknown non-http schemes that no factory claims return HTTP 400 from scrape.
Editor behaviour
- Typed tokens linkify on space or Enter.
- Attached punctuation while typing (e.g.
(youtube:video:jGwO_UgTS7I)) is not linkified; paste still handles tokens in surrounding punctuation. - Inside code blocks and inline code: not linkified.
- Inside existing links: not linkified.
- Sites with URL embeds disabled: tokens are not linkified.
Caching
- When the embed is cacheable (
AbstractEmbed::isCacheable()), scrape results are stored for 24 hours, keyed by the raw lookup token for pattern matches (or the normalized URL for http scrapes). Non-cacheable embeds (for example QuoteEmbed) skip that cache.
Rendering
- A token left as a plain link (no successful scrape / embed) renders as an unsafe link. Rely on the embed path. Do not expect the bare token to be a safe clickable URL.
- Factory priority decides which factory wins when multiple factories claim the same URL or token.
- Keep PHP and JavaScript regexes equivalent. They run in different regex engines.
- Anchor the PHP pattern with
^ and $. Frontend anchors are optional. - Avoid lookbehind, possessive quantifiers,
\A, and \z when sharing regex concepts across engines.