@tinacms/astro 0.0.1 → 0.3.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 (75) hide show
  1. package/README.md +50 -20
  2. package/dist/bridge.d.ts +7 -1
  3. package/dist/bridge.js +1 -0
  4. package/dist/data.d.ts +19 -0
  5. package/dist/data.js +64 -0
  6. package/dist/data.test-d.d.ts +1 -0
  7. package/dist/experimental.d.ts +10 -0
  8. package/dist/experimental.js +87 -0
  9. package/dist/index.d.ts +36 -1
  10. package/dist/index.js +92 -2
  11. package/dist/integration.d.ts +5 -0
  12. package/dist/integration.js +69 -0
  13. package/dist/internal/admin-origin.d.ts +6 -0
  14. package/dist/internal/escape.d.ts +8 -0
  15. package/dist/internal/forms-store.d.ts +12 -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 +18 -0
  20. package/dist/island-route.js +87 -0
  21. package/dist/middleware.d.ts +3 -0
  22. package/dist/middleware.js +123 -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/dist/vite.d.ts +21 -0
  30. package/dist/vite.js +24 -0
  31. package/package.json +88 -17
  32. package/src/CodeBlockNode.astro +28 -0
  33. package/src/Container.astro +56 -0
  34. package/src/ImageNode.astro +17 -0
  35. package/src/LinkNode.astro +22 -0
  36. package/src/MdxNode.astro +24 -0
  37. package/src/Node.astro +11 -4
  38. package/src/TinaIsland.astro +44 -0
  39. package/src/TinaMarkdown.astro +8 -0
  40. package/src/__tests__/IslandStub.astro +8 -0
  41. package/src/__tests__/TinaIsland.test.ts +60 -0
  42. package/src/__tests__/TinaMarkdown.test.ts +112 -0
  43. package/src/__tests__/__snapshots__/TinaMarkdown.test.ts.snap +7 -0
  44. package/src/__tests__/fixtures/FancyHeading.astro +3 -0
  45. package/src/__tests__/fixtures/MyFeature.astro +4 -0
  46. package/src/__tests__/fixtures/basic-kitchen-sink.json +60 -0
  47. package/src/__tests__/fixtures/code-block.json +34 -0
  48. package/src/__tests__/fixtures/leaf-marks.json +199 -0
  49. package/src/__tests__/fixtures/mdx-jsx-flow.json +40 -0
  50. package/src/__tests__/fixtures/mdx-jsx-text.json +53 -0
  51. package/src/__tests__/forms-store.test.ts +70 -0
  52. package/src/__tests__/integration.test.ts +124 -0
  53. package/src/__tests__/island-route.test.ts +119 -0
  54. package/src/__tests__/middleware.test.ts +102 -0
  55. package/src/__tests__/sanitize.test.ts +75 -0
  56. package/src/__tests__/vite.test.ts +67 -0
  57. package/src/bridge.ts +7 -0
  58. package/src/data.test-d.ts +53 -0
  59. package/src/data.ts +73 -0
  60. package/src/experimental.ts +14 -0
  61. package/src/index.ts +54 -0
  62. package/src/integration.ts +94 -0
  63. package/src/internal/admin-origin.ts +19 -0
  64. package/src/internal/escape.ts +15 -0
  65. package/src/internal/forms-store.ts +50 -0
  66. package/src/internal/request-context.ts +23 -0
  67. package/src/is-edit-mode.ts +68 -0
  68. package/src/island-route.ts +109 -0
  69. package/src/middleware.ts +89 -0
  70. package/src/sanitize.ts +64 -0
  71. package/src/tina-field.ts +1 -0
  72. package/src/types.ts +97 -0
  73. package/src/vite.ts +40 -0
  74. package/dist/preview.d.ts +0 -1
  75. package/dist/preview.js +0 -1
