17: 搜索引擎优化

seo 资源集中管理应用所需的搜索和共享元数据:sitemap.xmlrobots.txt、hreflang 替代项、规范链接,以及包含 <meta> 描述的 <head> 块,还有 Open Graph 和 Twitter 卡片。它从您已经设置的 props 中派生出所有内容,因此一个统一、完整但又适度的集合从一个地方发出。

17.1: 激活

添加资源(它需要 output):

{
	"resources": [..., "seo", "output"]
}

这单独服务于两个路由:

路由 输出
GET /sitemap.xml %app->pages%app->langs 构建的多语言网站地图
GET /robots.txt AllowDisallow,基于 indexable 常量的限制(见 17.4)

<head> 元数据是每个页面可选的:在需要的地方渲染 %seo->head(17.3)。

17.2: The sitemap and hreflang

The sitemap iterates %app->pages and, for each page, emits an hreflang alternate per entry in %app->langs plus an x-default. Localised paths come from %app->slugs (a uri => localised-uri map) when you have them; otherwise the path is prefixed with the language code.

In your <head>, the same alternates belong as <link rel=alternate hreflang> tags. The resource gives you one view for that, so you loop your languages and let seo format each link:

view head:
<foreach array_keys(%app->langs) AS $lang>
	{{ %seo->link($lang, $lang === %app->lang ? %req->uri : "/$lang".%req->uri) }}
</foreach>
{{ %seo->head }}

The app owns which URLs are alternates (it knows its own routing and localisation); seo owns the markup.

17.2.1 lastmod

An entry in %app->pages may be a plain uri string, or an object carrying that uri plus what else the page has to say:

prop pages => array_merge(
	[void, '/install', '/pricing'],
	array_map(fn($p) => obj(uri: '/blog/'.$p->slug, lastmod: $p->date), %blog->posts),
)

The value may be a Y-m-d date, a full timestamp, or a unix time. Where it comes from is yours to decide: a field on a record, a date in front matter, the modification time of a source file.

For pages whose date is not in your data, the lastmod resource works it out at build time. It resolves each uri in %app->pages to the class that renders it (/pricing to pricing.phlo or page.pricing.phlo), stamps a map next to your generated PHP, and reads it at runtime. Name the exceptions yourself:

prop %lastmod.sources => ['/server-setup' => app.'server.setup.phlo']

Run it from a build hook in data/app.json, so the dates are read where the sources are:

"runAfter": ["php %app/www/app.php lastmod::stamp"],
"release": { "runAfter": ["php %app/www/app.php lastmod::stamp %app/release/"] }

That map is also what a documentation page reads to print "last updated" for its reader, which is usually the better reason to have it.

Lesson. Do not resolve the date at runtime from a file. The assumption "the file is as old as its content" holds on the machine you edit on and breaks everywhere else: a release node carries no .phlo sources at all, and any deploy that copies or clones rewrites every modification time, so every page would claim to have changed on the day it was deployed. Stamp at build time, ship the map. And leave the field off where you have nothing real: a crawler that catches a site inventing them stops trusting it for the whole domain, which costs more than the pages you were trying to help.

17.3: 头块

%seo->head 渲染常规的元数据集,所有内容均来自现有的 props:

它是可组合的,而不是替代品:保留您应用程序自己的头标签(标题、CSRF、样式),并将 %seo->head 作为补充。没有关键词和营销填充;该集合包括 Open Graph、Twitter 卡、规范、hreflang、描述和 robots,这是通常的完整但受限的基线。

默认值从您已有的 props 中读取:

属性 默认来源
og:title 文档标题
og:description / description %app->description
og:image %app->image,如果没有则回退到站点根目录的 /icon.webp
og:url / canonical 当前请求 URL
og:site_name 应用程序 id
og:locale %app->lang 派生(例如 nl 变为 nl_NL
og:type website

17.4: robots.txt 和可索引常量

robots.txt 已生成,并且默认是安全的。除非应用将 indexable 常量设置为一个真实值,否则资源将提供:

User-agent: *
Disallow: /

因此,开发、阶段和任何非公开主机都可以通过不声明 indexable 来取消索引。仅在真实的生产入口点上设置它,同时也设置真实的主机:

phlo_app(
	id: 'Example',
	host: 'example.com',
	indexable: true,
);

使用 indexable: true,资源会提供一个 Allow: /,每个 %app->robotsDisallow 中的条目都有一行 Disallow:,并且还有一行 Sitemap:。由于 robots.txt 是来自路由,因此没有静态的 robots.txt 需要维护或意外从开发主机部署。

17.5: Per-app and per-page overrides

The defaults cover most apps. Override the rest with the cross-class injection idiom (see the Advanced chapter), which sets the prop at build time:

prop %seo.twitterCard = true
prop %seo.ogType = 'article'
prop %seo.siteName = 'Example Co'

twitterCard opts in to the Twitter summary card, ogType overrides the Open Graph type, and siteName overrides the site name (default: %app->title, falling back to the app id).

Two opt-outs are read per request from app props, so a single page can drop out of the index without touching the rest of the site:

Property Effect
%app->image The default Open Graph image for the whole app
%app->noIndex (or %app->noLink) Marks the current page noindex,follow and drops its canonical link

Set %app->noIndex in a route before rendering to keep that one page out of search results while the rest of the app stays indexable.


Reference. The seo and lastmod resources are documented per node in the Manual, generated from the resource files so it never drifts from the code.

最近更新于 2026年8月9日

我们使用必要的cookie来使该网站正常工作。在您的许可下,我们还使用分析工具来改善网站。