koh / semantic-delivery
Multi-channel content delivery and distribution for TYPO3 — Markdown/AI-Agent/Voice channel negotiation, Distribution adapters for LinkedIn, X, Bluesky, Facebook, Instagram, Dev.to, Buffer, Ayrshare, n8n. Consumes Schema.org via koh/structured-content.
Package info
github.com/ohartwig/semantic-delivery
Type:typo3-cms-extension
pkg:composer/koh/semantic-delivery
Requires
- php: ^8.5.11
- koh/structured-content: ^2.14
- typo3/cms-backend: ^14.0
- typo3/cms-core: ^14.0
- typo3/cms-frontend: ^14.0
Requires (Dev)
- koh/dev-typo3: ^1.0
Suggests
- koh/content-intelligence: Required for AI-powered social media content transformation
Provides
None
Conflicts
None
Replaces
None
README
Schema.org enrichment and multi-channel content transformation for TYPO3. Delivers structured content across Website, AI Agent, Voice, and Social Media channels with automated distribution to external platforms.
Features
- Schema.org JSON-LD — Automatic structured data enrichment for TYPO3 content
- Multi-Channel Transformation — Optimized output for Website, AI Agent, Voice, and Social Media
- Direct Platform Distribution — Post directly to LinkedIn, X/Twitter, Instagram, Facebook, Bluesky, Medium, and Dev.to via native APIs
- Proxy Distribution — Push via Ayrshare, Buffer, webhooks, or n8n
- Per-Page Platform Selection — Editors choose which platforms each page is distributed to; only platforms with configured credentials are shown
- Blog Cross-Posting — Publish full articles to Medium (HTML) and Dev.to (Markdown) with canonical URL attribution
- Podcast Episode Detection — Automatic new-episode monitoring with multi-platform job creation
- Channel Detection — Middleware-based automatic detection of requesting channel
- OAuth Token Management — Encrypted per-platform token storage with refresh support
- Schema Caching — Performance-optimized schema generation with TYPO3 caching
- DDD 4-Layer Architecture — Strict layer separation enforced by deptrac
Installation
composer require koh/semantic-delivery
Requirements
- PHP 8.2+
- TYPO3 14.0
Architecture
Classes/
├── Domain/ # Channel models, schema value objects, contracts
├── Application/ # Transformation services, distribution orchestration
├── Infrastructure/ # Distribution adapters, schema generators, TCA helpers
└── Presentation/ # Controllers, event listeners
Database Tables
| Table | Purpose |
|---|---|
tx_semanticdelivery_channel_content |
Channel-specific content variants |
tx_semanticdelivery_distribution_job |
Distribution job queue |
tx_semanticdelivery_schema_cache |
Cached Schema.org data |
tx_semanticdelivery_oauth_token |
Encrypted platform OAuth tokens |
Page Fields
| Field | Type | Description |
|---|---|---|
tx_semanticdelivery_schema_type |
Select | Schema.org type override (auto-detect by default, grouped by Article / WebPage / Other family — see Schema.org Generation) |
tx_semanticdelivery_channels |
CheckBox | Delivery channels (Website, AI Agent, Voice, Social Media) |
tx_semanticdelivery_distribution_enabled |
Check | Enable distribution for this page |
tx_semanticdelivery_distribution_platforms |
CheckBox | Target platforms (dynamically populated, see below) |
The platform selector only shows platforms that have credentials configured in the current site's settings. This prevents editors from selecting targets that would fail.
Schema.org Generation
Each frontend response is enriched with one or more <script type="application/ld+json"> blocks. Generators are auto-discovered (semantic_delivery.schema_generator tag) and ordered by priority — every generator that returns true from canHandle() contributes one document.
Generator Priority Order
| Priority | Generator | Output |
|---|---|---|
| 70 | FaqSchemaGenerator |
FAQPage (CType *faq* or Q&A patterns) |
| 60 | ProductSchemaGenerator / PackagesGridProductSchemaGenerator / ServiceSchemaGenerator |
Product, Service, Offer |
| 55 | BreadcrumbListSchemaGenerator, OrganizationSchemaGenerator, … |
BreadcrumbList, Organization |
| 50 | ArticleSchemaGenerator |
Article family — see resolver |
| 45 | WebPageSchemaGenerator |
WebPage family — see resolver |
| 30 | ContentBlockSchemaMapper |
Site-settings-driven CType → schema mapping |
| 10 | AutoDetectSchemaGenerator |
Fuzzy fallback (only when resolver did not match) |
Article / WebPage Family Routing
Application/Service/SchemaTypeResolverService is the single source of truth for picking a schema type from the Article- or WebPage-family. Both ArticleSchemaGenerator (priority 50) and WebPageSchemaGenerator (priority 45) delegate their canHandle() to the resolver, so a page emits either an Article-family or a WebPage-family document — never both.
Resolution order (highest priority first):
-
Manual override —
pages.tx_semanticdelivery_schema_type. Article-/WebPage-family values are honoured directly. Other values (e.g.Product,FAQPage) cause the resolver to returnnullso the dedicated generator owns the page. -
Slug heuristic — language-stable index/section patterns only:
Slug pattern Schema Type /blog(root)CollectionPage/blog/<sub>BlogPosting/news,/press,/presse(root)CollectionPage/news/<sub>,/press/<sub>,/presse/<sub>NewsArticle/portfolio,/referenz*(root)CollectionPage/portfolio/<sub>,/referenz*/<sub>ItemPageLanguage-specific patterns (
/kontakt,/ueber-uns,/about,/contact,/team,/profil) are not auto-detected — for these pages the editor sets the override in the backend (the dropdown shows a description per type). -
Hero content block hint — first matching CType wins:
Hero CType Family Schema Type moselwal_blogheroarticle BlogPostingmoselwal_pageheroarticle Articlemoselwal_herocardarticle Articlemoselwal_teaserarticle NewsArticlemoselwal_herowebpage WebPagemoselwal_oleherowebpage AboutPagemoselwal_nozzleherowebpage WebPage -
Keywords heuristic (
pages.keywordssubstrings):Keyword contains Schema Type tech,technicalTechArticlepaper,study,researchScholarlyArticlereportReportsocialSocialMediaPostingblogBlogPostingnews,pressNewsArticlecollection,indexCollectionPage -
Default —
Articlefor standard pages (doktype1 or 2 with content), otherwiseWebPage.
The resolver returns null (and the dedicated generator takes over) when the page contains FAQ / Service / Product / Podcast / PackagesGrid CTypes, or sits below /leistungen, /impressum, /datenschutz, /karriere, /podcasts.
Backend Override Dropdown
The page property dropdown is grouped via --div-- separators and every option carries a descriptive tooltip rendered by the TYPO3 backend:
Auto (detect via Slug / Hero-CB / Keywords)
── Article family ──
Article, BlogPosting, NewsArticle, TechArticle,
ScholarlyArticle, Report, SocialMediaPosting
── WebPage family ──
WebPage, AboutPage, ContactPage, CollectionPage,
ItemPage, ProfilePage, QAPage
── Other ──
FAQPage, Product, HowTo, Organization, LocalBusiness
The descriptions guide editors on when to pick each type — important for language-specific pages (/kontakt, /ueber-uns, /about …) that the resolver does not auto-detect. Values outside the Article/WebPage families let the dedicated generator (FAQ, Product, Organization, …) own the page.
Distribution
Supported Platforms
Social Media (short-form)
| Platform | Distributor | Auth Method | Character Limit |
|---|---|---|---|
| Bluesky | BlueskyDistributor |
App Password | 300 graphemes |
LinkedInDistributor |
OAuth 2.0 Access Token | 3,000 chars | |
| X/Twitter | TwitterXDistributor |
Bearer Token (OAuth 2.0) | 280 chars |
InstagramDistributor |
Page Access Token | 2,200 chars | |
FacebookDistributor |
Page Access Token | 63,000 chars |
Blog Platforms (long-form)
| Platform | Distributor | Auth Method | Content Format |
|---|---|---|---|
| Dev.to | DevToDistributor |
API Key | Markdown |
Blog distributors include canonical URL attribution back to the original TYPO3 page.
Note: Medium has discontinued issuing new API integration tokens. The MediumDistributor has been removed.
Proxy Services
| Platform | Distributor | Auth Method |
|---|---|---|
| Ayrshare | AyrshareDistributor |
API Key |
| Buffer | BufferDistributor |
Access Token |
| n8n | N8nDistributor |
Webhook URL |
| Late.dev | LateDevDistributor |
API Key |
| Generic Webhook | WebhookDistributor |
Custom URL |
Setup
1. Enable distribution in site settings
semanticDelivery: channels: socialMedia: enabled: true distribution: enabled: true defaultDistributor: 'bluesky'
2. Configure platform credentials
Credentials are configured per site in config/sites/<name>/config.yaml. Sensitive tokens use the %secret()% syntax (see moselwal/secret-resolver), public identifiers go directly into the YAML.
Only platforms with a non-empty primary credential appear in the backend page editor. Platforms without credentials are hidden from the editor so they cannot select targets that would fail.
3. Run the database schema update
vendor/bin/typo3 database:updateschema
4. Per-page platform selection
In the TYPO3 backend, each page has a Semantic Delivery tab:
- Check Enable Distribution
- Select target platforms (only configured platforms are shown)
- Save and publish
The distribution system respects this selection — pages are only distributed to checked platforms.
Platform Credential Guides
Bluesky
Auth: App Password (no OAuth required) Scope: Full post access
- Log in to bsky.app
- Go to Settings → Privacy and Security → App Passwords
- Click Add App Password, give it a name (e.g. "TYPO3 Distribution")
- Copy the generated password
# config/sites/my-site/config.yaml semanticDelivery: social: bluesky: handle: 'yourhandle.bsky.social' # or custom domain appPassword: '%secret(BLUESKY_APP_PASSWORD)%'
# secrets/dev/secrets.env (via sops) BLUESKY_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx
Token does not expire. Revoke at any time in App Passwords settings.
Auth: OAuth 2.0 Access Token
Modes: Personal posting (w_member_social) or Organization posting (w_organization_social)
Step 1 — Create a LinkedIn App:
- Go to linkedin.com/developers/apps → Create app
- Fill in App name, associate your LinkedIn Company Page, upload a logo
- Under Products tab → enable Share on LinkedIn (instant access)
- Under Auth tab → note your Client ID and Client Secret
- Add a Redirect URL (e.g.
https://yourdomain.com/linkedin-callback)
Step 2 — Get an Access Token:
Open this URL in a browser (replace placeholders):
https://www.linkedin.com/oauth/v2/authorization?response_type=code&client_id=YOUR_CLIENT_ID&redirect_uri=YOUR_REDIRECT_URL&scope=openid%20profile%20w_member_social
After consent, the browser redirects to your redirect URL with ?code=AUTH_CODE. Exchange it:
curl -X POST https://www.linkedin.com/oauth/v2/accessToken \ -d "grant_type=authorization_code" \ -d "code=AUTH_CODE" \ -d "redirect_uri=YOUR_REDIRECT_URL" \ -d "client_id=YOUR_CLIENT_ID" \ -d "client_secret=YOUR_CLIENT_SECRET"
The response contains access_token (valid for 60 days) and refresh_token.
Step 3 — Get your Person ID:
curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ "https://api.linkedin.com/v2/userinfo"
The sub field in the response is your Person ID.
Step 4 — Configure:
semanticDelivery: social: linkedin: accessToken: '%secret(LINKEDIN_ACCESS_TOKEN)%' personId: 'abc123def' # from /v2/userinfo → sub organizationId: '' # leave empty for personal posting
To post as a Company Page instead, set organizationId (requires w_organization_social scope via Community Management API product). When both are set, organizationId takes precedence.
Token expiry: LinkedIn tokens expire after 60 days. Refresh manually or automate via
OAuthTokenService.
X / Twitter
Auth: OAuth 2.0 Bearer Token
Scope: tweet.read, tweet.write, users.read
- Go to developer.x.com/en/portal
- Create a Project and an App within it
- Under User authentication settings → enable OAuth 2.0
- Set App permissions to Read and write
- Under Keys and tokens → generate Bearer Token
semanticDelivery: social: twitter: bearerToken: '%secret(TWITTER_BEARER_TOKEN)%'
Note: Free tier allows 1,500 tweets/month. The API requires a developer account with at least Basic access ($100/month) for tweet.write.
Auth: Page Access Token (Long-Lived)
Scope: pages_manage_posts, pages_read_engagement
Step 1 — Create a Facebook App:
- Go to developers.facebook.com → Create App
- Choose Business type
- Add the Facebook Login product
Step 2 — Get a Page Access Token:
- Open the Graph API Explorer
- Select your App from the dropdown
- Click Generate Access Token → grant
pages_manage_postspermission - Select the Page from the User or Page dropdown at top
- The token shown is now a Page Access Token
- Exchange for a Long-Lived Token (60 days):
curl "https://graph.facebook.com/v21.0/oauth/access_token?grant_type=fb_exchange_token&client_id=APP_ID&client_secret=APP_SECRET&fb_exchange_token=SHORT_LIVED_TOKEN"
Step 3 — Get your Page ID:
curl "https://graph.facebook.com/v21.0/me/accounts?access_token=YOUR_TOKEN"
The id field for your page is the Page ID.
semanticDelivery: social: facebook: accessToken: '%secret(FACEBOOK_ACCESS_TOKEN)%' pageId: '123456789012345'
Auth: Instagram Graph API via Facebook Page Token
Scope: instagram_basic, instagram_content_publish
Prerequisites: A Facebook Page linked to an Instagram Business/Creator account.
Step 1 — Get the Instagram Business Account ID:
curl "https://graph.facebook.com/v21.0/FACEBOOK_PAGE_ID?fields=instagram_business_account&access_token=PAGE_ACCESS_TOKEN"
The instagram_business_account.id is your Account ID.
Step 2 — Configure:
semanticDelivery: social: instagram: accessToken: '%secret(INSTAGRAM_ACCESS_TOKEN)%' # same as Facebook Page Token accountId: '17841400000000000'
Note: Instagram posting via Graph API requires an image or video. Text-only posts are not supported. The
InstagramTransformerhandles this by including hashtags and truncating to 2,200 characters.
Dev.to
Auth: API Key (no OAuth)
- Log in to dev.to
- Go to Settings → Extensions (or dev.to/settings/extensions)
- Under DEV Community API Keys → generate a new key
- Copy the key
semanticDelivery: blogging: devto: apiKey: '%secret(DEVTO_API_KEY)%'
Token does not expire. Posts are published as Markdown with canonical URL pointing back to the TYPO3 page.
Complete Site Config Example
# config/sites/my-site/config.yaml semanticDelivery: social: bluesky: handle: 'example.org' appPassword: '%secret(BLUESKY_APP_PASSWORD)%' linkedin: accessToken: '%secret(LINKEDIN_ACCESS_TOKEN)%' personId: 'abc123' organizationId: '' twitter: bearerToken: '%secret(TWITTER_BEARER_TOKEN)%' instagram: accessToken: '%secret(INSTAGRAM_ACCESS_TOKEN)%' accountId: '17841400000000' facebook: accessToken: '%secret(FACEBOOK_ACCESS_TOKEN)%' pageId: '123456789' blogging: devto: apiKey: '%secret(DEVTO_API_KEY)%'
Secret vs. public values:
| Type | Where | Examples |
|---|---|---|
| Tokens, passwords, API keys | %secret()% in YAML → /run/secrets/ or _FILE env |
accessToken, appPassword, bearerToken, apiKey |
| Public identifiers | Directly in YAML | handle, personId, organizationId, accountId, pageId |
CLI Commands
Detect new podcast episodes
# Dry run vendor/bin/typo3 semantic-delivery:detect-episodes --dry-run # Create distribution jobs vendor/bin/typo3 semantic-delivery:detect-episodes # Auto-publish without review vendor/bin/typo3 semantic-delivery:detect-episodes --auto-publish
Cache-Key v3.0.4+: Der Episode-Detector liest aus dem
content_blocks_collection_rssfeed-Cache mit'rssfeed_' . hash('sha256', $feedUrl). Muss mit dem Schreib-Key inmoselwal/content-blocks-collection'sPodcastFeedService::cacheIdentifierFor()übereinstimmen (vorher md5 — Mismatch führte dazu, dass der Detector keine Episoden fand). Eigenepodcast_hash_*/podcast_guids_*-Keys nutzen weiter md5, weil der Detector beide Seiten selbst pflegt.
Process pending jobs
vendor/bin/typo3 semantic-delivery:process-jobs
Recommended cron schedule
*/15 * * * * cd /app && vendor/bin/typo3 semantic-delivery:detect-episodes */5 * * * * cd /app && vendor/bin/typo3 semantic-delivery:process-jobs
Backend Module
Available under Content → Semantic Delivery (admin access):
- Preview — View transformed content per platform before publishing
- Queue — Manage pending distribution jobs
- Log — Review completed and failed distributions with external URLs
Adding a New Distributor
New distributors are auto-discovered via dependency injection:
- Create a class implementing
DistributorInterfaceinClasses/Infrastructure/Distribution/ - Optionally create a
TransformerInterfaceinClasses/Infrastructure/Transformer/ - Add a credential key mapping in
DistributionPlatformItemsProcFunc::CREDENTIAL_KEYS - Add the corresponding site setting in
settings.definitions.yamlandsettings.yaml
final class MastodonDistributor implements DistributorInterface { public function getIdentifier(): string { return 'mastodon'; } public function distribute(ChannelContent $content, string $platform, array $options = []): DistributionResult { // Call Mastodon API return DistributionResult::success($statusId, $statusUrl); } public function getAvailablePlatforms(): array { return ['mastodon']; } public function testConnection(): bool { return true; } }
The platform automatically appears in the page editor once credentials are configured for the site.
OAuth Token Service
For platforms requiring OAuth 2.0 with token refresh:
$oauthService->storeToken( platform: 'linkedin', siteIdentifier: 'example', accessToken: $accessToken, refreshToken: $refreshToken, expiresAt: time() + 3600, scope: 'w_organization_social', accountId: '12345678', accountName: 'Example Org', ); $token = $oauthService->getToken('linkedin', 'example'); if ($oauthService->isTokenExpired('linkedin', 'example')) { // Refresh token flow }
Tokens are encrypted at rest using AES-256-CBC with the TYPO3 encryption key.
Configuration Reference
Site Settings
| Setting | Type | Default | Description |
|---|---|---|---|
semanticDelivery.enabled |
bool | true |
Master switch |
semanticDelivery.schema.enabled |
bool | true |
JSON-LD generation |
semanticDelivery.schema.autoDetect |
bool | true |
Auto-detect schema types |
semanticDelivery.channels.socialMedia.enabled |
bool | true |
Social media channel |
semanticDelivery.distribution.enabled |
bool | true |
Distribution system |
semanticDelivery.distribution.defaultDistributor |
string | 'bluesky' |
Default distributor |
semanticDelivery.social.bluesky.handle |
string | '' |
Bluesky handle |
semanticDelivery.social.bluesky.appPassword |
string | '' |
Bluesky app password |
semanticDelivery.social.linkedin.accessToken |
string | '' |
LinkedIn OAuth token |
semanticDelivery.social.linkedin.organizationId |
string | '' |
LinkedIn org ID (w_organization_social) |
semanticDelivery.social.linkedin.personId |
string | '' |
LinkedIn person ID (w_member_social) |
semanticDelivery.social.twitter.bearerToken |
string | '' |
X/Twitter bearer token |
semanticDelivery.social.instagram.accessToken |
string | '' |
Instagram access token |
semanticDelivery.social.instagram.accountId |
string | '' |
Instagram account ID |
semanticDelivery.social.facebook.accessToken |
string | '' |
Facebook page token |
semanticDelivery.social.facebook.pageId |
string | '' |
Facebook page ID |
semanticDelivery.blogging.devto.apiKey |
string | '' |
Dev.to API key |
Development
composer install composer test # Unit tests composer phpstan # Static analysis (level 8) composer deptrac # DDD layer enforcement vendor/bin/php-cs-fixer fix # Code style (PER-CS3x0)
Dependencies
| Package | Type | Purpose |
|---|---|---|
koh/content-intelligence |
Optional | Content quality analysis |
moselwal/secret-resolver |
Suggested | %secret()% syntax for credentials |
koh/dev |
Dev | Shared QA tooling |
License
MIT — see LICENSE for details.