@ampless/runtime 1.0.0-beta.66 → 1.0.0-beta.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Post, MediaMetadata, AmplessPlugin, ContentFieldRenderer, PluginPublicRenderContext, TiptapNodeHtmlAdapters, TiptapNodeMarkdownAdapters, Config, ThemeModule, ThemeManifest, PluginPackageManifest } from 'ampless';
1
+ import { Post, MediaMetadata, AmplessPlugin, ContentFieldRenderer, TiptapNodeMarkdownAdapters, PluginPublicRenderContext, TiptapNodeHtmlAdapters, Config, ThemeModule, ThemeManifest, PluginPackageManifest } from 'ampless';
2
2
  export { Config, Post, ThemeManifest } from 'ampless';
3
3
  import { Metadata } from 'next';
4
4
  import { ReactNode } from 'react';
@@ -188,6 +188,21 @@ interface ContentFieldRegistry {
188
188
  * render that happens to walk the conflicting node.
189
189
  */
190
190
  declare function buildContentFieldRegistry(plugins: readonly AmplessPlugin[]): ContentFieldRegistry;
191
+ /**
192
+ * Merge every plugin's server-safe `tiptapNodeToMarkdown` map into a
193
+ * single `TiptapNodeMarkdownAdapters` registry. Eagerly errors on
194
+ * duplicate `nodeType` registration across plugins (two different
195
+ * functions) so the misuse surfaces at config time, not at the first
196
+ * markdown conversion that happens to walk the conflicting node. The
197
+ * exact same function registered twice (e.g. one shared adapter map
198
+ * reused by two plugin instances) is tolerated.
199
+ *
200
+ * Non-function entries are skipped with a warning rather than thrown:
201
+ * `definePlugin` already warns about them at definition time, and one
202
+ * broken plugin shouldn't take the whole site's markdown output down
203
+ * (a nodeType conflict, by contrast, is a config error — that throws).
204
+ */
205
+ declare function buildMarkdownAdapterRegistry(plugins: readonly AmplessPlugin[]): TiptapNodeMarkdownAdapters;
191
206
  interface RenderBodyOptions {
192
207
  contentFields?: ContentFieldRegistry;
193
208
  /**
@@ -280,6 +295,54 @@ declare function tiptapToMarkdown(doc: unknown, opts?: {
280
295
  * relied on.
281
296
  */
282
297
  declare function htmlToMarkdown(html: string): string;
298
+ interface PostToMarkdownOptions {
299
+ /**
300
+ * Per-nodeType tiptap→markdown serialisers (plugin embed nodes →
301
+ * bare URL lines). `createAmpless` injects
302
+ * `pluginHead.markdownAdapters`; a raw direct caller can omit this
303
+ * to fall back to the built-in switch (unknown atom nodes then emit
304
+ * unsupported-node placeholder comments).
305
+ */
306
+ nodeAdapters?: TiptapNodeMarkdownAdapters;
307
+ /** Prepend YAML frontmatter (default: true). */
308
+ frontmatter?: boolean;
309
+ /**
310
+ * Absolute site origin used to build the frontmatter's `canonical`
311
+ * line (`<siteUrl>/<slug>`). Omitted → no canonical line.
312
+ */
313
+ siteUrl?: string;
314
+ }
315
+ /**
316
+ * Convert a public post into its canonical Markdown representation:
317
+ * YAML frontmatter + format-dependent body. This is the single
318
+ * conversion point for every AI-readable surface (the `/<slug>.md`
319
+ * route, llms-full, public MCP `get_post`) — new surfaces should call
320
+ * this rather than reimplementing the format branches.
321
+ *
322
+ * Frontmatter carries **public fields only** (title / slug /
323
+ * publishedAt / updatedAt / tags / excerpt / canonical) — never
324
+ * postId, status, or metadata. Values are encoded with
325
+ * `JSON.stringify`: a JSON string is a valid YAML double-quoted
326
+ * scalar and a JSON array is a valid YAML flow sequence, so quoting /
327
+ * escaping is correct without a YAML library.
328
+ *
329
+ * Body quality varies by format:
330
+ * - `markdown`: the stored source, verbatim.
331
+ * - `tiptap`: `tiptapToMarkdown` with `opts.nodeAdapters` (plugin
332
+ * embeds become bare URL lines; unknown atom nodes emit an
333
+ * `ampless:unsupported-node` placeholder comment).
334
+ * - `html`: `htmlToMarkdown` — regex-based and approximate; complex
335
+ * markup may not fully survive (see htmlToMarkdown's limits).
336
+ * - `static`: no body conversion (the bundle bytes live in S3) — a
337
+ * markdown link to the served entrypoint, plus the excerpt when
338
+ * present.
339
+ *
340
+ * Pure, synchronous, and node-safe — same tier as `tiptapToMarkdown` /
341
+ * `htmlToMarkdown`. Themes / routes should usually call it through
342
+ * `ampless.postToMarkdown(post)`, which injects the plugin adapter
343
+ * registry and the effective site URL.
344
+ */
345
+ declare function postToMarkdown(post: Post, opts?: PostToMarkdownOptions): string;
283
346
 
284
347
  interface PluginHeadApi {
285
348
  /**
@@ -328,6 +391,15 @@ interface PluginHeadApi {
328
391
  * `contentFields`.
329
392
  */
330
393
  readonly contentFieldsRegistry: ContentFieldRegistry;
394
+ /**
395
+ * Merged tiptap→markdown adapter registry across all valid plugins
396
+ * (server-safe `tiptapNodeToMarkdown` manifests). Built once at
397
+ * `createPluginHead` construction time so duplicate `nodeType`
398
+ * registrations across plugins throw eagerly (config error).
399
+ * Threaded into `rendering.ts:postToMarkdown()` via
400
+ * `Ampless.postToMarkdown`.
401
+ */
402
+ readonly markdownAdapters: TiptapNodeMarkdownAdapters;
331
403
  /**
332
404
  * Resolve the per-plugin `PluginPublicRenderContext` snapshot used by
333
405
  * `contentFields` renderers. Same `settings()` binding the public
@@ -652,6 +724,22 @@ interface Ampless {
652
724
  * complete HTML documents that don't expand embed shortcuts.
653
725
  */
654
726
  renderBodyHtmlString(post: Post): string;
727
+ /**
728
+ * Convert a post into its canonical Markdown representation (YAML
729
+ * frontmatter + body). This is the **single conversion point** for
730
+ * every AI-readable surface — the `/<slug>.md` route, llms-full, and
731
+ * the public MCP `get_post` all go through here rather than
732
+ * reimplementing the format branches. Plugin embed nodes are
733
+ * serialised via `pluginHead.markdownAdapters` (bare URL lines);
734
+ * `format: 'html'` bodies go through the regex-based `htmlToMarkdown`
735
+ * and are approximate. Async because the frontmatter's `canonical`
736
+ * line needs the effective `site.url` from the S3 site-settings
737
+ * cache; with `frontmatter: false` that read is skipped entirely and
738
+ * only the body is returned.
739
+ */
740
+ postToMarkdown(post: Post, opts?: {
741
+ frontmatter?: boolean;
742
+ }): Promise<string>;
655
743
  renderThemeCss(cssVars: Record<string, string>): string;
656
744
  publicAssetUrl(key: string): string;
657
745
  isStorageConfigured(): boolean;
@@ -677,4 +765,4 @@ interface Ampless {
677
765
  */
678
766
  declare function createAmpless(opts: CreateAmplessOpts): Ampless;
679
767
 
680
- export { type Ampless, type AmplessOutputs, COLOR_SCHEME_SETTING_KEY, type ColorScheme, type ContentFieldRegistry, type CreateAmplessOpts, DEFAULT_COLOR_SCHEME, type DataOutput, type EffectiveSiteSettings, type EffectiveThemeConfig, type ListPostsByTagOptions, type ListPostsOptions, type ListPostsResult, type MediaApi, type PluginHeadApi, type PluginSettingsApi, type PluginSettingsSnapshot, type PostsApi, type PublicHtmlForPostResult, type PublicMediaShape, type PublicPostConnectionShape, type PublicPostShape, type RenderBodyOptions, type ResolvedAssetMeta, type ResolvedMedia, type ResolvedTheme, SUPPORTED_API_VERSION, type SeoApi, type SiteSettingsApi, type StorageOutput, type StreamS3Options, type ThemeActiveApi, type ThemeConfigApi, type ThemesRegistry, _resetStreamS3Cache, buildContentFieldRegistry, createAmpless, createPluginHead, createPluginSettings, escapeJsonLdInlineBody, htmlToMarkdown, loadPackageManifest, markdownToHtml, renderBody, renderBodyHtmlString, renderThemeCss, streamS3Object, streamS3ObjectWithRunner, tiptapToHtml, tiptapToMarkdown, validateColorScheme };
768
+ export { type Ampless, type AmplessOutputs, COLOR_SCHEME_SETTING_KEY, type ColorScheme, type ContentFieldRegistry, type CreateAmplessOpts, DEFAULT_COLOR_SCHEME, type DataOutput, type EffectiveSiteSettings, type EffectiveThemeConfig, type ListPostsByTagOptions, type ListPostsOptions, type ListPostsResult, type MediaApi, type PluginHeadApi, type PluginSettingsApi, type PluginSettingsSnapshot, type PostToMarkdownOptions, type PostsApi, type PublicHtmlForPostResult, type PublicMediaShape, type PublicPostConnectionShape, type PublicPostShape, type RenderBodyOptions, type ResolvedAssetMeta, type ResolvedMedia, type ResolvedTheme, SUPPORTED_API_VERSION, type SeoApi, type SiteSettingsApi, type StorageOutput, type StreamS3Options, type ThemeActiveApi, type ThemeConfigApi, type ThemesRegistry, _resetStreamS3Cache, buildContentFieldRegistry, buildMarkdownAdapterRegistry, createAmpless, createPluginHead, createPluginSettings, escapeJsonLdInlineBody, htmlToMarkdown, loadPackageManifest, markdownToHtml, postToMarkdown, renderBody, renderBodyHtmlString, renderThemeCss, streamS3Object, streamS3ObjectWithRunner, tiptapToHtml, tiptapToMarkdown, validateColorScheme };
package/dist/index.js CHANGED
@@ -399,6 +399,29 @@ function buildContentFieldRegistry(plugins) {
399
399
  }
400
400
  return { tiptap, markdownUrl, htmlPlaceholder };
401
401
  }
402
+ function buildMarkdownAdapterRegistry(plugins) {
403
+ const merged = {};
404
+ for (const plugin of plugins) {
405
+ const adapters = plugin.tiptapNodeToMarkdown;
406
+ if (!adapters) continue;
407
+ for (const [nodeType, adapter] of Object.entries(adapters)) {
408
+ if (typeof adapter !== "function") {
409
+ console.warn(
410
+ `[ampless markdownAdapters] plugin "${plugin.instanceId ?? plugin.name}" declares a non-function tiptapNodeToMarkdown entry for nodeType "${nodeType}". Skipping the entry.`
411
+ );
412
+ continue;
413
+ }
414
+ const existing = merged[nodeType];
415
+ if (existing && existing !== adapter) {
416
+ throw new Error(
417
+ `[ampless markdownAdapters] duplicate tiptap nodeType "${nodeType}" \u2014 already registered by another plugin. Each nodeType may be claimed by at most one plugin.`
418
+ );
419
+ }
420
+ merged[nodeType] = adapter;
421
+ }
422
+ }
423
+ return merged;
424
+ }
402
425
  function htmlPassthrough(html) {
403
426
  return createElement("span", { dangerouslySetInnerHTML: { __html: html } });
404
427
  }
@@ -776,9 +799,19 @@ function tiptapNodeToMarkdown(node, opts) {
776
799
  `;
777
800
  }
778
801
  default:
779
- return children;
802
+ if (children) return children;
803
+ console.warn(
804
+ `[ampless] tiptapToMarkdown: no adapter for node type "${String(node.type)}" \u2014 emitting placeholder`
805
+ );
806
+ return `
807
+ <!-- ampless:unsupported-node type="${sanitizeNodeTypeForComment(node.type)}" -->
808
+
809
+ `;
780
810
  }
781
811
  }
812
+ function sanitizeNodeTypeForComment(type) {
813
+ return String(type).replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 64);
814
+ }
782
815
  function tiptapTableToMarkdown(node, opts) {
783
816
  const rows = node.content ?? [];
784
817
  if (rows.length === 0) return "";
@@ -902,6 +935,62 @@ function convertHtmlTaskList(items) {
902
935
  }
903
936
  return out.join("\n");
904
937
  }
938
+ function postToMarkdown(post, opts = {}) {
939
+ const frontmatter = opts.frontmatter !== false ? postFrontmatter(post, opts.siteUrl) : "";
940
+ return frontmatter + postBodyToMarkdown(post, opts);
941
+ }
942
+ function postFrontmatter(post, siteUrl) {
943
+ const lines = [];
944
+ lines.push(`title: ${JSON.stringify(post.title)}`);
945
+ lines.push(`slug: ${JSON.stringify(post.slug)}`);
946
+ if (post.publishedAt) lines.push(`publishedAt: ${JSON.stringify(post.publishedAt)}`);
947
+ if (post.updatedAt) lines.push(`updatedAt: ${JSON.stringify(post.updatedAt)}`);
948
+ if (post.tags && post.tags.length > 0) lines.push(`tags: ${JSON.stringify(post.tags)}`);
949
+ if (post.excerpt) lines.push(`excerpt: ${JSON.stringify(post.excerpt)}`);
950
+ if (siteUrl) {
951
+ const origin = siteUrl.replace(/\/+$/, "");
952
+ lines.push(`canonical: ${JSON.stringify(`${origin}/${post.slug}`)}`);
953
+ }
954
+ return `---
955
+ ${lines.join("\n")}
956
+ ---
957
+
958
+ `;
959
+ }
960
+ function postBodyToMarkdown(post, opts) {
961
+ switch (post.format) {
962
+ case "markdown":
963
+ return typeof post.body === "string" ? post.body : String(post.body ?? "");
964
+ case "tiptap":
965
+ return tiptapToMarkdown(post.body, { nodeAdapters: opts.nodeAdapters });
966
+ case "html":
967
+ return htmlToMarkdown(String(post.body));
968
+ case "static": {
969
+ const link = `[${staticEntrypoint(post.body)}](/${post.slug}/)`;
970
+ return post.excerpt ? `${link}
971
+
972
+ ${post.excerpt}
973
+ ` : `${link}
974
+ `;
975
+ }
976
+ default:
977
+ return "";
978
+ }
979
+ }
980
+ function staticEntrypoint(body) {
981
+ let manifest = body;
982
+ if (typeof manifest === "string") {
983
+ try {
984
+ manifest = JSON.parse(manifest);
985
+ } catch {
986
+ return "index.html";
987
+ }
988
+ }
989
+ if (typeof manifest === "object" && manifest !== null && typeof manifest.entrypoint === "string" && manifest.entrypoint.length > 0) {
990
+ return manifest.entrypoint;
991
+ }
992
+ return "index.html";
993
+ }
905
994
 
906
995
  // src/plugin-package-manifest.ts
907
996
  var TRUST_LEVELS = ["untrusted", "trusted", "privileged"];
@@ -1520,6 +1609,7 @@ function createPluginHead(cmsConfig, pluginSettings) {
1520
1609
  }
1521
1610
  }
1522
1611
  const contentFieldsRegistry = buildContentFieldRegistry(validPlugins);
1612
+ const markdownAdapters = buildMarkdownAdapterRegistry(validPlugins);
1523
1613
  for (const plugin of validPlugins) {
1524
1614
  const caps = plugin.capabilities;
1525
1615
  if (!caps) continue;
@@ -1581,6 +1671,7 @@ function createPluginHead(cmsConfig, pluginSettings) {
1581
1671
  return collectPostScriptsForPage(validPlugins, cmsConfig.site, snapshot, posts);
1582
1672
  },
1583
1673
  contentFieldsRegistry,
1674
+ markdownAdapters,
1584
1675
  async contextForPlugins() {
1585
1676
  const snapshot = await pluginSettings.loadAll();
1586
1677
  return (plugin) => makeCtx(plugin, cmsConfig.site, snapshot);
@@ -1847,6 +1938,19 @@ function createAmpless(opts) {
1847
1938
  });
1848
1939
  },
1849
1940
  renderBodyHtmlString: (post) => renderBodyHtmlString(post),
1941
+ postToMarkdown: async (post, o) => {
1942
+ if (o?.frontmatter === false) {
1943
+ return postToMarkdown(post, {
1944
+ nodeAdapters: pluginHead.markdownAdapters,
1945
+ frontmatter: false
1946
+ });
1947
+ }
1948
+ const { site } = await settings.loadSiteSettings();
1949
+ return postToMarkdown(post, {
1950
+ nodeAdapters: pluginHead.markdownAdapters,
1951
+ siteUrl: site.url || void 0
1952
+ });
1953
+ },
1850
1954
  renderThemeCss: (cssVars) => renderThemeCss(cssVars),
1851
1955
  publicAssetUrl: (key) => storage.publicAssetUrl(key),
1852
1956
  isStorageConfigured: () => storage.isStorageConfigured(),
@@ -1870,6 +1974,7 @@ export {
1870
1974
  SUPPORTED_API_VERSION,
1871
1975
  _resetStreamS3Cache,
1872
1976
  buildContentFieldRegistry,
1977
+ buildMarkdownAdapterRegistry,
1873
1978
  createAmpless,
1874
1979
  createPluginHead,
1875
1980
  createPluginSettings,
@@ -1877,6 +1982,7 @@ export {
1877
1982
  htmlToMarkdown,
1878
1983
  loadPackageManifest,
1879
1984
  markdownToHtml,
1985
+ postToMarkdown,
1880
1986
  renderBody,
1881
1987
  renderBodyHtmlString,
1882
1988
  renderThemeCss,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ampless/runtime",
3
- "version": "1.0.0-beta.66",
3
+ "version": "1.0.0-beta.68",
4
4
  "description": "Public-side runtime for ampless: post fetching, theme dispatch, middleware, public route handlers",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -51,8 +51,8 @@
51
51
  "marked": "^18.0.4",
52
52
  "sanitize-html": "^2.17.4",
53
53
  "tailwind-merge": "^3.6.0",
54
- "@ampless/plugin-og-image": "0.2.0-beta.54",
55
- "ampless": "1.0.0-beta.54"
54
+ "@ampless/plugin-og-image": "0.2.0-beta.55",
55
+ "ampless": "1.0.0-beta.55"
56
56
  },
57
57
  "peerDependencies": {
58
58
  "@aws-amplify/adapter-nextjs": "^1",