@jant/core 0.3.20 → 0.3.22

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 (94) hide show
  1. package/dist/app.js +60 -17
  2. package/dist/index.js +8 -0
  3. package/dist/lib/feed.js +112 -0
  4. package/dist/lib/navigation.js +9 -9
  5. package/dist/lib/render.js +48 -0
  6. package/dist/lib/theme-components.js +18 -18
  7. package/dist/lib/view.js +228 -0
  8. package/dist/routes/api/timeline.js +20 -16
  9. package/dist/routes/dash/collections.js +38 -10
  10. package/dist/routes/dash/navigation.js +22 -8
  11. package/dist/routes/dash/redirects.js +19 -5
  12. package/dist/routes/dash/settings.js +57 -15
  13. package/dist/routes/feed/rss.js +34 -78
  14. package/dist/routes/feed/sitemap.js +11 -26
  15. package/dist/routes/pages/archive.js +18 -195
  16. package/dist/routes/pages/collection.js +16 -70
  17. package/dist/routes/pages/home.js +25 -47
  18. package/dist/routes/pages/page.js +15 -27
  19. package/dist/routes/pages/post.js +25 -79
  20. package/dist/routes/pages/search.js +20 -130
  21. package/dist/theme/components/MediaGallery.js +10 -10
  22. package/dist/theme/components/PageForm.js +22 -8
  23. package/dist/theme/components/PostForm.js +22 -8
  24. package/dist/theme/components/index.js +1 -1
  25. package/dist/theme/components/timeline/ArticleCard.js +7 -11
  26. package/dist/theme/components/timeline/ImageCard.js +10 -13
  27. package/dist/theme/components/timeline/LinkCard.js +4 -7
  28. package/dist/theme/components/timeline/NoteCard.js +5 -8
  29. package/dist/theme/components/timeline/QuoteCard.js +3 -6
  30. package/dist/theme/components/timeline/ThreadPreview.js +9 -10
  31. package/dist/theme/components/timeline/TimelineFeed.js +8 -5
  32. package/dist/theme/components/timeline/TimelineItem.js +22 -2
  33. package/dist/theme/components/timeline/index.js +1 -1
  34. package/dist/theme/index.js +6 -3
  35. package/dist/theme/layouts/SiteLayout.js +10 -39
  36. package/dist/theme/pages/ArchivePage.js +157 -0
  37. package/dist/theme/pages/CollectionPage.js +63 -0
  38. package/dist/theme/pages/HomePage.js +26 -0
  39. package/dist/theme/pages/PostPage.js +48 -0
  40. package/dist/theme/pages/SearchPage.js +120 -0
  41. package/dist/theme/pages/SinglePage.js +23 -0
  42. package/dist/theme/pages/index.js +11 -0
  43. package/package.json +2 -1
  44. package/src/app.tsx +48 -17
  45. package/src/i18n/locales/en.po +171 -147
  46. package/src/i18n/locales/zh-Hans.po +171 -147
  47. package/src/i18n/locales/zh-Hant.po +171 -147
  48. package/src/index.ts +51 -2
  49. package/src/lib/__tests__/theme-components.test.ts +33 -14
  50. package/src/lib/__tests__/view.test.ts +375 -0
  51. package/src/lib/feed.ts +148 -0
  52. package/src/lib/navigation.ts +11 -11
  53. package/src/lib/render.tsx +67 -0
  54. package/src/lib/theme-components.ts +27 -35
  55. package/src/lib/view.ts +318 -0
  56. package/src/routes/api/__tests__/timeline.test.ts +3 -3
  57. package/src/routes/api/timeline.tsx +32 -25
  58. package/src/routes/dash/collections.tsx +30 -10
  59. package/src/routes/dash/navigation.tsx +20 -10
  60. package/src/routes/dash/redirects.tsx +15 -5
  61. package/src/routes/dash/settings.tsx +53 -15
  62. package/src/routes/feed/rss.ts +47 -94
  63. package/src/routes/feed/sitemap.ts +8 -30
  64. package/src/routes/pages/archive.tsx +24 -209
  65. package/src/routes/pages/collection.tsx +19 -75
  66. package/src/routes/pages/home.tsx +42 -76
  67. package/src/routes/pages/page.tsx +17 -28
  68. package/src/routes/pages/post.tsx +28 -86
  69. package/src/routes/pages/search.tsx +29 -151
  70. package/src/services/search.ts +2 -8
  71. package/src/theme/components/MediaGallery.tsx +12 -12
  72. package/src/theme/components/PageForm.tsx +20 -10
  73. package/src/theme/components/PostForm.tsx +20 -10
  74. package/src/theme/components/index.ts +1 -0
  75. package/src/theme/components/timeline/ArticleCard.tsx +7 -19
  76. package/src/theme/components/timeline/ImageCard.tsx +10 -20
  77. package/src/theme/components/timeline/LinkCard.tsx +4 -11
  78. package/src/theme/components/timeline/NoteCard.tsx +5 -12
  79. package/src/theme/components/timeline/QuoteCard.tsx +3 -10
  80. package/src/theme/components/timeline/ThreadPreview.tsx +5 -5
  81. package/src/theme/components/timeline/TimelineFeed.tsx +7 -3
  82. package/src/theme/components/timeline/TimelineItem.tsx +43 -4
  83. package/src/theme/components/timeline/index.ts +1 -1
  84. package/src/theme/index.ts +7 -3
  85. package/src/theme/layouts/SiteLayout.tsx +25 -77
  86. package/src/theme/layouts/index.ts +2 -1
  87. package/src/theme/pages/ArchivePage.tsx +160 -0
  88. package/src/theme/pages/CollectionPage.tsx +60 -0
  89. package/src/theme/pages/HomePage.tsx +42 -0
  90. package/src/theme/pages/PostPage.tsx +44 -0
  91. package/src/theme/pages/SearchPage.tsx +128 -0
  92. package/src/theme/pages/SinglePage.tsx +24 -0
  93. package/src/theme/pages/index.ts +13 -0
  94. package/src/types.ts +262 -38
