@tinacms/astro 0.0.1 → 0.2.0

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.
Files changed (64) hide show
  1. package/README.md +37 -20
  2. package/dist/bridge-route.d.ts +3 -0
  3. package/dist/bridge-route.js +22 -0
  4. package/dist/bridge.d.ts +7 -1
  5. package/dist/bridge.js +1 -0
  6. package/dist/data.d.ts +16 -0
  7. package/dist/data.js +59 -0
  8. package/dist/experimental.d.ts +10 -0
  9. package/dist/experimental.js +57 -0
  10. package/dist/index.d.ts +36 -1
  11. package/dist/index.js +87 -2
  12. package/dist/integration.d.ts +25 -0
  13. package/dist/integration.js +22 -0
  14. package/dist/internal/escape.d.ts +8 -0
  15. package/dist/internal/forms-store.d.ts +23 -0
  16. package/dist/internal/request-context.d.ts +16 -0
  17. package/dist/is-edit-mode.d.ts +32 -0
  18. package/dist/is-edit-mode.js +37 -0
  19. package/dist/island-route.d.ts +43 -0
  20. package/dist/island-route.js +57 -0
  21. package/dist/middleware.d.ts +20 -0
  22. package/dist/middleware.js +109 -0
  23. package/dist/sanitize.d.ts +12 -1
  24. package/dist/sanitize.js +6 -10
  25. package/dist/tina-field.d.ts +1 -1
  26. package/dist/tina-field.js +1 -0
  27. package/dist/types.d.ts +92 -1
  28. package/dist/types.js +0 -1
  29. package/package.json +89 -17
  30. package/src/CodeBlockNode.astro +28 -0
  31. package/src/Container.astro +56 -0
  32. package/src/ImageNode.astro +17 -0
  33. package/src/LinkNode.astro +22 -0
  34. package/src/MdxNode.astro +24 -0
  35. package/src/Node.astro +11 -4
  36. package/src/TinaIsland.astro +42 -0
  37. package/src/TinaMarkdown.astro +8 -0
  38. package/src/__tests__/TinaMarkdown.test.ts +112 -0
  39. package/src/__tests__/__snapshots__/TinaMarkdown.test.ts.snap +7 -0
  40. package/src/__tests__/fixtures/FancyHeading.astro +3 -0
  41. package/src/__tests__/fixtures/MyFeature.astro +4 -0
  42. package/src/__tests__/fixtures/basic-kitchen-sink.json +60 -0
  43. package/src/__tests__/fixtures/code-block.json +34 -0
  44. package/src/__tests__/fixtures/leaf-marks.json +199 -0
  45. package/src/__tests__/fixtures/mdx-jsx-flow.json +40 -0
  46. package/src/__tests__/fixtures/mdx-jsx-text.json +53 -0
  47. package/src/__tests__/sanitize.test.ts +75 -0
  48. package/src/bridge-route.ts +33 -0
  49. package/src/bridge.ts +7 -0
  50. package/src/data.ts +97 -0
  51. package/src/experimental.ts +14 -0
  52. package/src/index.ts +54 -0
  53. package/src/integration.ts +49 -0
  54. package/src/internal/escape.ts +15 -0
  55. package/src/internal/forms-store.ts +41 -0
  56. package/src/internal/request-context.ts +23 -0
  57. package/src/is-edit-mode.ts +68 -0
  58. package/src/island-route.ts +110 -0
  59. package/src/middleware.ts +118 -0
  60. package/src/sanitize.ts +64 -0
  61. package/src/tina-field.ts +1 -0
  62. package/src/types.ts +97 -0
  63. package/dist/preview.d.ts +0 -1
  64. package/dist/preview.js +0 -1
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Astro middleware injected by the `tina()` integration.
3
+ *
4
+ * - Resolves `isEditMode(request)` once and stashes it on
5
+ * `context.locals.tinaEdit` so pages and components can branch on edit
6
+ * context without re-parsing headers.
7
+ * - Scopes the request and a per-request forms collector via
8
+ * AsyncLocalStorage so `tina()` reads them implicitly — the caller
9
+ * never threads `Astro.request` through their loaders.
10
+ * - In edit mode only, splices `<div data-tina-form>` payloads and a
11
+ * `<script>` that loads `/_tina/bridge.js` before `</head>`. The user
12
+ * writes nothing in their layout, and production HTML is byte-
13
+ * identical to a Tina-free Astro app.
14
+ * - In edit mode only, refreshes the `__tina_edit` cookie so the session
15
+ * survives in-iframe link clicks (whose Referer is the previous
16
+ * preview page, not `/admin/`).
17
+ */
18
+ import type { MiddlewareHandler } from 'astro';
19
+ import { escapeAttr } from './internal/escape';
20
+ import { type CollectedForm, formsStore } from './internal/forms-store';
21
+ import { requestStore } from './internal/request-context';
22
+ import { EDIT_COOKIE_HEADER, isEditMode } from './is-edit-mode';
23
+
24
+ const HEAD_CLOSE = '</head>';
25
+
26
+ export const onRequest: MiddlewareHandler = (context, next) => {
27
+ const editing = isEditMode(context.request);
28
+ (context.locals as { tinaEdit?: boolean }).tinaEdit = editing;
29
+
30
+ const forms: CollectedForm[] = [];
31
+ return requestStore.run(context.request, () =>
32
+ formsStore.run(forms, async () => {
33
+ const response = await next();
34
+ return editing ? injectEditMode(response, forms) : response;
35
+ })
36
+ );
37
+ };
38
+
39
+ export default onRequest;
40
+
41
+ async function injectEditMode(
42
+ response: Response,
43
+ forms: CollectedForm[]
44
+ ): Promise<Response> {
45
+ const init = editModeInit(response);
46
+ const contentType = response.headers.get('content-type') ?? '';
47
+
48
+ // Redirects / JSON / assets — pass body through and just keep the cookie fresh.
49
+ if (!contentType.includes('text/html')) {
50
+ return new Response(response.body, init);
51
+ }
52
+
53
+ const html = await response.text();
54
+ const headEnd = html.indexOf(HEAD_CLOSE);
55
+ if (headEnd === -1) {
56
+ // No </head> to anchor against — return the body untouched.
57
+ return new Response(html, init);
58
+ }
59
+
60
+ const injection = renderInjection(forms);
61
+ return new Response(
62
+ html.slice(0, headEnd) + injection + html.slice(headEnd),
63
+ init
64
+ );
65
+ }
66
+
67
+ function editModeInit(response: Response): ResponseInit {
68
+ const headers = new Headers(response.headers);
69
+ // Body length changed; let the runtime re-compute it.
70
+ headers.delete('content-length');
71
+ headers.append('Set-Cookie', EDIT_COOKIE_HEADER);
72
+ return { status: response.status, statusText: response.statusText, headers };
73
+ }
74
+
75
+ function renderInjection(forms: CollectedForm[]): string {
76
+ const formDivs = forms
77
+ .map(
78
+ (form) =>
79
+ `<div data-tina-form="${escapeAttr(JSON.stringify(form))}" hidden></div>`
80
+ )
81
+ .join('');
82
+ return formDivs + bridgeScript();
83
+ }
84
+
85
+ function bridgeScript(): string {
86
+ const origins = adminOrigins();
87
+ const initArg = origins ? `{adminOrigin:${JSON.stringify(origins)}}` : '';
88
+ return (
89
+ `<script type="module">` +
90
+ `import{init,refreshForms}from"/_tina/bridge.js";` +
91
+ `init(${initArg});` +
92
+ `document.addEventListener("astro:page-load",refreshForms);` +
93
+ `</script>`
94
+ );
95
+ }
96
+
97
+ /**
98
+ * Read `PUBLIC_TINA_ADMIN_ORIGIN` (comma-separated for multi-origin setups)
99
+ * from Astro's `import.meta.env`. Returns null when unset so `bridge.init()`
100
+ * falls back to `window.location.origin` — the common same-host case.
101
+ *
102
+ * `import.meta.env` is cast inline because the package ships no `env.d.ts`
103
+ * to keep the public type surface free of Vite/Astro client-types coupling.
104
+ */
105
+ function adminOrigins(): string[] | null {
106
+ const env = (
107
+ import.meta as ImportMeta & {
108
+ env?: Record<string, string | undefined>;
109
+ }
110
+ ).env;
111
+ const raw = env?.PUBLIC_TINA_ADMIN_ORIGIN;
112
+ if (!raw) return null;
113
+ const origins = raw
114
+ .split(',')
115
+ .map((s) => s.trim())
116
+ .filter(Boolean);
117
+ return origins.length > 0 ? origins : null;
118
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Sanitizes a CMS-supplied href, returning a safe URL or the fallback.
3
+ * Blocks dangerous schemes (javascript:, data:, vbscript:) and
4
+ * protocol-relative URLs (//evil.com). Allows relative paths, http(s),
5
+ * and mailto:.
6
+ */
7
+ export function sanitizeHref(value: unknown, fallback = '#'): string {
8
+ if (typeof value !== 'string') return fallback;
9
+ const trimmed = value.trim();
10
+ if (!trimmed) return fallback;
11
+ const lower = trimmed.toLowerCase();
12
+ if (
13
+ lower.startsWith('javascript:') ||
14
+ lower.startsWith('data:') ||
15
+ lower.startsWith('vbscript:')
16
+ ) {
17
+ return fallback;
18
+ }
19
+ if (
20
+ (trimmed.startsWith('/') && !trimmed.startsWith('//')) ||
21
+ trimmed.startsWith('./') ||
22
+ trimmed.startsWith('../') ||
23
+ trimmed.startsWith('#')
24
+ ) {
25
+ return trimmed;
26
+ }
27
+ try {
28
+ const url = new URL(trimmed);
29
+ if (
30
+ url.protocol === 'http:' ||
31
+ url.protocol === 'https:' ||
32
+ url.protocol === 'mailto:'
33
+ ) {
34
+ return trimmed;
35
+ }
36
+ } catch {
37
+ return fallback;
38
+ }
39
+ return fallback;
40
+ }
41
+
42
+ /**
43
+ * Validates a CMS-supplied image src, returning the src string if safe or ''
44
+ * if it is empty, not a string, or uses a non-http(s)/relative scheme.
45
+ */
46
+ export function sanitizeImageSrc(src: unknown): string {
47
+ if (typeof src !== 'string') return '';
48
+ const trimmed = src.trim();
49
+ if (!trimmed) return '';
50
+ if (
51
+ trimmed.startsWith('./') ||
52
+ trimmed.startsWith('../') ||
53
+ (trimmed.startsWith('/') && !trimmed.startsWith('//'))
54
+ ) {
55
+ return trimmed;
56
+ }
57
+ try {
58
+ const url = new URL(trimmed);
59
+ if (url.protocol === 'http:' || url.protocol === 'https:') return trimmed;
60
+ } catch {
61
+ return '';
62
+ }
63
+ return '';
64
+ }
@@ -0,0 +1 @@
1
+ export * from '@tinacms/bridge/tina-field';
package/src/types.ts ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Plate AST node types Tina returns from rich-text fields. Mirrors the shape
3
+ * documented in `packages/@tinacms/mdx/src/parse/plate.ts` — kept inline so
4
+ * the Astro renderer can stay framework-free and not pull @tinacms/mdx into
5
+ * the page bundle.
6
+ */
7
+
8
+ /**
9
+ * Astro doesn't publicly export `AstroComponentFactory`, so we use a
10
+ * structural placeholder. The consumer's Astro pipeline performs the real
11
+ * compile-time check that whatever they pass is a valid component.
12
+ */
13
+ export type AstroComponent = (...args: never[]) => unknown;
14
+
15
+ export type TinaRichTextRoot = {
16
+ type: 'root';
17
+ children: TinaRichTextNode[];
18
+ };
19
+
20
+ export type TinaRichTextNode =
21
+ | BlockElement
22
+ | InlineElement
23
+ | TextElement
24
+ | MdxElement;
25
+
26
+ export type LinkElement = {
27
+ type: 'a';
28
+ url: string;
29
+ title?: string;
30
+ children: InlineElement[];
31
+ };
32
+
33
+ export type ImageElement = {
34
+ type: 'img';
35
+ url: string;
36
+ alt?: string;
37
+ caption?: string;
38
+ };
39
+
40
+ export type CodeBlockElement = {
41
+ type: 'code_block';
42
+ lang?: string;
43
+ value?: string;
44
+ children?: { children: TextElement[] }[];
45
+ };
46
+
47
+ export type BlockElement =
48
+ | { type: 'p'; children: InlineElement[] }
49
+ | {
50
+ type: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
51
+ children: InlineElement[];
52
+ }
53
+ | { type: 'blockquote'; children: TinaRichTextNode[] }
54
+ | { type: 'ul' | 'ol'; children: TinaRichTextNode[] }
55
+ | { type: 'li'; children: TinaRichTextNode[] }
56
+ | { type: 'lic'; children: InlineElement[] }
57
+ | { type: 'hr' }
58
+ | { type: 'break' }
59
+ | ImageElement
60
+ | CodeBlockElement
61
+ | { type: 'maybe_mdx' }
62
+ | { type: 'html'; value: string }
63
+ | { type: 'invalid_markdown'; value: string };
64
+
65
+ export type InlineElement =
66
+ | TextElement
67
+ | LinkElement
68
+ | { type: 'html_inline'; value: string }
69
+ | MdxElement;
70
+
71
+ export type TextElement = {
72
+ type: 'text';
73
+ text: string;
74
+ bold?: boolean;
75
+ italic?: boolean;
76
+ underline?: boolean;
77
+ strikethrough?: boolean;
78
+ code?: boolean;
79
+ highlight?: boolean;
80
+ highlightColor?: string;
81
+ };
82
+
83
+ export type MdxElement = {
84
+ type: 'mdxJsxFlowElement' | 'mdxJsxTextElement';
85
+ name: string;
86
+ props: Record<string, unknown>;
87
+ children?: TinaRichTextNode[];
88
+ };
89
+
90
+ export type TinaRichTextContent =
91
+ | TinaRichTextRoot
92
+ | TinaRichTextNode[]
93
+ | null
94
+ | undefined;
95
+
96
+ /** A map of mdxJsx name (or default tag override) → Astro component. */
97
+ export type CustomComponentsMap = Record<string, AstroComponent>;
package/dist/preview.d.ts DELETED
@@ -1 +0,0 @@
1
- export * from "../src/preview"
package/dist/preview.js DELETED
@@ -1 +0,0 @@
1
- export * from "@tinacms/bridge/preview";