eQuantic.UIeQuantic.UI
Docs
Playground
GitHub
Docspt-BR
Integração com o servidor
Edit this page
18 min read
🌐 Esta página em: English · Português
O eQuantic.UI se integra ao ASP.NET Core por uma API fluente para registro de serviços, configuração de middleware e personalização do shell HTML.
Começo rápido
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddUI(options =>
{
options.ScanAssembly(typeof(Program).Assembly)
.WithSsr()
.UseTheme(PhotonTheme.Instance) // Tema write-once
.UseChartJs() // Gráficos
.UseApexCharts() // Gráficos
.ConfigureHtmlShell(shell =>
{
shell.SetTitle("My App")
.SetHtmlClass("dark")
.AddHeadTag("<meta name=\"theme-color\" content=\"#3b82f6\">");
});
});
var app = builder.Build();
app.UseStaticFiles();
app.UseServerActions(); // Middleware dos Server Actions
app.MapUI(); // Roteamento SPA e endpoints dos pacotes
app.Run();
AddUI
O AddUI() é o ponto de entrada principal, que registra todos os serviços centrais:
1
builder.Services.AddUI(options => { ... });
O que ele registra:
UIOptions (singleton) - a configuração
IServerActionRegistry - varre os assemblies procurando métodos [ServerAction]
IServerActionAuthorizationService - autorização dos server actions
IServerRenderingService - o motor de renderização do SSR
IAppTheme - o tema write-once selecionado (UseTheme(...); PhotonTheme por padrão, veja DesignSystem)
IComponentAssetProvider<T> - varrido automaticamente dos assemblies (veja Assets)
IThemeController - a mão do claro/escuro durante o SSR (UseInitialThemeMode(...)); a do browser assume na hidratação
Serviços do SignalR
UIOptions
A API fluente de configuração do framework de interface.
ScanAssembly
Varre um assembly procurando componentes [Page], métodos [ServerAction] e implementações de IComponentAssetProvider<T>.
1
options.ScanAssembly(typeof(Program).Assembly);
Vários assemblies podem ser varridos:
1
2
options.ScanAssembly(typeof(Program).Assembly)
.ScanAssembly(typeof(SharedComponents).Assembly);
WithSsr
Liga ou desliga a renderização no servidor globalmente. O padrão é true.
1
2
options.WithSsr(); // Liga (padrão)
options.WithSsr(false); // Desliga
Desde 0.2.0-preview.1
UseInitialThemeMode
O modo claro/escuro em que o servidor renderiza, que é o que o browser pinta antes de qualquer JavaScript rodar. Light se não for definido.
1
options.UseInitialThemeMode(ThemeMode.Dark);
Ele é um PADRÃO, não um valor fixo: um visitante que alternou o tema carrega um cookie (eq-theme), e ele vence. O controller do próprio browser o escreve a partir do document.cookie, o que não custa nada e não precisa de ida e volta por alternância, e o servidor o lê, que é a razão inteira de ser um cookie e não localStorage: o requisito não é "lembrar" e sim contar ao servidor. Lembrar no localStorage funciona perfeitamente e o servidor não enxerga uma palavra disso, então a página chegaria no modo padrão e seria corrigida na hidratação, que é justamente o piscar que isto remove.
Um valor de cookie não reconhecido é ignorado em vez de confiado: ele é texto fornecido pelo usuário, e essa pergunta tem exatamente duas respostas.
Desde 0.2.0-preview.11
O cookie de tema é configurável, inclusive desligado
1
2
options.UseThemeCookie(name: "acme-theme", days: 30); // renomear / encurtar
options.WithoutThemeCookie(); // nunca escrever um
É UMA configuração porque as duas metades têm que concordar: o controller do browser escreve esse cookie e o servidor o lê. Configure separadamente e eles se afastam, ponto em que o servidor lê um nome que ninguém escreve: a persistência para de funcionar enquanto cada parte dela continua parecendo correta. A configuração atravessa para o browser na configuração da própria página exatamente por isso.
Vale renomear quando dois apps eQuantic dividem um domínio e não deveriam herdar o tema um do outro, ou quando um site já tem uma convenção de cookies.
O WithoutThemeCookie() e o consentimento. Se um cookie de preferência precisa de consentimento sob o GDPR ou a LGPD depende da sua jurisdição e da sua própria avaliação. O framework não decide isso por você, ele te dá o interruptor. Com ele desligado nada é escrito e a alternância continua funcionando: o modo se aplica à página na frente do visitante, ele simplesmente não sobrevive a ela, então toda visita começa do UseInitialThemeMode ou do SO.
Note a cerca: este é um interruptor de tempo de build. Um app que quer começar a persistir no momento em que um visitante aceita um banner precisa da mão dele na escrita: o framework ainda não expõe um gancho de consentimento em tempo de execução.
Isto existe para que nada tenha que adivinhar. Um componente que oferece alternância de tema resolve IThemeController; no browser esse é o controller que carimba data-theme, mas durante o SSR não há browser, e um componente que resolvia nada tinha que assumir um modo, uma suposição que decide o markup que o leitor vê primeiro, então adivinhar errado significa que o primeiro paint está no tema errado e a hidratação corrige na frente dele.
O QUE A ALTERNÂNCIA LÊ IMPORTA TANTO QUANTO O QUE ELA ESCREVE Uma alternância pergunta ao controller o modo atual e aplica o outro, então um controller que reporta o modo errado aplica o modo em que a página já está: o primeiro clique não faz nada e o visitante clica duas vezes. Por isso o controller do browser lê, nesta ordem: o estilo inline dele (uma escolha viva), depois o color-scheme computado (que é como um modo declarado pelo servidor chega, como regra de folha de estilo que nunca aparece em element.style), depois o SO. Desde 0.2.0-preview.12
Aplicar um modo no servidor é deliberadamente inerte, e o modo é lido por requisição em vez de capturado: o controller é um singleton, então um valor capturado entregaria a escolha de um visitante ao primeiro paint do visitante seguinte. Um app que quer memória por requisição (um cookie, um cabeçalho) registra o próprio IThemeController, e este é registrado com TryAdd, então o seu vence.
Páginas individuais podem sair fora:
1
2
[Page("/interactive", DisableSsr = true)]
public class InteractivePage : StatefulComponent { }
WithAssetProvider\<T\>
Registra explicitamente uma implementação de IComponentAssetProvider<T>. Útil para provedores de assemblies externos que o ScanAssembly não cobre.
1
options.WithAssetProvider<ChartJsAssetProvider>();
Veja Assets para os detalhes do sistema de provedores de asset.
ConfigureHtmlShell
Configura o template HTML que embrulha todas as páginas.
1
2
3
4
5
6
7
8
options.ConfigureHtmlShell(shell =>
{
shell.SetTitle("My App")
.SetHtmlClass("dark")
.SetBaseStyles("body { margin: 0; }")
.AddHeadTag("<link rel=\"icon\" href=\"/favicon.ico\">")
.AddHeadTag("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">");
});
O header de proveniência
Desde 0.2.0-preview.29
Toda resposta que o app envia (páginas, Server Actions, bundles estáticos, até o 404) carrega x-powered-by: eQuantic.UI. Ele se instala sozinho: o AddUI registra um startup filter, então nenhum Program.cs o menciona. O valor é só o nome (versão em header de resposta é presente para scanner de vulnerabilidade), e ele nunca sobrescreve um x-powered-by que outra coisa já pôs.
1
2
// Para o app cujo checklist de hardening reprova qualquer x-powered-by:
options.WithoutPoweredByHeader();
Propriedades
Propriedade
Tipo
Padrão
Descrição
EnableSsr
bool
true
Interruptor global do SSR
EnableDefaultCss
bool
true
Injeta o CSS padrão do eQuantic (ponha false com Tailwind)
HtmlShell
HtmlShellOptions
-
Configuração do template HTML
HtmlShellOptions
Método
Descrição
SetTitle(string)
O <title> da página
SetHtmlClass(string)
Classe no elemento <html> (ex.: "dark")
SetBaseStyles(string)
O bloco <style> base
AddHeadTag(string)
Tag HTML crua injetada no <head>
O SetBaseStyles substitui só os padrões COSMÉTICOS. A invariante estrutural (#app como quadro determinado: height: 100dvh; display: grid, filhos min-height: 0, o espelho web da janela nativa) é emitida pelo próprio template do shell, antes dos estilos do app. Uma página de APP (raiz Height = Fill) recebe exatamente um viewport e rola internamente; uma página de DOCUMENTO (raiz de altura automática) transborda o quadro e o body rola como sempre rolou. Um app ainda pode sobrepor a regra deliberadamente; ele não consegue apagá-la sem querer.
MapPage: uma rota declarada ao lado de todo outro endpoint
Desde 0.2.0-preview.13
1
2
app.MapPage<HomePage>("/");
app.MapPage<DocPage>("/docs/{slug}", title: "Docs");
O atributo [Page("/route")] fica, e para uma página cuja rota faz parte do que ela É (um 404, um login) ele continua sendo a melhor resposta. O MapPage<T> é para o resto: rotas que um app quer ler num lugar só, rotas que diferem entre hosts, uma página montada num caminho que o arquivo dela não tem por que conhecer. Ele também é o único jeito de rotear uma página de um assembly que não é seu.
Declare junto de onde todo outro endpoint é declarado, antes do app.Run(). A rota se registra nos três lugares em que uma rota tem que existir (a tabela de endpoints, o índice de páginas do SSR, e a tabela do cliente para a navegação SPA), então nada rio abaixo consegue distinguir os dois jeitos de declarar uma rota. Uma rota que só se registra pela metade é pior que nenhuma: a página serve, e aí o primeiro link no cliente para ela recarrega o documento inteiro sem razão visível.
Um tipo que não é componente lança no arranque se nomeando, em vez de na primeira requisição a uma rota que ninguém consegue servir.
Uma página é construída a partir dos serviços da REQUISIÇÃO
Desde 0.2.0-preview.13
1
2
3
builder.Services.AddScoped<IOrders, Orders>(); // um DbContext, uma unidade de trabalho, o tenant atual
public sealed class OrdersPage(IOrders orders) : StatelessComponent { }
As páginas e os server actions são construídos a partir do context.RequestServices. Isto é uma correção, não uma feature: os dois construíam do container raiz da aplicação, e o .NET recusa entregar um serviço scoped a partir da raiz por design, porque um serviço scoped resolvido ali sobrevive à requisição e passa a ser compartilhado por todas as seguintes.
O caminho dos server actions é onde isso mais doía, já que é exatamente ali que as coisas scoped vivem. Aparecia como um 500 dizendo "An error occurred while processing the request."
Invisível até se pedir ao container que confira: um container construído com as opções padrão entrega serviços scoped a partir da raiz muito alegremente, e o ASP.NET Core só valida em Development.
Uma página cujo próprio construtor lança agora diz isso. Essa falha era engolida e a página silenciosamente reconstruída sem nada injetado: ela renderizava o estado vazio dela como se não tivesse pedido nada, a dependência era nula, e a exceção que explicava isso tinha sumido.
Middleware
UseServerActions
Registra o middleware dos Server Actions para tratar chamadas RPC vindas do browser.
1
app.UseServerActions();
Server Actions são métodos marcados com [ServerAction] que executam no servidor e devolvem resultados ao cliente:
1
2
3
4
5
6
[ServerAction]
public async Task<List<Todo>> LoadTodos()
{
using var db = new AppDbContext();
return await db.Todos.ToListAsync();
}
MapUI
Mapeia o roteamento SPA: toda rota [Page] ganha um endpoint, e um fallback serve o shell HTML para todo o resto.
1
app.MapUI();
No cliente, a navegação é um roteador SPA completo: navegação sem recarga, parâmetros de rota tipados, layout persistente por reconciliação na navegação, guardas, prefetch e restauração de rolagem, verificados de ponta a ponta pela suíte do Playwright.
O fallback responde a uma rota desconhecida com um HTTP 404 de verdade, nunca um 200 que só parece um, para que crawlers e monitores aprendam a verdade. O que renderiza com esse status:
A página 404 do próprio app, quando declarada. Roteie uma página write-once comum em "/404" e ela vira a página de não encontrado: renderizada no servidor e montada no cliente para toda URL desconhecida, com o tema do app, transpilada para o bundle do próprio app como qualquer página:
1
2
3
4
5
[Page("/404", Title = "Not found | My App")]
public sealed class NotFoundScreen : StatelessComponent
{
public override VisualNode Build(ComponentContext context) => /* qualquer página */;
}
Uma página roteada em "/500" é registrada do mesmo jeito: em produção, quando o SSR da página pedida falha, aquela página renderiza com status 500.
Uma embutida e estilizada, caso contrário. O runtime pinta uma página de não encontrado mínima e ciente do tema (tokens via var(--eq-color-*), fallback claro/escuro do SO quando o app não selecionou tema). O mesmo renderizador compartilhado dá suporte à página de erro de boot e à tela de boas-vindas sem páginas, e nenhuma delas depende de folha de estilo nenhuma existir.
Navegar direto para /404 cai numa página mapeada e responde 200; só o fallback fala 404.
RegisterServices / RegisterEndpoints
A costura pela qual um PACOTE estende o app, que é como o UseChartJs() e companhia são construídos, em vez de algo que um app normalmente chama:
1
2
options.RegisterServices(services => services.AddSingleton<IMyThing, MyThing>());
options.RegisterEndpoints(endpoints => endpoints.MapGet("/_mine/thing.js", ));
O primeiro roda dentro do AddUI(), o segundo dentro do MapUI(), então um pacote publica um método de extensão e um app ganha os serviços e as rotas dele numa linha só.
Estilo e ícones não precisam de registro
Não há UseTailwind() nem UseLucideIcons(). Os dois já existiram e sumiram, e a razão vale conhecer em vez de adivinhar:
O estilo é um motor só agora: C# tipado rebaixado para classes atômicas deduplicadas, descrito em Estilo. Nada é registrado e nenhuma folha de estilo utilitária é buscada. Qualquer CSS externo que você traga é assunto do build do seu app.
Os ícones são catálogos, não provedores: você nomeia o glifo (Glyph(LucideIcons.Search)) e o compilador embute aquele. Não há registro para acrescentar e nenhum nome para resolver em tempo de execução. Veja Ícones.
UseChartJs / UseApexCharts
Habilita os serviços e os endpoints de script de CDN das bibliotecas de gráfico.
1
2
3
4
5
builder.Services.AddUI(options =>
{
options.UseChartJs()
.UseApexCharts();
});
Hot reload (web)
Ligado em Development (ou forçado com options.HotReload = true): o servidor observa os *.cs do app, roda de novo o próprio target eqc do SDK ao salvar, e manda todo browser conectado atualizar por SSE (/_equantic/hmr). ~5s de salvar até os pixels com um MSBuild quente.
Comportamentos que tornam o circuito confiável:
Um reload disparado pelo hot reload MONTA (renderiza no cliente com o código novo) em vez de hidratar, porque o SSR ainda vem do assembly em execução do servidor, então adotar o DOM velho mostraria os pixels velhos.
O canal SSE manda um comentário : ping a cada 20s para que proxies que derrubam conexões ociosas e o Kestrel mantenham a requisição estacionada viva; a reconexão do próprio EventSource do browser trata quedas transitórias.
A reconstrução lê os pipes de saída dela concorrentemente e loga a duração.
Escopo: este pipeline atualiza o CLIENTE, enquanto o C# do próprio servidor (server actions, corpos de SSR) roda o assembly carregado. Para edições no servidor, rode sob dotnet watch run: o hot reload do .NET remenda o servidor em execução no processo, e este pipeline continua tratando a metade cliente.
O overlay de erro: uma pilha em C#, porque quem desenvolveu escreveu C#
Um erro não capturado em desenvolvimento levanta o modal no estilo do Next.js (mensagem, quadro de código, pilha de chamadas), exceto que a pilha que ele mostra é C#: Screens/PaymentsPage.cs:441, com as linhas do arquivo C# que falharam renderizadas e realçadas. Uma pilha de JS minificado é ruído de uma máquina que quem desenvolve nunca pediu para conhecer.
Como funciona: o browser percorre a pilha JS do erro por DOIS mapas. O .js.map do próprio bundle cai no intermediário TS (o Bun não compõe mapas de entrada), e o .ts.map gerado pelo eqc ao lado desse intermediário cai no C#: arquivo, linha, e o próprio texto do código, embutido no mapa, servido em desenvolvimento em /_equantic/src-map/{name} e respondendo 404 em produção. Frames que não conseguem mapear até o fim ficam rotulados (js), porque uma afirmação verdadeira sobre onde o mapeamento parou vence uma linha C# adivinhada.
A precisão é no nível de MEMBRO: o frame cai no arquivo certo e na linha do membro que o contém (o emissor registra mapeamentos por membro, não por instrução).
Renderização no servidor (SSR)
Quando o SSR está ligado, o framework:
1.
Encontra o componente [Page] que casa com a rota
2.
Cria a instância do componente (com suporte a DI)
3.
Coleta as dependências de asset (veja Assets)
4.
Coleta os metadados de SEO (veja abaixo)
5.
Renderiza a árvore de componentes para HTML
6.
Serializa o estado para a hidratação no cliente
7.
Serve a página HTML completa
Desde 0.2.0-preview.1
Dados do servidor no primeiro render (IServerPrefetch)
Uma página declara os dados de que precisa, o pipeline de SSR os aguarda antes de construir a árvore, e os valores viajam ao browser para que a hidratação veja exatamente o que o servidor renderizou. O markup carrega números reais para os crawlers, e o cliente nunca pisca um estado vazio virando um preenchido.
1
2
3
4
5
6
7
8
9
10
public sealed class HomePage : StatelessComponent, IServerPrefetch
{
private PackageStats _stats = PackageStats.Empty;
[ServerOnly]
public async Task PrefetchAsync(IServiceProvider services, CancellationToken cancellationToken)
=> _stats = await services.GetRequiredService<IPackageStats>().LoadAsync(cancellationToken);
public override VisualNode Build(ComponentContext context) => new HeroSection(_stats);
}
Três coisas decidem se isso funciona:
O [ServerOnly] mantém a implementação fora do bundle do cliente, então ela pode usar a superfície inteira do servidor: HttpClient, EF, os serviços da própria requisição.
Guarde os resultados em CAMPOS. A carga de hidratação viaja por nome de campo para os campos idênticos do gêmeo transpilado. Uma propriedade não atravessa.
Ele roda uma vez por requisição, antes do primeiro build, que é o que o diferencia de carregar num handler e chamar SetState.
Hosts nativos renderizam localmente e não fazem prefetch nenhum: um shell Photon carrega os mesmos dados antes de construir a árvore, como uma chamada explícita.
…e numa NAVEGAÇÃO do cliente
Desde 0.2.0-preview.21
Um link dentro de um app já iniciado nunca chega ao servidor, então por um tempo ele trocava o componente e mais nada: o prefetch não rodava, e toda página navegada renderizava o estado vazio que ela foi escrita para mostrar enquanto os dados carregam, sem nada carregando. O head mantinha o título e o canônico do documento anterior, o que para um crawler é uma página afirmando que duas URLs são o mesmo documento.
O roteador agora pede os dados da página à própria rota de destino, carregando um cabeçalho:
1
2
GET /docs/Photon X-EQ-Navigate: 1
→ { "title": "Photon | …", "head": "<link rel=canonical …>", "state": { … } }
Ir à rota em vez de a um endpoint lateral é a parte que sustenta tudo: os parâmetros de rota, a query e a resolução da página são os que uma carga completa teria, porque É a mesma rota. Um endpoint lateral recebendo um caminho teria que reimplementar os três.
O estado chega pela mesma porta que a carga do SSR usa, então os campos de IServerPrefetch são preenchidos antes do primeiro build. O head é remendado por identidade, no atributo que nomeia uma tag (name, property, rel), nunca acrescentando ao fim, ou o canônico da página anterior sobreviveria ao lado do novo. Uma falha não é fatal: a página então renderiza exatamente o que renderizava antes disto existir.
Ele não desenha. O PreparePageAsync roda o mesmo código que o shell roda menos o markup: uma navegação de cliente já tem o componente e constrói a árvore sozinha, então HTML renderizado aqui seria HTML jogado fora. Acessível uma vez por navegação, e nem um pouco uma vez por link sob o ponteiro, que é sobre o que a próxima seção fala.
Um link sob o ponteiro chega quente
Desde 0.2.0-preview.23
Apontar para um link do app aquece as duas metades da navegação que ele sugere: o bundle da página, e a carga acima. O clique que segue faz nenhuma requisição.
Medido no próprio site desta wiki, num documento grande: 172 ms → 28 ms até o primeiro remendo no DOM.
Três coisas tinham que ser verdade, e duas delas eram silenciosamente falsas por muito tempo:
Um link tem que convidar. O roteador aquece rotas no hover desde que foi escrito, controlado por data-prefetch, e nada no framework jamais marcava um link: código morto, e toda navegação pagava por tudo na hora do clique. Destinos internos ao app o carregam agora; uma URL absoluta é o servidor de outra pessoa e não carrega.
A resposta aquecida tem que ser ENCONTRADA. Medido primeiro, e a navegação aquecida saiu *mais lenta* que a fria: o hover guardava a carga sob uma string e o clique a procurava com um objeto URL. Um cache em que ninguém acerta é pior que nenhum cache, porque ele custa a requisição que estava lá para poupar, e parece estar funcionando.
O roteador pergunta uma vez por link e engole as falhas, então o pior caso é trabalho que o clique ia fazer de qualquer forma, feito um pouco antes.
O que sobra é o build da própria página. Num documento grande isso é a maior parte do tempo, e nenhuma alavanca do framework o encurta: o conteúdo decide.
Uma página que não achou nada (IHandleStatus)
Desde 0.2.0-preview.13
Uma rota como /docs/{slug} casa com todo slug, incluindo os que não nomeiam documento nenhum. A página renderiza "não encontrado", e sem isto o servidor ainda responde 200 OK, então o leitor vê a coisa certa enquanto toda máquina ouve a errada. Um crawler indexa a página vazia, um verificador de links chama o site de saudável, e uma sonda de disponibilidade nunca percebe. A falha é invisível justamente para as coisas cujo trabalho é perceber.
1
2
3
4
5
6
7
8
9
10
public sealed class DocPage : StatelessComponent, IServerPrefetch, IHandleStatus
{
private Doc? _doc;
[ServerOnly]
public async Task PrefetchAsync(IServiceProvider services, CancellationToken cancellationToken)
=> _doc = await services.GetRequiredService<IDocs>().FindAsync(Slug, cancellationToken);
public int StatusCode => _doc is null ? 404 : 200;
}
Lido depois do prefetch, porque "isto existe" é algo que uma página normalmente aprende carregando. Uma página que não o implementa responde 200, então nada escrito antes disto muda. Hosts nativos não têm status para responder e o ignoram; a árvore é a mesma de qualquer jeito.
SEO e metadados
Os componentes implementam IHandleMetadata para SEO dinâmico:
1
2
3
4
5
6
7
8
9
10
11
public class BlogPost : StatelessComponent, IHandleMetadata
{
public void ConfigureMetadata(SeoBuilder seo)
{
seo.Title("Blog Post Title")
.Description("A summary of the post...")
.Canonical("https://example.com/blog/post")
.OpenGraph("type", "article")
.Twitter("card", "summary_large_image");
}
}
Métodos do SeoBuilder:
Método
Descrição
Title(string)
Título da página
Description(string)
Meta description
Canonical(string)
URL canônica
Image(string, string?)
A imagem de compartilhamento. Escreve og:image E twitter:image, mais as duas variantes :alt quando você passa uma. Esquecer a metade do Twitter é por que um cartão aparece em branco em metade dos lugares em que é colado
Keywords(string)
Meta keywords
Robots(bool, bool)
Diretivas de index/follow
OpenGraph(string, string)
Propriedade OG
Twitter(string, string)
Propriedade de card do Twitter
Desde 0.2.0-preview.1
Padrões para o app inteiro, e uma página sobrepondo-os
O shell declara o que toda página deve dizer a menos que ela diga o contrário; o ConfigureMetadata da própria página o sobrepõe por chave, para que os dois nunca apareçam juntos.
1
2
3
4
5
6
builder.Services.AddUI(options => options
.ConfigureHtmlShell(shell => shell
.SetTitle("Acme")
.ConfigureMetadata(seo => seo
.Image("https://acme.test/og-default.png", "Acme")
.Twitter("card", "summary_large_image"))));
Uma página então redeclara só o que difere, e o tipo de cartão e a imagem de fallback acima sobrevivem intocados:
1
2
3
4
5
public void ConfigureMetadata(SeoBuilder seo) =>
seo.Title("Playground")
.Description("Write a component in C#, press Run…")
.Canonical("https://acme.test/playground")
.Image("https://acme.test/og-playground.png");
Vale declarar isso com clareza porque antes não funcionava. O AddDescription escrevia HTML cru no head, e HTML cru não compartilha chave com nada, então um app com uma descrição global e uma página com a dela publicavam duas <meta name="description">, e nenhuma página conseguia vencer. A única saída era deixar a global vazia, o que a tornava inútil para a única coisa para a qual uma global serve. Os metadados do shell agora semeiam a mesma coleção em que a página escreve.
O AddHeadTag continua sendo a saída de emergência para markup genuinamente cru (um <link rel="icon">, um bloco JSON-LD). Qualquer coisa com uma chave de metadado pertence ao ConfigureMetadata, ou não pode ser sobreposta.
O jeito recomendado de registrar recursos de interface é dentro do bloco fluente do AddUI:
1
2
3
4
5
6
7
builder.Services.AddUI(options =>
{
options
.UseChartJs()
.UseApexCharts()
.ScanAssembly(typeof(Program).Assembly);
});
Ordem do middleware:
1
2
3
4
app.UseStaticFiles();
app.UseRouting();
app.UseServerActions(); // Antes do MapUI
app.MapUI(); // Fallback SPA + endpoints dos pacotes (por último)