@@ -0,0 +1,40 @@
1
+ {
2
+ "type": "root",
3
+ "children": [
4
+ {
5
+ "type": "h1",
6
+ "children": [
7
+ {
8
+ "type": "text",
9
+ "text": "hello"
10
+ }
11
+ ]
12
+ },
13
+ {
14
+ "type": "mdxJsxFlowElement",
15
+ "name": "someFeature",
16
+ "children": [
17
+ {
18
+ "type": "text",
19
+ "text": ""
20
+ }
21
+ ],
22
+ "props": {
23
+ "children": {
24
+ "type": "root",
25
+ "children": [
26
+ {
27
+ "type": "p",
28
+ "children": [
29
+ {
30
+ "type": "text",
31
+ "text": "Testing this thing"
32
+ }
33
+ ]
34
+ }
35
+ ]
36
+ }
37
+ }
38
+ }
39
+ ]
40
+ }
@@ -0,0 +1,53 @@
1
+ {
2
+ "type": "root",
3
+ "children": [
4
+ {
5
+ "type": "h1",
6
+ "children": [
7
+ {
8
+ "type": "text",
9
+ "text": "hello "
10
+ },
11
+ {
12
+ "type": "mdxJsxTextElement",
13
+ "name": "someFeature",
14
+ "children": [
15
+ {
16
+ "type": "text",
17
+ "text": ""
18
+ }
19
+ ],
20
+ "props": {
21
+ "_value": "abc"
22
+ }
23
+ }
24
+ ]
25
+ },
26
+ {
27
+ "type": "p",
28
+ "children": [
29
+ {
30
+ "type": "text",
31
+ "text": "When not separated by whitespace"
32
+ },
33
+ {
34
+ "type": "mdxJsxTextElement",
35
+ "name": "someFeature",
36
+ "children": [
37
+ {
38
+ "type": "text",
39
+ "text": ""
40
+ }
41
+ ],
42
+ "props": {
43
+ "_value": "123"
44
+ }
45
+ },
46
+ {
47
+ "type": "text",
48
+ "text": "it still works"
49
+ }
50
+ ]
51
+ }
52
+ ]
53
+ }
@@ -0,0 +1,70 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ type CollectedForm,
4
+ formsStore,
5
+ recordForm,
6
+ sortByPriority,
7
+ } from '../internal/forms-store';
8
+
9
+ function makeForm(overrides: Partial<CollectedForm> = {}): CollectedForm {
10
+ return {
11
+ id: 'id',
12
+ query: 'query Q',
13
+ variables: {},
14
+ data: {},
15
+ ...overrides,
16
+ };
17
+ }
18
+
19
+ describe('recordForm', () => {
20
+ it('dedupes by id within a single request scope', () => {
21
+ const list: CollectedForm[] = [];
22
+ formsStore.run(list, () => {
23
+ recordForm(makeForm({ id: 'a' }));
24
+ recordForm(makeForm({ id: 'a' }));
25
+ recordForm(makeForm({ id: 'b' }));
26
+ });
27
+ expect(list.map((f) => f.id)).toEqual(['a', 'b']);
28
+ });
29
+
30
+ it('upgrades an existing entry to primary when a later call asserts it', () => {
31
+ const list: CollectedForm[] = [];
32
+ formsStore.run(list, () => {
33
+ // Layout calls global with no priority first.
34
+ recordForm(makeForm({ id: 'global' }));
35
+ // Page-level loader for the same id later flags it primary.
36
+ recordForm(makeForm({ id: 'global', priority: 'primary' }));
37
+ });
38
+ expect(list).toHaveLength(1);
39
+ expect(list[0]!.priority).toBe('primary');
40
+ });
41
+
42
+ it('is a no-op outside a request scope', () => {
43
+ // Just don't throw — there's no store to read.
44
+ expect(() => recordForm(makeForm())).not.toThrow();
45
+ });
46
+ });
47
+
48
+ describe('sortByPriority', () => {
49
+ it('moves primaries to the front while preserving relative order', () => {
50
+ const forms = [
51
+ makeForm({ id: 'a' }),
52
+ makeForm({ id: 'b', priority: 'primary' }),
53
+ makeForm({ id: 'c' }),
54
+ makeForm({ id: 'd', priority: 'primary' }),
55
+ ];
56
+ expect(sortByPriority(forms).map((f) => f.id)).toEqual([
57
+ 'b',
58
+ 'd',
59
+ 'a',
60
+ 'c',
61
+ ]);
62
+ });
63
+
64
+ it('returns a new array without mutating the input', () => {
65
+ const forms = [makeForm({ id: 'a' }), makeForm({ id: 'b' })];
66
+ const sorted = sortByPriority(forms);
67
+ expect(sorted).not.toBe(forms);
68
+ expect(forms.map((f) => f.id)).toEqual(['a', 'b']);
69
+ });
70
+ });
@@ -0,0 +1,124 @@
1
+ import { existsSync, mkdtempSync, readFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join, sep } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import type { AstroIntegration } from 'astro';
6
+ import type { Plugin as VitePlugin } from 'vite';
7
+ import { describe, expect, it, vi } from 'vitest';
8
+ import tina from '../integration';
9
+
10
+ type Hooks = AstroIntegration['hooks'];
11
+ type ConfigSetupArg = Parameters<NonNullable<Hooks['astro:config:setup']>>[0];
12
+ type ConfigDoneArg = Parameters<NonNullable<Hooks['astro:config:done']>>[0];
13
+ type BuildDoneArg = Parameters<NonNullable<Hooks['astro:build:done']>>[0];
14
+
15
+ function runConfigSetup() {
16
+ const addMiddleware = vi.fn();
17
+ const updateConfig = vi.fn();
18
+ const logger = { warn: vi.fn(), info: vi.fn() };
19
+ const integration = tina();
20
+ (
21
+ integration.hooks['astro:config:setup'] as NonNullable<
22
+ Hooks['astro:config:setup']
23
+ >
24
+ )({ addMiddleware, updateConfig, logger } as unknown as ConfigSetupArg);
25
+
26
+ const plugins: VitePlugin[] =
27
+ updateConfig.mock.calls[0]?.[0]?.vite?.plugins ?? [];
28
+ return { integration, addMiddleware, updateConfig, logger, plugins };
29
+ }
30
+
31
+ // Drive a Vite plugin's `configureServer` and capture the request handler it
32
+ // registers, so we can exercise it without a real dev server.
33
+ function devHandler(plugin: VitePlugin) {
34
+ let handler: ((req: any, res: any, next: () => void) => void) | undefined;
35
+ const server = { middlewares: { use: (h: any) => (handler = h) } };
36
+ (plugin.configureServer as any)(server);
37
+ if (!handler) throw new Error('plugin registered no middleware');
38
+ return handler;
39
+ }
40
+
41
+ function makeRes() {
42
+ return {
43
+ statusCode: 200,
44
+ headers: {} as Record<string, string>,
45
+ body: undefined as Buffer | string | undefined,
46
+ setHeader(name: string, value: string) {
47
+ this.headers[name.toLowerCase()] = value;
48
+ },
49
+ end(chunk?: Buffer | string) {
50
+ this.body = chunk;
51
+ },
52
+ };
53
+ }
54
+
55
+ describe('tina() integration — astro:config:setup', () => {
56
+ it('wires the middleware without writing to the source tree', () => {
57
+ const { addMiddleware, plugins } = runConfigSetup();
58
+
59
+ expect(addMiddleware).toHaveBeenCalledWith({
60
+ entrypoint: '@tinacms/astro/middleware',
61
+ order: 'pre',
62
+ });
63
+ // A dev-only Vite plugin handles the bridge; nothing is staged on disk.
64
+ const plugin = plugins.find((p) => p.name === '@tinacms/astro:bridge-dev');
65
+ expect(plugin).toBeDefined();
66
+ expect(plugin?.apply).toBe('serve');
67
+ });
68
+ });
69
+
70
+ describe('tina() integration — bridge dev plugin', () => {
71
+ it('serves the bridge bundle at /admin/bridge.js', () => {
72
+ const { plugins } = runConfigSetup();
73
+ const plugin = plugins.find((p) => p.name === '@tinacms/astro:bridge-dev')!;
74
+ const handler = devHandler(plugin);
75
+
76
+ const res = makeRes();
77
+ const next = vi.fn();
78
+ handler({ url: '/admin/bridge.js' }, res, next);
79
+
80
+ expect(next).not.toHaveBeenCalled();
81
+ expect(res.headers['content-type']).toBe('text/javascript');
82
+ expect((res.body as Buffer).length).toBeGreaterThan(0);
83
+ });
84
+
85
+ it('passes other requests through', () => {
86
+ const { plugins } = runConfigSetup();
87
+ const plugin = plugins.find((p) => p.name === '@tinacms/astro:bridge-dev')!;
88
+ const handler = devHandler(plugin);
89
+
90
+ const res = makeRes();
91
+ const next = vi.fn();
92
+ handler({ url: '/some/page' }, res, next);
93
+
94
+ expect(next).toHaveBeenCalledOnce();
95
+ expect(res.body).toBeUndefined();
96
+ });
97
+ });
98
+
99
+ describe('tina() integration — astro:build:done', () => {
100
+ it('emits bridge.js into the client output dir', () => {
101
+ const clientDir = mkdtempSync(join(tmpdir(), 'tina-client-'));
102
+ const { integration } = runConfigSetup();
103
+ const logger = { warn: vi.fn(), info: vi.fn() };
104
+
105
+ (
106
+ integration.hooks['astro:config:done'] as NonNullable<
107
+ Hooks['astro:config:done']
108
+ >
109
+ )({
110
+ config: { build: { client: pathToFileURL(clientDir + sep) } },
111
+ } as unknown as ConfigDoneArg);
112
+
113
+ (
114
+ integration.hooks['astro:build:done'] as NonNullable<
115
+ Hooks['astro:build:done']
116
+ >
117
+ )({ logger } as unknown as BuildDoneArg);
118
+
119
+ const bridgePath = join(clientDir, 'admin', 'bridge.js');
120
+ expect(existsSync(bridgePath)).toBe(true);
121
+ expect(readFileSync(bridgePath, 'utf-8').length).toBeGreaterThan(0);
122
+ expect(logger.warn).not.toHaveBeenCalled();
123
+ });
124
+ });
@@ -0,0 +1,119 @@
1
+ import { PREVIEW_CONTENT_TYPE, PRIME_HEADER } from '@tinacms/bridge/preview';
2
+ import type { APIContext } from 'astro';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { requestWithMetadata } from '../data';
5
+ import { experimental_createIslandRoute } from '../island-route';
6
+ import IslandStub from './IslandStub.astro';
7
+
8
+ const route = experimental_createIslandRoute({
9
+ post: {
10
+ fetch: () =>
11
+ requestWithMetadata(
12
+ Promise.resolve({
13
+ data: { post: { title: 'Hi' } },
14
+ query: 'query Post',
15
+ variables: { relativePath: 'hello.md' },
16
+ }),
17
+ { priority: 'primary' }
18
+ ),
19
+ component: IslandStub,
20
+ wrapper: { tag: 'article', className: 'prose' },
21
+ propsFromData: (data) => ({ value: data }),
22
+ },
23
+ global: {
24
+ fetch: () =>
25
+ requestWithMetadata(
26
+ Promise.resolve({
27
+ data: { config: { theme: 'dark' } },
28
+ query: 'query Global',
29
+ variables: {},
30
+ })
31
+ ),
32
+ component: IslandStub,
33
+ wrapper: { tag: 'header' },
34
+ propsFromData: (data) => ({ value: data }),
35
+ },
36
+ });
37
+
38
+ function call(
39
+ headers: Record<string, string>,
40
+ name = 'post'
41
+ ): Promise<Response> {
42
+ const url = new URL(`http://localhost/tina-island/${name}`);
43
+ const request = new Request(url, {
44
+ method: 'POST',
45
+ headers: { 'content-type': PREVIEW_CONTENT_TYPE, ...headers },
46
+ body: '{}',
47
+ });
48
+ return Promise.resolve(
49
+ route({ params: { name }, request, url } as unknown as APIContext)
50
+ ) as Promise<Response>;
51
+ }
52
+
53
+ describe('experimental_createIslandRoute', () => {
54
+ it('renders the wrapped island region', async () => {
55
+ const res = await call({});
56
+ expect(res.status).toBe(200);
57
+ const html = await res.text();
58
+ expect(html).toContain(
59
+ '<article class="prose" data-tina-island="/tina-island/post">'
60
+ );
61
+ expect(html).toContain('stub');
62
+ expect(html).not.toContain('data-tina-form=');
63
+ });
64
+
65
+ it('prepends the collected form payloads on a primed request', async () => {
66
+ const res = await call({ [PRIME_HEADER]: '1' });
67
+ expect(res.status).toBe(200);
68
+ const html = await res.text();
69
+ expect(html).toContain('data-tina-form=');
70
+ // The payload carries the query/variables the loader used.
71
+ expect(html).toContain('query Post');
72
+ expect(html).toContain('hello.md');
73
+ // The region HTML still follows the payload divs.
74
+ expect(html).toContain('data-tina-island="/tina-island/post"');
75
+ });
76
+
77
+ it('emits `data-tina-primary` only on payloads that set `priority: "primary"`', async () => {
78
+ // The `post` loader passes `{ priority: 'primary' }`; the `global`
79
+ // loader does not. Each island route call renders its own forms
80
+ // independently, so a positional marker (`i === 0`) would tag the
81
+ // first form of *every* island — on a page that primes
82
+ // `[page, global-header, global-footer]` the bridge would see three
83
+ // competing primaries and the first in DOM order (usually a layout
84
+ // global) would win the retry loop.
85
+ const postHtml = await (await call({ [PRIME_HEADER]: '1' }, 'post')).text();
86
+ expect(postHtml).toContain('data-tina-form=');
87
+ expect(postHtml).toContain('data-tina-primary');
88
+
89
+ const globalHtml = await (
90
+ await call({ [PRIME_HEADER]: '1' }, 'global')
91
+ ).text();
92
+ expect(globalHtml).toContain('data-tina-form=');
93
+ expect(globalHtml).not.toContain('data-tina-primary');
94
+ });
95
+
96
+ it('rejects non-preview requests', async () => {
97
+ const url = new URL('http://localhost/tina-island/post');
98
+ const res = (await route({
99
+ params: { name: 'post' },
100
+ request: new Request(url, { method: 'POST' }),
101
+ url,
102
+ } as unknown as APIContext)) as Response;
103
+ expect(res.status).toBe(404);
104
+ });
105
+
106
+ it('404s an unknown island', async () => {
107
+ const url = new URL('http://localhost/tina-island/nope');
108
+ const res = (await route({
109
+ params: { name: 'nope' },
110
+ request: new Request(url, {
111
+ method: 'POST',
112
+ headers: { 'content-type': PREVIEW_CONTENT_TYPE },
113
+ body: '{}',
114
+ }),
115
+ url,
116
+ } as unknown as APIContext)) as Response;
117
+ expect(res.status).toBe(404);
118
+ });
119
+ });
@@ -0,0 +1,102 @@
1
+ import type { APIContext, MiddlewareNext } from 'astro';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { requestWithMetadata } from '../data';
4
+ import { onRequest } from '../middleware';
5
+
6
+ function makeContext(
7
+ overrides: Partial<APIContext> & { request?: unknown }
8
+ ): APIContext {
9
+ return {
10
+ locals: {},
11
+ request: new Request('https://example.com/'),
12
+ isPrerendered: false,
13
+ ...overrides,
14
+ } as unknown as APIContext;
15
+ }
16
+
17
+ const passThrough: MiddlewareNext = () =>
18
+ Promise.resolve(
19
+ new Response('<html><head></head><body>ok</body></html>', {
20
+ headers: { 'content-type': 'text/html' },
21
+ })
22
+ );
23
+
24
+ describe('onRequest', () => {
25
+ it('short-circuits prerendered routes without touching request headers', async () => {
26
+ // A synthetic build-time Request whose header access would emit Astro's
27
+ // `Astro.request.headers` warning — modeled here as a throw.
28
+ const request = {
29
+ url: 'https://example.com/',
30
+ headers: {
31
+ get() {
32
+ throw new Error(
33
+ 'request headers must not be read while prerendering'
34
+ );
35
+ },
36
+ },
37
+ };
38
+ const context = makeContext({ isPrerendered: true, request });
39
+ const next = vi.fn(passThrough);
40
+
41
+ const response = await onRequest(context, next);
42
+
43
+ expect(next).toHaveBeenCalledOnce();
44
+ expect((context.locals as { tinaEdit?: boolean }).tinaEdit).toBe(false);
45
+ expect(await response.text()).toContain('ok');
46
+ });
47
+
48
+ it('resolves edit mode for non-prerendered requests', async () => {
49
+ const context = makeContext({ isPrerendered: false });
50
+ const next = vi.fn(passThrough);
51
+
52
+ await onRequest(context, next);
53
+
54
+ expect(next).toHaveBeenCalledOnce();
55
+ // Plain request — no iframe Sec-Fetch-Dest, no cookie — so not editing.
56
+ expect((context.locals as { tinaEdit?: boolean }).tinaEdit).toBe(false);
57
+ });
58
+
59
+ it('splices `priority: "primary"` form payloads before secondaries', async () => {
60
+ // Edit-mode request — iframe destination plus the edit cookie.
61
+ const request = new Request('https://example.com/', {
62
+ headers: {
63
+ 'sec-fetch-dest': 'iframe',
64
+ cookie: '__tina_edit=1',
65
+ },
66
+ });
67
+ const context = makeContext({ isPrerendered: false, request });
68
+
69
+ // The page calls global first (layout order) but the page-level loader
70
+ // marks its own document primary — the primary should land first in
71
+ // the rendered HTML regardless of call order.
72
+ const next: MiddlewareNext = async () => {
73
+ await requestWithMetadata(
74
+ Promise.resolve({
75
+ data: {},
76
+ query: 'query Global',
77
+ variables: {},
78
+ })
79
+ );
80
+ await requestWithMetadata(
81
+ Promise.resolve({
82
+ data: {},
83
+ query: 'query Page',
84
+ variables: { slug: 'home' },
85
+ }),
86
+ { priority: 'primary' }
87
+ );
88
+ return new Response('<html><head></head><body>ok</body></html>', {
89
+ headers: { 'content-type': 'text/html' },
90
+ });
91
+ };
92
+
93
+ const response = await onRequest(context, next);
94
+ const html = await response.text();
95
+
96
+ const pageIdx = html.indexOf('query Page');
97
+ const globalIdx = html.indexOf('query Global');
98
+ expect(pageIdx).toBeGreaterThan(-1);
99
+ expect(globalIdx).toBeGreaterThan(-1);
100
+ expect(pageIdx).toBeLessThan(globalIdx);
101
+ });
102
+ });
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { sanitizeHref, sanitizeImageSrc } from '../sanitize';
3
+
4
+ describe('sanitizeHref', () => {
5
+ it('blocks javascript: scheme', () => {
6
+ expect(sanitizeHref('javascript:alert(1)')).toBe('#');
7
+ expect(sanitizeHref('JavaScript:alert(1)')).toBe('#');
8
+ expect(sanitizeHref(' javascript:alert(1) ')).toBe('#');
9
+ });
10
+
11
+ it('blocks data: and vbscript: schemes', () => {
12
+ expect(sanitizeHref('data:text/html,<script>alert(1)</script>')).toBe('#');
13
+ expect(sanitizeHref('vbscript:msgbox(1)')).toBe('#');
14
+ });
15
+
16
+ it('blocks protocol-relative URLs', () => {
17
+ expect(sanitizeHref('//evil.com/path')).toBe('#');
18
+ });
19
+
20
+ it('allows http(s) URLs', () => {
21
+ expect(sanitizeHref('https://tina.io')).toBe('https://tina.io');
22
+ expect(sanitizeHref('http://tina.io/x')).toBe('http://tina.io/x');
23
+ });
24
+
25
+ it('allows mailto:', () => {
26
+ expect(sanitizeHref('mailto:hi@tina.io')).toBe('mailto:hi@tina.io');
27
+ });
28
+
29
+ it('allows relative, root-relative, and fragment paths', () => {
30
+ expect(sanitizeHref('/about')).toBe('/about');
31
+ expect(sanitizeHref('./relative')).toBe('./relative');
32
+ expect(sanitizeHref('../up')).toBe('../up');
33
+ expect(sanitizeHref('#section')).toBe('#section');
34
+ });
35
+
36
+ it('returns fallback for non-strings, empty, or invalid', () => {
37
+ expect(sanitizeHref(null)).toBe('#');
38
+ expect(sanitizeHref(undefined)).toBe('#');
39
+ expect(sanitizeHref('')).toBe('#');
40
+ expect(sanitizeHref(' ')).toBe('#');
41
+ expect(sanitizeHref(42)).toBe('#');
42
+ expect(sanitizeHref('not a url')).toBe('#');
43
+ });
44
+
45
+ it('honours a custom fallback', () => {
46
+ expect(sanitizeHref('javascript:alert(1)', '/safe')).toBe('/safe');
47
+ });
48
+ });
49
+
50
+ describe('sanitizeImageSrc', () => {
51
+ it('allows http(s) URLs', () => {
52
+ expect(sanitizeImageSrc('https://cdn.tina.io/x.png')).toBe(
53
+ 'https://cdn.tina.io/x.png'
54
+ );
55
+ });
56
+
57
+ it('allows relative paths', () => {
58
+ expect(sanitizeImageSrc('/uploads/x.png')).toBe('/uploads/x.png');
59
+ expect(sanitizeImageSrc('./local.png')).toBe('./local.png');
60
+ expect(sanitizeImageSrc('../up.png')).toBe('../up.png');
61
+ });
62
+
63
+ it('blocks protocol-relative and dangerous schemes', () => {
64
+ expect(sanitizeImageSrc('//evil.com/x.png')).toBe('');
65
+ expect(sanitizeImageSrc('javascript:alert(1)')).toBe('');
66
+ expect(sanitizeImageSrc('data:image/png;base64,xxx')).toBe('');
67
+ });
68
+
69
+ it('returns empty for non-strings, empty, or invalid', () => {
70
+ expect(sanitizeImageSrc(null)).toBe('');
71
+ expect(sanitizeImageSrc(undefined)).toBe('');
72
+ expect(sanitizeImageSrc('')).toBe('');
73
+ expect(sanitizeImageSrc(42)).toBe('');
74
+ });
75
+ });
@@ -0,0 +1,67 @@
1
+ import type { ViteDevServer } from 'vite';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { tinaAdminDevRedirect } from '../vite';
4
+
5
+ type Handler = (req: any, res: any, next: () => void) => void;
6
+
7
+ function registerHandler(): Handler {
8
+ const plugin = tinaAdminDevRedirect();
9
+ let handler: Handler | undefined;
10
+ const server = {
11
+ middlewares: { use: (h: Handler) => (handler = h) },
12
+ } as unknown as ViteDevServer;
13
+ (plugin.configureServer as any)(server);
14
+ if (!handler) throw new Error('plugin did not register a middleware');
15
+ return handler;
16
+ }
17
+
18
+ function makeRes() {
19
+ return {
20
+ statusCode: 200,
21
+ headers: {} as Record<string, string>,
22
+ ended: false,
23
+ setHeader(name: string, value: string) {
24
+ this.headers[name] = value;
25
+ },
26
+ end() {
27
+ this.ended = true;
28
+ },
29
+ };
30
+ }
31
+
32
+ describe('tinaAdminDevRedirect', () => {
33
+ it('only applies to the dev server', () => {
34
+ expect(tinaAdminDevRedirect().apply).toBe('serve');
35
+ });
36
+
37
+ it.each(['/admin', '/admin/', '/admin?foo=bar'])(
38
+ 'redirects %s to /admin/index.html',
39
+ (url) => {
40
+ const handler = registerHandler();
41
+ const res = makeRes();
42
+ const next = vi.fn();
43
+
44
+ handler({ url }, res, next);
45
+
46
+ expect(res.statusCode).toBe(302);
47
+ expect(res.headers.Location).toBe('/admin/index.html');
48
+ expect(res.ended).toBe(true);
49
+ expect(next).not.toHaveBeenCalled();
50
+ }
51
+ );
52
+
53
+ it.each(['/admin/index.html', '/admin/foo', '/admins', '/'])(
54
+ 'passes %s through untouched',
55
+ (url) => {
56
+ const handler = registerHandler();
57
+ const res = makeRes();
58
+ const next = vi.fn();
59
+
60
+ handler({ url }, res, next);
61
+
62
+ expect(next).toHaveBeenCalledOnce();
63
+ expect(res.ended).toBe(false);
64
+ expect(res.statusCode).toBe(200);
65
+ }
66
+ );
67
+ });
package/src/bridge.ts ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Re-exports of `@tinacms/bridge` so Astro projects can pull the bridge from
3
+ * `@tinacms/astro/bridge` instead of installing it separately. The bridge
4
+ * package stays publishable on its own for non-Astro frontends (Hugo, plain
5
+ * HTML, Eleventy).
6
+ */
7
+ export * from '@tinacms/bridge';