편집기 지원 (템플릿)

템플릿을 편집하는 동안 컴포넌트·핸들러·수정자·슬롯·태그·필터의 이름을 편집기가 알게 한다. 이름을 틀리면 페이지를 그려 볼 때가 아니라 치는 자리에서 안다.

두 갈래가 있다. 파이썬 쪽은 타입 스텁이 맡는다 — 컴포넌트 클래스의 .pyi로 pyright·mypy와 IDE가 필드와 핸들러를 안다. 템플릿 쪽이 이 문서다. manage.py wireview_lsp가 프로젝트를 읽어 JSON 하나로 내고, 편집기가 그것을 읽는다. 이 저장소의 editors/vscode/에 VS Code 확장이 있다.

VS Code 확장#

Marketplace에는 아직 올리지 않았다. 저장소에서 .vsix를 만들어 설치한다.

make ext-install    # editors/vscode 의 npm 의존성
make ext-package    # editors/vscode/dist/django-wireview-<버전>.vsix
code --install-extension editors/vscode/dist/django-wireview-0.1.0.vsix

할 수 있는 일, 설정, 알려진 한계는 확장의 README에 있다. 요약하면:

  • **/templates/**/*.html과, 메타데이터의 template_dirs 안에 있는 .html을 django-html 언어로 열고 구문 강조·주석·자동 닫기·들여쓰기를 준다. files.associations가 html로 적은 파일과 사용자가 html로 되돌린 문서는 그대로 둔다. 언어가 html이 아니게 되어 잃는 HTML 기능(태그·속성 자동완성, 닫는 태그)은 HTML 언어 서비스로 되돌린다. 다른 Django 확장은 필요 없다
  • Django 태그·필터와 django-wireview의 컴포넌트·인자·이벤트·수정자·핸들러·슬롯·훅의 자동완성, 호버, 정의로 이동
  • Django나 django-wireview가 렌더할 때 낼 오류의 진단. 확실하지 않으면 말하지 않는다 — 이 저장소의 모든 템플릿이 진단 0건인 것을 tests/test_vscode_extension.py가 확인한다
  • 파이썬 파일을 저장하면 메타데이터를 다시 만든다. 실패하면 마지막으로 성공한 것을 쓴다

신뢰하지 않은 워크스페이스(제한 모드)에서 확장은 프로세스를 하나도 띄우지 않고 메타데이터 파일도 읽지 않는다. wireview_lsp를 돌리는 것은 프로젝트의 코드를 실행하는 일이고, 메타데이터에 적힌 경로는 정의로 이동이 여는 파일이기 때문이다. 구문 강조·스니펫·HTML 기능만 되고, 워크스페이스를 신뢰하는 순간 메타데이터를 만든다.

확장은 라이브러리의 공개 API가 아니고 버전을 따로 매긴다(editors/vscode/package.json). 라이브러리와 확장 사이의 약속은 아래 JSON 하나다.

manage.py wireview_lsp#

python manage.py wireview_lsp                          # stdout
python manage.py wireview_lsp --output metadata.json   # 파일
python manage.py wireview_lsp --pretty

Django를 띄워 등록된 컴포넌트, 함수 컴포넌트, 훅 파일, 템플릿 엔진을 읽는다. 템플릿은 컴파일하지 않는다 — 편집 중인 템플릿 하나의 구문 오류가 다른 모든 것을 가리지 않게 하려는 것이다.

버전#

최상위의 version이 형식의 버전이다. minor는 키를 더할 때, major는 있던 키의 뜻이나 모양을 바꿀 때 올린다. 읽는 쪽은 major가 같고 minor가 자기가 아는 것 이상이면 읽는다. 확장은 1.1 이상의 1.x를 읽고, 2.0이면 확장을, 1.0이면 django-wireview를 올리라고 알린다. 규칙은 호환성 정책에도 있다.

형식 1.1#

1.0에 있던 키는 그대로다. 1.1이라고 적은 것이 더해진 것이다.

최상위:

키 뜻
version 형식의 버전, "1.1"
wireview_version 1.1 이 JSON을 만든 django-wireview의 버전. 설치되지 않은 소스 트리면 ""
generated_at 만든 시각(UTC, ISO 8601)
components 등록된 이름 → 컴포넌트(아래). 같은 이름이 둘이면 템플릿의 이름 찾기처럼 나중 것
function_components 1.1 {% func %}의 이름 → 함수 컴포넌트(아래)
hooks 1.1 훅 파일이 등록한 이름 → static_path(<app_label>/hooks/x.js), file_path, line_number(등록한 줄)
modifiers {% on %}의 수정자 → description, docstring(1.0부터 있던 키, description과 같은 값), has_argument, 1.1 argument("number", "text", null)
template_dirs 1.1 로더가 찾는 순서의 템플릿 디렉터리(DIRS와 앱의 templates). 절대 경로
template_builtins 1.1 템플릿 엔진의 내장 태그·필터(아래의 라이브러리 모양)
template_libraries 1.1 {% load %}의 이름 → 라이브러리: module, file_path, tags, filters

컴포넌트:

