pabal-web-mcp 0.1.6 → 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.
package/README.md CHANGED
@@ -1,6 +1,10 @@
1
1
  # pabal-web-mcp
2
2
 
3
- MCP (Model Context Protocol) server for ASO (App Store Optimization) data management with shared types and utilities.
3
+ MCP (Model Context Protocol) server for bidirectional conversion between ASO (App Store Optimization) and web SEO data.
4
+
5
+ This library enables seamless reuse of ASO data for web SEO purposes, allowing you to convert ASO metadata directly into web SEO content and vice versa.
6
+
7
+ [![한국어 docs](https://img.shields.io/badge/docs-Korean-green)](./i18n/README.ko.md)
4
8
 
5
9
  ## 🛠️ MCP Client Installation
6
10
 
@@ -12,18 +16,6 @@ MCP (Model Context Protocol) server for ASO (App Store Optimization) data manage
12
16
  > [!TIP]
13
17
  > If you repeatedly do ASO/store tasks, add a client rule like "always use pabal-web-mcp" so the MCP server auto-invokes without typing it every time.
14
18
 
15
- ### Global install (recommended)
16
-
17
- ```bash
18
- npm install -g pabal-web-mcp
19
-
20
- # or
21
-
22
- yarn global add pabal-web-mcp
23
- ```
24
-
25
- Install globally first for fastest starts and to avoid npm download issues (proxy/firewall/offline). You can still use `npx -y pabal-web-mcp`, but global install is recommended. After global install, set your MCP config to `command: "pabal-web-mcp"` (no `npx` needed).
26
-
27
19
  <details>
28
20
  <summary><b>Install in Cursor</b></summary>
29
21
 
@@ -235,6 +227,8 @@ console.log(asoData.googlePlay?.title);
235
227
 
236
228
  ## Supported Locales
237
229
 
230
+ Supports all languages supported by each store.
231
+
238
232
  | Unified | App Store | Google Play |
239
233
  | ------- | --------- | ----------- |
240
234
  | en-US | en-US | en-US |
@@ -251,3 +245,17 @@ console.log(asoData.googlePlay?.title);
251
245
  ## License
252
246
 
253
247
  MIT
248
+
249
+ ---
250
+
251
+ <br>
252
+
253
+ ## 🌐 Pabal Web
254
+
255
+ Want to manage ASO and SEO together? Check out **Pabal Web**.
256
+
257
+ [![Pabal Web](public/pabal-web.png)](https://pabal.quartz.best/)
258
+
259
+ **Pabal Web** is a Next.js-based web interface that provides a complete solution for unified management of ASO, SEO, Google Search Console indexing, and more.
260
+
261
+ 👉 [Visit Pabal Web](https://pabal.quartz.best/)
@@ -15,7 +15,7 @@ import {
15
15
  saveAsoToAsoDir,
16
16
  unifiedToAppStore,
17
17
  unifiedToGooglePlay
18
- } from "../chunk-YPDLNPLX.js";
18
+ } from "../chunk-OCOFNMN2.js";
19
19
 
20
20
  // src/bin/mcp-server.ts
21
21
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
@@ -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) {