Custom post type structure for posts with multiple child posts

I’ve waited for quite a while for an answer to this and have had to continue on with the project so thought I would answer myself

First off I set up the custom post type of language, then hooked in to the publish_language action to programatically add child posts like so:

function ta_insert_child_posts($post_id) {  
    if(($_POST['post_status'] == 'publish') && ($_POST['original_post_status'] != 'publish')) {
        $post = get_post($post_id);

        // Make sure it's a top level language being published
        if($post->post_parent == 0) {
            // Create our array of child post titles
            $child_posts = array('History', 'Where is it spoken', 'Also known as', 'Dialects', 'Alphabet & Writing System');

            foreach($child_posts as $child_post_title) {
                // Insert each new post as a child of the new language
                wp_insert_post(array(
                    'post_title' => $child_post_title,
                    'post_parent' => $post_id,
                    'post_type' => 'language',
                    'post_status' => $post->post_status 
                ));
            }
        }
    }
}
add_action('publish_language', 'ta_insert_child_posts');

Next, I had to add in logic to delete/trash child posts when their parent was deleted/trashed by hooking in to before_delete_post and trash_language

function ta_delete_child_posts($post_id) {
    global $post_type;

    if($post_type != 'language') return;

    $child_posts = get_posts(array('post_parent' => $post_id, 'post_type' => 'language'));

    if(is_array($child_posts)) {
        foreach($child_posts as $child_post) {
            wp_delete_post($child_post->ID, true);
        }
    }
}
add_action('before_delete_post', 'ta_delete_child_posts');

function ta_trash_child_posts($post_id) {
    $child_posts = get_posts(array('post_parent' => $post_id, 'post_type' => 'language'));

    if(is_array($child_posts)) {
        foreach($child_posts as $child_post) {
            wp_trash_post($child_post->ID);
        }
    }
}
add_action('trash_language', 'ta_trash_child_posts');

Ok so we now have child posts being published and deleted in sync with their parent language. Next I had to ensure only top level languages were being pulled through in the admin ui language list so I hooked in to the request action:

function ta_modify_request($request) {
    if(is_admin()) {
        $screen = get_current_screen();

        // We only want to retrieve top level language posts in the main request
        if($screen->post_type == 'language') {
           $request['post_parent'] = 0;
        }
    }

    return $request;
}
add_action('request', 'ta_modify_request');

Lastly, I had to inject some custom CSS and JavaScript by hooking in to admin_footer which added an expand/contract link to each language, with an ajax call to a function which gets child posts of the selected language and displays them in the standard wordpress table format:

function ta_child_posts_scripts() {
    $screen = get_current_screen();

    if($screen->post_type == 'language') {
?>
    <style type="text/css">
        #the-list tr .sorting-indicator {top:10px;position:relative;margin-top:0;cursor:pointer}
        #the-list tr .sorting-indicator.show:before {content:''}
        #the-list tr:hover .sorting-indicator {display:inline-block}
    </style>
    <script type="text/javascript">
        jQuery(function($) {
            $('#the-list tr .row-title').each(function() {
                $(this).after('<span class="sorting-indicator show" title="Show Child Posts"></span>');
            });

            $('#the-list tr .sorting-indicator').on('click', function() {
                var tr = $(this).parents('tr');

                if($(this).hasClass('show')) {
                    var data = {
                        action: 'ta_child_posts',
                        post_id: tr.attr('id')
                    };

                    $.post(ajaxurl, data, function(response) {
                        $(response).hide().insertAfter(tr).fadeIn();
                    });

                    $(this).removeClass('show').addClass('hide');

                } else {
                    tr.nextUntil('.level-0').fadeOut(function() { $(this).remove(); });

                    $(this).removeClass('hide').addClass('show');
                }
            });           

        });
    </script>
<?php
    }
}

add_action('admin_footer', 'ta_child_posts_scripts');

With this in place, all I had left to do was the add in the ajax callback function to get the child posts based on the selected language:

if(is_admin() && !class_exists('WP_List_Table')){
    require_once( ABSPATH . 'wp-admin/includes/class-wp-list-table.php' );
    require_once( ABSPATH . 'wp-admin/includes/class-wp-posts-list-table.php' );
}

function ta_get_child_posts() {

    if(empty($_POST['post_id'])) return;

    $post_id = explode('-', $_POST['post_id']);

    if(!isset($post_id[1])) return;

    $post_id = (int)$post_id[1];

    // Get child posts of the selected post
    $child_posts = get_posts(array('post_parent' => $post_id, 'post_type' => 'language'));

    set_current_screen('language');

    $ta_table = new WP_Posts_List_Table(array('screen' => get_current_screen()));

    $ta_table->prepare_items();

    // Since WP_List_Table provides no way to return its data we print the output with display_rows but catch it in an output buffer
    ob_start();

    $ta_table->display_rows($child_posts, 1);
    $rows = ob_get_clean();

    // Return the rows to the ajax callback
    die(print($rows));
}

add_action('wp_ajax_ta_child_posts', 'ta_get_child_posts');

I hope this helps out a future googler with a similar issue or anyone else browsing this website