키 뜻
name, fqn, app_key 템플릿이 찾는 세 이름: Counter, myapp.live.Counter, myapp:Counter
module, file_path, line_number 클래스가 있는 곳
kind 1.1 "component" 또는 "live_component"
docstring 클래스의 docstring
template_name Meta.template_name
template_path 1.1 그 이름이 가리키는 디스크의 파일, 없으면 null. 템플릿 디렉터리를 순서대로 찾고, 심볼릭 링크를 푼 실제 경로를 적는다
fields 필드 → type, annotation(그 안 객체의 repr에서 메모리 주소를 지운다 — 실행마다 같은 출력이 나오게. 문자열은 그대로다), default, required, description, 1.1 in_state. id·user·session·wire는 빠진다. 1.1부터 Meta.exclude_fields의 필드도 실린다(in_state: false) — 템플릿이 넘기는 인자이기 때문이다
accepts_extra_kwargs 1.1 사용자 클래스가 new()를(LiveComponent면 update()·update_many()도) 오버라이드했는가. 참이면 필드가 아닌 인자도 그 코드가 읽을 수 있다
properties 1.1 사용자 클래스의 property·cached_property → type, is_async, docstring, file_path, line_number
methods 메서드 → is_handler(클라이언트가 부를 수 있는가, is_client_callable과 같은 판정), is_async, parameters, docstring, line_number, 1.1 file_path(믹스인의 메서드는 믹스인의 파일)
slots Meta.slots
subscriptions, subscriptions_is_dynamic, temporary_assigns Meta의 그것. get_subscriptions()를 오버라이드하면 subscriptions_is_dynamic

parameters는 이름 → type, default, has_default, kind(POSITIONAL_OR_KEYWORD, KEYWORD_ONLY, VAR_KEYWORD …)이다. 함수 컴포넌트는 name, fqn, module, file_path, line_number, docstring, template_name, template_path, parameters, slots를 갖는다.

라이브러리(template_builtins와 template_libraries의 값):

키 뜻
tags 태그 → docstring, file_path, line_number(태그를 등록한 함수. simple_tag는 감싼 사용자 함수), end, intermediate
filters 필터 → docstring, file_path, line_number, argument("none", "optional", "required"), forbidden_in_filter_tag({% filter %}가 거절하는가. Django의 do_filter처럼 이름이 아니라 함수가 마지막으로 등록된 이름 _filter_name이 escape·safe인지로 정한다. 키가 없으면 편집기는 말하지 않는다)

휴리스틱의 한계#

Django는 블록 태그가 어디서 끝나는지 기록하지 않는다. 태그의 컴파일 함수가 parser.parse(("else", "endif"))처럼 멈출 이름을 넘길 뿐이다. 그래서 end(끝 태그)와 intermediate(사이의 태그)는 그 함수의 소스에서 읽는다:

  • parser.parse((...))와 parser.skip_past(...)의 따옴표 리터럴. end로 시작하는 첫 이름이 끝 태그, 나머지가 사이의 태그다
  • 이름을 변수로 넘기면(parser.parse(until)) 소스의 "end…" 리터럴
  • simple_block_tag는 그것이 기억하는 end_name
  • Django의 blocktranslate(blocktrans)는 끝 태그 이름을 실행 중에 end + 태그를 부른 이름으로 만든다. 그 컴파일 함수(do_block_translate)로 등록된 태그만 그렇게 읽는다 — 다른 라이브러리가 같은 이름으로 등록한 태그는 제 소스대로 읽는다

읽지 못하면 end는 null이다. null은 "블록이 아니다"의 증거가 아니다 — {% load %}도 null이고, 이름을 다른 함수에서 만드는 서드파티 블록 태그도 null이다. 확장은 null인 태그를 블록으로 다루지 않고, 모르는 end… 태그와 제자리를 벗어난 사이 태그에는 아무것도 말하지 않는다.

template_dirs는 TEMPLATES에 적힌 Django 엔진의 로더가 찾는 디렉터리다. Django 폼 렌더러의 템플릿 (django/forms/...)은 엔진이 아니라 렌더러가 따로 찾으므로 여기 없다 — Django 자신의 폼 위젯 템플릿을 열면 그 안의 {% include %}가 template-not-found 경고를 받을 수 있다.

필드의 annotation과 default는 실행마다 같도록 객체 repr의 메모리 주소(<function f.<lambda> at 0x…>의 at 0x…)를 뺀다. 값 안의 문자열은 그대로 두지만, 객체 repr 안에 <… at 0x…> 모양의 문자열이 있으면 정규화될 수 있다 (Label(text='<object at 0xCAFE>')가 Label(text='<object>')로).

필터의 argument는 Django의 FilterExpression.args_check가 세는 방식으로 함수의 인자를 센다. needs_autoescape 필터가 받는 autoescape는 Django가 넘기는 것이라 세지 않는다.

다른 편집기#

같은 JSON을 읽으면 된다. 확장이 하는 것을 따라 하려면:

  1. 프로젝트의 파이썬으로 manage.py wireview_lsp --output <파일>을 돌리고, 파이썬 파일이 바뀌면 다시 돌린다. 실패하면 지난 결과를 쓴다
  2. version의 major를 확인한다
  3. 템플릿 파일의 실제 경로(심볼릭 링크를 푼 것)가 어떤 컴포넌트의 template_path와 같으면 그 컴포넌트가 템플릿의 this다 — 핸들러와 변수를 거기서 찾는다
  4. 태그가 보이는가는 template_builtins에 템플릿의 {% load %}를 적힌 순서대로 얹은 것이다. 나중 load가 같은 이름을 덮어쓰고(Parser.add_library), load보다 앞에 쓴 태그·필터는 그 라이브러리를 아직 모른다

확장의 판단은 editors/vscode/src/core/의 순수 모듈에 있다(VS Code를 import하지 않는다). 다른 편집기의 플러그인이 그대로 가져다 쓸 수도 있다. editors/vscode/scripts/diagnose.ts가 VS Code 없이 그 모듈로 템플릿을 진단하는 예다:

node editors/vscode/scripts/diagnose.ts metadata.json myapp/templates