pabal-web-mcp 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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;
@@ -1610,7 +1610,6 @@ var improvePublicInputSchema = z3.object({
1610
1610
  });
1611
1611
  var jsonSchema3 = toJsonSchema3(improvePublicInputSchema, {
1612
1612
  name: "ImprovePublicInput",
1613
- target: "openApi3",
1614
1613
  $refStrategy: "none"
1615
1614
  });
1616
1615
  var inputSchema3 = jsonSchema3.definitions?.ImprovePublicInput || jsonSchema3;
@@ -1796,7 +1795,6 @@ var initProjectInputSchema = z4.object({
1796
1795
  });
1797
1796
  var jsonSchema4 = zodToJsonSchema4(initProjectInputSchema, {
1798
1797
  name: "InitProjectInput",
1799
- target: "openApi3",
1800
1798
  $refStrategy: "none"
1801
1799
  });
1802
1800
  var inputSchema4 = jsonSchema4.definitions?.InitProjectInput || jsonSchema4;
@@ -1891,12 +1889,13 @@ async function handleInitProject(input) {
1891
1889
  }
1892
1890
 
1893
1891
  // src/tools/create-blog-html.ts
1894
- import fs7 from "fs";
1892
+ import fs8 from "fs";
1895
1893
  import path8 from "path";
1896
1894
  import { z as z5 } from "zod";
1897
1895
  import { zodToJsonSchema as zodToJsonSchema5 } from "zod-to-json-schema";
1898
1896
 
1899
1897
  // src/utils/blog.util.ts
1898
+ import fs7 from "fs";
1900
1899
  import path7 from "path";
1901
1900
  var DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
1902
1901
  var BLOG_ROOT = "blogs";
@@ -1917,7 +1916,6 @@ function normalizeDate(date) {
1917
1916
  }
1918
1917
  return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
1919
1918
  }
1920
- var isKoreanLocale = (locale) => locale.trim().toLowerCase().startsWith("ko");
1921
1919
  var toPublicBlogBase = (appSlug, slug) => `/${BLOG_ROOT}/${appSlug}/${slug}`;
