No description
  • TypeScript 57.8%
  • JavaScript 41.7%
  • Astro 0.5%
Find a file
2026-10-13 16:04:51 +08:00
bin update 2026-09-09 18:00:56 +08:00
components update 2026-09-07 22:24:04 +08:00
packages/evenlope update 2026-10-13 16:04:51 +08:00
routes route with .astro .yml 2026-09-29 12:44:30 +08:00
runtime update 2026-08-20 14:22:55 +08:00
src update 2026-10-10 13:13:37 +08:00
test update 2026-10-10 13:13:37 +08:00
.gitignore update 2026-10-13 16:04:51 +08:00
package-lock.json 0.1.9 2026-09-30 10:32:20 +08:00
package.json update 2026-10-13 16:04:51 +08:00
README.md yaml 2026-09-30 10:32:04 +08:00
tsconfig.build.json dist 2026-08-19 12:35:57 +08:00
tsconfig.json init 2026-08-19 11:30:39 +08:00

@lorecord/site-engine

Astro-based site engine with a content and theme convention.

Create a site:

npm exec --package=@lorecord/site-engine@latest -- create-site my-site
cd my-site
npm install
npm run dev

Generated projects use this structure:

site/
  assets/   source assets processed by Astro
  config/   system, site, and application data
  pages/    page content
  themes/   theme schemas, content resolvers, components, and templates
  public/   files copied directly to the output root
src/        optional project-specific source extensions

Generated projects also include systemd.service.example as a starting point for running the built static site with site start under systemd. Adjust its deployment path, Node.js PATH, and service user before installing it.

The project entrypoint is site.config.mjs, not astro.config.mjs:

import { defineSiteConfig } from "@lorecord/site-engine/config";

export default defineSiteConfig();

defineSiteConfig() supplies Astro's source entrypoints, content collections, middleware, localized routes, text routes, sitemap, CSP, public directory, and /_assets build path. A project only adds config when it needs a real extension.

Use the site command for the complete lifecycle:

site dev
site build
site preview
site start
site prepare
site routes
site search:capture
site search:notify
site interactions:sync [--since ISO_DATE]
site interactions:register
site interactions:send [--since ISO_DATE]
site astro <command>

Sites can expose interaction endpoints in site/config/system.yml:

interaction:
  enabled: true
  endpoint: https://interact.example/v1/interaction
  # Optional; otherwise interactions:register derives /v1/site/sync.
  metadataEndpoint: https://interact.example/v1/site/sync
webmention:
  enabled: true
  accept: true
  endpoint: https://interact.example/v1/webmention
pingback:
  enabled: true
  accept: true
  endpoint: https://interact.example/v1/pingback
mention:
  send:
    enabled: true
    since: 2015-01-01
websub:
  enabled: true
  endpoint: https://pubsubhubbub.appspot.com/

interactions:sync uses the site-scoped interaction query to fetch a site's results once, then stores them beside each item source in the native and webmention channels. Query APIs are public; if a deployment still expects authentication, INTERACT_TOKEN or --token can be sent. --since consumes the incremental upsert/delete events. interactions:send discovers external webmention/pingback endpoints and records delivery status in each channel's target.yml. search:notify --targets indexnow,websub submits IndexNow and WebSub notifications after deployment. Configure indexNowKey in site/config/system.yml (or use INDEXNOW_KEY for both build and notification).

interactions:register sends the complete route, comment-policy, feature, and author metadata to interact in one authenticated request. It scans all Markdown page templates, including non-item pages such as a guestbook, and does not send page bodies. Use --token or INTERACT_TOKEN for the site's interact token; --endpoint can point at either /v1/interaction or the explicit /v1/site/sync endpoint. Registration is optional for a first native comment: interact can create an unregistered root with its default interaction policy; registration should be used when the site needs explicit policy, authors, titles, or language metadata. IndexNow checks that the deployed /indexnow.txt matches the submission key before sending URLs. A 403 SiteVerificationNotCompleted response is retried after 10, 30, and 60 seconds; other errors are reported immediately. HTTP 202 means the submission was accepted with key validation still pending. If all verification retries fail, rerun site search:notify --targets indexnow later; SEARCH_NOTIFY_FAIL_ON_ERROR=1 makes notification failures exit with an error.

For legacy Markdown content that may contain missing local images, enable a warning-only fallback:

export default defineSiteConfig({
  engine: {
    markdown: {
      missingImages: "warn",
    },
  },
});

