├── README.md └── import_webflow.py /README.md: -------------------------------------------------------------------------------- 1 | # webflow-to-django 2 | A script to convert exported webflow assets to neat django templates. 3 | 4 | Import Webflow files into Django project. 5 | 6 | Example usage: 7 | ```bash 8 | python scripts/import_webflow.py web export.webflow.zip 9 | ``` 10 | 11 | Note: you must be root of the project to run this script. 12 | 13 | 14 | Video showing how to use the basic form of the script is here: 15 | https://www.youtube.com/watch?v=ohJzBkgSIMQ 16 | -------------------------------------------------------------------------------- /import_webflow.py: -------------------------------------------------------------------------------- 1 | """ 2 | Import Webflow files into Django project. 3 | 4 | Example usage: 5 | ```bash 6 | python scripts/import_webflow.py web export.webflow.zip 7 | ``` 8 | Note: you must be in the root of the project to run this script. 9 | """ 10 | import zipfile 11 | import sys 12 | from pathlib import Path 13 | import os 14 | import shutil 15 | import logging 16 | import re 17 | from bs4 import BeautifulSoup, formatter 18 | from send2trash import send2trash 19 | from typing import List 20 | 21 | 22 | make_forms_async = """ 23 | // Add native form submission support for Webflow forms. This makes all forms asynchronous. 24 | async function submitForm(event, formElement) { 25 | /* 26 | We use this function to make static html forms submit data asynchronously. 27 | */ 28 | event.preventDefault(); 29 | 30 | const formData = new FormData(formElement); 31 | const response = await fetch(formElement.action, { method: formElement.method, body: formData }); 32 | 33 | if (response.ok) { 34 | formElement.style.display = 'none'; 35 | const formDoneElement = formElement.parentElement.querySelector('.w-form-done'); 36 | if (formDoneElement) { 37 | formDoneElement.style.display = 'block'; 38 | } 39 | } else { 40 | const formFailElement = formElement.parentElement.querySelector('.w-form-fail'); 41 | if (formFailElement) { 42 | formFailElement.style.display = 'block'; 43 | } 44 | } 45 | } 46 | 47 | const forms = document.querySelectorAll('form'); 48 | forms.forEach(form => form.addEventListener('submit', event => submitForm(event, form))); 49 | """ 50 | 51 | 52 | class WebflowImporter: 53 | def __init__(self): 54 | self.app_name: str = None 55 | self.html_paths: List[str] = [] 56 | self.static_files = { 57 | 'js': [], 58 | 'css': [], 59 | 'images': [], 60 | 'documents': [], 61 | } 62 | 63 | def move_static_file(self, work_dir: Path, target_app: str, static_file_type: str) -> None: 64 | """ 65 | Move the static files from the Webflow export to the static files location of the 66 | Django target app. 67 | """ 68 | dest_static_dir = f"{target_app}/static/{target_app}/{static_file_type}/" 69 | os.makedirs(dest_static_dir, exist_ok=True) 70 | 71 | source_static_dir = work_dir / static_file_type 72 | 73 | try: 74 | for file in os.listdir(source_static_dir.absolute()): 75 | file_src = os.path.join(source_static_dir, file) 76 | file_dest = os.path.join(dest_static_dir, file) 77 | os.rename(file_src, file_dest) 78 | sys.stdout.write(f'+ Moved {static_file_type} ({file} to {file_dest})\n') 79 | self.static_files[static_file_type].append(file) 80 | except FileNotFoundError: 81 | sys.stdout.write(f'No {static_file_type} files found. Continuing.\n') 82 | 83 | def move_html_files(self, work_dir: Path, target_app: str) -> None: 84 | """ 85 | Move the html files from the Webflow export to the templates location of the 86 | Django target app. 87 | """ 88 | dest_html_dir = f"{target_app}/templates/" 89 | os.makedirs(dest_html_dir, exist_ok=True) 90 | 91 | source_html_dir = work_dir 92 | for file in os.listdir(source_html_dir.absolute()): 93 | if file.endswith('.html'): 94 | file_src = os.path.join(source_html_dir, file) 95 | file_dest = os.path.join(dest_html_dir, file) 96 | os.rename(file_src, file_dest) 97 | sys.stdout.write(f'+ Moved html ({file} to {file_dest})\n') 98 | 99 | self.html_paths.append(file_dest) 100 | 101 | def update_htmls(self, target_app: str) -> None: 102 | """ 103 | Update all the exported html files that we exported from Webflow 104 | to use the correct static file paths. 105 | """ 106 | for html_path in self.html_paths: 107 | self.update_html(html_path, target_app) 108 | 109 | @staticmethod 110 | def update_html(html_path: str, target_app: str) -> None: 111 | """ 112 | Update an html file to use the correct 113 | static files paths that work with Django. 114 | """ 115 | with open(html_path, 'r') as f: 116 | soup = BeautifulSoup(f, 'html.parser') 117 | 118 | # Update the links element. 119 | for tag in soup.find_all('link'): 120 | if tag.get('href').startswith("css"): 121 | new_href = f"{{% static '{target_app}/{tag.get('href')}' %}}" 122 | tag["href"] = new_href 123 | sys.stdout.write(f'+ Updating link href to href=\"{new_href}\"\n') 124 | 125 | if tag.get('href').startswith("images"): 126 | new_href = f"{{% static '{target_app}/{tag.get('href')}' %}}" 127 | tag["href"] = new_href 128 | sys.stdout.write(f'+ Updating link href to href=\"{new_href}\"\n') 129 | 130 | # Update img elements. 131 | for tag in soup.find_all('img'): 132 | if tag.get('src').startswith("images"): # I.e., unconverted. 133 | new_src = f"{{% static '{target_app}/{tag.get('src')}' %}}" 134 | tag["src"] = new_src 135 | sys.stdout.write(f'+ Updating img src to src=\"{new_src}\"\n') 136 | 137 | 138 | if tag.get('srcset', '').startswith("images"): 139 | new_srcset = re.sub( 140 | r"images/([^ ]+)", 141 | rf"{{% static '{target_app}/images/\g<1>' %}}", 142 | tag.get('srcset') 143 | ) 144 | tag["srcset"] = new_srcset 145 | sys.stdout.write(f'+ Updating img srcset to srcset=\"{new_srcset}\"\n') 146 | 147 | # Update js elements. 148 | for tag in soup.find_all('script'): 149 | src = tag.get('src') 150 | if src and src.startswith("js"): 151 | new_src = f"{{% static '{target_app}/{tag.get('src')}' %}}" 152 | tag["src"] = new_src 153 | sys.stdout.write(f'+ Updating script src to src=\"{new_src}\"\n') 154 | 155 | # Update the lottie animation elements. 156 | for tag in soup.find_all('div', {'data-animation-type': 'lottie'}): 157 | data_src = tag.get("data-src") 158 | if data_src and data_src.startswith("documents"): 159 | new_data_src = f"{{% static '{target_app}/{tag.get('data-src')}' %}}" 160 | tag["data-src"] = new_data_src 161 | sys.stdout.write(f'+ Updating lottie data-src to data-src=\"{new_data_src}\"\n') 162 | 163 | # Find any element (including divs and img) with a data-for attribute. 164 | 165 | # Convert any collection lists to for loops that render django template context. 166 | for tag in soup.find_all(['div', 'img', 'li', 'ul'], {'data-for': True}): 167 | for_loop_data = tag.get('data-for') 168 | 169 | if len(for_loop_data.split(" ")) > 1: # E.g., "item in items" 170 | # Insert the django forloop. 171 | dj_forloop_tag = f"{{% for {for_loop_data} %}}" 172 | tag.insert(0, dj_forloop_tag) 173 | tag.insert(-1, "{% endfor %}") 174 | sys.stdout.write(f'+ Added for loop tag "{dj_forloop_tag}\"\n') 175 | 176 | else: # E.g., "item.name" 177 | # Insert the django variable. 178 | if tag.name == 'img': 179 | tag['src'] = f"{{{{ {for_loop_data} }}}}" 180 | else: 181 | dj_variable = "{{ " + for_loop_data + " }}" 182 | tag.insert(0, dj_variable) 183 | sys.stdout.write(f'+ Added variable tag "{dj_variable}\"\n') 184 | 185 | # Insert the django static template tag. 186 | html_tag = soup.find('html') 187 | static_template_tag = '{% load static %}' 188 | html_tag.insert(0, static_template_tag) 189 | sys.stdout.write(f'+ Added tag "{static_template_tag}\"\n') 190 | 191 | # Forms: Add the django csrf token to all forms. 192 | csrf_tag = '{% csrf_token %}' 193 | for form_tag in soup.find_all('form'): 194 | form_tag.insert(0, csrf_tag) 195 | sys.stdout.write(f'+ Added django csrf token to all forms.\n') 196 | 197 | # Forms: Make all forms async. 198 | script_tag = soup.new_tag('script') 199 | script_tag.append(make_forms_async) 200 | soup.body.append(script_tag) 201 | 202 | # Write the updated html file 203 | with open(html_path, 'w') as f: 204 | f.write(str(soup.prettify(formatter=formatter.HTMLFormatter(indent=4)))) 205 | sys.stdout.write(f'Saved html file with updates to {html_path}\n') 206 | 207 | 208 | if __name__ == "__main__": 209 | target_app = sys.argv[1] 210 | webflow_exported_assets = Path(sys.argv[2]) 211 | working_folder = webflow_exported_assets.parent / "webflow_export" 212 | 213 | if not Path(target_app).exists(): 214 | raise FileNotFoundError(f'App directory not found at: {target_app} .') 215 | 216 | shutil.unpack_archive(webflow_exported_assets, working_folder) 217 | 218 | importer = WebflowImporter() 219 | importer.move_static_file(working_folder, target_app, 'js') 220 | importer.move_static_file(working_folder, target_app, 'css') 221 | importer.move_static_file(working_folder, target_app, 'images') 222 | importer.move_static_file(working_folder, target_app, 'documents') 223 | importer.move_html_files(working_folder, target_app) 224 | importer.update_htmls(target_app) 225 | 226 | sys.stdout.write(f'Imported {webflow_exported_assets} to {target_app}.\n') 227 | send2trash(working_folder) 228 | 229 | delete_zip = str(input('Delete the zip file? (y/n): ')) 230 | if delete_zip == 'y': 231 | send2trash(webflow_exported_assets) 232 | sys.stdout.write(f'Moved to trash: {webflow_exported_assets}.\n') 233 | --------------------------------------------------------------------------------