pabal-web-mcp 1.0.0 → 1.1.1

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.
@@ -199,7 +199,6 @@ var asoToPublicInputSchema = z.object({
199
199
  });
200
200
  var jsonSchema = toJsonSchema(asoToPublicInputSchema, {
201
201
  name: "AsoToPublicInput",
202
- target: "openApi3",
203
202
  $refStrategy: "none"
204
203
  });
205
204
  var inputSchema = jsonSchema.definitions?.AsoToPublicInput || jsonSchema;
@@ -304,9 +303,11 @@ async function handleAsoToPublic(input) {
304
303
  prompt
305
304
  });
306
305
  const sourcesText = sources.join(" + ");
307
- conversionPrompts.push(`
306
+ conversionPrompts.push(
307
+ `
308
308
  --- ${unifiedLocale} (${sourcesText}) ---
309
- ${prompt}`);
309
+ ${prompt}`
310
+ );
310
311
  }
311
312
  const pullDataDir = getPullDataDir();
312
313
  let responseText = `Converting ASO data from pullData to public/products/${slug}/ structure.
@@ -517,7 +518,6 @@ var publicToAsoInputSchema = z2.object({
517
518
  });
518
519
  var jsonSchema2 = toJsonSchema2(publicToAsoInputSchema, {
519
520
  name: "PublicToAsoInput",
520
- target: "openApi3",
521
521
  $refStrategy: "none"
522
522
  });
523
523
  var inputSchema2 = jsonSchema2.definitions?.PublicToAsoInput || jsonSchema2;
@@ -546,9 +546,10 @@ async function downloadScreenshotsToAsoDir(slug, asoData) {
546
546
  "screenshots",
547
547
  googlePlayLocale
548
548
  );
549
- if (localeData.screenshots?.phone?.length > 0) {
550
- for (let i = 0; i < localeData.screenshots.phone.length; i++) {
551
- const url = localeData.screenshots.phone[i];
549
+ const phoneScreenshots = localeData.screenshots?.phone;
550
+ if (phoneScreenshots && phoneScreenshots.length > 0) {
551
+ for (let i = 0; i < phoneScreenshots.length; i++) {
552
+ const url = phoneScreenshots[i];
552
553
  const filename = `phone-${i + 1}.png`;
553
554
  const outputPath = path4.join(asoDir, filename);
554
555
  if (isLocalAssetPath(url)) {
@@ -1609,7 +1610,6 @@ var improvePublicInputSchema = z3.object({
1609
1610
  });
1610
1611
  var jsonSchema3 = toJsonSchema3(improvePublicInputSchema, {
1611
1612
  name: "ImprovePublicInput",
1612
- target: "openApi3",
1613
1613
  $refStrategy: "none"
1614
1614
  });
1615
1615
  var inputSchema3 = jsonSchema3.definitions?.ImprovePublicInput || jsonSchema3;
@@ -1795,7 +1795,6 @@ var initProjectInputSchema = z4.object({
1795
1795
  });
1796
1796
  var jsonSchema4 = zodToJsonSchema4(initProjectInputSchema, {
1797
1797
  name: "InitProjectInput",
1798
- target: "openApi3",
1799
1798
  $refStrategy: "none"
1800
1799
  });
1801
1800
  var inputSchema4 = jsonSchema4.definitions?.InitProjectInput || jsonSchema4;
@@ -1889,6 +1888,401 @@ async function handleInitProject(input) {
1889
1888
  };
1890
1889
  }
1891
1890
 
1891
+ // src/tools/create-blog-html.ts
1892
+ import fs7 from "fs";
1893
+ import path8 from "path";
1894
+ import { z as z5 } from "zod";
1895
+ import { zodToJsonSchema as zodToJsonSchema5 } from "zod-to-json-schema";
1896
+
1897
+ // src/utils/blog.util.ts
1898
+ import path7 from "path";
1899
+ var DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
1900
+ var BLOG_ROOT = "blogs";
1901
+ var removeDiacritics = (value) => value.normalize("NFKD").replace(/[\u0300-\u036f]/g, "");
1902
+ var compact = (items) => (items || []).filter((item) => Boolean(item && item.trim()));
1903
+ function slugifyTitle(title) {
1904
+ const normalized = removeDiacritics(title).toLowerCase().replace(/[^a-z0-9\s-]/g, " ").replace(/[_\s]+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
1905
+ return normalized || "post";
1906
+ }
1907
+ function normalizeDate(date) {
1908
+ if (date) {
1909
+ if (!DATE_REGEX.test(date)) {
1910
+ throw new Error(
1911
+ `Invalid date format "${date}". Use YYYY-MM-DD (e.g. 2024-09-30).`
1912
+ );
1913
+ }
1914
+ return date;
1915
+ }
1916
+ return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
1917
+ }
1918
+ var isKoreanLocale = (locale) => locale.trim().toLowerCase().startsWith("ko");
1919
+ var toPublicBlogBase = (appSlug, slug) => `/${BLOG_ROOT}/${appSlug}/${slug}`;
1920
+ function resolveCoverImagePath(appSlug, slug, coverImage) {
1921
+ if (!coverImage || !coverImage.trim()) {
1922
+ return `/products/${appSlug}/og-image.png`;
1923
+ }
1924
+ const cleaned = coverImage.trim();
1925
+ const relativePath = cleaned.replace(/^\.\//, "");
1926
+ if (!cleaned.startsWith("/") && !/^https?:\/\//.test(cleaned)) {
1927
+ return `${toPublicBlogBase(appSlug, slug)}/${relativePath}`;
1928
+ }
1929
+ if (cleaned.startsWith("./")) {
1930
+ return `${toPublicBlogBase(appSlug, slug)}/${relativePath}`;
1931
+ }
1932
+ return cleaned;
1933
+ }
1934
+ function resolveRelativeImagePath(appSlug, slug, relativePath) {
1935
+ const raw = relativePath?.trim() || "./images/hero.png";
1936
+ const normalized = raw.replace(/^\.\//, "");
1937
+ return {
1938
+ raw,
1939
+ absolute: `${toPublicBlogBase(appSlug, slug)}/${normalized}`
1940
+ };
1941
+ }
1942
+ function buildDescription(locale, topic, appSlug) {
1943
+ if (isKoreanLocale(locale)) {
1944
+ return `${topic}\uB97C \uC8FC\uC81C\uB85C ${appSlug}\uAC00 ASO\uC640 SEO\uB97C \uC5B4\uB5BB\uAC8C \uC5F0\uACB0\uD558\uACE0 \uBE14\uB85C\uADF8 \uD2B8\uB798\uD53D\uC744 \uC81C\uD488 \uD398\uC774\uC9C0\uB85C \uC774\uC5B4\uC8FC\uB294\uC9C0 \uC815\uB9AC\uD588\uC2B5\uB2C8\uB2E4.`;
1945
+ }
1946
+ return `How ${appSlug} teams turn "${topic}" into a bridge between ASO pages and SEO blogs without losing consistency.`;
1947
+ }
1948
+ function deriveTags(topic, appSlug) {
1949
+ const topicParts = topic.toLowerCase().split(/[^a-z0-9+]+/).filter(Boolean).slice(0, 6);
1950
+ const set = /* @__PURE__ */ new Set([...topicParts, appSlug.toLowerCase(), "blog"]);
1951
+ return Array.from(set);
1952
+ }
1953
+ function buildBlogMeta(options) {
1954
+ const publishedAt = normalizeDate(options.publishedAt);
1955
+ const modifiedAt = normalizeDate(options.modifiedAt || publishedAt);
1956
+ const coverImage = resolveCoverImagePath(
1957
+ options.appSlug,
1958
+ options.slug,
1959
+ options.coverImage
1960
+ );
1961
+ return {
1962
+ title: options.title,
1963
+ description: options.description || buildDescription(options.locale, options.topic, options.appSlug),
1964
+ appSlug: options.appSlug,
1965
+ slug: options.slug,
1966
+ locale: options.locale,
1967
+ publishedAt,
1968
+ modifiedAt,
1969
+ coverImage,
1970
+ tags: compact(options.tags)?.length ? Array.from(
1971
+ new Set(compact(options.tags).map((tag) => tag.toLowerCase()))
1972
+ ) : deriveTags(options.topic, options.appSlug)
1973
+ };
1974
+ }
1975
+ function renderBlogMetaBlock(meta) {
1976
+ const serialized = JSON.stringify(meta, null, 2);
1977
+ return `<!--BLOG_META
1978
+ ${serialized}
1979
+ -->`;
1980
+ }
1981
+ function renderEnglishBody(args) {
1982
+ const {
1983
+ meta,
1984
+ topic,
1985
+ appSlug,
1986
+ includeRelativeImageExample,
1987
+ relativeImagePath
1988
+ } = args;
1989
+ const lines = [];
1990
+ lines.push(`<h1>${meta.title}</h1>`);
1991
+ lines.push(
1992
+ `<p>${appSlug} keeps product pages and blogs aligned. This article shows how to use "${topic}" as a shared story so ASO and SEO stay in sync.</p>`
1993
+ );
1994
+ if (includeRelativeImageExample && relativeImagePath) {
1995
+ lines.push(
1996
+ `<img src="${relativeImagePath.raw}" alt="${appSlug} ${topic} cover" />`
1997
+ );
1998
+ lines.push(
1999
+ `<p>The image above is stored next to this file and resolves to <code>${relativeImagePath.absolute}</code> when published.</p>`
2000
+ );
2001
+ }
2002
+ lines.push(`<h2>Why the gap appears</h2>`);
2003
+ lines.push(
2004
+ `<p>ASO pages are tuned for storefronts while SEO posts speak to search crawlers. Teams often duplicate work, drift on messaging, and miss internal links back to /products/${appSlug}.</p>`
2005
+ );
2006
+ lines.push(`<h3>Signals that drift</h3>`);
2007
+ lines.push(
2008
+ `<p>Different headlines, mismatched screenshots, and stale dates make ranking harder. "${topic}" is a strong bridge topic because it touches both acquisition paths.</p>`
2009
+ );
2010
+ lines.push(`<h2>How to bridge with ${appSlug}</h2>`);
2011
+ lines.push(
2012
+ `<p>Start with the product story, then reuse it in blog form. Keep the same core claim, swap storefront keywords for search intent, and reference the canonical product slug.</p>`
2013
+ );
2014
+ lines.push(`<h3>Mini playbook</h3>`);
2015
+ lines.push(
2016
+ `<ul>
2017
+ <li>Reuse the app store hero claim inside the intro.</li>
2018
+ <li>Map ASO keywords to SEO phrases for the "${topic}" angle.</li>
2019
+ <li>Link feature blurbs to product screenshots and changelog notes.</li>
2020
+ <li>Close with a CTA back to <code>/products/${appSlug}</code>.</li>
2021
+ </ul>`
2022
+ );
2023
+ lines.push(`<h2>Example flow to copy</h2>`);
2024
+ lines.push(
2025
+ `<p>Pick one release, outline how it helps with "${topic}", then add a short proof (metric, quote, or screenshot). Keep h2/h3 hierarchy stable so translations stay predictable.</p>`
2026
+ );
2027
+ lines.push(`<h2>Wrap up</h2>`);
2028
+ lines.push(
2029
+ `<p>${appSlug} keeps ASO and SEO talking to each other. Publish this HTML under <code>/blogs/${appSlug}/${meta.slug}/${meta.locale}.html</code> and link it from the product page so traffic flows both ways.</p>`
2030
+ );
2031
+ lines.push(
2032
+ `<p><strong>CTA:</strong> Explore the product page at <a href="/products/${appSlug}">/products/${appSlug}</a>.</p>`
2033
+ );
2034
+ return lines.join("\n\n");
2035
+ }
2036
+ function renderKoreanBody(args) {
2037
+ const {
2038
+ meta,
2039
+ topic,
2040
+ appSlug,
2041
+ includeRelativeImageExample,
2042
+ relativeImagePath
2043
+ } = args;
2044
+ const lines = [];
2045
+ lines.push(`<h1>${meta.title}</h1>`);
2046
+ lines.push(
2047
+ `<p>${appSlug}\uB294 \uC81C\uD488 \uD398\uC774\uC9C0\uC640 \uBE14\uB85C\uADF8\uC758 \uD750\uB984\uC774 \uB04A\uAE30\uC9C0 \uC54A\uB3C4\uB85D "${topic}"\uC744 \uAC19\uC740 \uC774\uC57C\uAE30\uB85C \uBB36\uC5B4\uB0C5\uB2C8\uB2E4. \uC774 \uAE00\uC740 ASO \uC2E0\uD638\uC640 SEO \uCF58\uD150\uCE20\uB97C \uD558\uB098\uC758 \uBA54\uC2DC\uC9C0\uB85C \uC5F0\uACB0\uD558\uB294 \uBC29\uBC95\uC744 \uC124\uBA85\uD569\uB2C8\uB2E4.</p>`
2048
+ );
2049
+ if (includeRelativeImageExample && relativeImagePath) {
2050
+ lines.push(
2051
+ `<img src="${relativeImagePath.raw}" alt="${appSlug} ${topic} \uD45C\uC9C0 \uC774\uBBF8\uC9C0" />`
2052
+ );
2053
+ lines.push(
2054
+ `<p>\uC704 \uC774\uBBF8\uC9C0\uB294 \uAE00\uACFC \uAC19\uC740 \uD3F4\uB354\uC5D0 \uC800\uC7A5\uB418\uC5B4 \uD37C\uBE14\uB9AC\uC2DC \uC2DC <code>${relativeImagePath.absolute}</code> \uACBD\uB85C\uB85C \uB178\uCD9C\uB429\uB2C8\uB2E4.</p>`
2055
+ );
2056
+ }
2057
+ lines.push(`<h2>ASO\uC640 SEO\uAC00 \uAC08\uB77C\uC9C0\uB294 \uC9C0\uC810</h2>`);
2058
+ lines.push(
2059
+ `<p>\uC2A4\uD1A0\uC5B4 \uCD5C\uC801\uD654\uB294 \uC804\uD658\uC5D0 \uC9D1\uC911\uD558\uACE0, \uBE14\uB85C\uADF8\uB294 \uAC80\uC0C9 \uB178\uCD9C\uC744 \uB178\uB9BD\uB2C8\uB2E4. \uAC19\uC740 \uC81C\uD488\uC774\uB77C\uB3C4 \uC81C\uBAA9, \uC2A4\uD06C\uB9B0\uC0F7, \uB0A0\uC9DC\uAC00 \uB2EC\uB77C\uC9C0\uBA74 \uC2E0\uB8B0\uB3C4\uAC00 \uB5A8\uC5B4\uC9C0\uACE0 /products/${appSlug}\uB85C \uC774\uC5B4\uC9C0\uB294 \uB9C1\uD06C\uB3C4 \uC57D\uD574\uC9D1\uB2C8\uB2E4.</p>`
2060
+ );
2061
+ lines.push(`<h3>\uD754\uD788 \uB193\uCE58\uB294 \uC2E0\uD638</h3>`);
2062
+ lines.push(
2063
+ `<p>\uC2A4\uD1A0\uC5B4 \uBA54\uC2DC\uC9C0\uC640 \uBE14\uB85C\uADF8 \uCE74\uD53C\uAC00 \uB530\uB85C \uB180\uAC70\uB098, \uCD9C\uC2DC \uB9E5\uB77D\uC774 \uBE60\uC9C0\uB294 \uACBD\uC6B0\uC785\uB2C8\uB2E4. "${topic}" \uAC19\uC740 \uC8FC\uC81C\uB294 \uB450 \uCC44\uB110\uC744 \uBAA8\uB450 \uAC74\uB4DC\uB9AC\uAE30 \uB54C\uBB38\uC5D0 \uC77C\uAD00\uC131\uC774 \uB354 \uC911\uC694\uD569\uB2C8\uB2E4.</p>`
2064
+ );
2065
+ lines.push(`<h2>${appSlug}\uB85C \uB2E4\uB9AC \uB193\uAE30</h2>`);
2066
+ lines.push(
2067
+ `<p>\uC81C\uD488 \uC774\uC57C\uAE30\uB97C \uBA3C\uC800 \uC815\uB9AC\uD558\uACE0 \uBE14\uB85C\uADF8 \uBC84\uC804\uC73C\uB85C \uC7AC\uC0AC\uC6A9\uD569\uB2C8\uB2E4. \uD575\uC2EC \uC8FC\uC7A5\uACFC \uC99D\uAC70\uB294 \uC720\uC9C0\uD558\uB418, \uAC80\uC0C9 \uC758\uB3C4\uC5D0 \uB9DE\uCDB0 \uD0A4\uC6CC\uB4DC\uC640 \uC608\uC2DC\uB97C \uC870\uC815\uD558\uACE0 \uC81C\uD488 \uC2AC\uB7EC\uADF8\uB97C \uD568\uAED8 \uB178\uCD9C\uD569\uB2C8\uB2E4.</p>`
2068
+ );
2069
+ lines.push(`<h3>\uC801\uC6A9 \uCCB4\uD06C\uB9AC\uC2A4\uD2B8</h3>`);
2070
+ lines.push(
2071
+ `<ul>
2072
+ <li>\uC2A4\uD1A0\uC5B4 \uD5E4\uB4DC\uB77C\uC778\uC744 \uC778\uD2B8\uB85C\uC5D0 \uC7AC\uC0AC\uC6A9\uD558\uACE0 \uB3D9\uC77C\uD55C \uC8FC\uC7A5\uC73C\uB85C \uD480\uC5B4\uAC00\uAE30</li>
2073
+ <li>"${topic}"\uB97C \uC704\uD55C SEO \uD0A4\uC6CC\uB4DC\uB97C ASO \uD0A4\uC6CC\uB4DC\uC640 \uB9E4\uD551\uD558\uAE30</li>
2074
+ <li>\uC2E0\uAE30\uB2A5/\uC2A4\uD06C\uB9B0\uC0F7/\uBCC0\uACBD \uC0AC\uD56D\uC744 \uBE14\uB85C\uADF8 \uBCF8\uBB38\uC5D0 \uC9E7\uAC8C \uC5F0\uACB0\uD558\uAE30</li>
2075
+ <li>\uB9C8\uC9C0\uB9C9 \uBB38\uB2E8\uC5D0\uC11C <code>/products/${appSlug}</code>\uB85C \uC790\uC5F0\uC2A4\uB7EC\uC6B4 CTA \uBC30\uCE58</li>
2076
+ </ul>`
2077
+ );
2078
+ lines.push(`<h2>\uC0AC\uB840 \uD750\uB984 \uC608\uC2DC</h2>`);
2079
+ lines.push(
2080
+ `<p>\uCD5C\uADFC \uB9B4\uB9AC\uC2A4\uB97C \uD558\uB098 \uACE8\uB77C "${topic}"\uACFC \uC5B4\uB5BB\uAC8C \uB9DE\uBB3C\uB9AC\uB294\uC9C0 \uC124\uBA85\uD558\uACE0, \uC22B\uC790\xB7\uC778\uC6A9\xB7\uC2A4\uD06C\uB9B0\uC0F7 \uC911 \uD558\uB098\uB85C \uC99D\uAC70\uB97C \uB0A8\uAE30\uC138\uC694. h2/h3 \uAD6C\uC870\uB97C \uACE0\uC815\uD558\uBA74 \uB2E4\uAD6D\uC5B4 \uD655\uC7A5\uB3C4 \uC218\uC6D4\uD574\uC9D1\uB2C8\uB2E4.</p>`
2081
+ );
2082
+ lines.push(`<h2>\uB9C8\uBB34\uB9AC</h2>`);
2083
+ lines.push(
2084
+ `<p>${appSlug}\uB294 \uBE14\uB85C\uADF8\uC640 \uC81C\uD488 \uD398\uC774\uC9C0\uAC00 \uC11C\uB85C \uD2B8\uB798\uD53D\uC744 \uC8FC\uACE0\uBC1B\uB3C4\uB85D \uC124\uACC4\uD588\uC2B5\uB2C8\uB2E4. \uC774 HTML\uC744 <code>/blogs/${appSlug}/${meta.slug}/${meta.locale}.html</code> \uC704\uCE58\uC5D0 \uC800\uC7A5\uD558\uACE0 \uC81C\uD488 \uC0C1\uC138\uC5D0\uC11C \uB9C1\uD06C\uB97C \uAC78\uC5B4\uB450\uC138\uC694.</p>`
2085
+ );
2086
+ lines.push(
2087
+ `<p><strong>CTA:</strong> \uC81C\uD488 \uD398\uC774\uC9C0 <a href="/products/${appSlug}">/products/${appSlug}</a>\uC5D0\uC11C \uB354 \uC790\uC138\uD788 \uC0B4\uD3B4\uBCF4\uC138\uC694.</p>`
2088
+ );
2089
+ return lines.join("\n\n");
2090
+ }
2091
+ function renderBlogBody(options) {
2092
+ if (isKoreanLocale(options.meta.locale)) {
2093
+ return renderKoreanBody(options);
2094
+ }
2095
+ return renderEnglishBody(options);
2096
+ }
2097
+ function buildBlogHtmlDocument(options) {
2098
+ const metaBlock = renderBlogMetaBlock(options.meta);
2099
+ const body = renderBlogBody({
2100
+ meta: options.meta,
2101
+ topic: options.topic,
2102
+ appSlug: options.appSlug,
2103
+ includeRelativeImageExample: options.includeRelativeImageExample,
2104
+ relativeImagePath: options.relativeImagePath
2105
+ });
2106
+ return `${metaBlock}
2107
+ ${body}`;
2108
+ }
2109
+ function resolveTargetLocales(input, defaultLocale = "en-US") {
2110
+ if (input.locales?.length) {
2111
+ const locales = input.locales.map((loc) => loc.trim()).filter(Boolean);
2112
+ return Array.from(new Set(locales));
2113
+ }
2114
+ const fallback = input.locale?.trim() || defaultLocale;
2115
+ return fallback ? [fallback] : [];
2116
+ }
2117
+ function getBlogOutputPaths(options) {
2118
+ const baseDir = path7.join(
2119
+ options.publicDir,
2120
+ BLOG_ROOT,
2121
+ options.appSlug,
2122
+ options.slug
2123
+ );
2124
+ const filePath = path7.join(baseDir, `${options.locale}.html`);
2125
+ const publicBasePath = toPublicBlogBase(options.appSlug, options.slug);
2126
+ return { baseDir, filePath, publicBasePath };
2127
+ }
2128
+
2129
+ // src/tools/create-blog-html.ts
2130
+ var toJsonSchema4 = zodToJsonSchema5;
2131
+ var DATE_REGEX2 = /^\d{4}-\d{2}-\d{2}$/;
2132
+ var createBlogHtmlInputSchema = z5.object({
2133
+ appSlug: z5.string().trim().min(1, "appSlug is required").describe("Product/app slug used for paths and CTAs"),
2134
+ title: z5.string().trim().optional().describe(
2135
+ "English title used for slug (kebab-case). Falls back to topic when omitted."
2136
+ ),
2137
+ topic: z5.string().trim().min(1, "topic is required").describe("Topic/angle to write about in the blog body"),
2138
+ locale: z5.string().trim().optional().default("en-US").describe(
2139
+ "Primary locale (default en-US). Ignored when locales[] is set."
2140
+ ),
2141
+ locales: z5.array(z5.string().trim().min(1)).optional().describe(
2142
+ "Optional list of locales to generate. Each locale gets its own HTML file."
2143
+ ),
2144
+ description: z5.string().trim().optional().describe(
2145
+ "Meta description override. If omitted, the tool generates one from appSlug/topic per locale."
2146
+ ),
2147
+ tags: z5.array(z5.string().trim().min(1)).optional().describe(
2148
+ "Optional tags for BLOG_META. Defaults to tags derived from topic."
2149
+ ),
2150
+ coverImage: z5.string().trim().optional().describe(
2151
+ "Cover image path. Relative paths rewrite to /blogs/<app>/<slug>/..., default is /products/<appSlug>/og-image.png."
2152
+ ),
2153
+ includeRelativeImageExample: z5.boolean().optional().default(false).describe(
2154
+ "Inject a relative image example (./images/hero.png) into the body to demonstrate path rewriting."
2155
+ ),
2156
+ relativeImagePath: z5.string().trim().optional().describe(
2157
+ "Override the relative image path (default ./images/hero.png)."
2158
+ ),
2159
+ publishedAt: z5.string().trim().regex(DATE_REGEX2, "publishedAt must use YYYY-MM-DD").optional().describe("Publish date (YYYY-MM-DD). Defaults to today."),
2160
+ modifiedAt: z5.string().trim().regex(DATE_REGEX2, "modifiedAt must use YYYY-MM-DD").optional().describe("Last modified date (YYYY-MM-DD). Defaults to publishedAt."),
2161
+ overwrite: z5.boolean().optional().default(false).describe("Overwrite existing files when true (default: false).")
2162
+ }).describe("Generate static HTML blog posts with BLOG_META headers.");
2163
+ var jsonSchema5 = toJsonSchema4(createBlogHtmlInputSchema, {
2164
+ name: "CreateBlogHtmlInput",
2165
+ $refStrategy: "none"
2166
+ });
2167
+ var inputSchema5 = jsonSchema5.definitions?.CreateBlogHtmlInput || jsonSchema5;
2168
+ var createBlogHtmlTool = {
2169
+ name: "create-blog-html",
2170
+ description: `Generate HTML blog posts under public/blogs/<appSlug>/<slug>/<locale>.html with a BLOG_META block.
2171
+
2172
+ Slug rules:
2173
+ - slug = slugify(English title, kebab-case ASCII)
2174
+ - path: public/blogs/<appSlug>/<slug>/<locale>.html
2175
+ - coverImage default: /products/<appSlug>/og-image.png (relative paths are rewritten under /blogs/<app>/<slug>/)
2176
+ - overwrite defaults to false (throws when file exists)
2177
+
2178
+ Template:
2179
+ - Intro connecting topic/app
2180
+ - 3-4 sections (problem \u2192 solution \u2192 tips/examples) using h2/h3
2181
+ - Optional relative image example (./images/hero.png)
2182
+ - Conclusion + CTA linking to /products/<appSlug>
2183
+
2184
+ Supports multiple locales when locales[] is provided (default single locale). Content language follows locale (ko -> Korean, otherwise English).`,
2185
+ inputSchema: inputSchema5
2186
+ };
2187
+ async function handleCreateBlogHtml(input) {
2188
+ const publicDir = getPublicDir();
2189
+ const {
2190
+ appSlug,
2191
+ topic,
2192
+ title,
2193
+ description,
2194
+ tags,
2195
+ coverImage,
2196
+ includeRelativeImageExample = false,
2197
+ relativeImagePath,
2198
+ publishedAt,
2199
+ modifiedAt,
2200
+ overwrite = false
2201
+ } = input;
2202
+ const resolvedTitle = title && title.trim() || topic.trim();
2203
+ const slug = slugifyTitle(resolvedTitle);
2204
+ const targetLocales = resolveTargetLocales(input);
2205
+ if (!targetLocales.length) {
2206
+ throw new Error("At least one locale is required to generate blog HTML.");
2207
+ }
2208
+ const shouldIncludeRelativeImage = includeRelativeImageExample || Boolean(relativeImagePath);
2209
+ const relativeImage = shouldIncludeRelativeImage ? resolveRelativeImagePath(appSlug, slug, relativeImagePath) : void 0;
2210
+ const output = {
2211
+ slug,
2212
+ baseDir: path8.join(publicDir, "blogs", appSlug, slug),
2213
+ files: [],
2214
+ coverImage: coverImage && coverImage.trim().length > 0 ? coverImage.trim() : `/products/${appSlug}/og-image.png`,
2215
+ metaByLocale: {}
2216
+ };
2217
+ const plannedFiles = targetLocales.map(
2218
+ (locale) => getBlogOutputPaths({
2219
+ appSlug,
2220
+ slug,
2221
+ locale,
2222
+ publicDir
2223
+ })
2224
+ );
2225
+ const existing = plannedFiles.filter(
2226
+ ({ filePath }) => fs7.existsSync(filePath)
2227
+ );
2228
+ if (existing.length > 0 && !overwrite) {
2229
+ const existingList = existing.map((f) => f.filePath).join("\n- ");
2230
+ throw new Error(
2231
+ `Blog HTML already exists. Set overwrite=true to replace:
2232
+ - ${existingList}`
2233
+ );
2234
+ }
2235
+ fs7.mkdirSync(output.baseDir, { recursive: true });
2236
+ for (const locale of targetLocales) {
2237
+ const { filePath } = getBlogOutputPaths({
2238
+ appSlug,
2239
+ slug,
2240
+ locale,
2241
+ publicDir
2242
+ });
2243
+ const meta = buildBlogMeta({
2244
+ title: resolvedTitle,
2245
+ description,
2246
+ appSlug,
2247
+ slug,
2248
+ locale,
2249
+ topic,
2250
+ coverImage,
2251
+ tags,
2252
+ publishedAt,
2253
+ modifiedAt
2254
+ });
2255
+ output.coverImage = meta.coverImage;
2256
+ output.metaByLocale[locale] = meta;
2257
+ const html = buildBlogHtmlDocument({
2258
+ meta,
2259
+ topic,
2260
+ appSlug,
2261
+ includeRelativeImageExample: shouldIncludeRelativeImage,
2262
+ relativeImagePath: relativeImage
2263
+ });
2264
+ fs7.writeFileSync(filePath, html, "utf-8");
2265
+ output.files.push({ locale, path: filePath });
2266
+ }
2267
+ const summaryLines = [
2268
+ `Created blog HTML for ${appSlug}`,
2269
+ `Slug: ${slug}`,
2270
+ `Locales: ${targetLocales.join(", ")}`,
2271
+ `Cover image: ${output.coverImage}`,
2272
+ "",
2273
+ "Files:",
2274
+ ...output.files.map((file) => `- ${file.locale}: ${file.path}`)
2275
+ ];
2276
+ return {
2277
+ content: [
2278
+ {
2279
+ type: "text",
2280
+ text: summaryLines.join("\n")
2281
+ }
2282
+ ]
2283
+ };
2284
+ }
2285
+
1892
2286
  // src/tools/index.ts
1893
2287
  var tools = [
1894
2288
  {
@@ -1922,6 +2316,14 @@ var tools = [
1922
2316
  zodSchema: initProjectInputSchema,
1923
2317
  handler: handleInitProject,
1924
2318
  category: "Setup"
2319
+ },
2320
+ {
2321
+ name: createBlogHtmlTool.name,
2322
+ description: createBlogHtmlTool.description,
2323
+ inputSchema: createBlogHtmlTool.inputSchema,
2324
+ zodSchema: createBlogHtmlInputSchema,
2325
+ handler: handleCreateBlogHtml,
2326
+ category: "Content"
1925
2327
  }
1926
2328
  ];
1927
2329
  function getToolDefinitions() {
@@ -1929,7 +2331,8 @@ function getToolDefinitions() {
1929
2331
  asoToPublicTool,
1930
2332
  publicToAsoTool,
1931
2333
  improvePublicTool,
1932
- initProjectTool
2334
+ initProjectTool,
2335
+ createBlogHtmlTool
1933
2336
  ];
1934
2337
  }
1935
2338
  function getToolHandler(name) {
package/dist/index.d.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { androidpublisher_v3 } from '@googleapis/androidpublisher';
2
+ import { AppStoreVersionAttributes, AppStoreVersionLocalizationAttributes, AppInfoLocalizationAttributes, ScreenshotDisplayType } from 'appstore-connect-sdk/openapi';
3
+
1
4
  /**
2
5
  * Unified locale system for ASO (App Store Optimization)
3
6
  * Consolidates App Store Connect and Google Play Console locale codes
@@ -70,21 +73,32 @@ declare function isAppStoreLocale(locale: string): locale is AppStoreLocale;
70
73
  * Check if locale is supported by Google Play
71
74
  */
72
75
  declare function isGooglePlayLocale(locale: string): locale is GooglePlayLocale;
76
+ /**
77
+ * Google Play Android Publisher base types
78
+ */
79
+ type GooglePlayListing = androidpublisher_v3.Schema$Listing;
80
+ type GooglePlayImageType = NonNullable<androidpublisher_v3.Params$Resource$Edits$Images$List["imageType"]>;
81
+ type GooglePlayScreenshotType = Extract<GooglePlayImageType, "phoneScreenshots" | "sevenInchScreenshots" | "tenInchScreenshots" | "tvScreenshots" | "wearScreenshots">;
82
+ /**
83
+ * Google Play screenshots keyed by Android Publisher imageType
84
+ * Includes legacy aliases for existing data structures
85
+ */
86
+ interface GooglePlayScreenshots extends Partial<Record<GooglePlayScreenshotType, string[]>> {
87
+ phone?: string[];
88
+ tablet?: string[];
89
+ tablet7?: string[];
90
+ tablet10?: string[];
91
+ tv?: string[];
92
+ wear?: string[];
93
+ }
73
94
  /**
74
95
  * Google Play Store ASO data
75
96
  */
76
- interface GooglePlayAsoData {
77
- title: string;
78
- shortDescription: string;
79
- fullDescription: string;
80
- screenshots: {
81
- phone: string[];
82
- tablet?: string[];
83
- tablet7?: string[];
84
- tablet10?: string[];
85
- tv?: string[];
86
- wear?: string[];
87
- };
97
+ interface GooglePlayAsoData extends Pick<GooglePlayListing, "video" | "language"> {
98
+ title: NonNullable<GooglePlayListing["title"]>;
99
+ shortDescription: NonNullable<GooglePlayListing["shortDescription"]>;
100
+ fullDescription: NonNullable<GooglePlayListing["fullDescription"]>;
101
+ screenshots: GooglePlayScreenshots;
88
102
  featureGraphic?: string;
89
103
  promoGraphic?: string;
90
104
  category?: string;
@@ -99,59 +113,57 @@ interface GooglePlayAsoData {
99
113
  /**
100
114
  * Google Play release notes (per version)
101
115
  */
102
- interface GooglePlayReleaseNote {
103
- versionCode: number;
104
- versionName: string;
105
- releaseNotes: {
106
- [language: string]: string;
107
- };
108
- track: string;
109
- status: string;
110
- releaseDate?: string;
111
- }
116
+ type GooglePlayReleaseNote = androidpublisher_v3.Schema$TrackRelease;
112
117
  /**
113
118
  * App Store release notes (per version)
114
119
  */
115
- interface AppStoreReleaseNote {
116
- versionString: string;
117
- releaseNotes: {
118
- [locale: string]: string;
119
- };
120
- platform: string;
120
+ type AppStoreReleaseNote = Pick<AppStoreVersionAttributes, "versionString" | "platform"> & {
121
+ releaseNotes: Record<string, NonNullable<AppStoreVersionLocalizationAttributes["whatsNew"]>>;
121
122
  releaseDate?: string;
123
+ };
124
+ /**
125
+ * App Store Connect base types
126
+ */
127
+ type AppStoreInfoLocalization = AppInfoLocalizationAttributes;
128
+ type AppStoreVersionLocalization = AppStoreVersionLocalizationAttributes;
129
+ type AppStoreScreenshotDisplayType = ScreenshotDisplayType;
130
+ /**
131
+ * App Store screenshots keyed by official display type
132
+ * Includes legacy aliases used in existing data structures
133
+ */
134
+ interface AppStoreScreenshots extends Partial<Record<AppStoreScreenshotDisplayType, string[]>> {
135
+ iphone65?: string[];
136
+ iphone61?: string[];
137
+ iphone58?: string[];
138
+ iphone55?: string[];
139
+ iphone47?: string[];
140
+ iphone40?: string[];
141
+ ipadPro129?: string[];
142
+ ipadPro11?: string[];
143
+ ipad105?: string[];
144
+ ipad97?: string[];
145
+ appleWatch?: string[];
122
146
  }
123
147
  /**
124
148
  * App Store ASO data
125
149
  */
126
150
  interface AppStoreAsoData {
127
- name: string;
128
- subtitle?: string;
129
- description: string;
130
- keywords?: string;
131
- promotionalText?: string;
132
- screenshots: {
133
- iphone65?: string[];
134
- iphone61?: string[];
135
- iphone58?: string[];
136
- iphone55?: string[];
137
- iphone47?: string[];
138
- iphone40?: string[];
139
- ipadPro129?: string[];
140
- ipadPro11?: string[];
141
- ipad105?: string[];
142
- ipad97?: string[];
143
- appleWatch?: string[];
144
- };
151
+ name: NonNullable<AppStoreInfoLocalization["name"]>;
152
+ subtitle?: AppStoreInfoLocalization["subtitle"];
153
+ description: NonNullable<AppStoreVersionLocalization["description"]>;
154
+ keywords?: AppStoreVersionLocalization["keywords"];
155
+ promotionalText?: AppStoreVersionLocalization["promotionalText"];
156
+ screenshots: AppStoreScreenshots;
145
157
  appPreview?: string[];
146
158
  primaryCategory?: string;
147
159
  secondaryCategory?: string;
148
160
  contentRightId?: string;
149
- supportUrl?: string;
150
- marketingUrl?: string;
151
- privacyPolicyUrl?: string;
161
+ supportUrl?: AppStoreVersionLocalization["supportUrl"];
162
+ marketingUrl?: AppStoreVersionLocalization["marketingUrl"];
163
+ privacyPolicyUrl?: AppStoreInfoLocalization["privacyPolicyUrl"];
152
164
  bundleId: string;
153
- locale: string;
154
- whatsNew?: string;
165
+ locale: NonNullable<AppStoreVersionLocalization["locale"]>;
166
+ whatsNew?: AppStoreVersionLocalization["whatsNew"];
155
167
  }
156
168
  /**
157
169
  * Multilingual Google Play ASO data
@@ -192,6 +204,61 @@ declare function isGooglePlayMultilingual(data: GooglePlayAsoData | GooglePlayMu
192
204
  */
193
205
  declare function isAppStoreMultilingual(data: AppStoreAsoData | AppStoreMultilingualAsoData | undefined): data is AppStoreMultilingualAsoData;
194
206
 
207
+ /**
208
+ * Blog type definitions
209
+ * Used by both MCP tools and web application
210
+ */
211
+ /**
212
+ * Blog Meta Block (raw from HTML comment)
213
+ * All fields are optional as they may be parsed from HTML or defaults
214
+ */
215
+ interface BlogMetaBlock {
216
+ title: string;
217
+ description: string;
218
+ appSlug: string;
219
+ slug: string;
220
+ locale?: string;
221
+ coverImage?: string;
222
+ publishedAt?: string;
223
+ modifiedAt?: string;
224
+ tags?: string[];
225
+ }
226
+ /**
227
+ * Blog Meta (resolved with UnifiedLocale)
228
+ * Used by web application after normalization
229
+ */
230
+ interface BlogMeta extends BlogMetaBlock {
231
+ locale: UnifiedLocale;
232
+ coverImage?: string;
233
+ publishedAt?: string;
234
+ modifiedAt?: string;
235
+ }
236
+ /**
237
+ * Blog Article (complete parsed article with HTML content)
238
+ */
239
+ interface BlogArticle {
240
+ appSlug: string;
241
+ slug: string;
242
+ locale: UnifiedLocale;
243
+ html: string;
244
+ meta: BlogMeta;
245
+ filePath: string;
246
+ }
247
+ /**
248
+ * Blog Summary (aggregated metadata across locales)
249
+ */
250
+ interface BlogSummary {
251
+ appSlug: string;
252
+ slug: string;
253
+ title: string;
254
+ description: string;
255
+ coverImage?: string;
256
+ publishedAt?: string;
257
+ modifiedAt?: string;
258
+ locales: UnifiedLocale[];
259
+ defaultLocale: UnifiedLocale;
260
+ }
261
+
195
262
  interface ImageAsset {
196
263
  src: string;
197
264
  alt: string;
@@ -354,6 +421,136 @@ interface ProductLocale {
354
421
  landing?: LandingPageLocale;
355
422
  }
356
423
 
424
+ /**
425
+ * Site data type definitions
426
+ * Structure for public/site/ directory
427
+ * - contacts.json: Contact information
428
+ * - locales/en-US.json: Localized content (meta, hero, developer)
429
+ */
430
+ interface SiteData {
431
+ meta: {
432
+ title: string;
433
+ description: string;
434
+ };
435
+ hero: {
436
+ brand: string;
437
+ tagline: string;
438
+ subtitle: string;
439
+ };
440
+ contacts: {
441
+ id: string;
442
+ label: string;
443
+ icon: string;
444
+ value: string;
445
+ }[];
446
+ developer: {
447
+ name: string;
448
+ heading: string;
449
+ role: string;
450
+ bio: string;
451
+ principles: string[];
452
+ stack: string[];
453
+ now: string;
454
+ availability: string;
455
+ likes: string[];
456
+ location?: string;
457
+ timezone?: string;
458
+ visited: {
459
+ countries: {
460
+ code: string;
461
+ name: string;
462
+ flagEmoji: string;
463
+ cities: string[];
464
+ }[];
465
+ };
466
+ };
467
+ }
468
+
469
+ /**
470
+ * Types for the create-blog-html MCP tool
471
+ */
472
+ /**
473
+ * BlogMetaOutput for MCP tool output
474
+ * All fields are required (resolved values)
475
+ * Note: This is separate from blog.types.ts BlogMeta which uses UnifiedLocale
476
+ */
477
+ interface BlogMetaOutput {
478
+ title: string;
479
+ description: string;
480
+ appSlug: string;
481
+ slug: string;
482
+ locale: string;
483
+ publishedAt: string;
484
+ modifiedAt: string;
485
+ coverImage: string;
486
+ tags: string[];
487
+ }
488
+ interface CreateBlogHtmlInput {
489
+ /**
490
+ * Product/app slug used for paths and CTAs
491
+ */
492
+ appSlug: string;
493
+ /**
494
+ * English title used for slug creation and H1
495
+ */
496
+ title?: string;
497
+ /**
498
+ * Topic/angle to cover inside the article body
499
+ */
500
+ topic: string;
501
+ /**
502
+ * Single locale to generate (default en-US). Ignored when locales[] is provided.
503
+ */
504
+ locale?: string;
505
+ /**
506
+ * Optional list of locales to generate. Each gets its own HTML file.
507
+ */
508
+ locales?: string[];
509
+ /**
510
+ * Meta description override. If absent, a locale-aware summary is generated from topic/appSlug.
511
+ */
512
+ description?: string;
513
+ /**
514
+ * Optional tags for BLOG_META. If absent, tags are derived from topic and appSlug.
515
+ */
516
+ tags?: string[];
517
+ /**
518
+ * Optional cover image. Relative paths are rewritten to /blogs/<app>/<slug>/...
519
+ */
520
+ coverImage?: string;
521
+ /**
522
+ * Include a relative image example in the body (./images/hero.png by default).
523
+ */
524
+ includeRelativeImageExample?: boolean;
525
+ /**
526
+ * Override the relative image path used when includeRelativeImageExample is true.
527
+ */
528
+ relativeImagePath?: string;
529
+ /**
530
+ * Publish date (YYYY-MM-DD). Defaults to today.
531
+ */
532
+ publishedAt?: string;
533
+ /**
534
+ * Last modified date (YYYY-MM-DD). Defaults to publishedAt.
535
+ */
536
+ modifiedAt?: string;
537
+ /**
538
+ * Overwrite existing HTML files when true (default false).
539
+ */
540
+ overwrite?: boolean;
541
+ }
542
+ interface GeneratedBlogFile {
543
+ locale: string;
544
+ path: string;
545
+ }
546
+ interface CreateBlogHtmlResult {
547
+ slug: string;
548
+ baseDir: string;
549
+ files: GeneratedBlogFile[];
550
+ coverImage: string;
551
+ metaByLocale: Record<string, BlogMetaOutput>;
552
+ }
553
+
357
554
  /**
358
555
  * Locale conversion utilities for ASO platforms
359
556
  * Handles conversion between unified locales and platform-specific locale codes
@@ -609,4 +806,4 @@ declare function getPublicDir(): string;
609
806
  */
610
807
  declare function getProductsDir(): string;
611
808
 
612
- export { APP_STORE_TO_UNIFIED, type AppMetaLinks, type AppPageData, type AppStoreAsoData, type AppStoreLocale, type AppStoreMultilingualAsoData, type AppStoreReleaseNote, type AsoData, type AsoLocaleContent, type AsoTemplate, DEFAULT_LOCALE, type DeepPartial, type FeatureItem, GOOGLE_PLAY_TO_UNIFIED, type GooglePlayAsoData, type GooglePlayLocale, type GooglePlayMultilingualAsoData, type GooglePlayReleaseNote, type ImageAsset, type LandingCta, type LandingFeatures, type LandingHero, type LandingPage, type LandingPageLocale, type LandingReviews, type LandingScreenshots, type LayoutColors, type ProductConfig, type ProductContent, type ProductLocale, type ProductMetadata, type ProductScreenshots, type SupportedLocale, type Testimonial, UNIFIED_LOCALES, UNIFIED_TO_APP_STORE, UNIFIED_TO_GOOGLE_PLAY, type UnifiedLocale, appStoreToGooglePlay, appStoreToUnified, appStoreToUnifiedBatch, convertObjectFromAppStore, convertObjectFromGooglePlay, convertObjectToAppStore, convertObjectToGooglePlay, getAsoDataDir, getProductsDir, getPublicDir, getPullDataDir, getPushDataDir, googlePlayToAppStore, googlePlayToUnified, googlePlayToUnifiedBatch, isAppStoreLocale, isAppStoreMultilingual, isGooglePlayLocale, isGooglePlayMultilingual, isSupportedLocale, loadAsoFromConfig, saveAsoToAsoDir, saveAsoToConfig, unifiedToAppStore, unifiedToAppStoreBatch, unifiedToBothPlatforms, unifiedToGooglePlay, unifiedToGooglePlayBatch };
809
+ export { APP_STORE_TO_UNIFIED, type AppMetaLinks, type AppPageData, type AppStoreAsoData, type AppStoreInfoLocalization, type AppStoreLocale, type AppStoreMultilingualAsoData, type AppStoreReleaseNote, type AppStoreScreenshotDisplayType, type AppStoreScreenshots, type AppStoreVersionLocalization, type AsoData, type AsoLocaleContent, type AsoTemplate, type BlogArticle, type BlogMeta, type BlogMetaBlock, type BlogMetaOutput, type BlogSummary, type CreateBlogHtmlInput, type CreateBlogHtmlResult, DEFAULT_LOCALE, type DeepPartial, type FeatureItem, GOOGLE_PLAY_TO_UNIFIED, type GeneratedBlogFile, type GooglePlayAsoData, type GooglePlayImageType, type GooglePlayListing, type GooglePlayLocale, type GooglePlayMultilingualAsoData, type GooglePlayReleaseNote, type GooglePlayScreenshotType, type GooglePlayScreenshots, type ImageAsset, type LandingCta, type LandingFeatures, type LandingHero, type LandingPage, type LandingPageLocale, type LandingReviews, type LandingScreenshots, type LayoutColors, type ProductConfig, type ProductContent, type ProductLocale, type ProductMetadata, type ProductScreenshots, type SiteData, type SupportedLocale, type Testimonial, UNIFIED_LOCALES, UNIFIED_TO_APP_STORE, UNIFIED_TO_GOOGLE_PLAY, type UnifiedLocale, appStoreToGooglePlay, appStoreToUnified, appStoreToUnifiedBatch, convertObjectFromAppStore, convertObjectFromGooglePlay, convertObjectToAppStore, convertObjectToGooglePlay, getAsoDataDir, getProductsDir, getPublicDir, getPullDataDir, getPushDataDir, googlePlayToAppStore, googlePlayToUnified, googlePlayToUnifiedBatch, isAppStoreLocale, isAppStoreMultilingual, isGooglePlayLocale, isGooglePlayMultilingual, isSupportedLocale, loadAsoFromConfig, saveAsoToAsoDir, saveAsoToConfig, unifiedToAppStore, unifiedToAppStoreBatch, unifiedToBothPlatforms, unifiedToGooglePlay, unifiedToGooglePlayBatch };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pabal-web-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "type": "module",
5
5
  "description": "MCP server for ASO data management with shared types and utilities",
6
6
  "author": "skyu",
@@ -39,7 +39,9 @@
39
39
  "prepublishOnly": "npm run build"
40
40
  },
41
41
  "dependencies": {
42
+ "@googleapis/androidpublisher": "^33.2.0",
42
43
  "@modelcontextprotocol/sdk": "^1.22.0",
44
+ "appstore-connect-sdk": "^1.3.2",
43
45
  "zod": "^3.25.76",
44
46
  "zod-to-json-schema": "^3.25.0"
45
47
  },