1922
1920
  function resolveCoverImagePath(appSlug, slug, coverImage) {
1923
1921
  if (!coverImage || !coverImage.trim()) {
@@ -1933,20 +1931,6 @@ function resolveCoverImagePath(appSlug, slug, coverImage) {
1933
1931
  }
1934
1932
  return cleaned;
1935
1933
  }
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
1934
  function deriveTags(topic, appSlug) {
1951
1935
  const topicParts = topic.toLowerCase().split(/[^a-z0-9+]+/).filter(Boolean).slice(0, 6);
1952
1936
  const set = /* @__PURE__ */ new Set([...topicParts, appSlug.toLowerCase(), "blog"]);
@@ -1960,9 +1944,14 @@ function buildBlogMeta(options) {
1960
1944
  options.slug,
1961
1945
  options.coverImage
1962
1946
  );
1947
+ if (!options.description || !options.description.trim()) {
1948
+ throw new Error(
1949
+ "Description is required. The LLM must generate a meta description based on the topic and locale."
1950
+ );
1951
+ }
1963
1952
  return {
1964
1953
  title: options.title,
1965
- description: options.description || buildDescription(options.locale, options.topic, options.appSlug),
1954
+ description: options.description.trim(),
1966
1955
  appSlug: options.appSlug,
1967
1956
  slug: options.slug,
1968
1957
  locale: options.locale,
@@ -1980,128 +1969,18 @@ function renderBlogMetaBlock(meta) {
1980
1969
  ${serialized}
1981
1970
  -->`;
1982
1971
  }
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
1972
  function buildBlogHtmlDocument(options) {
2088
1973
  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
- });
1974
+ const body = options.content.trim();
2096
1975
  return `${metaBlock}
2097
1976
  ${body}`;
2098
1977
  }
2099
- function resolveTargetLocales(input, defaultLocale = "en-US") {
1978
+ function resolveTargetLocales(input) {
2100
1979
  if (input.locales?.length) {
2101
1980
  const locales = input.locales.map((loc) => loc.trim()).filter(Boolean);
2102
1981
  return Array.from(new Set(locales));
2103
1982
  }
2104
- const fallback = input.locale?.trim() || defaultLocale;
1983
+ const fallback = input.locale?.trim();
2105
1984
  return fallback ? [fallback] : [];
2106
1985
  }
2107
1986
  function getBlogOutputPaths(options) {
@@ -2115,6 +1994,63 @@ function getBlogOutputPaths(options) {
2115
1994
  const publicBasePath = toPublicBlogBase(options.appSlug, options.slug);
2116
1995
  return { baseDir, filePath, publicBasePath };
2117
1996
  }
1997
+ function parseBlogHtml(htmlContent) {
1998
+ const metaBlockRegex = /<!--BLOG_META\s*\n([\s\S]*?)\n-->/;
1999
+ const match = htmlContent.match(metaBlockRegex);
2000
+ if (!match) {
2001
+ return { meta: null, body: htmlContent.trim() };
2002
+ }
2003
+ try {
2004
+ const metaJson = match[1].trim();
2005
+ const meta = JSON.parse(metaJson);
2006
+ const body = htmlContent.replace(metaBlockRegex, "").trim();
2007
+ return { meta, body };
2008
+ } catch (error) {
2009
+ return { meta: null, body: htmlContent.trim() };
2010
+ }
2011
+ }
2012
+ function findExistingBlogPosts({
2013
+ appSlug,
2014
+ locale,
2015
+ publicDir,
2016
+ limit = 2
2017
+ }) {
2018
+ const blogAppDir = path7.join(publicDir, BLOG_ROOT, appSlug);
2019
+ if (!fs7.existsSync(blogAppDir)) {
2020
+ return [];
2021
+ }
2022
+ const posts = [];
2023
+ const subdirs = fs7.readdirSync(blogAppDir, { withFileTypes: true });
2024
+ for (const subdir of subdirs) {
2025
+ if (!subdir.isDirectory()) continue;
2026
+ const localeFile = path7.join(blogAppDir, subdir.name, `${locale}.html`);
2027
+ if (!fs7.existsSync(localeFile)) continue;
2028
+ try {
2029
+ const htmlContent = fs7.readFileSync(localeFile, "utf-8");
2030
+ const { meta, body } = parseBlogHtml(htmlContent);
2031
+ if (meta && meta.locale === locale) {
2032
+ posts.push({
2033
+ filePath: localeFile,
2034
+ meta,
2035
+ body,
2036
+ publishedAt: meta.publishedAt
2037
+ });
2038
+ }
2039
+ } catch (error) {
2040
+ continue;
2041
+ }
2042
+ }
2043
+ posts.sort((a, b) => {
2044
+ const dateA = new Date(a.publishedAt).getTime();
2045
+ const dateB = new Date(b.publishedAt).getTime();
2046
+ return dateB - dateA;
2047
+ });
2048
+ return posts.slice(0, limit).map(({ filePath, meta, body }) => ({
2049
+ filePath,
2050
+ meta,
2051
+ body
2052
+ }));
2053
+ }
2118
2054
 
2119
2055
  // src/tools/create-blog-html.ts
2120
2056
  var toJsonSchema4 = zodToJsonSchema5;
@@ -2125,28 +2061,30 @@ var createBlogHtmlInputSchema = z5.object({
2125
2061
  "English title used for slug (kebab-case). Falls back to topic when omitted."
2126
2062
  ),
2127
2063
  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."),
2064
+ locale: z5.string().trim().min(1, "locale is required").describe(
2065
+ "Primary locale (e.g., 'en-US', 'ko-KR'). Required to determine the language for blog content generation."
2066
+ ),
2129
2067
  locales: z5.array(z5.string().trim().min(1)).optional().describe(
2130
- "Optional list of locales to generate. Each locale gets its own HTML file."
2068
+ "Optional list of locales to generate. Each locale gets its own HTML file. If provided, locale parameter is ignored."
2131
2069
  ),
2132
- description: z5.string().trim().optional().describe(
2133
- "Meta description override. If omitted, the tool generates one from appSlug/topic per locale."
2070
+ content: z5.string().trim().min(1, "content is required").describe(
2071
+ "HTML content for the blog body. You (the LLM) must generate this HTML content based on the topic and locale. Structure should follow the pattern in public/en-US.html: paragraphs (<p>), headings (<h2>, <h3>), images (<img>), lists (<ul>, <li>), horizontal rules (<hr>), etc. The content should be written in the language corresponding to the locale."
2072
+ ),
2073
+ description: z5.string().trim().min(1, "description is required").describe(
2074
+ "Meta description for the blog post. You (the LLM) must generate this based on the topic and locale. Should be a concise summary of the blog content in the language corresponding to the locale."
2075
+ ),
2076
+ tags: z5.array(z5.string().trim().min(1)).optional().describe(
2077
+ "Optional tags for BLOG_META. Defaults to tags derived from topic."
2134
2078
  ),
2135
- tags: z5.array(z5.string().trim().min(1)).optional().describe("Optional tags for BLOG_META. Defaults to tags derived from topic."),
2136
2079
  coverImage: z5.string().trim().optional().describe(
2137
2080
  "Cover image path. Relative paths rewrite to /blogs/<app>/<slug>/..., default is /products/<appSlug>/og-image.png."
2138
2081
  ),
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
2082
  publishedAt: z5.string().trim().regex(DATE_REGEX2, "publishedAt must use YYYY-MM-DD").optional().describe("Publish date (YYYY-MM-DD). Defaults to today."),
2144
2083
  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
2084
  overwrite: z5.boolean().optional().default(false).describe("Overwrite existing files when true (default: false).")
2146
2085
  }).describe("Generate static HTML blog posts with BLOG_META headers.");
2147
2086
  var jsonSchema5 = toJsonSchema4(createBlogHtmlInputSchema, {
2148
2087
  name: "CreateBlogHtmlInput",
2149
- target: "openApi3",
2150
2088
  $refStrategy: "none"
2151
2089
  });
2152
2090
  var inputSchema5 = jsonSchema5.definitions?.CreateBlogHtmlInput || jsonSchema5;
@@ -2154,19 +2092,33 @@ var createBlogHtmlTool = {
2154
2092
  name: "create-blog-html",
2155
2093
  description: `Generate HTML blog posts under public/blogs/<appSlug>/<slug>/<locale>.html with a BLOG_META block.
2156
2094
 
2095
+ CRITICAL: WRITING STYLE CONSISTENCY
2096
+ Before generating content, you MUST:
2097
+ 1. Read existing blog posts from public/blogs/<appSlug>/*/<locale>.html (use findExistingBlogPosts utility or read files directly)
2098
+ 2. Analyze the writing style, tone, and format from 2 existing posts in the same locale
2099
+ 3. Match that exact writing style when generating the new blog post content and description
2100
+ 4. Maintain consistency in: paragraph structure, heading usage, tone, formality level, and overall format
2101
+
2102
+ IMPORTANT REQUIREMENTS:
2103
+ 1. The 'locale' parameter is REQUIRED. If the user does not provide a locale, you MUST ask them to specify which language/locale they want to write the blog in (e.g., 'en-US', 'ko-KR', 'ja-JP', etc.).
2104
+ 2. The 'content' parameter is REQUIRED. You (the LLM) must generate the HTML content based on the 'topic' and 'locale' provided by the user. The content should be written in the language corresponding to the locale AND match the writing style of existing blog posts for that locale.
2105
+ 3. The 'description' parameter is REQUIRED. You (the LLM) must generate this based on the topic, locale, AND the writing style of existing blog posts.
2106
+
2157
2107
  Slug rules:
2158
2108
  - slug = slugify(English title, kebab-case ASCII)
2159
2109
  - path: public/blogs/<appSlug>/<slug>/<locale>.html
2160
2110
  - coverImage default: /products/<appSlug>/og-image.png (relative paths are rewritten under /blogs/<app>/<slug>/)
2161
2111
  - overwrite defaults to false (throws when file exists)
2162
2112
 
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>
2113
+ HTML Structure (follows public/en-US.html pattern):
2114
+ - BLOG_META block at the top with JSON metadata
2115
+ - HTML body content: paragraphs (<p>), headings (<h2>, <h3>), images (<img>), lists (<ul>, <li>), horizontal rules (<hr>), etc.
2116
+ - You must generate the HTML content based on the topic, making it relevant and engaging for the target locale's language, while maintaining consistency with existing blog posts.
2168
2117
 
2169
- Supports multiple locales when locales[] is provided (default single locale). Content language follows locale (ko -> Korean, otherwise English).`,
2118
+ Supports multiple locales when locales[] is provided. Each locale gets its own HTML file. For each locale, you must:
2119
+ 1. Read existing posts in that locale to understand the writing style
2120
+ 2. Generate appropriate content in that locale's language
2121
+ 3. Match the writing style and format of existing posts`,
2170
2122
  inputSchema: inputSchema5
2171
2123
  };
2172
2124
  async function handleCreateBlogHtml(input) {
@@ -2178,20 +2130,36 @@ async function handleCreateBlogHtml(input) {
2178
2130
  description,
2179
2131
  tags,
2180
2132
  coverImage,
2181
- includeRelativeImageExample = false,
2182
- relativeImagePath,
2183
2133
  publishedAt,
2184
2134
  modifiedAt,
2185
- overwrite = false
2135
+ overwrite = false,
2136
+ content
2186
2137
  } = input;
2138
+ if (!content || !content.trim()) {
2139
+ throw new Error(
2140
+ "Content is required. Please provide HTML content for the blog body based on the topic and locale."
2141
+ );
2142
+ }
2187
2143
  const resolvedTitle = title && title.trim() || topic.trim();
2188
2144
  const slug = slugifyTitle(resolvedTitle);
2189
2145
  const targetLocales = resolveTargetLocales(input);
2190
2146
  if (!targetLocales.length) {
2191
- throw new Error("At least one locale is required to generate blog HTML.");
2147
+ throw new Error(
2148
+ "Locale is required. Please specify which language/locale you want to write the blog in (e.g., 'en-US', 'ko-KR', 'ja-JP')."
2149
+ );
2150
+ }
2151
+ const existingPostsByLocale = {};
2152
+ for (const locale of targetLocales) {
2153
+ const existingPosts = findExistingBlogPosts({
2154
+ appSlug,
2155
+ locale,
2156
+ publicDir,
2157
+ limit: 2
2158
+ });
2159
+ if (existingPosts.length > 0) {
2160
+ existingPostsByLocale[locale] = existingPosts;
2161
+ }
2192
2162
  }
2193
- const shouldIncludeRelativeImage = includeRelativeImageExample || Boolean(relativeImagePath);
2194
- const relativeImage = shouldIncludeRelativeImage ? resolveRelativeImagePath(appSlug, slug, relativeImagePath) : void 0;
2195
2163
  const output = {
2196
2164
  slug,
2197
2165
  baseDir: path8.join(publicDir, "blogs", appSlug, slug),
@@ -2207,7 +2175,9 @@ async function handleCreateBlogHtml(input) {
2207
2175
  publicDir
2208
2176
  })
2209
2177
  );
2210
- const existing = plannedFiles.filter(({ filePath }) => fs7.existsSync(filePath));
2178
+ const existing = plannedFiles.filter(
2179
+ ({ filePath }) => fs8.existsSync(filePath)
2180
+ );
2211
2181
  if (existing.length > 0 && !overwrite) {
2212
2182
  const existingList = existing.map((f) => f.filePath).join("\n- ");
2213
2183
  throw new Error(
@@ -2215,7 +2185,7 @@ async function handleCreateBlogHtml(input) {
2215
2185
  - ${existingList}`
2216
2186
  );
2217
2187
  }
2218
- fs7.mkdirSync(output.baseDir, { recursive: true });
2188
+ fs8.mkdirSync(output.baseDir, { recursive: true });
2219
2189
  for (const locale of targetLocales) {
2220
2190
  const { filePath } = getBlogOutputPaths({
2221
2191
  appSlug,
@@ -2239,12 +2209,9 @@ async function handleCreateBlogHtml(input) {
2239
2209
  output.metaByLocale[locale] = meta;
2240
2210
  const html = buildBlogHtmlDocument({
2241
2211
  meta,
2242
- topic,
2243
- appSlug,
2244
- includeRelativeImageExample: shouldIncludeRelativeImage,
2245
- relativeImagePath: relativeImage
2212
+ content
2246
2213
  });
2247
- fs7.writeFileSync(filePath, html, "utf-8");
2214
+ fs8.writeFileSync(filePath, html, "utf-8");
2248
2215
  output.files.push({ locale, path: filePath });
2249
2216
  }
2250
2217
  const summaryLines = [
@@ -2256,11 +2223,25 @@ async function handleCreateBlogHtml(input) {
2256
2223
  "Files:",
2257
2224
  ...output.files.map((file) => `- ${file.locale}: ${file.path}`)
2258
2225
  ];
2226
+ const styleReferenceInfo = [];
2227
+ for (const [locale, posts] of Object.entries(existingPostsByLocale)) {
2228
+ if (posts.length > 0) {
2229
+ styleReferenceInfo.push(
2230
+ `
2231
+ Writing style reference for ${locale}: Found ${posts.length} existing post(s) used for style consistency.`
2232
+ );
2233
+ }
2234
+ }
2235
+ if (styleReferenceInfo.length === 0) {
2236
+ styleReferenceInfo.push(
2237
+ "\nNote: No existing blog posts found for style reference. This is the first post for this app/locale combination."
2238
+ );
2239
+ }
2259
2240
  return {
2260
2241
  content: [
2261
2242
  {
2262
2243
  type: "text",
2263
- text: summaryLines.join("\n")
2244
+ text: summaryLines.join("\n") + styleReferenceInfo.join("")
2264
2245
  }
2265
2246
  ]
2266
2247
  };
package/dist/index.d.ts CHANGED
@@ -204,6 +204,61 @@ declare function isGooglePlayMultilingual(data: GooglePlayAsoData | GooglePlayMu
204
204
  */
205
205
  declare function isAppStoreMultilingual(data: AppStoreAsoData | AppStoreMultilingualAsoData | undefined): data is AppStoreMultilingualAsoData;
206
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
+
207
262
  interface ImageAsset {
208
263
  src: string;
209
264
  alt: string;
@@ -366,10 +421,60 @@ interface ProductLocale {
366
421
  landing?: LandingPageLocale;
367
422
  }
368
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
+
369
469
  /**
370
470
  * Types for the create-blog-html MCP tool
371
471
  */
372
- interface BlogMeta {
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 {
373
478
  title: string;
374
479
  description: string;
375
480
  appSlug: string;
@@ -394,13 +499,18 @@ interface CreateBlogHtmlInput {
394
499
  */
395
500
  topic: string;
396
501
  /**
397
- * Single locale to generate (default en-US). Ignored when locales[] is provided.
502
+ * Single locale to generate (REQUIRED). Ignored when locales[] is provided.
398
503
  */
399
- locale?: string;
504
+ locale: string;
400
505
  /**
401
506
  * Optional list of locales to generate. Each gets its own HTML file.
402
507
  */
403
508
  locales?: string[];
509
+ /**
510
+ * HTML content for the blog body. REQUIRED. The LLM must generate this based on the topic and locale.
511
+ * Structure should follow public/en-US.html pattern.
512
+ */
513
+ content: string;
404
514
  /**
405
515
  * Meta description override. If absent, a locale-aware summary is generated from topic/appSlug.
406
516
  */
@@ -443,7 +553,7 @@ interface CreateBlogHtmlResult {
443
553
  baseDir: string;
444
554
  files: GeneratedBlogFile[];
445
555
  coverImage: string;
446
- metaByLocale: Record<string, BlogMeta>;
556
+ metaByLocale: Record<string, BlogMetaOutput>;
447
557
  }
448
558
 
449
559
  /**
@@ -701,4 +811,4 @@ declare function getPublicDir(): string;
701
811
  */
702
812
  declare function getProductsDir(): string;
703
813
 
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 };
814
+ 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.1.0",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "description": "MCP server for ASO data management with shared types and utilities",
6
6
  "author": "skyu",