test(ci): add exporter heading levels and a11y typography smoke; update MAIN_PLAN
CI / test (pull_request) Has been cancelled

This commit is contained in:
2025-11-14 16:40:45 +09:00
parent aaa624a046
commit cd6a6ea069
93 changed files with 8543 additions and 13 deletions
+33
View File
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest'
import { generateAppsScriptTemplate } from '@/lib/integrations/sheets'
describe('Google Sheets Apps Script 템플릿', () => {
it('시트 ID와 허용 오리진으로 Apps Script 코드와 가이드를 생성한다', () => {
const out = generateAppsScriptTemplate({
sheetId: '1AbcDEFghiJKL_sheet_id',
allowedOrigins: ['https://example.com', 'http://localhost:3000'],
minSubmitSeconds: 2,
honeypotFieldName: '_hp',
sheetName: 'Responses',
})
expect(typeof out.code).toBe('string')
expect(typeof out.readme).toBe('string')
// 필수 키워드 포함 검증
expect(out.code).toContain('function doPost(e)')
expect(out.code).toContain('SpreadsheetApp.openById')
expect(out.code).toContain('e.postData')
expect(out.code).toContain('ContentService.createTextOutput')
expect(out.code).toContain('Access-Control-Allow-Origin')
// 환경설정 치환값 반영
expect(out.code).toContain('1AbcDEFghiJKL_sheet_id')
expect(out.code).toContain('Responses')
expect(out.code).toContain('_hp')
expect(out.code).toContain('minSubmitSeconds')
// 배포 가이드에 웹 앱 배포 안내 포함
expect(out.readme).toMatch(/Deploy.*as Web app/i)
})
})
+132
View File
@@ -0,0 +1,132 @@
type TemplateParams = {
sheetId: string
allowedOrigins: string[]
minSubmitSeconds: number
honeypotFieldName: string
sheetName?: string
}
export function generateAppsScriptTemplate(params: TemplateParams) {
const { sheetId, allowedOrigins, minSubmitSeconds, honeypotFieldName, sheetName = 'Responses' } = params
const code = `/**
* Apps Script Web App for accepting HTML form submissions and writing to Google Sheets.
* Deployed as Web App with Anyone (even anonymous) or Anyone within domain access, as needed.
*/
const CONFIG = {
SHEET_ID: '${sheetId}',
SHEET_NAME: '${sheetName}',
ALLOWED_ORIGINS: ${JSON.stringify(allowedOrigins)},
minSubmitSeconds: ${minSubmitSeconds},
HONEYPOT: '${honeypotFieldName}',
};
function doOptions(e) {
return corsResponse('', 204);
}
function doPost(e) {
try {
const origin = (e?.parameter?.origin) || (e?.headers && e.headers['Origin']) || '';
const now = Date.now();
const params = parseBody(e);
// CORS check
enforceCors(origin);
// Spam protection: honeypot must be empty
if (params[CONFIG.HONEYPOT]) {
return corsResponse(JSON.stringify({ ok: true }), 200, origin); // pretend success
}
// Spam protection: minimum submit time
const ts = Number(params['_ts'] || 0);
if (!isFinite(ts) || now - ts < CONFIG.minSubmitSeconds * 1000) {
return corsResponse(JSON.stringify({ ok: true }), 200, origin); // pretend success
}
// Write to sheet
const ss = SpreadsheetApp.openById(CONFIG.SHEET_ID);
const sh = ss.getSheetByName(CONFIG.SHEET_NAME) || ss.getSheets()[0];
const entries = Object.keys(params).filter(k => k !== CONFIG.HONEYPOT);
const values = entries.map(k => String(params[k]));
sh.appendRow([new Date()].concat(values));
return corsResponse(JSON.stringify({ ok: true }), 200, origin);
} catch (err) {
return corsResponse(JSON.stringify({ ok: false, error: String(err) }), 500);
}
}
function parseBody(e) {
// Accept form-urlencoded or JSON
if (e?.postData?.type === 'application/json') {
try { return JSON.parse(e.postData.contents || '{}'); } catch (_) { return {}; }
}
// Parse form-urlencoded
const raw = (e?.postData?.contents || '');
const out = {};
raw.split('&').forEach(p => {
const [k, v] = p.split('=');
if (k) out[decodeURIComponent(k)] = decodeURIComponent((v || '').replace(/\+/g, ' '));
});
// Also include e.parameter for safety
if (e?.parameter) {
Object.keys(e.parameter).forEach(k => { if (!(k in out)) out[k] = e.parameter[k]; });
}
return out;
}
function enforceCors(origin) {
if (!CONFIG.ALLOWED_ORIGINS || CONFIG.ALLOWED_ORIGINS.length === 0) return;
if (CONFIG.ALLOWED_ORIGINS.indexOf(origin) === -1) {
throw new Error('CORS: Origin not allowed: ' + origin);
}
}
function corsResponse(body, status, origin) {
const output = ContentService.createTextOutput(body).setMimeType(ContentService.MimeType.JSON);
const resp = HtmlService.createHtmlOutput().getResponse();
resp.setContent(output.getContent());
resp.setResponseCode(status || 200);
resp.setHeader('Access-Control-Allow-Origin', origin || '*');
resp.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
resp.setHeader('Access-Control-Allow-Headers', 'Content-Type');
return resp;
}
`;
const readme = `# Google Apps Script Web App (Google Sheets 저장)
이 스크립트는 HTML 폼 제출을 받아 Google 스프레드시트에 행을 추가합니다.
## 준비물
- Google 스프레드시트 생성 후 시트 ID 확인(주소창의 /d/ 와 /edit 사이)
- Apps Script 에디터 열기(스프레드시트 -> 확장 프로그램 -> Apps Script)
## 사용 방법
1. 새 프로젝트 생성 후, Code.gs 에 아래 코드 붙여넣기
2. CONFIG 내 값을 편집
- SHEET_ID: ${sheetId}
- SHEET_NAME: ${sheetName}
- ALLOWED_ORIGINS: ${JSON.stringify(allowedOrigins)}
- MIN_SUBMIT_SECONDS: ${minSubmitSeconds}
- HONEYPOT: ${honeypotFieldName}
3. 배포
- 상단 메뉴: Deploy -> Deploy as Web app
- 새 배포 -> Entry point: doPost
- Access: Anyone (권장) 또는 필요 정책에 맞게 설정
- Deploy를 눌러 Web app URL 획득
4. 빌더에서 폼 action URL 에 위 Web app URL 입력
## CORS 및 스팸 방지
- Access-Control-Allow-Origin 헤더가 설정됩니다.
- 허용 오리진만 통과하도록 enforceCors를 사용합니다.
- honeypot 필드와 제출 타임스탬프(_ts)로 기본 스팸 방지 동작을 합니다.
## 테스트
- doPost, SpreadsheetApp 관련 호출은 Apps Script 런타임에서 동작합니다.
`
return { code, readme }
}