pabal-web-mcp 1.1.1 → 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.
@@ -1889,12 +1889,13 @@ async function handleInitProject(input) {
1889
1889
  }
1890
1890
 
1891
1891
  // src/tools/create-blog-html.ts
1892
- import fs7 from "fs";
1892
+ import fs8 from "fs";
1893
1893
  import path8 from "path";
1894
1894
  import { z as z5 } from "zod";
1895
1895
  import { zodToJsonSchema as zodToJsonSchema5 } from "zod-to-json-schema";
1896
1896
 
1897
1897
  // src/utils/blog.util.ts
1898
+ import fs7 from "fs";
1898
1899
  import path7 from "path";
1899
1900
  var DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
1900
1901
  var BLOG_ROOT = "blogs";
@@ -1915,7 +1916,6 @@ function normalizeDate(date) {
1915
1916
  }
1916
1917
  return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
1917
1918
  }
1918
- var isKoreanLocale = (locale) => locale.trim().toLowerCase().startsWith("ko");
1919
1919
  var toPublicBlogBase = (appSlug, slug) => `/${BLOG_ROOT}/${appSlug}/${slug}`;
1920
1920
  function resolveCoverImagePath(appSlug, slug, coverImage) {
1921
1921
  if (!coverImage || !coverImage.trim()) {
@@ -1931,20 +1931,6 @@ function resolveCoverImagePath(appSlug, slug, coverImage) {
1931
1931
  }
1932
1932
  return cleaned;
1933
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
1934
  function deriveTags(topic, appSlug) {
1949
1935
  const topicParts = topic.toLowerCase().split(/[^a-z0-9+]+/).filter(Boolean).slice(0, 6);
1950
1936
  const set = /* @__PURE__ */ new Set([...topicParts, appSlug.toLowerCase(), "blog"]);
@@ -1958,9 +1944,14 @@ function buildBlogMeta(options) {
1958
1944
  options.slug,
1959
1945
  options.coverImage
1960
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
+ }
1961
1952
  return {
1962
1953
  title: options.title,
1963
- description: options.description || buildDescription(options.locale, options.topic, options.appSlug),
1954
+ description: options.description.trim(),
1964
1955
  appSlug: options.appSlug,
1965
1956
  slug: options.slug,
1966
1957
  locale: options.locale,
@@ -1978,140 +1969,18 @@ function renderBlogMetaBlock(meta) {
1978
1969
  ${serialized}
1979
1970
  -->`;
1980
1971
  }
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
1972
  function buildBlogHtmlDocument(options) {
2098
1973
  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
- });
1974
+ const body = options.content.trim();
2106
1975
  return `${metaBlock}
2107
1976
  ${body}`;
2108
1977
  }
2109
- function resolveTargetLocales(input, defaultLocale = "en-US") {
1978
+ function resolveTargetLocales(input) {
2110
1979
  if (input.locales?.length) {
2111
1980
  const locales = input.locales.map((loc) => loc.trim()).filter(Boolean);
2112
1981
  return Array.from(new Set(locales));
2113
1982
  }
2114
- const fallback = input.locale?.trim() || defaultLocale;
1983
+ const fallback = input.locale?.trim();
2115
1984
  return fallback ? [fallback] : [];
2116
1985
  }
2117
1986
  function getBlogOutputPaths(options) {
@@ -2125,6 +1994,63 @@ function getBlogOutputPaths(options) {
2125
1994
  const publicBasePath = toPublicBlogBase(options.appSlug, options.slug);
2126
1995
  return { baseDir, filePath, publicBasePath };
2127
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
+ }
2128
2054
 
2129
2055
  // src/tools/create-blog-html.ts
2130
2056
  var toJsonSchema4 = zodToJsonSchema5;
@@ -2135,14 +2061,17 @@ var createBlogHtmlInputSchema = z5.object({
2135
2061
  "English title used for slug (kebab-case). Falls back to topic when omitted."
2136
2062
  ),
2137
2063
  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."
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."
2140
2066
  ),
2141
2067
  locales: z5.array(z5.string().trim().min(1)).optional().describe(
2142
- "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."
2069
+ ),
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."
2143
2072
  ),
2144
- description: z5.string().trim().optional().describe(
2145
- "Meta description override. If omitted, the tool generates one from appSlug/topic per locale."
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."
2146
2075
  ),
2147
2076
  tags: z5.array(z5.string().trim().min(1)).optional().describe(
2148
2077
  "Optional tags for BLOG_META. Defaults to tags derived from topic."
@@ -2150,12 +2079,6 @@ var createBlogHtmlInputSchema = z5.object({
2150
2079
  coverImage: z5.string().trim().optional().describe(
2151
2080
  "Cover image path. Relative paths rewrite to /blogs/<app>/<slug>/..., default is /products/<appSlug>/og-image.png."
2152
2081
  ),
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
2082
  publishedAt: z5.string().trim().regex(DATE_REGEX2, "publishedAt must use YYYY-MM-DD").optional().describe("Publish date (YYYY-MM-DD). Defaults to today."),
2160
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."),
2161
2084
  overwrite: z5.boolean().optional().default(false).describe("Overwrite existing files when true (default: false).")
@@ -2169,19 +2092,33 @@ var createBlogHtmlTool = {
2169
2092
  name: "create-blog-html",
2170
2093
  description: `Generate HTML blog posts under public/blogs/<appSlug>/<slug>/<locale>.html with a BLOG_META block.
2171
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
+
2172
2107
  Slug rules:
2173
2108
  - slug = slugify(English title, kebab-case ASCII)
2174
2109
  - path: public/blogs/<appSlug>/<slug>/<locale>.html
2175
2110
  - coverImage default: /products/<appSlug>/og-image.png (relative paths are rewritten under /blogs/<app>/<slug>/)
2176
2111
  - overwrite defaults to false (throws when file exists)
2177
2112
 
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>
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.
2183
2117
 
2184
- 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`,
2185
2122
  inputSchema: inputSchema5
2186
2123
  };
2187
2124
  async function handleCreateBlogHtml(input) {
@@ -2193,20 +2130,36 @@ async function handleCreateBlogHtml(input) {
2193
2130
  description,
2194
2131
  tags,
2195
2132
  coverImage,
2196
- includeRelativeImageExample = false,
2197
- relativeImagePath,
2198
2133
  publishedAt,
2199
2134
  modifiedAt,
2200
- overwrite = false
2135
+ overwrite = false,
2136
+ content
2201
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
+ }
2202
2143
  const resolvedTitle = title && title.trim() || topic.trim();
2203
2144
  const slug = slugifyTitle(resolvedTitle);
2204
2145
  const targetLocales = resolveTargetLocales(input);
2205
2146
  if (!targetLocales.length) {
2206
- 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
+ }
2207
2162
  }
2208
- const shouldIncludeRelativeImage = includeRelativeImageExample || Boolean(relativeImagePath);
2209
- const relativeImage = shouldIncludeRelativeImage ? resolveRelativeImagePath(appSlug, slug, relativeImagePath) : void 0;
2210
2163
  const output = {
2211
2164
  slug,
2212
2165
  baseDir: path8.join(publicDir, "blogs", appSlug, slug),
@@ -2223,7 +2176,7 @@ async function handleCreateBlogHtml(input) {
2223
2176
  })
2224
2177
  );
2225
2178
  const existing = plannedFiles.filter(
2226
- ({ filePath }) => fs7.existsSync(filePath)
2179
+ ({ filePath }) => fs8.existsSync(filePath)
2227
2180
  );
2228
2181
  if (existing.length > 0 && !overwrite) {
2229
2182
  const existingList = existing.map((f) => f.filePath).join("\n- ");
@@ -2232,7 +2185,7 @@ async function handleCreateBlogHtml(input) {
2232
2185
  - ${existingList}`
2233
2186
  );
2234
2187
  }
2235
- fs7.mkdirSync(output.baseDir, { recursive: true });
2188
+ fs8.mkdirSync(output.baseDir, { recursive: true });
2236
2189
  for (const locale of targetLocales) {
2237
2190
  const { filePath } = getBlogOutputPaths({
2238
2191
  appSlug,
@@ -2256,12 +2209,9 @@ async function handleCreateBlogHtml(input) {
2256
2209
  output.metaByLocale[locale] = meta;
2257
2210
  const html = buildBlogHtmlDocument({
2258
2211
  meta,
2259
- topic,
2260
- appSlug,
2261
- includeRelativeImageExample: shouldIncludeRelativeImage,
2262
- relativeImagePath: relativeImage
2212
+ content
2263
2213
  });
2264
- fs7.writeFileSync(filePath, html, "utf-8");
2214
+ fs8.writeFileSync(filePath, html, "utf-8");
2265
2215
  output.files.push({ locale, path: filePath });
2266
2216
  }
2267
2217
  const summaryLines = [
@@ -2273,11 +2223,25 @@ async function handleCreateBlogHtml(input) {
2273
2223
  "Files:",
2274
2224
  ...output.files.map((file) => `- ${file.locale}: ${file.path}`)
2275
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
+ }
2276
2240
  return {
2277
2241
  content: [
2278
2242
  {
2279
2243
  type: "text",
2280
- text: summaryLines.join("\n")
2244
+ text: summaryLines.join("\n") + styleReferenceInfo.join("")
2281
2245
  }
2282
2246
  ]
2283
2247
  };
package/dist/index.d.ts CHANGED
@@ -499,13 +499,18 @@ interface CreateBlogHtmlInput {
499
499
  */
500
500
  topic: string;
501
501
  /**
502
- * Single locale to generate (default en-US). Ignored when locales[] is provided.
502
+ * Single locale to generate (REQUIRED). Ignored when locales[] is provided.
503
503
  */
504
- locale?: string;
504
+ locale: string;
505
505
  /**
506
506
  * Optional list of locales to generate. Each gets its own HTML file.
507
507
  */
508
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;
509
514
  /**
510
515
  * Meta description override. If absent, a locale-aware summary is generated from topic/appSlug.
511
516
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pabal-web-mcp",
3
- "version": "1.1.1",
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",