create-ait-app 0.0.1 → 0.0.3

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.
Files changed (112) hide show
  1. package/README.md +35 -49
  2. package/package.json +2 -6
  3. package/src/cli.js +52 -0
  4. package/src/main.js +88 -396
  5. package/src/sample-configs.js +140 -0
  6. package/src/sample-inject.js +65 -0
  7. package/src/scaffold.js +111 -0
  8. package/src/skills.js +66 -0
  9. package/src/templates.js +88 -0
  10. package/src/utils/copy-dir.js +19 -0
  11. package/src/utils/fetch-text.js +47 -0
  12. package/src/utils/package-name.js +26 -0
  13. package/templates/js/eslint.config.js +14 -0
  14. package/templates/js/index.html +12 -0
  15. package/templates/js/package.json +20 -0
  16. package/templates/js/samples/iaa/src/lib/inAppAds.js +119 -0
  17. package/templates/js/samples/iaa/src/pages/InAppAdsPage.js +83 -0
  18. package/templates/js/samples/iap/src/lib/inAppPurchase.js +105 -0
  19. package/templates/js/samples/iap/src/pages/InAppPurchasePage.js +102 -0
  20. package/templates/js/src/app.js +58 -0
  21. package/templates/js/src/main.js +2 -0
  22. package/templates/js/vite.config.js +3 -0
  23. package/templates/react/README.md +26 -0
  24. package/templates/react/eslint.config.js +30 -0
  25. package/templates/react/granite.config.ts +20 -0
  26. package/templates/react/index.html +12 -0
  27. package/templates/react/package.json +27 -0
  28. package/templates/react/public/appsintoss-logo.png +0 -0
  29. package/templates/react/samples/iaa/src/hooks/useInAppAds.js +102 -0
  30. package/templates/react/samples/iaa/src/pages/InAppAdsPage.css +72 -0
  31. package/templates/react/samples/iaa/src/pages/InAppAdsPage.jsx +75 -0
  32. package/templates/react/samples/iap/public/icon-document.png +0 -0
  33. package/templates/react/samples/iap/src/hooks/useInAppPurchase.js +95 -0
  34. package/templates/react/samples/iap/src/pages/InAppPurchasePage.css +115 -0
  35. package/templates/react/samples/iap/src/pages/InAppPurchasePage.jsx +115 -0
  36. package/templates/react/src/App.css +104 -0
  37. package/templates/react/src/App.jsx +45 -0
  38. package/templates/react/src/index.css +27 -0
  39. package/templates/react/src/main.jsx +10 -0
  40. package/templates/react/vite.config.js +6 -0
  41. package/templates/react-ts/README.md +26 -0
  42. package/templates/react-ts/eslint.config.js +28 -0
  43. package/templates/react-ts/granite.config.ts +20 -0
  44. package/templates/react-ts/index.html +12 -0
  45. package/templates/react-ts/public/appsintoss-logo.png +0 -0
  46. package/templates/react-ts/samples/iaa/src/pages/InAppAdsPage.css +72 -0
  47. package/templates/react-ts/samples/iap/public/icon-document.png +0 -0
  48. package/templates/react-ts/samples/iap/src/pages/InAppPurchasePage.css +115 -0
  49. package/templates/react-ts/src/App.css +104 -0
  50. package/templates/react-ts/src/index.css +27 -0
  51. package/templates/react-ts/src/vite-env.d.ts +6 -0
  52. package/templates/react-ts/tsconfig.app.json +22 -0
  53. package/templates/react-ts/tsconfig.json +7 -0
  54. package/templates/react-ts/tsconfig.node.json +20 -0
  55. package/templates/react-ts/vite.config.ts +6 -0
  56. package/templates/react-ts-tds/README.md +26 -0
  57. package/templates/react-ts-tds/granite.config.ts +20 -0
  58. package/templates/react-ts-tds/package.json +35 -0
  59. package/templates/react-ts-tds/public/appsintoss-logo.png +0 -0
  60. package/templates/ts/README.md +26 -0
  61. package/templates/ts/eslint.config.js +15 -0
  62. package/templates/ts/granite.config.ts +20 -0
  63. package/templates/ts/index.html +12 -0
  64. package/templates/ts/package.json +22 -0
  65. package/templates/ts/public/appsintoss-logo.png +0 -0
  66. package/templates/ts/samples/iaa/src/lib/inAppAds.ts +132 -0
  67. package/templates/ts/samples/iaa/src/pages/InAppAdsPage.css +72 -0
  68. package/templates/ts/samples/iaa/src/pages/InAppAdsPage.ts +85 -0
  69. package/templates/ts/samples/iap/public/icon-document.png +0 -0
  70. package/templates/ts/samples/iap/src/lib/inAppPurchase.ts +114 -0
  71. package/templates/ts/samples/iap/src/pages/InAppPurchasePage.css +115 -0
  72. package/templates/ts/samples/iap/src/pages/InAppPurchasePage.ts +105 -0
  73. package/templates/ts/src/App.css +104 -0
  74. package/templates/ts/src/app.ts +60 -0
  75. package/templates/ts/src/index.css +27 -0
  76. package/templates/ts/src/main.ts +2 -0
  77. package/templates/ts/src/vite-env.d.ts +1 -0
  78. package/templates/ts/tsconfig.app.json +22 -0
  79. package/templates/ts/tsconfig.json +7 -0
  80. package/templates/ts/tsconfig.node.json +20 -0
  81. package/templates/ts/vite.config.ts +3 -0
  82. /package/{template → templates/js}/README.md +0 -0
  83. /package/{template → templates/js}/granite.config.ts +0 -0
  84. /package/{template → templates/js}/public/appsintoss-logo.png +0 -0
  85. /package/{template/__default/__samples → templates/js/samples}/iaa/src/pages/InAppAdsPage.css +0 -0
  86. /package/{template/__default/__samples → templates/js/samples}/iap/public/icon-document.png +0 -0
  87. /package/{template/__default/__samples → templates/js/samples}/iap/src/pages/InAppPurchasePage.css +0 -0
  88. /package/{template/__default → templates/js}/src/App.css +0 -0
  89. /package/{template/__default → templates/js}/src/index.css +0 -0
  90. /package/{template → templates/react-ts}/package.json +0 -0
  91. /package/{template/__default/__samples → templates/react-ts/samples}/iaa/src/hooks/useInAppAds.tsx +0 -0
  92. /package/{template/__default/__samples → templates/react-ts/samples}/iaa/src/pages/InAppAdsPage.tsx +0 -0
  93. /package/{template/__default/__samples → templates/react-ts/samples}/iap/src/hooks/useInAppPurchase.ts +0 -0
  94. /package/{template/__default/__samples → templates/react-ts/samples}/iap/src/pages/InAppPurchasePage.tsx +0 -0
  95. /package/{template/__default → templates/react-ts}/src/App.tsx +0 -0
  96. /package/{template/__default → templates/react-ts}/src/main.tsx +0 -0
  97. /package/{template → templates/react-ts-tds}/eslint.config.js +0 -0
  98. /package/{template → templates/react-ts-tds}/index.html +0 -0
  99. /package/{template/__tds/__samples → templates/react-ts-tds/samples}/iaa/src/hooks/useInAppAds.tsx +0 -0
  100. /package/{template/__tds/__samples → templates/react-ts-tds/samples}/iaa/src/pages/InAppAdsPage.tsx +0 -0
  101. /package/{template/__tds/__samples → templates/react-ts-tds/samples}/iap/public/icon-document.png +0 -0
  102. /package/{template/__tds/__samples → templates/react-ts-tds/samples}/iap/src/hooks/useInAppPurchase.ts +0 -0
  103. /package/{template/__tds/__samples → templates/react-ts-tds/samples}/iap/src/pages/InAppPurchasePage.tsx +0 -0
  104. /package/{template/__tds → templates/react-ts-tds}/src/App.css +0 -0
  105. /package/{template/__tds → templates/react-ts-tds}/src/App.tsx +0 -0
  106. /package/{template/__tds → templates/react-ts-tds}/src/index.css +0 -0
  107. /package/{template/__tds → templates/react-ts-tds}/src/main.tsx +0 -0
  108. /package/{template → templates/react-ts-tds}/src/vite-env.d.ts +0 -0
  109. /package/{template → templates/react-ts-tds}/tsconfig.app.json +0 -0
  110. /package/{template → templates/react-ts-tds}/tsconfig.json +0 -0
  111. /package/{template → templates/react-ts-tds}/tsconfig.node.json +0 -0
  112. /package/{template → templates/react-ts-tds}/vite.config.ts +0 -0
