scx 템플릿

.scx는 SafeC 버전의 JSX/TSX/RSX입니다: 태그 단위로 문자열을 직접 만들지 않고 서버 렌더링 페이지를 작성하기 위해 소스에 직접 내장되는 HTML과 비슷한 마크업입니다. JSX가 React.createElement로, rsx!가 DOM 연산으로 변환되는 것처럼, 이것도 트랜스파일러입니다 — .scx 파일은 평범한 SafeC 소스에 값으로서의 마크업이 더해진 것으로, safec이 파일을 보기 전에 평범한 SafeC 호출로 다시 작성됩니다. safeguard build/check/test는 발견하는 모든 .scx 파일에 대해 이를 자동으로 수행합니다; safec 자체는 .scx의 존재를 전혀 모릅니다.

이는 의도적으로 "SafeC를 위한 JSX"의 가장 작고 유용한 버전입니다: 가상 DOM이 아니라 HTML 문자열(struct String)을 만듭니다 — 서버 사이드 렌더링(페이지를 만들어 std::http_serve에 응답 본문으로 넘기는 것)에는 적합하지만, 클라이언트 사이드 반응성이나 차등 재렌더링에는 적합하지 않습니다.

문법

마크업은 세 가지 문장 위치에 나타날 수 있습니다: return의 피연산자, 선언의 초기화식, 대입문의 오른쪽.

c
struct String render_home(const &char visitor_name) {
    return <html>
        <head><title>My Page</title></head>
        <body>
            <h1>Hello, {visitor_name}!</h1>
            <p>Welcome.</p>
        </body>
    </html>;
}

