pabal-web-mcp 1.0.0 → 1.1.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.
@@ -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)) {
@@ -1889,6 +1890,382 @@ async function handleInitProject(input) {
1889
1890
  };
1890
1891
  }
1891
1892
 
1893
+ // src/tools/create-blog-html.ts
1894
+ import fs7 from "fs";
1895
+ import path8 from "path";
1896
+ import { z as z5 } from "zod";
1897
+ import { zodToJsonSchema as zodToJsonSchema5 } from "zod-to-json-schema";
1898
+
1899
+ // src/utils/blog.util.ts
1900
+ import path7 from "path";
1901
+ var DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
1902
+ var BLOG_ROOT = "blogs";
1903
+ var removeDiacritics = (value) => value.normalize("NFKD").replace(/[\u0300-\u036f]/g, "");
1904
+ var compact = (items) => (items || []).filter((item) => Boolean(item && item.trim()));
1905
+ function slugifyTitle(title) {
1906
+ const normalized = removeDiacritics(title).toLowerCase().replace(/[^a-z0-9\s-]/g, " ").replace(/[_\s]+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
1907
+ return normalized || "post";
1908
+ }
1909
+ function normalizeDate(date) {
1910
+ if (date) {
1911
+ if (!DATE_REGEX.test(date)) {
1912
+ throw new Error(
1913
+ `Invalid date format "${date}". Use YYYY-MM-DD (e.g. 2024-09-30).`
1914
+ );
1915
+ }
1916
+ return date;
1917
+ }
1918
+ return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
1919
+ }
1920
+ var isKoreanLocale = (locale) => locale.trim().toLowerCase().startsWith("ko");
1921
+ var toPublicBlogBase = (appSlug, slug) => `/${BLOG_ROOT}/${appSlug}/${slug}`;
1922
+ function resolveCoverImagePath(appSlug, slug, coverImage) {
1923
+ if (!coverImage || !coverImage.trim()) {
1924
+ return `/products/${appSlug}/og-image.png`;
1925
+ }
1926
+ const cleaned = coverImage.trim();
1927
+ const relativePath = cleaned.replace(/^\.\//, "");
1928
+ if (!cleaned.startsWith("/") && !/^https?:\/\//.test(cleaned)) {
1929
+ return `${toPublicBlogBase(appSlug, slug)}/${relativePath}`;
1930
+ }
1931
+ if (cleaned.startsWith("./")) {
1932
+ return `${toPublicBlogBase(appSlug, slug)}/${relativePath}`;
1933
+ }
1934
+ return cleaned;
1935
+ }
1936
+ function resolveRelativeImagePath(appSlug, slug, relativePath) {
1937
+ const raw = relativePath?.trim() || "./images/hero.png";
1938
+ const normalized = raw.replace(/^\.\//, "");
1939
+ return {
1940
+ raw,
1941
+ absolute: `${toPublicBlogBase(appSlug, slug)}/${normalized}`
1942
+ };
1943
+ }
1944
+ function buildDescription(locale, topic, appSlug) {
1945
+ if (isKoreanLocale(locale)) {
1946
+ 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.`;
1947
+ }
1948
+ return `How ${appSlug} teams turn "${topic}" into a bridge between ASO pages and SEO blogs without losing consistency.`;
1949
+ }
1950
+ function deriveTags(topic, appSlug) {
1951
+ const topicParts = topic.toLowerCase().split(/[^a-z0-9+]+/).filter(Boolean).slice(0, 6);
1952
+ const set = /* @__PURE__ */ new Set([...topicParts, appSlug.toLowerCase(), "blog"]);
1953
+ return Array.from(set);
1954
+ }
1955
+ function buildBlogMeta(options) {
1956
+ const publishedAt = normalizeDate(options.publishedAt);
1957
+ const modifiedAt = normalizeDate(options.modifiedAt || publishedAt);
1958
+ const coverImage = resolveCoverImagePath(
1959
+ options.appSlug,
1960
+ options.slug,
1961
+ options.coverImage
1962
+ );
1963
+ return {
1964
+ title: options.title,
1965
+ description: options.description || buildDescription(options.locale, options.topic, options.appSlug),
1966
+ appSlug: options.appSlug,
1967
+ slug: options.slug,
1968
+ locale: options.locale,
1969
+ publishedAt,
1970
+ modifiedAt,
1971
+ coverImage,
1972
+ tags: compact(options.tags)?.length ? Array.from(
1973
+ new Set(compact(options.tags).map((tag) => tag.toLowerCase()))
1974
+ ) : deriveTags(options.topic, options.appSlug)
1975
+ };
1976
+ }
1977
+ function renderBlogMetaBlock(meta) {
1978
+ const serialized = JSON.stringify(meta, null, 2);
1979
+ return `<!--BLOG_META
1980
+ ${serialized}
1981
+ -->`;
1982
+ }
1983
+ function renderEnglishBody(args) {
1984
+ const { meta, topic, appSlug, includeRelativeImageExample, relativeImagePath } = args;
1985
+ const lines = [];
1986
+ lines.push(`<h1>${meta.title}</h1>`);
1987
+ lines.push(
1988
+ `<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>`
1989
+ );
1990
+ if (includeRelativeImageExample && relativeImagePath) {
1991
+ lines.push(
1992
+ `<img src="${relativeImagePath.raw}" alt="${appSlug} ${topic} cover" />`
1993
+ );
1994
+ lines.push(
1995
+ `<p>The image above is stored next to this file and resolves to <code>${relativeImagePath.absolute}</code> when published.</p>`
1996
+ );
1997
+ }
1998
+ lines.push(`<h2>Why the gap appears</h2>`);
1999
+ lines.push(
2000
+ `<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>`
2001
+ );
2002
+ lines.push(`<h3>Signals that drift</h3>`);
2003
+ lines.push(
2004
+ `<p>Different headlines, mismatched screenshots, and stale dates make ranking harder. "${topic}" is a strong bridge topic because it touches both acquisition paths.</p>`
2005
+ );
2006
+ lines.push(`<h2>How to bridge with ${appSlug}</h2>`);
2007
+ lines.push(
2008
+ `<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>`
2009
+ );
2010
+ lines.push(`<h3>Mini playbook</h3>`);
2011
+ lines.push(
2012
+ `<ul>
2013
+ <li>Reuse the app store hero claim inside the intro.</li>
2014
+ <li>Map ASO keywords to SEO phrases for the "${topic}" angle.</li>
2015
+ <li>Link feature blurbs to product screenshots and changelog notes.</li>
2016
+ <li>Close with a CTA back to <code>/products/${appSlug}</code>.</li>
2017
+ </ul>`
2018
+ );
2019
+ lines.push(`<h2>Example flow to copy</h2>`);
2020
+ lines.push(
2021
+ `<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>`
2022
+ );
2023
+ lines.push(`<h2>Wrap up</h2>`);
2024
+ lines.push(
2025
+ `<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>`
2026
+ );
2027
+ lines.push(
2028
+ `<p><strong>CTA:</strong> Explore the product page at <a href="/products/${appSlug}">/products/${appSlug}</a>.</p>`
2029
+ );
2030
+ return lines.join("\n\n");
2031
+ }
2032
+ function renderKoreanBody(args) {
2033
+ const { meta, topic, appSlug, includeRelativeImageExample, relativeImagePath } = args;
2034
+ const lines = [];
2035
+ lines.push(`<h1>${meta.title}</h1>`);
2036
+ lines.push(
2037
+ `<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>`
2038
+ );
2039
+ if (includeRelativeImageExample && relativeImagePath) {
2040
+ lines.push(
2041
+ `<img src="${relativeImagePath.raw}" alt="${appSlug} ${topic} \uD45C\uC9C0 \uC774\uBBF8\uC9C0" />`
2042
+ );
2043
+ lines.push(
2044
+ `<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>`
2045
+ );
2046
+ }
2047
+ lines.push(`<h2>ASO\uC640 SEO\uAC00 \uAC08\uB77C\uC9C0\uB294 \uC9C0\uC810</h2>`);
2048
+ lines.push(
2049
+ `<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>`
2050
+ );
2051
+ lines.push(`<h3>\uD754\uD788 \uB193\uCE58\uB294 \uC2E0\uD638</h3>`);
2052
+ lines.push(
2053
+ `<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>`
2054
+ );
2055
+ lines.push(`<h2>${appSlug}\uB85C \uB2E4\uB9AC \uB193\uAE30</h2>`);
2056
+ lines.push(
2057
+ `<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>`
2058
+ );
2059
+ lines.push(`<h3>\uC801\uC6A9 \uCCB4\uD06C\uB9AC\uC2A4\uD2B8</h3>`);
2060
+ lines.push(
2061
+ `<ul>
2062
+ <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>
2063
+ <li>"${topic}"\uB97C \uC704\uD55C SEO \uD0A4\uC6CC\uB4DC\uB97C ASO \uD0A4\uC6CC\uB4DC\uC640 \uB9E4\uD551\uD558\uAE30</li>
2064
+ <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>
2065
+ <li>\uB9C8\uC9C0\uB9C9 \uBB38\uB2E8\uC5D0\uC11C <code>/products/${appSlug}</code>\uB85C \uC790\uC5F0\uC2A4\uB7EC\uC6B4 CTA \uBC30\uCE58</li>
2066
+ </ul>`
2067
+ );
2068
+ lines.push(`<h2>\uC0AC\uB840 \uD750\uB984 \uC608\uC2DC</h2>`);
2069
+ lines.push(
2070
+ `<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>`
2071
+ );
2072
+ lines.push(`<h2>\uB9C8\uBB34\uB9AC</h2>`);
2073
+ lines.push(
2074
+ `<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>`
2075
+ );
2076
+ lines.push(
2077
+ `<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>`
2078
+ );
2079
+ return lines.join("\n\n");
2080
+ }
2081
+ function renderBlogBody(options) {
2082
+ if (isKoreanLocale(options.meta.locale)) {
2083
+ return renderKoreanBody(options);
2084
+ }
2085
+ return renderEnglishBody(options);
2086
+ }
2087
+ function buildBlogHtmlDocument(options) {
2088
+ const metaBlock = renderBlogMetaBlock(options.meta);
2089
+ const body = renderBlogBody({
2090
+ meta: options.meta,
2091
+ topic: options.topic,
2092
+ appSlug: options.appSlug,
2093
+ includeRelativeImageExample: options.includeRelativeImageExample,
2094
+ relativeImagePath: options.relativeImagePath
2095
+ });
2096
+ return `${metaBlock}
2097
+ ${body}`;
2098
+ }
2099
+ function resolveTargetLocales(input, defaultLocale = "en-US") {
2100
+ if (input.locales?.length) {
2101
+ const locales = input.locales.map((loc) => loc.trim()).filter(Boolean);
2102
+ return Array.from(new Set(locales));
2103
+ }
2104
+ const fallback = input.locale?.trim() || defaultLocale;
2105
+ return fallback ? [fallback] : [];
2106
+ }
2107
+ function getBlogOutputPaths(options) {
2108
+ const baseDir = path7.join(
2109
+ options.publicDir,
2110
+ BLOG_ROOT,
2111
+ options.appSlug,
2112
+ options.slug
2113
+ );
2114
+ const filePath = path7.join(baseDir, `${options.locale}.html`);
2115
+ const publicBasePath = toPublicBlogBase(options.appSlug, options.slug);
2116
+ return { baseDir, filePath, publicBasePath };
2117
+ }
2118
+
2119
+ // src/tools/create-blog-html.ts
2120
+ var toJsonSchema4 = zodToJsonSchema5;
2121
+ var DATE_REGEX2 = /^\d{4}-\d{2}-\d{2}$/;
2122
+ var createBlogHtmlInputSchema = z5.object({
2123
+ appSlug: z5.string().trim().min(1, "appSlug is required").describe("Product/app slug used for paths and CTAs"),
2124
+ title: z5.string().trim().optional().describe(
2125
+ "English title used for slug (kebab-case). Falls back to topic when omitted."
2126
+ ),
2127
+ topic: z5.string().trim().min(1, "topic is required").describe("Topic/angle to write about in the blog body"),
2128
+ locale: z5.string().trim().optional().default("en-US").describe("Primary locale (default en-US). Ignored when locales[] is set."),
2129
+ locales: z5.array(z5.string().trim().min(1)).optional().describe(
2130
+ "Optional list of locales to generate. Each locale gets its own HTML file."
2131
+ ),
2132
+ description: z5.string().trim().optional().describe(
2133
+ "Meta description override. If omitted, the tool generates one from appSlug/topic per locale."
2134
+ ),
2135
+ tags: z5.array(z5.string().trim().min(1)).optional().describe("Optional tags for BLOG_META. Defaults to tags derived from topic."),
2136
+ coverImage: z5.string().trim().optional().describe(
2137
+ "Cover image path. Relative paths rewrite to /blogs/<app>/<slug>/..., default is /products/<appSlug>/og-image.png."
2138
+ ),
2139
+ includeRelativeImageExample: z5.boolean().optional().default(false).describe(
2140
+ "Inject a relative image example (./images/hero.png) into the body to demonstrate path rewriting."
2141
+ ),
2142
+ relativeImagePath: z5.string().trim().optional().describe("Override the relative image path (default ./images/hero.png)."),
2143
+ publishedAt: z5.string().trim().regex(DATE_REGEX2, "publishedAt must use YYYY-MM-DD").optional().describe("Publish date (YYYY-MM-DD). Defaults to today."),
2144
+ modifiedAt: z5.string().trim().regex(DATE_REGEX2, "modifiedAt must use YYYY-MM-DD").optional().describe("Last modified date (YYYY-MM-DD). Defaults to publishedAt."),
2145
+ overwrite: z5.boolean().optional().default(false).describe("Overwrite existing files when true (default: false).")
2146
+ }).describe("Generate static HTML blog posts with BLOG_META headers.");
2147
+ var jsonSchema5 = toJsonSchema4(createBlogHtmlInputSchema, {
2148
+ name: "CreateBlogHtmlInput",
2149
+ target: "openApi3",
2150
+ $refStrategy: "none"
2151
+ });
2152
+ var inputSchema5 = jsonSchema5.definitions?.CreateBlogHtmlInput || jsonSchema5;
2153
+ var createBlogHtmlTool = {
2154
+ name: "create-blog-html",
2155
+ description: `Generate HTML blog posts under public/blogs/<appSlug>/<slug>/<locale>.html with a BLOG_META block.
2156
+
2157
+ Slug rules:
2158
+ - slug = slugify(English title, kebab-case ASCII)
2159
+ - path: public/blogs/<appSlug>/<slug>/<locale>.html
2160
+ - coverImage default: /products/<appSlug>/og-image.png (relative paths are rewritten under /blogs/<app>/<slug>/)
2161
+ - overwrite defaults to false (throws when file exists)
2162
+
2163
+ Template:
2164
+ - Intro connecting topic/app
2165
+ - 3-4 sections (problem \u2192 solution \u2192 tips/examples) using h2/h3
2166
+ - Optional relative image example (./images/hero.png)
2167
+ - Conclusion + CTA linking to /products/<appSlug>
2168
+
2169
+ Supports multiple locales when locales[] is provided (default single locale). Content language follows locale (ko -> Korean, otherwise English).`,
2170
+ inputSchema: inputSchema5
2171
+ };
2172
+ async function handleCreateBlogHtml(input) {
2173
+ const publicDir = getPublicDir();
2174
+ const {
2175
+ appSlug,
2176
+ topic,
2177
+ title,
2178
+ description,
2179
+ tags,
2180
+ coverImage,
2181
+ includeRelativeImageExample = false,
2182
+ relativeImagePath,
2183
+ publishedAt,
2184
+ modifiedAt,
2185
+ overwrite = false
2186
+ } = input;
2187
+ const resolvedTitle = title && title.trim() || topic.trim();
2188
+ const slug = slugifyTitle(resolvedTitle);
2189
+ const targetLocales = resolveTargetLocales(input);
2190
+ if (!targetLocales.length) {
2191
+ throw new Error("At least one locale is required to generate blog HTML.");
2192
+ }
2193
+ const shouldIncludeRelativeImage = includeRelativeImageExample || Boolean(relativeImagePath);
2194
+ const relativeImage = shouldIncludeRelativeImage ? resolveRelativeImagePath(appSlug, slug, relativeImagePath) : void 0;
2195
+ const output = {
2196
+ slug,
2197
+ baseDir: path8.join(publicDir, "blogs", appSlug, slug),
2198
+ files: [],
2199
+ coverImage: coverImage && coverImage.trim().length > 0 ? coverImage.trim() : `/products/${appSlug}/og-image.png`,
2200
+ metaByLocale: {}
2201
+ };
2202
+ const plannedFiles = targetLocales.map(
2203
+ (locale) => getBlogOutputPaths({
2204
+ appSlug,
2205
+ slug,
2206
+ locale,
2207
+ publicDir
2208
+ })
2209
+ );
2210
+ const existing = plannedFiles.filter(({ filePath }) => fs7.existsSync(filePath));
2211
+ if (existing.length > 0 && !overwrite) {
2212
+ const existingList = existing.map((f) => f.filePath).join("\n- ");
2213
+ throw new Error(
2214
+ `Blog HTML already exists. Set overwrite=true to replace:
2215
+ - ${existingList}`
2216
+ );
2217
+ }
2218
+ fs7.mkdirSync(output.baseDir, { recursive: true });
2219
+ for (const locale of targetLocales) {
2220
+ const { filePath } = getBlogOutputPaths({
2221
+ appSlug,
2222
+ slug,
2223
+ locale,
2224
+ publicDir
2225
+ });
2226
+ const meta = buildBlogMeta({
2227
+ title: resolvedTitle,
2228
+ description,
2229
+ appSlug,
2230
+ slug,
2231
+ locale,
2232
+ topic,
2233
+ coverImage,
2234
+ tags,
2235
+ publishedAt,
2236
+ modifiedAt
2237
+ });
2238
+ output.coverImage = meta.coverImage;
2239
+ output.metaByLocale[locale] = meta;
2240
+ const html = buildBlogHtmlDocument({
2241
+ meta,
2242
+ topic,
2243
+ appSlug,
2244
+ includeRelativeImageExample: shouldIncludeRelativeImage,
2245
+ relativeImagePath: relativeImage
2246
+ });
2247
+ fs7.writeFileSync(filePath, html, "utf-8");
2248
+ output.files.push({ locale, path: filePath });
2249
+ }
2250
+ const summaryLines = [
2251
+ `Created blog HTML for ${appSlug}`,
2252
+ `Slug: ${slug}`,
2253
+ `Locales: ${targetLocales.join(", ")}`,
2254
+ `Cover image: ${output.coverImage}`,
2255
+ "",
2256
+ "Files:",
2257
+ ...output.files.map((file) => `- ${file.locale}: ${file.path}`)
2258
+ ];
2259
+ return {
2260
+ content: [
2261
+ {
2262
+ type: "text",
2263
+ text: summaryLines.join("\n")
2264
+ }
2265
+ ]
2266
+ };
2267
+ }
2268
+
1892
2269
  // src/tools/index.ts
1893
2270
  var tools = [
1894
2271
  {
@@ -1922,6 +2299,14 @@ var tools = [
1922
2299
  zodSchema: initProjectInputSchema,
1923
2300
  handler: handleInitProject,
1924
2301
  category: "Setup"
2302
+ },
2303
+ {
2304
+ name: createBlogHtmlTool.name,
2305
+ description: createBlogHtmlTool.description,
2306
+ inputSchema: createBlogHtmlTool.inputSchema,
2307
+ zodSchema: createBlogHtmlInputSchema,
2308
+ handler: handleCreateBlogHtml,
2309
+ category: "Content"
1925
2310
  }
1926
2311
  ];
1927
2312
  function getToolDefinitions() {
@@ -1929,7 +2314,8 @@ function getToolDefinitions() {
1929
2314
  asoToPublicTool,
1930
2315
  publicToAsoTool,
1931
2316
  improvePublicTool,
1932
- initProjectTool
2317
+ initProjectTool,
2318
+ createBlogHtmlTool
1933
2319
  ];
1934
2320
  }
1935
2321
  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
@@ -354,6 +366,86 @@ interface ProductLocale {
354
366
  landing?: LandingPageLocale;
355
367
  }
356
368
 
369
+ /**
370
+ * Types for the create-blog-html MCP tool
371
+ */
372
+ interface BlogMeta {
373
+ title: string;
374
+ description: string;
375
+ appSlug: string;
376
+ slug: string;
377
+ locale: string;
378
+ publishedAt: string;
379
+ modifiedAt: string;
380
+ coverImage: string;
381
+ tags: string[];
382
+ }
383
+ interface CreateBlogHtmlInput {
384
+ /**
385
+ * Product/app slug used for paths and CTAs
386
+ */
387
+ appSlug: string;
388
+ /**
389
+ * English title used for slug creation and H1
390
+ */
391
+ title?: string;
392
+ /**
393
+ * Topic/angle to cover inside the article body
394
+ */
395
+ topic: string;
396
+ /**
397
+ * Single locale to generate (default en-US). Ignored when locales[] is provided.
398
+ */
399
+ locale?: string;
400
+ /**
401
+ * Optional list of locales to generate. Each gets its own HTML file.
402
+ */
403
+ locales?: string[];
404
+ /**
405
+ * Meta description override. If absent, a locale-aware summary is generated from topic/appSlug.
406
+ */
407
+ description?: string;
408
+ /**
409
+ * Optional tags for BLOG_META. If absent, tags are derived from topic and appSlug.
410
+ */
411
+ tags?: string[];
412
+ /**
413
+ * Optional cover image. Relative paths are rewritten to /blogs/<app>/<slug>/...
414
+ */
415
+ coverImage?: string;
416
+ /**
417
+ * Include a relative image example in the body (./images/hero.png by default).
418
+ */
419
+ includeRelativeImageExample?: boolean;
420
+ /**
421
+ * Override the relative image path used when includeRelativeImageExample is true.
422
+ */
423
+ relativeImagePath?: string;
424
+ /**
425
+ * Publish date (YYYY-MM-DD). Defaults to today.
426
+ */
427
+ publishedAt?: string;
428
+ /**
429
+ * Last modified date (YYYY-MM-DD). Defaults to publishedAt.
430
+ */
431
+ modifiedAt?: string;
432
+ /**
433
+ * Overwrite existing HTML files when true (default false).
434
+ */
435
+ overwrite?: boolean;
436
+ }
437
+ interface GeneratedBlogFile {
438
+ locale: string;
439
+ path: string;
440
+ }
441
+ interface CreateBlogHtmlResult {
442
+ slug: string;
443
+ baseDir: string;
444
+ files: GeneratedBlogFile[];
445
+ coverImage: string;
446
+ metaByLocale: Record<string, BlogMeta>;
447
+ }
448
+
357
449
  /**
358
450
  * Locale conversion utilities for ASO platforms
359
451
  * Handles conversion between unified locales and platform-specific locale codes
@@ -609,4 +701,4 @@ declare function getPublicDir(): string;
609
701
  */
610
702
  declare function getProductsDir(): string;
611
703
 
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 };
704
+ 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 BlogMeta, 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 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.0",
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
  },