@@ -0,0 +1,65 @@
1
+ function injectReactSamples(
2
+ appContent,
3
+ sampleChoices,
4
+ sampleConfig,
5
+ isTypeScript,
6
+ ) {
7
+ const hasSamples = sampleChoices.length > 0;
8
+ const sampleImports = hasSamples
9
+ ? sampleChoices
10
+ .map((id) => sampleConfig[id]?.import)
11
+ .filter(Boolean)
12
+ .join("\n") + '\nimport { useState } from "react";'
13
+ : "";
14
+ const pageState = isTypeScript
15
+ ? "useState<string | null>(null)"
16
+ : "useState(null)";
17
+ const pageStateAndRoutes = hasSamples
18
+ ? ` const [page, setPage] = ${pageState};\n\n` +
19
+ sampleChoices
20
+ .map((id) => sampleConfig[id]?.route)
21
+ .filter(Boolean)
22
+ .join("\n") +
23
+ "\n\n"
24
+ : "";
25
+ const sampleButtons = hasSamples
26
+ ? sampleChoices
27
+ .map((id) => sampleConfig[id]?.getButton())
28
+ .filter(Boolean)
29
+ .join("\n\n ")
30
+ : "";
31
+
32
+ return appContent
33
+ .replace("{{SAMPLE_IMPORTS}}", sampleImports)
34
+ .replace("{{PAGE_STATE_AND_ROUTES}}", pageStateAndRoutes)
35
+ .replace("{{SAMPLE_BUTTONS}}", sampleButtons);
36
+ }
37
+
38
+ function injectVanillaSamples(appContent, sampleChoices, sampleConfig) {
39
+ const hasSamples = sampleChoices.length > 0;
40
+ const sampleImports = hasSamples
41
+ ? sampleChoices
42
+ .map((id) => sampleConfig[id]?.import)
43
+ .filter(Boolean)
44
+ .join("\n")
45
+ : "";
46
+ const sampleRoutes = hasSamples
47
+ ? sampleChoices
48
+ .map((id) => sampleConfig[id]?.route)
49
+ .filter(Boolean)
50
+ .join("\n")
51
+ : "";
52
+ const sampleButtons = hasSamples
53
+ ? sampleChoices
54
+ .map((id) => sampleConfig[id]?.getButton())
55
+ .filter(Boolean)
56
+ .join("\n\n ")
57
+ : "";
58
+
59
+ return appContent
60
+ .replace("{{SAMPLE_IMPORTS}}", sampleImports)
61
+ .replace("{{SAMPLE_ROUTES}}", sampleRoutes)
62
+ .replace("{{SAMPLE_BUTTONS}}", sampleButtons);
63
+ }
64
+
65
+ module.exports = { injectReactSamples, injectVanillaSamples };
@@ -0,0 +1,111 @@
1
+ const { execSync } = require("child_process");
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+ const { copyDir } = require("./utils/copy-dir");
5
+ const { injectReactSamples, injectVanillaSamples } = require("./sample-inject");
6
+ const { SAMPLE_PRIMARY_COLOR } = require("./templates");
7
+
8
+ function scaffoldProject({
9
+ templateDir,
10
+ targetDir,
11
+ template,
12
+ sampleConfig,
13
+ sampleChoices,
14
+ projectName,
15
+ packageName,
16
+ packageManager,
17
+ }) {
18
+ copyDir(templateDir, targetDir, { exclude: ["samples"] });
19
+
20
+ const pkgPath = path.join(targetDir, "package.json");
21
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
22
+ pkg.name = packageName;
23
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
24
+
25
+ const configPath = path.join(targetDir, "granite.config.ts");
26
+ const configContent = fs.readFileSync(configPath, "utf-8");
27
+ fs.writeFileSync(
28
+ configPath,
29
+ configContent
30
+ .replace("{{APP_NAME}}", projectName)
31
+ .replace(
32
+ "{{PRIMARY_COLOR}}",
33
+ SAMPLE_PRIMARY_COLOR[
34
+ Math.floor(Math.random() * SAMPLE_PRIMARY_COLOR.length)
35
+ ],
36
+ ),
37
+ );
38
+
39
+ const readmePath = path.join(targetDir, "README.md");
40
+ const pmDev =
41
+ packageManager === "npm" ? "npm run dev" : `${packageManager} dev`;
42
+ const pmBuild =
43
+ packageManager === "npm" ? "npm run build" : `${packageManager} build`;
44
+ const pmDeploy =
45
+ packageManager === "npm" ? "npm run deploy" : `${packageManager} deploy`;
46
+
47
+ let readmeContent = fs.readFileSync(readmePath, "utf-8");
48
+ readmeContent = readmeContent
49
+ .replace(/\{\{APP_NAME\}\}/g, projectName)
50
+ .replace(/\{\{PM_DEV\}\}/g, pmDev)
51
+ .replace(/\{\{PM_BUILD\}\}/g, pmBuild)
52
+ .replace(/\{\{PM_DEPLOY\}\}/g, pmDeploy);
53
+ fs.writeFileSync(readmePath, readmeContent);
54
+
55
+ const samplesDir = path.join(templateDir, "samples");
56
+ for (const id of sampleChoices) {
57
+ const sampleRoot = path.join(samplesDir, id);
58
+ if (fs.existsSync(sampleRoot)) {
59
+ copyDir(sampleRoot, targetDir);
60
+ }
61
+ }
62
+
63
+ const appPath = path.join(targetDir, template.appFile);
64
+ if (fs.existsSync(appPath)) {
65
+ let appContent = fs.readFileSync(appPath, "utf-8");
66
+ appContent = template.isVanilla
67
+ ? injectVanillaSamples(appContent, sampleChoices, sampleConfig)
68
+ : injectReactSamples(
69
+ appContent,
70
+ sampleChoices,
71
+ sampleConfig,
72
+ template.isTypeScript,
73
+ );
74
+ fs.writeFileSync(appPath, appContent);
75
+ }
76
+ }
77
+
78
+ function installDependencies(targetDir, packageManager) {
79
+ console.log(`📦 의존성을 설치합니다...\n`);
80
+
81
+ const installCommands = {
82
+ npm: "npm install",
83
+ yarn: "yarn",
84
+ pnpm: "pnpm install",
85
+ };
86
+ execSync(installCommands[packageManager], {
87
+ stdio: "inherit",
88
+ cwd: targetDir,
89
+ });
90
+
91
+ console.log(`\n📦 @apps-in-toss/web-framework 최신 버전을 설치합니다...\n`);
92
+
93
+ const addCmd = { npm: "npm install", yarn: "yarn add", pnpm: "pnpm add" };
94
+ execSync(`${addCmd[packageManager]} @apps-in-toss/web-framework@latest`, {
95
+ stdio: "inherit",
96
+ cwd: targetDir,
97
+ });
98
+ }
99
+
100
+ function formatProject(targetDir, packageManager) {
101
+ console.log(`\n📐 코드 포맷팅을 실행합니다...\n`);
102
+ const formatCmd =
103
+ packageManager === "npm" ? "npm run format" : `${packageManager} format`;
104
+ execSync(formatCmd, { stdio: "inherit", cwd: targetDir });
105
+ }
106
+
107
+ module.exports = {
108
+ scaffoldProject,
109
+ installDependencies,
110
+ formatProject,
111
+ };
package/src/skills.js ADDED
@@ -0,0 +1,66 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const { fetchText } = require("./utils/fetch-text");
4
+
5
+ async function writeAiSkills({ targetDir, aiTool, useTds }) {
6
+ console.log(`\n📄 AI skills 파일을 추가합니다... (${aiTool})\n`);
7
+
8
+ const aitDocs = await fetchText(
9
+ "https://developers-apps-in-toss.toss.im/llms.txt",
10
+ );
11
+ let tdsDocs;
12
+ if (useTds) {
13
+ tdsDocs = await fetchText(
14
+ "https://tossmini-docs.toss.im/tds-mobile/llms-full.txt",
15
+ );
16
+ }
17
+
18
+ if (aiTool === "cursor") {
19
+ const skillsDir = path.join(targetDir, ".cursor", "skills");
20
+ fs.mkdirSync(skillsDir, { recursive: true });
21
+ fs.writeFileSync(path.join(skillsDir, "apps-in-toss.md"), aitDocs);
22
+ console.log(" ✓ .cursor/skills/apps-in-toss.md 추가 완료");
23
+ if (tdsDocs) {
24
+ fs.writeFileSync(path.join(skillsDir, "tds-mobile.md"), tdsDocs);
25
+ console.log(" ✓ .cursor/skills/tds-mobile.md 추가 완료");
26
+ }
27
+ } else if (aiTool === "claude") {
28
+ const docsDir = path.join(targetDir, "docs", "skills");
29
+ fs.mkdirSync(docsDir, { recursive: true });
30
+
31
+ fs.writeFileSync(path.join(docsDir, "apps-in-toss.md"), aitDocs);
32
+ console.log(" ✓ docs/skills/apps-in-toss.md 추가 완료");
33
+
34
+ if (tdsDocs) {
35
+ fs.writeFileSync(path.join(docsDir, "tds-mobile.md"), tdsDocs);
36
+ console.log(" ✓ docs/skills/tds-mobile.md 추가 완료");
37
+ }
38
+
39
+ let claudeMd = "@docs/skills/apps-in-toss.md\n";
40
+ if (tdsDocs) {
41
+ claudeMd += "@docs/skills/tds-mobile.md\n";
42
+ }
43
+ fs.writeFileSync(path.join(targetDir, "CLAUDE.md"), claudeMd);
44
+ console.log(" ✓ CLAUDE.md 추가 완료");
45
+ } else if (aiTool === "codex") {
46
+ const docsDir = path.join(targetDir, "docs", "skills");
47
+ fs.mkdirSync(docsDir, { recursive: true });
48
+
49
+ fs.writeFileSync(path.join(docsDir, "apps-in-toss.md"), aitDocs);
50
+ console.log(" ✓ docs/skills/apps-in-toss.md 추가 완료");
51
+
52
+ if (tdsDocs) {
53
+ fs.writeFileSync(path.join(docsDir, "tds-mobile.md"), tdsDocs);
54
+ console.log(" ✓ docs/skills/tds-mobile.md 추가 완료");
55
+ }
56
+
57
+ let agentsMd = "docs/skills/apps-in-toss.md 파일을 참고하세요.\n";
58
+ if (tdsDocs) {
59
+ agentsMd += "docs/skills/tds-mobile.md 파일을 참고하세요.\n";
60
+ }
61
+ fs.writeFileSync(path.join(targetDir, "AGENTS.md"), agentsMd);
62
+ console.log(" ✓ AGENTS.md 추가 완료");
63
+ }
64
+ }
65
+
66
+ module.exports = { writeAiSkills };
@@ -0,0 +1,88 @@
1
+ const path = require("path");
2
+ const {
3
+ REACT_SAMPLE_CONFIG,
4
+ REACT_JS_SAMPLE_CONFIG,
5
+ REACT_TDS_SAMPLE_CONFIG,
6
+ JS_SAMPLE_CONFIG,
7
+ TS_SAMPLE_CONFIG,
8
+ } = require("./sample-configs");
9
+
10
+ const TEMPLATES_DIR = path.resolve(__dirname, "..", "templates");
11
+
12
+ const TEMPLATE_IDS = ["react-ts", "react", "js", "ts"];
13
+
14
+ const TEMPLATE_REGISTRY = {
15
+ "react-ts": {
16
+ appFile: "src/App.tsx",
17
+ isVanilla: false,
18
+ isTypeScript: true,
19
+ useTds: false,
20
+ sampleConfig: REACT_SAMPLE_CONFIG,
21
+ },
22
+ react: {
23
+ appFile: "src/App.jsx",
24
+ isVanilla: false,
25
+ isTypeScript: false,
26
+ useTds: false,
27
+ sampleConfig: REACT_JS_SAMPLE_CONFIG,
28
+ },
29
+ "react-ts-tds": {
30
+ appFile: "src/App.tsx",
31
+ isVanilla: false,
32
+ isTypeScript: true,
33
+ useTds: true,
34
+ sampleConfig: REACT_TDS_SAMPLE_CONFIG,
35
+ },
36
+ js: {
37
+ appFile: "src/app.js",
38
+ isVanilla: true,
39
+ isTypeScript: false,
40
+ useTds: false,
41
+ sampleConfig: JS_SAMPLE_CONFIG,
42
+ },
43
+ ts: {
44
+ appFile: "src/app.ts",
45
+ isVanilla: true,
46
+ isTypeScript: true,
47
+ useTds: false,
48
+ sampleConfig: TS_SAMPLE_CONFIG,
49
+ },
50
+ };
51
+
52
+ const SAMPLE_PRIMARY_COLOR = [
53
+ "#FF8A65",
54
+ "#FD9B3C",
55
+ "#E0B20C",
56
+ "#3FD599",
57
+ "#81C784",
58
+ "#4DB6AC",
59
+ "#4DD0E1",
60
+ "#64B5F6",
61
+ "#655DFF",
62
+ "#9575CD",
63
+ "#BA68C8",
64
+ "#FF91D5",
65
+ "#F06292",
66
+ "#D7B59E",
67
+ ];
68
+
69
+ const TEMPLATE_CHOICES = [
70
+ { name: "react-ts — React + TypeScript (기본)", value: "react-ts" },
71
+ { name: "react — React + JavaScript", value: "react" },
72
+ { name: "js — Vanilla JavaScript", value: "js" },
73
+ { name: "ts — Vanilla TypeScript", value: "ts" },
74
+ ];
75
+
76
+ function resolveTemplateFolder(baseTemplateId, useTds) {
77
+ if (baseTemplateId === "react-ts" && useTds) return "react-ts-tds";
78
+ return baseTemplateId;
79
+ }
80
+
81
+ module.exports = {
82
+ TEMPLATES_DIR,
83
+ TEMPLATE_IDS,
84
+ TEMPLATE_REGISTRY,
85
+ TEMPLATE_CHOICES,
86
+ SAMPLE_PRIMARY_COLOR,
87
+ resolveTemplateFolder,
88
+ };
@@ -0,0 +1,19 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+
4
+ function copyDir(src, dest, { exclude = [] } = {}) {
5
+ fs.mkdirSync(dest, { recursive: true });
6
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
7
+ if (exclude.includes(entry.name)) continue;
8
+
9
+ const srcPath = path.join(src, entry.name);
10
+ const destPath = path.join(dest, entry.name);
11
+ if (entry.isDirectory()) {
12
+ copyDir(srcPath, destPath, { exclude });
13
+ } else {
14
+ fs.copyFileSync(srcPath, destPath);
15
+ }
16
+ }
17
+ }
18
+
19
+ module.exports = { copyDir };
@@ -0,0 +1,47 @@
1
+ const https = require("https");
2
+ const http = require("http");
3
+ const zlib = require("zlib");
4
+
5
+ function fetchText(url) {
6
+ return new Promise((resolve, reject) => {
7
+ const get = (targetUrl) => {
8
+ const client = targetUrl.startsWith("https") ? https : http;
9
+ const req = client.get(
10
+ targetUrl,
11
+ { headers: { "Accept-Encoding": "gzip, deflate" } },
12
+ (res) => {
13
+ if (
14
+ res.statusCode >= 300 &&
15
+ res.statusCode < 400 &&
16
+ res.headers.location
17
+ ) {
18
+ get(res.headers.location);
19
+ return;
20
+ }
21
+ if (res.statusCode !== 200) {
22
+ reject(new Error(`HTTP ${res.statusCode} for ${targetUrl}`));
23
+ return;
24
+ }
25
+
26
+ let stream = res;
27
+ const encoding = res.headers["content-encoding"];
28
+ if (encoding === "gzip") {
29
+ stream = res.pipe(zlib.createGunzip());
30
+ } else if (encoding === "deflate") {
31
+ stream = res.pipe(zlib.createInflate());
32
+ }
33
+
34
+ let data = "";
35
+ stream.setEncoding("utf-8");
36
+ stream.on("data", (chunk) => (data += chunk));
37
+ stream.on("end", () => resolve(data));
38
+ stream.on("error", reject);
39
+ },
40
+ );
41
+ req.on("error", reject);
42
+ };
43
+ get(url);
44
+ });
45
+ }
46
+
47
+ module.exports = { fetchText };
@@ -0,0 +1,26 @@
1
+ function toNpmPackageName(input) {
2
+ const raw = String(input || "").trim();
3
+ if (!raw) return "my-app";
4
+
5
+ if (raw.startsWith("@")) {
6
+ const slash = raw.indexOf("/");
7
+ if (slash > 1) {
8
+ const scope = raw.slice(0, slash).toLowerCase();
9
+ const name = raw.slice(slash + 1);
10
+ const normalizedName = toNpmPackageName(name);
11
+ return `${scope}/${normalizedName}`;
12
+ }
13
+ }
14
+
15
+ return (
16
+ raw
17
+ .toLowerCase()
18
+ .replace(/\s+/g, "-")
19
+ .replace(/[^a-z0-9._-]/g, "-")
20
+ .replace(/-+/g, "-")
21
+ .replace(/^[._-]+/, "")
22
+ .replace(/[._-]+$/, "") || "my-app"
23
+ );
24
+ }
25
+
26
+ module.exports = { toNpmPackageName };
@@ -0,0 +1,14 @@
1
+ import js from "@eslint/js";
2
+ import globals from "globals";
3
+
4
+ export default [
5
+ { ignores: ["dist"] },
6
+ {
7
+ files: ["**/*.{js,mjs}"],
8
+ ...js.configs.recommended,
9
+ languageOptions: {
10
+ ecmaVersion: 2020,
11
+ globals: globals.browser,
12
+ },
13
+ },
14
+ ];
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>AIT App</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.js"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "{{APP_NAME}}",
3
+ "private": true,
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "granite dev",
8
+ "build": "ait build",
9
+ "deploy": "ait deploy",
10
+ "lint": "eslint .",
11
+ "format": "prettier --write ."
12
+ },
13
+ "devDependencies": {
14
+ "@eslint/js": "^9.21.0",
15
+ "eslint": "^9.21.0",
16
+ "globals": "^15.15.0",
17
+ "prettier": "^3.4.2",
18
+ "vite": "^6.2.0"
19
+ }
20
+ }
@@ -0,0 +1,119 @@
1
+ import {
2
+ loadFullScreenAd,
3
+ showFullScreenAd,
4
+ } from "@apps-in-toss/web-framework";
5
+
6
+ // 참고문서: https://developers-apps-in-toss.toss.im/ads/intro.html
7
+ export function createInAppAds(adGroupId) {
8
+ const state = {
9
+ isAdLoaded: false,
10
+ isSupported: false,
11
+ lastReward: null,
12
+ };
13
+
14
+ let onUpdate = () => {};
15
+ let unregister = null;
16
+
17
+ function notify() {
18
+ onUpdate();
19
+ }
20
+
21
+ function load() {
22
+ state.isAdLoaded = false;
23
+ notify();
24
+
25
+ try {
26
+ unregister = loadFullScreenAd({
27
+ options: { adGroupId },
28
+ onEvent: (event) => {
29
+ if (event.type === "loaded") {
30
+ state.isAdLoaded = true;
31
+ notify();
32
+ }
33
+ },
34
+ onError: (error) => {
35
+ console.error("광고 로드 실패:", error);
36
+ },
37
+ });
38
+ } catch (error) {
39
+ alert(
40
+ "광고 로드 실패: \n\n- 인앱광고 기능은 브라우저가 아닌 샌드박스앱/토스앱에서 실행해주세요\n\n" +
41
+ error,
42
+ );
43
+ }
44
+ }
45
+
46
+ function showAd() {
47
+ if (!state.isSupported) {
48
+ console.info("현재 환경에서는 인앱 광고가 지원되지 않습니다.");
49
+ return;
50
+ }
51
+
52
+ if (!state.isAdLoaded) {
53
+ console.info("아직 광고가 로드되지 않았습니다.");
54
+ return;
55
+ }
56
+
57
+ try {
58
+ showFullScreenAd({
59
+ options: { adGroupId },
60
+ onEvent: (event) => {
61
+ switch (event.type) {
62
+ case "userEarnedReward":
63
+ state.lastReward = event.data;
64
+ notify();
65
+ break;
66
+ case "dismissed":
67
+ state.isAdLoaded = false;
68
+ notify();
69
+ load();
70
+ break;
71
+ case "failedToShow":
72
+ console.error("광고 표시 실패");
73
+ state.isAdLoaded = false;
74
+ notify();
75
+ load();
76
+ break;
77
+ }
78
+ },
79
+ onError: (error) => {
80
+ console.error("광고 표시 실패:", error);
81
+ state.isAdLoaded = false;
82
+ notify();
83
+ load();
84
+ },
85
+ });
86
+ } catch (error) {
87
+ console.error("광고 표시 실패:", error);
88
+ state.isAdLoaded = false;
89
+ notify();
90
+ load();
91
+ }
92
+ }
93
+
94
+ try {
95
+ state.isSupported = loadFullScreenAd.isSupported();
96
+ if (state.isSupported) {
97
+ load();
98
+ }
99
+ } catch (error) {
100
+ console.error("광고 지원 여부 확인 실패:", error);
101
+ state.isSupported = false;
102
+ }
103
+
104
+ return {
105
+ getState: () => state,
106
+ showAd,
107
+ subscribe: (listener) => {
108
+ onUpdate = listener;
109
+ return () => {
110
+ onUpdate = () => {};
111
+ try {
112
+ unregister?.();
113
+ } catch (error) {
114
+ console.error("광고 정리(cleanup) 중 에러:", error);
115
+ }
116
+ };
117
+ },
118
+ };
119
+ }
@@ -0,0 +1,83 @@
1
+ import { createInAppAds } from "../lib/inAppAds.js";
2
+ import "./InAppAdsPage.css";
3
+
4
+ // TODO: 서비스를 출시하기 전에 앱인토스 콘솔에서 발급한 광고그룹ID로 변경해주세요.
5
+ const TEST_INTERSTITIAL_ID = "ait-ad-test-interstitial-id";
6
+ const TEST_REWARDED_ID = "ait-ad-test-rewarded-id";
7
+
8
+ export function mountInAppAdsPage(onBack) {
9
+ const root = document.getElementById("root");
10
+ const interstitial = createInAppAds(TEST_INTERSTITIAL_ID);
11
+ const rewarded = createInAppAds(TEST_REWARDED_ID);
12
+
13
+ function render() {
14
+ const interstitialState = interstitial.getState();
15
+ const rewardedState = rewarded.getState();
16
+
17
+ root.innerHTML = `
18
+ <div class="app-header">
19
+ <h1 class="page-title">인앱광고</h1>
20
+ ${
21
+ !interstitialState.isSupported
22
+ ? '<p class="page-subtitle">이 환경에서는 인앱 광고를 사용할 수 없어요.</p>'
23
+ : ""
24
+ }
25
+ </div>
26
+
27
+ <div class="iaa-section-list">
28
+ <div class="iaa-section">
29
+ <div class="iaa-section-row">
30
+ <div class="iaa-section-info">
31
+ <h2 class="iaa-section-title">전면형 광고</h2>
32
+ <p class="iaa-section-desc">화면 전체에 표시되는 광고</p>
33
+ </div>
34
+ <button
35
+ type="button"
36
+ class="iaa-section-button"
37
+ data-action="show-interstitial"
38
+ ${interstitialState.isAdLoaded ? "" : "disabled"}
39
+ >
40
+ ${interstitialState.isAdLoaded ? "보기" : "로딩 중"}
41
+ </button>
42
+ </div>
43
+ </div>
44
+
45
+ <div class="iaa-section">
46
+ <div class="iaa-section-row">
47
+ <div class="iaa-section-info">
48
+ <h2 class="iaa-section-title">보상형 광고</h2>
49
+ <p class="iaa-section-desc">시청 완료 시 보상을 받는 광고</p>
50
+ </div>
51
+ <button
52
+ type="button"
53
+ class="iaa-section-button"
54
+ data-action="show-rewarded"
55
+ ${rewardedState.isAdLoaded ? "" : "disabled"}
56
+ >
57
+ ${rewardedState.isAdLoaded ? "보기" : "로딩 중"}
58
+ </button>
59
+ </div>
60
+ ${
61
+ rewardedState.lastReward
62
+ ? `<p class="iaa-reward-message">보상 획득: ${rewardedState.lastReward.unitType} ${rewardedState.lastReward.unitAmount}개</p>`
63
+ : ""
64
+ }
65
+ </div>
66
+ </div>
67
+
68
+ <button type="button" class="text-button iaa-back-btn" data-action="back">← 홈으로</button>
69
+ `;
70
+
71
+ root
72
+ .querySelector('[data-action="show-interstitial"]')
73
+ ?.addEventListener("click", () => interstitial.showAd());
74
+ root
75
+ .querySelector('[data-action="show-rewarded"]')
76
+ ?.addEventListener("click", () => rewarded.showAd());
77
+ root.querySelector('[data-action="back"]')?.addEventListener("click", onBack);
78
+ }
79
+
80
+ interstitial.subscribe(render);
81
+ rewarded.subscribe(render);
82
+ render();
83
+ }