feat: add brand autofill from website URL in onboarding
Users on the Brand step can type their website URL and click
'Preencher' (Portuguese) / 'Autofill' (English). The backend fetches
their homepage, parses standard meta tags, and returns:
- name ← og:site_name | title (suffix stripped at ' | ', ' - ')
- description ← meta[name=description] | og:description
- language ← <html lang> mapped to en / pt-BR / es
- logo ← apple-touch-icon | largest link[rel*=icon] | og:image
The logo is downloaded, validated (mime whitelist, 2MB max), and
attached to the workspace's 'logo' media collection so the avatar
updates immediately.
Zero LLM calls, zero external APIs. Uses symfony/dom-crawler +
symfony/css-selector (newly required) for meta extraction. Everything
else (Http client, workspace media, Intervention) was already in the
project.
Security:
- SSRF guardrail: resolves the host, rejects private / loopback /
link-local ranges, enforces http(s) scheme on both the initial
page fetch and the logo download.
- Rate limited at 10 req/min per user via the throttle middleware
alias on the route.
- Logo content-type must be one of the allowed image mimes; wrong
types are silently dropped so users never see broken images.
UX:
- 'Autofill' button next to the website input, disabled until there
is a URL; shows a spinner while running.
- If a logo was captured, a small preview appears below the input
so users can see what was pulled before saving.
- Success and error paths both surface as vue-sonner toasts, with
translations in en / pt-BR / es.
- Failures leave the form untouched — nothing is destructively
overwritten if parsing gave us nothing.
Tests (16 new): action-level coverage for happy path, title-suffix
fallback, language code normalization across 6 locales, scheme
rejection, private-range SSRF rejection, implicit https prefixing,
empty sites, upstream errors, and wrong-mime logo rejection. Plus
two controller-level tests for the autofill endpoint.
2026-04-16 14:13:54 +00:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
use App\Actions\Ai\AutofillBrand;
|
feat: use LLM to polish brand autofill when provider is configured
When config('trypost.ai.text_provider') points to a provider whose
API key is populated (services.gemini.api_key or
services.openai.api_key), the autofill action pipes the homepage
markdown through a BrandAnalyzer agent with structured output to
produce higher-quality values for:
- brand_description — 2-3 sentences summarizing the company based on
the actual page content, not the raw meta description (which is
often generic SEO boilerplate)
- brand_tone — classified into one of our seven enum values from the
writing style on the page
- content_language — detected from the actual content (more reliable
than <html lang> which is often wrong)
- brand_voice_notes — concrete writing guidelines inferred from the
site's style, written in the detected language
When the LLM provider is NOT configured (open-source self-hosted
deploys without API keys), the action keeps the existing
deterministic meta-tag-only flow — no crash, no noise, no LLM cost.
When the LLM fails mid-request, we log a warning and fall back to
the meta-tag values so the user still gets something useful.
Stack additions:
- league/html-to-markdown ^5.1 converts the main body to clean
markdown for the LLM input (truncated to 4000 chars).
- BrandAnalyzer agent (Agent + HasStructuredOutput) with schema
enums matching our allowed tones and languages.
- resources/views/prompts/brand_analyzer.blade.php holds the
instructions, including explicit enum lists and examples of good
voice_notes.
Frontend: Brand.vue now also fills brand_tone and brand_voice_notes
from the response when present.
Tests (+3): LLM-configured happy path, no-credentials fallback
(asserts BrandAnalyzer is never prompted via preventStrayPrompts),
and LLM-exception fallback (meta tags win, brand_tone stays null).
2026-04-16 14:33:02 +00:00
|
|
|
use App\Ai\Agents\BrandAnalyzer;
|
2026-05-21 19:27:55 +00:00
|
|
|
use App\Services\Brand\BrandMetadata;
|
feat: add brand autofill from website URL in onboarding
Users on the Brand step can type their website URL and click
'Preencher' (Portuguese) / 'Autofill' (English). The backend fetches
their homepage, parses standard meta tags, and returns:
- name ← og:site_name | title (suffix stripped at ' | ', ' - ')
- description ← meta[name=description] | og:description
- language ← <html lang> mapped to en / pt-BR / es
- logo ← apple-touch-icon | largest link[rel*=icon] | og:image
The logo is downloaded, validated (mime whitelist, 2MB max), and
attached to the workspace's 'logo' media collection so the avatar
updates immediately.
Zero LLM calls, zero external APIs. Uses symfony/dom-crawler +
symfony/css-selector (newly required) for meta extraction. Everything
else (Http client, workspace media, Intervention) was already in the
project.
Security:
- SSRF guardrail: resolves the host, rejects private / loopback /
link-local ranges, enforces http(s) scheme on both the initial
page fetch and the logo download.
- Rate limited at 10 req/min per user via the throttle middleware
alias on the route.
- Logo content-type must be one of the allowed image mimes; wrong
types are silently dropped so users never see broken images.
UX:
- 'Autofill' button next to the website input, disabled until there
is a URL; shows a spinner while running.
- If a logo was captured, a small preview appears below the input
so users can see what was pulled before saving.
- Success and error paths both surface as vue-sonner toasts, with
translations in en / pt-BR / es.
- Failures leave the form untouched — nothing is destructively
overwritten if parsing gave us nothing.
Tests (16 new): action-level coverage for happy path, title-suffix
fallback, language code normalization across 6 locales, scheme
rejection, private-range SSRF rejection, implicit https prefixing,
empty sites, upstream errors, and wrong-mime logo rejection. Plus
two controller-level tests for the autofill endpoint.
2026-04-16 14:13:54 +00:00
|
|
|
use Illuminate\Http\Client\Request as HttpRequest;
|
|
|
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
|
|
|
|
|
|
beforeEach(function () {
|
feat: use LLM to polish brand autofill when provider is configured
When config('trypost.ai.text_provider') points to a provider whose
API key is populated (services.gemini.api_key or
services.openai.api_key), the autofill action pipes the homepage
markdown through a BrandAnalyzer agent with structured output to
produce higher-quality values for:
- brand_description — 2-3 sentences summarizing the company based on
the actual page content, not the raw meta description (which is
often generic SEO boilerplate)
- brand_tone — classified into one of our seven enum values from the
writing style on the page
- content_language — detected from the actual content (more reliable
than <html lang> which is often wrong)
- brand_voice_notes — concrete writing guidelines inferred from the
site's style, written in the detected language
When the LLM provider is NOT configured (open-source self-hosted
deploys without API keys), the action keeps the existing
deterministic meta-tag-only flow — no crash, no noise, no LLM cost.
When the LLM fails mid-request, we log a warning and fall back to
the meta-tag values so the user still gets something useful.
Stack additions:
- league/html-to-markdown ^5.1 converts the main body to clean
markdown for the LLM input (truncated to 4000 chars).
- BrandAnalyzer agent (Agent + HasStructuredOutput) with schema
enums matching our allowed tones and languages.
- resources/views/prompts/brand_analyzer.blade.php holds the
instructions, including explicit enum lists and examples of good
voice_notes.
Frontend: Brand.vue now also fills brand_tone and brand_voice_notes
from the response when present.
Tests (+3): LLM-configured happy path, no-credentials fallback
(asserts BrandAnalyzer is never prompted via preventStrayPrompts),
and LLM-exception fallback (meta tags win, brand_tone stays null).
2026-04-16 14:33:02 +00:00
|
|
|
// Run tests without LLM credentials so the deterministic fallback is exercised.
|
Fix OpenRouter via laravel/ai default provider (revises #216) (#220)
* Fix OpenRouter support by using laravel/ai default provider config.
PR #216 patched Lab::OpenRouter into every agent match, but laravel/ai
already resolves config('ai.default') — including openrouter — when agents
omit provider(). Those matches also forced unknown providers to Gemini and
BrandAnalyzerRunner checked a non-existent services.openrouter key.
Remove the duplicated provider() overrides, gate availability on
ai.providers.*.key, and document OPENROUTER_API_KEY.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* chore(deps): bump laravel/ai to v0.10.3 for native OpenRouter support
v0.5.1's OpenRouter driver only covered text/embeddings via the legacy
Prism gateway. v0.10.3 ships a native OpenRouter gateway with image,
audio (TTS/STT), and web search support, and drops prism-php/prism as
a dependency. ai-sdk-development skill docs refreshed via boost:update
to match the installed version.
* fix: honor AI_IMAGE_PROVIDER instead of hardcoding OpenAI's gpt-image-2
AiImageClient always passed model: 'gpt-image-2' to Image::of()->generate(),
so AI_IMAGE_PROVIDER silently did nothing for any provider other than
OpenAI — gemini/xai/openrouter would fail against an OpenAI-only model id
and quietly fall back to a stock photo. Usage recording was hardcoded to
provider 'openai' too, so credits were billed against the wrong model
whenever a different provider actually ran.
Drop the hardcoded model so generation falls through to the SDK's own
config('ai.default_for_images') + per-provider default model, and read
the actual provider/model back off the response's meta for usage
recording and source_meta instead of assuming OpenAI.
* refactor: extract AiImageClient into single-purpose steps, fix uncaught exception
generate() built the prompt, called the SDK, and unpacked the response all
in one block, with bytes extraction happening after the try/catch — so a
response with an empty images collection threw an uncaught RuntimeException
from ImageResponse::firstImage() instead of returning null as documented.
Split into cleanKeywords(), buildPrompt(), resolveBrandContext(), and
toResult(), and moved response unpacking inside the try block so any
malformed response is treated as a failure like everything else. Added a
regression test with an empty-images fake response.
* fix: drop hardcoded default_text_model, resolve model per provider
default_text_model was the only per-modality model override in ai.php —
image, audio, transcription, embeddings, and reranking all just pick a
provider and let it use its own default model. Text had a config-pinned
model on top, forced into every agent's model() and into every usage
log's model field regardless of which provider actually ran. Switching
AI_TEXT_PROVIDER (e.g. to openrouter) kept sending OpenAI's model id to
whichever provider ended up handling the request.
Removed model() from all six agents so laravel/ai resolves the model
from the active provider's own default (OpenAI's default is already
'gpt-5.4', so no behavior change there). Usage-recording call sites now
read the actual provider/model back off the response's meta instead of
assuming config('ai.default')/default_text_model. StreamPostContent
needed the then() callback since broadcast()'s StreamableAgentResponse
doesn't expose meta directly.
* refactor: drop AiConfiguration wrapper, use data_get() for array reads
AiConfiguration was a one-line static helper used by only two call
sites, with no laravel/ai equivalent to lean on (confirmed AiManager
and the Provider base class expose no isConfigured()/hasKey() check —
the package's model is try-then-catch, not pre-flight checks). Inlined
the filled(config(...)) check directly into HandleInertiaRequests and
BrandAnalyzerRunner instead of keeping a class around one line of logic.
Also swapped direct array-key reads for data_get() per project
convention across every file touched by the recent AI provider/model
fixes (agents' budget arrays, RunGenerateNode/StreamPostCreation's
humanizer merge, RegeneratePostMediaImage's baseContext/copy/rendered
access). Write/assignment sites (`$x['key'] = ...`) are left as-is —
data_get() only reads.
* feat: enhance AI configuration with new providers and options
Added strict types declaration and updated Azure OpenAI API version. Introduced new 'bedrock' provider configuration with AWS credentials and role assumptions. Enhanced existing providers with additional options, including image deployment for Azure and OpenAI, and updated URLs for Gemini and Ollama. Added support for an 'openai-compatible' driver to broaden integration capabilities.
* fix: remove trailing newline in AI configuration file
* feat: add support for OpenRouter and ElevenLabs API keys in configuration
Updated the production Docker Compose file and example environment file to include commented-out entries for OPENROUTER_API_KEY and ELEVENLABS_API_KEY. This enhances the configuration options for AI providers, allowing for easier integration of additional services.
* feat: allow per-provider model overrides for every AI modality
Adds a `models` array to each provider block, wired to env vars, so
self-hosted operators can pin a specific text/image/audio/transcription/
embeddings/reranking model instead of relying on the package's built-in
default for that provider. Only added for the modalities each provider
actually implements (verified against laravel/ai's Provider classes).
Azure is left untouched — it resolves models via deployment names
(AZURE_OPENAI_DEPLOYMENT etc.), not raw model strings, which was already
wired before this change.
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-08-11 00:38:50 +00:00
|
|
|
config()->set('ai.providers.gemini.key', '');
|
|
|
|
|
config()->set('ai.providers.openai.key', '');
|
|
|
|
|
config()->set('ai.providers.openrouter.key', '');
|
|
|
|
|
config()->set('ai.providers.anthropic.key', '');
|
2026-04-17 02:05:51 +00:00
|
|
|
|
|
|
|
|
$this->autofill = fn (string $url) => app(AutofillBrand::class)($url);
|
feat: add brand autofill from website URL in onboarding
Users on the Brand step can type their website URL and click
'Preencher' (Portuguese) / 'Autofill' (English). The backend fetches
their homepage, parses standard meta tags, and returns:
- name ← og:site_name | title (suffix stripped at ' | ', ' - ')
- description ← meta[name=description] | og:description
- language ← <html lang> mapped to en / pt-BR / es
- logo ← apple-touch-icon | largest link[rel*=icon] | og:image
The logo is downloaded, validated (mime whitelist, 2MB max), and
attached to the workspace's 'logo' media collection so the avatar
updates immediately.
Zero LLM calls, zero external APIs. Uses symfony/dom-crawler +
symfony/css-selector (newly required) for meta extraction. Everything
else (Http client, workspace media, Intervention) was already in the
project.
Security:
- SSRF guardrail: resolves the host, rejects private / loopback /
link-local ranges, enforces http(s) scheme on both the initial
page fetch and the logo download.
- Rate limited at 10 req/min per user via the throttle middleware
alias on the route.
- Logo content-type must be one of the allowed image mimes; wrong
types are silently dropped so users never see broken images.
UX:
- 'Autofill' button next to the website input, disabled until there
is a URL; shows a spinner while running.
- If a logo was captured, a small preview appears below the input
so users can see what was pulled before saving.
- Success and error paths both surface as vue-sonner toasts, with
translations in en / pt-BR / es.
- Failures leave the form untouched — nothing is destructively
overwritten if parsing gave us nothing.
Tests (16 new): action-level coverage for happy path, title-suffix
fallback, language code normalization across 6 locales, scheme
rejection, private-range SSRF rejection, implicit https prefixing,
empty sites, upstream errors, and wrong-mime logo rejection. Plus
two controller-level tests for the autofill endpoint.
2026-04-16 14:13:54 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('extracts name, description, language, and logo from meta tags', function () {
|
|
|
|
|
Http::fake([
|
|
|
|
|
'example.com' => Http::response(<<<'HTML'
|
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
<html lang="pt-BR">
|
|
|
|
|
<head>
|
|
|
|
|
<title>Acme Coffee | The best beans online</title>
|
|
|
|
|
<meta name="description" content="Premium artisan coffee beans shipped worldwide.">
|
|
|
|
|
<meta property="og:site_name" content="Acme Coffee">
|
|
|
|
|
<meta property="og:image" content="https://example.com/og.png">
|
|
|
|
|
<link rel="apple-touch-icon" href="https://example.com/apple-touch-icon.png">
|
|
|
|
|
<link rel="icon" sizes="512x512" href="/icon-512.png">
|
|
|
|
|
</head>
|
|
|
|
|
<body>Welcome</body>
|
|
|
|
|
</html>
|
|
|
|
|
HTML, 200),
|
2026-04-17 02:05:51 +00:00
|
|
|
'example.com/icon-512.png' => Http::response(file_get_contents(__DIR__.'/../../fixtures/1x1.png'), 200, ['Content-Type' => 'image/png']),
|
feat: add brand autofill from website URL in onboarding
Users on the Brand step can type their website URL and click
'Preencher' (Portuguese) / 'Autofill' (English). The backend fetches
their homepage, parses standard meta tags, and returns:
- name ← og:site_name | title (suffix stripped at ' | ', ' - ')
- description ← meta[name=description] | og:description
- language ← <html lang> mapped to en / pt-BR / es
- logo ← apple-touch-icon | largest link[rel*=icon] | og:image
The logo is downloaded, validated (mime whitelist, 2MB max), and
attached to the workspace's 'logo' media collection so the avatar
updates immediately.
Zero LLM calls, zero external APIs. Uses symfony/dom-crawler +
symfony/css-selector (newly required) for meta extraction. Everything
else (Http client, workspace media, Intervention) was already in the
project.
Security:
- SSRF guardrail: resolves the host, rejects private / loopback /
link-local ranges, enforces http(s) scheme on both the initial
page fetch and the logo download.
- Rate limited at 10 req/min per user via the throttle middleware
alias on the route.
- Logo content-type must be one of the allowed image mimes; wrong
types are silently dropped so users never see broken images.
UX:
- 'Autofill' button next to the website input, disabled until there
is a URL; shows a spinner while running.
- If a logo was captured, a small preview appears below the input
so users can see what was pulled before saving.
- Success and error paths both surface as vue-sonner toasts, with
translations in en / pt-BR / es.
- Failures leave the form untouched — nothing is destructively
overwritten if parsing gave us nothing.
Tests (16 new): action-level coverage for happy path, title-suffix
fallback, language code normalization across 6 locales, scheme
rejection, private-range SSRF rejection, implicit https prefixing,
empty sites, upstream errors, and wrong-mime logo rejection. Plus
two controller-level tests for the autofill endpoint.
2026-04-16 14:13:54 +00:00
|
|
|
]);
|
|
|
|
|
|
2026-04-17 02:05:51 +00:00
|
|
|
$result = ($this->autofill)('https://example.com');
|
|
|
|
|
|
|
|
|
|
expect($result->name)->toBe('Acme Coffee');
|
|
|
|
|
expect($result->description)->toBe('Premium artisan coffee beans shipped worldwide.');
|
|
|
|
|
expect($result->language)->toBe('pt-BR');
|
|
|
|
|
// The 512x512 PNG favicon wins over the unsized apple-touch-icon; og:image is ignored.
|
|
|
|
|
expect($result->logoUrl)->toBe('https://example.com/icon-512.png');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('falls back to /favicon.ico when no icon link is declared', function () {
|
|
|
|
|
Http::fake([
|
|
|
|
|
'example.com' => Http::response('<html><head><title>Foo</title></head></html>', 200),
|
|
|
|
|
]);
|
feat: add brand autofill from website URL in onboarding
Users on the Brand step can type their website URL and click
'Preencher' (Portuguese) / 'Autofill' (English). The backend fetches
their homepage, parses standard meta tags, and returns:
- name ← og:site_name | title (suffix stripped at ' | ', ' - ')
- description ← meta[name=description] | og:description
- language ← <html lang> mapped to en / pt-BR / es
- logo ← apple-touch-icon | largest link[rel*=icon] | og:image
The logo is downloaded, validated (mime whitelist, 2MB max), and
attached to the workspace's 'logo' media collection so the avatar
updates immediately.
Zero LLM calls, zero external APIs. Uses symfony/dom-crawler +
symfony/css-selector (newly required) for meta extraction. Everything
else (Http client, workspace media, Intervention) was already in the
project.
Security:
- SSRF guardrail: resolves the host, rejects private / loopback /
link-local ranges, enforces http(s) scheme on both the initial
page fetch and the logo download.
- Rate limited at 10 req/min per user via the throttle middleware
alias on the route.
- Logo content-type must be one of the allowed image mimes; wrong
types are silently dropped so users never see broken images.
UX:
- 'Autofill' button next to the website input, disabled until there
is a URL; shows a spinner while running.
- If a logo was captured, a small preview appears below the input
so users can see what was pulled before saving.
- Success and error paths both surface as vue-sonner toasts, with
translations in en / pt-BR / es.
- Failures leave the form untouched — nothing is destructively
overwritten if parsing gave us nothing.
Tests (16 new): action-level coverage for happy path, title-suffix
fallback, language code normalization across 6 locales, scheme
rejection, private-range SSRF rejection, implicit https prefixing,
empty sites, upstream errors, and wrong-mime logo rejection. Plus
two controller-level tests for the autofill endpoint.
2026-04-16 14:13:54 +00:00
|
|
|
|
2026-04-17 02:05:51 +00:00
|
|
|
$result = ($this->autofill)('https://example.com');
|
feat: add brand autofill from website URL in onboarding
Users on the Brand step can type their website URL and click
'Preencher' (Portuguese) / 'Autofill' (English). The backend fetches
their homepage, parses standard meta tags, and returns:
- name ← og:site_name | title (suffix stripped at ' | ', ' - ')
- description ← meta[name=description] | og:description
- language ← <html lang> mapped to en / pt-BR / es
- logo ← apple-touch-icon | largest link[rel*=icon] | og:image
The logo is downloaded, validated (mime whitelist, 2MB max), and
attached to the workspace's 'logo' media collection so the avatar
updates immediately.
Zero LLM calls, zero external APIs. Uses symfony/dom-crawler +
symfony/css-selector (newly required) for meta extraction. Everything
else (Http client, workspace media, Intervention) was already in the
project.
Security:
- SSRF guardrail: resolves the host, rejects private / loopback /
link-local ranges, enforces http(s) scheme on both the initial
page fetch and the logo download.
- Rate limited at 10 req/min per user via the throttle middleware
alias on the route.
- Logo content-type must be one of the allowed image mimes; wrong
types are silently dropped so users never see broken images.
UX:
- 'Autofill' button next to the website input, disabled until there
is a URL; shows a spinner while running.
- If a logo was captured, a small preview appears below the input
so users can see what was pulled before saving.
- Success and error paths both surface as vue-sonner toasts, with
translations in en / pt-BR / es.
- Failures leave the form untouched — nothing is destructively
overwritten if parsing gave us nothing.
Tests (16 new): action-level coverage for happy path, title-suffix
fallback, language code normalization across 6 locales, scheme
rejection, private-range SSRF rejection, implicit https prefixing,
empty sites, upstream errors, and wrong-mime logo rejection. Plus
two controller-level tests for the autofill endpoint.
2026-04-16 14:13:54 +00:00
|
|
|
|
2026-04-17 02:05:51 +00:00
|
|
|
expect($result->logoUrl)->toBe('https://example.com/favicon.ico');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('ignores og:image when no icon links are present', function () {
|
|
|
|
|
Http::fake([
|
|
|
|
|
'example.com/page' => Http::response(<<<'HTML'
|
|
|
|
|
<html>
|
|
|
|
|
<head>
|
|
|
|
|
<title>Marketing</title>
|
|
|
|
|
<meta property="og:image" content="https://example.com/social-card.png">
|
|
|
|
|
</head>
|
|
|
|
|
</html>
|
|
|
|
|
HTML, 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = ($this->autofill)('https://example.com/page');
|
|
|
|
|
|
|
|
|
|
// og:image is NOT used — falls back to /favicon.ico at the origin instead.
|
|
|
|
|
expect($result->logoUrl)->toBe('https://example.com/favicon.ico');
|
feat: add brand autofill from website URL in onboarding
Users on the Brand step can type their website URL and click
'Preencher' (Portuguese) / 'Autofill' (English). The backend fetches
their homepage, parses standard meta tags, and returns:
- name ← og:site_name | title (suffix stripped at ' | ', ' - ')
- description ← meta[name=description] | og:description
- language ← <html lang> mapped to en / pt-BR / es
- logo ← apple-touch-icon | largest link[rel*=icon] | og:image
The logo is downloaded, validated (mime whitelist, 2MB max), and
attached to the workspace's 'logo' media collection so the avatar
updates immediately.
Zero LLM calls, zero external APIs. Uses symfony/dom-crawler +
symfony/css-selector (newly required) for meta extraction. Everything
else (Http client, workspace media, Intervention) was already in the
project.
Security:
- SSRF guardrail: resolves the host, rejects private / loopback /
link-local ranges, enforces http(s) scheme on both the initial
page fetch and the logo download.
- Rate limited at 10 req/min per user via the throttle middleware
alias on the route.
- Logo content-type must be one of the allowed image mimes; wrong
types are silently dropped so users never see broken images.
UX:
- 'Autofill' button next to the website input, disabled until there
is a URL; shows a spinner while running.
- If a logo was captured, a small preview appears below the input
so users can see what was pulled before saving.
- Success and error paths both surface as vue-sonner toasts, with
translations in en / pt-BR / es.
- Failures leave the form untouched — nothing is destructively
overwritten if parsing gave us nothing.
Tests (16 new): action-level coverage for happy path, title-suffix
fallback, language code normalization across 6 locales, scheme
rejection, private-range SSRF rejection, implicit https prefixing,
empty sites, upstream errors, and wrong-mime logo rejection. Plus
two controller-level tests for the autofill endpoint.
2026-04-16 14:13:54 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('falls back to title without og:site_name', function () {
|
|
|
|
|
Http::fake([
|
|
|
|
|
'example.com' => Http::response(<<<'HTML'
|
|
|
|
|
<html lang="en">
|
|
|
|
|
<head>
|
|
|
|
|
<title>Super SaaS | Landing page</title>
|
|
|
|
|
<meta name="description" content="A simple product.">
|
|
|
|
|
</head>
|
|
|
|
|
<body></body>
|
|
|
|
|
</html>
|
|
|
|
|
HTML, 200),
|
|
|
|
|
]);
|
|
|
|
|
|
2026-04-17 02:05:51 +00:00
|
|
|
$result = ($this->autofill)('https://example.com');
|
feat: add brand autofill from website URL in onboarding
Users on the Brand step can type their website URL and click
'Preencher' (Portuguese) / 'Autofill' (English). The backend fetches
their homepage, parses standard meta tags, and returns:
- name ← og:site_name | title (suffix stripped at ' | ', ' - ')
- description ← meta[name=description] | og:description
- language ← <html lang> mapped to en / pt-BR / es
- logo ← apple-touch-icon | largest link[rel*=icon] | og:image
The logo is downloaded, validated (mime whitelist, 2MB max), and
attached to the workspace's 'logo' media collection so the avatar
updates immediately.
Zero LLM calls, zero external APIs. Uses symfony/dom-crawler +
symfony/css-selector (newly required) for meta extraction. Everything
else (Http client, workspace media, Intervention) was already in the
project.
Security:
- SSRF guardrail: resolves the host, rejects private / loopback /
link-local ranges, enforces http(s) scheme on both the initial
page fetch and the logo download.
- Rate limited at 10 req/min per user via the throttle middleware
alias on the route.
- Logo content-type must be one of the allowed image mimes; wrong
types are silently dropped so users never see broken images.
UX:
- 'Autofill' button next to the website input, disabled until there
is a URL; shows a spinner while running.
- If a logo was captured, a small preview appears below the input
so users can see what was pulled before saving.
- Success and error paths both surface as vue-sonner toasts, with
translations in en / pt-BR / es.
- Failures leave the form untouched — nothing is destructively
overwritten if parsing gave us nothing.
Tests (16 new): action-level coverage for happy path, title-suffix
fallback, language code normalization across 6 locales, scheme
rejection, private-range SSRF rejection, implicit https prefixing,
empty sites, upstream errors, and wrong-mime logo rejection. Plus
two controller-level tests for the autofill endpoint.
2026-04-16 14:13:54 +00:00
|
|
|
|
2026-04-17 02:05:51 +00:00
|
|
|
expect($result->name)->toBe('Super SaaS');
|
|
|
|
|
expect($result->language)->toBe('en');
|
feat: add brand autofill from website URL in onboarding
Users on the Brand step can type their website URL and click
'Preencher' (Portuguese) / 'Autofill' (English). The backend fetches
their homepage, parses standard meta tags, and returns:
- name ← og:site_name | title (suffix stripped at ' | ', ' - ')
- description ← meta[name=description] | og:description
- language ← <html lang> mapped to en / pt-BR / es
- logo ← apple-touch-icon | largest link[rel*=icon] | og:image
The logo is downloaded, validated (mime whitelist, 2MB max), and
attached to the workspace's 'logo' media collection so the avatar
updates immediately.
Zero LLM calls, zero external APIs. Uses symfony/dom-crawler +
symfony/css-selector (newly required) for meta extraction. Everything
else (Http client, workspace media, Intervention) was already in the
project.
Security:
- SSRF guardrail: resolves the host, rejects private / loopback /
link-local ranges, enforces http(s) scheme on both the initial
page fetch and the logo download.
- Rate limited at 10 req/min per user via the throttle middleware
alias on the route.
- Logo content-type must be one of the allowed image mimes; wrong
types are silently dropped so users never see broken images.
UX:
- 'Autofill' button next to the website input, disabled until there
is a URL; shows a spinner while running.
- If a logo was captured, a small preview appears below the input
so users can see what was pulled before saving.
- Success and error paths both surface as vue-sonner toasts, with
translations in en / pt-BR / es.
- Failures leave the form untouched — nothing is destructively
overwritten if parsing gave us nothing.
Tests (16 new): action-level coverage for happy path, title-suffix
fallback, language code normalization across 6 locales, scheme
rejection, private-range SSRF rejection, implicit https prefixing,
empty sites, upstream errors, and wrong-mime logo rejection. Plus
two controller-level tests for the autofill endpoint.
2026-04-16 14:13:54 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('normalizes various language codes to supported locales', function (string $lang, ?string $expected) {
|
|
|
|
|
Http::fake([
|
|
|
|
|
'example.com' => Http::response("<html lang=\"{$lang}\"><head><title>X</title></head></html>", 200),
|
|
|
|
|
]);
|
|
|
|
|
|
2026-04-17 02:05:51 +00:00
|
|
|
$result = ($this->autofill)('https://example.com');
|
feat: add brand autofill from website URL in onboarding
Users on the Brand step can type their website URL and click
'Preencher' (Portuguese) / 'Autofill' (English). The backend fetches
their homepage, parses standard meta tags, and returns:
- name ← og:site_name | title (suffix stripped at ' | ', ' - ')
- description ← meta[name=description] | og:description
- language ← <html lang> mapped to en / pt-BR / es
- logo ← apple-touch-icon | largest link[rel*=icon] | og:image
The logo is downloaded, validated (mime whitelist, 2MB max), and
attached to the workspace's 'logo' media collection so the avatar
updates immediately.
Zero LLM calls, zero external APIs. Uses symfony/dom-crawler +
symfony/css-selector (newly required) for meta extraction. Everything
else (Http client, workspace media, Intervention) was already in the
project.
Security:
- SSRF guardrail: resolves the host, rejects private / loopback /
link-local ranges, enforces http(s) scheme on both the initial
page fetch and the logo download.
- Rate limited at 10 req/min per user via the throttle middleware
alias on the route.
- Logo content-type must be one of the allowed image mimes; wrong
types are silently dropped so users never see broken images.
UX:
- 'Autofill' button next to the website input, disabled until there
is a URL; shows a spinner while running.
- If a logo was captured, a small preview appears below the input
so users can see what was pulled before saving.
- Success and error paths both surface as vue-sonner toasts, with
translations in en / pt-BR / es.
- Failures leave the form untouched — nothing is destructively
overwritten if parsing gave us nothing.
Tests (16 new): action-level coverage for happy path, title-suffix
fallback, language code normalization across 6 locales, scheme
rejection, private-range SSRF rejection, implicit https prefixing,
empty sites, upstream errors, and wrong-mime logo rejection. Plus
two controller-level tests for the autofill endpoint.
2026-04-16 14:13:54 +00:00
|
|
|
|
2026-04-17 02:05:51 +00:00
|
|
|
expect($result->language)->toBe($expected);
|
feat: add brand autofill from website URL in onboarding
Users on the Brand step can type their website URL and click
'Preencher' (Portuguese) / 'Autofill' (English). The backend fetches
their homepage, parses standard meta tags, and returns:
- name ← og:site_name | title (suffix stripped at ' | ', ' - ')
- description ← meta[name=description] | og:description
- language ← <html lang> mapped to en / pt-BR / es
- logo ← apple-touch-icon | largest link[rel*=icon] | og:image
The logo is downloaded, validated (mime whitelist, 2MB max), and
attached to the workspace's 'logo' media collection so the avatar
updates immediately.
Zero LLM calls, zero external APIs. Uses symfony/dom-crawler +
symfony/css-selector (newly required) for meta extraction. Everything
else (Http client, workspace media, Intervention) was already in the
project.
Security:
- SSRF guardrail: resolves the host, rejects private / loopback /
link-local ranges, enforces http(s) scheme on both the initial
page fetch and the logo download.
- Rate limited at 10 req/min per user via the throttle middleware
alias on the route.
- Logo content-type must be one of the allowed image mimes; wrong
types are silently dropped so users never see broken images.
UX:
- 'Autofill' button next to the website input, disabled until there
is a URL; shows a spinner while running.
- If a logo was captured, a small preview appears below the input
so users can see what was pulled before saving.
- Success and error paths both surface as vue-sonner toasts, with
translations in en / pt-BR / es.
- Failures leave the form untouched — nothing is destructively
overwritten if parsing gave us nothing.
Tests (16 new): action-level coverage for happy path, title-suffix
fallback, language code normalization across 6 locales, scheme
rejection, private-range SSRF rejection, implicit https prefixing,
empty sites, upstream errors, and wrong-mime logo rejection. Plus
two controller-level tests for the autofill endpoint.
2026-04-16 14:13:54 +00:00
|
|
|
})->with([
|
2026-07-03 18:39:35 +00:00
|
|
|
// Every supported language, detected from its primary subtag.
|
|
|
|
|
['en', 'en'],
|
2026-08-05 22:45:30 +00:00
|
|
|
['uk', 'uk'],
|
feat: add brand autofill from website URL in onboarding
Users on the Brand step can type their website URL and click
'Preencher' (Portuguese) / 'Autofill' (English). The backend fetches
their homepage, parses standard meta tags, and returns:
- name ← og:site_name | title (suffix stripped at ' | ', ' - ')
- description ← meta[name=description] | og:description
- language ← <html lang> mapped to en / pt-BR / es
- logo ← apple-touch-icon | largest link[rel*=icon] | og:image
The logo is downloaded, validated (mime whitelist, 2MB max), and
attached to the workspace's 'logo' media collection so the avatar
updates immediately.
Zero LLM calls, zero external APIs. Uses symfony/dom-crawler +
symfony/css-selector (newly required) for meta extraction. Everything
else (Http client, workspace media, Intervention) was already in the
project.
Security:
- SSRF guardrail: resolves the host, rejects private / loopback /
link-local ranges, enforces http(s) scheme on both the initial
page fetch and the logo download.
- Rate limited at 10 req/min per user via the throttle middleware
alias on the route.
- Logo content-type must be one of the allowed image mimes; wrong
types are silently dropped so users never see broken images.
UX:
- 'Autofill' button next to the website input, disabled until there
is a URL; shows a spinner while running.
- If a logo was captured, a small preview appears below the input
so users can see what was pulled before saving.
- Success and error paths both surface as vue-sonner toasts, with
translations in en / pt-BR / es.
- Failures leave the form untouched — nothing is destructively
overwritten if parsing gave us nothing.
Tests (16 new): action-level coverage for happy path, title-suffix
fallback, language code normalization across 6 locales, scheme
rejection, private-range SSRF rejection, implicit https prefixing,
empty sites, upstream errors, and wrong-mime logo rejection. Plus
two controller-level tests for the autofill endpoint.
2026-04-16 14:13:54 +00:00
|
|
|
['pt', 'pt-BR'],
|
2026-07-03 18:39:35 +00:00
|
|
|
['es', 'es'],
|
|
|
|
|
['fr', 'fr'],
|
|
|
|
|
['de', 'de'],
|
|
|
|
|
['it', 'it'],
|
|
|
|
|
['nl', 'nl'],
|
|
|
|
|
['pl', 'pl'],
|
|
|
|
|
['el', 'el'],
|
|
|
|
|
['ja', 'ja'],
|
|
|
|
|
['ko', 'ko'],
|
|
|
|
|
['zh', 'zh'],
|
|
|
|
|
['ru', 'ru'],
|
|
|
|
|
['tr', 'tr'],
|
|
|
|
|
['ar', 'ar'],
|
|
|
|
|
// Region/script subtags still resolve to the supported language.
|
feat: add brand autofill from website URL in onboarding
Users on the Brand step can type their website URL and click
'Preencher' (Portuguese) / 'Autofill' (English). The backend fetches
their homepage, parses standard meta tags, and returns:
- name ← og:site_name | title (suffix stripped at ' | ', ' - ')
- description ← meta[name=description] | og:description
- language ← <html lang> mapped to en / pt-BR / es
- logo ← apple-touch-icon | largest link[rel*=icon] | og:image
The logo is downloaded, validated (mime whitelist, 2MB max), and
attached to the workspace's 'logo' media collection so the avatar
updates immediately.
Zero LLM calls, zero external APIs. Uses symfony/dom-crawler +
symfony/css-selector (newly required) for meta extraction. Everything
else (Http client, workspace media, Intervention) was already in the
project.
Security:
- SSRF guardrail: resolves the host, rejects private / loopback /
link-local ranges, enforces http(s) scheme on both the initial
page fetch and the logo download.
- Rate limited at 10 req/min per user via the throttle middleware
alias on the route.
- Logo content-type must be one of the allowed image mimes; wrong
types are silently dropped so users never see broken images.
UX:
- 'Autofill' button next to the website input, disabled until there
is a URL; shows a spinner while running.
- If a logo was captured, a small preview appears below the input
so users can see what was pulled before saving.
- Success and error paths both surface as vue-sonner toasts, with
translations in en / pt-BR / es.
- Failures leave the form untouched — nothing is destructively
overwritten if parsing gave us nothing.
Tests (16 new): action-level coverage for happy path, title-suffix
fallback, language code normalization across 6 locales, scheme
rejection, private-range SSRF rejection, implicit https prefixing,
empty sites, upstream errors, and wrong-mime logo rejection. Plus
two controller-level tests for the autofill endpoint.
2026-04-16 14:13:54 +00:00
|
|
|
['pt-PT', 'pt-BR'],
|
|
|
|
|
['en-US', 'en'],
|
2026-08-05 22:45:30 +00:00
|
|
|
['uk-UA', 'uk'],
|
feat: add brand autofill from website URL in onboarding
Users on the Brand step can type their website URL and click
'Preencher' (Portuguese) / 'Autofill' (English). The backend fetches
their homepage, parses standard meta tags, and returns:
- name ← og:site_name | title (suffix stripped at ' | ', ' - ')
- description ← meta[name=description] | og:description
- language ← <html lang> mapped to en / pt-BR / es
- logo ← apple-touch-icon | largest link[rel*=icon] | og:image
The logo is downloaded, validated (mime whitelist, 2MB max), and
attached to the workspace's 'logo' media collection so the avatar
updates immediately.
Zero LLM calls, zero external APIs. Uses symfony/dom-crawler +
symfony/css-selector (newly required) for meta extraction. Everything
else (Http client, workspace media, Intervention) was already in the
project.
Security:
- SSRF guardrail: resolves the host, rejects private / loopback /
link-local ranges, enforces http(s) scheme on both the initial
page fetch and the logo download.
- Rate limited at 10 req/min per user via the throttle middleware
alias on the route.
- Logo content-type must be one of the allowed image mimes; wrong
types are silently dropped so users never see broken images.
UX:
- 'Autofill' button next to the website input, disabled until there
is a URL; shows a spinner while running.
- If a logo was captured, a small preview appears below the input
so users can see what was pulled before saving.
- Success and error paths both surface as vue-sonner toasts, with
translations in en / pt-BR / es.
- Failures leave the form untouched — nothing is destructively
overwritten if parsing gave us nothing.
Tests (16 new): action-level coverage for happy path, title-suffix
fallback, language code normalization across 6 locales, scheme
rejection, private-range SSRF rejection, implicit https prefixing,
empty sites, upstream errors, and wrong-mime logo rejection. Plus
two controller-level tests for the autofill endpoint.
2026-04-16 14:13:54 +00:00
|
|
|
['es-MX', 'es'],
|
2026-07-03 17:12:25 +00:00
|
|
|
['ja-JP', 'ja'],
|
|
|
|
|
['zh-Hans', 'zh'],
|
2026-07-03 18:39:35 +00:00
|
|
|
// Unsupported languages stay null.
|
2026-07-03 17:12:25 +00:00
|
|
|
['sv', null],
|
feat: add brand autofill from website URL in onboarding
Users on the Brand step can type their website URL and click
'Preencher' (Portuguese) / 'Autofill' (English). The backend fetches
their homepage, parses standard meta tags, and returns:
- name ← og:site_name | title (suffix stripped at ' | ', ' - ')
- description ← meta[name=description] | og:description
- language ← <html lang> mapped to en / pt-BR / es
- logo ← apple-touch-icon | largest link[rel*=icon] | og:image
The logo is downloaded, validated (mime whitelist, 2MB max), and
attached to the workspace's 'logo' media collection so the avatar
updates immediately.
Zero LLM calls, zero external APIs. Uses symfony/dom-crawler +
symfony/css-selector (newly required) for meta extraction. Everything
else (Http client, workspace media, Intervention) was already in the
project.
Security:
- SSRF guardrail: resolves the host, rejects private / loopback /
link-local ranges, enforces http(s) scheme on both the initial
page fetch and the logo download.
- Rate limited at 10 req/min per user via the throttle middleware
alias on the route.
- Logo content-type must be one of the allowed image mimes; wrong
types are silently dropped so users never see broken images.
UX:
- 'Autofill' button next to the website input, disabled until there
is a URL; shows a spinner while running.
- If a logo was captured, a small preview appears below the input
so users can see what was pulled before saving.
- Success and error paths both surface as vue-sonner toasts, with
translations in en / pt-BR / es.
- Failures leave the form untouched — nothing is destructively
overwritten if parsing gave us nothing.
Tests (16 new): action-level coverage for happy path, title-suffix
fallback, language code normalization across 6 locales, scheme
rejection, private-range SSRF rejection, implicit https prefixing,
empty sites, upstream errors, and wrong-mime logo rejection. Plus
two controller-level tests for the autofill endpoint.
2026-04-16 14:13:54 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
test('rejects non-http schemes', function () {
|
2026-04-17 02:05:51 +00:00
|
|
|
expect(fn () => ($this->autofill)('ftp://example.com'))
|
|
|
|
|
->toThrow(RuntimeException::class);
|
feat: add brand autofill from website URL in onboarding
Users on the Brand step can type their website URL and click
'Preencher' (Portuguese) / 'Autofill' (English). The backend fetches
their homepage, parses standard meta tags, and returns:
- name ← og:site_name | title (suffix stripped at ' | ', ' - ')
- description ← meta[name=description] | og:description
- language ← <html lang> mapped to en / pt-BR / es
- logo ← apple-touch-icon | largest link[rel*=icon] | og:image
The logo is downloaded, validated (mime whitelist, 2MB max), and
attached to the workspace's 'logo' media collection so the avatar
updates immediately.
Zero LLM calls, zero external APIs. Uses symfony/dom-crawler +
symfony/css-selector (newly required) for meta extraction. Everything
else (Http client, workspace media, Intervention) was already in the
project.
Security:
- SSRF guardrail: resolves the host, rejects private / loopback /
link-local ranges, enforces http(s) scheme on both the initial
page fetch and the logo download.
- Rate limited at 10 req/min per user via the throttle middleware
alias on the route.
- Logo content-type must be one of the allowed image mimes; wrong
types are silently dropped so users never see broken images.
UX:
- 'Autofill' button next to the website input, disabled until there
is a URL; shows a spinner while running.
- If a logo was captured, a small preview appears below the input
so users can see what was pulled before saving.
- Success and error paths both surface as vue-sonner toasts, with
translations in en / pt-BR / es.
- Failures leave the form untouched — nothing is destructively
overwritten if parsing gave us nothing.
Tests (16 new): action-level coverage for happy path, title-suffix
fallback, language code normalization across 6 locales, scheme
rejection, private-range SSRF rejection, implicit https prefixing,
empty sites, upstream errors, and wrong-mime logo rejection. Plus
two controller-level tests for the autofill endpoint.
2026-04-16 14:13:54 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('rejects private network addresses', function () {
|
2026-04-17 02:05:51 +00:00
|
|
|
expect(fn () => ($this->autofill)('http://127.0.0.1'))
|
|
|
|
|
->toThrow(RuntimeException::class);
|
feat: add brand autofill from website URL in onboarding
Users on the Brand step can type their website URL and click
'Preencher' (Portuguese) / 'Autofill' (English). The backend fetches
their homepage, parses standard meta tags, and returns:
- name ← og:site_name | title (suffix stripped at ' | ', ' - ')
- description ← meta[name=description] | og:description
- language ← <html lang> mapped to en / pt-BR / es
- logo ← apple-touch-icon | largest link[rel*=icon] | og:image
The logo is downloaded, validated (mime whitelist, 2MB max), and
attached to the workspace's 'logo' media collection so the avatar
updates immediately.
Zero LLM calls, zero external APIs. Uses symfony/dom-crawler +
symfony/css-selector (newly required) for meta extraction. Everything
else (Http client, workspace media, Intervention) was already in the
project.
Security:
- SSRF guardrail: resolves the host, rejects private / loopback /
link-local ranges, enforces http(s) scheme on both the initial
page fetch and the logo download.
- Rate limited at 10 req/min per user via the throttle middleware
alias on the route.
- Logo content-type must be one of the allowed image mimes; wrong
types are silently dropped so users never see broken images.
UX:
- 'Autofill' button next to the website input, disabled until there
is a URL; shows a spinner while running.
- If a logo was captured, a small preview appears below the input
so users can see what was pulled before saving.
- Success and error paths both surface as vue-sonner toasts, with
translations in en / pt-BR / es.
- Failures leave the form untouched — nothing is destructively
overwritten if parsing gave us nothing.
Tests (16 new): action-level coverage for happy path, title-suffix
fallback, language code normalization across 6 locales, scheme
rejection, private-range SSRF rejection, implicit https prefixing,
empty sites, upstream errors, and wrong-mime logo rejection. Plus
two controller-level tests for the autofill endpoint.
2026-04-16 14:13:54 +00:00
|
|
|
|
2026-04-17 02:05:51 +00:00
|
|
|
expect(fn () => ($this->autofill)('http://192.168.1.1'))
|
|
|
|
|
->toThrow(RuntimeException::class);
|
feat: add brand autofill from website URL in onboarding
Users on the Brand step can type their website URL and click
'Preencher' (Portuguese) / 'Autofill' (English). The backend fetches
their homepage, parses standard meta tags, and returns:
- name ← og:site_name | title (suffix stripped at ' | ', ' - ')
- description ← meta[name=description] | og:description
- language ← <html lang> mapped to en / pt-BR / es
- logo ← apple-touch-icon | largest link[rel*=icon] | og:image
The logo is downloaded, validated (mime whitelist, 2MB max), and
attached to the workspace's 'logo' media collection so the avatar
updates immediately.
Zero LLM calls, zero external APIs. Uses symfony/dom-crawler +
symfony/css-selector (newly required) for meta extraction. Everything
else (Http client, workspace media, Intervention) was already in the
project.
Security:
- SSRF guardrail: resolves the host, rejects private / loopback /
link-local ranges, enforces http(s) scheme on both the initial
page fetch and the logo download.
- Rate limited at 10 req/min per user via the throttle middleware
alias on the route.
- Logo content-type must be one of the allowed image mimes; wrong
types are silently dropped so users never see broken images.
UX:
- 'Autofill' button next to the website input, disabled until there
is a URL; shows a spinner while running.
- If a logo was captured, a small preview appears below the input
so users can see what was pulled before saving.
- Success and error paths both surface as vue-sonner toasts, with
translations in en / pt-BR / es.
- Failures leave the form untouched — nothing is destructively
overwritten if parsing gave us nothing.
Tests (16 new): action-level coverage for happy path, title-suffix
fallback, language code normalization across 6 locales, scheme
rejection, private-range SSRF rejection, implicit https prefixing,
empty sites, upstream errors, and wrong-mime logo rejection. Plus
two controller-level tests for the autofill endpoint.
2026-04-16 14:13:54 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('adds https:// when scheme is missing', function () {
|
|
|
|
|
Http::fake([
|
|
|
|
|
'example.com' => Http::response('<html><head><title>ok</title></head></html>', 200),
|
|
|
|
|
]);
|
|
|
|
|
|
2026-04-17 02:05:51 +00:00
|
|
|
($this->autofill)('example.com');
|
feat: add brand autofill from website URL in onboarding
Users on the Brand step can type their website URL and click
'Preencher' (Portuguese) / 'Autofill' (English). The backend fetches
their homepage, parses standard meta tags, and returns:
- name ← og:site_name | title (suffix stripped at ' | ', ' - ')
- description ← meta[name=description] | og:description
- language ← <html lang> mapped to en / pt-BR / es
- logo ← apple-touch-icon | largest link[rel*=icon] | og:image
The logo is downloaded, validated (mime whitelist, 2MB max), and
attached to the workspace's 'logo' media collection so the avatar
updates immediately.
Zero LLM calls, zero external APIs. Uses symfony/dom-crawler +
symfony/css-selector (newly required) for meta extraction. Everything
else (Http client, workspace media, Intervention) was already in the
project.
Security:
- SSRF guardrail: resolves the host, rejects private / loopback /
link-local ranges, enforces http(s) scheme on both the initial
page fetch and the logo download.
- Rate limited at 10 req/min per user via the throttle middleware
alias on the route.
- Logo content-type must be one of the allowed image mimes; wrong
types are silently dropped so users never see broken images.
UX:
- 'Autofill' button next to the website input, disabled until there
is a URL; shows a spinner while running.
- If a logo was captured, a small preview appears below the input
so users can see what was pulled before saving.
- Success and error paths both surface as vue-sonner toasts, with
translations in en / pt-BR / es.
- Failures leave the form untouched — nothing is destructively
overwritten if parsing gave us nothing.
Tests (16 new): action-level coverage for happy path, title-suffix
fallback, language code normalization across 6 locales, scheme
rejection, private-range SSRF rejection, implicit https prefixing,
empty sites, upstream errors, and wrong-mime logo rejection. Plus
two controller-level tests for the autofill endpoint.
2026-04-16 14:13:54 +00:00
|
|
|
|
|
|
|
|
Http::assertSent(fn (HttpRequest $req) => str_starts_with($req->url(), 'https://example.com'));
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-17 02:05:51 +00:00
|
|
|
test('falls back to domain-derived name when site has no meta tags', function () {
|
feat: add brand autofill from website URL in onboarding
Users on the Brand step can type their website URL and click
'Preencher' (Portuguese) / 'Autofill' (English). The backend fetches
their homepage, parses standard meta tags, and returns:
- name ← og:site_name | title (suffix stripped at ' | ', ' - ')
- description ← meta[name=description] | og:description
- language ← <html lang> mapped to en / pt-BR / es
- logo ← apple-touch-icon | largest link[rel*=icon] | og:image
The logo is downloaded, validated (mime whitelist, 2MB max), and
attached to the workspace's 'logo' media collection so the avatar
updates immediately.
Zero LLM calls, zero external APIs. Uses symfony/dom-crawler +
symfony/css-selector (newly required) for meta extraction. Everything
else (Http client, workspace media, Intervention) was already in the
project.
Security:
- SSRF guardrail: resolves the host, rejects private / loopback /
link-local ranges, enforces http(s) scheme on both the initial
page fetch and the logo download.
- Rate limited at 10 req/min per user via the throttle middleware
alias on the route.
- Logo content-type must be one of the allowed image mimes; wrong
types are silently dropped so users never see broken images.
UX:
- 'Autofill' button next to the website input, disabled until there
is a URL; shows a spinner while running.
- If a logo was captured, a small preview appears below the input
so users can see what was pulled before saving.
- Success and error paths both surface as vue-sonner toasts, with
translations in en / pt-BR / es.
- Failures leave the form untouched — nothing is destructively
overwritten if parsing gave us nothing.
Tests (16 new): action-level coverage for happy path, title-suffix
fallback, language code normalization across 6 locales, scheme
rejection, private-range SSRF rejection, implicit https prefixing,
empty sites, upstream errors, and wrong-mime logo rejection. Plus
two controller-level tests for the autofill endpoint.
2026-04-16 14:13:54 +00:00
|
|
|
Http::fake([
|
|
|
|
|
'example.com' => Http::response('<html><body></body></html>', 200),
|
|
|
|
|
]);
|
|
|
|
|
|
2026-04-17 02:05:51 +00:00
|
|
|
$result = ($this->autofill)('https://example.com');
|
feat: add brand autofill from website URL in onboarding
Users on the Brand step can type their website URL and click
'Preencher' (Portuguese) / 'Autofill' (English). The backend fetches
their homepage, parses standard meta tags, and returns:
- name ← og:site_name | title (suffix stripped at ' | ', ' - ')
- description ← meta[name=description] | og:description
- language ← <html lang> mapped to en / pt-BR / es
- logo ← apple-touch-icon | largest link[rel*=icon] | og:image
The logo is downloaded, validated (mime whitelist, 2MB max), and
attached to the workspace's 'logo' media collection so the avatar
updates immediately.
Zero LLM calls, zero external APIs. Uses symfony/dom-crawler +
symfony/css-selector (newly required) for meta extraction. Everything
else (Http client, workspace media, Intervention) was already in the
project.
Security:
- SSRF guardrail: resolves the host, rejects private / loopback /
link-local ranges, enforces http(s) scheme on both the initial
page fetch and the logo download.
- Rate limited at 10 req/min per user via the throttle middleware
alias on the route.
- Logo content-type must be one of the allowed image mimes; wrong
types are silently dropped so users never see broken images.
UX:
- 'Autofill' button next to the website input, disabled until there
is a URL; shows a spinner while running.
- If a logo was captured, a small preview appears below the input
so users can see what was pulled before saving.
- Success and error paths both surface as vue-sonner toasts, with
translations in en / pt-BR / es.
- Failures leave the form untouched — nothing is destructively
overwritten if parsing gave us nothing.
Tests (16 new): action-level coverage for happy path, title-suffix
fallback, language code normalization across 6 locales, scheme
rejection, private-range SSRF rejection, implicit https prefixing,
empty sites, upstream errors, and wrong-mime logo rejection. Plus
two controller-level tests for the autofill endpoint.
2026-04-16 14:13:54 +00:00
|
|
|
|
2026-04-17 02:05:51 +00:00
|
|
|
expect($result->name)->toBe('Example');
|
|
|
|
|
expect($result->description)->toBeNull();
|
|
|
|
|
expect($result->language)->toBeNull();
|
|
|
|
|
// logoUrl always falls back to /favicon.ico since that URL exists on most sites.
|
|
|
|
|
expect($result->logoUrl)->toBe('https://example.com/favicon.ico');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('falls back to domain-derived name when title is a tagline with no separator', function () {
|
|
|
|
|
Http::fake([
|
|
|
|
|
'sendkit.dev' => Http::response(<<<'HTML'
|
|
|
|
|
<html>
|
|
|
|
|
<head>
|
|
|
|
|
<title>Email API, SMTP & Marketing Platform for Developers & AI Agents</title>
|
|
|
|
|
</head>
|
|
|
|
|
<body></body>
|
|
|
|
|
</html>
|
|
|
|
|
HTML, 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = ($this->autofill)('https://sendkit.dev');
|
|
|
|
|
|
|
|
|
|
expect($result->name)->toBe('Sendkit');
|
feat: add brand autofill from website URL in onboarding
Users on the Brand step can type their website URL and click
'Preencher' (Portuguese) / 'Autofill' (English). The backend fetches
their homepage, parses standard meta tags, and returns:
- name ← og:site_name | title (suffix stripped at ' | ', ' - ')
- description ← meta[name=description] | og:description
- language ← <html lang> mapped to en / pt-BR / es
- logo ← apple-touch-icon | largest link[rel*=icon] | og:image
The logo is downloaded, validated (mime whitelist, 2MB max), and
attached to the workspace's 'logo' media collection so the avatar
updates immediately.
Zero LLM calls, zero external APIs. Uses symfony/dom-crawler +
symfony/css-selector (newly required) for meta extraction. Everything
else (Http client, workspace media, Intervention) was already in the
project.
Security:
- SSRF guardrail: resolves the host, rejects private / loopback /
link-local ranges, enforces http(s) scheme on both the initial
page fetch and the logo download.
- Rate limited at 10 req/min per user via the throttle middleware
alias on the route.
- Logo content-type must be one of the allowed image mimes; wrong
types are silently dropped so users never see broken images.
UX:
- 'Autofill' button next to the website input, disabled until there
is a URL; shows a spinner while running.
- If a logo was captured, a small preview appears below the input
so users can see what was pulled before saving.
- Success and error paths both surface as vue-sonner toasts, with
translations in en / pt-BR / es.
- Failures leave the form untouched — nothing is destructively
overwritten if parsing gave us nothing.
Tests (16 new): action-level coverage for happy path, title-suffix
fallback, language code normalization across 6 locales, scheme
rejection, private-range SSRF rejection, implicit https prefixing,
empty sites, upstream errors, and wrong-mime logo rejection. Plus
two controller-level tests for the autofill endpoint.
2026-04-16 14:13:54 +00:00
|
|
|
});
|
|
|
|
|
|
2026-05-02 16:30:59 +00:00
|
|
|
test('extracts brand color from theme-color meta', function () {
|
|
|
|
|
Http::fake([
|
|
|
|
|
'example.com' => Http::response(<<<'HTML'
|
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
<html>
|
|
|
|
|
<head>
|
|
|
|
|
<title>Acme</title>
|
|
|
|
|
<meta name="theme-color" content="#0ea5e9">
|
|
|
|
|
</head>
|
|
|
|
|
<body></body>
|
|
|
|
|
</html>
|
|
|
|
|
HTML, 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = ($this->autofill)('https://example.com');
|
|
|
|
|
|
|
|
|
|
expect($result->brandColor)->toBe('#0ea5e9');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('extracts colors from CSS custom properties', function () {
|
|
|
|
|
Http::fake([
|
|
|
|
|
'example.com' => Http::response(<<<'HTML'
|
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
<html>
|
|
|
|
|
<head>
|
|
|
|
|
<title>Acme</title>
|
|
|
|
|
<style>
|
|
|
|
|
:root {
|
|
|
|
|
--primary: #ff5722;
|
|
|
|
|
--background: #0b0f19;
|
|
|
|
|
--foreground: #e2e8f0;
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body></body>
|
|
|
|
|
</html>
|
|
|
|
|
HTML, 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = ($this->autofill)('https://example.com');
|
|
|
|
|
|
|
|
|
|
expect($result->brandColor)->toBe('#ff5722');
|
|
|
|
|
expect($result->backgroundColor)->toBe('#0b0f19');
|
|
|
|
|
expect($result->textColor)->toBe('#e2e8f0');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('falls back to body { background } and body { color } rules', function () {
|
|
|
|
|
Http::fake([
|
|
|
|
|
'example.com' => Http::response(<<<'HTML'
|
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
<html>
|
|
|
|
|
<head>
|
|
|
|
|
<title>Acme</title>
|
|
|
|
|
<style>
|
|
|
|
|
body {
|
|
|
|
|
background-color: #ffffff;
|
|
|
|
|
color: #1f2937;
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body></body>
|
|
|
|
|
</html>
|
|
|
|
|
HTML, 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = ($this->autofill)('https://example.com');
|
|
|
|
|
|
|
|
|
|
expect($result->backgroundColor)->toBe('#ffffff');
|
|
|
|
|
expect($result->textColor)->toBe('#1f2937');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('extracts colors from external stylesheets', function () {
|
|
|
|
|
Http::fake([
|
|
|
|
|
'example.com' => Http::response(<<<'HTML'
|
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
<html>
|
|
|
|
|
<head>
|
|
|
|
|
<title>Acme</title>
|
|
|
|
|
<link rel="stylesheet" href="/app.css">
|
|
|
|
|
</head>
|
|
|
|
|
<body></body>
|
|
|
|
|
</html>
|
|
|
|
|
HTML, 200),
|
|
|
|
|
'example.com/app.css' => Http::response(':root { --primary: #1d4ed8; --background: #f8fafc; }', 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = ($this->autofill)('https://example.com');
|
|
|
|
|
|
|
|
|
|
expect($result->brandColor)->toBe('#1d4ed8');
|
|
|
|
|
expect($result->backgroundColor)->toBe('#f8fafc');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('falls back to dominant logo color when CSS has no signal', function () {
|
|
|
|
|
// Logo fixture is a solid blue PNG. CSS in the page has no semantic hooks,
|
|
|
|
|
// so AutofillBrand should reach into the logo and grab #1e6fff via
|
|
|
|
|
// LogoColorExtractor.
|
|
|
|
|
Http::fake([
|
|
|
|
|
'example.com' => Http::response(<<<'HTML'
|
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
<html>
|
|
|
|
|
<head>
|
|
|
|
|
<title>Acme</title>
|
|
|
|
|
<link rel="icon" sizes="32x32" href="/logo.png">
|
|
|
|
|
</head>
|
|
|
|
|
<body>Hello</body>
|
|
|
|
|
</html>
|
|
|
|
|
HTML, 200),
|
|
|
|
|
'example.com/logo.png' => Http::response(
|
|
|
|
|
file_get_contents(__DIR__.'/../../fixtures/blue-logo.png'),
|
|
|
|
|
200,
|
|
|
|
|
['Content-Type' => 'image/png'],
|
|
|
|
|
),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = ($this->autofill)('https://example.com');
|
|
|
|
|
|
|
|
|
|
expect($result->brandColor)->toBe('#1e6fff');
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-08 16:38:30 +00:00
|
|
|
test('returns null when CSS contains no extractable colour values', function () {
|
2026-05-02 16:30:59 +00:00
|
|
|
Http::fake([
|
|
|
|
|
'example.com' => Http::response(<<<'HTML'
|
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
<html>
|
|
|
|
|
<head>
|
|
|
|
|
<title>Acme</title>
|
|
|
|
|
<meta name="theme-color" content="rgb(255, 0, 0)">
|
2026-05-08 16:38:30 +00:00
|
|
|
<style>:root { --primary: notacolor; --background: red; }</style>
|
2026-05-02 16:30:59 +00:00
|
|
|
</head>
|
|
|
|
|
<body></body>
|
|
|
|
|
</html>
|
|
|
|
|
HTML, 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = ($this->autofill)('https://example.com');
|
|
|
|
|
|
2026-05-08 16:38:30 +00:00
|
|
|
// theme-color tier-1 and CSS-var tier-2 reject the rgb()/named values;
|
|
|
|
|
// the frequency tier-3 finds nothing extractable in this CSS.
|
2026-05-02 16:30:59 +00:00
|
|
|
expect($result->brandColor)->toBeNull();
|
|
|
|
|
expect($result->backgroundColor)->toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-08 16:38:30 +00:00
|
|
|
test('falls back to CSS colour frequency when no semantic var is exposed', function () {
|
|
|
|
|
// Tailwind/utility-style CSS with no --primary var: the brand colour just
|
|
|
|
|
// appears many times across utility classes. Frequency tier picks it up.
|
|
|
|
|
$css = str_repeat('.btn-primary { background-color: #2563eb; } ', 30);
|
|
|
|
|
Http::fake([
|
|
|
|
|
'example.com' => Http::response(<<<HTML
|
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
<html>
|
|
|
|
|
<head>
|
|
|
|
|
<title>Acme</title>
|
|
|
|
|
<style>{$css}</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body></body>
|
|
|
|
|
</html>
|
|
|
|
|
HTML, 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = ($this->autofill)('https://example.com');
|
|
|
|
|
|
|
|
|
|
expect($result->brandColor)->toBe('#2563eb');
|
|
|
|
|
});
|
|
|
|
|
|
feat: add brand autofill from website URL in onboarding
Users on the Brand step can type their website URL and click
'Preencher' (Portuguese) / 'Autofill' (English). The backend fetches
their homepage, parses standard meta tags, and returns:
- name ← og:site_name | title (suffix stripped at ' | ', ' - ')
- description ← meta[name=description] | og:description
- language ← <html lang> mapped to en / pt-BR / es
- logo ← apple-touch-icon | largest link[rel*=icon] | og:image
The logo is downloaded, validated (mime whitelist, 2MB max), and
attached to the workspace's 'logo' media collection so the avatar
updates immediately.
Zero LLM calls, zero external APIs. Uses symfony/dom-crawler +
symfony/css-selector (newly required) for meta extraction. Everything
else (Http client, workspace media, Intervention) was already in the
project.
Security:
- SSRF guardrail: resolves the host, rejects private / loopback /
link-local ranges, enforces http(s) scheme on both the initial
page fetch and the logo download.
- Rate limited at 10 req/min per user via the throttle middleware
alias on the route.
- Logo content-type must be one of the allowed image mimes; wrong
types are silently dropped so users never see broken images.
UX:
- 'Autofill' button next to the website input, disabled until there
is a URL; shows a spinner while running.
- If a logo was captured, a small preview appears below the input
so users can see what was pulled before saving.
- Success and error paths both surface as vue-sonner toasts, with
translations in en / pt-BR / es.
- Failures leave the form untouched — nothing is destructively
overwritten if parsing gave us nothing.
Tests (16 new): action-level coverage for happy path, title-suffix
fallback, language code normalization across 6 locales, scheme
rejection, private-range SSRF rejection, implicit https prefixing,
empty sites, upstream errors, and wrong-mime logo rejection. Plus
two controller-level tests for the autofill endpoint.
2026-04-16 14:13:54 +00:00
|
|
|
test('throws when upstream site returns an error', function () {
|
|
|
|
|
Http::fake([
|
|
|
|
|
'example.com' => Http::response('', 500),
|
|
|
|
|
]);
|
|
|
|
|
|
2026-04-17 02:05:51 +00:00
|
|
|
expect(fn () => ($this->autofill)('https://example.com'))
|
|
|
|
|
->toThrow(RuntimeException::class);
|
feat: add brand autofill from website URL in onboarding
Users on the Brand step can type their website URL and click
'Preencher' (Portuguese) / 'Autofill' (English). The backend fetches
their homepage, parses standard meta tags, and returns:
- name ← og:site_name | title (suffix stripped at ' | ', ' - ')
- description ← meta[name=description] | og:description
- language ← <html lang> mapped to en / pt-BR / es
- logo ← apple-touch-icon | largest link[rel*=icon] | og:image
The logo is downloaded, validated (mime whitelist, 2MB max), and
attached to the workspace's 'logo' media collection so the avatar
updates immediately.
Zero LLM calls, zero external APIs. Uses symfony/dom-crawler +
symfony/css-selector (newly required) for meta extraction. Everything
else (Http client, workspace media, Intervention) was already in the
project.
Security:
- SSRF guardrail: resolves the host, rejects private / loopback /
link-local ranges, enforces http(s) scheme on both the initial
page fetch and the logo download.
- Rate limited at 10 req/min per user via the throttle middleware
alias on the route.
- Logo content-type must be one of the allowed image mimes; wrong
types are silently dropped so users never see broken images.
UX:
- 'Autofill' button next to the website input, disabled until there
is a URL; shows a spinner while running.
- If a logo was captured, a small preview appears below the input
so users can see what was pulled before saving.
- Success and error paths both surface as vue-sonner toasts, with
translations in en / pt-BR / es.
- Failures leave the form untouched — nothing is destructively
overwritten if parsing gave us nothing.
Tests (16 new): action-level coverage for happy path, title-suffix
fallback, language code normalization across 6 locales, scheme
rejection, private-range SSRF rejection, implicit https prefixing,
empty sites, upstream errors, and wrong-mime logo rejection. Plus
two controller-level tests for the autofill endpoint.
2026-04-16 14:13:54 +00:00
|
|
|
});
|
|
|
|
|
|
feat: use LLM to polish brand autofill when provider is configured
When config('trypost.ai.text_provider') points to a provider whose
API key is populated (services.gemini.api_key or
services.openai.api_key), the autofill action pipes the homepage
markdown through a BrandAnalyzer agent with structured output to
produce higher-quality values for:
- brand_description — 2-3 sentences summarizing the company based on
the actual page content, not the raw meta description (which is
often generic SEO boilerplate)
- brand_tone — classified into one of our seven enum values from the
writing style on the page
- content_language — detected from the actual content (more reliable
than <html lang> which is often wrong)
- brand_voice_notes — concrete writing guidelines inferred from the
site's style, written in the detected language
When the LLM provider is NOT configured (open-source self-hosted
deploys without API keys), the action keeps the existing
deterministic meta-tag-only flow — no crash, no noise, no LLM cost.
When the LLM fails mid-request, we log a warning and fall back to
the meta-tag values so the user still gets something useful.
Stack additions:
- league/html-to-markdown ^5.1 converts the main body to clean
markdown for the LLM input (truncated to 4000 chars).
- BrandAnalyzer agent (Agent + HasStructuredOutput) with schema
enums matching our allowed tones and languages.
- resources/views/prompts/brand_analyzer.blade.php holds the
instructions, including explicit enum lists and examples of good
voice_notes.
Frontend: Brand.vue now also fills brand_tone and brand_voice_notes
from the response when present.
Tests (+3): LLM-configured happy path, no-credentials fallback
(asserts BrandAnalyzer is never prompted via preventStrayPrompts),
and LLM-exception fallback (meta tags win, brand_tone stays null).
2026-04-16 14:33:02 +00:00
|
|
|
test('when llm is configured, polishes description/tone/language/voice_notes via BrandAnalyzer', function () {
|
Fix OpenRouter via laravel/ai default provider (revises #216) (#220)
* Fix OpenRouter support by using laravel/ai default provider config.
PR #216 patched Lab::OpenRouter into every agent match, but laravel/ai
already resolves config('ai.default') — including openrouter — when agents
omit provider(). Those matches also forced unknown providers to Gemini and
BrandAnalyzerRunner checked a non-existent services.openrouter key.
Remove the duplicated provider() overrides, gate availability on
ai.providers.*.key, and document OPENROUTER_API_KEY.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* chore(deps): bump laravel/ai to v0.10.3 for native OpenRouter support
v0.5.1's OpenRouter driver only covered text/embeddings via the legacy
Prism gateway. v0.10.3 ships a native OpenRouter gateway with image,
audio (TTS/STT), and web search support, and drops prism-php/prism as
a dependency. ai-sdk-development skill docs refreshed via boost:update
to match the installed version.
* fix: honor AI_IMAGE_PROVIDER instead of hardcoding OpenAI's gpt-image-2
AiImageClient always passed model: 'gpt-image-2' to Image::of()->generate(),
so AI_IMAGE_PROVIDER silently did nothing for any provider other than
OpenAI — gemini/xai/openrouter would fail against an OpenAI-only model id
and quietly fall back to a stock photo. Usage recording was hardcoded to
provider 'openai' too, so credits were billed against the wrong model
whenever a different provider actually ran.
Drop the hardcoded model so generation falls through to the SDK's own
config('ai.default_for_images') + per-provider default model, and read
the actual provider/model back off the response's meta for usage
recording and source_meta instead of assuming OpenAI.
* refactor: extract AiImageClient into single-purpose steps, fix uncaught exception
generate() built the prompt, called the SDK, and unpacked the response all
in one block, with bytes extraction happening after the try/catch — so a
response with an empty images collection threw an uncaught RuntimeException
from ImageResponse::firstImage() instead of returning null as documented.
Split into cleanKeywords(), buildPrompt(), resolveBrandContext(), and
toResult(), and moved response unpacking inside the try block so any
malformed response is treated as a failure like everything else. Added a
regression test with an empty-images fake response.
* fix: drop hardcoded default_text_model, resolve model per provider
default_text_model was the only per-modality model override in ai.php —
image, audio, transcription, embeddings, and reranking all just pick a
provider and let it use its own default model. Text had a config-pinned
model on top, forced into every agent's model() and into every usage
log's model field regardless of which provider actually ran. Switching
AI_TEXT_PROVIDER (e.g. to openrouter) kept sending OpenAI's model id to
whichever provider ended up handling the request.
Removed model() from all six agents so laravel/ai resolves the model
from the active provider's own default (OpenAI's default is already
'gpt-5.4', so no behavior change there). Usage-recording call sites now
read the actual provider/model back off the response's meta instead of
assuming config('ai.default')/default_text_model. StreamPostContent
needed the then() callback since broadcast()'s StreamableAgentResponse
doesn't expose meta directly.
* refactor: drop AiConfiguration wrapper, use data_get() for array reads
AiConfiguration was a one-line static helper used by only two call
sites, with no laravel/ai equivalent to lean on (confirmed AiManager
and the Provider base class expose no isConfigured()/hasKey() check —
the package's model is try-then-catch, not pre-flight checks). Inlined
the filled(config(...)) check directly into HandleInertiaRequests and
BrandAnalyzerRunner instead of keeping a class around one line of logic.
Also swapped direct array-key reads for data_get() per project
convention across every file touched by the recent AI provider/model
fixes (agents' budget arrays, RunGenerateNode/StreamPostCreation's
humanizer merge, RegeneratePostMediaImage's baseContext/copy/rendered
access). Write/assignment sites (`$x['key'] = ...`) are left as-is —
data_get() only reads.
* feat: enhance AI configuration with new providers and options
Added strict types declaration and updated Azure OpenAI API version. Introduced new 'bedrock' provider configuration with AWS credentials and role assumptions. Enhanced existing providers with additional options, including image deployment for Azure and OpenAI, and updated URLs for Gemini and Ollama. Added support for an 'openai-compatible' driver to broaden integration capabilities.
* fix: remove trailing newline in AI configuration file
* feat: add support for OpenRouter and ElevenLabs API keys in configuration
Updated the production Docker Compose file and example environment file to include commented-out entries for OPENROUTER_API_KEY and ELEVENLABS_API_KEY. This enhances the configuration options for AI providers, allowing for easier integration of additional services.
* feat: allow per-provider model overrides for every AI modality
Adds a `models` array to each provider block, wired to env vars, so
self-hosted operators can pin a specific text/image/audio/transcription/
embeddings/reranking model instead of relying on the package's built-in
default for that provider. Only added for the modalities each provider
actually implements (verified against laravel/ai's Provider classes).
Azure is left untouched — it resolves models via deployment names
(AZURE_OPENAI_DEPLOYMENT etc.), not raw model strings, which was already
wired before this change.
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-08-11 00:38:50 +00:00
|
|
|
config()->set('ai.providers.gemini.key', 'fake-key');
|
feat: @mentions in comments, AI Action layer + MCP tools, settings tabs
Mentions in post comments
- @mention autocomplete (workspace members, current user excluded) with
marker syntax @[uuid] persisted, display names rendered via CommentBody
chips; live edit replaces markers with names and converts back on save.
- NotifyMentions action with workspace-scoped membership check, dedupes
same user, only newly-added mentions on update.
- Email + in-app via SendNotification job, respecting per-user
notification_preferences.mentioned_in_comment.
- Heartbeat-based presence (Cache, 60s TTL, 30s ping) so online recipients
get only the in-app notification — no email noise.
- Real-time bell on workspace.{id}.user.{id} private channel
(NotificationCreated event), scoped channel name avoids client-side
filtering and lays out a convention for future workspace channels.
- Mailable localized via lang/{en,es,pt-BR}/mail.php; Maizzle source
template for the email is committed and built into resources/views/mail.
AI generation refactor (Action layer + MCP)
- Extracted Actions/Ai/Generate{Image,Video} with QuotaExhaustedException
so agent tools and MCP tools share a single domain entry point.
- Mcp/Tools/Ai/Generate{Image,Video}Tool registered in TryPostServer; both
return MediaResource payloads.
- Orientation::imageApiSize maps non-OpenAI ratios to 1:1/2:3/3:2.
- config/ai.php is now the single source of truth driven by env, removing
the trypost.ai shim. Default text/image providers flipped to OpenAI.
Settings/UX
- /settings/workspace split into shadcn Tabs (Workspace / Brand / Users)
with three components.
- /assets and the in-editor MediaPicker open the ImagePreviewDialog
lightbox on image click while preserving action button behaviour.
- Comments tab landed via ?tab=comments&comment=<id> from notification
click (scroll-to + temporary highlight).
- Mention autocomplete popover flips above when near the viewport bottom.
- Real social platform PNGs replace Tabler brand glyphs in schedule
pills and post list, with hover tooltip carrying display_name + handle.
Bug fixes
- AcceptInvite: controller now passes workspace + role payload that the
Vue page expects; login/register CTAs preselect the invite email.
- WorkspaceInvite mailable: stopped referencing nonexistent
$invite->workspace and $invite->role; column added to the migration,
Invite model casts role to WorkspaceRole, CreateInvite persists it.
- PostCommentCreated: added broadcastAs so .PostCommentCreated actually
matches the Echo listener; payload now includes mentioned_users so
receivers render the chip correctly without a refetch.
- Preview components for X/Pinterest/Threads/Bluesky/LinkedIn/Mastodon/
TikTok/YouTube switched from item.type === 'image' to
!isVideoMedia(item) so media without a persisted type still renders.
- UpdatePostRequest now accepts media.*.{type,mime_type,size,...} so the
posts.media JSON keeps the metadata that the previews need.
- Removed throttle:6,1 from social connect routes (was 429ing legitimate
OAuth retries).
- Used MediaType enum cases instead of literal 'image'/'video' strings
when creating media rows.
Tests
- MentionParser unit tests, NotifyMentions feature tests including
online/offline channel selection and preference gating, MCP AI tool
happy paths, MentionedInComment mailable rendering, AcceptInvite +
search-members + index mentioned_users path. 1229 passing.
2026-05-01 23:59:03 +00:00
|
|
|
config()->set('ai.default', 'gemini');
|
feat: use LLM to polish brand autofill when provider is configured
When config('trypost.ai.text_provider') points to a provider whose
API key is populated (services.gemini.api_key or
services.openai.api_key), the autofill action pipes the homepage
markdown through a BrandAnalyzer agent with structured output to
produce higher-quality values for:
- brand_description — 2-3 sentences summarizing the company based on
the actual page content, not the raw meta description (which is
often generic SEO boilerplate)
- brand_tone — classified into one of our seven enum values from the
writing style on the page
- content_language — detected from the actual content (more reliable
than <html lang> which is often wrong)
- brand_voice_notes — concrete writing guidelines inferred from the
site's style, written in the detected language
When the LLM provider is NOT configured (open-source self-hosted
deploys without API keys), the action keeps the existing
deterministic meta-tag-only flow — no crash, no noise, no LLM cost.
When the LLM fails mid-request, we log a warning and fall back to
the meta-tag values so the user still gets something useful.
Stack additions:
- league/html-to-markdown ^5.1 converts the main body to clean
markdown for the LLM input (truncated to 4000 chars).
- BrandAnalyzer agent (Agent + HasStructuredOutput) with schema
enums matching our allowed tones and languages.
- resources/views/prompts/brand_analyzer.blade.php holds the
instructions, including explicit enum lists and examples of good
voice_notes.
Frontend: Brand.vue now also fills brand_tone and brand_voice_notes
from the response when present.
Tests (+3): LLM-configured happy path, no-credentials fallback
(asserts BrandAnalyzer is never prompted via preventStrayPrompts),
and LLM-exception fallback (meta tags win, brand_tone stays null).
2026-04-16 14:33:02 +00:00
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
'example.com' => Http::response(<<<'HTML'
|
|
|
|
|
<html lang="en">
|
|
|
|
|
<head>
|
|
|
|
|
<title>Widget Co</title>
|
|
|
|
|
<meta name="description" content="A very terse seo blurb.">
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
<main>
|
|
|
|
|
<h1>Build widgets faster</h1>
|
|
|
|
|
<p>Widget Co helps small teams ship production widgets 10x faster without writing boilerplate.</p>
|
|
|
|
|
</main>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|
|
|
|
|
HTML, 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
BrandAnalyzer::fake([
|
|
|
|
|
[
|
|
|
|
|
'description' => 'Widget Co helps small teams ship production widgets faster.',
|
|
|
|
|
'language' => 'en',
|
2026-06-12 20:09:39 +00:00
|
|
|
// A bogus value (dropped) and a second POV (dropped — single-select).
|
|
|
|
|
'voice_traits' => ['third_person', 'first_person', 'direct', 'no_hype', 'not_a_trait'],
|
feat: use LLM to polish brand autofill when provider is configured
When config('trypost.ai.text_provider') points to a provider whose
API key is populated (services.gemini.api_key or
services.openai.api_key), the autofill action pipes the homepage
markdown through a BrandAnalyzer agent with structured output to
produce higher-quality values for:
- brand_description — 2-3 sentences summarizing the company based on
the actual page content, not the raw meta description (which is
often generic SEO boilerplate)
- brand_tone — classified into one of our seven enum values from the
writing style on the page
- content_language — detected from the actual content (more reliable
than <html lang> which is often wrong)
- brand_voice_notes — concrete writing guidelines inferred from the
site's style, written in the detected language
When the LLM provider is NOT configured (open-source self-hosted
deploys without API keys), the action keeps the existing
deterministic meta-tag-only flow — no crash, no noise, no LLM cost.
When the LLM fails mid-request, we log a warning and fall back to
the meta-tag values so the user still gets something useful.
Stack additions:
- league/html-to-markdown ^5.1 converts the main body to clean
markdown for the LLM input (truncated to 4000 chars).
- BrandAnalyzer agent (Agent + HasStructuredOutput) with schema
enums matching our allowed tones and languages.
- resources/views/prompts/brand_analyzer.blade.php holds the
instructions, including explicit enum lists and examples of good
voice_notes.
Frontend: Brand.vue now also fills brand_tone and brand_voice_notes
from the response when present.
Tests (+3): LLM-configured happy path, no-credentials fallback
(asserts BrandAnalyzer is never prompted via preventStrayPrompts),
and LLM-exception fallback (meta tags win, brand_tone stays null).
2026-04-16 14:33:02 +00:00
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
2026-04-17 02:05:51 +00:00
|
|
|
$result = ($this->autofill)('https://example.com');
|
feat: use LLM to polish brand autofill when provider is configured
When config('trypost.ai.text_provider') points to a provider whose
API key is populated (services.gemini.api_key or
services.openai.api_key), the autofill action pipes the homepage
markdown through a BrandAnalyzer agent with structured output to
produce higher-quality values for:
- brand_description — 2-3 sentences summarizing the company based on
the actual page content, not the raw meta description (which is
often generic SEO boilerplate)
- brand_tone — classified into one of our seven enum values from the
writing style on the page
- content_language — detected from the actual content (more reliable
than <html lang> which is often wrong)
- brand_voice_notes — concrete writing guidelines inferred from the
site's style, written in the detected language
When the LLM provider is NOT configured (open-source self-hosted
deploys without API keys), the action keeps the existing
deterministic meta-tag-only flow — no crash, no noise, no LLM cost.
When the LLM fails mid-request, we log a warning and fall back to
the meta-tag values so the user still gets something useful.
Stack additions:
- league/html-to-markdown ^5.1 converts the main body to clean
markdown for the LLM input (truncated to 4000 chars).
- BrandAnalyzer agent (Agent + HasStructuredOutput) with schema
enums matching our allowed tones and languages.
- resources/views/prompts/brand_analyzer.blade.php holds the
instructions, including explicit enum lists and examples of good
voice_notes.
Frontend: Brand.vue now also fills brand_tone and brand_voice_notes
from the response when present.
Tests (+3): LLM-configured happy path, no-credentials fallback
(asserts BrandAnalyzer is never prompted via preventStrayPrompts),
and LLM-exception fallback (meta tags win, brand_tone stays null).
2026-04-16 14:33:02 +00:00
|
|
|
|
2026-04-17 02:05:51 +00:00
|
|
|
expect($result->description)->toBe('Widget Co helps small teams ship production widgets faster.');
|
|
|
|
|
expect($result->language)->toBe('en');
|
2026-06-12 20:09:39 +00:00
|
|
|
expect($result->toArray()['brand_voice_traits'])->toBe(['third_person', 'direct', 'no_hype']);
|
feat: use LLM to polish brand autofill when provider is configured
When config('trypost.ai.text_provider') points to a provider whose
API key is populated (services.gemini.api_key or
services.openai.api_key), the autofill action pipes the homepage
markdown through a BrandAnalyzer agent with structured output to
produce higher-quality values for:
- brand_description — 2-3 sentences summarizing the company based on
the actual page content, not the raw meta description (which is
often generic SEO boilerplate)
- brand_tone — classified into one of our seven enum values from the
writing style on the page
- content_language — detected from the actual content (more reliable
than <html lang> which is often wrong)
- brand_voice_notes — concrete writing guidelines inferred from the
site's style, written in the detected language
When the LLM provider is NOT configured (open-source self-hosted
deploys without API keys), the action keeps the existing
deterministic meta-tag-only flow — no crash, no noise, no LLM cost.
When the LLM fails mid-request, we log a warning and fall back to
the meta-tag values so the user still gets something useful.
Stack additions:
- league/html-to-markdown ^5.1 converts the main body to clean
markdown for the LLM input (truncated to 4000 chars).
- BrandAnalyzer agent (Agent + HasStructuredOutput) with schema
enums matching our allowed tones and languages.
- resources/views/prompts/brand_analyzer.blade.php holds the
instructions, including explicit enum lists and examples of good
voice_notes.
Frontend: Brand.vue now also fills brand_tone and brand_voice_notes
from the response when present.
Tests (+3): LLM-configured happy path, no-credentials fallback
(asserts BrandAnalyzer is never prompted via preventStrayPrompts),
and LLM-exception fallback (meta tags win, brand_tone stays null).
2026-04-16 14:33:02 +00:00
|
|
|
});
|
|
|
|
|
|
Fix OpenRouter via laravel/ai default provider (revises #216) (#220)
* Fix OpenRouter support by using laravel/ai default provider config.
PR #216 patched Lab::OpenRouter into every agent match, but laravel/ai
already resolves config('ai.default') — including openrouter — when agents
omit provider(). Those matches also forced unknown providers to Gemini and
BrandAnalyzerRunner checked a non-existent services.openrouter key.
Remove the duplicated provider() overrides, gate availability on
ai.providers.*.key, and document OPENROUTER_API_KEY.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* chore(deps): bump laravel/ai to v0.10.3 for native OpenRouter support
v0.5.1's OpenRouter driver only covered text/embeddings via the legacy
Prism gateway. v0.10.3 ships a native OpenRouter gateway with image,
audio (TTS/STT), and web search support, and drops prism-php/prism as
a dependency. ai-sdk-development skill docs refreshed via boost:update
to match the installed version.
* fix: honor AI_IMAGE_PROVIDER instead of hardcoding OpenAI's gpt-image-2
AiImageClient always passed model: 'gpt-image-2' to Image::of()->generate(),
so AI_IMAGE_PROVIDER silently did nothing for any provider other than
OpenAI — gemini/xai/openrouter would fail against an OpenAI-only model id
and quietly fall back to a stock photo. Usage recording was hardcoded to
provider 'openai' too, so credits were billed against the wrong model
whenever a different provider actually ran.
Drop the hardcoded model so generation falls through to the SDK's own
config('ai.default_for_images') + per-provider default model, and read
the actual provider/model back off the response's meta for usage
recording and source_meta instead of assuming OpenAI.
* refactor: extract AiImageClient into single-purpose steps, fix uncaught exception
generate() built the prompt, called the SDK, and unpacked the response all
in one block, with bytes extraction happening after the try/catch — so a
response with an empty images collection threw an uncaught RuntimeException
from ImageResponse::firstImage() instead of returning null as documented.
Split into cleanKeywords(), buildPrompt(), resolveBrandContext(), and
toResult(), and moved response unpacking inside the try block so any
malformed response is treated as a failure like everything else. Added a
regression test with an empty-images fake response.
* fix: drop hardcoded default_text_model, resolve model per provider
default_text_model was the only per-modality model override in ai.php —
image, audio, transcription, embeddings, and reranking all just pick a
provider and let it use its own default model. Text had a config-pinned
model on top, forced into every agent's model() and into every usage
log's model field regardless of which provider actually ran. Switching
AI_TEXT_PROVIDER (e.g. to openrouter) kept sending OpenAI's model id to
whichever provider ended up handling the request.
Removed model() from all six agents so laravel/ai resolves the model
from the active provider's own default (OpenAI's default is already
'gpt-5.4', so no behavior change there). Usage-recording call sites now
read the actual provider/model back off the response's meta instead of
assuming config('ai.default')/default_text_model. StreamPostContent
needed the then() callback since broadcast()'s StreamableAgentResponse
doesn't expose meta directly.
* refactor: drop AiConfiguration wrapper, use data_get() for array reads
AiConfiguration was a one-line static helper used by only two call
sites, with no laravel/ai equivalent to lean on (confirmed AiManager
and the Provider base class expose no isConfigured()/hasKey() check —
the package's model is try-then-catch, not pre-flight checks). Inlined
the filled(config(...)) check directly into HandleInertiaRequests and
BrandAnalyzerRunner instead of keeping a class around one line of logic.
Also swapped direct array-key reads for data_get() per project
convention across every file touched by the recent AI provider/model
fixes (agents' budget arrays, RunGenerateNode/StreamPostCreation's
humanizer merge, RegeneratePostMediaImage's baseContext/copy/rendered
access). Write/assignment sites (`$x['key'] = ...`) are left as-is —
data_get() only reads.
* feat: enhance AI configuration with new providers and options
Added strict types declaration and updated Azure OpenAI API version. Introduced new 'bedrock' provider configuration with AWS credentials and role assumptions. Enhanced existing providers with additional options, including image deployment for Azure and OpenAI, and updated URLs for Gemini and Ollama. Added support for an 'openai-compatible' driver to broaden integration capabilities.
* fix: remove trailing newline in AI configuration file
* feat: add support for OpenRouter and ElevenLabs API keys in configuration
Updated the production Docker Compose file and example environment file to include commented-out entries for OPENROUTER_API_KEY and ELEVENLABS_API_KEY. This enhances the configuration options for AI providers, allowing for easier integration of additional services.
* feat: allow per-provider model overrides for every AI modality
Adds a `models` array to each provider block, wired to env vars, so
self-hosted operators can pin a specific text/image/audio/transcription/
embeddings/reranking model instead of relying on the package's built-in
default for that provider. Only added for the modalities each provider
actually implements (verified against laravel/ai's Provider classes).
Azure is left untouched — it resolves models via deployment names
(AZURE_OPENAI_DEPLOYMENT etc.), not raw model strings, which was already
wired before this change.
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-08-11 00:38:50 +00:00
|
|
|
test('openrouter as default text provider enables BrandAnalyzer', function () {
|
|
|
|
|
config()->set('ai.default', 'openrouter');
|
|
|
|
|
config()->set('ai.providers.openrouter.key', 'sk-or-v1-test');
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
'example.com' => Http::response(<<<'HTML'
|
|
|
|
|
<html lang="en">
|
|
|
|
|
<head>
|
|
|
|
|
<title>OpenRouter Co</title>
|
|
|
|
|
<meta name="description" content="Terse blurb.">
|
|
|
|
|
</head>
|
|
|
|
|
<body><main><p>OpenRouter Co ships AI through one key.</p></main></body>
|
|
|
|
|
</html>
|
|
|
|
|
HTML, 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
BrandAnalyzer::fake([
|
|
|
|
|
[
|
|
|
|
|
'description' => 'OpenRouter Co ships AI through one key.',
|
|
|
|
|
'language' => 'en',
|
|
|
|
|
'voice_traits' => ['third_person', 'direct'],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = ($this->autofill)('https://example.com');
|
|
|
|
|
|
|
|
|
|
expect($result->description)->toBe('OpenRouter Co ships AI through one key.');
|
|
|
|
|
expect($result->toArray()['brand_voice_traits'])->toBe(['third_person', 'direct']);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-03 22:28:28 +00:00
|
|
|
test('LLM language detection wins and carries any supported language, not just en/es/pt-BR', function () {
|
Fix OpenRouter via laravel/ai default provider (revises #216) (#220)
* Fix OpenRouter support by using laravel/ai default provider config.
PR #216 patched Lab::OpenRouter into every agent match, but laravel/ai
already resolves config('ai.default') — including openrouter — when agents
omit provider(). Those matches also forced unknown providers to Gemini and
BrandAnalyzerRunner checked a non-existent services.openrouter key.
Remove the duplicated provider() overrides, gate availability on
ai.providers.*.key, and document OPENROUTER_API_KEY.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* chore(deps): bump laravel/ai to v0.10.3 for native OpenRouter support
v0.5.1's OpenRouter driver only covered text/embeddings via the legacy
Prism gateway. v0.10.3 ships a native OpenRouter gateway with image,
audio (TTS/STT), and web search support, and drops prism-php/prism as
a dependency. ai-sdk-development skill docs refreshed via boost:update
to match the installed version.
* fix: honor AI_IMAGE_PROVIDER instead of hardcoding OpenAI's gpt-image-2
AiImageClient always passed model: 'gpt-image-2' to Image::of()->generate(),
so AI_IMAGE_PROVIDER silently did nothing for any provider other than
OpenAI — gemini/xai/openrouter would fail against an OpenAI-only model id
and quietly fall back to a stock photo. Usage recording was hardcoded to
provider 'openai' too, so credits were billed against the wrong model
whenever a different provider actually ran.
Drop the hardcoded model so generation falls through to the SDK's own
config('ai.default_for_images') + per-provider default model, and read
the actual provider/model back off the response's meta for usage
recording and source_meta instead of assuming OpenAI.
* refactor: extract AiImageClient into single-purpose steps, fix uncaught exception
generate() built the prompt, called the SDK, and unpacked the response all
in one block, with bytes extraction happening after the try/catch — so a
response with an empty images collection threw an uncaught RuntimeException
from ImageResponse::firstImage() instead of returning null as documented.
Split into cleanKeywords(), buildPrompt(), resolveBrandContext(), and
toResult(), and moved response unpacking inside the try block so any
malformed response is treated as a failure like everything else. Added a
regression test with an empty-images fake response.
* fix: drop hardcoded default_text_model, resolve model per provider
default_text_model was the only per-modality model override in ai.php —
image, audio, transcription, embeddings, and reranking all just pick a
provider and let it use its own default model. Text had a config-pinned
model on top, forced into every agent's model() and into every usage
log's model field regardless of which provider actually ran. Switching
AI_TEXT_PROVIDER (e.g. to openrouter) kept sending OpenAI's model id to
whichever provider ended up handling the request.
Removed model() from all six agents so laravel/ai resolves the model
from the active provider's own default (OpenAI's default is already
'gpt-5.4', so no behavior change there). Usage-recording call sites now
read the actual provider/model back off the response's meta instead of
assuming config('ai.default')/default_text_model. StreamPostContent
needed the then() callback since broadcast()'s StreamableAgentResponse
doesn't expose meta directly.
* refactor: drop AiConfiguration wrapper, use data_get() for array reads
AiConfiguration was a one-line static helper used by only two call
sites, with no laravel/ai equivalent to lean on (confirmed AiManager
and the Provider base class expose no isConfigured()/hasKey() check —
the package's model is try-then-catch, not pre-flight checks). Inlined
the filled(config(...)) check directly into HandleInertiaRequests and
BrandAnalyzerRunner instead of keeping a class around one line of logic.
Also swapped direct array-key reads for data_get() per project
convention across every file touched by the recent AI provider/model
fixes (agents' budget arrays, RunGenerateNode/StreamPostCreation's
humanizer merge, RegeneratePostMediaImage's baseContext/copy/rendered
access). Write/assignment sites (`$x['key'] = ...`) are left as-is —
data_get() only reads.
* feat: enhance AI configuration with new providers and options
Added strict types declaration and updated Azure OpenAI API version. Introduced new 'bedrock' provider configuration with AWS credentials and role assumptions. Enhanced existing providers with additional options, including image deployment for Azure and OpenAI, and updated URLs for Gemini and Ollama. Added support for an 'openai-compatible' driver to broaden integration capabilities.
* fix: remove trailing newline in AI configuration file
* feat: add support for OpenRouter and ElevenLabs API keys in configuration
Updated the production Docker Compose file and example environment file to include commented-out entries for OPENROUTER_API_KEY and ELEVENLABS_API_KEY. This enhances the configuration options for AI providers, allowing for easier integration of additional services.
* feat: allow per-provider model overrides for every AI modality
Adds a `models` array to each provider block, wired to env vars, so
self-hosted operators can pin a specific text/image/audio/transcription/
embeddings/reranking model instead of relying on the package's built-in
default for that provider. Only added for the modalities each provider
actually implements (verified against laravel/ai's Provider classes).
Azure is left untouched — it resolves models via deployment names
(AZURE_OPENAI_DEPLOYMENT etc.), not raw model strings, which was already
wired before this change.
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-08-11 00:38:50 +00:00
|
|
|
config()->set('ai.providers.gemini.key', 'fake-key');
|
2026-07-03 21:52:45 +00:00
|
|
|
config()->set('ai.default', 'gemini');
|
|
|
|
|
|
2026-07-03 22:28:28 +00:00
|
|
|
// The <html lang> declares "en", so the deterministic extractor yields 'en'.
|
|
|
|
|
// The LLM reads the actual German body and returns 'de'. Since the fixture's
|
|
|
|
|
// deterministic value differs from the LLM's, this isolates the mergeLlm
|
|
|
|
|
// precedence: the LLM value must win, and it must be a language beyond the
|
|
|
|
|
// original en/es/pt-BR set.
|
2026-07-03 21:52:45 +00:00
|
|
|
Http::fake([
|
|
|
|
|
'example.com' => Http::response(<<<'HTML'
|
2026-07-03 22:28:28 +00:00
|
|
|
<html lang="en">
|
2026-07-03 21:52:45 +00:00
|
|
|
<head>
|
|
|
|
|
<title>Beispiel GmbH</title>
|
|
|
|
|
<meta name="description" content="Kurze Beschreibung.">
|
|
|
|
|
</head>
|
2026-07-03 22:28:28 +00:00
|
|
|
<body><main><p>Wir bauen Widgets für kleine Teams.</p></main></body>
|
2026-07-03 21:52:45 +00:00
|
|
|
</html>
|
|
|
|
|
HTML, 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
BrandAnalyzer::fake([
|
|
|
|
|
[
|
|
|
|
|
'description' => 'Beispiel GmbH baut Widgets für kleine Teams.',
|
|
|
|
|
'language' => 'de',
|
|
|
|
|
'voice_traits' => ['third_person'],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = ($this->autofill)('https://example.com');
|
|
|
|
|
|
|
|
|
|
expect($result->language)->toBe('de');
|
|
|
|
|
});
|
|
|
|
|
|
feat: use LLM to polish brand autofill when provider is configured
When config('trypost.ai.text_provider') points to a provider whose
API key is populated (services.gemini.api_key or
services.openai.api_key), the autofill action pipes the homepage
markdown through a BrandAnalyzer agent with structured output to
produce higher-quality values for:
- brand_description — 2-3 sentences summarizing the company based on
the actual page content, not the raw meta description (which is
often generic SEO boilerplate)
- brand_tone — classified into one of our seven enum values from the
writing style on the page
- content_language — detected from the actual content (more reliable
than <html lang> which is often wrong)
- brand_voice_notes — concrete writing guidelines inferred from the
site's style, written in the detected language
When the LLM provider is NOT configured (open-source self-hosted
deploys without API keys), the action keeps the existing
deterministic meta-tag-only flow — no crash, no noise, no LLM cost.
When the LLM fails mid-request, we log a warning and fall back to
the meta-tag values so the user still gets something useful.
Stack additions:
- league/html-to-markdown ^5.1 converts the main body to clean
markdown for the LLM input (truncated to 4000 chars).
- BrandAnalyzer agent (Agent + HasStructuredOutput) with schema
enums matching our allowed tones and languages.
- resources/views/prompts/brand_analyzer.blade.php holds the
instructions, including explicit enum lists and examples of good
voice_notes.
Frontend: Brand.vue now also fills brand_tone and brand_voice_notes
from the response when present.
Tests (+3): LLM-configured happy path, no-credentials fallback
(asserts BrandAnalyzer is never prompted via preventStrayPrompts),
and LLM-exception fallback (meta tags win, brand_tone stays null).
2026-04-16 14:33:02 +00:00
|
|
|
test('when llm is not configured, falls back to meta tags only', function () {
|
|
|
|
|
// beforeEach already cleared api keys.
|
|
|
|
|
Http::fake([
|
|
|
|
|
'example.com' => Http::response(<<<'HTML'
|
|
|
|
|
<html lang="pt-BR">
|
|
|
|
|
<head>
|
|
|
|
|
<title>Marca</title>
|
|
|
|
|
<meta name="description" content="Uma descrição curta.">
|
|
|
|
|
</head>
|
|
|
|
|
<body><main><p>hello</p></main></body>
|
|
|
|
|
</html>
|
|
|
|
|
HTML, 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// Fail loud if BrandAnalyzer is called.
|
|
|
|
|
BrandAnalyzer::fake()->preventStrayPrompts();
|
|
|
|
|
|
2026-04-17 02:05:51 +00:00
|
|
|
$result = ($this->autofill)('https://example.com');
|
feat: use LLM to polish brand autofill when provider is configured
When config('trypost.ai.text_provider') points to a provider whose
API key is populated (services.gemini.api_key or
services.openai.api_key), the autofill action pipes the homepage
markdown through a BrandAnalyzer agent with structured output to
produce higher-quality values for:
- brand_description — 2-3 sentences summarizing the company based on
the actual page content, not the raw meta description (which is
often generic SEO boilerplate)
- brand_tone — classified into one of our seven enum values from the
writing style on the page
- content_language — detected from the actual content (more reliable
than <html lang> which is often wrong)
- brand_voice_notes — concrete writing guidelines inferred from the
site's style, written in the detected language
When the LLM provider is NOT configured (open-source self-hosted
deploys without API keys), the action keeps the existing
deterministic meta-tag-only flow — no crash, no noise, no LLM cost.
When the LLM fails mid-request, we log a warning and fall back to
the meta-tag values so the user still gets something useful.
Stack additions:
- league/html-to-markdown ^5.1 converts the main body to clean
markdown for the LLM input (truncated to 4000 chars).
- BrandAnalyzer agent (Agent + HasStructuredOutput) with schema
enums matching our allowed tones and languages.
- resources/views/prompts/brand_analyzer.blade.php holds the
instructions, including explicit enum lists and examples of good
voice_notes.
Frontend: Brand.vue now also fills brand_tone and brand_voice_notes
from the response when present.
Tests (+3): LLM-configured happy path, no-credentials fallback
(asserts BrandAnalyzer is never prompted via preventStrayPrompts),
and LLM-exception fallback (meta tags win, brand_tone stays null).
2026-04-16 14:33:02 +00:00
|
|
|
|
2026-04-17 02:05:51 +00:00
|
|
|
expect($result->description)->toBe('Uma descrição curta.');
|
|
|
|
|
expect($result->language)->toBe('pt-BR');
|
feat: use LLM to polish brand autofill when provider is configured
When config('trypost.ai.text_provider') points to a provider whose
API key is populated (services.gemini.api_key or
services.openai.api_key), the autofill action pipes the homepage
markdown through a BrandAnalyzer agent with structured output to
produce higher-quality values for:
- brand_description — 2-3 sentences summarizing the company based on
the actual page content, not the raw meta description (which is
often generic SEO boilerplate)
- brand_tone — classified into one of our seven enum values from the
writing style on the page
- content_language — detected from the actual content (more reliable
than <html lang> which is often wrong)
- brand_voice_notes — concrete writing guidelines inferred from the
site's style, written in the detected language
When the LLM provider is NOT configured (open-source self-hosted
deploys without API keys), the action keeps the existing
deterministic meta-tag-only flow — no crash, no noise, no LLM cost.
When the LLM fails mid-request, we log a warning and fall back to
the meta-tag values so the user still gets something useful.
Stack additions:
- league/html-to-markdown ^5.1 converts the main body to clean
markdown for the LLM input (truncated to 4000 chars).
- BrandAnalyzer agent (Agent + HasStructuredOutput) with schema
enums matching our allowed tones and languages.
- resources/views/prompts/brand_analyzer.blade.php holds the
instructions, including explicit enum lists and examples of good
voice_notes.
Frontend: Brand.vue now also fills brand_tone and brand_voice_notes
from the response when present.
Tests (+3): LLM-configured happy path, no-credentials fallback
(asserts BrandAnalyzer is never prompted via preventStrayPrompts),
and LLM-exception fallback (meta tags win, brand_tone stays null).
2026-04-16 14:33:02 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('falls back to meta tags when BrandAnalyzer throws', function () {
|
Fix OpenRouter via laravel/ai default provider (revises #216) (#220)
* Fix OpenRouter support by using laravel/ai default provider config.
PR #216 patched Lab::OpenRouter into every agent match, but laravel/ai
already resolves config('ai.default') — including openrouter — when agents
omit provider(). Those matches also forced unknown providers to Gemini and
BrandAnalyzerRunner checked a non-existent services.openrouter key.
Remove the duplicated provider() overrides, gate availability on
ai.providers.*.key, and document OPENROUTER_API_KEY.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* chore(deps): bump laravel/ai to v0.10.3 for native OpenRouter support
v0.5.1's OpenRouter driver only covered text/embeddings via the legacy
Prism gateway. v0.10.3 ships a native OpenRouter gateway with image,
audio (TTS/STT), and web search support, and drops prism-php/prism as
a dependency. ai-sdk-development skill docs refreshed via boost:update
to match the installed version.
* fix: honor AI_IMAGE_PROVIDER instead of hardcoding OpenAI's gpt-image-2
AiImageClient always passed model: 'gpt-image-2' to Image::of()->generate(),
so AI_IMAGE_PROVIDER silently did nothing for any provider other than
OpenAI — gemini/xai/openrouter would fail against an OpenAI-only model id
and quietly fall back to a stock photo. Usage recording was hardcoded to
provider 'openai' too, so credits were billed against the wrong model
whenever a different provider actually ran.
Drop the hardcoded model so generation falls through to the SDK's own
config('ai.default_for_images') + per-provider default model, and read
the actual provider/model back off the response's meta for usage
recording and source_meta instead of assuming OpenAI.
* refactor: extract AiImageClient into single-purpose steps, fix uncaught exception
generate() built the prompt, called the SDK, and unpacked the response all
in one block, with bytes extraction happening after the try/catch — so a
response with an empty images collection threw an uncaught RuntimeException
from ImageResponse::firstImage() instead of returning null as documented.
Split into cleanKeywords(), buildPrompt(), resolveBrandContext(), and
toResult(), and moved response unpacking inside the try block so any
malformed response is treated as a failure like everything else. Added a
regression test with an empty-images fake response.
* fix: drop hardcoded default_text_model, resolve model per provider
default_text_model was the only per-modality model override in ai.php —
image, audio, transcription, embeddings, and reranking all just pick a
provider and let it use its own default model. Text had a config-pinned
model on top, forced into every agent's model() and into every usage
log's model field regardless of which provider actually ran. Switching
AI_TEXT_PROVIDER (e.g. to openrouter) kept sending OpenAI's model id to
whichever provider ended up handling the request.
Removed model() from all six agents so laravel/ai resolves the model
from the active provider's own default (OpenAI's default is already
'gpt-5.4', so no behavior change there). Usage-recording call sites now
read the actual provider/model back off the response's meta instead of
assuming config('ai.default')/default_text_model. StreamPostContent
needed the then() callback since broadcast()'s StreamableAgentResponse
doesn't expose meta directly.
* refactor: drop AiConfiguration wrapper, use data_get() for array reads
AiConfiguration was a one-line static helper used by only two call
sites, with no laravel/ai equivalent to lean on (confirmed AiManager
and the Provider base class expose no isConfigured()/hasKey() check —
the package's model is try-then-catch, not pre-flight checks). Inlined
the filled(config(...)) check directly into HandleInertiaRequests and
BrandAnalyzerRunner instead of keeping a class around one line of logic.
Also swapped direct array-key reads for data_get() per project
convention across every file touched by the recent AI provider/model
fixes (agents' budget arrays, RunGenerateNode/StreamPostCreation's
humanizer merge, RegeneratePostMediaImage's baseContext/copy/rendered
access). Write/assignment sites (`$x['key'] = ...`) are left as-is —
data_get() only reads.
* feat: enhance AI configuration with new providers and options
Added strict types declaration and updated Azure OpenAI API version. Introduced new 'bedrock' provider configuration with AWS credentials and role assumptions. Enhanced existing providers with additional options, including image deployment for Azure and OpenAI, and updated URLs for Gemini and Ollama. Added support for an 'openai-compatible' driver to broaden integration capabilities.
* fix: remove trailing newline in AI configuration file
* feat: add support for OpenRouter and ElevenLabs API keys in configuration
Updated the production Docker Compose file and example environment file to include commented-out entries for OPENROUTER_API_KEY and ELEVENLABS_API_KEY. This enhances the configuration options for AI providers, allowing for easier integration of additional services.
* feat: allow per-provider model overrides for every AI modality
Adds a `models` array to each provider block, wired to env vars, so
self-hosted operators can pin a specific text/image/audio/transcription/
embeddings/reranking model instead of relying on the package's built-in
default for that provider. Only added for the modalities each provider
actually implements (verified against laravel/ai's Provider classes).
Azure is left untouched — it resolves models via deployment names
(AZURE_OPENAI_DEPLOYMENT etc.), not raw model strings, which was already
wired before this change.
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-08-11 00:38:50 +00:00
|
|
|
config()->set('ai.providers.gemini.key', 'fake-key');
|
feat: @mentions in comments, AI Action layer + MCP tools, settings tabs
Mentions in post comments
- @mention autocomplete (workspace members, current user excluded) with
marker syntax @[uuid] persisted, display names rendered via CommentBody
chips; live edit replaces markers with names and converts back on save.
- NotifyMentions action with workspace-scoped membership check, dedupes
same user, only newly-added mentions on update.
- Email + in-app via SendNotification job, respecting per-user
notification_preferences.mentioned_in_comment.
- Heartbeat-based presence (Cache, 60s TTL, 30s ping) so online recipients
get only the in-app notification — no email noise.
- Real-time bell on workspace.{id}.user.{id} private channel
(NotificationCreated event), scoped channel name avoids client-side
filtering and lays out a convention for future workspace channels.
- Mailable localized via lang/{en,es,pt-BR}/mail.php; Maizzle source
template for the email is committed and built into resources/views/mail.
AI generation refactor (Action layer + MCP)
- Extracted Actions/Ai/Generate{Image,Video} with QuotaExhaustedException
so agent tools and MCP tools share a single domain entry point.
- Mcp/Tools/Ai/Generate{Image,Video}Tool registered in TryPostServer; both
return MediaResource payloads.
- Orientation::imageApiSize maps non-OpenAI ratios to 1:1/2:3/3:2.
- config/ai.php is now the single source of truth driven by env, removing
the trypost.ai shim. Default text/image providers flipped to OpenAI.
Settings/UX
- /settings/workspace split into shadcn Tabs (Workspace / Brand / Users)
with three components.
- /assets and the in-editor MediaPicker open the ImagePreviewDialog
lightbox on image click while preserving action button behaviour.
- Comments tab landed via ?tab=comments&comment=<id> from notification
click (scroll-to + temporary highlight).
- Mention autocomplete popover flips above when near the viewport bottom.
- Real social platform PNGs replace Tabler brand glyphs in schedule
pills and post list, with hover tooltip carrying display_name + handle.
Bug fixes
- AcceptInvite: controller now passes workspace + role payload that the
Vue page expects; login/register CTAs preselect the invite email.
- WorkspaceInvite mailable: stopped referencing nonexistent
$invite->workspace and $invite->role; column added to the migration,
Invite model casts role to WorkspaceRole, CreateInvite persists it.
- PostCommentCreated: added broadcastAs so .PostCommentCreated actually
matches the Echo listener; payload now includes mentioned_users so
receivers render the chip correctly without a refetch.
- Preview components for X/Pinterest/Threads/Bluesky/LinkedIn/Mastodon/
TikTok/YouTube switched from item.type === 'image' to
!isVideoMedia(item) so media without a persisted type still renders.
- UpdatePostRequest now accepts media.*.{type,mime_type,size,...} so the
posts.media JSON keeps the metadata that the previews need.
- Removed throttle:6,1 from social connect routes (was 429ing legitimate
OAuth retries).
- Used MediaType enum cases instead of literal 'image'/'video' strings
when creating media rows.
Tests
- MentionParser unit tests, NotifyMentions feature tests including
online/offline channel selection and preference gating, MCP AI tool
happy paths, MentionedInComment mailable rendering, AcceptInvite +
search-members + index mentioned_users path. 1229 passing.
2026-05-01 23:59:03 +00:00
|
|
|
config()->set('ai.default', 'gemini');
|
feat: use LLM to polish brand autofill when provider is configured
When config('trypost.ai.text_provider') points to a provider whose
API key is populated (services.gemini.api_key or
services.openai.api_key), the autofill action pipes the homepage
markdown through a BrandAnalyzer agent with structured output to
produce higher-quality values for:
- brand_description — 2-3 sentences summarizing the company based on
the actual page content, not the raw meta description (which is
often generic SEO boilerplate)
- brand_tone — classified into one of our seven enum values from the
writing style on the page
- content_language — detected from the actual content (more reliable
than <html lang> which is often wrong)
- brand_voice_notes — concrete writing guidelines inferred from the
site's style, written in the detected language
When the LLM provider is NOT configured (open-source self-hosted
deploys without API keys), the action keeps the existing
deterministic meta-tag-only flow — no crash, no noise, no LLM cost.
When the LLM fails mid-request, we log a warning and fall back to
the meta-tag values so the user still gets something useful.
Stack additions:
- league/html-to-markdown ^5.1 converts the main body to clean
markdown for the LLM input (truncated to 4000 chars).
- BrandAnalyzer agent (Agent + HasStructuredOutput) with schema
enums matching our allowed tones and languages.
- resources/views/prompts/brand_analyzer.blade.php holds the
instructions, including explicit enum lists and examples of good
voice_notes.
Frontend: Brand.vue now also fills brand_tone and brand_voice_notes
from the response when present.
Tests (+3): LLM-configured happy path, no-credentials fallback
(asserts BrandAnalyzer is never prompted via preventStrayPrompts),
and LLM-exception fallback (meta tags win, brand_tone stays null).
2026-04-16 14:33:02 +00:00
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
'example.com' => Http::response(<<<'HTML'
|
|
|
|
|
<html lang="en">
|
|
|
|
|
<head>
|
|
|
|
|
<title>Acme</title>
|
|
|
|
|
<meta name="description" content="Fallback desc.">
|
|
|
|
|
</head>
|
|
|
|
|
<body><main><p>hi</p></main></body>
|
|
|
|
|
</html>
|
|
|
|
|
HTML, 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
BrandAnalyzer::fake([
|
|
|
|
|
fn () => throw new RuntimeException('LLM went down'),
|
|
|
|
|
]);
|
|
|
|
|
|
2026-04-17 02:05:51 +00:00
|
|
|
$result = ($this->autofill)('https://example.com');
|
feat: use LLM to polish brand autofill when provider is configured
When config('trypost.ai.text_provider') points to a provider whose
API key is populated (services.gemini.api_key or
services.openai.api_key), the autofill action pipes the homepage
markdown through a BrandAnalyzer agent with structured output to
produce higher-quality values for:
- brand_description — 2-3 sentences summarizing the company based on
the actual page content, not the raw meta description (which is
often generic SEO boilerplate)
- brand_tone — classified into one of our seven enum values from the
writing style on the page
- content_language — detected from the actual content (more reliable
than <html lang> which is often wrong)
- brand_voice_notes — concrete writing guidelines inferred from the
site's style, written in the detected language
When the LLM provider is NOT configured (open-source self-hosted
deploys without API keys), the action keeps the existing
deterministic meta-tag-only flow — no crash, no noise, no LLM cost.
When the LLM fails mid-request, we log a warning and fall back to
the meta-tag values so the user still gets something useful.
Stack additions:
- league/html-to-markdown ^5.1 converts the main body to clean
markdown for the LLM input (truncated to 4000 chars).
- BrandAnalyzer agent (Agent + HasStructuredOutput) with schema
enums matching our allowed tones and languages.
- resources/views/prompts/brand_analyzer.blade.php holds the
instructions, including explicit enum lists and examples of good
voice_notes.
Frontend: Brand.vue now also fills brand_tone and brand_voice_notes
from the response when present.
Tests (+3): LLM-configured happy path, no-credentials fallback
(asserts BrandAnalyzer is never prompted via preventStrayPrompts),
and LLM-exception fallback (meta tags win, brand_tone stays null).
2026-04-16 14:33:02 +00:00
|
|
|
|
2026-04-17 02:05:51 +00:00
|
|
|
expect($result->description)->toBe('Fallback desc.');
|
|
|
|
|
expect($result->language)->toBe('en');
|
feat: use LLM to polish brand autofill when provider is configured
When config('trypost.ai.text_provider') points to a provider whose
API key is populated (services.gemini.api_key or
services.openai.api_key), the autofill action pipes the homepage
markdown through a BrandAnalyzer agent with structured output to
produce higher-quality values for:
- brand_description — 2-3 sentences summarizing the company based on
the actual page content, not the raw meta description (which is
often generic SEO boilerplate)
- brand_tone — classified into one of our seven enum values from the
writing style on the page
- content_language — detected from the actual content (more reliable
than <html lang> which is often wrong)
- brand_voice_notes — concrete writing guidelines inferred from the
site's style, written in the detected language
When the LLM provider is NOT configured (open-source self-hosted
deploys without API keys), the action keeps the existing
deterministic meta-tag-only flow — no crash, no noise, no LLM cost.
When the LLM fails mid-request, we log a warning and fall back to
the meta-tag values so the user still gets something useful.
Stack additions:
- league/html-to-markdown ^5.1 converts the main body to clean
markdown for the LLM input (truncated to 4000 chars).
- BrandAnalyzer agent (Agent + HasStructuredOutput) with schema
enums matching our allowed tones and languages.
- resources/views/prompts/brand_analyzer.blade.php holds the
instructions, including explicit enum lists and examples of good
voice_notes.
Frontend: Brand.vue now also fills brand_tone and brand_voice_notes
from the response when present.
Tests (+3): LLM-configured happy path, no-credentials fallback
(asserts BrandAnalyzer is never prompted via preventStrayPrompts),
and LLM-exception fallback (meta tags win, brand_tone stays null).
2026-04-16 14:33:02 +00:00
|
|
|
});
|
|
|
|
|
|
2026-05-21 19:27:55 +00:00
|
|
|
test('toArray swaps site background and text colours for the image palette fields', function () {
|
|
|
|
|
$metadata = new BrandMetadata(
|
|
|
|
|
brandColor: '#eab308',
|
|
|
|
|
backgroundColor: '#ffffff',
|
|
|
|
|
textColor: '#1f2937',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
$array = $metadata->toArray();
|
|
|
|
|
|
|
|
|
|
expect($array['background_color'])->toBe('#1f2937')
|
|
|
|
|
->and($array['text_color'])->toBe('#ffffff')
|
|
|
|
|
->and($array['brand_color'])->toBe('#eab308')
|
|
|
|
|
->and($metadata->backgroundColor)->toBe('#ffffff')
|
|
|
|
|
->and($metadata->textColor)->toBe('#1f2937');
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-17 02:05:51 +00:00
|
|
|
test('BrandMetadata toArray exposes the shape the controller expects', function () {
|
feat: add brand autofill from website URL in onboarding
Users on the Brand step can type their website URL and click
'Preencher' (Portuguese) / 'Autofill' (English). The backend fetches
their homepage, parses standard meta tags, and returns:
- name ← og:site_name | title (suffix stripped at ' | ', ' - ')
- description ← meta[name=description] | og:description
- language ← <html lang> mapped to en / pt-BR / es
- logo ← apple-touch-icon | largest link[rel*=icon] | og:image
The logo is downloaded, validated (mime whitelist, 2MB max), and
attached to the workspace's 'logo' media collection so the avatar
updates immediately.
Zero LLM calls, zero external APIs. Uses symfony/dom-crawler +
symfony/css-selector (newly required) for meta extraction. Everything
else (Http client, workspace media, Intervention) was already in the
project.
Security:
- SSRF guardrail: resolves the host, rejects private / loopback /
link-local ranges, enforces http(s) scheme on both the initial
page fetch and the logo download.
- Rate limited at 10 req/min per user via the throttle middleware
alias on the route.
- Logo content-type must be one of the allowed image mimes; wrong
types are silently dropped so users never see broken images.
UX:
- 'Autofill' button next to the website input, disabled until there
is a URL; shows a spinner while running.
- If a logo was captured, a small preview appears below the input
so users can see what was pulled before saving.
- Success and error paths both surface as vue-sonner toasts, with
translations in en / pt-BR / es.
- Failures leave the form untouched — nothing is destructively
overwritten if parsing gave us nothing.
Tests (16 new): action-level coverage for happy path, title-suffix
fallback, language code normalization across 6 locales, scheme
rejection, private-range SSRF rejection, implicit https prefixing,
empty sites, upstream errors, and wrong-mime logo rejection. Plus
two controller-level tests for the autofill endpoint.
2026-04-16 14:13:54 +00:00
|
|
|
Http::fake([
|
2026-04-17 02:05:51 +00:00
|
|
|
'example.com' => Http::response('<html lang="en"><head><title>Foo</title></head></html>', 200),
|
feat: add brand autofill from website URL in onboarding
Users on the Brand step can type their website URL and click
'Preencher' (Portuguese) / 'Autofill' (English). The backend fetches
their homepage, parses standard meta tags, and returns:
- name ← og:site_name | title (suffix stripped at ' | ', ' - ')
- description ← meta[name=description] | og:description
- language ← <html lang> mapped to en / pt-BR / es
- logo ← apple-touch-icon | largest link[rel*=icon] | og:image
The logo is downloaded, validated (mime whitelist, 2MB max), and
attached to the workspace's 'logo' media collection so the avatar
updates immediately.
Zero LLM calls, zero external APIs. Uses symfony/dom-crawler +
symfony/css-selector (newly required) for meta extraction. Everything
else (Http client, workspace media, Intervention) was already in the
project.
Security:
- SSRF guardrail: resolves the host, rejects private / loopback /
link-local ranges, enforces http(s) scheme on both the initial
page fetch and the logo download.
- Rate limited at 10 req/min per user via the throttle middleware
alias on the route.
- Logo content-type must be one of the allowed image mimes; wrong
types are silently dropped so users never see broken images.
UX:
- 'Autofill' button next to the website input, disabled until there
is a URL; shows a spinner while running.
- If a logo was captured, a small preview appears below the input
so users can see what was pulled before saving.
- Success and error paths both surface as vue-sonner toasts, with
translations in en / pt-BR / es.
- Failures leave the form untouched — nothing is destructively
overwritten if parsing gave us nothing.
Tests (16 new): action-level coverage for happy path, title-suffix
fallback, language code normalization across 6 locales, scheme
rejection, private-range SSRF rejection, implicit https prefixing,
empty sites, upstream errors, and wrong-mime logo rejection. Plus
two controller-level tests for the autofill endpoint.
2026-04-16 14:13:54 +00:00
|
|
|
]);
|
|
|
|
|
|
2026-04-17 02:05:51 +00:00
|
|
|
$result = ($this->autofill)('https://example.com');
|
feat: add brand autofill from website URL in onboarding
Users on the Brand step can type their website URL and click
'Preencher' (Portuguese) / 'Autofill' (English). The backend fetches
their homepage, parses standard meta tags, and returns:
- name ← og:site_name | title (suffix stripped at ' | ', ' - ')
- description ← meta[name=description] | og:description
- language ← <html lang> mapped to en / pt-BR / es
- logo ← apple-touch-icon | largest link[rel*=icon] | og:image
The logo is downloaded, validated (mime whitelist, 2MB max), and
attached to the workspace's 'logo' media collection so the avatar
updates immediately.
Zero LLM calls, zero external APIs. Uses symfony/dom-crawler +
symfony/css-selector (newly required) for meta extraction. Everything
else (Http client, workspace media, Intervention) was already in the
project.
Security:
- SSRF guardrail: resolves the host, rejects private / loopback /
link-local ranges, enforces http(s) scheme on both the initial
page fetch and the logo download.
- Rate limited at 10 req/min per user via the throttle middleware
alias on the route.
- Logo content-type must be one of the allowed image mimes; wrong
types are silently dropped so users never see broken images.
UX:
- 'Autofill' button next to the website input, disabled until there
is a URL; shows a spinner while running.
- If a logo was captured, a small preview appears below the input
so users can see what was pulled before saving.
- Success and error paths both surface as vue-sonner toasts, with
translations in en / pt-BR / es.
- Failures leave the form untouched — nothing is destructively
overwritten if parsing gave us nothing.
Tests (16 new): action-level coverage for happy path, title-suffix
fallback, language code normalization across 6 locales, scheme
rejection, private-range SSRF rejection, implicit https prefixing,
empty sites, upstream errors, and wrong-mime logo rejection. Plus
two controller-level tests for the autofill endpoint.
2026-04-16 14:13:54 +00:00
|
|
|
|
2026-04-17 02:05:51 +00:00
|
|
|
expect($result->toArray())->toHaveKeys([
|
|
|
|
|
'name',
|
|
|
|
|
'brand_description',
|
|
|
|
|
'content_language',
|
2026-06-12 20:09:39 +00:00
|
|
|
'brand_color',
|
|
|
|
|
'background_color',
|
|
|
|
|
'text_color',
|
2026-04-17 02:05:51 +00:00
|
|
|
'logo_url',
|
2026-06-12 20:09:39 +00:00
|
|
|
'brand_voice_traits',
|
2026-04-17 02:05:51 +00:00
|
|
|
]);
|
feat: add brand autofill from website URL in onboarding
Users on the Brand step can type their website URL and click
'Preencher' (Portuguese) / 'Autofill' (English). The backend fetches
their homepage, parses standard meta tags, and returns:
- name ← og:site_name | title (suffix stripped at ' | ', ' - ')
- description ← meta[name=description] | og:description
- language ← <html lang> mapped to en / pt-BR / es
- logo ← apple-touch-icon | largest link[rel*=icon] | og:image
The logo is downloaded, validated (mime whitelist, 2MB max), and
attached to the workspace's 'logo' media collection so the avatar
updates immediately.
Zero LLM calls, zero external APIs. Uses symfony/dom-crawler +
symfony/css-selector (newly required) for meta extraction. Everything
else (Http client, workspace media, Intervention) was already in the
project.
Security:
- SSRF guardrail: resolves the host, rejects private / loopback /
link-local ranges, enforces http(s) scheme on both the initial
page fetch and the logo download.
- Rate limited at 10 req/min per user via the throttle middleware
alias on the route.
- Logo content-type must be one of the allowed image mimes; wrong
types are silently dropped so users never see broken images.
UX:
- 'Autofill' button next to the website input, disabled until there
is a URL; shows a spinner while running.
- If a logo was captured, a small preview appears below the input
so users can see what was pulled before saving.
- Success and error paths both surface as vue-sonner toasts, with
translations in en / pt-BR / es.
- Failures leave the form untouched — nothing is destructively
overwritten if parsing gave us nothing.
Tests (16 new): action-level coverage for happy path, title-suffix
fallback, language code normalization across 6 locales, scheme
rejection, private-range SSRF rejection, implicit https prefixing,
empty sites, upstream errors, and wrong-mime logo rejection. Plus
two controller-level tests for the autofill endpoint.
2026-04-16 14:13:54 +00:00
|
|
|
});
|