void fill(&Page page, const &char title) {
    struct String heading = <h1 class="title">{title}</h1>;   // 초기화식
    if (page.draft) page.banner = <p class="draft">Draft</p>;  // 대입(어떤 lvalue든)
    page.body.push_str(&heading);
    heading.free();
}
  • 태그는 식별자입니다([a-zA-Z][a-zA-Z0-9-]*, 커스텀 엘리먼트를 위해 하이픈이 허용됩니다), HTML/XML처럼 열고 닫습니다: <div>...</div> 또는 자체 종료 형태인 <br/>.
  • 속성: name="literal text", name={expr}, 또는 값 없는 name (name=""으로 출력).
  • {expr}은 SafeC 표현식의 값을 출력에 보간하며, HTML 이스케이프 처리됩니다(&, <, >, ", '가 엔티티가 됩니다) — JSX가 {expr} 자식을 자동 이스케이프하는 것과 같은 안전한 기본 동작입니다.
  • {!expr}은 원시, 이스케이프되지 않은 형태로 보간합니다 — 미리 렌더링된 신뢰할 수 있는 HTML을 내장할 때 사용합니다(아래 페이지 조합하기 참고).
  • 마크업은 프래그먼트일 수 있습니다: 여러 최상위 엘리먼트와 함께 <!doctype html> 선언과 <!-- 주석 -->을 그대로 유지합니다 — 그래서 페이지 전체를 return <!doctype html><html>...</html>;로 쓸 수 있습니다.
  • <script>와 <style>은 원시 텍스트를 담습니다: 닫는 태그까지의 모든 내용이 쓴 그대로 복사되므로 JavaScript와 CSS의 중괄호를 이스케이프할 필요가 없습니다(그 안에서는 {...} 보간도 없습니다).
  • 마크업 바깥의 모든 것은 수정되지 않은, 평범한 SafeC입니다 — import, 구조체, 일반 함수. .scx 파일의 마크업이 아닌 대다수 부분은 정확히 .sc 파일과 같습니다; 문자열/문자 리터럴과 ////* */ 주석은 손대지 않고 그대로 훑고 지나가므로, 그 안에 있는 우연한 <나 return이 마크업으로 오인되는 일은 없습니다. 비교 연산도 마크업으로 오인되지 않습니다: 마크업은 표현식이 시작되어야 하는 곳(return이나 = 뒤)에서만 시작합니다.
  • 따옴표 형식의 #include "header.h"는 .sc 파일에서처럼 .scx 파일 옆에서 찾습니다(트랜스파일된 소스는 build/scx/에서 컴파일되므로, scx가 경로를 절대 경로로 바꿉니다).

출력은 빈 엘리먼트에 대한 HTML 규칙을 따릅니다: void 엘리먼트(br, hr, img, input, link, meta, source, ...)는 시작 태그만 쓰고, 그 밖의 빈 엘리먼트는 닫는 태그를 붙이며(<div></div>, <script src="app.js"></script> — HTML에서 <div/>는 열린 태그로 파싱됩니다), <svg>/<math> 안에서만 빈 엘리먼트를 자체 종료 형태로 씁니다(<path d="..."/>).

모든 {expr}/{!expr} 표현식은 C 문자열, 즉 const &char로 평가되어야 합니다 — 문자열 리터럴, const &char 매개변수, 또는 String의 as_ptr(). 숫자는 먼저 버퍼에 서식화합니다(예: std::fmt_int). scx는 자체적으로 타입 검사를 하지 않으므로(Sema가 실행되기 전에 텍스트 수준 패스로 실행됩니다), 타입 불일치는 생성된 코드에 대한 평범한 safec 컴파일 오류로 나타나며, 합성된 호출 지점을 가리킵니다.

왜 임의의 표현식이 아니라 문장인가

진짜 JSX/TSX는 마크업을 호스트 언어의 전체 표현식 문법에 통합합니다 (인자로 전달 가능하고, 삼항 연산자에 사용 가능하고, 루프 안에 내장 가능합니다). 이는 그들의 트랜스파일러가 표현식 컨텍스트를 이미 이해하는 진짜 JavaScript/TypeScript 파서 위에 구축되어 있기 때문입니다. scx의 트랜스파일러는 가볍고 독립적인 텍스트 수준 패스입니다 — safec의 파서를 공유하지 않으며, SafeC에는 표현식 중간에 여러 문장으로 값을 구성하기 위해 기댈 만한 문장-표현식 구문(({ ... }))도 없습니다.

마크업을 문장 전체 — return <markup>;, T name = <markup>;, lvalue = <markup>; — 로 제한하면 이 모든 문제를 우회할 수 있습니다: 생성된 빌더 문들은 원래 문장이 있던 바로 그 자리에, 같은 함수 안에, 이미 스코프에 있는 같은 매개변수와 지역 변수와 함께 들어갑니다 — 호이스팅도, 캡처 분석도, safec 자체에 새로운 문법도 필요 없습니다. scx에서 "컴포넌트"는 단순히 마크업을 반환하는 함수입니다 — struct String을 반환하는 render_x(...)를 작성하고 다른 함수처럼 호출하면 됩니다.

페이지 조합하기

컴포넌트가 그저 함수일 뿐이므로, 조합은 한 함수를 다른 함수에서 호출하는 것을 의미합니다 — 먼저 자식을 렌더링한 다음, 이미 만들어진 HTML을 {!...}로 붙여넣습니다:

c
struct String render_item(const &char name, int qty) {
    char qty_text[24];
    std::fmt_int(&qty_text[0], 24UL, (long long)qty);
    return <li>{name} &times; {qty_text}</li>;
}

struct String render_cart(const &String items_html) {
    return <ul>{!items_html.as_ptr()}</ul>;
}

호출자는 평범한 SafeC 코드(render_item(...) 호출을 순회하며 각각을 push(item.as_ptr())로 덧붙이는 루프)로 items_html을 만들어 참조로 넘긴 다음, 완성되어 필요한 곳은 이미 이스케이프된 결과를 {!...}로 원시 그대로 보간합니다 — {...}를 사용하면 이중으로 이스케이프됩니다. 이는 가상 DOM 없는 템플릿 엔진이 조합을 처리해야 하는 방식을 그대로 반영합니다: 프레임워크가 중첩된 엘리먼트 트리를 알아서 비교해주는 대신, 먼저 내부 콘텐츠를 만들고 그다음 이어 붙이는 것입니다.

런타임

생성된 코드는 std/scx/scx.h 아래의 작은 런타임을 호출합니다:

c
namespace std {
void scx_append_esc(&String buf, const &char s); // HTML 이스케이프 + 추가
}

safeguard는 마크업을 사용한 모든 파일에 #include <std/scx/scx.h>와 #include <std/collections/string.h>를 자동으로 주입합니다 — 손으로 작성한 .scx 소스는 이를 직접 포함하지 않습니다. 태그 사이의 리터럴 텍스트는 작성자가 쓴 것이므로 JSX처럼 그대로 출력되며(따라서 &times; 같은 엔티티도 그대로 통과합니다), 정적 속성 값은 트랜스파일 시점에 한 번 HTML 이스케이프됩니다. 정적 콘텐츠의 연속 구간마다 단일 String::push("...") 호출이 되며, 오직 {expr} 보간 지점만 런타임 호출 비용이 듭니다 — 생성된 코드는 다음과 같이 보입니다:

c
struct String __scx0 = std::String::new();
unsafe {
    __scx0.push("<h1>Hello, ");
    std::scx_append_esc(&__scx0, (visitor_name));
    __scx0.push("!</h1>");
}
return __scx0;

에디터 지원

sc-lsp(컴파일러 아키텍처의 도구 섹션과 sc-language-server의 README 참고)는 .scx를 직접 이해합니다 — SafeC 확장이 설치된 에디터에서 파일을 열면 .sc 파일과 동일한 진단, 호버, 정의로 이동, 완성, 문서 개요를 얻을 수 있습니다. 내부적으로 서버는 safeguard build가 사용하는 것과 같은 트랜스파일러로 버퍼를 트랜스파일하고(하나의 공유된 사본이므로 둘이 서로 어긋날 수 없습니다), 생성된 SafeC를 분석한 다음, 보고된 모든 위치를 .scx 버퍼 자신의 줄로 다시 매핑합니다 — 생성된 코드는 결코 보이지 않습니다. 그 매핑은 줄 단위입니다: 마크업 확장(대개의 일반적인 파일에서 대부분을 차지하는 부분) 바깥에서는 정확하며, 마크업 내부의 진단(예: {expr} 보간의 타입 오류)에 대해서는 정확한 하위 줄이 아니라 마크업 블록이 자신이 확장되는 코드와 1:1로 대응하지 않으므로 이를 감싸는 마크업 문장이 시작되는 줄을 가리킵니다. 잘못된 형식의 마크업 자체(닫는 태그 불일치, 종료되지 않은 {)도 트랜스파일러가 이를 발견한 정확한 줄에서 동일한 방식으로 보고됩니다.

프론트엔드 기능 게이팅하기

.scx 파일은 평범한 프로젝트 소스입니다 — safeguard는 기능 플래그와 무관하게 src/ 아래에서 발견하는 모든 파일을 컴파일하며, 이는 어떤 .sc 파일이든 마찬가지입니다. 프론트엔드/백엔드 분리는 파일을 제외하는 것이 아니라 scx로 렌더링된 페이지를 조건부로 호출하는 것에서 나옵니다:

c
// src/main.sc
#ifdef SAFEC_FEATURE_FRONTEND
struct String render_home(const &char visitor_name); // src/pages.scx
#endif

int main() {
#ifdef SAFEC_FEATURE_BACKEND
    std::http_serve((unsigned short)8123, handle); // handle()은 render_home()을 호출함
#else
    struct String page = render_home("static build");
    unsafe { printf("%s\n", page.as_ptr()); }
    page.free();
#endif
    return 0;
}

이 예제가 바탕으로 하는 완전하고 실제로 동작하는 프로젝트는 SafeC 저장소의 examples/fullstack_demo/를 참고하십시오 — frontend/backend/fullstack 기능을 갖춘 Package.toml, 세 개의 렌더링된 페이지를 가진 src/pages.scx, 그리고 fullstack/backend 모드에서는 HTTP로 페이지를 제공하고 frontend 전용 모드에서는 표준 출력으로 하나를 렌더링하는 src/main.sc가 있습니다:

bash
cd examples/fullstack_demo
safeguard run                                             # fullstack: HTTP 서버, HTML 페이지
safeguard run --features backend  --no-default-features   # HTTP 서버, JSON만
safeguard run --features frontend --no-default-features   # 서버 없음 — 페이지 하나를 렌더링하고 출력