Missing images are printed to the server log and rendered as ordinary <img> elements, while existing images continue to use Astro's image pipeline.

Markdown extensions

The engine can provide the shared readMore and Mermaid behavior used by themes. readMore removes a marker-only paragraph from the rendered content; the default marker is ===. Mermaid code blocks are rendered by the browser and, by default, are also pre-rendered into static HTML during a production build:

export default defineSiteConfig({
  engine: {
    markdown: {
      readMore: true,
      // Or: readMore: { marker: "<!-- more -->" }
      mermaid: true,
      // Or: mermaid: { prerender: false }
    },
  },
});

Set mermaid: { prerender: false } when a site needs client-only Mermaid rendering. Sites and themes can also supply mermaid.render(source, context) to replace the engine's static renderer for build-time output.

alerts, footnotes, and externalLinks are also engine features. Themes can provide localized footnote labels and explicit Markdown options, while the engine supplies the shared AST transformations and reads the site's base HTML link settings.

Affiliate alternatives are an explicit Markdown extension. The normal link destination is the affiliate URL; non-aff supplies its ordinary alternative:

Compare [IPRoyal](https://iproyal.com/?r=dallaslu){non-aff="https://iproyal.com/"} plans.

Reference links support the same suffix. Existing .class, #id, and quoted attributes can appear in the same block. Code spans, fenced code and escaped braces are not interpreted as attributes. Both destinations must be absolute HTTP(S) URLs without credentials; invalid values fail the build with the source file location. The engine never infers affiliate links from query parameters.

With non-aff, the engine preserves the standard Markdown anchor's href, adds rel="sponsored nofollow" and a visible benefit disclosure followed by a real original anchor. Both remain available without a theme, JavaScript, or in feeds. The static fallback also includes a localized affiliate marker (〔推广〕-style for CJK locales and [Aff]-style for Latin-script locales) and an ordinary-link marker. The stable data-affiliate-link, data-affiliate-primary, data-affiliate-original, data-affiliate-mark, data-affiliate-fallback, data-affiliate-disclosure, data-affiliate-ordinary and data-affiliate-original-mark attributes let themes enhance the presentation without guessing destinations or rewriting links on click. engine.markdown.affiliateLinks accepts defaultLocale, labels (disclosure, ordinary, affiliate, original, markerOpen, markerClose) or getLabels(locale, file) to localize the fallback. Pill styles, favicon display and interactive affiliate details belong to themes, not to this content relationship.

The aff form remains supported, with its original meaning: the standard Markdown destination is ordinary and the attribute supplies its affiliate variant:

Compare [IPRoyal](https://iproyal.com/){aff="https://iproyal.com/?r=dallaslu"} plans.

This form explicitly selects the affiliate variant as the rendered primary link. Both forms generate the same disclosure and original-link fallback. They can coexist in one article; neither is deprecated. Both forms automatically add sponsored and nofollow, preserving other rel tokens without duplicates; ordinary alternatives do not inherit these affiliate markers. If one link specifies both attributes, they define the two destinations explicitly, and its Markdown destination must match one of them. A third, conflicting destination is an error. An unrelated Markdown renderer follows the standard Markdown destination and may display the attribute suffix as text: the paired-link fallback requires the engine's extension, or an explicit ordinary Markdown link or footnote.

Themes may provide static files under site/themes/<theme>/public/. The engine serves these files during development and copies them to the site output during a production build. The standard sitemap.xsl and rss.xsl files are optional; their XML stylesheet declarations are emitted only when the active theme provides the corresponding file.

The engine also generates /sitemap.txt from the common page metadata. A theme can extend the page model, but sitemap visibility, localization, and taxonomy URLs are handled by the engine.

Feeds

The engine provides the default RSS feed structure through @lorecord/site-engine/feeds. It excludes drafts, scheduled future pages and deleted pages, selects pages by locale and the template configured by the theme, and maps standard page metadata to RSS items. A theme only declares its feed paths and template mapping:

export const feed = {
  feed: "item",
  "note/feed": "note",
};

The engine automatically creates both unlocalized and localized feed routes. Themes can still use the lower-level renderRssFeed() and FeedItem helpers for genuinely non-page feeds such as comments or external news.

Interactions and content envelopes

The engine owns interaction and envelope data processing. It reads comments, citations, and content envelopes stored beside a content source, filters and normalizes them, renders comment Markdown, and builds reply trees. Themes only render the resulting data:

import { getCommentsForSource } from "@lorecord/site-engine/interactions";
import { getEnvelopesForSource } from "@lorecord/site-engine/envelopes";

const comments = getCommentsForSource(sourcePath);
const envelopes = await getEnvelopesForSource(sourcePath);

Settings model

The engine provides an extensible settings model. Its base shape covers site identity and localized metadata such as title, homeTitle, and description. Themes extend that shape only for values they consume, while site instances provide the values under site/config/settings/.

import { defineSettingsModel, loadSettings } from "@lorecord/site-engine/settings";
import { z } from "zod";

const model = defineSettingsModel({
	settings: { companyFounded: z.string().default("") },
});

export const { settings, localeSettings } = loadSettings(model);

Settings models are Zod schemas. The default locale file is validated directly; other locale files are deep-merged over it and then validated, so translations only need to provide changed values. Settings schemas reject unknown fields to surface typos instead of silently dropping them. Use Zod defaults for optional values and model intentional extensions explicitly.

Theme implementation configuration remains separate under site/themes/<theme>/config/; it is not merged into the site settings model.

The engine contains no theme-specific content-kind aliases, locale metadata, or site copy.

Pages

The engine defines the common ContentPageBaseData fields used for routing, localization, publication state, authors, summaries, taxonomy, identifiers, licenses, and CFF metadata. Themes can add their own page fields without reimplementing page grouping or locale fallback:

import {
  createContentPageResolver,
  type ContentPageBaseData,
  type ContentPageEntry,
} from "@lorecord/site-engine/pages";

type ThemePageExtension = { toc?: boolean; menu?: { header?: boolean } };
type ThemePage = ContentPageBaseData<"en"> & ThemePageExtension;

const resolver = createContentPageResolver<"en", ContentPageEntry, ThemePageExtension>({
  // ...the normal page resolver options
  pageDataShape: {},
});

For each content path, the resolver materializes a route for every configured locale when at least one routable source variant exists. A default file such as template.md is the fallback source for missing locales; when no default file exists, the first available localized variant is used as the fallback source. The requested route locale remains distinct from contentLocale, and availableContentLocales contains only actual routable source variants.

A localized variant marked routable: false, draft: true, scheduled for the future, or deleted (deleted: true or a deleted date) blocks that locale instead of falling back to another language. visible is not a generic route switch; it remains a theme/content-type concern.

Content dates and publication

date: '2023-05-10'       # Creation date, not the filesystem creation time
published: '2023-05-18'  # Optional first publication date; otherwise date is used
updated: '2026-09-05'    # Optional content update date, not a build timestamp
living: true           # Optional: a continuously maintained work

draft: true prevents publication regardless of the dates; omission means false. Dates are date strings (optionally with time and timezone), not booleans. Use published only when creation and first publication differ. An effective first publication date in the future schedules the content: routes, locale fallbacks, feeds and sitemaps exclude it until that time. Static sites still need a new build and deployment at or after the scheduled time. Drafts, deletion records and undated ordinary pages do not need invented timestamps.

All consumers use resolvePageDates(data) from @lorecord/site-engine/pages: created reads date, published reads published ?? date, and updated reads updated. isPagePublished(data, now?) centralizes draft, schedule and deletion checks; routing and feed consumers additionally honor routable: false. living describes editorial intent, not a date override. Themes may highlight an update for living works, but first publication, archive chronology, feed publication and citation defaults remain unchanged. Schema.org output maps the three date facts to dateCreated, datePublished and dateModified.

A localized source with its own body has its own dates and living value; it does not inherit a different language's update or publication time. Actual fallback content and metadata-only overrides retain the underlying timeline.

The read boundary accepts legacy published: true/false, modified, and wrapped { date: ... } values. It normalizes them in memory to the canonical fields; conflicting updated/modified or published: false/draft: false fail with a source-specific error. New content should use only the canonical fields. Migration removes published: true, replaces published: false with draft: true, and renames modified to updated; existing date and explicit publication values are retained. Preserve source date precision and the site's existing timezone convention. Never rewrite historical revisions or envelope bytes to migrate metadata, and never use revision creation time as content update time.

Accounts and profiles

The engine loads account profiles from site/data/accounts/. A base file such as username.yml can be supplemented by locale files such as username.zh-CN.yml. Profile lookup removes credential fields before returning data to a site or theme:

import { getProfile, resolveProfiles } from "@lorecord/site-engine/profiles";

const author = getProfile("username", "zh-CN");
const authors = resolveProfiles(["username"], "zh-CN");

Themes can extend the common profile shape without taking ownership of account loading or locale merging:

import { createProfileResolver, type Profile } from "@lorecord/site-engine/profiles";

type ThemeProfile = Profile & { mastodon?: string };
export const { getProfile, resolveProfiles } = createProfileResolver<ThemeProfile>({
  extendProfile: (profile) => profile as ThemeProfile,
});

Deployment adapters

site build writes language configuration, the HTML route manifest, and ready entrypoints under:

.site-engine/generated/adapters/cloudflare-worker.mjs
.site-engine/generated/adapters/esa-function.mjs

Adapter factories are also exported from @lorecord/site-engine/adapters/cloudflare and @lorecord/site-engine/adapters/esa. Generated adapters embed their route data and do not import sibling files through relative .site-engine paths.

Portable site state

Commit site/data/seo/previous-sitemap.xml, the last captured online sitemap, so deleted URL detection also works in a fresh checkout. site search:capture updates it before deployment; failed capture preserves the previous snapshot. Both capture and notify honor PREVIOUS_SITEMAP_PATH and previousSitemapPath; the default follows a custom siteDir. Migrate an existing .search-engine-cache/previous-sitemap.xml to the new location when upgrading. Review and commit snapshot updates after deployment; a CI working-directory change alone is not shared with the next run. A retained snapshot may be stale and should be refreshed before deployment when the site is reachable.

Also retain article .data/interactions/ delivery/source records and site data/webhub.json state. Generated dist/, .astro/, .site-engine/, Vite caches and Wrangler local state remain disposable. Mermaid's current cache is process-local; there is no existing portable render-cache file to migrate.

Engine and theme responsibilities

The engine owns remote link metadata, HTTP/DNS transport, persistent caches and refresh scheduling. Import createLinkCardResolver from @lorecord/site-engine/link-cards, and createLinkIconResolver from @lorecord/site-engine/link-icons. Cache paths and formats are unchanged. Default rendering remains offline; only explicit refresh performs requests.

refreshRenderedLinks(distDirectory, options) from @lorecord/site-engine/link-refresh requires a theme-supplied collectLinkRequests(html) returning { cards: [{ url, language }], icons: [url] }. It owns source-directory mapping, deduplication, bounded concurrency and progress. It accepts the usual site path options, including rootDir, siteDir, pagesDir. Unavailable metadata is represented by undefined; unexpected resolver errors propagate instead of silently becoming cache misses. HTML selectors and card/icon markup belong to the theme and are not dependencies of the engine.

evaluateFreshness(value, now) from @lorecord/site-engine/freshness returns expired or stale state with normalized dates. Themes choose the warning text, CSS classes and placement. Likewise, presentation choices such as header-date selection, menus and rendered-content feature detection stay in the theme.

Queries, metadata and cache lifetime

selectArticles (content-query) centralizes published/deleted/locale eligibility. Themes choose includeHidden, recommendation weights and result counts. Both boolean and dated deletion markers exclude articles from lists.

Metadata composition belongs to the theme. metadata offers string/date/media helpers and warning-only display URL handling; components/JsonLd.astro only serializes the supplied object, escaping HTML delimiters. Neither chooses a Schema.org type or dictates the site's content model.

Display settings use warnings and defaults in development and production alike. Malformed freshness notices are warned about and omitted. Metadata strings are retained when a URL cannot be resolved. Conflicting routes, unsafe network targets and invalid integrity evidence remain errors at their respective boundaries.

createCacheScope owns memoized queries for one site/build, supports clear(), and evicts rejected promises. Press disables query reuse in development and clears scopes on HMR disposal. createLinkMetadata provides matching offline card/icon readers with explicit site paths and a clearable lifetime; development readers observe refreshed disk metadata without restarting the server.

Resource and taxonomy services

The Astro integration exports virtual:site-engine/media: its resolvePageMedia uses a source-file index built from the configured pages directory. Vite still owns asset URLs and small-file inlining. Relative parent paths and query/hash suffixes are supported; missing resources retain their value with a source warning.

taxonomy owns slug/label resolution and alias indexes. Themes retain templates, page titles and layout choices. Undefined terms remain usable; ambiguous aliases remain errors because silently picking one would change the destination.

Use createLinkMetadata for site-scoped rendering and the resolver factories for explicit refresh. The previous module-level resolveLinkCard / resolveLinkIcon singletons have been removed; no process-global reader state is created on import.