@@ -106,6 +106,7 @@ function NewCollectionContent() {
106
106
  <form
107
107
  data-signals="{title: '', path: '', description: ''}"
108
108
  data-on:submit__prevent="@post('/dash/collections')"
109
+ data-indicator="_loading"
109
110
  class="flex flex-col gap-4 max-w-lg"
110
111
  >
111
112
  <div class="field">
@@ -166,11 +167,20 @@ function NewCollectionContent() {
166
167
  </div>
167
168
 
168
169
  <div class="flex gap-2">
169
- <button type="submit" class="btn">
170
- {t({
171
- message: "Create Collection",
172
- comment: "@context: Button to save new collection",
173
- })}
170
+ <button type="submit" class="btn" data-attr-disabled="$_loading">
171
+ <span data-show="!$_loading">
172
+ {t({
173
+ message: "Create Collection",
174
+ comment: "@context: Button to save new collection",
175
+ })}
176
+ </span>
177
+ <span data-show="$_loading">
178
+ {t({
179
+ message: "Processing...",
180
+ comment:
181
+ "@context: Loading text shown on submit button while request is in progress",
182
+ })}
183
+ </span>
174
184
  </button>
175
185
  <a href="/dash/collections" class="btn-outline">
176
186
  {t({
@@ -297,6 +307,7 @@ function EditCollectionContent({ collection }: { collection: Collection }) {
297
307
  <form
298
308
  data-signals={signals}
299
309
  data-on:submit__prevent={`@post('/dash/collections/${collection.id}')`}
310
+ data-indicator="_loading"
300
311
  class="flex flex-col gap-4 max-w-lg"
301
312
  >
302
313
  <div class="field">
@@ -335,11 +346,20 @@ function EditCollectionContent({ collection }: { collection: Collection }) {
335
346
  </div>
336
347
 
337
348
  <div class="flex gap-2">
338
- <button type="submit" class="btn">
339
- {t({
340
- message: "Update Collection",
341
- comment: "@context: Button to save collection changes",
342
- })}
349
+ <button type="submit" class="btn" data-attr-disabled="$_loading">
350
+ <span data-show="!$_loading">
351
+ {t({
352
+ message: "Update Collection",
353
+ comment: "@context: Button to save collection changes",
354
+ })}
355
+ </span>
356
+ <span data-show="$_loading">
357
+ {t({
358
+ message: "Processing...",
359
+ comment:
360
+ "@context: Loading text shown on submit button while request is in progress",
361
+ })}
362
+ </span>
343
363
  </button>
344
364
  <a href={`/dash/collections/${collection.id}`} class="btn-outline">
345
365
  {t({
@@ -119,6 +119,7 @@ function NavigationFormContent({
119
119
  <form
120
120
  data-signals={signals}
121
121
  data-on:submit__prevent={`@post('${action}')`}
122
+ data-indicator="_loading"
122
123
  class="flex flex-col gap-4 max-w-lg"
123
124
  >
124
125
  <div class="field">
@@ -167,16 +168,25 @@ function NavigationFormContent({
167
168
  </div>
168
169
 
169
170
  <div class="flex gap-2">
170
- <button type="submit" class="btn">
171
- {isEdit
172
- ? t({
173
- message: "Save Changes",
174
- comment: "@context: Button to save edited navigation link",
175
- })
176
- : t({
177
- message: "Create Link",
178
- comment: "@context: Button to save new navigation link",
179
- })}
171
+ <button type="submit" class="btn" data-attr-disabled="$_loading">
172
+ <span data-show="!$_loading">
173
+ {isEdit
174
+ ? t({
175
+ message: "Save Changes",
176
+ comment: "@context: Button to save edited navigation link",
177
+ })
178
+ : t({
179
+ message: "Create Link",
180
+ comment: "@context: Button to save new navigation link",
181
+ })}
182
+ </span>
183
+ <span data-show="$_loading">
184
+ {t({
185
+ message: "Processing...",
186
+ comment:
187
+ "@context: Loading text shown on submit button while request is in progress",
188
+ })}
189
+ </span>
180
190
  </button>
181
191
  <a href="/dash/navigation" class="btn-outline">
182
192
  {t({
@@ -90,6 +90,7 @@ function NewRedirectContent() {
90
90
  <form
91
91
  data-signals="{fromPath: '', toPath: '', type: '301'}"
92
92
  data-on:submit__prevent="@post('/dash/redirects')"
93
+ data-indicator="_loading"
93
94
  class="flex flex-col gap-4 max-w-lg"
94
95
  >
95
96
  <div class="field">
@@ -157,11 +158,20 @@ function NewRedirectContent() {
157
158
  </div>
158
159
 
159
160
  <div class="flex gap-2">
160
- <button type="submit" class="btn">
161
- {t({
162
- message: "Create Redirect",
163
- comment: "@context: Button to save new redirect",
164
- })}
161
+ <button type="submit" class="btn" data-attr-disabled="$_loading">
162
+ <span data-show="!$_loading">
163
+ {t({
164
+ message: "Create Redirect",
165
+ comment: "@context: Button to save new redirect",
166
+ })}
167
+ </span>
168
+ <span data-show="$_loading">
169
+ {t({
170
+ message: "Processing...",
171
+ comment:
172
+ "@context: Loading text shown on submit button while request is in progress",
173
+ })}
174
+ </span>
165
175
  </button>
166
176
  <a href="/dash/redirects" class="btn-outline">
167
177
  {t({
@@ -123,6 +123,7 @@ function GeneralContent({
123
123
  <form
124
124
  data-signals={generalSignals}
125
125
  data-on:submit__prevent="@post('/dash/settings')"
126
+ data-indicator="_loading"
126
127
  >
127
128
  <div class="card">
128
129
  <header>
@@ -188,11 +189,20 @@ function GeneralContent({
188
189
  </section>
189
190
  </div>
190
191
 
191
- <button type="submit" class="btn mt-4">
192
- {t({
193
- message: "Save Settings",
194
- comment: "@context: Button to save settings",
195
- })}
192
+ <button type="submit" class="btn mt-4" data-attr-disabled="$_loading">
193
+ <span data-show="!$_loading">
194
+ {t({
195
+ message: "Save Settings",
196
+ comment: "@context: Button to save settings",
197
+ })}
198
+ </span>
199
+ <span data-show="$_loading">
200
+ {t({
201
+ message: "Processing...",
202
+ comment:
203
+ "@context: Loading text shown on submit button while request is in progress",
204
+ })}
205
+ </span>
196
206
  </button>
197
207
  </form>
198
208
  </div>
@@ -346,6 +356,7 @@ function AccountContent({ userName }: { userName: string }) {
346
356
  <form
347
357
  data-signals={profileSignals}
348
358
  data-on:submit__prevent="@post('/dash/settings/account')"
359
+ data-indicator="_profileLoading"
349
360
  >
350
361
  <div class="card">
351
362
  <header>
@@ -374,17 +385,31 @@ function AccountContent({ userName }: { userName: string }) {
374
385
  </section>
375
386
  </div>
376
387
 
377
- <button type="submit" class="btn mt-4">
378
- {t({
379
- message: "Save Profile",
380
- comment: "@context: Button to save profile",
381
- })}
388
+ <button
389
+ type="submit"
390
+ class="btn mt-4"
391
+ data-attr-disabled="$_profileLoading"
392
+ >
393
+ <span data-show="!$_profileLoading">
394
+ {t({
395
+ message: "Save Profile",
396
+ comment: "@context: Button to save profile",
397
+ })}
398
+ </span>
399
+ <span data-show="$_profileLoading">
400
+ {t({
401
+ message: "Processing...",
402
+ comment:
403
+ "@context: Loading text shown on submit button while request is in progress",
404
+ })}
405
+ </span>
382
406
  </button>
383
407
  </form>
384
408
 
385
409
  <form
386
410
  data-signals="{currentPassword: '', newPassword: '', confirmPassword: ''}"
387
411
  data-on:submit__prevent="@post('/dash/settings/password')"
412
+ data-indicator="_passwordLoading"
388
413
  >
389
414
  <div class="card">
390
415
  <header>
@@ -448,11 +473,24 @@ function AccountContent({ userName }: { userName: string }) {
448
473
  </section>
449
474
  </div>
450
475
 
451
- <button type="submit" class="btn mt-4">
452
- {t({
453
- message: "Change Password",
454
- comment: "@context: Button to change password",
455
- })}
476
+ <button
477
+ type="submit"
478
+ class="btn mt-4"
479
+ data-attr-disabled="$_passwordLoading"
480
+ >
481
+ <span data-show="!$_passwordLoading">
482
+ {t({
483
+ message: "Change Password",
484
+ comment: "@context: Button to change password",
485
+ })}
486
+ </span>
487
+ <span data-show="$_passwordLoading">
488
+ {t({
489
+ message: "Processing...",
490
+ comment:
491
+ "@context: Loading text shown on submit button while request is in progress",
492
+ })}
493
+ </span>
456
494
  </button>
457
495
  </form>
458
496
  </div>
@@ -3,24 +3,27 @@
3
3
  */
4
4
 
5
5
  import { Hono } from "hono";
6
- import type { Bindings } from "../../types.js";
6
+ import type { Context } from "hono";
7
+ import type { Bindings, FeedData } from "../../types.js";
7
8
  import type { AppVariables } from "../../app.js";
8
- import * as sqid from "../../lib/sqid.js";
9
- import * as time from "../../lib/time.js";
10
- import { getMediaUrl, getPublicUrlForProvider } from "../../lib/image.js";
9
+ import { defaultRssRenderer, defaultAtomRenderer } from "../../lib/feed.js";
10
+ import { getSiteLanguage } from "../../lib/config.js";
11
+ import { buildMediaMap } from "../../lib/media-helpers.js";
12
+ import { createMediaContext, toPostViews } from "../../lib/view.js";
11
13
 
12
14
  type Env = { Bindings: Bindings; Variables: AppVariables };
13
15
 
14
16
  export const rssRoutes = new Hono<Env>();
15
17
 
16
- // RSS 2.0 Feed - main feed at /feed
17
- rssRoutes.get("/", async (c) => {
18
+ /**
19
+ * Build FeedData from the Hono context.
20
+ */
21
+ async function buildFeedData(c: Context<Env>): Promise<FeedData> {
18
22
  const all = await c.var.services.settings.getAll();
19
23
  const siteName = all["SITE_NAME"] ?? "Jant";
20
24
  const siteDescription = all["SITE_DESCRIPTION"] ?? "";
21
25
  const siteUrl = c.env.SITE_URL;
22
- const r2PublicUrl = c.env.R2_PUBLIC_URL;
23
- const s3PublicUrl = c.env.S3_PUBLIC_URL;
26
+ const siteLanguage = await getSiteLanguage(c);
24
27
 
25
28
  const posts = await c.var.services.posts.list({
26
29
  visibility: ["featured", "quiet"],
@@ -29,45 +32,41 @@ rssRoutes.get("/", async (c) => {
29
32
 
30
33
  // Batch load media for enclosures
31
34
  const postIds = posts.map((p) => p.id);
32
- const mediaMap = await c.var.services.media.getByPostIds(postIds);
33
-
34
- const items = posts
35
- .map((post) => {
36
- const link = `${siteUrl}/p/${sqid.encode(post.id)}`;
37
- const title = post.title || `Post #${post.id}`;
38
- const pubDate = new Date(post.publishedAt * 1000).toUTCString();
39
-
40
- // Add enclosure for first media attachment
41
- const postMedia = mediaMap.get(post.id);
42
- const firstMedia = postMedia?.[0];
43
- const enclosure = firstMedia
44
- ? `\n <enclosure url="${getMediaUrl(firstMedia.id, firstMedia.storageKey, getPublicUrlForProvider(firstMedia.provider, r2PublicUrl, s3PublicUrl))}" length="${firstMedia.size}" type="${firstMedia.mimeType}"/>`
45
- : "";
35
+ const rawMediaMap = await c.var.services.media.getByPostIds(postIds);
36
+ const mediaCtx = createMediaContext(c);
37
+ const mediaMap = buildMediaMap(
38
+ rawMediaMap,
39
+ mediaCtx.r2PublicUrl,
40
+ mediaCtx.imageTransformUrl,
41
+ mediaCtx.s3PublicUrl,
42
+ );
43
+
44
+ // Transform to PostView[] with media
45
+ const postViews = toPostViews(
46
+ posts.map((p) => ({
47
+ ...p,
48
+ mediaAttachments: mediaMap.get(p.id) ?? [],
49
+ })),
50
+ mediaCtx,
51
+ );
52
+
53
+ return {
54
+ siteName,
55
+ siteDescription,
56
+ siteUrl,
57
+ siteLanguage,
58
+ posts: postViews,
59
+ };
60
+ }
46
61
 
47
- return `
48
- <item>
49
- <title><![CDATA[${escapeXml(title)}]]></title>
50
- <link>${link}</link>
51
- <guid isPermaLink="true">${link}</guid>
52
- <pubDate>${pubDate}</pubDate>
53
- <description><![CDATA[${post.contentHtml || ""}]]></description>${enclosure}
54
- </item>`;
55
- })
56
- .join("");
62
+ // RSS 2.0 Feed - main feed at /feed
63
+ rssRoutes.get("/", async (c) => {
64
+ const feedData = await buildFeedData(c);
57
65
 
58
- const rss = `<?xml version="1.0" encoding="UTF-8"?>
59
- <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
60
- <channel>
61
- <title>${escapeXml(siteName)}</title>
62
- <link>${siteUrl}</link>
63
- <description>${escapeXml(siteDescription)}</description>
64
- <language>en</language>
65
- <atom:link href="${siteUrl}/feed" rel="self" type="application/rss+xml"/>
66
- ${items}
67
- </channel>
68
- </rss>`;
66
+ const renderer = c.var.config.theme?.feed?.rss ?? defaultRssRenderer;
67
+ const xml = renderer(feedData);
69
68
 
70
- return new Response(rss, {
69
+ return new Response(xml, {
71
70
  headers: {
72
71
  "Content-Type": "application/rss+xml; charset=utf-8",
73
72
  },
@@ -76,60 +75,14 @@ rssRoutes.get("/", async (c) => {
76
75
 
77
76
  // Atom Feed
78
77
  rssRoutes.get("/atom.xml", async (c) => {
79
- const all = await c.var.services.settings.getAll();
80
- const siteName = all["SITE_NAME"] ?? "Jant";
81
- const siteDescription = all["SITE_DESCRIPTION"] ?? "";
82
- const siteUrl = c.env.SITE_URL;
83
-
84
- const posts = await c.var.services.posts.list({
85
- visibility: ["featured", "quiet"],
86
- limit: 50,
87
- });
78
+ const feedData = await buildFeedData(c);
88
79
 
89
- const entries = posts
90
- .map((post) => {
91
- const link = `${siteUrl}/p/${sqid.encode(post.id)}`;
92
- const title = post.title || `Post #${post.id}`;
93
- const updated = time.toISOString(post.updatedAt);
94
- const published = time.toISOString(post.publishedAt);
80
+ const renderer = c.var.config.theme?.feed?.atom ?? defaultAtomRenderer;
81
+ const xml = renderer(feedData);
95
82
 
96
- return `
97
- <entry>
98
- <title>${escapeXml(title)}</title>
99
- <link href="${link}" rel="alternate"/>
100
- <id>${link}</id>
101
- <published>${published}</published>
102
- <updated>${updated}</updated>
103
- <content type="html"><![CDATA[${post.contentHtml || ""}]]></content>
104
- </entry>`;
105
- })
106
- .join("");
107
-
108
- const now = time.toISOString(time.now());
109
-
110
- const atom = `<?xml version="1.0" encoding="UTF-8"?>
111
- <feed xmlns="http://www.w3.org/2005/Atom">
112
- <title>${escapeXml(siteName)}</title>
113
- <subtitle>${escapeXml(siteDescription)}</subtitle>
114
- <link href="${siteUrl}" rel="alternate"/>
115
- <link href="${siteUrl}/feed/atom.xml" rel="self"/>
116
- <id>${siteUrl}/</id>
117
- <updated>${now}</updated>
118
- ${entries}
119
- </feed>`;
120
-
121
- return new Response(atom, {
83
+ return new Response(xml, {
122
84
  headers: {
123
85
  "Content-Type": "application/atom+xml; charset=utf-8",
124
86
  },
125
87
  });
126
88
  });
127
-
128
- function escapeXml(str: string): string {
129
- return str
130
- .replace(/&/g, "&amp;")
131
- .replace(/</g, "&lt;")
132
- .replace(/>/g, "&gt;")
133
- .replace(/"/g, "&quot;")
134
- .replace(/'/g, "&apos;");
135
- }
@@ -5,8 +5,8 @@
5
5
  import { Hono } from "hono";
6
6
  import type { Bindings } from "../../types.js";
7
7
  import type { AppVariables } from "../../app.js";
8
- import * as sqid from "../../lib/sqid.js";
9
- import * as time from "../../lib/time.js";
8
+ import { defaultSitemapRenderer } from "../../lib/feed.js";
9
+ import { createMediaContext, toPostViewsFromPosts } from "../../lib/view.js";
10
10
 
11
11
  type Env = { Bindings: Bindings; Variables: AppVariables };
12
12
 
@@ -21,36 +21,14 @@ sitemapRoutes.get("/sitemap.xml", async (c) => {
21
21
  limit: 1000,
22
22
  });
23
23
 
24
- const urls = posts
25
- .map((post) => {
26
- const loc = `${siteUrl}/p/${sqid.encode(post.id)}`;
27
- const lastmod = time.toISOString(post.updatedAt).split("T")[0];
28
- const priority = post.visibility === "featured" ? "0.8" : "0.6";
24
+ // Transform to PostView[]
25
+ const mediaCtx = createMediaContext(c);
26
+ const postViews = toPostViewsFromPosts(posts, mediaCtx);
29
27
 
30
- return `
31
- <url>
32
- <loc>${loc}</loc>
33
- <lastmod>${lastmod}</lastmod>
34
- <priority>${priority}</priority>
35
- </url>`;
36
- })
37
- .join("");
28
+ const renderer = c.var.config.theme?.feed?.sitemap ?? defaultSitemapRenderer;
29
+ const xml = renderer({ siteUrl, posts: postViews });
38
30
 
39
- // Add homepage
40
- const homepageUrl = `
41
- <url>
42
- <loc>${siteUrl}/</loc>
43
- <priority>1.0</priority>
44
- <changefreq>daily</changefreq>
45
- </url>`;
46
-
47
- const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
48
- <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
49
- ${homepageUrl}
50
- ${urls}
51
- </urlset>`;
52
-
53
- return new Response(sitemap, {
31
+ return new Response(xml, {
54
32
  headers: {
55
33
  "Content-Type": "application/xml; charset=utf-8",
56
34
  },